diff --git a/package-lock.json b/package-lock.json index 4f60ea85..3f8b64e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.99", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.99", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/src/commands/branch/create.test.ts b/src/commands/branch/create.test.ts index 45fec7f0..3d67e211 100644 --- a/src/commands/branch/create.test.ts +++ b/src/commands/branch/create.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { Command } from 'commander'; import { registerBranchCreateCommand } from './create.js'; +import { CLIError } from '../../lib/errors.js'; vi.mock('../../lib/api/platform.js', () => ({ createBranchApi: vi.fn(async (_parentId: string, body: { mode: string; name: string }) => ({ @@ -25,6 +26,14 @@ vi.mock('../../lib/api/platform.js', () => ({ branch_created_at: new Date().toISOString(), branch_metadata: { mode: 'full' }, })), + listBranchesApi: vi.fn(async () => []), + NETWORK_ERROR_CODE: 'NETWORK_ERROR', +})); + +// The data-plane readiness probe. It MUST be mocked: unmocked it makes a real +// request to a fake host and then polls for minutes. +vi.mock('../../lib/api/oss.js', () => ({ + probeBackendHealth: vi.fn(async () => ({ reachable: true, status: 200 })), })); vi.mock('../../lib/credentials.js', () => ({ @@ -32,6 +41,7 @@ vi.mock('../../lib/credentials.js', () => ({ })); vi.mock('../../lib/config.js', () => ({ + buildOssHost: (appkey: string, region: string) => `https://${appkey}.${region}.insforge.app`, getProjectConfig: vi.fn(), saveProjectConfig: vi.fn(), getLocalConfigDir: () => '/tmp/.insforge', @@ -60,12 +70,38 @@ vi.mock('@clack/prompts', () => ({ spinner: () => spinnerMock, })); +// Run `fn` with process.exit + stderr captured, and always restore them. Returns +// the exit code fn triggered (undefined if it never exited). Timer/mock lifecycle +// stays with the caller — this only owns the exit/stderr swap. +async function withCapturedExit(fn: () => Promise): Promise { + let exitCode: number | undefined; + const origExit = process.exit; + const origStderr = process.stderr.write.bind(process.stderr); + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('__exit__'); + }) as typeof process.exit; + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + await fn(); + } finally { + process.exit = origExit; + process.stderr.write = origStderr; + } + return exitCode; +} + describe('branch create', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); spinnerMock.start.mockReset(); spinnerMock.message.mockReset(); spinnerMock.stop.mockReset(); + // clearAllMocks clears CALLS but keeps implementations, so a test that made + // the branch unreachable would otherwise leave every later test polling for + // the full readiness budget. + const { probeBackendHealth } = await import('../../lib/api/oss.js'); + (probeBackendHealth as Mock).mockResolvedValue({ reachable: true, status: 200 }); }); it('rejects when no project linked', async () => { @@ -280,4 +316,324 @@ describe('branch create', () => { expect.objectContaining({ name: 'feat-x', json: false, silent: true }), ); }); + it('does not report success while the branch host is not serving yet', async () => { + // 'ready' is a control-plane state. Reporting success on it alone is what + // makes the user's NEXT command fail against a host that resets. + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { probeBackendHealth } = await import('../../lib/api/oss.js'); + (probeBackendHealth as Mock).mockResolvedValue({ + reachable: false, + status: null, + detail: 'Connection to p1ky-x9p.us-east.insforge.app was reset.', + }); + vi.useFakeTimers(); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + const run = program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + await vi.runAllTimersAsync(); + await run; + vi.useRealTimers(); + const stopped = spinnerMock.stop.mock.calls.at(-1); + expect(String(stopped?.[0])).toContain('not serving yet'); + expect(stopped?.[1]).toBe(1); + }); + + it('exits non-zero when the branch never finishes provisioning', async () => { + // The sibling of "ready but not serving": if the branch is stuck in a + // non-terminal state past the poll budget it is equally unusable, so + // automation reading the exit code must not see success. (Review suggestion, + // InsForge/CLI#201.) + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { getBranchApi } = await import('../../lib/api/platform.js'); + const originalImpl = (getBranchApi as Mock).getMockImplementation(); + // Never reaches 'ready' — pollUntilReady exhausts its budget and returns the + // last 'creating' snapshot. + (getBranchApi as Mock).mockResolvedValue({ + id: 'branch-id', + parent_project_id: 'p1', + organization_id: 'o1', + name: 'feat-x', + appkey: 'p1ky-x9p', + region: 'us-east', + branch_state: 'creating', + branch_created_at: new Date().toISOString(), + branch_metadata: { mode: 'schema-only' }, + }); + let exitCode: number | undefined; + vi.useFakeTimers(); + try { + exitCode = await withCapturedExit(async () => { + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + const run = program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + await vi.runAllTimersAsync(); + await run; + }); + } finally { + vi.useRealTimers(); + // Restore the shared 'ready' impl — clearAllMocks keeps implementations, so + // leaving this 'creating' would make every later test poll the full budget. + (getBranchApi as Mock).mockImplementation(originalImpl!); + } + expect(exitCode).toBe(1); + }); + + it('adopts a branch that was created despite a transport failure', async () => { + // createBranchApi carries no idempotency key, so a reset on the RESPONSE + // leg leaves a real, billing branch behind. Giving up here orphans it. + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js'); + (createBranchApi as Mock).mockRejectedValueOnce( + new CLIError('Connection to api.insforge.dev was reset.', 1, 'NETWORK_ERROR'), + ); + (listBranchesApi as Mock).mockResolvedValueOnce([ + { + id: 'branch-id', + parent_project_id: 'p1', + organization_id: 'o1', + name: 'feat-x', + appkey: 'p1ky-x9p', + region: 'us-east', + branch_state: 'creating', + branch_created_at: new Date().toISOString(), + branch_metadata: { mode: 'schema-only' }, + }, + ]); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + await program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + expect(listBranchesApi as Mock).toHaveBeenCalledWith('p1', undefined); + // The run continued instead of exiting as a failed creation. + expect(String(spinnerMock.stop.mock.calls.at(-1)?.[0])).not.toContain('creation failed'); + }); + + it('does NOT adopt a same-name branch created with a DIFFERENT mode', async () => { + // A collaborator's same-name branch landing in the skew window — at the same + // moment our own request loses its response leg — must not be adopted, or a + // default --switch would move local context onto their branch. Requiring a + // matching mode narrows that collision. (cubic P2, InsForge/CLI#201.) + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js'); + (createBranchApi as Mock).mockRejectedValueOnce( + new CLIError('Connection to api.insforge.dev was reset.', 1, 'NETWORK_ERROR'), + ); + // Same name, freshly created (inside the window), but the WRONG mode. + (listBranchesApi as Mock).mockResolvedValueOnce([ + { + id: 'someone-elses-branch', + parent_project_id: 'p1', + organization_id: 'o1', + name: 'feat-x', + appkey: 'p1ky-x9p', + region: 'us-east', + branch_state: 'creating', + branch_created_at: new Date().toISOString(), + branch_metadata: { mode: 'full' }, + }, + ]); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + const exitCode = await withCapturedExit(() => + program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}) + ); + // No adoption → the original transport error propagates → non-zero exit. + expect(exitCode).toBe(1); + }); + + it('rethrows the original error when nothing was actually created', async () => { + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js'); + (createBranchApi as Mock).mockRejectedValueOnce( + new CLIError('Connection to api.insforge.dev was reset.', 1, 'NETWORK_ERROR'), + ); + (listBranchesApi as Mock).mockResolvedValueOnce([]); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + let exitCode: number | undefined; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('__exit__'); + }) as typeof process.exit; + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + await program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + } finally { + process.exit = origExit; + process.stderr.write = origStderr; + } + expect(exitCode).toBe(1); + }); + it('does NOT adopt on an API rejection — a duplicate name is a refusal, not a lost response', async () => { + // Adopting here would switch the caller into a pre-existing branch with a + // different mode and different data. Only a transport failure is ambiguous. + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js'); + (createBranchApi as Mock).mockRejectedValueOnce( + new CLIError("Branch name 'feat-x' already exists on this parent", 1), + ); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + let exitCode: number | undefined; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('__exit__'); + }) as typeof process.exit; + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + await program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + } finally { + process.exit = origExit; + process.stderr.write = origStderr; + } + expect(listBranchesApi as Mock).not.toHaveBeenCalled(); + expect(exitCode).toBe(1); + }); + + it('does NOT adopt a branch that predates the request', async () => { + // Same name, but it existed before we asked — so it is not ours. + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js'); + (createBranchApi as Mock).mockRejectedValueOnce( + new CLIError('Connection to api.insforge.dev was reset.', 1, 'NETWORK_ERROR'), + ); + (listBranchesApi as Mock).mockResolvedValueOnce([ + { + id: 'someone-elses', + parent_project_id: 'p1', + organization_id: 'o1', + name: 'feat-x', + appkey: 'p1ky-old', + region: 'us-east', + branch_state: 'ready', + branch_created_at: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), + branch_metadata: { mode: 'full' }, + }, + ]); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + let exitCode: number | undefined; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('__exit__'); + }) as typeof process.exit; + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + await program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + } finally { + process.exit = origExit; + process.stderr.write = origStderr; + } + expect(exitCode).toBe(1); + }); + + it('exits non-zero when the branch never serves, but still emits its identity first', async () => { + // The branch exists and is billing: automation must be able to find and + // delete it even though the command is failing. + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { probeBackendHealth } = await import('../../lib/api/oss.js'); + (probeBackendHealth as Mock).mockResolvedValue({ reachable: false, status: null }); + const lines: string[] = []; + const origLog = console.log; + console.log = ((...args: unknown[]) => { + lines.push(args.join(' ')); + }) as typeof console.log; + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = (() => true) as typeof process.stderr.write; + let exitCode: number | undefined; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error('__exit__'); + }) as typeof process.exit; + vi.useFakeTimers(); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + try { + const run = program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch', '--json'], { + from: 'user', + }) + .catch(() => {}); + await vi.runAllTimersAsync(); + await run; + } finally { + vi.useRealTimers(); + console.log = origLog; + process.exit = origExit; + process.stderr.write = origStderr; + } + const payload = lines.join('\n'); + expect(payload).toContain('branch-id'); + expect(payload).toContain('"serving"'); + expect(payload).toContain('false'); + expect(exitCode).toBe(1); + }); }); diff --git a/src/commands/branch/create.ts b/src/commands/branch/create.ts index bba78ca1..73f0940e 100644 --- a/src/commands/branch/create.ts +++ b/src/commands/branch/create.ts @@ -1,16 +1,36 @@ import type { Command } from 'commander'; import * as clack from '@clack/prompts'; -import { createBranchApi, getBranchApi } from '../../lib/api/platform.js'; +import { + createBranchApi, + getBranchApi, + listBranchesApi, + NETWORK_ERROR_CODE, +} from '../../lib/api/platform.js'; +import { probeBackendHealth } from '../../lib/api/oss.js'; import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; import { requireAuth } from '../../lib/credentials.js'; -import { getProjectConfig } from '../../lib/config.js'; +import { buildOssHost, getProjectConfig } from '../../lib/config.js'; import { outputJson, outputInfo } from '../../lib/output.js'; import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; import { runBranchSwitch } from './switch.js'; import type { Branch, BranchMode } from '../../types.js'; const POLL_INTERVAL_MS = 3_000; -const POLL_TIMEOUT_MS = 5 * 60 * 1_000; +// `branch_state` reaching 'ready' and the branch's own host answering are two +// different events, and the gap between them has been measured in MINUTES +// (2 min and 11.5 min on ap-southeast). A 5-minute ceiling reported the slower +// one as "still creating" when it was simply not finished yet, so the budget +// now covers the observed range with headroom. +const POLL_TIMEOUT_MS = 15 * 60 * 1_000; +// Once the control plane says ready, wait for the data plane too. Until this +// passes, every subsequent command against the branch fails. +const HEALTH_TIMEOUT_MS = 10 * 60 * 1_000; +const HEALTH_INTERVAL_MS = 5_000; +// Tolerance for clock skew when deciding whether a branch is the one we just +// asked for. Generous on purpose: the cost of being slightly wide is adopting a +// branch someone created seconds ago under the same name; the cost of being too +// narrow is orphaning a billing resource, which is the bug this exists to fix. +const CREATED_AT_SKEW_MS = 60_000; export function registerBranchCreateCommand(branch: Command): void { branch @@ -46,6 +66,10 @@ export function registerBranchCreateCommand(branch: Command): void { // ready })` below remains the sole authoritative output. const spinner = !json ? clack.spinner() : null; let ready: Branch; + // Whether the branch's own host answered. Separate from `provisioned` + // because the branch can be genuinely created and genuinely unusable, + // and the exit status has to reflect the second one. + let serving = false; // Tracks whether the branch reached `ready` state in the cloud — once // true, any later throw is a switch failure (local), not a creation // failure. Lets the catch render an accurate message instead of the @@ -53,7 +77,13 @@ export function registerBranchCreateCommand(branch: Command): void { let provisioned = false; try { spinner?.start(`Creating branch '${name}'...`); - const created = await createBranchApi(project.project_id, { mode, name }, apiUrl); + const requestedAt = Date.now() - CREATED_AT_SKEW_MS; + const created = await createBranchOrAdopt( + project.project_id, + { mode, name }, + apiUrl, + requestedAt, + ); captureEvent(project.project_id, 'cli_branch_create', { mode, parent_project_id: project.project_id, @@ -62,6 +92,16 @@ export function registerBranchCreateCommand(branch: Command): void { ready = await pollUntilReady(created.id, apiUrl, spinner); provisioned = ready.branch_state === 'ready'; + // 'ready' is a control-plane state: it means the provisioning job + // returned, not that the branch answers. Confirm the data plane + // before reporting success, otherwise the very next command the user + // runs — including the auto-switch below — hits a host that resets. + if (provisioned) { + spinner?.message('Branch ready. Waiting for it to start serving...'); + serving = await waitUntilServing(ready, spinner); + if (!serving) provisioned = false; + } + if (provisioned && opts.switch) { spinner?.message('Branch ready. Switching context...'); // silent: true always — the spinner owns user-facing output, and @@ -71,6 +111,11 @@ export function registerBranchCreateCommand(branch: Command): void { spinner?.stop(`Branch '${name}' is ready and active`); } else if (provisioned) { spinner?.stop(`Branch '${name}' is ready`); + } else if (ready.branch_state === 'ready') { + spinner?.stop( + `Branch '${name}' reports ready but is not serving yet — retry your next command shortly`, + 1, + ); } else { spinner?.stop(`Branch '${name}' is in '${ready.branch_state}' state`); } @@ -86,19 +131,47 @@ export function registerBranchCreateCommand(branch: Command): void { throw err; } + // Emit the branch identity BEFORE any failure is raised: the branch + // 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 }); - } else if (ready.branch_state === 'ready') { + outputJson({ branch: ready, serving }); + } else if (ready.branch_state === 'ready' && serving) { if (opts.switch) { outputInfo( '⚠ Re-source your dev server env (.env) to pick up the new INSFORGE_URL / ANON_KEY.', ); } + } else if (ready.branch_state === 'ready') { + outputInfo( + `Branch '${name}' exists but its host is not serving yet. Run \`insforge branch list\` to check, or \`insforge branch delete ${name}\` to remove it.`, + ); } else { outputInfo( `Branch '${name}' is still in '${ready.branch_state}' state. Run \`insforge branch list\` to check.`, ); } + + // Exit non-zero when the branch cannot be used. Reporting success here + // is what lets automation continue straight into a host that resets — + // the failure mode this whole change exists to remove. Two outcomes are + // "not usable", and both must fail: the branch never finished + // provisioning (still non-'ready' after the poll budget), and the branch + // is 'ready' but its host never started serving. + if (ready.branch_state !== 'ready') { + throw new CLIError( + `Branch '${name}' was created but did not finish provisioning (still '${ready.branch_state}') within ${ + Math.round(POLL_TIMEOUT_MS / 60_000) + } minutes.`, + ); + } + if (!serving) { + throw new CLIError( + `Branch '${name}' was created but its host did not start serving within ${ + Math.round(HEALTH_TIMEOUT_MS / 60_000) + } minutes.`, + ); + } } catch (err) { handleError(err, json); } finally { @@ -107,6 +180,92 @@ export function registerBranchCreateCommand(branch: Command): void { }); } +/** + * Create the branch, and if the request fails at the TRANSPORT layer, check + * whether it was created anyway before giving up. + * + * `createBranchApi` carries no idempotency key, and a reset on the RESPONSE leg + * leaves a fully created, billing branch behind while the CLI exits non-zero. + * The caller then has no id, no name in the output, and no reason to believe + * anything exists — so the branch is silently orphaned. `branch list` is + * authoritative here, and it is a control-plane call, so it still works while + * the branch's own host is unreachable. + * + * Two guards keep this from adopting something it did not create — a duplicate + * name is a REJECTION, not a lost response, and adopting on it would switch the + * caller into someone else's branch with a different mode and different data: + * + * 1. only a tagged transport failure is eligible; every HTTP/API rejection + * (duplicate name, quota, auth) rethrows untouched; + * 2. the branch must have been created at or after the moment we sent the + * request, so a pre-existing same-name branch is never a candidate; + * 3. the branch's mode must match what we asked for. + * + * Guard 3 narrows a residual collision the timestamp window alone cannot close: + * a collaborator creating a same-name branch inside the skew window, at the same + * moment our own request loses its response leg, would otherwise be adoptable — + * and with the default `--switch` that would silently move local context onto + * their branch. Requiring a mode match makes that require an even more specific + * coincidence (same name AND same mode AND the same ~60s AND our transport + * failure). The real fix is a server-issued idempotency/request token on + * `createBranchApi`; until that exists, this is the tightest client-side guard. + * Reported upstream: InsForge/InsForge#1790. + */ +function isTransportFailure(err: unknown): boolean { + return err instanceof CLIError && err.code === NETWORK_ERROR_CODE; +} + +async function createBranchOrAdopt( + parentId: string, + body: { mode: BranchMode; name: string }, + apiUrl: string | undefined, + requestedAt: number, +): Promise { + try { + return await createBranchApi(parentId, body, apiUrl); + } catch (err) { + if (!isTransportFailure(err)) throw err; + const existing = await listBranchesApi(parentId, apiUrl) + .then(branches => + branches.find( + branch => + branch.name === body.name && + branch.branch_metadata?.mode === body.mode && + Date.parse(branch.branch_created_at) >= requestedAt, + ), + ) + .catch(() => undefined); + if (!existing) throw err; + return existing; + } +} + +/** + * Poll the branch's own host until it serves, so 'ready' means usable. + * + * Returns false rather than throwing when the budget runs out: the branch DOES + * exist and is billing, so the command must still report its name and id and + * must not look like a failed creation. + */ +async function waitUntilServing( + branch: Branch, + spinner: ReturnType | null, +): Promise { + const baseUrl = buildOssHost(branch.appkey, branch.region); + const start = Date.now(); + let announced = false; + while (Date.now() - start < HEALTH_TIMEOUT_MS) { + const health = await probeBackendHealth(baseUrl); + if (health.reachable) return true; + if (spinner && !announced) { + spinner.message(`Branch is provisioning its instance (${baseUrl} not answering yet)...`); + announced = true; + } + await new Promise(r => setTimeout(r, HEALTH_INTERVAL_MS)); + } + return false; +} + async function pollUntilReady( branchId: string, apiUrl: string | undefined, diff --git a/src/lib/api/oss.ts b/src/lib/api/oss.ts index 7db9924d..09231427 100644 --- a/src/lib/api/oss.ts +++ b/src/lib/api/oss.ts @@ -1,5 +1,5 @@ import { getProjectConfig } from '../config.js'; -import { CLIError, ProjectNotLinkedError } from '../errors.js'; +import { CLIError, formatFetchError, ProjectNotLinkedError } from '../errors.js'; import type { ProjectConfig, RotateKeyResponse, @@ -227,3 +227,26 @@ export async function ossFetch( return res; } + +/** + * Probe an InsForge backend's `/api/health` on an EXPLICIT base URL. + * + * Unlike `ossFetch`, this deliberately does not read the linked project: a + * freshly created branch is not linked yet, and the whole point is to ask + * whether ITS host is answering before we tell the user it is usable. + * + * Unauthenticated and non-throwing — callers poll it, so a connection reset + * while the instance boots is an expected answer ("not yet"), not an error. + */ +export async function probeBackendHealth( + baseUrl: string, + timeoutMs = 10_000, +): Promise<{ reachable: boolean; status: number | null; detail?: string }> { + const url = `${baseUrl.replace(/\/$/, '')}/api/health`; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + return { reachable: res.ok, status: res.status }; + } catch (err) { + return { reachable: false, status: null, detail: formatFetchError(err, url) }; + } +} diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index 28a29340..90f81354 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -1,6 +1,6 @@ import { getAccessToken, getCredentials, getPlatformApiUrl } from '../config.js'; -import { AuthError, CLIError, formatFetchError } from '../errors.js'; import { refreshAccessToken } from '../credentials.js'; +import { AuthError, CLIError, formatFetchError } from '../errors.js'; import type { ApiKeyResponse, Backup, @@ -30,6 +30,10 @@ import type { User, } from '../../types.js'; +// Marks a CLIError that came from a failed fetch rather than an HTTP response: +// the request may still have been received and acted on by the server. +export const NETWORK_ERROR_CODE = 'NETWORK_ERROR'; + export interface PlatformFetchOptions extends RequestInit { /** * HTTP status codes that should be returned to the caller instead of @@ -97,7 +101,7 @@ export async function platformFetch( try { res = await fetch(fullUrl, { ...fetchOptions, headers }); } catch (err) { - throw new CLIError(formatFetchError(err, fullUrl)); + throw new CLIError(formatFetchError(err, fullUrl), 1, NETWORK_ERROR_CODE); } // Auto-refresh on 401 @@ -108,7 +112,7 @@ export async function platformFetch( try { retryRes = await fetch(fullUrl, { ...fetchOptions, headers }); } catch (err) { - throw new CLIError(formatFetchError(err, fullUrl)); + throw new CLIError(formatFetchError(err, fullUrl), 1, NETWORK_ERROR_CODE); } if (passThroughStatuses?.includes(retryRes.status)) { return retryRes;