@nathapp/nax 0.75.2 → 0.75.3

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.
@@ -14,7 +14,20 @@
14
14
  *
15
15
  * Both cap wall-clock time: an unbounded gate would hang `acpx flow run`, and
16
16
  * the post-run plugin awaits that subprocess.
17
+ *
18
+ * ## Why `node:child_process` and not `Bun.spawn`
19
+ *
20
+ * The rest of nax is Bun-native (see `.claude/rules/project-conventions.md`),
21
+ * but this module is **not** loaded by nax. `acpx flow run` loads it, in acpx's
22
+ * own process, and the published `acpx` binary is a Node program
23
+ * (`#!/usr/bin/env node`). Under Node the `Bun` global does not exist, so
24
+ * `Bun.spawn` threw `ReferenceError: Bun is not defined` on the flow's very
25
+ * first git call — aborting the flow before any node completed and before the
26
+ * result file was written. Everything under `flows/` must therefore stay on
27
+ * Node built-ins; `Bun.*` is banned here and only here, enforced by
28
+ * `scripts/check-flows-no-bun.ts`.
17
29
  */
30
+ import { spawn } from "node:child_process";
18
31
  import type { RunResult } from "./types";
19
32
 
20
33
  /** Fallbacks used when the plugin passes no explicit budget in the flow input. */
@@ -28,35 +41,71 @@ export interface ExecOptions {
28
41
  timeoutMs?: number;
29
42
  }
30
43
 
31
- async function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
32
- const proc = Bun.spawn(cmd, { cwd: opts.cwd, stdout: "pipe", stderr: "pipe" });
33
- let timedOut = false;
34
- // setTimeout (not Bun.sleep) because the handle must be cancellable the moment
35
- // the process exits — the documented exception in forbidden-patterns.md.
36
- const timer =
37
- opts.timeoutMs && opts.timeoutMs > 0
38
- ? setTimeout(() => {
39
- timedOut = true;
40
- proc.kill();
41
- }, opts.timeoutMs)
42
- : undefined;
43
- try {
44
- const [exitCode, stdout, stderr] = await Promise.all([
45
- proc.exited,
46
- new Response(proc.stdout).text(),
47
- new Response(proc.stderr).text(),
48
- ]);
49
- return timedOut
50
- ? {
51
- exitCode: exitCode === 0 ? 124 : exitCode,
52
- stdout,
53
- stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
54
- timedOut: true,
55
- }
56
- : { exitCode, stdout, stderr };
57
- } finally {
58
- if (timer) clearTimeout(timer);
59
- }
44
+ /** Exit code reported when the wall-clock cap kills the process, matching `timeout(1)`. */
45
+ const TIMEOUT_EXIT_CODE = 124;
46
+ /** Exit code reported when the binary is missing, matching a shell's "command not found". */
47
+ const NOT_FOUND_EXIT_CODE = 127;
48
+
49
+ function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
50
+ return new Promise<RunResult>((resolve) => {
51
+ const [file, ...args] = cmd;
52
+ const proc = spawn(file as string, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
53
+ let stdout = "";
54
+ let stderr = "";
55
+ let timedOut = false;
56
+ let settled = false;
57
+
58
+ proc.stdout.setEncoding("utf8");
59
+ proc.stderr.setEncoding("utf8");
60
+ proc.stdout.on("data", (chunk: string) => {
61
+ stdout += chunk;
62
+ });
63
+ proc.stderr.on("data", (chunk: string) => {
64
+ stderr += chunk;
65
+ });
66
+
67
+ // setTimeout (not a sleep) because the handle must be cancellable the moment
68
+ // the process exits — the documented exception in forbidden-patterns.md.
69
+ const timer =
70
+ opts.timeoutMs && opts.timeoutMs > 0
71
+ ? setTimeout(() => {
72
+ timedOut = true;
73
+ proc.kill();
74
+ }, opts.timeoutMs)
75
+ : undefined;
76
+
77
+ const settle = (result: RunResult): void => {
78
+ if (settled) return;
79
+ settled = true;
80
+ if (timer) clearTimeout(timer);
81
+ resolve(result);
82
+ };
83
+
84
+ // A missing binary (`gh`/`glab` not installed) surfaces as an `error` event
85
+ // under Node, where `Bun.spawn` used to throw. Resolving with 127 instead of
86
+ // rejecting keeps it a readable gate failure the flow can route on, rather
87
+ // than an exception that kills `acpx flow run` with no result file.
88
+ proc.on("error", (err: Error) => {
89
+ settle({ exitCode: NOT_FOUND_EXIT_CODE, stdout, stderr: `${stderr}${err.message}`, timedOut });
90
+ });
91
+
92
+ // `close` (not `exit`) so both pipes are fully drained before we read them.
93
+ // `code` is null when the process died from a signal — including our own
94
+ // timeout kill — so it maps to a non-zero code rather than a false green.
95
+ proc.on("close", (code: number | null) => {
96
+ const exitCode = code ?? (timedOut ? TIMEOUT_EXIT_CODE : 1);
97
+ settle(
98
+ timedOut
99
+ ? {
100
+ exitCode: exitCode === 0 ? TIMEOUT_EXIT_CODE : exitCode,
101
+ stdout,
102
+ stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
103
+ timedOut: true,
104
+ }
105
+ : { exitCode, stdout, stderr },
106
+ );
107
+ });
108
+ });
60
109
  }
