diff --git a/src/commands/secrets.ts b/src/commands/secrets.ts index 3044009..9747797 100644 --- a/src/commands/secrets.ts +++ b/src/commands/secrets.ts @@ -25,13 +25,43 @@ export async function secrets(opts: { branch?: string; output?: string; print?: info(' tip: `insta run -- ` injects these per-run with nothing written to disk') } -export async function secretsList(opts: { branch?: string }): Promise { +// Shape of GET /secrets/tree: the whole binding picture — project-wide secrets, then per-branch +// service groupings plus any branch-level (unbound) secrets. +type Tree = { + projectWide: string[] + branches: { name: string; isDefault: boolean; services: { type: string; name: string; secrets: string[] }[]; unbound: string[] }[] +} + +// Render one branch's service-grouped secrets, then its unbound (branch-level) secrets. +function renderBranch(b: Tree['branches'][number]): void { + for (const s of b.services) if (s.secrets.length) { info(` ${s.type}/${s.name}`); for (const n of s.secrets) info(` ${n}`) } + if (b.unbound.length) { info(' (branch-level)'); for (const n of b.unbound) info(` ${n}`) } +} + +// Show the full binding tree: project-wide, then every branch grouped by service. +export async function secretsTree(opts: { json?: boolean }): Promise { + const api = await ApiClient.load() + const p = await requireProject() + const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`) + if (handleApproval(res)) return + const tree: Tree = res.body + if (opts.json) return printJson(tree) + if (tree.projectWide.length) { info('(project-wide)'); for (const n of tree.projectWide) info(` ${n}`) } + for (const b of tree.branches) { info(`${b.name}${b.isDefault ? ' *' : ''}`); renderBranch(b) } +} + +// List secret names for the current (or given) branch, grouped by service. +export async function secretsList(opts: { branch?: string; json?: boolean }): Promise { const api = await ApiClient.load() const p = await requireProject() const branch = opts.branch ?? p.branch - const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets${q(branch)}`) + const res = await api.rawRequest('GET', `/projects/${p.projectId}/secrets/tree`) if (handleApproval(res)) return - for (const name of Object.keys(res.body.secrets)) info(name) + const tree: Tree = res.body + const b = tree.branches.find((x) => x.name === branch) + if (opts.json) return printJson({ projectWide: tree.projectWide, branch: b }) + if (tree.projectWide.length) { info('(project-wide)'); for (const n of tree.projectWide) info(` ${n}`) } + if (b) { info(`${b.name}`); renderBranch(b) } } async function readStdin(): Promise { @@ -40,17 +70,19 @@ async function readStdin(): Promise { return data.trim() } -// Set a user secret. Project-wide by default; --branch scopes it to one branch. Value comes from -// the argument, or stdin when omitted (keeps secret values out of shell history). -export async function secretsSet(name: string, value: string | undefined, opts: { branch?: string }): Promise { +// Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds +// it to a branch service instead, which implies the current branch (binding requires one). Value +// comes from the argument, or stdin when omitted (keeps secret values out of shell history). +export async function secretsSet(name: string, value: string | undefined, opts: { branch?: string; service?: string }): Promise { const api = await ApiClient.load() const p = await requireProject() const v = value ?? (await readStdin()) if (!v) die('value is required (pass as an argument or on stdin)') - const payload: Record = opts.branch ? { value: v, branch: opts.branch } : { value: v } + const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch + const payload: Record = { value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) } const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secrets/${encodeURIComponent(name)}`, payload) if (handleApproval(res)) return - info(`set ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`) + info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`) } export async function secretsUnset(name: string, opts: { branch?: string }): Promise { diff --git a/src/commands/services.ts b/src/commands/services.ts index dacee14..8b31a2e 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -131,3 +131,18 @@ export async function servicesUpgrade(type: string, name: string, spec: string, if (_opts.json) return printJson(res.body.service) info(`upgraded ${type} ${name} to ${spec}`) } + +// insta services secrets — the secret names bound to a service. +export async function servicesSecrets(type: string, name: string, opts: { branch?: string; json?: boolean } = {}): Promise { + assertType(type) + const api = await ApiClient.load() + const p = await requireProject() + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`) + const id = resolveServiceId(services, type, name) + const res = await api.rawRequest('GET', `/projects/${p.projectId}/services/${id}/secrets`) + if (handleApproval(res)) return + const { secrets } = res.body + if (opts.json) return printJson(secrets) + if (!secrets.length) return info(`(no secrets bound to ${type}/${name})`) + for (const n of secrets) info(n) +} diff --git a/src/index.ts b/src/index.ts index 895154b..fdff722 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,16 +117,21 @@ svc.command('scale [region]').description('Set a compute .option('--json').option('--branch ', 'branch (default: current)').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o))) svc.command('upgrade ').description('Change a compute/postgres service spec (paid plans only)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o))) +svc.command('secrets ').description("List a service's secret names") + .option('--branch ').option('--json').action(guard((type, name, o) => services.servicesSecrets(type, name, o))) // ---- secrets (seam) ---- const sec = program.command('secrets').description('Fetch the credential bundle (secret seam) into .env') .option('--branch ').option('-o, --output ', 'output file (default .env)').option('--print', 'print instead of writing').option('--json') .action(guard((o) => secretsCmd.secrets(o))) -sec.command('list').description('List secret names only').option('--branch ').action(guard((o) => secretsCmd.secretsList(o))) +sec.command('list').description('List secret names, grouped by service').option('--branch ').option('--json').action(guard((o) => secretsCmd.secretsList(o))) sec.command('set [value]').description('Set a user secret (project-wide; value from stdin if omitted)') - .option('--branch ', 'scope to one branch').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o))) + .option('--branch ', 'scope to one branch').option('--service ', 'bind to a branch service (implies current branch)') + .action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o))) sec.command('unset ').description('Remove a user secret') .option('--branch ', 'scope to one branch').action(guard((n, o) => secretsCmd.secretsUnset(n, o))) +sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json') + .action(guard((o) => secretsCmd.secretsTree(o))) // ---- deploy ---- program.command('deploy [dir]').description('Deploy a source directory (built remotely on Fly) or a prebuilt --image to a branch compute group')