Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/commands/create.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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();
});
});
34 changes: 33 additions & 1 deletion src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -136,6 +136,17 @@ async function animateBanner(): Promise<void> {
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, '');
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Malformed link aborts creation

When an existing .insforge/project.json contains malformed JSON, the new getProjectConfig() call throws after the remote project has been created but before saveProjectConfig() replaces the file, causing the command to exit with the remote project left unlinked from the working directory.

Knowledge Base Used: Project Lifecycle: Create, Link, List, and Branch

const projectConfig: ProjectConfig = {
project_id: project.id,
project_name: project.name,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 } : {}),
Expand Down
127 changes: 127 additions & 0 deletions src/commands/db/export.test.ts
Original file line number Diff line number Diff line change
@@ -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 <url>');
const dbCmd = program.command('db');
registerDbExportCommand(dbCmd);
return program;
}

function mockExportResponse(body: string) {
(ossFetch as unknown as ReturnType<typeof vi.fn>).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();
});
});
20 changes: 16 additions & 4 deletions src/commands/db/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string | null {
if (typeof parsed.content === 'string') return parsed.content;
if (typeof parsed.data === 'string') return parsed.data;
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Envelope detection matches raw JSON

A raw JSON export with a top-level string-valued data or content property is treated as an envelope, so the output retains only that property instead of preserving the complete JSON document; require envelope-specific metadata such as format before unwrapping it.

Knowledge Base Used: Database commands (insforge db ...)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

return null;
}

export function registerDbExportCommand(dbCmd: Command): void {
dbCmd
.command('export')
Expand Down Expand Up @@ -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<string, unknown>;
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;
Expand All @@ -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;
}
Expand Down
Loading