From c51d2ca5ce29bee92623a5dad95f83585dbd4bf0 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Wed, 6 May 2026 22:48:42 -0700 Subject: [PATCH 1/2] test(apply): cover extractEnvPairs + refreshStaleEnvDefaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 13 tests exercising the env-merge path that 0.1.68 introduced. Verifies: - KEY → value extraction including URL-style values - refresh fires when user value matches manifest default and platform has a different real value - refresh skips when user customized (value differs from default) - refresh skips when platform value equals default (self-hosted helper returns null) - multiple keys: only stale ones get refreshed, customized stay --- src/auth-providers/apply.test.ts | 80 +++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/auth-providers/apply.test.ts b/src/auth-providers/apply.test.ts index 76cefb9..74024fd 100644 --- a/src/auth-providers/apply.test.ts +++ b/src/auth-providers/apply.test.ts @@ -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', () => { @@ -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'); + }); +}); From a67a1ecb4dabcf4efffbfffbcd780917f153c061 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Thu, 7 May 2026 11:50:41 -0700 Subject: [PATCH 2/2] fix(db): unmask password + auto-run setup on --auth link (+ bump 0.1.71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cloud-link DX fixes that pair together: 1. Password unmask: the platform's `/api/metadata/database-connection-string` masks the password as `********`. Previously, we stored that masked URL in `.env.local` (via 0.1.67's auto-fill) and printed it from `db connection-string` — both unusable for actual connections. Now we also fetch `/api/metadata/database-password` and splice the unmasked value in. Self-hosted unchanged (helper returns null on either endpoint 500'ing for missing PROJECT_ID). 2. Auto-run setup: after `link --auth ` finishes the overlay + npm install, automatically run `npm run setup` if the auth-provider's packageJsonPatch added that script. The BA scaffold's setup chains schema → BA migrate → app SQL — so this is the step that actually creates the BA tables. Skipping it left users with a half-set-up project and another command to run. Failure-mode: if DATABASE_URL is wrong (network blocked, wrong IP, etc.) we surface the npm error and tell users to fix + re-run `npm run setup` manually. Doesn't fail the whole link. Net effect for cloud users: npx @insforge/cli link --project-id --template nextjs --auth better-auth → scaffold + npm install + DATABASE_URL pre-filled (real password) + schema/migrate/app SQL all run cd && npm run dev ← only command left Bumps 0.1.70 → 0.1.71. --- package-lock.json | 4 +-- package.json | 2 +- src/commands/db/connection-string.ts | 15 +++++------ src/commands/projects/link.ts | 35 ++++++++++++++++++++++++++ src/lib/api/oss.test.ts | 30 ++++++++++++++++++++++ src/lib/api/oss.ts | 37 ++++++++++++++++++++++------ 6 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 src/lib/api/oss.test.ts diff --git a/package-lock.json b/package-lock.json index 0721490..b0d207e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.68", + "version": "0.1.71", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.68", + "version": "0.1.71", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/package.json b/package.json index e514da5..e2ca4ab 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/commands/db/connection-string.ts b/src/commands/db/connection-string.ts index 4882197..03ad4bd 100644 --- a/src/commands/db/connection-string.ts +++ b/src/commands/db/connection-string.ts @@ -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 @@ -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) { diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index c9c3f56..ee7c1b7 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -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 { + 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 }; + hasSetup = typeof pkg.scripts?.setup === 'string'; + } catch { /* no package.json or unreadable — skip */ } + if (!hasSetup) return; + + 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') @@ -162,6 +189,9 @@ export function registerProjectLinkCommand(program: Command): void { if (templateDownloaded && !json) { await runNpmInstall(); + if (opts.auth) { + await runNpmSetupIfPresent(); + } } await installSkills(json); @@ -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) { @@ -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 @@ -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"); diff --git a/src/lib/api/oss.test.ts b/src/lib/api/oss.test.ts new file mode 100644 index 0000000..5a2fb03 --- /dev/null +++ b/src/lib/api/oss.test.ts @@ -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'); + }); +}); diff --git a/src/lib/api/oss.ts b/src/lib/api/oss.ts index a660601..0a1dea8 100644 --- a/src/lib/api/oss.ts +++ b/src/lib/api/oss.ts @@ -50,16 +50,37 @@ export async function getJwtSecret(): Promise { } } +// Splice the real password into a masked Postgres URL like +// `postgresql://postgres:********@host:5432/db?sslmode=require`. Replaces +// the segment between the first `://:` 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 { - // 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; }