From 229dac7f3086a5ed72c57b7256a4f27032446a2e Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 21 Jun 2026 14:31:48 -0400 Subject: [PATCH 1/5] feat(windows): cross-platform CLI detection, spawning, and termination Native Windows/PowerShell could not detect or run the agent CLIs because the codebase shelled out to `sh -lc 'command -v X'` (no `sh` on Windows) and spawned npm `.cmd`/`.ps1` shims without `shell: true` (refused by Node >=20). Fixes AlmanacCode/codealmanac#1. - Add src/process/exec.ts: pure-Node PATH/PATHEXT resolution (commandExists/resolveExecutable) and crossSpawn, which uses a shell only for Windows shims. Removes the `sh` dependency on every platform. - Route claude auth, codex readiness/status, and codex exec spawns through the shared module, collapsing three duplicate `commandExists` copies. - process-group: terminate via `taskkill /T /F` on Windows (POSIX `process.kill(-pgid)` is unavailable) and do not detach (a new console breaks piped stdio; the tree is killed by pid instead). - paths: stop the `.almanac` walk-up at the home directory. The global `~/.almanac` is already skipped; this also keeps tests hermetic on Windows, where the OS temp dir lives under the home dir. - Make the test suite portable on Windows: dirname() instead of slash-only regex, separator-agnostic path assertions, and a `.cmd` shim + path.delimiter for the fake codex CLI fixtures. Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-06-21-windows-support.md | 85 ++++++++++ src/agent/auth/claude.ts | 15 +- src/agent/readiness/providers/cli-status.ts | 13 +- src/harness/providers/codex/exec.ts | 8 +- src/harness/providers/codex/status.ts | 11 +- src/paths.ts | 14 ++ src/process/exec.ts | 177 ++++++++++++++++++++ src/process/process-group.ts | 43 ++++- test/autoregister.test.ts | 4 +- test/codex-harness-provider.test.ts | 21 ++- test/helpers.ts | 19 +++ test/list.test.ts | 4 +- test/process-exec.test.ts | 133 +++++++++++++++ test/registry.test.ts | 9 +- test/show.test.ts | 2 +- 15 files changed, 512 insertions(+), 46 deletions(-) create mode 100644 docs/plans/2026-06-21-windows-support.md create mode 100644 src/process/exec.ts create mode 100644 test/process-exec.test.ts diff --git a/docs/plans/2026-06-21-windows-support.md b/docs/plans/2026-06-21-windows-support.md new file mode 100644 index 00000000..ed219317 --- /dev/null +++ b/docs/plans/2026-06-21-windows-support.md @@ -0,0 +1,85 @@ +# Windows Support Implementation Plan + +**Issue:** [#1 — Not Detecting Codex CLI](https://github.com/AlmanacCode/codealmanac/issues/1) +**Prior art:** Draft [PR #2](https://github.com/AlmanacCode/codealmanac/pull/2) (`codex/windows-support`, cut from v0.2.23, now CONFLICTING). We borrow its scheduler/setup/doctor/install work and re-apply onto current `main`, but fix the parts it missed and consolidate duplicated primitives. + +**Goal:** Make codealmanac work end-to-end on native Windows / PowerShell: provider detection, agent execution (capture/bootstrap), run cancellation, and auto-scheduling. No change to capture/Garden semantics on macOS. + +--- + +## Root cause (verified) + +Three independent layers break on native Windows: + +1. **Detection (the reported bug).** `commandExists` / `defaultCommandExists` / `resolveClaudeExecutable` shell out to `sh -lc 'command -v X'`. Windows has no `sh`, so every provider reports "not found on PATH". Three duplicated copies: + - `src/agent/readiness/providers/cli-status.ts:8` ← **the live path the user's screenshot hits** (`codex-cli.ts` → `commandExists`) + - `src/agent/auth/claude.ts:37` (`resolveClaudeExecutable`) + - `src/harness/providers/codex/status.ts:4` (`defaultCommandExists`) +2. **Spawning the CLIs.** Every `spawn(command, …)` omits `shell`. On Node ≥20 Windows refuses to spawn npm's `.cmd`/`.ps1` shims without `shell: true` (CVE-2024-27980 hardening). Affects status probes **and** the real run paths (`harness/providers/codex/exec.ts`, `app-server.ts` → `process/process-group.ts`, `agent/auth/claude.ts` `defaultSpawnCli`). +3. **Process-group lifecycle.** `process/process-group.ts` uses `detached:true` + `process.kill(-pgid)` (POSIX negative-PID group signal). On Windows this throws/no-ops, leaking the agent's child tree on cancel. Windows needs `taskkill /PID /T /F`. + +Plus the **scheduling** layer is macOS-launchd-only (`src/automation/`, `/usr/bin/env` hardcoded in setup) — no Windows Task Scheduler path. + +PR #2 only patched the **old** single-file `harness/providers/codex.ts` (since refactored into `codex/`) and never touched layer-1's live `agent/readiness` path, `agent/auth/claude.ts`, or layer 3. + +--- + +## Architecture decision + +Rather than scatter `if (process.platform === "win32") { where … } else { sh … }` + `shell: process.platform === "win32"` across 5+ spawn sites (PR #2's approach, and a smell this project's CLAUDE.md explicitly pushes back on — "a central status file should not know provider-specific details", "no one-off fixes"), introduce **one shared cross-platform process module** and route every caller through it: + +`src/process/exec.ts` (new): +- `commandExists(command): boolean` — pure-Node PATH + PATHEXT scan (no subprocess at all). Removes the `sh` dependency on **every** platform, which is strictly more correct. +- `resolveExecutable(command): string | undefined` — full resolved path (used by claude auth's `pathToClaudeCodeExecutable` and to feed spawns). +- `crossSpawn(command, args, options)` — thin wrapper that sets `shell: true` on win32 and resolves shims; single place that knows the Windows quirk. + +This collapses 3 copies of `commandExists` into 1 and removes per-site platform branches. + +--- + +## Tasks (TDD: failing test → implement → verify, per project convention) + +### Task 1 — Shared cross-platform exec module +- **Create** `src/process/exec.ts`: `commandExists`, `resolveExecutable`, `crossSpawn`. +- **Test** `test/process-exec.test.ts`: PATHEXT resolution on a faked win32 env, POSIX `command -v`-equivalent behavior, missing-command returns false. Use `withTempHome` style env injection (inject PATH/PATHEXT + platform, no real subprocess). + +### Task 2 — Route detection + status spawns through it +- **Modify** `src/agent/readiness/providers/cli-status.ts` — `commandExists` + `runStatusCommand` use the shared module. +- **Modify** `src/agent/auth/claude.ts` — `resolveClaudeExecutable` + `defaultSpawnCli` use the shared module. +- **Modify** `src/harness/providers/codex/status.ts` — delete the duplicated `defaultCommandExists`/`defaultRunStatus`, import shared. +- **Test**: extend existing provider/codex-harness tests to assert detection succeeds with a Windows `.cmd` shim on PATH (faked). + +### Task 3 — Route run/execution spawns through it +- **Modify** `src/harness/providers/codex/exec.ts` and `app-server.ts` (via `process-group.ts`) to spawn through the shared helper so `.cmd`/`.ps1` shims launch. +- **Modify** `src/process/background.ts` detached spawn similarly. + +### Task 4 — Windows-safe process termination +- **Modify** `src/process/process-group.ts`: on win32, terminate via `taskkill /PID /T /F` instead of `process.kill(-pgid)`; keep POSIX path unchanged. Guard `detached` semantics per-platform. +- **Test** `test/process-group.test.ts`: win32 branch invokes taskkill (injected exec), POSIX branch unchanged. + +### Task 5 — Windows Task Scheduler (borrow PR #2) +- **Create** `src/commands/automation/windows.ts` (install/status/uninstall via `schtasks`, manifests under `~/.almanac/automation/`). Re-apply PR #2's file; fix the stray tab-indentation in its source. +- **Modify** `src/commands/automation.ts` — `platform` injection + win32 branch (from PR #2). +- **Modify** `src/cli/register-wiki-lifecycle-commands.ts` — generic "platform scheduler" descriptions. +- **Test** `test/automation.test.ts` — add `platform:"darwin"` to existing launchd tests; add win32 schtasks tests (from PR #2). + +### Task 6 — Setup / doctor / install path platform-awareness (borrow PR #2) +- **Create** `src/install/ephemeral.ts` (`looksEphemeralInstallPath`, handles `%TEMP%`/`%TMP%`/`_npx`). +- **Modify** `src/commands/setup.ts` (win32 `almanac.cmd` program args), `setup/install-path.ts` (`cmd.exe /d /s /c npm.cmd …`), `doctor-checks/install.ts` + `probes.ts` + `types.ts`, `uninstall.ts`. +- **Test**: extend `test/setup.test.ts`, `test/doctor.test.ts`, `test/uninstall.test.ts` with win32 cases (from PR #2). + +### Task 7 — CI + docs +- **Modify** `.github/workflows/ci.yml` — add `windows-latest` matrix (Node 20 & 22) (from PR #2). +- **Modify** `README.md` — drop "macOS only", document Windows support + scheduler caveat. +- Update `.almanac/` pages PR #2 touched if still accurate. + +--- + +## Out of scope / risks +- `cursor-agent` on Windows is detected/spawned the same way but unverified (no cursor CLI here). +- WSL is already covered (it's Linux); this targets **native** Windows. +- `taskkill`-based termination is best-effort; can't send graceful SIGTERM-equivalent, so Windows cancel is harder-kill than macOS. Acceptable. +- Path-with-spaces quoting under `shell:true` — covered by resolving full paths and quoting; status/run args are simple flags. + +## Verification +`npm run lint` (tsc), `npm test` (vitest), `npm run build` (tsup) — all green. Then a real-machine smoke test on this Windows box: `almanac` status detects Codex, and a `capture`/`bootstrap` dry run launches the agent. diff --git a/src/agent/auth/claude.ts b/src/agent/auth/claude.ts index 9b6f3520..2de249f5 100644 --- a/src/agent/auth/claude.ts +++ b/src/agent/auth/claude.ts @@ -1,8 +1,9 @@ -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import type { SpawnCliFn, SpawnedProcess } from "../types.js"; +import { crossSpawn, resolveExecutable } from "../../process/exec.js"; /** * Claude auth gate — accepts either an active Claude subscription login @@ -34,12 +35,7 @@ const AUTH_TIMEOUT_MS = 10_000; * the same binary so Almanac agrees with `claude auth status`. */ export function resolveClaudeExecutable(): string | undefined { - const result = spawnSync("sh", ["-lc", "command -v claude"], { - encoding: "utf8", - }); - if (result.status !== 0) return undefined; - const found = result.stdout.trim().split("\n")[0]?.trim(); - return found !== undefined && found.length > 0 ? found : undefined; + return resolveExecutable("claude"); } /** @@ -58,8 +54,9 @@ function resolveCliJsPath(): string { * Claude Code CLI. */ export const defaultSpawnCli: SpawnCliFn = (args: string[]) => { - const command = resolveClaudeExecutable() ?? "claude"; - const child = spawn(command, args, { + // Pass the bare command so crossSpawn lets the shell resolve the npm + // `.cmd` shim on Windows; on POSIX it resolves via PATH as before. + const child = crossSpawn("claude", args, { stdio: ["ignore", "pipe", "pipe"], }); return child as unknown as SpawnedProcess; diff --git a/src/agent/readiness/providers/cli-status.ts b/src/agent/readiness/providers/cli-status.ts index 3468fa95..62ee2df1 100644 --- a/src/agent/readiness/providers/cli-status.ts +++ b/src/agent/readiness/providers/cli-status.ts @@ -1,15 +1,12 @@ -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; import type { SpawnCliFn } from "../../types.js"; +import { commandExists, crossSpawn } from "../../../process/exec.js"; const STATUS_TIMEOUT_MS = 3_000; -export function commandExists(command: string): boolean { - const result = spawnSync("sh", ["-lc", `command -v ${command}`], { - encoding: "utf8", - }); - return result.status === 0 && result.stdout.trim().length > 0; -} +// Re-exported so providers keep a single import site for PATH detection. +export { commandExists }; export function runInjectedStatusCommand( spawnCli: SpawnCliFn, @@ -75,7 +72,7 @@ export function runStatusCommand( resolve(value); }; try { - child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + child = crossSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); resolve({ ok: false, detail: msg }); diff --git a/src/harness/providers/codex/exec.ts b/src/harness/providers/codex/exec.ts index 1eeab697..c4ffc42f 100644 --- a/src/harness/providers/codex/exec.ts +++ b/src/harness/providers/codex/exec.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { crossSpawn } from "../../../process/exec.js"; import type { HarnessResult } from "../../events.js"; import type { HarnessRunHooks } from "../../types.js"; @@ -15,7 +15,7 @@ export function runCodexCli( hooks?: HarnessRunHooks, ): Promise { return new Promise((resolve) => { - const child = spawn(request.command, request.args, { + const child = crossSpawn(request.command, request.args, { cwd: request.cwd, env: request.env, stdio: ["ignore", "pipe", "pipe"], @@ -50,11 +50,11 @@ export function runCodexCli( } }; - child.stdout.on("data", (chunk) => { + child.stdout?.on("data", (chunk) => { stdoutBuf += chunk.toString("utf8"); flushLines(); }); - child.stderr.on("data", (chunk) => { + child.stderr?.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.on("error", (err: NodeJS.ErrnoException) => { diff --git a/src/harness/providers/codex/status.ts b/src/harness/providers/codex/status.ts index 753a2a9e..def1b176 100644 --- a/src/harness/providers/codex/status.ts +++ b/src/harness/providers/codex/status.ts @@ -1,10 +1,9 @@ -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { type ChildProcess } from "node:child_process"; + +import { commandExists, crossSpawn } from "../../../process/exec.js"; export function defaultCommandExists(command: string): boolean { - const result = spawnSync("sh", ["-lc", `command -v ${command}`], { - encoding: "utf8", - }); - return result.status === 0 && result.stdout.trim().length > 0; + return commandExists(command); } export function defaultRunStatus( @@ -16,7 +15,7 @@ export function defaultRunStatus( let stdout = ""; let stderr = ""; try { - child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + child = crossSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (err: unknown) { resolve({ ok: false, diff --git a/src/paths.ts b/src/paths.ts index 043532c8..e82e617c 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -48,9 +48,18 @@ export function getRepoAlmanacDir(cwd: string): string { * init` anywhere inside their home directory (outside a real wiki), we * must NOT treat `~` as an enclosing wiki root. Otherwise init would try * to register the home dir itself as a wiki. + * + * When the walk starts inside the user's home directory we stop at the + * home boundary rather than ascending into system directories above it: a + * wiki located *above* your home is never the one you mean, and the only + * `.almanac` at home itself is the global state dir (skipped above). This + * also keeps tests hermetic on Windows, where the OS temp dir lives under + * the home directory and would otherwise let a sandbox walk into the real + * `~/.almanac`. */ export function findNearestAlmanacDir(startDir: string): string | null { const globalDir = getGlobalAlmanacDir(); + const home = homedir(); let current = isAbsolute(startDir) ? startDir : resolve(startDir); // Walk until we hit the filesystem root. `dirname("/")` returns `"/"`, @@ -60,6 +69,11 @@ export function findNearestAlmanacDir(startDir: string): string | null { if (candidate !== globalDir && existsSync(candidate)) { return current; } + // Do not ascend above the user's home directory (the global + // `~/.almanac` was already skipped just above). + if (current === home) { + return null; + } const parent = dirname(current); if (parent === current) { return null; diff --git a/src/process/exec.ts b/src/process/exec.ts new file mode 100644 index 00000000..ecd949c1 --- /dev/null +++ b/src/process/exec.ts @@ -0,0 +1,177 @@ +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +/** + * Cross-platform process helpers. + * + * The codebase used to shell out to `sh -lc 'command -v X'` to test for an + * executable on PATH. That assumes a POSIX shell, which native Windows / + * PowerShell does not have, so every provider was reported "not found". + * + * `resolveExecutable`/`commandExists` replace that with a pure-Node PATH scan + * (PATHEXT-aware on Windows). No subprocess is spawned, so it works the same + * on every platform and is faster. + * + * `crossSpawn` is the single place that knows the Windows quirk that npm's + * global bins are `.cmd`/`.ps1` shims which Node ≥20 refuses to spawn without + * `shell: true`. + */ + +export interface ResolveOptions { + /** Override platform; defaults to `process.platform`. For tests. */ + platform?: NodeJS.Platform; + /** Override environment; defaults to `process.env`. For tests. */ + env?: NodeJS.ProcessEnv; + /** Override the on-disk check; defaults to a real "is a file" probe. */ + fileExists?: (candidate: string) => boolean; +} + +const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"; + +function defaultFileExists(candidate: string): boolean { + try { + return existsSync(candidate) && statSync(candidate).isFile(); + } catch { + return false; + } +} + +/** + * Read the PATH value regardless of casing. Windows exposes it as `Path` + * through `process.env`, but an injected env (or a child's inherited env) + * may use either key. + */ +function readPath(env: NodeJS.ProcessEnv): string { + return env.PATH ?? env.Path ?? env.path ?? ""; +} + +function windowsExtensions(env: NodeJS.ProcessEnv): string[] { + const raw = env.PATHEXT ?? env.Pathext ?? DEFAULT_WINDOWS_PATHEXT; + return raw + .split(";") + .map((ext) => ext.trim().toLowerCase()) + .filter((ext) => ext.length > 0); +} + +/** + * Resolve a command to a full executable path by scanning PATH, or return + * `undefined` if it is not found. Mirrors what a shell does on `command -v` + * (POSIX) or via PATHEXT (Windows). + */ +export function resolveExecutable( + command: string, + options: ResolveOptions = {}, +): string | undefined { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const fileExists = options.fileExists ?? defaultFileExists; + const isWindows = platform === "win32"; + const pathlib = isWindows ? path.win32 : path.posix; + + const candidatesFor = (base: string): string[] => { + if (!isWindows) return [base]; + const exts = windowsExtensions(env); + const hasKnownExt = exts.includes(pathlib.extname(base).toLowerCase()); + // If the command already carries a runnable extension, trust it as-is. + // Otherwise try each PATHEXT extension in order (PATHEXT defines the + // precedence, e.g. `.EXE` before `.CMD`). + return hasKnownExt ? [base] : exts.map((ext) => `${base}${ext}`); + }; + + const firstExisting = (bases: string[]): string | undefined => { + for (const base of bases) { + for (const candidate of candidatesFor(base)) { + if (fileExists(candidate)) return candidate; + } + } + return undefined; + }; + + // A command that already contains a path separator is resolved directly + // against the filesystem rather than against PATH. + if (command.includes("/") || (isWindows && command.includes("\\"))) { + return firstExisting([command]); + } + + const dirs = readPath(env) + .split(isWindows ? ";" : ":") + .map((dir) => dir.trim()) + .filter((dir) => dir.length > 0); + + for (const dir of dirs) { + const resolved = firstExisting([pathlib.join(dir, command)]); + if (resolved !== undefined) return resolved; + } + return undefined; +} + +/** Whether a command resolves to a runnable executable on PATH. */ +export function commandExists( + command: string, + options: ResolveOptions = {}, +): boolean { + return resolveExecutable(command, options) !== undefined; +} + +export interface CrossSpawnOptions extends SpawnOptions { + /** Override platform; defaults to `process.platform`. For tests. */ + platform?: NodeJS.Platform; +} + +/** + * Quote an argument so it survives cmd.exe re-parsing under `shell: true`. + * Node does not escape args when `shell` is enabled — it joins them with + * spaces — so any arg containing whitespace or a cmd metacharacter must be + * wrapped. The live callers pass only simple flags, but quoting keeps the + * helper honest if that changes. + */ +export function quoteWindowsArg(arg: string): string { + if (arg.length === 0) return '""'; + if (!/[\s"^&|<>()%!]/u.test(arg)) return arg; + return `"${arg.replaceAll('"', '\\"')}"`; +} + +/** + * Spawn a child process, transparently handling Windows command shims. + * + * On Windows, npm installs CLIs (codex, claude, cursor-agent) as `.cmd`/`.ps1` + * shims, and Node ≥20 refuses to spawn those without `shell: true`. We enable + * the shell and let cmd.exe resolve the shim via PATHEXT, quoting args so they + * are not re-split. + * + * NOTE: with `shell: true`, multi-line or metacharacter-heavy args cannot be + * passed reliably to a `.cmd` shim — a Windows command-line limitation, not a + * quoting bug. The live run path (Codex app-server) sends prompts over stdio, + * so only simple flag args reach the command line here. + */ +const WINDOWS_SHIM_EXTENSIONS = new Set([".cmd", ".bat", ".ps1"]); + +/** Whether a Windows command must be launched through a shell to run. */ +export function needsWindowsShell( + command: string, + options: ResolveOptions = {}, +): boolean { + const resolved = resolveExecutable(command, options) ?? command; + return WINDOWS_SHIM_EXTENSIONS.has(path.win32.extname(resolved).toLowerCase()); +} + +export function crossSpawn( + command: string, + args: readonly string[], + options: CrossSpawnOptions = {}, +): ChildProcess { + const { platform = process.platform, ...spawnOptions } = options; + if (platform === "win32" && needsWindowsShell(command, { platform })) { + // npm `.cmd`/`.ps1` shims need a shell on Node ≥20. Let cmd.exe resolve + // the bare command via PATHEXT and quote args so they are not re-split. + return spawn(command, args.map(quoteWindowsArg), { + ...spawnOptions, + shell: true, + }); + } + // A directly-runnable executable (.exe, node, an absolute path) is spawned + // without a shell so the child's pid is the real process — important for + // process-group termination. + return spawn(command, [...args], spawnOptions); +} diff --git a/src/process/process-group.ts b/src/process/process-group.ts index 17195685..17436a4d 100644 --- a/src/process/process-group.ts +++ b/src/process/process-group.ts @@ -1,6 +1,9 @@ -import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { spawnSync, type ChildProcess, type SpawnOptions } from "node:child_process"; + +import { crossSpawn } from "./exec.js"; const DEFAULT_GRACE_MS = 2_000; +const IS_WINDOWS = process.platform === "win32"; export interface ProcessGroupChild extends ChildProcess { readonly processGroupId?: number; @@ -17,9 +20,14 @@ export function spawnInProcessGroup( args: readonly string[], options: SpawnOptions, ): ProcessGroupChild { - const child = spawn(command, [...args], { + // On POSIX, `detached: true` puts the child in its own process group so we + // can signal the whole group. On Windows we deliberately do NOT detach: + // a detached child gets a new console that breaks piped stdio, and we + // terminate the tree by pid via `taskkill /T` instead. crossSpawn handles + // Windows `.cmd`/`.ps1` shims. + const child = crossSpawn(command, args, { ...options, - detached: true, + detached: !IS_WINDOWS, }) as ProcessGroupChild; const processGroupId = child.pid; Object.defineProperty(child, "processGroupId", { @@ -29,6 +37,16 @@ export function spawnInProcessGroup( const killProcess = child.kill.bind(child); child.kill = ((signal: NodeJS.Signals = "SIGTERM") => { + // Windows has no process-group signals; force-kill the tree by pid. + if (IS_WINDOWS) { + const killedTree = killWindowsTree(child); + try { + return killProcess(signal) || killedTree; + } catch (err: unknown) { + if (isProcessUnavailableError(err)) return killedTree; + throw err; + } + } const sentGroupSignal = sendProcessGroupSignal(child, signal); try { return killProcess(signal) || sentGroupSignal; @@ -77,6 +95,20 @@ export function attachAbortSignalToProcessGroup( return remove; } +/** + * Force-terminate a child and all of its descendants on Windows via + * `taskkill /T /F`. Windows offers no graceful group signal, so this is + * always a hard kill — acceptable for run cancellation. + */ +function killWindowsTree(child: ProcessGroupChild): boolean { + const pid = child.pid; + if (pid === undefined) return false; + const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + }); + return result.status === 0; +} + function sendProcessGroupSignal( child: ProcessGroupChild, signal: NodeJS.Signals, @@ -99,8 +131,11 @@ function hasExited(child: ChildProcess): boolean { function isProcessGroupAlive(child: ProcessGroupChild): boolean { const processGroupId = child.processGroupId ?? child.pid; if (processGroupId === undefined) return false; + // On Windows we can only probe the single pid (no process groups); once the + // root has exited the taskkill tree is gone too. + const probeTarget = IS_WINDOWS ? processGroupId : -processGroupId; try { - process.kill(-processGroupId, 0); + process.kill(probeTarget, 0); return true; } catch (err: unknown) { return !isProcessUnavailableError(err); diff --git a/test/autoregister.test.ts b/test/autoregister.test.ts index ab88c435..a37c124b 100644 --- a/test/autoregister.test.ts +++ b/test/autoregister.test.ts @@ -1,5 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { initWiki } from "../src/commands/init.js"; @@ -101,7 +101,7 @@ describe("autoRegisterIfNeeded", () => { // Corrupt the registry on disk. const registryPath = getRegistryPath(); - await mkdir(registryPath.replace(/\/registry\.json$/, ""), { + await mkdir(dirname(registryPath), { recursive: true, }); await writeFile(registryPath, "garbage{", "utf8"); diff --git a/test/codex-harness-provider.test.ts b/test/codex-harness-provider.test.ts index d2942e84..63d6707e 100644 --- a/test/codex-harness-provider.test.ts +++ b/test/codex-harness-provider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { chmod, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { applyCodexJsonlEvent, @@ -16,6 +16,7 @@ import { } from "../src/harness/providers/codex.js"; import type { AgentRunSpec } from "../src/harness/types.js"; import { + addWindowsCmdShim, createProcessTreeFixture, isProcessAlive, waitForDead, @@ -406,8 +407,9 @@ rl.on("line", (line) => { `, ); await chmod(codexPath, 0o755); + await addWindowsCmdShim(binDir, "codex"); const oldPath = process.env.PATH; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; try { const events: unknown[] = []; await expect( @@ -526,8 +528,9 @@ rl.on("line", (line) => { `, ); await chmod(codexPath, 0o755); + await addWindowsCmdShim(binDir, "codex"); const oldPath = process.env.PATH; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; try { const events: unknown[] = []; await expect( @@ -585,9 +588,10 @@ setInterval(() => {}, 1000); `, ); await chmod(codexPath, 0o755); + await addWindowsCmdShim(binDir, "codex"); const oldPath = process.env.PATH; const oldTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS = "25"; try { await expect( @@ -638,9 +642,10 @@ setInterval(() => {}, 1000); `, ); await chmod(codexPath, 0o755); + await addWindowsCmdShim(binDir, "codex"); const oldPath = process.env.PATH; const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "25"; try { await expect( @@ -705,9 +710,10 @@ setInterval(() => {}, 1000); `, ); await chmod(codexPath, 0o755); + await addWindowsCmdShim(binDir, "codex"); const oldPath = process.env.PATH; const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "250"; try { const run = runCodexAppServer({ @@ -764,9 +770,10 @@ rl.on("line", (line) => { `, ); await chmod(codexPath, 0o755); + await addWindowsCmdShim(binDir, "codex"); const oldPath = process.env.PATH; const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "25"; try { await expect( diff --git a/test/helpers.ts b/test/helpers.ts index e5a7af6e..d0157288 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -76,6 +76,25 @@ export async function writePage( return path; } +/** + * A fake CLI written for tests is a `#!/usr/bin/env node` shebang script, + * which Windows cannot execute directly. This adds a `.cmd` shim next to it + * (like npm's global bins) that runs the script through `node`, so the same + * fixture works on Windows — and exercises the production `.cmd` shim path. + * No-op on POSIX. + */ +export async function addWindowsCmdShim( + binDir: string, + name: string, +): Promise { + if (process.platform !== "win32") return; + await writeFile( + join(binDir, `${name}.cmd`), + `@echo off\r\nnode "%~dp0${name}" %*\r\n`, + "utf8", + ); +} + export async function createProcessTreeFixture(prefix: string): Promise { const dir = await mkdtemp(join(tmpdir(), prefix)); const grandchild = join(dir, "grandchild.js"); diff --git a/test/list.test.ts b/test/list.test.ts index 23bf5a75..feb96863 100644 --- a/test/list.test.ts +++ b/test/list.test.ts @@ -37,7 +37,9 @@ describe("almanac list", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toMatch(/alpha/); expect(result.stdout).toMatch(/first wiki/); - expect(result.stdout).toMatch(new RegExp(repo.replace(/\//g, "\\/"))); + // Compare the path literally — building a RegExp from a Windows path + // would treat its backslashes as regex escapes. + expect(result.stdout).toContain(repo); }); }); diff --git a/test/process-exec.test.ts b/test/process-exec.test.ts new file mode 100644 index 00000000..0a1eb6cf --- /dev/null +++ b/test/process-exec.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { commandExists, resolveExecutable } from "../src/process/exec.js"; + +/** + * These tests inject `platform`, `env`, and `fileExists` so they exercise + * Windows resolution logic on any host (CI runs on Linux too). No real + * subprocess or filesystem is touched. + */ + +describe("resolveExecutable — POSIX", () => { + const env = { PATH: "/usr/local/bin:/usr/bin:/bin" }; + + it("finds a bare command on PATH", () => { + const present = new Set(["/usr/bin/codex"]); + const resolved = resolveExecutable("codex", { + platform: "linux", + env, + fileExists: (p) => present.has(p), + }); + expect(resolved).toBe("/usr/bin/codex"); + }); + + it("returns undefined when absent", () => { + const resolved = resolveExecutable("codex", { + platform: "linux", + env, + fileExists: () => false, + }); + expect(resolved).toBeUndefined(); + }); + + it("does not append Windows extensions on POSIX", () => { + const present = new Set(["/usr/bin/codex.cmd"]); + const resolved = resolveExecutable("codex", { + platform: "linux", + env, + fileExists: (p) => present.has(p), + }); + expect(resolved).toBeUndefined(); + }); +}); + +describe("resolveExecutable — Windows", () => { + const env = { + Path: "C:\\Windows\\system32;C:\\Users\\dev\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }; + + it("resolves an npm .cmd shim from a bare command", () => { + const shim = "C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd"; + const resolved = resolveExecutable("codex", { + platform: "win32", + env, + fileExists: (p) => p === shim, + }); + expect(resolved).toBe(shim); + }); + + it("prefers .EXE over .CMD per PATHEXT order", () => { + const dir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; + const present = new Set([`${dir}\\codex.exe`, `${dir}\\codex.cmd`]); + const resolved = resolveExecutable("codex", { + platform: "win32", + env, + fileExists: (p) => present.has(p), + }); + expect(resolved).toBe(`${dir}\\codex.exe`); + }); + + it("reads PATH when Path key is absent", () => { + const shim = "C:\\bin\\codex.cmd"; + const resolved = resolveExecutable("codex", { + platform: "win32", + env: { PATH: "C:\\bin", PATHEXT: ".CMD" }, + fileExists: (p) => p === shim, + }); + expect(resolved).toBe(shim); + }); + + it("honors an extension already present on the command", () => { + const shim = "C:\\bin\\codex.cmd"; + const resolved = resolveExecutable("codex.cmd", { + platform: "win32", + env: { PATH: "C:\\bin", PATHEXT: ".CMD" }, + fileExists: (p) => p === shim, + }); + expect(resolved).toBe(shim); + }); + + it("falls back to a default PATHEXT when env lacks one", () => { + const shim = "C:\\bin\\codex.cmd"; + const resolved = resolveExecutable("codex", { + platform: "win32", + env: { Path: "C:\\bin" }, + fileExists: (p) => p === shim, + }); + expect(resolved).toBe(shim); + }); + + it("returns undefined when only a non-PATHEXT extension exists", () => { + // npm also drops a bare `codex` (no ext, a shell script) on Windows; + // it is not directly runnable and must not count as found. + const resolved = resolveExecutable("codex", { + platform: "win32", + env, + fileExists: (p) => p === "C:\\Users\\dev\\AppData\\Roaming\\npm\\codex", + }); + expect(resolved).toBeUndefined(); + }); +}); + +describe("commandExists", () => { + it("is true when resolvable", () => { + expect( + commandExists("codex", { + platform: "win32", + env: { Path: "C:\\bin", PATHEXT: ".CMD" }, + fileExists: (p) => p === "C:\\bin\\codex.cmd", + }), + ).toBe(true); + }); + + it("is false when not resolvable", () => { + expect( + commandExists("codex", { + platform: "linux", + env: { PATH: "/usr/bin" }, + fileExists: () => false, + }), + ).toBe(false); + }); +}); diff --git a/test/registry.test.ts b/test/registry.test.ts index 8159d237..8f17322f 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import { describe, expect, it } from "vitest"; import { getRegistryPath } from "../src/paths.js"; @@ -131,7 +132,7 @@ describe("registry", () => { it("tolerates an empty registry file", async () => { await withTempHome(async () => { const path = getRegistryPath(); - await mkdir(path.replace(/\/registry\.json$/, ""), { recursive: true }); + await mkdir(dirname(path), { recursive: true }); await writeFile(path, "", "utf8"); expect(await readRegistry()).toEqual([]); }); @@ -140,7 +141,7 @@ describe("registry", () => { it("refuses to silently accept malformed JSON", async () => { await withTempHome(async () => { const path = getRegistryPath(); - await mkdir(path.replace(/\/registry\.json$/, ""), { recursive: true }); + await mkdir(dirname(path), { recursive: true }); await writeFile(path, "not json", "utf8"); await expect(readRegistry()).rejects.toThrow(/not valid JSON/); }); @@ -163,7 +164,7 @@ describe("registry", () => { it("rejects entries missing a non-empty name", async () => { await withTempHome(async () => { const path = getRegistryPath(); - await mkdir(path.replace(/\/registry\.json$/, ""), { recursive: true }); + await mkdir(dirname(path), { recursive: true }); await writeFile( path, JSON.stringify([{ path: "/x", description: "", registered_at: "" }]), @@ -176,7 +177,7 @@ describe("registry", () => { it("rejects entries missing a non-empty path", async () => { await withTempHome(async () => { const path = getRegistryPath(); - await mkdir(path.replace(/\/registry\.json$/, ""), { recursive: true }); + await mkdir(dirname(path), { recursive: true }); await writeFile( path, JSON.stringify([{ name: "x", description: "", registered_at: "" }]), diff --git a/test/show.test.ts b/test/show.test.ts index 338d755b..3e95264e 100644 --- a/test/show.test.ts +++ b/test/show.test.ts @@ -391,7 +391,7 @@ describe("almanac show — single field flags (bare output)", () => { path: true, }); expect(r.stdout.trim()).toMatch( - /\/\.almanac\/pages\/checkout-flow\.md$/, + /[\\/]\.almanac[\\/]pages[\\/]checkout-flow\.md$/, ); }); }); From e964a04ffdc8066729f66748f01895ec06a256ce Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 21 Jun 2026 14:31:58 -0400 Subject: [PATCH 2/5] feat(windows): Windows Task Scheduler automation + platform-aware setup macOS schedules auto-capture/garden/update via launchd plists; Windows had no equivalent, so `almanac setup`/`automation` failed natively. - Add src/commands/automation/windows.ts: install/status/uninstall via `schtasks`, recording a JSON manifest per task under `~/.almanac/automation/` so status/doctor can report what was installed. - Branch runAutomationInstall/Uninstall/Status on a `platform` option (defaults to process.platform), delegating to the Windows adapter. - setup/automation-step: thread `platform`; ephemeral installs use `almanac.cmd` program args on Windows instead of `/usr/bin/env almanac`. - uninstall: thread `platform` to the scheduler uninstall. - Tests pin launchd cases to platform "darwin" (so they keep exercising launchd on any host) and add a Windows Task Scheduler suite. Co-Authored-By: Claude Opus 4.8 --- src/commands/automation.ts | 62 +++++- src/commands/automation/windows.ts | 291 ++++++++++++++++++++++++++ src/commands/setup.ts | 2 + src/commands/setup/automation-step.ts | 25 ++- src/commands/uninstall.ts | 3 + test/automation.test.ts | 107 ++++++++++ test/setup.test.ts | 18 +- test/uninstall.test.ts | 5 + 8 files changed, 504 insertions(+), 9 deletions(-) create mode 100644 src/commands/automation/windows.ts diff --git a/src/commands/automation.ts b/src/commands/automation.ts index 7602379a..aa0f60d7 100644 --- a/src/commands/automation.ts +++ b/src/commands/automation.ts @@ -1,6 +1,8 @@ +import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { bootstrapLaunchdJob, @@ -33,8 +35,20 @@ import type { CommandResult } from "../cli/helpers.js"; import { ensureAutomationCaptureSince } from "../config/index.js"; import { parseDuration } from "../indexer/duration.js"; import { findNearestAlmanacDir } from "../paths.js"; +import { + installWindowsAutomation, + statusWindowsAutomation, + uninstallWindowsAutomation, + type WindowsAutomationJob, +} from "./automation/windows.js"; export { cleanupLegacyHooks } from "../automation/legacy-hooks.js"; +export { + defaultWindowsCaptureManifestPath, + readWindowsManifest, + windowsManifestPath, + windowsTaskName, +} from "./automation/windows.js"; export interface AutomationOptions { tasks?: ScheduledTaskId[]; @@ -54,6 +68,8 @@ export interface AutomationOptions { exec?: ExecFn; now?: Date; configPath?: string; + /** Override scheduler platform; production uses `process.platform`. */ + platform?: NodeJS.Platform; } export interface AutomationStatusOptions { @@ -63,6 +79,8 @@ export interface AutomationStatusOptions { gardenPlistPath?: string; updatePlistPath?: string; exec?: ExecFn; + /** Override scheduler platform; production uses `process.platform`. */ + platform?: NodeJS.Platform; } interface PlannedAutomationJob { @@ -82,6 +100,25 @@ const TASK_LABELS: Record = { update: "auto-update automation", }; +const execFileAsync = promisify(execFile); + +async function defaultWindowsExec( + file: string, + args: string[], +): Promise<{ stdout?: string; stderr?: string }> { + return await execFileAsync(file, args); +} + +function toWindowsJob(planned: PlannedAutomationJob): WindowsAutomationJob { + return { + taskId: planned.task.id, + intervalInput: planned.intervalInput, + intervalSeconds: planned.job.intervalSeconds, + programArguments: planned.job.programArguments, + workingDirectory: planned.job.workingDirectory, + }; +} + export async function runAutomationInstall( options: AutomationOptions = {}, ): Promise { @@ -90,8 +127,6 @@ export async function runAutomationInstall( return { stdout: "", stderr: `almanac: ${plan.error}\n`, exitCode: 1 }; } - await writeAutomationPlists(plan.value); - const captureJob = plan.value.jobs.find((job) => job.task.id === "capture"); const captureSince = captureJob === undefined ? null @@ -99,6 +134,19 @@ export async function runAutomationInstall( (options.now ?? new Date()).toISOString(), options.configPath, ); + + if ((options.platform ?? process.platform) === "win32") { + return installWindowsAutomation({ + home: options.homeDir ?? homedir(), + jobs: plan.value.jobs.map(toWindowsJob), + disabledTaskIds: plan.value.disabledGardenPlistPath !== null ? ["garden"] : [], + captureSince, + exec: options.exec ?? defaultWindowsExec, + }); + } + + await writeAutomationPlists(plan.value); + const activated = await activateAutomationJobs(plan.value, options.exec); if (!activated.ok) { return activated.result; @@ -116,6 +164,13 @@ export async function runAutomationUninstall( ): Promise { const home = options.homeDir ?? homedir(); const tasks = selectedTaskIds(options.tasks, false); + if ((options.platform ?? process.platform) === "win32") { + return uninstallWindowsAutomation({ + home, + taskIds: tasks, + exec: options.exec ?? defaultWindowsExec, + }); + } const exec = options.exec; const removed: string[] = []; for (const task of tasks.map((id) => scheduledTaskDefinition(id))) { @@ -145,6 +200,9 @@ export async function runAutomationStatus( ): Promise { const home = options.homeDir ?? homedir(); const tasks = selectedTaskIds(options.tasks, false); + if ((options.platform ?? process.platform) === "win32") { + return statusWindowsAutomation({ home, taskIds: tasks }); + } const sections: string[] = []; for (const task of tasks.map((id) => scheduledTaskDefinition(id))) { const status = await readLaunchdJobStatus({ diff --git a/src/commands/automation/windows.ts b/src/commands/automation/windows.ts new file mode 100644 index 00000000..1569cb6f --- /dev/null +++ b/src/commands/automation/windows.ts @@ -0,0 +1,291 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +import type { ExecFn } from "../../automation/launchd.js"; +import type { ScheduledTaskId } from "../../automation/tasks.js"; +import type { CommandResult } from "../../cli/helpers.js"; + +/** + * Windows scheduler adapter. + * + * macOS schedules automation through launchd plists; Windows uses Task + * Scheduler via `schtasks`. We mirror the plist model by recording a small + * JSON manifest per task under `~/.almanac/automation/` so `status` and + * `doctor` can report what was installed without re-querying the scheduler. + */ + +const WINDOWS_TASK_NAMES: Record = { + capture: "\\CodeAlmanac\\CaptureSweep", + garden: "\\CodeAlmanac\\Garden", + update: "\\CodeAlmanac\\Update", +}; + +const TASK_LABELS: Record = { + capture: "auto-capture automation", + garden: "garden automation", + update: "auto-update automation", +}; + +export interface WindowsAutomationJob { + taskId: ScheduledTaskId; + intervalInput: string; + intervalSeconds: number; + programArguments: string[]; + workingDirectory?: string; +} + +export interface WindowsAutomationManifest { + scheduler: "windows-task-scheduler"; + taskName: string; + command: string[]; + intervalSeconds: number; + workingDirectory?: string; +} + +export function windowsManifestPath( + taskId: ScheduledTaskId, + home: string = homedir(), +): string { + return path.join(home, ".almanac", "automation", `windows-${taskId}.json`); +} + +export function defaultWindowsCaptureManifestPath(home: string = homedir()): string { + return windowsManifestPath("capture", home); +} + +export function windowsTaskName(taskId: ScheduledTaskId): string { + return WINDOWS_TASK_NAMES[taskId]; +} + +export async function installWindowsAutomation(args: { + home: string; + jobs: WindowsAutomationJob[]; + disabledTaskIds: ScheduledTaskId[]; + captureSince: string | null; + exec: ExecFn; +}): Promise { + // Validate every interval before creating any task so a bad value does not + // leave a half-installed schedule. + for (const job of args.jobs) { + const schedule = windowsSchedule(job.intervalSeconds); + if (!schedule.ok) { + return { stdout: "", stderr: `almanac: ${schedule.error}\n`, exitCode: 1 }; + } + } + + await mkdir(path.join(args.home, ".almanac", "automation"), { recursive: true }); + + for (const job of args.jobs) { + const taskName = WINDOWS_TASK_NAMES[job.taskId]; + const schedule = windowsSchedule(job.intervalSeconds); + if (!schedule.ok) continue; // already validated above + try { + await args.exec("schtasks", [ + "/Create", + "/TN", + taskName, + ...schedule.args, + "/TR", + windowsTaskCommand(job.programArguments, { + workingDirectory: job.workingDirectory, + }), + "/F", + ]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + stdout: "", + stderr: `almanac: ${TASK_LABELS[job.taskId]} schtasks create failed: ${msg}\n`, + exitCode: 1, + }; + } + await writeWindowsManifest(windowsManifestPath(job.taskId, args.home), { + scheduler: "windows-task-scheduler", + taskName, + command: job.programArguments, + intervalSeconds: job.intervalSeconds, + workingDirectory: job.workingDirectory, + }); + } + + for (const taskId of args.disabledTaskIds) { + await deleteWindowsTask(taskId, args.home, args.exec); + } + + return { + stdout: formatWindowsInstall(args.jobs, args.disabledTaskIds, args.captureSince, args.home), + stderr: "", + exitCode: 0, + }; +} + +export async function uninstallWindowsAutomation(args: { + home: string; + taskIds: ScheduledTaskId[]; + exec: ExecFn; +}): Promise { + const removed: string[] = []; + for (const taskId of args.taskIds) { + const manifestPath = windowsManifestPath(taskId, args.home); + if (!existsSync(manifestPath)) continue; + await deleteWindowsTask(taskId, args.home, args.exec); + removed.push(manifestPath); + } + if (removed.length === 0) { + return { stdout: "almanac: automation not installed\n", stderr: "", exitCode: 0 }; + } + return { + stdout: + `almanac: automation removed\n` + + removed.map((pathValue) => ` manifest: ${pathValue}\n`).join(""), + stderr: "", + exitCode: 0, + }; +} + +export async function statusWindowsAutomation(args: { + home: string; + taskIds: ScheduledTaskId[]; +}): Promise { + const sections: string[] = []; + for (const taskId of args.taskIds) { + const manifest = await readWindowsManifest(windowsManifestPath(taskId, args.home)); + sections.push(formatWindowsStatus(TASK_LABELS[taskId], manifest)); + } + return { stdout: sections.join(""), stderr: "", exitCode: 0 }; +} + +async function deleteWindowsTask( + taskId: ScheduledTaskId, + home: string, + exec: ExecFn, +): Promise { + try { + await exec("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAMES[taskId], "/F"]); + } catch { + // Already absent is still a successful disable/uninstall. + } + await rm(windowsManifestPath(taskId, home), { force: true }); +} + +function formatWindowsInstall( + jobs: WindowsAutomationJob[], + disabledTaskIds: ScheduledTaskId[], + captureSince: string | null, + home: string, +): string { + const lines = ["almanac: automation installed", " scheduler: Windows Task Scheduler"]; + for (const job of jobs) { + lines.push(` ${job.taskId} interval: ${job.intervalInput}`); + if (job.taskId === "capture") { + const quiet = readArgument(job.programArguments, "--quiet"); + if (quiet !== null) lines.push(` capture quiet: ${quiet}`); + if (captureSince !== null) { + lines.push(` capturing transcripts after: ${captureSince}`); + } + } + lines.push(` ${job.taskId} command: ${job.programArguments.join(" ")}`); + lines.push(` ${job.taskId} task: ${WINDOWS_TASK_NAMES[job.taskId]}`); + lines.push(` ${job.taskId} manifest: ${windowsManifestPath(job.taskId, home)}`); + } + for (const taskId of disabledTaskIds) { + lines.push(` ${taskId}: disabled`); + } + return `${lines.join("\n")}\n`; +} + +function formatWindowsStatus( + label: string, + manifest: WindowsAutomationManifest | null, +): string { + if (manifest === null) return `${label}: not installed\n`; + const quiet = readArgument(manifest.command, "--quiet"); + return ( + `${label}: installed\n` + + ` scheduler: Windows Task Scheduler\n` + + ` task: ${manifest.taskName}\n` + + ` interval: ${manifest.intervalSeconds}s\n` + + (quiet !== null ? ` quiet: ${quiet}\n` : "") + ); +} + +function readArgument(args: string[], flag: string): string | null { + const index = args.indexOf(flag); + if (index < 0) return null; + return args[index + 1] ?? null; +} + +async function writeWindowsManifest( + manifestPath: string, + manifest: WindowsAutomationManifest, +): Promise { + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +} + +export async function readWindowsManifest( + manifestPath: string, +): Promise { + if (!existsSync(manifestPath)) return null; + try { + const parsed = JSON.parse(await readFile(manifestPath, "utf8")) as Partial; + if ( + parsed.scheduler === "windows-task-scheduler" && + typeof parsed.taskName === "string" && + Array.isArray(parsed.command) && + parsed.command.every((arg) => typeof arg === "string") && + typeof parsed.intervalSeconds === "number" + ) { + return { + scheduler: "windows-task-scheduler", + taskName: parsed.taskName, + command: parsed.command, + intervalSeconds: parsed.intervalSeconds, + workingDirectory: + typeof parsed.workingDirectory === "string" ? parsed.workingDirectory : undefined, + }; + } + } catch { + return null; + } + return null; +} + +/** + * Translate an interval in seconds to schtasks `/SC` arguments. Task + * Scheduler accepts whole-minute intervals from 1 to 1439, or whole-day + * intervals — anything else is rejected with a clear error. + */ +export function windowsSchedule( + seconds: number, +): { ok: true; args: string[] } | { ok: false; error: string } { + if (seconds % 60 === 0 && seconds / 60 >= 1 && seconds / 60 <= 1439) { + return { ok: true, args: ["/SC", "MINUTE", "/MO", String(seconds / 60)] }; + } + const daySeconds = 24 * 60 * 60; + if (seconds % daySeconds === 0 && seconds / daySeconds >= 1 && seconds / daySeconds <= 365) { + return { ok: true, args: ["/SC", "DAILY", "/MO", String(seconds / daySeconds)] }; + } + return { + ok: false, + error: + "Windows Task Scheduler automation interval must be whole minutes up to 1439 minutes, or whole days", + }; +} + +function windowsTaskCommand( + args: string[], + options: { workingDirectory?: string } = {}, +): string { + const command = args.map(quoteWindowsTaskArg).join(" "); + if (options.workingDirectory === undefined) return command; + const cdCommand = `cd /d ${quoteWindowsTaskArg(options.workingDirectory)}`; + return `cmd.exe /d /s /c "${cdCommand} && ${command}"`; +} + +function quoteWindowsTaskArg(arg: string): string { + if (arg.length === 0) return '""'; + if (!/[\s"\\:]/u.test(arg)) return arg; + return `"${arg.replaceAll('"', '\\"')}"`; +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f598d7bd..517a8cff 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -100,6 +100,8 @@ export interface SetupOptions { updatePlistPath?: string; /** Override launchctl execution. */ automationExec?: AutomationExecFn; + /** Override scheduler platform; production uses `process.platform`. */ + platform?: NodeJS.Platform; /** Override `~/.claude/` dir for guide install. */ claudeDir?: string; /** Override `~/.codex/` dir for Codex instruction install. */ diff --git a/src/commands/setup/automation-step.ts b/src/commands/setup/automation-step.ts index bff9996b..75c43d5c 100644 --- a/src/commands/setup/automation-step.ts +++ b/src/commands/setup/automation-step.ts @@ -27,6 +27,8 @@ export interface AutomationSetupStepOptions { gardenPlistPath?: string; updatePlistPath?: string; automationExec?: AutomationExecFn; + /** Override scheduler platform; production uses `process.platform`. */ + platform?: NodeJS.Platform; } export type SetupStepResult = @@ -59,6 +61,7 @@ export async function runAutomationSetupStep(args: { ); } else { await cleanupLegacyHooks(); + const platform = args.options.platform ?? process.platform; const res = await runAutomationInstall({ tasks: ["capture", "garden"], every: args.options.automationEvery, @@ -67,14 +70,15 @@ export async function runAutomationSetupStep(args: { gardenOff: args.options.gardenOff, cwd: process.cwd(), programArguments: args.ephemeral - ? globalAlmanacProgramArguments(args.options.automationQuiet) + ? globalAlmanacProgramArguments(platform, args.options.automationQuiet) : undefined, gardenProgramArguments: args.ephemeral - ? globalGardenProgramArguments() + ? globalGardenProgramArguments(platform) : undefined, plistPath: args.options.automationPlistPath, gardenPlistPath: args.options.gardenPlistPath, exec: args.options.automationExec, + platform, }); if (res.exitCode !== 0) { stepActive(args.out, `Auto-capture automation: ${res.stderr.trim()}`); @@ -100,10 +104,11 @@ export async function runAutomationSetupStep(args: { tasks: ["update"], every: args.options.autoUpdateEvery, updateProgramArguments: args.ephemeral - ? globalUpdateProgramArguments() + ? globalUpdateProgramArguments(platform) : undefined, updatePlistPath: args.options.updatePlistPath, exec: args.options.automationExec, + platform, }); if (update.exitCode !== 0) { stepActive(args.out, `Auto-update automation: ${update.stderr.trim()}`); @@ -125,14 +130,22 @@ export async function runAutomationSetupStep(args: { return { ok: true }; } -function globalAlmanacProgramArguments(quiet = "45m"): string[] { +function globalAlmanacProgramArguments( + platform: NodeJS.Platform, + quiet = "45m", +): string[] { + if (platform === "win32") { + return ["almanac.cmd", "capture", "sweep", "--quiet", quiet]; + } return ["/usr/bin/env", "almanac", "capture", "sweep", "--quiet", quiet]; } -function globalGardenProgramArguments(): string[] { +function globalGardenProgramArguments(platform: NodeJS.Platform): string[] { + if (platform === "win32") return ["almanac.cmd", "garden"]; return ["/usr/bin/env", "almanac", "garden"]; } -function globalUpdateProgramArguments(): string[] { +function globalUpdateProgramArguments(platform: NodeJS.Platform): string[] { + if (platform === "win32") return ["almanac.cmd", "update"]; return ["/usr/bin/env", "almanac", "update"]; } diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index ca96ede7..46f3cec2 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -47,6 +47,8 @@ export interface UninstallOptions { // ─── Injection points ──────────────────────────────────────────── automationPlistPath?: string; gardenPlistPath?: string; + /** Override scheduler platform; production uses `process.platform`. */ + platform?: NodeJS.Platform; automationExec?: AutomationExecFn; claudeDir?: string; codexDir?: string; @@ -93,6 +95,7 @@ export async function runUninstall( plistPath: options.automationPlistPath, gardenPlistPath: options.gardenPlistPath, exec: options.automationExec, + platform: options.platform, }); if (res.exitCode !== 0) { return { stdout: "", stderr: res.stderr, exitCode: res.exitCode }; diff --git a/test/automation.test.ts b/test/automation.test.ts index 73c1fb9f..9922cc59 100644 --- a/test/automation.test.ts +++ b/test/automation.test.ts @@ -35,6 +35,7 @@ describe("almanac automation", () => { }; const first = await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec, @@ -51,6 +52,7 @@ describe("almanac automation", () => { }); const second = await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec, @@ -109,6 +111,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", cwd: nested, plistPath, gardenPlistPath, @@ -139,6 +142,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", every: "1m", quiet: "1s", gardenEvery: "1w", @@ -182,6 +186,7 @@ describe("almanac automation", () => { ); await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec: async () => ({}), @@ -190,6 +195,7 @@ describe("almanac automation", () => { expect(await readFile(gardenPlistPath, "utf8")).toContain("garden"); const result = await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, gardenOff: true, @@ -225,6 +231,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", plistPath, gardenOff: true, exec: async () => ({}), @@ -259,6 +266,7 @@ describe("almanac automation", () => { ); await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec: async () => ({}), @@ -266,6 +274,7 @@ describe("almanac automation", () => { }); const result = await runAutomationStatus({ + platform: "darwin", plistPath, gardenPlistPath, exec: async (_file, args) => { @@ -294,6 +303,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", tasks: ["update"], every: "1d", updatePlistPath, @@ -322,12 +332,14 @@ describe("almanac automation", () => { "com.codealmanac.update.plist", ); await runAutomationInstall({ + platform: "darwin", tasks: ["update"], updatePlistPath, exec: async () => ({}), }); const result = await runAutomationStatus({ + platform: "darwin", tasks: ["update"], updatePlistPath, exec: async () => ({}), @@ -349,12 +361,14 @@ describe("almanac automation", () => { "com.codealmanac.update.plist", ); await runAutomationInstall({ + platform: "darwin", tasks: ["update"], updatePlistPath, exec: async () => ({}), }); const result = await runAutomationUninstall({ + platform: "darwin", tasks: ["update"], updatePlistPath, exec: async () => ({}), @@ -366,3 +380,96 @@ describe("almanac automation", () => { }); }); }); + +describe("almanac automation — Windows Task Scheduler", () => { + it("creates schtasks tasks and records manifests", async () => { + await withTempHome(async (home) => { + const calls: string[][] = []; + const result = await runAutomationInstall({ + platform: "win32", + homeDir: home, + every: "1h", + gardenEvery: "1d", + exec: async (file, args) => { + calls.push([file, ...args]); + return {}; + }, + now: new Date("2026-05-12T05:10:00.000Z"), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("scheduler: Windows Task Scheduler"); + expect(result.stdout).toContain("capture task: \\CodeAlmanac\\CaptureSweep"); + + // capture: 1h → 60 minutes; garden: 1d → DAILY. + const captureCall = calls.find((c) => c.includes("\\CodeAlmanac\\CaptureSweep")); + expect(captureCall).toEqual( + expect.arrayContaining(["schtasks", "/Create", "/SC", "MINUTE", "/MO", "60", "/F"]), + ); + const gardenCall = calls.find((c) => c.includes("\\CodeAlmanac\\Garden")); + expect(gardenCall).toEqual(expect.arrayContaining(["/SC", "DAILY", "/MO", "1"])); + + const manifest = JSON.parse( + await readFile( + join(home, ".almanac", "automation", "windows-capture.json"), + "utf8", + ), + ); + expect(manifest).toMatchObject({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\CaptureSweep", + intervalSeconds: 3600, + }); + }); + }); + + it("rejects sub-minute intervals on Windows", async () => { + await withTempHome(async (home) => { + const result = await runAutomationInstall({ + platform: "win32", + homeDir: home, + every: "30s", + gardenOff: true, + exec: async () => ({}), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("whole minutes"); + }); + }); + + it("reports and uninstalls Windows tasks via manifests", async () => { + await withTempHome(async (home) => { + await runAutomationInstall({ + platform: "win32", + homeDir: home, + gardenOff: true, + exec: async () => ({}), + now: new Date("2026-05-12T05:10:00.000Z"), + }); + + const status = await runAutomationStatus({ platform: "win32", homeDir: home }); + expect(status.stdout).toContain("auto-capture automation: installed"); + expect(status.stdout).toContain("scheduler: Windows Task Scheduler"); + + const deleted: string[][] = []; + const uninstall = await runAutomationUninstall({ + platform: "win32", + homeDir: home, + exec: async (file, args) => { + deleted.push([file, ...args]); + return {}; + }, + }); + expect(uninstall.exitCode).toBe(0); + expect(uninstall.stdout).toContain("automation removed"); + expect( + deleted.some( + (c) => c.includes("/Delete") && c.includes("\\CodeAlmanac\\CaptureSweep"), + ), + ).toBe(true); + await expect( + readFile(join(home, ".almanac", "automation", "windows-capture.json"), "utf8"), + ).rejects.toThrow(); + }); + }); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts index 2063444b..99531fbd 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -107,6 +107,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async (file: string, args: string[]) => { calls.push([file, ...args].join(" ")); return {}; @@ -119,7 +120,8 @@ describe("codealmanac setup", () => { expect(res.exitCode).toBe(0); expect(existsSync(env.plistPath)).toBe(true); const plist = await readFile(env.plistPath, "utf8"); - expect(plist).toContain("dist/codealmanac.js"); + // Normalize separators — the embedded path is OS-native (`\` on Windows). + expect(plist.replaceAll("\\", "/")).toContain("dist/codealmanac.js"); expect(plist).toContain("capture"); expect(plist).toContain("sweep"); await expect(readConfig()).resolves.toMatchObject({ @@ -143,6 +145,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin" as const, automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -169,6 +172,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => { throw new Error("should not run"); }, @@ -192,6 +196,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -221,6 +226,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", updatePlistPath, automationExec: async () => ({}), claudeDir: env.claudeDir, @@ -262,6 +268,7 @@ describe("codealmanac setup", () => { interactive: true, options: { automationPlistPath: env.plistPath, + platform: "darwin", updatePlistPath, automationExec: async () => ({}), }, @@ -290,6 +297,7 @@ describe("codealmanac setup", () => { model: "claude-opus-4-6", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -315,6 +323,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -337,6 +346,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -357,6 +367,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_OUT_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -384,6 +395,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -407,6 +419,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", claudeDir: env.claudeDir, guidesDir: env.guidesDir, stdout: env.out, @@ -430,6 +443,7 @@ describe("codealmanac setup", () => { isTTY: false, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", claudeDir: env.claudeDir, guidesDir: env.guidesDir, stdout: env.out, @@ -454,6 +468,7 @@ describe("codealmanac setup", () => { spawnGlobalInstall: async () => {}, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => ({}), claudeDir: env.claudeDir, guidesDir: env.guidesDir, @@ -480,6 +495,7 @@ describe("codealmanac setup", () => { }, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, + platform: "darwin", automationExec: async () => { throw new Error("should not install automation from ephemeral path"); }, diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index cd63e78a..2157e283 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -54,6 +54,7 @@ describe("almanac uninstall", () => { yes: true, isTTY: false, automationPlistPath: env.plistPath, + platform: "darwin", gardenPlistPath: env.gardenPlistPath, automationExec: async (file: string, args: string[]) => { calls.push([file, ...args].join(" ")); @@ -85,6 +86,7 @@ describe("almanac uninstall", () => { yes: true, isTTY: false, automationPlistPath: env.plistPath, + platform: "darwin", gardenPlistPath: env.gardenPlistPath, automationExec: async () => ({}), claudeDir: env.claudeDir, @@ -102,6 +104,7 @@ describe("almanac uninstall", () => { yes: true, isTTY: false, automationPlistPath: env.plistPath, + platform: "darwin", gardenPlistPath: env.gardenPlistPath, automationExec: async () => ({}), claudeDir: env.claudeDir, @@ -124,6 +127,7 @@ describe("almanac uninstall", () => { keepAutomation: true, isTTY: false, automationPlistPath: env.plistPath, + platform: "darwin", gardenPlistPath: env.gardenPlistPath, automationExec: async () => { throw new Error("should not run"); @@ -148,6 +152,7 @@ describe("almanac uninstall", () => { keepGuides: true, isTTY: false, automationPlistPath: env.plistPath, + platform: "darwin", gardenPlistPath: env.gardenPlistPath, automationExec: async () => ({}), claudeDir: env.claudeDir, From 1dc0257f03a499617051f8fb6994549f48f19f13 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 21 Jun 2026 14:36:40 -0400 Subject: [PATCH 3/5] feat(windows): doctor, ephemeral detection, global install + Windows CI - Add src/install/ephemeral.ts (shared by setup and doctor) recognizing npx/dlx caches and OS temp dirs including Windows %TEMP%/%TMP%. - doctor: report the Windows Task Scheduler task (via the JSON manifest + `schtasks /Query`) instead of always probing a launchd plist. - install-path: install globally via `cmd.exe /d /s /c npm.cmd ...` on Windows, since npm is a `.cmd` shim that won't spawn without a shell. - CLI: describe automation as the platform scheduler, not macOS launchd. - CI: run the build/typecheck/test loop on windows-latest as well as ubuntu-latest, across Node 20 and 22. - README: document macOS/Linux/Windows support and the per-OS scheduler. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 9 ++- README.md | 2 + src/cli/register-wiki-lifecycle-commands.ts | 4 +- src/commands/doctor-checks/install.ts | 78 +++++++++++++++++++-- src/commands/doctor-checks/probes.ts | 18 ++--- src/commands/doctor-checks/types.ts | 6 ++ src/commands/setup/install-path.ts | 40 +++++++---- src/install/ephemeral.ts | 41 +++++++++++ test/doctor.test.ts | 41 +++++++++++ test/install-paths.test.ts | 56 +++++++++++++++ 10 files changed, 257 insertions(+), 38 deletions(-) create mode 100644 src/install/ephemeral.ts create mode 100644 test/install-paths.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae2eaf00..93711aa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,16 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: - # One Node version failing shouldn't cancel the other — we want to see - # which versions regress independently. + # One cell failing shouldn't cancel the others — we want to see which + # OS/Node combinations regress independently. fail-fast: false matrix: + # Linux and Windows are both first-class targets. macOS shares the + # POSIX path with Linux, so it isn't a separate cell here. + os: [ubuntu-latest, windows-latest] # package.json declares `engines.node: >=20`. We test 20 (minimum) and # 22 (current LTS) so both supported versions stay green. Extending # to newer LTS is ~30s of extra runtime, which is worth the coverage. diff --git a/README.md b/README.md index 0d359777..c5a81ca3 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ almanac Requires Node 20, or Node 22 and newer. The npm package is `codealmanac`; the commands are `almanac` and `alm`. +Works on macOS, Linux, and native Windows (PowerShell or cmd) — WSL counts as Linux. Scheduled automation uses launchd on macOS and Task Scheduler on Windows. + ## Try The Sample Wiki Want to see the shape before running an agent over your own repo? diff --git a/src/cli/register-wiki-lifecycle-commands.ts b/src/cli/register-wiki-lifecycle-commands.ts index e344ce74..543ad390 100644 --- a/src/cli/register-wiki-lifecycle-commands.ts +++ b/src/cli/register-wiki-lifecycle-commands.ts @@ -324,7 +324,7 @@ export function registerWikiLifecycleCommands(program: Command): void { automation .command("install [tasks...]") - .description("install the macOS launchd automation jobs") + .description("install the platform scheduler automation jobs (launchd on macOS, Task Scheduler on Windows)") .option("--every ", "run interval for capture or a single selected task") .option("--quiet ", "minimum quiet time before capture (default: 45m)") .option("--garden-every ", "Garden run interval (default: 4h)") @@ -353,7 +353,7 @@ export function registerWikiLifecycleCommands(program: Command): void { automation .command("uninstall [tasks...]") - .description("remove the macOS launchd automation jobs") + .description("remove the platform scheduler automation jobs (launchd on macOS, Task Scheduler on Windows)") .action(async (tasks: string[]) => { const parsed = parseAutomationTaskIds(tasks); if (!parsed.ok) { diff --git a/src/commands/doctor-checks/install.ts b/src/commands/doctor-checks/install.ts index 751d3dda..5e24d47a 100644 --- a/src/commands/doctor-checks/install.ts +++ b/src/commands/doctor-checks/install.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; @@ -7,7 +8,7 @@ import { hasClaudeImportLine, } from "../../agent/install-targets.js"; import type { ClaudeAuthStatus } from "../../agent/readiness/providers/claude/index.js"; -import { defaultPlistPath } from "../automation.js"; +import { defaultPlistPath, windowsManifestPath } from "../automation.js"; import { classifyInstallPath, detectInstallPath, @@ -41,8 +42,12 @@ export async function gatherInstallChecks( const auth = await safeCheckAuth(options.spawnCli); checks.push(describeAuth(auth)); - const plistPath = options.automationPlistPath ?? defaultPlistPath(homedir()); - checks.push(describeAutomation(plistPath)); + checks.push(describeAutomation({ + platform: options.platform ?? process.platform, + home: options.automationHome ?? homedir(), + plistPath: options.automationPlistPath, + windowsTaskExists: options.windowsTaskExists ?? defaultWindowsTaskExists, + })); const claudeDir = options.claudeDir ?? path.join(homedir(), ".claude"); const codexDir = options.codexDir ?? path.join(homedir(), ".codex"); @@ -114,7 +119,16 @@ function describeAuth(auth: ClaudeAuthStatus): Check { }; } -function describeAutomation(plistPath: string): Check { +function describeAutomation(args: { + platform: NodeJS.Platform; + home: string; + plistPath?: string; + windowsTaskExists: (taskName: string) => boolean; +}): Check { + if (args.platform === "win32") { + return describeWindowsAutomation(args.home, args.windowsTaskExists); + } + const plistPath = args.plistPath ?? defaultPlistPath(args.home); if (existsSync(plistPath)) { return { status: "ok", @@ -130,6 +144,60 @@ function describeAutomation(plistPath: string): Check { }; } +function describeWindowsAutomation( + home: string, + windowsTaskExists: (taskName: string) => boolean, +): Check { + const manifestPath = windowsManifestPath("capture", home); + const taskName = readWindowsTaskName(manifestPath); + if (taskName === null) { + return { + status: "problem", + key: "install.automation", + message: existsSync(manifestPath) + ? `auto-capture automation manifest is invalid (${manifestPath})` + : "auto-capture automation not installed", + fix: "run: almanac automation install", + }; + } + if (!windowsTaskExists(taskName)) { + return { + status: "problem", + key: "install.automation", + message: `auto-capture manifest exists but the Windows Task Scheduler task is missing (${taskName})`, + fix: "run: almanac automation install", + }; + } + return { + status: "ok", + key: "install.automation", + message: `auto-capture automation installed with Windows Task Scheduler (${taskName})`, + }; +} + +function readWindowsTaskName(manifestPath: string): string | null { + try { + const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as { + scheduler?: unknown; + taskName?: unknown; + }; + if (parsed.scheduler === "windows-task-scheduler" && typeof parsed.taskName === "string") { + return parsed.taskName; + } + } catch { + return null; + } + return null; +} + +function defaultWindowsTaskExists(taskName: string): boolean { + if (process.platform !== "win32") return true; + const result = spawnSync("schtasks", ["/Query", "/TN", taskName], { + encoding: "utf8", + }); + return result.status === 0; +} + function describeGuides(claudeDir: string): Check { const mini = path.join(claudeDir, "almanac.md"); const ref = path.join(claudeDir, "almanac-reference.md"); diff --git a/src/commands/doctor-checks/probes.ts b/src/commands/doctor-checks/probes.ts index ac099374..e911192c 100644 --- a/src/commands/doctor-checks/probes.ts +++ b/src/commands/doctor-checks/probes.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +8,7 @@ import { type ClaudeAuthStatus, type SpawnCliFn, } from "../../agent/readiness/providers/claude/index.js"; +import { looksEphemeralInstallPath } from "../../install/ephemeral.js"; import type { SqliteProbeResult } from "./types.js"; // Single `createRequire` instance — used by package/binding probes. @@ -45,23 +45,15 @@ export function detectInstallPath(): string | null { /** * Classify the detected install path as permanent or ephemeral. - * Ephemeral locations (npm npx cache, pnpm dlx cache, /tmp/) are valid - * installs but will disappear when the cache is evicted or the machine - * reboots. Doctor reports them as `info` rather than `ok`. + * Ephemeral locations (npm npx cache, pnpm dlx cache, OS temp dirs) are + * valid installs but will disappear when the cache is evicted or the + * machine reboots. Doctor reports them as `info` rather than `ok`. */ export function classifyInstallPath( raw: string | null, ): { installPath: string | null; isEphemeral: boolean } { if (raw === null) return { installPath: null, isEphemeral: false }; - const home = homedir(); - const ephemeralPrefixes = [ - path.join(home, ".npm", "_npx"), - path.join(home, ".local", "share", "pnpm", "dlx"), - "/tmp/", - "/var/folders/", - ]; - const isEphemeral = ephemeralPrefixes.some((p) => raw.startsWith(p)); - return { installPath: raw, isEphemeral }; + return { installPath: raw, isEphemeral: looksEphemeralInstallPath(raw) }; } /** diff --git a/src/commands/doctor-checks/types.ts b/src/commands/doctor-checks/types.ts index 9f91ce8b..7e8786fa 100644 --- a/src/commands/doctor-checks/types.ts +++ b/src/commands/doctor-checks/types.ts @@ -20,6 +20,12 @@ export interface DoctorOptions { providerStatuses?: ProviderStatus[]; /** Override auto-capture launchd plist path. */ automationPlistPath?: string; + /** Override scheduler platform; production uses `process.platform`. */ + platform?: NodeJS.Platform; + /** Override the `~/.almanac/` home used for Windows manifest lookup. */ + automationHome?: string; + /** Override the Windows Task Scheduler task-existence probe. */ + windowsTaskExists?: (taskName: string) => boolean; /** Override `~/.claude/settings.json` path. */ settingsPath?: string; /** Override `~/.almanac/` directory. */ diff --git a/src/commands/setup/install-path.ts b/src/commands/setup/install-path.ts index b8f08d3f..807fdb4c 100644 --- a/src/commands/setup/install-path.ts +++ b/src/commands/setup/install-path.ts @@ -1,9 +1,10 @@ import { execFile } from "node:child_process"; import { createRequire } from "node:module"; -import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { looksEphemeralInstallPath } from "../../install/ephemeral.js"; + /** * Return the directory of the currently-running codealmanac install by * walking up from this module's file to the nearest `package.json` whose @@ -40,32 +41,41 @@ export function detectCurrentInstallPath(): string { * - `~/.npm/_npx/` — npm's npx cache (GC'd on version bumps or * `npm cache clean`) * - `~/.local/share/pnpm/dlx/` — pnpm's dlx (like npx) cache - * - `/tmp/` or `/var/folders/` — common CI / temp paths + * - `/tmp/`, `/var/folders/`, `%TEMP%`, `%TMP%` — common temp paths * * A global install (`~/.nvm/.../lib/node_modules/`, `/usr/local/lib/...`, * `~/.local/lib/node_modules/`) is NOT ephemeral. */ export function detectEphemeral(installPath: string): boolean { - if (installPath.length === 0) return false; - const home = homedir(); - if (installPath.startsWith(path.join(home, ".npm", "_npx"))) return true; - if ( - installPath.startsWith(path.join(home, ".local", "share", "pnpm", "dlx")) - ) return true; - if (installPath.startsWith("/tmp/")) return true; - if (installPath.startsWith("/var/folders/")) return true; - return false; + return looksEphemeralInstallPath(installPath); +} + +/** + * The platform command that installs codealmanac globally. On Windows npm is + * the `npm.cmd` shim, which cannot be spawned directly without a shell. + */ +export function globalInstallCommand( + platform: NodeJS.Platform = process.platform, +): { file: string; args: string[] } { + if (platform === "win32") { + return { + file: "cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd install -g codealmanac@latest"], + }; + } + return { file: "npm", args: ["install", "-g", "codealmanac@latest"] }; } /** - * Spawn `npm install -g codealmanac@latest` in a child process and wait - * for it to finish. Rejects on non-zero exit or spawn error. + * Spawn the global install in a child process and wait for it to finish. + * Rejects on non-zero exit or spawn error. */ export function spawnGlobalInstall(): Promise { + const command = globalInstallCommand(); return new Promise((resolve, reject) => { execFile( - "npm", - ["install", "-g", "codealmanac@latest"], + command.file, + command.args, { shell: false }, (err, _stdout, stderr) => { if (err !== null) { diff --git a/src/install/ephemeral.ts b/src/install/ephemeral.ts new file mode 100644 index 00000000..69e8159a --- /dev/null +++ b/src/install/ephemeral.ts @@ -0,0 +1,41 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +/** + * Whether an install path looks ephemeral — an npx/dlx cache or an OS temp + * dir that will disappear on cache eviction or reboot. Shared by setup and + * doctor so the two never drift. + * + * Recognized prefixes (cross-platform): + * - `~/.npm/_npx` — npm npx cache + * - `~/.local/share/pnpm/dlx`— pnpm dlx cache + * - `%TEMP%` / `%TMP%` / `$TMPDIR` / `/tmp` / `/var/folders` — temp dirs + */ +export function looksEphemeralInstallPath( + installPath: string, + options: { home?: string; env?: NodeJS.ProcessEnv } = {}, +): boolean { + if (installPath.length === 0) return false; + const home = options.home ?? homedir(); + const env = options.env ?? process.env; + const prefixes = [ + path.join(home, ".npm", "_npx"), + path.join(home, ".local", "share", "pnpm", "dlx"), + env.TEMP, + env.TMP, + env.TMPDIR, + "/tmp", + "/var/folders", + ].filter((value): value is string => value !== undefined && value.length > 0); + + const normalized = normalize(installPath); + return prefixes.some((prefix) => hasPrefix(normalized, normalize(prefix))); +} + +function normalize(value: string): string { + return value.replaceAll("\\", "/").replace(/\/+$/u, "").toLowerCase(); +} + +function hasPrefix(value: string, prefix: string): boolean { + return value === prefix || value.startsWith(`${prefix}/`); +} diff --git a/test/doctor.test.ts b/test/doctor.test.ts index cc7b1f0a..ff55c61e 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -87,6 +87,7 @@ describe("almanac doctor", () => { cwd: repo, json: true, automationPlistPath: env.plistPath, + platform: "darwin", claudeDir: env.claudeDir, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), sqliteProbe: SQLITE_OK, @@ -120,6 +121,7 @@ describe("almanac doctor", () => { cwd: home, json: true, automationPlistPath: missingPlist, + platform: "darwin", claudeDir: env.claudeDir, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), sqliteProbe: SQLITE_OK, @@ -136,6 +138,44 @@ describe("almanac doctor", () => { }); }); + it("reports the Windows Task Scheduler task when the manifest exists", async () => { + await withTempHome(async (home) => { + const env = await scaffoldHealthyInstall(home); + const manifestPath = join(home, ".almanac", "automation", "windows-capture.json"); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + JSON.stringify({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\CaptureSweep", + command: ["almanac.cmd", "capture", "sweep"], + intervalSeconds: 3600, + }), + "utf8", + ); + + const r = await runDoctor({ + cwd: home, + json: true, + platform: "win32", + automationHome: home, + windowsTaskExists: () => true, + claudeDir: env.claudeDir, + spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), + sqliteProbe: SQLITE_OK, + installPath: "/fake", + versionOverride: "0.1.3", + }); + + const parsed = JSON.parse(r.stdout); + const automation = parsed.install.find( + (c: { key: string }) => c.key === "install.automation", + ); + expect(automation.status).toBe("ok"); + expect(automation.message).toMatch(/Windows Task Scheduler/); + }); + }); + it("reports auth problems without hiding other install checks", async () => { await withTempHome(async (home) => { const env = await scaffoldHealthyInstall(home); @@ -143,6 +183,7 @@ describe("almanac doctor", () => { cwd: home, json: true, automationPlistPath: env.plistPath, + platform: "darwin", claudeDir: env.claudeDir, spawnCli: fakeSpawnCli(LOGGED_OUT_STDOUT), sqliteProbe: SQLITE_OK, diff --git a/test/install-paths.test.ts b/test/install-paths.test.ts new file mode 100644 index 00000000..075550c4 --- /dev/null +++ b/test/install-paths.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { globalInstallCommand } from "../src/commands/setup/install-path.js"; +import { looksEphemeralInstallPath } from "../src/install/ephemeral.js"; + +describe("looksEphemeralInstallPath", () => { + it("flags the npm _npx cache", () => { + expect( + looksEphemeralInstallPath("/home/dev/.npm/_npx/abc/node_modules/codealmanac", { + home: "/home/dev", + env: {}, + }), + ).toBe(true); + }); + + it("flags Windows %TEMP% npx locations", () => { + expect( + looksEphemeralInstallPath( + "C:\\Users\\dev\\AppData\\Local\\Temp\\_npx\\1\\node_modules\\codealmanac", + { + home: "C:\\Users\\dev", + env: { TEMP: "C:\\Users\\dev\\AppData\\Local\\Temp" }, + }, + ), + ).toBe(true); + }); + + it("does not flag a global install", () => { + expect( + looksEphemeralInstallPath("/usr/local/lib/node_modules/codealmanac", { + home: "/home/dev", + env: {}, + }), + ).toBe(false); + }); + + it("treats the empty path as non-ephemeral", () => { + expect(looksEphemeralInstallPath("", { home: "/home/dev", env: {} })).toBe(false); + }); +}); + +describe("globalInstallCommand", () => { + it("runs npm directly on POSIX", () => { + expect(globalInstallCommand("linux")).toEqual({ + file: "npm", + args: ["install", "-g", "codealmanac@latest"], + }); + }); + + it("runs npm.cmd through cmd.exe on Windows", () => { + expect(globalInstallCommand("win32")).toEqual({ + file: "cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd install -g codealmanac@latest"], + }); + }); +}); From e5c30bfee4e54e6625249968d9f56be6235526a4 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 21 Jun 2026 14:39:05 -0400 Subject: [PATCH 4/5] fix(windows): spawn .cmd shims via verbatim cmd.exe, not shell:true `shell: true` with an args array triggers Node's DEP0190 deprecation (args are concatenated, not escaped) and printed a warning on every Codex invocation. Launch shims through `cmd.exe /d /s /c` with windowsVerbatimArguments and a hand-quoted command line instead, and spawn the resolved executable directly for non-shim targets. Co-Authored-By: Claude Opus 4.8 --- src/process/exec.ts | 59 ++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/src/process/exec.ts b/src/process/exec.ts index ecd949c1..e3e5ba9e 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -120,11 +120,9 @@ export interface CrossSpawnOptions extends SpawnOptions { } /** - * Quote an argument so it survives cmd.exe re-parsing under `shell: true`. - * Node does not escape args when `shell` is enabled — it joins them with - * spaces — so any arg containing whitespace or a cmd metacharacter must be - * wrapped. The live callers pass only simple flags, but quoting keeps the - * helper honest if that changes. + * Quote an argument so it survives cmd.exe parsing. Any token containing + * whitespace or a cmd metacharacter is wrapped in double quotes (embedded + * quotes escaped). Used to build a verbatim cmd.exe command line. */ export function quoteWindowsArg(arg: string): string { if (arg.length === 0) return '""'; @@ -132,46 +130,41 @@ export function quoteWindowsArg(arg: string): string { return `"${arg.replaceAll('"', '\\"')}"`; } +const WINDOWS_SHIM_EXTENSIONS = new Set([".cmd", ".bat", ".ps1"]); + /** * Spawn a child process, transparently handling Windows command shims. * * On Windows, npm installs CLIs (codex, claude, cursor-agent) as `.cmd`/`.ps1` - * shims, and Node ≥20 refuses to spawn those without `shell: true`. We enable - * the shell and let cmd.exe resolve the shim via PATHEXT, quoting args so they - * are not re-split. + * shims that Node >=20 cannot spawn directly. We run those through cmd.exe with + * a hand-quoted, verbatim command line. We avoid `shell: true` because it is + * deprecated (DEP0190) and does not escape args — we escape them ourselves. * - * NOTE: with `shell: true`, multi-line or metacharacter-heavy args cannot be - * passed reliably to a `.cmd` shim — a Windows command-line limitation, not a - * quoting bug. The live run path (Codex app-server) sends prompts over stdio, - * so only simple flag args reach the command line here. + * NOTE: a multi-line / metacharacter-heavy arg cannot be passed reliably to a + * `.cmd` shim — a Windows command-line limitation, not a quoting bug. The live + * run path (Codex app-server) sends prompts over stdio, so only simple flag + * args reach the command line here. */ -const WINDOWS_SHIM_EXTENSIONS = new Set([".cmd", ".bat", ".ps1"]); - -/** Whether a Windows command must be launched through a shell to run. */ -export function needsWindowsShell( - command: string, - options: ResolveOptions = {}, -): boolean { - const resolved = resolveExecutable(command, options) ?? command; - return WINDOWS_SHIM_EXTENSIONS.has(path.win32.extname(resolved).toLowerCase()); -} - export function crossSpawn( command: string, args: readonly string[], options: CrossSpawnOptions = {}, ): ChildProcess { const { platform = process.platform, ...spawnOptions } = options; - if (platform === "win32" && needsWindowsShell(command, { platform })) { - // npm `.cmd`/`.ps1` shims need a shell on Node ≥20. Let cmd.exe resolve - // the bare command via PATHEXT and quote args so they are not re-split. - return spawn(command, args.map(quoteWindowsArg), { - ...spawnOptions, - shell: true, - }); + if (platform === "win32") { + const resolved = resolveExecutable(command, { platform }) ?? command; + if (WINDOWS_SHIM_EXTENSIONS.has(path.win32.extname(resolved).toLowerCase())) { + const comspec = process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe"; + const line = [resolved, ...args].map(quoteWindowsArg).join(" "); + return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { + ...spawnOptions, + windowsVerbatimArguments: true, + }); + } + // A directly-runnable executable (.exe, node, an absolute path) is spawned + // without a shell so the child's pid is the real process — important for + // process-group termination. + return spawn(resolved, [...args], spawnOptions); } - // A directly-runnable executable (.exe, node, an absolute path) is spawned - // without a shell so the child's pid is the real process — important for - // process-group termination. return spawn(command, [...args], spawnOptions); } From 11d880b5a1a0c2a124d937bc9f7a764c6fb7514f Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 21 Jun 2026 15:58:51 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(windows):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20quoting,=20PATH,=20case,=20npx=20cache,=20orphan=20?= =?UTF-8?q?tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves automated review findings on the Windows support PR: - exec.ts: strip surrounding double quotes from PATH entries (Windows can wrap dirs in quotes), so resolveExecutable doesn't build quoted paths. - exec.ts/windows.ts: fix cmd.exe arg quoting — double backslash runs that precede a quote and trailing backslash runs, so a path like `C:\My Projects\` can't escape the closing quote. - paths.ts: compare the home-dir walk-up boundary case-insensitively on Windows (drive-letter/path casing can differ), preserving the isolation. - install/ephemeral.ts: recognize the Windows npm-cache `_npx` location (via npm_config_cache / %LocalAppData%\npm-cache) as ephemeral so npx setups aren't treated as durable. - automation/windows.ts: uninstall attempts deletion of the deterministic task names even when the manifest is missing, so an orphaned schtasks job can't keep running. Co-Authored-By: Claude Opus 4.8 --- src/commands/automation/windows.ts | 24 +++++++++++++++----- src/install/ephemeral.ts | 9 ++++++-- src/paths.ts | 11 +++++++-- src/process/exec.ts | 15 +++++++++---- test/automation.test.ts | 24 ++++++++++++++++++++ test/install-paths.test.ts | 21 +++++++++++++++++ test/process-exec.test.ts | 36 +++++++++++++++++++++++++++++- 7 files changed, 125 insertions(+), 15 deletions(-) diff --git a/src/commands/automation/windows.ts b/src/commands/automation/windows.ts index 1569cb6f..1bde5c14 100644 --- a/src/commands/automation/windows.ts +++ b/src/commands/automation/windows.ts @@ -129,9 +129,12 @@ export async function uninstallWindowsAutomation(args: { const removed: string[] = []; for (const taskId of args.taskIds) { const manifestPath = windowsManifestPath(taskId, args.home); - if (!existsSync(manifestPath)) continue; - await deleteWindowsTask(taskId, args.home, args.exec); - removed.push(manifestPath); + // Task names are deterministic, so attempt deletion even when the manifest + // is missing — otherwise an orphaned schtasks job (manifest write failed, + // or `~/.almanac/automation` was deleted) would keep running forever. + const manifestExisted = existsSync(manifestPath); + const deleted = await deleteWindowsTask(taskId, args.home, args.exec); + if (manifestExisted || deleted) removed.push(manifestPath); } if (removed.length === 0) { return { stdout: "almanac: automation not installed\n", stderr: "", exitCode: 0 }; @@ -161,13 +164,17 @@ async function deleteWindowsTask( taskId: ScheduledTaskId, home: string, exec: ExecFn, -): Promise { +): Promise { + let deleted = false; try { await exec("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAMES[taskId], "/F"]); + deleted = true; } catch { - // Already absent is still a successful disable/uninstall. + // schtasks exits non-zero when the task does not exist; treat as + // "nothing to delete" rather than an error. } await rm(windowsManifestPath(taskId, home), { force: true }); + return deleted; } function formatWindowsInstall( @@ -287,5 +294,10 @@ function windowsTaskCommand( function quoteWindowsTaskArg(arg: string): string { if (arg.length === 0) return '""'; if (!/[\s"\\:]/u.test(arg)) return arg; - return `"${arg.replaceAll('"', '\\"')}"`; + // Double backslashes before a quote and any trailing backslash run so the + // closing quote isn't escaped; escape embedded quotes. + const escaped = arg + .replace(/(\\*)"/g, '$1$1\\"') + .replace(/(\\+)$/u, "$1$1"); + return `"${escaped}"`; } diff --git a/src/install/ephemeral.ts b/src/install/ephemeral.ts index 69e8159a..3dae4376 100644 --- a/src/install/ephemeral.ts +++ b/src/install/ephemeral.ts @@ -7,8 +7,10 @@ import path from "node:path"; * doctor so the two never drift. * * Recognized prefixes (cross-platform): - * - `~/.npm/_npx` — npm npx cache - * - `~/.local/share/pnpm/dlx`— pnpm dlx cache + * - `~/.npm/_npx` — npm npx cache (POSIX) + * - `/_npx` — npm npx cache (Windows lives under + * `%LocalAppData%\npm-cache\_npx`) + * - `~/.local/share/pnpm/dlx` — pnpm dlx cache * - `%TEMP%` / `%TMP%` / `$TMPDIR` / `/tmp` / `/var/folders` — temp dirs */ export function looksEphemeralInstallPath( @@ -18,8 +20,11 @@ export function looksEphemeralInstallPath( if (installPath.length === 0) return false; const home = options.home ?? homedir(); const env = options.env ?? process.env; + const npmCache = env.npm_config_cache ?? + (env.LOCALAPPDATA !== undefined ? path.join(env.LOCALAPPDATA, "npm-cache") : undefined); const prefixes = [ path.join(home, ".npm", "_npx"), + npmCache !== undefined ? path.join(npmCache, "_npx") : undefined, path.join(home, ".local", "share", "pnpm", "dlx"), env.TEMP, env.TMP, diff --git a/src/paths.ts b/src/paths.ts index e82e617c..576d8a36 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -57,6 +57,12 @@ export function getRepoAlmanacDir(cwd: string): string { * the home directory and would otherwise let a sandbox walk into the real * `~/.almanac`. */ +function samePath(a: string, b: string): boolean { + return process.platform === "win32" + ? a.toLowerCase() === b.toLowerCase() + : a === b; +} + export function findNearestAlmanacDir(startDir: string): string | null { const globalDir = getGlobalAlmanacDir(); const home = homedir(); @@ -70,8 +76,9 @@ export function findNearestAlmanacDir(startDir: string): string | null { return current; } // Do not ascend above the user's home directory (the global - // `~/.almanac` was already skipped just above). - if (current === home) { + // `~/.almanac` was already skipped just above). Compare case-insensitively + // on Windows, where drive-letter / path casing can differ. + if (samePath(current, home)) { return null; } const parent = dirname(current); diff --git a/src/process/exec.ts b/src/process/exec.ts index e3e5ba9e..b9ccd914 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -96,7 +96,9 @@ export function resolveExecutable( const dirs = readPath(env) .split(isWindows ? ";" : ":") - .map((dir) => dir.trim()) + // Windows PATH entries are sometimes wrapped in double quotes to tolerate + // spaces; strip them so path.join doesn't embed quotes into candidates. + .map((dir) => dir.trim().replace(/^"+|"+$/g, "")) .filter((dir) => dir.length > 0); for (const dir of dirs) { @@ -121,13 +123,18 @@ export interface CrossSpawnOptions extends SpawnOptions { /** * Quote an argument so it survives cmd.exe parsing. Any token containing - * whitespace or a cmd metacharacter is wrapped in double quotes (embedded - * quotes escaped). Used to build a verbatim cmd.exe command line. + * whitespace or a cmd metacharacter is wrapped in double quotes. Backslashes + * that precede a quote — or that trail the argument right before the closing + * quote — are doubled so the closing quote isn't escaped + * (CommandLineToArgvW / cmd.exe rules); embedded quotes are escaped. */ export function quoteWindowsArg(arg: string): string { if (arg.length === 0) return '""'; if (!/[\s"^&|<>()%!]/u.test(arg)) return arg; - return `"${arg.replaceAll('"', '\\"')}"`; + const escaped = arg + .replace(/(\\*)"/g, '$1$1\\"') + .replace(/(\\+)$/u, "$1$1"); + return `"${escaped}"`; } const WINDOWS_SHIM_EXTENSIONS = new Set([".cmd", ".bat", ".ps1"]); diff --git a/test/automation.test.ts b/test/automation.test.ts index 9922cc59..1a4dfc4e 100644 --- a/test/automation.test.ts +++ b/test/automation.test.ts @@ -472,4 +472,28 @@ describe("almanac automation — Windows Task Scheduler", () => { ).rejects.toThrow(); }); }); + + it("deletes an orphaned task even when no manifest exists", async () => { + await withTempHome(async (home) => { + // No install — the manifest dir never existed, but a scheduled task may + // still be present (manifest write failed, or the dir was deleted). + const deleted: string[][] = []; + const result = await runAutomationUninstall({ + platform: "win32", + homeDir: home, + tasks: ["capture"], + exec: async (file, args) => { + deleted.push([file, ...args]); + return {}; // simulate schtasks finding and deleting the task + }, + }); + + expect( + deleted.some( + (c) => c.includes("/Delete") && c.includes("\\CodeAlmanac\\CaptureSweep"), + ), + ).toBe(true); + expect(result.stdout).toContain("automation removed"); + }); + }); }); diff --git a/test/install-paths.test.ts b/test/install-paths.test.ts index 075550c4..34592df5 100644 --- a/test/install-paths.test.ts +++ b/test/install-paths.test.ts @@ -25,6 +25,27 @@ describe("looksEphemeralInstallPath", () => { ).toBe(true); }); + it("flags the Windows npm-cache _npx location via LOCALAPPDATA", () => { + expect( + looksEphemeralInstallPath( + "C:\\Users\\dev\\AppData\\Local\\npm-cache\\_npx\\a1\\node_modules\\codealmanac", + { + home: "C:\\Users\\dev", + env: { LOCALAPPDATA: "C:\\Users\\dev\\AppData\\Local" }, + }, + ), + ).toBe(true); + }); + + it("honors npm_config_cache for the npx location", () => { + expect( + looksEphemeralInstallPath("D:\\npmcache\\_npx\\a1\\node_modules\\codealmanac", { + home: "C:\\Users\\dev", + env: { npm_config_cache: "D:\\npmcache" }, + }), + ).toBe(true); + }); + it("does not flag a global install", () => { expect( looksEphemeralInstallPath("/usr/local/lib/node_modules/codealmanac", { diff --git a/test/process-exec.test.ts b/test/process-exec.test.ts index 0a1eb6cf..af7c2472 100644 --- a/test/process-exec.test.ts +++ b/test/process-exec.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { commandExists, resolveExecutable } from "../src/process/exec.js"; +import { + commandExists, + quoteWindowsArg, + resolveExecutable, +} from "../src/process/exec.js"; /** * These tests inject `platform`, `env`, and `fileExists` so they exercise @@ -88,6 +92,16 @@ describe("resolveExecutable — Windows", () => { expect(resolved).toBe(shim); }); + it("strips surrounding quotes from quoted PATH entries", () => { + const shim = "C:\\Program Files\\nodejs\\codex.cmd"; + const resolved = resolveExecutable("codex", { + platform: "win32", + env: { Path: '"C:\\Program Files\\nodejs"', PATHEXT: ".CMD" }, + fileExists: (p) => p === shim, + }); + expect(resolved).toBe(shim); + }); + it("falls back to a default PATHEXT when env lacks one", () => { const shim = "C:\\bin\\codex.cmd"; const resolved = resolveExecutable("codex", { @@ -110,6 +124,26 @@ describe("resolveExecutable — Windows", () => { }); }); +describe("quoteWindowsArg", () => { + it("leaves simple flags untouched", () => { + expect(quoteWindowsArg("--json")).toBe("--json"); + expect(quoteWindowsArg("mcp_servers={}")).toBe("mcp_servers={}"); + }); + + it("quotes args with spaces", () => { + expect(quoteWindowsArg("hello world")).toBe('"hello world"'); + }); + + it("doubles a trailing backslash so it cannot escape the closing quote", () => { + // C:\a b\ -> "C:\a b\\" (the doubled backslash is a literal backslash) + expect(quoteWindowsArg("C:\\a b\\")).toBe('"C:\\a b\\\\"'); + }); + + it("escapes embedded quotes and their preceding backslashes", () => { + expect(quoteWindowsArg('a"b')).toBe('"a\\"b"'); + }); +}); + describe("commandExists", () => { it("is true when resolvable", () => { expect(