diff --git a/package.json b/package.json index 36e4256..ceeda18 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build": "tsc", "start": "node dist/src/cli.js", "demo": "node dist/src/cli.js --demo", - "test": "npm run build && node --test dist/tests/*.test.js", + "test": "npm run build && node scripts/run-tests.mjs", "clean": "rm -rf dist" }, "devDependencies": { diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..73706ce --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Cross-version test runner. + * + * `node --test ` only expands glob patterns itself since Node 21, but + * this package claims engines >= 18.17 — on Node 18/20, and on Windows cmd + * where the shell never expands the pattern either, `dist/tests/*.test.js` + * is passed through literally and the run fails. Resolve the compiled test + * files here and hand node the explicit list instead, so `npm test` behaves + * the same on every supported Node version and platform. + */ +import { spawnSync } from 'node:child_process' +import { readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const dir = fileURLToPath(new URL('../dist/tests/', import.meta.url)) +const files = readdirSync(dir) + .filter(f => f.endsWith('.test.js')) + .sort() + .map(f => path.join(dir, f)) + +if (files.length === 0) { + console.error('run-tests: no compiled test files found in dist/tests/') + process.exit(1) +} + +const res = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' }) +process.exit(res.status ?? 1) diff --git a/src/cli.ts b/src/cli.ts index cc09329..3b9b3eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ */ import { appendFileSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' import readline from 'node:readline/promises' @@ -21,7 +22,12 @@ import { StreamRenderer } from './render.js' import { listSessions, loadSession, saveSession } from './session.js' import { changedFiles } from './tools/edit.js' -const VERSION = '0.1.0' +// Single source of truth: the version in package.json, so a release bump +// can never drift out of sync with --version / the REPL banner. createRequire +// resolves relative to this file (dist/src/cli.js -> ../../package.json), +// which is the same layout in a published npm tarball. +const require = createRequire(import.meta.url) +export const VERSION: string = require('../../package.json').version const HIST_PATH = path.join(os.homedir(), '.corecoder_ts_history') // ---------------------------------------------------------------- colors @@ -155,13 +161,15 @@ export async function main(): Promise { * owns Ctrl+C wiring: in the REPL readline's raw mode swallows ^C and emits a * 'SIGINT' *event*, while one-shot mode gets the real process signal — two * different hooks, one abort path. - * Returns the final text, or null if interrupted/errored. + * Returns the final text, plus whether it was streamed and — when null — + * whether the turn was cancelled (^C) or failed, so the caller can pick the + * right process exit code: 130 for interrupt, 1 for error. */ async function runTurn( agent: Agent, input: string, ac: AbortController, -): Promise<{ text: string | null; streamed: boolean }> { +): Promise<{ text: string | null; streamed: boolean; aborted: boolean }> { // Streamed text renders as markdown line-by-line (see render.ts). The // renderer holds at most one partial line, flushed before tool banners. const renderer = new StreamRenderer(s => process.stdout.write(s), useColor) @@ -183,15 +191,16 @@ async function runTurn( step = await gen.next() } renderer.flush() - return { text: step.value, streamed } + return { text: step.value, streamed, aborted: false } } catch (e) { renderer.flush() - if (e instanceof Error && e.name === 'AbortError') { + const aborted = e instanceof Error && e.name === 'AbortError' + if (aborted) { console.log(yellow('\nInterrupted.')) } else { console.log(red(`\nError: ${e instanceof Error ? e.message : e}`)) } - return { text: null, streamed } + return { text: null, streamed, aborted } } } @@ -200,6 +209,12 @@ function renderMarkdown(text: string): void { new StreamRenderer(s => process.stdout.write(s), useColor).renderAll(text) } +/** Exit code for a finished turn: 0 success, 130 interrupt (^C), 1 error. */ +export function turnExitCode(result: { text: string | null; aborted: boolean }): number { + if (result.text !== null) return 0 + return result.aborted ? 130 : 1 +} + /** Non-interactive: run one prompt and exit. */ async function runOnce(agent: Agent, prompt: string): Promise { // No readline here, so ^C arrives as a real process signal. @@ -207,10 +222,10 @@ async function runOnce(agent: Agent, prompt: string): Promise { const onSigint = () => ac.abort() process.once('SIGINT', onSigint) try { - const { text, streamed } = await runTurn(agent, prompt, ac) - if (text === null) return 130 - if (!streamed && text) renderMarkdown(text) - return 0 + const { text, streamed, aborted } = await runTurn(agent, prompt, ac) + const code = turnExitCode({ text, aborted }) + if (code === 0 && !streamed && text) renderMarkdown(text) + return code } finally { process.removeListener('SIGINT', onSigint) } diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..1d8d66d --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,22 @@ +/** CLI contract tests: exit codes and version sync. */ + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +// importing cli.js is safe: isDirectRun guards against running main() +import { turnExitCode, VERSION } from '../src/cli.js' + +test('turnExitCode: 0 success, 130 interrupt, 1 error', () => { + assert.equal(turnExitCode({ text: 'done', aborted: false }), 0) + assert.equal(turnExitCode({ text: 'done', aborted: true }), 0) + assert.equal(turnExitCode({ text: null, aborted: true }), 130) + assert.equal(turnExitCode({ text: null, aborted: false }), 1) +}) + +test('CLI version is read from package.json (single source of truth)', () => { + // from dist/tests/ the package.json is two levels up + const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) + assert.equal(VERSION, pkg.version) + assert.match(VERSION, /^\d+\.\d+\.\d+$/) +})