diff --git a/src/commands/create.test.ts b/src/commands/create.test.ts index f48e0ea6..e3ae6385 100644 --- a/src/commands/create.test.ts +++ b/src/commands/create.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import * as path from 'node:path'; +import { detectRelink } from './create.js'; +import type { ProjectConfig } from '../types.js'; /** * Unit tests for create command logic extracted into pure functions. @@ -140,3 +142,30 @@ describe('create command: org auto-select logic', () => { expect(result.error).toBeNull(); }); }); + +describe('create command: silent relink detection', () => { + function makeConfig(projectId: string, projectName: string): ProjectConfig { + return { + project_id: projectId, + project_name: projectName, + org_id: 'org1', + appkey: 'key', + region: 'us-east', + api_key: 'api-key', + oss_host: 'https://key.us-east.insforge.app', + }; + } + + it('returns the previous link when the directory points at a different project', () => { + const previous = makeConfig('proj-prod', 'prod'); + expect(detectRelink(previous, 'proj-staging')).toBe(previous); + }); + + it('returns null when the directory is not linked', () => { + expect(detectRelink(null, 'proj-new')).toBeNull(); + }); + + it('returns null when the directory already points at the new project', () => { + expect(detectRelink(makeConfig('proj-new', 'new'), 'proj-new')).toBeNull(); + }); +}); diff --git a/src/commands/create.ts b/src/commands/create.ts index 7864093d..aa00a11b 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -14,7 +14,7 @@ import { } from '../lib/api/platform.js'; import { getAnonKey, runRawSql } from '../lib/api/oss.js'; import { applyAuthProvider, VALID_AUTH_PROVIDERS, type AuthProvider } from '../auth-providers/apply.js'; -import { getGlobalConfig, saveGlobalConfig, saveProjectConfig, getFrontendUrl, buildOssHost } from '../lib/config.js'; +import { getGlobalConfig, saveGlobalConfig, getProjectConfig, saveProjectConfig, getFrontendUrl, buildOssHost } from '../lib/config.js'; import { requireAuth } from '../lib/credentials.js'; import { handleError, getRootOpts, CLIError } from '../lib/errors.js'; import { outputJson } from '../lib/output.js'; @@ -136,6 +136,17 @@ async function animateBanner(): Promise { process.stderr.write('\n'); } +/** + * Returns the previously linked project config when `create` is about to + * overwrite `.insforge/project.json` with a DIFFERENT project (a silent + * relink), or null when the directory was unlinked or already points at + * the new project. + */ +export function detectRelink(previous: ProjectConfig | null, newProjectId: string): ProjectConfig | null { + if (previous && previous.project_id !== newProjectId) return previous; + return null; +} + function getDefaultProjectName(): string { const dirName = path.basename(process.cwd()); const sanitized = dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); @@ -355,6 +366,11 @@ export function registerCreateCommand(program: Command): void { // 6. Fetch API key and link project const apiKey = await getProjectApiKey(project.id, apiUrl); + // Blank projects link in cwd, so an existing .insforge/project.json + // pointing at another project is about to be silently overwritten — + // detect it BEFORE saving so we can warn loudly (otherwise later + // db query/export commands hit the wrong project unnoticed). + const previousLink = detectRelink(getProjectConfig(), project.id); const projectConfig: ProjectConfig = { project_id: project.id, project_name: project.name, @@ -369,6 +385,14 @@ export function registerCreateCommand(program: Command): void { s?.stop(`Project "${project.name}" created and linked`); + if (previousLink && !json) { + clack.log.warn( + `LINK CHANGED: this directory was linked to "${previousLink.project_name}" (${previousLink.project_id}) ` + + `and now points to "${project.name}" (${project.id}).\n` + + `Subsequent db/storage commands run here will target the new project.`, + ); + } + // 7. Download template or seed env for blank projects const githubTemplates = ['chatbot', 'crm', 'e-commerce', 'nextjs', 'react', 'todo']; if (opts.marketplace) { @@ -511,6 +535,14 @@ export function registerCreateCommand(program: Command): void { project: { id: project.id, name: project.name, appkey: project.appkey, region: project.region }, template, ...(dirName ? { directory: dirName } : {}), + ...(previousLink + ? { + linkChanged: { + previousProjectId: previousLink.project_id, + previousProjectName: previousLink.project_name, + }, + } + : {}), urls: { dashboard: dashboardUrl, ...(liveUrl ? { liveSite: liveUrl } : {}), diff --git a/src/commands/db/export.test.ts b/src/commands/db/export.test.ts new file mode 100644 index 00000000..542c4238 --- /dev/null +++ b/src/commands/db/export.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { registerDbExportCommand, extractExportContent } from './export.js'; + +vi.mock('../../lib/api/oss.js', () => ({ + ossFetch: vi.fn(), +})); +vi.mock('../../lib/credentials.js', () => ({ + requireAuth: vi.fn(async () => {}), +})); +vi.mock('../../lib/command-telemetry.js', () => ({ + trackCommandUsage: vi.fn(async () => {}), +})); + +import { ossFetch } from '../../lib/api/oss.js'; + +function makeProgram() { + const program = new Command().exitOverride(); + program.option('--json').option('--api-url '); + const dbCmd = program.command('db'); + registerDbExportCommand(dbCmd); + return program; +} + +function mockExportResponse(body: string) { + (ossFetch as unknown as ReturnType).mockResolvedValue({ + text: async () => body, + }); +} + +describe('extractExportContent', () => { + it('unwraps a { content } envelope', () => { + expect(extractExportContent({ format: 'sql', content: 'CREATE TABLE a ();' })).toBe( + 'CREATE TABLE a ();', + ); + }); + + it('unwraps a { data } envelope', () => { + expect(extractExportContent({ format: 'sql', data: 'CREATE TABLE a ();' })).toBe( + 'CREATE TABLE a ();', + ); + }); + + it('prefers content over data when both are present', () => { + expect(extractExportContent({ content: 'from-content', data: 'from-data' })).toBe( + 'from-content', + ); + }); + + it('returns null for non-string content/data', () => { + expect(extractExportContent({ format: 'json', data: { tables: [] } })).toBeNull(); + expect(extractExportContent({ rows: [1, 2, 3] })).toBeNull(); + }); +}); + +describe('db export -o', () => { + let dir: string; + + beforeEach(() => { + vi.clearAllMocks(); + dir = mkdtempSync(join(tmpdir(), 'insforge-export-test-')); + return () => rmSync(dir, { recursive: true, force: true }); + }); + + it('writes raw SQL when the backend returns a { format, data } envelope', async () => { + const sql = 'CREATE TABLE users (id uuid PRIMARY KEY);'; + mockExportResponse(JSON.stringify({ format: 'sql', data: sql, tables: ['users'] })); + const outFile = join(dir, 'dump.sql'); + + await makeProgram().parseAsync( + ['db', 'export', '--format', 'sql', '-o', outFile], + { from: 'user' }, + ); + + expect(readFileSync(outFile, 'utf-8')).toBe(sql); + }); + + it('writes raw SQL when the backend returns a { format, content } envelope', async () => { + const sql = 'CREATE TABLE posts (id serial);'; + mockExportResponse(JSON.stringify({ format: 'sql', content: sql, tables: ['posts'] })); + const outFile = join(dir, 'dump.sql'); + + await makeProgram().parseAsync( + ['db', 'export', '--format', 'sql', '-o', outFile], + { from: 'user' }, + ); + + expect(readFileSync(outFile, 'utf-8')).toBe(sql); + }); + + it('writes the response verbatim when it is not an envelope', async () => { + const raw = '-- raw sql dump\nCREATE TABLE t ();'; + mockExportResponse(raw); + const outFile = join(dir, 'dump.sql'); + + await makeProgram().parseAsync( + ['db', 'export', '--format', 'sql', '-o', outFile], + { from: 'user' }, + ); + + expect(readFileSync(outFile, 'utf-8')).toBe(raw); + }); +}); + +describe('db export --json', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('includes SQL content when the backend returns a { format, data } envelope', async () => { + const sql = 'CREATE TABLE users (id uuid PRIMARY KEY);'; + mockExportResponse(JSON.stringify({ format: 'sql', data: sql, timestamp: '2026-07-28' })); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await makeProgram().parseAsync( + ['--json', 'db', 'export', '--format', 'sql'], + { from: 'user' }, + ); + + expect(log).toHaveBeenCalledOnce(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ format: 'sql', content: sql }); + log.mockRestore(); + }); +}); diff --git a/src/commands/db/export.ts b/src/commands/db/export.ts index 01a28c62..2994015d 100644 --- a/src/commands/db/export.ts +++ b/src/commands/db/export.ts @@ -6,6 +6,17 @@ import { handleError, getRootOpts } from '../../lib/errors.js'; import { outputJson, outputSuccess } from '../../lib/output.js'; import { trackCommandUsage } from '../../lib/command-telemetry.js'; +/** + * The backend may wrap the export in a JSON envelope keyed `content` or + * `data` — unwrap either so `-o file.sql` writes raw SQL/JSON rather than + * the envelope itself. Returns null when the payload isn't a wrapper. + */ +export function extractExportContent(parsed: Record): string | null { + if (typeof parsed.content === 'string') return parsed.content; + if (typeof parsed.data === 'string') return parsed.data; + return null; +} + export function registerDbExportCommand(dbCmd: Command): void { dbCmd .command('export') @@ -43,13 +54,14 @@ export function registerDbExportCommand(dbCmd: Command): void { const raw = await res.text(); - // API may return JSON wrapper { format, content, tables } or raw SQL/JSON text + // API may return JSON wrapper { format, content|data, tables } or raw SQL/JSON text let content: string; let meta: { format?: string; tables?: string[] } | null = null; try { const parsed = JSON.parse(raw) as Record; - if (typeof parsed.content === 'string') { - content = parsed.content; + const wrapped = extractExportContent(parsed); + if (wrapped !== null) { + content = wrapped; meta = { format: parsed.format as string, tables: parsed.tables as string[] }; } else { content = raw; @@ -59,7 +71,7 @@ export function registerDbExportCommand(dbCmd: Command): void { } if (json) { - outputJson(meta ?? { content }); + outputJson(meta ? { ...meta, content } : { content }); await trackCommandUsage('db', 'export', true); return; }