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
52 changes: 52 additions & 0 deletions src/commands/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,58 @@ export async function secretsUnset(name: string, opts: { branch?: string }): Pro
info(`unset ${name} (${opts.branch ? `branch ${opts.branch}` : 'project-wide'})`)
}

export async function secretsBind(envName: string, source: string, opts: { branch?: string; to?: string; sourceName?: string; json?: boolean }): Promise<void> {
if (!opts.to) die('--to <compute/name> is required')
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
const res = await api.rawRequest('PUT', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}`, {
branch,
target: opts.to,
source,
...(opts.sourceName ? { sourceName: opts.sourceName } : {}),
})
if (handleApproval(res)) return
if (opts.json) return printJson({ ok: true })
info(`bound ${envName} on ${opts.to} to ${source}${opts.sourceName ? `.${opts.sourceName}` : ''} (branch ${branch})`)
}

export async function secretsUnbind(envName: string, opts: { branch?: string; from?: string; json?: boolean }): Promise<void> {
if (!opts.from) die('--from <compute/name> is required')
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
const res = await api.rawRequest('DELETE', `/projects/${p.projectId}/secret-bindings/${encodeURIComponent(envName)}?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.from)}`)
if (handleApproval(res)) return
if (opts.json) return printJson({ ok: true })
info(`unbound ${envName} from ${opts.from} (branch ${branch})`)
}

export async function secretsBindings(opts: { branch?: string; target?: string; json?: boolean }): Promise<void> {
if (!opts.target) die('--target <compute/name> is required')
const api = await ApiClient.load()
const p = await requireProject()
const branch = opts.branch ?? p.branch
const res = await api.rawRequest('GET', `/projects/${p.projectId}/secret-bindings?branch=${encodeURIComponent(branch)}&target=${encodeURIComponent(opts.target)}`)
if (handleApproval(res)) return
const bindings = res.body.bindings ?? []
if (opts.json) return printJson(bindings)
if (!bindings.length) return info(`(no secret bindings for ${opts.target} on ${branch})`)
for (const b of bindings) info(`${b.envName} <- ${b.source.type}/${b.source.name}.${b.sourceName}`)

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: When a binding was created without --source-name, b.sourceName is absent and this line prints a literal .undefined suffix, e.g. api <- postgres/mydb.undefined. The bind command's own message guards the suffix with a ternary (opts.sourceName ? .${opts.sourceName} : ''), so the listing should too. Only append the source name when it is defined.

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 134:

<comment>When a binding was created without `--source-name`, `b.sourceName` is absent and this line prints a literal `.undefined` suffix, e.g. `api <- postgres/mydb.undefined`. The `bind` command's own message guards the suffix with a ternary (`opts.sourceName ? `.${opts.sourceName}` : ''`), so the listing should too. Only append the source name when it is defined.</comment>

<file context>
@@ -94,6 +94,58 @@ export async function secretsUnset(name: string, opts: { branch?: string }): Pro
+  const bindings = res.body.bindings ?? []
+  if (opts.json) return printJson(bindings)
+  if (!bindings.length) return info(`(no secret bindings for ${opts.target} on ${branch})`)
+  for (const b of bindings) info(`${b.envName} <- ${b.source.type}/${b.source.name}.${b.sourceName}`)
+}
+
</file context>
Suggested change
for (const b of bindings) info(`${b.envName} <- ${b.source.type}/${b.source.name}.${b.sourceName}`)
for (const b of bindings) info(`${b.envName} <- ${b.source.type}/${b.source.name}${b.sourceName ? `.${b.sourceName}` : ''}`)

}

export async function secretsSources(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}/secret-sources?branch=${encodeURIComponent(branch)}`)
if (handleApproval(res)) return
const sources = res.body.sources ?? []
if (opts.json) return printJson(sources)
if (!sources.length) return info(`(no credential sources on ${branch})`)
for (const s of sources) info(`${s.service.type}/${s.service.name}: ${s.secrets.join(', ')}`)
}

/** Gitignore the env file we just wrote (git repos only; idempotent). Returns true if added. */
export function ensureIgnored(cwd: string, name: string): boolean {
if (!existsSync(join(cwd, '.git'))) return false
Expand Down
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,26 @@ sec.command('set <name> [value]').description('Set a user secret (project-wide;
.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('bind <env-name> <source>').description('Bind a service credential into a compute env var')
.option('--branch <branch>', 'branch (default: current)')
.option('--to <compute-service>', 'target compute service, e.g. compute/api')
.option('--source-name <name>', 'source credential name when the source exposes more than one')
.option('--json')
.action(guard((n, source, o) => secretsCmd.secretsBind(n, source, o)))
sec.command('unbind <env-name>').description('Remove a service credential binding from a compute env var')
.option('--branch <branch>', 'branch (default: current)')
.option('--from <compute-service>', 'target compute service, e.g. compute/api')
.option('--json')
.action(guard((n, o) => secretsCmd.secretsUnbind(n, o)))
sec.command('bindings').description('List service credential bindings for a compute service')
.option('--branch <branch>', 'branch (default: current)')
.option('--target <compute-service>', 'target compute service, e.g. compute/api')
.option('--json')
.action(guard((o) => secretsCmd.secretsBindings(o)))
sec.command('sources').description('List service credential sources available for binding')
.option('--branch <branch>', 'branch (default: current)')
.option('--json')
.action(guard((o) => secretsCmd.secretsSources(o)))
sec.command('tree').description('Show secrets as project → branch → service → secrets').option('--json')
.action(guard((o) => secretsCmd.secretsTree(o)))

Expand Down
Loading