Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions src/commands/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,43 @@ export async function secrets(opts: { branch?: string; output?: string; print?:
info(' tip: `insta run -- <cmd>` injects these per-run with nothing written to disk')
}

export async function secretsList(opts: { branch?: string }): Promise<void> {
// 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<void> {
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<void> {
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: insta secrets list --branch <unknown> silently looks like an empty branch because the missing branch is ignored and only project-wide names are printed. Validating the lookup before either output path would turn typos or stale links into an actionable error and keep the JSON shape stable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/secrets.ts, line 61:

<comment>`insta secrets list --branch <unknown>` silently looks like an empty branch because the missing branch is ignored and only project-wide names are printed. Validating the lookup before either output path would turn typos or stale links into an actionable error and keep the JSON shape stable.</comment>

<file context>
@@ -25,13 +25,43 @@ export async function secrets(opts: { branch?: string; output?: string; print?:
   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}`) }
</file context>
Suggested change
const b = tree.branches.find((x) => x.name === branch)
const b = tree.branches.find((x) => x.name === branch)
if (!b) die(`branch not found: ${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<string> {
Expand All @@ -40,17 +70,19 @@ async function readStdin(): Promise<string> {
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<void> {
// 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<void> {
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<string, string> = opts.branch ? { value: v, branch: opts.branch } : { value: v }
const branch = opts.service ? (opts.branch ?? p.branch) : opts.branch
const payload: Record<string, string> = { 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<void> {
Expand Down
15 changes: 15 additions & 0 deletions src/commands/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type> <name> — the secret names bound to a service.
export async function servicesSecrets(type: string, name: string, opts: { branch?: string; json?: boolean } = {}): Promise<void> {
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)
}
9 changes: 7 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,21 @@ svc.command('scale <type> <name> <number> [region]').description('Set a compute
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, number, region, o) => services.servicesScale(type, name, number, region, o)))
svc.command('upgrade <type> <name> <spec>').description('Change a compute/postgres service spec (paid plans only)')
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((type, name, spec, o) => services.servicesUpgrade(type, name, spec, o)))
svc.command('secrets <type> <name>').description("List a service's secret names")
.option('--branch <b>').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 <branch>').option('-o, --output <file>', '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 <branch>').action(guard((o) => secretsCmd.secretsList(o)))
sec.command('list').description('List secret names, grouped by service').option('--branch <branch>').option('--json').action(guard((o) => secretsCmd.secretsList(o)))
sec.command('set <name> [value]').description('Set a user secret (project-wide; value from stdin if omitted)')
.option('--branch <branch>', 'scope to one branch').action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)))
.option('--branch <branch>', 'scope to one branch').option('--service <type/name>', 'bind to a branch service (implies current branch)')
.action(guard((n, v, o) => secretsCmd.secretsSet(n, v, o)))
sec.command('unset <name>').description('Remove a user secret')
.option('--branch <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')
Expand Down
Loading