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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"cli": "bun src/cli.ts",
"test": "bun test src/commands/version.test.ts src/commands/setup.test.ts src/commands/skill.test.ts src/commands/doctor.test.ts src/commands/telemetry.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/serve.test.ts && bun test src/commands/ai.test.ts",
"test": "bun test src/commands/version.test.ts src/commands/setup.test.ts src/commands/skill.test.ts src/commands/doctor.test.ts src/commands/telemetry.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/serve.test.ts && bun test src/commands/ai.test.ts && bun test src/cli.test.ts src/utils/stdout.test.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, it, expect } from 'bun:test';
import { parseArgs } from 'util';
import * as git from '@archon/git';
import { tmpdir } from 'node:os';

// Test the argument parsing logic used in cli.ts
describe('CLI argument parsing', () => {
Expand Down Expand Up @@ -398,9 +399,11 @@ describe('CLI git repo check', () => {
});

it('should return null for system directories outside any git repo', async () => {
// /tmp is typically not inside a git repo
// Note: This test may need adjustment if /tmp happens to be inside a repo
const result = await git.findRepoRoot('/tmp');
// The OS temp dir is not inside a git repo on any supported platform.
// Hardcoding '/tmp' fails on Windows, where that path does not exist —
// this file was absent from the package test script until #2384, so the
// POSIX assumption never surfaced in CI.
const result = await git.findRepoRoot(tmpdir());
expect(result).toBeNull();
});
});
Expand All @@ -412,7 +415,7 @@ describe('CLI git repo check', () => {

it('should detect existing directories', () => {
expect(existsSync(process.cwd())).toBe(true);
expect(existsSync('/tmp')).toBe(true);
expect(existsSync(tmpdir())).toBe(true);
});

it('should detect non-existent directories', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,14 @@ async function main(): Promise<number> {
}
}

// Run main and exit with the returned code
// Exit explicitly so a lingering handle (DB pool, spawned child, timer) can
// never leave the CLI hanging after its work is done.
//
// This is safe for piped output because every machine-readable payload is
// emitted through `writeStdout()`/`writeJsonLine()` (src/utils/stdout.ts), which
// resolves only once the bytes have reached the OS. The #2384 truncation
// happened inside `console.log` at call time — not at exit — so deferring the
// exit would not have recovered it.
main()
.then(exitCode => {
process.exit(exitCode);
Expand Down
21 changes: 19 additions & 2 deletions packages/cli/src/commands/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,21 @@ import {

let logSpy: ReturnType<typeof spyOn<Console, 'log'>>;
let errSpy: ReturnType<typeof spyOn<Console, 'error'>>;
let stdoutSpy: ReturnType<typeof spyOn>;
function out(): string {
return [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n');
}

/**
* `--json` payloads go through `writeJsonLine()` (src/utils/stdout.ts), i.e.
* `process.stdout.write`, so a piped consumer can never get a truncated
* document (#2384). Capture them here; real-pipe delivery is covered by
* src/utils/stdout.test.ts.
*/
function jsonOut(): string {
return ((stdoutSpy.mock.calls[0]?.[0] as string) ?? '').trimEnd();
}

beforeEach(() => {
enabled = true;
mockPersist.mockClear();
Expand All @@ -154,10 +165,16 @@ beforeEach(() => {
loadConfigResult = { assistant: 'claude', tiers: {} };
logSpy = spyOn(console, 'log').mockImplementation(() => {});
errSpy = spyOn(console, 'error').mockImplementation(() => {});
stdoutSpy = spyOn(process.stdout, 'write').mockImplementation((...args: unknown[]) => {
const callback = args.find(arg => typeof arg === 'function');
if (typeof callback === 'function') (callback as () => void)();
return true;
});
});
afterEach(() => {
logSpy.mockRestore();
errSpy.mockRestore();
stdoutSpy.mockRestore();
});

describe('gate (vault unavailable — defensive guard)', () => {
Expand Down Expand Up @@ -367,7 +384,7 @@ describe('aiTierListCommand', () => {
it('--json emits structured output', async () => {
loadConfigResult = { assistant: 'claude', tiers: {} };
expect(await aiTierListCommand(true)).toBe(0);
const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as {
const parsed = JSON.parse(jsonOut()) as {
defaultAssistant: string;
tiers: unknown[];
};
Expand Down Expand Up @@ -523,7 +540,7 @@ describe('aiAliasListCommand', () => {
aliases: { '@fast': { provider: 'claude', model: 'haiku' } },
};
expect(await aiAliasListCommand(true)).toBe(0);
const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as { aliases: unknown[] };
const parsed = JSON.parse(jsonOut()) as { aliases: unknown[] };
expect(Array.isArray(parsed.aliases)).toBe(true);
expect(parsed.aliases.length).toBe(1);
});
Expand Down
19 changes: 7 additions & 12 deletions packages/cli/src/commands/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* identity so a connected key attaches to the same user across invocations.
*/
import { password, text, isCancel, cancel } from '@clack/prompts';
import { writeJsonLine } from '../utils/stdout';
import { createLogger } from '@archon/paths';
import {
isPerUserProviderKeysEnabled,
Expand Down Expand Up @@ -481,17 +482,11 @@ export async function aiTierListCommand(json?: boolean): Promise<number> {
});

if (json) {
console.log(
JSON.stringify(
{
defaultAssistant: config.assistant,
userDefaultAssistant: userPrefs.defaultProvider ?? null,
tiers: rows,
},
null,
2
)
);
await writeJsonLine({
defaultAssistant: config.assistant,
userDefaultAssistant: userPrefs.defaultProvider ?? null,
tiers: rows,
});
return 0;
}

Expand Down Expand Up @@ -622,7 +617,7 @@ export async function aiAliasListCommand(json?: boolean): Promise<number> {
}));

if (json) {
console.log(JSON.stringify({ aliases: rows }, null, 2));
await writeJsonLine({ aliases: rows });
return 0;
}

Expand Down
21 changes: 11 additions & 10 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
import { writeStdout } from '../utils/stdout';
import {
validateWorkflowResources,
validateCommand,
Expand Down Expand Up @@ -133,12 +134,12 @@ export async function validateWorkflowsCommand(
const allNames = results.map(r => r.workflowName);
const similar = findSimilar(name, allNames);
if (json) {
console.log(
JSON.stringify({
await writeStdout(
`${JSON.stringify({
error: `Workflow '${name}' not found`,
suggestions: similar,
available: allNames,
})
})}\n`
);
} else {
console.error(`Workflow '${name}' not found.`);
Expand Down Expand Up @@ -166,16 +167,16 @@ export async function validateWorkflowsCommand(
).length;

if (json) {
console.log(
JSON.stringify({
await writeStdout(
`${JSON.stringify({
results: filteredResults,
summary: {
total: filteredResults.length,
valid: filteredResults.length - totalErrors,
errors: totalErrors,
warnings: totalWarnings,
},
})
})}\n`
);
} else {
console.log(`\nValidating workflows in ${cwd}\n`);
Expand Down Expand Up @@ -215,7 +216,7 @@ export async function validateCommandsCommand(
const result = await validateCommand(name, cwd, config);

if (jsonOutput) {
console.log(JSON.stringify(result));
await writeStdout(`${JSON.stringify(result)}\n`);
} else {
const statusLabel = result.valid ? 'ok' : 'ERRORS';
console.log(`\n ${result.commandName.padEnd(40, ' ')} ${statusLabel}`);
Expand All @@ -242,16 +243,16 @@ export async function validateCommandsCommand(
const totalErrors = totalCommandErrors + totalScriptErrors;

if (jsonOutput) {
console.log(
JSON.stringify({
await writeStdout(
`${JSON.stringify({
results: commandResults,
scripts: scriptResults,
summary: {
total: commandResults.length + scriptResults.length,
valid: commandResults.length + scriptResults.length - totalErrors,
errors: totalErrors,
},
})
})}\n`
);
} else {
if (commandResults.length === 0 && scriptResults.length === 0) {
Expand Down
Loading
Loading