Skip to content
Open
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
47 changes: 47 additions & 0 deletions src/commands/branch/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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({
Expand Down
3 changes: 2 additions & 1 deletion src/commands/branch/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions src/commands/branch/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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({
Expand Down
3 changes: 2 additions & 1 deletion src/commands/branch/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions src/commands/branch/redact.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
for (const field of SECRET_FIELDS) delete safe[field];
return safe;
}
30 changes: 30 additions & 0 deletions src/commands/branch/reset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down
3 changes: 2 additions & 1 deletion src/commands/branch/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
Expand Down