From d4d8ef98085e74d0869af0a17b43f13989b0848e Mon Sep 17 00:00:00 2001 From: Lennart Date: Tue, 9 Jul 2024 11:21:37 +0200 Subject: [PATCH] feat: Support workspaces in destination (#101) --- .changeset/beige-rings-brake.md | 5 + .changeset/cool-actors-visit.md | 2 +- .changeset/fluffy-fans-explain.md | 5 + .changeset/silver-oranges-wink.md | 7 + .github/workflows/integration-testing.yml | 7 + README.md | 2 +- docs/src/content/docs/guide/features.md | 2 +- docs/src/content/docs/reference/config.md | 7 + eslint.config.js | 1 + integration/__tests__/kitchen-sink.ts | 58 -------- integration/__tests__/missing-information.ts | 13 +- integration/__tests__/scan-once.ts | 87 ++++++++++++ integration/fixtures/index.ts | 1 + .../destination/fixture.pnpm-workspace.yaml | 2 + .../destination/package.json | 7 + .../packages/destination-workspaces/bin.mjs | 7 + .../destination-workspaces/package.json | 12 ++ .../source/package.json | 5 + .../say-hello-world-workspaces/index.mjs | 7 + .../say-hello-world-workspaces/package.json | 6 + .../source/pnpm-workspace.yaml | 2 + integration/models/application-config.ts | 3 +- integration/models/application.ts | 6 + integration/presets.ts | 17 +-- src/cli.ts | 46 ++++-- src/types.ts | 18 +++ src/utils/__tests__/initial-setup.ts | 100 +++++++++---- src/utils/__tests__/set-npm-tag-in-deps.ts | 131 ++++++++++++++++++ src/utils/check-deps-changes.ts | 3 +- src/utils/initial-setup.ts | 78 +++++++++-- src/utils/set-npm-tag-in-deps.ts | 55 ++++++++ src/verdaccio/add-dependencies.ts | 14 +- src/verdaccio/index.ts | 10 +- src/verdaccio/install-packages.ts | 49 ++++--- src/verdaccio/publish-package.ts | 2 +- src/verdaccio/verdaccio-config.ts | 4 +- src/watcher.ts | 14 +- 37 files changed, 630 insertions(+), 165 deletions(-) create mode 100644 .changeset/beige-rings-brake.md create mode 100644 .changeset/fluffy-fans-explain.md create mode 100644 .changeset/silver-oranges-wink.md delete mode 100644 integration/__tests__/kitchen-sink.ts create mode 100644 integration/__tests__/scan-once.ts create mode 100644 integration/fixtures/kitchen-sink-workspaces/destination/fixture.pnpm-workspace.yaml create mode 100644 integration/fixtures/kitchen-sink-workspaces/destination/package.json create mode 100644 integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/bin.mjs create mode 100644 integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/package.json create mode 100644 integration/fixtures/kitchen-sink-workspaces/source/package.json create mode 100644 integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/index.mjs create mode 100644 integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/package.json create mode 100644 integration/fixtures/kitchen-sink-workspaces/source/pnpm-workspace.yaml create mode 100644 src/utils/__tests__/set-npm-tag-in-deps.ts create mode 100644 src/utils/set-npm-tag-in-deps.ts diff --git a/.changeset/beige-rings-brake.md b/.changeset/beige-rings-brake.md new file mode 100644 index 0000000..f0ce1f5 --- /dev/null +++ b/.changeset/beige-rings-brake.md @@ -0,0 +1,5 @@ +--- +"secco": patch +--- + +Correctly display additional information e.g. during `npm install` when `VERBOSE` env var is set diff --git a/.changeset/cool-actors-visit.md b/.changeset/cool-actors-visit.md index 4faea9f..a2d9515 100644 --- a/.changeset/cool-actors-visit.md +++ b/.changeset/cool-actors-visit.md @@ -2,4 +2,4 @@ "secco": minor --- -Support Yarn Berry (currently v3 & v4) by modyfing the .yarnrc.yml file inside the destination before trying to install packages from the local Verdaccio registry +Support Yarn Berry (currently v3 & v4) by modyfing the `.yarnrc.yml` file inside the destination before trying to install packages from the local Verdaccio registry diff --git a/.changeset/fluffy-fans-explain.md b/.changeset/fluffy-fans-explain.md new file mode 100644 index 0000000..eaf2d0d --- /dev/null +++ b/.changeset/fluffy-fans-explain.md @@ -0,0 +1,5 @@ +--- +"secco": minor +--- + +Add `SECCO_VERDACCIO_PORT` environment variable. You can use this to change the default port (`4873`) when secco uses Verdaccio. diff --git a/.changeset/silver-oranges-wink.md b/.changeset/silver-oranges-wink.md new file mode 100644 index 0000000..cc20c90 --- /dev/null +++ b/.changeset/silver-oranges-wink.md @@ -0,0 +1,7 @@ +--- +"secco": minor +--- + +You can now use secco inside destinations that are set up with [workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces). It should work for all supported package managers (npm, yarn, pnpm, bun). + +Please note: secco will automatically use the `--force-verdaccio` flag when inside a workspaces project. diff --git a/.github/workflows/integration-testing.yml b/.github/workflows/integration-testing.yml index 6b348fa..c3dcdfe 100644 --- a/.github/workflows/integration-testing.yml +++ b/.github/workflows/integration-testing.yml @@ -41,3 +41,10 @@ jobs: env: INTEGRATION_PM_NAME: ${{ matrix.pm.name }} INTEGRATION_PM_VERSION: ${{ matrix.pm.version }} + - name: Upload temp dir (optional) + if: ${{ cancelled() || failure() }} + uses: actions/upload-artifact@v4 + with: + name: temp-dir-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.pm.name }} + path: ${{ runner.temp }}/secco-**/**/* + retention-days: 1 diff --git a/README.md b/README.md index 545b668..9e4d5b8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ secco solves these problems and streamlines the process of local package testing - **Link Multiple Projects.** secco reads the `.seccorc` file to make the connection between destination and source. This allows you to use `secco` with as many source folders as you wish. - **npm, yarn, pnpm, and bun support.** You can use any of these package managers in your source and destination projects. - **Watch and CI mode.** By default, secco starts a watch task. But you can also only run it once, enabling CI End-To-End testing use cases. -- **Workspaces (in source).** Your source folder can be a monorepo using workspaces. +- **Workspaces.** Your source & destination folders can be a monorepo using workspaces. lekoarts.de diff --git a/docs/src/content/docs/guide/features.md b/docs/src/content/docs/guide/features.md index 8cb8ab1..4d77361 100644 --- a/docs/src/content/docs/guide/features.md +++ b/docs/src/content/docs/guide/features.md @@ -9,4 +9,4 @@ secco boasts a lot of features to enable you to test local changes more quickly. - **Link Multiple Projects.** secco reads the `.seccorc` file to make the connection between destination and source. This allows you to use `secco` with as many source folders as you wish. - **npm, yarn, pnpm, and bun support.** You can use any of these package managers in your source and destination projects. - **Watch and CI mode.** By default, secco starts a watch task. But you can also only run it once, enabling CI End-To-End testing use cases. -- **Workspaces (in source).** Your source folder can be a monorepo using workspaces. +- **Workspaces.** Your source & destination folders can be a monorepo using workspaces. diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index 3be4793..b72b2b2 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -35,3 +35,10 @@ Equivalent to `source.path`. - **Default:** `false` Equivalent to the [`--verbose`](/reference/flags/#--verbose) flag. + +### `SECCO_VERDACCIO_PORT` + +- **Type:** `string` +- **Default:** `4873` + +Configure the port that secco's internal Verdaccio instance uses. diff --git a/eslint.config.js b/eslint.config.js index 87e77bb..becba39 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,7 @@ export default antfu( }, ], 'ts/array-type': ['error', { default: 'generic' }], + 'node/prefer-global/process': 'off', }, }, ) diff --git a/integration/__tests__/kitchen-sink.ts b/integration/__tests__/kitchen-sink.ts deleted file mode 100644 index 0890b90..0000000 --- a/integration/__tests__/kitchen-sink.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Application } from '../models/application' -import { presets } from '../presets' - -describe.sequential('mode: sequential', () => { - let app: Application - - beforeAll(async () => { - app = await presets.kitchenSink.commit() - }) - - afterAll(async () => { - await app.cleanup() - }) - - it('should run Verdaccio with --force-verdaccio', () => { - const [exitCode, logs] = app.cli(['--scan-once', '--force-verdaccio'], { verbose: true }) - - logs.should.contain('[log] [Verdaccio] Starting server...') - logs.should.contain('[log] [Verdaccio] Started successfully!') - logs.should.contain('[log] Publishing `say-hello-world@0.0.2-secco-') - logs.should.contain('[log] Published `say-hello-world@0.0.2-secco-') - logs.should.contain(`[debug] Detected package manager in destination: ${app.packageManager.split('@')[0]}`) - logs.should.contain('[log] Installing packages from local registry:') - logs.should.contain('[success] Installation finished successfully!') - - expect(exitCode).toBe(0) - }) - - it('verbose should be enabled through --verbose flag', () => { - const [exitCode, logs] = app.cli(['--verbose', '--scan-once']) - - logs.should.contain('[debug] Found 1 packages in source.') - logs.should.contain('[debug] Found 1 destination packages.') - - expect(exitCode).toBe(0) - }) - - it('verbose should be enabled through VERBOSE env var', () => { - const [exitCode, logs] = app.cli(['--scan-once'], { verbose: true }) - - logs.should.contain('[debug] Found 1 packages in source.') - logs.should.contain('[debug] Found 1 destination packages.') - - expect(exitCode).toBe(0) - }) - - it('should copy files on consecutive runs', () => { - const [exitCode, logs] = app.cli(['--scan-once'], { verbose: true }) - - logs.should.not.contain('[log] [Verdaccio] Starting server...') - logs.should.not.contain('[success] Installation finished successfully!') - logs.should.contain('[log] Copied `index.mjs` to `node_modules/say-hello-world/index.mjs`') - logs.should.contain('[log] Copied `package.json` to `node_modules/say-hello-world/package.json`') - logs.should.contain('[info] Copied 2 files. Exiting...') - - expect(exitCode).toBe(0) - }) -}) diff --git a/integration/__tests__/missing-information.ts b/integration/__tests__/missing-information.ts index 0c2f619..86d25cc 100644 --- a/integration/__tests__/missing-information.ts +++ b/integration/__tests__/missing-information.ts @@ -3,7 +3,7 @@ import { SeccoCLI } from '../helpers/invoke-cli' const missingSourcePackagesLocation = join(__dirname, '..', 'fixtures', 'missing-source-packages') -describe('missing .seccorc', () => { +describe('missing information', () => { it('should display error when no .seccorc or env var is found', () => { const [exitCode, logs] = SeccoCLI().setFixture('empty').invoke(['']) @@ -11,9 +11,7 @@ describe('missing .seccorc', () => { logs.should.contain('Please run `secco init` to create a new `.seccorc` file.') expect(exitCode).toBe(0) }) -}) -describe('missing package.json', () => { it('should display error when no package.json is found', () => { const [exitCode, logs] = SeccoCLI().setFixture('existing-config-file').invoke(['']) @@ -21,9 +19,7 @@ describe('missing package.json', () => { logs.should.contain('Current directory must contain a `package.json` file.') expect(exitCode).toBe(0) }) -}) -describe('missing source packages', () => { it('should display error when no source package is found in package.json', () => { const [exitCode, logs] = SeccoCLI().setFixture('missing-source-packages').setEnv({ SECCO_SOURCE_PATH: missingSourcePackagesLocation }).invoke(['']) @@ -31,4 +27,11 @@ describe('missing source packages', () => { logs.should.contain(`If you only want to use \`secco\` you'll need to add the dependencies to your \`package.json\`.`) expect(exitCode).toBe(0) }) + + it('should display error when source.path is incorrect', () => { + const [exitCode, logs] = SeccoCLI().setFixture('missing-source-packages').setEnv({ SECCO_SOURCE_PATH: '/Users/secco' }).invoke(['']) + + logs.should.contain(`[fatal] Couldn't find package.json in /Users/secco`) + expect(exitCode).toBe(0) + }) }) diff --git a/integration/__tests__/scan-once.ts b/integration/__tests__/scan-once.ts new file mode 100644 index 0000000..e640bd0 --- /dev/null +++ b/integration/__tests__/scan-once.ts @@ -0,0 +1,87 @@ +import fs from 'fs-extra' +import { join } from 'pathe' +import type { Application } from '../models/application' +import { presets } from '../presets' + +async function renamePnpmWorkspaceFixture(app: Application) { + const fixture = join(app.dir, 'destination', 'fixture.pnpm-workspace.yaml') + const tmpWorkspaceYaml = join(app.dir, 'destination', 'pnpm-workspace.yaml') + + await fs.rename(fixture, tmpWorkspaceYaml) +} + +describe.sequential('scan-once', () => { + describe.sequential('single package', () => { + let app: Application + + beforeAll(async () => { + app = await presets.kitchenSink.commit() + + process.env.SECCO_VERDACCIO_PORT = '4873' + }) + + afterAll(async () => { + await app.cleanup() + }) + + it('should run Verdaccio with --force-verdaccio', () => { + const [exitCode, logs] = app.cli(['--scan-once', '--force-verdaccio', '--verbose']) + + logs.should.contain('[log] [Verdaccio] Starting server...') + logs.should.contain('[log] [Verdaccio] Started successfully!') + logs.should.contain('[log] Publishing `say-hello-world@0.0.2-secco-') + logs.should.contain('[log] Published `say-hello-world@0.0.2-secco-') + logs.should.contain(`[debug] Detected package manager in destination: ${app.packageManager.split('@')[0]}`) + logs.should.contain('[log] Installing packages from local registry:') + logs.should.contain('[success] Installation finished successfully!') + + expect(exitCode).toBe(0) + }) + + it('should copy files on consecutive runs', () => { + const [exitCode, logs] = app.cli(['--scan-once'], { verbose: true }) + + logs.should.not.contain('[log] [Verdaccio] Starting server...') + logs.should.not.contain('[success] Installation finished successfully!') + logs.should.contain('[log] Copied `index.mjs` to `node_modules/say-hello-world/index.mjs`') + logs.should.contain('[log] Copied `package.json` to `node_modules/say-hello-world/package.json`') + logs.should.contain('[info] Copied 2 files. Exiting...') + + expect(exitCode).toBe(0) + }) + }) + + describe.sequential('workspaces', () => { + let app: Application + + beforeAll(async () => { + app = await presets.kitchenSinkWorkspaces.commit() + + if (process.env.INTEGRATION_PM_NAME === 'pnpm') { + await renamePnpmWorkspaceFixture(app) + } + + process.env.SECCO_VERDACCIO_PORT = '4874' + }) + + afterAll(async () => { + await app.cleanup() + }) + + it('should work (with Verdaccio by default)', () => { + const [exitCode, logs] = app.cli(['--scan-once'], { verbose: true }) + + logs.logOutput() + + logs.should.contain('[log] [Verdaccio] Starting server...') + logs.should.contain('[log] [Verdaccio] Started successfully!') + logs.should.contain('[log] Publishing `say-hello-world-workspaces@1.0.0-secco-') + logs.should.contain('[log] Published `say-hello-world-workspaces@1.0.0-secco-') + logs.should.contain(`[debug] Detected package manager in destination: ${app.packageManager.split('@')[0]}`) + logs.should.contain('[log] Installing packages from local registry:') + logs.should.contain('[success] Installation finished successfully!') + + expect(exitCode).toBe(0) + }) + }) +}) diff --git a/integration/fixtures/index.ts b/integration/fixtures/index.ts index 005332c..36e9363 100644 --- a/integration/fixtures/index.ts +++ b/integration/fixtures/index.ts @@ -7,6 +7,7 @@ import { resolve } from 'pathe' */ export const fixtures = { 'kitchen-sink': resolve(__dirname, 'kitchen-sink'), + 'kitchen-sink-workspaces': resolve(__dirname, 'kitchen-sink-workspaces'), } as const export type Fixture = keyof typeof fixtures diff --git a/integration/fixtures/kitchen-sink-workspaces/destination/fixture.pnpm-workspace.yaml b/integration/fixtures/kitchen-sink-workspaces/destination/fixture.pnpm-workspace.yaml new file mode 100644 index 0000000..18ec407 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/destination/fixture.pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/integration/fixtures/kitchen-sink-workspaces/destination/package.json b/integration/fixtures/kitchen-sink-workspaces/destination/package.json new file mode 100644 index 0000000..50f79f8 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/destination/package.json @@ -0,0 +1,7 @@ +{ + "name": "kitchen-sink-workspaces-destination", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/bin.mjs b/integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/bin.mjs new file mode 100644 index 0000000..2da06e4 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/bin.mjs @@ -0,0 +1,7 @@ +import { sayHelloWorld } from 'say-hello-world' + +function run() { + sayHelloWorld() +} + +run() diff --git a/integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/package.json b/integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/package.json new file mode 100644 index 0000000..4f3f9b5 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/destination/packages/destination-workspaces/package.json @@ -0,0 +1,12 @@ +{ + "name": "destination-workspaces", + "version": "1.0.0", + "private": true, + "main": "bin.mjs", + "scripts": { + "start": "node bin.mjs" + }, + "dependencies": { + "say-hello-world-workspaces": "^1.0.0" + } +} diff --git a/integration/fixtures/kitchen-sink-workspaces/source/package.json b/integration/fixtures/kitchen-sink-workspaces/source/package.json new file mode 100644 index 0000000..96b0d97 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/source/package.json @@ -0,0 +1,5 @@ +{ + "name": "kitchen-sink-workspaces-source", + "private": true, + "packageManager": "pnpm@9.4.0" +} diff --git a/integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/index.mjs b/integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/index.mjs new file mode 100644 index 0000000..0e31129 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/index.mjs @@ -0,0 +1,7 @@ +function sayHelloWorld() { + // eslint-disable-next-line no-console + console.log('Hello World!') +} +export { + sayHelloWorld, +} diff --git a/integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/package.json b/integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/package.json new file mode 100644 index 0000000..ad47c9a --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/source/packages/say-hello-world-workspaces/package.json @@ -0,0 +1,6 @@ +{ + "name": "say-hello-world-workspaces", + "version": "1.0.0", + "packageManager": "pnpm@9.4.0", + "main": "index.mjs" +} diff --git a/integration/fixtures/kitchen-sink-workspaces/source/pnpm-workspace.yaml b/integration/fixtures/kitchen-sink-workspaces/source/pnpm-workspace.yaml new file mode 100644 index 0000000..18ec407 --- /dev/null +++ b/integration/fixtures/kitchen-sink-workspaces/source/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/integration/models/application-config.ts b/integration/models/application-config.ts index 3eda4f1..7022bfe 100644 --- a/integration/models/application-config.ts +++ b/integration/models/application-config.ts @@ -48,7 +48,8 @@ export function applicationConfig() { commit: async () => { logger.log(`Creating application "${name}"`) - const isolatedDir = await mkdtemp(join(tmpdir(), `secco-${name}-`)) + const tempDir = process.env.RUNNER_TEMP || tmpdir() + const isolatedDir = await mkdtemp(join(tempDir, `secco-${name}-`)) logger.log(`Copying template "${basename(template)}" to "${isolatedDir}"`) await cp(template, isolatedDir, { recursive: true }) diff --git a/integration/models/application.ts b/integration/models/application.ts index e1274db..b948337 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -3,6 +3,7 @@ import { join } from 'pathe' import type { InvokeResult } from '../helpers/invoke-cli' import { SeccoCLI } from '../helpers/invoke-cli' import { createLogger } from '../helpers/logger' +import { isTruthy } from '../../src/utils/is-truthy' import type { ApplicationConfig } from './application-config' export type Application = ReturnType @@ -27,6 +28,11 @@ export function application(config: ApplicationConfig, isolatedDir: string) { }).invoke(args) }, cleanup: async () => { + if (isTruthy(process.env.CI)) { + logger.log(`Skipping cleanup in CI environment`) + return + } + logger.log(`Cleaning up...`) await rm(isolatedDir, { recursive: true, force: true }) diff --git a/integration/presets.ts b/integration/presets.ts index 32a57d6..14bba9d 100644 --- a/integration/presets.ts +++ b/integration/presets.ts @@ -1,5 +1,3 @@ -/* eslint-disable ts/no-namespace */ -/* eslint-disable node/prefer-global/process */ import { applicationConfig } from './models/application-config' import { fixtures } from './fixtures' @@ -8,20 +6,17 @@ const constants = { INTEGRATION_PM_VERSION: process.env.INTEGRATION_PM_VERSION, } -declare global { - namespace NodeJS { - interface ProcessEnv { - INTEGRATION_PM_NAME?: 'npm' | 'pnpm' | 'yarn' | 'bun' - INTEGRATION_PM_VERSION?: string - } - } -} - const kitchenSink = applicationConfig() .setName('kitchen-sink') .setTemplate(fixtures['kitchen-sink']) .setPackageManager(constants.INTEGRATION_PM_NAME, constants.INTEGRATION_PM_VERSION) +const kitchenSinkWorkspaces = applicationConfig() + .setName('kitchen-sink-workspaces') + .setTemplate(fixtures['kitchen-sink-workspaces']) + .setPackageManager(constants.INTEGRATION_PM_NAME, constants.INTEGRATION_PM_VERSION) + export const presets = { kitchenSink, + kitchenSinkWorkspaces, } as const diff --git a/src/cli.ts b/src/cli.ts index bcd848b..c616d57 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,7 @@ import { detectPackageManager } from 'nypm' import { getConfig } from './utils/config' import { logger } from './utils/logger' import { commands } from './commands' -import { checkDirHasPackageJson, findWorkspacesInSource, getDestinationPackages, getPackageNamesToFilePath, getPackages } from './utils/initial-setup' +import { checkDirHasPackageJson, findWorkspacesInDestination, findWorkspacesInSource, getAbsolutePathsForDestinationPackages, getDestinationPackages, getPackageNamesToFilePath, getPackages } from './utils/initial-setup' import type { CliArguments, Destination, Source } from './types' import { CLI_NAME } from './constants' import { watcher } from './watcher' @@ -47,8 +47,9 @@ const parser = yargsInstace async function run() { const argv: CliArguments = await parser + const verbose = argv.verbose || isTruthy(process.env.VERBOSE) - if (argv.verbose || isTruthy(process.env.VERBOSE)) + if (verbose) logger.level = 4 const seccoConfig = getConfig() @@ -58,17 +59,33 @@ ${JSON.stringify(seccoConfig, null, 2)}`) checkDirHasPackageJson() + const destinationPath = process.cwd() + const { source: sourceConfig } = seccoConfig - const { hasWorkspaces, workspaces } = findWorkspacesInSource(sourceConfig.path) - const pm = await detectPackageManager(sourceConfig.path, { includeParentDirs: false }) - logger.debug(`Detected package manager in source: ${pm?.name}`) - logger.debug(`Source has workspaces: ${hasWorkspaces}`) + const { hasWorkspaces: sourceHasWorkspaces, workspaces: sourceWorkspaces } = findWorkspacesInSource(sourceConfig.path) + const { hasWorkspaces: destinationHasWorkspaces, workspaces: destinationWorkspaces } = findWorkspacesInDestination(destinationPath) + + const pmSource = await detectPackageManager(sourceConfig.path, { includeParentDirs: false }) + const pmDestination = await detectPackageManager(destinationPath, { includeParentDirs: false }) + + if (!pmDestination) { + logger.fatal(`Failed to detect package manager in ${destinationPath} + +If you have control over the destination, manually add the "packageManager" key to its \`package.json\` file.`) + process.exit() + } + + logger.debug(`Detected package manager in source: ${pmSource?.name}`) + logger.debug(`Detected package manager in destination: ${pmDestination?.name}`) + logger.debug(`Source has workspaces: ${sourceHasWorkspaces}`) + logger.debug(`Destination has workspaces: ${destinationHasWorkspaces}`) - const sourcePackages = getPackages(sourceConfig.path, workspaces) - logger.debug(`Found ${sourcePackages.length} packages in source.`) + const sourcePackages = getPackages(sourceConfig.path, sourceWorkspaces) + logger.debug(`Found ${sourcePackages.length} ${sourcePackages.length === 1 ? 'package' : 'packages'} in source.`) const packageNamesToFilePath = getPackageNamesToFilePath() - const destinationPackages = getDestinationPackages(sourcePackages) - logger.debug(`Found ${destinationPackages.length} destination packages.`) + const destinationPackages = getDestinationPackages(sourcePackages, destinationWorkspaces) + const absolutePathsForDestinationPackages = getAbsolutePathsForDestinationPackages() + logger.debug(`Found ${destinationPackages.length} ${destinationPackages.length === 1 ? 'package' : 'packages'} in destination.`) if (!argv?.packageNames && destinationPackages.length === 0) { logger.error(`You haven't got any source dependencies in your current \`package.json\`. @@ -87,17 +104,20 @@ If you only want to use \`${CLI_NAME}\` you'll need to add the dependencies to y const source: Source = { ...sourceConfig, - hasWorkspaces, + hasWorkspaces: sourceHasWorkspaces, packages: sourcePackages, packageNamesToFilePath, - pm, + pm: pmSource, } const destination: Destination = { packages: destinationPackages, + hasWorkspaces: destinationHasWorkspaces, + absolutePathsForDestinationPackages, + pm: pmDestination, } - watcher(source, destination, argv.packageNames, { scanOnce: argv.scanOnce, forceVerdaccio: argv.forceVerdaccio, verbose: argv.verbose }) + watcher(source, destination, argv.packageNames, { scanOnce: argv.scanOnce, forceVerdaccio: argv.forceVerdaccio, verbose }) } run() diff --git a/src/types.ts b/src/types.ts index a8bf014..0a452f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,10 @@ +/* eslint-disable ts/no-namespace */ import type { PackageManager } from 'nypm' import type { Config } from './utils/config' export type PackageNames = Array export type PackageNamesToFilePath = Map +export type AbsolutePathsForDestinationPackages = Set export type SourcePackages = Array export type DestinationPackages = Array export type DepTree = Record> @@ -24,7 +26,10 @@ export type Source = Config['source'] & { } export interface Destination { + hasWorkspaces: boolean packages: DestinationPackages + absolutePathsForDestinationPackages: AbsolutePathsForDestinationPackages + pm: PackageManager } export interface PackageJson { @@ -36,3 +41,16 @@ export interface PackageJson { peerDependencies?: Record workspaces?: Array | { packages: Array } } + +declare global { + namespace NodeJS { + interface ProcessEnv { + INTEGRATION_PM_NAME?: 'npm' | 'pnpm' | 'yarn' | 'bun' + INTEGRATION_PM_VERSION?: string + SECCO_VERDACCIO_PORT?: string + CI?: string + GITHUB_ACTIONS?: string + RUNNER_TEMP?: string + } + } +} diff --git a/src/utils/__tests__/initial-setup.ts b/src/utils/__tests__/initial-setup.ts index 7d00bc2..774798d 100644 --- a/src/utils/__tests__/initial-setup.ts +++ b/src/utils/__tests__/initial-setup.ts @@ -3,55 +3,97 @@ import fs from 'fs-extra' import type { Mock } from 'vitest' import { vi } from 'vitest' import { logger } from '../logger' -import { checkDirHasPackageJson, getDestinationPackages, getPackageNamesToFilePath, getPackages, isPrivate } from '../initial-setup' +import { checkDirHasPackageJson, getAbsolutePathsForDestinationPackages, getDestinationPackages, getPackageNamesToFilePath, getPackages, isPrivate } from '../initial-setup' describe('getDestinationPackages', () => { - it('returns an empty array if destination package.json is missing', () => { - vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(undefined as any) + describe('single package', () => { + it('returns an empty array if destination package.json is missing', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(undefined as any) - const result = getDestinationPackages(['package1', 'package2']) + const result = getDestinationPackages(['package1', 'package2'], null) - expect(result).toEqual([]) - }) + expect(result).toEqual([]) + }) - it('returns an empty array if sourcePackages is empty', () => { - vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{}') + it('returns an empty array if sourcePackages is empty', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{}') - const result = getDestinationPackages([]) + const result = getDestinationPackages([], null) - expect(result).toEqual([]) - }) + expect(result).toEqual([]) + }) - it('returns an empty array if there are no matching dependencies', () => { - vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"dependencies": {"package3": "^1.0.0"}}') + it('returns an empty array if there are no matching dependencies', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"dependencies": {"package3": "^1.0.0"}}') - const result = getDestinationPackages(['package1', 'package2']) + const result = getDestinationPackages(['package1', 'package2'], null) - expect(result).toEqual([]) - }) + expect(result).toEqual([]) + }) - it('returns an array of matching dependencies', () => { - vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"dependencies": {"package1": "^1.0.0", "package3": "^1.0.0"}}') + it('returns an array of matching dependencies', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"dependencies": {"package1": "^1.0.0", "package3": "^1.0.0"}}') - const result = getDestinationPackages(['package1', 'package2']) + const result = getDestinationPackages(['package1', 'package2'], null) - expect(result).toEqual(['package1']) - }) + expect(result).toEqual(['package1']) + }) + + it('returns an array of matching devDependencies', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"devDependencies": {"package2": "^1.0.0", "package3": "^1.0.0"}}') + + const result = getDestinationPackages(['package1', 'package2'], null) - it('returns an array of matching devDependencies', () => { - vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"devDependencies": {"package2": "^1.0.0", "package3": "^1.0.0"}}') + expect(result).toEqual(['package2']) + }) + + it('returns an array of matching dependencies and devDependencies', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"dependencies": {"package1": "^1.0.0", "package3": "^1.0.0"}, "devDependencies": {"package2": "^1.0.0"}}') + + const result = getDestinationPackages(['package1', 'package2'], null) - const result = getDestinationPackages(['package1', 'package2']) + expect(result).toEqual(['package1', 'package2']) + }) - expect(result).toEqual(['package2']) + it('sets the pkg name + path in destinationPackageNameToFilePath Map if intersection', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"name": "package2", "dependencies": {"package1": "^1.0.0"}}') + + getDestinationPackages(['package1'], null) + + expect(getAbsolutePathsForDestinationPackages().has(process.cwd())).toBe(true) + }) }) + describe('workspaces', () => { + it('returns an empty array if no workspaces are found', () => { + const result = getDestinationPackages(['package1', 'package2'], []) + + expect(result).toEqual([]) + }) + + it('returns an empty array if there are no matching dependencies', () => { + const result = getDestinationPackages(['package1', 'package2'], [{ location: 'location-package3', package: { name: 'package3', dependencies: { package4: '^1.0.0' } } }]) + + expect(result).toEqual([]) + }) + + it('returns an array of matching dependencies', () => { + const result = getDestinationPackages(['package1', 'package2'], [{ location: 'location-package3', package: { name: 'package3', dependencies: { package1: '^1.0.0' } } }, { location: 'location-package4', package: { name: 'package4', dependencies: { package2: '^1.0.0' } } }]) + + expect(result).toEqual(['package1', 'package2']) + }) - it('returns an array of matching dependencies and devDependencies', () => { - vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{"dependencies": {"package1": "^1.0.0", "package3": "^1.0.0"}, "devDependencies": {"package2": "^1.0.0"}}') + it('returns an array of matching devDependencies', () => { + const result = getDestinationPackages(['package1', 'package2'], [{ location: 'location-package3', package: { name: 'package3', devDependencies: { package1: '^1.0.0' } } }, { location: 'location-package4', package: { name: 'package4', devDependencies: { package2: '^1.0.0' } } }]) - const result = getDestinationPackages(['package1', 'package2']) + expect(result).toEqual(['package1', 'package2']) + }) - expect(result).toEqual(['package1', 'package2']) + it('sets the pkg name + path in destinationPackageNameToFilePath Map if intersection', () => { + getDestinationPackages(['package1', 'package2'], [{ location: 'location-package3', package: { name: 'package3', dependencies: { package1: '^1.0.0' } } }, { location: 'location-package4', package: { name: 'package4', devDependencies: { package2: '^1.0.0' } } }]) + + expect(getAbsolutePathsForDestinationPackages().has('location-package3')).toBe(true) + expect(getAbsolutePathsForDestinationPackages().has('location-package4')).toBe(true) + }) }) }) diff --git a/src/utils/__tests__/set-npm-tag-in-deps.ts b/src/utils/__tests__/set-npm-tag-in-deps.ts new file mode 100644 index 0000000..4d02d70 --- /dev/null +++ b/src/utils/__tests__/set-npm-tag-in-deps.ts @@ -0,0 +1,131 @@ +import { adjustDeps, setNpmTagInDeps } from '../set-npm-tag-in-deps' + +const TEST_VERSION = '1.0.0-test-09833' + +describe('adjustDeps', () => { + it('should return false if the dependencies object is empty', () => { + const result = adjustDeps({ deps: {}, packagesToInstall: ['package1', 'package2'], newlyPublishedPackageVersions: {} }) + expect(result).toBe(false) + }) + + it('should update the versions of packages to be installed', () => { + const deps = { + package1: '1.0.0', + package2: '2.0.0', + package3: '3.0.0', + } + const result = adjustDeps({ deps, packagesToInstall: ['package1', 'package3'], newlyPublishedPackageVersions: { package1: TEST_VERSION, package3: TEST_VERSION } }) + + expect(result).toBe(true) + expect(deps.package1).toBe(TEST_VERSION) + expect(deps.package2).toBe('2.0.0') + expect(deps.package3).toBe(TEST_VERSION) + }) + + it('should return false if no packages to install are found in the dependencies object', () => { + const deps = { + package1: '1.0.0', + package2: '2.0.0', + package3: '3.0.0', + } + const result = adjustDeps({ deps, packagesToInstall: ['package4', 'package5'], newlyPublishedPackageVersions: {} }) + + expect(result).toBe(false) + expect(deps.package1).toBe('1.0.0') + expect(deps.package2).toBe('2.0.0') + expect(deps.package3).toBe('3.0.0') + }) +}) + +describe('setNpmTagInDeps', () => { + it(`should update dependencies with ${TEST_VERSION}`, () => { + const packageJson = { + dependencies: { + 'package-1': '^1.0.0', + 'react': '^18.0.0', + }, + devDependencies: { + 'package-2': '^1.0.0', + 'vitest': '^1.0.0', + }, + peerDependencies: { + 'package-1': '^1.0.0', + 'react': '^18.0.0', + }, + } + const { updatedPkgJson, changed } = setNpmTagInDeps({ packageJson, packagesToInstall: ['package-1', 'package-2'], newlyPublishedPackageVersions: { 'package-1': TEST_VERSION, 'package-2': TEST_VERSION } }) + + expect(updatedPkgJson.dependencies).toEqual({ + 'package-1': TEST_VERSION, + 'react': '^18.0.0', + }) + expect(updatedPkgJson.devDependencies).toEqual({ + 'package-2': TEST_VERSION, + 'vitest': '^1.0.0', + }) + expect(updatedPkgJson.peerDependencies).toEqual({ + 'package-1': TEST_VERSION, + 'react': '^18.0.0', + }) + + expect(changed).toBe(true) + }) + + it('should not update unrelated dependencies', () => { + const packageJson = { + dependencies: { + 'package-1': '^1.0.0', + 'react': '^18.0.0', + }, + devDependencies: { + 'package-2': '^1.0.0', + }, + peerDependencies: { + 'package-1': '^1.0.0', + 'react': '^18.0.0', + }, + } + const { updatedPkgJson, changed } = setNpmTagInDeps({ packageJson, packagesToInstall: ['package-1'], newlyPublishedPackageVersions: { 'package-1': TEST_VERSION } }) + + expect(updatedPkgJson.dependencies).toEqual({ + 'package-1': TEST_VERSION, + 'react': '^18.0.0', + }) + expect(updatedPkgJson.devDependencies).toEqual({ + 'package-2': '^1.0.0', + }) + expect(updatedPkgJson.peerDependencies).toEqual({ + 'package-1': TEST_VERSION, + 'react': '^18.0.0', + }) + + expect(changed).toBe(true) + }) + + it('should not change pkgJson if no packages to install are found', () => { + const packageJson = { + dependencies: { + react: '^18.0.0', + }, + devDependencies: { + 'package-2': '^1.0.0', + }, + peerDependencies: { + react: '^18.0.0', + }, + } + const { updatedPkgJson, changed } = setNpmTagInDeps({ packageJson, packagesToInstall: ['package-1'], newlyPublishedPackageVersions: { 'package-1': TEST_VERSION } }) + + expect(updatedPkgJson.dependencies).toEqual({ + react: '^18.0.0', + }) + expect(updatedPkgJson.devDependencies).toEqual({ + 'package-2': '^1.0.0', + }) + expect(updatedPkgJson.peerDependencies).toEqual({ + react: '^18.0.0', + }) + + expect(changed).toBe(false) + }) +}) diff --git a/src/utils/check-deps-changes.ts b/src/utils/check-deps-changes.ts index 510bce4..646b8d6 100644 --- a/src/utils/check-deps-changes.ts +++ b/src/utils/check-deps-changes.ts @@ -43,7 +43,8 @@ export async function checkDepsChanges(args: CheckDependencyChangesArgs) { try { // The package might already be installed (e.g. the "latest" version) // nodeModulesFilePath might not exist, but this is okay since we catch the resulting error - nodeModulePkgJson = destr(fs.readFileSync(args.nodeModulesFilePath, 'utf8')) + const nodeModulePkgJsonString = await fs.readFile(args.nodeModulesFilePath, 'utf8') + nodeModulePkgJson = destr(nodeModulePkgJsonString) } catch { pkgNotInstalled = true diff --git a/src/utils/initial-setup.ts b/src/utils/initial-setup.ts index c127fb8..7777517 100644 --- a/src/utils/initial-setup.ts +++ b/src/utils/initial-setup.ts @@ -31,6 +31,15 @@ export function findWorkspacesInSource(sourcePath: Source['path']) { } } +export function findWorkspacesInDestination(destinationPath: string) { + const workspaces = findWorkspaces(destinationPath) + + return { + hasWorkspaces: Boolean(workspaces), + workspaces, + } +} + export function hasConfigFile() { const configPath = join(currentDir, CONFIG_FILE_NAME) return fs.existsSync(configPath) @@ -41,14 +50,23 @@ export function isPrivate(pkgJson: PackageJson) { } const packageNameToFilePath = new Map() +const absolutePathsForDestinationPackages = new Set() /** - * Returns a map (package name to absolute file path) of packages inside the source repository + * Returns a Map (package name to absolute file path) of packages inside the source repository */ export function getPackageNamesToFilePath() { return packageNameToFilePath } +/** + * Returns a Set of absolute paths to packages inside destination that use source packages. + * Will be later used to only modify the package.json files that are actually using the source packages. + */ +export function getAbsolutePathsForDestinationPackages() { + return absolutePathsForDestinationPackages +} + /** * Go through the source folder and get all names of packages. * @@ -61,7 +79,17 @@ export function getPackageNamesToFilePath() { export function getPackages(sourcePath: Source['path'], workspaces: ReturnType['workspaces']) { // If workspaces is an empty Array or null, it means it's not a monorepo if (!workspaces) { - const pkgJsonPath = fs.readFileSync(join(sourcePath, 'package.json'), 'utf-8') + let pkgJsonPath = '' + + try { + pkgJsonPath = fs.readFileSync(join(sourcePath, 'package.json'), 'utf-8') + } + catch (e) { + logger.fatal(`Couldn't find package.json in ${sourcePath}. Make sure that the source.path inside \`${CONFIG_FILE_NAME}\` is correct.`) + + process.exit() + } + const pkgJson = destr(pkgJsonPath) if (pkgJson?.name) { @@ -98,17 +126,43 @@ export function getPackages(sourcePath: Source['path'], workspaces: ReturnType, devDependencies?: Record }>(fs.readFileSync(join(currentDir, 'package.json'), 'utf-8')) +export function getDestinationPackages(sourcePackages: SourcePackages, workspaces: ReturnType['workspaces']) { + if (!workspaces) { + const destPkgJson = destr<{ dependencies?: Record, devDependencies?: Record, name: string }>(fs.readFileSync(join(currentDir, 'package.json'), 'utf-8')) - if (!destPkgJson) - return [] + if (!destPkgJson) + return [] + + // Intersect sourcePackages with destination dependencies to get list of packages that are used + const deps = intersection( + sourcePackages, + Object.keys(merge({}, destPkgJson.dependencies, destPkgJson.devDependencies)), + ) + + if (deps.length > 0) { + absolutePathsForDestinationPackages.add(currentDir) + } + + return deps + } + + if (workspaces.length > 0) { + return workspaces.map((workspace) => { + const absolutePath = workspace.location + const pkgJson = workspace.package + + const deps = intersection( + sourcePackages, + Object.keys(merge({}, pkgJson.dependencies, pkgJson.devDependencies)), + ) - // Intersect sourcePackages with destination dependencies to get list of packages that are used - const destinationPackages = intersection( - sourcePackages, - Object.keys(merge({}, destPkgJson.dependencies, destPkgJson.devDependencies)), - ) + if (deps.length > 0) { + absolutePathsForDestinationPackages.add(absolutePath) + } + + return deps + }).flat() + } - return destinationPackages + return [] } diff --git a/src/utils/set-npm-tag-in-deps.ts b/src/utils/set-npm-tag-in-deps.ts new file mode 100644 index 0000000..656fe97 --- /dev/null +++ b/src/utils/set-npm-tag-in-deps.ts @@ -0,0 +1,55 @@ +import type { PackageJson } from '../types' + +interface SetNpmTagInDepsArgs { + packageJson: PackageJson + packagesToInstall: Array + newlyPublishedPackageVersions: Record +} + +interface AdjustDepsArgs { + deps: PackageJson['dependencies'] | PackageJson['devDependencies'] | PackageJson['peerDependencies'] + packagesToInstall: Array + newlyPublishedPackageVersions: Record +} + +/** + * Traverse the dependencies object and adjust the versions of the packages that are to be installed. + * Use the newlyPublishedPackageVersions object so that the local registry is used for those dependencies. + * + * @returns {boolean} Whether the dependencies object was changed + */ +export function adjustDeps({ deps, packagesToInstall, newlyPublishedPackageVersions }: AdjustDepsArgs) { + if (!deps) + return false + + let changed = false + + Object.keys(deps).forEach((depName) => { + if (packagesToInstall.includes(depName)) { + deps[depName] = newlyPublishedPackageVersions[depName] + changed = true + } + }) + + return changed +} + +/** + * When the destination uses workspaces, the dependencies/devDependencies/peerDependencies versions of source packages need to be changed to the newly published package versions. + * Once this work is done, a mere `npm install` will install the packages from the local registry. + */ +export function setNpmTagInDeps({ packageJson, packagesToInstall, newlyPublishedPackageVersions }: SetNpmTagInDepsArgs) { + // Make a new object to avoid mutating the original package.json + const pkgJson = { ...packageJson } + let changed = false + + // Adjust all dependencies. If any of them are changed, `changed` should be set to true + changed = adjustDeps({ deps: pkgJson.dependencies, packagesToInstall, newlyPublishedPackageVersions }) || changed + changed = adjustDeps({ deps: pkgJson.devDependencies, packagesToInstall, newlyPublishedPackageVersions }) || changed + changed = adjustDeps({ deps: pkgJson.peerDependencies, packagesToInstall, newlyPublishedPackageVersions }) || changed + + return { + updatedPkgJson: pkgJson, + changed, + } +} diff --git a/src/verdaccio/add-dependencies.ts b/src/verdaccio/add-dependencies.ts index 4bfea6c..dd204b2 100644 --- a/src/verdaccio/add-dependencies.ts +++ b/src/verdaccio/add-dependencies.ts @@ -2,8 +2,6 @@ import type { PackageManager, PackageManagerName } from 'nypm' import type { PromisifiedSpawnArgs } from '../utils/promisified-spawn' import { REGISTRY_URL } from './verdaccio-config' -// TODO(feature): Handle workspaces - interface GetAddDependenciesCmdArgs { packages: Array pm: PackageManager @@ -11,6 +9,12 @@ interface GetAddDependenciesCmdArgs { env?: NodeJS.ProcessEnv } +interface GetInstallCmdArgs { + pm: PackageManager + externalRegistry?: boolean + env?: NodeJS.ProcessEnv +} + const installMap: Record = { npm: 'install', pnpm: 'add', @@ -30,3 +34,9 @@ export function getAddDependenciesCmd({ packages, pm, externalRegistry = false, return commands } + +export function getInstallCmd({ pm, externalRegistry = false, env = {} }: GetInstallCmdArgs) { + const commands: PromisifiedSpawnArgs = [pm.command, ['install', !externalRegistry ? `--registry=${REGISTRY_URL}` : null].filter(Boolean), { env }] + + return commands +} diff --git a/src/verdaccio/index.ts b/src/verdaccio/index.ts index 3fccb9e..ed04a41 100644 --- a/src/verdaccio/index.ts +++ b/src/verdaccio/index.ts @@ -4,7 +4,7 @@ import { customAlphabet } from 'nanoid/non-secure' import fs from 'fs-extra' import { intersection } from 'lodash-es' import { logger } from '../utils/logger' -import type { DestinationPackages, PackageNamesToFilePath, Source } from '../types' +import type { Destination, PackageNamesToFilePath, Source } from '../types' import { VERDACCIO_CONFIG } from './verdaccio-config' import { publishPackage } from './publish-package' import { installPackages } from './install-packages' @@ -15,6 +15,7 @@ async function startVerdaccio() { let resolved = false logger.log('[Verdaccio] Starting server...') + logger.debug(`[Verdaccio] Port: ${VERDACCIO_CONFIG.port}`) // Clear Verdaccio storage fs.removeSync(VERDACCIO_CONFIG.storage as string) @@ -43,13 +44,13 @@ async function startVerdaccio() { export interface PublishPackagesAndInstallArgs { packagesToPublish: Array - destinationPackages: DestinationPackages packageNamesToFilePath: PackageNamesToFilePath ignorePackageJsonChanges: (packageName: string, contentArray: Array) => () => void source: Source + destination: Destination } -export async function publishPackagesAndInstall({ packageNamesToFilePath, destinationPackages, ignorePackageJsonChanges, packagesToPublish, source }: PublishPackagesAndInstallArgs) { +export async function publishPackagesAndInstall({ packageNamesToFilePath, ignorePackageJsonChanges, packagesToPublish, source, destination }: PublishPackagesAndInstallArgs) { await startVerdaccio() const versionPostfix = `${Date.now()}-${nanoid()}` @@ -66,10 +67,11 @@ export async function publishPackagesAndInstall({ packageNamesToFilePath, destin }) } - const packagesToInstall = intersection(packagesToPublish, destinationPackages) + const packagesToInstall = intersection(packagesToPublish, destination.packages) await installPackages({ packagesToInstall, newlyPublishedPackageVersions, + destination, }) } diff --git a/src/verdaccio/install-packages.ts b/src/verdaccio/install-packages.ts index 2b523a0..80a7cea 100644 --- a/src/verdaccio/install-packages.ts +++ b/src/verdaccio/install-packages.ts @@ -1,34 +1,30 @@ import process from 'node:process' +import fs from 'fs-extra' +import { destr } from 'destr' +import { join } from 'pathe' import { execa } from 'execa' -import { detectPackageManager } from 'nypm' import { logger } from '../utils/logger' import type { PromisifiedSpawnArgs } from '../utils/promisified-spawn' import { promisifiedSpawn } from '../utils/promisified-spawn' -import { getAddDependenciesCmd } from './add-dependencies' +import type { Destination, PackageJson } from '../types' +import { getAbsolutePathsForDestinationPackages } from '../utils/initial-setup' +import { setNpmTagInDeps } from '../utils/set-npm-tag-in-deps' +import { getAddDependenciesCmd, getInstallCmd } from './add-dependencies' import { REGISTRY_URL } from './verdaccio-config' interface InstallPackagesArgs { packagesToInstall: Array newlyPublishedPackageVersions: Record + destination: Destination } -export async function installPackages({ newlyPublishedPackageVersions, packagesToInstall }: InstallPackagesArgs) { - const cwd = process.cwd() - const pm = await detectPackageManager(cwd, { includeParentDirs: false }) - logger.debug(`Detected package manager in destination: ${pm?.name}`) - - if (!pm) { - logger.fatal(`Failed to detect package manager in ${cwd} - -If you have control over the destination, manually add the "packageManager" key to its \`package.json\` file.`) - process.exit() - } +export async function installPackages({ newlyPublishedPackageVersions, packagesToInstall, destination }: InstallPackagesArgs) { + const { pm, hasWorkspaces } = destination + const { name, majorVersion } = pm const listOfPackagesToInstall = packagesToInstall.map(p => ` - ${p}`).join('\n') logger.log(`Installing packages from local registry:\n${listOfPackagesToInstall}`) - const { name, majorVersion } = pm - let externalRegistry = false let env: NodeJS.ProcessEnv = {} @@ -38,6 +34,11 @@ If you have control over the destination, manually add the "packageManager" key await execa`yarn config set npmRegistryServer ${REGISTRY_URL}` await execa`yarn config set unsafeHttpWhitelist --json ["localhost"]` + // secco tries to look at node_modules paths, so Yarn plug'n'play is not suitable + await execa`yarn config set nodeLinker node-modules` + // In pull requests these values would be enabled, breaking the installation + await execa`yarn config set enableHardenedMode false` + await execa`yarn config set enableImmutableInstalls false` } if (name === 'bun') { @@ -47,8 +48,22 @@ If you have control over the destination, manually add the "packageManager" key let installCmd!: PromisifiedSpawnArgs - if (false) { - // TODO(feature): Support workspace in destination repository + if (hasWorkspaces) { + const absolutePaths = getAbsolutePathsForDestinationPackages() + + absolutePaths.forEach((absPath) => { + const pkgJsonPath = join(absPath, 'package.json') + const packageJson = destr(fs.readFileSync(pkgJsonPath, 'utf8')) + const { changed, updatedPkgJson } = setNpmTagInDeps({ packageJson, packagesToInstall, newlyPublishedPackageVersions }) + + if (changed) { + logger.debug(`Adjusting dependencies in ${pkgJsonPath} to use newly published versions.`) + + fs.outputJSONSync(pkgJsonPath, updatedPkgJson, { spaces: 2 }) + } + }) + + installCmd = getInstallCmd({ pm, externalRegistry, env }) } else { const packages = packagesToInstall.map((p) => { diff --git a/src/verdaccio/publish-package.ts b/src/verdaccio/publish-package.ts index 307f195..0cd9b17 100644 --- a/src/verdaccio/publish-package.ts +++ b/src/verdaccio/publish-package.ts @@ -60,7 +60,7 @@ function adjustPackageJson({ sourcePkgJsonPath, packageName, packageNamesToFileP } } -type PublishPackageArgs = Omit & { +type PublishPackageArgs = Omit & { packageName: string versionPostfix: string } diff --git a/src/verdaccio/verdaccio-config.ts b/src/verdaccio/verdaccio-config.ts index 6c7a845..7c59c5d 100644 --- a/src/verdaccio/verdaccio-config.ts +++ b/src/verdaccio/verdaccio-config.ts @@ -3,10 +3,12 @@ import { join } from 'pathe' import type { Config as VerdaccioConfig } from '@verdaccio/types' import { CLI_NAME } from '../constants' +const PORT = Number.parseInt(process.env.SECCO_VERDACCIO_PORT || '') || 4873 // Default + // @ts-expect-error: Verdaccio's types are wrong export const VERDACCIO_CONFIG: VerdaccioConfig = { storage: join(os.tmpdir(), 'verdaccio', 'storage'), - port: 4873, // Default + port: PORT, max_body_size: '100mb', web: { enable: true, diff --git a/src/watcher.ts b/src/watcher.ts index 821e47e..80f51d1 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -36,17 +36,17 @@ interface PrivateCopyPathArgs extends CopyPathArgs { export async function watcher(source: Source, destination: Destination, packages: PackageNames | undefined, options: WatcherOptions) { const { packageNamesToFilePath, packages: sourcePackages } = source - const { packages: destinationPackages } = destination + const { packages: destinationPackages, hasWorkspaces: destinationHasWorkspaces } = destination const { verbose: isVerbose, scanOnce } = options let { forceVerdaccio } = options setDefaultSpawnStdio(isVerbose ? 'inherit' : 'ignore') - if (false) { - // Current logic of copying files from source to destination doesn't work yet with workspaces (inside destination), so force verdaccio usage for now. - // TODO(feature): Support workspaces in destination - // Reuse find-workspaces package to find all workspaces in destination + // Current logic of copying files from source to destination doesn't work with workspaces (inside destination), so force verdaccio usage for now. + // TODO: Implement file copying logic for workspaces in destination + if (destinationHasWorkspaces && !forceVerdaccio) { forceVerdaccio = true + logger.info('Workspaces detected in destination. Automatically enabling \`--force-verdaccio\` flag.') } let afterPackageInstallation = false @@ -147,9 +147,9 @@ export async function watcher(source: Source, destination: Destination, packages await publishPackagesAndInstall({ packagesToPublish: allPackagesToWatch, packageNamesToFilePath, - destinationPackages, ignorePackageJsonChanges, source, + destination, }) } else { @@ -288,9 +288,9 @@ export async function watcher(source: Source, destination: Destination, packages await publishPackagesAndInstall({ packagesToPublish: Array.from(packagesToPublish), packageNamesToFilePath, - destinationPackages, ignorePackageJsonChanges, source, + destination, }) packagesToPublish.clear()