Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
164 changes: 164 additions & 0 deletions src/commands/preview/create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { registerPreviewCreateCommand } from './create.js';
import { readPreviewManifest, writePreviewManifest } from '../../lib/preview-manifest.js';
import { getBranchApi, deleteBranchApi } from '../../lib/api/platform.js';

vi.mock('../../lib/api/platform.js', () => ({
createBranchApi: vi.fn(async (_p: string, body: { mode: string; name: string }) => ({
id: 'branch-123',
parent_project_id: 'p1',
organization_id: 'o1',
name: body.name,
appkey: 'p1ky-x9p',
region: 'us-east',
branch_state: 'creating',
branch_created_at: '2026-06-10T00:00:00.000Z',
branch_metadata: { mode: body.mode },
})),
getBranchApi: vi.fn(async () => ({
id: 'branch-123',
parent_project_id: 'p1',
organization_id: 'o1',
name: 'feat-likes',
appkey: 'p1ky-x9p',
region: 'us-east',
branch_state: 'ready',
branch_created_at: '2026-06-10T00:00:00.000Z',
branch_metadata: { mode: 'full' },
})),
deleteBranchApi: vi.fn(async () => {}),
}));
vi.mock('../../lib/credentials.js', () => ({ requireAuth: vi.fn(async () => ({})) }));
vi.mock('../../lib/analytics.js', () => ({
captureEvent: vi.fn(),
shutdownAnalytics: vi.fn(async () => {}),
}));

let tmpBase: string;
vi.mock('../../lib/config.js', () => ({
getProjectConfig: vi.fn(() => ({ project_id: 'p1', branched_from: null })),
}));

