@rig-c/core 2.63.0 → 2.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -617,7 +617,7 @@ commands take it and what its default is.
617
617
  | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
618
618
  | `guide <RULE>` / `guide §<n>[.<m>[.<k>]] [--all]` | the guide's row for the rule a failure line names — every `FAIL` line, `vote --record` refusal, `ingest` finding and coded compile error ends with ` (see rigc guide <RULE>)`; only legacy uncoded library errors point at `§5.1` — or a section's heading, own prose and subsection index, read from the installed `docs/AUTHORING.md` when it runs. `--all` includes the subsection bodies; a leaf prints its full text either way. Sizes describe full ledger spans. `--all` with a rule is refused, exit 2; an unknown name is refused with the nearest real ones, exit 1 |
619
619
  | `skills install [--dir …] [--copy]` | links every agent skill the package ships into `.agents/skills` (or `--dir`) as relative symlinks, or copies them with `--copy` — where Codex, Gemini CLI and Antigravity look. A second run has nothing to do; an entry already there that is not this command's is refused by name and nothing is written |
620
- | `review <dir> […]` | the human review of a build, handed to the `rig-review` bin of `@rig-c/review` installed beside the package, with every argument untouched and its exit code returned: `rigc review open <dir>` prints a loopback URL to give a person, `check <dir>` validates the folder it would serve, `feedback <dir>` prints what they left, and `rigc review --help` is review's own usage. rigc does not depend on it: without it the command prints `rigc review: @rig-c/review is not installed — npm i -D @rig-c/review` and exits 2. An empty argument (`""`) is handed over only where the running Bun keeps one when it starts a process — Bun 1.2.0 drops it, measured by a probe rather than by version — and is otherwise refused by name, `rigc review: argument N is empty, and Bun <version>, the Bun running this, drops an empty argument when it starts a process — …`, exit 2 |
620
+ | `review <dir> […]` | the human review of a build, handed to the `rig-review` bin of `@rig-c/review` installed beside the package; rigc does not depend on it and never installs it. `rigc review <dir> [--reference <video>] [--animation <name>] [--compare <dir>]` is `rig-review open <dir> …` with those flags 1:1 and prints the loopback URL to give a person; a first argument that is one of review's own words (`open`, `check`, `feedback`, `control`, `strip`, `compare`) or starts with `-` (`--help`, `--`) is handed over untouched. stdout is review's byte for byte, stderr review's plus the launcher's own refusals, the exit code review's. Refused by name, exit 2, before the bin starts: `REVIEW_OUTPUT_MISSING <path>` (the target is not a directory); `REVIEW_NOT_INSTALLED` with two lines, `bun add -d @rig-c/review` and `bunx @rig-c/review open <dir> …` carrying the flags given — the same on a terminal and off, with nothing installed or written; `REVIEW_BUN_TOO_OLD: Bun <found>, @rig-c/review needs <required>`, the floor read off the installed package's `engines.bun`; and an empty argument (`""`) under a Bun that drops one (Bun 1.2.0, measured by a probe), `rigc review: argument N is empty, and Bun <version>, the Bun running this, drops an empty argument when it starts a process — …`. docs/AUTHORING.md lists every line |
621
621
 
622
622
  `diff`, `bonedist`, `check` and `bench` measure against something you were given; the
623
623
  first three work on any reference you have, and `bench` is a repository workflow that needs a clone
package/bin/rigc.cjs CHANGED
@@ -56,7 +56,15 @@ const entry = present === null ? 'cli_core.ts' : 'cli.ts';
56
56
  const args = process.argv.slice(2);
57
57
  const result = spawnSync('bun', [path.join(root, entry), ...args], { stdio: 'inherit' });
58
58
 
