diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index 64af7f22..100c03fa 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -112,18 +112,70 @@ export default class Base_javascript { } /** - * Checks if a required lock file exists in the manifest directory or at the workspace root. - * When TRUSTIFY_DA_WORKSPACE_DIR is provided (via env var or opts), - * checks only that directory for the lock file. + * Walks up the directory tree from manifestDir looking for the lock file. + * Stops when the lock file is found, when a package.json with a "workspaces" + * field is encountered without a lock file (workspace root boundary), or + * when the filesystem root is reached. + * + * When TRUSTIFY_DA_WORKSPACE_DIR is set, checks only that directory (no walk-up). + * + * @param {string} manifestDir - The directory to start searching from + * @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR + * @returns {string|null} The directory containing the lock file, or null + * @protected + */ + _isWorkspaceRoot(dir) { + if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + return true + } + const pkgJsonPath = path.join(dir, 'package.json') + if (fs.existsSync(pkgJsonPath)) { + try { + const content = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) + if (content.workspaces) { + return true + } + } catch (_) { + // ignore parse errors + } + } + return false + } + + _findLockFileDir(manifestDir, opts = {}) { + const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) + if (workspaceDir) { + const dir = path.resolve(workspaceDir) + return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null + } + + let dir = path.resolve(manifestDir) + let parent = dir + + do { + dir = parent + + if (fs.existsSync(path.join(dir, this._lockFileName()))) { + return dir + } + + if (this._isWorkspaceRoot(dir)) { + return null + } + + parent = path.dirname(dir) + } while (parent !== dir) + + return null + } + + /** * @param {string} manifestDir - The base directory where the manifest is located - * @param {{TRUSTIFY_DA_WORKSPACE_DIR?: string}} [opts={}] - optional workspace root + * @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR * @returns {boolean} True if the lock file exists */ validateLockFile(manifestDir, opts = {}) { - const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) - const dirToCheck = workspaceDir ? path.resolve(workspaceDir) : manifestDir - const lock = path.join(dirToCheck, this._lockFileName()) - return fs.existsSync(lock) + return this._findLockFileDir(manifestDir, opts) !== null } /** @@ -188,8 +240,7 @@ export default class Base_javascript { _buildDependencyTree(includeTransitive, opts = {}) { this._version(); const manifestDir = path.dirname(this.#manifest.manifestPath); - const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts) - const cmdDir = workspaceDir ? path.resolve(workspaceDir) : manifestDir; + const cmdDir = this._findLockFileDir(manifestDir, opts) || manifestDir; this.#createLockFile(cmdDir); let output = this.#executeListCmd(includeTransitive, cmdDir); @@ -310,7 +361,7 @@ export default class Base_javascript { */ #executeListCmd(includeTransitive, manifestDir) { const listArgs = this._listCmdArgs(includeTransitive, manifestDir); - return this.#invokeCommand(listArgs); + return this.#invokeCommand(listArgs, { cwd: manifestDir }); } /** @@ -332,14 +383,12 @@ export default class Base_javascript { const isWindows = os.platform() === 'win32'; if (isWindows) { - // On Windows, --prefix flag doesn't work as expected - // Instead of installing from the prefix folder, it installs from current working directory process.chdir(manifestDir); } try { const args = this._updateLockFileCmdArgs(manifestDir); - this.#invokeCommand(args); + this.#invokeCommand(args, { cwd: manifestDir }); } finally { if (isWindows) { process.chdir(originalDir); diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 59708b08..eec02da1 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -53,6 +53,8 @@ suite('testing the javascript-npm data provider', async () => { [ { name: 'npm/with_lock_file', validation: true }, { name: 'npm/without_lock_file', validation: false }, + { name: 'npm/workspace_member_with_lock/packages/module-a', validation: true }, + { name: 'npm/workspace_member_without_lock/packages/module-a', validation: false }, { name: 'pnpm/with_lock_file', validation: true }, { name: 'pnpm/without_lock_file', validation: false }, { name: 'yarn-classic/with_lock_file', validation: true }, @@ -168,4 +170,38 @@ suite('testing the javascript-npm data provider', async () => { expect(provider.isSupported('package.json')).to.be.true }) + test('verify workspace member walks up and finds lock file at workspace root', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' + const provider = match(manifest, availableProviders) + expect(provider).to.not.be.null + expect(provider.isSupported('package.json')).to.be.true + }) + + test('verify workspace member throws when workspace root has no lock file', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json' + expect(() => match(manifest, availableProviders)) + .to.throw('package.json requires a lock file') + }) + + test('verify match with opts.TRUSTIFY_DA_WORKSPACE_DIR overrides walk-up for workspace member', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' + const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_with_lock' } + const provider = match(manifest, availableProviders, opts) + expect(provider).to.not.be.null + expect(provider.isSupported('package.json')).to.be.true + }) + + test('verify pnpm workspace member stops at pnpm-workspace.yaml boundary', () => { + const manifest = 'test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json' + expect(() => match(manifest, availableProviders)) + .to.throw('package.json requires a lock file') + }) + + test('verify match with wrong TRUSTIFY_DA_WORKSPACE_DIR fails even when walk-up would succeed', () => { + const manifest = 'test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json' + const opts = { TRUSTIFY_DA_WORKSPACE_DIR: 'test/providers/provider_manifests/npm/workspace_member_without_lock' } + expect(() => match(manifest, availableProviders, opts)) + .to.throw('package.json requires a lock file') + }) + }).beforeAll(() => clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z'))).afterAll(() => clock.restore()); diff --git a/test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json b/test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json new file mode 100644 index 00000000..268208f5 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_with_lock/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace-root", + "lockfileVersion": 3 +} diff --git a/test/providers/provider_manifests/npm/workspace_member_with_lock/package.json b/test/providers/provider_manifests/npm/workspace_member_with_lock/package.json new file mode 100644 index 00000000..98101a90 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_with_lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-root", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json b/test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json new file mode 100644 index 00000000..05c16625 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_with_lock/packages/module-a/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-a", + "version": "1.0.0" +} diff --git a/test/providers/provider_manifests/npm/workspace_member_without_lock/package.json b/test/providers/provider_manifests/npm/workspace_member_without_lock/package.json new file mode 100644 index 00000000..98101a90 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_without_lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-root", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json b/test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json new file mode 100644 index 00000000..05c16625 --- /dev/null +++ b/test/providers/provider_manifests/npm/workspace_member_without_lock/packages/module-a/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-a", + "version": "1.0.0" +} diff --git a/test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json new file mode 100644 index 00000000..2266cc9d --- /dev/null +++ b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-pnpm-workspace-root", + "private": true +} diff --git a/test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json new file mode 100644 index 00000000..05c16625 --- /dev/null +++ b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/packages/module-a/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-a", + "version": "1.0.0" +} diff --git a/test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/test/providers/provider_manifests/pnpm/workspace_member_without_lock/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*'