Skip to content
Closed
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
15 changes: 14 additions & 1 deletion package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions src/commands/config/apply.ts
Original file line number Diff line number Diff line change
@@ -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>', '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));
}
Comment on lines +42 to +46
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 | 🟠 Major | ⚡ Quick win

--json currently emits two separate JSON documents.

At Line 43 and Line 70, the command prints two top-level JSON payloads in a single run when changes are applied. That breaks parsers expecting one JSON document per invocation.

Suggested fix
-        if (json) {
-          console.log(JSON.stringify(result, null, 2));
-        } else {
+        if (!json) {
           console.log(formatPlan(result));
         }
@@
-        if (json) {
-          console.log(JSON.stringify({ applied: true, changes: result.changes }));
+        if (json) {
+          console.log(JSON.stringify({ plan: result, applied: true }, null, 2));
         } else {

Also applies to: 69-71

🤖 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/config/apply.ts` around lines 42 - 46, The command currently
prints two separate JSON payloads when the --json flag is used (the JSON print
of result at the console.log(JSON.stringify(result...)) and a second JSON output
later), which breaks single-document parsers; update the apply command so that
when the json flag is true it emits exactly one JSON document containing the
final output (merge or replace whatever intermediate JSON payloads into the
single canonical result object) and skip any additional
console.log(JSON.stringify(...)) or formatPlan(...) calls that would print a
second document; specifically change the logic around the json check in apply.ts
(the console.log(JSON.stringify(result...)) and the later JSON print near the
code that currently calls formatPlan(result)) so only one JSON.stringify(result)
is executed and non-JSON formatted output (formatPlan) is used only when json is
false.


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);
}
Comment on lines +65 to +67
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

Remove or underscore the unused file parameter.

Line 87 introduces an unused argument that already shows up in CI lint warnings. Either remove it from applyChange and its call site, or rename to _file if intentionally reserved.

Suggested fix
-        for (const change of result.changes) {
-          await applyChange(change, file);
-        }
+        for (const change of result.changes) {
+          await applyChange(change);
+        }
@@
-async function applyChange(
-  change: DiffChange,
-  file: InsforgeConfig,
-): Promise<void> {
+async function applyChange(change: DiffChange): Promise<void> {

Also applies to: 85-88

🤖 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/config/apply.ts` around lines 65 - 67, The call to
applyChange(result.changes) passes an unused file parameter; remove the unused
parameter from applyChange's signature and all its call sites (including the
loop that does await applyChange(change, file)) or, if you intend to reserve it,
rename the parameter to _file in the applyChange function and the call sites to
suppress linter warnings; update applyChange's definition and every place that
calls applyChange to match the new parameter list (or underscore name) so the
codebase compiles cleanly.


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<void> {
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}`);
}
62 changes: 62 additions & 0 deletions src/commands/config/export.ts
Original file line number Diff line number Diff line change
@@ -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 <path>', '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 ?? [],
},
Comment on lines +42 to +45
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 | 🟠 Major | ⚡ Quick win

Don’t synthesize allowed_redirect_urls: [] when metadata omits the field.
Line 44 conflates “missing” with “explicit empty,” which can produce unintended destructive diffs on apply.

Suggested patch
-        const config: InsforgeConfig = {
-          auth: {
-            allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [],
-          },
-        };
+        const redirects = raw.auth?.allowedRedirectUrls;
+        const config: InsforgeConfig =
+          redirects === undefined
+            ? {}
+            : {
+                auth: { allowed_redirect_urls: redirects },
+              };
📝 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
const config: InsforgeConfig = {
auth: {
allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [],
},
const redirects = raw.auth?.allowedRedirectUrls;
const config: InsforgeConfig =
redirects === undefined
? {}
: {
auth: { allowed_redirect_urls: redirects },
};
🤖 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/config/export.ts` around lines 42 - 45, The current construction
of const config: InsforgeConfig always emits auth.allowed_redirect_urls as an
empty array when raw.auth?.allowedRedirectUrls is missing; change the config
assembly so allowed_redirect_urls is only set when raw.auth?.allowedRedirectUrls
is defined (i.e. do not synthesize an empty array). Update the config/auth
object creation in export.ts (the const config and auth block) to conditionally
include allowed_redirect_urls (using a conditional spread or guard on
raw.auth?.allowedRedirectUrls !== undefined) so the field is omitted when the
source metadata omits it.

};

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);
}
});
}
14 changes: 14 additions & 0 deletions src/commands/config/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
50 changes: 50 additions & 0 deletions src/commands/config/plan.ts
Original file line number Diff line number Diff line change
@@ -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>', '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);
}
});
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions src/lib/config-diff.test.ts
Original file line number Diff line number Diff line change
@@ -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: [],
},
]);
});
});
Loading
Loading