describe('preview create', () => {
beforeEach(async () => {
vi.clearAllMocks();
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-cmd-'));
vi.spyOn(process, 'cwd').mockReturnValue(tmpBase);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it('creates a branch and writes a manifest', async () => {
const program = new Command();
program.exitOverride();
const preview = program.command('preview');
registerPreviewCreateCommand(preview);
await program.parseAsync(['preview', 'create', 'feat-likes'], { from: 'user' });

const manifest = await readPreviewManifest(tmpBase, 'feat-likes');
expect(manifest).not.toBeNull();
expect(manifest?.branchId).toBe('branch-123');
expect(manifest?.appkey).toBe('p1ky-x9p');
});

it('wires the given env file at the branch backend and backs it up', async () => {
const envFile = path.join(tmpBase, '.env.custom');
await fs.writeFile(
envFile,
'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app\n',
);

const program = new Command();
program.exitOverride();
const preview = program.command('preview');
registerPreviewCreateCommand(preview);
await program.parseAsync(
['preview', 'create', 'feat-likes', '--wire-env', envFile],
{ from: 'user' },
);

const content = await fs.readFile(envFile, 'utf-8');
expect(content).toContain(
'NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app',
);
expect(content).not.toContain('prod.insforge.app');

const backup = await fs.readFile(envFile + '.preview-bak', 'utf-8');
expect(backup).toContain('NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app');

const manifest = await readPreviewManifest(tmpBase, 'feat-likes');
expect(manifest?.wiredEnvFile).toBe(envFile);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

it('defaults --wire-env to .env.local and records it was created', async () => {
const program = new Command();
program.exitOverride();
const preview = program.command('preview');
registerPreviewCreateCommand(preview);
// --wire-env with no value; .env.local does not exist in tmpBase yet.
await program.parseAsync(['preview', 'create', 'feat-likes', '--wire-env'], { from: 'user' });

const created = await fs.readFile(path.join(tmpBase, '.env.local'), 'utf-8');
expect(created).toContain('NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app');
const manifest = await readPreviewManifest(tmpBase, 'feat-likes');
expect(manifest?.wiredEnvFile).toBe('.env.local');
expect(manifest?.wiredEnvCreated).toBe(true);
});

it('rolls back the branch when provisioning never reaches ready', async () => {
vi.mocked(getBranchApi).mockResolvedValueOnce({
id: 'branch-123',
parent_project_id: 'p1',
organization_id: 'o1',
name: 'feat-likes',
appkey: 'p1ky-x9p',
region: 'us-east',
branch_state: 'conflicted',
branch_created_at: '2026-06-10T00:00:00.000Z',
branch_metadata: { mode: 'full' },
} as Awaited<ReturnType<typeof getBranchApi>>);
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => { throw new Error('exit'); }) as never);

const program = new Command();
program.exitOverride();
const preview = program.command('preview');
registerPreviewCreateCommand(preview);
await expect(
program.parseAsync(['preview', 'create', 'feat-likes'], { from: 'user' }),
).rejects.toThrow();

// The half-provisioned branch is cleaned up, and no manifest is left behind.
expect(deleteBranchApi).toHaveBeenCalledWith('branch-123', undefined);
expect(await readPreviewManifest(tmpBase, 'feat-likes')).toBeNull();
exitSpy.mockRestore();
});

it('refuses to create when a preview of the same name already exists', async () => {
await writePreviewManifest(tmpBase, {
name: 'feat-likes',
branchId: 'old-branch',
appkey: 'old',
createdAt: '2026-06-10T00:00:00.000Z',
});
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => { throw new Error('exit'); }) as never);

const program = new Command();
program.exitOverride();
const preview = program.command('preview');
registerPreviewCreateCommand(preview);
await expect(
program.parseAsync(['preview', 'create', 'feat-likes'], { from: 'user' }),
).rejects.toThrow();

// The existing manifest is untouched (its branchId is not clobbered).
const manifest = await readPreviewManifest(tmpBase, 'feat-likes');
expect(manifest?.branchId).toBe('old-branch');
exitSpy.mockRestore();
});
});
142 changes: 142 additions & 0 deletions src/commands/preview/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { Command } from 'commander';
import path from 'node:path';
import { existsSync, copyFileSync } from 'node:fs';
import { createBranchApi, getBranchApi, deleteBranchApi } from '../../lib/api/platform.js';
import { CLIError, getRootOpts, handleError } from '../../lib/errors.js';
import { requireAuth } from '../../lib/credentials.js';
import { getProjectConfig } from '../../lib/config.js';
import { outputJson, outputInfo } from '../../lib/output.js';
import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js';
import { writePreviewManifest, readPreviewManifest, assertSafeName } from '../../lib/preview-manifest.js';
import { overwriteEnvFile } from '../../lib/env-writer.js';
import type { Branch } from '../../types.js';

const POLL_INTERVAL_MS = 3_000;
const POLL_TIMEOUT_MS = 5 * 60 * 1_000;

