Unify repeat-install freshness and `verifyDepsBeforeRun` validation across pnpm v11 and v12 (Closes pnpm/tasks#161, pnpm/tasks#157, pnpm/tasks#148): 1. Centralize v11 workspace state updates in `@pnpm/workspace.state` (`preserveCarriedSettings`) and `@pnpm/installing.commands` (`workspaceStateUpdate.ts`), preserving carried `dev`/`optional`/`production` settings when `ignoredWorkspaceStateSettings` ignores them and preserving v12-recorded `autoDedupe`. A filtered install that prunes direct dependencies no longer records the workspace root as installed. 2. Hold `{.}...` workspace dependencies for non-recursive `pnpm run`, `pnpm exec`, and `pnpm restart` inside a workspace project in v11 (`selectProjectsToVerify.ts`). 3. Align v11 recorded validation (`checkRecordedWorkspace`) and legacy unrecorded validation (`checkModifiedSinceLastValidation`) in `@pnpm/deps.status` with v12's `check_freshness`, including empty-project handling, `dedupeDirectDeps`, `dedupePeerDependents`, `autoInstallPeers`, `excludeLinksFromLockfile`, catalog/override config comparison, and `opts.lockfileDir` resolution. 4. Forward effective CLI flags (`--lockfile-dir`, `--modules-dir`, `--virtual-store-dir`, `--store-dir`, `--dir`, `--workspace-root`, `--prod`, `--dev`, `--no-optional`, `--filter`, `--filter-prod`, `--test-pattern`, `--changed-files-ignore-pattern`, and `--config.*`) when `verifyDepsBeforeRun: install` spawns `pnpm install` in both v11 and v12. 5. Align `GatePolicy::dedupe_peers` and empty-project predicates in v12 `pnpm-package-manager`, and run `repeat_install_oracle.rs` across v12, v11, and mixed v11/v12 writers in CI, with a longer nextest period for the peer convergence harness that shares the job. 6. Parse `--config.<key>=<value>` with the type the command declares for `<key>` in v11, so the list and number options forwarded to the spawned install arrive as lists and numbers; forward `--npmrc-auth-file` in v11 and `--test-pattern` / `--changed-files-ignore-pattern` in v12, so both forward the same set; and skip the current-lockfile read for a project whose manifest changed but whose lockfile did not, as v12 does. 7. Resolve relative v11 store overrides against the workspace or project root before forwarding them to the spawned install. Remove the isolated linker's duplicate package merge so filtered installs retain unselected dependencies and drop obsolete selected dependencies from the current lockfile. Cover both cases with CLI regressions. The restore API also handles a selected project absent from the previous current lockfile. 8. Record workspace validation from the projects the installers actually processed. Ignored projects receive no new proof, and empty or entirely ignored selections leave state untouched. Cover both lockfile modes and an ignored workspace root.
635 lines
23 KiB
TypeScript
635 lines
23 KiB
TypeScript
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { expect, test } from '@jest/globals'
|
|
import { parseCliArgs } from '@pnpm/cli.parse-cli-args'
|
|
import type { PnpmError } from '@pnpm/error'
|
|
import { temporaryDirectory } from 'tempy'
|
|
|
|
const DEFAULT_OPTS = {
|
|
getCommandLongName: (_commandName: string) => _commandName,
|
|
getTypesByCommandName: (_commandName: string) => ({}),
|
|
renamedOptions: { prefix: 'dir' },
|
|
shorthandsByCommandName: {},
|
|
universalOptionsTypes: {},
|
|
universalShorthands: {},
|
|
}
|
|
|
|
test('a command is recursive if it has a --filter option', async () => {
|
|
const { options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { filter: [String, Array] },
|
|
}, ['--filter', 'foo', 'update'])
|
|
expect(cmd).toBe('update')
|
|
expect(options).toHaveProperty(['recursive'])
|
|
})
|
|
|
|
test('a command is recursive if it has a --filter-prod option', async () => {
|
|
const { options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { 'filter-prod': [String, Array] },
|
|
}, ['--filter-prod', 'foo', 'update'])
|
|
expect(cmd).toBe('update')
|
|
expect(options).toHaveProperty(['recursive'])
|
|
})
|
|
|
|
test('a command is recursive if -r option is used', async () => {
|
|
const { options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { recursive: Boolean },
|
|
universalShorthands: { r: '--recursive' },
|
|
}, ['-r', 'update'])
|
|
expect(cmd).toBe('update')
|
|
expect(options).toHaveProperty(['recursive'])
|
|
})
|
|
|
|
test('a command is recursive if --recursive option is used', async () => {
|
|
const { options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { recursive: Boolean },
|
|
}, ['-r', 'update'])
|
|
expect(cmd).toBe('update')
|
|
expect(options).toHaveProperty(['recursive'])
|
|
})
|
|
|
|
test('recursive is returned as the command name if no subcommand passed', async () => {
|
|
const { options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { filter: [String, Array] },
|
|
}, ['recursive'])
|
|
expect(cmd).toBe('recursive')
|
|
expect(options).toHaveProperty(['recursive'])
|
|
})
|
|
|
|
test('when running a global command inside a workspace, the workspace should be ignored', async () => {
|
|
const { workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { global: Boolean },
|
|
}, ['--global', 'add', 'foo'])
|
|
expect(workspaceDir).toBeFalsy()
|
|
})
|
|
|
|
test('when reading the global config inside a workspace, the workspace should be ignored', async () => {
|
|
const { workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => commandName === 'config' ? { location: ['global', 'project'] } : {},
|
|
}, ['config', 'get', 'minimumReleaseAge', '--location=global'])
|
|
expect(workspaceDir).toBeFalsy()
|
|
})
|
|
|
|
test('--location=project takes precedence over --global when looking for the workspace', async () => {
|
|
const { workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { global: Boolean },
|
|
getTypesByCommandName: (commandName: string) => commandName === 'config' ? { location: ['global', 'project'] } : {},
|
|
}, ['config', 'get', 'nodeLinker', '--global', '--location=project'])
|
|
expect(workspaceDir).toBeTruthy()
|
|
})
|
|
|
|
test('--workspace-root is allowed with --global when --location=project overrides it', async () => {
|
|
const { workspaceDir, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { global: Boolean, 'workspace-root': Boolean },
|
|
getTypesByCommandName: (commandName: string) => commandName === 'config' ? { location: ['global', 'project'] } : {},
|
|
}, ['config', 'set', 'nodeLinker', 'hoisted', '--global', '--location=project', '--workspace-root'])
|
|
expect(options.dir).toBe(workspaceDir)
|
|
})
|
|
|
|
test('when running with --ignore-workspace option inside a workspace, the workspace should be ignored', async () => {
|
|
const { workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { global: Boolean },
|
|
}, ['--ignore-workspace', 'add', 'foo'])
|
|
expect(workspaceDir).toBeFalsy()
|
|
})
|
|
|
|
test('command is used recursively', async () => {
|
|
const { cmd, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: {},
|
|
}, ['recursive', 'outdated'])
|
|
expect(cmd).toBe('outdated')
|
|
expect(options.recursive).toBe(true)
|
|
})
|
|
|
|
test('the install command is converted to add when called with args', async () => {
|
|
const { params, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, ['install', 'rimraf@1'])
|
|
expect(cmd).toBe('add')
|
|
expect(params).toStrictEqual(['rimraf@1'])
|
|
})
|
|
|
|
test('the "i" command is converted to add when called with args', async () => {
|
|
const { params, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getCommandLongName: (commandName) => commandName === 'i' ? 'install' : commandName,
|
|
}, ['i', 'rimraf@1'])
|
|
expect(cmd).toBe('add')
|
|
expect(params).toStrictEqual(['rimraf@1'])
|
|
})
|
|
|
|
test('detect unknown options', async () => {
|
|
const { unknownOptions } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
if (commandName === 'install') {
|
|
return {
|
|
bar: Boolean,
|
|
recursive: Boolean,
|
|
registry: String,
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
universalOptionsTypes: { filter: [String, Array] },
|
|
}, ['install', '--save-dev', '--registry=https://example.com', '--@scope:registry=https://scope.example.com/npm', '--qar', '--filter=packages'])
|
|
expect(Array.from(unknownOptions.entries())).toStrictEqual([['save-dev', []], ['qar', ['bar']]])
|
|
})
|
|
|
|
test('allow any option that starts with "config."', async () => {
|
|
const { options, unknownOptions } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
if (commandName === 'install') {
|
|
return {
|
|
bar: Boolean,
|
|
recursive: Boolean,
|
|
registry: String,
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
universalOptionsTypes: { filter: [String, Array] },
|
|
}, ['install', '--config.save-dev', '--registry=https://example.com', '--config.qar', '--filter=packages'])
|
|
expect(Array.from(unknownOptions.entries())).toStrictEqual([])
|
|
expect(options.qar).toBe(true)
|
|
expect(options['save-dev']).toBe(true)
|
|
})
|
|
|
|
test('a "config." option takes the type the command declares for it', async () => {
|
|
const { options, rawCliConfig } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
if (commandName === 'install') {
|
|
return {
|
|
'fetch-retries': Number,
|
|
'hoist-pattern': [String, Array],
|
|
offline: Boolean,
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
universalOptionsTypes: { 'test-pattern': [String, Array] },
|
|
}, ['install', '--config.hoist-pattern=*eslint*', '--config.test-pattern=test/**', '--config.fetch-retries=3', '--config.offline=false', '--config.qar=1'])
|
|
expect(options['hoist-pattern']).toStrictEqual(['*eslint*'])
|
|
expect(options['test-pattern']).toStrictEqual(['test/**'])
|
|
expect(options['fetch-retries']).toBe(3)
|
|
expect(options.offline).toBe(false)
|
|
expect(options.qar).toBe('1')
|
|
expect(rawCliConfig).toStrictEqual({ 'hoist-pattern': ['*eslint*'], 'test-pattern': ['test/**'], 'fetch-retries': 3, offline: false, qar: '1' })
|
|
})
|
|
|
|
test('do not incorrectly change "install" command to "add"', async () => {
|
|
const { cmd, fallbackCommandUsed } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
switch (commandName) {
|
|
case 'install': return { 'network-concurrency': Number }
|
|
default: return {}
|
|
}
|
|
},
|
|
universalOptionsTypes: {
|
|
prefix: String,
|
|
},
|
|
universalShorthands: {
|
|
C: '--prefix',
|
|
r: '--recursive',
|
|
},
|
|
}, ['install', '-C', os.homedir(), '--network-concurrency', '1'])
|
|
expect(cmd).toBe('install')
|
|
expect(fallbackCommandUsed).toBeFalsy()
|
|
})
|
|
|
|
test('if a help option is used, set cmd to "help"', async () => {
|
|
const { cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, ['install', '--help'])
|
|
expect(cmd).toBe('help')
|
|
})
|
|
|
|
test('if a help option is used with an unknown command, do not set cmd to "help"', async () => {
|
|
const { cmd, fallbackCommandUsed } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getCommandLongName: () => null,
|
|
fallbackCommand: 'run',
|
|
}, ['eslint', '--help'])
|
|
expect(cmd).toBe('run')
|
|
expect(fallbackCommandUsed).toBeTruthy()
|
|
})
|
|
|
|
test('no command', async () => {
|
|
const { cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, ['--version'])
|
|
expect(cmd).toBeNull()
|
|
})
|
|
|
|
// Regression for pnpm/pnpm#11487 — --pm-on-fail must reach the consumer even when
|
|
// short-circuited by --help, otherwise users can't bypass the
|
|
// packageManager check just to read help text for a stale-pinned project.
|
|
test('universal options typed in the exploratory parse survive the --help short-circuit', async () => {
|
|
const { cmd, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { 'pm-on-fail': ['ignore', 'warn', 'error'] },
|
|
}, ['install', '--pm-on-fail=ignore', '--help'])
|
|
expect(cmd).toBe('help')
|
|
expect(options).toMatchObject({ 'pm-on-fail': 'ignore' })
|
|
})
|
|
|
|
test('universal options typed in the exploratory parse survive the --version short-circuit', async () => {
|
|
const { cmd, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { 'pm-on-fail': ['ignore', 'warn', 'error'] },
|
|
}, ['--pm-on-fail=ignore', '--version'])
|
|
expect(cmd).toBeNull()
|
|
expect(options).toMatchObject({ version: true, 'pm-on-fail': 'ignore' })
|
|
})
|
|
|
|
test('command-specific options do NOT leak through the --help short-circuit', async () => {
|
|
// We're not executing the command, so its options shouldn't appear in
|
|
// cliOptions and accidentally influence config (e.g. --frozen-lockfile
|
|
// shouldn't bleed into the help path).
|
|
const { cmd, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (name) => name === 'install' ? { 'frozen-lockfile': Boolean } : {},
|
|
}, ['install', '--frozen-lockfile', '--help'])
|
|
expect(cmd).toBe('help')
|
|
expect(options).not.toHaveProperty(['frozen-lockfile'])
|
|
})
|
|
|
|
// renamedOptions (e.g. pnpm's --prefix → dir) must be applied in the
|
|
// short-circuit too, otherwise consumers downstream receive inconsistent
|
|
// keys depending on whether --help/--version was the entry path.
|
|
test('renamedOptions are applied to picked universal options in --help short-circuit', async () => {
|
|
const { cmd, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { prefix: String },
|
|
renamedOptions: { prefix: 'dir' },
|
|
}, ['install', '--prefix=/foo', '--help'])
|
|
expect(cmd).toBe('help')
|
|
expect(options).toMatchObject({ dir: '/foo' })
|
|
expect(options).not.toHaveProperty(['prefix'])
|
|
})
|
|
|
|
test('renamedOptions are applied to picked universal options in --version short-circuit', async () => {
|
|
const { cmd, options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { prefix: String },
|
|
renamedOptions: { prefix: 'dir' },
|
|
}, ['--prefix=/foo', '--version'])
|
|
expect(cmd).toBeNull()
|
|
expect(options).toMatchObject({ version: true, dir: '/foo' })
|
|
expect(options).not.toHaveProperty(['prefix'])
|
|
})
|
|
|
|
test('use command-specific shorthands', async () => {
|
|
const { options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
if (commandName === 'install') {
|
|
return {
|
|
dev: Boolean,
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
shorthandsByCommandName: {
|
|
install: { D: '--dev' },
|
|
},
|
|
}, ['install', '-D'])
|
|
expect(options).toHaveProperty(['dev'])
|
|
})
|
|
|
|
test('bare --color does not consume a following command-specific shorthand', async () => {
|
|
const { cmd, options, params } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
if (commandName === 'run') {
|
|
return {
|
|
recursive: Boolean,
|
|
sort: Boolean,
|
|
stream: Boolean,
|
|
'workspace-concurrency': Number,
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
shorthandsByCommandName: {
|
|
run: {
|
|
parallel: ['--workspace-concurrency=Infinity', '--no-sort', '--stream', '--recursive'],
|
|
},
|
|
},
|
|
universalOptionsTypes: {
|
|
color: [Boolean, 'always', 'auto', 'never'],
|
|
recursive: Boolean,
|
|
},
|
|
}, ['--recursive', '--color', '--parallel', 'run', 'dev'])
|
|
|
|
expect(cmd).toBe('run')
|
|
expect(params).toStrictEqual(['dev'])
|
|
expect(options.color).toBe(true)
|
|
expect(options.recursive).toBe(true)
|
|
expect(options['workspace-concurrency']).toBe(Infinity)
|
|
expect(options.sort).toBe(false)
|
|
expect(options.stream).toBe(true)
|
|
})
|
|
|
|
test('command-specific shorthands override universal shorthands', async () => {
|
|
const { options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
getTypesByCommandName: (commandName: string) => {
|
|
if (commandName === 'add') {
|
|
return {
|
|
'save-dev': Boolean,
|
|
'save-prod': Boolean,
|
|
'save-optional': Boolean,
|
|
'save-exact': Boolean,
|
|
loglevel: String,
|
|
parseable: Boolean,
|
|
}
|
|
}
|
|
return {}
|
|
},
|
|
universalShorthands: {
|
|
d: '--loglevel',
|
|
p: '--parseable',
|
|
},
|
|
shorthandsByCommandName: {
|
|
add: {
|
|
d: '--save-dev',
|
|
p: '--save-prod',
|
|
o: '--save-optional',
|
|
e: '--save-exact',
|
|
},
|
|
},
|
|
}, ['add', '-d', '-p', '-o', '-e', 'package'])
|
|
expect(options['save-dev']).toBe(true)
|
|
expect(options['save-prod']).toBe(true)
|
|
expect(options['save-optional']).toBe(true)
|
|
expect(options['save-exact']).toBe(true)
|
|
expect(options.loglevel).toBeUndefined()
|
|
expect(options.parseable).toBeUndefined()
|
|
})
|
|
|
|
test('any unknown command is treated as a script', async () => {
|
|
const { options, cmd, params, fallbackCommandUsed } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
fallbackCommand: 'run',
|
|
getCommandLongName: () => null,
|
|
universalOptionsTypes: { filter: [String, Array] },
|
|
}, ['--recursive', 'foo'])
|
|
expect(cmd).toBe('run')
|
|
expect(params).toStrictEqual(['foo'])
|
|
expect(options).toHaveProperty(['recursive'])
|
|
expect(fallbackCommandUsed).toBeTruthy()
|
|
})
|
|
|
|
test('run script with --help before script name is help command', async () => {
|
|
const { cmd, params } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
fallbackCommand: 'run',
|
|
}, ['run', '--help', 'foo'])
|
|
expect(cmd).toBe('help')
|
|
expect(params).toStrictEqual(['run', 'foo'])
|
|
})
|
|
|
|
test.each([
|
|
['foo', { params: 'foo', options: {} }],
|
|
['foo --bar baz --qux', { params: 'foo --bar baz --qux', options: {} }],
|
|
['-r foo', { params: 'foo', options: { recursive: true } }],
|
|
['-r foo --bar baz --qux', { params: 'foo --bar baz --qux', options: { recursive: true } }],
|
|
|
|
// Edge case where option value is the script name. Fortunately nopt handles this correctly.
|
|
['--test-pattern test test foo', { params: 'test foo', options: { 'test-pattern': ['test'] } }],
|
|
|
|
// Ensure even builtin flags are passed to the script.
|
|
['foo -r', { params: 'foo -r', options: {} }],
|
|
['foo --recursive', { params: 'foo --recursive', options: {} }],
|
|
['foo -h', { params: 'foo -h', options: {} }],
|
|
['foo --help', { params: 'foo --help', options: {} }],
|
|
['foo --filter=bar', { params: 'foo --filter=bar', options: {} }],
|
|
])('run script arguments are correct for: %s', async (testInput, expected) => {
|
|
for (const testWithCommandFallback of [true, false]) {
|
|
// Whether or not the leading "run" portion of the command is written
|
|
// shouldn't affect its arg parsing. Test both scenarios for good measure.
|
|
const input = [...(testWithCommandFallback ? [] : ['run']), ...testInput.split(' ')]
|
|
|
|
// eslint-disable-next-line no-await-in-loop -- two variants, asserted one after the other
|
|
const { options, cmd, params, fallbackCommandUsed } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
fallbackCommand: 'run',
|
|
getCommandLongName: (name) => name === 'run' ? 'run' : null,
|
|
universalOptionsTypes: { filter: [String, Array], 'test-pattern': [String, Array] },
|
|
}, input)
|
|
expect(cmd).toBe('run')
|
|
expect(params).toStrictEqual(expected.params.split(' '))
|
|
expect(options).toStrictEqual(expected.options)
|
|
|
|
if (testWithCommandFallback) {
|
|
expect(fallbackCommandUsed).toBeTruthy()
|
|
}
|
|
}
|
|
})
|
|
|
|
test("don't use the fallback command if no command is present", async () => {
|
|
const { cmd, params } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
fallbackCommand: 'run',
|
|
getCommandLongName: () => null,
|
|
universalOptionsTypes: { filter: [String, Array] },
|
|
}, [])
|
|
expect(cmd).toBeNull()
|
|
expect(params).toStrictEqual([])
|
|
})
|
|
|
|
test('--workspace-root changes the directory to the workspace root', async () => {
|
|
const { options, workspaceDir } = await parseCliArgs({ ...DEFAULT_OPTS }, ['--workspace-root'])
|
|
expect(workspaceDir).toBeTruthy()
|
|
expect(options.dir).toBe(workspaceDir)
|
|
})
|
|
|
|
test('--workspace-root fails if used with --global', async () => {
|
|
let err!: PnpmError
|
|
try {
|
|
await parseCliArgs({ ...DEFAULT_OPTS }, ['--workspace-root', '--global'])
|
|
} catch (_err: any) { // eslint-disable-line
|
|
err = _err
|
|
}
|
|
expect(err).toBeTruthy()
|
|
expect(err.code).toBe('ERR_PNPM_OPTIONS_CONFLICT')
|
|
})
|
|
|
|
test('--workspace-root fails if used outside of a workspace', async () => {
|
|
process.chdir(temporaryDirectory())
|
|
let err!: PnpmError
|
|
try {
|
|
await parseCliArgs({ ...DEFAULT_OPTS }, ['--workspace-root'])
|
|
} catch (_err: any) { // eslint-disable-line
|
|
err = _err
|
|
}
|
|
expect(err).toBeTruthy()
|
|
expect(err.code).toBe('ERR_PNPM_NOT_IN_WORKSPACE')
|
|
})
|
|
|
|
// Regression for pnpm/pnpm#11535. The renamed option (`--prefix` → `dir`) must be
|
|
// considered when locating the workspace root; otherwise running pnpm from
|
|
// a directory outside the project (e.g. `pnpm --prefix=child install` from
|
|
// the parent dir) misses the workspace manifest in the prefix dir, and
|
|
// settings declared there (e.g. allowBuilds) are silently overwritten.
|
|
function setupParentWithChildWorkspace (): { parent: string, child: string } {
|
|
const parent = temporaryDirectory()
|
|
const child = path.join(parent, 'child')
|
|
fs.mkdirSync(child)
|
|
fs.writeFileSync(path.join(child, 'pnpm-workspace.yaml'), '')
|
|
process.chdir(parent)
|
|
return { parent, child }
|
|
}
|
|
|
|
test('workspaceDir resolves from --prefix when prefix is renamed to dir', async () => {
|
|
const { child } = setupParentWithChildWorkspace()
|
|
const { workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { prefix: String },
|
|
}, ['install', '--prefix=child'])
|
|
expect(workspaceDir && fs.realpathSync.native(workspaceDir)).toBe(fs.realpathSync.native(child))
|
|
})
|
|
|
|
test('workspaceDir resolves from --prefix on the --help short-circuit', async () => {
|
|
const { child } = setupParentWithChildWorkspace()
|
|
const { cmd, workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { prefix: String, help: Boolean },
|
|
}, ['install', '--prefix=child', '--help'])
|
|
expect(cmd).toBe('help')
|
|
expect(workspaceDir && fs.realpathSync.native(workspaceDir)).toBe(fs.realpathSync.native(child))
|
|
})
|
|
|
|
test('workspaceDir resolves from --prefix on the --version short-circuit', async () => {
|
|
const { child } = setupParentWithChildWorkspace()
|
|
const { cmd, workspaceDir } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { prefix: String, version: Boolean },
|
|
}, ['--prefix=child', '--version'])
|
|
expect(cmd).toBeNull()
|
|
expect(workspaceDir && fs.realpathSync.native(workspaceDir)).toBe(fs.realpathSync.native(child))
|
|
})
|
|
|
|
// When both the alias and the canonical option are supplied, the canonical
|
|
// value must win and the alias must be dropped — otherwise --prefix could
|
|
// silently overwrite an explicit --dir.
|
|
test('canonical option wins when both --prefix and --dir are passed', async () => {
|
|
const { options } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
universalOptionsTypes: { prefix: String, dir: String },
|
|
}, ['install', '--prefix=fromPrefix', '--dir=fromDir'])
|
|
expect(options.dir).toBe('fromDir')
|
|
expect(options).not.toHaveProperty(['prefix'])
|
|
})
|
|
|
|
test('everything after an escape arg is a parameter', async () => {
|
|
const { params, options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
escapeArgs: ['exec'],
|
|
}, ['-r', 'exec', 'rm', '-rf', 'node_modules'])
|
|
expect(cmd).toBe('exec')
|
|
expect(options).toHaveProperty(['recursive'])
|
|
expect(params).toStrictEqual(['rm', '-rf', 'node_modules'])
|
|
})
|
|
|
|
test('everything after an escape arg is a parameter, even if it has a help option', async () => {
|
|
const { params, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
escapeArgs: ['exec'],
|
|
}, ['exec', 'rm', '--help'])
|
|
expect(cmd).toBe('exec')
|
|
expect(params).toStrictEqual(['rm', '--help'])
|
|
})
|
|
|
|
test('an escape arg appearing as another command\'s parameter does not stop option parsing', async () => {
|
|
const { params, options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
escapeArgs: ['create', 'exec', 'test'],
|
|
getTypesByCommandName: (commandName: string) => commandName === 'team' ? { registry: String } : {},
|
|
}, ['team', 'create', '@org:newteam', '--registry', 'https://registry.example.com/'])
|
|
expect(cmd).toBe('team')
|
|
expect(params).toStrictEqual(['create', '@org:newteam'])
|
|
expect(options.registry).toBe('https://registry.example.com/')
|
|
})
|
|
|
|
test('`pnpm install ""` is going to be just `pnpm install`', async () => {
|
|
const { params, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, ['install', ''])
|
|
expect(cmd).toBe('add')
|
|
// empty string in params will be filtered at: https://github.com/pnpm/pnpm/blob/32679f0ad4/pkg-manager/plugin-commands-installation/src/installDeps.ts#L196
|
|
expect(params).toStrictEqual([''])
|
|
})
|
|
|
|
test('should not swallows empty string in params', async () => {
|
|
const { params, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, ['run', 'echo', '', 'foo', '', 'bar'])
|
|
expect(cmd).toBe('run')
|
|
expect(params).toStrictEqual(['echo', '', 'foo', '', 'bar'])
|
|
})
|
|
|
|
test('dlx parses CLI options in between "dlx" and the command name', async () => {
|
|
const { params, options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, [
|
|
'--reporter=append-only',
|
|
'dlx',
|
|
'--allow-build=some-package',
|
|
'--package=some-bin-package',
|
|
'some-command',
|
|
'--this-is-not-a-flag',
|
|
'another-argument',
|
|
])
|
|
expect(cmd).toBe('dlx')
|
|
expect(options).toStrictEqual({
|
|
reporter: 'append-only',
|
|
'allow-build': 'some-package',
|
|
package: 'some-bin-package',
|
|
})
|
|
expect(params).toStrictEqual([
|
|
'some-command',
|
|
'--this-is-not-a-flag',
|
|
'another-argument',
|
|
])
|
|
})
|
|
|
|
test('dlx stops parsing after "--"', async () => {
|
|
const { params, options, cmd } = await parseCliArgs({
|
|
...DEFAULT_OPTS,
|
|
}, [
|
|
'dlx',
|
|
'--package=some-package',
|
|
'--allow-build=foo',
|
|
'--allow-build=bar',
|
|
'--',
|
|
'--this-is-a-command',
|
|
'argument',
|
|
])
|
|
expect(cmd).toBe('dlx')
|
|
expect(options).toStrictEqual({
|
|
package: 'some-package',
|
|
'allow-build': ['foo', 'bar'],
|
|
})
|
|
expect(params).toStrictEqual([
|
|
'--this-is-a-command',
|
|
'argument',
|
|
])
|
|
})
|