diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e5d0ce3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Lint (tsc --noEmit) + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml index 52c811c..59b2914 100644 --- a/.github/workflows/release-binary.yml +++ b/.github/workflows/release-binary.yml @@ -23,6 +23,16 @@ jobs: - os: macos-latest target: darwin-x64 node-arch: x64 + # Windows bundles are built on ubuntu-latest because esbuild output + # is platform-agnostic JS — the bundle runs under Node on any OS. + # install.ps1 generates the accompanying axme-code.cmd wrapper at + # install time so we ship a single file per Windows arch. + - os: ubuntu-latest + target: windows-x64 + node-arch: x64 + - os: ubuntu-latest + target: windows-arm64 + node-arch: arm64 runs-on: ${{ matrix.os }} permissions: diff --git a/README.md b/README.md index 779384f..12c66be 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,19 @@ Decisions enforce verification requirements: agent must run tests and show proof **Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (CLI or VS Code extension).** +**Linux / macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/AxmeAI/axme-code/main/install.sh | bash ``` +Installs to `~/.local/bin/axme-code`. Supports x64 and ARM64. -Installs to `~/.local/bin/axme-code`. Supports Linux and macOS (x64 and ARM64). +**Windows (native):** +```powershell +irm https://raw.githubusercontent.com/AxmeAI/axme-code/main/install.ps1 | iex +``` +Installs to `%LOCALAPPDATA%\Programs\axme-code` and adds it to your User PATH. Requires Node.js 20+ on PATH. Supports x64 and ARM64. -**Windows via WSL2** is supported. Install a WSL2 distro (`wsl --install -d Ubuntu-22.04`), then install both Claude Code and axme-code **inside** your WSL distro — not on the Windows host. Native Windows is not yet supported. +**Windows via WSL2:** if you already live in WSL2, use the Linux install one-liner inside your distro. Install Claude Code and axme-code **inside** the WSL distro, not on the Windows host. ### Setup diff --git a/build.mjs b/build.mjs index 695ae1d..40e56e8 100644 --- a/build.mjs +++ b/build.mjs @@ -31,10 +31,16 @@ await build({ define, }); -// Create bin wrapper +// Create bin wrappers — POSIX shebang entry + Windows .cmd wrapper. Shipping +// both means install.sh/install.ps1 can place them side-by-side on any +// platform; the one that matches the shell wins. import { writeFileSync, chmodSync, mkdirSync } from "fs"; writeFileSync("dist/axme-code.js", '#!/usr/bin/env node\nimport("./cli.mjs");\n'); chmodSync("dist/axme-code.js", 0o755); +// Windows CMD wrapper — forwards all args to node + axme-code.js. %~dp0 +// resolves to the directory of the .cmd at runtime so this works regardless +// of cwd or PATH entry style. +writeFileSync("dist/axme-code.cmd", "@echo off\r\nnode \"%~dp0axme-code.js\" %*\r\n"); // --- Plugin bundled builds (self-contained, zero external deps) --- @@ -64,13 +70,15 @@ await build({ define, }); -// Plugin bin wrapper — sets NODE_PATH so SDK can be found from CLAUDE_PLUGIN_DATA +// Plugin bin wrappers — POSIX bash script + Windows .cmd. Both forward to +// node + the plugin's bundled cli.mjs, located one directory up from bin/. mkdirSync("dist/plugin/bin", { recursive: true }); writeFileSync("dist/plugin/bin/axme-code", `#!/bin/bash PLUGIN_DIR="\$(cd "\$(dirname "\$0")/.." && pwd)" exec node "\$PLUGIN_DIR/cli.mjs" "\$@" `); chmodSync("dist/plugin/bin/axme-code", 0o755); +writeFileSync("dist/plugin/bin/axme-code.cmd", "@echo off\r\nnode \"%~dp0..\\cli.mjs\" %*\r\n"); // Plugin package.json — only SDK for npm install in CLAUDE_PLUGIN_DATA writeFileSync("dist/plugin/package.json", JSON.stringify({ @@ -100,21 +108,26 @@ writeFileSync("dist/plugin/.mcp.json", JSON.stringify({ }, }, null, 2) + "\n"); -// Plugin hooks — safety enforcement via bundled CLI +// Plugin hooks — safety enforcement via bundled CLI. All commands quote the +// ${CLAUDE_PLUGIN_ROOT} expansion so paths with spaces survive sh -c and +// cmd.exe /c unchanged. The SessionStart hook used to shell out to `test -d +// ... || (cd ... && npm install)` which was POSIX-only; the lazy SDK +// install is now inside the `check-init` subcommand so this command is a +// plain Node invocation and works on Windows natively. writeFileSync("dist/plugin/hooks/hooks.json", JSON.stringify({ description: "AXME Code safety enforcement and session tracking", hooks: { SessionStart: [{ hooks: [{ type: "command", - command: "test -d ${CLAUDE_PLUGIN_ROOT}/node_modules/@anthropic-ai/claude-agent-sdk || (cd ${CLAUDE_PLUGIN_ROOT} && npm install --omit=dev --ignore-scripts 2>/dev/null) ; node ${CLAUDE_PLUGIN_ROOT}/cli.mjs check-init", + command: 'node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" check-init', timeout: 30, }], }], PreToolUse: [{ hooks: [{ type: "command", - command: "node ${CLAUDE_PLUGIN_ROOT}/cli.mjs hook pre-tool-use", + command: 'node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" hook pre-tool-use', timeout: 5, }], }], @@ -122,14 +135,14 @@ writeFileSync("dist/plugin/hooks/hooks.json", JSON.stringify({ matcher: "Edit|Write|NotebookEdit", hooks: [{ type: "command", - command: "node ${CLAUDE_PLUGIN_ROOT}/cli.mjs hook post-tool-use", + command: 'node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" hook post-tool-use', timeout: 10, }], }], SessionEnd: [{ hooks: [{ type: "command", - command: "node ${CLAUDE_PLUGIN_ROOT}/cli.mjs hook session-end", + command: 'node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" hook session-end', timeout: 120, }], }], diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..7542673 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,109 @@ +# AXME Code - Windows installer +# +# Downloads the axme-code standalone Node bundle from GitHub Releases, +# places it under %LOCALAPPDATA%\Programs\axme-code\ along with a .cmd +# wrapper, and adds that directory to the User PATH. +# +# Usage: +# iwr -useb https://raw.githubusercontent.com/AxmeAI/axme-code/main/install.ps1 | iex +# Or: irm https://raw.githubusercontent.com/AxmeAI/axme-code/main/install.ps1 | iex +# +# Requires: Windows PowerShell 5.1+ or PowerShell 7+, Node.js 20+ on PATH. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$Repo = if ($env:AXME_REPO) { $env:AXME_REPO } else { 'AxmeAI/axme-code' } +$InstallDir = if ($env:AXME_INSTALL_DIR) { $env:AXME_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\axme-code' } + +function Get-Arch { + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -eq 'ARM64') { return 'arm64' } + if ($arch -eq 'AMD64') { return 'x64' } + if ($arch -eq 'x86') { throw "32-bit Windows is not supported. axme-code requires x64 or arm64." } + throw "Unsupported PROCESSOR_ARCHITECTURE: $arch" +} + +function Get-LatestTag { + $url = "https://api.github.com/repos/$Repo/releases/latest" + try { + $release = Invoke-RestMethod -Uri $url -Headers @{ 'User-Agent' = 'axme-code-installer' } + return $release.tag_name + } catch { + throw "Failed to fetch latest release from $url : $($_.Exception.Message)" + } +} + +function Test-Node { + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { + Write-Warning "node.exe not found on PATH. Install Node.js 20+ from https://nodejs.org before running axme-code." + return + } + try { + $versionLine = & node --version 2>$null + if ($versionLine -match 'v(\d+)') { + $major = [int]$Matches[1] + if ($major -lt 20) { + Write-Warning "Found Node $versionLine but axme-code requires Node 20+." + } + } + } catch { } +} + +# --- Main ----------------------------------------------------------------- + +$arch = Get-Arch +$platform = "windows-$arch" + +$version = if ($args.Count -ge 1 -and $args[0]) { $args[0] } else { Get-LatestTag } +if (-not $version) { throw 'Could not determine version. Specify as first argument, e.g. install.ps1 v0.2.9' } + +Write-Host "Installing axme-code $version ($platform) to $InstallDir..." + +$null = New-Item -ItemType Directory -Path $InstallDir -Force + +$downloadUrl = "https://github.com/$Repo/releases/download/$version/axme-code-$platform" +$jsTarget = Join-Path $InstallDir 'axme-code.js' +$cmdTarget = Join-Path $InstallDir 'axme-code.cmd' + +Write-Host "Downloading $downloadUrl..." +try { + Invoke-WebRequest -Uri $downloadUrl -OutFile $jsTarget -UseBasicParsing +} catch { + throw "Download failed: $($_.Exception.Message). Check that release $version has asset axme-code-$platform." +} + +# Generate the .cmd wrapper. %~dp0 expands to the directory of the .cmd at +# runtime (with trailing backslash), so this works regardless of how the +# user put the install dir on PATH. +$cmdContent = "@echo off`r`nnode `"%~dp0axme-code.js`" %*`r`n" +Set-Content -Path $cmdTarget -Value $cmdContent -NoNewline -Encoding ASCII + +# Add install dir to User PATH if not already present. [Environment]::SetEnvironmentVariable +# writes to the registry so it persists across sessions; the current session +# gets updated via $env:Path. +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if ($null -eq $userPath) { $userPath = '' } +$pathEntries = $userPath -split ';' | Where-Object { $_ -ne '' } +if ($pathEntries -notcontains $InstallDir) { + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + $env:Path = "$env:Path;$InstallDir" + Write-Host "Added $InstallDir to User PATH." +} else { + Write-Host "$InstallDir already on User PATH." +} + +Test-Node + +Write-Host '' +Write-Host "Installed axme-code to $jsTarget" +Write-Host "Wrapper at $cmdTarget" +Write-Host '' +Write-Host 'Get started:' +Write-Host ' cd your-project' +Write-Host ' axme-code setup' +Write-Host '' +Write-Host 'Note: if the axme-code command is not found, open a new terminal so PATH refreshes.' diff --git a/package.json b/package.json index f637b3f..f69b0cd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "node build.mjs", "start": "node dist/server.js", "dev": "tsx src/cli.ts", - "test": "tsx --test test/*.test.ts", + "test": "node scripts/run-tests.mjs", "lint": "tsc --noEmit" }, "engines": { diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..2e186a7 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Cross-platform test runner. Enumerates test/*.test.ts and spawns tsx --test + * with explicit file paths. Replaces the shell-glob pattern in the npm test + * script, which fails on Windows because cmd.exe/PowerShell don't expand *. + */ + +import { readdirSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { join, resolve } from "node:path"; + +const testDir = resolve("test"); +const files = readdirSync(testDir) + .filter((f) => f.endsWith(".test.ts")) + .sort() + .map((f) => join(testDir, f)); + +if (files.length === 0) { + console.error(`No .test.ts files found in ${testDir}`); + process.exit(1); +} + +const isWin = process.platform === "win32"; +const tsx = isWin ? "npx.cmd" : "npx"; +const child = spawn(tsx, ["tsx", "--test", ...files], { + stdio: "inherit", + shell: isWin, +}); + +child.on("exit", (code) => { + process.exit(code ?? 0); +}); + +child.on("error", (err) => { + console.error(`Failed to start tsx: ${err.message}`); + process.exit(1); +}); diff --git a/src/cli.ts b/src/cli.ts index fe6da5d..e9acec9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,7 @@ * axme-code hook - Run hook (pre-tool-use, post-tool-use, session-end) */ -import { resolve, join } from "node:path"; +import { resolve, join, basename } from "node:path"; import { writeFileSync, existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs"; import yaml from "js-yaml"; import { initProjectWithLLM, initWorkspaceWithLLM } from "./tools/init.js"; @@ -203,7 +203,7 @@ async function ensureAuthConfiguredForSetup(): Promise { function generateWorkspaceYaml(workspacePath: string, ws: WorkspaceInfo): void { const wsYaml = yaml.dump({ - name: workspacePath.split("/").pop(), + name: basename(workspacePath), type: ws.type, manifest: ws.manifestPath, projects: ws.projects, @@ -213,6 +213,22 @@ function generateWorkspaceYaml(workspacePath: string, ws: WorkspaceInfo): void { console.log(" workspace.yaml: created"); } +/** + * Build a shell-portable hook command: `"" "" hook + * --workspace ""`. Using the absolute node binary and the + * axme-code entry file makes the command independent of PATH, so hooks + * fire reliably even when the `axme-code`/`axme-code.cmd` wrapper + * is not on the session PATH (common on Windows). Quoting every segment + * lets Claude Code hand the string to `sh -c` or `cmd.exe /c` without + * word-splitting on spaces. + */ +function buildHookCommand(hookName: string, projectPath: string): string { + const nodeExec = process.execPath; + const self = resolve(process.argv[1] ?? "axme-code"); + const q = (s: string) => `"${s}"`; + return `${q(nodeExec)} ${q(self)} hook ${hookName} --workspace ${q(projectPath)}`; +} + function configureHooks(projectPath: string): void { const claudeDir = join(projectPath, ".claude"); const settingsPath = join(claudeDir, "settings.json"); @@ -239,7 +255,7 @@ function configureHooks(projectPath: string): void { settings.hooks.PreToolUse.push({ hooks: [{ type: "command", - command: `axme-code hook pre-tool-use --workspace ${projectPath}`, + command: buildHookCommand("pre-tool-use", projectPath), timeout: 5, }], }); @@ -251,7 +267,7 @@ function configureHooks(projectPath: string): void { matcher: "Edit|Write|NotebookEdit", hooks: [{ type: "command", - command: `axme-code hook post-tool-use --workspace ${projectPath}`, + command: buildHookCommand("post-tool-use", projectPath), timeout: 10, }], }); @@ -261,7 +277,7 @@ function configureHooks(projectPath: string): void { settings.hooks.SessionEnd.push({ hooks: [{ type: "command", - command: `axme-code hook session-end --workspace ${projectPath}`, + command: buildHookCommand("session-end", projectPath), timeout: 120, }], }); @@ -405,7 +421,7 @@ async function main() { const totalCost = workspaceResult.cost.costUsd + projectResults.reduce((s, r) => s + r.cost.costUsd, 0); console.log(` Workspace: ${workspaceResult.decisions.count} decisions, ${workspaceResult.memories.count} memories`); for (const r of projectResults) { - const name = r.projectPath.split("/").pop(); + const name = basename(r.projectPath); console.log(` ${name}: ${r.decisions.count} decisions (${r.decisions.fromScan} LLM + ${r.decisions.fromPresets} presets)`); } if (totalCost > 0) console.log(` Total cost: $${totalCost.toFixed(2)}`); @@ -545,7 +561,34 @@ async function main() { } case "check-init": { - // Plugin SessionStart hook — ensures CLAUDE.md exists and outputs instruction + // Plugin SessionStart hook — lazy-install the SDK if we're running from + // a plugin root that hasn't had one yet, then ensure CLAUDE.md exists + // and output the instruction. Moving the lazy install inline here (vs. + // an inline shell test in hooks.json) makes SessionStart cross-platform + // — the previous `test -d ... || (cd ... && npm install) ; node ...` + // uses POSIX-only syntax that cmd.exe can't execute. + if (process.env.CLAUDE_PLUGIN_ROOT) { + const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; + const sdkDir = join(pluginRoot, "node_modules", "@anthropic-ai", "claude-agent-sdk"); + if (!existsSync(sdkDir)) { + try { + const { execSync } = await import("node:child_process"); + // execSync always spawns through a shell (sh on POSIX, cmd.exe on + // Windows), so `npm` resolves to `npm.cmd` on Windows without any + // extra flag. + execSync("npm install --omit=dev --ignore-scripts", { + cwd: pluginRoot, + stdio: "ignore", + timeout: 25_000, + }); + } catch { + // Silent — fall through. The plugin still works for deterministic + // paths (safety hooks, context lookup) even without the SDK; + // only LLM-backed scans need it and they'll fail loudly later. + } + } + } + const checkPath = resolve(args[1] || "."); const claudeMdPath = join(checkPath, "CLAUDE.md"); const axmeSection = `## AXME Code diff --git a/src/storage/decisions.ts b/src/storage/decisions.ts index fb5e34e..db2c452 100644 --- a/src/storage/decisions.ts +++ b/src/storage/decisions.ts @@ -7,7 +7,7 @@ */ import { readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, resolve, basename } from "node:path"; import { atomicWrite, ensureDir, pathExists } from "./engine.js"; import { logDecisionSaved, logDecisionSuperseded } from "./worklog.js"; import type { Decision } from "../types.js"; @@ -218,7 +218,7 @@ export function saveScopedDecisions( decisions: Array>, projectPath: string, workspacePath?: string, ): { saved: number; crossProject: number } { let saved = 0, crossProject = 0; - const projectName = projectPath.split("/").pop() ?? ""; + const projectName = basename(projectPath); for (const d of decisions) { const scope = d.scope; @@ -263,7 +263,7 @@ export function listScopedDecisions(projectPath: string, workspacePath?: string) const projectDecisions = listDecisions(projectPath); if (!workspacePath || workspacePath === projectPath) return projectDecisions; - const projectName = projectPath.split("/").pop() ?? ""; + const projectName = basename(projectPath); const wsDecisions = listDecisions(workspacePath); const relevantWs = wsDecisions.filter(d => d.scope && (d.scope.includes(projectName) || d.scope.includes("all")) diff --git a/src/storage/engine.ts b/src/storage/engine.ts index bc99314..f0627f1 100644 --- a/src/storage/engine.ts +++ b/src/storage/engine.ts @@ -36,11 +36,17 @@ export function atomicWrite(filePath: string, content: string): void { const tmpPath = join(dir, `.tmp-${randomUUID()}`); try { - writeFileSync(tmpPath, content, "utf-8"); - // fsync before rename ensures content is on disk, not just in OS buffers. - // On crash between write and rename, the file is intact. - const fd = openSync(tmpPath, "r"); - try { fsyncSync(fd); } finally { closeSync(fd); } + // Write and fsync through the same writable fd. Windows' FlushFileBuffers + // (Node maps fsyncSync to it) requires write access on the handle — a + // read-only fd returns EPERM. POSIX allows fsync on any fd, so the write- + // fd pattern is correct on both platforms. + const fd = openSync(tmpPath, "w"); + try { + writeSync(fd, Buffer.from(content, "utf-8")); + fsyncSync(fd); + } finally { + closeSync(fd); + } renameSync(tmpPath, filePath); } catch (err) { // Clean up temp file on failure diff --git a/src/storage/memory.ts b/src/storage/memory.ts index e05a079..0e3f5f8 100644 --- a/src/storage/memory.ts +++ b/src/storage/memory.ts @@ -7,7 +7,7 @@ */ import { readFileSync, readdirSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, resolve, basename } from "node:path"; import { atomicWrite, ensureDir, pathExists, removeFile } from "./engine.js"; import type { Memory, MemoryType } from "../types.js"; import { AXME_CODE_DIR } from "../types.js"; @@ -48,7 +48,7 @@ export function saveScopedMemories( memories: Memory[], projectPath: string, workspacePath?: string, ): { saved: number; crossProject: number } { let saved = 0, crossProject = 0; - const projectName = projectPath.split("/").pop() ?? ""; + const projectName = basename(projectPath); for (const m of memories) { const scope = m.scope; @@ -115,7 +115,7 @@ export function listScopedMemories(projectPath: string, workspacePath?: string): const projectMemories = listMemories(projectPath); if (!workspacePath || workspacePath === projectPath) return projectMemories; - const projectName = projectPath.split("/").pop() ?? ""; + const projectName = basename(projectPath); const wsMemories = listMemories(workspacePath); const relevantWs = wsMemories.filter(m => m.scope && (m.scope.includes(projectName) || m.scope.includes("all")) diff --git a/src/storage/safety.ts b/src/storage/safety.ts index fa9d6a4..15ed668 100644 --- a/src/storage/safety.ts +++ b/src/storage/safety.ts @@ -6,7 +6,7 @@ */ import { readFileSync, existsSync, readdirSync } from "node:fs"; -import { join, resolve, dirname } from "node:path"; +import { join, resolve, dirname, basename } from "node:path"; import { execSync } from "node:child_process"; import { homedir } from "node:os"; import yaml from "js-yaml"; @@ -505,7 +505,7 @@ export function checkFilePath(rules: SafetyRules, filePath: string, operation: " function matchesPattern(filePath: string, pattern: string): boolean { if (filePath === pattern || filePath.startsWith(pattern)) return true; - const fileName = filePath.split("/").pop() ?? ""; + const fileName = basename(filePath); // Basename match: ".env" matches "/any/path/.env" if (fileName === pattern) return true; if (pattern.includes("*")) { @@ -610,7 +610,7 @@ export function saveScopedSafetyRule( } else { // Single-repo session with a scope list: just write to the project updateSafetyRule(projectPath, ruleType, value); - repos.push(projectPath.split("/").pop() ?? ""); + repos.push(basename(projectPath)); } return { target: "scoped", repos }; } diff --git a/src/storage/sessions.ts b/src/storage/sessions.ts index a0efb37..e04c8d6 100644 --- a/src/storage/sessions.ts +++ b/src/storage/sessions.ts @@ -858,10 +858,13 @@ export function attachClaudeSession( // meta.json is briefly unavailable due to concurrent write. let session = loadSession(projectPath, axmeSessionId); if (!session) { + // Cross-platform sync sleep: Atomics.wait blocks the current thread + // without spawning a subprocess (POSIX `sleep` isn't available in + // cmd.exe). A fresh SharedArrayBuffer is never notified, so wait + // always elapses the full timeout. + const waitArr = new Int32Array(new SharedArrayBuffer(4)); for (let retry = 0; retry < 3 && !session; retry++) { - const { setTimeout: wait } = require("node:timers/promises"); - // Sync sleep — hooks are short-lived subprocesses, blocking is OK. - try { require("child_process").execSync("sleep 0.05"); } catch {} + Atomics.wait(waitArr, 0, 0, 50); session = loadSession(projectPath, axmeSessionId); } if (!session) return; diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts index 9b28f36..200f071 100644 --- a/src/tools/cleanup.ts +++ b/src/tools/cleanup.ts @@ -8,7 +8,7 @@ */ import { readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { pathExists, readJson, removeFile } from "../storage/engine.js"; import { AXME_CODE_DIR } from "../types.js"; import type { SessionMeta } from "../types.js"; @@ -109,7 +109,7 @@ export function cleanupLegacyArtifacts( ]; for (const p of legacyPaths) { if (!pathExists(p)) continue; - const name = p.split("/").pop() ?? p; + const name = basename(p) ?? p; if (opts.dryRun) { log(` [dry-run] would remove legacy ${name}`); } else { diff --git a/src/tools/init.ts b/src/tools/init.ts index c20834a..7246eb2 100644 --- a/src/tools/init.ts +++ b/src/tools/init.ts @@ -5,7 +5,7 @@ * Workspace init runs repos with concurrency limit. */ -import { join } from "node:path"; +import { join, basename } from "node:path"; import { existsSync } from "node:fs"; import { ensureDir, pathExists } from "../storage/engine.js"; import { writeOracleFiles, initOracleDeterministic, oracleExists } from "../storage/oracle.js"; @@ -142,7 +142,7 @@ export async function initProjectWithLLM(projectPath: string, opts?: { // --- LLM scanners in PARALLEL --- const log = opts?.onProgress ?? (() => {}); - const projectName = projectPath.split("/").pop(); + const projectName = basename(projectPath); let oracleLlm = false; let oracleFiles = 0; @@ -319,7 +319,7 @@ export async function initWorkspaceWithLLM(workspacePath: string, opts?: { const ws = detectWorkspace(workspacePath); if (ws.type !== "single") { const wsYaml = yaml.dump({ - name: ws.root.split("/").pop(), + name: basename(ws.root), type: ws.type, manifest: ws.manifestPath, projects: ws.projects, @@ -361,7 +361,7 @@ export async function initWorkspaceWithLLM(workspacePath: string, opts?: { completed++; if (settled.status === "fulfilled") { const r = settled.value; - const name = r.projectPath.split("/").pop(); + const name = basename(r.projectPath); if (r.durationMs === 0) { log(` [${completed}/${gitRepos.length}] ${name}: skipped (already initialized)`); } else { diff --git a/src/utils/agent-options.ts b/src/utils/agent-options.ts index 95ba4b2..101cbcc 100644 --- a/src/utils/agent-options.ts +++ b/src/utils/agent-options.ts @@ -49,11 +49,16 @@ export function findClaudePath(): string | undefined { return _claudePath; } - // 3. which claude (PATH lookup) + // 3. PATH lookup — `which` on POSIX, `where.exe` on Windows. + // Use stdio:['ignore','pipe','ignore'] to suppress stderr leakage (Windows + // PowerShell renders tool-not-found messages even when we try/catch). try { - const p = execSync("which claude", { encoding: "utf-8", timeout: 5000 }).trim(); - if (p && existsSync(p)) { - _claudePath = p; + const lookup = process.platform === "win32" ? "where.exe claude" : "which claude"; + const p = execSync(lookup, { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim(); + // `where` may return multiple lines (one per match); take the first. + const first = p.split(/\r?\n/)[0].trim(); + if (first && existsSync(first)) { + _claudePath = first; return _claudePath; } } catch { /* not in PATH — continue to standard locations */ } diff --git a/test/agent-sdk-paths.test.ts b/test/agent-sdk-paths.test.ts index 0068654..1a24c74 100644 --- a/test/agent-sdk-paths.test.ts +++ b/test/agent-sdk-paths.test.ts @@ -17,10 +17,14 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { test } from "node:test"; import assert from "node:assert"; -const AGENTS_DIR = new URL("../src/agents/", import.meta.url).pathname; +// `new URL(...).pathname` returns POSIX-style "/C:/..." on Windows, which +// breaks readdirSync with a doubled drive prefix. fileURLToPath returns the +// platform-native path ("C:\\..." on Windows, "/home/..." on POSIX). +const AGENTS_DIR = fileURLToPath(new URL("../src/agents/", import.meta.url)); function walk(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { diff --git a/test/audit-dedup.test.ts b/test/audit-dedup.test.ts index c27dc87..9a638e3 100644 --- a/test/audit-dedup.test.ts +++ b/test/audit-dedup.test.ts @@ -2,6 +2,8 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { mkdirSync, rmSync, existsSync, writeFileSync, utimesSync, readdirSync } from "node:fs"; import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; import { spawn } from "node:child_process"; import { acquireLock, @@ -11,7 +13,7 @@ import { listSessions, } from "../src/storage/sessions.js"; -const TEST_ROOT = "/tmp/axme-audit-dedup-test"; +const TEST_ROOT = join(tmpdir(), "axme-audit-dedup-test"); const AXME_DIR = join(TEST_ROOT, ".axme-code"); function setup() { @@ -126,31 +128,44 @@ describe("ensureAxmeSessionForClaude - concurrent calls", () => { // This is the real test: spawn N child processes that each call // ensureAxmeSessionForClaude concurrently, simulating parallel hooks. -// Skip on CI — filesystem lock timing is unreliable on shared runners +// Skip on CI — filesystem lock timing is unreliable on shared runners. +// Skip on Windows — `npx tsx` subprocess startup takes ~2-3s each, exceeding +// the 3s LOCK_WAIT_MS budget when 5 workers race, causing legitimate +// duplicate-session creation that this test flags. Production hooks don't +// contend with tsx startup — they are short axme-code subprocesses — so this +// is purely a test-harness timing issue on the slower platform. const isCI = !!process.env.CI; -describe("ensureAxmeSessionForClaude - parallel processes (E2E)", { skip: isCI }, () => { +const skipReason = isCI ? "CI filesystem timing" : process.platform === "win32" ? "Windows tsx startup >3s" : false; +describe("ensureAxmeSessionForClaude - parallel processes (E2E)", { skip: skipReason }, () => { beforeEach(() => setup()); afterEach(() => cleanup()); it("5 parallel processes create exactly 1 session", async () => { const N = 5; const claudeId = "concurrent-test-claude"; - const transcript = "/tmp/test-transcript.jsonl"; + const transcript = join(tmpdir(), "test-transcript.jsonl"); - // Write a worker script + // Write a worker script. We use pathToFileURL for the import specifier + // so backslashes in Windows paths don't get interpreted as escape chars, + // and JSON.stringify for the function args so embedded backslashes are + // emitted as "\\\\" in the generated source. const workerScript = join(TEST_ROOT, "worker.ts"); + const sessionsModule = pathToFileURL(join(process.cwd(), "src/storage/sessions.js")).href; writeFileSync(workerScript, [ - `import { ensureAxmeSessionForClaude } from "${join(process.cwd(), "src/storage/sessions.js")}";`, - `const result = ensureAxmeSessionForClaude("${TEST_ROOT}", "${claudeId}", "${transcript}");`, + `import { ensureAxmeSessionForClaude } from ${JSON.stringify(sessionsModule)};`, + `const result = ensureAxmeSessionForClaude(${JSON.stringify(TEST_ROOT)}, ${JSON.stringify(claudeId)}, ${JSON.stringify(transcript)});`, `process.stdout.write(result);`, ].join("\n")); - // Spawn N workers in parallel using npx tsx + // Spawn N workers in parallel using npx tsx. Windows resolves `npx` via + // npx.cmd, which Node's spawn won't find without shell:true. + const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; const runWorker = () => new Promise((resolve, reject) => { - const child = spawn("npx", ["tsx", workerScript], { + const child = spawn(npxCmd, ["tsx", workerScript], { stdio: ["pipe", "pipe", "pipe"], cwd: process.cwd(), + shell: process.platform === "win32", }); let stdout = ""; let stderr = ""; diff --git a/test/auth-config.test.ts b/test/auth-config.test.ts index 391845f..929de97 100644 --- a/test/auth-config.test.ts +++ b/test/auth-config.test.ts @@ -11,18 +11,24 @@ import { } from "../src/utils/auth-config.js"; const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; const originalKey = process.env.ANTHROPIC_API_KEY; let tmpHome: string; +// Node's os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows. We +// mock both so the same test works across platforms. beforeEach(() => { tmpHome = mkdtempSync(join(tmpdir(), "axme-auth-")); process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; delete process.env.ANTHROPIC_API_KEY; }); afterEach(() => { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = originalKey; rmSync(tmpHome, { recursive: true, force: true }); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 399185c..7db833d 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -83,7 +83,7 @@ describe("getOrCreateMid", () => { assert.equal(readFileSync(filePath, "utf-8").trim(), mid); }); - it("sets file mode 0600", () => { + it("sets file mode 0600", { skip: process.platform === "win32" ? "POSIX file modes not supported on Windows (security via ACLs)" : false }, () => { getOrCreateMid(); const mode = statSync(_getMidFilePath()).mode & 0o777; assert.equal(mode, 0o600);