diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd161c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: ci + +on: + push: + branches: + - main + - "feat/**" + - "fix/**" + - "docs/**" + - "chore/**" + 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 dfebc69..1010db9 100644 --- a/README.md +++ b/README.md @@ -102,15 +102,21 @@ The plugin ships with the MCP server, safety hooks, and CLI bundled together; no ### Option 2: Standalone binary -Install the CLI system-wide (useful if you want to run `axme-code` outside Claude Code, e.g. for scripting): +Install the CLI system-wide (useful if you want to run `axme-code` outside Claude Code, e.g. for scripting). +**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. Then in each project: 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-lock.json b/package-lock.json index 5e77ef5..7cc3f25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.9", "license": "MIT", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.84", + "@anthropic-ai/claude-agent-sdk": "^0.2.112", "@modelcontextprotocol/sdk": "^1.29.0", "js-yaml": "^4.1.0" }, @@ -28,13 +28,13 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.92", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.92.tgz", - "integrity": "sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw==", + "version": "0.2.112", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.112.tgz", + "integrity": "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig==", "license": "SEE LICENSE IN README.md", "dependencies": { - "@anthropic-ai/sdk": "^0.80.0", - "@modelcontextprotocol/sdk": "^1.27.1" + "@anthropic-ai/sdk": "^0.81.0", + "@modelcontextprotocol/sdk": "^1.29.0" }, "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index f637b3f..322f93d 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "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": { "node": ">=20" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.84", + "@anthropic-ai/claude-agent-sdk": "^0.2.112", "@modelcontextprotocol/sdk": "^1.29.0", "js-yaml": "^4.1.0" }, @@ -34,8 +34,19 @@ "url": "https://github.com/AxmeAI/axme-code.git" }, "license": "MIT", - "keywords": ["mcp", "claude-code", "ai-agent", "developer-tools"], - "files": ["dist", "templates", ".claude-plugin", "README.md", "LICENSE"], + "keywords": [ + "mcp", + "claude-code", + "ai-agent", + "developer-tools" + ], + "files": [ + "dist", + "templates", + ".claude-plugin", + "README.md", + "LICENSE" + ], "overrides": { "@anthropic-ai/sdk": ">=0.81.0" } 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/agents/memory-extractor.ts b/src/agents/memory-extractor.ts index 35c25a4..787ea7a 100644 --- a/src/agents/memory-extractor.ts +++ b/src/agents/memory-extractor.ts @@ -12,7 +12,7 @@ import type { Memory } from "../types.js"; import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js"; import { toMemorySlug } from "../storage/memory.js"; -import { buildAgentEnv, findClaudePath } from "../utils/agent-options.js"; +import { buildAgentEnv, claudePathForSdk } from "../utils/agent-options.js"; export interface MemoryExtractionResult { memories: Memory[]; @@ -69,7 +69,7 @@ export async function runMemoryExtraction(opts: { const startTime = Date.now(); const model = opts.model ?? "claude-haiku-4-5"; - const claudePath = findClaudePath(); + const claudePath = claudePathForSdk(); const queryOpts = { cwd: opts.projectPath, model, diff --git a/src/agents/session-auditor.ts b/src/agents/session-auditor.ts index 7fb2c27..152f1c2 100644 --- a/src/agents/session-auditor.ts +++ b/src/agents/session-auditor.ts @@ -21,7 +21,7 @@ import { basename, relative } from "node:path"; import type { Memory, Decision, SessionHandoff, WorkspaceInfo } from "../types.js"; import { DEFAULT_AUDITOR_MODEL } from "../types.js"; import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js"; -import { buildAgentEnv, findClaudePath } from "../utils/agent-options.js"; +import { buildAgentEnv, claudePathForSdk } from "../utils/agent-options.js"; import { toMemorySlug } from "../storage/memory.js"; import { toSlug, listDecisions } from "../storage/decisions.js"; import { listMemories } from "../storage/memory.js"; @@ -625,7 +625,7 @@ async function runSingleAuditCall(opts: { }> { const sdk = await import("@anthropic-ai/claude-agent-sdk"); - const claudePath = findClaudePath(); + const claudePath = claudePathForSdk(); const queryOpts = { cwd: opts.sessionOrigin, model: opts.model, @@ -886,7 +886,7 @@ JSON SCHEMA: ANALYSIS TO FORMAT: ${freeTextAnalysis}`; - const claudePath = findClaudePath(); + const claudePath = claudePathForSdk(); const queryOpts = { cwd: sessionOrigin, model, diff --git a/src/cli.ts b/src/cli.ts index fe6da5d..f9aaec9 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"; @@ -143,19 +143,19 @@ function generateClaudeMd(projectPath: string, isWorkspace: boolean): void { /** * Check if Claude auth is available. - * Checks: ANTHROPIC_API_KEY, or claude binary exists on disk. + * Checks: ANTHROPIC_API_KEY, or a locatable `claude` CLI (which is evidence + * the user has either an API key or a signed-in Claude subscription). + * + * Delegates to findClaudePath() so the PATH-separator (":" vs ";"), executable + * suffixes (".cmd", ".exe" on Windows), and standard-install locations are all + * handled the same way scanner agents resolve the binary — no divergence. */ function hasAuth(): boolean { if (process.env.ANTHROPIC_API_KEY) return true; - - // Check common claude binary locations directly (no shell needed) - const { env } = process; - const pathDirs = (env.PATH || "").split(":"); - for (const dir of pathDirs) { - if (existsSync(join(dir, "claude"))) return true; - } - - return false; + // findClaudePath already checks AXME_CLAUDE_EXECUTABLE, CLAUDE_CODE_ENTRYPOINT, + // PATH lookup, standard install locations, and nvm dirs, cross-platform. + const { findClaudePath } = require("./utils/agent-options.js") as typeof import("./utils/agent-options.js"); + return !!findClaudePath(); } /** @@ -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..78d1009 100644 --- a/src/utils/agent-options.ts +++ b/src/utils/agent-options.ts @@ -4,7 +4,7 @@ import { execSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { resolveAuthMode } from "./auth-config.js"; @@ -21,7 +21,11 @@ type Options = import("@anthropic-ai/claude-agent-sdk").Options; * Resolution order (B-009 — first match wins): * 1. `AXME_CLAUDE_EXECUTABLE` env var — explicit override for CI / unusual installs * 2. `CLAUDE_CODE_ENTRYPOINT` env var — set by Claude Code itself in some contexts - * 3. `which claude` — standard PATH lookup (works on most dev machines) + * 3. `which`/`where.exe` PATH lookup. On Windows the npm shim is + * `claude.cmd`, but spawn() of .cmd hits CVE-2024-27980 EINVAL — so we + * derive the real `claude.exe` underneath + * `/node_modules/@anthropic-ai/claude-code/bin/claude.exe` + * and return that instead. * 4. Standard install locations (no PATH dependency): * - ~/.local/bin/claude * - /usr/local/bin/claude @@ -49,12 +53,47 @@ 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; - return _claudePath; + const lookup = process.platform === "win32" ? "where.exe claude" : "which claude"; + const p = execSync(lookup, { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim(); + const lines = p.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + if (process.platform === "win32") { + // npm's claude.cmd is a shim that ultimately invokes the real + // claude.exe under /node_modules/@anthropic-ai/claude-code/ + // bin/claude.exe. The Agent SDK can spawn .exe directly (no + // CVE-2024-27980 EINVAL like .cmd/.bat) AND avoids the SDK's own + // import.meta.url-based fallback which crashes inside our esbuild + // bundle (fileURLToPath(undefined)). Always prefer the .exe. + const cmd = lines.find((r) => /\\claude\.cmd$/i.test(r)); + if (cmd) { + const exeCandidate = join( + dirname(cmd), + "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe", + ); + if (existsSync(exeCandidate)) { + _claudePath = exeCandidate; + return _claudePath; + } + } + // Fallback: any .exe directly in the where.exe output (rare on npm + // installs but possible for custom layouts). + const directExe = lines.find((r) => /\.exe$/i.test(r) && existsSync(r)); + if (directExe) { + _claudePath = directExe; + return _claudePath; + } + // Last resort on Windows: bare name / .ps1 / .cmd. SDK will likely + // fail on these (.cmd → spawn EINVAL, bare → not executable by + // cmd.exe), so caller's deterministic fallback kicks in. + } else { + const first = lines[0]; + if (first && existsSync(first)) { + _claudePath = first; + return _claudePath; + } } } catch { /* not in PATH — continue to standard locations */ } @@ -96,6 +135,19 @@ export function _resetFindClaudePath(): void { _claudePath = undefined; } +/** + * Value to pass as the SDK's `pathToClaudeCodeExecutable` option. + * + * Always equal to findClaudePath() — the SDK needs an explicit path because + * its own `fileURLToPath(import.meta.url)` fallback crashes inside our + * esbuild bundle (import.meta.url is undefined there). On Windows + * findClaudePath() now resolves the `.cmd` shim back to the real `.exe` + * (CVE-2024-27980 only affected .cmd/.bat — passing .exe to spawn is safe). + */ +export function claudePathForSdk(): string | undefined { + return findClaudePath(); +} + export type AgentRole = "scanner" | "tester" | "reviewer" | "engineer" | "architect" | "auditor"; const ROLE_TOOLS: Record = { @@ -162,7 +214,7 @@ export function buildAgentQueryOptions(base: { }, role: AgentRole): Options { const tools = ROLE_TOOLS[role]; - const claudePath = findClaudePath(); + const claudePath = claudePathForSdk(); return { cwd: base.cwd, diff --git a/test/agent-sdk-paths.test.ts b/test/agent-sdk-paths.test.ts index 0068654..ebc5e3a 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)) { @@ -32,7 +36,7 @@ function walk(dir: string, out: string[] = []): string[] { return out; } -test("every src/agents file calling sdk.query imports buildAgentQueryOptions or findClaudePath", () => { +test("every src/agents file calling sdk.query imports buildAgentQueryOptions or claudePathForSdk", () => { const files = walk(AGENTS_DIR); const offenders: string[] = []; @@ -41,7 +45,11 @@ test("every src/agents file calling sdk.query imports buildAgentQueryOptions or if (!src.includes("sdk.query(")) continue; const hasBuilder = /import\s+[^;]*\bbuildAgentQueryOptions\b[^;]*from\s+["'][^"']*agent-options/.test(src); - const hasFinder = /import\s+[^;]*\bfindClaudePath\b[^;]*from\s+["'][^"']*agent-options/.test(src); + // Both `claudePathForSdk` and the older `findClaudePath` are accepted — + // the former is the correct Windows-safe choice (returns undefined on + // win32 to dodge `spawn EINVAL` on .cmd), the latter remains allowed + // only for backwards-compat regression surface. + const hasFinder = /import\s+[^;]*\b(claudePathForSdk|findClaudePath)\b[^;]*from\s+["'][^"']*agent-options/.test(src); if (!hasBuilder && !hasFinder) { offenders.push(file.replace(AGENTS_DIR, "")); @@ -52,7 +60,7 @@ test("every src/agents file calling sdk.query imports buildAgentQueryOptions or offenders, [], `The following files call sdk.query() but import neither buildAgentQueryOptions ` + - `nor findClaudePath from utils/agent-options. Without pathToClaudeCodeExecutable ` + + `nor claudePathForSdk from utils/agent-options. Without pathToClaudeCodeExecutable ` + `the bundled CJS build will crash with fileURLToPath(undefined) (B-006 / D-121):\n` + ` ${offenders.join("\n ")}`, ); 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..29b1eaf 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); @@ -330,7 +330,7 @@ describe("sendTelemetry with HTTP stub", () => { it("sends startup event with required common fields", async () => { sendTelemetry("startup"); // Wait for setImmediate + fetch - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.equal(receivedRequests.length, 1); const event = receivedRequests[0].events[0]; assert.equal(event.event, "startup"); @@ -356,7 +356,7 @@ describe("sendTelemetry with HTTP stub", () => { chunks: 1, error_class: null, }); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.equal(receivedRequests.length, 1); const event = receivedRequests[0].events[0]; assert.equal(event.event, "audit_complete"); @@ -368,14 +368,14 @@ describe("sendTelemetry with HTTP stub", () => { it("does NOT send when AXME_TELEMETRY_DISABLED is set", async () => { process.env.AXME_TELEMETRY_DISABLED = "1"; sendTelemetry("startup"); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.equal(receivedRequests.length, 0); }); it("does NOT send when DO_NOT_TRACK is set", async () => { process.env.DO_NOT_TRACK = "1"; sendTelemetry("startup"); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.equal(receivedRequests.length, 0); }); @@ -383,7 +383,7 @@ describe("sendTelemetry with HTTP stub", () => { process.env.AXME_TELEMETRY_DISABLED = "1"; sendTelemetry("startup"); sendStartupEvents(); - await new Promise((r) => setTimeout(r, 100)); + await new Promise((r) => setTimeout(r, 2000)); assert.equal(existsSync(_getMidFilePath()), false); }); @@ -393,7 +393,7 @@ describe("sendTelemetry with HTTP stub", () => { process.env.AXME_TELEMETRY_ENDPOINT = "http://127.0.0.1:1/dead-endpoint"; sendTelemetry("startup"); - await new Promise((r) => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 2000)); const queuePath = _getQueueFilePath(); assert.equal(existsSync(queuePath), true); @@ -409,7 +409,7 @@ describe("sendTelemetry with HTTP stub", () => { await new Promise((resolve) => server.close(() => resolve())); process.env.AXME_TELEMETRY_ENDPOINT = "http://127.0.0.1:1/dead"; sendTelemetry("startup", { test: "first" }); - await new Promise((r) => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 2000)); // Verify queue has 1 event assert.equal(existsSync(_getQueueFilePath()), true); @@ -431,7 +431,7 @@ describe("sendTelemetry with HTTP stub", () => { // Send again — should ship queued + new in one batch receivedRequests.length = 0; sendTelemetry("startup", { test: "second" }); - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 2000)); assert.equal(receivedRequests.length, 1); assert.equal(receivedRequests[0].events.length, 2); @@ -461,7 +461,7 @@ describe("sendStartupEvents", () => { sendStartupEvents(); sendStartupEvents(); sendStartupEvents(); - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 2000)); // First call sends install + startup (2 batches with batch limit), second/third calls do nothing // We can't strictly count because batching is async, but it must be more than 0 and not 9 @@ -491,7 +491,7 @@ describe("reportError", () => { process.env.AXME_TELEMETRY_ENDPOINT = `http://127.0.0.1:${port}/v1/telemetry/events`; reportError("audit", "prompt_too_long", true); - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 2000)); assert.ok(received); const event = received.events[0]; @@ -519,7 +519,7 @@ describe("reportError", () => { process.env.AXME_TELEMETRY_ENDPOINT = `http://127.0.0.1:${port}/v1/telemetry/events`; reportError("hook", "network_error", false); - await new Promise((r) => setTimeout(r, 300)); + await new Promise((r) => setTimeout(r, 2000)); assert.ok(received); const event = received.events[0]; @@ -686,7 +686,7 @@ describe("ci field detection in events", () => { process.env.AXME_TELEMETRY_ENDPOINT = `http://127.0.0.1:${port}/v1/telemetry/events`; sendTelemetry("startup"); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.ok(received, "request received"); assert.equal(received.events[0].ci, true, "ci=true in payload"); @@ -710,7 +710,7 @@ describe("ci field detection in events", () => { process.env.AXME_TELEMETRY_ENDPOINT = `http://127.0.0.1:${port}/v1/telemetry/events`; sendTelemetry("startup"); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.ok(received, "request received"); assert.equal(received.events[0].ci, false, "ci=false default"); @@ -749,7 +749,7 @@ describe("audit_complete payload shape", () => { dropped_count: 0, error_class: null, }); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.ok(received); const event = received.events[0]; @@ -796,7 +796,7 @@ describe("setup_complete payload shape", () => { is_workspace: false, child_repos: 0, }); - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 2000)); assert.ok(received); const event = received.events[0];