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
19 changes: 16 additions & 3 deletions package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@insforge/cli",
"version": "0.1.74",
"version": "0.1.75",
"description": "InsForge CLI - Command line tool for InsForge platform",
"type": "module",
"bin": {
Expand Down 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
2 changes: 2 additions & 0 deletions src/commands/compute/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function registerComputeDeployCommand(computeCmd: Command): void {
const verb = existing ? 'updated' : 'deployed';
outputSuccess(`Service "${service.name}" ${verb} [${service.status}]`);
if (service.endpointUrl) console.log(` Endpoint: ${service.endpointUrl}`);
if (service.port !== undefined) console.log(` Port: ${service.port} (container must listen on this port)`);
}
await reportCliUsage('cli.compute.deploy', true);
return;
Expand Down Expand Up @@ -269,6 +270,7 @@ export function registerComputeDeployCommand(computeCmd: Command): void {
const verb = existing ? 'updated' : 'deployed';
outputSuccess(`Service "${service.name}" ${verb} [${service.status}]`);
if (service.endpointUrl) console.log(` Endpoint: ${service.endpointUrl}`);
if (service.port !== undefined) console.log(` Port: ${service.port} (container must listen on this port)`);
console.log(` Image: ${imageRef} (built remotely; no local image to clean up)`);
}

Expand Down
2 changes: 2 additions & 0 deletions src/commands/compute/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ export function registerComputeUpdateCommand(computeCmd: Command): void {
outputJson(service);
} else {
outputSuccess(`Service "${service.name}" updated [${service.status}]`);
if (service.endpointUrl) console.log(` Endpoint: ${service.endpointUrl}`);
if (service.port !== undefined) console.log(` Port: ${service.port} (container must listen on this port)`);
}
await reportCliUsage('cli.compute.update', true);
} catch (err) {
Expand Down
168 changes: 168 additions & 0 deletions src/commands/config/apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerConfigApplyCommand } from './apply.js';
import type * as ErrorsModule from '../../lib/errors.js';

// Per-test we override what /api/metadata returns by reassigning this.
let nextMetadataResponse: unknown = {};
const ossFetchMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/api/metadata' && (!init || init.method === undefined || init.method === 'GET')) {
return new Response(JSON.stringify(nextMetadataResponse), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});

vi.mock('../../lib/api/oss.js', () => ({
ossFetch: (path: string, init?: RequestInit) => ossFetchMock(path, init),
}));

vi.mock('../../lib/credentials.js', () => ({
requireAuth: vi.fn(async () => ({ accessToken: 'tok', userId: 'u' })),
}));

vi.mock('../../lib/skills.js', () => ({
reportCliUsage: vi.fn(async () => {}),
}));

vi.mock('../../lib/errors.js', async (orig) => {
const actual = await orig<typeof ErrorsModule>();
return {
...actual,
// Force handleError to throw rather than process.exit so tests can inspect.
handleError: vi.fn((err: unknown) => {
throw err;
}),
};
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function makeProgram(): Command {
const program = new Command().exitOverride();
program.option('--json').option('--yes').option('--api-url <url>');
const cfg = program.command('config');
registerConfigApplyCommand(cfg);
return program;
}

async function runJson(program: Command, argv: string[]): Promise<unknown[]> {
const out: string[] = [];
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
out.push(args.map(String).join(' '));
});
try {
await program.parseAsync(argv, { from: 'user' });
} finally {
logSpy.mockRestore();
}
return out.flatMap((s) => {
try {
return [JSON.parse(s)];
} catch {
return [];
}
});
}

let tmp: string;

beforeEach(() => {
vi.clearAllMocks();
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});

