diff --git a/src/commands/branch/create.test.ts b/src/commands/branch/create.test.ts index 598c181..13f5af2 100644 --- a/src/commands/branch/create.test.ts +++ b/src/commands/branch/create.test.ts @@ -202,6 +202,53 @@ describe('branch create', () => { expect(out).toContain('feat-x'); }); + it('--json output leaves out the branch database_password', async () => { + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + appkey: 'p1ky', + region: 'us-east', + api_key: 'k', + oss_host: 'p1ky.us-east.insforge.app', + }); + const { getBranchApi } = await import('../../lib/api/platform.js'); + (getBranchApi as Mock).mockResolvedValueOnce({ + id: 'branch-id', + parent_project_id: 'p1', + organization_id: 'o1', + name: 'feat-x', + appkey: 'p1ky-x9p', + region: 'us-east', + branch_state: 'ready', + branch_created_at: new Date().toISOString(), + branch_metadata: { mode: 'schema-only' }, + database_password: 'plaintext-secret', + }); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }; + try { + await program.parseAsync( + ['create', 'feat-x', '--mode', 'schema-only', '--no-switch', '--json'], + { from: 'user' }, + ); + } finally { + console.log = origLog; + } + const out = logs.join('\n'); + expect(out).not.toContain('plaintext-secret'); + const parsed = JSON.parse(out); + expect(parsed.branch.id).toBe('branch-id'); + expect(parsed.branch).not.toHaveProperty('database_password'); + }); + it('happy path without --no-switch invokes runBranchSwitch', async () => { const { getProjectConfig } = await import('../../lib/config.js'); (getProjectConfig as Mock).mockReturnValue({ diff --git a/src/commands/branch/create.ts b/src/commands/branch/create.ts index 5e2c607..9cef018 100644 --- a/src/commands/branch/create.ts +++ b/src/commands/branch/create.ts @@ -14,6 +14,7 @@ import { outputJson, outputInfo } from '../../lib/output.js'; import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; import { runBranchSwitch } from './switch.js'; import { readBranchWithRetry } from './poll.js'; +import { redactBranch } from './redact.js'; import type { Branch, BranchMode } from '../../types.js'; const POLL_INTERVAL_MS = 3_000; @@ -143,7 +144,7 @@ export function registerBranchCreateCommand(branch: Command): void { // exists and is billing, so a caller must be able to find and delete it // even when this command is about to exit non-zero. if (json) { - outputJson({ branch: ready, serving }); + outputJson({ branch: redactBranch(ready), serving }); } else if (ready.branch_state === 'ready' && serving) { if (opts.switch) { outputInfo( diff --git a/src/commands/branch/list.test.ts b/src/commands/branch/list.test.ts index 5dd6ebd..6321b14 100644 --- a/src/commands/branch/list.test.ts +++ b/src/commands/branch/list.test.ts @@ -117,6 +117,52 @@ describe('branch list', () => { expect(parsed.data[0].name).toBe('feat-x'); }); + it('json mode leaves out each branch database_password', async () => { + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { listBranchesApi } = await import('../../lib/api/platform.js'); + (listBranchesApi as Mock).mockResolvedValueOnce([ + { + id: 'b1', + name: 'feat-x', + branch_state: 'ready', + organization_id: 'o1', + parent_project_id: 'p1', + appkey: 'k1', + region: 'us-east', + branch_created_at: '2026-04-29T00:00:00Z', + branch_metadata: { mode: 'full' }, + database_password: 'plaintext-secret', + }, + { + id: 'b2', + name: 'feat-y', + branch_state: 'ready', + organization_id: 'o1', + parent_project_id: 'p1', + appkey: 'k2', + region: 'us-east', + branch_created_at: '2026-04-30T00:00:00Z', + branch_metadata: { mode: 'schema-only' }, + database_password: 'second-secret', + }, + ]); + const program = makeProgram(); + const logs = await runWithCapturedLog(program, ['list', '--json']); + const out = logs.join('\n'); + expect(out).not.toContain('plaintext-secret'); + expect(out).not.toContain('second-secret'); + const parsed = JSON.parse(out); + expect(parsed.data.map((b: { name: string }) => b.name)).toEqual(['feat-x', 'feat-y']); + for (const branch of parsed.data) { + expect(branch).not.toHaveProperty('database_password'); + } + }); + it('table mode marks the current branch with `*`', async () => { const { getProjectConfig } = await import('../../lib/config.js'); (getProjectConfig as Mock).mockReturnValue({ diff --git a/src/commands/branch/list.ts b/src/commands/branch/list.ts index a2d7817..0373126 100644 --- a/src/commands/branch/list.ts +++ b/src/commands/branch/list.ts @@ -5,6 +5,7 @@ import { requireAuth } from '../../lib/credentials.js'; import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputTable, outputInfo } from '../../lib/output.js'; import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; +import { redactBranch } from './redact.js'; export function registerBranchListCommand(branch: Command): void { branch @@ -24,7 +25,7 @@ export function registerBranchListCommand(branch: Command): void { captureEvent(parentId, 'cli_branch_list', { count: branches.length }); if (json) { - outputJson({ data: branches }); + outputJson({ data: branches.map(redactBranch) }); return; } if (branches.length === 0) { diff --git a/src/commands/branch/redact.ts b/src/commands/branch/redact.ts new file mode 100644 index 0000000..74b8001 --- /dev/null +++ b/src/commands/branch/redact.ts @@ -0,0 +1,13 @@ +import type { Branch } from '../../types.js'; + +// Fields the control plane returns on a branch that are credentials, not state. +// Command output ends up in terminal scrollback and CI logs, so they never +// reach stdout. `ai setup` does the same for the gateway key. +const SECRET_FIELDS = ['database_password']; + +/** A copy of the branch that is safe to print. */ +export function redactBranch(branch: Branch): Branch { + const safe = { ...branch } as Branch & Record; + for (const field of SECRET_FIELDS) delete safe[field]; + return safe; +} diff --git a/src/commands/branch/reset.test.ts b/src/commands/branch/reset.test.ts index 85d7065..0b256eb 100644 --- a/src/commands/branch/reset.test.ts +++ b/src/commands/branch/reset.test.ts @@ -112,6 +112,36 @@ describe('branch reset', () => { })); }); + it('--json output leaves out the branch database_password', async () => { + const { getBranchApi } = await import('../../lib/api/platform.js'); + (getBranchApi as Mock).mockResolvedValueOnce({ + id: 'b1', + name: 'feat-x', + branch_state: 'ready', + organization_id: 'o1', + parent_project_id: 'p1', + appkey: 'k', + region: 'us-east', + branch_created_at: '2026', + database_password: 'plaintext-secret', + }); + const program = makeProgram(); + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + try { + await program.parseAsync(['reset', 'feat-x', '--yes', '--json'], { from: 'user' }); + } finally { + logSpy.mockRestore(); + } + const out = logs.join('\n'); + expect(out).not.toContain('plaintext-secret'); + const parsed = JSON.parse(out); + expect(parsed.branch.id).toBe('b1'); + expect(parsed.branch).not.toHaveProperty('database_password'); + }); + it('reset of merged branch is allowed (entry_state=merged threaded through analytics)', async () => { const program = makeProgram(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/src/commands/branch/reset.ts b/src/commands/branch/reset.ts index cd90ec9..c3a81f0 100644 --- a/src/commands/branch/reset.ts +++ b/src/commands/branch/reset.ts @@ -7,6 +7,7 @@ import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputSuccess, outputInfo } from '../../lib/output.js'; import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; import { readBranchWithRetry } from './poll.js'; +import { redactBranch } from './redact.js'; import type { Branch } from '../../types.js'; const POLL_INTERVAL_MS = 3_000; @@ -70,7 +71,7 @@ export function registerBranchResetCommand(branch: Command): void { const final = await pollUntilReady(target.id, name, apiUrl, !json, initial.branch_state); if (json) { - outputJson({ branch: final }); + outputJson({ branch: redactBranch(final) }); } else if (final.branch_state === 'ready') { outputSuccess(`Branch '${name}' is back to T0 and ready.`); outputInfo('⚠ Reminder: edge functions, website, and compute aren’t touched by reset; redeploy if needed.');