From ba6a95ffc943d2e87cb4a51aeb69686bf4281686 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:03:57 +0000 Subject: [PATCH 1/5] chore: start secrets-list-rm From dc25a49b9abbaeef14730656bab40db9c40dd7d3 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:12:28 +0000 Subject: [PATCH 2/5] feat(cli): add secrets list and secrets rm (#37) Complete the write-only `millwright secrets` group with `list` and `rm`, following the shape of `repo list` / `repo remove`. - `secrets list [--scope ] [--all-scopes]` prints secret names only, sorted; values are never fetched or printed (the listing never asks SSM to decrypt and ignores the returned ciphertext). `--all-scopes` walks the whole `secrets/` prefix and prints ` ` lines, and does not need an origin remote. - `secrets rm [--scope ]` deletes one parameter, reports the path removed, and throws a CommandError (exit 1) when nothing was deleted. - Both pre-check the name with `isSecretNameSegment` before discovery and default the scope from the cwd `origin` remote exactly like `secrets set`; the shared pre-check and scope resolution are factored into helpers in secrets.ts. `set` keeps its `promptSecret` dep; list/rm take the narrower `SecretsScopeDeps`. - New `secretFromParameterName` inverse in millwright-state ssm-paths.ts so `list` inverts parameter names the same way `repo list` does; a recursive listing of `secrets/acme/` is filtered to the exact scope so `acme/api` secrets do not leak into a listing of scope `acme`. - Docs: README secrets section, the "Secrets not resolving" step 1 in docs/operations.md now answers "is it written?" with `secrets list`, and the CLI surface note in okf-bundle/interfaces/cli.md. Files: packages/millwright-cli/src/{secrets,cli,index}.ts, packages/millwright-state/src/{ssm-paths,index}.ts, tests in packages/millwright-cli/test/{secrets,cli}.test.ts and packages/millwright-state/test/ssm-paths.test.ts. --- docs/operations.md | 11 +- okf-bundle/interfaces/cli.md | 2 + packages/millwright-cli/README.md | 10 ++ packages/millwright-cli/src/cli.ts | 30 +++- packages/millwright-cli/src/index.ts | 13 +- packages/millwright-cli/src/secrets.ts | 140 +++++++++++++++--- packages/millwright-cli/test/cli.test.ts | 15 +- packages/millwright-cli/test/secrets.test.ts | 105 ++++++++++++- packages/millwright-state/src/index.ts | 2 + packages/millwright-state/src/ssm-paths.ts | 33 +++++ .../millwright-state/test/ssm-paths.test.ts | 19 +++ 11 files changed, 351 insertions(+), 29 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 4e15637..9a13645 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -421,9 +421,14 @@ Symptoms and levers: Secrets are gated at **dispatch**, by the decider, through job-role variant selection — not at synth time. -1. **Is the secret written?** `millwright secrets set [--scope ]` - writes `/millwright//secrets//`; the scope defaults to - the repo of the cwd's `origin` remote. +1. **Is the secret written?** `millwright secrets list [--scope ]` + prints the secret names in a scope (`--all-scopes` for every scope; values + are never shown). The scope defaults to the repo of the cwd's `origin` + remote, the same default `secrets set` and the dispatch-time `Secret` + lookup use — if the name is missing, or sits under a different scope than + the workflow resolves against, write it with `millwright secrets set + [--scope ]`. Retire one with `millwright secrets rm + [--scope ]`. 2. **Does the ref qualify?** `secretsAllowedRefs` is unset by default, which means *no ref receives secrets*. Patterns match the **short** ref name (`main`, `release/1.2`), anchored at both ends, with `*` as the only diff --git a/okf-bundle/interfaces/cli.md b/okf-bundle/interfaces/cli.md index b6d7abf..3fd4a48 100644 --- a/okf-bundle/interfaces/cli.md +++ b/okf-bundle/interfaces/cli.md @@ -22,6 +22,8 @@ Setup & ops millwright doctor millwright refresh-host-keys millwright secrets set [--scope ] + millwright secrets list [--scope ] [--all-scopes] + millwright secrets rm [--scope ] Definition millwright synth diff --git a/packages/millwright-cli/README.md b/packages/millwright-cli/README.md index e5e3b60..dd3f0e6 100644 --- a/packages/millwright-cli/README.md +++ b/packages/millwright-cli/README.md @@ -151,6 +151,16 @@ as a SecureString under the deployment CMK. The scope defaults to the repo of the working directory's `origin` remote; secrets flow only to runs on refs matched by the repo's `secretsAllowedRefs`. +`millwright secrets list [--scope ]` prints the secret **names** in a +scope, sorted — never their values; there is no way to read a value back +through the CLI. `--all-scopes` enumerates every scope in the deployment +instead, one ` ` line each. + +`millwright secrets rm [--scope ]` deletes one secret and +reports the parameter path it removed; it fails with a non-zero exit when no +such secret exists in that scope. Use it when a repo is offboarded or a secret +is retired so the SecureString does not linger in the config plane. + `millwright refresh-host-keys` re-pins GitHub's SSH host keys from the `/meta` endpoint — the manual hatch for confirmed key rotations. The poller honors the new pins on its next tick. diff --git a/packages/millwright-cli/src/cli.ts b/packages/millwright-cli/src/cli.ts index 980277a..80169e1 100644 --- a/packages/millwright-cli/src/cli.ts +++ b/packages/millwright-cli/src/cli.ts @@ -42,7 +42,7 @@ import { runsShow, runsShowLocal, } from './runs'; -import { secretsSet } from './secrets'; +import { secretsList, secretsRm, secretsSet } from './secrets'; import { SetupDeps, refreshHostKeys, setup } from './setup'; import { DEFAULT_ENTRY, runSynthCommand } from './synth-command'; import { VERSION } from './version'; @@ -532,6 +532,34 @@ function buildProgramWithSignal(): { program: Command; exitCode: () => number } ); }); + secrets + .command('list') + .description('list secret names for a scope (never values)') + .option('--scope ', 'secret scope; defaults to the repo of the cwd origin remote') + .option('--all-scopes', 'list every scope in the deployment instead of one') + .action(async (options: { scope?: string; allScopes?: boolean }) => { + await secretsList( + { ssm: new SSMClient({}), output }, + { + scope: options.scope, + allScopes: options.allScopes === true, + explicitName: program.opts().deployment, + }, + ); + }); + + secrets + .command('rm') + .description('delete one workflow secret') + .argument('', 'secret name as given to "secrets set"') + .option('--scope ', 'secret scope; defaults to the repo of the cwd origin remote') + .action(async (name: string, options: { scope?: string }) => { + await secretsRm( + { ssm: new SSMClient({}), output }, + { name, scope: options.scope, explicitName: program.opts().deployment }, + ); + }); + return { program, exitCode: () => signal.code }; } diff --git a/packages/millwright-cli/src/index.ts b/packages/millwright-cli/src/index.ts index 22a9b0e..317950f 100644 --- a/packages/millwright-cli/src/index.ts +++ b/packages/millwright-cli/src/index.ts @@ -153,7 +153,18 @@ export { DoctorReport, doctor, } from './doctor'; -export { SecretsDeps, SecretsSetOptions, parseGithubRemote, secretsSet } from './secrets'; +export { + SecretsDeps, + SecretsListEntry, + SecretsListOptions, + SecretsRmOptions, + SecretsScopeDeps, + SecretsSetOptions, + parseGithubRemote, + secretsList, + secretsRm, + secretsSet, +} from './secrets'; export { EventBridgeClientLike, RepoAddOptions, diff --git a/packages/millwright-cli/src/secrets.ts b/packages/millwright-cli/src/secrets.ts index f53c5ca..3f1a3ee 100644 --- a/packages/millwright-cli/src/secrets.ts +++ b/packages/millwright-cli/src/secrets.ts @@ -1,31 +1,63 @@ /** - * `millwright secrets set [--scope ]` (spec §9.2, §15): write - * one workflow secret to `/millwright//secrets//` as a - * SecureString under the deployment CMK. The scope defaults to the repo — - * inferred from the working directory's `origin` remote — matching how - * `Secret` references resolve at dispatch (§4.2); secrets flow only to runs - * on `secretsAllowedRefs`-matched refs (§12a). + * `millwright secrets set|list|rm` (spec §9.2, §15): manage workflow secrets + * at `/millwright//secrets//`. `set` writes a SecureString + * under the deployment CMK; `list` shows the names in a scope (never values — + * the listing never asks SSM to decrypt); `rm` deletes one. The scope + * defaults to the repo — inferred from the working directory's `origin` + * remote — matching how `Secret` references resolve at dispatch (§4.2); + * secrets flow only to runs on `secretsAllowedRefs`-matched refs (§12a). */ import { execFile } from 'node:child_process'; -import { isSecretNameSegment, secretParameterName } from '@copperbox/millwright-state'; -import { CommandError, configKeyId, putSecureStringParameter } from './config-plane'; +import { + configPlaneRoot, + isSecretNameSegment, + secretFromParameterName, + secretParameterName, +} from '@copperbox/millwright-state'; +import { + CommandError, + configKeyId, + deleteParameters, + listParametersByPrefix, + putSecureStringParameter, +} from './config-plane'; import { DiscoverOptions, SsmClientLike, discoverDeployment } from './discovery'; -export interface SecretsDeps { +/** What `secrets list` and `secrets rm` need; `set` additionally prompts. */ +export interface SecretsScopeDeps { readonly ssm: SsmClientLike; readonly output: (line: string) => void; - /** Reads the secret value without echoing. */ - readonly promptSecret: (question: string) => Promise; /** Injectable for tests. @default the cwd's `origin` remote */ readonly inferRepo?: () => Promise; } +export interface SecretsDeps extends SecretsScopeDeps { + /** Reads the secret value without echoing. */ + readonly promptSecret: (question: string) => Promise; +} + export interface SecretsSetOptions extends DiscoverOptions { readonly name: string; readonly scope?: string; } +export interface SecretsListOptions extends DiscoverOptions { + readonly scope?: string; + /** Enumerate every scope under the deployment instead of one. */ + readonly allScopes?: boolean; +} + +export interface SecretsRmOptions extends DiscoverOptions { + readonly name: string; + readonly scope?: string; +} + +export interface SecretsListEntry { + readonly scope: string; + readonly name: string; +} + /** `owner/repo` from an SSH, ssh://, git://, or https GitHub remote URL. */ export function parseGithubRemote(url: string): string | undefined { const match = @@ -44,26 +76,37 @@ async function originRepo(): Promise { return url ? parseGithubRemote(url) : undefined; } -export async function secretsSet(deps: SecretsDeps, options: SecretsSetOptions): Promise { - // Pre-flight the shape `secretParameterName` accepts, before discovery and - // the value prompt. The env var a secret lands in is named by the - // workflow's record key, not by this parameter name, so kebab-case is fine. - if (!isSecretNameSegment(options.name)) { +/** + * Pre-flight the shape `secretParameterName` accepts, before discovery (and, + * for `set`, the value prompt). The env var a secret lands in is named by the + * workflow's record key, not by this parameter name, so kebab-case is fine. + */ +function assertSecretName(name: string): void { + if (!isSecretNameSegment(name)) { throw new CommandError( - `"${options.name}" is not a secret name — it becomes one segment of the secret's ` + + `"${name}" is not a secret name — it becomes one segment of the secret's ` + 'SSM parameter path, so it must match [A-Za-z0-9_.-]+ (no "/")', ); } - const deployment = await discoverDeployment(deps.ssm, options); - const keyId = configKeyId(deployment); +} - const scope = options.scope ?? (await (deps.inferRepo ?? originRepo)()); +/** `--scope` if given, else the cwd's `origin` repo; a CommandError when neither. */ +async function resolveScope(deps: SecretsScopeDeps, explicit: string | undefined): Promise { + const scope = explicit ?? (await (deps.inferRepo ?? originRepo)()); if (!scope) { throw new CommandError( 'no --scope given and the working directory has no GitHub "origin" remote to default ' + 'to — pass --scope (or a shared scope name)', ); } + return scope; +} + +export async function secretsSet(deps: SecretsDeps, options: SecretsSetOptions): Promise { + assertSecretName(options.name); + const deployment = await discoverDeployment(deps.ssm, options); + const keyId = configKeyId(deployment); + const scope = await resolveScope(deps, options.scope); const value = await deps.promptSecret(`Value for ${options.name} (input hidden): `); if (!value) { @@ -84,3 +127,60 @@ export async function secretsSet(deps: SecretsDeps, options: SecretsSetOptions): "repo's secretsAllowedRefs.", ); } + +/** + * Names only. `GetParametersByPath` without `WithDecryption` hands back + * SecureStrings as ciphertext, and this never looks at `value` at all — there + * is deliberately no way to read a secret back through the CLI. + */ +export async function secretsList( + deps: SecretsScopeDeps, + options: SecretsListOptions = {}, +): Promise { + const deployment = await discoverDeployment(deps.ssm, options); + const scope = options.allScopes ? undefined : await resolveScope(deps, options.scope); + const prefix = + scope === undefined + ? `${configPlaneRoot(deployment.name)}/secrets/` + : `${configPlaneRoot(deployment.name)}/secrets/${scope}/`; + + const entries: SecretsListEntry[] = []; + for (const parameter of await listParametersByPrefix(deps.ssm, prefix)) { + const parts = secretFromParameterName(deployment.name, parameter.name); + // A recursive listing of `…/secrets/acme/` also returns `acme/api`'s + // secrets; keep only the scope asked for. + if (parts && (scope === undefined || parts.scope === scope)) { + entries.push(parts); + } + } + entries.sort((a, b) => a.scope.localeCompare(b.scope) || a.name.localeCompare(b.name)); + + const where = scope === undefined ? 'every scope' : `scope ${scope}`; + if (entries.length === 0) { + deps.output( + `No secrets in ${scope === undefined ? 'any scope' : where} (deployment "${deployment.name}").`, + ); + return entries; + } + deps.output(`Secrets in ${where} (deployment "${deployment.name}"):`); + for (const entry of entries) { + deps.output(scope === undefined ? `${entry.scope} ${entry.name}` : entry.name); + } + return entries; +} + +export async function secretsRm(deps: SecretsScopeDeps, options: SecretsRmOptions): Promise { + assertSecretName(options.name); + const deployment = await discoverDeployment(deps.ssm, options); + const scope = await resolveScope(deps, options.scope); + + const parameter = secretParameterName(deployment.name, scope, options.name); + const deleted = await deleteParameters(deps.ssm, [parameter]); + if (deleted.length === 0) { + throw new CommandError( + `no secret named ${options.name} in scope ${scope} (deployment "${deployment.name}") — ` + + `"millwright secrets list --scope ${scope}" shows what exists`, + ); + } + deps.output(`Deleted ${parameter} (scope ${scope}).`); +} diff --git a/packages/millwright-cli/test/cli.test.ts b/packages/millwright-cli/test/cli.test.ts index 8f68802..b179314 100644 --- a/packages/millwright-cli/test/cli.test.ts +++ b/packages/millwright-cli/test/cli.test.ts @@ -51,10 +51,19 @@ describe('buildProgram', () => { ); }); - it('secrets set takes --scope', () => { + it('secrets set/list/rm take --scope; list also takes --all-scopes', () => { const secrets = program.commands.find((command) => command.name() === 'secrets')!; - const set = secrets.commands.find((command) => command.name() === 'set')!; - expect(set.options.map((option) => option.long)).toContain('--scope'); + expect(secrets.commands.map((command) => command.name())).toEqual( + expect.arrayContaining(['set', 'list', 'rm']), + ); + for (const name of ['set', 'list', 'rm']) { + const sub = secrets.commands.find((command) => command.name() === name)!; + expect(sub.options.map((option) => option.long)).toContain('--scope'); + } + const list = secrets.commands.find((command) => command.name() === 'list')!; + expect(list.options.map((option) => option.long)).toContain('--all-scopes'); + const rm = secrets.commands.find((command) => command.name() === 'rm')!; + expect(rm.registeredArguments.map((argument) => argument.name())).toEqual(['name']); }); it('setup takes the --pat fallback and App-creation options', () => { diff --git a/packages/millwright-cli/test/secrets.test.ts b/packages/millwright-cli/test/secrets.test.ts index 6f11fab..c75e170 100644 --- a/packages/millwright-cli/test/secrets.test.ts +++ b/packages/millwright-cli/test/secrets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CommandError } from '../src/config-plane'; -import { SecretsDeps, parseGithubRemote, secretsSet } from '../src/secrets'; +import { SecretsDeps, parseGithubRemote, secretsList, secretsRm, secretsSet } from '../src/secrets'; import { FakeSsm } from './fake-ssm'; function fixture(overrides: Partial = {}) { @@ -63,6 +63,109 @@ describe('secrets set', () => { }); }); +describe('secrets list', () => { + it('lists the secret names for a scope, sorted, without values', async () => { + const { ssm, deps, lines } = fixture(); + ssm.set('/millwright/prod/secrets/acme/api/NPM_TOKEN', 'hunter2', 'SecureString'); + ssm.set('/millwright/prod/secrets/acme/api/AWS_KEY', 'sekrit', 'SecureString'); + ssm.set('/millwright/prod/secrets/acme/web/OTHER', 'nope', 'SecureString'); + const entries = await secretsList(deps, { scope: 'acme/api' }); + expect(entries).toEqual([ + { scope: 'acme/api', name: 'AWS_KEY' }, + { scope: 'acme/api', name: 'NPM_TOKEN' }, + ]); + expect(lines).toEqual(['Secrets in scope acme/api (deployment "prod"):', 'AWS_KEY', 'NPM_TOKEN']); + expect(lines.join('\n')).not.toContain('hunter2'); + }); + + it('defaults the scope to the repo inferred from the origin remote', async () => { + const { ssm, deps } = fixture({ inferRepo: async () => 'acme/web' }); + ssm.set('/millwright/prod/secrets/acme/web/DEPLOY_TOKEN', 'x', 'SecureString'); + const entries = await secretsList(deps, {}); + expect(entries.map((e) => e.name)).toEqual(['DEPLOY_TOKEN']); + }); + + it('demands --scope when no origin remote is inferable', async () => { + const { deps } = fixture(); + await expect(secretsList(deps, {})).rejects.toThrow(/pass --scope/); + }); + + it('says so when the scope holds no secrets', async () => { + const { deps, lines } = fixture(); + await expect(secretsList(deps, { scope: 'acme/api' })).resolves.toEqual([]); + expect(lines.join('\n')).toContain('No secrets in scope acme/api'); + }); + + it('enumerates every scope with --all-scopes', async () => { + const { ssm, deps, lines } = fixture(); + ssm.set('/millwright/prod/secrets/acme/web/OTHER', 'x', 'SecureString'); + ssm.set('/millwright/prod/secrets/acme/api/NPM_TOKEN', 'x', 'SecureString'); + ssm.set('/millwright/prod/secrets/shared/SLACK_HOOK', 'x', 'SecureString'); + ssm.set('/millwright/prod/repos/acme/api/deploy-key', 'KEY', 'SecureString'); + const entries = await secretsList(deps, { allScopes: true }); + expect(entries).toEqual([ + { scope: 'acme/api', name: 'NPM_TOKEN' }, + { scope: 'acme/web', name: 'OTHER' }, + { scope: 'shared', name: 'SLACK_HOOK' }, + ]); + expect(lines).toEqual([ + 'Secrets in every scope (deployment "prod"):', + 'acme/api NPM_TOKEN', + 'acme/web OTHER', + 'shared SLACK_HOOK', + ]); + }); + + it('does not need an origin remote with --all-scopes', async () => { + const { deps, lines } = fixture(); + await expect(secretsList(deps, { allScopes: true })).resolves.toEqual([]); + expect(lines.join('\n')).toContain('No secrets in any scope'); + }); +}); + +describe('secrets rm', () => { + it('deletes the scoped parameter and reports the path', async () => { + const { ssm, deps, lines } = fixture(); + ssm.set('/millwright/prod/secrets/acme/api/NPM_TOKEN', 'hunter2', 'SecureString'); + ssm.set('/millwright/prod/secrets/acme/api/AWS_KEY', 'sekrit', 'SecureString'); + await secretsRm(deps, { name: 'NPM_TOKEN', scope: 'acme/api' }); + expect(ssm.parameters.has('/millwright/prod/secrets/acme/api/NPM_TOKEN')).toBe(false); + expect(ssm.parameters.has('/millwright/prod/secrets/acme/api/AWS_KEY')).toBe(true); + expect(lines[0]).toBe('Deleted /millwright/prod/secrets/acme/api/NPM_TOKEN (scope acme/api).'); + }); + + it('defaults the scope to the repo inferred from the origin remote', async () => { + const { ssm, deps } = fixture({ inferRepo: async () => 'acme/web' }); + ssm.set('/millwright/prod/secrets/acme/web/DEPLOY_TOKEN', 'x', 'SecureString'); + await secretsRm(deps, { name: 'DEPLOY_TOKEN' }); + expect(ssm.parameters.has('/millwright/prod/secrets/acme/web/DEPLOY_TOKEN')).toBe(false); + }); + + it('fails clearly when the secret does not exist', async () => { + const { deps } = fixture(); + await expect(secretsRm(deps, { name: 'NPM_TOKEN', scope: 'acme/api' })).rejects.toThrow( + CommandError, + ); + await expect(secretsRm(deps, { name: 'NPM_TOKEN', scope: 'acme/api' })).rejects.toThrow( + /no secret named NPM_TOKEN in scope acme\/api/, + ); + }); + + it('rejects names that cannot be SSM path segments before touching the deployment', async () => { + // No manifest: discovery would fail, so a rejection proves the pre-check ran first. + const { deps } = fixture({ ssm: new FakeSsm() }); + await expect(secretsRm(deps, { name: 'not/a/name', scope: 'acme/api' })).rejects.toThrow( + /not a secret name/, + ); + await expect(secretsRm(deps, { name: '', scope: 'acme/api' })).rejects.toThrow(CommandError); + }); + + it('demands --scope when no origin remote is inferable', async () => { + const { deps } = fixture(); + await expect(secretsRm(deps, { name: 'NPM_TOKEN' })).rejects.toThrow(/pass --scope/); + }); +}); + describe('parseGithubRemote', () => { it('handles the usual remote URL shapes', () => { expect(parseGithubRemote('git@github.com:acme/api.git')).toBe('acme/api'); diff --git a/packages/millwright-state/src/index.ts b/packages/millwright-state/src/index.ts index 0a10395..f021476 100644 --- a/packages/millwright-state/src/index.ts +++ b/packages/millwright-state/src/index.ts @@ -180,6 +180,8 @@ export { manifestParameterName, repoConfigParameterName, repoFromConfigParameterName, + SecretParameterParts, + secretFromParameterName, secretParameterName, } from './ssm-paths'; export { diff --git a/packages/millwright-state/src/ssm-paths.ts b/packages/millwright-state/src/ssm-paths.ts index cec63ab..d9d15a8 100644 --- a/packages/millwright-state/src/ssm-paths.ts +++ b/packages/millwright-state/src/ssm-paths.ts @@ -104,3 +104,36 @@ export function secretParameterName( } return `${configPlaneRoot(deploymentName)}/secrets/${scope}/${secretName}`; } + +export interface SecretParameterParts { + readonly scope: string; + readonly name: string; +} + +/** + * Inverse of `secretParameterName` for one deployment; undefined when the + * name is not one of its secret parameters. The scope is everything between + * the `/secrets/` prefix and the final segment, so `owner/repo` scopes and + * single-segment shared scopes both invert. `millwright secrets list` + * discovers secrets by listing the prefix and inverting each name. + */ +export function secretFromParameterName( + deploymentName: string, + parameterName: string, +): SecretParameterParts | undefined { + const prefix = `${configPlaneRoot(deploymentName)}/secrets/`; + if (!parameterName.startsWith(prefix)) { + return undefined; + } + const rest = parameterName.slice(prefix.length); + const split = rest.lastIndexOf('/'); + if (split <= 0) { + return undefined; + } + const scope = rest.slice(0, split); + const name = rest.slice(split + 1); + if (scope.startsWith('/') || scope.includes('//') || !isSecretNameSegment(name)) { + return undefined; + } + return { scope, name }; +} diff --git a/packages/millwright-state/test/ssm-paths.test.ts b/packages/millwright-state/test/ssm-paths.test.ts index f9f0227..630ef99 100644 --- a/packages/millwright-state/test/ssm-paths.test.ts +++ b/packages/millwright-state/test/ssm-paths.test.ts @@ -8,6 +8,7 @@ import { manifestParameterName, repoConfigParameterName, repoFromConfigParameterName, + secretFromParameterName, secretParameterName, } from '../src'; @@ -41,6 +42,24 @@ describe('SSM config-plane paths', () => { ).toBeUndefined(); }); + it('inverts secret parameter names into scope + name for secrets list', () => { + expect(secretFromParameterName(NAME, secretParameterName(NAME, REPO, 'NPM_TOKEN'))).toEqual({ + scope: REPO, + name: 'NPM_TOKEN', + }); + expect(secretFromParameterName(NAME, secretParameterName(NAME, 'shared', 'HOOK'))).toEqual({ + scope: 'shared', + name: 'HOOK', + }); + expect(secretFromParameterName(NAME, deployKeyParameterName(NAME, REPO))).toBeUndefined(); + expect(secretFromParameterName(NAME, '/millwright/ci-platform/secrets/NPM_TOKEN')).toBeUndefined(); + expect(secretFromParameterName(NAME, '/millwright/ci-platform/secrets//X')).toBeUndefined(); + expect(secretFromParameterName(NAME, '/millwright/ci-platform/secrets/a/')).toBeUndefined(); + expect( + secretFromParameterName('other', secretParameterName(NAME, REPO, 'NPM_TOKEN')), + ).toBeUndefined(); + }); + it('inverts manifest parameter names for CLI discovery', () => { expect(deploymentNameFromManifestParameter(manifestParameterName(NAME))).toBe(NAME); expect(deploymentNameFromManifestParameter('/millwright/x/repos/a/b/config')).toBeUndefined(); From e7fd1bb4b0fcd2c96c1efc43fe77c338bc1d1271 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Mon, 21 Sep 2026 01:14:42 +0000 Subject: [PATCH 3/5] refactor(cli): name the all-scopes branch in secrets list --- packages/millwright-cli/src/secrets.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/millwright-cli/src/secrets.ts b/packages/millwright-cli/src/secrets.ts index 3f1a3ee..5d0001a 100644 --- a/packages/millwright-cli/src/secrets.ts +++ b/packages/millwright-cli/src/secrets.ts @@ -139,32 +139,29 @@ export async function secretsList( ): Promise { const deployment = await discoverDeployment(deps.ssm, options); const scope = options.allScopes ? undefined : await resolveScope(deps, options.scope); - const prefix = - scope === undefined - ? `${configPlaneRoot(deployment.name)}/secrets/` - : `${configPlaneRoot(deployment.name)}/secrets/${scope}/`; + const everyScope = scope === undefined; + const secretsRoot = `${configPlaneRoot(deployment.name)}/secrets/`; + const prefix = everyScope ? secretsRoot : `${secretsRoot}${scope}/`; const entries: SecretsListEntry[] = []; for (const parameter of await listParametersByPrefix(deps.ssm, prefix)) { const parts = secretFromParameterName(deployment.name, parameter.name); // A recursive listing of `…/secrets/acme/` also returns `acme/api`'s // secrets; keep only the scope asked for. - if (parts && (scope === undefined || parts.scope === scope)) { + if (parts && (everyScope || parts.scope === scope)) { entries.push(parts); } } entries.sort((a, b) => a.scope.localeCompare(b.scope) || a.name.localeCompare(b.name)); - const where = scope === undefined ? 'every scope' : `scope ${scope}`; + const deploymentLabel = `(deployment "${deployment.name}")`; if (entries.length === 0) { - deps.output( - `No secrets in ${scope === undefined ? 'any scope' : where} (deployment "${deployment.name}").`, - ); + deps.output(`No secrets in ${everyScope ? 'any scope' : `scope ${scope}`} ${deploymentLabel}.`); return entries; } - deps.output(`Secrets in ${where} (deployment "${deployment.name}"):`); + deps.output(`Secrets in ${everyScope ? 'every scope' : `scope ${scope}`} ${deploymentLabel}:`); for (const entry of entries) { - deps.output(scope === undefined ? `${entry.scope} ${entry.name}` : entry.name); + deps.output(everyScope ? `${entry.scope} ${entry.name}` : entry.name); } return entries; } From b9f53c0d4dd7b78dfa769d391ec2d4bcda171803 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:17:49 +0000 Subject: [PATCH 4/5] chore(release): v0.8.0 (minor) --- package-lock.json | 22 +++++++++++----------- package.json | 2 +- packages/millwright-cdk/package.json | 8 ++++---- packages/millwright-cdk/src/version.ts | 2 +- packages/millwright-cli/package.json | 6 +++--- packages/millwright-cli/src/version.ts | 2 +- packages/millwright-state/package.json | 2 +- packages/millwright-workflows/package.json | 2 +- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8496681..c7b6eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "millwright", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "millwright", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "workspaces": [ "packages/*" @@ -2727,12 +2727,12 @@ }, "packages/millwright-cdk": { "name": "@copperbox/millwright-cdk", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { - "@copperbox/millwright-cli": "^0.7.0", - "@copperbox/millwright-state": "^0.7.0", - "@copperbox/millwright-workflows": "^0.7.0", + "@copperbox/millwright-cli": "^0.8.0", + "@copperbox/millwright-state": "^0.8.0", + "@copperbox/millwright-workflows": "^0.8.0", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, @@ -2760,7 +2760,7 @@ }, "packages/millwright-cli": { "name": "@copperbox/millwright-cli", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.1108.0", @@ -2773,8 +2773,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.7.0", - "@copperbox/millwright-workflows": "^0.7.0", + "@copperbox/millwright-state": "^0.8.0", + "@copperbox/millwright-workflows": "^0.8.0", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" @@ -2792,7 +2792,7 @@ }, "packages/millwright-state": { "name": "@copperbox/millwright-state", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "engines": { "node": ">=20" @@ -2800,7 +2800,7 @@ }, "packages/millwright-workflows": { "name": "@copperbox/millwright-workflows", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index e47b8da..3909c37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "millwright", - "version": "0.7.0", + "version": "0.8.0", "private": true, "description": "Millwright monorepo — polling-driven CI/CD in your own AWS account", "license": "MIT", diff --git a/packages/millwright-cdk/package.json b/packages/millwright-cdk/package.json index ff3a169..4b0fb12 100644 --- a/packages/millwright-cdk/package.json +++ b/packages/millwright-cdk/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cdk", - "version": "0.7.0", + "version": "0.8.0", "description": "The Millwright CDK construct — deploys the millwright control plane into your AWS account", "license": "MIT", "repository": { @@ -23,9 +23,9 @@ "test": "vitest run" }, "dependencies": { - "@copperbox/millwright-cli": "^0.7.0", - "@copperbox/millwright-state": "^0.7.0", - "@copperbox/millwright-workflows": "^0.7.0", + "@copperbox/millwright-cli": "^0.8.0", + "@copperbox/millwright-state": "^0.8.0", + "@copperbox/millwright-workflows": "^0.8.0", "esbuild": "^0.28.0", "ssh2": "^1.17.0" }, diff --git a/packages/millwright-cdk/src/version.ts b/packages/millwright-cdk/src/version.ts index a3545c8..1d76ee6 100644 --- a/packages/millwright-cdk/src/version.ts +++ b/packages/millwright-cdk/src/version.ts @@ -1,5 +1,5 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.7.0'; +export const VERSION = '0.8.0'; /** * Highest run-model schemaVersion this control plane accepts. Synth fails diff --git a/packages/millwright-cli/package.json b/packages/millwright-cli/package.json index 9aae3a6..2f7892a 100644 --- a/packages/millwright-cli/package.json +++ b/packages/millwright-cli/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-cli", - "version": "0.7.0", + "version": "0.8.0", "description": "millwright CLI — operate a millwright deployment from operator and developer machines", "license": "MIT", "repository": { @@ -33,8 +33,8 @@ "@aws-sdk/client-sfn": "^3.1108.0", "@aws-sdk/client-ssm": "^3.700.0", "@aws-sdk/lib-dynamodb": "^3.1108.0", - "@copperbox/millwright-state": "^0.7.0", - "@copperbox/millwright-workflows": "^0.7.0", + "@copperbox/millwright-state": "^0.8.0", + "@copperbox/millwright-workflows": "^0.8.0", "commander": "^12.1.0", "ssh2": "^1.17.0", "typescript": "^5.7.0" diff --git a/packages/millwright-cli/src/version.ts b/packages/millwright-cli/src/version.ts index 88a9716..168163e 100644 --- a/packages/millwright-cli/src/version.ts +++ b/packages/millwright-cli/src/version.ts @@ -1,2 +1,2 @@ // Kept in lockstep with package.json by scripts/set-version.mjs — do not edit by hand. -export const VERSION = '0.7.0'; +export const VERSION = '0.8.0'; diff --git a/packages/millwright-state/package.json b/packages/millwright-state/package.json index 813e381..3fac154 100644 --- a/packages/millwright-state/package.json +++ b/packages/millwright-state/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-state", - "version": "0.7.0", + "version": "0.8.0", "description": "Millwright's shared data-plane helpers — state/polling table item accessors, SSM config-plane paths, S3 layout", "license": "MIT", "repository": { diff --git a/packages/millwright-workflows/package.json b/packages/millwright-workflows/package.json index f52ef08..da3fe0a 100644 --- a/packages/millwright-workflows/package.json +++ b/packages/millwright-workflows/package.json @@ -1,6 +1,6 @@ { "name": "@copperbox/millwright-workflows", - "version": "0.7.0", + "version": "0.8.0", "description": "Millwright workflow definition library — the only install in watched repos", "license": "MIT", "repository": { From 64b35f948c682d84a6e46650f736c0ea95f6eaf4 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:29:49 +0000 Subject: [PATCH 5/5] fix(cli): reject --all-scopes with --scope and cover nested scope filtering - secrets list: --all-scopes now conflicts with --scope instead of silently dropping it, so a stale --scope cannot widen the listing by accident - alias SecretsListEntry to SecretParameterParts so the row type tracks the inverse in millwright-state - add tests for the nested-scope case the scope filter exists for, and for the option conflict --- packages/millwright-cli/src/cli.ts | 8 +++++-- packages/millwright-cli/src/secrets.ts | 7 +++---- packages/millwright-cli/test/cli.test.ts | 11 ++++++++++ packages/millwright-cli/test/secrets.test.ts | 22 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/millwright-cli/src/cli.ts b/packages/millwright-cli/src/cli.ts index 80169e1..d6181fe 100644 --- a/packages/millwright-cli/src/cli.ts +++ b/packages/millwright-cli/src/cli.ts @@ -14,7 +14,7 @@ import { RepoConfigFormatError, RunModelError, } from '@copperbox/millwright-state'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { CommandError, requireManifestResource } from './config-plane'; import { DefinitionLoadError } from './definition-loader'; import { DEPLOYMENT_ENV_VAR, Deployment, DiscoveryError, discoverDeployment } from './discovery'; @@ -536,7 +536,11 @@ function buildProgramWithSignal(): { program: Command; exitCode: () => number } .command('list') .description('list secret names for a scope (never values)') .option('--scope ', 'secret scope; defaults to the repo of the cwd origin remote') - .option('--all-scopes', 'list every scope in the deployment instead of one') + .addOption( + new Option('--all-scopes', 'list every scope in the deployment instead of one').conflicts( + 'scope', + ), + ) .action(async (options: { scope?: string; allScopes?: boolean }) => { await secretsList( { ssm: new SSMClient({}), output }, diff --git a/packages/millwright-cli/src/secrets.ts b/packages/millwright-cli/src/secrets.ts index 5d0001a..845c622 100644 --- a/packages/millwright-cli/src/secrets.ts +++ b/packages/millwright-cli/src/secrets.ts @@ -10,6 +10,7 @@ import { execFile } from 'node:child_process'; import { + SecretParameterParts, configPlaneRoot, isSecretNameSegment, secretFromParameterName, @@ -53,10 +54,8 @@ export interface SecretsRmOptions extends DiscoverOptions { readonly scope?: string; } -export interface SecretsListEntry { - readonly scope: string; - readonly name: string; -} +/** One row of `secrets list`: the inverted parameter name. */ +export type SecretsListEntry = SecretParameterParts; /** `owner/repo` from an SSH, ssh://, git://, or https GitHub remote URL. */ export function parseGithubRemote(url: string): string | undefined { diff --git a/packages/millwright-cli/test/cli.test.ts b/packages/millwright-cli/test/cli.test.ts index b179314..01da1d9 100644 --- a/packages/millwright-cli/test/cli.test.ts +++ b/packages/millwright-cli/test/cli.test.ts @@ -66,6 +66,17 @@ describe('buildProgram', () => { expect(rm.registeredArguments.map((argument) => argument.name())).toEqual(['name']); }); + it('secrets list rejects --all-scopes combined with --scope', async () => { + const fresh = buildProgram(); + const list = fresh.commands + .find((command) => command.name() === 'secrets')! + .commands.find((command) => command.name() === 'list')!; + list.exitOverride().configureOutput({ writeErr: () => undefined }); + await expect( + fresh.parseAsync(['secrets', 'list', '--all-scopes', '--scope', 'acme/api'], { from: 'user' }), + ).rejects.toThrow(/'--all-scopes' cannot be used with option '--scope/); + }); + it('setup takes the --pat fallback and App-creation options', () => { const setup = program.commands.find((command) => command.name() === 'setup')!; const flags = setup.options.map((option) => option.long); diff --git a/packages/millwright-cli/test/secrets.test.ts b/packages/millwright-cli/test/secrets.test.ts index c75e170..bb3b536 100644 --- a/packages/millwright-cli/test/secrets.test.ts +++ b/packages/millwright-cli/test/secrets.test.ts @@ -78,6 +78,28 @@ describe('secrets list', () => { expect(lines.join('\n')).not.toContain('hunter2'); }); + it('keeps a nested scope out of its parent scope listing', async () => { + // The recursive listing under `…/secrets/acme/` also returns `acme/api`'s + // secrets; only the scope filter keeps NPM_TOKEN out of scope `acme`. + const { ssm, deps, lines } = fixture(); + ssm.set('/millwright/prod/secrets/acme/DEPLOY_KEY', 'x', 'SecureString'); + ssm.set('/millwright/prod/secrets/acme/api/NPM_TOKEN', 'x', 'SecureString'); + const entries = await secretsList(deps, { scope: 'acme' }); + expect(entries).toEqual([{ scope: 'acme', name: 'DEPLOY_KEY' }]); + expect(lines).toEqual(['Secrets in scope acme (deployment "prod"):', 'DEPLOY_KEY']); + }); + + it('lists a nested scope and its parent under their own scopes with --all-scopes', async () => { + const { ssm, deps } = fixture(); + ssm.set('/millwright/prod/secrets/acme/DEPLOY_KEY', 'x', 'SecureString'); + ssm.set('/millwright/prod/secrets/acme/api/NPM_TOKEN', 'x', 'SecureString'); + const entries = await secretsList(deps, { allScopes: true }); + expect(entries).toEqual([ + { scope: 'acme', name: 'DEPLOY_KEY' }, + { scope: 'acme/api', name: 'NPM_TOKEN' }, + ]); + }); + it('defaults the scope to the repo inferred from the origin remote', async () => { const { ssm, deps } = fixture({ inferRepo: async () => 'acme/web' }); ssm.set('/millwright/prod/secrets/acme/web/DEPLOY_TOKEN', 'x', 'SecureString');