From 775ab93d133640a35be20f8492eef89fa58df98f Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:00:00 +0200
Subject: [PATCH 1/9] Fix: cross-platform process-tree termination via
killProcessTree
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
jaiph run spawns the workflow leader detached and previously stopped it
with process.kill(-pid, signal) — a POSIX-only negative-PID group kill.
On win32 that throws, the child.kill() fallback terminated only the
leader, and the agent backends / script children it spawned were
orphaned.
Add src/runtime/kernel/portability.ts exporting killProcessTree(pid,
signal) as the single sanctioned home for group kills. On POSIX it
preserves prior behavior (negative-PID group kill with a per-process
ESRCH fallback); on win32 it force-kills the whole tree with
`taskkill /pid /T /F` (spawned, not shelled) via an injectable
seam, degrading to a per-process kill if taskkill cannot launch.
Because taskkill /F is already forceful, the SIGTERM->SIGKILL
escalation is a documented no-op on win32.
Repoint every group-kill call site at the helper: run teardown
(lifecycle.ts), the prompt watchdog (prompt.ts), and the Docker
run-timeout kill (docker.ts). New unit tests stub process.platform to
cover both branches (negative-PID kill on POSIX plus ESRCH fallback;
taskkill /T argv on win32) and prove win32 never calls process.kill
with a negative PID; a src/-wide lint test asserts no production file
outside portability.ts matches `process.kill(-`. Docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +
docs/architecture.md | 2 +-
docs/configuration.md | 2 +-
src/cli/run/lifecycle.ts | 11 +-
src/runtime/docker.ts | 18 +--
src/runtime/kernel/portability.test.ts | 204 +++++++++++++++++++++++++
src/runtime/kernel/portability.ts | 78 ++++++++++
src/runtime/kernel/prompt.test.ts | 26 +++-
src/runtime/kernel/prompt.ts | 18 +--
9 files changed, 332 insertions(+), 31 deletions(-)
create mode 100644 src/runtime/kernel/portability.test.ts
create mode 100644 src/runtime/kernel/portability.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c09f9e5..1de9058f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Unreleased
+## All changes
+
+- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
+
# 0.10.0
## Summary
diff --git a/docs/architecture.md b/docs/architecture.md
index 6dc87d33..d672076b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -123,7 +123,7 @@ User-visible contracts (banner, hooks, run artifacts, `run_summary.jsonl`, `retu
### CLI responsibilities
- Parse, validate, and launch workflows/tests.
-- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation).
+- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation). Terminating a run means terminating the whole tree — the detached leader plus the agent backends and script children it spawned — routed through **`killProcessTree(pid, signal)`** (`src/runtime/kernel/portability.ts`), the single sanctioned home for group kills. On POSIX it signals the leader's process group with **`process.kill(-pid, signal)`**, falling back to a per-process kill if the group no longer exists (`ESRCH`). On **`win32`** a negative-PID group kill throws and a per-process kill would orphan the children, so it force-kills the tree with **`taskkill /pid /T /F`** (spawned, not shelled), degrading to a per-process kill if `taskkill` cannot be launched. Because `taskkill /F` is already forceful, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32`. All group-kill call sites route through this helper: run teardown (`src/cli/run/lifecycle.ts`), the prompt watchdog (`src/runtime/kernel/prompt.ts`), and the Docker run-timeout kill (`src/runtime/docker.ts`).
- Parse live runtime events; render terminal progress; trigger hooks — skipped in **`jaiph run --raw`** (child stdio inherited; see [CLI](cli.md#jaiph-run)).
## Contracts
diff --git a/docs/configuration.md b/docs/configuration.md
index d1ba8de1..83bdb949 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -236,7 +236,7 @@ The retry backoff above handles a backend that *fails*. A separate set of watchd
Set any variable to `0` to disable that layer. The idle timer resets on every chunk of backend output, so a slow-but-active run is bounded only by the absolute cap.
-The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it sends `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container.
+The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it terminates the backend's whole process tree (via `killProcessTree`; see [Architecture](architecture.md)) with `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. On Windows the tree is force-killed with `taskkill /T` on the first signal, so the `SIGKILL` escalation is a no-op. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container.
## Custom agent commands
diff --git a/src/cli/run/lifecycle.ts b/src/cli/run/lifecycle.ts
index 55f93206..392e90c1 100644
--- a/src/cli/run/lifecycle.ts
+++ b/src/cli/run/lifecycle.ts
@@ -1,6 +1,7 @@
import { ChildProcess } from "node:child_process";
import { spawnJaiphWorkflowProcess } from "../../runtime/kernel/workflow-launch";
+import { killProcessTree } from "../../runtime/kernel/portability";
export function spawnRunProcess(
args: string[],
@@ -17,15 +18,7 @@ export function terminateRunProcessGroup(
if (!pid) {
return;
}
- try {
- process.kill(-pid, signal);
- } catch {
- try {
- child.kill(signal);
- } catch {
- // no-op
- }
- }
+ killProcessTree(pid, signal);
}
export function setupRunSignalHandlers(
diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts
index de91f7c8..b281f050 100644
--- a/src/runtime/docker.ts
+++ b/src/runtime/docker.ts
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join, resolve, dirname, relative } from "node:path";
import type { RuntimeConfig } from "../types";
import { OVERLAY_RUN_SH_BASE64, decodeEmbeddedAsset } from "./embedded-assets";
+import { killProcessTree } from "./kernel/portability";
/** Resolved Docker runtime config with defaults applied and env overrides merged. */
export interface DockerRunConfig {
@@ -812,17 +813,16 @@ export function spawnDockerProcess(opts: DockerSpawnOptions): DockerSpawnResult
let timeoutTimer: NodeJS.Timeout | undefined;
if (opts.config.timeoutSeconds > 0) {
timeoutTimer = setTimeout(() => {
- try {
- child.kill("SIGTERM");
- } catch {
- // no-op
+ const pid = child.pid;
+ if (!pid) {
+ return;
}
+ // Terminate the `docker run` child and its descendants. On win32 the
+ // taskkill /T force-kills the tree, so the SIGKILL escalation below is a
+ // documented no-op there (see killProcessTree).
+ killProcessTree(pid, "SIGTERM");
setTimeout(() => {
- try {
- child.kill("SIGKILL");
- } catch {
- // no-op
- }
+ killProcessTree(pid, "SIGKILL");
}, 5000);
}, opts.config.timeoutSeconds * 1000);
}
diff --git a/src/runtime/kernel/portability.test.ts b/src/runtime/kernel/portability.test.ts
new file mode 100644
index 00000000..6ee257c6
--- /dev/null
+++ b/src/runtime/kernel/portability.test.ts
@@ -0,0 +1,204 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { readFileSync, readdirSync } from "node:fs";
+import { resolve, join } from "node:path";
+import { killProcessTree, _portability } from "./portability";
+
+// Precedent for platform stubbing: src/runtime/docker.test.ts.
+function withPlatform(platform: string, fn: () => void): void {
+ const orig = Object.getOwnPropertyDescriptor(process, "platform");
+ Object.defineProperty(process, "platform", { value: platform, configurable: true });
+ try {
+ fn();
+ } finally {
+ if (orig) Object.defineProperty(process, "platform", orig);
+ }
+}
+
+/** Capture every `process.kill(pid, signal)` call while `fn` runs. */
+function withKillSpy(
+ fn: (calls: Array<{ pid: number; signal: NodeJS.Signals | undefined }>) => void,
+ behavior?: (pid: number, signal: NodeJS.Signals | undefined) => void,
+): void {
+ const calls: Array<{ pid: number; signal: NodeJS.Signals | undefined }> = [];
+ const orig = process.kill;
+ (process as { kill: typeof process.kill }).kill = ((pid: number, signal?: NodeJS.Signals) => {
+ calls.push({ pid, signal });
+ if (behavior) behavior(pid, signal);
+ return true;
+ }) as typeof process.kill;
+ try {
+ fn(calls);
+ } finally {
+ (process as { kill: typeof process.kill }).kill = orig;
+ }
+}
+
+/** Capture the `_portability.spawn` invocation and return a fake child. */
+function withSpawnSpy(
+ fn: (calls: Array<{ command: string; args: string[] }>, fakeChild: EventEmitter) => void,
+): void {
+ const calls: Array<{ command: string; args: string[] }> = [];
+ const fakeChild = new EventEmitter() as EventEmitter & { unref?: () => void };
+ fakeChild.unref = () => {};
+ const orig = _portability.spawn;
+ _portability.spawn = ((command: string, args: string[]) => {
+ calls.push({ command, args });
+ return fakeChild as unknown as ReturnType;
+ }) as typeof _portability.spawn;
+ try {
+ fn(calls, fakeChild);
+ } finally {
+ _portability.spawn = orig;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// POSIX branch
+// ---------------------------------------------------------------------------
+
+test("POSIX: killProcessTree sends signal to the negative PID (process group)", () => {
+ withPlatform("linux", () => {
+ withKillSpy((calls) => {
+ killProcessTree(1234, "SIGTERM");
+ assert.equal(calls.length, 1, "exactly one process.kill call on the happy path");
+ assert.deepEqual(calls[0], { pid: -1234, signal: "SIGTERM" });
+ });
+ });
+});
+
+test("POSIX: falls back to per-process kill when the group kill throws (ESRCH)", () => {
+ withPlatform("linux", () => {
+ withKillSpy(
+ (calls) => {
+ killProcessTree(1234, "SIGINT");
+ assert.deepEqual(calls, [
+ { pid: -1234, signal: "SIGINT" }, // group kill attempted first
+ { pid: 1234, signal: "SIGINT" }, // then per-process fallback
+ ]);
+ },
+ (pid) => {
+ if (pid < 0) throw new Error("ESRCH");
+ },
+ );
+ });
+});
+
+test("POSIX: SIGKILL escalation also uses the negative PID group kill", () => {
+ withPlatform("linux", () => {
+ withKillSpy((calls) => {
+ killProcessTree(999, "SIGKILL");
+ assert.deepEqual(calls[0], { pid: -999, signal: "SIGKILL" });
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// win32 branch
+// ---------------------------------------------------------------------------
+
+test("win32: killProcessTree spawns `taskkill /pid /T /F`", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((spawnCalls) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(4321, "SIGTERM");
+ assert.equal(spawnCalls.length, 1, "taskkill spawned once");
+ assert.equal(spawnCalls[0].command, "taskkill");
+ assert.deepEqual(spawnCalls[0].args, ["/pid", "4321", "/T", "/F"]);
+ assert.equal(killCalls.length, 0, "no process.kill on the happy win32 path");
+ });
+ });
+ });
+});
+
+test("win32: SIGINT also spawns taskkill /T (Ctrl-C path)", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((spawnCalls) => {
+ killProcessTree(7, "SIGINT");
+ assert.deepEqual(spawnCalls[0].args, ["/pid", "7", "/T", "/F"]);
+ });
+ });
+});
+
+test("win32: SIGKILL escalation is a documented no-op (tree already force-killed)", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((spawnCalls) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(4321, "SIGKILL");
+ assert.equal(spawnCalls.length, 0, "no second taskkill on SIGKILL escalation");
+ assert.equal(killCalls.length, 0, "no process.kill on SIGKILL escalation");
+ });
+ });
+ });
+});
+
+test("win32: degrades to per-process kill when taskkill cannot be spawned", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((_spawnCalls, fakeChild) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(555, "SIGTERM");
+ // spawn returns a child that reports failure asynchronously via "error".
+ fakeChild.emit("error", new Error("spawn taskkill ENOENT"));
+ assert.deepEqual(killCalls, [{ pid: 555, signal: "SIGTERM" }]);
+ });
+ });
+ });
+});
+
+test("win32: NEVER calls process.kill with a negative PID (SIGTERM, SIGINT, SIGKILL)", () => {
+ withPlatform("win32", () => {
+ withSpawnSpy((_spawnCalls, fakeChild) => {
+ withKillSpy((killCalls) => {
+ killProcessTree(1234, "SIGTERM");
+ fakeChild.emit("error", new Error("no taskkill")); // force the fallback path too
+ killProcessTree(1234, "SIGINT");
+ fakeChild.emit("error", new Error("no taskkill"));
+ killProcessTree(1234, "SIGKILL");
+ for (const call of killCalls) {
+ assert.ok(call.pid > 0, `win32 must never signal a negative PID (saw ${call.pid})`);
+ }
+ });
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Lint contract: only the portability module may group-kill via negative PID.
+// ---------------------------------------------------------------------------
+
+// Tests run from dist/src/runtime/kernel/, so repo root is five levels up.
+const REPO_ROOT = resolve(__dirname, "../../../..");
+const SRC_ROOT = join(REPO_ROOT, "src");
+
+/** Non-test production source files (excludes *.test.ts / *.acceptance.test.ts). */
+function walkProductionTsFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...walkProductionTsFiles(full));
+ } else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+test("no production source file outside portability.ts invokes process.kill with a negative PID", () => {
+ // Match `process.kill(-` allowing whitespace, e.g. `process.kill( -pid`.
+ const negativeGroupKill = /process\.kill\(\s*-/;
+ const offenders: string[] = [];
+ for (const file of walkProductionTsFiles(SRC_ROOT)) {
+ const rel = file.slice(REPO_ROOT.length + 1);
+ // The helper is the one sanctioned home for the negative-PID group kill.
+ if (rel === join("src", "runtime", "kernel", "portability.ts")) continue;
+ const content = readFileSync(file, "utf8");
+ if (negativeGroupKill.test(content)) offenders.push(rel);
+ }
+ assert.deepEqual(
+ offenders,
+ [],
+ `negative-PID group kill must be confined to portability.ts; offenders: ${offenders.join(", ")}`,
+ );
+});
diff --git a/src/runtime/kernel/portability.ts b/src/runtime/kernel/portability.ts
new file mode 100644
index 00000000..a012ef90
--- /dev/null
+++ b/src/runtime/kernel/portability.ts
@@ -0,0 +1,78 @@
+// Cross-platform process-tree termination.
+//
+// `jaiph run` launches the workflow leader detached (`workflow-launch.ts`), and
+// the leader in turn spawns agent backends (`prompt.ts`) and script children,
+// or a `docker run` child (`docker.ts`). Terminating a run cleanly means
+// terminating that whole tree, not just the leader — otherwise backends and
+// script children are orphaned.
+//
+// POSIX has a single primitive for this: a detached child is its own process
+// group leader (pgid == pid), so `process.kill(-pid, signal)` delivers `signal`
+// to every process in the group. Windows has no such primitive — a negative pid
+// throws, and `child.kill()` / `process.kill(pid)` terminate only the leader.
+// `killProcessTree` hides that difference behind one call site.
+
+import { spawn, type ChildProcess } from "node:child_process";
+
+/**
+ * Test seam for the `taskkill` spawn. Swapped out in unit tests so the win32
+ * branch can be exercised on any host without a real `taskkill` binary.
+ */
+export const _portability = {
+ spawn(command: string, args: string[]): ChildProcess {
+ return spawn(command, args, { stdio: "ignore" });
+ },
+};
+
+/**
+ * Terminate `pid` and its descendants with `signal`, portably.
+ *
+ * POSIX: `process.kill(-pid, signal)` signals the whole process group. If the
+ * target is not a group leader the group does not exist (ESRCH) and we fall
+ * back to signaling the single process, matching the pre-portability behavior.
+ *
+ * win32: negative-pid group kill is unsupported (it throws) and per-process
+ * kill orphans children, so we spawn `taskkill /pid /T /F`, which force-
+ * terminates the entire tree. If `taskkill` cannot be launched we degrade to a
+ * per-process `process.kill(pid, signal)`. Because `taskkill /F` is already a
+ * forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after
+ * a `SIGTERM`/`SIGINT` is a **documented no-op** — the tree is already gone.
+ */
+export function killProcessTree(pid: number, signal: NodeJS.Signals): void {
+ if (process.platform === "win32") {
+ killProcessTreeWin32(pid, signal);
+ return;
+ }
+ try {
+ process.kill(-pid, signal);
+ } catch {
+ killSingleProcess(pid, signal);
+ }
+}
+
+function killProcessTreeWin32(pid: number, signal: NodeJS.Signals): void {
+ // `taskkill /F` already force-killed the tree on the first (SIGTERM/SIGINT)
+ // call, so the SIGKILL escalation has nothing left to terminate.
+ if (signal === "SIGKILL") {
+ return;
+ }
+ let child: ChildProcess;
+ try {
+ child = _portability.spawn("taskkill", ["/pid", String(pid), "/T", "/F"]);
+ } catch {
+ killSingleProcess(pid, signal);
+ return;
+ }
+ // `spawn` reports a missing/unspawnable `taskkill` asynchronously via "error",
+ // not by throwing — degrade to a per-process kill when that fires.
+ child.once?.("error", () => killSingleProcess(pid, signal));
+ child.unref?.();
+}
+
+function killSingleProcess(pid: number, signal: NodeJS.Signals): void {
+ try {
+ process.kill(pid, signal);
+ } catch {
+ // no-op: process already gone or not signalable.
+ }
+}
diff --git a/src/runtime/kernel/prompt.test.ts b/src/runtime/kernel/prompt.test.ts
index fa10db5c..1e042d7c 100644
--- a/src/runtime/kernel/prompt.test.ts
+++ b/src/runtime/kernel/prompt.test.ts
@@ -1,4 +1,4 @@
-import { describe, it } from "node:test";
+import { describe, it, afterEach } from "node:test";
import * as assert from "node:assert/strict";
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -235,18 +235,40 @@ describe("executePrompt", () => {
});
/** Minimal ChildProcess stand-in: an EventEmitter with a recording `kill`. */
+const FAKE_CHILD_PID = 4242;
+let origProcessKill: typeof process.kill | undefined;
+
+// The watchdog terminates via killProcessTree(pid, signal) (portability.ts),
+// which on POSIX calls process.kill(-pid|pid, signal). Spy on process.kill and
+// record the signals aimed at the fake child's pid (either sign). Restored per
+// test by the afterEach below so the spy never leaks into other suites.
function makeFakeChild(): { child: ChildProcess; killSignals: string[] } {
const emitter = new EventEmitter() as EventEmitter & { pid: number; kill: (s?: string) => boolean };
const killSignals: string[] = [];
- emitter.pid = 4242;
+ emitter.pid = FAKE_CHILD_PID;
emitter.kill = (signal?: string) => {
killSignals.push(signal ?? "SIGTERM");
return true;
};
+ origProcessKill = process.kill;
+ (process as { kill: typeof process.kill }).kill = ((pid: number, signal?: NodeJS.Signals) => {
+ if (Math.abs(pid) === FAKE_CHILD_PID) {
+ killSignals.push(signal ?? "SIGTERM");
+ return true;
+ }
+ return origProcessKill!(pid, signal);
+ }) as typeof process.kill;
return { child: emitter as unknown as ChildProcess, killSignals };
}
describe("installPromptWatchdog", () => {
+ afterEach(() => {
+ if (origProcessKill) {
+ (process as { kill: typeof process.kill }).kill = origProcessKill;
+ origProcessKill = undefined;
+ }
+ });
+
it("layer 2: terminates and fails when output stalls past the idle timeout", async () => {
const { child, killSignals } = makeFakeChild();
const events: Array<{ status: number; reason: string; final: string }> = [];
diff --git a/src/runtime/kernel/prompt.ts b/src/runtime/kernel/prompt.ts
index c4b26f05..d09c4977 100644
--- a/src/runtime/kernel/prompt.ts
+++ b/src/runtime/kernel/prompt.ts
@@ -5,6 +5,7 @@ import { writeFileSync, readFileSync, existsSync, accessSync, mkdirSync, cpSync,
import { basename, delimiter, join } from "node:path";
import { parseStream, type StreamWriter } from "./stream-parser";
import { consumeNextMockResponse, dispatchMockArms, type MockPromptArm } from "./mock";
+import { killProcessTree } from "./portability";
export type PromptConfig = {
backend: string;
@@ -452,17 +453,16 @@ export function installPromptWatchdog(
};
const killChild = (): void => {
- try {
- child.kill("SIGTERM");
- } catch {
- // no-op
+ const pid = child.pid;
+ if (!pid) {
+ return;
}
+ // Terminate the backend and any descendants it spawned. On win32 this
+ // taskkill /T already force-kills the tree, so the SIGKILL escalation
+ // below is a documented no-op there (see killProcessTree).
+ killProcessTree(pid, "SIGTERM");
const escalate = setTimeout(() => {
- try {
- child.kill("SIGKILL");
- } catch {
- // no-op
- }
+ killProcessTree(pid, "SIGKILL");
}, 5000);
escalate.unref?.();
};
From afef44b85a14d860743b35b12c3d1fb1bd2f567d Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:17:58 +0200
Subject: [PATCH 2/9] Fix: execute script steps via explicit interpreter, not
shebang
Script steps previously ran by spawning the emitted file directly,
relying on the `#!/usr/bin/env ` shebang and the 0o755 exec bit.
Windows honors neither, and `noexec` mounts on Linux strip the exec bit,
both breaking script execution. The runtime already knows the
interpreter since it writes the shebang itself, so `executeScript` now
reads the emitted script's shebang, resolves the interpreter through the
new `resolveInterpreterFromShebang` (`src/parse/script-bash.ts`), and
spawns `` explicitly. The shebang
line is still written into every emitted script so they remain directly
executable by hand on POSIX, but the runtime no longer depends on it or
on the exec bit being honored. A spawn ENOENT from a missing interpreter
now surfaces as a diagnosable Jaiph error naming the interpreter instead
of a raw `spawn ENOENT`. New unit tests assert the resolved
interpreter and argv on an injectable `_scriptSpawn` seam, prove a script
with the exec bit stripped (0o644) still runs on POSIX, and prove a
missing-interpreter shebang produces the diagnosable error.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 104 ++++++++++
docs/architecture.md | 1 +
docs/setup.md | 2 +-
src/parse/script-bash.test.ts | 62 ++++++
src/parse/script-bash.ts | 39 ++++
.../node-workflow-runtime.script-exec.test.ts | 183 ++++++++++++++++++
src/runtime/kernel/node-workflow-runtime.ts | 63 +++++-
8 files changed, 449 insertions(+), 6 deletions(-)
create mode 100644 src/parse/script-bash.test.ts
create mode 100644 src/runtime/kernel/node-workflow-runtime.script-exec.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1de9058f..e6418d99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
# 0.10.0
diff --git a/QUEUE.md b/QUEUE.md
index 64f890c3..d41cd418 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -13,3 +13,107 @@ Process rules:
7. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance.
***
+
+## Portability: single `resolveShell()` seam for inline shell lines and hooks #dev-ready
+
+Inline workflow shell lines run via hardcoded `spawn("sh", ["-c", ...])` (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hooks do the same (`src/cli/run/hooks.ts`). Jaiph's language semantics require POSIX `sh` on all platforms — inline lines must NOT be translated to cmd/PowerShell, or workflows stop being portable.
+
+Add `resolveShell(): string` to the portability module:
+
+* On POSIX: `sh`.
+* On `win32`: locate `sh.exe` on `PATH`, then in the standard Git for Windows locations (`/bin/sh.exe`, `/usr/bin/sh.exe`). If none found, throw a Jaiph error with a stable code (e.g. `E_NO_POSIX_SHELL`) telling the user to install Git for Windows.
+* Resolution is memoized per process.
+
+Both call sites go through `resolveShell()`. No other `spawn("sh", ...)` remains in `src/`.
+
+Acceptance:
+
+* Unit tests stub `process.platform` and `PATH` lookup: POSIX returns `sh`; win32 returns a discovered `sh.exe` path; win32 with no shell available throws `E_NO_POSIX_SHELL` and the message names Git for Windows.
+* A grep-style test asserts no literal `spawn("sh"` / `spawn('sh'` call sites exist in `src/` outside the portability module.
+* Inline shell-line semantics on POSIX are unchanged (existing e2e passes).
+
+## Portability: home directory, Docker gating, and ANSI on win32 #dev-ready
+
+Remaining small POSIX assumptions for a host-only Windows runtime:
+
+1. `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) reads `execEnv.HOME || process.env.HOME`. Use `os.homedir()` as the final fallback so `USERPROFILE`-only environments resolve; an explicit `HOME` in `execEnv` still wins.
+2. Docker sandboxing (`src/runtime/docker.ts`) hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches. On `win32`, the Docker sandbox is out of scope: `resolveDockerConfig` must resolve to host-only mode with a one-line notice (same UX as an explicit `JAIPH_UNSAFE=true`), never attempt `docker` probing, and never hard-fail because Docker is missing.
+3. Live status rendering already gates SGR colors on `isTTY` + `NO_COLOR` and uses a single erase/cursor-up sequence (`src/cli/run/stderr-handler.ts`). Node enables VT processing on Windows 10+; no rendering change required. Add a `canUseAnsi()` helper to the portability module and route the existing gates through it so the policy lives in one place.
+
+Acceptance:
+
+* Unit test: with `HOME` unset and `os.homedir()` stubbed, `prepareClaudeEnv` resolves the config dir from `os.homedir()`; with `execEnv.HOME` set, it wins.
+* Unit test: with `process.platform` stubbed to `win32`, Docker resolution returns host-only mode, emits the notice once, and performs zero `docker` invocations (spawn spied).
+* Unit test: `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set; all color/erase emission sites consume it (grep test: no direct `isTTY && NO_COLOR` gating outside the portability module).
+
+## Distro: build and release `jaiph-windows-x64.exe` #dev-ready
+
+The release workflow (`.github/workflows/release.yml`) cross-compiles four assets (`jaiph-{darwin,linux}-{arm64,x64}`) via `bun build --compile` and publishes them with `SHA256SUMS`. Add Windows:
+
+* New matrix entry: `--target=bun-windows-x64`, asset name `jaiph-windows-x64.exe` (Bun has no Windows arm64 target — do not add one).
+* Include the `.exe` in `SHA256SUMS` generation and in both the stable and nightly `gh release` upload lists.
+* Add a Windows sanity gate alongside the existing linux-x64 one: run `jaiph-windows-x64.exe --version` on a `windows-latest` job and, for stable releases, assert the output matches the tag.
+* Update the "Release asset naming contract" in `docs/contributing.md` to include the new asset name.
+
+Acceptance:
+
+* A nightly release run publishes five binaries + `SHA256SUMS`, and the `.exe` checksum entry verifies against the downloaded asset.
+* The Windows sanity gate fails the release when `--version` output mismatches the tag (verified by test or a deliberate dry-run with a wrong version).
+* `docs/contributing.md` naming contract lists `jaiph-windows-x64.exe`; the e2e installer test's asset-name mapping and the contract stay in sync (grep/parity check).
+
+## Distro: PowerShell installer at `jaiph.org/install.ps1` #dev-ready
+
+The bash installer (`docs/install`) downloads a per-platform binary from the GitHub Release for a pinned ref and verifies it against `SHA256SUMS`. Windows users need a native equivalent — the bash script must keep rejecting Windows and point at the PowerShell one.
+
+Add `docs/install.ps1` (served as `https://jaiph.org/install.ps1`, `irm https://jaiph.org/install.ps1 | iex`):
+
+* Downloads `jaiph-windows-x64.exe` and `SHA256SUMS` from the pinned release ref (default: current stable tag; overridable via `JAIPH_REPO_REF` / first argument, mirroring the bash installer).
+* Verifies the SHA-256 (`Get-FileHash`) against `SHA256SUMS`; mismatch aborts and installs nothing.
+* Installs to `%LOCALAPPDATA%\jaiph\bin\jaiph.exe` (overridable via `JAIPH_BIN_DIR`), adds the directory to the user `PATH` if absent, and prints the same try-it hints as the bash installer.
+* Non-x64 / ARM Windows: exit with a documented unsupported-platform message.
+* Update `docs/install`'s unsupported-platform message to mention the PowerShell installer for Windows, and document the Windows install path in `docs/setup.md` and the main page install tabs.
+
+Acceptance:
+
+* Pester (or equivalent) tests run on `windows-latest` in CI, pointing the installer at a `file://`-style local release directory (same technique as `e2e/tests/07_installer_binary.sh`): happy path installs and `jaiph --version` works; checksum mismatch exits non-zero and leaves no binary; unsupported arch exits with the documented message.
+* The installed binary runs from a shell with no Node/npm/Bun on `PATH`.
+* `docs/setup.md` and the main page reference the PowerShell one-liner (docs parity check passes).
+
+## Distro: main-page install tabs — Windows variant with platform auto-detect #dev-ready
+
+The main page (`docs/index.html`) hero has three install tabs (run sample / init project / just install), all showing bash `curl … | bash` one-liners. Tab switching in `docs/assets/js/main.js` is click-only; there is no platform detection. Windows visitors currently see commands that cannot run natively.
+
+Given a published PowerShell installer at `https://jaiph.org/install.ps1` (`irm https://jaiph.org/install.ps1 | iex`):
+
+* Add a Windows variant to the install section: at minimum the "Just install" panel must offer the PowerShell one-liner alongside the curl one (separate tab, sub-toggle, or swapped command — implementer's choice, but the PowerShell command must be copy-able via the existing copy button).
+* Auto-detect the visitor's platform on load (`navigator.userAgentData?.platform` with `navigator.platform` fallback) and default to the Windows variant for Windows visitors; macOS/Linux visitors see exactly today's default. Manual switching always remains possible; no layout shift for non-Windows users.
+* Tabs whose commands have no PowerShell equivalent (run sample / init project) must not show a bash-only command as the Windows default — show the install one-liner plus a short "then run:" `jaiph` command instead, or an equivalent honest fallback.
+* Keep the static-render constraint: the page must remain correct with JS disabled (bash commands shown, Windows variant reachable via tab markup).
+
+Acceptance:
+
+* Playwright docs tests (same suite as the "Try it out" test) assert: with a Windows platform emulated, the page defaults to the PowerShell command; with macOS/Linux emulated, the default is unchanged from today; manual tab switching works in both.
+* The copy button on the Windows variant copies the exact `irm … | iex` line (asserted via clipboard stub).
+* With JS disabled, all bash commands render and no panel is blank.
+* Docs parity check (`.jaiph/docs_parity.jh`) passes with the new content.
+
+## Distro: native Windows smoke job in CI #dev-ready
+
+CI's only Windows coverage runs the e2e suite inside WSL (`e2e-wsl` in `.github/workflows/ci.yml`), which exercises the Linux binary. Developing Jaiph on Windows is out of scope — this job proves *running* Jaiph natively works.
+
+Add a `windows-native-smoke` job on `windows-latest`:
+
+* Build the standalone Windows binary from the checkout (`bun build --compile --target=bun-windows-x64`).
+* With Git for Windows' `sh.exe` available (preinstalled on the runner), run a sample workflow host-only (`JAIPH_UNSAFE=true`) that covers: an inline shell line, a `script` step with a non-bash lang tag (e.g. ` ```node `), string interpolation, and `log` output.
+* Assert the process tree is cleaned up after a mid-run cancellation (spawn `jaiph run`, terminate it, assert no orphaned child processes remain).
+* No agent-backend credentials in this job: `prompt`-step coverage is limited to the credential pre-flight failing with the documented error, not a hang.
+* Keep `e2e-wsl` as-is; do not gate its removal on this task.
+
+Acceptance:
+
+* The smoke job is required for merge (listed in the CI gate alongside `test`/`e2e`/`e2e-wsl`).
+* Workflow output assertions run against actual `jaiph.exe` stdout (exit code + expected `log` lines).
+* The cancellation assertion fails if any child of the workflow leader survives termination.
+* The job completes with no WSL usage (fails if `wsl` is invoked).
+
+***
diff --git a/docs/architecture.md b/docs/architecture.md
index d672076b..b3fd5fa7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -72,6 +72,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)**
- `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.
+ - **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ``. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`.
- One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `ensure_call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST.
- **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure).
- Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back.
diff --git a/docs/setup.md b/docs/setup.md
index e9c1a9e7..e64585bc 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -15,7 +15,7 @@ The curl installer downloads a per-platform standalone binary from the current s
## Prerequisites
-- A POSIX `sh` (the runtime uses `sh -c` for inline shell lines inside workflows; emitted `script` steps follow their own shebang).
+- A POSIX `sh` (the runtime uses `sh -c` for inline shell lines inside workflows). Each emitted `script` step runs under the interpreter named by its shebang (`bash` by default), so that interpreter must be on `PATH`; the runtime spawns it explicitly and does not rely on the file's exec bit, so scripts also work under `noexec` mounts.
- For the curl installer (step 1): `curl` and either `shasum` or `sha256sum` on `PATH`.
- For the npm alternative (step 1): Node.js and npm on the host.
diff --git a/src/parse/script-bash.test.ts b/src/parse/script-bash.test.ts
new file mode 100644
index 00000000..03f920fd
--- /dev/null
+++ b/src/parse/script-bash.test.ts
@@ -0,0 +1,62 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { resolveInterpreterFromShebang } from "./script-bash";
+
+test("resolveInterpreterFromShebang: #!/usr/bin/env bash resolves to bash", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env bash"), {
+ command: "bash",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: #!/usr/bin/env node resolves to node", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env node"), {
+ command: "node",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: #!/usr/bin/env python3 resolves to python3", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env python3"), {
+ command: "python3",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: absolute-path shebang spawns that path", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/bin/bash"), {
+ command: "/bin/bash",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: custom interpreter shebang resolves to the named interpreter", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env my-lang"), {
+ command: "my-lang",
+ prefixArgs: [],
+ });
+ assert.deepEqual(resolveInterpreterFromShebang("#!/opt/tools/customlang"), {
+ command: "/opt/tools/customlang",
+ prefixArgs: [],
+ });
+});
+
+test("resolveInterpreterFromShebang: interpreter flags after the interpreter are preserved", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env node --experimental-vm-modules"), {
+ command: "node",
+ prefixArgs: ["--experimental-vm-modules"],
+ });
+});
+
+test("resolveInterpreterFromShebang: env -S split flag is skipped", () => {
+ assert.deepEqual(resolveInterpreterFromShebang("#!/usr/bin/env -S deno run"), {
+ command: "deno",
+ prefixArgs: ["run"],
+ });
+});
+
+test("resolveInterpreterFromShebang: non-shebang line returns null", () => {
+ assert.equal(resolveInterpreterFromShebang("echo hi"), null);
+ assert.equal(resolveInterpreterFromShebang(""), null);
+ assert.equal(resolveInterpreterFromShebang("#!"), null);
+});
diff --git a/src/parse/script-bash.ts b/src/parse/script-bash.ts
index c4d69251..70d25329 100644
--- a/src/parse/script-bash.ts
+++ b/src/parse/script-bash.ts
@@ -9,3 +9,42 @@ export function scriptShebangIsBash(shebang?: string): boolean {
if (/^#!\/usr\/bin\/env\s+bash(?:\s|$)/.test(t)) return true;
return false;
}
+
+/** The interpreter to spawn for a script, plus any leading interpreter flags. */
+export type ScriptInterpreter = { command: string; prefixArgs: string[] };
+
+/**
+ * Resolve the interpreter a script should be executed with from its shebang
+ * line, so the runtime can spawn ``
+ * explicitly rather than relying on the OS honoring the shebang or the file's
+ * exec bit (Windows honors neither; `noexec` mounts break the exec bit too).
+ *
+ * Supported forms:
+ * `#!/usr/bin/env bash` -> { command: "bash", prefixArgs: [] }
+ * `#!/usr/bin/env python3` -> { command: "python3", prefixArgs: [] }
+ * `#!/bin/bash` -> { command: "/bin/bash", prefixArgs: [] }
+ * `#!/usr/bin/env node --foo` -> { command: "node", prefixArgs: ["--foo"] }
+ * `#!/usr/bin/env -S deno run` -> { command: "deno", prefixArgs: ["run"] }
+ *
+ * The interpreter is spawned by name so Node resolves it on PATH; a bare name
+ * that names a missing interpreter surfaces as an ENOENT the caller can turn
+ * into a diagnosable error. Returns null when the line is not a shebang.
+ */
+export function resolveInterpreterFromShebang(shebangLine: string): ScriptInterpreter | null {
+ const t = shebangLine.trim();
+ if (!t.startsWith("#!")) return null;
+ const rest = t.slice(2).trim();
+ if (rest === "") return null;
+ const tokens = rest.split(/\s+/);
+ const first = tokens[0]!;
+ // `#!/usr/bin/env [args...]`: the real interpreter is the token
+ // after `env` (skipping a leading `-S` split flag). Spawn it directly.
+ if (first === "env" || first.endsWith("/env")) {
+ let idx = 1;
+ if (tokens[idx] === "-S") idx += 1;
+ if (idx >= tokens.length) return null;
+ return { command: tokens[idx]!, prefixArgs: tokens.slice(idx + 1) };
+ }
+ // `#!/absolute/path/interp [args...]`: spawn that path directly.
+ return { command: first, prefixArgs: tokens.slice(1) };
+}
diff --git a/src/runtime/kernel/node-workflow-runtime.script-exec.test.ts b/src/runtime/kernel/node-workflow-runtime.script-exec.test.ts
new file mode 100644
index 00000000..21a44455
--- /dev/null
+++ b/src/runtime/kernel/node-workflow-runtime.script-exec.test.ts
@@ -0,0 +1,183 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildRuntimeGraph } from "./graph";
+import { NodeWorkflowRuntime, _scriptSpawn } from "./node-workflow-runtime";
+
+/** Minimal fake ChildProcess that emits `close(exitCode)` on the next tick. */
+function fakeChild(exitCode: number): EventEmitter {
+ const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
+ const makeStream = (): EventEmitter & { setEncoding: () => void } => {
+ const s = new EventEmitter() as EventEmitter & { setEncoding: () => void };
+ s.setEncoding = () => {};
+ return s;
+ };
+ child.stdout = makeStream();
+ child.stderr = makeStream();
+ setImmediate(() => child.emit("close", exitCode));
+ return child;
+}
+
+/** Swap `_scriptSpawn.spawn` for a recording stub while `fn` runs. */
+async function withSpawnSpy(
+ fn: (calls: Array<{ command: string; args: string[] }>) => Promise,
+): Promise {
+ const calls: Array<{ command: string; args: string[] }> = [];
+ const orig = _scriptSpawn.spawn;
+ _scriptSpawn.spawn = ((command: string, args: string[]) => {
+ calls.push({ command, args });
+ return fakeChild(0) as unknown as ReturnType;
+ }) as typeof _scriptSpawn.spawn;
+ try {
+ await fn(calls);
+ } finally {
+ _scriptSpawn.spawn = orig;
+ }
+}
+
+function makeRuntime(root: string): { runtime: NodeWorkflowRuntime; env: NodeJS.ProcessEnv; scriptsDir: string } {
+ const jh = join(root, "flow.jh");
+ writeFileSync(jh, ["workflow default() {", ' log "noop"', "}", ""].join("\n"));
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_WORKSPACE: root,
+ };
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ return { runtime, env, scriptsDir };
+}
+
+// AC1: executeScript spawns the resolved interpreter with the script path as
+// argv[1] (spawn args[0]) — asserted on the spawn call, not on side effects.
+const SHEBANG_CASES: Array<{ label: string; shebang: string; expected: string }> = [
+ { label: "bash", shebang: "#!/usr/bin/env bash", expected: "bash" },
+ { label: "node", shebang: "#!/usr/bin/env node", expected: "node" },
+ { label: "python3", shebang: "#!/usr/bin/env python3", expected: "python3" },
+ { label: "custom", shebang: "#!/usr/bin/env my-custom-lang", expected: "my-custom-lang" },
+];
+
+for (const c of SHEBANG_CASES) {
+ test(`executeScript: spawns resolved interpreter "${c.expected}" with the script path as argv[1] (${c.label} shebang)`, async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-script-exec-"));
+ try {
+ const { runtime, env, scriptsDir } = makeRuntime(root);
+ const scriptName = `run_${c.label}`;
+ const scriptPath = join(scriptsDir, scriptName);
+ writeFileSync(scriptPath, `${c.shebang}\necho hi\n`);
+ await withSpawnSpy(async (calls) => {
+ const result = await (runtime as unknown as {
+ executeScript: (
+ filePath: string,
+ scriptName: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+ ) => Promise<{ status: number }>;
+ }).executeScript(join(root, "flow.jh"), scriptName, ["a1", "a2"], env);
+ assert.equal(result.status, 0);
+ assert.equal(calls.length, 1, "expected exactly one spawn call");
+ assert.equal(calls[0]!.command, c.expected, "spawned command is the resolved interpreter");
+ assert.equal(calls[0]!.args[0], scriptPath, "script path is argv[1] (spawn args[0])");
+ assert.deepEqual(calls[0]!.args, [scriptPath, "a1", "a2"], "script args follow the script path");
+ });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+}
+
+// AC2: a script with the exec bit stripped (0o644) still executes on POSIX,
+// because the runtime spawns the interpreter explicitly rather than the file.
+test("executeScript: script with exec bit stripped (0o644) still executes through the runtime", { skip: process.platform === "win32" }, async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-script-noexec-"));
+ try {
+ const jh = join(root, "flow.jh");
+ writeFileSync(
+ jh,
+ [
+ "script write_marker = ```",
+ 'printf "ran-ok" > marker.txt',
+ "```",
+ "",
+ "workflow default() {",
+ " run write_marker()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ const scriptPath = join(scriptsDir, "write_marker");
+ writeFileSync(
+ scriptPath,
+ ["#!/usr/bin/env bash", "set -euo pipefail", 'printf "ran-ok" > marker.txt', ""].join("\n"),
+ );
+ // Strip the exec bit: with shebang+exec-bit execution this would fail EACCES.
+ chmodSync(scriptPath, 0o644);
+
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_WORKSPACE: root,
+ };
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ const status = await runtime.runDefault([]);
+ assert.equal(status, 0, "workflow succeeded despite stripped exec bit");
+ assert.equal(readFileSync(join(root, "marker.txt"), "utf8"), "ran-ok");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+// AC3: a shebang naming a missing interpreter fails with a diagnosable Jaiph
+// error naming the interpreter, not a raw ENOENT.
+test("executeScript: missing interpreter fails with a diagnosable error naming the interpreter (not raw ENOENT)", async () => {
+ const root = mkdtempSync(join(tmpdir(), "jaiph-script-badinterp-"));
+ try {
+ const missing = "jaiph-nonexistent-interp-xyz";
+ const jh = join(root, "flow.jh");
+ writeFileSync(
+ jh,
+ [
+ "script run_bad = ```",
+ 'echo "unreachable"',
+ "```",
+ "",
+ "workflow default() {",
+ " run run_bad()",
+ "}",
+ "",
+ ].join("\n"),
+ );
+ const scriptsDir = join(root, "scripts");
+ mkdirSync(scriptsDir, { recursive: true });
+ writeFileSync(join(scriptsDir, "run_bad"), `#!/usr/bin/env ${missing}\necho unreachable\n`);
+
+ const graph = buildRuntimeGraph(jh);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ JAIPH_TEST_MODE: "1",
+ JAIPH_RUNS_DIR: join(root, ".jaiph", "runs"),
+ JAIPH_SCRIPTS: scriptsDir,
+ JAIPH_WORKSPACE: root,
+ };
+ const runtime = new NodeWorkflowRuntime(graph, { env, cwd: root, suppressLiveEvents: true });
+ const result = await runtime.runNamedWorkflow("default", []);
+ assert.equal(result.status, 1, "workflow failed on missing interpreter");
+ const message = `${result.output ?? ""}${result.error ?? ""}`;
+ assert.match(message, new RegExp(missing), "error names the missing interpreter");
+ assert.doesNotMatch(message, /ENOENT/, "error is not a raw ENOENT");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index c2012aa6..42b3305c 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
-import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve as resolvePath } from "node:path";
import { PassThrough } from "node:stream";
import { randomUUID } from "node:crypto";
@@ -27,6 +27,7 @@ import {
stripOuterQuotes,
type PromptSchemaField,
} from "./runtime-arg-parser";
+import { resolveInterpreterFromShebang } from "../../parse/script-bash";
import { RuntimeEventEmitter, type Frame } from "./runtime-event-emitter";
import { executeMockBodyDef, type MockBodyDef, type StepResult } from "./runtime-mock";
import { linesOfDelimitedString } from "../string-lines";
@@ -42,6 +43,13 @@ export type { MockBodyDef } from "./runtime-mock";
const HANDLE_PREFIX = "__JAIPH_HANDLE__";
+/**
+ * Test seam for the script/shell subprocess spawn. Swapped out in unit tests so
+ * the resolved interpreter + argv can be asserted on the spawn call itself
+ * without side effects (mirrors `_portability.spawn` in `portability.ts`).
+ */
+export const _scriptSpawn = { spawn };
+
export function formatInvalidAsyncHandleError(handleId: string): string {
return `invalid async handle "${handleId}" — the handle was never created or was already consumed`;
}
@@ -1479,16 +1487,22 @@ export class NodeWorkflowRuntime {
return { status: 0, output: res.output, error: "" };
}
- /** Spawn a child process, stream stdout/stderr into io and collect them into the StepResult. */
+ /**
+ * Spawn a child process, stream stdout/stderr into io and collect them into
+ * the StepResult. When `interpreter` is set, a spawn ENOENT (the interpreter
+ * binary is missing on PATH) is turned into a diagnosable Jaiph error naming
+ * the interpreter instead of a raw `spawn ENOENT`.
+ */
private spawnAndCapture(
command: string,
args: string[],
env: NodeJS.ProcessEnv,
cwd: string,
io: StepIO | undefined,
+ interpreter?: string,
): Promise {
return new Promise((resolve) => {
- const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
+ const child = _scriptSpawn.spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
let error = "";
child.stdout?.setEncoding("utf8");
@@ -1502,7 +1516,10 @@ export class NodeWorkflowRuntime {
io?.appendErr(chunk);
});
child.on("error", (err) => {
- const msg = err instanceof Error ? err.message : String(err);
+ const code = (err as NodeJS.ErrnoException).code;
+ const msg = code === "ENOENT" && interpreter
+ ? `script interpreter "${interpreter}" not found — install it or fix the script shebang`
+ : err instanceof Error ? err.message : String(err);
error += msg;
io?.appendErr(msg);
resolve({ status: 1, output, error });
@@ -1536,7 +1553,43 @@ export class NodeWorkflowRuntime {
if (!scriptsDir) {
return { status: 1, output: "", error: "JAIPH_SCRIPTS not set for script execution" };
}
- return this.spawnAndCapture(join(scriptsDir, scriptName), args, env, this.scriptCwd(env, filePath), io);
+ const scriptPath = join(scriptsDir, scriptName);
+ const interp = this.resolveScriptInterpreter(scriptPath);
+ if (!interp.ok) return { status: 1, output: "", error: interp.error };
+ // Spawn `` explicitly. This does not
+ // depend on the OS honoring the shebang line (Windows) or on the file's
+ // exec bit (stripped bit / `noexec` mounts); the shebang is still written
+ // into the file so it stays directly executable by hand on POSIX.
+ return this.spawnAndCapture(
+ interp.command,
+ [...interp.prefixArgs, scriptPath, ...args],
+ env,
+ this.scriptCwd(env, filePath),
+ io,
+ interp.command,
+ );
+ }
+
+ /**
+ * Resolve the interpreter to spawn for an emitted script from its shebang
+ * line. Emitted scripts always carry a shebang (`buildScriptFiles`); a script
+ * without one falls back to bash (Jaiph's default script language) rather
+ * than depending on the OS exec bit.
+ */
+ private resolveScriptInterpreter(
+ scriptPath: string,
+ ): { ok: true; command: string; prefixArgs: string[] } | { ok: false; error: string } {
+ let firstLine: string;
+ try {
+ const content = readFileSync(scriptPath, "utf8");
+ const nl = content.indexOf("\n");
+ firstLine = nl === -1 ? content : content.slice(0, nl);
+ } catch {
+ return { ok: false, error: `script file not found or unreadable: ${scriptPath}` };
+ }
+ const interp = resolveInterpreterFromShebang(firstLine);
+ if (!interp) return { ok: true, command: "bash", prefixArgs: [] };
+ return { ok: true, command: interp.command, prefixArgs: interp.prefixArgs };
}
/**
From 7a0f2e4d64953ba9e32ec9d252e2cd89391b2ccb Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:35:22 +0200
Subject: [PATCH 3/9] Fix: route inline shell lines and hooks through
resolveShell() seam
Inline workflow shell lines (executeShLine) and CLI hook commands used a
hardcoded spawn("sh", ["-c", ...]), which fails on win32 where there is no
sh on the default PATH. Add resolveShell() to the portability module as the
single seam both call sites go through: on POSIX it returns bare sh; on
win32 it locates Git for Windows' bundled sh.exe, first on PATH and then in
the standard install layouts (/bin/sh.exe, /usr/bin/sh.exe) under
each known root, throwing a diagnosable E_NO_POSIX_SHELL error naming Git
for Windows if none is found. Resolution is memoized per process. Inline
lines are never translated to cmd/PowerShell, so POSIX shell semantics are
unchanged. Unit tests stub process.platform and the PATH/existence lookup
across all branches, and a src/-wide lint test asserts no spawn("sh") call
site remains outside portability.ts.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 18 ---
docs/architecture.md | 1 +
src/cli/run/hooks.ts | 3 +-
src/runtime/kernel/node-workflow-runtime.ts | 3 +-
src/runtime/kernel/portability.test.ts | 140 +++++++++++++++++++-
src/runtime/kernel/portability.ts | 63 ++++++++-
7 files changed, 206 insertions(+), 23 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6418d99..875ce769 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
diff --git a/QUEUE.md b/QUEUE.md
index d41cd418..c02bca1e 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Portability: single `resolveShell()` seam for inline shell lines and hooks #dev-ready
-
-Inline workflow shell lines run via hardcoded `spawn("sh", ["-c", ...])` (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hooks do the same (`src/cli/run/hooks.ts`). Jaiph's language semantics require POSIX `sh` on all platforms — inline lines must NOT be translated to cmd/PowerShell, or workflows stop being portable.
-
-Add `resolveShell(): string` to the portability module:
-
-* On POSIX: `sh`.
-* On `win32`: locate `sh.exe` on `PATH`, then in the standard Git for Windows locations (`/bin/sh.exe`, `/usr/bin/sh.exe`). If none found, throw a Jaiph error with a stable code (e.g. `E_NO_POSIX_SHELL`) telling the user to install Git for Windows.
-* Resolution is memoized per process.
-
-Both call sites go through `resolveShell()`. No other `spawn("sh", ...)` remains in `src/`.
-
-Acceptance:
-
-* Unit tests stub `process.platform` and `PATH` lookup: POSIX returns `sh`; win32 returns a discovered `sh.exe` path; win32 with no shell available throws `E_NO_POSIX_SHELL` and the message names Git for Windows.
-* A grep-style test asserts no literal `spawn("sh"` / `spawn('sh'` call sites exist in `src/` outside the portability module.
-* Inline shell-line semantics on POSIX are unchanged (existing e2e passes).
-
## Portability: home directory, Docker gating, and ANSI on win32 #dev-ready
Remaining small POSIX assumptions for a host-only Windows runtime:
diff --git a/docs/architecture.md b/docs/architecture.md
index b3fd5fa7..c6f5c6a0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -73,6 +73,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)**
- `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.
- **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ``. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`.
+ - **Inline shell lines resolve their shell through one portable seam.** A single-line shell step (`executeShLine`) and CLI hook commands (`src/cli/run/hooks.ts`) both run under POSIX `sh -c`, but the shell itself is resolved through **`resolveShell()`** (`src/runtime/kernel/portability.ts`) rather than a hardcoded `spawn("sh", …)`. On POSIX this is bare `sh`; on **`win32`**, where there is no `sh` on the default `PATH`, it discovers Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root — memoizes the result for the process, and throws a diagnosable **`E_NO_POSIX_SHELL`** error naming Git for Windows if none is found. Inline lines are **never** translated to `cmd`/PowerShell: Jaiph's shell semantics are POSIX `sh` on every platform, so the seam only ever chooses *which* `sh` to invoke, never rewrites the command — otherwise workflows would stop being portable. `resolveShell()` is the single call site for the POSIX shell; no other `spawn("sh", …)` remains in `src/`.
- One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `ensure_call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST.
- **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure).
- Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back.
diff --git a/src/cli/run/hooks.ts b/src/cli/run/hooks.ts
index 5cc2a95e..e3af0305 100644
--- a/src/cli/run/hooks.ts
+++ b/src/cli/run/hooks.ts
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
+import { resolveShell } from "../../runtime/kernel/portability";
import type { HookConfig, HookEventName, HookPayload } from "../../types";
import type { RunEmitter } from "./emitter";
@@ -133,7 +134,7 @@ export function runHooksForEvent(
for (const cmd of commands) {
try {
- const child = spawn("sh", ["-c", cmd], {
+ const child = spawn(resolveShell(), ["-c", cmd], {
stdio: ["pipe", "ignore", "pipe"],
env: { ...process.env },
});
diff --git a/src/runtime/kernel/node-workflow-runtime.ts b/src/runtime/kernel/node-workflow-runtime.ts
index 42b3305c..4e8b923e 100644
--- a/src/runtime/kernel/node-workflow-runtime.ts
+++ b/src/runtime/kernel/node-workflow-runtime.ts
@@ -28,6 +28,7 @@ import {
type PromptSchemaField,
} from "./runtime-arg-parser";
import { resolveInterpreterFromShebang } from "../../parse/script-bash";
+import { resolveShell } from "./portability";
import { RuntimeEventEmitter, type Frame } from "./runtime-event-emitter";
import { executeMockBodyDef, type MockBodyDef, type StepResult } from "./runtime-mock";
import { linesOfDelimitedString } from "../string-lines";
@@ -1597,7 +1598,7 @@ export class NodeWorkflowRuntime {
* the workspace, matching script cwd semantics.
*/
private executeShLine(scope: Scope, command: string, io: StepIO): Promise {
- return this.spawnAndCapture("sh", ["-c", command], scope.env, this.scriptCwd(scope.env, scope.filePath), io);
+ return this.spawnAndCapture(resolveShell(), ["-c", command], scope.env, this.scriptCwd(scope.env, scope.filePath), io);
}
private async executeInlineScript(
diff --git a/src/runtime/kernel/portability.test.ts b/src/runtime/kernel/portability.test.ts
index 6ee257c6..f95a3476 100644
--- a/src/runtime/kernel/portability.test.ts
+++ b/src/runtime/kernel/portability.test.ts
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
-import { killProcessTree, _portability } from "./portability";
+import { killProcessTree, resolveShell, _portability } from "./portability";
// Precedent for platform stubbing: src/runtime/docker.test.ts.
function withPlatform(platform: string, fn: () => void): void {
@@ -163,6 +163,144 @@ test("win32: NEVER calls process.kill with a negative PID (SIGTERM, SIGINT, SIGK
});
});
+// ---------------------------------------------------------------------------
+// resolveShell(): single POSIX-sh seam for inline shell lines and hooks.
+// ---------------------------------------------------------------------------
+
+/** Stub `_portability.fileExists` and `process.env` while `fn` runs. */
+function withShellEnv(
+ opts: { exists: (path: string) => boolean; env: Record },
+ fn: () => void,
+): void {
+ const origExists = _portability.fileExists;
+ const savedEnv: Record = {};
+ for (const key of Object.keys(opts.env)) {
+ savedEnv[key] = process.env[key];
+ if (opts.env[key] === undefined) delete process.env[key];
+ else process.env[key] = opts.env[key];
+ }
+ _portability.fileExists = opts.exists;
+ _portability.resetShellCache();
+ try {
+ fn();
+ } finally {
+ _portability.fileExists = origExists;
+ for (const key of Object.keys(savedEnv)) {
+ if (savedEnv[key] === undefined) delete process.env[key];
+ else process.env[key] = savedEnv[key];
+ }
+ _portability.resetShellCache();
+ }
+}
+
+test("POSIX: resolveShell returns bare `sh`", () => {
+ withPlatform("linux", () => {
+ _portability.resetShellCache();
+ try {
+ assert.equal(resolveShell(), "sh");
+ } finally {
+ _portability.resetShellCache();
+ }
+ });
+});
+
+test("win32: resolveShell returns an sh.exe discovered on PATH", () => {
+ withPlatform("win32", () => {
+ // Compute the expected path with the same `join` production uses so the
+ // assertion is host-separator-independent (POSIX CI vs a real win32 host).
+ const onPath = join("C:\\git\\bin", "sh.exe");
+ withShellEnv(
+ {
+ env: { PATH: "C:\\tools;C:\\git\\bin", ProgramFiles: undefined, "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: (p) => p === onPath,
+ },
+ () => {
+ assert.equal(resolveShell(), onPath);
+ },
+ );
+ });
+});
+
+test("win32: resolveShell falls back to the standard Git for Windows location", () => {
+ withPlatform("win32", () => {
+ // Only the Git usr/bin layout exists; nothing on PATH.
+ const gitUsrBin = join("C:\\Program Files", "Git", "usr", "bin", "sh.exe");
+ withShellEnv(
+ {
+ env: { PATH: "C:\\tools", ProgramFiles: "C:\\Program Files", "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: (p) => p === gitUsrBin,
+ },
+ () => {
+ assert.equal(resolveShell(), gitUsrBin);
+ },
+ );
+ });
+});
+
+test("win32: resolveShell throws E_NO_POSIX_SHELL naming Git for Windows when no sh.exe exists", () => {
+ withPlatform("win32", () => {
+ withShellEnv(
+ {
+ env: { PATH: "C:\\tools", ProgramFiles: "C:\\Program Files", "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: () => false,
+ },
+ () => {
+ assert.throws(
+ () => resolveShell(),
+ (err: Error) => {
+ assert.match(err.message, /E_NO_POSIX_SHELL/);
+ assert.match(err.message, /Git for Windows/);
+ return true;
+ },
+ );
+ },
+ );
+ });
+});
+
+test("resolveShell memoizes: fileExists is not consulted on the second call", () => {
+ withPlatform("win32", () => {
+ const onPath = join("C:\\git\\bin", "sh.exe");
+ let calls = 0;
+ withShellEnv(
+ {
+ env: { PATH: "C:\\git\\bin", ProgramFiles: undefined, "ProgramFiles(x86)": undefined, ProgramW6432: undefined },
+ exists: (p) => {
+ calls++;
+ return p === onPath;
+ },
+ },
+ () => {
+ assert.equal(resolveShell(), onPath);
+ const after = calls;
+ assert.equal(resolveShell(), onPath);
+ assert.equal(calls, after, "second call is served from the memoized cache");
+ },
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Lint contract: no `spawn("sh", …)` outside the portability module.
+// ---------------------------------------------------------------------------
+
+test("no production source file invokes spawn(\"sh\", …) directly — all go through resolveShell", () => {
+ // Match spawn("sh" / spawn('sh' with optional whitespace after the paren.
+ const rawShSpawn = /spawn\(\s*['"]sh['"]/;
+ const offenders: string[] = [];
+ for (const file of walkProductionTsFiles(SRC_ROOT)) {
+ const rel = file.slice(REPO_ROOT.length + 1);
+ if (rel === join("src", "runtime", "kernel", "portability.ts")) continue;
+ const content = readFileSync(file, "utf8");
+ if (rawShSpawn.test(content)) offenders.push(rel);
+ }
+ assert.deepEqual(
+ offenders,
+ [],
+ `inline shell must resolve through resolveShell(); offenders: ${offenders.join(", ")}`,
+ );
+});
+
// ---------------------------------------------------------------------------
// Lint contract: only the portability module may group-kill via negative PID.
// ---------------------------------------------------------------------------
diff --git a/src/runtime/kernel/portability.ts b/src/runtime/kernel/portability.ts
index a012ef90..d8e8be76 100644
--- a/src/runtime/kernel/portability.ts
+++ b/src/runtime/kernel/portability.ts
@@ -13,17 +13,76 @@
// `killProcessTree` hides that difference behind one call site.
import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
/**
- * Test seam for the `taskkill` spawn. Swapped out in unit tests so the win32
- * branch can be exercised on any host without a real `taskkill` binary.
+ * Test seams. Swapped out in unit tests so the win32 branches can be exercised
+ * on any host without a real `taskkill` binary or a real `sh.exe` on disk.
*/
export const _portability = {
spawn(command: string, args: string[]): ChildProcess {
return spawn(command, args, { stdio: "ignore" });
},
+ /** Existence probe for `resolveShell` PATH / Git-for-Windows lookups. */
+ fileExists(path: string): boolean {
+ return existsSync(path);
+ },
+ /** Reset the memoized shell so a test can re-run resolution under a new platform. */
+ resetShellCache(): void {
+ cachedShell = undefined;
+ },
};
+let cachedShell: string | undefined;
+
+/**
+ * Resolve the POSIX shell used to run inline workflow shell lines and hooks.
+ *
+ * Jaiph's language semantics require POSIX `sh` on every platform — inline
+ * lines are never translated to cmd/PowerShell, or workflows stop being
+ * portable. On POSIX the answer is simply `sh`. On win32 there is no `sh` on
+ * the default PATH, so we locate Git for Windows' bundled `sh.exe`, first on
+ * PATH and then in the standard install locations. The result is memoized for
+ * the lifetime of the process.
+ */
+export function resolveShell(): string {
+ if (cachedShell !== undefined) return cachedShell;
+ cachedShell = process.platform === "win32" ? resolveWindowsPosixShell() : "sh";
+ return cachedShell;
+}
+
+function resolveWindowsPosixShell(): string {
+ // PATH first: honor an sh.exe the user already put on their PATH.
+ const path = process.env.PATH ?? "";
+ for (const dir of path.split(";")) {
+ if (!dir) continue;
+ const candidate = join(dir, "sh.exe");
+ if (_portability.fileExists(candidate)) return candidate;
+ }
+ // Then the standard Git for Windows layouts under each known install root.
+ const roots = [
+ process.env.ProgramFiles,
+ process.env["ProgramFiles(x86)"],
+ process.env.ProgramW6432,
+ "C:\\Program Files",
+ "C:\\Program Files (x86)",
+ ];
+ for (const root of roots) {
+ if (!root) continue;
+ for (const rel of [join("Git", "bin", "sh.exe"), join("Git", "usr", "bin", "sh.exe")]) {
+ const candidate = join(root, rel);
+ if (_portability.fileExists(candidate)) return candidate;
+ }
+ }
+ throw new Error(
+ "E_NO_POSIX_SHELL Jaiph requires a POSIX `sh` to run inline shell lines and hooks, " +
+ "but `sh.exe` was not found on PATH or in the standard Git for Windows install " +
+ "locations. Install Git for Windows (https://git-scm.com/download/win), which " +
+ "bundles `sh.exe`.",
+ );
+}
+
/**
* Terminate `pid` and its descendants with `signal`, portably.
*
From 2b1922df3f659fd0cd20c9f3a31fec96590d5c5c Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 13:55:59 +0200
Subject: [PATCH 4/9] Fix: portability home-dir fallback, Docker gating on
win32, ANSI policy
Close three remaining POSIX assumptions that broke a host-only Windows
runtime. prepareClaudeEnv now falls back to os.homedir() when neither
execEnv.HOME nor process.env.HOME is set, so USERPROFILE-only
environments resolve the .claude config dir; an explicit execEnv.HOME
still wins. resolveDockerConfig forces host-only mode on win32 with a
one-line notice (same UX as JAIPH_UNSAFE=true), so the CLI never probes
docker and never hard-fails on a missing daemon, and
JAIPH_DOCKER_ENABLED=true cannot override it. A new canUseAnsi() helper
in the portability module centralizes the isTTY + NO_COLOR gate; every
color/erase emission site routes through it so the policy lives in one
place. Adds unit tests for each and a src-wide lint test asserting no
production file outside portability.ts gates directly on isTTY &&
NO_COLOR. Docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 14 ------
docs/architecture.md | 4 +-
docs/configuration.md | 5 +-
docs/env-vars.md | 4 +-
docs/sandboxing.md | 4 ++
src/cli/commands/run.ts | 3 +-
src/cli/run/progress.ts | 9 ++--
src/cli/shared/errors.ts | 3 +-
src/runtime/docker.test.ts | 38 ++++++++++++++++
src/runtime/docker.ts | 34 ++++++++++++--
src/runtime/kernel/portability.test.ts | 63 +++++++++++++++++++++++++-
src/runtime/kernel/portability.ts | 16 +++++++
src/runtime/kernel/prompt.test.ts | 46 +++++++++++++++++++
src/runtime/kernel/prompt.ts | 5 +-
15 files changed, 219 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 875ce769..102f19dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
- **Fix — Portability: cross-platform process-tree termination:** `jaiph run` spawns the workflow leader with `detached: true` (`src/runtime/kernel/workflow-launch.ts`) and previously stopped it via `process.kill(-pid, signal)` — a negative-PID group kill, which signals the leader's whole process group. That primitive is POSIX-only: on `win32` it throws, the previous `child.kill()` fallback terminated only the leader, and the agent backends / script children it spawned were **orphaned**. A new portability module `src/runtime/kernel/portability.ts` exports **`killProcessTree(pid, signal)`** and is now the single sanctioned home for group kills. On POSIX it preserves the prior behavior — `process.kill(-pid, signal)` with a per-process fallback when the group no longer exists (`ESRCH`). On `win32` it force-kills the entire tree with `taskkill /pid /T /F` (spawned via an injectable `_portability.spawn` seam, not shelled), degrading to a per-process `process.kill(pid, signal)` if `taskkill` cannot be launched (reported asynchronously via the child's `error` event). Because `taskkill /F` is already a forceful kill with no graceful phase, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32` — the tree is already gone. Every subprocess-termination call site is repointed at the helper: run teardown `terminateRunProcessGroup` (`src/cli/run/lifecycle.ts`, previously the negative-PID group kill) and the two SIGTERM→SIGKILL escalation paths that previously assumed POSIX signal semantics via `child.kill()` — the prompt watchdog's `killChild` (`src/runtime/kernel/prompt.ts`) and the Docker run-timeout kill (`src/runtime/docker.ts`) — so those escalations now terminate the whole child tree instead of just the leader. POSIX Ctrl-C behavior of `jaiph run` is unchanged (leader and children terminate; exit code and cleanup semantics identical), covered by the existing signal-lifecycle e2e. New unit tests (`src/runtime/kernel/portability.test.ts`) cover both platform branches by stubbing `process.platform` (precedent: `src/runtime/docker.test.ts`) and assert the exact mechanism invoked — negative-PID kill on POSIX (plus the ESRCH per-process fallback and SIGKILL escalation), `taskkill /pid /T /F` argv on `win32` (SIGTERM, SIGINT/Ctrl-C, and the degrade-to-per-process path), a proof that the win32 branch **never** calls `process.kill` with a negative PID, and a `src/`-wide lint test asserting no production file outside `portability.ts` matches `process.kill(-`. The watchdog unit tests in `src/runtime/kernel/prompt.test.ts` are updated to spy on `process.kill` (the watchdog now terminates through `killProcessTree`). Docs updated (`docs/architecture.md`, `docs/configuration.md`).
diff --git a/QUEUE.md b/QUEUE.md
index c02bca1e..b55ccf29 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,20 +14,6 @@ Process rules:
***
-## Portability: home directory, Docker gating, and ANSI on win32 #dev-ready
-
-Remaining small POSIX assumptions for a host-only Windows runtime:
-
-1. `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) reads `execEnv.HOME || process.env.HOME`. Use `os.homedir()` as the final fallback so `USERPROFILE`-only environments resolve; an explicit `HOME` in `execEnv` still wins.
-2. Docker sandboxing (`src/runtime/docker.ts`) hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches. On `win32`, the Docker sandbox is out of scope: `resolveDockerConfig` must resolve to host-only mode with a one-line notice (same UX as an explicit `JAIPH_UNSAFE=true`), never attempt `docker` probing, and never hard-fail because Docker is missing.
-3. Live status rendering already gates SGR colors on `isTTY` + `NO_COLOR` and uses a single erase/cursor-up sequence (`src/cli/run/stderr-handler.ts`). Node enables VT processing on Windows 10+; no rendering change required. Add a `canUseAnsi()` helper to the portability module and route the existing gates through it so the policy lives in one place.
-
-Acceptance:
-
-* Unit test: with `HOME` unset and `os.homedir()` stubbed, `prepareClaudeEnv` resolves the config dir from `os.homedir()`; with `execEnv.HOME` set, it wins.
-* Unit test: with `process.platform` stubbed to `win32`, Docker resolution returns host-only mode, emits the notice once, and performs zero `docker` invocations (spawn spied).
-* Unit test: `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set; all color/erase emission sites consume it (grep test: no direct `isTTY && NO_COLOR` gating outside the portability module).
-
## Distro: build and release `jaiph-windows-x64.exe` #dev-ready
The release workflow (`.github/workflows/release.yml`) cross-compiles four assets (`jaiph-{darwin,linux}-{arm64,x64}`) via `bun build --compile` and publishes them with `SHA256SUMS`. Add Windows:
diff --git a/docs/architecture.md b/docs/architecture.md
index c6f5c6a0..d72c38de 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -92,7 +92,7 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `WorkflowStepDef.type` (8 variants) and an `emitExpr` helper switches on `Expr.kind` (8 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture.
- **Docker runtime helper (`src/runtime/docker.ts`)**
- - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image; every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that `jaiph` exists in the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits.
+ - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). On **`win32`** the Docker sandbox is out of scope: **`resolveDockerConfig`** forces host-only mode (same UX as an explicit **`JAIPH_UNSAFE=true`**) with a one-line notice, so the CLI never probes `docker` and never hard-fails on a missing daemon (`JAIPH_DOCKER_ENABLED=true` cannot override this). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image; every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that `jaiph` exists in the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits.
- **Workspace immutability:** By default Docker runs cannot modify the host workspace. The host checkout is mounted read-only (overlay) or as a disposable clone (copy); `/jaiph/workspace` is sandbox-local and discarded on exit. The only host-writable path is `/jaiph/run` (run artifacts). Workflows that need to capture workspace changes should write files (for example a `git diff` into a temp path) and publish them with `artifacts.save()`. The explicit opt-in **inplace** mode (truthy **`JAIPH_INPLACE`** — `1` or `true`, or `jaiph run --inplace`) breaks this contract on purpose — the host workspace itself is bind-mounted read-write so the run's edits persist live on the host, with the rest of the sandbox (caps, env allowlist, mount set) unchanged. See [Sandboxing](sandboxing.md) for the full contract and [Save artifacts](artifacts.md).
## Local module graph
@@ -170,7 +170,7 @@ Authoring rules, fixtures, and mock syntax for `*.test.jh` are documented in [Te
## CLI progress reporting pipeline
-The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
+The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
## Distribution: Node vs Bun standalone
diff --git a/docs/configuration.md b/docs/configuration.md
index 83bdb949..d7dc7492 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,14 +83,17 @@ In-file `runtime.docker_enabled` is not supported (`E_PARSE`); use the env-only
## Docker enablement
+Checks are applied top to bottom; the first match wins.
+
| Check | Result |
|---|---|
+| Platform is Windows (`win32`) | Docker off (host-only mode, with a one-line notice). Overrides everything below, including `JAIPH_DOCKER_ENABLED=true`. |
| `JAIPH_DOCKER_ENABLED` is set to exact `true` | Docker on. |
| `JAIPH_DOCKER_ENABLED` is set to any other value | Docker off. |
| `JAIPH_DOCKER_ENABLED` is unset and `JAIPH_UNSAFE=true` | Docker off. |
| Default (no env) | Docker on. |
-`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. See [Sandboxing](sandboxing.md) for the full model.
+`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. On Windows the Docker sandbox is out of scope, so `jaiph run` resolves to host-only mode automatically without probing `docker` or failing on a missing daemon — see [Sandboxing — Windows runs host-only](sandboxing.md#windows-runs-host-only) for the full model.
## Precedence
{: #precedence}
diff --git a/docs/env-vars.md b/docs/env-vars.md
index 5f232352..42927c27 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -43,7 +43,7 @@ The table below covers every `JAIPH_*` name read from `process.env` / `env` in `
| `JAIPH_CODEX_API_URL` | runtime | string | `https://api.openai.com/v1/chat/completions` | — | Chat-completions endpoint for the `codex` backend. |
| `JAIPH_DEBUG` | host, runtime | bool (exact `"true"`) | `false` | `run.debug` | Enable debug tracing for the run. |
| `JAIPH_DEBUG_LOCKED` | internal | bool | — | — | Lock flag for `JAIPH_DEBUG`. |
-| `JAIPH_DOCKER_ENABLED` | host | bool (exact `true`) | — | — | Force Docker on (`true`) or off (any other value). When unset, Docker is on unless `JAIPH_UNSAFE=true`. |
+| `JAIPH_DOCKER_ENABLED` | host | bool (exact `true`) | — | — | Force Docker on (`true`) or off (any other value). When unset, Docker is on unless `JAIPH_UNSAFE=true`. Ignored on Windows (`win32`), where the sandbox is out of scope and runs are always host-only. |
| `JAIPH_DOCKER_IMAGE` | host | string | `ghcr.io/jaiphlang/jaiph-runtime:` | `runtime.docker_image` | Container image. Must already contain `jaiph`. |
| `JAIPH_DOCKER_KEEP_SANDBOX` | host | bool (`1` / `true`) | `false` | — | Copy mode only — when enabled, leave the host-side `.sandbox-/` clone on disk after exit for debugging. |
| `JAIPH_DOCKER_NETWORK` | host | string (`default`, `none`, or named network) | `default` | `runtime.docker_network` | `docker run --network` value. `none` disables egress. |
@@ -115,7 +115,7 @@ These error codes surface during Docker-backed `jaiph run` invocations. They are
| Code | Trigger | Behaviour |
|---|---|---|
-| `E_DOCKER_NOT_FOUND` | `docker info` fails (Docker not installed or daemon not running). | Run exits before launch. No fallback to local execution. |
+| `E_DOCKER_NOT_FOUND` | `docker info` fails (Docker not installed or daemon not running). | Run exits before launch. No fallback to local execution. Not reachable on Windows, where the CLI resolves to host-only mode without probing `docker`. |
| `E_DOCKER_PULL` | `docker pull` fails (network error, image not found, auth failure). | Run exits before launch. |
| `E_DOCKER_NO_JAIPH` | Selected image does not contain a `jaiph` CLI. | Run exits before launch. |
| `E_DOCKER_RUNS_DIR` | Absolute `JAIPH_RUNS_DIR` points outside the workspace. | Run exits before launch. |
diff --git a/docs/sandboxing.md b/docs/sandboxing.md
index 054626f2..c1523b4a 100644
--- a/docs/sandboxing.md
+++ b/docs/sandboxing.md
@@ -75,6 +75,10 @@ A second, equally deliberate choice: **enablement lives entirely in environment
The escape hatch — `JAIPH_UNSAFE=true` or `jaiph run --unsafe` — exists because some environments genuinely cannot run Docker (a sandboxed CI without nested virtualization, a developer iterating on the runtime itself). The choice to take that hatch should be visible and ergonomic, which is why it is a single explicit host-side switch rather than an in-file `config` knob.
+## Windows runs host-only
+
+On Windows (`win32`) the Docker sandbox is out of scope: the sandbox modes rely on POSIX socket paths and Linux/macOS-specific workspace presentation, so Jaiph does not attempt them there. `jaiph run` on Windows resolves to **host-only mode automatically** — the same posture as an explicit `JAIPH_UNSAFE=true` — and prints a one-line notice that the run is host-only. The CLI never probes for `docker` and never fails just because a Docker daemon is absent, and `JAIPH_DOCKER_ENABLED=true` cannot force the sandbox back on. Windows workflows therefore run with no OS sandbox; keep the [not-protected-against](#what-docker-does-not-protect-against) list in mind, or run under WSL, where the Linux path (and the full sandbox) applies.
+
## Why `jaiph test` does not use Docker
The test runner runs in-process on the host. This is intentional: tests are a development feedback loop, they typically mock prompts and replace external calls, and Docker spawn overhead would harm the iteration cycle. Tests already get isolation from the things they care about (prompts, network) through the runtime's mock infrastructure. The Docker boundary is for `jaiph run`, where the workflow is executing real scripts against real resources.
diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts
index 5f143e54..aa2b73c7 100644
--- a/src/cli/commands/run.ts
+++ b/src/cli/commands/run.ts
@@ -12,6 +12,7 @@ import { basename } from "node:path";
import { parsejaiph } from "../../parser";
import { buildScripts, buildScriptsFromGraph } from "../../transpiler";
import { loadModuleGraph, writeModuleGraph } from "../../transpile/module-graph";
+import { canUseAnsi } from "../../runtime/kernel/portability";
import { metadataToConfig } from "../../config";
import { buildStepDisplayParamPairs, formatNamedParamsForDisplay } from "./format-params.js";
import {
@@ -130,7 +131,7 @@ export async function runWorkflow(rest: string[]): Promise {
const outDir = target ? resolve(target) : mkdtempSync(join(tmpdir(), "jaiph-run-"));
const shouldCleanup = !target;
try {
- const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const colorEnabled = canUseAnsi();
const isTTY = !!process.stdout.isTTY;
const startedAt = Date.now();
diff --git a/src/cli/run/progress.ts b/src/cli/run/progress.ts
index 546c7aac..f3b9b9b8 100644
--- a/src/cli/run/progress.ts
+++ b/src/cli/run/progress.ts
@@ -1,6 +1,7 @@
import { resolve } from "node:path";
import { jaiphModule, type Expr, type WorkflowStepDef } from "../../types";
import { workflowSymbolForFile } from "../../transpiler";
+import { canUseAnsi } from "../../runtime/kernel/portability";
export type TreeRow = {
rawLabel: string;
@@ -282,7 +283,7 @@ export function parseLabel(rawLabel: string): { kind: string; name: string } {
export function styleKeywordLabel(rawLabel: string): string {
const { kind, name } = parseLabel(rawLabel);
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return `${kind} ${name}`;
}
@@ -290,7 +291,7 @@ export function styleKeywordLabel(rawLabel: string): string {
}
export function styleDim(text: string): string {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return text;
}
@@ -298,7 +299,7 @@ export function styleDim(text: string): string {
}
export function styleYellow(text: string): string {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return text;
}
@@ -306,7 +307,7 @@ export function styleYellow(text: string): string {
}
export function styleBold(text: string): string {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return text;
}
diff --git a/src/cli/shared/errors.ts b/src/cli/shared/errors.ts
index 91582395..f7c13888 100644
--- a/src/cli/shared/errors.ts
+++ b/src/cli/shared/errors.ts
@@ -1,9 +1,10 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { CONTAINER_RUN_DIR } from "../../runtime/docker";
+import { canUseAnsi } from "../../runtime/kernel/portability";
export function colorPalette(): { green: string; red: string; dim: string; reset: string } {
- const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined;
+ const enabled = canUseAnsi();
if (!enabled) {
return { green: "", red: "", dim: "", reset: "" };
}
diff --git a/src/runtime/docker.test.ts b/src/runtime/docker.test.ts
index a227fac4..ffbdfcb8 100644
--- a/src/runtime/docker.test.ts
+++ b/src/runtime/docker.test.ts
@@ -22,7 +22,9 @@ import {
cleanupDocker,
withDockerExitGuard,
_dockerExec,
+ _dockerSpawn,
_uidDetect,
+ _win32Notice,
type DockerRunConfig,
type DockerSpawnOptions,
type DockerSpawnResult,
@@ -220,6 +222,42 @@ test("resolveDockerConfig: in-file dockerEnabled is ignored (field removed from
assert.equal(cfg.enabled, false, "JAIPH_UNSAFE disables Docker regardless of in-file");
});
+test("resolveDockerConfig: win32 forces host-only mode, notice once, zero docker invocations", () => {
+ const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
+ // Spy every docker entry point so we can assert win32 never probes.
+ const origExec = _dockerExec.run;
+ const origSpawn = _dockerSpawn.run;
+ const origWrite = _win32Notice.write;
+ const origEmitted = _win32Notice.emitted;
+ let execCalls = 0;
+ let spawnCalls = 0;
+ const notices: string[] = [];
+ _dockerExec.run = () => { execCalls += 1; };
+ _dockerSpawn.run = () => { spawnCalls += 1; return {} as any; };
+ _win32Notice.write = (msg) => { notices.push(msg); };
+ _win32Notice.emitted = false;
+ try {
+ // Even an explicit JAIPH_DOCKER_ENABLED=true cannot re-enable Docker on win32.
+ const first = resolveDockerConfig(undefined, { JAIPH_DOCKER_ENABLED: "true" });
+ const second = resolveDockerConfig(undefined, {});
+ assert.equal(first.enabled, false, "win32 resolves to host-only mode");
+ assert.equal(second.enabled, false, "win32 resolves to host-only mode on every call");
+ assert.equal(notices.length, 1, "notice is emitted exactly once across calls");
+ assert.match(notices[0], /Windows/, "notice mentions Windows");
+ assert.match(notices[0], /host-only|no sandbox/, "notice describes host-only mode");
+ assert.equal(execCalls, 0, "no docker exec probing on win32");
+ assert.equal(spawnCalls, 0, "no docker spawn on win32");
+ } finally {
+ _dockerExec.run = origExec;
+ _dockerSpawn.run = origSpawn;
+ _win32Notice.write = origWrite;
+ _win32Notice.emitted = origEmitted;
+ if (origPlatform) Object.defineProperty(process, "platform", origPlatform);
+ else Object.defineProperty(process, "platform", { value: process.platform, configurable: true });
+ }
+});
+
test("checkDockerAvailable: E_DOCKER_NOT_FOUND message mentions JAIPH_UNSAFE", () => {
const src = readFileSync(join(__dirname, "docker.ts"), "utf8");
assert.ok(
diff --git a/src/runtime/docker.ts b/src/runtime/docker.ts
index b281f050..b1e49c04 100644
--- a/src/runtime/docker.ts
+++ b/src/runtime/docker.ts
@@ -89,9 +89,34 @@ const DEFAULTS: DockerRunConfig = {
timeoutSeconds: 14400,
};
+/**
+ * Test seam for the one-time win32 host-only notice. Tests reset `emitted`
+ * between runs and can spy `write` to assert the notice fires exactly once.
+ */
+export const _win32Notice = {
+ emitted: false,
+ write(message: string): void {
+ process.stderr.write(message);
+ },
+};
+
+/** Emit the win32 host-only notice at most once per process. */
+function emitWin32HostOnlyNotice(): void {
+ if (_win32Notice.emitted) return;
+ _win32Notice.emitted = true;
+ _win32Notice.write(
+ "jaiph: Docker sandbox is not supported on Windows; running host-only (no sandbox).\n",
+ );
+}
+
/**
* Resolve effective Docker config.
- * Precedence: env vars (`JAIPH_DOCKER_*`) > unsafe default rule.
+ * Precedence: platform > env vars (`JAIPH_DOCKER_*`) > unsafe default rule.
+ *
+ * On win32 the Docker sandbox is out of scope: resolution is forced to
+ * host-only mode (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line
+ * notice, so the CLI never probes `docker` and never hard-fails on a missing
+ * daemon. `JAIPH_DOCKER_ENABLED=true` cannot override this.
*
* Default rule (when no explicit `JAIPH_DOCKER_ENABLED` is set):
* - `JAIPH_UNSAFE=true` → Docker off (explicit "run on host" escape hatch)
@@ -101,9 +126,12 @@ export function resolveDockerConfig(
inFile: RuntimeConfig | undefined,
env: Record,
): DockerRunConfig {
- // enabled: env JAIPH_DOCKER_ENABLED > unsafe default rule
+ // enabled: win32 host-only override > env JAIPH_DOCKER_ENABLED > unsafe default rule
let enabled: boolean;
- if (env.JAIPH_DOCKER_ENABLED !== undefined) {
+ if (process.platform === "win32") {
+ emitWin32HostOnlyNotice();
+ enabled = false;
+ } else if (env.JAIPH_DOCKER_ENABLED !== undefined) {
enabled = env.JAIPH_DOCKER_ENABLED === "true";
} else {
// Default: Docker on unless the user explicitly opts out via JAIPH_UNSAFE.
diff --git a/src/runtime/kernel/portability.test.ts b/src/runtime/kernel/portability.test.ts
index f95a3476..173d42a2 100644
--- a/src/runtime/kernel/portability.test.ts
+++ b/src/runtime/kernel/portability.test.ts
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { readFileSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
-import { killProcessTree, resolveShell, _portability } from "./portability";
+import { killProcessTree, resolveShell, canUseAnsi, _portability } from "./portability";
// Precedent for platform stubbing: src/runtime/docker.test.ts.
function withPlatform(platform: string, fn: () => void): void {
@@ -280,6 +280,67 @@ test("resolveShell memoizes: fileExists is not consulted on the second call", ()
});
});
+// ---------------------------------------------------------------------------
+// canUseAnsi(): single ANSI color/erase policy for the CLI.
+// ---------------------------------------------------------------------------
+
+/** Run `fn` with NO_COLOR forced to a given presence. */
+function withNoColor(value: string | undefined, fn: () => void): void {
+ const saved = process.env.NO_COLOR;
+ if (value === undefined) delete process.env.NO_COLOR;
+ else process.env.NO_COLOR = value;
+ try {
+ fn();
+ } finally {
+ if (saved === undefined) delete process.env.NO_COLOR;
+ else process.env.NO_COLOR = saved;
+ }
+}
+
+test("canUseAnsi: true only when isTTY and NO_COLOR is unset", () => {
+ withNoColor(undefined, () => {
+ assert.equal(canUseAnsi({ isTTY: true }), true);
+ });
+});
+
+test("canUseAnsi: false when isTTY is false", () => {
+ withNoColor(undefined, () => {
+ assert.equal(canUseAnsi({ isTTY: false }), false);
+ assert.equal(canUseAnsi({}), false);
+ });
+});
+
+test("canUseAnsi: false when NO_COLOR is set, even on a TTY", () => {
+ withNoColor("1", () => {
+ assert.equal(canUseAnsi({ isTTY: true }), false);
+ });
+ // NO_COLOR honored even when empty (https://no-color.org: any value).
+ withNoColor("", () => {
+ assert.equal(canUseAnsi({ isTTY: true }), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Lint contract: the `isTTY && NO_COLOR` policy lives only in portability.ts.
+// ---------------------------------------------------------------------------
+
+test("no production source outside portability.ts gates directly on isTTY && NO_COLOR", () => {
+ // Match either ordering of the combined gate on a single line.
+ const combinedGate = /(isTTY[^\n]*NO_COLOR)|(NO_COLOR[^\n]*isTTY)/;
+ const offenders: string[] = [];
+ for (const file of walkProductionTsFiles(SRC_ROOT)) {
+ const rel = file.slice(REPO_ROOT.length + 1);
+ if (rel === join("src", "runtime", "kernel", "portability.ts")) continue;
+ const content = readFileSync(file, "utf8");
+ if (combinedGate.test(content)) offenders.push(rel);
+ }
+ assert.deepEqual(
+ offenders,
+ [],
+ `ANSI gating must route through canUseAnsi(); offenders: ${offenders.join(", ")}`,
+ );
+});
+
// ---------------------------------------------------------------------------
// Lint contract: no `spawn("sh", …)` outside the portability module.
// ---------------------------------------------------------------------------
diff --git a/src/runtime/kernel/portability.ts b/src/runtime/kernel/portability.ts
index d8e8be76..4f4c6263 100644
--- a/src/runtime/kernel/portability.ts
+++ b/src/runtime/kernel/portability.ts
@@ -34,6 +34,22 @@ export const _portability = {
},
};
+/**
+ * Whether ANSI SGR colors and cursor/erase sequences may be emitted.
+ *
+ * Single policy for the whole CLI: ANSI is on only when writing to a TTY and
+ * `NO_COLOR` is unset (https://no-color.org). Live status rendering, the
+ * progress tree, and error formatting route their `isTTY && NO_COLOR` gate
+ * through here so the rule lives in one place rather than being re-derived at
+ * each emission site.
+ *
+ * On Windows 10+ Node enables VT processing on the console automatically, so
+ * `isTTY` is a sufficient proxy for ANSI support — no extra win32 branch.
+ */
+export function canUseAnsi(stream: { isTTY?: boolean } = process.stdout): boolean {
+ return Boolean(stream.isTTY) && process.env.NO_COLOR === undefined;
+}
+
let cachedShell: string | undefined;
/**
diff --git a/src/runtime/kernel/prompt.test.ts b/src/runtime/kernel/prompt.test.ts
index 1e042d7c..7d875a72 100644
--- a/src/runtime/kernel/prompt.test.ts
+++ b/src/runtime/kernel/prompt.test.ts
@@ -467,6 +467,52 @@ describe("prepareClaudeEnv", () => {
}
});
+ it("resolves the config dir from os.homedir() when HOME is unset", () => {
+ // The named `import { homedir }` in prompt.ts reads node:os's raw (mutable)
+ // require object at call time, so stubbing homedir on that same cached
+ // object changes what prepareClaudeEnv sees. Also clear process.env.HOME so
+ // the os.homedir() fallback (not the ambient HOME) is what resolves.
+ const osRaw = require("node:os") as { homedir: () => string };
+ const root = mkdtempSync(join(tmpdir(), "jaiph-claude-env-homedir-"));
+ const origHomedir = osRaw.homedir;
+ const savedHome = process.env.HOME;
+ delete process.env.HOME;
+ osRaw.homedir = () => root;
+ try {
+ const prepared = prepareClaudeEnv({}, join(root, "workspace"));
+ assert.equal(prepared.error, undefined);
+ assert.equal(prepared.warning, undefined);
+ // A writable /.claude means execEnv is returned unchanged and
+ // session-env is created there — proving os.homedir() was the source.
+ assert.equal(prepared.env.CLAUDE_CONFIG_DIR, undefined);
+ assert.ok(existsSync(join(root, ".claude", "session-env")));
+ } finally {
+ osRaw.homedir = origHomedir;
+ if (savedHome === undefined) delete process.env.HOME;
+ else process.env.HOME = savedHome;
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it("prefers execEnv.HOME over os.homedir()", () => {
+ const osRaw = require("node:os") as { homedir: () => string };
+ const homeRoot = mkdtempSync(join(tmpdir(), "jaiph-claude-env-home-wins-"));
+ const homedirRoot = mkdtempSync(join(tmpdir(), "jaiph-claude-env-homedir-unused-"));
+ const origHomedir = osRaw.homedir;
+ osRaw.homedir = () => homedirRoot;
+ try {
+ const prepared = prepareClaudeEnv({ HOME: homeRoot }, join(homeRoot, "workspace"));
+ assert.equal(prepared.error, undefined);
+ // Config resolves under execEnv.HOME, not the os.homedir() stub.
+ assert.ok(existsSync(join(homeRoot, ".claude", "session-env")));
+ assert.ok(!existsSync(join(homedirRoot, ".claude")));
+ } finally {
+ osRaw.homedir = origHomedir;
+ rmSync(homeRoot, { recursive: true, force: true });
+ rmSync(homedirRoot, { recursive: true, force: true });
+ }
+ });
+
it("falls back to workspace-local claude config when default is not writable", () => {
const root = mkdtempSync(join(tmpdir(), "jaiph-claude-env-fallback-"));
try {
diff --git a/src/runtime/kernel/prompt.ts b/src/runtime/kernel/prompt.ts
index d09c4977..b124cb79 100644
--- a/src/runtime/kernel/prompt.ts
+++ b/src/runtime/kernel/prompt.ts
@@ -3,6 +3,7 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import { writeFileSync, readFileSync, existsSync, accessSync, mkdirSync, cpSync, constants as fsConstants } from "node:fs";
import { basename, delimiter, join } from "node:path";
+import { homedir } from "node:os";
import { parseStream, type StreamWriter } from "./stream-parser";
import { consumeNextMockResponse, dispatchMockArms, type MockPromptArm } from "./mock";
import { killProcessTree } from "./portability";
@@ -229,7 +230,9 @@ type ClaudeEnvPreparation = {
* Falls back to workspace-local `.jaiph/claude-config` when home config is not writable.
*/
export function prepareClaudeEnv(execEnv: NodeJS.ProcessEnv, workspaceRoot: string): ClaudeEnvPreparation {
- const home = execEnv.HOME || process.env.HOME || "";
+ // Final fallback to os.homedir() so USERPROFILE-only environments (Windows)
+ // resolve; an explicit HOME in execEnv still wins.
+ const home = execEnv.HOME || process.env.HOME || homedir() || "";
const defaultConfigDir = home ? join(home, ".claude") : "";
const configuredDir = execEnv.CLAUDE_CONFIG_DIR || defaultConfigDir;
From 07fb4e2c2576f91e96b33741e62e07bd72083d57 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 14:16:01 +0200
Subject: [PATCH 5/9] Feat: build and release jaiph-windows-x64.exe
Add a Windows x64 target to the release workflow so every release ships
a fifth standalone binary alongside darwin/linux x arm64/x64. A new
bun-windows-x64 matrix entry (with an ext field so the compiled outfile
and uploaded artifact carry the .exe suffix) produces jaiph-windows-x64.exe;
Bun has no windows-arm64 target so Windows is x64 only. The .exe is
included in SHA256SUMS generation and in both the stable and nightly
gh release upload lists.
A new sanity-windows job runs on windows-latest, downloads the .exe
artifact, and runs jaiph-windows-x64.exe --version through a version
gate; the publish job now needs [build, sanity-windows], so a Windows
version mismatch fails the whole release. The linux-x64 gate's inline
comparison is extracted into a shared, testable
scripts/release-version-check.sh that both gates delegate to.
Update the release asset naming contract in docs/contributing.md and
docs/architecture.md, and add integration/release-workflow.test.ts
asserting the five-binary matrix, SHA256SUMS and upload-list coverage,
the shared gate behavior, and naming-contract/installer parity.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/release.yml | 83 ++++++++---
CHANGELOG.md | 1 +
QUEUE.md | 15 --
docs/architecture.md | 2 +-
docs/contributing.md | 7 +-
integration/release-workflow.test.ts | 201 +++++++++++++++++++++++++++
scripts/release-version-check.sh | 34 +++++
7 files changed, 304 insertions(+), 39 deletions(-)
create mode 100644 integration/release-workflow.test.ts
create mode 100755 scripts/release-version-check.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ab8d24d6..af3850a1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -57,15 +57,24 @@ jobs:
- target: bun-darwin-arm64
os: darwin
arch: arm64
+ ext: ""
- target: bun-darwin-x64
os: darwin
arch: x64
+ ext: ""
- target: bun-linux-x64
os: linux
arch: x64
+ ext: ""
- target: bun-linux-arm64
os: linux
arch: arm64
+ ext: ""
+ # Bun has no bun-windows-arm64 target; windows ships x64 only.
+ - target: bun-windows-x64
+ os: windows
+ arch: x64
+ ext: ".exe"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -88,20 +97,61 @@ jobs:
- name: Cross-compile standalone binary for ${{ matrix.target }}
run: |
set -euo pipefail
- bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "jaiph-${{ matrix.os }}-${{ matrix.arch }}"
- ls -la "jaiph-${{ matrix.os }}-${{ matrix.arch }}"
+ out="jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}"
+ bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "${out}"
+ ls -la "${out}"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
- name: jaiph-${{ matrix.os }}-${{ matrix.arch }}
- path: jaiph-${{ matrix.os }}-${{ matrix.arch }}
+ name: jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
+ path: jaiph-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
if-no-files-found: error
retention-days: 7
+ sanity-windows:
+ name: Sanity gate (windows-x64 --version)
+ needs: build
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Resolve tag and channel
+ id: meta
+ shell: bash
+ run: |
+ set -euo pipefail
+ case "${GITHUB_REF}" in
+ refs/tags/v*)
+ tag="${GITHUB_REF_NAME}"; channel="stable" ;;
+ refs/heads/nightly|refs/tags/nightly)
+ tag="nightly"; channel="nightly" ;;
+ *)
+ echo "Unsupported ref for release: ${GITHUB_REF}" >&2; exit 1 ;;
+ esac
+ echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
+ echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
+
+ - name: Download windows binary artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: jaiph-windows-x64.exe
+ path: release-assets
+
+ - name: Sanity gate (windows-x64 --version)
+ working-directory: release-assets
+ shell: bash
+ run: |
+ set -euo pipefail
+ got="$(./jaiph-windows-x64.exe --version)"
+ echo "got: ${got}"
+ bash ../scripts/release-version-check.sh \
+ "${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}"
+
release:
name: Publish release assets
- needs: build
+ needs: [build, sanity-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -138,7 +188,7 @@ jobs:
set -euo pipefail
ls -la
rm -f SHA256SUMS
- sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 > SHA256SUMS
+ sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 jaiph-windows-x64.exe > SHA256SUMS
cat SHA256SUMS
- name: Sanity gate (linux-x64 --version)
@@ -148,19 +198,8 @@ jobs:
chmod +x jaiph-linux-x64
got="$(./jaiph-linux-x64 --version)"
echo "got: ${got}"
- if [ "${{ steps.meta.outputs.channel }}" = "stable" ]; then
- tag="${{ steps.meta.outputs.tag }}"
- expected="jaiph ${tag#v}"
- if [ "${got}" != "${expected}" ]; then
- echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2
- exit 1
- fi
- else
- if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then
- echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2
- exit 1
- fi
- fi
+ bash ../scripts/release-version-check.sh \
+ "${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}"
- name: Publish stable release ${{ steps.meta.outputs.tag }}
if: steps.meta.outputs.channel == 'stable'
@@ -172,13 +211,15 @@ jobs:
gh release upload "${tag}" --clobber \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
else
gh release create "${tag}" \
--title "${tag}" \
- --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64) plus SHA256SUMS." \
+ --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64, windows x64) plus SHA256SUMS." \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
fi
@@ -191,6 +232,7 @@ jobs:
gh release upload nightly --clobber \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
else
gh release create nightly \
@@ -200,5 +242,6 @@ jobs:
--target "${GITHUB_SHA}" \
jaiph-darwin-arm64 jaiph-darwin-x64 \
jaiph-linux-x64 jaiph-linux-arm64 \
+ jaiph-windows-x64.exe \
SHA256SUMS
fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 102f19dc..32dfc3b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
- **Fix — Portability: execute script steps via an explicit interpreter, not shebang + exec bit:** Script steps used to run by spawning the emitted file directly (`executeScript` in `src/runtime/kernel/node-workflow-runtime.ts`), relying on the `#!/usr/bin/env ` shebang line and the `0o755` bit set by `src/transpile/build.ts`. Windows honors neither, and `noexec` mounts on Linux strip the exec bit — both break script execution. The runtime already knows the interpreter (it writes the shebang itself), so `executeScript` now reads the emitted script's shebang and resolves the interpreter through the new **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang (`#!/bin/bash`) → spawn that path, an `env -S` split-flag form → the real interpreter after `-S`, a missing/blank shebang → default `bash` — then spawns `` explicitly. The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX) and the `0o755` bit is still set, but the runtime no longer depends on either being honored by the OS. A spawn `ENOENT` from an interpreter missing on `PATH` now surfaces as a diagnosable Jaiph error naming the interpreter (`script interpreter "" not found — install it or fix the script shebang`) instead of a raw `spawn ENOENT`. New unit tests assert the resolved interpreter + argv on the spawn call itself through an injectable `_scriptSpawn` seam (`src/runtime/kernel/node-workflow-runtime.script-exec.test.ts`), prove that a script with the exec bit stripped (`0o644`) still runs on POSIX, and prove that a missing-interpreter shebang produces the diagnosable error; `resolveInterpreterFromShebang` gets its own unit coverage (`src/parse/script-bash.test.ts`). Docs updated (`docs/architecture.md`, `docs/setup.md`).
diff --git a/QUEUE.md b/QUEUE.md
index b55ccf29..6f022f58 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,21 +14,6 @@ Process rules:
***
-## Distro: build and release `jaiph-windows-x64.exe` #dev-ready
-
-The release workflow (`.github/workflows/release.yml`) cross-compiles four assets (`jaiph-{darwin,linux}-{arm64,x64}`) via `bun build --compile` and publishes them with `SHA256SUMS`. Add Windows:
-
-* New matrix entry: `--target=bun-windows-x64`, asset name `jaiph-windows-x64.exe` (Bun has no Windows arm64 target — do not add one).
-* Include the `.exe` in `SHA256SUMS` generation and in both the stable and nightly `gh release` upload lists.
-* Add a Windows sanity gate alongside the existing linux-x64 one: run `jaiph-windows-x64.exe --version` on a `windows-latest` job and, for stable releases, assert the output matches the tag.
-* Update the "Release asset naming contract" in `docs/contributing.md` to include the new asset name.
-
-Acceptance:
-
-* A nightly release run publishes five binaries + `SHA256SUMS`, and the `.exe` checksum entry verifies against the downloaded asset.
-* The Windows sanity gate fails the release when `--version` output mismatches the tag (verified by test or a deliberate dry-run with a wrong version).
-* `docs/contributing.md` naming contract lists `jaiph-windows-x64.exe`; the e2e installer test's asset-name mapping and the contract stay in sync (grep/parity check).
-
## Distro: PowerShell installer at `jaiph.org/install.ps1` #dev-ready
The bash installer (`docs/install`) downloads a per-platform binary from the GitHub Release for a pinned ref and verifies it against `SHA256SUMS`. Windows users need a native equivalent — the bash script must keep rejecting Windows and point at the PowerShell one.
diff --git a/docs/architecture.md b/docs/architecture.md
index d72c38de..4e8a7ce8 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -176,7 +176,7 @@ The progress UI combines a **static** step tree derived from the workflow AST (`
- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `runtime/overlay-run.sh` and `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), and copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. The published `jaiph` bin is **`node dist/src/cli.js`**.
- **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `overlay-run.sh` and `docs/jaiph-skill.md` are also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)).
-- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the four binaries, runs a `--version` sanity gate on the linux-x64 output, and uploads the five assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract).
+- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the six assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract).
## Mermaid architecture diagram
diff --git a/docs/contributing.md b/docs/contributing.md
index 05119b24..91e422fe 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -212,7 +212,7 @@ The workflow refuses to start when the git tree is dirty or when `v` al
Pushing a **`v*`** tag triggers two things in this repo:
1. **Docker image publish** — the `docker-publish` job in `ci.yml` pushes `ghcr.io/jaiphlang/jaiph-runtime:` and `:latest` after the other CI jobs succeed.
-2. **Standalone-binary release** — `.github/workflows/release.yml` cross-compiles the Bun-compiled standalone binary for four targets via `oven-sh/setup-bun` and `bun build --compile --target=…`, generates a `SHA256SUMS` file, runs a Linux x64 sanity gate (`./jaiph-linux-x64 --version` must equal `jaiph `), and uploads all five assets to the GitHub Release for the tag (creating it if needed). The release job waits for the `CI` workflow on the same SHA to succeed before publishing. Re-runs are available via `workflow_dispatch`.
+2. **Standalone-binary release** — `.github/workflows/release.yml` cross-compiles the Bun-compiled standalone binary for five targets via `oven-sh/setup-bun` and `bun build --compile --target=…`, generates a `SHA256SUMS` file, runs a Linux x64 sanity gate and a `windows-latest` Windows x64 sanity gate (`--version` must equal `jaiph ` for stable tags — both delegate to `scripts/release-version-check.sh`), and uploads all six assets to the GitHub Release for the tag (creating it if needed). The Windows gate is a required dependency of the publish job, so a version mismatch there fails the whole release. The release job waits for the `CI` workflow on the same SHA to succeed before publishing. Re-runs are available via `workflow_dispatch`.
Pushes to the **`nightly`** branch follow the same matrix and upload to a **rolling prerelease** tagged `nightly` (`gh release upload nightly --clobber`), so `jaiph use nightly` keeps working under the binary installer.
@@ -228,9 +228,10 @@ The installer (`docs/install`) downloads these exact filenames from the release
| `bun-darwin-x64` | `jaiph-darwin-x64` |
| `bun-linux-x64` | `jaiph-linux-x64` |
| `bun-linux-arm64` | `jaiph-linux-arm64` |
-| — | `SHA256SUMS` (covers all four binaries) |
+| `bun-windows-x64` | `jaiph-windows-x64.exe` |
+| — | `SHA256SUMS` (covers all five binaries) |
-Every release (stable `v*` and rolling `nightly`) ships exactly these five assets.
+Bun has no `bun-windows-arm64` target, so Windows ships x64 only. Every release (stable `v*` and rolling `nightly`) ships exactly these six assets.
### Local docs site (Jekyll)
diff --git a/integration/release-workflow.test.ts b/integration/release-workflow.test.ts
new file mode 100644
index 00000000..522b3bf5
--- /dev/null
+++ b/integration/release-workflow.test.ts
@@ -0,0 +1,201 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { createHash, randomBytes } from "node:crypto";
+import { spawnSync } from "node:child_process";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Acceptance for "Distro: build and release jaiph-windows-x64.exe".
+//
+// These guards turn each acceptance bullet into a check that fails when the
+// contract is violated:
+// 1. The release workflow builds/ships five binaries + SHA256SUMS, and a
+// checksum entry for the .exe verifies against the asset (round-trip via
+// the installer's own lookup).
+// 2. The shared version-check gate (scripts/release-version-check.sh, invoked
+// by both the linux-x64 and the windows-latest gate) fails on a
+// tag/version mismatch and passes on a match.
+// 3. The docs/contributing.md naming contract, the release matrix, the
+// installer (docs/install), and the e2e installer test all agree on the
+// asset names (grep/parity check).
+
+const REPO_ROOT = process.cwd();
+const RELEASE_YML = readFileSync(join(REPO_ROOT, ".github/workflows/release.yml"), "utf8");
+const CONTRIBUTING = readFileSync(join(REPO_ROOT, "docs/contributing.md"), "utf8");
+const INSTALLER = readFileSync(join(REPO_ROOT, "docs/install"), "utf8");
+const INSTALLER_TEST = readFileSync(join(REPO_ROOT, "e2e/tests/07_installer_binary.sh"), "utf8");
+const VERSION_CHECK = join(REPO_ROOT, "scripts/release-version-check.sh");
+
+// Single source of truth for the assets a release must ship.
+const BINARY_ASSETS = [
+ "jaiph-darwin-arm64",
+ "jaiph-darwin-x64",
+ "jaiph-linux-x64",
+ "jaiph-linux-arm64",
+ "jaiph-windows-x64.exe",
+];
+// Names the bash installer (and its e2e test) can construct from {os}×{arch}.
+const INSTALLER_ASSETS = [
+ "jaiph-darwin-arm64",
+ "jaiph-darwin-x64",
+ "jaiph-linux-x64",
+ "jaiph-linux-arm64",
+];
+
+// Slice a workflow's job/step body out of the YAML by a stable anchor so
+// per-section assertions don't accidentally match text from another job.
+function sliceBetween(text: string, start: string, end: string | null): string {
+ const from = text.indexOf(start);
+ assert.notEqual(from, -1, `expected to find "${start}" in workflow`);
+ const to = end === null ? text.length : text.indexOf(end, from + start.length);
+ assert.notEqual(to, -1, `expected to find "${end}" after "${start}"`);
+ return text.slice(from, to === text.length ? text.length : to);
+}
+
+// ── Acceptance 1: five binaries + SHA256SUMS are built and uploaded ───────────
+
+test("release matrix cross-compiles the windows-x64 target (x64 only)", () => {
+ assert.match(RELEASE_YML, /target:\s*bun-windows-x64/, "windows-x64 target present");
+ const winEntry = sliceBetween(RELEASE_YML, "target: bun-windows-x64", "steps:");
+ assert.match(winEntry, /os:\s*windows/);
+ assert.match(winEntry, /arch:\s*x64/);
+ assert.match(winEntry, /ext:\s*"\.exe"/);
+ // Bun has no windows arm64 target — do not add one.
+ assert.doesNotMatch(RELEASE_YML, /target:\s*bun-windows-arm64/, "no windows arm64 target");
+ // The four original targets are still built.
+ for (const target of ["bun-darwin-arm64", "bun-darwin-x64", "bun-linux-x64", "bun-linux-arm64"]) {
+ assert.match(RELEASE_YML, new RegExp(`target:\\s*${target}\\b`), `${target} still built`);
+ }
+});
+
+test("SHA256SUMS generation covers all five binaries including the .exe", () => {
+ const shaLine = RELEASE_YML.split("\n").find((l) => l.includes("sha256sum ") && l.includes("SHA256SUMS"));
+ assert.ok(shaLine, "found the sha256sum generation line");
+ for (const asset of BINARY_ASSETS) {
+ assert.ok(shaLine!.includes(asset), `SHA256SUMS covers ${asset}`);
+ }
+});
+
+test("both stable and nightly release uploads include the .exe and SHA256SUMS", () => {
+ const stable = sliceBetween(RELEASE_YML, "Publish stable release", "Publish nightly prerelease");
+ const nightly = sliceBetween(RELEASE_YML, "Publish nightly prerelease", null);
+ for (const section of [stable, nightly]) {
+ for (const asset of [...BINARY_ASSETS, "SHA256SUMS"]) {
+ assert.ok(section.includes(asset), `upload list includes ${asset}`);
+ }
+ }
+});
+
+test("a SHA256SUMS entry for the .exe verifies against the asset via the installer's lookup", () => {
+ // Mirror the release: hash a windows binary, write the SHA256SUMS line, then
+ // resolve it back with the exact awk lookup docs/install uses. A mismatch
+ // between "generation" and "verification" would fail here.
+ const dir = mkdtempSync(join(tmpdir(), "jaiph-sha-"));
+ try {
+ const asset = "jaiph-windows-x64.exe";
+ const binPath = join(dir, asset);
+ const bytes = randomBytes(4096);
+ writeFileSync(binPath, bytes);
+ const expected = createHash("sha256").update(bytes).digest("hex");
+ const sumsPath = join(dir, "SHA256SUMS");
+ writeFileSync(sumsPath, `${expected} ${asset}\n`);
+
+ // The installer resolves a checksum with this awk expression.
+ assert.match(INSTALLER, /awk -v name="\$\{BIN_NAME\}" '\$2 == name \|\| \$2 == "\*"name \{ print \$1 \}'/);
+ const looked = spawnSync(
+ "awk",
+ ["-v", `name=${asset}`, '$2 == name || $2 == "*"name { print $1 }', sumsPath],
+ { encoding: "utf8" },
+ );
+ assert.equal(looked.status, 0, looked.stderr);
+ assert.equal(looked.stdout.trim(), expected, "installer lookup returns the asset's checksum");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// ── Acceptance 2: the version sanity gate fails on a tag/version mismatch ──────
+
+function runVersionCheck(channel: string, tag: string, got: string) {
+ return spawnSync("bash", [VERSION_CHECK, channel, tag, got], { encoding: "utf8" });
+}
+
+test("version sanity gate fails when a stable --version mismatches the tag", () => {
+ const bad = runVersionCheck("stable", "v9.9.9", "jaiph 1.2.3");
+ assert.equal(bad.status, 1, "mismatch exits non-zero");
+ assert.match(bad.stderr, /Version sanity check failed/);
+
+ const good = runVersionCheck("stable", "v1.2.3", "jaiph 1.2.3");
+ assert.equal(good.status, 0, good.stderr);
+});
+
+test("version sanity gate only requires a version-shaped banner for nightly", () => {
+ const good = runVersionCheck("nightly", "nightly", "jaiph 0.10.0");
+ assert.equal(good.status, 0, good.stderr);
+
+ const bad = runVersionCheck("nightly", "nightly", "not-a-version");
+ assert.equal(bad.status, 1, "garbage banner fails even on nightly");
+ assert.match(bad.stderr, /Version sanity check failed/);
+});
+
+test("a windows-latest job runs the .exe --version through the shared gate and blocks publish", () => {
+ const job = sliceBetween(RELEASE_YML, "sanity-windows:", "\n release:");
+ assert.match(job, /runs-on:\s*windows-latest/);
+ assert.match(job, /jaiph-windows-x64\.exe --version/);
+ assert.match(job, /release-version-check\.sh/, "windows gate delegates to the shared script");
+ // The linux gate uses the same shared script (no duplicated comparison logic).
+ const linux = sliceBetween(RELEASE_YML, "Sanity gate (linux-x64 --version)", "Publish stable release");
+ assert.match(linux, /release-version-check\.sh/);
+ // A windows gate failure must fail the release: publish depends on it.
+ assert.match(RELEASE_YML, /needs:\s*\[build, sanity-windows\]/, "release job needs sanity-windows");
+});
+
+// ── Acceptance 3: contract ↔ matrix ↔ installer parity ────────────────────────
+
+function contractAssets(): string[] {
+ const section = sliceBetween(CONTRIBUTING, "#### Release asset naming contract", "### Local docs site");
+ const names = new Set();
+ for (const m of section.matchAll(/`(jaiph-[A-Za-z0-9.\-]+|SHA256SUMS)`/g)) {
+ names.add(m[1]);
+ }
+ return [...names];
+}
+
+test("the naming contract lists exactly the five binaries plus SHA256SUMS", () => {
+ const listed = contractAssets().sort();
+ const expected = [...BINARY_ASSETS, "SHA256SUMS"].sort();
+ assert.deepEqual(listed, expected, "contract asset set matches the release");
+ // The prose count stays in sync with the table.
+ assert.match(CONTRIBUTING, /exactly these six assets/);
+});
+
+test("release matrix builds exactly the binaries named in the contract", () => {
+ // Every contract binary maps to a matrix target of the same os/arch, and the
+ // matrix builds nothing the contract omits.
+ const matrixTargets = [...RELEASE_YML.matchAll(/target:\s*(bun-[a-z0-9-]+)/g)].map((m) => m[1]);
+ const built = matrixTargets
+ .map((t) => t.replace(/^bun-/, "jaiph-"))
+ .map((n) => (n === "jaiph-windows-x64" ? "jaiph-windows-x64.exe" : n))
+ .sort();
+ assert.deepEqual(built, [...BINARY_ASSETS].sort(), "matrix binaries == contract binaries");
+});
+
+test("installer and its e2e test can only produce asset names the contract lists", () => {
+ // Installer + e2e test construct names from {os}×{arch}; pin the construction
+ // so a rename in the contract that isn't mirrored here fails the parity check.
+ assert.match(INSTALLER, /BIN_NAME="jaiph-\$\{os\}-\$\{arch\}"/);
+ assert.match(INSTALLER_TEST, /HOST_BIN_NAME="jaiph-\$\{HOST_OS\}-\$\{HOST_ARCH\}"/);
+ const listed = new Set(contractAssets());
+ for (const asset of INSTALLER_ASSETS) {
+ assert.ok(listed.has(asset), `contract lists installer asset ${asset}`);
+ // Both bash sources still support the os/arch that produces this name.
+ const [, os, arch] = asset.split("-");
+ for (const src of [INSTALLER, INSTALLER_TEST]) {
+ assert.ok(src.includes(os), `bash source supports os ${os}`);
+ assert.ok(src.includes(arch), `bash source supports arch ${arch}`);
+ }
+ }
+ // The .exe is release-only; the bash installer does not download it.
+ assert.ok(listed.has("jaiph-windows-x64.exe"));
+});
diff --git a/scripts/release-version-check.sh b/scripts/release-version-check.sh
new file mode 100755
index 00000000..0cd0c5c2
--- /dev/null
+++ b/scripts/release-version-check.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+#
+# Sanity-check a compiled jaiph binary's `--version` output against the release
+# channel/tag. Shared by the linux-x64 and windows-x64 gates in
+# `.github/workflows/release.yml` so the comparison lives in one place and is
+# unit-testable (see integration/release-workflow.test.ts).
+#
+# Usage: release-version-check.sh
+# channel: "stable" | "nightly"
+# tag: release tag (e.g. "v0.10.0" for stable, "nightly" otherwise)
+# version-output: the string printed by ` --version`
+#
+# For a stable release the output must equal "jaiph " exactly.
+# For any other channel it only has to look like a jaiph version banner.
+set -euo pipefail
+
+channel="${1:?channel required}"
+tag="${2:?tag required}"
+got="${3?version output required}"
+
+if [ "${channel}" = "stable" ]; then
+ expected="jaiph ${tag#v}"
+ if [ "${got}" != "${expected}" ]; then
+ echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2
+ exit 1
+ fi
+else
+ if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then
+ echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2
+ exit 1
+ fi
+fi
+
+echo "Version sanity check passed: ${got}"
From dca4ea52c01a93111ac9ed590285b66d2255773f Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 14:44:42 +0200
Subject: [PATCH 6/9] Feat: PowerShell installer at jaiph.org/install.ps1
Add docs/install.ps1 as the Windows counterpart to the POSIX docs/install
(run with irm https://jaiph.org/install.ps1 | iex). It downloads
jaiph-windows-x64.exe and SHA256SUMS from the pinned release ref (default
the current stable tag, overridable via JAIPH_REPO_REF / first arg plus
JAIPH_RELEASE_BASE_URL for local file:// dirs used by tests), verifies the
SHA-256 with Get-FileHash and installs nothing on mismatch, then installs
to %LOCALAPPDATA%\jaiph\bin\jaiph.exe (overridable via JAIPH_BIN_DIR), adds
it to the user PATH if absent, and prints the same try-it hints as the bash
installer. Non-x64 / ARM Windows exits non-zero with a documented
unsupported-platform message.
The bash installer now rejects Windows-like uname values and points at the
PowerShell one-liner, and prepare_release.jh rewrites the pinned ref in both
installers in lockstep. CI gains an installer-powershell job on
windows-latest that cross-compiles the real .exe and runs
e2e/tests/installer_powershell.ps1 (checksum mismatch, unsupported arch,
happy-path install with no Node/npm/Bun on PATH); the Docker publish job now
needs it. Host-portable guards live in integration/installer-powershell.test.ts.
Docs updated (setup.md, index.html, architecture.md, contributing.md, README).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/ci.yml | 39 ++++++-
.jaiph/prepare_release.jh | 26 +++--
CHANGELOG.md | 1 +
QUEUE.md | 18 ---
README.md | 6 +
docs/architecture.md | 2 +-
docs/contributing.md | 5 +-
docs/index.html | 4 +
docs/install | 7 ++
docs/install.ps1 | 136 ++++++++++++++++++++++
docs/setup.md | 9 ++
e2e/tests/installer_powershell.ps1 | 141 +++++++++++++++++++++++
integration/installer-powershell.test.ts | 111 ++++++++++++++++++
13 files changed, 472 insertions(+), 33 deletions(-)
create mode 100644 docs/install.ps1
create mode 100644 e2e/tests/installer_powershell.ps1
create mode 100644 integration/installer-powershell.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c911083d..dfa0a737 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -275,9 +275,46 @@ jobs:
$bashScript = $bashScript -replace "`r", ""
wsl -d "$distro" -- bash -lc "$bashScript"
+ installer-powershell:
+ name: PowerShell installer (windows-x64)
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build TypeScript + embed assets
+ run: npm run build
+
+ # Same build as release.yml's windows-x64 leg, so the acceptance runs the
+ # real self-contained binary the release ships.
+ - name: Cross-compile windows-x64 standalone binary
+ shell: bash
+ run: |
+ set -euo pipefail
+ bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile jaiph-windows-x64.exe
+ ls -la jaiph-windows-x64.exe
+
+ - name: Run PowerShell installer acceptance (docs/install.ps1)
+ shell: pwsh
+ run: |
+ $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe"
+ ./e2e/tests/installer_powershell.ps1
+
docker-publish:
name: Publish Docker runtime image
- needs: [test, e2e, docs-local, e2e-wsl]
+ needs: [test, e2e, docs-local, e2e-wsl, installer-powershell]
if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh
index a110429c..a57c7089 100755
--- a/.jaiph/prepare_release.jh
+++ b/.jaiph/prepare_release.jh
@@ -58,18 +58,20 @@ script npm_version_no_tag = `npm version "$1" --no-git-tag-version --allow-same-
script update_install_release_ref = ```python3
import sys
old, new = sys.argv[1], sys.argv[2]
-path = "docs/install"
-with open(path, "r", encoding="utf-8") as f:
- src = f.read()
needle = f"v{old}"
-count = src.count(needle)
-if count == 0:
- sys.stderr.write(f"docs/install: hardcoded ref v{old} not found\n")
- sys.exit(1)
-new_src = src.replace(needle, f"v{new}")
-with open(path, "w", encoding="utf-8") as f:
- f.write(new_src)
-print(count)
+total = 0
+# Both installers pin the same hardcoded ref; keep them in lockstep.
+for path in ("docs/install", "docs/install.ps1"):
+ with open(path, "r", encoding="utf-8") as f:
+ src = f.read()
+ count = src.count(needle)
+ if count == 0:
+ sys.stderr.write(f"{path}: hardcoded ref v{old} not found\n")
+ sys.exit(1)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(src.replace(needle, f"v{new}"))
+ total += count
+print(total)
```
script run_npm_build = `npm run build >&2`
@@ -124,7 +126,7 @@ workflow default(arg) {
log """
prepare_release: staged release v${version}
- package.json + package-lock.json (npm version ${version})
- - docs/install (release ref v${old_version} -> v${version})
+ - docs/install + docs/install.ps1 (release ref v${old_version} -> v${version})
- docs/registry (regenerated)
- dist/ (rebuilt; jaiph --version == jaiph ${version})
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32dfc3b2..3a6a2f11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
- **Fix — Portability: single `resolveShell()` seam for inline shell lines and hooks:** Inline workflow shell lines (`executeShLine` in `src/runtime/kernel/node-workflow-runtime.ts`) and hook commands (`src/cli/run/hooks.ts`) used to run via a hardcoded `spawn("sh", ["-c", …])`. That works on POSIX but fails on `win32`, where there is no `sh` on the default `PATH`. A new **`resolveShell(): string`** in the portability module (`src/runtime/kernel/portability.ts`) is now the single seam both call sites go through. On POSIX it returns bare `sh`. On `win32` it locates Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root (`%ProgramFiles%`, `%ProgramFiles(x86)%`, `%ProgramW6432%`, plus the default `C:\Program Files` / `C:\Program Files (x86)`) — and throws a diagnosable Jaiph error with the stable code **`E_NO_POSIX_SHELL`** naming Git for Windows (https://git-scm.com/download/win) as the fix if none is found. Resolution is memoized for the lifetime of the process. Crucially, inline lines are **never** translated to `cmd`/PowerShell — Jaiph's language semantics require POSIX `sh` on every platform, or workflows stop being portable — so the seam only ever resolves *which* `sh` to run, never rewrites the command. POSIX inline-shell semantics are unchanged (existing e2e passes). New unit tests (`src/runtime/kernel/portability.test.ts`) stub `process.platform` and the `PATH` / existence lookup: POSIX returns `sh`, `win32` returns a discovered `sh.exe` path (both the on-`PATH` and Git-for-Windows-fallback branches), `win32` with no shell available throws `E_NO_POSIX_SHELL` with a message naming Git for Windows, and the result is memoized (the existence probe is not consulted on a second call); a `src/`-wide lint test asserts no production file outside `portability.ts` calls `spawn("sh"` / `spawn('sh'` directly. Docs updated (`docs/architecture.md`).
diff --git a/QUEUE.md b/QUEUE.md
index 6f022f58..5028cbc8 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Distro: PowerShell installer at `jaiph.org/install.ps1` #dev-ready
-
-The bash installer (`docs/install`) downloads a per-platform binary from the GitHub Release for a pinned ref and verifies it against `SHA256SUMS`. Windows users need a native equivalent — the bash script must keep rejecting Windows and point at the PowerShell one.
-
-Add `docs/install.ps1` (served as `https://jaiph.org/install.ps1`, `irm https://jaiph.org/install.ps1 | iex`):
-
-* Downloads `jaiph-windows-x64.exe` and `SHA256SUMS` from the pinned release ref (default: current stable tag; overridable via `JAIPH_REPO_REF` / first argument, mirroring the bash installer).
-* Verifies the SHA-256 (`Get-FileHash`) against `SHA256SUMS`; mismatch aborts and installs nothing.
-* Installs to `%LOCALAPPDATA%\jaiph\bin\jaiph.exe` (overridable via `JAIPH_BIN_DIR`), adds the directory to the user `PATH` if absent, and prints the same try-it hints as the bash installer.
-* Non-x64 / ARM Windows: exit with a documented unsupported-platform message.
-* Update `docs/install`'s unsupported-platform message to mention the PowerShell installer for Windows, and document the Windows install path in `docs/setup.md` and the main page install tabs.
-
-Acceptance:
-
-* Pester (or equivalent) tests run on `windows-latest` in CI, pointing the installer at a `file://`-style local release directory (same technique as `e2e/tests/07_installer_binary.sh`): happy path installs and `jaiph --version` works; checksum mismatch exits non-zero and leaves no binary; unsupported arch exits with the documented message.
-* The installed binary runs from a shell with no Node/npm/Bun on `PATH`.
-* `docs/setup.md` and the main page reference the PowerShell one-liner (docs parity check passes).
-
## Distro: main-page install tabs — Windows variant with platform auto-detect #dev-ready
The main page (`docs/index.html`) hero has three install tabs (run sample / init project / just install), all showing bash `curl … | bash` one-liners. Tab switching in `docs/assets/js/main.js` is click-only; there is no platform detection. Windows visitors currently see commands that cannot run natively.
diff --git a/README.md b/README.md
index 82fb3d97..bd6dbbe6 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,12 @@ Requires `node` and `curl`. The script installs Jaiph automatically if needed.
curl -fsSL https://jaiph.org/install | bash
```
+On Windows, install with PowerShell instead (installs `jaiph-windows-x64.exe` to `%LOCALAPPDATA%\jaiph\bin`):
+
+```powershell
+irm https://jaiph.org/install.ps1 | iex
+```
+
Or install from npm:
```bash
diff --git a/docs/architecture.md b/docs/architecture.md
index 4e8a7ce8..e9bd19e9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -176,7 +176,7 @@ The progress UI combines a **static** step tree derived from the workflow AST (`
- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `runtime/overlay-run.sh` and `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), and copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. The published `jaiph` bin is **`node dist/src/cli.js`**.
- **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `overlay-run.sh` and `docs/jaiph-skill.md` are also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)).
-- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the six assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract).
+- **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the six assets to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract). Two installers consume these assets: the POSIX **`docs/install`** (`curl … | bash`, darwin/linux; rejects Windows and points at the PowerShell one) and **`docs/install.ps1`** (`irm https://jaiph.org/install.ps1 | iex`, Windows x64), which downloads `jaiph-windows-x64.exe`, verifies it against `SHA256SUMS` with `Get-FileHash`, and installs to `%LOCALAPPDATA%\jaiph\bin`.
## Mermaid architecture diagram
diff --git a/docs/contributing.md b/docs/contributing.md
index 91e422fe..0c9751f2 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -171,6 +171,8 @@ Tests that span multiple modules, require subprocess/PTY harnesses, exercise pro
| `integration/docs-reference-task5.test.ts` | Integration | Reference quadrant — permalinks, nav placement, `env-vars.md` source parity against `src/`, anti-tutorial shape guards |
| `integration/docs-tutorials-task6.test.ts` | Integration | Tutorial quadrant — permalinks, `/getting-started` redirect absorption, runnable `first-workflow` snippet with documented output |
| `integration/docs-nav-structure-task7.test.ts` | Integration | Nav spine — five Diátaxis section headings in documented order; every published page under its quadrant exactly once |
+| `integration/release-workflow.test.ts` | Integration | Release matrix / asset-naming contract — five-binary matrix (no windows-arm64), `SHA256SUMS` + upload lists include `jaiph-windows-x64.exe`, shared version-gate script, naming contract ↔ matrix ↔ installer parity |
+| `integration/installer-powershell.test.ts` | Integration | Windows PowerShell installer (`docs/install.ps1`) contract — download/verify/install steps, bash↔PowerShell lockstep release ref, and `docs/setup.md` / main-page one-liner parity |
| `integration/sample-build/build.test.ts` | Integration | Build/transpile behavior — `buildScripts`, script extraction |
| `integration/sample-build/cli-tree.test.ts` | Integration | CLI tree output rendering for sample workflows |
| `integration/sample-build/run-core.test.ts` | Integration | Core runtime execution — workflow runs, step sequencing, artifacts |
@@ -187,7 +189,7 @@ The `integration/sample-build/` directory also has a shared `helpers.ts` module
## CI pipeline
-The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **six** jobs; on a typical feature-branch push, **five** of them run. The sixth — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, and WSL jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
+The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **seven** jobs; on a typical feature-branch push, **six** of them run. The seventh — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, and PowerShell-installer jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
| Job | Runner | Purpose |
|-----|--------|---------|
@@ -196,6 +198,7 @@ The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defin
| **E2E** | Matrix: **`ubuntu-latest` twice** + **`macos-latest`** | Job id `e2e`; in the Actions UI each leg appears as **`E2E (,
+
On Windows, install with PowerShell instead:
+
irm https://jaiph.org/install.ps1 | iex
+
This installs jaiph-windows-x64.exe to
+ %LOCALAPPDATA%\jaiph\bin and adds it to your user PATH.
Or install from npm: npm install -g jaiph
diff --git a/docs/install b/docs/install
index cd692382..716c178e 100755
--- a/docs/install
+++ b/docs/install
@@ -104,6 +104,13 @@ else
case "${uname_s}" in
Darwin) os="darwin" ;;
Linux) os="linux" ;;
+ MINGW*|MSYS*|CYGWIN*|Windows_NT)
+ print_error "Unsupported platform: ${uname_s} ${uname_m}"
+ echo "On Windows, install with PowerShell instead:"
+ echo " irm https://jaiph.org/install.ps1 | iex"
+ echo "Or build from source per https://jaiph.org/contributing#installing-from-source"
+ exit 1
+ ;;
*)
print_error "Unsupported platform: ${uname_s} ${uname_m}"
echo "Build from source per https://jaiph.org/contributing#installing-from-source"
diff --git a/docs/install.ps1 b/docs/install.ps1
new file mode 100644
index 00000000..b6899d4b
--- /dev/null
+++ b/docs/install.ps1
@@ -0,0 +1,136 @@
+#!/usr/bin/env pwsh
+#
+# Jaiph Windows installer — the native counterpart to docs/install (the POSIX
+# curl installer, which rejects Windows and points here). Downloads the pinned
+# release's jaiph-windows-x64.exe + SHA256SUMS, verifies the checksum, installs
+# to %LOCALAPPDATA%\jaiph\bin\jaiph.exe, and adds that dir to the user PATH.
+#
+# irm https://jaiph.org/install.ps1 | iex
+#
+# Overrides mirror docs/install (env, or first argument for the ref):
+# JAIPH_REPO_REF release ref (default: current stable tag)
+# JAIPH_BIN_DIR install dir (default: %LOCALAPPDATA%\jaiph\bin)
+# JAIPH_RELEASE_BASE_URL base URL for the release assets; a local directory
+# or file:// URL installs offline (used by the tests,
+# same technique as e2e/tests/07_installer_binary.sh).
+
+param([string]$RepoRef)
+
+$ErrorActionPreference = "Stop"
+
+# ── Output helpers (honour NO_COLOR, same as docs/install) ────────────────────
+$UseColor = -not (Test-Path Env:\NO_COLOR)
+function Write-Line { param([string]$Text, [string]$Color)
+ if ($UseColor -and $Color) { Write-Host $Text -ForegroundColor $Color } else { Write-Host $Text }
+}
+function Print-Step { param([string]$m) Write-Line "> $m" "DarkGray" }
+function Print-Success { param([string]$m) Write-Line "+ $m" "Green" }
+function Print-Warning { param([string]$m) Write-Line "! $m" "Yellow" }
+function Print-Error { param([string]$m) Write-Line "x $m" "Red" }
+
+# A local directory or file:// URL resolves to a filesystem path we copy from;
+# anything else is fetched over the network. Mirrors curl's native file:// support.
+function Resolve-LocalBase {
+ param([string]$BaseUrl)
+ if ($BaseUrl -like "file://*") { return ([uri]$BaseUrl).LocalPath }
+ if (Test-Path -LiteralPath $BaseUrl -PathType Container) { return (Resolve-Path -LiteralPath $BaseUrl).Path }
+ return $null
+}
+
+function Get-ReleaseAsset {
+ param([string]$BaseUrl, [string]$Name, [string]$OutFile)
+ $localBase = Resolve-LocalBase $BaseUrl
+ if ($localBase) {
+ $src = Join-Path $localBase $Name
+ if (-not (Test-Path -LiteralPath $src)) { throw "Failed to download $BaseUrl/$Name" }
+ Copy-Item -LiteralPath $src -Destination $OutFile -Force
+ } else {
+ Invoke-WebRequest -Uri "$BaseUrl/$Name" -OutFile $OutFile -UseBasicParsing
+ }
+}
+
+# Resolve the expected hash for $Name from a sha256sum-format SHA256SUMS file
+# (mirrors the installer's awk lookup: match the name, tolerate a `*` prefix).
+function Get-ExpectedSum {
+ param([string]$SumsFile, [string]$Name)
+ foreach ($line in Get-Content -LiteralPath $SumsFile) {
+ $parts = $line -split "\s+", 2
+ if ($parts.Count -lt 2) { continue }
+ $entry = $parts[1].Trim().TrimStart("*")
+ if ($entry -eq $Name) { return $parts[0].Trim() }
+ }
+ return $null
+}
+
+Write-Host ""
+Write-Line "Jaiph installer (Windows)" "White"
+Write-Host ""
+
+# ── Platform gate: Windows ships x64 only (Bun has no windows-arm64 target) ───
+$rawArch = if ($env:PROCESSOR_ARCHITECTURE) { $env:PROCESSOR_ARCHITECTURE } else { "" }
+if ($rawArch.ToUpper() -ne "AMD64" -and $rawArch.ToUpper() -ne "X64") {
+ Print-Error "Unsupported platform: windows $rawArch"
+ Write-Host "jaiph ships a Windows binary for x64 only."
+ Write-Host "Build from source per https://jaiph.org/contributing#installing-from-source"
+ exit 1
+}
+
+$RepoRef = if ($RepoRef) { $RepoRef } elseif ($env:JAIPH_REPO_REF) { $env:JAIPH_REPO_REF } else { "v0.10.0" }
+$BinName = "jaiph-windows-x64.exe"
+$BaseUrl = if ($env:JAIPH_RELEASE_BASE_URL) { $env:JAIPH_RELEASE_BASE_URL } else { "https://github.com/jaiphlang/jaiph/releases/download/$RepoRef" }
+$BinDir = if ($env:JAIPH_BIN_DIR) { $env:JAIPH_BIN_DIR } else { Join-Path $env:LOCALAPPDATA "jaiph\bin" }
+$Target = Join-Path $BinDir "jaiph.exe"
+
+$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("jaiph-install-" + [System.IO.Path]::GetRandomFileName())
+New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
+try {
+ Print-Step "Downloading $BinName ($RepoRef)..."
+ try { Get-ReleaseAsset $BaseUrl $BinName (Join-Path $tmpDir $BinName) }
+ catch { Print-Error "Failed to download $BaseUrl/$BinName"; exit 1 }
+
+ Print-Step "Downloading SHA256SUMS..."
+ try { Get-ReleaseAsset $BaseUrl "SHA256SUMS" (Join-Path $tmpDir "SHA256SUMS") }
+ catch { Print-Error "Failed to download $BaseUrl/SHA256SUMS"; exit 1 }
+
+ Print-Step "Verifying checksum..."
+ $expected = Get-ExpectedSum (Join-Path $tmpDir "SHA256SUMS") $BinName
+ if (-not $expected) { Print-Error "No checksum entry for $BinName in SHA256SUMS"; exit 1 }
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $tmpDir $BinName)).Hash
+ if ($actual -ine $expected) {
+ Print-Error "Checksum mismatch for $BinName"
+ Write-Host " expected: $expected"
+ Write-Host " got: $actual"
+ exit 1
+ }
+
+ Print-Step "Installing binary to $Target..."
+ New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
+ Copy-Item -LiteralPath (Join-Path $tmpDir $BinName) -Destination $Target -Force
+} finally {
+ Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+$version = try { & $Target --version } catch { "jaiph ($RepoRef)" }
+Write-Host ""
+Print-Success "Installed $version to $Target"
+
+# Add the install dir to the user PATH if it is not already there.
+$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+$segments = if ($userPath) { $userPath -split ";" | Where-Object { $_ -ne "" } } else { @() }
+if ($segments -contains $BinDir) {
+ Print-Success "$BinDir is already on PATH"
+ Write-Host ""
+ Write-Host "Try:"
+ Write-Host " jaiph --version"
+ Write-Host " jaiph --help"
+} else {
+ $newPath = if ($userPath) { "$userPath;$BinDir" } else { $BinDir }
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
+ $env:Path = "$env:Path;$BinDir"
+ Print-Warning "Added $BinDir to your user PATH"
+ Write-Host ""
+ Write-Host "Open a new terminal, then try:"
+ Write-Host " jaiph --version"
+ Write-Host " jaiph --help"
+}
+Write-Host ""
diff --git a/docs/setup.md b/docs/setup.md
index e64585bc..55929f2b 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -17,6 +17,7 @@ The curl installer downloads a per-platform standalone binary from the current s
- A POSIX `sh` (the runtime uses `sh -c` for inline shell lines inside workflows). Each emitted `script` step runs under the interpreter named by its shebang (`bash` by default), so that interpreter must be on `PATH`; the runtime spawns it explicitly and does not rely on the file's exec bit, so scripts also work under `noexec` mounts.
- For the curl installer (step 1): `curl` and either `shasum` or `sha256sum` on `PATH`.
+- For the PowerShell installer (step 1, Windows): PowerShell (`irm`/`Invoke-WebRequest` and `Get-FileHash` are built in).
- For the npm alternative (step 1): Node.js and npm on the host.
## 1. Install the binary
@@ -29,6 +30,14 @@ curl -fsSL https://jaiph.org/install | bash
This downloads `jaiph-{darwin|linux}-{arm64|x64}` and `SHA256SUMS` from the current stable Release, verifies the checksum, and installs the binary to `~/.local/bin/jaiph`. Override the install location with `JAIPH_BIN_DIR`.
+**Windows (PowerShell):** the curl installer rejects Windows and points you here. Use the PowerShell one-liner instead:
+
+```powershell
+irm https://jaiph.org/install.ps1 | iex
+```
+
+This downloads `jaiph-windows-x64.exe` and `SHA256SUMS` from the current stable Release, verifies the checksum with `Get-FileHash`, installs the binary to `%LOCALAPPDATA%\jaiph\bin\jaiph.exe`, and adds that directory to your user `PATH` (open a new terminal to pick it up). Override the ref with `JAIPH_REPO_REF` (or the first argument) and the install location with `JAIPH_BIN_DIR`. Windows ships an x64 binary only — Bun has no Windows arm64 target, so ARM Windows exits with an unsupported-platform message.
+
(Alternative) Install via npm when you already have Node on the host and want package-manager-tracked installs:
```bash
diff --git a/e2e/tests/installer_powershell.ps1 b/e2e/tests/installer_powershell.ps1
new file mode 100644
index 00000000..be3d6fef
--- /dev/null
+++ b/e2e/tests/installer_powershell.ps1
@@ -0,0 +1,141 @@
+#!/usr/bin/env pwsh
+#
+# Acceptance for docs/install.ps1 (the Windows PowerShell installer), the native
+# counterpart to e2e/tests/07_installer_binary.sh. Network-free: it points the
+# installer at a local release directory via JAIPH_RELEASE_BASE_URL and shims the
+# architecture via PROCESSOR_ARCHITECTURE (same technique as the bash test's
+# file:// base URL and fake `uname`). It covers each acceptance bullet with a
+# check that fails when the contract is violated:
+# - checksum mismatch -> non-zero exit, nothing installed
+# - unsupported arch -> non-zero exit with the documented message
+# - happy path -> installs and `jaiph --version` works with no
+# Node/npm/Bun on PATH (self-contained binary)
+#
+# The happy-path case needs a real jaiph-windows-x64.exe; set
+# JAIPH_TEST_WINDOWS_EXE to a prebuilt binary to run it. Without it that case is
+# skipped, mirroring the bun-gated parity section of the bash test.
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
+$InstallScript = Join-Path $RepoRoot "docs\install.ps1"
+
+$script:Failures = 0
+function Report-Pass { param([string]$m) Write-Host "PASS: $m" -ForegroundColor Green }
+function Report-Fail { param([string]$m) Write-Host "FAIL: $m" -ForegroundColor Red; $script:Failures++ }
+function Assert-Equal {
+ param($Actual, $Expected, [string]$Message)
+ if ($Actual -eq $Expected) { Report-Pass $Message }
+ else { Report-Fail "$Message (expected '$Expected', got '$Actual')" }
+}
+function Assert-Contains {
+ param([string]$Haystack, [string]$Needle, [string]$Message)
+ if ($Haystack -like "*$Needle*") { Report-Pass $Message }
+ else { Report-Fail "$Message (missing '$Needle')`n---`n$Haystack`n---" }
+}
+
+# Run docs/install.ps1 as a child process so its `exit` does not stop this
+# runner. Env overrides are inherited from the current process.
+function Invoke-Installer {
+ $out = & pwsh -NoProfile -ExecutionPolicy Bypass -File $InstallScript 2>&1 | Out-String
+ return [pscustomobject]@{ Code = $LASTEXITCODE; Out = $out }
+}
+
+$OrigArch = $env:PROCESSOR_ARCHITECTURE
+$Work = Join-Path ([System.IO.Path]::GetTempPath()) ("jaiph-ps-test-" + [System.IO.Path]::GetRandomFileName())
+New-Item -ItemType Directory -Path $Work -Force | Out-Null
+
+try {
+ $BinName = "jaiph-windows-x64.exe"
+
+ # ── Checksum mismatch ───────────────────────────────────────────────────────
+ Write-Host "`n== Checksum mismatch fails and installs nothing =="
+ $relBad = Join-Path $Work "release-mismatch"
+ $binBad = Join-Path $Work "bin-mismatch"
+ New-Item -ItemType Directory -Path $relBad, $binBad -Force | Out-Null
+ Set-Content -Path (Join-Path $relBad $BinName) -Value "real-binary-bytes" -NoNewline
+ # Wrong hash so the installer reaches verify and fails (not a download error).
+ Set-Content -Path (Join-Path $relBad "SHA256SUMS") `
+ -Value ("0000000000000000000000000000000000000000000000000000000000000000 $BinName")
+
+ $env:PROCESSOR_ARCHITECTURE = "AMD64"
+ $env:JAIPH_RELEASE_BASE_URL = $relBad
+ $env:JAIPH_BIN_DIR = $binBad
+ $bad = Invoke-Installer
+ Assert-Equal $bad.Code 1 "checksum mismatch exits non-zero"
+ Assert-Contains $bad.Out "Checksum mismatch" "checksum mismatch is reported"
+ if (Test-Path (Join-Path $binBad "jaiph.exe")) {
+ Report-Fail "installer left a binary on checksum failure"
+ } else {
+ Report-Pass "checksum mismatch leaves no binary"
+ }
+
+ # ── Unsupported arch ────────────────────────────────────────────────────────
+ Write-Host "`n== Unsupported arch exits with the documented message =="
+ $binArm = Join-Path $Work "bin-arm"
+ New-Item -ItemType Directory -Path $binArm -Force | Out-Null
+ $env:PROCESSOR_ARCHITECTURE = "ARM64"
+ $env:JAIPH_BIN_DIR = $binArm
+ $arm = Invoke-Installer
+ Assert-Equal $arm.Code 1 "unsupported arch exits non-zero"
+ Assert-Contains $arm.Out "Unsupported platform: windows ARM64" "error names the detected arch"
+ Assert-Contains $arm.Out "contributing" "error points at the from-source instructions"
+ if (Test-Path (Join-Path $binArm "jaiph.exe")) {
+ Report-Fail "installer left a binary on unsupported arch"
+ } else {
+ Report-Pass "unsupported arch leaves no binary"
+ }
+ $env:PROCESSOR_ARCHITECTURE = "AMD64"
+
+ # ── Happy path (needs a real windows binary) ────────────────────────────────
+ Write-Host "`n== Happy path installs and --version works with no Node/npm/Bun =="
+ $exe = $env:JAIPH_TEST_WINDOWS_EXE
+ if (-not $exe -or -not (Test-Path $exe)) {
+ Write-Host "SKIP: JAIPH_TEST_WINDOWS_EXE not set — skipping happy-path install"
+ } else {
+ $relOk = Join-Path $Work "release-ok"
+ $binOk = Join-Path $Work "bin-ok"
+ New-Item -ItemType Directory -Path $relOk, $binOk -Force | Out-Null
+ Copy-Item -LiteralPath $exe -Destination (Join-Path $relOk $BinName) -Force
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $relOk $BinName)).Hash.ToLower()
+ Set-Content -Path (Join-Path $relOk "SHA256SUMS") -Value "$hash $BinName"
+
+ $env:JAIPH_RELEASE_BASE_URL = $relOk
+ $env:JAIPH_BIN_DIR = $binOk
+ $ok = Invoke-Installer
+ Assert-Equal $ok.Code 0 "happy path exits zero"
+
+ $Target = Join-Path $binOk "jaiph.exe"
+ if (Test-Path $Target) { Report-Pass "installed $Target" } else { Report-Fail "no binary installed at $Target" }
+ Assert-Contains $ok.Out "Added $binOk to your user PATH" "adds the install dir to PATH"
+
+ # Run the installed binary with only the Windows system dirs on PATH: it must
+ # work with no Node/npm/Bun visible (self-contained, like the bash parity check).
+ # (`$env:SystemRoot` is Windows-only; off-Windows local runs skip the PATH
+ # strip and just confirm the binary runs, since the separator/system dirs differ.)
+ $cleanPath = if ($env:SystemRoot) { "$env:SystemRoot\System32;$env:SystemRoot" } else { $env:Path }
+ if ($env:SystemRoot) {
+ foreach ($tool in "node", "npm", "bun") {
+ $visible = & pwsh -NoProfile -Command "`$env:Path = '$cleanPath'; [bool](Get-Command $tool -ErrorAction SilentlyContinue)"
+ if ($visible -eq "True") { Report-Fail "$tool unexpectedly visible on stripped PATH" }
+ }
+ }
+ $verOut = (& pwsh -NoProfile -Command "`$env:Path = '$cleanPath'; & '$Target' --version" | Out-String).Trim()
+ $pkgVersion = (Get-Content (Join-Path $RepoRoot "package.json") -Raw | ConvertFrom-Json).version
+ Assert-Equal $verOut "jaiph $pkgVersion" "jaiph --version works without Node/npm/Bun on PATH"
+ }
+} finally {
+ $env:PROCESSOR_ARCHITECTURE = $OrigArch
+ Remove-Item Env:\JAIPH_RELEASE_BASE_URL -ErrorAction SilentlyContinue
+ Remove-Item Env:\JAIPH_BIN_DIR -ErrorAction SilentlyContinue
+ Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Host ""
+if ($script:Failures -gt 0) {
+ Write-Host "$($script:Failures) check(s) failed" -ForegroundColor Red
+ exit 1
+}
+Write-Host "All PowerShell installer checks passed" -ForegroundColor Green
+exit 0
diff --git a/integration/installer-powershell.test.ts b/integration/installer-powershell.test.ts
new file mode 100644
index 00000000..56fcace1
--- /dev/null
+++ b/integration/installer-powershell.test.ts
@@ -0,0 +1,111 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+
+// Acceptance for "Distro: PowerShell installer at jaiph.org/install.ps1".
+//
+// The Pester-equivalent runtime behaviour (happy path / checksum mismatch /
+// unsupported arch) is exercised on windows-latest by
+// e2e/tests/installer_powershell.ps1. These host-portable guards cover the
+// contract that must hold everywhere and fail when it is violated:
+// 1. docs/install.ps1 exists and implements the download → verify → install
+// contract with the documented overrides and unsupported-arch message.
+// 2. The bash installer keeps rejecting Windows and points at the PowerShell
+// one; both installers pin the same release ref (prepare_release keeps
+// them in lockstep).
+// 3. docs/setup.md and the main page reference the PowerShell one-liner
+// (docs parity).
+
+const REPO_ROOT = process.cwd();
+const read = (p: string) => readFileSync(join(REPO_ROOT, p), "utf8");
+
+const PS_INSTALLER = read("docs/install.ps1");
+const BASH_INSTALLER = read("docs/install");
+const SETUP = read("docs/setup.md");
+const INDEX = read("docs/index.html");
+const PREPARE_RELEASE = read(".jaiph/prepare_release.jh");
+
+const ONE_LINER = "irm https://jaiph.org/install.ps1 | iex";
+
+// ── Acceptance 1: install.ps1 implements the contract ─────────────────────────
+
+test("docs/install.ps1 exists", () => {
+ assert.ok(existsSync(join(REPO_ROOT, "docs/install.ps1")), "docs/install.ps1 present");
+});
+
+test("install.ps1 downloads the windows-x64 asset and SHA256SUMS", () => {
+ assert.match(PS_INSTALLER, /jaiph-windows-x64\.exe/, "downloads the .exe asset");
+ assert.match(PS_INSTALLER, /SHA256SUMS/, "downloads SHA256SUMS");
+});
+
+test("install.ps1 verifies the checksum with Get-FileHash and aborts on mismatch", () => {
+ assert.match(PS_INSTALLER, /Get-FileHash\s+-Algorithm\s+SHA256/, "hashes with Get-FileHash SHA256");
+ assert.match(PS_INSTALLER, /Checksum mismatch/, "reports a checksum mismatch");
+ // The install copy must come after the verify (nothing installed on mismatch):
+ // the mismatch branch exits before the "Installing binary" step.
+ const mismatchIdx = PS_INSTALLER.indexOf("Checksum mismatch");
+ const installIdx = PS_INSTALLER.indexOf("Installing binary to");
+ assert.ok(mismatchIdx !== -1 && installIdx !== -1 && mismatchIdx < installIdx, "verify precedes install");
+});
+
+test("install.ps1 installs to %LOCALAPPDATA%\\jaiph\\bin\\jaiph.exe, overridable via JAIPH_BIN_DIR", () => {
+ assert.match(PS_INSTALLER, /LOCALAPPDATA/, "defaults under LOCALAPPDATA");
+ assert.match(PS_INSTALLER, /jaiph\\bin/, "installs into jaiph\\bin");
+ assert.match(PS_INSTALLER, /jaiph\.exe/, "target is jaiph.exe");
+ assert.match(PS_INSTALLER, /env:JAIPH_BIN_DIR/, "JAIPH_BIN_DIR override honoured");
+});
+
+test("install.ps1 supports JAIPH_REPO_REF / first-arg override and a base-url override", () => {
+ assert.match(PS_INSTALLER, /param\(\[string\]\$RepoRef\)/, "accepts a ref as the first argument");
+ assert.match(PS_INSTALLER, /env:JAIPH_REPO_REF/, "JAIPH_REPO_REF override honoured");
+ assert.match(PS_INSTALLER, /env:JAIPH_RELEASE_BASE_URL/, "release base URL is overridable (for tests)");
+});
+
+test("install.ps1 adds the install dir to the user PATH and prints the try-it hints", () => {
+ assert.match(PS_INSTALLER, /SetEnvironmentVariable\("Path",\s*\$newPath,\s*"User"\)/, "updates the user PATH");
+ assert.match(PS_INSTALLER, /jaiph --version/, "prints try-it hint: --version");
+ assert.match(PS_INSTALLER, /jaiph --help/, "prints try-it hint: --help");
+});
+
+test("install.ps1 rejects non-x64 Windows with a documented unsupported message", () => {
+ assert.match(PS_INSTALLER, /PROCESSOR_ARCHITECTURE/, "detects the arch");
+ assert.match(PS_INSTALLER, /Unsupported platform: windows/, "documented unsupported-platform message");
+ assert.match(PS_INSTALLER, /contributing#installing-from-source/, "points at the from-source instructions");
+ // x64 is accepted; there is no windows-arm64 asset to install.
+ assert.match(PS_INSTALLER, /"AMD64"|"X64"/, "x64 is the supported arch");
+});
+
+// ── Acceptance 2: bash installer rejects Windows and refs stay in lockstep ────
+
+test("bash installer rejects Windows and points at the PowerShell installer", () => {
+ assert.match(BASH_INSTALLER, /MINGW\*\|MSYS\*\|CYGWIN\*\|Windows_NT/, "detects Windows-like uname");
+ assert.match(BASH_INSTALLER, /irm https:\/\/jaiph\.org\/install\.ps1 \| iex/, "points at the PowerShell one-liner");
+ // The generic unsupported message (AIX etc.) is preserved for the e2e test.
+ assert.match(BASH_INSTALLER, /Unsupported platform: \$\{uname_s\} \$\{uname_m\}/);
+});
+
+test("both installers pin the same release ref", () => {
+ const bashRef = BASH_INSTALLER.match(/JAIPH_REPO_REF:-(v\d+\.\d+\.\d+)/);
+ const psRef = PS_INSTALLER.match(/else\s*\{\s*"(v\d+\.\d+\.\d+)"\s*\}/);
+ assert.ok(bashRef, "bash installer pins a vX.Y.Z ref");
+ assert.ok(psRef, "PowerShell installer pins a vX.Y.Z ref");
+ assert.equal(psRef![1], bashRef![1], "PowerShell ref matches the bash ref");
+});
+
+test("prepare_release refreshes both installers' pinned ref in lockstep", () => {
+ // The release-prep workflow must rewrite the ref in both files, or the
+ // PowerShell installer would drift to a stale release on the next bump.
+ assert.match(PREPARE_RELEASE, /"docs\/install",\s*"docs\/install\.ps1"/, "ref refresh covers both files");
+});
+
+// ── Acceptance 3: docs reference the PowerShell one-liner ─────────────────────
+
+test("docs/setup.md references the PowerShell one-liner and Windows install path", () => {
+ assert.ok(SETUP.includes(ONE_LINER), "setup.md includes the irm | iex one-liner");
+ assert.match(SETUP, /%LOCALAPPDATA%\\jaiph\\bin/, "setup.md documents the Windows install path");
+});
+
+test("the main page references the PowerShell one-liner", () => {
+ assert.ok(INDEX.includes(ONE_LINER), "index.html includes the irm | iex one-liner");
+});
From d75834468a08966be7d61b0fad79cda35bb4a754 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 15:01:54 +0200
Subject: [PATCH 7/9] Feat: main-page install tabs Windows variant w/
auto-detect
The landing page hero install card now offers a Windows PowerShell path
alongside the POSIX curl | bash one-liners. A new .os-switch sub-toggle
(macOS/Linux vs Windows) sits above the run-sample / init-project /
just-install tabs, and each panel wraps its content in .os-variant blocks.
On load, attachOsSwitch() (docs/assets/js/main.js) auto-selects the
Windows variant for Windows visitors via isWindowsPlatform(), which
prefers navigator.userAgentData?.platform and falls back to
navigator.platform. macOS/Linux visitors keep today's POSIX default with
no layout shift; manual switching stays available and is remembered
card-wide. The 'Just install' Windows variant exposes the copy-able
irm https://jaiph.org/install.ps1 | iex line; the run-sample and
init-project tabs (no PowerShell pipe equivalent) show the install
one-liner plus a short 'then run:' jaiph command instead of a bash-only
default. The static-render constraint holds: the POSIX variant carries
is-active in the markup and CSS hides inactive variants, so with JS
disabled all bash one-liners render and no panel is blank.
New Playwright cases in the docs suite assert the Windows default, the
unchanged macOS/Linux default, manual platform + tab switching, the
clipboard contents of the Windows copy button, and the JS-disabled
static render.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
QUEUE.md | 18 -----
docs/assets/css/style.css | 35 +++++++++
docs/assets/js/main.js | 47 ++++++++++++
docs/index.html | 78 +++++++++++++------
e2e/playwright/landing-page.spec.ts | 114 +++++++++++++++++++++++++++-
6 files changed, 251 insertions(+), 42 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a6a2f11..d231ab29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change.
- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
- **Fix — Portability: home-dir fallback, Docker gating on Windows, and a single ANSI policy:** Three remaining POSIX assumptions that broke a host-only `win32` runtime are closed. (1) **Home directory** — `prepareClaudeEnv` (`src/runtime/kernel/prompt.ts`) resolved the `.claude` config dir from `execEnv.HOME || process.env.HOME`, which is empty in a `USERPROFILE`-only Windows environment. It now falls back to `os.homedir()` as a final source (`execEnv.HOME || process.env.HOME || homedir()`); an explicit `HOME` in `execEnv` still wins. (2) **Docker gating** — the Docker sandbox hardcodes POSIX socket paths and Linux/macOS workspace-presentation branches, so it is out of scope on Windows. `resolveDockerConfig` (`src/runtime/docker.ts`) now forces **host-only mode** on `win32` (same UX as an explicit `JAIPH_UNSAFE=true`) with a one-line notice emitted once per process, so the CLI never probes `docker` and never hard-fails on a missing daemon; `JAIPH_DOCKER_ENABLED=true` cannot override this. (3) **ANSI colors** — a new **`canUseAnsi(stream?)`** helper in the portability module (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR`-unset, and every color/erase emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it so the policy lives in one place; no rendering change is needed because Node enables console VT processing on Windows 10+ automatically, making `isTTY` a sufficient ANSI proxy. New unit tests: `prepareClaudeEnv` resolves from a stubbed `os.homedir()` when `HOME` is unset and prefers `execEnv.HOME` when set (`src/runtime/kernel/prompt.test.ts`); `resolveDockerConfig` under a stubbed `win32` returns host-only, emits the notice once, and performs zero `docker` invocations (`src/runtime/docker.test.ts`); `canUseAnsi()` is false when `isTTY` is false or `NO_COLOR` is set, plus a `src/`-wide lint test asserting no production file outside `portability.ts` gates directly on `isTTY && NO_COLOR` (`src/runtime/kernel/portability.test.ts`). Docs updated (`docs/architecture.md`, `docs/sandboxing.md`, `docs/configuration.md`, `docs/env-vars.md`).
diff --git a/QUEUE.md b/QUEUE.md
index 5028cbc8..c1ad77a5 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,24 +14,6 @@ Process rules:
***
-## Distro: main-page install tabs — Windows variant with platform auto-detect #dev-ready
-
-The main page (`docs/index.html`) hero has three install tabs (run sample / init project / just install), all showing bash `curl … | bash` one-liners. Tab switching in `docs/assets/js/main.js` is click-only; there is no platform detection. Windows visitors currently see commands that cannot run natively.
-
-Given a published PowerShell installer at `https://jaiph.org/install.ps1` (`irm https://jaiph.org/install.ps1 | iex`):
-
-* Add a Windows variant to the install section: at minimum the "Just install" panel must offer the PowerShell one-liner alongside the curl one (separate tab, sub-toggle, or swapped command — implementer's choice, but the PowerShell command must be copy-able via the existing copy button).
-* Auto-detect the visitor's platform on load (`navigator.userAgentData?.platform` with `navigator.platform` fallback) and default to the Windows variant for Windows visitors; macOS/Linux visitors see exactly today's default. Manual switching always remains possible; no layout shift for non-Windows users.
-* Tabs whose commands have no PowerShell equivalent (run sample / init project) must not show a bash-only command as the Windows default — show the install one-liner plus a short "then run:" `jaiph` command instead, or an equivalent honest fallback.
-* Keep the static-render constraint: the page must remain correct with JS disabled (bash commands shown, Windows variant reachable via tab markup).
-
-Acceptance:
-
-* Playwright docs tests (same suite as the "Try it out" test) assert: with a Windows platform emulated, the page defaults to the PowerShell command; with macOS/Linux emulated, the default is unchanged from today; manual tab switching works in both.
-* The copy button on the Windows variant copies the exact `irm … | iex` line (asserted via clipboard stub).
-* With JS disabled, all bash commands render and no panel is blank.
-* Docs parity check (`.jaiph/docs_parity.jh`) passes with the new content.
-
## Distro: native Windows smoke job in CI #dev-ready
CI's only Windows coverage runs the e2e suite inside WSL (`e2e-wsl` in `.github/workflows/ci.yml`), which exercises the Linux binary. Developing Jaiph on Windows is out of scope — this job proves *running* Jaiph natively works.
diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css
index 2b3cc45b..2543e3e0 100644
--- a/docs/assets/css/style.css
+++ b/docs/assets/css/style.css
@@ -704,6 +704,41 @@ pre code .code-line::before {
display: block;
}
+.os-switch {
+ display: flex;
+ gap: 0.4rem;
+ margin-bottom: 0.75rem;
+}
+
+.os-switch-button {
+ border: 1px solid var(--tab-border);
+ border-radius: 8px;
+ background: var(--tab-bg);
+ color: var(--muted);
+ font-size: 0.8rem;
+ font-weight: 600;
+ font-family: "Fira Code", monospace;
+ padding: 0.3rem 0.7rem;
+ cursor: pointer;
+}
+
+.os-switch-button.is-active {
+ background: var(--tab-active-bg);
+ border-color: var(--tab-active-border);
+ color: var(--tab-active-color);
+}
+
+/* Platform variants: static markup shows the POSIX (bash) variant; JS reveals
+ the Windows variant on demand (and by default for Windows visitors). Without
+ JS the POSIX variant stays visible and the Windows one remains in the markup. */
+.os-variant {
+ display: none;
+}
+
+.os-variant.is-active {
+ display: block;
+}
+
.primitive-list {
margin: 0.5rem 0 0;
}
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index 6c9e91ef..8521742e 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -732,6 +732,51 @@
});
}
+ /**
+ * True when the visitor is on Windows. Prefers the modern
+ * navigator.userAgentData.platform, falling back to navigator.platform.
+ */
+ function isWindowsPlatform() {
+ var platform = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
+ return /win/i.test(platform);
+ }
+
+ /**
+ * Toggles the visible platform variant (posix / windows) across every
+ * panel in the install card, and reflects the choice on the switch buttons.
+ */
+ function setOsVariant(root, os) {
+ root.querySelectorAll(".os-switch-button").forEach(function (button) {
+ button.classList.toggle("is-active", button.getAttribute("data-os") === os);
+ });
+ root.querySelectorAll(".os-variant").forEach(function (variant) {
+ variant.classList.toggle("is-active", variant.getAttribute("data-os") === os);
+ });
+ }
+
+ /**
+ * Wires the platform sub-toggle in the install card and auto-selects the
+ * Windows variant for Windows visitors. Manual switching stays available;
+ * non-Windows visitors keep the static (POSIX) default with no layout shift.
+ */
+ function attachOsSwitch() {
+ var root = document.querySelector("section.try-it-out .card");
+ if (!root || root.querySelectorAll(".os-variant").length === 0) {
+ return;
+ }
+ root.querySelectorAll(".os-switch-button").forEach(function (button) {
+ button.addEventListener("click", function () {
+ var os = button.getAttribute("data-os");
+ if (os) {
+ setOsVariant(root, os);
+ }
+ });
+ });
+ if (isWindowsPlatform()) {
+ setOsVariant(root, "windows");
+ }
+ }
+
function attachCodeTabs() {
const buttons = document.querySelectorAll(".code-tab-button");
buttons.forEach(function (button) {
@@ -979,6 +1024,7 @@
highlightAll();
attachCopyButtons();
attachCodeTabs();
+ attachOsSwitch();
attachDocsNavToggle();
attachThemeToggle();
});
@@ -989,6 +1035,7 @@
highlightAll();
attachCopyButtons();
attachCodeTabs();
+ attachOsSwitch();
attachDocsNavToggle();
attachThemeToggle();
}
diff --git a/docs/index.html b/docs/index.html
index 565e3622..8af4a1dd 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -75,36 +75,68 @@
Try it out!
+
+
+
+
+
-
curl -fsSL https://jaiph.org/run | bash -s 'workflowdefault() {constresponse=prompt"Say: Hello, I am [model name]!"log response}'
-
Installs Jaiph v0.10.0 to ~/.local/bin (if not
- already
- installed), and runs the sample workflow with Cursor CLI agent backend (the default one).
- See more samples!
-
Jaiph is under heavy development. Core features and workflow syntax are
- stable since v0.8.0, but you may expect breaking changes before v1.0.0.
+
+
curl -fsSL https://jaiph.org/run | bash -s 'workflowdefault() {constresponse=prompt"Say: Hello, I am [model name]!"log response}'
+
Installs Jaiph v0.10.0 to ~/.local/bin (if not
+ already
+ installed), and runs the sample workflow with Cursor CLI agent backend (the default one).
+ See more samples!
+
Jaiph is under heavy development. Core features and workflow syntax are
+ stable since v0.8.0, but you may expect breaking changes before v1.0.0.
+
+
+
On Windows, install with PowerShell:
+
irm https://jaiph.org/install.ps1 | iex
+
then run a sample workflow:
+
jaiph run say_hello.jh Adam
+
Installs Jaiph v0.10.0 to %LOCALAPPDATA%\jaiph\bin.
+ Grab say_hello.jh from the samples below, then run it. See
+ more samples!
+
Jaiph is under heavy development. Core features and workflow syntax are
+ stable since v0.8.0, but you may expect breaking changes before v1.0.0.
+
-
Run the script below from the project directory:
-
curl -fsSL https://jaiph.org/init | bash
-
Installs Jaiph v0.10.0 to ~/.local/bin (if not
- already installed), and runs jaiph init to initialize the Jaiph workspace in the
- current directory.
+
+
Run the script below from the project directory:
+
curl -fsSL https://jaiph.org/init | bash
+
Installs Jaiph v0.10.0 to ~/.local/bin (if not
+ already installed), and runs jaiph init to initialize the Jaiph workspace in the
+ current directory.
+
+
+
On Windows, install with PowerShell:
+
irm https://jaiph.org/install.ps1 | iex
+
then initialize the Jaiph workspace in the current directory:
+
jaiph init
+
Installs Jaiph v0.10.0 to %LOCALAPPDATA%\jaiph\bin
+ (if not already installed).
+
-
curl -fsSL https://jaiph.org/install | bash
-
The installer will install the version 0.10.0 of Jaiph to
- ~/.local/bin. To switch versions, use jaiph use nightly
- or jaiph use <version> to switch.
-
-
On Windows, install with PowerShell instead:
-
irm https://jaiph.org/install.ps1 | iex
-
This installs jaiph-windows-x64.exe to
- %LOCALAPPDATA%\jaiph\bin and adds it to your user PATH.
-
Or install from npm: npm install -g jaiph
+
+
curl -fsSL https://jaiph.org/install | bash
+
The installer will install the version 0.10.0 of Jaiph to
+ ~/.local/bin. To switch versions, use jaiph use nightly
+ or jaiph use <version> to switch.
+
+
Or install from npm: npm install -g jaiph
+
+
+
irm https://jaiph.org/install.ps1 | iex
+
This installs jaiph-windows-x64.exe to
+ %LOCALAPPDATA%\jaiph\bin and adds it to your user PATH.
+
Or install from npm: npm install -g jaiph
+
diff --git a/e2e/playwright/landing-page.spec.ts b/e2e/playwright/landing-page.spec.ts
index 4802e2f6..afaf34cb 100644
--- a/e2e/playwright/landing-page.spec.ts
+++ b/e2e/playwright/landing-page.spec.ts
@@ -109,7 +109,11 @@ test.describe.serial('docs landing page', () => {
await page.goto('/');
- const codeEl = page.locator('section.try-it-out [data-panel="try-run-sample"] pre code');
+ // Scope to the active platform variant: the run-sample panel now holds a
+ // POSIX (bash) and a Windows variant. On CI (Linux) the POSIX one is active.
+ const codeEl = page.locator(
+ 'section.try-it-out [data-panel="try-run-sample"] .os-variant.is-active pre code',
+ );
await expect(codeEl).toBeVisible();
let script = (await codeEl.innerText()).trim();
script = script.replace(/https:\/\/jaiph\.org/g, LOCAL_DOCS_SITE);
@@ -146,6 +150,114 @@ test.describe.serial('docs landing page', () => {
});
});
+ test.describe('install tabs — platform variants', () => {
+ const PS_INSTALL = 'irm https://jaiph.org/install.ps1 | iex';
+
+ /** Override the platform Jaiph auto-detects, before any page script runs. */
+ async function emulatePlatform(page: import('@playwright/test').Page, platform: string) {
+ await page.addInitScript((p) => {
+ Object.defineProperty(navigator, 'platform', { configurable: true, get: () => p });
+ Object.defineProperty(navigator, 'userAgentData', {
+ configurable: true,
+ get: () => ({ platform: /win/i.test(p) ? 'Windows' : 'Linux' }),
+ });
+ }, platform);
+ }
+
+ const activeRunSampleVariant = (page: import('@playwright/test').Page) =>
+ page.locator('section.try-it-out [data-panel="try-run-sample"] .os-variant.is-active');
+
+ test('Windows visitor defaults to the PowerShell install command', async ({ page }) => {
+ await emulatePlatform(page, 'Win32');
+ await page.goto('/');
+
+ const active = activeRunSampleVariant(page);
+ await expect(active).toHaveAttribute('data-os', 'windows');
+ await expect(active.locator('pre code').first()).toHaveText(PS_INSTALL);
+ });
+
+ test('macOS/Linux visitor keeps the bash default unchanged', async ({ page }) => {
+ await emulatePlatform(page, 'MacIntel');
+ await page.goto('/');
+
+ const active = activeRunSampleVariant(page);
+ await expect(active).toHaveAttribute('data-os', 'posix');
+ await expect(active.locator('pre code')).toContainText('curl -fsSL https://jaiph.org/run | bash');
+ });
+
+ test('manual platform + tab switching works in both directions', async ({ page }) => {
+ await emulatePlatform(page, 'MacIntel');
+ await page.goto('/');
+
+ // Starts on POSIX; manual switch to Windows reveals the PowerShell command.
+ await expect(activeRunSampleVariant(page)).toHaveAttribute('data-os', 'posix');
+ await page.locator('section.try-it-out .os-switch-button[data-os="windows"]').click();
+ await expect(activeRunSampleVariant(page)).toHaveAttribute('data-os', 'windows');
+
+ // Tab switch keeps the chosen platform: the install tab shows PowerShell.
+ await page.locator('[data-target="try-install-only"]').click();
+ const installActive = page.locator(
+ 'section.try-it-out [data-panel="try-install-only"] .os-variant.is-active',
+ );
+ await expect(installActive).toHaveAttribute('data-os', 'windows');
+ await expect(installActive.locator('pre code').first()).toHaveText(PS_INSTALL);
+
+ // Switch back to POSIX; the install tab shows the bash one-liner.
+ await page.locator('section.try-it-out .os-switch-button[data-os="posix"]').click();
+ await expect(installActive).toHaveAttribute('data-os', 'posix');
+ await expect(installActive.locator('pre code').first()).toHaveText(
+ 'curl -fsSL https://jaiph.org/install | bash',
+ );
+ });
+
+ test('copy button on the Windows install variant copies the exact irm line', async ({ page }) => {
+ await page.addInitScript(() => {
+ (window as unknown as { __copied: string[] }).__copied = [];
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ writeText: (t: string) => {
+ (window as unknown as { __copied: string[] }).__copied.push(t);
+ return Promise.resolve();
+ },
+ },
+ });
+ });
+ await page.goto('/');
+
+ await page.locator('section.try-it-out .os-switch-button[data-os="windows"]').click();
+ await page.locator('[data-target="try-install-only"]').click();
+
+ const winInstall = page.locator(
+ 'section.try-it-out [data-panel="try-install-only"] .os-variant[data-os="windows"]',
+ );
+ await winInstall.locator('.copy-code-button').first().click();
+
+ const copied = await page.evaluate(() => (window as unknown as { __copied: string[] }).__copied);
+ expect(copied).toContain(PS_INSTALL);
+ });
+
+ test('with JS disabled, bash commands render and no panel is blank', async ({ browser }) => {
+ const context = await browser.newContext({ javaScriptEnabled: false });
+ const page = await context.newPage();
+ try {
+ await page.goto('/');
+ const card = page.locator('section.try-it-out .card');
+ // All three bash one-liners are statically present (not JS-injected).
+ await expect(card).toContainText('curl -fsSL https://jaiph.org/run | bash');
+ await expect(card).toContainText('curl -fsSL https://jaiph.org/init | bash');
+ await expect(card).toContainText('curl -fsSL https://jaiph.org/install | bash');
+ // The Windows variant is reachable via static tab markup.
+ await expect(card).toContainText(PS_INSTALL);
+ // The active panel renders its bash command (not blank).
+ const activePanel = page.locator('section.try-it-out .code-tab-panel.is-active');
+ await expect(activePanel).toContainText('curl -fsSL https://jaiph.org/run | bash');
+ } finally {
+ await context.close();
+ }
+ });
+ });
+
test.describe('landing page samples', () => {
test('each tab: source from page runs and output matches (normalized)', async ({ page }) => {
await page.goto('/#samples');
From d4bed974399fa479c5e90efb831d391ffd44d574 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 16:05:39 +0200
Subject: [PATCH 8/9] Queue: add native Windows support tasks (portability,
then distro)
Co-Authored-By: Claude Fable 5
---
QUEUE.md | 20 --------------------
1 file changed, 20 deletions(-)
diff --git a/QUEUE.md b/QUEUE.md
index c1ad77a5..72c078fc 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -14,23 +14,3 @@ Process rules:
***
-## Distro: native Windows smoke job in CI #dev-ready
-
-CI's only Windows coverage runs the e2e suite inside WSL (`e2e-wsl` in `.github/workflows/ci.yml`), which exercises the Linux binary. Developing Jaiph on Windows is out of scope — this job proves *running* Jaiph natively works.
-
-Add a `windows-native-smoke` job on `windows-latest`:
-
-* Build the standalone Windows binary from the checkout (`bun build --compile --target=bun-windows-x64`).
-* With Git for Windows' `sh.exe` available (preinstalled on the runner), run a sample workflow host-only (`JAIPH_UNSAFE=true`) that covers: an inline shell line, a `script` step with a non-bash lang tag (e.g. ` ```node `), string interpolation, and `log` output.
-* Assert the process tree is cleaned up after a mid-run cancellation (spawn `jaiph run`, terminate it, assert no orphaned child processes remain).
-* No agent-backend credentials in this job: `prompt`-step coverage is limited to the credential pre-flight failing with the documented error, not a hang.
-* Keep `e2e-wsl` as-is; do not gate its removal on this task.
-
-Acceptance:
-
-* The smoke job is required for merge (listed in the CI gate alongside `test`/`e2e`/`e2e-wsl`).
-* Workflow output assertions run against actual `jaiph.exe` stdout (exit code + expected `log` lines).
-* The cancellation assertion fails if any child of the workflow leader survives termination.
-* The job completes with no WSL usage (fails if `wsl` is invoked).
-
-***
From 1383e762800474ec727797b9e3955b37d15596a7 Mon Sep 17 00:00:00 2001
From: Jakub Dzikowski
Date: Mon, 13 Jul 2026 16:11:12 +0200
Subject: [PATCH 9/9] Feat: native Windows smoke job in CI (windows-latest, no
WSL)
Co-Authored-By: Claude Fable 5
---
.github/workflows/ci.yml | 42 +++-
CHANGELOG.md | 1 +
docs/contributing.md | 4 +-
e2e/tests/windows_native_smoke.ps1 | 275 +++++++++++++++++++++++
integration/windows-native-smoke.test.ts | 124 ++++++++++
5 files changed, 444 insertions(+), 2 deletions(-)
create mode 100644 e2e/tests/windows_native_smoke.ps1
create mode 100644 integration/windows-native-smoke.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dfa0a737..8e908b3d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -312,9 +312,49 @@ jobs:
$env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe"
./e2e/tests/installer_powershell.ps1
+ windows-native-smoke:
+ name: Native Windows smoke (windows-latest, no WSL)
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build TypeScript + embed assets
+ run: npm run build
+
+ # Same build as release.yml's windows-x64 leg / the installer-powershell
+ # job, so the smoke test runs the real self-contained binary we ship.
+ - name: Cross-compile windows-x64 standalone binary
+ shell: bash
+ run: |
+ set -euo pipefail
+ bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile jaiph-windows-x64.exe
+ ls -la jaiph-windows-x64.exe
+
+ # Native run only — no `wsl`. Git for Windows' sh.exe (preinstalled on the
+ # runner) is the POSIX shell for inline lines; the harness shadows `wsl` so
+ # any accidental invocation fails the job.
+ - name: Run native Windows smoke (e2e/tests/windows_native_smoke.ps1)
+ shell: pwsh
+ run: |
+ $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe"
+ ./e2e/tests/windows_native_smoke.ps1
+
docker-publish:
name: Publish Docker runtime image
- needs: [test, e2e, docs-local, e2e-wsl, installer-powershell]
+ needs: [test, e2e, docs-local, e2e-wsl, installer-powershell, windows-native-smoke]
if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d231ab29..f06d178c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## All changes
+- **Feature — Distro: native Windows smoke job in CI:** CI's only Windows coverage was `e2e-wsl`, which runs the suite inside WSL against the *Linux* binary. A new **`windows-native-smoke`** job (`.github/workflows/ci.yml`, `windows-latest`) proves the *native* `jaiph.exe` runs. It cross-compiles the standalone binary from the checkout (`bun build --compile --target=bun-windows-x64`, the same build as the release / `installer-powershell` legs) and runs **`e2e/tests/windows_native_smoke.ps1`** against it. The harness (a PowerShell acceptance test, same convention as `installer_powershell.ps1`, pointed at the binary via `JAIPH_TEST_WINDOWS_EXE`) covers three contracts, each failing when violated: (1) a **sample workflow** runs host-only (`JAIPH_UNSAFE=true`) exercising an inline shell line (through Git for Windows' `sh.exe`), a `script` step with a non-bash lang tag (` ```node `), `${…}` string interpolation, and `log` output — with assertions against the real `jaiph.exe` **stdout** (exit code `0` plus the interpolated `log` line and the `PASS` footer); (2) a **mid-run cancellation** records the workflow leader's descendant PID tree (`Win32_Process` parent/child links), delivers a real **Ctrl-C** isolated to the leader's own console (`AttachConsole` + `GenerateConsoleCtrlEvent`, so the CI runner shell is untouched), and fails if **any** descendant survives — exercising the win32 `taskkill /T /F` teardown path in `killProcessTree`; (3) a **`prompt`-step credential pre-flight** (`agent.backend = "codex"`, `OPENAI_API_KEY` unset, and `JAIPH_UNSAFE` dropped so the pre-flight runs — win32 forces host-only regardless) fails fast with the documented `E_AGENT_CREDENTIALS` error, bounded by a 30s `WaitForExit` so a hang is a failure, rather than calling any backend. The job never touches WSL — it shadows `wsl` so an accidental call throws — and now gates merge alongside `test`/`e2e`/`e2e-wsl` (added to `docker-publish`'s `needs`); `e2e-wsl` is left in place. Host-portable guards in `integration/windows-native-smoke.test.ts` pin the CI job shape and the harness contract (build command, stdout assertions, cancellation orphan check, pre-flight error, no-WSL, gate membership) so a regression fails `npm test` on any platform.
- **Feature — Distro: main-page install tabs — Windows variant with platform auto-detect:** The landing page (`docs/index.html`) hero install card now offers a Windows PowerShell path alongside the POSIX `curl … | bash` one-liners, and defaults to the right one for the visitor's platform. A new **`.os-switch`** sub-toggle (two buttons, `data-os="posix"` "macOS / Linux" and `data-os="windows"` "Windows", `role="group"`) sits above the existing run-sample / init-project / just-install tabs, and every tab panel now wraps its content in two **`.os-variant`** blocks (`data-os="posix"` / `data-os="windows"`). On load, **`attachOsSwitch()`** (`docs/assets/js/main.js`, scoped to `section.try-it-out .card`) auto-selects the Windows variant for Windows visitors via **`isWindowsPlatform()`** — which prefers `navigator.userAgentData?.platform` and falls back to `navigator.platform`, matching `/win/i` — while macOS/Linux visitors keep exactly today's POSIX default with no layout shift; **`setOsVariant()`** flips the `is-active` class on both the switch buttons and the variants across all three panels, so a manual platform choice (or a tab switch) is remembered card-wide. The **"Just install"** Windows variant is the copy-able `irm https://jaiph.org/install.ps1 | iex` line (via the existing copy button) plus the `npm install -g jaiph` alternative. The **"Run sample"** and **"Init project"** tabs have no PowerShell equivalent for their `curl … | bash -s` / `curl … | bash` pipes, so instead of showing a bash-only command as the Windows default they show the install one-liner followed by a short "then run:" `jaiph run say_hello.jh Adam` / `jaiph init` step (installing to `%LOCALAPPDATA%\jaiph\bin`). The static-render constraint holds: the POSIX variant carries `is-active` in the shipped markup and CSS keeps `.os-variant { display: none }` / `.os-variant.is-active { display: block }` (`docs/assets/css/style.css`), so with JS disabled all three bash one-liners render, no panel is blank, and the Windows variant stays reachable in the tab markup — JS only reveals it on demand (and by default for Windows). New Playwright cases in the docs suite (`e2e/playwright/landing-page.spec.ts`, alongside the "Try it out" test) emulate the platform before page scripts run and assert: a Windows visitor defaults to the `irm … | iex` command, a macOS/Linux visitor's default is unchanged from today, manual platform + tab switching works in both directions, the copy button on the Windows variant copies the exact `irm … | iex` line (asserted through a clipboard stub), and with JavaScript disabled all bash commands render with no blank panel. No `docs/*.md` or `README.md` change is needed — the Windows install path is already documented in `docs/setup.md` (`/how-to/install`) and the README install section; this task is landing-page presentation only, with no runtime, CLI, or installer behavior change.
- **Feature — Distro: PowerShell installer at `jaiph.org/install.ps1`:** Windows now has a native install path to match the POSIX `curl … | bash` one. A new **`docs/install.ps1`** (served as `https://jaiph.org/install.ps1`, run with `irm https://jaiph.org/install.ps1 | iex`) is the counterpart to `docs/install`: it downloads **`jaiph-windows-x64.exe`** and `SHA256SUMS` from the pinned release ref (default the current stable tag `v0.10.0`, overridable via `JAIPH_REPO_REF` or the first argument — mirroring the bash installer — plus `JAIPH_RELEASE_BASE_URL` for a local/`file://` release dir used by the tests), verifies the SHA-256 with **`Get-FileHash`** against `SHA256SUMS` and aborts installing nothing on mismatch, then installs to **`%LOCALAPPDATA%\jaiph\bin\jaiph.exe`** (overridable via `JAIPH_BIN_DIR`), adds that directory to the user `PATH` if absent, and prints the same `jaiph --version` / `jaiph --help` try-it hints as the bash installer. Non-x64 / ARM Windows exits non-zero with a documented unsupported-platform message pointing at the from-source instructions (Bun has no Windows arm64 target, so Windows ships x64 only). The bash installer (`docs/install`) now rejects Windows-like `uname` values (`MINGW*`/`MSYS*`/`CYGWIN*`/`Windows_NT`) and points at the PowerShell one-liner instead of the generic build-from-source message; the release-prep workflow (`.jaiph/prepare_release.jh`) rewrites the pinned `vX.Y.Z` ref in **both** `docs/install` and `docs/install.ps1` in lockstep so they can never drift. CI gains an **`installer-powershell`** job on `windows-latest` that cross-compiles the real `jaiph-windows-x64.exe` (same build as the release leg) and runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1` — checksum mismatch (non-zero, no binary left), unsupported arch (documented message), and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`; the Docker publish job now `needs` it. Host-portable guards live in `integration/installer-powershell.test.ts` (installer contract, bash↔PowerShell lockstep ref, docs parity). Docs updated (`docs/setup.md`, `docs/index.html`, `docs/architecture.md`, `docs/contributing.md`).
- **Feature — Distro: build and release `jaiph-windows-x64.exe`:** The release workflow (`.github/workflows/release.yml`) now cross-compiles and publishes a Windows binary alongside the existing darwin/linux × arm64/x64 set. A new matrix entry `bun-windows-x64` (`os: windows`, `arch: x64`) produces the asset **`jaiph-windows-x64.exe`**; Bun has no `bun-windows-arm64` target, so Windows ships x64 only. The matrix gained an `ext` field (`""` for POSIX, `".exe"` for Windows) so the compiled `--outfile` and the uploaded artifact carry the extension. `SHA256SUMS` generation now covers all **five** binaries including the `.exe`, and both the stable (`v*`) and rolling `nightly` `gh release` upload lists ship the `.exe` — so every release publishes **six** assets (five binaries + `SHA256SUMS`). A new **`sanity-windows`** job runs on `windows-latest`, downloads the `.exe` artifact, and runs `jaiph-windows-x64.exe --version` through the version gate; the publish job now `needs: [build, sanity-windows]`, so a Windows version mismatch fails the whole release. The linux-x64 gate's inline version comparison was extracted into a shared, unit-testable **`scripts/release-version-check.sh`** (``: stable output must equal `jaiph ` exactly; any other channel only has to look like a `jaiph ` banner), and both the linux-x64 and windows-x64 gates delegate to it so the comparison lives in one place. The bash installer (`docs/install`) and its e2e test (`e2e/tests/07_installer_binary.sh`) are unchanged — they resolve darwin/linux names from `{os}×{arch}` and cannot install a Windows `.exe` via `curl … | bash`; Windows users download the `.exe` from the Release directly. New acceptance tests (`integration/release-workflow.test.ts`) assert the five-binary matrix (and the absence of a windows-arm64 target), that `SHA256SUMS` and both upload lists include the `.exe`, that a `.exe` checksum entry round-trips through the installer's own `awk` lookup, that the shared gate fails on a stable tag/version mismatch and passes on a match, that the `windows-latest` job runs `--version` through the shared script and blocks publish, and that the naming contract ↔ release matrix ↔ installer asset names stay in parity. Docs updated (`docs/architecture.md`, `docs/contributing.md`).
diff --git a/docs/contributing.md b/docs/contributing.md
index 0c9751f2..cc911d3a 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -173,6 +173,7 @@ Tests that span multiple modules, require subprocess/PTY harnesses, exercise pro
| `integration/docs-nav-structure-task7.test.ts` | Integration | Nav spine — five Diátaxis section headings in documented order; every published page under its quadrant exactly once |
| `integration/release-workflow.test.ts` | Integration | Release matrix / asset-naming contract — five-binary matrix (no windows-arm64), `SHA256SUMS` + upload lists include `jaiph-windows-x64.exe`, shared version-gate script, naming contract ↔ matrix ↔ installer parity |
| `integration/installer-powershell.test.ts` | Integration | Windows PowerShell installer (`docs/install.ps1`) contract — download/verify/install steps, bash↔PowerShell lockstep release ref, and `docs/setup.md` / main-page one-liner parity |
+| `integration/windows-native-smoke.test.ts` | Integration | Host-portable guards for the `windows-native-smoke` CI job and its `e2e/tests/windows_native_smoke.ps1` harness — job shape (windows-latest, `bun --compile` build, gate membership alongside `test`/`e2e`/`e2e-wsl`), stdout/exit-code assertions, cancellation orphan check, `prompt` pre-flight error, and no-WSL enforcement |
| `integration/sample-build/build.test.ts` | Integration | Build/transpile behavior — `buildScripts`, script extraction |
| `integration/sample-build/cli-tree.test.ts` | Integration | CLI tree output rendering for sample workflows |
| `integration/sample-build/run-core.test.ts` | Integration | Core runtime execution — workflow runs, step sequencing, artifacts |
@@ -189,7 +190,7 @@ The `integration/sample-build/` directory also has a shared `helpers.ts` module
## CI pipeline
-The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **seven** jobs; on a typical feature-branch push, **six** of them run. The seventh — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, and PowerShell-installer jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
+The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **eight** jobs; on a typical feature-branch push, **seven** of them run. The eighth — **Publish Docker runtime image** — runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, PowerShell-installer, and native-Windows-smoke jobs succeed (ShellCheck is not a publish gate). It builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)).
| Job | Runner | Purpose |
|-----|--------|---------|
@@ -199,6 +200,7 @@ The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defin
| **Getting started (local)** | `ubuntu-latest` | Serves the Jekyll site from `docs/` on `127.0.0.1:4000`, smoke-checks key routes with `curl`, builds the same local runtime image as E2E for any Docker-backed sample paths, installs Playwright (Chromium), and runs `npx playwright test` for landing-page samples. |
| **E2E install and CLI workflow (windows-latest + wsl)** | `windows-latest` | Provisions or selects a WSL distro, installs Node inside it, and runs `npm run test:e2e` under WSL with **`JAIPH_UNSAFE=true`**. |
| **PowerShell installer (windows-x64)** | `windows-latest` | Cross-compiles `jaiph-windows-x64.exe` (same as the release leg), then runs `e2e/tests/installer_powershell.ps1` against `docs/install.ps1`: checksum mismatch, unsupported arch, and a happy-path install where `jaiph --version` works with no Node/npm/Bun on `PATH`. |
+| **Native Windows smoke (windows-latest, no WSL)** | `windows-latest` | Cross-compiles `jaiph-windows-x64.exe` (same as the release leg) and runs `e2e/tests/windows_native_smoke.ps1` against it — proving the *native* binary runs, where `e2e-wsl` exercises the *Linux* binary under WSL. Uses Git for Windows' `sh.exe` (preinstalled) as the POSIX shell and touches **no WSL** (an accidental `wsl` call fails the job). Covers: a host-only sample workflow (`JAIPH_UNSAFE=true`) exercising an inline shell line, a `script` step with a non-bash lang tag (` ```node `), string interpolation, and `log` output, asserted against real `jaiph.exe` stdout (exit code + `log` lines); a mid-run cancellation that fails if any child of the workflow leader survives termination; and a `prompt`-step credential pre-flight that fails fast with the documented `E_AGENT_CREDENTIALS` error (bounded so a hang is a failure), not a backend call. |
| **Publish Docker runtime image** | `ubuntu-latest` | *Conditional (see above).* Multi-arch push to GHCR. |
### Version tags, releases, and npm
diff --git a/e2e/tests/windows_native_smoke.ps1 b/e2e/tests/windows_native_smoke.ps1
new file mode 100644
index 00000000..09262bf7
--- /dev/null
+++ b/e2e/tests/windows_native_smoke.ps1
@@ -0,0 +1,275 @@
+#!/usr/bin/env pwsh
+#
+# Native Windows smoke test for the standalone jaiph.exe (the `windows-native-smoke`
+# CI job runs this on windows-latest). Developing Jaiph on Windows is out of scope;
+# this proves that *running* a workflow natively — with Git for Windows' sh.exe as
+# the POSIX shell and no WSL — works. It covers each acceptance bullet with a check
+# that fails when the contract is violated:
+#
+# 1. A sample workflow runs host-only (JAIPH_UNSAFE=true) covering an inline
+# shell line, a `script` step with a non-bash lang tag (```node), string
+# interpolation, and `log` output. Assertions run against the real jaiph.exe
+# stdout (exit code + expected `log` lines).
+# 2. A mid-run cancellation cleans up the process tree: we start `jaiph run`,
+# record the workflow leader's descendant PIDs, deliver a real Ctrl-C, and
+# fail if any descendant survives termination (the win32 taskkill /T /F path).
+# 3. A `prompt`-step workflow with a configured backend but no credentials fails
+# fast with the documented E_AGENT_CREDENTIALS error rather than hanging.
+#
+# The binary under test is provided via JAIPH_TEST_WINDOWS_EXE (same convention as
+# installer_powershell.ps1). Without it the test cannot run and exits non-zero.
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+# Hard ban: this smoke test must never touch WSL — exercising the Linux binary is
+# e2e-wsl's job. Shadow `wsl` so any accidental invocation in this session throws
+# loudly instead of silently succeeding.
+function wsl {
+ throw "WSL must not be used in the native Windows smoke test (e2e-wsl covers WSL)"
+}
+
+$script:Failures = 0
+function Report-Pass { param([string]$m) Write-Host "PASS: $m" -ForegroundColor Green }
+function Report-Fail { param([string]$m) Write-Host "FAIL: $m" -ForegroundColor Red; $script:Failures++ }
+function Assert-Equal {
+ param($Actual, $Expected, [string]$Message)
+ if ($Actual -eq $Expected) { Report-Pass $Message }
+ else { Report-Fail "$Message (expected '$Expected', got '$Actual')" }
+}
+function Assert-True {
+ param([bool]$Condition, [string]$Message)
+ if ($Condition) { Report-Pass $Message } else { Report-Fail $Message }
+}
+function Assert-Contains {
+ param([string]$Haystack, [string]$Needle, [string]$Message)
+ if ($Haystack -like "*$Needle*") { Report-Pass $Message }
+ else { Report-Fail "$Message (missing '$Needle')`n---`n$Haystack`n---" }
+}
+
+$Exe = $env:JAIPH_TEST_WINDOWS_EXE
+if (-not $Exe -or -not (Test-Path $Exe)) {
+ Write-Host "FAIL: JAIPH_TEST_WINDOWS_EXE is not set to an existing jaiph.exe" -ForegroundColor Red
+ exit 1
+}
+$Exe = (Resolve-Path $Exe).Path
+
+# Host-only: the Docker sandbox is out of scope on win32 (resolveDockerConfig
+# forces host mode), but be explicit so this never probes for a daemon.
+$env:JAIPH_UNSAFE = "true"
+$env:JAIPH_DOCKER_ENABLED = "false"
+
+$Work = Join-Path ([System.IO.Path]::GetTempPath()) ("jaiph-winsmoke-" + [System.IO.Path]::GetRandomFileName())
+New-Item -ItemType Directory -Path $Work -Force | Out-Null
+
+# Enumerate the full descendant tree of a PID via the parent/child links in
+# Win32_Process (procps-free, unlike `pgrep`, and portable across runner images).
+function Get-DescendantPids {
+ param([int]$RootPid)
+ $all = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId
+ $result = New-Object System.Collections.Generic.List[int]
+ $frontier = @($RootPid)
+ while ($frontier.Count -gt 0) {
+ $next = New-Object System.Collections.Generic.List[int]
+ foreach ($proc in $all) {
+ if ($frontier -contains [int]$proc.ParentProcessId) {
+ $childPid = [int]$proc.ProcessId
+ if (-not $result.Contains($childPid)) {
+ $result.Add($childPid)
+ $next.Add($childPid)
+ }
+ }
+ }
+ $frontier = $next.ToArray()
+ }
+ return $result.ToArray()
+}
+
+function Test-PidAlive {
+ param([int]$TargetPid)
+ return [bool](Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)
+}
+
+# Ctrl-C delivery. To signal only the jaiph leader (never the CI runner shell),
+# we detach from our own console, attach to the leader's own console, make the
+# attached process ignore Ctrl-C, then GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)
+# — which hits every process in that (isolated) console — and finally reattach to
+# our parent console so the rest of the script can still write output.
+Add-Type -Namespace JaiphSmoke -Name Native -MemberDefinition @'
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool SetConsoleCtrlHandler(System.IntPtr HandlerRoutine, bool Add);
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool AttachConsole(uint dwProcessId);
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool FreeConsole();
+[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
+public static extern bool AllocConsole();
+'@
+
+$CTRL_C_EVENT = 0
+$ATTACH_PARENT_PROCESS = [uint32]"0xFFFFFFFF"
+$leaderPid = $null # referenced in the outer finally under Set-StrictMode
+
+# Send a real Ctrl-C to $TargetPid's console only, sparing this process' console.
+function Send-CtrlC {
+ param([int]$TargetPid)
+ [JaiphSmoke.Native]::FreeConsole() | Out-Null
+ $attached = [JaiphSmoke.Native]::AttachConsole([uint32]$TargetPid)
+ if (-not $attached) {
+ # Nothing to attach to (child already gone / no console); restore and bail.
+ [JaiphSmoke.Native]::AttachConsole($ATTACH_PARENT_PROCESS) | Out-Null
+ return $false
+ }
+ [JaiphSmoke.Native]::SetConsoleCtrlHandler([System.IntPtr]::Zero, $true) | Out-Null
+ $sent = [JaiphSmoke.Native]::GenerateConsoleCtrlEvent($CTRL_C_EVENT, 0)
+ # Restore our own console so Write-Host works again after this call.
+ [JaiphSmoke.Native]::FreeConsole() | Out-Null
+ if (-not [JaiphSmoke.Native]::AttachConsole($ATTACH_PARENT_PROCESS)) {
+ [JaiphSmoke.Native]::AllocConsole() | Out-Null
+ }
+ [JaiphSmoke.Native]::SetConsoleCtrlHandler([System.IntPtr]::Zero, $false) | Out-Null
+ return [bool]$sent
+}
+
+try {
+ # ── 1. Sample workflow: inline shell + node script + interpolation + log ─────
+ Write-Host "`n== Sample workflow runs host-only against jaiph.exe =="
+
+ $sampleWf = Join-Path $Work "sample.jh"
+ Set-Content -LiteralPath $sampleWf -Encoding utf8 -Value @'
+script node_step = ```node
+process.stdout.write("node-step-output\n");
+```
+
+workflow default() {
+ const who = "Windows"
+ echo "inline shell for ${who}"
+ run node_step()
+ log "smoke greeting for ${who}"
+}
+'@
+
+ $sampleOut = Join-Path $Work "sample.out"
+ $sampleErr = Join-Path $Work "sample.err"
+ $sample = Start-Process -FilePath $Exe -ArgumentList @("run", $sampleWf) `
+ -NoNewWindow -PassThru -Wait -WorkingDirectory $Work `
+ -RedirectStandardOutput $sampleOut -RedirectStandardError $sampleErr
+ $sampleStdout = Get-Content -LiteralPath $sampleOut -Raw
+ $sampleStderr = Get-Content -LiteralPath $sampleErr -Raw
+
+ Assert-Equal $sample.ExitCode 0 "sample workflow exits 0"
+ # Assert against stdout: the interpolated `log` line and the success footer.
+ Assert-Contains $sampleStdout "smoke greeting for Windows" "log line (with interpolation) is on stdout"
+ Assert-Contains $sampleStdout "PASS workflow default" "success footer is on stdout"
+ if ($script:Failures -gt 0) {
+ Write-Host "sample stderr was:`n$sampleStderr"
+ }
+
+ # ── 2. Mid-run cancellation cleans up the process tree ───────────────────────
+ Write-Host "`n== Mid-run cancellation leaves no orphaned child processes =="
+
+ $cancelWf = Join-Path $Work "cancel.jh"
+ Set-Content -LiteralPath $cancelWf -Encoding utf8 -Value @'
+script long_sleep = ```node
+setInterval(() => {}, 1000);
+```
+
+workflow default() {
+ run long_sleep()
+}
+'@
+
+ # Launch the leader in its OWN (hidden) console so the Ctrl-C we send later is
+ # isolated to its process group and never reaches the CI runner shell. We do
+ # not redirect its stdio here — the cancellation contract is about the process
+ # tree, not output — because redirection would force a shared console.
+ $leader = Start-Process -FilePath $Exe -ArgumentList @("run", $cancelWf) `
+ -WindowStyle Hidden -PassThru -WorkingDirectory $Work
+ $leaderPid = $leader.Id
+
+ # Wait for the workflow to spawn its child tree (detached runner -> node child).
+ $descendants = @()
+ for ($i = 0; $i -lt 100; $i++) {
+ Start-Sleep -Milliseconds 200
+ $descendants = Get-DescendantPids -RootPid $leaderPid
+ if ($descendants.Count -ge 1) { break }
+ }
+ Assert-True ($descendants.Count -ge 1) "workflow leader spawned a child process tree"
+
+ # Deliver a real Ctrl-C to the leader's console only, then wait for it to exit.
+ Send-CtrlC -TargetPid $leaderPid | Out-Null
+ $exited = $leader.WaitForExit(15000)
+ Assert-True $exited "workflow leader exits after Ctrl-C (no hang)"
+
+ # Give the win32 teardown (taskkill /T /F) a moment to reap the tree.
+ Start-Sleep -Milliseconds 1500
+ $survivors = @($descendants | Where-Object { Test-PidAlive -TargetPid $_ })
+ Assert-True ($survivors.Count -eq 0) "no child of the workflow leader survives termination"
+ if ($survivors.Count -gt 0) {
+ Write-Host "surviving PIDs: $($survivors -join ', ')"
+ # Best-effort reap so the CI runner is not left with orphans.
+ foreach ($p in $survivors) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }
+ }
+
+ # ── 3. prompt-step credential pre-flight fails fast (no hang) ─────────────────
+ Write-Host "`n== prompt-step pre-flight fails with the documented error, not a hang =="
+
+ $promptWf = Join-Path $Work "prompt.jh"
+ Set-Content -LiteralPath $promptWf -Encoding utf8 -Value @'
+config {
+ agent.backend = "codex"
+}
+
+workflow default() {
+ const answer = prompt "Say hello"
+ log answer
+}
+'@
+
+ # The credential pre-flight is skipped under JAIPH_UNSAFE (that flag means
+ # "trust my host env"), so drop it here — on win32 resolveDockerConfig already
+ # forces host-only mode, so the run stays host-only without it. codex has no
+ # login fallback: with OPENAI_API_KEY unset the pre-flight hard-fails before any
+ # backend call, so this fails fast with E_AGENT_CREDENTIALS instead of hanging.
+ $origOpenAi = $env:OPENAI_API_KEY
+ $origUnsafe = $env:JAIPH_UNSAFE
+ Remove-Item Env:\OPENAI_API_KEY -ErrorAction SilentlyContinue
+ Remove-Item Env:\JAIPH_UNSAFE -ErrorAction SilentlyContinue
+ try {
+ $promptOut = Join-Path $Work "prompt.out"
+ $promptErr = Join-Path $Work "prompt.err"
+ $prompt = Start-Process -FilePath $Exe -ArgumentList @("run", $promptWf) `
+ -NoNewWindow -PassThru -WorkingDirectory $Work `
+ -RedirectStandardOutput $promptOut -RedirectStandardError $promptErr
+ $promptExited = $prompt.WaitForExit(30000)
+ if (-not $promptExited) {
+ $prompt.Kill($true)
+ Report-Fail "prompt pre-flight hung (did not exit within 30s)"
+ } else {
+ $promptStderr = Get-Content -LiteralPath $promptErr -Raw
+ Assert-True ($prompt.ExitCode -ne 0) "prompt pre-flight exits non-zero"
+ Assert-Contains $promptStderr "E_AGENT_CREDENTIALS" "pre-flight names the documented error code"
+ Assert-Contains $promptStderr "OPENAI_API_KEY" "pre-flight names the missing credential"
+ }
+ } finally {
+ if ($null -ne $origOpenAi) { $env:OPENAI_API_KEY = $origOpenAi }
+ if ($null -ne $origUnsafe) { $env:JAIPH_UNSAFE = $origUnsafe }
+ }
+} finally {
+ # Defensive: if the cancellation contract regressed (or the harness threw mid-run),
+ # force-kill the leader's tree so the CI runner is never left with orphans.
+ if ($null -ne $leaderPid) {
+ & taskkill.exe /PID $leaderPid /T /F 2>&1 | Out-Null
+ }
+ Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Host ""
+if ($script:Failures -gt 0) {
+ Write-Host "$($script:Failures) check(s) failed" -ForegroundColor Red
+ exit 1
+}
+Write-Host "All native Windows smoke checks passed" -ForegroundColor Green
+exit 0
diff --git a/integration/windows-native-smoke.test.ts b/integration/windows-native-smoke.test.ts
new file mode 100644
index 00000000..8ceed4cc
--- /dev/null
+++ b/integration/windows-native-smoke.test.ts
@@ -0,0 +1,124 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+
+// Acceptance for "Distro: native Windows smoke job in CI".
+//
+// The runtime behaviour (running the real jaiph.exe, cancelling mid-run, the
+// prompt pre-flight) is exercised on windows-latest by
+// e2e/tests/windows_native_smoke.ps1. These host-portable guards pin the
+// contract that must hold everywhere and fail when it is violated:
+// 1. The windows-native-smoke job exists on windows-latest, builds the
+// standalone .exe, and runs the smoke harness — and it is required for
+// merge (the docker-publish gate needs it, alongside test/e2e/e2e-wsl).
+// 2. Output assertions run against actual jaiph.exe stdout (exit code +
+// expected log lines); the cancellation check fails if any child survives.
+// 3. The job uses no WSL (e2e-wsl is left as-is and is the only WSL lane).
+
+const REPO_ROOT = process.cwd();
+const CI_YML = readFileSync(join(REPO_ROOT, ".github/workflows/ci.yml"), "utf8");
+const HARNESS_PATH = "e2e/tests/windows_native_smoke.ps1";
+const HARNESS = readFileSync(join(REPO_ROOT, HARNESS_PATH), "utf8");
+
+// Slice a job body out of the YAML by stable anchors so per-job assertions do
+// not accidentally match text from another job (e.g. the WSL job).
+function sliceBetween(text: string, start: string, end: string | null): string {
+ const from = text.indexOf(start);
+ assert.notEqual(from, -1, `expected to find "${start}" in workflow`);
+ const to = end === null ? text.length : text.indexOf(end, from + start.length);
+ assert.notEqual(to, -1, `expected to find "${end}" after "${start}"`);
+ return text.slice(from, to === text.length ? text.length : to);
+}
+
+const SMOKE_JOB = sliceBetween(CI_YML, "\n windows-native-smoke:", "\n docker-publish:");
+
+// ── Acceptance 1: the job exists, builds the .exe, runs the harness, gates merge ─
+
+test("windows-native-smoke runs natively on windows-latest", () => {
+ assert.match(SMOKE_JOB, /runs-on:\s*windows-latest/, "job runs on windows-latest");
+});
+
+test("windows-native-smoke builds the standalone windows-x64 binary from the checkout", () => {
+ assert.match(
+ SMOKE_JOB,
+ /bun build --compile --target=bun-windows-x64 \.\/src\/cli\.ts --outfile jaiph-windows-x64\.exe/,
+ "compiles jaiph-windows-x64.exe with bun --compile",
+ );
+});
+
+test("windows-native-smoke runs the smoke harness against the built exe", () => {
+ assert.match(SMOKE_JOB, /windows_native_smoke\.ps1/, "invokes the smoke harness");
+ assert.match(
+ SMOKE_JOB,
+ /JAIPH_TEST_WINDOWS_EXE\s*=\s*Join-Path \$env:GITHUB_WORKSPACE "jaiph-windows-x64\.exe"/,
+ "points the harness at the freshly built exe",
+ );
+});
+
+test("windows-native-smoke is required for merge in the CI gate", () => {
+ // docker-publish is the CI gate (release-workflow uses the same pattern): its
+ // needs list is what must be green. The smoke job joins test/e2e/e2e-wsl there.
+ const needs = sliceBetween(CI_YML, "\n docker-publish:", "\n if:");
+ assert.match(needs, /needs:\s*\[[^\]]*\bwindows-native-smoke\b[^\]]*\]/, "gate needs windows-native-smoke");
+ for (const gate of ["test", "e2e", "e2e-wsl"]) {
+ assert.match(needs, new RegExp(`\\b${gate}\\b`), `gate still needs ${gate}`);
+ }
+});
+
+// ── Acceptance 2: assertions run against real jaiph.exe stdout / cancellation ──
+
+test("harness exists and runs the built exe (not a Node fallback)", () => {
+ assert.ok(existsSync(join(REPO_ROOT, HARNESS_PATH)), "windows_native_smoke.ps1 present");
+ assert.match(HARNESS, /JAIPH_TEST_WINDOWS_EXE/, "runs the binary provided by CI");
+});
+
+test("sample run asserts exit code and expected log lines against stdout", () => {
+ // The sample workflow covers every required construct.
+ assert.match(HARNESS, /script node_step = ```node/, "script step with a non-bash lang tag");
+ assert.match(HARNESS, /echo "inline shell for \$\{who\}"/, "inline shell line with interpolation");
+ assert.match(HARNESS, /log "smoke greeting for \$\{who\}"/, "log output with interpolation");
+ // Assertions read stdout (redirected to a file), not merged output, and check
+ // both the exit code and the interpolated log line.
+ assert.match(HARNESS, /-RedirectStandardOutput \$sampleOut/, "captures stdout separately");
+ assert.match(HARNESS, /Assert-Equal \$sample\.ExitCode 0/, "asserts the exit code");
+ assert.match(
+ HARNESS,
+ /Assert-Contains \$sampleStdout "smoke greeting for Windows"/,
+ "asserts the interpolated log line on stdout",
+ );
+});
+
+test("cancellation check fails if any child of the workflow leader survives", () => {
+ // Record the descendant tree, deliver a real Ctrl-C, then assert none survive.
+ assert.match(HARNESS, /Get-DescendantPids -RootPid \$leaderPid/, "captures the leader's descendant tree");
+ assert.match(HARNESS, /GenerateConsoleCtrlEvent/, "delivers a real Ctrl-C (not a plain kill)");
+ assert.match(
+ HARNESS,
+ /Assert-True \(\$survivors\.Count -eq 0\) "no child of the workflow leader survives termination"/,
+ "fails when a child survives",
+ );
+});
+
+test("prompt-step pre-flight fails fast with the documented error (no hang)", () => {
+ assert.match(HARNESS, /agent\.backend = "codex"/, "configures a backend with no login path");
+ assert.match(HARNESS, /Remove-Item Env:\\OPENAI_API_KEY/, "runs with the credential unset");
+ assert.match(HARNESS, /WaitForExit\(30000\)/, "bounds the run so a hang is a failure");
+ assert.match(HARNESS, /prompt pre-flight hung/, "reports a hang as a failure");
+ assert.match(HARNESS, /Assert-Contains \$promptStderr "E_AGENT_CREDENTIALS"/, "asserts the documented error");
+});
+
+// ── Acceptance 3: no WSL usage ────────────────────────────────────────────────
+
+test("windows-native-smoke never invokes wsl", () => {
+ // e2e-wsl is the only WSL lane; the smoke job must not shell out to `wsl`.
+ assert.doesNotMatch(SMOKE_JOB, /\bwsl\s+-/, "no wsl flag invocation in the CI job");
+ assert.doesNotMatch(HARNESS, /(^|[^a-zA-Z])wsl\s+-/m, "no wsl flag invocation in the harness");
+ // The harness actively guards against it by shadowing `wsl`.
+ assert.match(HARNESS, /function wsl \{/, "harness shadows wsl so any call throws");
+});
+
+test("e2e-wsl is left in place (its removal is not gated on this task)", () => {
+ assert.match(CI_YML, /\n e2e-wsl:/, "the WSL job still exists");
+ assert.match(CI_YML, /wsl -d "\$distro"/, "e2e-wsl still runs the suite inside WSL");
+});