export function registerPreviewCreateCommand(preview: Command): void {
preview
.command('create <name>')
.description('Create an isolated full-stack preview environment (experimental)')
.option(
'--wire-env [file]',
'Point a frontend env file at the branch backend (default .env.local)',
)
.action(async (name: string, opts: { wireEnv?: string | boolean }, cmd) => {
const { json, apiUrl } = getRootOpts(cmd);
try {
await requireAuth(apiUrl);
const project = getProjectConfig();
if (!project) {
throw new CLIError('No project linked. Run `insforge link` first.');
}
if (project.branched_from) {
throw new CLIError(
'This directory is on a branch. Switch to the parent before creating a preview.',
);
}

try {
assertSafeName(name);
} catch (e) {
throw new CLIError(e instanceof Error ? e.message : String(e));
}

if (await readPreviewManifest(process.cwd(), name)) {
throw new CLIError(
`A preview named '${name}' already exists. Tear it down first: insforge preview teardown ${name}`,
);
}

const created = await createBranchApi(project.project_id, { mode: 'full', name }, apiUrl);
captureEvent(project.project_id, 'cli_preview_create', {
mode: 'full',
parent_project_id: project.project_id,
});

let ready: Branch;
try {
ready = await pollUntilReady(created.id, apiUrl);
} catch (pollErr) {
try {
await deleteBranchApi(created.id, apiUrl);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
} catch {
// Best effort — fall through to the actionable error below.
}
const detail = pollErr instanceof Error ? pollErr.message : String(pollErr);
throw new CLIError(
`Preview '${name}' did not become ready: ${detail}. ` +
`If the branch still exists, remove it with: insforge branch delete ${name}`,
);
}

const previewUrl = `https://${ready.appkey}.${ready.region}.insforge.app`;

await writePreviewManifest(process.cwd(), {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
name,
branchId: ready.id,
appkey: ready.appkey,
createdAt: ready.branch_created_at,
});

let wiredEnvFile: string | undefined;
if (opts.wireEnv) {
const envFile: string = typeof opts.wireEnv === 'string' ? opts.wireEnv : '.env.local';
wiredEnvFile = envFile;
const envPath = path.resolve(process.cwd(), envFile);
// Back up an existing file so teardown can restore it. If the file
// doesn't exist, `overwriteEnvFile` creates it — record that so
// teardown deletes our creation instead of looking for a backup.
const envExisted = existsSync(envPath);
if (envExisted && !existsSync(envPath + '.preview-bak')) {
copyFileSync(envPath, envPath + '.preview-bak');
}
overwriteEnvFile(envPath, { NEXT_PUBLIC_INSFORGE_URL: previewUrl });

await writePreviewManifest(process.cwd(), {
name,
branchId: ready.id,
appkey: ready.appkey,
createdAt: ready.branch_created_at,
wiredEnvFile,
...(envExisted ? {} : { wiredEnvCreated: true }),
});
}

if (json) {
outputJson({ preview: { name, branchId: ready.id, appkey: ready.appkey, url: previewUrl } });
} else {
outputInfo(`Preview '${name}' ready.`);
outputInfo(` Backend URL: ${previewUrl}`);
if (wiredEnvFile) {
outputInfo(
` Wired ${wiredEnvFile}: NEXT_PUBLIC_INSFORGE_URL -> branch backend (backup: ${wiredEnvFile}.preview-bak)`,
);
}
if (!wiredEnvFile) {
outputInfo(` Point your frontend at this backend (set NEXT_PUBLIC_INSFORGE_URL), then verify.`);
}
outputInfo(` Tear down when done: insforge preview teardown ${name}`);
}
} catch (err) {
handleError(err, json);
} finally {
await shutdownAnalytics();
}
});
}

async function pollUntilReady(branchId: string, apiUrl: string | undefined): Promise<Branch> {
const start = Date.now();
while (Date.now() - start < POLL_TIMEOUT_MS) {
const branch = await getBranchApi(branchId, apiUrl);
if (branch.branch_state === 'ready') return branch;
if (branch.branch_state === 'deleted' || branch.branch_state === 'conflicted') {
throw new CLIError(`Preview creation failed (state: ${branch.branch_state})`);
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
const branch = await getBranchApi(branchId, apiUrl);
if (branch.branch_state === 'ready') return branch;
throw new CLIError('Preview creation timed out.');
}
21 changes: 21 additions & 0 deletions src/commands/preview/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerPreviewCommands } from './index.js';

describe('registerPreviewCommands', () => {
it('registers `preview` as a usable command', () => {
const program = new Command();
registerPreviewCommands(program);
const found = program.commands.find((c) => c.name() === 'preview');
expect(found).toBeDefined();
});

it('hides `preview` from help output (behavior, not internals)', () => {
const program = new Command();
program.name('insforge');
registerPreviewCommands(program);
// Assert observable behavior — hidden commands are excluded from help —
// rather than Commander's private `_hidden` field, which can change.
expect(program.helpInformation()).not.toContain('preview');
});
});
12 changes: 12 additions & 0 deletions src/commands/preview/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// src/commands/preview/index.ts
import type { Command } from 'commander';
import { registerPreviewCreateCommand } from './create.js';
import { registerPreviewTeardownCommand } from './teardown.js';

export function registerPreviewCommands(program: Command): void {
const preview = program
.command('preview', { hidden: true })
.description('[experimental] Isolated full-stack preview environments');
registerPreviewCreateCommand(preview);
registerPreviewTeardownCommand(preview);
}
Loading
Loading