From 1f8db5ffb531293d2d7892a5d33c935af2817339 Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 00:23:22 -0700 Subject: [PATCH 1/7] feat(windows): add scheduler support --- .almanac/pages/almanac-doctor.md | 10 +- .almanac/pages/automation.md | 21 +- .almanac/pages/capture-automation.md | 15 +- .almanac/pages/capture-flow.md | 2 +- .almanac/pages/lifecycle-cli.md | 2 +- docs/plans/2026-05-14-windows-support.md | 78 ++++++ src/cli/register-wiki-lifecycle-commands.ts | 4 +- src/commands/automation.ts | 59 ++++- src/commands/automation/windows.ts | 259 ++++++++++++++++++++ src/commands/doctor-checks/install.ts | 47 +++- src/commands/doctor-checks/probes.ts | 18 +- src/commands/doctor-checks/types.ts | 2 + src/commands/setup.ts | 23 +- src/commands/setup/install-path.ts | 32 ++- src/commands/uninstall.ts | 2 +- src/install/ephemeral.ts | 36 +++ src/install/global.ts | 4 +- test/automation.test.ts | 88 ++++++- test/doctor.test.ts | 55 +++++ test/setup.test.ts | 62 +++++ 20 files changed, 753 insertions(+), 66 deletions(-) create mode 100644 docs/plans/2026-05-14-windows-support.md create mode 100644 src/commands/automation/windows.ts create mode 100644 src/install/ephemeral.ts diff --git a/.almanac/pages/almanac-doctor.md b/.almanac/pages/almanac-doctor.md index 90bb5432..81d5d554 100644 --- a/.almanac/pages/almanac-doctor.md +++ b/.almanac/pages/almanac-doctor.md @@ -8,14 +8,16 @@ files: - src/commands/doctor-checks/wiki.ts - src/commands/doctor-checks/updates.ts - src/commands/doctor-checks/probes.ts + - src/install/ephemeral.ts - src/commands/setup.ts - src/abi-guard.ts - test/doctor.test.ts sources: - docs/plans/2026-04-30-doctor-refactor.md - docs/bugs/codealmanac-known-bugs.md + - docs/plans/2026-05-14-windows-support.md - /Users/kushagrachitkara/.codex/sessions/2026/05/12/rollout-2026-05-12T00-52-10-019e1b2c-0679-7bb0-a926-b8643aa710c1.jsonl -verified: 2026-05-13 +verified: 2026-05-14 status: active --- @@ -41,12 +43,16 @@ The install section currently reports: - install-path detection, including whether the current binary is running from an ephemeral `npx`-style location - `better-sqlite3` native-binding readiness - Claude authentication state -- whether scheduled capture automation is installed +- whether scheduled capture automation is installed for the active platform - whether Claude guide files exist under `~/.claude/` - whether `~/.claude/CLAUDE.md` contains the Almanac import line [[src/commands/doctor-checks/install.ts]] expresses repairs as `fix: "run: ..."` strings. The command prints those hints, but it does not execute them. +The automation check is platform-aware as of the Windows support work. macOS checks the launchd capture plist at `~/Library/LaunchAgents/com.codealmanac.capture-sweep.plist`; Windows checks the Task Scheduler manifest at `~/.almanac/automation/windows-capture-sweep.json` and reports the task name recorded there. + +Install-path classification is shared with setup through [[src/install/ephemeral.ts]]. That helper normalizes slashes and case before checking npm npx, pnpm dlx, `/tmp`, `/var/folders`, and Windows temp directories such as `%TEMP%`. Without that shared helper, setup and doctor could disagree about whether a Windows `npx` install is durable enough for scheduler installation. + ## Relationship to the SQLite ABI guard [[install-time-node-launcher]] now reduces the most common mismatch path by pinning published bins to the installing Node executable. [[src/abi-guard.ts]] still fails early when `better-sqlite3` cannot load under the current Node ABI and prints an exact rebuild command. Doctor surfaces the same failure class as structured install state: `install.sqlite` reports whether the binding loads and points users at `npm rebuild better-sqlite3` when it does not. diff --git a/.almanac/pages/automation.md b/.almanac/pages/automation.md index a97cf3c9..7cebfc42 100644 --- a/.almanac/pages/automation.md +++ b/.almanac/pages/automation.md @@ -1,9 +1,11 @@ --- title: Automation -summary: Automation is the macOS launchd layer that schedules `almanac capture sweep` and `almanac garden`, while capture eligibility and dedupe stay inside Almanac-owned state. +summary: Automation is the platform scheduler layer that wakes `almanac capture sweep` and `almanac garden`, while capture eligibility and dedupe stay inside Almanac-owned state. topics: [automation, cli, flows] files: - src/commands/automation.ts + - src/commands/automation/windows.ts + - src/install/ephemeral.ts - src/commands/setup.ts - src/commands/uninstall.ts - src/cli.ts @@ -16,17 +18,18 @@ files: - test/uninstall.test.ts sources: - docs/plans/2026-05-11-scheduled-quiet-session-capture.md + - docs/plans/2026-05-14-windows-support.md status: active -verified: 2026-05-13 +verified: 2026-05-14 --- # Automation -Automation is the scheduler layer around Almanac's recurring maintenance work. In the current product shape, that means two launchd jobs on macOS: one wakes `almanac capture sweep`, and the other wakes `almanac garden`. The scheduler decides when Almanac starts. Almanac still decides what to capture, whether a wiki needs gardening, and how job state is recorded. +Automation is the scheduler layer around Almanac's recurring maintenance work. The current implementation has two platform adapters: macOS writes launchd jobs, and Windows writes Task Scheduler tasks through `schtasks`. In both cases one scheduler entry wakes `almanac capture sweep`, and the other wakes `almanac garden`. The scheduler decides when Almanac starts. Almanac still decides what to capture, whether a wiki needs gardening, and how job state is recorded. ## Public command surface -`almanac automation install|status|uninstall` is the explicit scheduler-management surface. `install` writes launchd plists, bootstraps them with `launchctl`, and prints the effective capture interval, quiet window, activation timestamp, commands, and plist paths. `status` reads the plist files back and reports whether capture and Garden automation are installed. `uninstall` unloads and removes whichever CodeAlmanac plists exist. +`almanac automation install|status|uninstall` is the explicit scheduler-management surface. On macOS, `install` writes launchd plists, bootstraps them with `launchctl`, and prints the effective capture interval, quiet window, activation timestamp, commands, and plist paths. On Windows, `install` calls `schtasks /Create` and writes local manifests under `~/.almanac/automation/` so status and doctor can report the installed task names without shelling out. `status` reads the platform-owned record, and `uninstall` removes the scheduled capture and Garden entries for the active platform. `almanac setup` is the onboarding entry point for the same automation surface. Setup installs scheduled capture and scheduled Garden by default unless the user passes `--skip-automation` or `--garden-off`. That makes automation a first-run product behavior rather than a hidden expert-only command. @@ -40,6 +43,14 @@ Both jobs get an explicit `PATH` assembled for launchd from the current environm There are two command-path modes. Direct `almanac automation install` writes absolute `ProgramArguments` for the current Node executable and resolved `dist/codealmanac.js` entrypoint. Setup uses a stricter rule when it was launched from ephemeral `npx`: it installs automation only after a durable global install succeeds, then writes `/usr/bin/env almanac ...` commands instead of pinning launchd to the transient cache path. +## Windows Task Scheduler contract + +On Windows, capture uses the task name `\CodeAlmanac\CaptureSweep`, and Garden uses `\CodeAlmanac\Garden`. `runAutomationInstall({ platform: "win32" })` maps minute-sized intervals to `schtasks /Create /SC MINUTE /MO ` and whole-day intervals to `/SC DAILY /MO `. The default capture cadence (`5h`) is therefore a 300-minute task, and the default Garden cadence (`2d`) is a two-day task. + +The Windows adapter stores manifests at `~/.almanac/automation/windows-capture-sweep.json` and `~/.almanac/automation/windows-garden.json`. Those files are local scheduler metadata, not capture state. They record the task name, command, interval seconds, and quiet window where applicable. Doctor uses the capture manifest to decide whether automation is installed on Windows; it no longer checks a launchd plist on that platform. + +Setup also changes the durable-global command shape on Windows. After an ephemeral `npx` setup successfully installs the package globally, scheduled commands use npm's Windows command shim (`almanac.cmd ...`) instead of `/usr/bin/env almanac ...`. The global install helper uses `cmd.exe /d /s /c npm.cmd install -g codealmanac@latest` on Windows because Node's `execFile` cannot directly launch `.cmd` files. + ## What the scheduler owns and what it does not The scheduler owns wakeup cadence and command invocation. It does not own transcript eligibility, cursor state, or capture dedupe. Those remain inside Almanac and are described by [[capture-flow]], [[capture-automation]], and [[capture-ledger]]. @@ -56,4 +67,4 @@ The install path validates its duration flags instead of silently falling back t Current automation is scheduler-first, but setup and uninstall still run private cleanup for older provider hook installs. `cleanupLegacyHooks()` removes CodeAlmanac-owned `almanac-capture.sh` commands from observed Claude, Codex, and Cursor hook files and deletes the old Claude shell script path when present. [[sessionend-hook]] keeps the historical shapes and rationale for that migration boundary. -`almanac uninstall` removes both launchd jobs unless the user passes `--keep-automation`. That keeps automation cleanup aligned with the broader global-install cleanup described in [[global-agent-instructions]]. +`almanac uninstall` removes both platform scheduler jobs unless the user passes `--keep-automation`. That keeps automation cleanup aligned with the broader global-install cleanup described in [[global-agent-instructions]]. diff --git a/.almanac/pages/capture-automation.md b/.almanac/pages/capture-automation.md index 8b322496..2a83551d 100644 --- a/.almanac/pages/capture-automation.md +++ b/.almanac/pages/capture-automation.md @@ -4,8 +4,10 @@ summary: CodeAlmanac's auto-capture contract is scheduler-backed quiet-session c topics: [flows, agents, cli, automation] files: - docs/plans/2026-05-11-scheduled-quiet-session-capture.md + - docs/plans/2026-05-14-windows-support.md - src/commands/capture-sweep.ts - src/commands/automation.ts + - src/commands/automation/windows.ts - src/update/config.ts - src/commands/session-transcripts.ts - src/commands/operations.ts @@ -17,8 +19,9 @@ files: - test/automation.test.ts sources: - /Users/kushagrachitkara/.codex/sessions/2026/05/11/rollout-2026-05-11T14-32-08-019e18f4-5e73-7790-ba49-73cc02544a58.jsonl + - docs/plans/2026-05-14-windows-support.md status: implemented -verified: 2026-05-13 +verified: 2026-05-14 --- # Capture Automation @@ -224,7 +227,7 @@ The session explicitly rejected the idea that CodeAlmanac itself should keep a s - macOS: `launchd`, likely via `~/Library/LaunchAgents/com.codealmanac.capture-sweep.plist` - Linux: `systemd --user` timer, with cron as fallback -- Windows: Task Scheduler later if Windows support is added +- Windows: Task Scheduler tasks created with `schtasks` The key implementation invariant is that the scheduler only invokes the CLI. It should not embed transcript-discovery or capture logic itself. @@ -236,7 +239,7 @@ That keeps the system debuggable: The same session tightened that separation one step further: the scheduler entry should only need wakeup-level state such as interval, command path, and log paths. Sweep behavior such as enabled apps, quiet window, and other capture defaults should live in CodeAlmanac-owned config that `almanac capture sweep` reads when it starts. For the first version, even that split can stay minimal: the wakeup cadence may still be the only scheduler-owned knob, and changing it would rewrite or reload the platform scheduler entry. -The current macOS implementation now follows that stronger shape, but with one setup-time distinction. Direct installs still write launchd `ProgramArguments` as the absolute Node executable plus the resolved `dist/codealmanac.js` entrypoint, then append `capture sweep`. Setup switches to `/usr/bin/env almanac ...` only after it has first converted an ephemeral `npx` launch into a durable global install. If that durable install does not happen, setup leaves automation uninstalled instead of writing a launchd entry that points into the temporary `npx` cache. +The current platform implementations now follow that stronger shape, with one setup-time distinction. Direct macOS installs still write launchd `ProgramArguments` as the absolute Node executable plus the resolved `dist/codealmanac.js` entrypoint, then append `capture sweep`. Direct Windows installs create Task Scheduler tasks and record manifests under `~/.almanac/automation/`. Setup switches to the platform's durable global command only after it has first converted an ephemeral `npx` launch into a durable global install: `/usr/bin/env almanac ...` on Unix-like shells, and `almanac.cmd ...` on Windows. If that durable install does not happen, setup leaves automation uninstalled instead of writing a scheduler entry that points into the temporary `npx` cache. That direct-install shape has one operational consequence that surfaced during the 2026-05-13 Garden smoke test. If launchd is pinned to an absolute Node path such as `~/.nvm/versions/node/v24.15.0/bin/node`, rebuilding `better-sqlite3` from a shell running a different Node version repairs the shell runtime, not the scheduled job. The reliable fix is to rebuild with the exact `node` or `npm` from the plist command path, or to prepend that Node version's bin directory to `PATH` before running `npm rebuild better-sqlite3`. @@ -343,7 +346,7 @@ The scheduler mechanism is platform-owned: - macOS: `launchd` agent under `~/Library/LaunchAgents/` - Linux: `systemd --user` timer, with cron as a weaker fallback -- Windows: Task Scheduler if Windows support is added later +- Windows: Task Scheduler tasks via `schtasks` The agent-facing command discussed in the session was conceptually a sweep such as `almanac capture sweep --quiet 45m`, run by the platform scheduler on a configurable cadence. Early in the discussion that cadence was imagined as every few minutes; later turns settled on "default around five hours, user-configurable" for the first implementation. @@ -435,7 +438,9 @@ The Garden plist owns graph-maintenance cadence, using `StartInterval = 172800` - working directory: the nearest wiki root found from the install command's current directory - stdout/stderr: user-visible log files under the same CodeAlmanac-owned log directory -`launchd` is the thing that stays resident. When the timer fires, macOS starts a new `almanac` process, waits for it to exit, and then returns to sleeping until the next interval. The same separation should hold on Linux with a `systemd --user` timer and later on any Windows scheduler support. +On Windows, scheduler install creates Task Scheduler tasks named `\CodeAlmanac\CaptureSweep` and `\CodeAlmanac\Garden`. The capture default maps to `/SC MINUTE /MO 300`, while the Garden default maps to `/SC DAILY /MO 2`. CodeAlmanac stores task manifests under `~/.almanac/automation/` for local status and doctor output; those manifests are not capture ledger state. + +`launchd` or Task Scheduler is the thing that stays resident. When the timer fires, the OS starts a new `almanac` process, waits for it to exit, and then returns to sleeping until the next interval. The same separation should hold on Linux with a `systemd --user` timer. This separation is part of the product contract, not just an implementation convenience: diff --git a/.almanac/pages/capture-flow.md b/.almanac/pages/capture-flow.md index cd6830c0..866552ec 100644 --- a/.almanac/pages/capture-flow.md +++ b/.almanac/pages/capture-flow.md @@ -164,4 +164,4 @@ Raw provider events are normalized and written to `.almanac/runs/.jsonl` ## Scheduled automation -Automatic capture is scheduler-driven. `almanac automation install` writes a macOS launchd plist that runs `almanac capture sweep` every 5h by default, alongside the separate scheduled Garden plist described in [[capture-automation]]. Sweep applies quiet-window and ledger rules in-process, so launchd is only the wakeup mechanism; it does not own capture state. +Automatic capture is scheduler-driven. `almanac automation install` writes a platform scheduler entry that runs `almanac capture sweep` every 5h by default, alongside separate scheduled Garden automation described in [[capture-automation]]. macOS uses launchd plists; Windows uses Task Scheduler tasks plus manifests under `~/.almanac/automation/`. Sweep applies quiet-window and ledger rules in-process, so the OS scheduler is only the wakeup mechanism; it does not own capture state. diff --git a/.almanac/pages/lifecycle-cli.md b/.almanac/pages/lifecycle-cli.md index 8e33d028..2763f488 100644 --- a/.almanac/pages/lifecycle-cli.md +++ b/.almanac/pages/lifecycle-cli.md @@ -47,7 +47,7 @@ There is one CLI-shape wrinkle inside that surface: `capture` itself has `--json ## Automation commands -`almanac automation install|status|uninstall` manages macOS launchd jobs for scheduled capture and scheduled Garden. Capture runs `almanac capture sweep` every 5h by default; Garden runs `almanac garden` every 2d by default. `automation install --every --quiet ` customizes capture cadence and the transcript quiet window, `--garden-every ` customizes Garden cadence, and `--garden-off` removes the Garden plist while leaving capture automation installed. Direct automation installs write absolute `ProgramArguments` for the current Node executable plus the resolved `dist/codealmanac.js` entrypoint, and the Garden plist records the nearest wiki root as `WorkingDirectory` so scheduled `almanac garden` resolves the intended `.almanac/` graph. Setup adds one extra rule for ephemeral `npx` launches: it installs automation only after a durable global install succeeds, and in that case writes `/usr/bin/env almanac capture sweep --quiet ...` and `/usr/bin/env almanac garden` instead of pinning launchd to the transient cache path. +`almanac automation install|status|uninstall` manages platform scheduler jobs for scheduled capture and scheduled Garden. macOS uses launchd plists; Windows uses Task Scheduler tasks created with `schtasks`. Capture runs `almanac capture sweep` every 5h by default; Garden runs `almanac garden` every 2d by default. `automation install --every --quiet ` customizes capture cadence and the transcript quiet window, `--garden-every ` customizes Garden cadence, and `--garden-off` removes Garden automation while leaving capture automation installed. Direct macOS installs write absolute `ProgramArguments` for the current Node executable plus the resolved `dist/codealmanac.js` entrypoint, and the Garden plist records the nearest wiki root as `WorkingDirectory` so scheduled `almanac garden` resolves the intended `.almanac/` graph. Windows installs write local manifests under `~/.almanac/automation/` so `automation status` and `doctor` can report Task Scheduler state without assuming launchd files exist. Setup adds one extra rule for ephemeral `npx` launches: it installs automation only after a durable global install succeeds, then uses the platform's durable command shim (`/usr/bin/env almanac ...` on macOS/Linux-style shells, `almanac.cmd ...` on Windows) instead of pinning the scheduler to the transient cache path. The install command also establishes the auto-capture activation cursor. On first install it writes `automation.capture_since` to `~/.almanac/config.toml`; future sweeps skip transcripts whose mtime is before that timestamp. Reinstalling automation preserves the existing timestamp so rerunning setup repairs the scheduler without redefining what historical transcript material is in scope. The config write now runs legacy config migration first, so introducing `automation.capture_since` does not clobber older JSON-based agent settings. diff --git a/docs/plans/2026-05-14-windows-support.md b/docs/plans/2026-05-14-windows-support.md new file mode 100644 index 00000000..b0991b8a --- /dev/null +++ b/docs/plans/2026-05-14-windows-support.md @@ -0,0 +1,78 @@ +# Windows Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the setup, automation, and doctor surfaces work on Windows without changing capture or Garden semantics. + +**Architecture:** Keep `almanac automation` as the public command and split scheduler behavior by platform. macOS continues to use launchd plists; Windows uses Task Scheduler through `schtasks` and records small local manifests under `~/.almanac/automation/` so status and doctor can report what was installed. + +**Tech Stack:** TypeScript, Node child processes, Windows `schtasks`, macOS launchd, Vitest. + +--- + +## Research Notes + +Microsoft documents `schtasks /Create` with `/SC MINUTE`, `/MO `, `/TN `, `/TR `, and `/F` for replacing existing tasks. Minute schedules accept intervals in the 1-1439 minute range, while daily schedules accept day modifiers. Node's `child_process` documentation says Windows `.bat` and `.cmd` files cannot be launched directly with `execFile`; callers should use `cmd.exe`, `exec`, or `spawn` with shell behavior. This matters because npm's Windows global bins are command shims such as `almanac.cmd` and `npm.cmd`. + +## Task 1: Windows Scheduler Adapter + +**Files:** +- Modify: `src/commands/automation.ts` +- Create: `src/commands/automation/windows.ts` +- Test: `test/automation.test.ts` + +**Steps:** +1. Add failing tests for `runAutomationInstall({ platform: "win32" })` that expect `schtasks /Create` calls for capture and Garden. +2. Add failing tests for Windows status and uninstall that use manifests under `~/.almanac/automation/`. +3. Implement Windows install/status/uninstall helpers behind the existing automation command entry points. +4. Preserve existing launchd behavior for non-Windows platforms. + +## Task 2: Setup and Doctor Platform Awareness + +**Files:** +- Modify: `src/commands/setup.ts` +- Modify: `src/commands/doctor-checks/install.ts` +- Modify: `src/commands/doctor-checks/types.ts` +- Test: `test/setup.test.ts` +- Test: `test/doctor.test.ts` + +**Steps:** +1. Add failing setup coverage proving Windows npx bootstrap writes `almanac.cmd` scheduler commands, not `/usr/bin/env`. +2. Add failing doctor coverage proving Windows checks the Task Scheduler manifest instead of a launchd plist. +3. Add `platform` injection points for tests while defaulting production behavior to `process.platform`. +4. Update CLI descriptions and comments so user-facing language says platform scheduler, not macOS launchd. + +## Task 3: Shared Ephemeral Install Detection + +**Files:** +- Create: `src/install/ephemeral.ts` +- Modify: `src/commands/setup/install-path.ts` +- Modify: `src/commands/doctor-checks/probes.ts` +- Test: `test/setup.test.ts` +- Test: `test/doctor.test.ts` + +**Steps:** +1. Add failing tests for Windows temp/npx paths such as `C:\Users\\AppData\Local\Temp\_npx\...`. +2. Extract shared path-prefix classification that normalizes slashes and case. +3. Use the shared helper from setup and doctor so they do not drift. + +## Task 4: Windows npm Command Shims + +**Files:** +- Modify: `src/commands/setup/install-path.ts` +- Test: `test/setup.test.ts` + +**Steps:** +1. Add failing coverage for the command used to install the global package on Windows. +2. Use `cmd.exe /d /s /c npm.cmd install -g codealmanac@latest` on Windows. +3. Keep the direct `npm install -g codealmanac@latest` command on Unix-like platforms. + +## Verification + +Run: + +```bash +npm run lint +npm test +npm run build +``` diff --git a/src/cli/register-wiki-lifecycle-commands.ts b/src/cli/register-wiki-lifecycle-commands.ts index 79bda4d1..ab2fd7a3 100644 --- a/src/cli/register-wiki-lifecycle-commands.ts +++ b/src/cli/register-wiki-lifecycle-commands.ts @@ -323,7 +323,7 @@ export function registerWikiLifecycleCommands(program: Command): void { automation .command("install") - .description("install the macOS launchd automation jobs") + .description("install the platform scheduler automation jobs") .option("--every ", "capture run interval (default: 5h)") .option("--quiet ", "minimum quiet time before capture (default: 45m)") .option("--garden-every ", "Garden run interval (default: 2d)") @@ -346,7 +346,7 @@ export function registerWikiLifecycleCommands(program: Command): void { automation .command("uninstall") - .description("remove the macOS launchd automation jobs") + .description("remove the platform scheduler automation jobs") .action(async () => { const result = await runAutomationUninstall(); emit(result); diff --git a/src/commands/automation.ts b/src/commands/automation.ts index ceac9028..8784cc8b 100644 --- a/src/commands/automation.ts +++ b/src/commands/automation.ts @@ -10,6 +10,15 @@ import type { CommandResult } from "../cli/helpers.js"; import { parseDuration } from "../indexer/duration.js"; import { findNearestAlmanacDir } from "../paths.js"; import { ensureAutomationCaptureSince } from "../update/config.js"; +import { + installWindowsAutomation, + statusWindowsAutomation, + uninstallWindowsAutomation, +} from "./automation/windows.js"; +export { + defaultWindowsCaptureManifestPath, + defaultWindowsGardenManifestPath, +} from "./automation/windows.js"; const execFileAsync = promisify(execFile); @@ -28,12 +37,14 @@ export interface AutomationOptions { exec?: ExecFn; now?: Date; configPath?: string; + platform?: NodeJS.Platform; } export interface AutomationStatusOptions { homeDir?: string; plistPath?: string; gardenPlistPath?: string; + platform?: NodeJS.Platform; } type ExecFn = ( @@ -58,6 +69,7 @@ const LAUNCHD_FALLBACK_PATHS = [ export async function runAutomationInstall( options: AutomationOptions = {}, ): Promise { + const platform = options.platform ?? process.platform; const interval = parseInterval(options.every ?? DEFAULT_EVERY); if (!interval.ok) { return { stdout: "", stderr: `almanac: ${interval.error}\n`, exitCode: 1 }; @@ -76,6 +88,31 @@ export async function runAutomationInstall( } const home = options.homeDir ?? homedir(); + const programArguments = options.programArguments ?? defaultProgramArguments(quietValue); + const gardenProgramArguments = options.gardenProgramArguments ?? defaultGardenProgramArguments(); + const gardenWorkingDirectory = findNearestAlmanacDir(options.cwd ?? process.cwd()) ?? + path.resolve(options.cwd ?? process.cwd()); + const captureSince = await ensureAutomationCaptureSince( + (options.now ?? new Date()).toISOString(), + options.configPath, + ); + const exec = options.exec ?? defaultExec; + + if (platform === "win32") { + return await installWindowsAutomation({ + home, + intervalSeconds: interval.seconds, + intervalLabel: options.every ?? DEFAULT_EVERY, + quietValue, + gardenIntervalSeconds: gardenInterval?.seconds ?? null, + gardenIntervalLabel: gardenValue, + programArguments, + gardenProgramArguments, + exec, + captureSince, + }); + } + const plist = options.plistPath ?? defaultPlistPath(home); const gardenPlist = options.gardenPlistPath ?? defaultGardenPlistPath(home); const logsDir = path.join(home, ".almanac", "logs"); @@ -83,10 +120,6 @@ export async function runAutomationInstall( await mkdir(path.dirname(gardenPlist), { recursive: true }); await mkdir(logsDir, { recursive: true }); - const programArguments = options.programArguments ?? defaultProgramArguments(quietValue); - const gardenProgramArguments = options.gardenProgramArguments ?? defaultGardenProgramArguments(); - const gardenWorkingDirectory = findNearestAlmanacDir(options.cwd ?? process.cwd()) ?? - path.resolve(options.cwd ?? process.cwd()); const environmentVariables = { PATH: buildLaunchPath(home, options.env?.PATH ?? process.env.PATH), }; @@ -112,11 +145,6 @@ export async function runAutomationInstall( }); } - const captureSince = await ensureAutomationCaptureSince( - (options.now ?? new Date()).toISOString(), - options.configPath, - ); - const exec = options.exec ?? defaultExec; const target = launchctlTarget(); try { await exec("launchctl", ["bootout", target, plist]); @@ -194,6 +222,14 @@ export async function runAutomationUninstall( options: AutomationOptions = {}, ): Promise { const home = options.homeDir ?? homedir(); + const platform = options.platform ?? process.platform; + if (platform === "win32") { + return await uninstallWindowsAutomation({ + home, + exec: options.exec ?? defaultExec, + }); + } + const plist = options.plistPath ?? defaultPlistPath(home); const gardenPlist = options.gardenPlistPath ?? defaultGardenPlistPath(home); const exec = options.exec ?? defaultExec; @@ -236,6 +272,11 @@ export async function runAutomationStatus( options: AutomationStatusOptions = {}, ): Promise { const home = options.homeDir ?? homedir(); + const platform = options.platform ?? process.platform; + if (platform === "win32") { + return await statusWindowsAutomation(home); + } + const plist = options.plistPath ?? defaultPlistPath(home); const gardenPlist = options.gardenPlistPath ?? defaultGardenPlistPath(home); const capture = await readAutomationPlist(plist); diff --git a/src/commands/automation/windows.ts b/src/commands/automation/windows.ts new file mode 100644 index 00000000..72b156f5 --- /dev/null +++ b/src/commands/automation/windows.ts @@ -0,0 +1,259 @@ +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 { CommandResult } from "../../cli/helpers.js"; + +type ExecFn = ( + file: string, + args: string[], +) => Promise<{ stdout?: string; stderr?: string }>; + +const WINDOWS_CAPTURE_TASK = "\\CodeAlmanac\\CaptureSweep"; +const WINDOWS_GARDEN_TASK = "\\CodeAlmanac\\Garden"; + +interface WindowsAutomationManifest { + scheduler: "windows-task-scheduler"; + taskName: string; + command: string[]; + intervalSeconds: number; + quiet?: string; +} + +export function defaultWindowsCaptureManifestPath(home: string = homedir()): string { + return path.join(home, ".almanac", "automation", "windows-capture-sweep.json"); +} + +export function defaultWindowsGardenManifestPath(home: string = homedir()): string { + return path.join(home, ".almanac", "automation", "windows-garden.json"); +} + +export async function installWindowsAutomation(args: { + home: string; + intervalSeconds: number; + intervalLabel: string; + quietValue: string; + gardenIntervalSeconds: number | null; + gardenIntervalLabel: string; + programArguments: string[]; + gardenProgramArguments: string[]; + exec: ExecFn; + captureSince: string; +}): Promise { + const captureSchedule = windowsSchedule(args.intervalSeconds); + if (!captureSchedule.ok) { + return { stdout: "", stderr: `almanac: ${captureSchedule.error}\n`, exitCode: 1 }; + } + const gardenSchedule = args.gardenIntervalSeconds === null + ? null + : windowsSchedule(args.gardenIntervalSeconds); + if (gardenSchedule !== null && !gardenSchedule.ok) { + return { stdout: "", stderr: `almanac: ${gardenSchedule.error}\n`, exitCode: 1 }; + } + + const captureManifest = defaultWindowsCaptureManifestPath(args.home); + const gardenManifest = defaultWindowsGardenManifestPath(args.home); + await mkdir(path.dirname(captureManifest), { recursive: true }); + + try { + await args.exec("schtasks", [ + "/Create", + "/TN", + WINDOWS_CAPTURE_TASK, + ...captureSchedule.args, + "/TR", + windowsTaskCommand(args.programArguments), + "/F", + ]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + stdout: "", + stderr: `almanac: Windows task manifest prepared at ${captureManifest}, but schtasks create failed: ${msg}\n`, + exitCode: 1, + }; + } + await writeWindowsManifest(captureManifest, { + scheduler: "windows-task-scheduler", + taskName: WINDOWS_CAPTURE_TASK, + command: args.programArguments, + intervalSeconds: args.intervalSeconds, + quiet: args.quietValue, + }); + + if (args.gardenIntervalSeconds !== null && gardenSchedule !== null) { + try { + await args.exec("schtasks", [ + "/Create", + "/TN", + WINDOWS_GARDEN_TASK, + ...gardenSchedule.args, + "/TR", + windowsTaskCommand(args.gardenProgramArguments), + "/F", + ]); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { + stdout: "", + stderr: `almanac: Windows capture task installed, but garden task create failed: ${msg}\n`, + exitCode: 1, + }; + } + await writeWindowsManifest(gardenManifest, { + scheduler: "windows-task-scheduler", + taskName: WINDOWS_GARDEN_TASK, + command: args.gardenProgramArguments, + intervalSeconds: args.gardenIntervalSeconds, + }); + } else if (existsSync(gardenManifest)) { + try { + await args.exec("schtasks", ["/Delete", "/TN", WINDOWS_GARDEN_TASK, "/F"]); + } catch { + // Already absent is still a successful disable. + } + await rm(gardenManifest, { force: true }); + } + + return { + stdout: + `almanac: automation installed\n` + + ` scheduler: Windows Task Scheduler\n` + + ` capture interval: ${args.intervalLabel}\n` + + ` capture quiet: ${args.quietValue}\n` + + ` capturing transcripts after: ${args.captureSince}\n` + + ` capture command: ${args.programArguments.join(" ")}\n` + + ` capture task: ${WINDOWS_CAPTURE_TASK}\n` + + ` capture manifest: ${captureManifest}\n` + + (args.gardenIntervalSeconds !== null + ? ` garden interval: ${args.gardenIntervalLabel}\n` + + ` garden command: ${args.gardenProgramArguments.join(" ")}\n` + + ` garden task: ${WINDOWS_GARDEN_TASK}\n` + + ` garden manifest: ${gardenManifest}\n` + : ` garden: disabled\n`), + stderr: "", + exitCode: 0, + }; +} + +export async function uninstallWindowsAutomation(args: { + home: string; + exec: ExecFn; +}): Promise { + const manifests = [ + { path: defaultWindowsCaptureManifestPath(args.home), taskName: WINDOWS_CAPTURE_TASK }, + { path: defaultWindowsGardenManifestPath(args.home), taskName: WINDOWS_GARDEN_TASK }, + ]; + const removed: string[] = []; + for (const manifest of manifests) { + if (!existsSync(manifest.path)) continue; + try { + await args.exec("schtasks", ["/Delete", "/TN", manifest.taskName, "/F"]); + } catch { + // Already absent is still a successful uninstall. + } + await rm(manifest.path, { force: true }); + removed.push(manifest.path); + } + 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(home: string): Promise { + const capture = await readWindowsManifest(defaultWindowsCaptureManifestPath(home)); + const garden = await readWindowsManifest(defaultWindowsGardenManifestPath(home)); + return { + stdout: + formatWindowsStatus("auto-capture automation", capture) + + formatWindowsStatus("garden automation", garden), + stderr: "", + exitCode: 0, + }; +} + +function formatWindowsStatus( + label: string, + manifest: WindowsAutomationManifest | null, +): string { + if (manifest === null) return `${label}: not installed\n`; + return ( + `${label}: installed\n` + + ` scheduler: Windows Task Scheduler\n` + + ` task: ${manifest.taskName}\n` + + ` interval: ${manifest.intervalSeconds}s\n` + + (manifest.quiet !== undefined ? ` quiet: ${manifest.quiet}\n` : "") + ); +} + +async function writeWindowsManifest( + manifestPath: string, + manifest: WindowsAutomationManifest, +): Promise { + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +} + +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, + quiet: typeof parsed.quiet === "string" ? parsed.quiet : undefined, + }; + } + } catch { + return null; + } + return null; +} + +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[]): string { + return args.map(quoteWindowsTaskArg).join(" "); +} + +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/doctor-checks/install.ts b/src/commands/doctor-checks/install.ts index e52cef97..c8bd4fc7 100644 --- a/src/commands/doctor-checks/install.ts +++ b/src/commands/doctor-checks/install.ts @@ -1,10 +1,13 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import type { ClaudeAuthStatus } from "../../agent/providers/claude/index.js"; -import { defaultPlistPath } from "../automation.js"; +import { + defaultPlistPath, + defaultWindowsCaptureManifestPath, +} from "../automation.js"; import { IMPORT_LINE } from "../setup.js"; import { classifyInstallPath, @@ -39,8 +42,11 @@ 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({ + home: homedir(), + platform: options.platform ?? process.platform, + plistPath: options.automationPlistPath, + })); const claudeDir = options.claudeDir ?? path.join(homedir(), ".claude"); checks.push(describeGuides(claudeDir)); @@ -111,7 +117,29 @@ function describeAuth(auth: ClaudeAuthStatus): Check { }; } -function describeAutomation(plistPath: string): Check { +function describeAutomation(args: { + home: string; + platform: NodeJS.Platform; + plistPath?: string; +}): Check { + if (args.platform === "win32") { + const manifestPath = defaultWindowsCaptureManifestPath(args.home); + if (existsSync(manifestPath)) { + const taskName = readWindowsTaskName(manifestPath); + return { + status: "ok", + key: "install.automation", + message: `auto-capture automation installed with Windows Task Scheduler (${taskName ?? manifestPath})`, + }; + } + return { + status: "problem", + key: "install.automation", + message: "auto-capture automation not installed", + fix: "run: almanac automation install", + }; + } + const plistPath = args.plistPath ?? defaultPlistPath(args.home); if (existsSync(plistPath)) { return { status: "ok", @@ -127,6 +155,15 @@ function describeAutomation(plistPath: string): Check { }; } +function readWindowsTaskName(manifestPath: string): string | null { + try { + const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as { taskName?: unknown }; + return typeof parsed.taskName === "string" ? parsed.taskName : null; + } catch { + return null; + } +} + 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 8a4f73f8..29afcd64 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/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 bf72bf4d..8f63339f 100644 --- a/src/commands/doctor-checks/types.ts +++ b/src/commands/doctor-checks/types.ts @@ -20,6 +20,8 @@ export interface DoctorOptions { providerStatuses?: ProviderStatus[]; /** Override auto-capture launchd plist path. */ automationPlistPath?: string; + /** Override platform for scheduler checks; production uses process.platform. */ + platform?: NodeJS.Platform; /** Override `~/.claude/settings.json` path. */ settingsPath?: string; /** Override `~/.almanac/` directory. */ diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 929741c5..a2248654 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -65,8 +65,8 @@ type AutomationExecFn = ( * * Setup installs: * - * 1. macOS launchd jobs that periodically run `almanac capture sweep` - * and `almanac garden`. + * 1. Platform scheduler jobs that periodically run `almanac capture sweep` + * and `almanac garden` (launchd on macOS, Task Scheduler on Windows). * 2. The short "how to use Almanac" guide at * `~/.claude/almanac.md`, sourced from `guides/mini.md` in the * package. @@ -124,6 +124,8 @@ export interface SetupOptions { guidesDir?: string; /** Override interactivity; defaults to `process.stdin.isTTY`. */ isTTY?: boolean; + /** Override platform for scheduler tests; production uses process.platform. */ + platform?: NodeJS.Platform; /** Stdout sink; defaults to `process.stdout`. */ stdout?: NodeJS.WritableStream; /** @@ -332,6 +334,7 @@ export async function runSetup( ); } else { await cleanupLegacyHooks(); + const platform = options.platform ?? process.platform; const res = await runAutomationInstall({ every: options.automationEvery, quiet: options.automationQuiet, @@ -339,14 +342,15 @@ export async function runSetup( gardenOff: options.gardenOff, cwd: process.cwd(), programArguments: ephem - ? globalAlmanacProgramArguments(options.automationQuiet) + ? globalAlmanacProgramArguments(platform, options.automationQuiet) : undefined, gardenProgramArguments: ephem - ? globalGardenProgramArguments() + ? globalGardenProgramArguments(platform) : undefined, plistPath: options.automationPlistPath, gardenPlistPath: options.gardenPlistPath, exec: options.automationExec, + platform, }); if (res.exitCode !== 0) { stepActive(out, `Auto-capture automation: ${res.stderr.trim()}`); @@ -438,11 +442,18 @@ type AgentChoice = | { ok: true; provider: AgentProviderId; model: string | null } | { ok: false; error: string }; -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"]; } diff --git a/src/commands/setup/install-path.ts b/src/commands/setup/install-path.ts index b8f08d3f..092cc726 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,21 +41,13 @@ 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); } /** @@ -63,9 +56,10 @@ export function detectEphemeral(installPath: string): boolean { */ export function spawnGlobalInstall(): Promise { return new Promise((resolve, reject) => { + const command = globalInstallCommand(); execFile( - "npm", - ["install", "-g", "codealmanac@latest"], + command.file, + command.args, { shell: false }, (err, _stdout, stderr) => { if (err !== null) { @@ -83,3 +77,15 @@ export function spawnGlobalInstall(): Promise { ); }); } + +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"] }; +} diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index da879ccf..fa2d65a5 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -30,7 +30,7 @@ type AutomationExecFn = ( * `~/.claude/almanac-reference.md`. Legacy `codealmanac*.md` guide * files are removed too. * 3. The managed Almanac block from Codex's global AGENTS file. - * 4. The scheduled capture/Garden launchd jobs and legacy hook files. + * 4. The scheduled capture/Garden platform scheduler jobs and legacy hook files. * * Flags: * --yes skip confirmations; remove everything diff --git a/src/install/ephemeral.ts b/src/install/ephemeral.ts new file mode 100644 index 00000000..2a576f8b --- /dev/null +++ b/src/install/ephemeral.ts @@ -0,0 +1,36 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +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 normalizedPath = normalizeInstallPath(installPath); + return prefixes.some((prefix) => + hasNormalizedPrefix(normalizedPath, normalizeInstallPath(prefix)) + ); +} + +function normalizeInstallPath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/+$/u, "").toLowerCase(); +} + +function hasNormalizedPrefix(value: string, prefix: string): boolean { + return value === prefix || value.startsWith(`${prefix}/`); +} diff --git a/src/install/global.ts b/src/install/global.ts index d3e4441e..25abe25b 100644 --- a/src/install/global.ts +++ b/src/install/global.ts @@ -10,8 +10,8 @@ import { isNewer } from "../update/semver.js"; /** * Bare `codealmanac` is the npm bootstrap surface. When it is invoked * through `npx`, the running package can live in a temporary cache; if - * setup installs a launchd job that calls `almanac`, the binary must still - * be available later. This helper makes the promise durable: + * setup installs a platform scheduler job that calls `almanac`, the binary + * must still be available later. This helper makes the promise durable: * * 1. If already running from the global npm package, run setup locally. * 2. Otherwise ensure `npm i -g codealmanac@latest` has succeeded. diff --git a/test/automation.test.ts b/test/automation.test.ts index 677fde81..9e84db3d 100644 --- a/test/automation.test.ts +++ b/test/automation.test.ts @@ -2,11 +2,97 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { runAutomationInstall } from "../src/commands/automation.js"; +import { + runAutomationInstall, + runAutomationStatus, + runAutomationUninstall, +} from "../src/commands/automation.js"; import { readConfig } from "../src/update/config.js"; import { withTempHome } from "./helpers.js"; describe("almanac automation", () => { + it("installs Windows Task Scheduler tasks through the platform adapter", async () => { + await withTempHome(async (home) => { + const calls: string[] = []; + + const result = await runAutomationInstall({ + platform: "win32", + homeDir: home, + every: "20m", + quiet: "5m", + gardenEvery: "2d", + programArguments: ["C:\\Program Files\\nodejs\\node.exe", "C:\\codealmanac\\dist\\codealmanac.js", "capture", "sweep", "--quiet", "5m"], + gardenProgramArguments: ["C:\\Program Files\\nodejs\\node.exe", "C:\\codealmanac\\dist\\codealmanac.js", "garden"], + exec: async (file, args) => { + calls.push([file, ...args].join(" ")); + 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"); + expect(result.stdout).toContain("garden task: \\CodeAlmanac\\Garden"); + expect(calls).toContain( + "schtasks /Create /TN \\CodeAlmanac\\CaptureSweep /SC MINUTE /MO 20 /TR \"C:\\Program Files\\nodejs\\node.exe\" \"C:\\codealmanac\\dist\\codealmanac.js\" capture sweep --quiet 5m /F", + ); + expect(calls).toContain( + "schtasks /Create /TN \\CodeAlmanac\\Garden /SC DAILY /MO 2 /TR \"C:\\Program Files\\nodejs\\node.exe\" \"C:\\codealmanac\\dist\\codealmanac.js\" garden /F", + ); + const captureManifest = await readFile( + join(home, ".almanac", "automation", "windows-capture-sweep.json"), + "utf8", + ); + expect(JSON.parse(captureManifest)).toMatchObject({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\CaptureSweep", + intervalSeconds: 1200, + quiet: "5m", + }); + }); + }); + + it("reports and uninstalls Windows scheduler tasks from manifests", async () => { + await withTempHome(async (home) => { + const calls: string[] = []; + await runAutomationInstall({ + platform: "win32", + homeDir: home, + gardenOff: true, + programArguments: ["almanac.cmd", "capture", "sweep", "--quiet", "45m"], + exec: async (file, args) => { + calls.push([file, ...args].join(" ")); + return {}; + }, + 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"); + expect(status.stdout).toContain("task: \\CodeAlmanac\\CaptureSweep"); + expect(status.stdout).toContain("quiet: 45m"); + expect(status.stdout).toContain("garden automation: not installed"); + + const uninstall = await runAutomationUninstall({ + platform: "win32", + homeDir: home, + exec: async (file, args) => { + calls.push([file, ...args].join(" ")); + return {}; + }, + }); + + expect(uninstall.exitCode).toBe(0); + expect(uninstall.stdout).toContain("automation removed"); + expect(calls).toContain("schtasks /Delete /TN \\CodeAlmanac\\CaptureSweep /F"); + await expect( + readFile(join(home, ".almanac", "automation", "windows-capture-sweep.json"), "utf8"), + ).rejects.toThrow(); + }); + }); + it("records auto-capture activation once and preserves it on reinstall", async () => { await withTempHome(async (home) => { const plistPath = join( diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 21b737b4..06c414bf 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -7,6 +7,7 @@ import type { SpawnedProcess, } from "../src/agent/providers/claude/index.js"; import { runDoctor } from "../src/commands/doctor.js"; +import { classifyInstallPath } from "../src/commands/doctor-checks/probes.js"; import { IMPORT_LINE } from "../src/commands/setup.js"; import { makeRepo, @@ -66,6 +67,25 @@ async function scaffoldHealthyInstall(home: string): Promise<{ } describe("almanac doctor", () => { + it("classifies Windows npx and temp install paths as ephemeral", () => { + const previousTemp = process.env.TEMP; + process.env.TEMP = "C:\\Users\\Ada\\AppData\\Local\\Temp"; + try { + expect( + classifyInstallPath("C:\\Users\\Ada\\AppData\\Local\\Temp\\_npx\\abc\\node_modules\\codealmanac"), + ).toMatchObject({ isEphemeral: true }); + expect( + classifyInstallPath("C:\\Users\\Ada\\AppData\\Roaming\\npm\\node_modules\\codealmanac"), + ).toMatchObject({ isEphemeral: false }); + } finally { + if (previousTemp === undefined) { + delete process.env.TEMP; + } else { + process.env.TEMP = previousTemp; + } + } + }); + it("emits stable install keys in JSON mode", async () => { await withTempHome(async (home) => { const env = await scaffoldHealthyInstall(home); @@ -126,6 +146,41 @@ describe("almanac doctor", () => { }); }); + it("checks the Windows automation manifest instead of a launchd plist", async () => { + await withTempHome(async (home) => { + const env = await scaffoldHealthyInstall(home); + const manifestPath = join(home, ".almanac", "automation", "windows-capture-sweep.json"); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + JSON.stringify({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\CaptureSweep", + }), + "utf8", + ); + + const r = await runDoctor({ + cwd: home, + json: true, + platform: "win32", + 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).toContain("Windows Task Scheduler"); + expect(automation.message).toContain("\\CodeAlmanac\\CaptureSweep"); + }); + }); + it("reports auth problems without hiding other install checks", async () => { await withTempHome(async (home) => { const env = await scaffoldHealthyInstall(home); diff --git a/test/setup.test.ts b/test/setup.test.ts index 7b849326..7c2dbc9a 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -9,6 +9,10 @@ import type { SpawnedProcess, } from "../src/agent/providers/claude/index.js"; import { hasImportLine, runSetup } from "../src/commands/setup.js"; +import { + detectEphemeral, + globalInstallCommand, +} from "../src/commands/setup/install-path.js"; import { readConfig, writeConfig } from "../src/update/config.js"; import { withTempHome } from "./helpers.js"; @@ -84,6 +88,36 @@ afterEach(() => { }); describe("codealmanac setup", () => { + it("detects Windows npx and temp install paths as ephemeral", () => { + const previousTemp = process.env.TEMP; + process.env.TEMP = "C:\\Users\\Ada\\AppData\\Local\\Temp"; + try { + expect( + detectEphemeral("C:\\Users\\Ada\\AppData\\Local\\Temp\\_npx\\abc\\node_modules\\codealmanac"), + ).toBe(true); + expect( + detectEphemeral("C:\\Users\\Ada\\AppData\\Roaming\\npm\\node_modules\\codealmanac"), + ).toBe(false); + } finally { + if (previousTemp === undefined) { + delete process.env.TEMP; + } else { + process.env.TEMP = previousTemp; + } + } + }); + + it("runs global npm install through cmd.exe on Windows", () => { + expect(globalInstallCommand("win32")).toEqual({ + file: "cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd install -g codealmanac@latest"], + }); + expect(globalInstallCommand("darwin")).toEqual({ + file: "npm", + args: ["install", "-g", "codealmanac@latest"], + }); + }); + it("installs automation + guides + CLAUDE.md import when --yes", async () => { await withTempHome(async (home) => { const env = await scaffold(home); @@ -397,6 +431,34 @@ describe("codealmanac setup", () => { }); }); + it("uses the npm Windows command shim for automation after npx setup installs globally on Windows", async () => { + await withTempHome(async (home) => { + const env = await scaffold(home); + const calls: string[] = []; + const res = await runSetup({ + yes: true, + isTTY: false, + platform: "win32", + installPath: join(home, ".npm", "_npx", "abc", "node_modules", "codealmanac"), + spawnGlobalInstall: async () => {}, + spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), + automationPlistPath: env.plistPath, + automationExec: async (file: string, args: string[]) => { + calls.push([file, ...args].join(" ")); + return {}; + }, + claudeDir: env.claudeDir, + guidesDir: env.guidesDir, + stdout: env.out, + }); + + expect(res.exitCode).toBe(0); + expect(calls.some((call) => call.includes("schtasks /Create"))).toBe(true); + expect(calls.some((call) => call.includes("almanac.cmd capture sweep --quiet 45m"))).toBe(true); + expect(calls.some((call) => call.includes("/usr/bin/env"))).toBe(false); + }); + }); + it("skips automation from npx setup when the durable global install fails", async () => { await withTempHome(async (home) => { const env = await scaffold(home); From fe8d87e7f4d83a6df9c82006d7ef9d3dae9b2f34 Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 00:38:21 -0700 Subject: [PATCH 2/7] ci: test on windows runners --- .github/workflows/ci.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae2eaf00..6313d292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,26 @@ name: ci # Runs on every push (any branch) and every pull request. Covers the full -# verification loop — install, build, typecheck, test — so red builds surface -# on the first commit, not at release time. Release is handled separately in -# publish.yml (tag-triggered). +# verification loop across Linux and Windows — install, build, typecheck, +# test — so red builds surface on the first commit, not at release time. +# Release is handled separately in publish.yml (tag-triggered). on: push: pull_request: 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 OS/Node pair failing shouldn't cancel the others — we want to + # see whether regressions are runtime-specific or platform-specific. fail-fast: false matrix: + 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. + # 22 (current LTS) on each supported OS so native bindings, path + # handling, npm shims, and scheduler code stay honest. node-version: [20, 22] steps: From a6e71554f031f6014f43875537a1eab55bb13cb2 Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 00:44:58 -0700 Subject: [PATCH 3/7] test(windows): fix ci portability --- .almanac/pages/automation.md | 4 ++ docs/plans/2026-05-14-windows-support.md | 2 + src/commands/uninstall.ts | 2 + test/codex-harness-provider.test.ts | 63 ++++++++++++++---------- test/doctor.test.ts | 3 ++ test/list.test.ts | 2 +- test/registry.test.ts | 9 ++-- test/setup.test.ts | 13 +++++ test/show.test.ts | 2 +- test/uninstall.test.ts | 5 ++ 10 files changed, 73 insertions(+), 32 deletions(-) diff --git a/.almanac/pages/automation.md b/.almanac/pages/automation.md index 7cebfc42..bcede199 100644 --- a/.almanac/pages/automation.md +++ b/.almanac/pages/automation.md @@ -11,9 +11,11 @@ files: - src/cli.ts - src/cli/register-setup-commands.ts - src/cli/register-wiki-lifecycle-commands.ts + - .github/workflows/ci.yml - src/commands/capture-sweep.ts - src/update/config.ts - test/automation.test.ts + - test/codex-harness-provider.test.ts - test/cli.test.ts - test/uninstall.test.ts sources: @@ -51,6 +53,8 @@ The Windows adapter stores manifests at `~/.almanac/automation/windows-capture-s Setup also changes the durable-global command shape on Windows. After an ephemeral `npx` setup successfully installs the package globally, scheduled commands use npm's Windows command shim (`almanac.cmd ...`) instead of `/usr/bin/env almanac ...`. The global install helper uses `cmd.exe /d /s /c npm.cmd install -g codealmanac@latest` on Windows because Node's `execFile` cannot directly launch `.cmd` files. +The repository verifies this path in GitHub Actions with a matrix over `ubuntu-latest` and `windows-latest` on Node 20 and Node 22. Keep platform-specific scheduler tests explicit about `platform: "darwin"` or `platform: "win32"` rather than inheriting `process.platform`; otherwise a Windows runner will correctly take the Task Scheduler branch while a macOS-oriented test is still asserting launchd plist behavior. Fake command-line binaries in tests need the same split: extensionless executable scripts work on Unix-like runners, while Windows needs a `.cmd` shim on `PATH`. + ## What the scheduler owns and what it does not The scheduler owns wakeup cadence and command invocation. It does not own transcript eligibility, cursor state, or capture dedupe. Those remain inside Almanac and are described by [[capture-flow]], [[capture-automation]], and [[capture-ledger]]. diff --git a/docs/plans/2026-05-14-windows-support.md b/docs/plans/2026-05-14-windows-support.md index b0991b8a..52d280af 100644 --- a/docs/plans/2026-05-14-windows-support.md +++ b/docs/plans/2026-05-14-windows-support.md @@ -76,3 +76,5 @@ npm run lint npm test npm run build ``` + +CI now runs the same install, build, typecheck, and test loop on both `ubuntu-latest` and `windows-latest` for Node 20 and Node 22. The first Windows sandbox run surfaced test portability issues rather than build failures: macOS scheduler tests needed explicit `platform: "darwin"` injection, path assertions needed to avoid slash-only regexes, registry tests needed `dirname(...)` instead of slash replacement, and fake `codex` binaries needed a `.cmd` shim plus `path.delimiter` on Windows. diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index fa2d65a5..0dd86de2 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -49,6 +49,7 @@ export interface UninstallOptions { // ─── Injection points ──────────────────────────────────────────── automationPlistPath?: string; gardenPlistPath?: string; + platform?: NodeJS.Platform; automationExec?: AutomationExecFn; claudeDir?: string; codexDir?: string; @@ -97,6 +98,7 @@ export async function runUninstall( const res = await runAutomationUninstall({ plistPath: options.automationPlistPath, gardenPlistPath: options.gardenPlistPath, + platform: options.platform, exec: options.automationExec, }); if (res.exitCode !== 0) { diff --git a/test/codex-harness-provider.test.ts b/test/codex-harness-provider.test.ts index 0b5c1732..ccd3d5c6 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,27 @@ import { } from "../src/harness/providers/codex.js"; import type { AgentRunSpec } from "../src/harness/types.js"; +async function writeFakeCodex(binDir: string, script: string): Promise { + if (process.platform === "win32") { + const scriptPath = join(binDir, "codex-fake.cjs"); + await writeFile(scriptPath, script.replace(/^#![^\n]*\n/u, ""), "utf8"); + await writeFile( + join(binDir, "codex.cmd"), + "@echo off\r\nnode \"%~dp0codex-fake.cjs\" %*\r\n", + "utf8", + ); + return; + } + + const codexPath = join(binDir, "codex"); + await writeFile(codexPath, script, "utf8"); + await chmod(codexPath, 0o755); +} + +function prependPath(binDir: string, oldPath: string | undefined): string { + return `${binDir}${delimiter}${oldPath ?? ""}`; +} + describe("Codex harness provider", () => { it("builds a simple codex exec JSONL request", () => { const spec: AgentRunSpec = { @@ -295,9 +316,8 @@ describe("Codex harness provider", () => { it("runs against a fake app-server process and emits structured events", async () => { const binDir = await mkdtemp(join(tmpdir(), "codealmanac-codex-bin-")); - const codexPath = join(binDir, "codex"); - await writeFile( - codexPath, + await writeFakeCodex( + binDir, `#!/usr/bin/env node const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin }); @@ -397,9 +417,8 @@ rl.on("line", (line) => { }); `, ); - await chmod(codexPath, 0o755); const oldPath = process.env.PATH; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = prependPath(binDir, oldPath); try { const events: unknown[] = []; await expect( @@ -461,9 +480,8 @@ rl.on("line", (line) => { it("does not let helper turn failures poison the root app-server result", async () => { const binDir = await mkdtemp(join(tmpdir(), "codealmanac-codex-helper-fail-bin-")); - const codexPath = join(binDir, "codex"); - await writeFile( - codexPath, + await writeFakeCodex( + binDir, `#!/usr/bin/env node const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin }); @@ -512,9 +530,8 @@ rl.on("line", (line) => { }); `, ); - await chmod(codexPath, 0o755); const oldPath = process.env.PATH; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = prependPath(binDir, oldPath); try { const events: unknown[] = []; await expect( @@ -564,17 +581,15 @@ rl.on("line", (line) => { it("fails instead of hanging when app-server does not answer an RPC", async () => { const binDir = await mkdtemp(join(tmpdir(), "codealmanac-codex-silent-bin-")); - const codexPath = join(binDir, "codex"); - await writeFile( - codexPath, + await writeFakeCodex( + binDir, `#!/usr/bin/env node setInterval(() => {}, 1000); `, ); - await chmod(codexPath, 0o755); const oldPath = process.env.PATH; const oldTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = prependPath(binDir, oldPath); process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS = "25"; try { await expect( @@ -600,9 +615,8 @@ setInterval(() => {}, 1000); it("fails instead of hanging when app-server accepts a turn but never completes", async () => { const binDir = await mkdtemp(join(tmpdir(), "codealmanac-codex-stall-bin-")); - const codexPath = join(binDir, "codex"); - await writeFile( - codexPath, + await writeFakeCodex( + binDir, `#!/usr/bin/env node const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin }); @@ -624,10 +638,9 @@ rl.on("line", (line) => { setInterval(() => {}, 1000); `, ); - await chmod(codexPath, 0o755); const oldPath = process.env.PATH; const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = prependPath(binDir, oldPath); process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "25"; try { await expect( @@ -654,9 +667,8 @@ setInterval(() => {}, 1000); it("does not start the turn watchdog after same-flush completion", async () => { const binDir = await mkdtemp(join(tmpdir(), "codealmanac-codex-fast-bin-")); - const codexPath = join(binDir, "codex"); - await writeFile( - codexPath, + await writeFakeCodex( + binDir, `#!/usr/bin/env node const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin }); @@ -677,10 +689,9 @@ rl.on("line", (line) => { }); `, ); - await chmod(codexPath, 0o755); const oldPath = process.env.PATH; const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = `${binDir}:${oldPath ?? ""}`; + process.env.PATH = prependPath(binDir, oldPath); process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "25"; try { await expect( diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 06c414bf..9b1872dc 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -96,6 +96,7 @@ describe("almanac doctor", () => { const r = await runDoctor({ cwd: repo, json: true, + platform: "darwin", automationPlistPath: env.plistPath, claudeDir: env.claudeDir, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), @@ -129,6 +130,7 @@ describe("almanac doctor", () => { const r = await runDoctor({ cwd: home, json: true, + platform: "darwin", automationPlistPath: missingPlist, claudeDir: env.claudeDir, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), @@ -187,6 +189,7 @@ describe("almanac doctor", () => { const r = await runDoctor({ cwd: home, json: true, + platform: "darwin", automationPlistPath: env.plistPath, claudeDir: env.claudeDir, spawnCli: fakeSpawnCli(LOGGED_OUT_STDOUT), diff --git a/test/list.test.ts b/test/list.test.ts index 23bf5a75..ea0224e5 100644 --- a/test/list.test.ts +++ b/test/list.test.ts @@ -37,7 +37,7 @@ 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, "\\/"))); + expect(result.stdout).toContain(repo); }); }); 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/setup.test.ts b/test/setup.test.ts index 7c2dbc9a..20742230 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -138,6 +138,7 @@ describe("codealmanac setup", () => { const res = await runSetup({ yes: true, isTTY: false, + platform: "darwin" as const, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async (file: string, args: string[]) => { @@ -174,6 +175,7 @@ describe("codealmanac setup", () => { const common = { yes: true, isTTY: false, + platform: "darwin" as const, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => ({}), @@ -200,6 +202,7 @@ describe("codealmanac setup", () => { yes: true, skipAutomation: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => { @@ -223,6 +226,7 @@ describe("codealmanac setup", () => { yes: true, skipGuides: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => ({}), @@ -248,6 +252,7 @@ describe("codealmanac setup", () => { res = await runSetup({ yes: true, isTTY: false, + platform: "darwin", agent: "claude", model: "claude-opus-4-6", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), @@ -276,6 +281,7 @@ describe("codealmanac setup", () => { yes: true, isTTY: false, autoCommit: true, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => ({}), @@ -298,6 +304,7 @@ describe("codealmanac setup", () => { await runSetup({ yes: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => ({}), @@ -318,6 +325,7 @@ describe("codealmanac setup", () => { const res = await runSetup({ yes: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_OUT_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => ({}), @@ -345,6 +353,7 @@ describe("codealmanac setup", () => { await runSetup({ yes: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, automationExec: async () => ({}), @@ -368,6 +377,7 @@ describe("codealmanac setup", () => { skipAutomation: true, skipGuides: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, claudeDir: env.claudeDir, @@ -391,6 +401,7 @@ describe("codealmanac setup", () => { skipAutomation: true, skipGuides: true, isTTY: false, + platform: "darwin", spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), automationPlistPath: env.plistPath, claudeDir: env.claudeDir, @@ -413,6 +424,7 @@ describe("codealmanac setup", () => { const res = await runSetup({ yes: true, isTTY: false, + platform: "darwin", installPath: join(home, ".npm", "_npx", "abc", "node_modules", "codealmanac"), spawnGlobalInstall: async () => {}, spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), @@ -465,6 +477,7 @@ describe("codealmanac setup", () => { const res = await runSetup({ yes: true, isTTY: false, + platform: "darwin", installPath: join(home, ".npm", "_npx", "abc", "node_modules", "codealmanac"), spawnGlobalInstall: async () => { throw new Error("npm unavailable"); diff --git a/test/show.test.ts b/test/show.test.ts index 382d5c69..ad00f342 100644 --- a/test/show.test.ts +++ b/test/show.test.ts @@ -350,7 +350,7 @@ describe("almanac show — single field flags (bare output)", () => { slug: "checkout-flow", path: true, }); - expect(r.stdout.trim()).toMatch( + expect(r.stdout.trim().replaceAll("\\", "/")).toMatch( /\/\.almanac\/pages\/checkout-flow\.md$/, ); }); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index cd63e78a..3be91301 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -53,6 +53,7 @@ describe("almanac uninstall", () => { const res = await runUninstall({ yes: true, isTTY: false, + platform: "darwin", automationPlistPath: env.plistPath, gardenPlistPath: env.gardenPlistPath, automationExec: async (file: string, args: string[]) => { @@ -84,6 +85,7 @@ describe("almanac uninstall", () => { await runUninstall({ yes: true, isTTY: false, + platform: "darwin", automationPlistPath: env.plistPath, gardenPlistPath: env.gardenPlistPath, automationExec: async () => ({}), @@ -101,6 +103,7 @@ describe("almanac uninstall", () => { const res = await runUninstall({ yes: true, isTTY: false, + platform: "darwin", automationPlistPath: env.plistPath, gardenPlistPath: env.gardenPlistPath, automationExec: async () => ({}), @@ -123,6 +126,7 @@ describe("almanac uninstall", () => { yes: true, keepAutomation: true, isTTY: false, + platform: "darwin", automationPlistPath: env.plistPath, gardenPlistPath: env.gardenPlistPath, automationExec: async () => { @@ -147,6 +151,7 @@ describe("almanac uninstall", () => { yes: true, keepGuides: true, isTTY: false, + platform: "darwin", automationPlistPath: env.plistPath, gardenPlistPath: env.gardenPlistPath, automationExec: async () => ({}), From f4a210f5ae6edac472aa969d1a1608c6b473d31f Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 00:48:49 -0700 Subject: [PATCH 4/7] test(windows): cover remaining path cases --- .almanac/pages/automation.md | 2 +- docs/plans/2026-05-14-windows-support.md | 2 +- test/automation.test.ts | 7 +++ test/autoregister.test.ts | 6 +-- test/codex-harness-provider.test.ts | 54 ++++++++++++++++-------- test/setup.test.ts | 2 +- 6 files changed, 49 insertions(+), 24 deletions(-) diff --git a/.almanac/pages/automation.md b/.almanac/pages/automation.md index bcede199..a58f3171 100644 --- a/.almanac/pages/automation.md +++ b/.almanac/pages/automation.md @@ -53,7 +53,7 @@ The Windows adapter stores manifests at `~/.almanac/automation/windows-capture-s Setup also changes the durable-global command shape on Windows. After an ephemeral `npx` setup successfully installs the package globally, scheduled commands use npm's Windows command shim (`almanac.cmd ...`) instead of `/usr/bin/env almanac ...`. The global install helper uses `cmd.exe /d /s /c npm.cmd install -g codealmanac@latest` on Windows because Node's `execFile` cannot directly launch `.cmd` files. -The repository verifies this path in GitHub Actions with a matrix over `ubuntu-latest` and `windows-latest` on Node 20 and Node 22. Keep platform-specific scheduler tests explicit about `platform: "darwin"` or `platform: "win32"` rather than inheriting `process.platform`; otherwise a Windows runner will correctly take the Task Scheduler branch while a macOS-oriented test is still asserting launchd plist behavior. Fake command-line binaries in tests need the same split: extensionless executable scripts work on Unix-like runners, while Windows needs a `.cmd` shim on `PATH`. +The repository verifies this path in GitHub Actions with a matrix over `ubuntu-latest` and `windows-latest` on Node 20 and Node 22. Keep platform-specific scheduler tests explicit about `platform: "darwin"` or `platform: "win32"` rather than inheriting `process.platform`; otherwise a Windows runner will correctly take the Task Scheduler branch while a macOS-oriented test is still asserting launchd plist behavior. Fake command-line binaries in tests need the same split: extensionless executable scripts work on Unix-like runners, while Windows needs a `.cmd` shim on the executable search path. When a test mutates the path on Windows, update and restore the `Path` key as well as `PATH`; spawned child processes may ignore a newly-added uppercase `PATH` when the original environment uses `Path`. ## What the scheduler owns and what it does not diff --git a/docs/plans/2026-05-14-windows-support.md b/docs/plans/2026-05-14-windows-support.md index 52d280af..133783dd 100644 --- a/docs/plans/2026-05-14-windows-support.md +++ b/docs/plans/2026-05-14-windows-support.md @@ -77,4 +77,4 @@ npm test npm run build ``` -CI now runs the same install, build, typecheck, and test loop on both `ubuntu-latest` and `windows-latest` for Node 20 and Node 22. The first Windows sandbox run surfaced test portability issues rather than build failures: macOS scheduler tests needed explicit `platform: "darwin"` injection, path assertions needed to avoid slash-only regexes, registry tests needed `dirname(...)` instead of slash replacement, and fake `codex` binaries needed a `.cmd` shim plus `path.delimiter` on Windows. +CI now runs the same install, build, typecheck, and test loop on both `ubuntu-latest` and `windows-latest` for Node 20 and Node 22. The first Windows sandbox runs surfaced test portability issues rather than build failures: macOS scheduler tests needed explicit `platform: "darwin"` injection, path assertions needed to avoid slash-only regexes, registry tests needed `dirname(...)` instead of slash replacement, and fake `codex` binaries needed a `.cmd` shim plus `path.delimiter` on Windows. The fake CLI helper also has to update `process.env.Path` on Windows, not just `process.env.PATH`, because spawned children can inherit the original mixed-case path key. diff --git a/test/automation.test.ts b/test/automation.test.ts index 9e84db3d..9857c903 100644 --- a/test/automation.test.ts +++ b/test/automation.test.ts @@ -117,6 +117,7 @@ describe("almanac automation", () => { }; const first = await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec, @@ -133,6 +134,7 @@ describe("almanac automation", () => { }); const second = await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec, @@ -191,6 +193,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", cwd: nested, plistPath, gardenPlistPath, @@ -221,6 +224,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", every: "1m", quiet: "1s", gardenEvery: "1w", @@ -264,6 +268,7 @@ describe("almanac automation", () => { ); await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, exec: async () => ({}), @@ -272,6 +277,7 @@ describe("almanac automation", () => { expect(await readFile(gardenPlistPath, "utf8")).toContain("garden"); const result = await runAutomationInstall({ + platform: "darwin", plistPath, gardenPlistPath, gardenOff: true, @@ -307,6 +313,7 @@ describe("almanac automation", () => { ); const result = await runAutomationInstall({ + platform: "darwin", plistPath, gardenOff: true, exec: async () => ({}), diff --git a/test/autoregister.test.ts b/test/autoregister.test.ts index ab88c435..ce27e932 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,9 +101,7 @@ describe("autoRegisterIfNeeded", () => { // Corrupt the registry on disk. const registryPath = getRegistryPath(); - await mkdir(registryPath.replace(/\/registry\.json$/, ""), { - recursive: true, - }); + await mkdir(dirname(registryPath), { recursive: true }); await writeFile(registryPath, "garbage{", "utf8"); await expect(autoRegisterIfNeeded(repo)).rejects.toThrow( diff --git a/test/codex-harness-provider.test.ts b/test/codex-harness-provider.test.ts index ccd3d5c6..47c4f164 100644 --- a/test/codex-harness-provider.test.ts +++ b/test/codex-harness-provider.test.ts @@ -33,8 +33,33 @@ async function writeFakeCodex(binDir: string, script: string): Promise { await chmod(codexPath, 0o755); } -function prependPath(binDir: string, oldPath: string | undefined): string { - return `${binDir}${delimiter}${oldPath ?? ""}`; +interface PathSnapshot { + PATH?: string; + Path?: string; +} + +function prependProcessPath(binDir: string): PathSnapshot { + const snapshot = { PATH: process.env.PATH, Path: process.env.Path }; + const currentPath = process.platform === "win32" + ? process.env.Path ?? process.env.PATH + : process.env.PATH; + const nextPath = `${binDir}${delimiter}${currentPath ?? ""}`; + process.env.PATH = nextPath; + if (process.platform === "win32") process.env.Path = nextPath; + return snapshot; +} + +function restoreProcessPath(snapshot: PathSnapshot): void { + if (snapshot.PATH === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = snapshot.PATH; + } + if (snapshot.Path === undefined) { + delete process.env.Path; + } else { + process.env.Path = snapshot.Path; + } } describe("Codex harness provider", () => { @@ -417,8 +442,7 @@ rl.on("line", (line) => { }); `, ); - const oldPath = process.env.PATH; - process.env.PATH = prependPath(binDir, oldPath); + const pathSnapshot = prependProcessPath(binDir); try { const events: unknown[] = []; await expect( @@ -474,7 +498,7 @@ rl.on("line", (line) => { ]), ); } finally { - process.env.PATH = oldPath; + restoreProcessPath(pathSnapshot); } }); @@ -530,8 +554,7 @@ rl.on("line", (line) => { }); `, ); - const oldPath = process.env.PATH; - process.env.PATH = prependPath(binDir, oldPath); + const pathSnapshot = prependProcessPath(binDir); try { const events: unknown[] = []; await expect( @@ -575,7 +598,7 @@ rl.on("line", (line) => { ]), ); } finally { - process.env.PATH = oldPath; + restoreProcessPath(pathSnapshot); } }); @@ -587,9 +610,8 @@ rl.on("line", (line) => { setInterval(() => {}, 1000); `, ); - const oldPath = process.env.PATH; + const pathSnapshot = prependProcessPath(binDir); const oldTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS; - process.env.PATH = prependPath(binDir, oldPath); process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS = "25"; try { await expect( @@ -604,7 +626,7 @@ setInterval(() => {}, 1000); error: expect.stringContaining("initialize timed out after 25ms"), }); } finally { - process.env.PATH = oldPath; + restoreProcessPath(pathSnapshot); if (oldTimeout === undefined) { delete process.env.CODEALMANAC_CODEX_APP_SERVER_RPC_TIMEOUT_MS; } else { @@ -638,9 +660,8 @@ rl.on("line", (line) => { setInterval(() => {}, 1000); `, ); - const oldPath = process.env.PATH; + const pathSnapshot = prependProcessPath(binDir); const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = prependPath(binDir, oldPath); process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "25"; try { await expect( @@ -655,7 +676,7 @@ setInterval(() => {}, 1000); error: expect.stringContaining("turn timed out after 25ms"), }); } finally { - process.env.PATH = oldPath; + restoreProcessPath(pathSnapshot); if (oldTurnTimeout === undefined) { delete process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; } else { @@ -689,9 +710,8 @@ rl.on("line", (line) => { }); `, ); - const oldPath = process.env.PATH; + const pathSnapshot = prependProcessPath(binDir); const oldTurnTimeout = process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; - process.env.PATH = prependPath(binDir, oldPath); process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS = "25"; try { await expect( @@ -706,7 +726,7 @@ rl.on("line", (line) => { providerSessionId: "thread-1", }); } finally { - process.env.PATH = oldPath; + restoreProcessPath(pathSnapshot); if (oldTurnTimeout === undefined) { delete process.env.CODEALMANAC_CODEX_APP_SERVER_TURN_TIMEOUT_MS; } else { diff --git a/test/setup.test.ts b/test/setup.test.ts index 20742230..0aae563c 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -153,7 +153,7 @@ 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"); + expect(plist.replaceAll("\\", "/")).toContain("dist/codealmanac.js"); expect(plist).toContain("capture"); expect(plist).toContain("sweep"); await expect(readConfig()).resolves.toMatchObject({ From fcbb8a9b214479a2485581facdf6435a0876e5f1 Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 00:52:42 -0700 Subject: [PATCH 5/7] fix(windows): run codex command shims --- .almanac/pages/global-agent-instructions.md | 5 +++-- docs/plans/2026-05-14-windows-support.md | 2 +- src/harness/providers/codex.ts | 12 +++++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.almanac/pages/global-agent-instructions.md b/.almanac/pages/global-agent-instructions.md index 6edf940e..35bb8625 100644 --- a/.almanac/pages/global-agent-instructions.md +++ b/.almanac/pages/global-agent-instructions.md @@ -7,6 +7,7 @@ files: - src/commands/uninstall.ts - src/commands/doctor-checks/install.ts - src/agent/providers/codex-instructions.ts + - src/harness/providers/codex.ts - test/setup.test.ts - test/uninstall.test.ts - test/doctor.test.ts @@ -16,7 +17,7 @@ sources: - /Users/kushagrachitkara/.codex/sessions/2026/05/12/rollout-2026-05-12T20-25-14-019e1f5d-ff59-7ee1-a73b-836277d8092b.jsonl - /Users/kushagrachitkara/.codex/sessions/2026/05/13/rollout-2026-05-13T13-34-26-019e230c-4437-7422-9e8d-b7caa9b592fc.jsonl status: active -verified: 2026-05-13 +verified: 2026-05-14 --- # Global Agent Instructions @@ -81,4 +82,4 @@ The same session also confirmed the reinstall path from a markdown-only reset: a [[src/commands/doctor-checks/install.ts]] currently verifies only the Claude-side artifacts through `install.guides` and `install.import`. There is no Codex-specific doctor check yet, so debugging "Codex is not seeing Almanac guidance" still requires reading `~/.codex/AGENTS.override.md` and `~/.codex/AGENTS.md` directly and checking which file is active. -The provider-status path adds one more practical split for Codex debugging. [[src/harness/providers/codex.ts]] treats Codex as installed only when the `codex` executable is visible on `PATH`; otherwise it reports `codex not found on PATH` before any AGENTS-file logic matters. A support triage for "Codex works in one place but Almanac cannot see it" should therefore start with `which codex` and `codex --version`, then move on to which of `~/.codex/AGENTS.override.md` or `~/.codex/AGENTS.md` is active. +The provider-status path adds one more practical split for Codex debugging. [[src/harness/providers/codex.ts]] treats Codex as installed only when the `codex` executable is visible on `PATH`; otherwise it reports `codex not found on PATH` before any AGENTS-file logic matters. On Unix-like systems, status checks use `command -v codex`; on Windows they use `where codex`, and actual Codex process launches run through the Windows shell so npm command shims such as `codex.cmd` are executable. A support triage for "Codex works in one place but Almanac cannot see it" should therefore start with `which codex`/`where codex` and `codex --version`, then move on to which of `~/.codex/AGENTS.override.md` or `~/.codex/AGENTS.md` is active. diff --git a/docs/plans/2026-05-14-windows-support.md b/docs/plans/2026-05-14-windows-support.md index 133783dd..c69500f2 100644 --- a/docs/plans/2026-05-14-windows-support.md +++ b/docs/plans/2026-05-14-windows-support.md @@ -77,4 +77,4 @@ npm test npm run build ``` -CI now runs the same install, build, typecheck, and test loop on both `ubuntu-latest` and `windows-latest` for Node 20 and Node 22. The first Windows sandbox runs surfaced test portability issues rather than build failures: macOS scheduler tests needed explicit `platform: "darwin"` injection, path assertions needed to avoid slash-only regexes, registry tests needed `dirname(...)` instead of slash replacement, and fake `codex` binaries needed a `.cmd` shim plus `path.delimiter` on Windows. The fake CLI helper also has to update `process.env.Path` on Windows, not just `process.env.PATH`, because spawned children can inherit the original mixed-case path key. +CI now runs the same install, build, typecheck, and test loop on both `ubuntu-latest` and `windows-latest` for Node 20 and Node 22. The first Windows sandbox runs surfaced test portability issues rather than build failures: macOS scheduler tests needed explicit `platform: "darwin"` injection, path assertions needed to avoid slash-only regexes, registry tests needed `dirname(...)` instead of slash replacement, and fake `codex` binaries needed a `.cmd` shim plus `path.delimiter` on Windows. The fake CLI helper also has to update `process.env.Path` on Windows, not just `process.env.PATH`, because spawned children can inherit the original mixed-case path key. The same hosted run exposed one production launcher issue: Codex provider process launches must be Windows-shell-aware so npm shims such as `codex.cmd` are runnable out of the box. diff --git a/src/harness/providers/codex.ts b/src/harness/providers/codex.ts index f848359c..fd76c8ae 100644 --- a/src/harness/providers/codex.ts +++ b/src/harness/providers/codex.ts @@ -179,6 +179,7 @@ export function runCodexCli( cwd: request.cwd, env: request.env, stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", }); let stdoutBuf = ""; @@ -395,6 +396,7 @@ export async function runCodexAppServer( cwd: request.cwd, env: request.env, stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", }); const pending = new Map(); const state: CodexRunState = { success: false, result: "" }; @@ -1482,6 +1484,11 @@ function pruneUndefined>(value: T): T { } function defaultCommandExists(command: string): boolean { + if (process.platform === "win32") { + const result = spawnSync("where", [command], { encoding: "utf8" }); + return result.status === 0 && result.stdout.trim().length > 0; + } + const result = spawnSync("sh", ["-lc", `command -v ${command}`], { encoding: "utf8", }); @@ -1497,7 +1504,10 @@ function defaultRunStatus( let stdout = ""; let stderr = ""; try { - child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); } catch (err: unknown) { resolve({ ok: false, From 345bfea90e9460132e8cf75c37b2f1a5310e684f Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 01:35:05 -0700 Subject: [PATCH 6/7] fix(windows-review): harden scheduler support --- .almanac/pages/almanac-doctor.md | 2 +- .almanac/pages/automation.md | 6 +- .almanac/pages/install-time-node-launcher.md | 2 +- src/commands/automation.ts | 1 + src/commands/automation/windows.ts | 67 ++++++++++++-------- src/commands/doctor-checks/install.ts | 56 ++++++++++++++-- src/commands/doctor-checks/types.ts | 2 + src/harness/providers/codex.ts | 1 - src/install/global.ts | 26 ++++++-- test/automation.test.ts | 15 ++++- test/doctor.test.ts | 67 ++++++++++++++++++++ test/global-bootstrap.test.ts | 26 ++++++++ 12 files changed, 228 insertions(+), 43 deletions(-) diff --git a/.almanac/pages/almanac-doctor.md b/.almanac/pages/almanac-doctor.md index 81d5d554..93536b40 100644 --- a/.almanac/pages/almanac-doctor.md +++ b/.almanac/pages/almanac-doctor.md @@ -49,7 +49,7 @@ The install section currently reports: [[src/commands/doctor-checks/install.ts]] expresses repairs as `fix: "run: ..."` strings. The command prints those hints, but it does not execute them. -The automation check is platform-aware as of the Windows support work. macOS checks the launchd capture plist at `~/Library/LaunchAgents/com.codealmanac.capture-sweep.plist`; Windows checks the Task Scheduler manifest at `~/.almanac/automation/windows-capture-sweep.json` and reports the task name recorded there. +The automation check is platform-aware as of the Windows support work. macOS checks the launchd capture plist at `~/Library/LaunchAgents/com.codealmanac.capture-sweep.plist`; Windows validates the Task Scheduler manifest at `~/.almanac/automation/windows-capture-sweep.json` and then confirms the recorded task name exists with `schtasks /Query`. A stale or malformed manifest is reported as a repairable problem, because otherwise doctor can say automation is healthy after a partial install or manual Task Scheduler deletion. Install-path classification is shared with setup through [[src/install/ephemeral.ts]]. That helper normalizes slashes and case before checking npm npx, pnpm dlx, `/tmp`, `/var/folders`, and Windows temp directories such as `%TEMP%`. Without that shared helper, setup and doctor could disagree about whether a Windows `npx` install is durable enough for scheduler installation. diff --git a/.almanac/pages/automation.md b/.almanac/pages/automation.md index a58f3171..18f4b2ef 100644 --- a/.almanac/pages/automation.md +++ b/.almanac/pages/automation.md @@ -49,9 +49,11 @@ There are two command-path modes. Direct `almanac automation install` writes abs On Windows, capture uses the task name `\CodeAlmanac\CaptureSweep`, and Garden uses `\CodeAlmanac\Garden`. `runAutomationInstall({ platform: "win32" })` maps minute-sized intervals to `schtasks /Create /SC MINUTE /MO ` and whole-day intervals to `/SC DAILY /MO `. The default capture cadence (`5h`) is therefore a 300-minute task, and the default Garden cadence (`2d`) is a two-day task. -The Windows adapter stores manifests at `~/.almanac/automation/windows-capture-sweep.json` and `~/.almanac/automation/windows-garden.json`. Those files are local scheduler metadata, not capture state. They record the task name, command, interval seconds, and quiet window where applicable. Doctor uses the capture manifest to decide whether automation is installed on Windows; it no longer checks a launchd plist on that platform. +The Windows adapter stores manifests at `~/.almanac/automation/windows-capture-sweep.json` and `~/.almanac/automation/windows-garden.json`. Those files are local scheduler metadata, not capture state. They record the task name, command, interval seconds, quiet window where applicable, and the Garden working directory. Doctor validates the capture manifest and checks the Task Scheduler task with `schtasks /Query`; it no longer checks a launchd plist on that platform. -Setup also changes the durable-global command shape on Windows. After an ephemeral `npx` setup successfully installs the package globally, scheduled commands use npm's Windows command shim (`almanac.cmd ...`) instead of `/usr/bin/env almanac ...`. The global install helper uses `cmd.exe /d /s /c npm.cmd install -g codealmanac@latest` on Windows because Node's `execFile` cannot directly launch `.cmd` files. +Garden needs a scheduler working directory because `almanac garden` resolves the target wiki by walking upward from `cwd`. Task Scheduler does not have the launchd-style `WorkingDirectory` field in the simple `schtasks /Create` CLI surface, so Windows Garden wraps the command with `cmd.exe /d /s /c "cd /d && "`. Without that wrapper, scheduled Garden starts from Task Scheduler's default directory and fails to find `.almanac/`. + +Setup also changes the durable-global command shape on Windows. After an ephemeral `npx` setup successfully installs the package globally, scheduled commands use npm's Windows command shim (`almanac.cmd ...`) instead of `/usr/bin/env almanac ...`. Both setup's global install helper and the bare `codealmanac` bootstrap use `cmd.exe /d /s /c npm.cmd ...` on Windows because Node cannot directly launch `.cmd` shims without a shell or explicit `cmd.exe`. The repository verifies this path in GitHub Actions with a matrix over `ubuntu-latest` and `windows-latest` on Node 20 and Node 22. Keep platform-specific scheduler tests explicit about `platform: "darwin"` or `platform: "win32"` rather than inheriting `process.platform`; otherwise a Windows runner will correctly take the Task Scheduler branch while a macOS-oriented test is still asserting launchd plist behavior. Fake command-line binaries in tests need the same split: extensionless executable scripts work on Unix-like runners, while Windows needs a `.cmd` shim on the executable search path. When a test mutates the path on Windows, update and restore the `Path` key as well as `PATH`; spawned child processes may ignore a newly-added uppercase `PATH` when the original environment uses `Path`. diff --git a/.almanac/pages/install-time-node-launcher.md b/.almanac/pages/install-time-node-launcher.md index 3634dcb6..ae548511 100644 --- a/.almanac/pages/install-time-node-launcher.md +++ b/.almanac/pages/install-time-node-launcher.md @@ -55,4 +55,4 @@ npm install -g codealmanac@latest ## Relationship to setup bootstrap -`src/install/global.ts` still treats bare `codealmanac` as the npm bootstrap surface. When the current package root is not the durable global install, it runs `npm i -g codealmanac@latest` if needed and reruns setup from the global package's `dist/launcher.js`, not from `dist/codealmanac.js`. That keeps the post-bootstrap setup flow on the same pinned-runtime path as later interactive CLI invocations. +`src/install/global.ts` still treats bare `codealmanac` as the npm bootstrap surface. When the current package root is not the durable global install, it runs `npm i -g codealmanac@latest` if needed and reruns setup from the global package's `dist/launcher.js`, not from `dist/codealmanac.js`. On Windows, both `npm root -g` and `npm i -g codealmanac@latest` go through `cmd.exe /d /s /c npm.cmd ...`; bare `spawn("npm", ...)` does not reliably launch npm's `.cmd` shim there. That keeps the post-bootstrap setup flow on the same pinned-runtime path as later interactive CLI invocations. diff --git a/src/commands/automation.ts b/src/commands/automation.ts index 8784cc8b..d60d9bb9 100644 --- a/src/commands/automation.ts +++ b/src/commands/automation.ts @@ -108,6 +108,7 @@ export async function runAutomationInstall( gardenIntervalLabel: gardenValue, programArguments, gardenProgramArguments, + gardenWorkingDirectory, exec, captureSince, }); diff --git a/src/commands/automation/windows.ts b/src/commands/automation/windows.ts index 72b156f5..4d7feb7a 100644 --- a/src/commands/automation/windows.ts +++ b/src/commands/automation/windows.ts @@ -19,6 +19,7 @@ interface WindowsAutomationManifest { command: string[]; intervalSeconds: number; quiet?: string; + workingDirectory?: string; } export function defaultWindowsCaptureManifestPath(home: string = homedir()): string { @@ -38,6 +39,7 @@ export async function installWindowsAutomation(args: { gardenIntervalLabel: string; programArguments: string[]; gardenProgramArguments: string[]; + gardenWorkingDirectory: string; exec: ExecFn; captureSince: string; }): Promise { @@ -86,13 +88,15 @@ export async function installWindowsAutomation(args: { try { await args.exec("schtasks", [ "/Create", - "/TN", - WINDOWS_GARDEN_TASK, - ...gardenSchedule.args, - "/TR", - windowsTaskCommand(args.gardenProgramArguments), - "/F", - ]); + "/TN", + WINDOWS_GARDEN_TASK, + ...gardenSchedule.args, + "/TR", + windowsTaskCommand(args.gardenProgramArguments, { + workingDirectory: args.gardenWorkingDirectory, + }), + "/F", + ]); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return { @@ -101,12 +105,13 @@ export async function installWindowsAutomation(args: { exitCode: 1, }; } - await writeWindowsManifest(gardenManifest, { - scheduler: "windows-task-scheduler", - taskName: WINDOWS_GARDEN_TASK, - command: args.gardenProgramArguments, - intervalSeconds: args.gardenIntervalSeconds, - }); + await writeWindowsManifest(gardenManifest, { + scheduler: "windows-task-scheduler", + taskName: WINDOWS_GARDEN_TASK, + command: args.gardenProgramArguments, + intervalSeconds: args.gardenIntervalSeconds, + workingDirectory: args.gardenWorkingDirectory, + }); } else if (existsSync(gardenManifest)) { try { await args.exec("schtasks", ["/Delete", "/TN", WINDOWS_GARDEN_TASK, "/F"]); @@ -126,12 +131,13 @@ export async function installWindowsAutomation(args: { ` capture command: ${args.programArguments.join(" ")}\n` + ` capture task: ${WINDOWS_CAPTURE_TASK}\n` + ` capture manifest: ${captureManifest}\n` + - (args.gardenIntervalSeconds !== null - ? ` garden interval: ${args.gardenIntervalLabel}\n` + - ` garden command: ${args.gardenProgramArguments.join(" ")}\n` + - ` garden task: ${WINDOWS_GARDEN_TASK}\n` + - ` garden manifest: ${gardenManifest}\n` - : ` garden: disabled\n`), + (args.gardenIntervalSeconds !== null + ? ` garden interval: ${args.gardenIntervalLabel}\n` + + ` garden command: ${args.gardenProgramArguments.join(" ")}\n` + + ` garden cwd: ${args.gardenWorkingDirectory}\n` + + ` garden task: ${WINDOWS_GARDEN_TASK}\n` + + ` garden manifest: ${gardenManifest}\n` + : ` garden: disabled\n`), stderr: "", exitCode: 0, }; @@ -220,11 +226,14 @@ async function readWindowsManifest( ) { return { scheduler: "windows-task-scheduler", - taskName: parsed.taskName, - command: parsed.command, - intervalSeconds: parsed.intervalSeconds, - quiet: typeof parsed.quiet === "string" ? parsed.quiet : undefined, - }; + taskName: parsed.taskName, + command: parsed.command, + intervalSeconds: parsed.intervalSeconds, + quiet: typeof parsed.quiet === "string" ? parsed.quiet : undefined, + workingDirectory: typeof parsed.workingDirectory === "string" + ? parsed.workingDirectory + : undefined, + }; } } catch { return null; @@ -248,8 +257,14 @@ function windowsSchedule( }; } -function windowsTaskCommand(args: string[]): string { - return args.map(quoteWindowsTaskArg).join(" "); +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 { diff --git a/src/commands/doctor-checks/install.ts b/src/commands/doctor-checks/install.ts index c8bd4fc7..72f97379 100644 --- a/src/commands/doctor-checks/install.ts +++ b/src/commands/doctor-checks/install.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; @@ -46,6 +47,7 @@ export async function gatherInstallChecks( home: homedir(), platform: options.platform ?? process.platform, plistPath: options.automationPlistPath, + windowsTaskExists: options.windowsTaskExists ?? defaultWindowsTaskExists, })); const claudeDir = options.claudeDir ?? path.join(homedir(), ".claude"); @@ -121,21 +123,32 @@ function describeAutomation(args: { home: string; platform: NodeJS.Platform; plistPath?: string; + windowsTaskExists: (taskName: string) => boolean; }): Check { if (args.platform === "win32") { const manifestPath = defaultWindowsCaptureManifestPath(args.home); - if (existsSync(manifestPath)) { - const taskName = readWindowsTaskName(manifestPath); + const manifest = readWindowsManifest(manifestPath); + if (manifest !== null) { + if (!args.windowsTaskExists(manifest.taskName)) { + return { + status: "problem", + key: "install.automation", + message: `auto-capture automation manifest exists, but Windows Task Scheduler task is missing (${manifest.taskName})`, + fix: "run: almanac automation install", + }; + } return { status: "ok", key: "install.automation", - message: `auto-capture automation installed with Windows Task Scheduler (${taskName ?? manifestPath})`, + message: `auto-capture automation installed with Windows Task Scheduler (${manifest.taskName})`, }; } return { status: "problem", key: "install.automation", - message: "auto-capture automation not installed", + message: existsSync(manifestPath) + ? `auto-capture automation manifest is invalid (${manifestPath})` + : "auto-capture automation not installed", fix: "run: almanac automation install", }; } @@ -155,13 +168,42 @@ function describeAutomation(args: { }; } -function readWindowsTaskName(manifestPath: string): string | null { +interface WindowsAutomationManifest { + scheduler: "windows-task-scheduler"; + taskName: string; + command: string[]; + intervalSeconds: number; +} + +function readWindowsManifest(manifestPath: string): WindowsAutomationManifest | null { try { - const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as { taskName?: unknown }; - return typeof parsed.taskName === "string" ? parsed.taskName : null; + const parsed = JSON.parse(readFileSync(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, + }; + } } 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 { diff --git a/src/commands/doctor-checks/types.ts b/src/commands/doctor-checks/types.ts index 8f63339f..307fea10 100644 --- a/src/commands/doctor-checks/types.ts +++ b/src/commands/doctor-checks/types.ts @@ -22,6 +22,8 @@ export interface DoctorOptions { automationPlistPath?: string; /** Override platform for scheduler checks; production uses process.platform. */ platform?: NodeJS.Platform; + /** Override Windows Task Scheduler task lookup. */ + windowsTaskExists?: (taskName: string) => boolean; /** Override `~/.claude/settings.json` path. */ settingsPath?: string; /** Override `~/.almanac/` directory. */ diff --git a/src/harness/providers/codex.ts b/src/harness/providers/codex.ts index fd76c8ae..6548c543 100644 --- a/src/harness/providers/codex.ts +++ b/src/harness/providers/codex.ts @@ -179,7 +179,6 @@ export function runCodexCli( cwd: request.cwd, env: request.env, stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", }); let stdoutBuf = ""; diff --git a/src/install/global.ts b/src/install/global.ts index 25abe25b..fea9b3aa 100644 --- a/src/install/global.ts +++ b/src/install/global.ts @@ -28,6 +28,7 @@ export interface CodealmanacBootstrapOptions { currentPackageRoot?: string; globalPackageRoot?: string; env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; } const SKIP_BOOTSTRAP_ENV = "CODEALMANAC_SKIP_GLOBAL_BOOTSTRAP"; @@ -36,6 +37,7 @@ export async function runCodealmanacBootstrap( opts: CodealmanacBootstrapOptions, ): Promise { const env = opts.env ?? process.env; + const platform = opts.platform ?? process.platform; const runSetupFn = opts.runSetup ?? runSetup; const currentRoot = opts.currentPackageRoot ?? findCurrentPackageRoot(); @@ -46,7 +48,7 @@ export async function runCodealmanacBootstrap( const globalRootResult = opts.globalPackageRoot !== undefined ? { ok: true as const, path: opts.globalPackageRoot } - : await resolveGlobalPackageRoot(opts.spawnFn ?? spawn); + : await resolveGlobalPackageRoot(opts.spawnFn ?? spawn, platform); if (!globalRootResult.ok) { return { @@ -62,10 +64,11 @@ export async function runCodealmanacBootstrap( } if (await shouldInstallGlobal(currentRoot, globalRoot)) { + const npmInstall = npmCommand(["i", "-g", "codealmanac@latest"], platform); const install = await spawnInherited( opts.spawnFn ?? spawn, - "npm", - ["i", "-g", "codealmanac@latest"], + npmInstall.cmd, + npmInstall.args, env, ); if (install.exitCode !== 0) { @@ -153,8 +156,10 @@ function findCurrentPackageRoot(): string { async function resolveGlobalPackageRoot( spawnFn: typeof spawn, + platform: NodeJS.Platform, ): Promise<{ ok: true; path: string } | { ok: false; stderr: string }> { - const result = await spawnCaptured(spawnFn, "npm", ["root", "-g"]); + const npmRoot = npmCommand(["root", "-g"], platform); + const result = await spawnCaptured(spawnFn, npmRoot.cmd, npmRoot.args); if (result.exitCode !== 0) { return { ok: false, @@ -177,6 +182,19 @@ async function resolveGlobalPackageRoot( return { ok: true, path: path.join(root, "codealmanac") }; } +function npmCommand( + args: string[], + platform: NodeJS.Platform, +): { cmd: string; args: string[] } { + if (platform === "win32") { + return { + cmd: "cmd.exe", + args: ["/d", "/s", "/c", ["npm.cmd", ...args].join(" ")], + }; + } + return { cmd: "npm", args }; +} + async function spawnInherited( spawnFn: typeof spawn, cmd: string, diff --git a/test/automation.test.ts b/test/automation.test.ts index 9857c903..610cc4f5 100644 --- a/test/automation.test.ts +++ b/test/automation.test.ts @@ -13,11 +13,14 @@ import { withTempHome } from "./helpers.js"; describe("almanac automation", () => { it("installs Windows Task Scheduler tasks through the platform adapter", async () => { await withTempHome(async (home) => { + const repo = join(home, "repo"); + await mkdir(join(repo, ".almanac"), { recursive: true }); const calls: string[] = []; const result = await runAutomationInstall({ platform: "win32", homeDir: home, + cwd: repo, every: "20m", quiet: "5m", gardenEvery: "2d", @@ -38,7 +41,7 @@ describe("almanac automation", () => { "schtasks /Create /TN \\CodeAlmanac\\CaptureSweep /SC MINUTE /MO 20 /TR \"C:\\Program Files\\nodejs\\node.exe\" \"C:\\codealmanac\\dist\\codealmanac.js\" capture sweep --quiet 5m /F", ); expect(calls).toContain( - "schtasks /Create /TN \\CodeAlmanac\\Garden /SC DAILY /MO 2 /TR \"C:\\Program Files\\nodejs\\node.exe\" \"C:\\codealmanac\\dist\\codealmanac.js\" garden /F", + `schtasks /Create /TN \\CodeAlmanac\\Garden /SC DAILY /MO 2 /TR cmd.exe /d /s /c "cd /d ${repo} && "C:\\Program Files\\nodejs\\node.exe" "C:\\codealmanac\\dist\\codealmanac.js" garden" /F`, ); const captureManifest = await readFile( join(home, ".almanac", "automation", "windows-capture-sweep.json"), @@ -50,6 +53,16 @@ describe("almanac automation", () => { intervalSeconds: 1200, quiet: "5m", }); + const gardenManifest = await readFile( + join(home, ".almanac", "automation", "windows-garden.json"), + "utf8", + ); + expect(JSON.parse(gardenManifest)).toMatchObject({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\Garden", + intervalSeconds: 172800, + workingDirectory: repo, + }); }); }); diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 9b1872dc..011f1a28 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -158,6 +158,8 @@ describe("almanac doctor", () => { JSON.stringify({ scheduler: "windows-task-scheduler", taskName: "\\CodeAlmanac\\CaptureSweep", + command: ["almanac.cmd", "capture", "sweep", "--quiet", "45m"], + intervalSeconds: 18000, }), "utf8", ); @@ -171,6 +173,7 @@ describe("almanac doctor", () => { sqliteProbe: SQLITE_OK, installPath: "/fake", versionOverride: "0.1.3", + windowsTaskExists: () => true, }); const parsed = JSON.parse(r.stdout); @@ -183,6 +186,70 @@ describe("almanac doctor", () => { }); }); + it("flags stale or malformed Windows automation manifests", async () => { + await withTempHome(async (home) => { + const env = await scaffoldHealthyInstall(home); + const manifestPath = join(home, ".almanac", "automation", "windows-capture-sweep.json"); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + JSON.stringify({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\CaptureSweep", + command: ["almanac.cmd", "capture", "sweep", "--quiet", "45m"], + intervalSeconds: 18000, + }), + "utf8", + ); + + const stale = await runDoctor({ + cwd: home, + json: true, + platform: "win32", + claudeDir: env.claudeDir, + spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), + sqliteProbe: SQLITE_OK, + installPath: "/fake", + versionOverride: "0.1.3", + windowsTaskExists: () => false, + }); + + let parsed = JSON.parse(stale.stdout); + let automation = parsed.install.find( + (c: { key: string }) => c.key === "install.automation", + ); + expect(automation.status).toBe("problem"); + expect(automation.message).toContain("Task Scheduler task is missing"); + + await writeFile( + manifestPath, + JSON.stringify({ + scheduler: "windows-task-scheduler", + taskName: "\\CodeAlmanac\\CaptureSweep", + }), + "utf8", + ); + const malformed = await runDoctor({ + cwd: home, + json: true, + platform: "win32", + claudeDir: env.claudeDir, + spawnCli: fakeSpawnCli(LOGGED_IN_STDOUT), + sqliteProbe: SQLITE_OK, + installPath: "/fake", + versionOverride: "0.1.3", + windowsTaskExists: () => true, + }); + + parsed = JSON.parse(malformed.stdout); + automation = parsed.install.find( + (c: { key: string }) => c.key === "install.automation", + ); + expect(automation.status).toBe("problem"); + expect(automation.message).toContain("manifest is invalid"); + }); + }); + it("reports auth problems without hiding other install checks", async () => { await withTempHome(async (home) => { const env = await scaffoldHealthyInstall(home); diff --git a/test/global-bootstrap.test.ts b/test/global-bootstrap.test.ts index 258935b3..b8dbd365 100644 --- a/test/global-bootstrap.test.ts +++ b/test/global-bootstrap.test.ts @@ -75,6 +75,32 @@ describe("runCodealmanacBootstrap", () => { }); }); + it("uses npm.cmd through cmd.exe for Windows global bootstrap", async () => { + await withTempHome(async (home) => { + const currentRoot = join(home, "_npx", "codealmanac"); + const globalRoot = join(home, "global", "node_modules", "codealmanac"); + await writePackage(currentRoot, "0.1.5"); + + const calls: { cmd: string; args: string[]; stdio: unknown; env?: NodeJS.ProcessEnv }[] = []; + const result = await runCodealmanacBootstrap({ + setupOptions: { yes: true }, + setupArgs: ["--yes"], + currentPackageRoot: currentRoot, + globalPackageRoot: globalRoot, + runSetup: vi.fn(), + spawnFn: fakeSpawn(calls, [0, 0]), + platform: "win32", + }); + + expect(result.exitCode).toBe(0); + expect(calls[0]).toMatchObject({ + cmd: "cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd i -g codealmanac@latest"], + stdio: "inherit", + }); + }); + }); + it("runs setup locally when already executing from the global package", async () => { await withTempHome(async (home) => { const globalRoot = join(home, "global", "node_modules", "codealmanac"); From 2011fc88e005e2b45c2cf0244146b1bf4466d550 Mon Sep 17 00:00:00 2001 From: divitsheth Date: Thu, 14 May 2026 01:37:55 -0700 Subject: [PATCH 7/7] test(windows): stabilize scheduler assertions --- test/automation.test.ts | 11 ++++++++--- test/global-bootstrap.test.ts | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/automation.test.ts b/test/automation.test.ts index 610cc4f5..a8a8a1e3 100644 --- a/test/automation.test.ts +++ b/test/automation.test.ts @@ -40,9 +40,14 @@ describe("almanac automation", () => { expect(calls).toContain( "schtasks /Create /TN \\CodeAlmanac\\CaptureSweep /SC MINUTE /MO 20 /TR \"C:\\Program Files\\nodejs\\node.exe\" \"C:\\codealmanac\\dist\\codealmanac.js\" capture sweep --quiet 5m /F", ); - expect(calls).toContain( - `schtasks /Create /TN \\CodeAlmanac\\Garden /SC DAILY /MO 2 /TR cmd.exe /d /s /c "cd /d ${repo} && "C:\\Program Files\\nodejs\\node.exe" "C:\\codealmanac\\dist\\codealmanac.js" garden" /F`, - ); + const gardenCall = calls.find((call) => call.includes("\\CodeAlmanac\\Garden")); + expect(gardenCall).toBeDefined(); + const gardenTaskCall = gardenCall ?? ""; + expect(gardenTaskCall).toContain("/SC DAILY /MO 2"); + expect(gardenTaskCall).toContain('cmd.exe /d /s /c "cd /d'); + expect(gardenTaskCall).toContain(repo); + expect(gardenTaskCall).toContain('"C:\\Program Files\\nodejs\\node.exe"'); + expect(gardenTaskCall).toContain('"C:\\codealmanac\\dist\\codealmanac.js" garden'); const captureManifest = await readFile( join(home, ".almanac", "automation", "windows-capture-sweep.json"), "utf8", diff --git a/test/global-bootstrap.test.ts b/test/global-bootstrap.test.ts index b8dbd365..26abe4d2 100644 --- a/test/global-bootstrap.test.ts +++ b/test/global-bootstrap.test.ts @@ -52,6 +52,7 @@ describe("runCodealmanacBootstrap", () => { globalPackageRoot: globalRoot, runSetup, spawnFn: fakeSpawn(calls, [0, 0]), + platform: "darwin", }); expect(result.exitCode).toBe(0);