describe('config apply (capability probe)', () => {
it('applies changes when backend exposes the field', async () => {
nextMetadataResponse = {
auth: { allowedRedirectUrls: ['https://old.com'] },
};
const tomlPath = join(tmp, 'insforge.toml');
writeFileSync(
tomlPath,
'[auth]\nallowed_redirect_urls = ["https://new.com", "https://old.com"]\n',
);

const program = makeProgram();
const docs = await runJson(program, [
'--json',
'--yes',
'config',
'apply',
'--file',
tomlPath,
]);

// Single JSON doc emitted (one of the prior review items).
expect(docs).toHaveLength(1);
const result = docs[0] as { applied: unknown[]; skipped: unknown[] };
expect(result.applied).toHaveLength(1);
expect(result.skipped).toHaveLength(0);
// PUT was issued.
const putCalls = ossFetchMock.mock.calls.filter(
(c) => c[1]?.method === 'PUT' && c[0] === '/api/auth/config',
);
expect(putCalls).toHaveLength(1);

rmSync(tmp, { recursive: true, force: true });
});

it('skips changes (and never PUTs) when the backend omits the field', async () => {
// Legacy backend: auth slice exists but no allowedRedirectUrls field.
nextMetadataResponse = { auth: { someOtherField: 'x' } };
const tomlPath = join(tmp, 'insforge.toml');
writeFileSync(tomlPath, '[auth]\nallowed_redirect_urls = ["https://new.com"]\n');

const program = makeProgram();
const docs = await runJson(program, [
'--json',
'--yes',
'config',
'apply',
'--file',
tomlPath,
]);

const result = docs[0] as {
applied: unknown[];
skipped: Array<{ key: string; reason: string }>;
};
expect(result.applied).toHaveLength(0);
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0].key).toBe('auth.allowed_redirect_urls');
expect(result.skipped[0].reason).toMatch(/upgrade/);
// No PUT ever issued — protects against silent-drop on permissive servers.
const putCalls = ossFetchMock.mock.calls.filter((c) => c[1]?.method === 'PUT');
expect(putCalls).toHaveLength(0);

rmSync(tmp, { recursive: true, force: true });
});

it('treats an empty array on the wire as supported (empty != absent)', async () => {
nextMetadataResponse = { auth: { allowedRedirectUrls: [] } };
const tomlPath = join(tmp, 'insforge.toml');
writeFileSync(tomlPath, '[auth]\nallowed_redirect_urls = ["https://new.com"]\n');

const program = makeProgram();
const docs = await runJson(program, [
'--json',
'--yes',
'config',
'apply',
'--file',
tomlPath,
]);

const result = docs[0] as { applied: unknown[]; skipped: unknown[] };
expect(result.applied).toHaveLength(1);
expect(result.skipped).toHaveLength(0);

rmSync(tmp, { recursive: true, force: true });
});
});
138 changes: 138 additions & 0 deletions src/commands/config/apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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, CLIError } 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 { metadataSupports, changePath } from '../../lib/config-capabilities.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, yes } = 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 });
const approved = opts.autoApprove || yes;

// Render the plan immediately in interactive mode so the user can read
// it before confirming. In --json mode hold output until the end so
// we emit a single JSON document (parsable by jq, etc.).
if (!json) {
console.log(formatPlan(result));
}

if (result.changes.length === 0 || opts.dryRun) {
if (json) {
console.log(
JSON.stringify({ plan: result, applied: false, dryRun: !!opts.dryRun }, null, 2),
);
}
await reportCliUsage('cli.config.apply', true);
return;
}

if (!approved) {
if (json) {
// No TTY in --json runs; require explicit consent rather than
// silently applying or hanging on a prompt.
throw new CLIError(
'Refusing to apply in --json mode without --auto-approve or --yes.',
1,
'CONFIRMATION_REQUIRED',
);
}
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;
}
}

// Per-change capability gate. Each change is independent: a backend
// that supports `auth.allowed_redirect_urls` but not (future)
// `email.smtp` should apply the first and skip the second with a
// named warning. Better than failing the whole batch.
const applied: DiffChange[] = [];
const skipped: Array<{ key: string; reason: string }> = [];
for (const change of result.changes) {
const path = changePath(change);
if (!metadataSupports(raw, change)) {
skipped.push({
key: path,
reason: `your backend doesn't expose ${path} — upgrade the project to apply this section`,
});
continue;
}
await applyChange(change);
applied.push(change);
}

if (json) {
console.log(
JSON.stringify({ plan: result, applied, skipped }, null, 2),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
);
} else {
if (skipped.length) {
console.warn(
pc.yellow(`⚠ Skipped ${skipped.length} section(s):`) +
'\n' +
skipped.map((s) => ` - ${s.key}: ${s.reason}`).join('\n'),
);
}
if (applied.length) {
console.log(
`${pc.green('✓')} Applied ${applied.length} of ${result.changes.length} change(s).`,
);
} else {
console.log('Nothing applied.');
}
}
await reportCliUsage('cli.config.apply', true);
} catch (err) {
await reportCliUsage('cli.config.apply', false);
handleError(err, json);
}
});
}

async function applyChange(change: DiffChange): 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}`);
}
Loading
Loading