From 8c1f3f447e0d31b7fa7c77d7c2ba407698eefd38 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Sat, 15 Aug 2026 10:10:51 -0700 Subject: [PATCH 1/2] Fix CLI contract: distinct exit codes, version from package.json, Node 18 test runner --- package.json | 2 +- scripts/run-tests.mjs | 29 +++++++++++++++++++++++++++++ src/cli.ts | 35 +++++++++++++++++++++++++---------- tests/cli.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 scripts/run-tests.mjs create mode 100644 tests/cli.test.ts 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+$/) +}) From 86a6258d7fdc45088b4087ced959b756d78af006 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Sat, 15 Aug 2026 10:13:33 -0700 Subject: [PATCH 2/2] Add CI matrix and npm publish hygiene (files whitelist, exports, license) --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++++++++ package.json | 33 ++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ad8611 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Test (node ${{ matrix.node }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node: [18, 20, 22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + # npm test = build + run every compiled test; the runner script keeps + # node --test working on Node 18 (glob expansion is a Node 21+ feature) + - run: npm test + - name: Offline demo smoke test + run: node dist/src/cli.js --demo + - name: CLI version check + run: node dist/src/cli.js --version + + package: + name: Package hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + # prints the tarball contents so a wrong "files" whitelist fails loudly + - run: npm pack --dry-run diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c8fb149 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nullcache + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index ceeda18..b7db631 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,26 @@ "description": "TypeScript port of CoreCoder — minimal AI coding agent with an AsyncGenerator event-stream core. Zero runtime dependencies.", "license": "MIT", "type": "module", + "keywords": [ + "ai", + "coding-agent", + "claude-code", + "cli", + "llm", + "agent", + "typescript", + "openai-compatible", + "terminal" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/nullcache/corecoder-ts.git" + }, + "bugs": { + "url": "https://github.com/nullcache/corecoder-ts/issues" + }, + "homepage": "https://github.com/nullcache/corecoder-ts#readme", + "sideEffects": false, "engines": { "node": ">=18.17.0" }, @@ -12,11 +32,24 @@ }, "main": "dist/src/index.js", "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "default": "./dist/src/index.js" + } + }, + "files": [ + "dist/src", + "README.md", + "LICENSE" + ], "scripts": { "build": "tsc", "start": "node dist/src/cli.js", "demo": "node dist/src/cli.js --demo", "test": "npm run build && node scripts/run-tests.mjs", + "prepublishOnly": "npm run build", "clean": "rm -rf dist" }, "devDependencies": {