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
182 changes: 182 additions & 0 deletions src/commands/create.recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { CLIError, isTransientApiError } from '../lib/errors.js';

vi.mock('../lib/api/platform.js', () => ({
listOrganizations: vi.fn(),
createProject: vi.fn(),
getProject: vi.fn(),
getProjectApiKey: vi.fn(),
NETWORK_ERROR_CODE: 'NETWORK_ERROR',
}));

import {
createProjectOrReportAmbiguousResult,
isAmbiguousProjectCreateFailure,
waitForProjectActive,
} from './create.js';

const createdProject = {
id: 'project-id',
organization_id: 'org-id',
name: 'demo',
appkey: 'demo-appkey',
region: 'eu-central',
status: 'creating',
instance_type: 'shared',
service_version: null,
customized_domain: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};

describe('create project recovery', () => {
beforeEach(async () => {
vi.clearAllMocks();
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockResolvedValue(createdProject);
(platform.getProject as Mock).mockResolvedValue({ ...createdProject, status: 'active' });
});

it('reports an unknown result after a gateway failure without adopting a project', async () => {
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockRejectedValueOnce(
new CLIError('Request failed: 502', 1, undefined, 502),
);

await expect(createProjectOrReportAmbiguousResult('org-id', 'demo', 'eu-central', undefined))
.rejects.toMatchObject({
code: 'PROJECT_CREATE_RESULT_UNKNOWN',
statusCode: 502,
message: expect.stringContaining('npx @insforge/cli list --json'),
});
});

it('reports an unknown result when the create response cannot be decoded', async () => {
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockRejectedValueOnce(new SyntaxError('Unexpected end of JSON input'));

await expect(createProjectOrReportAmbiguousResult('org-id', 'demo', 'eu-central', undefined))
.rejects.toMatchObject({
code: 'PROJECT_CREATE_RESULT_UNKNOWN',
message: expect.stringContaining('npx @insforge/cli list --json'),
});
});

it('keeps a one-off Platform endpoint in the recovery command', async () => {
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockRejectedValueOnce(
new CLIError('Request failed: 502', 1, undefined, 502),
);

await expect(createProjectOrReportAmbiguousResult(
'org-id', 'demo', 'eu-central', 'https://platform.example.test',
)).rejects.toMatchObject({
message: expect.stringContaining(
'npx @insforge/cli --api-url YOUR_API_URL list --json',
),
});
});

it('prevents shell expansion in a custom endpoint', async () => {
const platform = await import('../lib/api/platform.js');
(platform.createProject as Mock).mockRejectedValueOnce(
new CLIError('Request failed: 502', 1, undefined, 502),
);
const apiUrl = 'https://platform.example.test/$(whoami)`touch-pwned`';

const error = await createProjectOrReportAmbiguousResult('org-id', 'demo', 'eu-central', apiUrl)
.catch(err => err as CLIError);

expect(error.message).toContain(
'npx @insforge/cli --api-url YOUR_API_URL list --json',
);
expect(error.message).toContain('replacing `YOUR_API_URL` with the exact value used for creation');
expect(error.message).not.toContain(apiUrl);
});

it('never reconciles an ordinary API 500', async () => {
const platform = await import('../lib/api/platform.js');
const failure = new CLIError('Internal server error', 1, undefined, 500);
(platform.createProject as Mock).mockRejectedValueOnce(failure);

await expect(createProjectOrReportAmbiguousResult('org-id', 'demo', 'eu-central', undefined))
.rejects.toBe(failure);
});

it('recognizes only gateway and transport failures as ambiguous', () => {
expect(isAmbiguousProjectCreateFailure(new CLIError('network', 1, 'NETWORK_ERROR'))).toBe(true);
expect(isAmbiguousProjectCreateFailure(new CLIError('gateway', 1, undefined, 503))).toBe(true);
expect(isAmbiguousProjectCreateFailure(new CLIError('invalid request', 1, undefined, 400))).toBe(false);
expect(isAmbiguousProjectCreateFailure(new CLIError('server error', 1, undefined, 500))).toBe(false);
expect(isAmbiguousProjectCreateFailure(new SyntaxError('Unexpected end of JSON input'))).toBe(true);
});

it('continues polling through three transient activation-read failures when the project recovers', async () => {
const platform = await import('../lib/api/platform.js');
(platform.getProject as Mock)
.mockRejectedValueOnce(new CLIError('Request failed: 502', 1, undefined, 502))
.mockRejectedValueOnce(new CLIError('Request failed: 502', 1, undefined, 502))
.mockRejectedValueOnce(new CLIError('Request failed: 502', 1, undefined, 502))
.mockResolvedValueOnce({ ...createdProject, status: 'active' });
vi.useFakeTimers();
try {
const pending = waitForProjectActive('project-id');
await vi.runAllTimersAsync();
await expect(pending).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
expect(platform.getProject).toHaveBeenCalledTimes(4);
});

it('preserves the last actionable error when the activation deadline expires', async () => {
const platform = await import('../lib/api/platform.js');
(platform.getProject as Mock).mockRejectedValue(
new CLIError('Request failed: 502', 1, undefined, 502),
);
vi.useFakeTimers();
try {
const startedAt = Date.now();
const pending = waitForProjectActive('project-id', undefined, 10_000)
.catch(err => err as CLIError);
await vi.runAllTimersAsync();
const timeout = await pending;
expect(timeout).toMatchObject({
code: 'PROJECT_ACTIVATION_TIMEOUT',
message: expect.stringContaining('Last control-plane error: Request failed: 502'),
});
expect(timeout.statusCode).toBeUndefined();
expect(isTransientApiError(timeout)).toBe(false);
expect(Date.now() - startedAt).toBe(10_000);
} finally {
vi.useRealTimers();
}
expect(platform.getProject).toHaveBeenCalledTimes(4);
});

it('aborts an in-flight status request at the activation deadline', async () => {
const platform = await import('../lib/api/platform.js');
let requestSignal: AbortSignal | undefined;
(platform.getProject as Mock).mockImplementation(
(_projectId: string, _apiUrl: string | undefined, signal: AbortSignal) => new Promise((_, reject) => {
requestSignal = signal;
signal.addEventListener('abort', () => {
reject(new CLIError('Request aborted', 1, 'NETWORK_ERROR'));
});
}),
);
vi.useFakeTimers();
try {
const startedAt = Date.now();
const pending = waitForProjectActive('project-id', undefined, 10_000)
.catch(err => err as CLIError);
await vi.advanceTimersByTimeAsync(10_000);
const timeout = await pending;
expect(timeout.code).toBe('PROJECT_ACTIVATION_TIMEOUT');
expect(requestSignal?.aborted).toBe(true);
expect(Date.now() - startedAt).toBe(10_000);
} finally {
vi.useRealTimers();
}
});
});
117 changes: 108 additions & 9 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import {
createProject,
getProject,
getProjectApiKey,
NETWORK_ERROR_CODE,
} from '../lib/api/platform.js';
import { getAnonKey, runRawSql } from '../lib/api/oss.js';
import { applyAuthProvider, VALID_AUTH_PROVIDERS, type AuthProvider } from '../auth-providers/apply.js';
import { getGlobalConfig, saveGlobalConfig, saveProjectConfig, getFrontendUrl, buildOssHost } from '../lib/config.js';
import { requireAuth } from '../lib/credentials.js';
import { handleError, getRootOpts, CLIError } from '../lib/errors.js';
import { handleError, getRootOpts, CLIError, isTransientApiError } from '../lib/errors.js';
import { outputJson } from '../lib/output.js';
import { readEnvFile } from '../lib/env.js';
import { installSkills, reportCliUsage } from '../lib/skills.js';
Expand All @@ -37,14 +38,112 @@ const SAFE_MARKETPLACE_SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/;

export type Framework = 'react' | 'nextjs';

async function waitForProjectActive(projectId: string, apiUrl?: string, timeoutMs = 120_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const project = await getProject(projectId, apiUrl);
if (project.status === 'active') return;
await new Promise((r) => setTimeout(r, 3000));
const PROJECT_POLL_INTERVAL_MS = 3_000;
const PROJECT_POLL_TIMEOUT_MS = 120_000;
const PROXY_STATUSES = new Set([502, 503, 504]);

/**
* Wait for project provisioning without mistaking a transient control-plane
* read failure for a failed creation.
*/
export async function waitForProjectActive(
projectId: string,
apiUrl?: string,
timeoutMs = PROJECT_POLL_TIMEOUT_MS,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastTransientError: CLIError | undefined;
while (true) {
const remainingBeforeRequest = deadline - Date.now();
if (remainingBeforeRequest <= 0) break;

const controller = new AbortController();
let requestTimedOut = false;
const abortTimer = setTimeout(() => {
requestTimedOut = true;
controller.abort();
}, remainingBeforeRequest);
try {
const project = await getProject(projectId, apiUrl, controller.signal);
// A request that completes after the deadline cannot establish that the
// project became active within the configured activation window.
if (Date.now() >= deadline) break;
// A successful control-plane read means a previous transient error is
// no longer useful when explaining a later provisioning timeout.
lastTransientError = undefined;
if (project.status === 'active') return;
} catch (err) {
if (requestTimedOut || Date.now() >= deadline) break;
if (!isTransientApiError(err)) throw err;
// Keep polling through the configured deadline: a temporary control
// plane outage must not turn into an early create failure. If it never
// recovers, preserve the last classified API error at the deadline.
lastTransientError = err as CLIError;
} finally {
clearTimeout(abortTimer);
}

const remainingBeforeSleep = deadline - Date.now();
if (remainingBeforeSleep <= 0) break;
await new Promise((r) => setTimeout(r, Math.min(PROJECT_POLL_INTERVAL_MS, remainingBeforeSleep)));
}
if (lastTransientError) {
throw new CLIError(
`Project activation timed out. Last control-plane error: ${lastTransientError.message}`,
1,
'PROJECT_ACTIVATION_TIMEOUT',
);
}
throw new CLIError('Project creation timed out. Check the dashboard for status.');
throw new CLIError(
'Project activation timed out. Check the dashboard for status.',
1,
'PROJECT_ACTIVATION_TIMEOUT',
);
}

/** A 502/503/504 or a lost transport response says nothing reliable about the POST outcome. */
export function isAmbiguousProjectCreateFailure(err: unknown): boolean {
// createProject parses the response after a POST. A malformed body means
// the server may have created the project even though no result reached us.
if (err instanceof SyntaxError) return true;
if (!(err instanceof CLIError)) return false;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return err.code === NETWORK_ERROR_CODE ||
(err.statusCode !== undefined && PROXY_STATUSES.has(err.statusCode));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/**
* A gateway or transport failure can arrive after the platform accepted the
* POST. The create-project API exposes no request correlation or idempotency
* key, so a later project-list result cannot prove ownership. Never adopt a
* name/time match: that could attach the caller to a collaborator's project.
*/
export async function createProjectOrReportAmbiguousResult(
orgId: string,
name: string,
region: string | undefined,
apiUrl: string | undefined,
): Promise<Awaited<ReturnType<typeof createProject>>> {
try {
return await createProject(orgId, name, region, apiUrl);
} catch (err) {
if (!isAmbiguousProjectCreateFailure(err)) throw err;
const apiError = err instanceof CLIError ? err : undefined;
const apiUrlReplacement = apiUrl
? ', replacing `YOUR_API_URL` with the exact value used for creation'
: '';
throw new CLIError(
'Project creation may have succeeded, but the platform did not return a result. ' +
`Run \`${getProjectVerificationCommand(apiUrl)}\`${apiUrlReplacement} before retrying to avoid creating a duplicate project.`,
apiError?.exitCode ?? 1,
'PROJECT_CREATE_RESULT_UNKNOWN',
apiError?.statusCode,
);
}
}

function getProjectVerificationCommand(apiUrl?: string): string {
const apiUrlArg = apiUrl ? ' --api-url YOUR_API_URL' : '';
return `npx @insforge/cli${apiUrlArg} list --json`;
}

const INSFORGE_BANNER = [
Expand Down Expand Up @@ -348,7 +447,7 @@ export function registerCreateCommand(program: Command): void {
try {
s?.start('Creating project...');

const project = await createProject(orgId, projectName, opts.region, apiUrl);
const project = await createProjectOrReportAmbiguousResult(orgId, projectName, opts.region, apiUrl);

s?.message('Waiting for project to become active...');
await waitForProjectActive(project.id, apiUrl);
Expand Down
40 changes: 40 additions & 0 deletions src/lib/api/platform.abort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const configMock = vi.hoisted(() => ({
getAccessToken: vi.fn(),
getCredentials: vi.fn(),
getPlatformApiUrl: vi.fn(),
}));
const credentialsMock = vi.hoisted(() => ({
refreshAccessToken: vi.fn(),
}));

vi.mock('../config.js', () => configMock);
vi.mock('../credentials.js', () => credentialsMock);

import { platformFetch } from './platform.js';

describe('platformFetch cancellation', () => {
beforeEach(() => {
vi.clearAllMocks();
configMock.getAccessToken.mockReturnValue('expired-token');
configMock.getPlatformApiUrl.mockReturnValue('https://platform.example.test');
});

it('passes the request signal to a 401 token refresh', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
const controller = new AbortController();
credentialsMock.refreshAccessToken.mockImplementation(
(_apiUrl: string | undefined, signal: AbortSignal) => new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(new Error('Refresh aborted')));
}),
);

const pending = platformFetch('/projects/v1/project-id', { signal: controller.signal });
await vi.waitFor(() => expect(credentialsMock.refreshAccessToken).toHaveBeenCalledTimes(1));
controller.abort();

await expect(pending).rejects.toThrow('Refresh aborted');
expect(credentialsMock.refreshAccessToken).toHaveBeenCalledWith(undefined, controller.signal);
});
});
Loading
Loading