From bbf1594af5aa94e2f78cf9e0a7298a76cd730c6f Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 17 Oct 2026 18:35:15 +0200 Subject: [PATCH 1/6] fix: harden lock takeover, killGroup and JSON extraction Audit pass over every module. - build: add unrun so tsdown can load its TypeScript config on Node versions without native type stripping. - json: lastJsonObject retries from an earlier `}` when a stray one in later prose ends no object, and a guard that rejects the last object now falls back to an earlier one. The total try cap is unchanged. - proc: killGroup sends SIGTERM to every detached descendant group, not only the target, and waits the grace period for all of them before SIGKILL. isAlive rejects 0, negative and non-integer pids, which kill(2) reads as process groups. cwdOf reads /proc on Linux. The spawnGroup options are optional. - lock: a stale lock is renamed aside and checked by inode and pid before removal, so two runs clearing the same stale lock cannot both take it. - ansi: strip short escapes such as ESC ( B, ESC 7 and ESC =, and never treat `[` or `{` as an escape's final byte. - watchdog: default to a monotonic clock. - Require Node 22 and test on Node 22 and 24. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 +- README.md | 31 +++++++++-------- package.json | 3 +- pnpm-lock.yaml | 24 ++++++++++++-- src/ansi.ts | 6 ++-- src/json.ts | 26 +++++++++------ src/lock.ts | 72 ++++++++++++++++++++++++++++++++-------- src/proc.ts | 52 +++++++++++++++++++++-------- src/watchdog.ts | 4 ++- test/json.test.ts | 16 +++++++-- test/lock.test.ts | 7 ++++ test/log.test.ts | 11 ++++++ test/proc.test.ts | 20 ++++++++++- 14 files changed, 213 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index add4ddd..182c5b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: [lts/*] + node: [22, 24] os: [ubuntu-latest, windows-latest, macos-latest] fail-fast: false steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 221ea44..0439869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `clikit/json`: `lastJson(text, validate?)`, `tryLastJson(text, validate?)` and `lastJsonObject(text, validate?)` read the JSON value that ends CLI output or the last object in model output, through ANSI escapes and CRLF, with an optional type guard. -- `clikit/proc`: `spawnGroup`, `killGroup` (SIGTERM, grace period, SIGKILL to the group and every detached group beneath it), `isAlive`, `cwdOf` and `startOf`. +- `clikit/proc`: `spawnGroup`, `killGroup` (SIGTERM to the group and every detached group beneath it, a grace period, then SIGKILL), `isAlive`, `cwdOf` and `startOf`. - `clikit/watchdog`: `Watchdog` with a wall-clock and a stall limit. - `clikit/lock`: `acquireLock` pidfile lock with stale-pid detection, and `LockHeld`. - `clikit/env`: `withoutSecrets`, `withoutGitVars` and `SECRET_VARS`. diff --git a/README.md b/README.md index c188b90..9e72656 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ yarn add clikit bun add clikit ``` -Requires Node.js 20 or later. +Requires Node.js 22 or later. > **Pick only what you need.** `import { lastJson } from 'clikit/json'` loads just that module. The root `clikit` re-exports everything, and bundlers tree-shake it too. @@ -113,11 +113,11 @@ tryLastJson('no json here') // undefined #### `lastJsonObject(text, validate?)` -The last `{…}` object in `text` — a model's answer in prose or a code fence — or `undefined`. It ends at the last `}` and walks back over `{` until the slice parses. Arrays and scalars are not looked for, because prose is full of `[1]`-style brackets. +The last `{…}` object in `text` — a model's answer in prose or a code fence — or `undefined`. It walks back from the last `}` over `{` until a slice parses, and from an earlier `}` when a stray one in later prose ends nothing. Arrays and scalars are not looked for, because prose is full of `[1]`-style brackets. #### `validate`: plug in zod, valibot or your own guard -All three accept an optional type guard `(value: unknown) => value is T` and then return `T`. A value the guard rejects is skipped: `lastJson` tries an earlier line start, `lastJsonObject` an enclosing brace. No schema library is a dependency. +All three accept an optional type guard `(value: unknown) => value is T` and then return `T`. A value the guard rejects is skipped: `lastJson` tries an earlier line start, `lastJsonObject` an enclosing brace and then an earlier object. No schema library is a dependency. ```ts import { z } from 'zod' @@ -130,15 +130,15 @@ const pr = lastJson(stdout, isPr) // typed as { number: number, title: string } ### `clikit/proc` -Process groups and process facts. **POSIX only for groups:** `killGroup` throws on Windows, which has no process groups; `cwdOf` reads `lsof` and `startOf` reads `ps`, so both return `null` where those are missing (Windows, minimal containers). `isAlive` and `spawnGroup` work everywhere. +Process groups and process facts. **POSIX only for groups:** `killGroup` throws on Windows, which has no process groups; `cwdOf` reads `/proc` on Linux and `lsof` elsewhere, `startOf` reads `ps`, so both return `null` where those are missing (Windows, minimal containers). `isAlive` and `spawnGroup` work everywhere. -#### `spawnGroup(cmd, args, { cwd, env })` +#### `spawnGroup(cmd, args, { cwd?, env? })` `spawn` with `detached: true` and piped stdio: the child leads a new process group, so one signal reaches every grandchild that stays in it. Returns the `ChildProcess`. #### `killGroup(pid, graceMs = 10_000, snapshot?)` -SIGTERM to the group led by `pid`, up to `graceMs` for it to exit, then SIGKILL — to that group *and* to every detached group started beneath it (found with `ps -axo pid=,ppid=,pgid=`, before the SIGTERM and again after the grace). Groups merely shared with the target are left alone. If `ps` fails (EAGAIN at the process limit), the target group is still killed. Rejects for `pid` 0, 1, negative or non-integer, which `kill(2)` would read as "my group" or "every process". +SIGTERM to the group led by `pid` *and* to every detached group started beneath it, up to `graceMs` for all of them to exit, then SIGKILL to the survivors and to any detached group the leader started during the grace (found with `ps -axo pid=,ppid=,pgid=`, before the SIGTERM and again after the grace). Groups merely shared with the target are left alone. If `ps` fails (EAGAIN at the process limit), the target group is still killed. Rejects for `pid` 0, 1, negative or non-integer, which `kill(2)` would read as "my group" or "every process". #### `isAlive(pid)`, `cwdOf(pid)`, `startOf(pid)` @@ -154,13 +154,13 @@ startOf(process.pid) // 'Sat Oct 17 00:51:27 2026' #### `new Watchdog({ wallMs, stallMs }, onFire, now?)` -Call `poke()` on every sign of life and `check()` on a timer. `onFire('timed_out' | 'stalled')` runs once, on the first check at or past a limit (a 45-minute wall clock fires at minute 45). `setLimits()` applies from the next check, `stop()` disarms it, and `now` is injectable for tests. +Call `poke()` on every sign of life and `check()` on a timer. `onFire('timed_out' | 'stalled')` runs once, on the first check at or past a limit (a 45-minute wall clock fires at minute 45). `setLimits()` applies from the next check, `stop()` disarms it, and `now` is injectable for tests. The default clock is monotonic (`performance.now()`): an NTP step neither fires nor delays it, and time the machine spends asleep does not count, so waking a laptop does not report every child as stalled. ### `clikit/lock` #### `acquireLock(path)` -Creates `path` with `O_EXCL` and writes this process's pid; returns `{ release() }`. The directory is created owner-only (`0700`) if needed. A file naming a dead pid, or holding no pid (a crash between create and write), is stale and replaced. A live holder — including one owned by another user (`EPERM`) — throws `LockHeld`. +Writes this process's pid to a private file and hard-links it into place, so the lock never exists half-written and exactly one of two racing runs wins; returns `{ release() }`. The directory is created owner-only (`0700`) if needed. A file naming a dead pid, or holding no pid, is stale and replaced: it is renamed aside first and put back if it turns out to be another run's fresh lock, so two runs clearing one stale lock cannot both win. A live holder — including one owned by another user (`EPERM`) — throws `LockHeld`. #### `LockHeld` @@ -183,7 +183,7 @@ withoutGitVars({ PATH: '/usr/bin', GIT_DIR: '/repo/.git', GIT_INDEX_FILE: 'x' }) #### `stripAnsi(text, { caret? })` -Removes CSI (colour, cursor), OSC (titles, hyperlinks) and two-byte escapes. With `caret: true` it also removes escapes written in caret notation (`^[[31m`), which is how `gh` prints them into a pipe. That stays opt-in because elsewhere `^[` is content. +Removes CSI (colour, cursor), OSC (titles, hyperlinks) and short escapes such as `tput sgr0`'s `ESC ( B`, `ESC 7` / `ESC 8` and `ESC =`. With `caret: true` it also removes escapes written in caret notation (`^[[31m`), which is how `gh` prints them into a pipe. That stays opt-in because elsewhere `^[` is content. ```ts stripAnsi('^[[31merror^[[0m \x1B[1mbold\x1B[22m', { caret: true }) // 'error bold' @@ -210,7 +210,7 @@ import { killGroup, spawnGroup } from 'clikit/proc' import { Watchdog } from 'clikit/watchdog' async function runJson(cmd: string, args: string[]): Promise { - const child = spawnGroup(cmd, args, { cwd: process.cwd(), env: process.env as Record }) + const child = spawnGroup(cmd, args) let stdout = '' let reason: string | undefined @@ -333,19 +333,18 @@ Use jsonrepair or partial-json when the JSON itself is damaged or still streamin | clikit | Alternative | What differs | | --- | --- | --- | | `spawnGroup` + `killGroup` | [tree-kill](https://www.npmjs.com/package/tree-kill), [fkill](https://www.npmjs.com/package/fkill), [execa](https://www.npmjs.com/package/execa) | tree-kill signals each pid of the tree once and supports Windows; fkill kills by pid, name or port across platforms; execa is a full process runner with timeouts and `forceKillAfterDelay`. `killGroup` is POSIX-only, but follows grandchildren that started their own detached groups, re-scans after the grace period and refuses `kill(0)` / `kill(-1)`. | -| `Watchdog` | [p-timeout](https://www.npmjs.com/package/p-timeout), execa's `timeout` | Those bound the total time. `Watchdog` adds a stall limit that resets on every sign of life, and leaves the reaction to you. | +| `Watchdog` | [p-timeout](https://www.npmjs.com/package/p-timeout), `AbortSignal.timeout`, execa's `timeout` | Those bound the total time. `Watchdog` adds a stall limit that resets on every sign of life, and leaves the reaction to you. | | `acquireLock` | [proper-lockfile](https://www.npmjs.com/package/proper-lockfile), [lockfile](https://www.npmjs.com/package/lockfile) | proper-lockfile uses mtime-refreshed lock directories and works over network file systems; lockfile retries and waits. `acquireLock` is a synchronous pidfile that checks whether the holder is alive. | -| `stripAnsi` | [strip-ansi](https://www.npmjs.com/package/strip-ansi) | strip-ansi covers more escape variants. clikit adds opt-in caret notation (`^[[31m`) for `gh` logs. | +| `stripAnsi` | [strip-ansi](https://www.npmjs.com/package/strip-ansi), Node's `util.stripVTControlCharacters` | Both cover 8-bit C1 escapes as well; the built-in needs no install. clikit adds opt-in caret notation (`^[[31m`) for `gh` logs. | | `errorMessage`, `errnoCode` | [serialize-error](https://www.npmjs.com/package/serialize-error) | serialize-error turns a whole error into a plain object and back. These two read the message and the errno name safely from any thrown value. | | `withoutSecrets`, `withoutGitVars` | [env-var](https://www.npmjs.com/package/env-var) | env-var reads and validates variables. These filter what a child inherits. | ## Limits -- `lastJson` finds objects, arrays and `null` — not bare numbers, strings or booleans, which look like ordinary log lines. Only the value that *ends* the output counts, and on the last line only the first `[` or `{` is tried. -- `lastJsonObject` ends at the last `}` in the text. A stray `}` in prose after the answer hides it. It walks back over at most 1,024 opening braces, so an answer holding more than about a thousand nested objects is not found; that cap keeps hostile input (200,000 nested levels) under a second. -- A `validate` guard can only reject: `lastJsonObject` does not then look for an earlier, separate object. +- `lastJson` finds objects, arrays and `null` — not bare numbers, strings or booleans, which look like ordinary log lines. Only the value that *ends* the output counts; on the last line every `[` or `{` is tried, left to right. +- `lastJsonObject` tries at most 1,024 brace pairs in all, so an answer holding more than about a thousand nested objects, or buried under a thousand braces of later prose, is not found; that cap keeps hostile input (200,000 nested levels) under a second. - `killGroup` is POSIX-only and needs `ps`; `cwdOf` needs `lsof`. -- `acquireLock` is for one machine. It does not wait or retry, and it is not safe on network file systems. +- `acquireLock` is for one machine. It does not wait or retry, and it is not safe on network file systems. Clearing a stale lock is safe between two runs; three runs racing over one stale lock in the same few microseconds can still end with two holders, because Node has no `rename` that refuses to overwrite. - `failureSignature` is tuned for GitHub Actions, pnpm, turbo, vitest, tsc and Playwright output; other tools fall back to their last five lines. ## Contributing diff --git a/package.json b/package.json index b198e18..391a375 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dist" ], "engines": { - "node": ">=20" + "node": ">=22" }, "publishConfig": { "access": "public", @@ -75,6 +75,7 @@ "publint": "^0.3.25", "tsdown": "^0.23.1", "typescript": "^6.0.3", + "unrun": "^0.3.1", "vitest": "^5.0.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4ac5ea..40e1c72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,10 +22,13 @@ importers: version: 0.3.25 tsdown: specifier: ^0.23.1 - version: 0.23.1(publint@0.3.25)(typescript@6.0.3) + version: 0.23.1(publint@0.3.25)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)) typescript: specifier: ^6.0.3 version: 6.0.3 + unrun: + specifier: ^0.3.1 + version: 0.3.1(synckit@0.11.13) vitest: specifier: ^5.0.5 version: 5.0.5(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1)) @@ -1755,6 +1758,16 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unrun@0.3.1: + resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} + engines: {node: ^22.13.0 || >=24.0.0} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + update-browserslist-db@1.3.4: resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true @@ -3684,7 +3697,7 @@ snapshots: dependencies: typescript: 6.0.3 - tsdown@0.23.1(publint@0.3.25)(typescript@6.0.3): + tsdown@0.23.1(publint@0.3.25)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.13)): dependencies: cac: 7.1.0 defu: 6.1.7 @@ -3703,6 +3716,7 @@ snapshots: optionalDependencies: publint: 0.3.25 typescript: 6.0.3 + unrun: 0.3.1(synckit@0.11.13) transitivePeerDependencies: - '@typescript/native-preview' - '@volar/typescript' @@ -3751,6 +3765,12 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unrun@0.3.1(synckit@0.11.13): + dependencies: + rolldown: 1.2.14 + optionalDependencies: + synckit: 0.11.13 + update-browserslist-db@1.3.4(browserslist@4.29.3): dependencies: browserslist: 4.29.3 diff --git a/src/ansi.ts b/src/ansi.ts index 35ae8fb..c324e5d 100644 --- a/src/ansi.ts +++ b/src/ansi.ts @@ -1,6 +1,8 @@ /* eslint-disable no-control-regex -- ESC and BEL are exactly what terminal escapes are made of */ -// CSI sequences (colour, cursor), OSC sequences (titles, hyperlinks) ended by BEL or ST, and two-byte escapes. -const ANSI = /\x1B(?:\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[\x40-\x5A\x5C-\x5F])/g +// CSI sequences (colour, cursor), OSC sequences (titles, hyperlinks) ended by BEL or ST, and short escapes such as +// `tput sgr0`'s charset reset `ESC ( B`, cursor save `ESC 7` and keypad mode `ESC =`. No escape is taken to end in +// `[` or `{`, which no terminal uses, so a stray or truncated one cannot swallow the JSON value that follows it. +const ANSI = /\x1B(?:\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x5A\x5C-\x7A\x7C-\x7E]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[\x20-\x2F]?[\x30-\x5A\x5C-\x7A\x7C-\x7E])/g /* eslint-enable no-control-regex */ // gh writes ESC as a literal `^[` when its output is not a terminal; anywhere else `^[` is content, so it is opt-in. diff --git a/src/json.ts b/src/json.ts index 714a10e..61667cd 100644 --- a/src/json.ts +++ b/src/json.ts @@ -106,23 +106,29 @@ export function lastJsonObject(text: string): unknown export function lastJsonObject(text: string, validate: Guard): T | undefined export function lastJsonObject(raw: string, validate?: Guard): unknown { const text = stripAnsi(raw) - const end = text.lastIndexOf('}') + const accept = validate ?? any + const first = text.indexOf('{') - if (end < 0) { + if (first < 0) { return undefined } - let i = text.lastIndexOf('{', end) + let tries = 0 - for (let tries = 0; i >= 0 && tries < MAX_BRACE_TRIES; tries++) { - const value = parse(text.slice(i, end + 1)) + // A `}` in the prose after the answer ends no object; every start fails for it and the next `}` back is tried. + for (let end = text.lastIndexOf('}'); end > first && tries < MAX_BRACE_TRIES; end = text.lastIndexOf('}', end - 1)) { + let i = text.lastIndexOf('{', end) - if (value !== FAILED && (validate ?? any)(value)) { - return value - } + for (; i >= 0 && tries < MAX_BRACE_TRIES; tries++) { + const value = parse(text.slice(i, end + 1)) - // An inner brace, or a value the guard rejected: retry from the enclosing one. - i = i === 0 ? -1 : text.lastIndexOf('{', i - 1) + if (value !== FAILED && accept(value)) { + return value + } + + // An inner brace, or a value the guard rejected: retry from the enclosing one. + i = i === 0 ? -1 : text.lastIndexOf('{', i - 1) + } } return undefined diff --git a/src/lock.ts b/src/lock.ts index a114d98..a4ffb2d 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -1,4 +1,4 @@ -import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import process from 'node:process' import { errnoCode } from './errors' @@ -46,7 +46,7 @@ function unlinkIfPresent(path: string): void { // Releasing must not delete a lock that was meanwhile cleared as stale and taken by another run. function releaseIfOwned(path: string): void { - if (holder(path) === process.pid) { + if (holder(path)?.pid === process.pid) { unlinkIfPresent(path) } } @@ -75,9 +75,16 @@ function create(path: string): Lock | undefined { } } -function holder(path: string): number | undefined { +interface Holder { + pid: number + ino: bigint +} + +function holder(path: string): Holder | undefined { + let fd: number + try { - return Number(readFileSync(path, 'utf8')) + fd = openSync(path, 'r') } catch (e) { if (errnoCode(e) === 'ENOENT') { @@ -86,6 +93,49 @@ function holder(path: string): number | undefined { throw e } + + try { + return { pid: Number(readFileSync(fd, 'utf8')), ino: fstatSync(fd, { bigint: true }).ino } + } + finally { + closeSync(fd) + } +} + +// unlink(2) cannot be told "only if it is still this file": another run may clear the same stale lock and take it +// between our read and our unlink. rename(2) is atomic, so the file is moved aside first and checked there; one +// that turns out to be another run's lock goes back. The pid is compared too, because a freed inode is often reused +// at once. Object.is: a garbage file reads as NaN, which never equals itself. +function removeStale(path: string, stale: Holder): void { + const aside = `${path}.${process.pid}.stale` + + try { + renameSync(path, aside) + } + catch (e) { + if (errnoCode(e) === 'ENOENT') { + return + } + + throw e + } + + const moved = holder(aside) + + if (moved && (moved.ino !== stale.ino || !Object.is(moved.pid, stale.pid))) { + try { + linkSync(aside, path) + } + catch (e) { + // EEXIST: a third run took the free path meanwhile, and its lock stands. Anything else leaves the moved lock + // where it is, so its owner can still be found. + if (errnoCode(e) !== 'EEXIST') { + throw e + } + } + } + + unlinkIfPresent(aside) } /** @@ -103,21 +153,17 @@ export function acquireLock(path: string): Lock { return lock } - const pid = holder(path) + const held = holder(path) - if (pid === undefined) { + if (held === undefined) { continue } // An empty file reads as 0, and kill(0, 0) asks about our own process group, which is always alive. - if (Number.isInteger(pid) && pid > 0 && running(pid)) { - throw new LockHeld(pid, path) + if (Number.isInteger(held.pid) && held.pid > 0 && running(held.pid)) { + throw new LockHeld(held.pid, path) } - // Only if it is still the file judged stale: another run may have replaced it since the read. - // Object.is: a garbage file reads as NaN, which never equals itself. - if (Object.is(holder(path), pid)) { - unlinkIfPresent(path) - } + removeStale(path, held) } } diff --git a/src/proc.ts b/src/proc.ts index d3dc5d3..6218223 100644 --- a/src/proc.ts +++ b/src/proc.ts @@ -1,22 +1,32 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { execFileSync, spawn } from 'node:child_process' +import { readlinkSync } from 'node:fs' import process from 'node:process' export interface SpawnGroupOptions { - cwd: string - env: Record + /** Defaults to the current working directory. */ + cwd?: string + /** Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv } /** * Spawns `cmd` as the leader of a new process group, with piped stdio, so {@link killGroup} can later reach every * grandchild that stays in the group. */ -export function spawnGroup(cmd: string, args: string[], options: SpawnGroupOptions): ChildProcessWithoutNullStreams { +export function spawnGroup(cmd: string, args: string[], options: SpawnGroupOptions = {}): ChildProcessWithoutNullStreams { return spawn(cmd, args, { cwd: options.cwd, env: options.env, detached: true, stdio: 'pipe' }) } -/** Whether a process with this pid exists and can be signalled by this user. */ +/** + * Whether a process with this pid exists and can be signalled by this user. A pid that is not a positive integer is + * never alive: kill(2) would read 0 or a negative number as a process group. + */ export function isAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + try { process.kill(pid, 0) return true @@ -26,8 +36,21 @@ export function isAlive(pid: number): boolean { } } -/** The working directory of a live process, read with `lsof`; `null` once it is gone or when `lsof` is missing. */ +/** + * The working directory of a live process, read from `/proc` on Linux and with `lsof` elsewhere; `null` once it is + * gone or when neither is available. + */ export function cwdOf(pid: number): string | null { + // Minimal containers ship without lsof, and procfs answers without starting a process. + if (process.platform === 'linux') { + try { + return readlinkSync(`/proc/${pid}/cwd`) + } + catch { + /* not readable: lsof may still answer */ + } + } + try { const out = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', @@ -130,8 +153,8 @@ function signal(group: number, s: NodeJS.Signals): void { const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) /** - * Stops the process group led by `pid` and every detached group started beneath it: SIGTERM, up to `graceMs` for - * the group to exit, then SIGKILL. POSIX only (macOS, Linux); throws on Windows, which has no process groups. + * Stops the process group led by `pid` and every detached group started beneath it: SIGTERM to all of them, up to + * `graceMs` for them to exit, then SIGKILL. POSIX only (macOS, Linux); throws on Windows, which has no process groups. * `snapshot` replaces the `ps -axo pid=,ppid=,pgid=` call that maps the process tree. */ export async function killGroup(pid: number, graceMs = 10_000, snapshot: () => string = psSnapshot): Promise { @@ -145,24 +168,27 @@ export async function killGroup(pid: number, graceMs = 10_000, snapshot: () => s } // Taken before SIGTERM: once the leader dies its detached children are reparented and no longer reachable from pid. - const others = new Set(descendantGroups(pid, snapshot)) + const groups = new Set([pid, ...descendantGroups(pid, snapshot)]) + + for (const group of groups) { + signal(group, 'SIGTERM') + } - signal(pid, 'SIGTERM') const until = Date.now() + graceMs - // The leader can exit on SIGTERM long before its grandchildren finish shutting down; they get the grace too. - while (Date.now() < until && groupAlive(pid)) { + // The leader can exit on SIGTERM long before its descendants finish shutting down; they get the grace too. + while (Date.now() < until && [...groups].some(groupAlive)) { await sleep(100) } // A leader that outlived the grace may have started more detached groups since the first snapshot. if (isAlive(pid)) { for (const group of descendantGroups(pid, snapshot)) { - others.add(group) + groups.add(group) } } - for (const group of [pid, ...others]) { + for (const group of groups) { signal(group, 'SIGKILL') } diff --git a/src/watchdog.ts b/src/watchdog.ts index 7652b37..9a77401 100644 --- a/src/watchdog.ts +++ b/src/watchdog.ts @@ -25,7 +25,9 @@ export class Watchdog { private readonly now: () => number - constructor(limits: WatchdogLimits, onFire: (why: WatchdogReason) => void, now: () => number = Date.now) { + // A monotonic clock by default, so an NTP step cannot fire or delay it. It pauses while the machine sleeps, as the + // child does: a laptop waking up must not read its own sleep as the child's stall. + constructor(limits: WatchdogLimits, onFire: (why: WatchdogReason) => void, now: () => number = () => performance.now()) { this.limits = limits this.onFire = onFire this.now = now diff --git a/test/json.test.ts b/test/json.test.ts index 4e87cb1..f34142d 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -93,10 +93,22 @@ describe('lastJsonObject reads the last object in model output', () => { expect(lastJsonObject('a set {1, 2}')).toBeUndefined() }) - it('returns undefined when the guard rejects the last object', () => { + it('returns the last object the guard accepts, skipping later ones it rejects', () => { const hasVerdict = (v: unknown): v is { verdict: string } => typeof v === 'object' && v !== null && 'verdict' in v - expect(lastJsonObject('{"verdict":"ok"} then {"other":1}', hasVerdict)).toBeUndefined() + expect(lastJsonObject('{"verdict":"ok"} then {"other":1}', hasVerdict)?.verdict).toBe('ok') expect(lastJsonObject('answer: {"verdict":"ok"}', hasVerdict)?.verdict).toBe('ok') + expect(lastJsonObject('{"other":1}', hasVerdict)).toBeUndefined() + }) + + it('finds the answer when the prose after it holds a stray closing brace', () => { + expect(lastJsonObject('```json\n{"verdict":"approve"}\n```\nUse `}` to close a block.')).toEqual({ verdict: 'approve' }) + }) + + it('finishes fast on input full of closing braces', () => { + const t0 = Date.now() + expect(lastJsonObject(`{${'x}'.repeat(200_000)}`)).toBeUndefined() + expect(lastJsonObject('}'.repeat(200_000))).toBeUndefined() + expect(Date.now() - t0).toBeLessThan(1_000) }) it('finishes fast on deeply nested input that never closes', () => { diff --git a/test/lock.test.ts b/test/lock.test.ts index e3458ce..11f93aa 100644 --- a/test/lock.test.ts +++ b/test/lock.test.ts @@ -73,6 +73,13 @@ it('release leaves a lock that another run has since taken', () => { expect(readFileSync(path, 'utf8')).toBe('1') }) +it('replacing a stale lock leaves no file but the lock behind', () => { + const path = tmp() + writeFileSync(path, 'not a pid') + acquireLock(path) + expect(readdirSync(dirname(path))).toEqual(['app.lock']) +}) + it('leaves no staging file behind', () => { const path = tmp() acquireLock(path) diff --git a/test/log.test.ts b/test/log.test.ts index bde40d3..79f274f 100644 --- a/test/log.test.ts +++ b/test/log.test.ts @@ -6,6 +6,17 @@ describe('stripAnsi', () => { expect(stripAnsi('\x1B[1;31mFAIL\x1B[0m \x1B[2K\x1B]8;;https://x.dev\x1B\\docs\x1B]8;;\x1B\\')).toBe('FAIL docs') }) + it('removes short escapes: tput sgr0\'s charset reset, cursor save and restore, keypad mode', () => { + expect(stripAnsi('\x1B(B\x1B[mok\x1B7\x1B8\x1B=\x1B>')).toBe('ok') + }) + + it('leaves the JSON after a stray ESC intact', () => { + expect(stripAnsi('\x1B{"a":1}')).toBe('\x1B{"a":1}') + expect(stripAnsi('\x1B[1,2]')).toBe('\x1B[1,2]') + expect(stripAnsi('\x1B[{"a":1}')).toBe('\x1B[{"a":1}') + expect(stripAnsi('\x1B[[1]')).toBe('\x1B[[1]') + }) + it('removes gh\'s literal ^[ escapes only when asked', () => { expect(stripAnsi('^[[36;1mpnpm test^[[0m')).toBe('^[[36;1mpnpm test^[[0m') expect(stripAnsi('^[[36;1mpnpm test^[[0m', { caret: true })).toBe('pnpm test') diff --git a/test/proc.test.ts b/test/proc.test.ts index d5d332a..5e43bba 100644 --- a/test/proc.test.ts +++ b/test/proc.test.ts @@ -1,7 +1,8 @@ import type { Buffer } from 'node:buffer' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { execSync } from 'node:child_process' -import { realpathSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { cwdOf, isAlive, killGroup, spawnGroup, startOf } from '../src/proc' import { tempDir } from './helpers' @@ -43,6 +44,17 @@ describe.skipIf(process.platform === 'win32')('process groups', () => { expect(survivors(sleepPid, 313)).toBe('') }) + it('killGroup gives a detached descendant SIGTERM and the grace period, not just SIGKILL', async () => { + const marker = join(tempDir('clikit-'), 'termed') + const shell = `trap 'touch ${marker}; exit 0' TERM; echo ready; while :; do sleep 0.05; done` + const cli = `const s = require('node:child_process').spawn('sh', ['-c', ${JSON.stringify(shell)}], { detached: true, stdio: ['ignore', 'inherit', 'ignore'] }); setInterval(() => {}, 1000)` + + const c = spawnGroup(process.execPath, ['-e', cli], { env }) + await firstOutput(c) + await killGroup(c.pid as number, 2_000) + expect(existsSync(marker)).toBe(true) + }) + it('killGroup reaches a detached group started during the grace period', async () => { const cli = 'const cp = require(\'node:child_process\'); process.on(\'SIGTERM\', () => { const s = cp.spawn(\'sleep\', [\'317\'], { detached: true, stdio: \'ignore\' }); process.stdout.write(s.pid + \'\\n\'); }); process.stdout.write(\'ready\\n\'); setInterval(() => {}, 1000)' @@ -152,6 +164,12 @@ describe.skipIf(process.platform === 'win32')('process groups', () => { }) }) +it('isAlive never reads 0 or a negative pid as a live process group', () => { + expect(isAlive(0)).toBe(false) + expect(isAlive(-1)).toBe(false) + expect(isAlive(1.5)).toBe(false) +}) + it('isAlive tells a running process from a finished one', async () => { expect(isAlive(process.pid)).toBe(true) const c = spawnGroup(process.execPath, ['-e', ''], { cwd: process.cwd(), env }) From abfac8eb2fb4f60dd9f253fec668a12d17f08767 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 17 Oct 2026 19:08:16 +0200 Subject: [PATCH 2/6] chore: name the license holder as package author --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 391a375..60a58dd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "packageManager": "pnpm@10.23.0", "description": "Small, zero-dependency helpers for CLIs that run other CLIs: read the JSON that ends noisy output, kill whole process trees, watchdog timeouts, pidfile locks, clean child env, CI failure signatures.", - "author": "arenuzzz", + "author": "Maksym Synenko", "license": "MIT", "homepage": "https://github.com/devmaxxx/clikit#readme", "repository": { From 50e311278a75a251e36d734a63505e92009bd1bc Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 17 Oct 2026 19:11:50 +0200 Subject: [PATCH 3/6] ci: run on pull requests to any branch --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 182c5b2..c875ac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main jobs: lint: From 919e573c58fd934ee2ec0a278349a064baea94e3 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 17 Oct 2026 19:13:25 +0200 Subject: [PATCH 4/6] docs: add navigation, contents and a stability note to the readme --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e72656..aee39a9 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,29 @@ [![npm downloads](https://img.shields.io/npm/dm/clikit)](https://www.npmjs.com/package/clikit) [![CI](https://github.com/devmaxxx/clikit/actions/workflows/ci.yml/badge.svg)](https://github.com/devmaxxx/clikit/actions/workflows/ci.yml) [![types](https://img.shields.io/npm/types/clikit)](https://www.npmjs.com/package/clikit) +[![node](https://img.shields.io/node/v/clikit)](./package.json) [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json) [![bundle size](https://img.shields.io/bundlejs/size/clikit)](https://bundlejs.com/?q=clikit) [![license: MIT](https://img.shields.io/npm/l/clikit)](./LICENSE) +[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Recipes](#recipes) · [Alternatives](#compared-with-single-purpose-packages) · [Changelog](./CHANGELOG.md) + +## Contents + +- [Why](#why) +- [Features](#features) +- [Install](#install) +- [Quick start](#quick-start) +- [API](#api): [json](#clikitjson) · [proc](#clikitproc) · [watchdog](#clikitwatchdog) · [lock](#clikitlock) · [env](#clikitenv) · [log](#clikitlog) · [errors](#clikiterrors) +- [Recipes](#recipes) +- [Compared with single-purpose packages](#compared-with-single-purpose-packages) +- [Limits](#limits) +- [Stability](#stability) +- [Contributing](#contributing) +- [License](#license) + ## Why A tool that drives other tools — a task runner, a CI bot, an AI agent harness — keeps hitting the same handful of problems: @@ -347,6 +364,10 @@ Use jsonrepair or partial-json when the JSON itself is damaged or still streamin - `acquireLock` is for one machine. It does not wait or retry, and it is not safe on network file systems. Clearing a stale lock is safe between two runs; three runs racing over one stale lock in the same few microseconds can still end with two holders, because Node has no `rename` that refuses to overwrite. - `failureSignature` is tuned for GitHub Actions, pnpm, turbo, vitest, tsc and Playwright output; other tools fall back to their last five lines. +## Stability + +clikit is pre-1.0. It follows [semver](https://semver.org), so until 1.0 a minor release (`0.x`) may change an API; every change is listed in the [changelog](./CHANGELOG.md). Pin a minor range (`~0.1.0`) if you need to stay put. + ## Contributing Issues and pull requests are welcome at [github.com/devmaxxx/clikit](https://github.com/devmaxxx/clikit). @@ -359,7 +380,7 @@ pnpm lint pnpm typecheck ``` -Tests pin behaviour, not implementation: a new case should describe what a caller relies on. +Tests pin behaviour, not implementation: a new case should describe what a caller relies on. CI runs lint, typecheck and the tests on Linux, macOS and Windows with Node 22 and 24 for every pull request. Add a line to the [changelog](./CHANGELOG.md) under the unreleased version. ## License From 4b13091bc7a5d9271a3e0f66aeafcf21091d385c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 17 Oct 2026 19:20:57 +0200 Subject: [PATCH 5/6] docs: add runnable examples and run them in ci --- .github/workflows/ci.yml | 1 + README.md | 3 +++ eslint.config.js | 7 +++++++ examples/ci-signature.mjs | 22 ++++++++++++++++++++++ examples/env.mjs | 16 ++++++++++++++++ examples/json.mjs | 30 ++++++++++++++++++++++++++++++ examples/lock.mjs | 32 ++++++++++++++++++++++++++++++++ examples/run.mjs | 20 ++++++++++++++++++++ examples/watchdog.mjs | 32 ++++++++++++++++++++++++++++++++ package.json | 3 ++- 10 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 examples/ci-signature.mjs create mode 100644 examples/env.mjs create mode 100644 examples/json.mjs create mode 100644 examples/lock.mjs create mode 100644 examples/run.mjs create mode 100644 examples/watchdog.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c875ac9..5222087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,4 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test + - run: pnpm examples diff --git a/README.md b/README.md index aee39a9..4594410 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ errnoCode(err) // 'ENOENT' for fs errors; undefined for a numeric exit code ## Recipes +Runnable versions live in [`examples/`](./examples): each one prints what it does and asserts the result, and CI runs them against the build on every platform. Try them with `pnpm build && pnpm examples`, or one at a time with `node examples/watchdog.mjs`. + ### Run a CLI with a watchdog and parse its JSON answer ```ts @@ -376,6 +378,7 @@ Issues and pull requests are welcome at [github.com/devmaxxx/clikit](https://git pnpm install pnpm build pnpm test +pnpm examples pnpm lint pnpm typecheck ``` diff --git a/eslint.config.js b/eslint.config.js index 8c176a3..eaeb4a5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,4 +3,11 @@ import antfu from '@antfu/eslint-config' export default antfu({ type: 'lib', +}, { + // Examples are scripts a reader runs: they print what they do and await at the top level. + files: ['examples/**'], + rules: { + 'no-console': 'off', + 'antfu/no-top-level-await': 'off', + }, }) diff --git a/examples/ci-signature.mjs b/examples/ci-signature.mjs new file mode 100644 index 0000000..6c715f7 --- /dev/null +++ b/examples/ci-signature.mjs @@ -0,0 +1,22 @@ +// Tell "the same CI failure again" from "a different failure" across reruns. +import assert from 'node:assert/strict' +import { failureSignature } from 'clikit/log' + +// What `gh run view --log-failed` prints: job, step and a timestamp that changes on every run. +function log(at, assertion) { + return [ + `unit\tRun tests\t${at}1Z \x1B[31m FAIL \x1B[39m src/sum.test.ts > adds (12ms)`, + `unit\tRun tests\t${at}2Z ${assertion} at /home/runner/work/app/src/sum.ts:10:5`, + `unit\tRun tests\t${at}3Z ##[error]Process completed with exit code 1.`, + ].join('\n') +} + +const first = failureSignature(log('2026-10-06T17:44:02.', 'AssertionError: expected 3 to be 4')) +const rerun = failureSignature(log('2026-10-06T18:02:51.', 'AssertionError: expected 3 to be 4')) +const other = failureSignature(log('2026-10-06T18:30:07.', 'TypeError: sum is not a function')) + +console.log('first run →', first) +console.log('rerun →', rerun) +console.log('other bug →', other) +assert.equal(first, rerun) +assert.notEqual(first, other) diff --git a/examples/env.mjs b/examples/env.mjs new file mode 100644 index 0000000..07cde4d --- /dev/null +++ b/examples/env.mjs @@ -0,0 +1,16 @@ +// Start a child without the credentials and git bindings this process carries. +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import process from 'node:process' +import { withoutGitVars, withoutSecrets } from 'clikit/env' + +const parent = { ...process.env, GH_TOKEN: 'ghp_example', GIT_DIR: '/elsewhere/.git', GIT_SSH_COMMAND: 'ssh -i key' } +const env = withoutGitVars(withoutSecrets(parent)) + +const seen = JSON.parse(execFileSync(process.execPath, ['-e', String.raw` + const { GH_TOKEN, GIT_DIR, GIT_SSH_COMMAND } = process.env + console.log(JSON.stringify({ GH_TOKEN, GIT_DIR, GIT_SSH_COMMAND })) +`], { env, encoding: 'utf8' })) + +console.log('the child sees', seen) +assert.deepEqual(seen, { GIT_SSH_COMMAND: 'ssh -i key' }) diff --git a/examples/json.mjs b/examples/json.mjs new file mode 100644 index 0000000..f06abb6 --- /dev/null +++ b/examples/json.mjs @@ -0,0 +1,30 @@ +// Read the JSON answer a CLI prints after its progress lines, and the object a model wraps in prose. +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import process from 'node:process' +import { lastJson, lastJsonObject } from 'clikit/json' + +// Stands in for `pnpm ls --json`: progress, colour and a CRLF pretty-printed answer, all on stdout. +const cli = String.raw` +process.stdout.write('Progress: resolved 412, reused 410, downloaded 2\n') +process.stdout.write('\x1B[32m✔\x1B[39m Done in 1.2s\r\n') +process.stdout.write(JSON.stringify({ name: 'app', version: '1.4.0' }, null, 2).replace(/\n/g, '\r\n') + '\r\n') +` +const stdout = execFileSync(process.execPath, ['-e', cli], { encoding: 'utf8' }) + +const pkg = lastJson(stdout) +console.log('lastJson →', pkg) +assert.deepEqual(pkg, { name: 'app', version: '1.4.0' }) + +const reply = [ + 'Sure! Here is the review:', + '```json', + '{ "verdict": "approve", "issues": [] }', + '```', + 'Close the block with `}` if you edit it.', +].join('\n') + +const isReview = v => typeof v === 'object' && v !== null && 'verdict' in v +const review = lastJsonObject(reply, isReview) +console.log('lastJsonObject →', review) +assert.deepEqual(review, { verdict: 'approve', issues: [] }) diff --git a/examples/lock.mjs b/examples/lock.mjs new file mode 100644 index 0000000..4657b1a --- /dev/null +++ b/examples/lock.mjs @@ -0,0 +1,32 @@ +// Keep one copy of a daemon running, and recover from a lock left behind by a crash. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { acquireLock, LockHeld } from 'clikit/lock' + +const dir = mkdtempSync(join(tmpdir(), 'clikit-example-')) +const path = join(dir, 'daemon.lock') + +try { + const lock = acquireLock(path) + console.log(`first run holds ${path}`) + + assert.throws(() => acquireLock(path), (e) => { + console.log(`second run: ${e.message}`) + return e instanceof LockHeld && e.pid === process.pid + }) + + lock.release() + + // A crashed run leaves its pid behind; that process is gone, so the lock is stale and taken over. + const crashed = spawnSync(process.execPath, ['-e', '']).pid + writeFileSync(path, String(crashed)) + acquireLock(path).release() + console.log(`a lock left by dead pid ${crashed} was taken over`) +} +finally { + rmSync(dir, { recursive: true, force: true }) +} diff --git a/examples/run.mjs b/examples/run.mjs new file mode 100644 index 0000000..e51c23b --- /dev/null +++ b/examples/run.mjs @@ -0,0 +1,20 @@ +// Runs every example against the built package; `pnpm build` first. +import { spawnSync } from 'node:child_process' +import { readdirSync } from 'node:fs' +import { basename, join } from 'node:path' +import process from 'node:process' + +const dir = import.meta.dirname +const self = basename(import.meta.filename) +let failed = 0 + +for (const file of readdirSync(dir).filter(f => f.endsWith('.mjs') && f !== self).sort()) { + console.log(`\n▶ ${file}`) + const { status } = spawnSync(process.execPath, [join(dir, file)], { stdio: 'inherit' }) + + if (status !== 0) { + failed++ + } +} + +process.exit(failed === 0 ? 0 : 1) diff --git a/examples/watchdog.mjs b/examples/watchdog.mjs new file mode 100644 index 0000000..cde964c --- /dev/null +++ b/examples/watchdog.mjs @@ -0,0 +1,32 @@ +// Run a CLI under a wall-clock and a stall limit, and stop its whole process tree when it hangs. +import assert from 'node:assert/strict' +import process from 'node:process' +import { killGroup, spawnGroup } from 'clikit/proc' +import { Watchdog } from 'clikit/watchdog' + +if (process.platform === 'win32') { + console.log('skipped: process groups are POSIX-only') + process.exit(0) +} + +// Prints twice, then hangs with a grandchild that would outlive a plain child.kill(). +const child = spawnGroup('sh', ['-c', 'echo working; sleep 0.2; echo still working; sleep 60 & wait']) +let reason + +const dog = new Watchdog({ wallMs: 10_000, stallMs: 500 }, (why) => { + reason = why + void killGroup(child.pid, 1_000) +}) +const tick = setInterval(() => dog.check(), 50) + +child.stdout.on('data', (b) => { + process.stdout.write(`child: ${b}`) + dog.poke() +}) + +const signal = await new Promise(r => child.on('close', (_code, sig) => r(sig))) +clearInterval(tick) +dog.stop() + +console.log(`watchdog fired: ${reason}; child ended by ${signal}`) +assert.equal(reason, 'stalled') diff --git a/package.json b/package.json index 60a58dd..a3f5cb4 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "lint": "eslint .", "typecheck": "tsc", "test": "vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "pnpm run build", + "examples": "node examples/run.mjs" }, "devDependencies": { "@antfu/eslint-config": "^9.5.1", From 5cdc4a96a03cffe63ec5c931c05ae7db63cbefdf Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 17 Oct 2026 19:33:55 +0200 Subject: [PATCH 6/6] ci: test the oldest node on linux only --- .github/workflows/ci.yml | 7 ++++++- README.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5222087..4e24181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,16 @@ jobs: - run: pnpm typecheck test: + name: test (${{ matrix.os }}, node ${{ matrix.node }}) runs-on: ${{ matrix.os }} strategy: matrix: - node: [22, 24] + node: [24] os: [ubuntu-latest, windows-latest, macos-latest] + # The oldest supported Node behaves the same on every OS; one job is enough to hold the engines floor. + include: + - node: 22 + os: ubuntu-latest fail-fast: false steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 4594410..6b67fa5 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ pnpm lint pnpm typecheck ``` -Tests pin behaviour, not implementation: a new case should describe what a caller relies on. CI runs lint, typecheck and the tests on Linux, macOS and Windows with Node 22 and 24 for every pull request. Add a line to the [changelog](./CHANGELOG.md) under the unreleased version. +Tests pin behaviour, not implementation: a new case should describe what a caller relies on. CI runs lint, typecheck, the tests and the examples for every pull request: on Linux, macOS and Windows with Node 24, and on Linux with Node 22, the oldest supported. Add a line to the [changelog](./CHANGELOG.md) under the unreleased version. ## License