59
- if (!result.error && result.status === 0 && args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
59
+ // ⚠️ The exit and the signal are read before `error` (issue #1488): Bun 1.2.0 —
60
+ // the declared minimum, and what runs this file as `bun bin/rigc.cjs` — sets
61
+ // `error` on every child that exits non-zero, so reading it first answered the
62
+ // CLI's own refusal (exit 2) with "could not launch bun" and exit 1. `error` is
63
+ // a launch that failed only where the child left neither a status nor a signal.
64
+ // Node never sets `error` beside a status, so under node nothing here changes.
65
+ const launched = typeof result.status === 'number' || result.signal !== null;
66
+
67
+ if (launched && result.status === 0 && args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
60
68
  process.stderr.write(
61
69
  present === null
62
70
  ? `entry: ${entry} — ${RUNTIME} absent — the round trip and the commands that need it are not available here; see --help\n`
@@ -64,20 +72,18 @@ if (!result.error && result.status === 0 && args.length === 1 && (args[0] === '-
64
72
  );
65
73
  }
66
74
 
67
- if (result.error) {
68
- if (result.error.code === 'ENOENT') {
69
- process.stderr.write('rigc runs on Bun, which was not found on PATH — install it from https://bun.sh\n');
70
- } else {
71
- process.stderr.write(`rigc: could not launch bun: ${result.error.message}\n`);
72
- }
73
- process.exit(1);
74
- }
75
-
76
- if (result.signal) {
75
+ if (typeof result.status === 'number') {
76
+ process.exit(result.status);
77
+ } else if (result.signal !== null) {
77
78
  // A signal (e.g. Ctrl-C) killed the child — die the same way instead of
78
79
  // inventing an exit code, so the caller sees the same thing it would have
79
80
  // seen running bun directly.
80
81
  process.kill(process.pid, result.signal);
81
82
  } else {
82
- process.exit(typeof result.status === 'number' ? result.status : 1);
83
+ if (result.error && result.error.code === 'ENOENT') {
84
+ process.stderr.write('rigc runs on Bun, which was not found on PATH — install it from https://bun.sh\n');
85
+ } else if (result.error) {
86
+ process.stderr.write(`rigc: could not launch bun: ${result.error.message}\n`);
87
+ }
88
+ process.exit(1);
83
89
  }
package/docs/AUTHORING.md CHANGED
@@ -143,9 +143,9 @@ bun cli.ts vote --candidate path/to/spine-a --candidate path/to/spine-b # -> b
143
143
  bun cli.ts vote --record vote-<id>.json # -> votes.jsonl
144
144
 
145
145
  # …and when the build is for a person to look at rather than for you to choose
146
- # between, hand it over: with @rig-c/review installed beside rigc, give the person
147
- # the loopback URL this prints (rigc review --help is review's own usage):
148
- bun cli.ts review open path/to/spine
146
+ # between, hand it over: give the person the loopback URL this prints on stdout
147
+ # (`rigc review <dir>` is `rig-review open <dir>`; rigc review --help is review's own usage):
148
+ bun cli.ts review path/to/spine [--reference reference.mp4] [--animation <name>]
149
149
  ```
150
150
 
151
151
  `build` compiles, runs the named assertions — through a `@esotericsoftware/spine-core`
@@ -326,6 +326,29 @@ the package's copy (<file> differs)`. A link to the package's own folder is not
326
326
  the way however it is spelled, so an absolute link somebody else made is kept and
327
327
  reported `already linked`.
328
328
 
329
+ `review` is the third, and it is a launcher: its body is the `rig-review` bin of
330
+ `@rig-c/review`, a separate package rigc does not depend on and **never installs**.
331
+ `rigc review <dir> [--reference <video>] [--animation <name>] [--compare <dir>]`
332
+ hands over `open <dir> …` with those flags 1:1; a first argument that is one of
333
+ rig-review's own words — `open`, `check`, `feedback`, `control`, `strip`,
334
+ `compare`, or anything starting with `-` (`--help`, `-h`, and `--`, which hands
335
+ the rest over as it is) — is handed over untouched. Nothing else is added: no
336
+ `--reference` is derived, because no build rigc writes records the clip it was
337
+ retargeted from. What comes back is review's: **stdout is review's stdout byte for
338
+ byte** (the URL line an agent gives a person), stderr is review's plus only the
339
+ launcher's own refusals, and the exit code is review's. The launcher relies on
340
+ review's side of that contract — the URL alone on stdout, its diagnostics on
341
+ stderr, its own refusals in its own words — and does not restate it. Before the
342
+ bin starts, it refuses by name, exit 2, nothing on stdout:
343
+
344
+ | Line on stderr | When | What to do |
345
+ | --- | --- | --- |
346
+ | `rigc review: REVIEW_OUTPUT_MISSING <path>: nothing is there — …` (or `it is not a directory`) | the first argument was read as a target and is not a directory. Checked first, before the package is looked for | point it at the `--out` a green `build` wrote, or pass `--` first to hand the argument over as it is |
347
+ | `rigc review: REVIEW_NOT_INSTALLED: @rig-c/review is not installed beside this project or beside rig-c, and rigc does not install it — …`, then `bun add -d @rig-c/review`, then `bunx @rig-c/review <what the bin would have been handed>` | the package resolves neither from the working directory up nor from rig-c's root up. The same three lines on a terminal and off one; nothing is installed, fetched or written, and `package.json` and `bun.lock` are untouched | run the first line to install it, or the second to run it once without installing (it fetches into Bun's cache — that is your choice to make, not rigc's) |
348
+ | `rigc review: REVIEW_BUN_TOO_OLD: Bun <found>, @rig-c/review needs <required>` | the installed package's own `engines.bun` floor is above the running Bun. `<required>` is read off that manifest, not written in rigc | upgrade Bun |
349
+ | `rigc review: REVIEW_BUN_RANGE_UNREAD: @rig-c/review declares engines.bun "<range>", and rigc reads only ">=X.Y.Z" — …` | the installed package's `engines.bun` is a range shape rigc does not read, so whether this Bun satisfies it was not guessed | run the bin yourself (`bunx rig-review …` from the project), or a rigc that reads that shape |
350
+ | `rigc review: argument N is empty, and Bun <version>, the Bun running this, drops an empty argument when it starts a process — …` | an `""` argument under a Bun that would drop it (Bun 1.2.0), measured by a probe | give it a value, or run rigc under a Bun that keeps it |
351
+
329
352
  Pick the profile deliberately, and know which one you got by saying nothing. The
330
353
  default is `spine`: "is this valid Spine 4.3 that any runtime plays correctly",
331
354
  which is the question a rig authored anywhere is asking. `--profile rig-play`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.63.0",
3
+ "version": "2.64.1",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,7 @@ import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, fram
47
47
  import { type CompileResult } from '../types.ts';
48
48
  import { attachmentRegionJoins } from '../region_joins.ts';
49
49
  import { cmdRepack } from './repack.ts';
50
+ import { writeWhole } from './write_whole.ts';
50
51
  import { GUIDE_FILE, GUIDE_RULE_TABLES_ELSEWHERE, guideAnswer, refusalLine } from '../guide.ts';
51
52
  import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
52
53
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
@@ -2213,7 +2214,9 @@ export function cmdGuide(flags: Record<string, string>, positional: string[]): v
2213
2214
  }
2214
2215
  const answer = guideAnswer(positional[0], { guide: readFileSync(join(PACKAGE_ROOT, GUIDE_FILE), 'utf8'), elsewhere }, { all: flags.all !== undefined });
2215
2216
  if (!answer.ok) throw new GuideError(refusalLine(answer.rule, answer.detail));
2216
- for (const line of answer.lines) console.log(line);
2217
+ // One write of the whole answer, not `console.log`: a §section with `--all` runs past 64 KiB, which Bun 1.2.0's
2218
+ // console corrupts at a chunk boundary and a non-blocking pipe cuts short on every Bun (issue #1487, `writeWhole`).
2219
+ writeWhole(1, answer.lines.map((line) => `${line}\n`).join(''));
2217
2220
  }
2218
2221
 
2219
2222
  // ---------------------------------------------------------------------------
@@ -2341,31 +2344,128 @@ export function emptyArgumentRefusal(pkg: string, raw: readonly string[], keeps:
2341
2344
  );
2342
2345
  }
2343
2346
 
2347
+ /**
2348
+ * One argument as a POSIX shell reads it back (issue #1486): left bare when it is made only of characters no shell
2349
+ * treats specially, otherwise single-quoted with each `'` spelled `'\''`. The empty argument is `''`. Deterministic
2350
+ * — the same argument always prints the same word — so a guidance line naming the flags a caller gave is one string.
2351
+ */
2352
+ export function shellWord(arg: string): string {
2353
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg;
2354
+ return `'${arg.replaceAll("'", "'\\''")}'`;
2355
+ }
2356
+
2357
+ /**
2358
+ * What a delegating command hands its bin, and the build directory it was pointed at (issue #1486). A delegate that
2359
+ * names a `bareTarget` subcommand treats a first argument that is none of its `subcommands` and does not start with
2360
+ * `-` (`--help`, `-h`, `--` and every flag pass through) as a target: `rigc review <dir> …` hands over
2361
+ * `open <dir> …`. Every other command line — no argument at all, an empty first argument, or a subcommand — is handed
2362
+ * over as given, and `target` is `null`.
2363
+ */
2364
+ export function delegateArguments(delegate: { subcommands?: readonly string[]; bareTarget?: string }, raw: readonly string[]): { argv: string[]; target: string | null } {
2365
+ const first = raw[0];
2366
+ if (delegate.bareTarget === undefined || first === undefined || first === '' || first.startsWith('-') || (delegate.subcommands ?? []).includes(first)) {
2367
+ return { argv: [...raw], target: null };
2368
+ }
2369
+ return { argv: [delegate.bareTarget, ...raw], target: first };
2370
+ }
2371
+
2372
+ /**
2373
+ * The refusal for a delegated package that does not resolve (issue #1486): `REVIEW_NOT_INSTALLED` and two guidance
2374
+ * lines — the command that installs it, and the one that runs it once without installing, carrying `argv` (what the
2375
+ * bin would have been handed, a bare target already spelled `open <dir>`) word for word. With nothing to carry, the
2376
+ * second line names the target it needs as `<output>`. Nothing reads the terminal, so it is the same on a TTY and
2377
+ * off one, and rigc runs neither line: an install and a one-off fetch both change state the caller did not ask for.
2378
+ */
2379
+ export function notInstalledRefusal(pkg: string, argv: readonly string[]): string {
2380
+ const words = argv.length === 0 ? ['open', '<output>'] : argv.map(shellWord);
2381
+ return (
2382
+ `REVIEW_NOT_INSTALLED: ${pkg} is not installed beside this project or beside rig-c, and rigc does not install it — ` +
2383
+ `install it, or run it once without installing:\n` +
2384
+ `bun add -d ${pkg}\n` +
2385
+ `bunx ${pkg} ${words.join(' ')}`
2386
+ );
2387
+ }
2388
+
2389
+ /**
2390
+ * The refusal for a delegated package whose manifest declares a Bun floor the running Bun is under, or `null`
2391
+ * (issue #1486). The floor is read off the resolved package's own `engines.bun`, never written here: a package that
2392
+ * declares none has no floor to hold. Only `>=X.Y.Z` is read; any other range shape is refused by name rather than
2393
+ * guessed at (`REVIEW_BUN_RANGE_UNREAD`). A running version with a pre-release tag is below its own release, as
2394
+ * semver orders them.
2395
+ */
2396
+ export function bunFloorRefusal(pkg: string, engines: unknown, running: string): string | null {
2397
+ if (engines === undefined) return null;
2398
+ const isObject = typeof engines === 'object' && engines !== null && !Array.isArray(engines);
2399
+ const range = isObject ? (engines as Record<string, unknown>).bun : undefined;
2400
+ if (isObject && range === undefined) return null;
2401
+ const floor = typeof range === 'string' ? /^\s*>=\s*(\d+)\.(\d+)\.(\d+)\s*$/.exec(range) : null;
2402
+ if (floor === null) {
2403
+ const what = isObject ? `engines.bun ${JSON.stringify(range)}` : `engines ${JSON.stringify(engines)}`;
2404
+ return `REVIEW_BUN_RANGE_UNREAD: ${pkg} declares ${what}, and rigc reads only ">=X.Y.Z" — it did not guess whether Bun ${running} satisfies it`;
2405
+ }
2406
+ const found = /^(\d+)\.(\d+)\.(\d+)(-\S*)?/.exec(running);
2407
+ const below = ((): boolean => {
2408
+ if (found === null) return true;
2409
+ for (let i = 1; i <= 3; i++) {
2410
+ const a = Number(found[i]);
2411
+ const b = Number(floor[i]);
2412
+ if (a !== b) return a < b;
2413
+ }
2414
+ return found[4] !== undefined;
2415
+ })();
2416
+ return below ? `REVIEW_BUN_TOO_OLD: Bun ${running}, ${pkg} needs ${range as string}` : null;
2417
+ }
2418
+
2344
2419
  /**
2345
2420
  * A delegating command's body (`CommandDoc.delegate`, issue #1457): resolve the
2346
2421
  * package's bin and run it under the Bun running this — the bin is a Bun script
2347
- * — with every argument untouched and stdio inherited, then exit with its code,
2348
- * or die by its signal the way `bin/rigc.cjs` does, so a caller reads what it
2349
- * would have read running the bin itself. Not installed, it is refused by name
2350
- * with the one command that installs it, exit 2, and nothing else is printed.
2422
+ * — with stdio inherited, then exit with its code, or die by its signal the way
2423
+ * `bin/rigc.cjs` does, so a caller reads what it would have read running the bin
2424
+ * itself: stdout is the bin's byte for byte, stderr the bin's plus only the
2425
+ * launcher's own refusals, the exit code the bin's.
2426
+ *
2427
+ * Issue #1486 put four things in front of the spawn, in this order, each a
2428
+ * refusal by name with exit 2 and nothing on stdout: a bare target that is not a
2429
+ * directory (`REVIEW_OUTPUT_MISSING <path>`, read before the install because a
2430
+ * guidance line pointing at nothing is no guidance); the package absent
2431
+ * (`REVIEW_NOT_INSTALLED` and two guidance lines — never an install, never a
2432
+ * `bunx`); a Bun below the package's declared floor (`REVIEW_BUN_TOO_OLD`); an
2433
+ * empty argument this Bun would drop (issue #1465). The argument list is the
2434
+ * caller's, a bare target prefixed with `open`, and nothing else: no
2435
+ * `--reference` is derived, because no build rigc writes records the clip it was
2436
+ * retargeted from.
2351
2437
  */
2352
2438
  function cmdDelegate(command: string, raw: readonly string[]): void {
2353
2439
  const delegate = COMMANDS.find((c) => c.name === command)?.delegate;
2354
2440
  if (delegate === undefined) throw new Error(`internal: ${command} names no delegate`);
2441
+ const { argv, target } = delegateArguments(delegate, raw);
2442
+ if (target !== null && !(existsSync(target) && statSync(target).isDirectory())) {
2443
+ throw new DelegateError(
2444
+ `REVIEW_OUTPUT_MISSING ${target}: ${existsSync(target) ? 'it is not a directory' : 'nothing is there'} — \`rigc review <dir>\` opens a build's output directory, ` +
2445
+ `and the first argument is read as one because it is none of ${(delegate.subcommands ?? []).join(', ')} and does not start with "-" (\`rigc review -- <arg>\` hands it over as it is)`,
2446
+ 2,
2447
+ );
2448
+ }
2355
2449
  const manifest = resolveDelegate(delegate.package);
2356
- if (manifest === null) throw new DelegateError(`${delegate.package} is not installed — npm i -D ${delegate.package}`, 2);
2357
- const bins = (readJsonFile(manifest) as { bin?: unknown }).bin;
2450
+ if (manifest === null) throw new DelegateError(notInstalledRefusal(delegate.package, argv), 2);
2451
+ const read = readJsonFile(manifest) as { bin?: unknown; engines?: unknown };
2452
+ const bins = read.bin;
2358
2453
  const relativeBin = typeof bins === 'object' && bins !== null ? (bins as Record<string, unknown>)[delegate.bin] : undefined;
2359
2454
  const bin = typeof relativeBin === 'string' ? join(dirname(manifest), relativeBin) : null;
2360
2455
  if (bin === null || !existsSync(bin)) {
2361
2456
  throw new DelegateError(
2362
- `${delegate.package} at ${dirname(manifest)} ${bin === null ? `declares no "${delegate.bin}" bin` : `declares the "${delegate.bin}" bin ${String(relativeBin)}, which is not there`} — npm i -D ${delegate.package}`,
2457
+ `${delegate.package} at ${dirname(manifest)} ${bin === null ? `declares no "${delegate.bin}" bin` : `declares the "${delegate.bin}" bin ${String(relativeBin)}, which is not there`} — bun add -d ${delegate.package}`,
2363
2458
  2,
2364
2459
  );
2365
2460
  }
2366
- const dropped = emptyArgumentRefusal(delegate.package, raw, spawnKeepsEmptyArguments, process.versions.bun ?? 'unknown');
2461
+ const old = bunFloorRefusal(delegate.package, read.engines, process.versions.bun ?? 'unknown');
2462
+ if (old !== null) throw new DelegateError(old, 2);
2463
+ const dropped = emptyArgumentRefusal(delegate.package, argv, spawnKeepsEmptyArguments, process.versions.bun ?? 'unknown');
2367
2464
  if (dropped !== null) throw new DelegateError(dropped, 2);
2368
- const ran = spawnSync(process.execPath, [bin, ...raw], { stdio: 'inherit' });
2465
+ // 🔒 The `--` is Bun's, not the bin's: Bun 1.2.0 and 1.4.2 alike drop one `--` standing right after the script, so
2466
+ // `rigc review -- <dir>` reached the bin as `<dir>` (measured, issue #1486). One given here is the one dropped, and
2467
+ // the arguments after it arrive as they were.
2468
+ const ran = spawnSync(process.execPath, [bin, '--', ...argv], { stdio: 'inherit' });
2369
2469
  // ⚠️ The exit and the signal are read before `error`: Bun 1.2.0 — the declared minimum — sets `error` on every
2370
2470
  // child that exits non-zero, so reading it first refused review's own exit 7 as "could not run". `error` is a
2371
2471
  // launch that failed only where the child left neither a status nor a signal.
package/src/cli/shared.ts CHANGED
@@ -70,6 +70,7 @@ import { RepackError } from '../repack.ts';
70
70
  import { SplitError } from '../meshsplit.ts';
71
71
  import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
72
72
  import type { CompileResult, DroppedState } from '../types.ts';
73
+ import { routeConsoleLogThroughWriteWhole, StdoutWriteError } from './write_whole.ts';
73
74
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
74
75
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
75
76
 
@@ -1418,8 +1419,12 @@ interface CommandDoc {
1418
1419
  * delegate's exit code is the command's. `flags` is empty for that reason:
1419
1420
  * rigc documents no flag of a program it does not own. A control that reads
1420
1421
  * a command's flag table off `--help` reads it off the commands without this.
1422
+ *
1423
+ * `bareTarget` and `subcommands` (issue #1486): a first argument that is none
1424
+ * of `subcommands` and does not start with `-` is a target, handed over as
1425
+ * `<bareTarget> <target> …` — the one rewrite the launcher makes.
1421
1426
  */
1422
- delegate?: { package: string; bin: string };
1427
+ delegate?: { package: string; bin: string; bareTarget?: string; subcommands?: readonly string[] };
1423
1428
  }
1424
1429
 
1425
1430
  export const COMMANDS: CommandDoc[] = [
@@ -1952,9 +1957,12 @@ export const COMMANDS: CommandDoc[] = [
1952
1957
  name: 'review',
1953
1958
  runtime: false,
1954
1959
  spineFormat: false,
1955
- delegate: { package: '@rig-c/review', bin: 'rig-review' },
1960
+ // rig-review's own first words: every one of them, `--help`, `-h` and `--` (rig-review's way to its legacy bare
1961
+ // form) pass through; anything else first is a build directory, handed over as `open <dir>` (issue #1486).
1962
+ delegate: { package: '@rig-c/review', bin: 'rig-review', bareTarget: 'open', subcommands: ['open', 'check', 'feedback', 'control', 'strip', 'compare'] },
1956
1963
  usage: [
1957
- "rigc review <dir> [...] (@rig-c/review's rig-review, installed beside: every argument handed over untouched, its exit code returned — `rigc review open <dir>` prints a loopback URL to give a person, `rigc review --help` is rig-review's own usage)",
1964
+ "rigc review <dir> [--reference <video>] [--animation <name>] [--compare <dir>] (rig-review open <dir> …: prints a loopback URL to give a person)",
1965
+ "rigc review open|check|feedback|control|strip|compare|--help … (@rig-c/review's rig-review, installed beside: every argument handed over untouched, its stdout and exit code returned; rigc never installs it)",
1958
1966
  ],
1959
1967
  flags: [],
1960
1968
  },
@@ -2960,6 +2968,8 @@ export function helpArgv(rest: readonly string[]): string[] | null {
2960
2968
  * always had, written once for both entries.
2961
2969
  */
2962
2970
  export function runCli(entry: CliEntry, argv: readonly string[]): void {
2971
+ // Before anything prints: every line on stdout goes out whole, or is refused by name (issue #1493).
2972
+ routeConsoleLogThroughWriteWhole();
2963
2973
  const docs = entryCommands(entry.linksRuntime);
2964
2974
  const known = docs.map((doc) => doc.name);
2965
2975
  const registered = Object.keys(entry.runs).sort();
@@ -3178,6 +3188,12 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
3178
3188
  console.error(`rigc skills install: ${err.message}`);
3179
3189
  process.exit(1);
3180
3190
  }
3191
+ // A document the reader stopped taking (issue #1487): exit 1 like a lookup that could not answer, and the line
3192
+ // says how many bytes went out of how many and why — a silent short write is what `writeWhole` exists to refuse.
3193
+ if (err instanceof StdoutWriteError) {
3194
+ console.error(`rigc ${command}: ${err.message}`);
3195
+ process.exit(1);
3196
+ }
3181
3197
  // A rule or section the guide does not have (issue #1397): exit 1 like a lookup that found nothing, and the
3182
3198
  // message names what was asked and the nearest real names — the usage under it would bury them.
3183
3199
  if (err instanceof GuideError) {
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `writeWhole(fd, text)`: every byte of `text`, UTF-8, onto `fd`, or a named
3
+ * refusal — the writer a command uses for a document too large to trust to
4
+ * `console.log` (issue #1487).
5
+ *
6
+ * ⚖️ Why not `console.log`: on Bun 1.2.0, the declared minimum, one ~322 KB
7
+ * line with non-ASCII in it came out 330,634 bytes for 330,632, a stray
8
+ * `\xe2\x00` at a 64 KiB chunk boundary — to a file, a pipe and a slow pipe
9
+ * alike. And on a stdout whose pipe is non-blocking (a parent set `O_NONBLOCK`
10
+ * on it), it stopped near 64 KiB on 1.2.0 *and* 1.4.2 with exit 0.
11
+ *
12
+ * ⚖️ Why a loop and not one `writeSync(fd, text)`: on that non-blocking pipe
13
+ * `writeSync` returns a short count — 65,536 of 330,632 — and throws nothing,
14
+ * so a single call drops the rest in silence on both Buns. Each call here
15
+ * writes from where the last one stopped.
16
+ *
17
+ * ⚖️ Why not `process.stdout.write` and a wait for `drain`: whole on 1.4.2,
18
+ * but on 1.2.0 over the non-blocking pipe it wrote more bytes than it was
19
+ * given (397,312 and climbing, diverging at byte 69,633) and never exited.
20
+ *
21
+ * A pipe that is full answers `EAGAIN` (or a zero count): the loop waits —
22
+ * 1 ms, doubling to {@link STALL_WAIT_CAP_MS} — and tries again, and after
23
+ * {@link MAX_STALLS} answers in a row with no byte taken it refuses with
24
+ * {@link StdoutWriteError}. `EPIPE` — the reader closed its end, as `| head`
25
+ * does — stops the write quietly, which is what `console.log` did there (exit 0
26
+ * on both Buns, measured): the reader has what it asked for. Any other error
27
+ * (`EBADF`, `EIO`) refuses at once.
28
+ * The wait is `Atomics.wait` on a cell nothing notifies: a delay, not a reading
29
+ * of the clock — no value written depends on how long it lasted, and the bound
30
+ * is a count of attempts, so whether it refuses depends on the reader alone.
31
+ */
32
+ import { writeSync } from 'node:fs';
33
+
34
+ /** The answers in a row with no byte taken before the writer refuses — about a minute of waits at the cap. */
35
+ export const MAX_STALLS = 1000;
36
+ /** The longest single wait between two attempts on a full pipe, in milliseconds. */
37
+ export const STALL_WAIT_CAP_MS = 64;
38
+
39
+ /** A write the reader never took: what was written, of how many bytes, and why it stopped. */
40
+ export class StdoutWriteError extends Error {
41
+ override name = 'StdoutWriteError';
42
+ }
43
+
44
+ /** `fs.writeSync`'s buffer form: bytes written, or a throw carrying `code`. */
45
+ export type WriteFn = (fd: number, buffer: Uint8Array, offset: number, length: number) => number;
46
+
47
+ /**
48
+ * The selftest's plants (issue #1487): `write` stands in for `fs.writeSync` so a
49
+ * short count or an `EAGAIN` can be forced, and `wait` for the delay so a plant
50
+ * runs without sleeping. Nothing outside the selftest passes either.
51
+ */
52
+ export interface WriteWholePlants {
53
+ write?: WriteFn;
54
+ wait?: (ms: number) => void;
55
+ }
56
+
57
+ const STALL_CODES = new Set(['EAGAIN', 'EWOULDBLOCK', 'EINTR']);
58
+
59
+ function sleep(ms: number): void {
60
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
61
+ }
62
+
63
+ export function writeWhole(fd: number, text: string, plants: WriteWholePlants = {}): void {
64
+ const write = plants.write ?? ((f: number, b: Uint8Array, o: number, l: number) => writeSync(f, b, o, l));
65
+ const wait = plants.wait ?? sleep;
66
+ const bytes = Buffer.from(text, 'utf8');
67
+ let offset = 0;
68
+ let stalls = 0;
69
+ while (offset < bytes.length) {
70
+ let taken = 0;
71
+ try {
72
+ taken = write(fd, bytes, offset, bytes.length - offset);
73
+ } catch (err) {
74
+ const code = (err as NodeJS.ErrnoException).code;
75
+ // The reader closed its end (`| head`): it has what it asked for, and nobody is left to read the rest.
76
+ if (code === 'EPIPE') return;
77
+ if (code === undefined || !STALL_CODES.has(code)) {
78
+ throw new StdoutWriteError(`${offset} of ${bytes.length} byte(s) confirmed written to fd ${fd} before it stopped: ${code ?? String(err)} — the descriptor is not writable`);
79
+ }
80
+ }
81
+ if (taken > 0) {
82
+ offset += taken;
83
+ stalls = 0;
84
+ continue;
85
+ }
86
+ stalls += 1;
87
+ if (stalls > MAX_STALLS) {
88
+ throw new StdoutWriteError(`${offset} of ${bytes.length} byte(s) confirmed written to fd ${fd}, and the reader took nothing for ${MAX_STALLS} attempt(s) in a row — it stopped reading`);
89
+ }
90
+ wait(Math.min(2 ** (stalls - 1), STALL_WAIT_CAP_MS));
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The CLI's one stdout policy (issue #1493): `console.log` of this process,
96
+ * every call, through {@link writeWhole} on fd 1 — so the ~250 lines the
97
+ * commands print are written the way the guide's document is, and a
98
+ * non-blocking pipe gets all of them instead of the first ~64 KiB with exit 0.
99
+ * `runCli` installs it before anything prints, for both entries; calling it
100
+ * again changes nothing.
101
+ *
102
+ * ⚖️ One `writeWhole` per call, not a buffer flushed at exit or at a size:
103
+ * Bun's own `console.log` is synchronous per call on a blocking stdout — a
104
+ * line printed between two `console.error` lines lands between them under
105
+ * `2>&1`, to a file and to a pipe, on 1.2.0 and 1.4.2 (measured) — and a
106
+ * write per call keeps that order, keeps a long `render` or `check` printing
107
+ * as it goes, and leaves nothing behind for a `process.exit` to drop, or for a
108
+ * delegate's child, which writes to the same fd, to overtake. A buffer would
109
+ * have to answer all three and still reorders `2>&1`.
110
+ *
111
+ * The text is what Bun's `console.log` prints: the arguments joined by one
112
+ * space, a string as it is and anything else as `Bun.inspect` shows it, and a
113
+ * newline. Format directives (`%s` with a second argument) are not applied —
114
+ * every call in the CLI passes one string, so none has one to apply.
115
+ */
116
+ let consoleLogRouted = false;
117
+
118
+ export function routeConsoleLogThroughWriteWhole(): void {
119
+ if (consoleLogRouted) return;
120
+ consoleLogRouted = true;
121
+ console.log = (...args: unknown[]): void => {
122
+ writeWhole(1, `${args.map((arg) => (typeof arg === 'string' ? arg : Bun.inspect(arg))).join(' ')}\n`);
123
+ };
124
+ }