Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@insforge/cli",
"version": "0.1.70",
"version": "0.1.71",
"description": "InsForge CLI - Command line tool for InsForge platform",
"type": "module",
"bin": {
Expand Down
80 changes: 79 additions & 1 deletion src/auth-providers/apply.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { extractEnvKeys, filterCollidingEnvLines } from './apply.js';
import { extractEnvKeys, extractEnvPairs, filterCollidingEnvLines, refreshStaleEnvDefaults } from './apply.js';

describe('extractEnvKeys', () => {
it('finds plain KEY=value lines', () => {
Expand Down Expand Up @@ -45,3 +45,81 @@ describe('filterCollidingEnvLines', () => {
expect(filtered).toBe(append);
});
});

describe('extractEnvPairs', () => {
it('returns KEY → value pairs', () => {
const m = extractEnvPairs('FOO=1\nBAR=hello world\n');
expect(m.get('FOO')).toBe('1');
expect(m.get('BAR')).toBe('hello world');
});
it('preserves URL-style values verbatim', () => {
const m = extractEnvPairs('DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/insforge\n');
expect(m.get('DATABASE_URL')).toBe('postgresql://postgres:postgres@127.0.0.1:5432/insforge');
});
});

describe('refreshStaleEnvDefaults', () => {
it('replaces user value when it matches manifest default and platform has real value', () => {
const existing = 'DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/insforge\nFOO=keep-me\n';
const defaults = new Map([
['DATABASE_URL', 'postgresql://postgres:postgres@127.0.0.1:5432/insforge'],
]);
const platform = new Map([
['DATABASE_URL', 'postgresql://postgres:secret@cloud.host:5432/db?sslmode=require'],
]);
const { updated, refreshed } = refreshStaleEnvDefaults(existing, defaults, platform);
expect(refreshed).toEqual(['DATABASE_URL']);
expect(updated).toContain('DATABASE_URL=postgresql://postgres:secret@cloud.host:5432/db?sslmode=require');
expect(updated).not.toContain('127.0.0.1');
expect(updated).toContain('FOO=keep-me');
});

it('preserves user value when it differs from the manifest default', () => {
const existing = 'DATABASE_URL=postgresql://customized@host/db\n';
const defaults = new Map([
['DATABASE_URL', 'postgresql://postgres:postgres@127.0.0.1:5432/insforge'],
]);
const platform = new Map([
['DATABASE_URL', 'postgresql://cloud@host/db?sslmode=require'],
]);
const { updated, refreshed } = refreshStaleEnvDefaults(existing, defaults, platform);
expect(refreshed).toEqual([]);
expect(updated).toContain('postgresql://customized@host/db');
});

it('skips refresh when platform has no real value (self-hosted, helper returned null)', () => {
const existing = 'DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/insforge\n';
const defaults = new Map([
['DATABASE_URL', 'postgresql://postgres:postgres@127.0.0.1:5432/insforge'],
]);
const platform = new Map([
['DATABASE_URL', 'postgresql://postgres:postgres@127.0.0.1:5432/insforge'],
]);
const { updated, refreshed } = refreshStaleEnvDefaults(existing, defaults, platform);
expect(refreshed).toEqual([]);
expect(updated).toBe(existing);
});

it('handles multiple keys, refreshing only the stale ones', () => {
const existing = [
'DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/insforge',
'BETTER_AUTH_SECRET=user-set-this-already',
'INSFORGE_JWT_SECRET=replace-with-output-of-cli-secrets-get-JWT_SECRET',
].join('\n') + '\n';
const defaults = new Map([
['DATABASE_URL', 'postgresql://postgres:postgres@127.0.0.1:5432/insforge'],
['BETTER_AUTH_SECRET', 'replace-with-32-random-bytes'],
['INSFORGE_JWT_SECRET', 'replace-with-output-of-cli-secrets-get-JWT_SECRET'],
]);
const platform = new Map([
['DATABASE_URL', 'postgresql://cloud@host/db?sslmode=require'],
['BETTER_AUTH_SECRET', 'random-bytes-1234'],
['INSFORGE_JWT_SECRET', 'real-jwt-secret-from-platform'],
]);
const { updated, refreshed } = refreshStaleEnvDefaults(existing, defaults, platform);
expect(refreshed.sort()).toEqual(['DATABASE_URL', 'INSFORGE_JWT_SECRET']);
expect(updated).toContain('DATABASE_URL=postgresql://cloud@host/db?sslmode=require');
expect(updated).toContain('BETTER_AUTH_SECRET=user-set-this-already');
expect(updated).toContain('INSFORGE_JWT_SECRET=real-jwt-secret-from-platform');
});
});
15 changes: 8 additions & 7 deletions src/commands/db/connection-string.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { Command } from 'commander';
import { ossFetch } from '../../lib/api/oss.js';
import { getDatabaseConnectionString } from '../../lib/api/oss.js';
import { requireAuth } from '../../lib/credentials.js';
import { handleError, getRootOpts } from '../../lib/errors.js';
import { handleError, getRootOpts, CLIError } from '../../lib/errors.js';
import { outputJson } from '../../lib/output.js';
import { reportCliUsage } from '../../lib/skills.js';
import type { ConnectionStringResponse } from '../../types.js';

export function registerDbConnectionStringCommand(dbCmd: Command): void {
dbCmd
Expand All @@ -14,12 +13,14 @@ export function registerDbConnectionStringCommand(dbCmd: Command): void {
const { json } = getRootOpts(cmd);
try {
await requireAuth();
const res = await ossFetch('/api/metadata/database-connection-string');
const body = (await res.json()) as ConnectionStringResponse;
const url = await getDatabaseConnectionString();
if (!url) {
throw new CLIError('Could not fetch the database connection string. This command requires a cloud project (self-hosted instances expose Postgres directly via your docker-compose).');
}
if (json) {
outputJson(body);
outputJson({ connectionURL: url });
} else {
console.log(body.connectionURL);
console.log(url);
}
await reportCliUsage('cli.db.connection-string', true);
} catch (err) {
Expand Down
35 changes: 35 additions & 0 deletions src/commands/projects/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ async function runNpmInstall(startMessage = 'Installing dependencies...'): Promi
}
}

// Run `npm run setup` if the auth-provider's packageJsonPatch added that
// script. The BA scaffold's setup chains schema → BA migrate → app SQL,
// hitting DATABASE_URL — so this is the step that actually creates tables.
// We always attempt it after a successful --auth overlay; if DATABASE_URL is
// wrong (network issue, missing IP allowlist, etc.) the user gets a clear
// error and can re-run `npm run setup` after fixing.
async function runNpmSetupIfPresent(): Promise<void> {
const pkgPath = path.join(process.cwd(), 'package.json');
let hasSetup = false;
try {
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as { scripts?: Record<string, string> };
hasSetup = typeof pkg.scripts?.setup === 'string';
} catch { /* no package.json or unreadable — skip */ }
if (!hasSetup) return;
Comment on lines +50 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don’t silently swallow parse/read failures for package.json

The empty catch hides malformed package.json and permission/read errors, making setup skips hard to diagnose.

Suggested fix
-  try {
-    const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as { scripts?: Record<string, string> };
-    hasSetup = typeof pkg.scripts?.setup === 'string';
-  } catch { /* no package.json or unreadable — skip */ }
+  try {
+    const raw = await fs.readFile(pkgPath, 'utf-8');
+    const pkg = JSON.parse(raw) as { scripts?: Record<string, string> };
+    hasSetup = typeof pkg.scripts?.setup === 'string';
+  } catch (err) {
+    const code = (err as NodeJS.ErrnoException).code;
+    if (code !== 'ENOENT') {
+      clack.log.warn('Could not read/parse package.json; skipping `npm run setup`.');
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/projects/link.ts` around lines 50 - 54, The catch block around
reading/parsing package.json currently swallows all errors; update the handler
in the try/catch that uses pkgPath, hasSetup, JSON.parse and fs.readFile to
surface failures instead of silently ignoring them — log or emit the actual
error (including pkgPath and the thrown error) via the project's logger or
console.error inside the catch, and then decide whether to rethrow or continue
to return early; preserve the existing behavior of returning when hasSetup is
false but ensure malformed JSON and permission/read errors are logged with clear
context.


const spinner = clack.spinner();
spinner.start('Running setup (schema + migrations)...');
try {
await execAsync('npm run setup', { cwd: process.cwd(), maxBuffer: 20 * 1024 * 1024 });
spinner.stop('Setup complete');
} catch (err) {
spinner.stop('Setup failed');
clack.log.warn(`npm run setup failed: ${(err as Error).message.split('\n')[0]}`);
clack.log.info('Inspect the error, fix DATABASE_URL or network access, then run `npm run setup` manually.');
}
}

export function registerProjectLinkCommand(program: Command): void {
program
.command('link')
Expand Down Expand Up @@ -162,6 +189,9 @@ export function registerProjectLinkCommand(program: Command): void {

if (templateDownloaded && !json) {
await runNpmInstall();
if (opts.auth) {
await runNpmSetupIfPresent();
}
}

await installSkills(json);
Expand Down Expand Up @@ -214,6 +244,7 @@ export function registerProjectLinkCommand(program: Command): void {
// "Cannot find package 'pg'".
if (result.packageJsonPatched && !json) {
await runNpmInstall('Installing new dependencies...');
await runNpmSetupIfPresent();
}
if (!json) clack.note(result.nextSteps, "What's next");
} catch (err) {
Expand Down Expand Up @@ -404,6 +435,9 @@ export function registerProjectLinkCommand(program: Command): void {

if (templateDownloaded && !json) {
await runNpmInstall();
if (opts.auth) {
await runNpmSetupIfPresent();
}
}

// Install agent skills inside the project directory
Expand Down Expand Up @@ -439,6 +473,7 @@ export function registerProjectLinkCommand(program: Command): void {
// the direct-OSS bare-overlay path above.
if (result.packageJsonPatched && !json) {
await runNpmInstall('Installing new dependencies...');
await runNpmSetupIfPresent();
}

if (!json) clack.note(result.nextSteps, "What's next");
Expand Down
30 changes: 30 additions & 0 deletions src/lib/api/oss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { spliceDatabasePassword } from './oss.js';

describe('spliceDatabasePassword', () => {
// Real shape from cloud `/api/metadata/database-connection-string`
const masked = 'postgresql://postgres:********@b4jh2kvi.us-east.database.insforge.app:5432/insforge?sslmode=require';

it('replaces the masked password with the real one', () => {
const result = spliceDatabasePassword(masked, '66666b99c46288a34220009437d8a3c2');
expect(result).toBe('postgresql://postgres:66666b99c46288a34220009437d8a3c2@b4jh2kvi.us-east.database.insforge.app:5432/insforge?sslmode=require');
expect(result).not.toContain('********');
});

it('preserves the rest of the URL exactly (host, port, db, query)', () => {
const result = spliceDatabasePassword(masked, 'pw');
expect(result).toContain('@b4jh2kvi.us-east.database.insforge.app:5432/insforge?sslmode=require');
});

it('handles passwords containing special characters', () => {
// Backend already URL-encodes special chars; we just inject verbatim.
const result = spliceDatabasePassword(masked, 'p%40ss%3Aword');
expect(result).toContain(':p%40ss%3Aword@');
});

it('only replaces the first `://user:...@` block (in case the URL has @ elsewhere)', () => {
const m = 'postgresql://postgres:********@db.host.app:5432/insforge?options=user%3D%40admin';
const result = spliceDatabasePassword(m, 'realpw');
expect(result).toBe('postgresql://postgres:realpw@db.host.app:5432/insforge?options=user%3D%40admin');
});
Comment on lines +25 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Edge-case test description and fixture are misaligned

Line 26 encodes @ as %40, so this test does not actually verify behavior when a literal @ appears elsewhere in the URL, despite the test name claiming it does.

Suggested test fixture adjustment
-  it('only replaces the first `://user:...@` block (in case the URL has @ elsewhere)', () => {
-    const m = 'postgresql://postgres:********@db.host.app:5432/insforge?options=user%3D%40admin';
+  it('only replaces the first `://user:...@` block (in case the URL has @ elsewhere)', () => {
+    const m = 'postgresql://postgres:********@db.host.app:5432/insforge?note=owner@team';
     const result = spliceDatabasePassword(m, 'realpw');
-    expect(result).toBe('postgresql://postgres:realpw@db.host.app:5432/insforge?options=user%3D%40admin');
+    expect(result).toBe('postgresql://postgres:realpw@db.host.app:5432/insforge?note=owner@team');
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('only replaces the first `://user:...@` block (in case the URL has @ elsewhere)', () => {
const m = 'postgresql://postgres:********@db.host.app:5432/insforge?options=user%3D%40admin';
const result = spliceDatabasePassword(m, 'realpw');
expect(result).toBe('postgresql://postgres:realpw@db.host.app:5432/insforge?options=user%3D%40admin');
});
it('only replaces the first `://user:...@` block (in case the URL has @ elsewhere)', () => {
const m = 'postgresql://postgres:********@db.host.app:5432/insforge?note=owner@team';
const result = spliceDatabasePassword(m, 'realpw');
expect(result).toBe('postgresql://postgres:realpw@db.host.app:5432/insforge?note=owner@team');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/api/oss.test.ts` around lines 25 - 29, The test "only replaces the
first `://user:...@` block..." uses an encoded '%40' so it doesn't exercise a
literal '@' elsewhere; update the fixture in the test that calls
spliceDatabasePassword to include a raw '@' later in the URL (e.g., in the query
or path) instead of '%40' so the test verifies only the first credential block
is replaced by spliceDatabasePassword; locate the failing test case around the
call to spliceDatabasePassword and change the input string to include a literal
'@' outside the credential portion while keeping the expected result
accordingly.

});
37 changes: 29 additions & 8 deletions src/lib/api/oss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,37 @@ export async function getJwtSecret(): Promise<string | null> {
}
}

// Splice the real password into a masked Postgres URL like
// `postgresql://postgres:********@host:5432/db?sslmode=require`. Replaces
// the segment between the first `://<user>:` and the next `@`. Exported
// for unit testing.
export function spliceDatabasePassword(maskedUrl: string, password: string): string {
return maskedUrl.replace(/^(postgresql:\/\/[^:]+:)[^@]+(@)/, `$1${password}$2`);
}

export async function getDatabaseConnectionString(): Promise<string | null> {
// Cloud-only: returns the project's Postgres URL (with sslmode). Self-hosted
// returns null so callers leave DATABASE_URL at the localhost default —
// self-hosters know their own connection string.
// Cloud-only: returns the project's Postgres URL with the real password
// substituted in. The platform's `/database-connection-string` endpoint
// masks the password (`postgresql://postgres:********@...`), so we also
// hit `/database-password` and splice the unmasked value in. Without this
// splice, callers (e.g., `link`'s .env.local auto-fill) would write a URL
// BA's pg pool can't authenticate with.
// Self-hosted returns null on either endpoint (PROJECT_ID not configured)
// so we fall back gracefully.
try {
const res = await ossFetch('/api/metadata/database-connection-string');
const data = await res.json() as { connectionURL?: string };
return typeof data.connectionURL === 'string' && data.connectionURL.length > 0
? data.connectionURL
: null;
const [urlRes, pwRes] = await Promise.all([
ossFetch('/api/metadata/database-connection-string'),
ossFetch('/api/metadata/database-password'),
]);
const urlBody = await urlRes.json() as { connectionURL?: string };
const pwBody = await pwRes.json() as { databasePassword?: string };

const masked = urlBody.connectionURL;
const password = pwBody.databasePassword;
if (typeof masked !== 'string' || !masked) return null;
if (typeof password !== 'string' || !password) return null;

return spliceDatabasePassword(masked, password);
} catch {
return null;
}
Expand Down
Loading