diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 59009cb06a..79254b603c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -424,6 +424,7 @@ Commands: publish Publish package to registry stage Stage a package for publishing (npm staged publishing workflow) owner Manage package owners [alias: author] + team Manage organization teams and memberships cache Manage package cache config Manage package manager configuration [alias: c] login Log in to a registry [alias: adduser] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/package.json new file mode 100644 index 0000000000..8736056626 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/package.json @@ -0,0 +1,6 @@ +{ + "name": "pm-team", + "version": "1.0.0", + "private": true, + "packageManager": "npm@11.12.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/registry.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/registry.mjs new file mode 100644 index 0000000000..3f486734fa --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/registry.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; + +const { packageManager } = JSON.parse(readFileSync('package.json', 'utf8')); +const yarnClassic = packageManager === 'yarn@1.22.22'; +const fallback = !yarnClassic && !['npm@11.12.0', 'pnpm@11.13.0'].includes(packageManager); +const requests = []; +let deny = false; +const server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += chunk; + const url = new URL(request.url, 'http://localhost'); + requests.push({ + method: request.method, + path: url.pathname, + body: body ? JSON.parse(body) : null, + auth: request.headers.authorization, + otp: request.headers['npm-otp'], + }); + response.setHeader('content-type', 'application/json'); + if (deny) { + response.writeHead(403).end(JSON.stringify({ error: 'team operation denied' })); + } else if (request.method === 'GET') { + const name = url.pathname.endsWith('/user') ? 'alice' : 'developers'; + response.end(JSON.stringify(url.searchParams.get('format') === 'cli' ? [name] : [{ name }])); + } else { + response.end(JSON.stringify({ ok: true })); + } +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const registry = `http://127.0.0.1:${server.address().port}/`; +writeFileSync('.npmrc', `registry=${registry}\n//127.0.0.1:${server.address().port}/:_authToken=fixture-token\n`); + +async function run(args, method, path, body = null, failure = false) { + requests.length = 0; + const argv = ['pm', 'team', ...args, '--json', '--registry', registry, '--otp', '123456']; + console.log(`vp pm team ${args.join(' ')} --json --registry --otp `); + const windows = process.platform === 'win32'; + const child = spawn(windows ? 'cmd.exe' : 'vp', windows ? ['/d', '/s', '/c', 'vp', ...argv] : argv, { + env: { ...process.env, npm_config_update_notifier: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null, stderr); + assert.equal(requests.length, 1, `${stdout}\n${stderr}`); + assert.equal(requests[0].method, method); + assert.equal(requests[0].path, path); + assert.deepEqual(requests[0].body, body); + assert.equal(requests[0].auth, 'Bearer fixture-token'); + if (method !== 'GET' && !yarnClassic) assert.equal(requests[0].otp, '123456'); + assert.equal(stderr.includes('falling back to npm team'), fallback, stderr); + if (failure) { + assert.notEqual(code, 0); + assert.match(stdout + stderr, /team operation denied/); + console.log('Registry denial returned a non-zero exit code.'); + } else { + assert.equal(code, 0, `${stdout}\n${stderr}`); + process.stdout.write(stdout); + if (fallback) console.log(stderr.trim()); + } + console.log(`Verified ${method} ${path} with authentication${method === 'GET' || yarnClassic ? '' : ' and OTP'}.`); + if (yarnClassic && body?.user === '') { + console.log('Yarn Classic sent an empty username. Its upstream behavior is preserved.'); + } +} + +try { + const teams = yarnClassic ? '/org/@org/team' : '/-/org/org/team'; + const team = yarnClassic ? '/team/@org/developers' : '/-/team/org/developers'; + const createPath = yarnClassic ? '/team/@org' : teams; + const createBody = yarnClassic ? { team: 'developers' } : { name: 'developers' }; + const userBody = { user: yarnClassic ? '' : 'alice' }; + await run(['create', '@org:developers'], 'PUT', createPath, createBody); + await run(['add', '@org:developers', 'alice'], 'PUT', `${team}/user`, userBody); + await run(['ls', '@org'], 'GET', teams); + await run(['list', '@org:developers'], 'GET', `${team}/user`); + await run(['remove', '@org:developers', 'alice'], 'DELETE', `${team}/user`, userBody); + await run(['destroy', '@org:developers'], 'DELETE', team); + deny = true; + await run(['create', '@org:developers'], 'PUT', createPath, createBody, true); +} finally { + server.close(); + server.closeAllConnections(); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots.toml new file mode 100644 index 0000000000..48181b5c7a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots.toml @@ -0,0 +1,59 @@ +[[case]] +name = "pm_team_npm" +vp = ["local", "global"] +steps = [ + { argv = ["vp", "pm", "team", "--help"] }, + { argv = ["vp", "pm", "team", "add", "--help"] }, + { argv = ["vp", "pm", "team", "ls", "@org", "--", "--version"], snapshot = false }, + { argv = ["node", "registry.mjs"], comment = "all team operations use a loopback registry with fake credentials", timeout = 120000 }, +] + +[[case]] +name = "pm_team_pnpm" +vp = ["local", "global"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@11.13.0"], snapshot = false }, + { argv = ["vp", "pm", "team", "ls", "@org", "--", "--version"], snapshot = false }, + { argv = ["node", "registry.mjs"], comment = "pnpm uses its native team command at the minimum supported version", timeout = 120000 }, +] + +[[case]] +name = "pm_team_yarn_classic" +vp = ["local", "global"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "yarn@1.22.22"], snapshot = false }, + { argv = ["vp", "pm", "team", "ls", "@org", "--", "--version"], snapshot = false }, + { argv = ["node", "registry.mjs"], comment = "Yarn Classic uses its native team command without correcting scope or username values", timeout = 120000 }, +] + +[[case]] +name = "pm_team_old_pnpm" +vp = ["local", "global"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@11.12.0"], snapshot = false }, + { argv = ["vp", "pm", "team", "ls", "@org", "--", "--version"], snapshot = false }, + { argv = ["node", "registry.mjs"], comment = "older pnpm versions warn and execute npm team without dropping mutations", timeout = 120000 }, +] + +[[case]] +name = "pm_team_yarn_modern" +vp = ["local", "global"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "yarn@4.18.0"], snapshot = false }, + { argv = ["vp", "pm", "team", "create", "@org:developers"], comment = "Yarn Modern warns and succeeds without running any team command" }, + { argv = ["vp", "pm", "team", "add", "@org:developers", "alice", "--json", "--registry", "http://127.0.0.1:1", "--otp", "123456"] }, + { argv = ["vp", "pm", "team", "ls", "@org"] }, + { argv = ["vp", "pm", "team", "list", "@org:developers", "--json", "--", "--help"] }, + { argv = ["vp", "pm", "team", "rm", "@org:developers", "alice"] }, + { argv = ["vp", "pm", "team", "remove", "@org:developers", "alice"] }, + { argv = ["vp", "pm", "team", "destroy", "@org:developers"] }, +] + +[[case]] +name = "pm_team_bun" +vp = ["local", "global"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "bun@1.4.0"], snapshot = false }, + { argv = ["vp", "pm", "team", "ls", "@org", "--", "--version"], snapshot = false }, + { argv = ["node", "registry.mjs"], comment = "Bun warns and executes npm team while preserving registry failures", timeout = 120000 }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_bun.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_bun.global.md new file mode 100644 index 0000000000..86edb5a2f7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_bun.global.md @@ -0,0 +1,59 @@ +# pm_team_bun + +## `vpt json-edit package.json packageManager bun@1.4.0` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +Bun warns and executes npm team while preserving registry failures + +``` +vp pm team create @org:developers --json --registry --otp +{ + "created": true, + "team": "org:developers" +} +warn: bun does not support team, falling back to npm team. +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +{ + "added": true, + "team": "org:developers", + "user": "alice" +} +warn: bun does not support team, falling back to npm team. +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +warn: bun does not support team, falling back to npm team. +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +warn: bun does not support team, falling back to npm team. +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{ + "removed": true, + "team": "org:developers", + "user": "alice" +} +warn: bun does not support team, falling back to npm team. +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +{ + "deleted": true, + "team": "org:developers" +} +warn: bun does not support team, falling back to npm team. +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_bun.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_bun.local.md new file mode 100644 index 0000000000..86edb5a2f7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_bun.local.md @@ -0,0 +1,59 @@ +# pm_team_bun + +## `vpt json-edit package.json packageManager bun@1.4.0` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +Bun warns and executes npm team while preserving registry failures + +``` +vp pm team create @org:developers --json --registry --otp +{ + "created": true, + "team": "org:developers" +} +warn: bun does not support team, falling back to npm team. +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +{ + "added": true, + "team": "org:developers", + "user": "alice" +} +warn: bun does not support team, falling back to npm team. +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +warn: bun does not support team, falling back to npm team. +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +warn: bun does not support team, falling back to npm team. +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{ + "removed": true, + "team": "org:developers", + "user": "alice" +} +warn: bun does not support team, falling back to npm team. +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +{ + "deleted": true, + "team": "org:developers" +} +warn: bun does not support team, falling back to npm team. +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_npm.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_npm.global.md new file mode 100644 index 0000000000..43795989c0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_npm.global.md @@ -0,0 +1,95 @@ +# pm_team_npm + +## `vp pm team --help` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp pm team + +Manage organization teams and memberships + +Commands: + create Create a team in an organization + destroy Destroy a team in an organization + add Add a user to a team + rm Remove a user from a team [alias: remove] + list List teams in an organization or members of a team [alias: ls] + +Options: + -h, --help Print help + +Documentation: https://viteplus.dev/guide/package-management +``` + +## `vp pm team add --help` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp pm team add [OPTIONS] [-- ...] + +Add a user to a team + +Arguments: + Organization and team name + Username + [PASS_THROUGH_ARGS]... Additional arguments + +Options: + --otp One-time password for authentication + --json Output in the package manager's native JSON format + --registry Registry URL + -h, --help Print help + +Documentation: https://viteplus.dev/guide/package-management +``` + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +all team operations use a loopback registry with fake credentials + +``` +vp pm team create @org:developers --json --registry --otp +{ + "created": true, + "team": "org:developers" +} +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +{ + "added": true, + "team": "org:developers", + "user": "alice" +} +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{ + "removed": true, + "team": "org:developers", + "user": "alice" +} +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +{ + "deleted": true, + "team": "org:developers" +} +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_npm.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_npm.local.md new file mode 100644 index 0000000000..0882aadf39 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_npm.local.md @@ -0,0 +1,87 @@ +# pm_team_npm + +## `vp pm team --help` + +``` +Manage organization teams and memberships + +Usage: vp pm team + +Commands: + create Create a team in an organization + destroy Destroy a team in an organization + add Add a user to a team + rm Remove a user from a team [alias: remove] + list List teams in an organization or members of a team [alias: ls] + +Options: + -h, --help Print help +``` + +## `vp pm team add --help` + +``` +Add a user to a team + +Usage: vp pm team add [OPTIONS] [-- ...] + +Arguments: + Organization and team name + Username + [PASS_THROUGH_ARGS]... Additional arguments + +Options: + --otp One-time password for authentication + --json Output in the package manager's native JSON format + --registry Registry URL + -h, --help Print help +``` + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +all team operations use a loopback registry with fake credentials + +``` +vp pm team create @org:developers --json --registry --otp +{ + "created": true, + "team": "org:developers" +} +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +{ + "added": true, + "team": "org:developers", + "user": "alice" +} +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{ + "removed": true, + "team": "org:developers", + "user": "alice" +} +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +{ + "deleted": true, + "team": "org:developers" +} +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_old_pnpm.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_old_pnpm.global.md new file mode 100644 index 0000000000..63b6748313 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_old_pnpm.global.md @@ -0,0 +1,59 @@ +# pm_team_old_pnpm + +## `vpt json-edit package.json packageManager pnpm@11.12.0` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +older pnpm versions warn and execute npm team without dropping mutations + +``` +vp pm team create @org:developers --json --registry --otp +{ + "created": true, + "team": "org:developers" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +{ + "added": true, + "team": "org:developers", + "user": "alice" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{ + "removed": true, + "team": "org:developers", + "user": "alice" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +{ + "deleted": true, + "team": "org:developers" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_old_pnpm.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_old_pnpm.local.md new file mode 100644 index 0000000000..63b6748313 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_old_pnpm.local.md @@ -0,0 +1,59 @@ +# pm_team_old_pnpm + +## `vpt json-edit package.json packageManager pnpm@11.12.0` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +older pnpm versions warn and execute npm team without dropping mutations + +``` +vp pm team create @org:developers --json --registry --otp +{ + "created": true, + "team": "org:developers" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +{ + "added": true, + "team": "org:developers", + "user": "alice" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{ + "removed": true, + "team": "org:developers", + "user": "alice" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +{ + "deleted": true, + "team": "org:developers" +} +warn: pnpm team requires pnpm >= 11.13.0, falling back to npm team. +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_pnpm.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_pnpm.global.md new file mode 100644 index 0000000000..b1e997958a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_pnpm.global.md @@ -0,0 +1,39 @@ +# pm_team_pnpm + +## `vpt json-edit package.json packageManager pnpm@11.13.0` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +pnpm uses its native team command at the minimum supported version + +``` +vp pm team create @org:developers --json --registry --otp +org:developers +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +alice added to @org:developers +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +-alice removed from @org:developers +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +-org:developers +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_pnpm.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_pnpm.local.md new file mode 100644 index 0000000000..b1e997958a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_pnpm.local.md @@ -0,0 +1,39 @@ +# pm_team_pnpm + +## `vpt json-edit package.json packageManager pnpm@11.13.0` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +pnpm uses its native team command at the minimum supported version + +``` +vp pm team create @org:developers --json --registry --otp +org:developers +Verified PUT /-/org/org/team with authentication and OTP. +vp pm team add @org:developers alice --json --registry --otp +alice added to @org:developers +Verified PUT /-/team/org/developers/user with authentication and OTP. +vp pm team ls @org --json --registry --otp +[ + "developers" +] +Verified GET /-/org/org/team with authentication. +vp pm team list @org:developers --json --registry --otp +[ + "alice" +] +Verified GET /-/team/org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +-alice removed from @org:developers +Verified DELETE /-/team/org/developers/user with authentication and OTP. +vp pm team destroy @org:developers --json --registry --otp +-org:developers +Verified DELETE /-/team/org/developers with authentication and OTP. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /-/org/org/team with authentication and OTP. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_classic.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_classic.global.md new file mode 100644 index 0000000000..00a9920cd3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_classic.global.md @@ -0,0 +1,55 @@ +# pm_team_yarn_classic + +## `vpt json-edit package.json packageManager yarn@1.22.22` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +Yarn Classic uses its native team command without correcting scope or username values + +``` +vp pm team create @org:developers --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Creating team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +{"type":"step","data":{"message":"Revoking token","current":3,"total":3}} +{"type":"info","data":"Not revoking login token, specified via config file."} +Verified PUT /team/@org with authentication. +vp pm team add @org:developers alice --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Adding user to team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +{"type":"step","data":{"message":"Revoking token","current":3,"total":3}} +{"type":"info","data":"Not revoking login token, specified via config file."} +Verified PUT /team/@org/developers/user with authentication. +Yarn Classic sent an empty username. Its upstream behavior is preserved. +vp pm team ls @org --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Listing teams","current":2,"total":3}} +{"type":"inspect","data":["developers"]} +Verified GET /org/@org/team with authentication. +vp pm team list @org:developers --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Listing teams","current":2,"total":3}} +{"type":"inspect","data":["alice"]} +Verified GET /team/@org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Removing user from team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +Verified DELETE /team/@org/developers/user with authentication. +Yarn Classic sent an empty username. Its upstream behavior is preserved. +vp pm team destroy @org:developers --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Removing team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +{"type":"step","data":{"message":"Revoking token","current":3,"total":3}} +{"type":"info","data":"Not revoking login token, specified via config file."} +Verified DELETE /team/@org/developers with authentication. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /team/@org with authentication. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_classic.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_classic.local.md new file mode 100644 index 0000000000..00a9920cd3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_classic.local.md @@ -0,0 +1,55 @@ +# pm_team_yarn_classic + +## `vpt json-edit package.json packageManager yarn@1.22.22` + + +## `vp pm team ls @org -- --version` + + +## `node registry.mjs` + +Yarn Classic uses its native team command without correcting scope or username values + +``` +vp pm team create @org:developers --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Creating team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +{"type":"step","data":{"message":"Revoking token","current":3,"total":3}} +{"type":"info","data":"Not revoking login token, specified via config file."} +Verified PUT /team/@org with authentication. +vp pm team add @org:developers alice --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Adding user to team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +{"type":"step","data":{"message":"Revoking token","current":3,"total":3}} +{"type":"info","data":"Not revoking login token, specified via config file."} +Verified PUT /team/@org/developers/user with authentication. +Yarn Classic sent an empty username. Its upstream behavior is preserved. +vp pm team ls @org --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Listing teams","current":2,"total":3}} +{"type":"inspect","data":["developers"]} +Verified GET /org/@org/team with authentication. +vp pm team list @org:developers --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Listing teams","current":2,"total":3}} +{"type":"inspect","data":["alice"]} +Verified GET /team/@org/developers/user with authentication. +vp pm team remove @org:developers alice --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Removing user from team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +Verified DELETE /team/@org/developers/user with authentication. +Yarn Classic sent an empty username. Its upstream behavior is preserved. +vp pm team destroy @org:developers --json --registry --otp +{"type":"step","data":{"message":"Logging in","current":1,"total":3}} +{"type":"step","data":{"message":"Removing team","current":2,"total":3}} +{"type":"inspect","data":{"ok":true}} +{"type":"step","data":{"message":"Revoking token","current":3,"total":3}} +{"type":"info","data":"Not revoking login token, specified via config file."} +Verified DELETE /team/@org/developers with authentication. +vp pm team create @org:developers --json --registry --otp +Registry denial returned a non-zero exit code. +Verified PUT /team/@org with authentication. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_modern.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_modern.global.md new file mode 100644 index 0000000000..d4700b930a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_modern.global.md @@ -0,0 +1,48 @@ +# pm_team_yarn_modern + +## `vpt json-edit package.json packageManager yarn@4.18.0` + + +## `vp pm team create @org:developers` + +Yarn Modern warns and succeeds without running any team command + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team add @org:developers alice --json --registry http://127.0.0.1:1 --otp 123456` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team ls @org` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team list @org:developers --json -- --help` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team rm @org:developers alice` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team remove @org:developers alice` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team destroy @org:developers` + +``` +warn: yarn@2+ does not have a 'team' command. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_modern.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_modern.local.md new file mode 100644 index 0000000000..d4700b930a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pm_team/snapshots/pm_team_yarn_modern.local.md @@ -0,0 +1,48 @@ +# pm_team_yarn_modern + +## `vpt json-edit package.json packageManager yarn@4.18.0` + + +## `vp pm team create @org:developers` + +Yarn Modern warns and succeeds without running any team command + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team add @org:developers alice --json --registry http://127.0.0.1:1 --otp 123456` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team ls @org` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team list @org:developers --json -- --help` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team rm @org:developers alice` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team remove @org:developers alice` + +``` +warn: yarn@2+ does not have a 'team' command. +``` + +## `vp pm team destroy @org:developers` + +``` +warn: yarn@2+ does not have a 'team' command. +``` diff --git a/crates/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index 7bb8635090..9cec64c9b9 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -14,8 +14,8 @@ use crate::{ DeprecateArgs, DistTagCommand, DlxArgs, FundArgs, InstallArgs, LicensesCommand, LinkArgs, ListArgs, LoginArgs, LogoutArgs, OutdatedArgs, OutdatedFormat, OwnerCommand, PackArgs, PatchArgs, PatchCommitArgs, PingArgs, PkgCommand, PruneArgs, PublishArgs, RebuildArgs, - RemoveArgs, Resolution, SearchArgs, StageCommand, TokenCommand, UnlinkArgs, UpdateArgs, - VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, + RemoveArgs, Resolution, SearchArgs, StageCommand, TeamCommand, TokenCommand, UnlinkArgs, + UpdateArgs, VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, resolve_for_manager as resolve_args_for_manager, }, }; @@ -123,6 +123,10 @@ pub enum PmCommand { #[command(subcommand, visible_alias = "author")] Owner(OwnerCommand), + /// Manage organization teams and memberships + #[command(subcommand)] + Team(TeamCommand), + /// Manage package cache Cache(CacheArgs), @@ -361,6 +365,7 @@ impl PmCommand { Self::Publish(args) => resolve_args_for_manager(manager, args), Self::Stage(args) => resolve_args_for_manager(manager, args), Self::Owner(args) => resolve_args_for_manager(manager, args), + Self::Team(args) => resolve_args_for_manager(manager, args), Self::Cache(args) => resolve_args_for_manager(manager, args), Self::Config(args) => resolve_args_for_manager(manager, args), Self::Login(args) => resolve_args_for_manager(manager, args), @@ -395,6 +400,13 @@ impl PmCommand { | ConfigCommand::Set { json, .. } => *json, ConfigCommand::Delete { .. } => false, }, + Self::Team(args) => match args { + TeamCommand::Create { json, .. } + | TeamCommand::Destroy { json, .. } + | TeamCommand::Add { json, .. } + | TeamCommand::Rm { json, .. } + | TeamCommand::List { json, .. } => *json, + }, Self::Token(args) => match args { TokenCommand::List { json, .. } | TokenCommand::Create { json, .. } => *json, TokenCommand::Revoke { .. } => false, @@ -491,6 +503,43 @@ mod tests { )); } + #[test] + fn team_list_routes_native_commands_and_npm_fallbacks() { + for (client, version, program, subcommand, scope, fallback) in [ + (PackageManagerType::Npm, "11.12.0", "npm", "ls", "@org:team", false), + (PackageManagerType::Pnpm, "11.13.0", "pnpm", "ls", "@org:team", false), + (PackageManagerType::Pnpm, "11.12.0", "npm", "ls", "@org:team", true), + (PackageManagerType::Yarn, "1.22.22", "yarn", "list", "@org:team", false), + (PackageManagerType::Bun, "1.4.0", "npm", "ls", "@org:team", true), + ] { + for alias in ["ls", "list"] { + let command = parse(&["pm", "team", alias, "@org:team", "--json"]).unwrap(); + assert!(command.is_quiet_or_machine_readable()); + assert!(command.should_render_diagnostics()); + let result = + command.resolve_for_manager(&package_manager(client, version)).unwrap(); + assert_eq!(!result.diagnostics.is_empty(), fallback); + let resolved = expect_run(result.outcome); + assert_eq!(resolved.program, program); + assert_eq!(resolved.args, vec!["team", subcommand, scope, "--json"]); + } + } + } + + #[test] + fn team_list_warns_and_noops_for_yarn_modern() { + for alias in ["ls", "list"] { + let command = parse(&["pm", "team", alias, "@org:team", "--json"]).unwrap(); + assert!(command.is_quiet_or_machine_readable()); + assert!(command.should_render_diagnostics()); + let result = command + .resolve_for_manager(&package_manager(PackageManagerType::Yarn, "4.18.0")) + .unwrap(); + assert_eq!(result.outcome, crate::resolution::CommandResolution::Noop); + assert_eq!(result.diagnostics.len(), 1); + } + } + #[test] fn licenses_list_parses_and_resolves_for_supported_managers() { for (client, version, program, prefix) in [ diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 92913af925..dc99cf0827 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -40,6 +40,6 @@ pub use resolution::{ AddArgs, ApproveBuildsArgs, AuditArgs, CacheArgs, ConfigCommand, DedupeArgs, DeprecateArgs, DistTagCommand, DlxArgs, FundArgs, InstallArgs, LicensesCommand, LinkArgs, ListArgs, LoginArgs, LogoutArgs, OutdatedArgs, OutdatedFormat, OwnerCommand, PackArgs, PingArgs, PruneArgs, - PublishArgs, RebuildArgs, RemoveArgs, SearchArgs, StageCommand, TokenCommand, UnlinkArgs, - UpdateArgs, VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, + PublishArgs, RebuildArgs, RemoveArgs, SearchArgs, StageCommand, TeamCommand, TokenCommand, + UnlinkArgs, UpdateArgs, VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, }; diff --git a/crates/vp_pm_cli/src/resolution/commands/mod.rs b/crates/vp_pm_cli/src/resolution/commands/mod.rs index 384eab8f43..49f1bce162 100644 --- a/crates/vp_pm_cli/src/resolution/commands/mod.rs +++ b/crates/vp_pm_cli/src/resolution/commands/mod.rs @@ -28,6 +28,7 @@ mod rebuild; mod remove; mod search; mod stage; +mod team; mod token; mod unlink; mod update; @@ -67,6 +68,7 @@ pub use rebuild::RebuildArgs; pub use remove::RemoveArgs; pub use search::SearchArgs; pub use stage::StageCommand; +pub use team::TeamCommand; pub use token::TokenCommand; pub use unlink::UnlinkArgs; pub use update::UpdateArgs; diff --git a/crates/vp_pm_cli/src/resolution/commands/team.rs b/crates/vp_pm_cli/src/resolution/commands/team.rs new file mode 100644 index 0000000000..395e126e64 --- /dev/null +++ b/crates/vp_pm_cli/src/resolution/commands/team.rs @@ -0,0 +1,354 @@ +use vp_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum TeamCommand { + /// Create a team in an organization + Create { + /// Organization and team name + #[arg(value_name = "SCOPE:TEAM")] + team: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Output in the package manager's native JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Destroy a team in an organization + Destroy { + /// Organization and team name + #[arg(value_name = "SCOPE:TEAM")] + team: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Output in the package manager's native JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Add a user to a team + Add { + /// Organization and team name + #[arg(value_name = "SCOPE:TEAM")] + team: String, + + /// Username + user: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Output in the package manager's native JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Remove a user from a team + #[command(visible_alias = "remove")] + Rm { + /// Organization and team name + #[arg(value_name = "SCOPE:TEAM")] + team: String, + + /// Username + user: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Output in the package manager's native JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// List teams in an organization or members of a team + #[command(visible_alias = "ls")] + List { + /// Organization name, optionally followed by a team name + #[arg(value_name = "SCOPE[:TEAM]")] + scope: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Output in the package manager's native JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, +} + +impl Resolve for Npm { + fn resolve(&self, args: &TeamCommand, _diag: &mut Diagnostics) -> CommandResolution { + resolve_team("npm", args) + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &TeamCommand, diag: &mut Diagnostics) -> CommandResolution { + if !self.supports_v11_13_commands() { + diag.warn( + DiagnosticKind::FallbackCommand, + "pnpm team requires pnpm >= 11.13.0, falling back to npm team.", + ); + return resolve_team("npm", args); + } + resolve_team("pnpm", args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &TeamCommand, diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "yarn@2+ does not have a 'team' command.", + ); + return CommandResolution::Noop; + } + resolve_team("yarn", args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &TeamCommand, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun does not support team, falling back to npm team.", + ); + resolve_team("npm", args) + } +} + +fn resolve_team(program: &str, args: &TeamCommand) -> CommandResolution { + let (subcommand, scope, user) = match args { + TeamCommand::Create { team, .. } => ("create", team, None), + TeamCommand::Destroy { team, .. } => ("destroy", team, None), + TeamCommand::Add { team, user, .. } => ("add", team, Some(user)), + TeamCommand::Rm { team, user, .. } => { + (if program == "yarn" { "remove" } else { "rm" }, team, Some(user)) + } + TeamCommand::List { scope, .. } => { + (if program == "yarn" { "list" } else { "ls" }, scope, None) + } + }; + let mut cmd = CommandBuilder::new(program); + cmd.arg("team").arg(subcommand).arg(scope); + if let Some(user) = user { + cmd.arg(user); + } + match args { + TeamCommand::Create { otp, json, registry, pass_through_args, .. } + | TeamCommand::Destroy { otp, json, registry, pass_through_args, .. } + | TeamCommand::Add { otp, json, registry, pass_through_args, .. } + | TeamCommand::Rm { otp, json, registry, pass_through_args, .. } + | TeamCommand::List { otp, json, registry, pass_through_args, .. } => { + cmd.arg_if("--json", *json) + .option("--registry", registry.as_ref()) + .option("--otp", otp.as_ref()) + .extend(pass_through_args.iter()); + } + } + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, expect_run, npm, parse_subcommand, pnpm, yarn}, + }; + + #[test] + fn all_subcommands_forward_operands_and_options() { + for (subcommand, native, user) in [ + ("create", "create", None), + ("destroy", "destroy", None), + ("add", "add", Some("alice")), + ("rm", "rm", Some("alice")), + ("remove", "rm", Some("alice")), + ("ls", "ls", None), + ("list", "ls", None), + ] { + let mut argv = vec![subcommand, "@org:team"]; + argv.extend(user); + argv.extend([ + "--json", + "--registry", + "https://registry.example", + "--otp", + "123456", + "--", + "--help", + ]); + let args = parse_subcommand::(argv).unwrap(); + for (result, program) in [ + (resolve(&npm("11.12.0"), args.clone()), "npm"), + (resolve(&pnpm("11.13.0"), args.clone()), "pnpm"), + (resolve(&yarn("1.22.22"), args), "yarn"), + ] { + assert!(result.diagnostics.is_empty()); + let resolved = expect_run(result.outcome); + let native = match (program, native) { + ("yarn", "rm") => "remove", + ("yarn", "ls") => "list", + _ => native, + }; + let mut expected = vec!["team", native, "@org:team"]; + expected.extend(user); + expected.extend([ + "--json", + "--registry", + "https://registry.example", + "--otp", + "123456", + "--help", + ]); + assert_eq!(resolved.program, program); + assert_eq!(resolved.args, expected); + } + } + } + + #[test] + fn npm_fallbacks_preserve_mutations_and_warn() { + let args = + parse_subcommand::(["destroy", "@org:team", "--otp", "123456"]).unwrap(); + for (result, message) in [ + ( + resolve(&pnpm("11.12.0"), args.clone()), + "pnpm team requires pnpm >= 11.13.0, falling back to npm team.", + ), + ( + resolve(&pnpm("11.13.0-beta.1"), args.clone()), + "pnpm team requires pnpm >= 11.13.0, falling back to npm team.", + ), + (resolve(&bun("1.4.0"), args), "bun does not support team, falling back to npm team."), + ] { + let command = expect_run(result.outcome); + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["team", "destroy", "@org:team", "--otp", "123456"]); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].kind, DiagnosticKind::FallbackCommand); + assert_eq!(result.diagnostics[0].message, message); + } + } + + #[test] + fn yarn_modern_warns_and_skips_all_team_commands() { + for version in ["2.0.0", "4.18.0"] { + for (subcommand, user) in [ + ("create", None), + ("destroy", None), + ("add", Some("alice")), + ("rm", Some("alice")), + ("remove", Some("alice")), + ("ls", None), + ("list", None), + ] { + let mut argv = vec![subcommand, "@org:team"]; + argv.extend(user); + argv.extend([ + "--json", + "--registry", + "https://registry.example", + "--otp", + "123456", + "--", + "--help", + ]); + let args = parse_subcommand::(argv).unwrap(); + let result = resolve(&yarn(version), args); + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + assert_eq!( + result.diagnostics[0].message, + "yarn@2+ does not have a 'team' command." + ); + } + } + } + + #[test] + fn list_accepts_organizations_and_teams_with_or_without_at_prefix() { + for scope in ["@org", "org", "@org:team", "org:team"] { + let args = parse_subcommand::(["list", scope]).unwrap(); + let command = expect_run(resolve(&yarn("1.22.22"), args.clone()).outcome); + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["team", "list", scope]); + let command = expect_run(resolve(&Npm::unknown_version(), args).outcome); + assert_eq!(command.args, vec!["team", "ls", scope]); + } + } + + #[test] + fn parser_rejects_missing_and_extra_operands() { + for args in [ + vec![], + vec!["create"], + vec!["destroy"], + vec!["add", "@org:team"], + vec!["rm", "@org:team"], + vec!["ls"], + vec!["ls", "@org", "extra"], + vec!["create", "@org:team", "extra"], + ] { + assert!(parse_subcommand::(args).is_err()); + } + } +} diff --git a/crates/vp_pm_cli/src/resolution/dialect.rs b/crates/vp_pm_cli/src/resolution/dialect.rs index 83fa1a42ce..3918ae1980 100644 --- a/crates/vp_pm_cli/src/resolution/dialect.rs +++ b/crates/vp_pm_cli/src/resolution/dialect.rs @@ -105,6 +105,11 @@ impl Pnpm { pub(crate) fn supports_v11_commands(&self) -> bool { self.version >= Version::new(11, 0, 0) } + + /// Native `team` commands landed in pnpm 11.13.0. + pub(crate) fn supports_v11_13_commands(&self) -> bool { + self.version >= Version::new(11, 13, 0) + } } impl Yarn { diff --git a/crates/vp_pm_cli/src/resolution/mod.rs b/crates/vp_pm_cli/src/resolution/mod.rs index 5c1c8ffa4c..52302e0da0 100644 --- a/crates/vp_pm_cli/src/resolution/mod.rs +++ b/crates/vp_pm_cli/src/resolution/mod.rs @@ -48,8 +48,8 @@ pub use commands::{ DeprecateArgs, DistTagCommand, DlxArgs, FundArgs, InstallArgs, LicensesCommand, LinkArgs, ListArgs, LoginArgs, LogoutArgs, OutdatedArgs, OutdatedFormat, OwnerCommand, PackArgs, PatchArgs, PatchCommitArgs, PingArgs, PkgCommand, PruneArgs, PublishArgs, RebuildArgs, - RemoveArgs, SearchArgs, StageCommand, TokenCommand, UnlinkArgs, UpdateArgs, VersionArgs, - ViewArgs, WhoamiArgs, WhyArgs, + RemoveArgs, SearchArgs, StageCommand, TeamCommand, TokenCommand, UnlinkArgs, UpdateArgs, + VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, }; use diagnostics::DiagnosticKind; /// Diagnostics emitted while unsupported arguments and fallbacks are resolved. diff --git a/docs/guide/package-management.md b/docs/guide/package-management.md index 7ece07905f..ce4b399650 100644 --- a/docs/guide/package-management.md +++ b/docs/guide/package-management.md @@ -212,7 +212,7 @@ Use these when you need lower-level package-manager behavior. - **Patching**: `patch`, `patch-commit` - **Packaging and publishing**: `pack`, `version`, `publish`, `stage`, `dist-tag`, `deprecate` - **Inspection**: `view`, `search`, `licenses`, `audit`, `fund`, `ping` - - **Authentication and access**: `login`, `logout`, `whoami`, `token`, `owner` + - **Authentication and access**: `login`, `logout`, `whoami`, `token`, `owner`, `team` - **Configuration**: `config`, `cache` - **Package manifest**: `pkg get`, `pkg set`, `pkg delete`, `pkg fix`