61
110
 
62
111
  /** Spawn an argv array directly — no shell. For flow-constructed commands. */
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { FinishError } from "../errors";
2
3
  import { DEFAULT_GATE_TIMEOUT_MS, runShell } from "../exec";
3
4
  import type { ShellRunFn } from "../types";
@@ -12,9 +13,17 @@ export interface QualityCommands {
12
13
 
13
14
  export const _qualityDeps: { runShell: ShellRunFn; readText: (path: string) => Promise<string | null> } = {
14
15
  runShell,
16
+ // node:fs, not Bun.file — this module runs inside acpx's Node process, where
17
+ // the `Bun` global does not exist (see the header of `../exec.ts`). A single
18
+ // read that treats ENOENT as "absent" also avoids the exists()-then-read race
19
+ // the Bun version had.
15
20
  readText: async (path) => {
16
- const file = Bun.file(path);
17
- return (await file.exists()) ? await file.text() : null;
21
+ try {
22
+ return await readFile(path, "utf8");
23
+ } catch (err) {
24
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
25
+ throw err;
26
+ }
18
27
  },
19
28
  };
20
29
 
@@ -1,3 +1,5 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
1
3
  import type { FinishResult } from "../types";
2
4
 
3
5
  export function resultPath(repoRoot: string): string {
@@ -5,8 +7,14 @@ export function resultPath(repoRoot: string): string {
5
7
  }
6
8
 
7
9
  export const _resultDeps: { writeText: (p: string, s: string) => Promise<void> } = {
10
+ // node:fs, not Bun.write — this module runs inside acpx's Node process, where
11
+ // the `Bun` global does not exist (see the header of `../exec.ts`). The mkdir
12
+ // is not redundant: Bun.write creates missing parent directories implicitly,
13
+ // writeFile does not, and this is the one artifact the plugin needs on disk to
14
+ // report an outcome at all.
8
15
  writeText: async (p, s) => {
9
- await Bun.write(p, s);
16
+ await mkdir(dirname(p), { recursive: true });
17
+ await writeFile(p, s, "utf8");
10
18
  },
11
19
  };
12
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.75.2",
3
+ "version": "0.75.3",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,9 +11,10 @@
11
11
  "dev": "bun run bin/nax.ts",
12
12
  "build": "bun build bin/nax.ts --outdir dist --target bun --define \"GIT_COMMIT=\\\"$(git rev-parse --short HEAD)\\\"\"",
13
13
  "typecheck": "bun x tsc --noEmit && bun x tsc --noEmit -p tsconfig.contracts.json",
14
- "lint": "bun x biome check src/ bin/ flows/ && bun run check:no-real-global-nax && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes",
14
+ "lint": "bun x biome check src/ bin/ flows/ && bun run check:flows-no-bun && bun run check:no-real-global-nax && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes",
15
15
  "lint:json": "bun x biome check src/ bin/ flows/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
16
  "lint:fix": "bun x biome check --write src/ bin/ flows/",
17
+ "check:flows-no-bun": "bun run scripts/check-flows-no-bun.ts",
17
18
  "check:no-real-global-nax": "bun run scripts/check-no-real-global-nax.ts",
18
19
  "check:alias-internals": "bun run scripts/check-alias-internals.ts",
19
20
  "check:deep-relatives": "bun run scripts/check-deep-relatives.ts",