diff --git a/package-lock.json b/package-lock.json index 3c0a4b0..4d3d5a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "commander": "^13.1.0", "open": "^10.1.0", "picocolors": "^1.1.1", - "posthog-node": "^5.28.9" + "posthog-node": "^5.28.9", + "smol-toml": "^1.6.1" }, "bin": { "insforge": "dist/index.js" @@ -4195,6 +4196,18 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index 96e4461..fd91816 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "commander": "^13.1.0", "open": "^10.1.0", "picocolors": "^1.1.1", - "posthog-node": "^5.28.9" + "posthog-node": "^5.28.9", + "smol-toml": "^1.6.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/commands/config/apply.ts b/src/commands/config/apply.ts new file mode 100644 index 0000000..8651f21 --- /dev/null +++ b/src/commands/config/apply.ts @@ -0,0 +1,97 @@ +// CLI/src/commands/config/apply.ts +import type { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { ossFetch } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { parseConfigToml } from '../../lib/config-toml.js'; +import { diffConfig, type DiffChange } from '../../lib/config-diff.js'; +import { formatPlan } from '../../lib/config-format.js'; +import type { InsforgeConfig } from '../../lib/config-schema.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +export function registerConfigApplyCommand(cfg: Command): void { + cfg + .command('apply') + .description('Apply insforge.toml to the live project') + .option('--file ', 'path to insforge.toml', 'insforge.toml') + .option('--dry-run', 'show plan, do not apply') + .option('--auto-approve', 'skip confirmation prompt') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const tomlPath = resolve(process.cwd(), opts.file); + const tomlSource = readFileSync(tomlPath, 'utf8'); + const file = parseConfigToml(tomlSource); + + const res = await ossFetch('/api/metadata'); + const raw = (await res.json()) as { + auth?: { allowedRedirectUrls?: string[] }; + }; + const live: InsforgeConfig = { + auth: { allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [] }, + }; + + const result = diffConfig({ live, file }); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatPlan(result)); + } + + if (result.changes.length === 0 || opts.dryRun) { + await reportCliUsage('cli.config.apply', true); + return; + } + + if (!opts.autoApprove && !json) { + const ok = await p.confirm({ + message: 'Apply these changes?', + initialValue: false, + }); + if (!ok || p.isCancel(ok)) { + console.log('Aborted.'); + await reportCliUsage('cli.config.apply', true); + return; + } + } + + for (const change of result.changes) { + await applyChange(change, file); + } + + if (json) { + console.log(JSON.stringify({ applied: true, changes: result.changes })); + } else { + const s = result.summary; + console.log( + `${pc.green('✓')} Applied (${s.add} added, ${s.modify} modified, ${s.remove} removed)`, + ); + } + await reportCliUsage('cli.config.apply', true); + } catch (err) { + await reportCliUsage('cli.config.apply', false); + handleError(err, json); + } + }); +} + +async function applyChange( + change: DiffChange, + file: InsforgeConfig, +): Promise { + if (change.section === 'auth' && change.key === 'allowed_redirect_urls') { + await ossFetch('/api/auth/config', { + method: 'PUT', + body: JSON.stringify({ allowedRedirectUrls: change.to }), + }); + return; + } + throw new Error(`Unsupported change type: ${change.section}.${change.key}`); +} diff --git a/src/commands/config/export.ts b/src/commands/config/export.ts new file mode 100644 index 0000000..93bb8f6 --- /dev/null +++ b/src/commands/config/export.ts @@ -0,0 +1,62 @@ +// CLI/src/commands/config/export.ts +import type { Command } from 'commander'; +import { writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { ossFetch } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { stringifyConfigToml } from '../../lib/config-toml.js'; +import type { InsforgeConfig } from '../../lib/config-schema.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +export function registerConfigExportCommand(cfg: Command): void { + cfg + .command('export') + .description('Pull live project config and write insforge.toml') + .option('--out ', 'output path', 'insforge.toml') + .option('--force', 'overwrite without confirmation') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const target = resolve(process.cwd(), opts.out); + if (existsSync(target) && !opts.force) { + const ok = await p.confirm({ + message: `${opts.out} exists. Overwrite?`, + initialValue: false, + }); + if (!ok || p.isCancel(ok)) { + console.log('Aborted.'); + return; + } + } + + const res = await ossFetch('/api/metadata'); + const raw = (await res.json()) as { + auth?: { allowedRedirectUrls?: string[] }; + }; + + const config: InsforgeConfig = { + auth: { + allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [], + }, + }; + + const toml = stringifyConfigToml(config); + writeFileSync(target, toml, 'utf8'); + + if (json) { + console.log(JSON.stringify({ written: target, config })); + } else { + console.log(`${pc.green('✓')} Wrote ${target}`); + } + await reportCliUsage('cli.config.export', true); + } catch (err) { + await reportCliUsage('cli.config.export', false); + handleError(err, json); + } + }); +} diff --git a/src/commands/config/index.ts b/src/commands/config/index.ts new file mode 100644 index 0000000..a669d4f --- /dev/null +++ b/src/commands/config/index.ts @@ -0,0 +1,14 @@ +// CLI/src/commands/config/index.ts +import type { Command } from 'commander'; +import { registerConfigExportCommand } from './export.js'; +import { registerConfigPlanCommand } from './plan.js'; +import { registerConfigApplyCommand } from './apply.js'; + +export function registerConfigCommand(program: Command): void { + const cfg = program + .command('config') + .description('Manage insforge.toml (declarative project configuration)'); + registerConfigExportCommand(cfg); + registerConfigPlanCommand(cfg); + registerConfigApplyCommand(cfg); +} diff --git a/src/commands/config/plan.ts b/src/commands/config/plan.ts new file mode 100644 index 0000000..043dde7 --- /dev/null +++ b/src/commands/config/plan.ts @@ -0,0 +1,50 @@ +// CLI/src/commands/config/plan.ts +import type { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ossFetch } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { parseConfigToml } from '../../lib/config-toml.js'; +import { diffConfig } from '../../lib/config-diff.js'; +import { formatPlan } from '../../lib/config-format.js'; +import type { InsforgeConfig } from '../../lib/config-schema.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +export function registerConfigPlanCommand(cfg: Command): void { + cfg + .command('plan') + .description('Show diff between insforge.toml and live project state') + .option('--file ', 'path to insforge.toml', 'insforge.toml') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const tomlPath = resolve(process.cwd(), opts.file); + const tomlSource = readFileSync(tomlPath, 'utf8'); + const file = parseConfigToml(tomlSource); + + const res = await ossFetch('/api/metadata'); + const raw = (await res.json()) as { + auth?: { allowedRedirectUrls?: string[] }; + }; + const live: InsforgeConfig = { + auth: { allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [] }, + }; + + const result = diffConfig({ live, file }); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Plan for insforge.toml (file: ${opts.file}):\n`); + console.log(formatPlan(result)); + } + await reportCliUsage('cli.config.plan', true); + } catch (err) { + await reportCliUsage('cli.config.plan', false); + handleError(err, json); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 2b74fd0..1c2edc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,7 @@ import { registerMetadataCommand } from './commands/metadata.js'; import { registerDiagnoseCommands } from './commands/diagnose/index.js'; import { registerPaymentsCommands } from './commands/payments/index.js'; import { registerPosthogSetupCommand } from './commands/posthog/setup.js'; +import { registerConfigCommand } from './commands/config/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')) as { version: string }; @@ -217,6 +218,9 @@ registerSchedulesUpdateCommand(schedulesCmd); registerSchedulesDeleteCommand(schedulesCmd); registerSchedulesLogsCommand(schedulesCmd); +// Config commands +registerConfigCommand(program); + if (process.argv.length <= 2 && process.stdout.isTTY) { await showInteractiveMenu(); } else { diff --git a/src/lib/config-diff.test.ts b/src/lib/config-diff.test.ts new file mode 100644 index 0000000..afa50a0 --- /dev/null +++ b/src/lib/config-diff.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { diffConfig } from './config-diff.js'; + +describe('diffConfig', () => { + it('detects an array change in allowed_redirect_urls', () => { + const live = { auth: { allowed_redirect_urls: ['https://a.com'] } }; + const file = { auth: { allowed_redirect_urls: ['https://a.com', 'https://b.com'] } }; + expect(diffConfig({ live, file })).toEqual({ + changes: [ + { + section: 'auth', + op: 'modify', + key: 'allowed_redirect_urls', + from: ['https://a.com'], + to: ['https://a.com', 'https://b.com'], + }, + ], + summary: { add: 0, modify: 1, remove: 0, kept: 0 }, + }); + }); + + it('returns no changes for converged state', () => { + const same = { auth: { allowed_redirect_urls: ['https://a.com'] } }; + expect(diffConfig({ live: same, file: same })).toEqual({ + changes: [], + summary: { add: 0, modify: 0, remove: 0, kept: 0 }, + }); + }); + + it('treats missing field in file as no-op (no remove)', () => { + const live = { auth: { allowed_redirect_urls: ['https://a.com'] } }; + const file = {}; + expect(diffConfig({ live, file })).toEqual({ + changes: [], + summary: { add: 0, modify: 0, remove: 0, kept: 0 }, + }); + }); + + it('treats empty-array vs non-empty as a real change', () => { + const live = { auth: { allowed_redirect_urls: ['https://a.com'] } }; + const file = { auth: { allowed_redirect_urls: [] } }; + expect(diffConfig({ live, file }).changes).toEqual([ + { + section: 'auth', + op: 'modify', + key: 'allowed_redirect_urls', + from: ['https://a.com'], + to: [], + }, + ]); + }); +}); diff --git a/src/lib/config-diff.ts b/src/lib/config-diff.ts new file mode 100644 index 0000000..1206ccd --- /dev/null +++ b/src/lib/config-diff.ts @@ -0,0 +1,67 @@ +import type { InsforgeConfig } from './config-schema.js'; + +export type DiffChange = { + section: 'auth'; + op: 'modify'; + key: 'allowed_redirect_urls'; + from: string[]; + to: string[]; +}; + +export interface DiffSummary { + add: number; + modify: number; + remove: number; + kept: number; +} + +export interface DiffResult { + changes: DiffChange[]; + summary: DiffSummary; +} + +export interface DiffInput { + live: InsforgeConfig; + file: InsforgeConfig; +} + +/** + * Compute the changes the file would impose on the live state. + * v1 scope: auth.allowed_redirect_urls only. Default-keep for absent fields + * — if the file omits a section, live state is left alone. + */ +export function diffConfig({ live, file }: DiffInput): DiffResult { + const changes: DiffChange[] = []; + + const fileAuth = file.auth; + const liveAuth = live.auth ?? {}; + + if (fileAuth && 'allowed_redirect_urls' in fileAuth) { + const fromV = liveAuth.allowed_redirect_urls ?? []; + const toV = fileAuth.allowed_redirect_urls ?? []; + if (!arrayEquals(fromV, toV)) { + changes.push({ + section: 'auth', + op: 'modify', + key: 'allowed_redirect_urls', + from: fromV, + to: toV, + }); + } + } + + return { changes, summary: summarize(changes) }; +} + +function summarize(changes: DiffChange[]): DiffSummary { + const s: DiffSummary = { add: 0, modify: 0, remove: 0, kept: 0 }; + for (const c of changes) { + if (c.op === 'modify') s.modify++; + } + return s; +} + +function arrayEquals(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + return a.every((v, i) => v === b[i]); +} diff --git a/src/lib/config-format.test.ts b/src/lib/config-format.test.ts new file mode 100644 index 0000000..183f06a --- /dev/null +++ b/src/lib/config-format.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { formatPlan } from './config-format.js'; + +describe('formatPlan', () => { + it('renders a single-section plan', () => { + const out = formatPlan({ + changes: [ + { + section: 'auth', + op: 'modify', + key: 'allowed_redirect_urls', + from: ['https://a.com'], + to: ['https://a.com', 'https://b.com'], + }, + ], + summary: { add: 0, modify: 1, remove: 0, kept: 0 }, + }); + expect(out).toContain('auth:'); + expect(out).toContain('~ allowed_redirect_urls:'); + expect(out).toContain('["https://a.com"]'); + expect(out).toContain('["https://a.com","https://b.com"]'); + expect(out).toContain('0 add, 1 modify, 0 remove, 0 untracked kept.'); + }); + + it('renders no-change plans cleanly', () => { + const out = formatPlan({ + changes: [], + summary: { add: 0, modify: 0, remove: 0, kept: 0 }, + }); + expect(out).toContain('No changes. Live state matches insforge.toml.'); + }); +}); diff --git a/src/lib/config-format.ts b/src/lib/config-format.ts new file mode 100644 index 0000000..b06dc19 --- /dev/null +++ b/src/lib/config-format.ts @@ -0,0 +1,34 @@ +import type { DiffChange, DiffResult } from './config-diff.js'; + +export function formatPlan(result: DiffResult): string { + if (result.changes.length === 0) { + return 'No changes. Live state matches insforge.toml.'; + } + + const bySection = new Map(); + for (const c of result.changes) { + const arr = bySection.get(c.section) ?? []; + arr.push(c); + bySection.set(c.section, arr); + } + + const lines: string[] = []; + for (const [section, changes] of bySection) { + lines.push(` ${section}:`); + for (const c of changes) { + lines.push(` ${formatChange(c)}`); + } + lines.push(''); + } + + const s = result.summary; + lines.push( + `${s.add} add, ${s.modify} modify, ${s.remove} remove, ${s.kept} untracked kept.`, + ); + + return lines.join('\n'); +} + +function formatChange(c: DiffChange): string { + return `~ ${c.key}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`; +} diff --git a/src/lib/config-schema.ts b/src/lib/config-schema.ts new file mode 100644 index 0000000..4224803 --- /dev/null +++ b/src/lib/config-schema.ts @@ -0,0 +1,66 @@ +// CLI/src/lib/config-schema.ts + +/** + * The shape of insforge.toml after parsing. v1 MVP scope: only the + * [auth] allowed_redirect_urls field is wired. Every future section + * (SMTP, OAuth providers, deployments, etc.) extends this type. + */ +export interface InsforgeConfig { + project_id?: string; + auth?: AuthConfig; +} + +export interface AuthConfig { + allowed_redirect_urls?: string[]; +} + +export class ConfigValidationError extends Error { + constructor(public readonly path: string, message: string) { + super(`config.${path}: ${message}`); + this.name = 'ConfigValidationError'; + } +} + +/** + * Validates a parsed TOML object against the v1 schema. + * Throws ConfigValidationError with the path of the first violation. + */ +export function validateConfig(input: unknown): InsforgeConfig { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new ConfigValidationError('', 'must be an object'); + } + const obj = input as Record; + const out: InsforgeConfig = {}; + + if ('project_id' in obj) { + if (typeof obj.project_id !== 'string') { + throw new ConfigValidationError('project_id', 'must be a string'); + } + out.project_id = obj.project_id; + } + + if ('auth' in obj) out.auth = validateAuth(obj.auth); + + return out; +} + +function validateAuth(input: unknown): AuthConfig { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new ConfigValidationError('auth', 'must be an object'); + } + const obj = input as Record; + const out: AuthConfig = {}; + + if ('allowed_redirect_urls' in obj) { + const v = obj.allowed_redirect_urls; + if (!Array.isArray(v) || !v.every((u) => typeof u === 'string')) { + throw new ConfigValidationError( + 'auth.allowed_redirect_urls', + 'must be an array of strings', + ); + } + out.allowed_redirect_urls = v; + } + + return out; +} diff --git a/src/lib/config-secrets.test.ts b/src/lib/config-secrets.test.ts new file mode 100644 index 0000000..5c985b6 --- /dev/null +++ b/src/lib/config-secrets.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { parseEnvRef, validateSensitiveString } from './config-secrets.js'; +import { ConfigValidationError } from './config-schema.js'; + +describe('parseEnvRef', () => { + it('extracts the secret name from a well-formed env() reference', () => { + expect(parseEnvRef('env(GOOGLE_CLIENT_SECRET)')).toBe('GOOGLE_CLIENT_SECRET'); + expect(parseEnvRef('env(SMTP_PASSWORD)')).toBe('SMTP_PASSWORD'); + expect(parseEnvRef('env(_INTERNAL)')).toBe('_INTERNAL'); + }); + + it('returns null for literal values', () => { + expect(parseEnvRef('actual-secret-123')).toBeNull(); + expect(parseEnvRef('')).toBeNull(); + expect(parseEnvRef('env(lower_case)')).toBeNull(); + expect(parseEnvRef('env(WITH SPACE)')).toBeNull(); + expect(parseEnvRef('env()')).toBeNull(); + expect(parseEnvRef('something env(GOOD)')).toBeNull(); + expect(parseEnvRef('env(GOOD) and more')).toBeNull(); + }); +}); + +describe('validateSensitiveString', () => { + it('accepts well-formed env() references', () => { + expect( + validateSensitiveString( + 'email.smtp.password', + 'env(SMTP_PASSWORD)', + 'SMTP_PASSWORD', + ), + ).toBe('env(SMTP_PASSWORD)'); + }); + + it('rejects literal values with an actionable error', () => { + let caught: ConfigValidationError | null = null; + try { + validateSensitiveString( + 'email.smtp.password', + 'MyActualPassword', + 'SMTP_PASSWORD', + ); + } catch (err) { + caught = err as ConfigValidationError; + } + expect(caught).toBeInstanceOf(ConfigValidationError); + expect(caught!.path).toBe('email.smtp.password'); + expect(caught!.message).toContain('sensitive field must be an env() reference'); + expect(caught!.message).toContain('insforge secrets add SMTP_PASSWORD'); + expect(caught!.message).toContain('password = "env(SMTP_PASSWORD)"'); + }); + + it('rejects malformed env() references (lowercase, empty, etc.)', () => { + expect(() => + validateSensitiveString('x.y', 'env(lower_case)', 'GOOD_NAME'), + ).toThrow(ConfigValidationError); + expect(() => validateSensitiveString('x.y', 'env()', 'GOOD_NAME')).toThrow( + ConfigValidationError, + ); + }); + + it('rejects non-string values', () => { + expect(() => validateSensitiveString('x.y', 123, 'GOOD_NAME')).toThrow( + /must be a string/, + ); + expect(() => validateSensitiveString('x.y', null, 'GOOD_NAME')).toThrow( + /must be a string/, + ); + expect(() => + validateSensitiveString('x.y', undefined, 'GOOD_NAME'), + ).toThrow(/must be a string/); + }); +}); diff --git a/src/lib/config-secrets.ts b/src/lib/config-secrets.ts new file mode 100644 index 0000000..51b8518 --- /dev/null +++ b/src/lib/config-secrets.ts @@ -0,0 +1,72 @@ +// CLI/src/lib/config-secrets.ts +// +// Sensitive-field validation for insforge.toml. +// +// Sensitive fields (OAuth client_secret, SMTP password, S3 secret key, etc.) +// MUST be `env(NAME)` references in the TOML — never literal values. This is +// the same convention used by Vercel (vercel.json), Fly.io (fly.toml), and +// Supabase (supabase/config.toml). Rejecting literals at validation time +// makes the file unconditionally safe to commit to git. +// +// The actual secret VALUES live in the project's secrets store +// (`insforge secrets add NAME `). The server resolves env() refs at +// apply time and fails loudly if the named secret is missing. +// +// This module is registered in config-schema.ts when sensitive fields are +// added to the TOML surface (SMTP password, OAuth client_secret, etc.). +// The MVP scope ([auth] allowed_redirect_urls only) has zero sensitive +// fields, so the validator is foundation-laid-but-not-yet-used. The first +// section to use it will be [email.smtp] or [auth.providers.]. + +import { ConfigValidationError } from './config-schema.js'; + +/** Matches `env(NAME)` where NAME is upper-snake-case. */ +const ENV_REF_PATTERN = /^env\(([A-Z_][A-Z0-9_]*)\)$/; + +/** + * Returns the secret name (e.g. "GOOGLE_CLIENT_SECRET") if the value is a + * well-formed env() reference. Returns null otherwise. + */ +export function parseEnvRef(value: string): string | null { + const match = value.match(ENV_REF_PATTERN); + return match ? match[1] : null; +} + +/** + * Validate a sensitive string field. Returns the env() reference unchanged + * if it's well-formed; otherwise throws ConfigValidationError with an + * actionable error message that names the exact `insforge secrets add` + * command the user should run. + * + * @param path The dotted path of the field (e.g. "email.smtp.password"), + * used in the error message. + * @param value The value parsed from TOML — typically a string, but we + * accept unknown to keep the validator caller simple. + * @param suggestedSecretName The conventional name to suggest in the error + * if the user pasted a literal (e.g. "SMTP_PASSWORD"). Should + * be UPPER_SNAKE_CASE. + */ +export function validateSensitiveString( + path: string, + value: unknown, + suggestedSecretName: string, +): string { + if (typeof value !== 'string') { + throw new ConfigValidationError(path, 'must be a string'); + } + + if (parseEnvRef(value) !== null) { + return value; + } + + // Literal value (or malformed env() ref). Reject with an actionable error. + throw new ConfigValidationError( + path, + `sensitive field must be an env() reference; got literal value.\n` + + ` fix:\n` + + ` 1. insforge secrets add ${suggestedSecretName} ""\n` + + ` 2. update insforge.toml:\n` + + ` ${path.split('.').pop()} = "env(${suggestedSecretName})"\n` + + ` 3. insforge config apply`, + ); +} diff --git a/src/lib/config-toml.test.ts b/src/lib/config-toml.test.ts new file mode 100644 index 0000000..21ff312 --- /dev/null +++ b/src/lib/config-toml.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { parseConfigToml, stringifyConfigToml } from './config-toml.js'; + +describe('parseConfigToml', () => { + it('parses MVP fields end-to-end', () => { + const toml = ` +project_id = "proj-abc" + +[auth] +allowed_redirect_urls = ["https://app.example.com", "http://localhost:3000"] +`; + expect(parseConfigToml(toml)).toEqual({ + project_id: 'proj-abc', + auth: { + allowed_redirect_urls: ['https://app.example.com', 'http://localhost:3000'], + }, + }); + }); + + it('throws ConfigValidationError on bad type', () => { + expect(() => + parseConfigToml('[auth]\nallowed_redirect_urls = "not-an-array"'), + ).toThrow(/allowed_redirect_urls.*array of strings/); + }); + + it('throws on malformed TOML with a clear message', () => { + expect(() => parseConfigToml('[auth\nbroken')).toThrow(/TOML parse error/); + }); + + it('accepts an empty config', () => { + expect(parseConfigToml('')).toEqual({}); + }); +}); + +describe('stringifyConfigToml', () => { + it('round-trips a config through stringify → parse', () => { + const original = { + project_id: 'proj-abc', + auth: { allowed_redirect_urls: ['https://a.com', 'http://localhost:3000'] }, + }; + expect(parseConfigToml(stringifyConfigToml(original))).toEqual(original); + }); + + it('omits sections that are undefined', () => { + const out = stringifyConfigToml({ project_id: 'proj-x' }); + expect(out).toContain('project_id'); + expect(out).not.toContain('[auth]'); + }); +}); diff --git a/src/lib/config-toml.ts b/src/lib/config-toml.ts new file mode 100644 index 0000000..7ddfef7 --- /dev/null +++ b/src/lib/config-toml.ts @@ -0,0 +1,38 @@ +import * as smolToml from 'smol-toml'; +import { validateConfig, type InsforgeConfig } from './config-schema.js'; + +export function parseConfigToml(input: string): InsforgeConfig { + let parsed: unknown; + try { + parsed = smolToml.parse(input); + } catch (err) { + throw new Error(`TOML parse error: ${(err as Error).message}`); + } + return validateConfig(parsed); +} + +/** + * Render a normalized config back to TOML. Section ordering is deterministic + * (project_id → auth) so diffs are stable across runs of `insforge config export`. + */ +export function stringifyConfigToml(config: InsforgeConfig): string { + const lines: string[] = []; + + if (config.project_id !== undefined) { + lines.push(`project_id = ${JSON.stringify(config.project_id)}`); + lines.push(''); + } + + if (config.auth) { + lines.push('[auth]'); + if (config.auth.allowed_redirect_urls !== undefined) { + const urls = config.auth.allowed_redirect_urls + .map((u) => JSON.stringify(u)) + .join(', '); + lines.push(`allowed_redirect_urls = [${urls}]`); + } + lines.push(''); + } + + return lines.join('\n').replace(/\n+$/, '\n'); +}