From 5108b22767aae84908ecaf3a842ca48a3f885c74 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 12 Jun 2026 15:23:19 -0700 Subject: [PATCH 01/12] feat(preview): experimental preview commands for isolated full-stack e2e environments --- src/commands/preview/create.test.ts | 91 +++++++++++++++++++++++++ src/commands/preview/create.ts | 95 +++++++++++++++++++++++++++ src/commands/preview/index.test.ts | 14 ++++ src/commands/preview/index.ts | 12 ++++ src/commands/preview/teardown.test.ts | 72 ++++++++++++++++++++ src/commands/preview/teardown.ts | 44 +++++++++++++ src/index.ts | 4 ++ src/lib/env-writer.overwrite.test.ts | 61 +++++++++++++++++ src/lib/env-writer.ts | 36 ++++++++++ src/lib/preview-manifest.test.ts | 49 ++++++++++++++ src/lib/preview-manifest.ts | 47 +++++++++++++ 11 files changed, 525 insertions(+) create mode 100644 src/commands/preview/create.test.ts create mode 100644 src/commands/preview/create.ts create mode 100644 src/commands/preview/index.test.ts create mode 100644 src/commands/preview/index.ts create mode 100644 src/commands/preview/teardown.test.ts create mode 100644 src/commands/preview/teardown.ts create mode 100644 src/lib/env-writer.overwrite.test.ts create mode 100644 src/lib/preview-manifest.test.ts create mode 100644 src/lib/preview-manifest.ts diff --git a/src/commands/preview/create.test.ts b/src/commands/preview/create.test.ts new file mode 100644 index 0000000..7f13aa4 --- /dev/null +++ b/src/commands/preview/create.test.ts @@ -0,0 +1,91 @@ +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 } from '../../lib/preview-manifest.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' }, + })), +})); +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 () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-cmd-')); + vi.spyOn(process, 'cwd').mockReturnValue(tmpBase); + }); + + 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); + }); +}); diff --git a/src/commands/preview/create.ts b/src/commands/preview/create.ts new file mode 100644 index 0000000..5af6869 --- /dev/null +++ b/src/commands/preview/create.ts @@ -0,0 +1,95 @@ +import type { Command } from 'commander'; +import path from 'node:path'; +import { existsSync, copyFileSync } from 'node:fs'; +import { createBranchApi, getBranchApi } 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 } 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 ') + .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, 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.', + ); + } + + const created = await createBranchApi(project.project_id, { mode: 'full', name }, apiUrl); + captureEvent(project.project_id, 'cli_preview_create', { name }); + const ready = await pollUntilReady(created.id, apiUrl); + + const previewUrl = `https://${ready.appkey}.${ready.region}.insforge.app`; + + let wiredEnvFile: string | undefined; + if (opts.wireEnv) { + wiredEnvFile = typeof opts.wireEnv === 'string' ? opts.wireEnv : '.env.local'; + const envPath = path.resolve(process.cwd(), wiredEnvFile); + if (existsSync(envPath)) { + 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 ? { wiredEnvFile } : {}), + }); + + 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)`, + ); + } + outputInfo(` Point your frontend at this backend, then run:`); + outputInfo(` insforge preview test ${name}`); + } + } catch (err) { + handleError(err, json); + } finally { + await shutdownAnalytics(); + } + }); +} + +async function pollUntilReady(branchId: string, apiUrl: string | undefined): Promise { + 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)); + } + throw new CLIError('Preview creation timed out.'); +} diff --git a/src/commands/preview/index.test.ts b/src/commands/preview/index.test.ts new file mode 100644 index 0000000..d32aaba --- /dev/null +++ b/src/commands/preview/index.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerPreviewCommands } from './index.js'; + +describe('registerPreviewCommands', () => { + it('registers a hidden `preview` command group', () => { + const program = new Command(); + registerPreviewCommands(program); + const found = program.commands.find((c) => c.name() === 'preview'); + expect(found).toBeDefined(); + // hidden commands are excluded from help output + expect((found as unknown as { _hidden?: boolean })._hidden).toBe(true); + }); +}); diff --git a/src/commands/preview/index.ts b/src/commands/preview/index.ts new file mode 100644 index 0000000..0317b1f --- /dev/null +++ b/src/commands/preview/index.ts @@ -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); +} diff --git a/src/commands/preview/teardown.test.ts b/src/commands/preview/teardown.test.ts new file mode 100644 index 0000000..8834131 --- /dev/null +++ b/src/commands/preview/teardown.test.ts @@ -0,0 +1,72 @@ +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 { registerPreviewTeardownCommand } from './teardown.js'; +import { writePreviewManifest, readPreviewManifest } from '../../lib/preview-manifest.js'; +import { deleteBranchApi } from '../../lib/api/platform.js'; + +vi.mock('../../lib/api/platform.js', () => ({ 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; + +describe('preview teardown', () => { + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-td-')); + vi.spyOn(process, 'cwd').mockReturnValue(tmpBase); + await writePreviewManifest(tmpBase, { + name: 'feat-likes', + branchId: 'branch-123', + appkey: 'p1ky-x9p', + createdAt: '2026-06-10T00:00:00.000Z', + }); + }); + + it('deletes the branch and removes the manifest', async () => { + const program = new Command(); + program.exitOverride(); + const preview = program.command('preview'); + registerPreviewTeardownCommand(preview); + await program.parseAsync(['preview', 'teardown', 'feat-likes'], { from: 'user' }); + + expect(deleteBranchApi).toHaveBeenCalledWith('branch-123', undefined); + expect(await readPreviewManifest(tmpBase, 'feat-likes')).toBeNull(); + }); + + it('restores the wired env file from its backup', async () => { + const envName = '.env.custom'; + const envPath = path.join(tmpBase, envName); + await fs.writeFile( + envPath, + 'NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app\n', + ); + await fs.writeFile( + envPath + '.preview-bak', + 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app\n', + ); + await writePreviewManifest(tmpBase, { + name: 'feat-wired', + branchId: 'branch-456', + appkey: 'p1ky-x9p', + createdAt: '2026-06-10T00:00:00.000Z', + wiredEnvFile: envName, + }); + + const program = new Command(); + program.exitOverride(); + const preview = program.command('preview'); + registerPreviewTeardownCommand(preview); + await program.parseAsync(['preview', 'teardown', 'feat-wired'], { from: 'user' }); + + const restored = await fs.readFile(envPath, 'utf-8'); + expect(restored).toContain('NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app'); + await expect(fs.access(envPath + '.preview-bak')).rejects.toThrow(); + expect(await readPreviewManifest(tmpBase, 'feat-wired')).toBeNull(); + }); +}); diff --git a/src/commands/preview/teardown.ts b/src/commands/preview/teardown.ts new file mode 100644 index 0000000..8cf9f29 --- /dev/null +++ b/src/commands/preview/teardown.ts @@ -0,0 +1,44 @@ +import type { Command } from 'commander'; +import path from 'node:path'; +import { existsSync, copyFileSync, rmSync } from 'node:fs'; +import { deleteBranchApi } from '../../lib/api/platform.js'; +import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { outputInfo, outputJson } from '../../lib/output.js'; +import { shutdownAnalytics } from '../../lib/analytics.js'; +import { readPreviewManifest, deletePreviewManifest } from '../../lib/preview-manifest.js'; + +export function registerPreviewTeardownCommand(preview: Command): void { + preview + .command('teardown ') + .description('Delete a preview environment created by `preview create` (experimental)') + .action(async (name: string, _opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(apiUrl); + const manifest = await readPreviewManifest(process.cwd(), name); + if (!manifest) { + throw new CLIError(`No preview named '${name}' found in this directory.`); + } + await deleteBranchApi(manifest.branchId, apiUrl); + + if (manifest.wiredEnvFile) { + const envPath = path.resolve(process.cwd(), manifest.wiredEnvFile); + const backupPath = envPath + '.preview-bak'; + if (existsSync(backupPath)) { + copyFileSync(backupPath, envPath); + rmSync(backupPath, { force: true }); + outputInfo(` Restored ${manifest.wiredEnvFile} from backup.`); + } + } + + await deletePreviewManifest(process.cwd(), name); + if (json) outputJson({ teardown: { name, ok: true } }); + else outputInfo(`Preview '${name}' torn down.`); + } catch (err) { + handleError(err, json); + } finally { + await shutdownAnalytics(); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 4bac25b..40e79b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { registerWhoamiCommand } from './commands/whoami.js'; import { registerOrgsCommands } from './commands/orgs/list.js'; import { registerProjectsCommands } from './commands/projects/list.js'; import { registerBranchCommands } from './commands/branch/index.js'; +import { registerPreviewCommands } from './commands/preview/index.js'; import { registerProjectLinkCommand } from './commands/projects/link.js'; import { registerDbCommands } from './commands/db/query.js'; import { registerDbTablesCommand } from './commands/db/tables.js'; @@ -125,6 +126,9 @@ registerProjectsCommands(projectsCmd); // Branch commands registerBranchCommands(program); +// Preview commands (experimental, hidden from --help) +registerPreviewCommands(program); + // Database commands const dbCmd = program.command('db').description('Database operations'); registerDbCommands(dbCmd); diff --git a/src/lib/env-writer.overwrite.test.ts b/src/lib/env-writer.overwrite.test.ts new file mode 100644 index 0000000..0e3f4f5 --- /dev/null +++ b/src/lib/env-writer.overwrite.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { overwriteEnvFile } from './env-writer.js'; + +describe('overwriteEnvFile', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), 'env-overwrite-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("overwrites an existing key's value", async () => { + const file = path.join(dir, '.env'); + await writeFile(file, 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app\n'); + const res = overwriteEnvFile(file, { + NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app', + }); + expect(res.changed).toEqual(['NEXT_PUBLIC_INSFORGE_URL']); + expect(res.added).toEqual([]); + const content = await readFile(file, 'utf-8'); + expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); + expect(content).not.toContain('prod.insforge.app'); + }); + + it('appends a key that is absent', async () => { + const file = path.join(dir, '.env'); + await writeFile(file, 'EXISTING=1\n'); + const res = overwriteEnvFile(file, { + NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app', + }); + expect(res.added).toEqual(['NEXT_PUBLIC_INSFORGE_URL']); + expect(res.changed).toEqual([]); + const content = await readFile(file, 'utf-8'); + expect(content).toContain('EXISTING=1'); + expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); + }); + + it('leaves unrelated lines and comments intact', async () => { + const file = path.join(dir, '.env'); + const original = [ + '# leading comment', + 'OTHER_KEY=keepme', + 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app', + '# trailing comment', + '', + ].join('\n'); + await writeFile(file, original); + overwriteEnvFile(file, { + NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app', + }); + const content = await readFile(file, 'utf-8'); + expect(content).toContain('# leading comment'); + expect(content).toContain('OTHER_KEY=keepme'); + expect(content).toContain('# trailing comment'); + expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); + }); +}); diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 459a174..5fbf559 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -78,3 +78,39 @@ export function upsertEnvFile( return result; } + +/** + * Overwrite (or append) env vars in-place. Unlike upsertEnvFile, this REPLACES + * the value of keys that already exist. Used to repoint a frontend at a branch + * preview backend. Returns which keys were changed vs newly added. + */ +export function overwriteEnvFile( + path: string, + entries: Record, +): { changed: string[]; added: string[] } { + const exists = existsSync(path); + let content = exists ? readFileSync(path, 'utf-8') : ''; + const changed: string[] = []; + const added: string[] = []; + const additions: string[] = []; + + for (const [key, value] of Object.entries(entries)) { + const re = KEY_LINE_RE(key); + if (re.test(content)) { + content = content.replace(re, `${key}=${value}`); + changed.push(key); + } else { + additions.push(`${key}=${value}`); + added.push(key); + } + } + + if (additions.length > 0) { + if (content.length > 0 && !content.endsWith('\n')) content += '\n'; + content += additions.join('\n') + '\n'; + } + if (changed.length > 0 || additions.length > 0) { + writeFileSync(path, content); + } + return { changed, added }; +} diff --git a/src/lib/preview-manifest.test.ts b/src/lib/preview-manifest.test.ts new file mode 100644 index 0000000..f34fbb8 --- /dev/null +++ b/src/lib/preview-manifest.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + writePreviewManifest, + readPreviewManifest, + deletePreviewManifest, + type PreviewManifest, +} from './preview-manifest.js'; + +describe('preview-manifest', () => { + let dir: string; + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-test-')); + }); + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('round-trips a manifest by name', async () => { + const manifest: PreviewManifest = { + name: 'feat-likes', + branchId: 'branch-123', + appkey: 'p1ky-x9p', + createdAt: '2026-06-10T00:00:00.000Z', + }; + await writePreviewManifest(dir, manifest); + const read = await readPreviewManifest(dir, 'feat-likes'); + expect(read).toEqual(manifest); + }); + + it('returns null for a missing manifest', async () => { + const read = await readPreviewManifest(dir, 'nope'); + expect(read).toBeNull(); + }); + + it('deletes a manifest', async () => { + const manifest: PreviewManifest = { + name: 'feat-likes', + branchId: 'branch-123', + appkey: 'p1ky-x9p', + createdAt: '2026-06-10T00:00:00.000Z', + }; + await writePreviewManifest(dir, manifest); + await deletePreviewManifest(dir, 'feat-likes'); + expect(await readPreviewManifest(dir, 'feat-likes')).toBeNull(); + }); +}); diff --git a/src/lib/preview-manifest.ts b/src/lib/preview-manifest.ts new file mode 100644 index 0000000..cc4eded --- /dev/null +++ b/src/lib/preview-manifest.ts @@ -0,0 +1,47 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +export interface PreviewManifest { + name: string; + branchId: string; + appkey: string; + createdAt: string; + wiredEnvFile?: string; +} + +function previewDir(baseDir: string): string { + return path.join(baseDir, '.insforge', 'previews'); +} + +function manifestPath(baseDir: string, name: string): string { + return path.join(previewDir(baseDir), `${name}.json`); +} + +export async function writePreviewManifest( + baseDir: string, + manifest: PreviewManifest, +): Promise { + await fs.mkdir(previewDir(baseDir), { recursive: true }); + await fs.writeFile( + manifestPath(baseDir, manifest.name), + JSON.stringify(manifest, null, 2), + 'utf8', + ); +} + +export async function readPreviewManifest( + baseDir: string, + name: string, +): Promise { + try { + const raw = await fs.readFile(manifestPath(baseDir, name), 'utf8'); + return JSON.parse(raw) as PreviewManifest; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +export async function deletePreviewManifest(baseDir: string, name: string): Promise { + await fs.rm(manifestPath(baseDir, name), { force: true }); +} From 16f06f113c56ec2c33e3b815bc27357b5cdfd42d Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 12 Jun 2026 16:24:14 -0700 Subject: [PATCH 02/12] =?UTF-8?q?fix(preview):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20literal=20env=20values,=20early=20manifest,=20safe?= =?UTF-8?q?=20names,=20resilient=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/preview/create.ts | 41 ++++++++++++++++++--------- src/commands/preview/teardown.test.ts | 25 ++++++++++++++++ src/commands/preview/teardown.ts | 25 ++++++++++++---- src/lib/env-writer.overwrite.test.ts | 10 +++++++ src/lib/env-writer.ts | 2 +- src/lib/preview-manifest.ts | 8 ++++++ 6 files changed, 91 insertions(+), 20 deletions(-) diff --git a/src/commands/preview/create.ts b/src/commands/preview/create.ts index 5af6869..14a0840 100644 --- a/src/commands/preview/create.ts +++ b/src/commands/preview/create.ts @@ -42,24 +42,37 @@ export function registerPreviewCreateCommand(preview: Command): void { const previewUrl = `https://${ready.appkey}.${ready.region}.insforge.app`; - let wiredEnvFile: string | undefined; - if (opts.wireEnv) { - wiredEnvFile = typeof opts.wireEnv === 'string' ? opts.wireEnv : '.env.local'; - const envPath = path.resolve(process.cwd(), wiredEnvFile); - if (existsSync(envPath)) { - 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 ? { wiredEnvFile } : {}), }); + 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) { + 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 { @@ -70,8 +83,10 @@ export function registerPreviewCreateCommand(preview: Command): void { ` Wired ${wiredEnvFile}: NEXT_PUBLIC_INSFORGE_URL -> branch backend (backup: ${wiredEnvFile}.preview-bak)`, ); } - outputInfo(` Point your frontend at this backend, then run:`); - outputInfo(` insforge preview test ${name}`); + 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); diff --git a/src/commands/preview/teardown.test.ts b/src/commands/preview/teardown.test.ts index 8834131..4810d5f 100644 --- a/src/commands/preview/teardown.test.ts +++ b/src/commands/preview/teardown.test.ts @@ -69,4 +69,29 @@ describe('preview teardown', () => { await expect(fs.access(envPath + '.preview-bak')).rejects.toThrow(); expect(await readPreviewManifest(tmpBase, 'feat-wired')).toBeNull(); }); + + it('deletes an env file that --wire-env created (no backup to restore)', async () => { + const envName = '.env.local'; + const envPath = path.join(tmpBase, envName); + // The file exists (preview created it) but there is no .preview-bak. + await fs.writeFile(envPath, 'NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app\n'); + await writePreviewManifest(tmpBase, { + name: 'feat-created', + branchId: 'branch-789', + appkey: 'p1ky-x9p', + createdAt: '2026-06-10T00:00:00.000Z', + wiredEnvFile: envName, + wiredEnvCreated: true, + }); + + const program = new Command(); + program.exitOverride(); + const preview = program.command('preview'); + registerPreviewTeardownCommand(preview); + await program.parseAsync(['preview', 'teardown', 'feat-created'], { from: 'user' }); + + // The created env file is removed, not left pointing at the deleted branch. + await expect(fs.access(envPath)).rejects.toThrow(); + expect(await readPreviewManifest(tmpBase, 'feat-created')).toBeNull(); + }); }); diff --git a/src/commands/preview/teardown.ts b/src/commands/preview/teardown.ts index 8cf9f29..385df34 100644 --- a/src/commands/preview/teardown.ts +++ b/src/commands/preview/teardown.ts @@ -22,13 +22,26 @@ export function registerPreviewTeardownCommand(preview: Command): void { } await deleteBranchApi(manifest.branchId, apiUrl); + // The branch is now gone (irreversible). Finish local cleanup defensively + // so a failure restoring the env file never aborts before the manifest is + // removed — otherwise the manifest would keep pointing at a deleted branch. if (manifest.wiredEnvFile) { - const envPath = path.resolve(process.cwd(), manifest.wiredEnvFile); - const backupPath = envPath + '.preview-bak'; - if (existsSync(backupPath)) { - copyFileSync(backupPath, envPath); - rmSync(backupPath, { force: true }); - outputInfo(` Restored ${manifest.wiredEnvFile} from backup.`); + try { + const envPath = path.resolve(process.cwd(), manifest.wiredEnvFile); + const backupPath = envPath + '.preview-bak'; + if (manifest.wiredEnvCreated) { + // We created this file during `--wire-env`; remove it rather than + // leave it pointing at a deleted preview backend. + rmSync(envPath, { force: true }); + outputInfo(` Removed ${manifest.wiredEnvFile} (created by preview).`); + } else if (existsSync(backupPath)) { + copyFileSync(backupPath, envPath); + rmSync(backupPath, { force: true }); + outputInfo(` Restored ${manifest.wiredEnvFile} from backup.`); + } + } catch (envErr) { + const msg = envErr instanceof Error ? envErr.message : String(envErr); + outputInfo(` ⚠ Could not restore ${manifest.wiredEnvFile} (${msg}). Restore it manually.`); } } diff --git a/src/lib/env-writer.overwrite.test.ts b/src/lib/env-writer.overwrite.test.ts index 0e3f4f5..0e43918 100644 --- a/src/lib/env-writer.overwrite.test.ts +++ b/src/lib/env-writer.overwrite.test.ts @@ -58,4 +58,14 @@ describe('overwriteEnvFile', () => { expect(content).toContain('# trailing comment'); expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); }); + + it('writes values containing $ literally (no String.replace special patterns)', async () => { + const file = path.join(dir, '.env'); + await writeFile(file, 'NEXT_PUBLIC_INSFORGE_URL=https://old.example.com\n'); + const tricky = 'https://x.app/?a=$1&b=$&c=$`'; + overwriteEnvFile(file, { NEXT_PUBLIC_INSFORGE_URL: tricky }); + const out = await readFile(file, 'utf8'); + expect(out).toContain(`NEXT_PUBLIC_INSFORGE_URL=${tricky}`); + }); + }); diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 5fbf559..38bdc52 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -97,7 +97,7 @@ export function overwriteEnvFile( for (const [key, value] of Object.entries(entries)) { const re = KEY_LINE_RE(key); if (re.test(content)) { - content = content.replace(re, `${key}=${value}`); + content = content.replace(re, () => `${key}=${value}`); changed.push(key); } else { additions.push(`${key}=${value}`); diff --git a/src/lib/preview-manifest.ts b/src/lib/preview-manifest.ts index cc4eded..4ea7244 100644 --- a/src/lib/preview-manifest.ts +++ b/src/lib/preview-manifest.ts @@ -7,13 +7,21 @@ export interface PreviewManifest { appkey: string; createdAt: string; wiredEnvFile?: string; + wiredEnvCreated?: boolean; } function previewDir(baseDir: string): string { return path.join(baseDir, '.insforge', 'previews'); } +function assertSafeName(name: string): void { + if (!/^[A-Za-z0-9._-]+$/.test(name)) { + throw new Error(`Invalid preview name '${name}': use only letters, digits, '.', '_', '-'.`); + } +} + function manifestPath(baseDir: string, name: string): string { + assertSafeName(name); return path.join(previewDir(baseDir), `${name}.json`); } From f64f000b31a529664639b32ac365d32e2546b02c Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 12 Jun 2026 16:49:33 -0700 Subject: [PATCH 03/12] fix(preview): cleanup branch on poll failure, overwrite all duplicate keys, assert hidden via help --- src/commands/preview/create.ts | 19 +++++++++++++++++-- src/commands/preview/index.test.ts | 13 ++++++++++--- src/lib/env-writer.overwrite.test.ts | 16 ++++++++++++++++ src/lib/env-writer.ts | 3 ++- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/commands/preview/create.ts b/src/commands/preview/create.ts index 14a0840..a4cd457 100644 --- a/src/commands/preview/create.ts +++ b/src/commands/preview/create.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander'; import path from 'node:path'; import { existsSync, copyFileSync } from 'node:fs'; -import { createBranchApi, getBranchApi } from '../../lib/api/platform.js'; +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'; @@ -38,7 +38,22 @@ export function registerPreviewCreateCommand(preview: Command): void { const created = await createBranchApi(project.project_id, { mode: 'full', name }, apiUrl); captureEvent(project.project_id, 'cli_preview_create', { name }); - const ready = await pollUntilReady(created.id, apiUrl); + + let ready: Branch; + try { + ready = await pollUntilReady(created.id, apiUrl); + } catch (pollErr) { + try { + await deleteBranchApi(created.id, apiUrl); + } 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`; diff --git a/src/commands/preview/index.test.ts b/src/commands/preview/index.test.ts index d32aaba..18baa18 100644 --- a/src/commands/preview/index.test.ts +++ b/src/commands/preview/index.test.ts @@ -3,12 +3,19 @@ import { Command } from 'commander'; import { registerPreviewCommands } from './index.js'; describe('registerPreviewCommands', () => { - it('registers a hidden `preview` command group', () => { + 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(); - // hidden commands are excluded from help output - expect((found as unknown as { _hidden?: boolean })._hidden).toBe(true); + }); + + 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'); }); }); diff --git a/src/lib/env-writer.overwrite.test.ts b/src/lib/env-writer.overwrite.test.ts index 0e43918..0c8c607 100644 --- a/src/lib/env-writer.overwrite.test.ts +++ b/src/lib/env-writer.overwrite.test.ts @@ -68,4 +68,20 @@ describe('overwriteEnvFile', () => { expect(out).toContain(`NEXT_PUBLIC_INSFORGE_URL=${tricky}`); }); + + it('rewrites ALL occurrences of a duplicated key', async () => { + const file = path.join(dir, '.env'); + await writeFile(file, [ + 'NEXT_PUBLIC_INSFORGE_URL=https://a.example.com', + 'OTHER=keep', + 'NEXT_PUBLIC_INSFORGE_URL=https://b.example.com', + ].join('\n') + '\n'); + overwriteEnvFile(file, { NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app' }); + const out = await readFile(file, 'utf8'); + expect(out.match(/NEXT_PUBLIC_INSFORGE_URL=https:\/\/branch\.insforge\.app/g)?.length).toBe(2); + expect(out).not.toContain('a.example.com'); + expect(out).not.toContain('b.example.com'); + expect(out).toContain('OTHER=keep'); + }); + }); diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 38bdc52..06c5997 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -95,8 +95,9 @@ export function overwriteEnvFile( const additions: string[] = []; for (const [key, value] of Object.entries(entries)) { - const re = KEY_LINE_RE(key); + const re = new RegExp(KEY_LINE_RE(key).source, 'gm'); if (re.test(content)) { + re.lastIndex = 0; content = content.replace(re, () => `${key}=${value}`); changed.push(key); } else { From aa43a5bb5e2cec95f8221e682e4778c2a9088611 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 12 Jun 2026 17:39:16 -0700 Subject: [PATCH 04/12] fix(preview): validate name before provisioning, guard duplicate creates, tolerate 404 on teardown, drop name from analytics --- src/commands/preview/create.test.ts | 75 ++++++++++++++++++++++++++- src/commands/preview/create.ts | 23 ++++++-- src/commands/preview/teardown.test.ts | 2 +- src/commands/preview/teardown.ts | 4 +- src/lib/api/platform.ts | 12 ++++- src/lib/preview-manifest.test.ts | 12 +++++ src/lib/preview-manifest.ts | 2 +- 7 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/commands/preview/create.test.ts b/src/commands/preview/create.test.ts index 7f13aa4..fe116d1 100644 --- a/src/commands/preview/create.test.ts +++ b/src/commands/preview/create.test.ts @@ -4,7 +4,8 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { registerPreviewCreateCommand } from './create.js'; -import { readPreviewManifest } from '../../lib/preview-manifest.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 }) => ({ @@ -29,6 +30,7 @@ vi.mock('../../lib/api/platform.js', () => ({ 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', () => ({ @@ -43,6 +45,7 @@ vi.mock('../../lib/config.js', () => ({ describe('preview create', () => { beforeEach(async () => { + vi.clearAllMocks(); tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-cmd-')); vi.spyOn(process, 'cwd').mockReturnValue(tmpBase); }); @@ -88,4 +91,74 @@ describe('preview create', () => { const manifest = await readPreviewManifest(tmpBase, 'feat-likes'); expect(manifest?.wiredEnvFile).toBe(envFile); }); + + 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>); + 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(); + }); }); diff --git a/src/commands/preview/create.ts b/src/commands/preview/create.ts index a4cd457..e5d52ea 100644 --- a/src/commands/preview/create.ts +++ b/src/commands/preview/create.ts @@ -7,7 +7,7 @@ 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 } from '../../lib/preview-manifest.js'; +import { writePreviewManifest, readPreviewManifest, assertSafeName } from '../../lib/preview-manifest.js'; import { overwriteEnvFile } from '../../lib/env-writer.js'; import type { Branch } from '../../types.js'; @@ -36,8 +36,23 @@ export function registerPreviewCreateCommand(preview: Command): void { ); } + 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', { name }); + captureEvent(project.project_id, 'cli_preview_create', { + mode: 'full', + parent_project_id: project.project_id, + }); let ready: Branch; try { @@ -73,7 +88,7 @@ export function registerPreviewCreateCommand(preview: Command): void { // 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) { + if (envExisted && !existsSync(envPath + '.preview-bak')) { copyFileSync(envPath, envPath + '.preview-bak'); } overwriteEnvFile(envPath, { NEXT_PUBLIC_INSFORGE_URL: previewUrl }); @@ -121,5 +136,7 @@ async function pollUntilReady(branchId: string, apiUrl: string | undefined): Pro } 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.'); } diff --git a/src/commands/preview/teardown.test.ts b/src/commands/preview/teardown.test.ts index 4810d5f..7b1c22e 100644 --- a/src/commands/preview/teardown.test.ts +++ b/src/commands/preview/teardown.test.ts @@ -35,7 +35,7 @@ describe('preview teardown', () => { registerPreviewTeardownCommand(preview); await program.parseAsync(['preview', 'teardown', 'feat-likes'], { from: 'user' }); - expect(deleteBranchApi).toHaveBeenCalledWith('branch-123', undefined); + expect(deleteBranchApi).toHaveBeenCalledWith('branch-123', undefined, { ignoreNotFound: true }); expect(await readPreviewManifest(tmpBase, 'feat-likes')).toBeNull(); }); diff --git a/src/commands/preview/teardown.ts b/src/commands/preview/teardown.ts index 385df34..797f93a 100644 --- a/src/commands/preview/teardown.ts +++ b/src/commands/preview/teardown.ts @@ -20,7 +20,9 @@ export function registerPreviewTeardownCommand(preview: Command): void { if (!manifest) { throw new CLIError(`No preview named '${name}' found in this directory.`); } - await deleteBranchApi(manifest.branchId, apiUrl); + // Tolerate a 404 — if the branch was already deleted by other means, we + // still want to clean up the local manifest + wired env file. + await deleteBranchApi(manifest.branchId, apiUrl, { ignoreNotFound: true }); // The branch is now gone (irreversible). Finish local cleanup defensively // so a failure restoring the env file never aborts before the manifest is diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index a564216..c2cd4c6 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -312,8 +312,16 @@ export async function getBranchApi(branchId: string, apiUrl?: string): Promise { - await platformFetch(`/projects/v1/branches/${branchId}`, { method: 'DELETE' }, apiUrl); +export async function deleteBranchApi( + branchId: string, + apiUrl?: string, + opts?: { ignoreNotFound?: boolean }, +): Promise { + await platformFetch( + `/projects/v1/branches/${branchId}`, + { method: 'DELETE', ...(opts?.ignoreNotFound ? { passThroughStatuses: [404] } : {}) }, + apiUrl, + ); } /** diff --git a/src/lib/preview-manifest.test.ts b/src/lib/preview-manifest.test.ts index f34fbb8..62a89e0 100644 --- a/src/lib/preview-manifest.test.ts +++ b/src/lib/preview-manifest.test.ts @@ -7,6 +7,7 @@ import { readPreviewManifest, deletePreviewManifest, type PreviewManifest, + assertSafeName, } from './preview-manifest.js'; describe('preview-manifest', () => { @@ -46,4 +47,15 @@ describe('preview-manifest', () => { await deletePreviewManifest(dir, 'feat-likes'); expect(await readPreviewManifest(dir, 'feat-likes')).toBeNull(); }); + + describe('assertSafeName', () => { + it('accepts safe names', () => { + expect(() => assertSafeName('feat-likes_1.2')).not.toThrow(); + }); + it('rejects git-style and traversal names', () => { + expect(() => assertSafeName('feat/likes')).toThrow(/Invalid preview name/); + expect(() => assertSafeName('../escape')).toThrow(/Invalid preview name/); + }); + }); + }); diff --git a/src/lib/preview-manifest.ts b/src/lib/preview-manifest.ts index 4ea7244..a10e3d5 100644 --- a/src/lib/preview-manifest.ts +++ b/src/lib/preview-manifest.ts @@ -14,7 +14,7 @@ function previewDir(baseDir: string): string { return path.join(baseDir, '.insforge', 'previews'); } -function assertSafeName(name: string): void { +export function assertSafeName(name: string): void { if (!/^[A-Za-z0-9._-]+$/.test(name)) { throw new Error(`Invalid preview name '${name}': use only letters, digits, '.', '_', '-'.`); } From 3e1323fd900cf3d39f11d8dc5f6f923152a17550 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 12 Jun 2026 18:29:39 -0700 Subject: [PATCH 05/12] fix(preview): gate teardown output behind --json, wrap teardown name validation, type create opts --- src/commands/preview/create.ts | 2 +- src/commands/preview/teardown.test.ts | 34 +++++++++++++++++++++++++++ src/commands/preview/teardown.ts | 15 ++++++++---- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/commands/preview/create.ts b/src/commands/preview/create.ts index e5d52ea..b1ec914 100644 --- a/src/commands/preview/create.ts +++ b/src/commands/preview/create.ts @@ -22,7 +22,7 @@ export function registerPreviewCreateCommand(preview: Command): void { '--wire-env [file]', 'Point a frontend env file at the branch backend (default .env.local)', ) - .action(async (name: string, opts, cmd) => { + .action(async (name: string, opts: { wireEnv?: string | boolean }, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { await requireAuth(apiUrl); diff --git a/src/commands/preview/teardown.test.ts b/src/commands/preview/teardown.test.ts index 7b1c22e..d5c3069 100644 --- a/src/commands/preview/teardown.test.ts +++ b/src/commands/preview/teardown.test.ts @@ -94,4 +94,38 @@ describe('preview teardown', () => { await expect(fs.access(envPath)).rejects.toThrow(); expect(await readPreviewManifest(tmpBase, 'feat-created')).toBeNull(); }); + + it('keeps --json stdout clean of env-restore chatter', async () => { + const envName = '.env.local'; + const envPath = path.join(tmpBase, envName); + await fs.writeFile(envPath, 'NEXT_PUBLIC_INSFORGE_URL=https://branch.app\n'); + await fs.writeFile(envPath + '.preview-bak', 'NEXT_PUBLIC_INSFORGE_URL=https://prod.app\n'); + await writePreviewManifest(tmpBase, { + name: 'feat-json', + branchId: 'branch-json', + appkey: 'p1ky-x9p', + createdAt: '2026-06-10T00:00:00.000Z', + wiredEnvFile: envName, + }); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { + logs.push(String(m)); + }); + + const program = new Command(); + program.exitOverride(); + program.option('--json'); + const preview = program.command('preview'); + registerPreviewTeardownCommand(preview); + await program.parseAsync(['--json', 'preview', 'teardown', 'feat-json'], { from: 'user' }); + + logSpy.mockRestore(); + // Every line written to stdout must be valid JSON — no "Restored ..." chatter. + for (const line of logs) { + expect(() => JSON.parse(line)).not.toThrow(); + } + expect(logs.some((l) => l.includes('"teardown"'))).toBe(true); + }); + }); diff --git a/src/commands/preview/teardown.ts b/src/commands/preview/teardown.ts index 797f93a..b3eb05e 100644 --- a/src/commands/preview/teardown.ts +++ b/src/commands/preview/teardown.ts @@ -6,7 +6,7 @@ import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; import { requireAuth } from '../../lib/credentials.js'; import { outputInfo, outputJson } from '../../lib/output.js'; import { shutdownAnalytics } from '../../lib/analytics.js'; -import { readPreviewManifest, deletePreviewManifest } from '../../lib/preview-manifest.js'; +import { readPreviewManifest, deletePreviewManifest, assertSafeName } from '../../lib/preview-manifest.js'; export function registerPreviewTeardownCommand(preview: Command): void { preview @@ -16,6 +16,11 @@ export function registerPreviewTeardownCommand(preview: Command): void { const { json, apiUrl } = getRootOpts(cmd); try { await requireAuth(apiUrl); + try { + assertSafeName(name); + } catch (e) { + throw new CLIError(e instanceof Error ? e.message : String(e)); + } const manifest = await readPreviewManifest(process.cwd(), name); if (!manifest) { throw new CLIError(`No preview named '${name}' found in this directory.`); @@ -35,15 +40,17 @@ export function registerPreviewTeardownCommand(preview: Command): void { // We created this file during `--wire-env`; remove it rather than // leave it pointing at a deleted preview backend. rmSync(envPath, { force: true }); - outputInfo(` Removed ${manifest.wiredEnvFile} (created by preview).`); + if (!json) outputInfo(` Removed ${manifest.wiredEnvFile} (created by preview).`); } else if (existsSync(backupPath)) { copyFileSync(backupPath, envPath); rmSync(backupPath, { force: true }); - outputInfo(` Restored ${manifest.wiredEnvFile} from backup.`); + if (!json) outputInfo(` Restored ${manifest.wiredEnvFile} from backup.`); } } catch (envErr) { const msg = envErr instanceof Error ? envErr.message : String(envErr); - outputInfo(` ⚠ Could not restore ${manifest.wiredEnvFile} (${msg}). Restore it manually.`); + if (!json) { + outputInfo(` ⚠ Could not restore ${manifest.wiredEnvFile} (${msg}). Restore it manually.`); + } } } From 21c86899ff48e2a7542021587e265eb918bcd787 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Mon, 15 Jun 2026 22:44:33 -0700 Subject: [PATCH 06/12] feat(link): --with-test-agents installs Playwright Test Agents for insforge-verify at link time --- src/commands/projects/link.test.ts | 4 ++-- src/commands/projects/link.ts | 11 ++++----- src/lib/skills.ts | 36 +++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/commands/projects/link.test.ts b/src/commands/projects/link.test.ts index 8bdbe0f..bfbeafe 100644 --- a/src/commands/projects/link.test.ts +++ b/src/commands/projects/link.test.ts @@ -89,7 +89,7 @@ describe('project link: skills-only fast path', () => { const { requireAuth } = await import('../../lib/credentials.js'); const { listOrganizations } = await import('../../lib/api/platform.js'); - expect(installSkills).toHaveBeenCalledWith(false); + expect(installSkills).toHaveBeenCalledWith(false, undefined, false); expect(trackCommand).toHaveBeenCalledWith('link', 'skills-only', { skills_only: true }); expect(reportCliUsage).toHaveBeenCalledWith('cli.link_skills_only', true, 1); // Skills-only path must never trigger auth or the org picker. @@ -104,7 +104,7 @@ describe('project link: skills-only fast path', () => { const { installSkills } = await import('../../lib/skills.js'); const { outputJson } = await import('../../lib/output.js'); - expect(installSkills).toHaveBeenCalledWith(true); + expect(installSkills).toHaveBeenCalledWith(true, undefined, false); expect(outputJson).toHaveBeenCalledWith({ success: true, skills_only: true }); }); diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index e9bd3df..cce9fb2 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -75,6 +75,7 @@ export function registerProjectLinkCommand(program: Command): void { .option('--auth ', 'Wire a third-party auth provider into the chosen template (currently: better-auth)') .option('--api-base-url ', 'API Base URL for direct linking (OSS/Self-hosted)') .option('--api-key ', 'API Key for direct linking (OSS/Self-hosted)') + .option('--with-test-agents', 'Also install Playwright Test Agents for `insforge-verify` (restart Claude Code after)') .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); @@ -110,7 +111,7 @@ export function registerProjectLinkCommand(program: Command): void { if (isSkillsOnly) { try { - await installSkills(json); + await installSkills(json, undefined, Boolean(opts.withTestAgents)); trackCommand('link', 'skills-only', { skills_only: true }); await reportCliUsage('cli.link_skills_only', true, 1); @@ -229,7 +230,7 @@ export function registerProjectLinkCommand(program: Command): void { } } - await installSkills(json, opts.auth as string | undefined); + await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); trackCommand('link', 'oss-org', { direct: true, template }); await reportCliUsage('cli.link_direct', true, 6, projectConfig); @@ -291,7 +292,7 @@ export function registerProjectLinkCommand(program: Command): void { trackCommand('link', 'oss-org', { direct: true }); // Install agent skills - await installSkills(json, opts.auth as string | undefined); + await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); await reportCliUsage('cli.link_direct', true, 6, projectConfig); // Report agent-connected event (best-effort) @@ -475,7 +476,7 @@ export function registerProjectLinkCommand(program: Command): void { } // Install agent skills inside the project directory - await installSkills(json, opts.auth as string | undefined); + await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); await reportCliUsage('cli.link', true, 6, projectConfig); if (!json) { @@ -517,7 +518,7 @@ export function registerProjectLinkCommand(program: Command): void { } // No template — install agent skills in the current directory - await installSkills(json, opts.auth as string | undefined); + await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); await reportCliUsage('cli.link', true, 6, projectConfig); if (!json) { diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 913fcc8..0d766e7 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -84,7 +84,11 @@ const PROVIDER_SKILLS: Record = { 'better-auth': { repo: 'better-auth/skills', label: 'Better Auth skills' }, }; -export async function installSkills(json: boolean, authProvider?: string): Promise { +export async function installSkills( + json: boolean, + authProvider?: string, + withTestAgents = false, +): Promise { try { if (!json) clack.log.info('Installing InsForge agent skills (global)...'); await execAsync(`npx skills add insforge/agent-skills -g -y ${AGENT_FLAGS}`, { @@ -151,6 +155,36 @@ export async function installSkills(json: boolean, authProvider?: string): Promi } catch { // non-critical, silently ignore } + + // Opt-in: install Playwright Test Agents (planner/generator/healer) into the + // project's `.claude/agents/` so the `insforge-verify` skill can drive UI + // testing. We do this at link time — BEFORE the user's Claude Code session — + // because subagents load at session start: running `init-agents` mid-session + // leaves them unavailable ("Agent type 'playwright-test-planner' not found"). + if (withTestAgents) { + try { + if (!json) clack.log.info('Installing Playwright Test Agents (.claude/agents)...'); + await execAsync('npx playwright init-agents --loop=claude', { + cwd: process.cwd(), + timeout: SKILL_INSTALL_TIMEOUT_MS, + }); + if (!json) { + clack.log.success('Playwright Test Agents installed.'); + // The one step the CLI cannot do for the user — and the one that + // actually avoids the "not found" wall. + clack.log.warn( + 'Restart Claude Code (or start a fresh session) so the test agents load before verifying.', + ); + } + } catch (err) { + if (!json) { + clack.log.warn(`Could not install Playwright Test Agents: ${describeExecError(err)}`); + clack.log.info( + 'Ensure Playwright is available (`npm i -D @playwright/test && npx playwright install chromium`), then run `npx playwright init-agents --loop=claude`.', + ); + } + } + } } export async function reportCliUsage( From 3dd19acfe1f4da9658020b2696163648b4bca08e Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 16 Jun 2026 11:29:08 -0700 Subject: [PATCH 07/12] docs(preview): add README covering verify-loop context, flow, design decisions, gotchas --- src/commands/preview/README.md | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/commands/preview/README.md diff --git a/src/commands/preview/README.md b/src/commands/preview/README.md new file mode 100644 index 0000000..cfa010e --- /dev/null +++ b/src/commands/preview/README.md @@ -0,0 +1,96 @@ +# Preview environments (experimental) + +`insforge preview create` / `insforge preview teardown` — hidden, experimental +commands that stand up and tear down an **isolated full-stack environment** for +verifying a change before merging the branch to prod. + +A "preview" is a thin orchestration layer over existing primitives: it creates a +copy-on-write **branch** (own backend + a copy of the data), records a local +**manifest** so teardown can find and remove it, and optionally **wires a local +env file** at the branch backend. It does not invent new backend behaviour. + +## Where this fits — the verify loop + +These commands are the CLI half of the `insforge-verify` agent skill. The full +loop an agent runs: + +1. `preview create` → isolated branch backend (this command) +2. `branch switch` + apply migrations + seed verified users +3. `deployments deploy` → frontend on the branch's own https slug +4. Playwright Test Agents drive the UI +5. Backend ground-truth + cross-user RLS probes (the part only a backend platform + can do — catches "UI looks right, backend is wrong" false passes) +6. fix → re-verify +7. `preview teardown` → branch and its branch-scoped deployment go away together + +`preview create` only does step 1. **It does not deploy** — the frontend deploy is +a separate `deployments deploy` step, and it must run in branch context (see +Gotchas). + +## Commands + +| Command | Does | +| --- | --- | +| `preview create [--wire-env [file]]` | Branch the linked project, write a manifest, optionally point a local env file at the branch backend. | +| `preview teardown ` | Delete the branch, restore/remove the wired env file, drop the manifest. | + +Both are hidden (`{ hidden: true }`, like `orgs`/`projects`/`records`) while +experimental. + +## Flow + +**create:** `assertSafeName` (before anything remote) → `createBranchApi({ mode: 'full' })` +→ poll until ready → **write a baseline manifest immediately** (before any local +file mutation) → if `--wire-env`: back up the existing env file (once), rewrite +`NEXT_PUBLIC_INSFORGE_URL` to the branch backend, update the manifest with the +wired-file info. + +**teardown:** `assertSafeName` → read the manifest → `deleteBranchApi(..., { ignoreNotFound: true })` +→ restore the env file from `.preview-bak` (or delete it if `preview create` created +it) → delete the manifest. + +## Files + +| File | Role | +| --- | --- | +| `create.ts` | `preview create` action + `pollUntilReady`. | +| `teardown.ts` | `preview teardown` action (resilient local cleanup). | +| `index.ts` | Registers the hidden `preview` parent command. | +| `../../lib/preview-manifest.ts` | `PreviewManifest` type, `assertSafeName`, read/write/delete under `.insforge/previews/.json`. | +| `../../lib/env-writer.ts` | `overwriteEnvFile` — rewrites every matching `KEY=` line via a replacer function (so `$`-patterns in values are written literally). | +| `../../lib/api/platform.ts` | `deleteBranchApi(branchId, apiUrl?, { ignoreNotFound })` — passes `passThroughStatuses: [404]` when tolerating a missing branch. | + +## Key design decisions + +- **Manifest before local mutations.** The manifest is written right after the + branch is ready, before any env wiring. A crash mid-wire still leaves a manifest + that `teardown` can use — no orphaned remote branch. +- **Name validation before provisioning.** `assertSafeName` runs before + `createBranchApi`, so a git-style name like `feat/likes` is rejected up front + instead of orphaning a fully provisioned branch the manifest can't name. +- **Rollback on poll failure.** If provisioning never reaches ready, the branch is + best-effort deleted so a failed create doesn't leak a branch. +- **404-tolerant teardown.** If the branch is already gone, teardown still cleans + up the manifest and env file instead of aborting. +- **Reversible env wiring.** `--wire-env` backs up the original file to + `.preview-bak` (never overwriting an existing backup) and records + `wiredEnvCreated` when it created the file — so teardown restores the original or + removes a file we created, never stranding a pointer at a deleted backend. +- **Analytics carry no free text.** `cli_preview_create` sends only + `{ mode, parent_project_id }`, never the user-chosen name (per `DEVELOPMENT.md` §2). + +## Gotchas + +- **Deploy in branch context.** `preview create` does not deploy. Run + `deployments deploy` only after `branch switch ` — a deploy in parent + context targets the prod site and is not removed by `teardown`. +- **`preview create` ≠ a running frontend.** It gives you a backend (the branch). + The frontend is a separate deploy step. + +## Try it + +```bash +insforge preview create my-feature --wire-env # branch + point .env.local at it +# ... verify against the branch ... +insforge preview teardown my-feature # branch + env restore + manifest gone +``` From 0c743ab8e4a12c6a30746839a9cd1460498d8140 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 16 Jun 2026 13:59:08 -0700 Subject: [PATCH 08/12] fix(deployments): exclude Playwright test artifacts (test-results, playwright-report, .playwright-mcp) from deploy uploads --- src/commands/deployments/deploy.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commands/deployments/deploy.ts b/src/commands/deployments/deploy.ts index d9ab838..a0f5992 100644 --- a/src/commands/deployments/deploy.ts +++ b/src/commands/deployments/deploy.ts @@ -53,6 +53,9 @@ const EXCLUDE_PATTERNS = [ '.cache', 'skills', 'coverage', + 'test-results', + 'playwright-report', + '.playwright-mcp', IGNORE_FILE_NAME, ]; From 517a2a9130e2f4ab7f3a08ab8681780742aa1148 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 18 Jun 2026 12:06:30 -0700 Subject: [PATCH 09/12] feat(verify): light-mode verify probes, default browser MCP, drop preview commands --- src/commands/preview/README.md | 96 --------------- src/commands/preview/create.test.ts | 164 -------------------------- src/commands/preview/create.ts | 142 ---------------------- src/commands/preview/index.test.ts | 21 ---- src/commands/preview/index.ts | 12 -- src/commands/preview/teardown.test.ts | 131 -------------------- src/commands/preview/teardown.ts | 66 ----------- src/commands/projects/link.test.ts | 4 +- src/commands/projects/link.ts | 11 +- src/commands/verify/finding.ts | 48 ++++++++ src/commands/verify/index.ts | 14 +++ src/commands/verify/rls.ts | 66 +++++++++++ src/commands/verify/truth.ts | 54 +++++++++ src/index.ts | 6 +- src/lib/analytics.ts | 30 +++++ src/lib/browser-mcp.test.ts | 89 ++++++++++++++ src/lib/browser-mcp.ts | 135 +++++++++++++++++++++ src/lib/preview-manifest.test.ts | 61 ---------- src/lib/preview-manifest.ts | 55 --------- src/lib/skills.ts | 41 +++---- src/lib/verify-probe.test.ts | 50 ++++++++ src/lib/verify-probe.ts | 108 +++++++++++++++++ 22 files changed, 625 insertions(+), 779 deletions(-) delete mode 100644 src/commands/preview/README.md delete mode 100644 src/commands/preview/create.test.ts delete mode 100644 src/commands/preview/create.ts delete mode 100644 src/commands/preview/index.test.ts delete mode 100644 src/commands/preview/index.ts delete mode 100644 src/commands/preview/teardown.test.ts delete mode 100644 src/commands/preview/teardown.ts create mode 100644 src/commands/verify/finding.ts create mode 100644 src/commands/verify/index.ts create mode 100644 src/commands/verify/rls.ts create mode 100644 src/commands/verify/truth.ts create mode 100644 src/lib/browser-mcp.test.ts create mode 100644 src/lib/browser-mcp.ts delete mode 100644 src/lib/preview-manifest.test.ts delete mode 100644 src/lib/preview-manifest.ts create mode 100644 src/lib/verify-probe.test.ts create mode 100644 src/lib/verify-probe.ts diff --git a/src/commands/preview/README.md b/src/commands/preview/README.md deleted file mode 100644 index cfa010e..0000000 --- a/src/commands/preview/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# Preview environments (experimental) - -`insforge preview create` / `insforge preview teardown` — hidden, experimental -commands that stand up and tear down an **isolated full-stack environment** for -verifying a change before merging the branch to prod. - -A "preview" is a thin orchestration layer over existing primitives: it creates a -copy-on-write **branch** (own backend + a copy of the data), records a local -**manifest** so teardown can find and remove it, and optionally **wires a local -env file** at the branch backend. It does not invent new backend behaviour. - -## Where this fits — the verify loop - -These commands are the CLI half of the `insforge-verify` agent skill. The full -loop an agent runs: - -1. `preview create` → isolated branch backend (this command) -2. `branch switch` + apply migrations + seed verified users -3. `deployments deploy` → frontend on the branch's own https slug -4. Playwright Test Agents drive the UI -5. Backend ground-truth + cross-user RLS probes (the part only a backend platform - can do — catches "UI looks right, backend is wrong" false passes) -6. fix → re-verify -7. `preview teardown` → branch and its branch-scoped deployment go away together - -`preview create` only does step 1. **It does not deploy** — the frontend deploy is -a separate `deployments deploy` step, and it must run in branch context (see -Gotchas). - -## Commands - -| Command | Does | -| --- | --- | -| `preview create [--wire-env [file]]` | Branch the linked project, write a manifest, optionally point a local env file at the branch backend. | -| `preview teardown ` | Delete the branch, restore/remove the wired env file, drop the manifest. | - -Both are hidden (`{ hidden: true }`, like `orgs`/`projects`/`records`) while -experimental. - -## Flow - -**create:** `assertSafeName` (before anything remote) → `createBranchApi({ mode: 'full' })` -→ poll until ready → **write a baseline manifest immediately** (before any local -file mutation) → if `--wire-env`: back up the existing env file (once), rewrite -`NEXT_PUBLIC_INSFORGE_URL` to the branch backend, update the manifest with the -wired-file info. - -**teardown:** `assertSafeName` → read the manifest → `deleteBranchApi(..., { ignoreNotFound: true })` -→ restore the env file from `.preview-bak` (or delete it if `preview create` created -it) → delete the manifest. - -## Files - -| File | Role | -| --- | --- | -| `create.ts` | `preview create` action + `pollUntilReady`. | -| `teardown.ts` | `preview teardown` action (resilient local cleanup). | -| `index.ts` | Registers the hidden `preview` parent command. | -| `../../lib/preview-manifest.ts` | `PreviewManifest` type, `assertSafeName`, read/write/delete under `.insforge/previews/.json`. | -| `../../lib/env-writer.ts` | `overwriteEnvFile` — rewrites every matching `KEY=` line via a replacer function (so `$`-patterns in values are written literally). | -| `../../lib/api/platform.ts` | `deleteBranchApi(branchId, apiUrl?, { ignoreNotFound })` — passes `passThroughStatuses: [404]` when tolerating a missing branch. | - -## Key design decisions - -- **Manifest before local mutations.** The manifest is written right after the - branch is ready, before any env wiring. A crash mid-wire still leaves a manifest - that `teardown` can use — no orphaned remote branch. -- **Name validation before provisioning.** `assertSafeName` runs before - `createBranchApi`, so a git-style name like `feat/likes` is rejected up front - instead of orphaning a fully provisioned branch the manifest can't name. -- **Rollback on poll failure.** If provisioning never reaches ready, the branch is - best-effort deleted so a failed create doesn't leak a branch. -- **404-tolerant teardown.** If the branch is already gone, teardown still cleans - up the manifest and env file instead of aborting. -- **Reversible env wiring.** `--wire-env` backs up the original file to - `.preview-bak` (never overwriting an existing backup) and records - `wiredEnvCreated` when it created the file — so teardown restores the original or - removes a file we created, never stranding a pointer at a deleted backend. -- **Analytics carry no free text.** `cli_preview_create` sends only - `{ mode, parent_project_id }`, never the user-chosen name (per `DEVELOPMENT.md` §2). - -## Gotchas - -- **Deploy in branch context.** `preview create` does not deploy. Run - `deployments deploy` only after `branch switch ` — a deploy in parent - context targets the prod site and is not removed by `teardown`. -- **`preview create` ≠ a running frontend.** It gives you a backend (the branch). - The frontend is a separate deploy step. - -## Try it - -```bash -insforge preview create my-feature --wire-env # branch + point .env.local at it -# ... verify against the branch ... -insforge preview teardown my-feature # branch + env restore + manifest gone -``` diff --git a/src/commands/preview/create.test.ts b/src/commands/preview/create.test.ts deleted file mode 100644 index fe116d1..0000000 --- a/src/commands/preview/create.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -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); - }); - - 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); - }); - - 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>); - 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(); - }); -}); diff --git a/src/commands/preview/create.ts b/src/commands/preview/create.ts deleted file mode 100644 index b1ec914..0000000 --- a/src/commands/preview/create.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 ') - .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); - } 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(), { - 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 { - 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.'); -} diff --git a/src/commands/preview/index.test.ts b/src/commands/preview/index.test.ts deleted file mode 100644 index 18baa18..0000000 --- a/src/commands/preview/index.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -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'); - }); -}); diff --git a/src/commands/preview/index.ts b/src/commands/preview/index.ts deleted file mode 100644 index 0317b1f..0000000 --- a/src/commands/preview/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -// 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); -} diff --git a/src/commands/preview/teardown.test.ts b/src/commands/preview/teardown.test.ts deleted file mode 100644 index d5c3069..0000000 --- a/src/commands/preview/teardown.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 { registerPreviewTeardownCommand } from './teardown.js'; -import { writePreviewManifest, readPreviewManifest } from '../../lib/preview-manifest.js'; -import { deleteBranchApi } from '../../lib/api/platform.js'; - -vi.mock('../../lib/api/platform.js', () => ({ 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; - -describe('preview teardown', () => { - beforeEach(async () => { - tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-td-')); - vi.spyOn(process, 'cwd').mockReturnValue(tmpBase); - await writePreviewManifest(tmpBase, { - name: 'feat-likes', - branchId: 'branch-123', - appkey: 'p1ky-x9p', - createdAt: '2026-06-10T00:00:00.000Z', - }); - }); - - it('deletes the branch and removes the manifest', async () => { - const program = new Command(); - program.exitOverride(); - const preview = program.command('preview'); - registerPreviewTeardownCommand(preview); - await program.parseAsync(['preview', 'teardown', 'feat-likes'], { from: 'user' }); - - expect(deleteBranchApi).toHaveBeenCalledWith('branch-123', undefined, { ignoreNotFound: true }); - expect(await readPreviewManifest(tmpBase, 'feat-likes')).toBeNull(); - }); - - it('restores the wired env file from its backup', async () => { - const envName = '.env.custom'; - const envPath = path.join(tmpBase, envName); - await fs.writeFile( - envPath, - 'NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app\n', - ); - await fs.writeFile( - envPath + '.preview-bak', - 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app\n', - ); - await writePreviewManifest(tmpBase, { - name: 'feat-wired', - branchId: 'branch-456', - appkey: 'p1ky-x9p', - createdAt: '2026-06-10T00:00:00.000Z', - wiredEnvFile: envName, - }); - - const program = new Command(); - program.exitOverride(); - const preview = program.command('preview'); - registerPreviewTeardownCommand(preview); - await program.parseAsync(['preview', 'teardown', 'feat-wired'], { from: 'user' }); - - const restored = await fs.readFile(envPath, 'utf-8'); - expect(restored).toContain('NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app'); - await expect(fs.access(envPath + '.preview-bak')).rejects.toThrow(); - expect(await readPreviewManifest(tmpBase, 'feat-wired')).toBeNull(); - }); - - it('deletes an env file that --wire-env created (no backup to restore)', async () => { - const envName = '.env.local'; - const envPath = path.join(tmpBase, envName); - // The file exists (preview created it) but there is no .preview-bak. - await fs.writeFile(envPath, 'NEXT_PUBLIC_INSFORGE_URL=https://p1ky-x9p.us-east.insforge.app\n'); - await writePreviewManifest(tmpBase, { - name: 'feat-created', - branchId: 'branch-789', - appkey: 'p1ky-x9p', - createdAt: '2026-06-10T00:00:00.000Z', - wiredEnvFile: envName, - wiredEnvCreated: true, - }); - - const program = new Command(); - program.exitOverride(); - const preview = program.command('preview'); - registerPreviewTeardownCommand(preview); - await program.parseAsync(['preview', 'teardown', 'feat-created'], { from: 'user' }); - - // The created env file is removed, not left pointing at the deleted branch. - await expect(fs.access(envPath)).rejects.toThrow(); - expect(await readPreviewManifest(tmpBase, 'feat-created')).toBeNull(); - }); - - it('keeps --json stdout clean of env-restore chatter', async () => { - const envName = '.env.local'; - const envPath = path.join(tmpBase, envName); - await fs.writeFile(envPath, 'NEXT_PUBLIC_INSFORGE_URL=https://branch.app\n'); - await fs.writeFile(envPath + '.preview-bak', 'NEXT_PUBLIC_INSFORGE_URL=https://prod.app\n'); - await writePreviewManifest(tmpBase, { - name: 'feat-json', - branchId: 'branch-json', - appkey: 'p1ky-x9p', - createdAt: '2026-06-10T00:00:00.000Z', - wiredEnvFile: envName, - }); - - const logs: string[] = []; - const logSpy = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { - logs.push(String(m)); - }); - - const program = new Command(); - program.exitOverride(); - program.option('--json'); - const preview = program.command('preview'); - registerPreviewTeardownCommand(preview); - await program.parseAsync(['--json', 'preview', 'teardown', 'feat-json'], { from: 'user' }); - - logSpy.mockRestore(); - // Every line written to stdout must be valid JSON — no "Restored ..." chatter. - for (const line of logs) { - expect(() => JSON.parse(line)).not.toThrow(); - } - expect(logs.some((l) => l.includes('"teardown"'))).toBe(true); - }); - -}); diff --git a/src/commands/preview/teardown.ts b/src/commands/preview/teardown.ts deleted file mode 100644 index b3eb05e..0000000 --- a/src/commands/preview/teardown.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Command } from 'commander'; -import path from 'node:path'; -import { existsSync, copyFileSync, rmSync } from 'node:fs'; -import { deleteBranchApi } from '../../lib/api/platform.js'; -import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; -import { requireAuth } from '../../lib/credentials.js'; -import { outputInfo, outputJson } from '../../lib/output.js'; -import { shutdownAnalytics } from '../../lib/analytics.js'; -import { readPreviewManifest, deletePreviewManifest, assertSafeName } from '../../lib/preview-manifest.js'; - -export function registerPreviewTeardownCommand(preview: Command): void { - preview - .command('teardown ') - .description('Delete a preview environment created by `preview create` (experimental)') - .action(async (name: string, _opts, cmd) => { - const { json, apiUrl } = getRootOpts(cmd); - try { - await requireAuth(apiUrl); - try { - assertSafeName(name); - } catch (e) { - throw new CLIError(e instanceof Error ? e.message : String(e)); - } - const manifest = await readPreviewManifest(process.cwd(), name); - if (!manifest) { - throw new CLIError(`No preview named '${name}' found in this directory.`); - } - // Tolerate a 404 — if the branch was already deleted by other means, we - // still want to clean up the local manifest + wired env file. - await deleteBranchApi(manifest.branchId, apiUrl, { ignoreNotFound: true }); - - // The branch is now gone (irreversible). Finish local cleanup defensively - // so a failure restoring the env file never aborts before the manifest is - // removed — otherwise the manifest would keep pointing at a deleted branch. - if (manifest.wiredEnvFile) { - try { - const envPath = path.resolve(process.cwd(), manifest.wiredEnvFile); - const backupPath = envPath + '.preview-bak'; - if (manifest.wiredEnvCreated) { - // We created this file during `--wire-env`; remove it rather than - // leave it pointing at a deleted preview backend. - rmSync(envPath, { force: true }); - if (!json) outputInfo(` Removed ${manifest.wiredEnvFile} (created by preview).`); - } else if (existsSync(backupPath)) { - copyFileSync(backupPath, envPath); - rmSync(backupPath, { force: true }); - if (!json) outputInfo(` Restored ${manifest.wiredEnvFile} from backup.`); - } - } catch (envErr) { - const msg = envErr instanceof Error ? envErr.message : String(envErr); - if (!json) { - outputInfo(` ⚠ Could not restore ${manifest.wiredEnvFile} (${msg}). Restore it manually.`); - } - } - } - - await deletePreviewManifest(process.cwd(), name); - if (json) outputJson({ teardown: { name, ok: true } }); - else outputInfo(`Preview '${name}' torn down.`); - } catch (err) { - handleError(err, json); - } finally { - await shutdownAnalytics(); - } - }); -} diff --git a/src/commands/projects/link.test.ts b/src/commands/projects/link.test.ts index bfbeafe..17adcc2 100644 --- a/src/commands/projects/link.test.ts +++ b/src/commands/projects/link.test.ts @@ -89,7 +89,7 @@ describe('project link: skills-only fast path', () => { const { requireAuth } = await import('../../lib/credentials.js'); const { listOrganizations } = await import('../../lib/api/platform.js'); - expect(installSkills).toHaveBeenCalledWith(false, undefined, false); + expect(installSkills).toHaveBeenCalledWith(false, undefined); expect(trackCommand).toHaveBeenCalledWith('link', 'skills-only', { skills_only: true }); expect(reportCliUsage).toHaveBeenCalledWith('cli.link_skills_only', true, 1); // Skills-only path must never trigger auth or the org picker. @@ -104,7 +104,7 @@ describe('project link: skills-only fast path', () => { const { installSkills } = await import('../../lib/skills.js'); const { outputJson } = await import('../../lib/output.js'); - expect(installSkills).toHaveBeenCalledWith(true, undefined, false); + expect(installSkills).toHaveBeenCalledWith(true, undefined); expect(outputJson).toHaveBeenCalledWith({ success: true, skills_only: true }); }); diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index 371053a..28004c3 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -75,7 +75,6 @@ export function registerProjectLinkCommand(program: Command): void { .option('--auth ', 'Wire a third-party auth provider into the chosen template (currently: better-auth)') .option('--api-base-url ', 'API Base URL for direct linking (OSS/Self-hosted)') .option('--api-key ', 'API Key for direct linking (OSS/Self-hosted)') - .option('--with-test-agents', 'Also install Playwright Test Agents for `insforge-verify` (restart Claude Code after)') .option('--guard [state]', 'Enable the human-in-the-loop safety guard for this project (use --guard off to disable)') .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); @@ -125,7 +124,7 @@ export function registerProjectLinkCommand(program: Command): void { if (isSkillsOnly) { try { - await installSkills(json, undefined, Boolean(opts.withTestAgents)); + await installSkills(json, undefined); trackCommand('link', 'skills-only', { skills_only: true }); await reportCliUsage('cli.link_skills_only', true, 1); @@ -244,7 +243,7 @@ export function registerProjectLinkCommand(program: Command): void { } } - await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); + await installSkills(json, opts.auth as string | undefined); trackCommand('link', 'oss-org', { direct: true, template }); await reportCliUsage('cli.link_direct', true, 6, projectConfig); @@ -306,7 +305,7 @@ export function registerProjectLinkCommand(program: Command): void { trackCommand('link', 'oss-org', { direct: true }); // Install agent skills - await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); + await installSkills(json, opts.auth as string | undefined); await reportCliUsage('cli.link_direct', true, 6, projectConfig); // Report agent-connected event (best-effort) @@ -490,7 +489,7 @@ export function registerProjectLinkCommand(program: Command): void { } // Install agent skills inside the project directory - await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); + await installSkills(json, opts.auth as string | undefined); await reportCliUsage('cli.link', true, 6, projectConfig); if (!json) { @@ -532,7 +531,7 @@ export function registerProjectLinkCommand(program: Command): void { } // No template — install agent skills in the current directory - await installSkills(json, opts.auth as string | undefined, Boolean(opts.withTestAgents)); + await installSkills(json, opts.auth as string | undefined); await reportCliUsage('cli.link', true, 6, projectConfig); if (!json) { diff --git a/src/commands/verify/finding.ts b/src/commands/verify/finding.ts new file mode 100644 index 0000000..7b6caa4 --- /dev/null +++ b/src/commands/verify/finding.ts @@ -0,0 +1,48 @@ +import type { Command } from 'commander'; +import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; +import { outputJson, outputInfo } from '../../lib/output.js'; +import { shutdownAnalytics, trackVerifyFinding } from '../../lib/analytics.js'; +import { getProjectConfig } from '../../lib/config.js'; + +// Record a "loud" error the browser surfaced during the drive — a 4xx/5xx, a +// `column does not exist`, a console exception — that the agent saw via +// `browser_console_messages` / `browser_network_requests`. The rls/truth probes +// only cover the *silent* findings; this is how the loud ones reach PostHog too. +export function registerVerifyFindingCommand(verify: Command): void { + verify + .command('finding') + .description('Record a loud error surfaced during the drive (4xx/5xx, column-not-found, console) as a finding (experimental)') + .requiredOption('--kind ', 'short error kind, e.g. pgrst_column_not_found, http_500, console_error') + .option('--type ', 'finding type', 'error') + .option('--status ', 'HTTP status, if any', (v) => parseInt(v, 10)) + .option('--endpoint ', 'the endpoint/URL that errored') + .option('--message ', 'the error message the page showed') + .option('--table ', 'related table, if known') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const config = getProjectConfig(); + if (!config) throw new CLIError('No linked project found — run `insforge link` first.'); + const finding = { + type: opts.type as string, + kind: opts.kind as string, + status: Number.isNaN(opts.status) ? undefined : (opts.status as number | undefined), + endpoint: opts.endpoint as string | undefined, + message: opts.message as string | undefined, + table: opts.table as string | undefined, + }; + trackVerifyFinding(finding, config); + await shutdownAnalytics(); // flush the PostHog event before exit + + if (json) { + outputJson({ recorded: true, finding }); + } else { + outputInfo( + `📝 recorded ${finding.type} finding: ${finding.kind}${finding.status ? ` (${finding.status})` : ''}${finding.message ? ` — ${finding.message}` : ''}`, + ); + } + } catch (e) { + handleError(e, json); + } + }); +} diff --git a/src/commands/verify/index.ts b/src/commands/verify/index.ts new file mode 100644 index 0000000..5287425 --- /dev/null +++ b/src/commands/verify/index.ts @@ -0,0 +1,14 @@ +// src/commands/verify/index.ts +import type { Command } from 'commander'; +import { registerVerifyRlsCommand } from './rls.js'; +import { registerVerifyTruthCommand } from './truth.js'; +import { registerVerifyFindingCommand } from './finding.js'; + +export function registerVerifyCommands(program: Command): void { + const verify = program + .command('verify', { hidden: true }) + .description('[experimental] Backend-truth & RLS probes + loud-error recording for insforge-verify'); + registerVerifyRlsCommand(verify); + registerVerifyTruthCommand(verify); + registerVerifyFindingCommand(verify); +} diff --git a/src/commands/verify/rls.ts b/src/commands/verify/rls.ts new file mode 100644 index 0000000..322c97e --- /dev/null +++ b/src/commands/verify/rls.ts @@ -0,0 +1,66 @@ +import type { Command } from 'commander'; +import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson, outputInfo } from '../../lib/output.js'; +import { shutdownAnalytics, trackVerifyFinding } from '../../lib/analytics.js'; +import { classifyRls, getAnonKey, login, rawsqlRows, recordsCount } from '../../lib/verify-probe.js'; + +export function registerVerifyRlsCommand(verify: Command): void { + verify + .command('rls') + .description('Cross-user RLS isolation probe — checks B cannot read A, A can read own (experimental)') + .requiredOption('--table ', 'user-scoped table to probe') + .requiredOption('--owner ', 'owner column on the table (e.g. user_id)') + .option('--user-a ', 'seeded user A email', 'verify-a@example.com') + .option('--user-b ', 'seeded user B email', 'verify-b@example.com') + .option('--password ', 'seeded users password', 'Test1234!pass') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const config = getProjectConfig(); + if (!config) throw new CLIError('No linked project found — run `insforge link` first.'); + const baseUrl = config.oss_host; + const adminKey = config.api_key; + + const aToken = await login(baseUrl, opts.userA, opts.password); + const bToken = await login(baseUrl, opts.userB, opts.password); + const anon = await getAnonKey(baseUrl, adminKey); + if (!aToken || !bToken || !anon) { + throw new CLIError( + 'Login or anon-key fetch returned empty — seed BOTH users first. An empty token turns every probe into an anonymous request that silently "passes" isolation.', + ); + } + + const rows = await rawsqlRows( + baseUrl, + adminKey, + `select id from auth.users where email='${String(opts.userA).replace(/'/g, "''")}'`, + ); + const aId = (rows[0] as { id?: string })?.id; + if (!aId) throw new CLIError(`Could not find user A (${opts.userA}) — seed it first.`); + + const filter = `${opts.owner}=eq.${aId}`; + const bReadRowsOfA = await recordsCount(baseUrl, opts.table, filter, bToken, anon); + const aReadOwnRows = await recordsCount(baseUrl, opts.table, filter, aToken, anon); + const anonReadRows = await recordsCount(baseUrl, opts.table, undefined, undefined, anon); + + const { type, evidence } = classifyRls({ bReadRowsOfA, aReadOwnRows, anonReadRows }); + const finding = { type, table: opts.table as string, evidence }; + trackVerifyFinding(finding, config); + await shutdownAnalytics(); // flush the PostHog event before exit + + if (json) { + outputJson({ passed: type === 'none', finding }); + } else if (type === 'rls_leak') { + outputInfo(`❌ rls_leak on ${opts.table}: B read ${bReadRowsOfA} of A's rows (anon read ${anonReadRows}).`); + } else if (type === 'rls_overrestrict') { + outputInfo(`❌ rls_overrestrict on ${opts.table}: A could not read its own rows (positive control empty).`); + } else { + outputInfo(`✅ isolation holds on ${opts.table}: B=0, anon=0, A=${aReadOwnRows}.`); + } + process.exitCode = type === 'none' ? 0 : 1; + } catch (e) { + handleError(e, json); + } + }); +} diff --git a/src/commands/verify/truth.ts b/src/commands/verify/truth.ts new file mode 100644 index 0000000..4bdccf3 --- /dev/null +++ b/src/commands/verify/truth.ts @@ -0,0 +1,54 @@ +import type { Command } from 'commander'; +import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson, outputInfo } from '../../lib/output.js'; +import { shutdownAnalytics, trackVerifyFinding } from '../../lib/analytics.js'; +import { classifyTruth, rawsqlRows } from '../../lib/verify-probe.js'; + +export function registerVerifyTruthCommand(verify: Command): void { + verify + .command('truth') + .description('Backend-truth cross-check — compare a DB read to what the UI claimed (experimental)') + .requiredOption('--query ', 'a read proving what the UI showed; compares the first column of the first row') + .option('--expect ', 'the value the UI displayed (compared as a scalar)') + .option('--expect-count ', 'expect this many rows instead of a scalar value') + .option('--table ', 'table name, for the finding label') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + const config = getProjectConfig(); + if (!config) throw new CLIError('No linked project found — run `insforge link` first.'); + + const rows = await rawsqlRows(config.oss_host, config.api_key, opts.query); + + let result: { type: 'false_pass' | 'none'; evidence: Record }; + if (opts.expectCount !== undefined) { + result = classifyTruth(rows.length, String(opts.expectCount)); + } else if (opts.expect !== undefined) { + const first = rows[0]; + const dbValue = + first && typeof first === 'object' ? Object.values(first as Record)[0] : first; + result = classifyTruth(dbValue, String(opts.expect)); + } else { + throw new CLIError('Provide --expect (scalar) or --expect-count (row count).'); + } + + const finding = { type: result.type, table: opts.table as string | undefined, evidence: result.evidence }; + trackVerifyFinding(finding, config); + await shutdownAnalytics(); // flush the PostHog event before exit + + if (json) { + outputJson({ passed: result.type === 'none', finding }); + } else if (result.type === 'false_pass') { + outputInfo( + `❌ false_pass${opts.table ? ` on ${opts.table}` : ''}: UI claimed ${JSON.stringify(result.evidence.ui_claimed)} but DB has ${JSON.stringify(result.evidence.db_actual)}.`, + ); + } else { + outputInfo(`✅ backend truth matches: ${JSON.stringify(result.evidence.db_actual)}.`); + } + process.exitCode = result.type === 'none' ? 0 : 1; + } catch (e) { + handleError(e, json); + } + }); +} diff --git a/src/index.ts b/src/index.ts index abec004..32aab51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { registerWhoamiCommand } from './commands/whoami.js'; import { registerOrgsCommands } from './commands/orgs/list.js'; import { registerProjectsCommands } from './commands/projects/list.js'; import { registerBranchCommands } from './commands/branch/index.js'; -import { registerPreviewCommands } from './commands/preview/index.js'; +import { registerVerifyCommands } from './commands/verify/index.js'; import { registerProjectLinkCommand } from './commands/projects/link.js'; import { registerDbCommands } from './commands/db/query.js'; import { registerDbTablesCommand } from './commands/db/tables.js'; @@ -136,8 +136,8 @@ registerProjectsCommands(projectsCmd); // Branch commands registerBranchCommands(program); -// Preview commands (experimental, hidden from --help) -registerPreviewCommands(program); +// Verify probe commands (experimental, hidden from --help) +registerVerifyCommands(program); // Database commands const dbCmd = program.command('db').description('Database operations'); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 824a89a..57daba6 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -128,3 +128,33 @@ export async function shutdownAnalytics(): Promise { // ignore } } + +export interface VerifyFinding { + type: string; + table?: string; + kind?: string; + status?: number; + endpoint?: string; + message?: string; + evidence?: Record; +} + +/** + * Emit a verify finding to PostHog — the central, cross-user rail (finding rate + what + * broke), same as the other track* helpers here. NOT the per-project `oss_host/api/usage/mcp` + * table, which only stores `(tool_name, success)` and drops the finding. The recording lives + * in the tool — a finding is recorded because the probe ran, not because the agent remembered + * to. Best-effort; the caller flushes via `shutdownAnalytics()` before exit. + */ +export function trackVerifyFinding(finding: VerifyFinding, config: ProjectConfig): void { + captureEvent(config.project_id, 'verify_finding', { + finding_type: finding.type, + passed: finding.type === 'none', + table: finding.table, + kind: finding.kind, + status: finding.status, + endpoint: finding.endpoint, + message: finding.message, + ...finding.evidence, + }); +} diff --git a/src/lib/browser-mcp.test.ts b/src/lib/browser-mcp.test.ts new file mode 100644 index 0000000..dbd7d97 --- /dev/null +++ b/src/lib/browser-mcp.test.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ensureCodexToml, mergeJsonMcp } from './browser-mcp.js'; + +const HEADLESS_SERVER = { + command: 'npx', + args: ['@playwright/mcp@latest', '--headless'], +}; + +describe('mergeJsonMcp', () => { + let dir: string; + let file: string; + const read = () => JSON.parse(readFileSync(file, 'utf-8')); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'insforge-mcp-')); + file = join(dir, '.cursor', 'mcp.json'); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('creates the file (and parent dirs) with the server under mcpServers', () => { + expect(mergeJsonMcp(file, 'mcpServers', HEADLESS_SERVER)).toBe(true); + expect(read().mcpServers['playwright']).toEqual(HEADLESS_SERVER); + }); + + it('merges without clobbering other servers', () => { + writeFileSync(join(dir, 'cfg.json'), JSON.stringify({ mcpServers: { other: { command: 'x' } } })); + expect(mergeJsonMcp(join(dir, 'cfg.json'), 'mcpServers', HEADLESS_SERVER)).toBe(true); + const cfg = JSON.parse(readFileSync(join(dir, 'cfg.json'), 'utf-8')); + expect(cfg.mcpServers.other).toEqual({ command: 'x' }); + expect(cfg.mcpServers['playwright']).toBeDefined(); + }); + + it('is idempotent — returns false when already present and identical', () => { + mergeJsonMcp(file, 'mcpServers', HEADLESS_SERVER); + expect(mergeJsonMcp(file, 'mcpServers', HEADLESS_SERVER)).toBe(false); + }); + + it('recovers from malformed JSON by starting fresh', () => { + const bad = join(dir, 'bad.json'); + writeFileSync(bad, '{ not valid json'); + expect(mergeJsonMcp(bad, 'mcpServers', HEADLESS_SERVER)).toBe(true); + expect(JSON.parse(readFileSync(bad, 'utf-8')).mcpServers['playwright']).toBeDefined(); + }); + + it('supports the VS Code `servers` key', () => { + expect(mergeJsonMcp(file, 'servers', HEADLESS_SERVER)).toBe(true); + expect(read().servers['playwright']).toEqual(HEADLESS_SERVER); + }); +}); + +describe('ensureCodexToml', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'insforge-codex-')); + file = join(dir, '.codex', 'config.toml'); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('appends a [mcp_servers.playwright] block when absent', () => { + expect(ensureCodexToml(file)).toBe(true); + const toml = readFileSync(file, 'utf-8'); + expect(toml).toContain('[mcp_servers.playwright]'); + expect(toml).toContain('command = "npx"'); + expect(toml).toContain('"--headless"'); + }); + + it('is idempotent — returns false when the block already exists', () => { + ensureCodexToml(file); + expect(ensureCodexToml(file)).toBe(false); + }); + + it('preserves existing TOML content', () => { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, '[some_other_section]\nkey = "value"\n'); + expect(ensureCodexToml(file)).toBe(true); + const toml = readFileSync(file, 'utf-8'); + expect(toml).toContain('[some_other_section]'); + expect(toml).toContain('[mcp_servers.playwright]'); + }); +}); diff --git a/src/lib/browser-mcp.ts b/src/lib/browser-mcp.ts new file mode 100644 index 0000000..7d45e8d --- /dev/null +++ b/src/lib/browser-mcp.ts @@ -0,0 +1,135 @@ +import { exec } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +const MCP_CONFIG_TIMEOUT_MS = 60_000; + +// `@playwright/mcp` is the browser-automation MCP (browser_navigate/click/snapshot + +// console/network tools) the light-mode `insforge-verify` skill drives directly — NOT +// `run-test-mcp-server`, which is the Test Agents (planner/generator) pipeline and has no +// browser_* tools. +const MCP_SERVER_NAME = 'playwright'; +const MCP_COMMAND = 'npx'; +const MCP_ARGS = ['@playwright/mcp@latest', '--headless']; + +/** + * Merge the Playwright MCP server into a JSON MCP config (user/global scope), + * returning true if it changed the file. `key` is the top-level object servers live + * under — `mcpServers` for Cursor/Windsurf/Gemini, `servers` for VS Code. Malformed + * JSON is replaced rather than crashing the link. + */ +export function mergeJsonMcp( + file: string, + key: 'mcpServers' | 'servers', + server: Record, +): boolean { + let config: Record> = {}; + if (existsSync(file)) { + try { + config = JSON.parse(readFileSync(file, 'utf-8')) as typeof config; + } catch { + config = {}; + } + } + config[key] ??= {}; + if (JSON.stringify(config[key][MCP_SERVER_NAME]) === JSON.stringify(server)) return false; + config[key][MCP_SERVER_NAME] = server; + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); + return true; +} + +/** Append a `[mcp_servers.playwright]` block to Codex's global TOML config if absent. */ +export function ensureCodexToml(file: string): boolean { + const existing = existsSync(file) ? readFileSync(file, 'utf-8') : ''; + if (existing.includes(`[mcp_servers.${MCP_SERVER_NAME}]`)) return false; + const args = MCP_ARGS.map((a) => `"${a}"`).join(', '); + const block = `\n[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "${MCP_COMMAND}"\nargs = [${args}]\n`; + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, existing + block); + return true; +} + +async function commandExists(cmd: string): Promise { + return execAsync(`command -v ${cmd}`).then( + () => true, + () => false, + ); +} + +/** + * One agent's recipe for registering the browser MCP at user/global scope — mirroring + * how `skills add -a -g` delegates skill placement per agent. `apply` returns a + * label of what it configured, or null if the agent isn't present (skip it). Add an + * agent by adding one entry — no other call site changes (cf. AGENT_FLAGS). + */ +interface BrowserMcpTarget { + agent: string; + apply: (home: string) => Promise; +} + +const JSON_MCP_SERVER = { command: MCP_COMMAND, args: MCP_ARGS }; + +const BROWSER_MCP_TARGETS: BrowserMcpTarget[] = [ + { + // Claude Code: delegate to its own CLI at user scope (global across projects), + // exactly like the skills install delegates placement. Idempotent + quiet on repeat + // links: skip if already configured. Skipped if the `claude` CLI isn't on PATH. + agent: 'Claude Code', + apply: async () => { + if (!(await commandExists('claude'))) return null; + const present = await execAsync(`claude mcp get ${MCP_SERVER_NAME}`) + .then(() => true) + .catch(() => false); + if (present) return null; + await execAsync( + `claude mcp add ${MCP_SERVER_NAME} -s user -- ${MCP_COMMAND} ${MCP_ARGS.join(' ')}`, + { timeout: MCP_CONFIG_TIMEOUT_MS }, + ); + return 'user scope'; + }, + }, + { + // Cursor: no CLI — write its global config file, only if Cursor is set up. + agent: 'Cursor', + apply: async (home) => { + if (!existsSync(join(home, '.cursor'))) return null; + return mergeJsonMcp(join(home, '.cursor', 'mcp.json'), 'mcpServers', JSON_MCP_SERVER) + ? '~/.cursor/mcp.json' + : null; + }, + }, + { + // Codex: global TOML, only if Codex is set up. + agent: 'Codex', + apply: async (home) => { + if (!existsSync(join(home, '.codex'))) return null; + return ensureCodexToml(join(home, '.codex', 'config.toml')) ? '~/.codex/config.toml' : null; + }, + }, +]; + +/** + * Configure the Playwright browser MCP at user/global scope for whichever agents + * are present, so light-mode `insforge-verify` can drive the browser. Global to match + * how the InsForge skills install (`skills add … -g`); the server command is identical + * across agents — only where/how it's registered differs. No network beyond each agent's + * own CLI, no LLM, no subagents (the user's agent is the driving brain). Returns a label + * per agent configured. Best-effort: one agent failing never blocks the others. + */ +export async function configureBrowserMcp(home = homedir()): Promise { + const configured: string[] = []; + for (const target of BROWSER_MCP_TARGETS) { + try { + const label = await target.apply(home); + if (label) configured.push(`${target.agent} (${label})`); + } catch { + // best-effort per agent + } + } + return configured; +} diff --git a/src/lib/preview-manifest.test.ts b/src/lib/preview-manifest.test.ts deleted file mode 100644 index 62a89e0..0000000 --- a/src/lib/preview-manifest.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - writePreviewManifest, - readPreviewManifest, - deletePreviewManifest, - type PreviewManifest, - assertSafeName, -} from './preview-manifest.js'; - -describe('preview-manifest', () => { - let dir: string; - beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-test-')); - }); - afterEach(async () => { - await fs.rm(dir, { recursive: true, force: true }); - }); - - it('round-trips a manifest by name', async () => { - const manifest: PreviewManifest = { - name: 'feat-likes', - branchId: 'branch-123', - appkey: 'p1ky-x9p', - createdAt: '2026-06-10T00:00:00.000Z', - }; - await writePreviewManifest(dir, manifest); - const read = await readPreviewManifest(dir, 'feat-likes'); - expect(read).toEqual(manifest); - }); - - it('returns null for a missing manifest', async () => { - const read = await readPreviewManifest(dir, 'nope'); - expect(read).toBeNull(); - }); - - it('deletes a manifest', async () => { - const manifest: PreviewManifest = { - name: 'feat-likes', - branchId: 'branch-123', - appkey: 'p1ky-x9p', - createdAt: '2026-06-10T00:00:00.000Z', - }; - await writePreviewManifest(dir, manifest); - await deletePreviewManifest(dir, 'feat-likes'); - expect(await readPreviewManifest(dir, 'feat-likes')).toBeNull(); - }); - - describe('assertSafeName', () => { - it('accepts safe names', () => { - expect(() => assertSafeName('feat-likes_1.2')).not.toThrow(); - }); - it('rejects git-style and traversal names', () => { - expect(() => assertSafeName('feat/likes')).toThrow(/Invalid preview name/); - expect(() => assertSafeName('../escape')).toThrow(/Invalid preview name/); - }); - }); - -}); diff --git a/src/lib/preview-manifest.ts b/src/lib/preview-manifest.ts deleted file mode 100644 index a10e3d5..0000000 --- a/src/lib/preview-manifest.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -export interface PreviewManifest { - name: string; - branchId: string; - appkey: string; - createdAt: string; - wiredEnvFile?: string; - wiredEnvCreated?: boolean; -} - -function previewDir(baseDir: string): string { - return path.join(baseDir, '.insforge', 'previews'); -} - -export function assertSafeName(name: string): void { - if (!/^[A-Za-z0-9._-]+$/.test(name)) { - throw new Error(`Invalid preview name '${name}': use only letters, digits, '.', '_', '-'.`); - } -} - -function manifestPath(baseDir: string, name: string): string { - assertSafeName(name); - return path.join(previewDir(baseDir), `${name}.json`); -} - -export async function writePreviewManifest( - baseDir: string, - manifest: PreviewManifest, -): Promise { - await fs.mkdir(previewDir(baseDir), { recursive: true }); - await fs.writeFile( - manifestPath(baseDir, manifest.name), - JSON.stringify(manifest, null, 2), - 'utf8', - ); -} - -export async function readPreviewManifest( - baseDir: string, - name: string, -): Promise { - try { - const raw = await fs.readFile(manifestPath(baseDir, name), 'utf8'); - return JSON.parse(raw) as PreviewManifest; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw err; - } -} - -export async function deletePreviewManifest(baseDir: string, name: string): Promise { - await fs.rm(manifestPath(baseDir, name), { force: true }); -} diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 0d766e7..abc1cb6 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { promisify } from 'node:util'; import * as clack from '@clack/prompts'; import { writeLocalAgentsMd } from './agents-md.js'; +import { configureBrowserMcp } from './browser-mcp.js'; import { getProjectConfig } from './config.js'; const execAsync = promisify(exec); @@ -87,7 +88,7 @@ const PROVIDER_SKILLS: Record = { export async function installSkills( json: boolean, authProvider?: string, - withTestAgents = false, + withBrowserMcp = true, ): Promise { try { if (!json) clack.log.info('Installing InsForge agent skills (global)...'); @@ -156,31 +157,31 @@ export async function installSkills( // non-critical, silently ignore } - // Opt-in: install Playwright Test Agents (planner/generator/healer) into the - // project's `.claude/agents/` so the `insforge-verify` skill can drive UI - // testing. We do this at link time — BEFORE the user's Claude Code session — - // because subagents load at session start: running `init-agents` mid-session - // leaves them unavailable ("Agent type 'playwright-test-planner' not found"). - if (withTestAgents) { + // Opt-in: configure the Playwright browser MCP (`@playwright/mcp`) so the `insforge-verify` + // skill can drive the UI directly (light mode — no spec-generation subagents). + // This only declares the MCP server in `.mcp.json`; the driving "brain" is the + // user's own agent, so it stays agent-agnostic and needs no extra LLM key. The + // server loads at session start like any MCP config, so we do it at link time. + if (withBrowserMcp) { try { - if (!json) clack.log.info('Installing Playwright Test Agents (.claude/agents)...'); - await execAsync('npx playwright init-agents --loop=claude', { - cwd: process.cwd(), - timeout: SKILL_INSTALL_TIMEOUT_MS, - }); + const configured = await configureBrowserMcp(); if (!json) { - clack.log.success('Playwright Test Agents installed.'); - // The one step the CLI cannot do for the user — and the one that - // actually avoids the "not found" wall. - clack.log.warn( - 'Restart Claude Code (or start a fresh session) so the test agents load before verifying.', - ); + if (configured.length) { + clack.log.success(`Configured the Playwright browser MCP for: ${configured.join(', ')}.`); + clack.log.warn( + 'Restart your agent (or reload MCP servers) so the browser tools load before verifying.', + ); + } else { + clack.log.info( + 'No supported agent found to auto-configure the browser MCP. Add it manually — Claude: `claude mcp add playwright -s user -- npx @playwright/mcp@latest --headless`.', + ); + } } } catch (err) { if (!json) { - clack.log.warn(`Could not install Playwright Test Agents: ${describeExecError(err)}`); + clack.log.warn(`Could not configure the browser MCP: ${describeExecError(err)}`); clack.log.info( - 'Ensure Playwright is available (`npm i -D @playwright/test && npx playwright install chromium`), then run `npx playwright init-agents --loop=claude`.', + 'Add a `playwright` MCP server to your agent manually (command: `npx @playwright/mcp@latest --headless`).', ); } } diff --git a/src/lib/verify-probe.test.ts b/src/lib/verify-probe.test.ts new file mode 100644 index 0000000..aa71b84 --- /dev/null +++ b/src/lib/verify-probe.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { classifyRls, classifyTruth } from './verify-probe.js'; + +describe('classifyRls', () => { + it('flags rls_leak when B reads any of A\'s rows', () => { + const r = classifyRls({ bReadRowsOfA: 3, aReadOwnRows: 5, anonReadRows: 0 }); + expect(r.type).toBe('rls_leak'); + expect(r.evidence.user_b_read_rows_of_a).toBe(3); + }); + + it('flags rls_leak when anonymous reads any rows', () => { + expect(classifyRls({ bReadRowsOfA: 0, aReadOwnRows: 5, anonReadRows: 2 }).type).toBe('rls_leak'); + }); + + it('flags rls_overrestrict when A cannot read its own rows (positive control empty)', () => { + expect(classifyRls({ bReadRowsOfA: 0, aReadOwnRows: 0, anonReadRows: 0 }).type).toBe('rls_overrestrict'); + }); + + it('passes (none) when B=0, anon=0, and A sees its own rows', () => { + expect(classifyRls({ bReadRowsOfA: 0, aReadOwnRows: 5, anonReadRows: 0 }).type).toBe('none'); + }); + + it('prioritises a real leak over the positive-control check', () => { + // B leaks AND A sees nothing — the leak is the more severe finding to surface + expect(classifyRls({ bReadRowsOfA: 4, aReadOwnRows: 0, anonReadRows: 0 }).type).toBe('rls_leak'); + }); +}); + +describe('classifyTruth', () => { + it('flags false_pass when the DB value differs from what the UI claimed', () => { + const r = classifyTruth(1, '3'); + expect(r.type).toBe('false_pass'); + expect(r.evidence).toEqual({ ui_claimed: '3', db_actual: 1 }); + }); + + it('passes when the DB value matches (number vs string normalised)', () => { + expect(classifyTruth(3, '3').type).toBe('none'); + expect(classifyTruth('3', '3').type).toBe('none'); + expect(classifyTruth(' 3 ', '3').type).toBe('none'); + }); + + it('treats null/undefined as empty and mismatching a non-empty expectation', () => { + expect(classifyTruth(null, '3').type).toBe('false_pass'); + expect(classifyTruth(undefined, '0').type).toBe('false_pass'); + }); + + it('passes when both sides are empty', () => { + expect(classifyTruth(null, '').type).toBe('none'); + }); +}); diff --git a/src/lib/verify-probe.ts b/src/lib/verify-probe.ts new file mode 100644 index 0000000..0b7645f --- /dev/null +++ b/src/lib/verify-probe.ts @@ -0,0 +1,108 @@ +// Deterministic verify probes for `insforge verify rls/truth`. +// +// The verdict logic is pure + unit-tested; the fetch wiring is a thin layer on +// top. Findings are emitted via `trackVerifyFinding` (src/lib/analytics.ts) so the +// recording is in the tool, not in agent prose. + +export type RlsFindingType = 'rls_leak' | 'rls_overrestrict' | 'none'; +export type TruthFindingType = 'false_pass' | 'none'; + +/** + * Classify a cross-user RLS isolation probe from its row counts. Deterministic: + * - B reading A's rows (or anon reading any) -> rls_leak + * - A failing to read its own rows (positive control empty) -> rls_overrestrict + * (catches a policy that silently empties a real user's data — the break no + * scanner catches, since it returns 200 + []) + */ +export function classifyRls(input: { + bReadRowsOfA: number; + aReadOwnRows: number; + anonReadRows: number; +}): { type: RlsFindingType; evidence: Record } { + const evidence = { + user_b_read_rows_of_a: input.bReadRowsOfA, + user_a_read_own_rows: input.aReadOwnRows, + anon_read_rows: input.anonReadRows, + }; + if (input.bReadRowsOfA > 0) return { type: 'rls_leak', evidence }; + if (input.anonReadRows > 0) return { type: 'rls_leak', evidence }; + if (input.aReadOwnRows === 0) return { type: 'rls_overrestrict', evidence }; + return { type: 'none', evidence }; +} + +function normalizeScalar(v: unknown): string { + if (v === null || v === undefined) return ''; + return String(v).trim(); +} + +/** + * Classify a backend-truth check. The UI claimed `expected`; the DB returned + * `dbValue`. A mismatch is a false pass (the write returned 200 + optimistic UI + * but never persisted, or persisted the wrong value). Compared as normalized + * scalars so `3` and `"3"` agree. + */ +export function classifyTruth( + dbValue: unknown, + expected: string, +): { type: TruthFindingType; evidence: Record } { + const evidence = { ui_claimed: expected, db_actual: dbValue }; + return { + type: normalizeScalar(dbValue) === normalizeScalar(expected) ? 'none' : 'false_pass', + evidence, + }; +} + +// ---- fetch wiring (not unit-tested; the verdicts above are) ---- + +function extractToken(j: unknown): string { + const obj = j as { accessToken?: string; data?: { accessToken?: string } }; + return obj?.accessToken ?? obj?.data?.accessToken ?? ''; +} + +function extractRows(j: unknown): unknown[] { + if (Array.isArray(j)) return j; + const obj = j as { data?: unknown[]; records?: unknown[]; rows?: unknown[] }; + return obj?.data ?? obj?.records ?? obj?.rows ?? []; +} + +export async function login(baseUrl: string, email: string, password: string): Promise { + const res = await fetch(`${baseUrl}/api/auth/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + return extractToken(await res.json().catch(() => ({}))); +} + +export async function getAnonKey(baseUrl: string, adminKey: string): Promise { + const res = await fetch(`${baseUrl}/api/auth/tokens/anon`, { + method: 'POST', + headers: { Authorization: `Bearer ${adminKey}` }, + }); + return extractToken(await res.json().catch(() => ({}))); +} + +export async function rawsqlRows(baseUrl: string, adminKey: string, query: string): Promise { + const res = await fetch(`${baseUrl}/api/database/advance/rawsql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminKey}` }, + body: JSON.stringify({ query, params: [] }), + }); + return extractRows(await res.json().catch(() => ({}))); +} + +/** Count rows from the data API. A 401/403 (or any non-2xx) counts as 0 rows. */ +export async function recordsCount( + baseUrl: string, + table: string, + query: string | undefined, + token: string | undefined, + anon: string, +): Promise { + const url = `${baseUrl}/api/database/records/${encodeURIComponent(table)}${query ? `?${query}` : ''}`; + const headers: Record = { apikey: anon }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url, { headers }); + if (!res.ok) return 0; + return extractRows(await res.json().catch(() => [])).length; +} From 26d56bf1b5e8be6bec92c33917823c5999e67474 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 18 Jun 2026 12:11:06 -0700 Subject: [PATCH 10/12] chore(verify): remove dead overwriteEnvFile, simplify skills-only installSkills call --- src/commands/projects/link.test.ts | 4 +- src/commands/projects/link.ts | 2 +- src/lib/env-writer.overwrite.test.ts | 87 ---------------------------- src/lib/env-writer.ts | 37 ------------ 4 files changed, 3 insertions(+), 127 deletions(-) delete mode 100644 src/lib/env-writer.overwrite.test.ts diff --git a/src/commands/projects/link.test.ts b/src/commands/projects/link.test.ts index 17adcc2..8bdbe0f 100644 --- a/src/commands/projects/link.test.ts +++ b/src/commands/projects/link.test.ts @@ -89,7 +89,7 @@ describe('project link: skills-only fast path', () => { const { requireAuth } = await import('../../lib/credentials.js'); const { listOrganizations } = await import('../../lib/api/platform.js'); - expect(installSkills).toHaveBeenCalledWith(false, undefined); + expect(installSkills).toHaveBeenCalledWith(false); expect(trackCommand).toHaveBeenCalledWith('link', 'skills-only', { skills_only: true }); expect(reportCliUsage).toHaveBeenCalledWith('cli.link_skills_only', true, 1); // Skills-only path must never trigger auth or the org picker. @@ -104,7 +104,7 @@ describe('project link: skills-only fast path', () => { const { installSkills } = await import('../../lib/skills.js'); const { outputJson } = await import('../../lib/output.js'); - expect(installSkills).toHaveBeenCalledWith(true, undefined); + expect(installSkills).toHaveBeenCalledWith(true); expect(outputJson).toHaveBeenCalledWith({ success: true, skills_only: true }); }); diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index 28004c3..024411c 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -124,7 +124,7 @@ export function registerProjectLinkCommand(program: Command): void { if (isSkillsOnly) { try { - await installSkills(json, undefined); + await installSkills(json); trackCommand('link', 'skills-only', { skills_only: true }); await reportCliUsage('cli.link_skills_only', true, 1); diff --git a/src/lib/env-writer.overwrite.test.ts b/src/lib/env-writer.overwrite.test.ts deleted file mode 100644 index 0c8c607..0000000 --- a/src/lib/env-writer.overwrite.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { overwriteEnvFile } from './env-writer.js'; - -describe('overwriteEnvFile', () => { - let dir: string; - beforeEach(async () => { - dir = await mkdtemp(path.join(os.tmpdir(), 'env-overwrite-')); - }); - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it("overwrites an existing key's value", async () => { - const file = path.join(dir, '.env'); - await writeFile(file, 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app\n'); - const res = overwriteEnvFile(file, { - NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app', - }); - expect(res.changed).toEqual(['NEXT_PUBLIC_INSFORGE_URL']); - expect(res.added).toEqual([]); - const content = await readFile(file, 'utf-8'); - expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); - expect(content).not.toContain('prod.insforge.app'); - }); - - it('appends a key that is absent', async () => { - const file = path.join(dir, '.env'); - await writeFile(file, 'EXISTING=1\n'); - const res = overwriteEnvFile(file, { - NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app', - }); - expect(res.added).toEqual(['NEXT_PUBLIC_INSFORGE_URL']); - expect(res.changed).toEqual([]); - const content = await readFile(file, 'utf-8'); - expect(content).toContain('EXISTING=1'); - expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); - }); - - it('leaves unrelated lines and comments intact', async () => { - const file = path.join(dir, '.env'); - const original = [ - '# leading comment', - 'OTHER_KEY=keepme', - 'NEXT_PUBLIC_INSFORGE_URL=https://prod.insforge.app', - '# trailing comment', - '', - ].join('\n'); - await writeFile(file, original); - overwriteEnvFile(file, { - NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app', - }); - const content = await readFile(file, 'utf-8'); - expect(content).toContain('# leading comment'); - expect(content).toContain('OTHER_KEY=keepme'); - expect(content).toContain('# trailing comment'); - expect(content).toContain('NEXT_PUBLIC_INSFORGE_URL=https://branch.insforge.app'); - }); - - it('writes values containing $ literally (no String.replace special patterns)', async () => { - const file = path.join(dir, '.env'); - await writeFile(file, 'NEXT_PUBLIC_INSFORGE_URL=https://old.example.com\n'); - const tricky = 'https://x.app/?a=$1&b=$&c=$`'; - overwriteEnvFile(file, { NEXT_PUBLIC_INSFORGE_URL: tricky }); - const out = await readFile(file, 'utf8'); - expect(out).toContain(`NEXT_PUBLIC_INSFORGE_URL=${tricky}`); - }); - - - it('rewrites ALL occurrences of a duplicated key', async () => { - const file = path.join(dir, '.env'); - await writeFile(file, [ - 'NEXT_PUBLIC_INSFORGE_URL=https://a.example.com', - 'OTHER=keep', - 'NEXT_PUBLIC_INSFORGE_URL=https://b.example.com', - ].join('\n') + '\n'); - overwriteEnvFile(file, { NEXT_PUBLIC_INSFORGE_URL: 'https://branch.insforge.app' }); - const out = await readFile(file, 'utf8'); - expect(out.match(/NEXT_PUBLIC_INSFORGE_URL=https:\/\/branch\.insforge\.app/g)?.length).toBe(2); - expect(out).not.toContain('a.example.com'); - expect(out).not.toContain('b.example.com'); - expect(out).toContain('OTHER=keep'); - }); - -}); diff --git a/src/lib/env-writer.ts b/src/lib/env-writer.ts index 06c5997..459a174 100644 --- a/src/lib/env-writer.ts +++ b/src/lib/env-writer.ts @@ -78,40 +78,3 @@ export function upsertEnvFile( return result; } - -/** - * Overwrite (or append) env vars in-place. Unlike upsertEnvFile, this REPLACES - * the value of keys that already exist. Used to repoint a frontend at a branch - * preview backend. Returns which keys were changed vs newly added. - */ -export function overwriteEnvFile( - path: string, - entries: Record, -): { changed: string[]; added: string[] } { - const exists = existsSync(path); - let content = exists ? readFileSync(path, 'utf-8') : ''; - const changed: string[] = []; - const added: string[] = []; - const additions: string[] = []; - - for (const [key, value] of Object.entries(entries)) { - const re = new RegExp(KEY_LINE_RE(key).source, 'gm'); - if (re.test(content)) { - re.lastIndex = 0; - content = content.replace(re, () => `${key}=${value}`); - changed.push(key); - } else { - additions.push(`${key}=${value}`); - added.push(key); - } - } - - if (additions.length > 0) { - if (content.length > 0 && !content.endsWith('\n')) content += '\n'; - content += additions.join('\n') + '\n'; - } - if (changed.length > 0 || additions.length > 0) { - writeFileSync(path, content); - } - return { changed, added }; -} From 29acf6cf12169b12ab3d0e960254b79439cdfe55 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 18 Jun 2026 12:29:31 -0700 Subject: [PATCH 11/12] fix(verify): guard read-only SQL, assert fetch ok, harden MCP merge, fix finding props --- src/commands/verify/truth.ts | 7 ++++++- src/lib/analytics.ts | 2 +- src/lib/api/platform.ts | 12 ++---------- src/lib/browser-mcp.test.ts | 10 ++++++++++ src/lib/browser-mcp.ts | 5 ++++- src/lib/verify-probe.test.ts | 23 ++++++++++++++++++++++- src/lib/verify-probe.ts | 32 ++++++++++++++++++++++++++++++-- 7 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/commands/verify/truth.ts b/src/commands/verify/truth.ts index 4bdccf3..e4f0443 100644 --- a/src/commands/verify/truth.ts +++ b/src/commands/verify/truth.ts @@ -3,7 +3,7 @@ import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputInfo } from '../../lib/output.js'; import { shutdownAnalytics, trackVerifyFinding } from '../../lib/analytics.js'; -import { classifyTruth, rawsqlRows } from '../../lib/verify-probe.js'; +import { classifyTruth, isReadOnlyQuery, rawsqlRows } from '../../lib/verify-probe.js'; export function registerVerifyTruthCommand(verify: Command): void { verify @@ -18,6 +18,11 @@ export function registerVerifyTruthCommand(verify: Command): void { try { const config = getProjectConfig(); if (!config) throw new CLIError('No linked project found — run `insforge link` first.'); + if (!isReadOnlyQuery(opts.query)) { + throw new CLIError( + 'verify truth runs a single read-only query — it must start with SELECT or WITH and not chain statements.', + ); + } const rows = await rawsqlRows(config.oss_host, config.api_key, opts.query); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 57daba6..28607d5 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -148,6 +148,7 @@ export interface VerifyFinding { */ export function trackVerifyFinding(finding: VerifyFinding, config: ProjectConfig): void { captureEvent(config.project_id, 'verify_finding', { + ...finding.evidence, finding_type: finding.type, passed: finding.type === 'none', table: finding.table, @@ -155,6 +156,5 @@ export function trackVerifyFinding(finding: VerifyFinding, config: ProjectConfig status: finding.status, endpoint: finding.endpoint, message: finding.message, - ...finding.evidence, }); } diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index c2cd4c6..a564216 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -312,16 +312,8 @@ export async function getBranchApi(branchId: string, apiUrl?: string): Promise { - await platformFetch( - `/projects/v1/branches/${branchId}`, - { method: 'DELETE', ...(opts?.ignoreNotFound ? { passThroughStatuses: [404] } : {}) }, - apiUrl, - ); +export async function deleteBranchApi(branchId: string, apiUrl?: string): Promise { + await platformFetch(`/projects/v1/branches/${branchId}`, { method: 'DELETE' }, apiUrl); } /** diff --git a/src/lib/browser-mcp.test.ts b/src/lib/browser-mcp.test.ts index dbd7d97..e827ef8 100644 --- a/src/lib/browser-mcp.test.ts +++ b/src/lib/browser-mcp.test.ts @@ -51,6 +51,16 @@ describe('mergeJsonMcp', () => { expect(mergeJsonMcp(file, 'servers', HEADLESS_SERVER)).toBe(true); expect(read().servers['playwright']).toEqual(HEADLESS_SERVER); }); + + it('starts fresh on valid-but-non-object JSON (array / null / primitive)', () => { + for (const bad of ['[1,2,3]', 'null', '"a string"', '42']) { + const f = join(dir, `${bad.replace(/\W/g, '')}.json`); + writeFileSync(f, bad); + expect(mergeJsonMcp(f, 'mcpServers', HEADLESS_SERVER)).toBe(true); + // No crash, no silent loss — server is written under a fresh object. + expect(JSON.parse(readFileSync(f, 'utf-8')).mcpServers['playwright']).toEqual(HEADLESS_SERVER); + } + }); }); describe('ensureCodexToml', () => { diff --git a/src/lib/browser-mcp.ts b/src/lib/browser-mcp.ts index 7d45e8d..ec5634d 100644 --- a/src/lib/browser-mcp.ts +++ b/src/lib/browser-mcp.ts @@ -30,7 +30,10 @@ export function mergeJsonMcp( let config: Record> = {}; if (existsSync(file)) { try { - config = JSON.parse(readFileSync(file, 'utf-8')) as typeof config; + const parsed = JSON.parse(readFileSync(file, 'utf-8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = parsed as typeof config; + } } catch { config = {}; } diff --git a/src/lib/verify-probe.test.ts b/src/lib/verify-probe.test.ts index aa71b84..1fe03bb 100644 --- a/src/lib/verify-probe.test.ts +++ b/src/lib/verify-probe.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { classifyRls, classifyTruth } from './verify-probe.js'; +import { classifyRls, classifyTruth, isReadOnlyQuery } from './verify-probe.js'; describe('classifyRls', () => { it('flags rls_leak when B reads any of A\'s rows', () => { @@ -48,3 +48,24 @@ describe('classifyTruth', () => { expect(classifyTruth(null, '').type).toBe('none'); }); }); + +describe('isReadOnlyQuery', () => { + it('allows SELECT / WITH (any case, leading whitespace, trailing semicolon)', () => { + expect(isReadOnlyQuery('select 1')).toBe(true); + expect(isReadOnlyQuery('SELECT * FROM t')).toBe(true); + expect(isReadOnlyQuery(' with c as (select 1) select * from c')).toBe(true); + expect(isReadOnlyQuery('select 1;')).toBe(true); + }); + + it('rejects writes / DDL', () => { + expect(isReadOnlyQuery('delete from users')).toBe(false); + expect(isReadOnlyQuery('UPDATE accounts SET balance = 0')).toBe(false); + expect(isReadOnlyQuery('insert into t values (1)')).toBe(false); + expect(isReadOnlyQuery('drop table t')).toBe(false); + }); + + it('rejects statement chaining', () => { + expect(isReadOnlyQuery('select 1; delete from users')).toBe(false); + expect(isReadOnlyQuery('select 1; update t set x = 1')).toBe(false); + }); +}); diff --git a/src/lib/verify-probe.ts b/src/lib/verify-probe.ts index 0b7645f..428974f 100644 --- a/src/lib/verify-probe.ts +++ b/src/lib/verify-probe.ts @@ -52,6 +52,20 @@ export function classifyTruth( }; } +/** + * A query is safe for `verify truth` only if it's a single read — starts with SELECT or + * WITH and chains no further statements (a trailing `;` is fine). Guards against an + * agent-generated destructive query (`DELETE FROM …`, `…; UPDATE …`) running with the + * admin key. Not a full SQL parser, but it blocks the common destructive shapes. + */ +export function isReadOnlyQuery(query: string): boolean { + const q = query.trim(); + if (!/^(select|with)\b/i.test(q)) return false; + // No statement chaining beyond a single trailing semicolon. + if (q.replace(/;\s*$/, '').includes(';')) return false; + return true; +} + // ---- fetch wiring (not unit-tested; the verdicts above are) ---- function extractToken(j: unknown): string { @@ -65,12 +79,21 @@ function extractRows(j: unknown): unknown[] { return obj?.data ?? obj?.records ?? obj?.rows ?? []; } +/** Throw on a non-2xx response so a backend error (expired key, bad SQL, 500) isn't read + * as an empty/zero result — which would masquerade as a passing probe. */ +async function assertOk(res: Response, what: string): Promise { + if (res.ok) return; + const body = await res.text().catch(() => ''); + throw new Error(`${what} failed (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`); +} + export async function login(baseUrl: string, email: string, password: string): Promise { const res = await fetch(`${baseUrl}/api/auth/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); + await assertOk(res, `login (${email})`); return extractToken(await res.json().catch(() => ({}))); } @@ -79,6 +102,7 @@ export async function getAnonKey(baseUrl: string, adminKey: string): Promise ({}))); } @@ -88,10 +112,13 @@ export async function rawsqlRows(baseUrl: string, adminKey: string, query: strin headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminKey}` }, body: JSON.stringify({ query, params: [] }), }); + await assertOk(res, 'rawsql query'); return extractRows(await res.json().catch(() => ({}))); } -/** Count rows from the data API. A 401/403 (or any non-2xx) counts as 0 rows. */ +/** Count rows from the data API. A 401/403 (RLS/auth blocked) counts as 0 rows — the + * expected "can't see it" result; any other non-2xx throws so a transport/server error + * isn't read as 0 rows (which would be a false isolation pass). */ export async function recordsCount( baseUrl: string, table: string, @@ -103,6 +130,7 @@ export async function recordsCount( const headers: Record = { apikey: anon }; if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(url, { headers }); - if (!res.ok) return 0; + if (res.status === 401 || res.status === 403) return 0; + await assertOk(res, `data API read (${table})`); return extractRows(await res.json().catch(() => [])).length; } From 1452c8644ab91ca5687ebbdce10a524bbe271409 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 18 Jun 2026 12:36:43 -0700 Subject: [PATCH 12/12] fix(verify): block destructive DML hidden inside a CTE in isReadOnlyQuery --- src/lib/verify-probe.test.ts | 6 ++++++ src/lib/verify-probe.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/src/lib/verify-probe.test.ts b/src/lib/verify-probe.test.ts index 1fe03bb..0753496 100644 --- a/src/lib/verify-probe.test.ts +++ b/src/lib/verify-probe.test.ts @@ -68,4 +68,10 @@ describe('isReadOnlyQuery', () => { expect(isReadOnlyQuery('select 1; delete from users')).toBe(false); expect(isReadOnlyQuery('select 1; update t set x = 1')).toBe(false); }); + + it('rejects DML hidden inside a CTE (WITH … DELETE/UPDATE/INSERT … SELECT)', () => { + expect(isReadOnlyQuery('with x as (delete from users returning id) select id from x')).toBe(false); + expect(isReadOnlyQuery('WITH x AS (UPDATE t SET c = 1 RETURNING id) SELECT * FROM x')).toBe(false); + expect(isReadOnlyQuery('with x as (insert into t values (1) returning id) select id from x')).toBe(false); + }); }); diff --git a/src/lib/verify-probe.ts b/src/lib/verify-probe.ts index 428974f..cc8c394 100644 --- a/src/lib/verify-probe.ts +++ b/src/lib/verify-probe.ts @@ -63,6 +63,7 @@ export function isReadOnlyQuery(query: string): boolean { if (!/^(select|with)\b/i.test(q)) return false; // No statement chaining beyond a single trailing semicolon. if (q.replace(/;\s*$/, '').includes(';')) return false; + if (/\b(insert|update|delete|truncate|drop|alter|create|grant|revoke)\b/i.test(q)) return false; return true; }