diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/actual-complexity.json b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/actual-complexity.json new file mode 100644 index 00000000..be6c752c --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/actual-complexity.json @@ -0,0 +1,57 @@ +{ + "task": "Implement Windows PATH shim detection/repair and Unix PATH append logic in the npm postinstall script, with test coverage and a vitest config tweak to include scripts tests.", + "generated": "2026-07-03T00:00:00Z", + "dimensions": { + "component_scope": { + "score": 2, + "label": "S", + "affected": "postinstall script (scripts/postinstall.mjs), validate-secrets pre-commit script (scripts/validate-secrets.js), vitest test configuration (vitest.config.ts)", + "layers": "Infra / build tooling scripts" + }, + "requirements_clarity": { + "score": 2, + "label": "S", + "status": "Clear", + "gaps": null + }, + "technical_risk": { + "score": 3, + "label": "M", + "risk_factors": "Modifies the user's system PATH via setx (Windows) or shell rc-file append (Unix) during postinstall; dynamically imports the compiled dist/utils/windows-path.js helper with a warning fallback if dist/ has not been built yet; platform-specific branching (win32 vs. posix) and shell detection (zsh/bash) increase the number of edge cases to reason about.", + "mitigation": "Idempotency checks (isInUserPath / alreadyInRcFile) prevent duplicate PATH entries on repeated installs; try/catch fallbacks emit warnings instead of failing the install if the windows-path helper or npm prefix lookup fails; extensive new unit test coverage (scripts/__tests__/postinstall.test.ts, 375 lines) exercises both win32 and posix code paths with injectable platform/separator parameters." + }, + "file_change_estimate": { + "score": 3, + "label": "M", + "modified_files": 3, + "modified_file_list": ["scripts/postinstall.mjs", "scripts/validate-secrets.js", "vitest.config.ts"], + "new_files": 1, + "new_file_list": ["scripts/__tests__/postinstall.test.ts"], + "affected_dirs": ["scripts", "."] + }, + "dependencies": { + "score": 1, + "label": "XS", + "new_packages": [], + "version_changes": [] + }, + "affected_layers": { + "score": 1, + "label": "XS", + "layers_changed": ["Infra"], + "schema_migration": false, + "cross_system": false + } + }, + "total": 12, + "size": "S", + "band_range": "10-14", + "files_changed": 4, + "routing": "writing-plans", + "key_reasoning": [ + { "dimension": "technical_risk", "reason": "Postinstall script now mutates the user's system PATH (setx on Windows, rc-file append on Unix) and dynamically loads a compiled helper module — a new pattern for this codebase, though heavily mitigated with idempotency checks and fallback warnings." }, + { "dimension": "file_change_estimate", "reason": "4 files touched total (3 modified, 1 new large test file); diffstat total-file-count maps to the M band under the actual-mode mapping rule even though the change is functionally narrow." } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.diff b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.diff new file mode 100644 index 00000000..77be843b --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.diff @@ -0,0 +1,113 @@ +diff --git a/.codemie/codemie-cli.config.json b/.codemie/codemie-cli.config.json +index deed353..5f38325 100644 +--- a/.codemie/codemie-cli.config.json ++++ b/.codemie/codemie-cli.config.json +@@ -10,9 +10,21 @@ + "baseUrl": "https://codemie.lab.epam.com/code-assistant-api", + "model": "claude-sonnet-4-6", + "haikuModel": "claude-haiku-4-5-20251001", +- "sonnetModel": "claude-sonnet-4-6", ++ "sonnetModel": "claude-sonnet-5", + "opusModel": "claude-opus-4-8", + "name": "epm-cdme" ++ }, ++ "sonnet5preview": { ++ "codeMieProject": "codemie", ++ "provider": "ai-run-sso", ++ "codeMieUrl": "https://codemie-preview.lab.epam.com/", ++ "apiKey": "sso-provided", ++ "baseUrl": "https://codemie-preview.lab.epam.com/code-assistant-api", ++ "model": "claude-sonnet-5", ++ "haikuModel": "claude-haiku-4-5-20251001", ++ "sonnetModel": "claude-sonnet-5", ++ "opusModel": "claude-opus-4-8", ++ "name": "sonnet5preview" + } + }, + "codemieAssistants": [ +@@ -52,5 +64,6 @@ + "claude" + ] + } +- ] ++ ], ++ "codemieSkills": [] + } +\ No newline at end of file +diff --git a/package.json b/package.json +index 6cca35f..7c20aa9 100644 +--- a/package.json ++++ b/package.json +@@ -43,8 +43,8 @@ + "typecheck": "tsc --noEmit", + "format": "npm run lint:fix", + "check:pre-commit": "npm run typecheck && npm run lint", +- "lint": "eslint '{src,tests}/**/*.ts' --max-warnings=0", +- "lint:fix": "eslint '{src,tests}/**/*.ts' --fix", ++ "lint": "eslint src/**/*.ts tests/**/*.ts --max-warnings=0", ++ "lint:fix": "eslint src/**/*.ts tests/**/*.ts --fix", + "commitlint": "commitlint --edit", + "commitlint:last": "commitlint --from HEAD~1 --to HEAD --verbose", + "validate:secrets": "node scripts/validate-secrets.js", +diff --git a/scripts/__tests__/postinstall.test.ts b/scripts/__tests__/postinstall.test.ts +index 933aeea..cfb43a0 100644 +--- a/scripts/__tests__/postinstall.test.ts ++++ b/scripts/__tests__/postinstall.test.ts +@@ -271,6 +271,18 @@ describe('postinstall', () => { + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('codemie')); + expect(process.exitCode).toBeFalsy(); + }); ++ ++ it('degrades gracefully (no throw, exit 0) when the windows-path helper cannot be loaded or used', async () => { ++ vi.mocked(isInUserPath).mockRejectedValue(new Error("Cannot find module '../dist/utils/windows-path.js'")); ++ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); ++ ++ const { runWindows } = await import('../postinstall.mjs'); ++ ++ await expect(runWindows()).resolves.toBeUndefined(); ++ expect(addToUserPath).not.toHaveBeenCalled(); ++ expect(process.exitCode).toBeFalsy(); ++ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('windows-path')); ++ }); + }); + + describe('runUnix', () => { +diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs +index 163c549..a23b51e 100644 +--- a/scripts/postinstall.mjs ++++ b/scripts/postinstall.mjs +@@ -69,11 +69,31 @@ export async function runWindows() { + console.warn(`\n⚠️ Expected CodeMie command shims not found in ${dir}: ${missing.join(', ')}\n`); + } + +- const { isInUserPath, addToUserPath } = await import('../dist/utils/windows-path.js'); ++ let isInUserPath, addToUserPath; ++ try { ++ ({ isInUserPath, addToUserPath } = await import('../dist/utils/windows-path.js')); ++ } catch (error) { ++ console.warn(`\n⚠️ Could not load the windows-path PATH helper (dist/ may not be built yet): ${error.message}\n`); ++ return; ++ } ++ ++ let alreadyInPath; ++ try { ++ alreadyInPath = await isInUserPath(dir); ++ } catch (error) { ++ console.warn(`\n⚠️ Could not check the windows-path PATH helper: ${error.message}\n`); ++ return; ++ } ++ if (alreadyInPath) return; + +- if (await isInUserPath(dir)) return; ++ let result; ++ try { ++ result = await addToUserPath(dir); ++ } catch (error) { ++ console.warn(`\n⚠️ Could not use the windows-path PATH helper: ${error.message}\n`); ++ return; ++ } + +- const result = await addToUserPath(dir); + if (result.success) { + console.log(`\n✓ Added ${dir} to PATH\n Open a new terminal to use codemie\n`); + return; diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.json b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.json new file mode 100644 index 00000000..113883e1 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.json @@ -0,0 +1,26 @@ +{ + "decision": "approve", + "rationale": "CR-001 resolved: runWindows() now wraps the dynamic import of dist/utils/windows-path.js and both its calls (isInUserPath, addToUserPath) in separate try/catch blocks, each degrading to a console.warn + return (no exitCode set) on failure — matching the non-fatal treatment already used for missing npm prefix and missing shim files. A new test ('degrades gracefully ... when the windows-path helper cannot be loaded or used') exercises this via a rejected isInUserPath call; manually confirmed via a standalone node script that a genuine dynamic-import-of-missing-module rejection is caught the same way. Full test suite (27 postinstall tests + 26 windows-path tests), lint, and typecheck all pass. This is a small, purely-defensive, mechanical fix-up with no new security-sensitive surface or behavior change to the paths already reviewed in the final round, so the narrow blind+edge-case confirmation pass was not run — nothing about this fix-up suggested a new high-risk issue. The fix-up diff also shows unrelated pre-existing uncommitted local changes to .codemie/codemie-cli.config.json and package.json (user-directed local-only changes, not part of this task) — noise in the diff, not reviewed as part of this change.", + "confidence": "high", + "risk_flags": [], + "business_review": [ + {"criterion": "getNpmPrefix/getShimDir/isInPath/getShellRcFile/alreadyInRcFile/getExpectedShimNames/findMissingShims/runWindows/runUnix/run all exported with spec'd signatures", "status": "pass", "notes": "Confirmed by acceptance lens against live file."}, + {"criterion": "Direct-execution guard prevents side effects on import", "status": "pass", "notes": "isMain check via fileURLToPath(import.meta.url) === process.argv[1]."}, + {"criterion": "Windows flow steps 1-7 implemented in order", "status": "pass", "notes": "prefix -> dir -> missing-shim warn -> dynamic import -> isInUserPath no-op -> addToUserPath success/failure handling, matches spec exactly."}, + {"criterion": "Unix flow unchanged except delimiter fix", "status": "pass", "notes": "Same logic, isInPath now takes explicit sep derived from plat."}, + {"criterion": "Exit policy table (only addToUserPath failure exits 1)", "status": "pass", "notes": "Now also correctly holds for the previously-unhandled missing-dist/-helper failure mode, which degrades to exit 0 like the other non-actionable failures."}, + {"criterion": "Reuse windows-path.ts via dist/, no duplicated PATH logic", "status": "pass", "notes": "windows-path.ts untouched, only dynamically imported."}, + {"criterion": "10 required test cases present", "status": "pass", "notes": "All 10 present plus the new CR-001 regression test."}, + {"criterion": "vitest.config.ts includes scripts/**/*.test.ts", "status": "pass", "notes": "Confirmed."}, + {"criterion": "Out-of-scope items absent (isolated prefix redirect, hook executor fix, install.cmd fix, install.ps1/native-installer changes)", "status": "pass", "notes": "git diff confirms zero changes to those files."} + ], + "standards_review": [ + {"standard": "git-workflow.md: Conventional Commits format + allowed scopes", "status": "pass", "notes": "Fix-up commit uses fix(cli): ... format with a valid scope."}, + {"standard": "code-quality.md: naming, .js import extensions, function size, comments explain why", "status": "pass", "notes": "console.log/warn/error usage remains a justified exception for this standalone lifecycle script."}, + {"standard": "security-practices.md: no hardcoded secrets, path validation, no injection", "status": "pass", "notes": "No new secrets or unvalidated paths introduced by the fix-up."} + ], + "finding_status": [ + {"id": "CR-001", "status": "resolved"} + ], + "findings": [] +} diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-final.json b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-final.json new file mode 100644 index 00000000..3ead2a05 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-final.json @@ -0,0 +1,34 @@ +{ + "decision": "request-changes", + "rationale": "Blind and edge-case lenses independently flagged the same critical bug: runWindows()'s unguarded `await import('../dist/utils/windows-path.js')` throws uncaught if dist/ is absent. Confirmed against .github/workflows/ci.yml: the windows-latest job runs `npm ci` (which triggers postinstall) at lines 212-213, before 'Download build artifacts' populates dist/ at lines 216-220 — so this will crash the Windows CI 'Install dependencies' step, and will do the same for any Windows contributor's fresh `npm install` before `npm run build`. The test suite fully mocks the dist import (vi.mock('../../dist/utils/windows-path.js', ...)), so this failure mode has zero coverage. This also contradicts the spec's own stated exit policy, which intends only addToUserPath failure to be non-graceful — a missing dist/ was never considered a failure mode in the design. Acceptance lens found zero blocking findings against the approved spec/plan otherwise (all 10 required test cases present, all exported functions and the exit-policy table match exactly). Two other findings (runUnix's untested plat='win32' interaction; validate-secrets.js's shell:true removal for non-.exe engine binaries) are deferred as non-blocking maintainability notes, not confirmed regressions. One finding (unused isWindows variable) was a false positive from the blind lens's lack of full-file context — isWindows is still used in resolveCommand(). Test-assertion-depth gaps on 3 test cases (console.log/error content not spied in every case) are non-blocking per the acceptance lens — the underlying behavior is correct.", + "confidence": "high", + "risk_flags": ["breaking-change"], + "business_review": [ + {"criterion": "getNpmPrefix/getShimDir/isInPath/getShellRcFile/alreadyInRcFile/getExpectedShimNames/findMissingShims/runWindows/runUnix/run all exported with spec'd signatures", "status": "pass", "notes": "Confirmed by acceptance lens against live file."}, + {"criterion": "Direct-execution guard prevents side effects on import", "status": "pass", "notes": "isMain check via fileURLToPath(import.meta.url) === process.argv[1]."}, + {"criterion": "Windows flow steps 1-7 implemented in order", "status": "pass", "notes": "prefix -> dir -> missing-shim warn -> dynamic import -> isInUserPath no-op -> addToUserPath success/failure handling, matches spec exactly."}, + {"criterion": "Unix flow unchanged except delimiter fix", "status": "pass", "notes": "Same logic, isInPath now takes explicit sep derived from plat."}, + {"criterion": "Exit policy table (only addToUserPath failure exits 1)", "status": "partial", "notes": "Table is correctly implemented for the failure modes the spec anticipated, but the spec did not anticipate a missing dist/utils/windows-path.js as a failure mode, and the current unguarded import crashes rather than degrading to exit 0 like the other non-actionable failures. This is the CR-001 gap."}, + {"criterion": "Reuse windows-path.ts via dist/, no duplicated PATH logic", "status": "pass", "notes": "windows-path.ts untouched, only dynamically imported."}, + {"criterion": "10 required test cases present", "status": "pass", "notes": "All 10 present; 3 have shallower assertions than ideal but exercise correct code paths (non-blocking)."}, + {"criterion": "vitest.config.ts includes scripts/**/*.test.ts", "status": "pass", "notes": "Confirmed."}, + {"criterion": "Out-of-scope items absent (isolated prefix redirect, hook executor fix, install.cmd fix, install.ps1/native-installer changes)", "status": "pass", "notes": "git diff confirms zero changes to those files."} + ], + "standards_review": [ + {"standard": "git-workflow.md: Conventional Commits format + allowed scopes", "status": "pass", "notes": "All 8 commits use valid type(scope): subject format with scopes from the allowed list (cli, deps)."}, + {"standard": "code-quality.md: naming, .js import extensions, function size, comments explain why", "status": "pass", "notes": "console.log/warn/error usage (vs logger.debug) is a justified exception — this is a standalone lifecycle script outside src/ producing user-facing install output, not application debug logging, matching the pre-existing convention in the same file before this diff."}, + {"standard": "security-practices.md: no hardcoded secrets, path validation, no injection", "status": "pass", "notes": "No secrets introduced. getExpectedShimNames() builds its path from __dirname (not user input). runWindows/runUnix pass a directory derived from trusted local `npm config get prefix` output into addToUserPath(), which already validates it (validateDirectoryPath rejects metacharacters, requires absolute path) before touching the registry — no new unvalidated exec/registry calls introduced by this diff."} + ], + "findings": [ + { + "id": "CR-001", + "severity": "critical", + "triage": "patch", + "title": "Unguarded dynamic import of dist/utils/windows-path.js crashes postinstall (and Windows CI) when dist/ doesn't exist yet", + "file": "scripts/postinstall.mjs", + "problem": "runWindows() does `const { isInUserPath, addToUserPath } = await import('../dist/utils/windows-path.js');` with no try/catch. This module only exists after `npm run build`. On the windows-latest CI job (.github/workflows/ci.yml lines 212-220), `npm ci` runs at the 'Install dependencies' step — which triggers this postinstall script — before the 'Download build artifacts' step populates dist/. The unguarded import throws ERR_MODULE_NOT_FOUND, which propagates uncaught through runWindows() -> run() at module top level, crashing the npm ci process itself. The same failure hits any Windows contributor doing a fresh `npm install` before ever running `npm run build` (dist/ is gitignored).", + "impact": "Breaks the Windows CI job's dependency-install step entirely (not just a degraded warning), and breaks a fresh Windows clone's first `npm install`. This is a regression from the pre-existing script, which never touched dist/ and never threw uncaught. It also contradicts the spec's own intent that only addToUserPath failure should be non-graceful — everything else should degrade to a warning and exit 0.", + "recommendation": "Wrap the dynamic import (and the isInUserPath/addToUserPath calls that depend on it) in try/catch inside runWindows(). On failure, console.warn a message noting the PATH helper could not be loaded and return without setting process.exitCode — the same non-fatal degradation already used for a missing npm prefix or missing shim files." + } + ] +} diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review.diff b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review.diff new file mode 100644 index 00000000..beded3db --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review.diff @@ -0,0 +1,558 @@ +diff --git a/scripts/__tests__/postinstall.test.ts b/scripts/__tests__/postinstall.test.ts +new file mode 100644 +index 0000000..933aeea +--- /dev/null ++++ b/scripts/__tests__/postinstall.test.ts +@@ -0,0 +1,363 @@ ++import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; ++import { execSync } from 'node:child_process'; ++import { existsSync, readFileSync, appendFileSync } from 'node:fs'; ++import { isInUserPath, addToUserPath } from '../../dist/utils/windows-path.js'; ++ ++vi.mock('node:child_process', () => ({ ++ execSync: vi.fn(), ++})); ++ ++vi.mock('node:fs', () => ({ ++ existsSync: vi.fn(), ++ readFileSync: vi.fn(), ++ appendFileSync: vi.fn(), ++})); ++ ++vi.mock('../../dist/utils/windows-path.js', () => ({ ++ isInUserPath: vi.fn(), ++ addToUserPath: vi.fn(), ++})); ++ ++describe('postinstall', () => { ++ const originalPlatform = process.platform; ++ const originalExitCode = process.exitCode; ++ ++ beforeEach(() => { ++ vi.mocked(execSync).mockReset(); ++ vi.mocked(isInUserPath).mockClear(); ++ vi.mocked(addToUserPath).mockClear(); ++ vi.mocked(appendFileSync).mockClear(); ++ vi.mocked(existsSync).mockReset(); ++ // Default to a valid package.json shape so any test that doesn't care about ++ // getExpectedShimNames() specifically (e.g. runWindows/run tests focused on ++ // PATH persistence) doesn't trip JSON.parse on a leftover rc-file-shaped ++ // mock value from an earlier test. Tests that need a different return ++ // value (rc-file contents, custom bin fields) override it in their own body. ++ vi.mocked(readFileSync).mockReset(); ++ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ bin: { codemie: './bin/codemie.js' } })); ++ }); ++ ++ afterEach(() => { ++ vi.restoreAllMocks(); ++ Object.defineProperty(process, 'platform', { ++ value: originalPlatform, ++ configurable: true, ++ }); ++ process.exitCode = originalExitCode; ++ }); ++ ++ describe('getNpmPrefix', () => { ++ it('returns the trimmed npm prefix on success', async () => { ++ vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); ++ ++ const { getNpmPrefix } = await import('../postinstall.mjs'); ++ const result = getNpmPrefix(); ++ ++ expect(result).toBe('C:\\Users\\Test\\AppData\\Roaming\\npm'); ++ expect(execSync).toHaveBeenCalledWith( ++ 'npm config get prefix', ++ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] } ++ ); ++ }); ++ ++ it('returns null if npm is unavailable', async () => { ++ vi.mocked(execSync).mockImplementation(() => { ++ throw new Error('command not found'); ++ }); ++ ++ const { getNpmPrefix } = await import('../postinstall.mjs'); ++ const result = getNpmPrefix(); ++ ++ expect(result).toBeNull(); ++ }); ++ }); ++ ++ describe('getShimDir', () => { ++ it('returns the prefix directly on win32 (no bin/ join)', async () => { ++ const { getShimDir } = await import('../postinstall.mjs'); ++ const result = getShimDir('C:\\Users\\Test\\AppData\\Roaming\\npm', 'win32'); ++ ++ expect(result).toBe('C:\\Users\\Test\\AppData\\Roaming\\npm'); ++ }); ++ ++ it('returns prefix/bin on non-win32 platforms', async () => { ++ const { getShimDir } = await import('../postinstall.mjs'); ++ const result = getShimDir('/usr/local', 'darwin'); ++ ++ expect(result).toBe('/usr/local/bin'); ++ }); ++ }); ++ ++ describe('isInPath', () => { ++ const originalPath = process.env.PATH; ++ ++ afterEach(() => { ++ process.env.PATH = originalPath; ++ }); ++ ++ it('detects a directory using ";" on win32', async () => { ++ process.env.PATH = 'C:\\Windows;C:\\Users\\Test\\AppData\\Roaming\\npm;C:\\Windows\\System32'; ++ ++ const { isInPath } = await import('../postinstall.mjs'); ++ expect(isInPath('C:\\Users\\Test\\AppData\\Roaming\\npm', ';')).toBe(true); ++ expect(isInPath('C:\\Nonexistent', ';')).toBe(false); ++ }); ++ ++ it('detects a directory using ":" on unix', async () => { ++ process.env.PATH = '/usr/local/bin:/usr/local/lib/node/bin:/usr/bin'; ++ ++ const { isInPath } = await import('../postinstall.mjs'); ++ expect(isInPath('/usr/local/lib/node/bin', ':')).toBe(true); ++ expect(isInPath('/nonexistent', ':')).toBe(false); ++ }); ++ }); ++ ++ describe('getShellRcFile', () => { ++ const originalShell = process.env.SHELL; ++ ++ afterEach(() => { ++ process.env.SHELL = originalShell; ++ }); ++ ++ it('returns .zshrc when SHELL contains zsh', async () => { ++ process.env.SHELL = '/bin/zsh'; ++ ++ const { getShellRcFile } = await import('../postinstall.mjs'); ++ expect(getShellRcFile()).toMatch(/\.zshrc$/); ++ }); ++ ++ it('returns .bash_profile when it exists and SHELL contains bash', async () => { ++ process.env.SHELL = '/bin/bash'; ++ vi.mocked(existsSync).mockReturnValue(true); ++ ++ const { getShellRcFile } = await import('../postinstall.mjs'); ++ expect(getShellRcFile()).toMatch(/\.bash_profile$/); ++ }); ++ ++ it('returns .bashrc when .bash_profile does not exist and SHELL contains bash', async () => { ++ process.env.SHELL = '/bin/bash'; ++ vi.mocked(existsSync).mockReturnValue(false); ++ ++ const { getShellRcFile } = await import('../postinstall.mjs'); ++ expect(getShellRcFile()).toMatch(/\.bashrc$/); ++ }); ++ ++ it('returns null when SHELL is unset (Windows)', async () => { ++ delete process.env.SHELL; ++ ++ const { getShellRcFile } = await import('../postinstall.mjs'); ++ expect(getShellRcFile()).toBeNull(); ++ }); ++ }); ++ ++ describe('alreadyInRcFile', () => { ++ it('returns false when the rc file does not exist', async () => { ++ vi.mocked(existsSync).mockReturnValue(false); ++ ++ const { alreadyInRcFile } = await import('../postinstall.mjs'); ++ expect(alreadyInRcFile('/home/user/.bashrc', '/usr/local/bin')).toBe(false); ++ }); ++ ++ it('returns true when the rc file already contains the dir', async () => { ++ vi.mocked(existsSync).mockReturnValue(true); ++ vi.mocked(readFileSync).mockReturnValue('export PATH="/usr/local/bin:$PATH"\n'); ++ ++ const { alreadyInRcFile } = await import('../postinstall.mjs'); ++ expect(alreadyInRcFile('/home/user/.bashrc', '/usr/local/bin')).toBe(true); ++ }); ++ }); ++ ++ describe('getExpectedShimNames', () => { ++ it('returns the keys of package.json bin field', async () => { ++ vi.mocked(readFileSync).mockReturnValue( ++ JSON.stringify({ bin: { codemie: './bin/codemie.js', 'codemie-claude': './bin/codemie-claude.js' } }) ++ ); ++ ++ const { getExpectedShimNames } = await import('../postinstall.mjs'); ++ expect(getExpectedShimNames()).toEqual(['codemie', 'codemie-claude']); ++ }); ++ }); ++ ++ describe('findMissingShims', () => { ++ it('returns names whose .cmd file does not exist in dir', async () => { ++ vi.mocked(existsSync).mockImplementation((p) => !String(p).includes('codemie-claude.cmd')); ++ ++ const { findMissingShims } = await import('../postinstall.mjs'); ++ const result = findMissingShims('C:\\npm', ['codemie', 'codemie-claude']); ++ ++ expect(result).toEqual(['codemie-claude']); ++ }); ++ ++ it('returns an empty array when all shims exist', async () => { ++ vi.mocked(existsSync).mockReturnValue(true); ++ ++ const { findMissingShims } = await import('../postinstall.mjs'); ++ const result = findMissingShims('C:\\npm', ['codemie', 'codemie-claude']); ++ ++ expect(result).toEqual([]); ++ }); ++ }); ++ ++ describe('runWindows', () => { ++ beforeEach(() => { ++ vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); ++ vi.mocked(existsSync).mockReturnValue(true); // all shims present by default ++ }); ++ ++ it('does nothing if npm prefix cannot be determined', async () => { ++ vi.mocked(execSync).mockImplementation(() => { ++ throw new Error('npm not found'); ++ }); ++ ++ const { runWindows } = await import('../postinstall.mjs'); ++ await runWindows(); ++ ++ expect(isInUserPath).not.toHaveBeenCalled(); ++ expect(process.exitCode).toBeFalsy(); ++ }); ++ ++ it('is a no-op when the shim dir is already in PATH', async () => { ++ vi.mocked(isInUserPath).mockResolvedValue(true); ++ ++ const { runWindows } = await import('../postinstall.mjs'); ++ await runWindows(); ++ ++ expect(addToUserPath).not.toHaveBeenCalled(); ++ expect(process.exitCode).toBeFalsy(); ++ }); ++ ++ it('adds the shim dir to PATH when missing, and does not set a failing exit code', async () => { ++ vi.mocked(isInUserPath).mockResolvedValue(false); ++ vi.mocked(addToUserPath).mockResolvedValue({ ++ success: true, ++ pathAdded: 'C:\\Users\\Test\\AppData\\Roaming\\npm', ++ requiresRestart: true, ++ alreadyInPath: false, ++ }); ++ ++ const { runWindows } = await import('../postinstall.mjs'); ++ await runWindows(); ++ ++ expect(addToUserPath).toHaveBeenCalledWith('C:\\Users\\Test\\AppData\\Roaming\\npm'); ++ expect(process.exitCode).toBeFalsy(); ++ }); ++ ++ it('sets exitCode 1 and prints manual instructions when addToUserPath fails', async () => { ++ vi.mocked(isInUserPath).mockResolvedValue(false); ++ vi.mocked(addToUserPath).mockResolvedValue({ ++ success: false, ++ error: 'setx failed: access denied', ++ requiresRestart: false, ++ alreadyInPath: false, ++ }); ++ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); ++ ++ const { runWindows } = await import('../postinstall.mjs'); ++ await runWindows(); ++ ++ expect(process.exitCode).toBe(1); ++ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('setx failed: access denied')); ++ }); ++ ++ it('warns but does not fail when expected shim files are missing', async () => { ++ vi.mocked(existsSync).mockReturnValue(false); // no shims found ++ vi.mocked(isInUserPath).mockResolvedValue(true); ++ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ bin: { codemie: './bin/codemie.js' } })); ++ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); ++ ++ const { runWindows } = await import('../postinstall.mjs'); ++ await runWindows(); ++ ++ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('codemie')); ++ expect(process.exitCode).toBeFalsy(); ++ }); ++ }); ++ ++ describe('runUnix', () => { ++ beforeEach(() => { ++ vi.mocked(execSync).mockReturnValue('/usr/local\n' as unknown as Buffer); ++ process.env.PATH = '/usr/bin:/bin'; ++ process.env.SHELL = '/bin/bash'; ++ }); ++ ++ it('does nothing if npm prefix cannot be determined', async () => { ++ vi.mocked(execSync).mockImplementation(() => { ++ throw new Error('npm not found'); ++ }); ++ ++ const { runUnix } = await import('../postinstall.mjs'); ++ runUnix('linux'); ++ ++ expect(appendFileSync).not.toHaveBeenCalled(); ++ }); ++ ++ it('is a no-op when the shim dir is already in PATH', async () => { ++ process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; ++ ++ const { runUnix } = await import('../postinstall.mjs'); ++ runUnix('linux'); ++ ++ expect(appendFileSync).not.toHaveBeenCalled(); ++ }); ++ ++ it('is a no-op when the rc file already contains the dir', async () => { ++ vi.mocked(existsSync).mockReturnValue(true); ++ vi.mocked(readFileSync).mockReturnValue('export PATH="/usr/local/bin:$PATH"\n'); ++ ++ const { runUnix } = await import('../postinstall.mjs'); ++ runUnix('linux'); ++ ++ expect(appendFileSync).not.toHaveBeenCalled(); ++ }); ++ ++ it('appends to the rc file when the dir is missing from PATH and not already recorded', async () => { ++ vi.mocked(existsSync).mockReturnValue(true); ++ vi.mocked(readFileSync).mockReturnValue('# existing rc contents\n'); ++ ++ const { runUnix } = await import('../postinstall.mjs'); ++ runUnix('linux'); ++ ++ expect(appendFileSync).toHaveBeenCalledWith( ++ expect.stringContaining('.bash_profile'), ++ expect.stringContaining('/usr/local/bin') ++ ); ++ }); ++ }); ++ ++ describe('run', () => { ++ it('dispatches to the windows PATH mechanism (registry) on win32, not the rc-file mechanism', async () => { ++ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); ++ vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); ++ vi.mocked(existsSync).mockReturnValue(true); ++ vi.mocked(isInUserPath).mockResolvedValue(false); ++ vi.mocked(addToUserPath).mockResolvedValue({ ++ success: true, ++ pathAdded: 'C:\\Users\\Test\\AppData\\Roaming\\npm', ++ requiresRestart: true, ++ alreadyInPath: false, ++ }); ++ ++ const { run } = await import('../postinstall.mjs'); ++ await run(); ++ ++ expect(addToUserPath).toHaveBeenCalled(); ++ expect(appendFileSync).not.toHaveBeenCalled(); ++ }); ++ ++ it('dispatches to the rc-file mechanism on non-win32 platforms, not the windows PATH mechanism', async () => { ++ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); ++ vi.mocked(execSync).mockReturnValue('/usr/local\n' as unknown as Buffer); ++ process.env.PATH = '/usr/bin:/bin'; ++ process.env.SHELL = '/bin/bash'; ++ vi.mocked(existsSync).mockReturnValue(true); ++ vi.mocked(readFileSync).mockReturnValue('# existing rc contents\n'); ++ ++ const { run } = await import('../postinstall.mjs'); ++ await run(); ++ ++ expect(appendFileSync).toHaveBeenCalled(); ++ expect(isInUserPath).not.toHaveBeenCalled(); ++ expect(addToUserPath).not.toHaveBeenCalled(); ++ }); ++ }); ++}); +diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs +index 6fcff67..163c549 100644 +--- a/scripts/postinstall.mjs ++++ b/scripts/postinstall.mjs +@@ -1,52 +1,120 @@ + #!/usr/bin/env node + import { execSync } from 'node:child_process'; + import { existsSync, readFileSync, appendFileSync } from 'node:fs'; +-import { homedir } from 'node:os'; +-import { join } from 'node:path'; ++import { homedir, platform } from 'node:os'; ++import { join, delimiter, dirname, posix as pathPosix } from 'node:path'; ++import { fileURLToPath } from 'node:url'; + +-function getNpmBinDir() { +- try { +- const prefix = execSync('npm config get prefix', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); +- return join(prefix, 'bin'); +- } catch { +- return null; +- } ++const __dirname = dirname(fileURLToPath(import.meta.url)); ++ ++export function getNpmPrefix() { ++ try { ++ return execSync('npm config get prefix', { ++ encoding: 'utf8', ++ stdio: ['pipe', 'pipe', 'ignore'], ++ }).trim(); ++ } catch { ++ return null; ++ } + } + +-function getShellRcFile() { +- const shell = process.env.SHELL ?? ''; +- const home = homedir(); +- if (shell.includes('zsh')) return join(home, '.zshrc'); +- if (shell.includes('bash')) { +- const bashProfile = join(home, '.bash_profile'); +- return existsSync(bashProfile) ? bashProfile : join(home, '.bashrc'); +- } +- return null; ++export function getShimDir(prefix, plat = platform()) { ++ // posix.join (not the OS-native join) so this is correct regardless of which ++ // OS this script itself is running on, matching how src/utils/windows-path.ts ++ // uses path.win32 explicitly for its own platform-specific branch. ++ return plat === 'win32' ? prefix : pathPosix.join(prefix, 'bin'); + } + +-function isInPath(dir) { +- return (process.env.PATH ?? '').split(':').includes(dir); ++export function isInPath(dir, sep = delimiter) { ++ // sep defaults to the real path.delimiter for actual use; the parameter exists ++ // so both the ';' (win32) and ':' (posix) branches are deterministically ++ // testable regardless of which OS this script itself runs on. ++ return (process.env.PATH ?? '').split(sep).includes(dir); + } + +-function alreadyInRcFile(rcFile, dir) { +- if (!existsSync(rcFile)) return false; +- return readFileSync(rcFile, 'utf8').includes(dir); ++export function getShellRcFile() { ++ const shell = process.env.SHELL ?? ''; ++ const home = homedir(); ++ if (shell.includes('zsh')) return join(home, '.zshrc'); ++ if (shell.includes('bash')) { ++ const bashProfile = join(home, '.bash_profile'); ++ return existsSync(bashProfile) ? bashProfile : join(home, '.bashrc'); ++ } ++ return null; + } + +-const npmBin = getNpmBinDir(); +-if (!npmBin) process.exit(0); ++export function alreadyInRcFile(rcFile, dir) { ++ if (!existsSync(rcFile)) return false; ++ return readFileSync(rcFile, 'utf8').includes(dir); ++} + +-if (isInPath(npmBin)) process.exit(0); ++export function getExpectedShimNames() { ++ const packageJsonPath = join(__dirname, '..', 'package.json'); ++ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); ++ return Object.keys(pkg.bin ?? {}); ++} + +-const rcFile = getShellRcFile(); +-if (!rcFile) { +- console.log(`\n⚠️ Add to PATH manually:\n export PATH="${npmBin}:$PATH"\n`); +- process.exit(0); ++export function findMissingShims(dir, names) { ++ return names.filter((name) => !existsSync(join(dir, `${name}.cmd`))); + } + +-if (alreadyInRcFile(rcFile, npmBin)) process.exit(0); ++export async function runWindows() { ++ const prefix = getNpmPrefix(); ++ if (!prefix) return; ++ ++ const dir = getShimDir(prefix, 'win32'); ++ ++ const missing = findMissingShims(dir, getExpectedShimNames()); ++ if (missing.length > 0) { ++ console.warn(`\n⚠️ Expected CodeMie command shims not found in ${dir}: ${missing.join(', ')}\n`); ++ } ++ ++ const { isInUserPath, addToUserPath } = await import('../dist/utils/windows-path.js'); ++ ++ if (await isInUserPath(dir)) return; ++ ++ const result = await addToUserPath(dir); ++ if (result.success) { ++ console.log(`\n✓ Added ${dir} to PATH\n Open a new terminal to use codemie\n`); ++ return; ++ } ++ ++ console.error(`\n✗ Could not update PATH automatically: ${result.error}`); ++ console.error(` Add manually: setx PATH "%PATH%;${dir}"`); ++ console.error(` (or via System Properties > Environment Variables)\n`); ++ process.exitCode = 1; ++} + +-appendFileSync(rcFile, `\n# Added by @codemieai/code\nexport PATH="${npmBin}:$PATH"\n`); ++export function runUnix(plat = platform()) { ++ const prefix = getNpmPrefix(); ++ if (!prefix) return; + +-console.log(`\n✓ Added ${npmBin} to PATH in ${rcFile}`); +-console.log(` Run: source ${rcFile}\n`); ++ const npmBin = getShimDir(prefix, plat); ++ if (isInPath(npmBin, plat === 'win32' ? ';' : ':')) return; ++ ++ const rcFile = getShellRcFile(); ++ if (!rcFile) { ++ console.log(`\n⚠️ Add to PATH manually:\n export PATH="${npmBin}:$PATH"\n`); ++ return; ++ } ++ ++ if (alreadyInRcFile(rcFile, npmBin)) return; ++ ++ appendFileSync(rcFile, `\n# Added by @codemieai/code\nexport PATH="${npmBin}:$PATH"\n`); ++ ++ console.log(`\n✓ Added ${npmBin} to PATH in ${rcFile}`); ++ console.log(` Run: source ${rcFile}\n`); ++} ++ ++export async function run() { ++ if (platform() === 'win32') { ++ await runWindows(); ++ } else { ++ runUnix(); ++ } ++} ++ ++const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; ++if (isMain) { ++ await run(); ++} +diff --git a/scripts/validate-secrets.js b/scripts/validate-secrets.js +index decbdb7..8a5c3ad 100755 +--- a/scripts/validate-secrets.js ++++ b/scripts/validate-secrets.js +@@ -95,9 +95,11 @@ if (hasConfig) { + + console.log('Running Gitleaks secrets detection...'); + ++// engineBin is already a fully-resolved absolute path from `where`/`which`, so no shell ++// is needed to invoke it — shell:true on Windows re-splits the path on spaces (e.g. ++// "C:\Program Files\...") since args are concatenated into a string, not passed as argv. + const gitleaks = spawn(engineBin, args, { + stdio: ['pipe', 'inherit', 'inherit'], +- shell: isWindows, + }); + + gitleaks.stdin.write(stagedDiff); +diff --git a/vitest.config.ts b/vitest.config.ts +index b38fb6c..3b0c8ef 100644 +--- a/vitest.config.ts ++++ b/vitest.config.ts +@@ -4,7 +4,7 @@ export default defineConfig({ + test: { + globals: true, + environment: 'node', +- include: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'tests/**/*.test.ts'], ++ include: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'tests/**/*.test.ts', 'scripts/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + // Force color output for consistent test behavior (chalk output length varies with/without colors) + env: { diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/complexity-assessment.json b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/complexity-assessment.json new file mode 100644 index 00000000..0d404b05 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/complexity-assessment.json @@ -0,0 +1,26 @@ +{ + "schema": 1, + "task": "Fix scripts/postinstall.mjs so Windows npm global installs correctly detect the npm prefix, use the right PATH separator, and persist the CodeMie bin path to the Windows user PATH (reusing src/utils/windows-path.ts), with exported helpers and new Vitest coverage.", + "generated": "2026-07-02", + "dimensions": { + "component_scope": { "score": 4, "label": "L" }, + "requirements_clarity": { "score": 3, "label": "M" }, + "technical_risk": { "score": 3, "label": "M" }, + "file_change_estimate": { "score": 2, "label": "S" }, + "dependencies": { "score": 2, "label": "S" }, + "affected_layers": { "score": 2, "label": "S" } + }, + "total": 16, + "size": "M", + "routing": "brainstorming", + "key_reasoning": [ + { "dimension": "component_scope", "reason": "Touches scripts/postinstall.mjs (export refactor of a previously side-effect-only script), vitest.config.ts, a new test file, and reuse/possible extension of src/utils/windows-path.ts — a shared utility already consumed by src/utils/native-installer.ts, which is what pushed this from M to L." }, + { "dimension": "requirements_clarity", "reason": "Acceptance criteria are unusually detailed (platform branch, separator, persistence mechanism, shim files, test file, no regression), but one real design decision is open: whether npm install -g should mimic install.ps1's isolated-prefix/wrapper-shim pattern or simply fix detection for npm's actual unmanaged global prefix — this needs resolving before implementation." }, + { "dimension": "technical_risk", "reason": "Two independent existing Windows PATH-persistence implementations already exist in the repo (install.ps1's .NET API, windows-path.ts's setx/reg.exe); reusing the already-tested, exported windows-path.ts keeps risk moderate, but no repo precedent exists for mocking raw child_process calls if that reuse path isn't taken." }, + { "dimension": "file_change_estimate", "reason": "Realistic footprint is small: postinstall.mjs (rewrite), vitest.config.ts (test.include glob fix), scripts/__tests__/postinstall.test.ts (new), and possibly a small addition to windows-path.ts." } + ], + "red_flags_applied": [ + "Component Scope bumped from M (3) to L (4): touches core shared utility src/utils/windows-path.ts, which is consumed elsewhere by src/utils/native-installer.ts" + ], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/decisions.jsonl b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/decisions.jsonl new file mode 100644 index 00000000..9b2962aa --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/decisions.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-07-02T12:05:40.419Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved after revising exit policy: addToUserPath() failure now exits 1 with manual setx instructions; missing npm prefix and missing shims stay warn-and-exit-0.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"question":"spec.approved gate for EPMCDME-13191 npm Windows PATH/bin shims fix","artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/spec.md"],"phase":"sdlc-standard Stage 3","risk_flags":[],"note":"initial round requested a change to the exit-policy section (addToUserPath failure should exit non-zero); spec revised and re-approved in same turn"}} +{"ts":"2026-07-02T12:20:00.000Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the 8-task incremental TDD plan for postinstall.mjs.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"question":"plan.approved gate for EPMCDME-13191 npm Windows PATH/bin shims fix","artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/plan.md"],"phase":"sdlc-standard Stage 4","risk_flags":[]}} +{"ts":"2026-07-02T13:00:00.000Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"request-changes","rationale":"User confirmed CR-001 (unguarded dynamic import crashes on missing dist/) should be fixed.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-final.json"],"phase":"sdlc-standard Stage 6","risk_flags":["breaking-change"],"prior_orchestrator_verdict_findings":["CR-001"]}} +{"ts":"2026-07-02T13:15:00.000Z","gate_id":"code-review.check","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved after CR-001 fix confirmed resolved.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/code-review-check.json"],"phase":"sdlc-standard Stage 6","prior_orchestrator_verdict_finding_status":[{"id":"CR-001","status":"resolved"}]}} +{"ts":"2026-07-02T13:30:00.000Z","gate_id":"feature.verification","mode":"hitl","verdict":{"decision":"acknowledge-and-proceed","rationale":"qa-gates: 8/9 gates pass. Integration test failures confirmed pre-existing/flaky and unrelated via A/B stash comparison against main. Separately discovered pre-existing test-isolation bug (tests mutate real .codemie/codemie-cli.config.json and package.json instead of fixtures) - flagged as follow-up, not fixed in this ticket.","follow_ups":["file follow-up ticket: integration tests mutate real project config files instead of fixtures","file follow-up ticket: eslint.config.mjs / tsconfig.json do not cover scripts/** at all"],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/qa-report.md"],"phase":"sdlc-standard Stage 7"}} diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/events.jsonl b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/events.jsonl new file mode 100644 index 00000000..0df33425 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/events.jsonl @@ -0,0 +1,5 @@ +{"schema":1,"ts":"2026-07-02T12:05:40.419Z","event":"decision.recorded","run_id":"npm-windows-path-shims","phase":"sdlc-standard-stage-3","actor":"decision-router","summary":"Decision recorded for spec.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false,"prior_context":{"question":"spec.approved gate for EPMCDME-13191 npm Windows PATH/bin shims fix","phase":"sdlc-standard Stage 3","risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/spec.md"]}}} +{"schema":1,"ts":"2026-07-02T12:20:00.000Z","event":"decision.recorded","run_id":"npm-windows-path-shims","phase":"sdlc-standard-stage-4","actor":"decision-router","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false,"prior_context":{"artifact_refs":["docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/plan.md"]}}} +{"schema":1,"ts":"2026-07-02T13:00:00.000Z","event":"decision.recorded","run_id":"npm-windows-path-shims","phase":"sdlc-standard-stage-6","actor":"decision-router","summary":"Decision recorded for code-review.final: request-changes (CR-001)","artifacts":["decisions.jsonl","code-review-final.json"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-07-02T13:15:00.000Z","event":"decision.recorded","run_id":"npm-windows-path-shims","phase":"sdlc-standard-stage-6","actor":"decision-router","summary":"Decision recorded for code-review.check: approve","artifacts":["decisions.jsonl","code-review-check.json"],"data":{"gate_id":"code-review.check","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-07-02T13:30:00.000Z","event":"decision.recorded","run_id":"npm-windows-path-shims","phase":"sdlc-standard-stage-7","actor":"decision-router","summary":"Decision recorded for feature.verification: acknowledge-and-proceed","artifacts":["decisions.jsonl","qa-report.md"],"data":{"gate_id":"feature.verification","mode":"hitl","decision":"acknowledge-and-proceed","source":"hitl","escalated":false}} diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/plan.md b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/plan.md new file mode 100644 index 00000000..67d6d9f0 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/plan.md @@ -0,0 +1,807 @@ +# npm Windows PATH / bin shims fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix `scripts/postinstall.mjs` so `npm install -g @codemieai/code` correctly detects npm's Windows global prefix and persists it to the Windows user PATH, so `codemie` resolves after install (including for non-interactive bash hook subprocesses), while leaving Unix behavior unchanged. + +**Architecture:** Refactor the currently side-effect-only, zero-export `postinstall.mjs` into small exported functions gated behind a direct-execution guard. Add a Windows branch (`runWindows`) that detects npm's real prefix (no `bin/` join), warns (non-fatal) if expected shim files are missing, and reuses the already-tested `dist/utils/windows-path.js` (`isInUserPath`/`addToUserPath`) for registry-backed PATH persistence — exiting 1 only if that persistence call itself fails. The existing Unix branch (`runUnix`) keeps its current logic, with the hardcoded `:` PATH separator replaced by `path.delimiter`. + +**Tech Stack:** Node.js ESM (`.mjs`), Vitest, existing `src/utils/windows-path.ts` (compiled to `dist/utils/windows-path.js`). + +--- + +## File structure + +- **Modify:** `scripts/postinstall.mjs` — refactor into exported functions + platform branch (see spec: `docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/spec.md`) +- **Create:** `scripts/__tests__/postinstall.test.ts` — new test file, built incrementally task-by-task +- **Modify:** `vitest.config.ts` — add `scripts/**/*.test.ts` to `test.include` + +No other files change. `src/utils/windows-path.ts` / `dist/utils/windows-path.js` are consumed as-is, not modified. + +--- + +### Task 1: Enable `scripts/` tests in Vitest + +**Files:** +- Modify: `vitest.config.ts:7` + +**Test-first: no** — this is a test-runner config change; there is no independent unit test for "does vitest.config.ts include the right glob." Task 2 will prove this works by being the first test that must actually run. + +- [ ] **Step 1: Update `test.include`** + +In `vitest.config.ts`, change: + +```ts + include: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'tests/**/*.test.ts'], +``` + +to: + +```ts + include: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'tests/**/*.test.ts', 'scripts/**/*.test.ts'], +``` + +- [ ] **Step 2: Commit** + +```bash +git add vitest.config.ts +git commit -m "chore(deps): include scripts/**/*.test.ts in vitest test glob" +``` + +--- + +### Task 2: `getNpmPrefix()` and `getShimDir()` — the core Windows detection fix + +**Files:** +- Create: `scripts/__tests__/postinstall.test.ts` +- Modify: `scripts/postinstall.mjs` (full rewrite starts here, built up task by task) + +**Test-first: yes — "getShimDir returns the prefix itself on win32 (no bin/ join), and prefix/bin on other platforms"** + +- [ ] **Step 1: Write the failing test** + +Create `scripts/__tests__/postinstall.test.ts` with the mock scaffolding the whole file will reuse, plus the first two test cases: + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'node:child_process'; + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + appendFileSync: vi.fn(), +})); + +vi.mock('../../dist/utils/windows-path.js', () => ({ + isInUserPath: vi.fn(), + addToUserPath: vi.fn(), +})); + +describe('postinstall', () => { + const originalPlatform = process.platform; + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.mocked(execSync).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + process.exitCode = originalExitCode; + }); + + describe('getNpmPrefix', () => { + it('returns the trimmed npm prefix on success', async () => { + vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); + + const { getNpmPrefix } = await import('../postinstall.mjs'); + const result = getNpmPrefix(); + + expect(result).toBe('C:\\Users\\Test\\AppData\\Roaming\\npm'); + expect(execSync).toHaveBeenCalledWith( + 'npm config get prefix', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] } + ); + }); + + it('returns null if npm is unavailable', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('command not found'); + }); + + const { getNpmPrefix } = await import('../postinstall.mjs'); + const result = getNpmPrefix(); + + expect(result).toBeNull(); + }); + }); + + describe('getShimDir', () => { + it('returns the prefix directly on win32 (no bin/ join)', async () => { + const { getShimDir } = await import('../postinstall.mjs'); + const result = getShimDir('C:\\Users\\Test\\AppData\\Roaming\\npm', 'win32'); + + expect(result).toBe('C:\\Users\\Test\\AppData\\Roaming\\npm'); + }); + + it('returns prefix/bin on non-win32 platforms', async () => { + const { getShimDir } = await import('../postinstall.mjs'); + const result = getShimDir('/usr/local', 'darwin'); + + expect(result).toBe('/usr/local/bin'); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: FAIL — `scripts/postinstall.mjs` has no exports today, so `import('../postinstall.mjs')` will not provide `getNpmPrefix`/`getShimDir` (they are `undefined`, calling them throws `TypeError: getNpmPrefix is not a function`). + +- [ ] **Step 3: Write minimal implementation** + +Replace the full contents of `scripts/postinstall.mjs` with: + +```js +#!/usr/bin/env node +import { execSync } from 'node:child_process'; +import { platform } from 'node:os'; +import { posix as pathPosix } from 'node:path'; + +export function getNpmPrefix() { + try { + return execSync('npm config get prefix', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +export function getShimDir(prefix, plat = platform()) { + // posix.join (not the OS-native join) so this stays correct — and testable — + // regardless of which OS this script itself runs on; native `join` always + // matches the real host OS, not the `plat` parameter, which breaks simulating + // a non-win32 platform from a Windows dev/CI machine. + return plat === 'win32' ? prefix : pathPosix.join(prefix, 'bin'); +} +``` + +(The rest of the module — `isInPath`, `getShellRcFile`, `alreadyInRcFile`, shim verification, `runWindows`/`runUnix`/`run`, and the direct-execution guard — is added in the following tasks, along with the additional imports each one needs. Task 4 introduces the OS-native `join` (distinct from this task's `pathPosix.join`) for real filesystem paths on the actual host OS. Importing symbols before they're used would trip the `no-unused-vars` lint rule at this task's commit, so imports grow incrementally task-by-task rather than all appearing here. The old top-level side-effecting statements are removed now; nothing runs yet when the file is executed directly until Task 7 restores that behavior.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/postinstall.mjs scripts/__tests__/postinstall.test.ts +git commit -m "refactor(cli): export getNpmPrefix/getShimDir, fix Windows shim dir detection" +``` + +--- + +### Task 3: `isInPath()`, `getShellRcFile()`, `alreadyInRcFile()` — export the Unix helpers + +**Test-first: yes — "isInPath splits PATH on path.delimiter (`;` on win32, `:` elsewhere)"** + +- [ ] **Step 1: Write the failing test** + +Add to `scripts/__tests__/postinstall.test.ts`, inside the `describe('postinstall', ...)` block, after the `getShimDir` describe block: + +```ts + describe('isInPath', () => { + const originalPath = process.env.PATH; + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it('detects a directory using ";" on win32', async () => { + process.env.PATH = 'C:\\Windows;C:\\Users\\Test\\AppData\\Roaming\\npm;C:\\Windows\\System32'; + + const { isInPath } = await import('../postinstall.mjs'); + expect(isInPath('C:\\Users\\Test\\AppData\\Roaming\\npm')).toBe(true); + expect(isInPath('C:\\Nonexistent')).toBe(false); + }); + + it('detects a directory using ":" on unix', async () => { + process.env.PATH = '/usr/local/bin:/usr/local/lib/node/bin:/usr/bin'; + + const { isInPath } = await import('../postinstall.mjs'); + expect(isInPath('/usr/local/lib/node/bin')).toBe(true); + expect(isInPath('/nonexistent')).toBe(false); + }); + }); + + describe('getShellRcFile', () => { + const originalShell = process.env.SHELL; + + afterEach(() => { + process.env.SHELL = originalShell; + }); + + it('returns .zshrc when SHELL contains zsh', async () => { + process.env.SHELL = '/bin/zsh'; + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toMatch(/\.zshrc$/); + }); + + it('returns .bash_profile when it exists and SHELL contains bash', async () => { + process.env.SHELL = '/bin/bash'; + vi.mocked(existsSync).mockReturnValue(true); + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toMatch(/\.bash_profile$/); + }); + + it('returns .bashrc when .bash_profile does not exist and SHELL contains bash', async () => { + process.env.SHELL = '/bin/bash'; + vi.mocked(existsSync).mockReturnValue(false); + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toMatch(/\.bashrc$/); + }); + + it('returns null when SHELL is unset (Windows)', async () => { + delete process.env.SHELL; + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toBeNull(); + }); + }); + + describe('alreadyInRcFile', () => { + it('returns false when the rc file does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + const { alreadyInRcFile } = await import('../postinstall.mjs'); + expect(alreadyInRcFile('/home/user/.bashrc', '/usr/local/bin')).toBe(false); + }); + + it('returns true when the rc file already contains the dir', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('export PATH="/usr/local/bin:$PATH"\n'); + + const { alreadyInRcFile } = await import('../postinstall.mjs'); + expect(alreadyInRcFile('/home/user/.bashrc', '/usr/local/bin')).toBe(true); + }); + }); +``` + +Add `existsSync` and `readFileSync` to the test file's top-level import from `node:fs`: + +```ts +import { existsSync, readFileSync } from 'node:fs'; +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: FAIL — `isInPath`, `getShellRcFile`, `alreadyInRcFile` are not exported yet. + +- [ ] **Step 3: Write minimal implementation** + +Update the imports at the top of `scripts/postinstall.mjs`: + +```js +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { join, delimiter, posix as pathPosix } from 'node:path'; +``` + +Append to `scripts/postinstall.mjs` (after `getShimDir`). Note: like `getShimDir`'s `plat` parameter, `isInPath` takes an optional `sep` parameter (defaulting to the real `path.delimiter`) so both branches are deterministically testable regardless of host OS — otherwise `path.delimiter` always reflects the actual machine running the test, not the platform under test: + +```js +export function isInPath(dir) { + return (process.env.PATH ?? '').split(delimiter).includes(dir); +} + +export function getShellRcFile() { + const shell = process.env.SHELL ?? ''; + const home = homedir(); + if (shell.includes('zsh')) return join(home, '.zshrc'); + if (shell.includes('bash')) { + const bashProfile = join(home, '.bash_profile'); + return existsSync(bashProfile) ? bashProfile : join(home, '.bashrc'); + } + return null; +} + +export function alreadyInRcFile(rcFile, dir) { + if (!existsSync(rcFile)) return false; + return readFileSync(rcFile, 'utf8').includes(dir); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: PASS (all tests so far) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/postinstall.mjs scripts/__tests__/postinstall.test.ts +git commit -m "refactor(cli): export isInPath/getShellRcFile/alreadyInRcFile with path.delimiter fix" +``` + +--- + +### Task 4: `getExpectedShimNames()` and `findMissingShims()` — shim verification diagnostic + +**Test-first: yes — "findMissingShims returns names whose .cmd file is absent from the directory"** + +- [ ] **Step 1: Write the failing test** + +Add to `scripts/__tests__/postinstall.test.ts`: + +```ts + describe('getExpectedShimNames', () => { + it('returns the keys of package.json bin field', async () => { + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ bin: { codemie: './bin/codemie.js', 'codemie-claude': './bin/codemie-claude.js' } }) + ); + + const { getExpectedShimNames } = await import('../postinstall.mjs'); + expect(getExpectedShimNames()).toEqual(['codemie', 'codemie-claude']); + }); + }); + + describe('findMissingShims', () => { + it('returns names whose .cmd file does not exist in dir', async () => { + vi.mocked(existsSync).mockImplementation((p) => !String(p).includes('codemie-claude.cmd')); + + const { findMissingShims } = await import('../postinstall.mjs'); + const result = findMissingShims('C:\\npm', ['codemie', 'codemie-claude']); + + expect(result).toEqual(['codemie-claude']); + }); + + it('returns an empty array when all shims exist', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const { findMissingShims } = await import('../postinstall.mjs'); + const result = findMissingShims('C:\\npm', ['codemie', 'codemie-claude']); + + expect(result).toEqual([]); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: FAIL — `getExpectedShimNames`, `findMissingShims` are not exported yet. + +- [ ] **Step 3: Write minimal implementation** + +Update the `node:path` import and add a new `node:url` import at the top of `scripts/postinstall.mjs`: + +```js +import { join, delimiter, dirname, posix as pathPosix } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +``` + +Append to `scripts/postinstall.mjs` (after `alreadyInRcFile`): + +```js +export function getExpectedShimNames() { + const packageJsonPath = join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return Object.keys(pkg.bin ?? {}); +} + +export function findMissingShims(dir, names) { + return names.filter((name) => !existsSync(join(dir, `${name}.cmd`))); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: PASS (all tests so far) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/postinstall.mjs scripts/__tests__/postinstall.test.ts +git commit -m "feat(cli): add missing-shim diagnostic for Windows postinstall" +``` + +--- + +### Task 5: `runWindows()` — PATH persistence via `dist/utils/windows-path.js` + +**Test-first: yes — "runWindows adds the shim dir to PATH when not already present, and exits 1 only when addToUserPath fails"** + +- [ ] **Step 1: Write the failing test** + +Add to `scripts/__tests__/postinstall.test.ts`. First add the `isInUserPath`/`addToUserPath` mock import at the top: + +```ts +import { isInUserPath, addToUserPath } from '../../dist/utils/windows-path.js'; +``` + +Then add the describe block: + +```ts + describe('runWindows', () => { + beforeEach(() => { + vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); + vi.mocked(existsSync).mockReturnValue(true); // all shims present by default + }); + + it('does nothing if npm prefix cannot be determined', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('npm not found'); + }); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(isInUserPath).not.toHaveBeenCalled(); + expect(process.exitCode).toBeFalsy(); + }); + + it('is a no-op when the shim dir is already in PATH', async () => { + vi.mocked(isInUserPath).mockResolvedValue(true); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(addToUserPath).not.toHaveBeenCalled(); + expect(process.exitCode).toBeFalsy(); + }); + + it('adds the shim dir to PATH when missing, and does not set a failing exit code', async () => { + vi.mocked(isInUserPath).mockResolvedValue(false); + vi.mocked(addToUserPath).mockResolvedValue({ success: true, pathAdded: 'C:\\Users\\Test\\AppData\\Roaming\\npm', requiresRestart: true, alreadyInPath: false }); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(addToUserPath).toHaveBeenCalledWith('C:\\Users\\Test\\AppData\\Roaming\\npm'); + expect(process.exitCode).toBeFalsy(); + }); + + it('sets exitCode 1 and prints manual instructions when addToUserPath fails', async () => { + vi.mocked(isInUserPath).mockResolvedValue(false); + vi.mocked(addToUserPath).mockResolvedValue({ success: false, error: 'setx failed: access denied', requiresRestart: false, alreadyInPath: false }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('setx failed: access denied')); + }); + + it('warns but does not fail when expected shim files are missing', async () => { + vi.mocked(existsSync).mockReturnValue(false); // no shims found + vi.mocked(isInUserPath).mockResolvedValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ bin: { codemie: './bin/codemie.js' } })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('codemie')); + expect(process.exitCode).toBeFalsy(); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: FAIL — `runWindows` is not exported yet. + +- [ ] **Step 3: Write minimal implementation** + +Append to `scripts/postinstall.mjs` (after `findMissingShims`): + +```js +export async function runWindows() { + const prefix = getNpmPrefix(); + if (!prefix) return; + + const dir = getShimDir(prefix, 'win32'); + + const missing = findMissingShims(dir, getExpectedShimNames()); + if (missing.length > 0) { + console.warn(`\n⚠️ Expected CodeMie command shims not found in ${dir}: ${missing.join(', ')}\n`); + } + + const { isInUserPath, addToUserPath } = await import('../dist/utils/windows-path.js'); + + if (await isInUserPath(dir)) return; + + const result = await addToUserPath(dir); + if (result.success) { + console.log(`\n✓ Added ${dir} to PATH\n Open a new terminal to use codemie\n`); + return; + } + + console.error(`\n✗ Could not update PATH automatically: ${result.error}`); + console.error(` Add manually: setx PATH "%PATH%;${dir}"`); + console.error(` (or via System Properties > Environment Variables)\n`); + process.exitCode = 1; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: PASS (all tests so far) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/postinstall.mjs scripts/__tests__/postinstall.test.ts +git commit -m "feat(cli): add runWindows — persist npm shim dir to Windows user PATH" +``` + +--- + +### Task 6: `runUnix()` — wrap the existing Unix logic + +**Test-first: yes — "runUnix appends to the rc file when the shim dir is missing from PATH, and is a no-op when already present or already recorded"** + +- [ ] **Step 1: Write the failing test** + +Add `appendFileSync` to the test file's `node:fs` import: + +```ts +import { existsSync, readFileSync, appendFileSync } from 'node:fs'; +``` + +Add to `scripts/__tests__/postinstall.test.ts`: + +```ts + describe('runUnix', () => { + beforeEach(() => { + vi.mocked(execSync).mockReturnValue('/usr/local\n' as unknown as Buffer); + process.env.PATH = '/usr/bin:/bin'; + process.env.SHELL = '/bin/bash'; + }); + + it('does nothing if npm prefix cannot be determined', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('npm not found'); + }); + + const { runUnix } = await import('../postinstall.mjs'); + runUnix(); + + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('is a no-op when the shim dir is already in PATH', async () => { + process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; + + const { runUnix } = await import('../postinstall.mjs'); + runUnix(); + + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('is a no-op when the rc file already contains the dir', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('export PATH="/usr/local/bin:$PATH"\n'); + + const { runUnix } = await import('../postinstall.mjs'); + runUnix(); + + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('appends to the rc file when the dir is missing from PATH and not already recorded', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('# existing rc contents\n'); + + const { runUnix } = await import('../postinstall.mjs'); + runUnix(); + + expect(appendFileSync).toHaveBeenCalledWith( + expect.stringContaining('.bash_profile'), + expect.stringContaining('/usr/local/bin') + ); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: FAIL — `runUnix` is not exported yet. + +- [ ] **Step 3: Write minimal implementation** + +Update the `node:fs` import at the top of `scripts/postinstall.mjs` to add `appendFileSync`: + +```js +import { existsSync, readFileSync, appendFileSync } from 'node:fs'; +``` + +Append to `scripts/postinstall.mjs` (after `runWindows`). Note: `runUnix` takes an optional `plat` parameter (defaulting to the real `platform()`), same rationale as `getShimDir`'s `plat` and `isInPath`'s `sep` — otherwise `platform()` always reflects the real host OS regardless of which branch a test is exercising. The separator passed to `isInPath` is derived from `plat` for the same reason: + +```js +export function runUnix(plat = platform()) { + const prefix = getNpmPrefix(); + if (!prefix) return; + + const npmBin = getShimDir(prefix, plat); + if (isInPath(npmBin, plat === 'win32' ? ';' : ':')) return; + + const rcFile = getShellRcFile(); + if (!rcFile) { + console.log(`\n⚠️ Add to PATH manually:\n export PATH="${npmBin}:$PATH"\n`); + return; + } + + if (alreadyInRcFile(rcFile, npmBin)) return; + + appendFileSync(rcFile, `\n# Added by @codemieai/code\nexport PATH="${npmBin}:$PATH"\n`); + + console.log(`\n✓ Added ${npmBin} to PATH in ${rcFile}`); + console.log(` Run: source ${rcFile}\n`); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: PASS (all tests so far) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/postinstall.mjs scripts/__tests__/postinstall.test.ts +git commit -m "refactor(cli): wrap existing unix PATH logic into runUnix" +``` + +--- + +### Task 7: `run()` and the direct-execution guard + +**Test-first: yes — "run() dispatches to runWindows on win32 and runUnix otherwise"** + +- [ ] **Step 1: Write the failing test** + +Add to `scripts/__tests__/postinstall.test.ts`. Note: `vi.spyOn(mod, 'runWindows')` does **not** intercept `run()`'s internal call to `runWindows` — ESM same-module calls bind directly to the local function, not through the mutable export namespace object, so spying on the export is a no-op for this purpose. Test the actual dispatched behavior instead (which is what matters anyway): + +```ts + describe('run', () => { + it('dispatches to the windows PATH mechanism (registry) on win32, not the rc-file mechanism', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(isInUserPath).mockResolvedValue(false); + vi.mocked(addToUserPath).mockResolvedValue({ + success: true, + pathAdded: 'C:\\Users\\Test\\AppData\\Roaming\\npm', + requiresRestart: true, + alreadyInPath: false, + }); + + const { run } = await import('../postinstall.mjs'); + await run(); + + expect(addToUserPath).toHaveBeenCalled(); + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('dispatches to the rc-file mechanism on non-win32 platforms, not the windows PATH mechanism', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + vi.mocked(execSync).mockReturnValue('/usr/local\n' as unknown as Buffer); + process.env.PATH = '/usr/bin:/bin'; + process.env.SHELL = '/bin/bash'; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('# existing rc contents\n'); + + const { run } = await import('../postinstall.mjs'); + await run(); + + expect(appendFileSync).toHaveBeenCalled(); + expect(isInUserPath).not.toHaveBeenCalled(); + expect(addToUserPath).not.toHaveBeenCalled(); + }); + }); +``` + +**Mock isolation note:** by this point the test file has several module-level mocks (`execSync`, `existsSync`, `readFileSync`, `appendFileSync`, `isInUserPath`, `addToUserPath`) whose call history and return values persist across tests unless explicitly cleared — `vi.restoreAllMocks()` in the top-level `afterEach` does not reliably reset factory-created `vi.fn()` mocks (only real `vi.spyOn` spies). The top-level `beforeEach` must explicitly `mockClear()`/`mockReset()` each of these, and give `readFileSync` a safe default valid-JSON return (since `runWindows` always calls `getExpectedShimNames()`, which `JSON.parse`s it, even in tests that don't care about shim names). Without this, tests pass or fail depending on execution order rather than their own setup — add these resets now if they weren't already added incrementally in earlier tasks: + +```ts + beforeEach(() => { + vi.mocked(execSync).mockReset(); + vi.mocked(isInUserPath).mockClear(); + vi.mocked(addToUserPath).mockClear(); + vi.mocked(appendFileSync).mockClear(); + vi.mocked(existsSync).mockReset(); + vi.mocked(readFileSync).mockReset(); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ bin: { codemie: './bin/codemie.js' } })); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: FAIL — `run` is not exported yet. + +- [ ] **Step 3: Write minimal implementation** + +Append to `scripts/postinstall.mjs` (after `runUnix`): + +```js +export async function run() { + if (platform() === 'win32') { + await runWindows(); + } else { + runUnix(); + } +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + await run(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run scripts/__tests__/postinstall.test.ts` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add scripts/postinstall.mjs scripts/__tests__/postinstall.test.ts +git commit -m "feat(cli): add run() dispatcher and direct-execution guard to postinstall.mjs" +``` + +--- + +### Task 8: Full verification pass + +**Test-first: no** — this task runs the full existing suites to confirm no regressions; it doesn't add new behavior of its own. + +- [ ] **Step 1: Run the full test suite** + +Run: `npm test` +Expected: PASS — all existing `src/**` and `tests/**` tests plus the new `scripts/__tests__/postinstall.test.ts` suite. + +- [ ] **Step 2: Run lint and typecheck** + +Run: `npm run lint && npm run typecheck` +Expected: PASS. (`postinstall.mjs` is plain JS, not part of `tsconfig.json`'s `include` — confirm `npm run typecheck` doesn't attempt to type-check it; if it does, no action needed since the file has no TS syntax errors, but if `eslint` flags anything in the new file, fix inline.) + +- [ ] **Step 3: Manually sanity-check the exported shape** + +Run: `node --input-type=module -e "const m = await import('./scripts/postinstall.mjs'); console.log(Object.keys(m))"` +Expected output includes: `getNpmPrefix, getShimDir, isInPath, getShellRcFile, alreadyInRcFile, getExpectedShimNames, findMissingShims, runWindows, runUnix, run` + +- [ ] **Step 4: Commit (if any fixups were needed)** + +```bash +git add -A +git commit -m "chore: fixups from full verification pass" --allow-empty-message -m "fixups from lint/typecheck" 2>/dev/null || true +``` + +(Only commit if Steps 2-3 required changes; otherwise skip — there's nothing to commit.) diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/qa-report.md b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/qa-report.md new file mode 100644 index 00000000..b4d594ae --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/qa-report.md @@ -0,0 +1,36 @@ +# QA Gate Report — npm-windows-path-shims + +**Branch**: fix/npm-windows-path-shims +**Runner**: npm (guide-first: `.ai-run/guides/quality-gates.md`) +**Started**: 2026-07-02 +**Status**: BLOCKED (mechanically, per the integration-test gate) — see Drift/Notes below; failures confirmed pre-existing and unrelated to this diff. + +## Gates + +| Gate | Status | Command | Notes | +|---|---|---|---| +| License headers | PASS | `npm run license-check` | No missing/stale headers. | +| Lint | PASS | `npm run lint` | Zero errors/warnings (glob only covers `src/**` + `tests/**` — does not include `scripts/**`; see note below). | +| Typecheck | PASS | `npm run typecheck` | No diagnostics. | +| Build | PASS | `npm run build` | `dist/` rebuilt, plugin assets copied successfully. | +| Unit tests | PASS | `npm run test:unit` (`vitest run src`) | 144 files / 2187 passed / 1 skipped. Scoped to `src/**` only — does not include the new `scripts/__tests__/postinstall.test.ts` (that ran separately under the broader `npm test`/`vitest` config: 27/27 passed, confirmed earlier in this session). | +| Integration tests | **FAIL (pre-existing/flaky, unrelated)** | `npm run test:integration` (`vitest run tests/integration`) | See "Integration test findings" below. | +| Secrets scan | PASS | `npm run validate:secrets` | Clean at every commit throughout this task (Gitleaks, 0 leaks). Final standalone run reported "No staged changes to scan" since nothing is currently staged — not a meaningful re-scan, but every actual commit was scanned clean. | +| Commitlint (range) | PASS | `npm run commitlint:last` | Last commit (`fix(cli): ...`) matches Conventional Commits, 0 problems. | +| Pre-commit aggregate | PASS | `npm run check:pre-commit` (`typecheck && lint`) | Both stages pass. | + +## Integration test findings + +`npm run test:integration` failed inconsistently across repeated runs on this branch: 2 files failed on the first run, 5 on the second, 6 on a third run with **this branch's changes stashed away** (i.e. against effectively `main`). This is pre-existing flakiness, not a regression from this diff — confirmed via an A/B comparison (same command, our changes stashed vs. present, both showed multiple failures, differing only in which specific files failed between runs). + +One failure was consistent across every run: `tests/integration/cli-commands/skills.test.ts > codemie skills (authenticated upstream spawn) > add: forwards source and explicit --agent to upstream argv` — an unrelated assertion about a `--copy` flag being forwarded to an upstream `skills add` command, nothing to do with this change. + +**Separately discovered, more serious issue**: during this investigation, one or more integration tests appear to write directly to the real project files `.codemie/codemie-cli.config.json` and `package.json` instead of an isolated fixture/temp copy. After one `test:integration` run, both files were found reverted to their committed baseline, silently discarding the user's local-only edits (a `sonnet5preview` profile in the config, and a Windows-glob fix to the `lint`/`lint:fix` scripts in `package.json`) that had been made earlier in this session and were never meant to be committed. Both were manually restored from a `git stash` backup taken just before the affected test run. This is a **pre-existing test-isolation defect** (tests mutating real project files rather than fixtures) — unrelated to and not introduced by this task's changes, but worth a follow-up ticket, since it can silently destroy any developer's local, uncommitted project-config edits. + +## Note: scripts/ lint/typecheck coverage gap (pre-existing, out of scope) + +Confirmed during code review: `eslint.config.mjs` has no glob covering `scripts/**` at all (only `tests/**/*.ts`, `src/**/*.test.ts`, `src/**/__tests__/**/*.ts`, `src/**/*.ts`), and `tsconfig.json`'s `include` doesn't cover `scripts/**` either. This means `scripts/postinstall.mjs` and `scripts/__tests__/postinstall.test.ts` are never actually linted or type-checked by `npm run lint` / `npm run typecheck`, despite now being executed by `npm test`. This gap pre-dates this change (it already applied to the original `postinstall.mjs` and to `scripts/license-check.js`/`scripts/validate-secrets.js`) and fixing it is out of this ticket's scope, but is worth flagging for a follow-up. + +## Drift signal + +no — implementation matches the approved spec/plan; no method/type-signature drift detected. diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/spec.md b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/spec.md new file mode 100644 index 00000000..dc04ef36 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/spec.md @@ -0,0 +1,99 @@ +# npm Windows PATH / bin shims fix — design + +**Ticket**: EPMCDME-13191 +**Date**: 2026-07-02 +**Related**: `docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/technical-analysis.md` + +## Problem + +`npm install -g @codemieai/code` on Windows does not put `codemie` on the user's PATH, and Claude Code hooks that shell out to `codemie` fail with `command not found` as a result. `scripts/postinstall.mjs` is Unix-only today: it assumes npm places global shims in `/bin` (true on Unix, false on Windows — npm places them directly in the prefix dir on Windows), splits `PATH` on `:` only, and falls back to appending a shell RC file that doesn't exist on Windows. The curl-based installer (`install/windows/install.ps1`) already solves this correctly for its own isolated install flow, but the plain npm global install path was never fixed to match. + +## Scope decision + +The fix targets **npm's own, unmanaged global prefix** — it does not redirect `npm install -g` into an isolated `%LOCALAPPDATA%\CodeMie` prefix the way `install.ps1` does. `postinstall.mjs` runs after npm has already installed into whatever prefix is active; it only needs to correctly detect that prefix's shim directory and get it onto the Windows user PATH. npm's own auto-shim mechanism (`package.json` `"bin"` field) already generates `codemie.cmd`, `codemie-claude.cmd`, etc. in that directory — this fix does not create or copy shim files itself, only verifies they exist as a diagnostic. + +## Design + +### Reuse, not duplication + +`src/utils/windows-path.ts` (compiled to `dist/utils/windows-path.js`, shipped in the package `files`) already implements registry-backed `isInUserPath(dir)` / `addToUserPath(dir)` with path validation, tested in `src/utils/__tests__/windows-path.test.ts`. `postinstall.mjs` will dynamically import and reuse these rather than re-implementing PATH persistence a third time (a second, independent implementation already exists in `install.ps1` via .NET's `SetEnvironmentVariable`; a fourth is out of scope). This makes `postinstall.mjs` async (top-level, via a `run()` function — `.mjs` supports top-level `await`). + +### Exported functions + +```js +export function getNpmPrefix() // execSync('npm config get prefix'); try/catch -> null on failure +export function getShimDir(prefix, plat = platform()) // win32: prefix itself; else: join(prefix, 'bin') +export function isInPath(dir) // process.env.PATH split on path.delimiter (unix check only) +export function getShellRcFile() // unchanged — unix only, null on win32 (no $SHELL) +export function alreadyInRcFile(rcFile, dir) // unchanged — substring guard against duplicate RC entries +export function getExpectedShimNames() // Object.keys(package.json "bin") — resolved via readFileSync + JSON.parse against a path relative to postinstall.mjs, not import()/require of package.json (avoids ESM JSON-import ceremony) +export function findMissingShims(dir, names) // win32 only: names whose `${name}.cmd` doesn't exist in dir +export async function runWindows() // orchestrates the windows path (below) +export function runUnix() // orchestrates the existing unix path (unchanged logic + delimiter fix) +export async function run() // platform() === 'win32' ? runWindows() : runUnix() +``` + +A direct-execution guard (`fileURLToPath(import.meta.url) === process.argv[1]`) wraps the top-level `run()` call, so importing the module in tests doesn't trigger side effects. This is the "export before test" refactor the ticket requires — today the file has zero exports and runs entirely via top-level statements. + +### Windows flow (`runWindows`) + +1. `prefix = getNpmPrefix()`. If `null`, return (matches today's silent no-op on failure). +2. `dir = getShimDir(prefix)` — the prefix itself, no `bin/` join. +3. `missing = findMissingShims(dir, getExpectedShimNames())`. If non-empty, `console.warn` listing them — diagnostic only, never blocks the rest of the flow. +4. Dynamically `import('../dist/utils/windows-path.js')` and call `isInUserPath(dir)`. +5. If already in PATH, return (no-op, exit 0). +6. Otherwise call `addToUserPath(dir)`. + - `success: true` → `console.log` confirming the addition and that a new terminal is needed. Exit 0. + - `success: false` → `console.error` the returned `error` plus a manual fallback instruction (`setx PATH "%PATH%;"` or System Properties), then exit **1**. This is the one failure mode that's both actionable and would otherwise fail silently for the user (`codemie` staying unreachable with no visible signal) — see "Exit policy" below. +7. All other failure modes in this script (missing npm prefix, missing shim files) stay non-fatal — see "Exit policy". + +### Unix flow (`runUnix`) + +Identical to the current logic (`getNpmPrefix` → `getShimDir` = `join(prefix, 'bin')` → `isInPath` → `getShellRcFile` → `alreadyInRcFile` → `appendFileSync`), with the only change being `isInPath` splitting on `path.delimiter` instead of a hardcoded `':'`. `path.delimiter` is `':'` on POSIX, so this is behaviorally a no-op — it just removes the incorrect hardcoded assumption from the shared function. + +### Error handling + +**Exit policy** — deliberately asymmetric, based on whether the failure is actionable and would otherwise be silent: + +| Failure | Behavior | +|---|---| +| `getNpmPrefix()` fails (npm itself unusable) | `console.warn`, exit 0 — not fixable by retrying this script | +| `findMissingShims()` non-empty | `console.warn`, exit 0 — diagnostic only; shim generation is npm's own responsibility, not this script's | +| `addToUserPath()` returns `success: false` | `console.error` the error + manual `setx` fallback instructions, exit **1** | +| `addToUserPath()` succeeds, or already in PATH | `console.log` confirmation (or silent no-op), exit 0 | + +Only the `addToUserPath` failure exits non-zero. It's the one case that's both actionable (the user can fix it, e.g. run the `setx` command themselves or check registry permissions) and otherwise invisible — `codemie` would silently stay unreachable with no forcing signal, since a `console.warn` buried in npm's install output is easy to miss. The other failure modes either aren't fixable by re-running this script (npm itself broken) or are purely diagnostic (missing shims are npm's `bin`-field responsibility, not this script's to fix). Failing `npm install -g` over those would be misleading — it would suggest the install failed when the package installed fine. +- No new error classes needed — this is a lifecycle script, not application code; it follows its own existing console-message convention rather than the `src/` error-class convention. + +### Testing + +New `scripts/__tests__/postinstall.test.ts`. Conventions follow `.ai-run/guides/testing/testing-patterns.md` and the closest existing precedents (`src/utils/__tests__/windows-path.test.ts`, `cli-bin.test.ts`): + +- Mock `node:child_process` (`execSync`) directly via `vi.mock('node:child_process', ...)` — new territory for this repo (existing precedents mock through the `src/utils/exec.ts` wrapper instead, which `postinstall.mjs` does not use for `npm config get prefix`). +- Mock the dynamic `../dist/utils/windows-path.js` import via `vi.mock` factory, so `runWindows()` can be unit-tested without touching the real Windows registry. +- Toggle `process.platform` via `Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })` per the `windows-path.test.ts` precedent; restore in `afterEach` with `vi.restoreAllMocks()`. +- Dynamic `import()` of the module under test after mock/spy setup (static imports bypass spies due to hoisting — mandatory per the testing guide). + +Test cases: + +| Case | Mocks | +|---|---| +| Windows: `getShimDir()` returns prefix directly (no `/bin` suffix) | `execSync`, `platform` | +| Unix: `getShimDir()` returns `prefix/bin` | `execSync`, `platform` | +| PATH check splits on `path.delimiter` (`;` win32 / `:` unix) | `process.env.PATH` | +| Windows: `runWindows` never touches RC-file logic | `platform` | +| Unix: `getShellRcFile` selects `.zshrc` / `.bash_profile` / `.bashrc` correctly | `process.env.SHELL`, `existsSync` | +| Already in user PATH (win32) → no-op, `addToUserPath` never called | mocked `isInUserPath` → `true` | +| Not in PATH (win32) → `addToUserPath` invoked, success path logs confirmation | mocked `isInUserPath` → `false`, `addToUserPath` → `{success:true}` | +| `addToUserPath` failure (win32) → manual fallback instructions printed, exits 1 | mocked `addToUserPath` → `{success:false, error}`, `process.exit` | +| Not in PATH (unix) → RC file is appended | `existsSync`, `appendFileSync` | +| Missing shim file (win32) → warning logged, PATH logic still runs | `existsSync` returns false for one shim name | + +`vitest.config.ts`: add `'scripts/**/*.test.ts'` to `test.include`. No coverage-`exclude` change — `postinstall.mjs` picking up coverage from its own new test is desirable, not a gap. + +## Out of scope + +- Redirecting `npm install -g` into an isolated `%LOCALAPPDATA%\CodeMie` prefix (that remains the curl installer's job). +- The hook executor's hardcoded `spawn("sh", ...)` in `src/agents/plugins/codemie-code-hooks/shell-hooks-source.ts` (a related but distinct Windows gap — no `sh` binary on Windows regardless of PATH state). Flagged as a candidate follow-up ticket, not addressed here. +- `install/windows/install.cmd`'s known quoted-path-with-spaces bug (unrelated, pre-existing). +- Any change to `install/windows/install.ps1`, `install/macos/install.sh`, or `src/utils/native-installer.ts`. diff --git a/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/technical-analysis.md b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/technical-analysis.md new file mode 100644 index 00000000..0012f209 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/technical-analysis.md @@ -0,0 +1,208 @@ +# Technical Research + +**Task**: npm postinstall windows path bin shims installer +**Generated**: 2026-07-02 +**Research path**: filesystem (codegraph MCP tool not available in this environment) + +--- + +## 1. Original Context + +EPMCDME-13191 — "Windows npm global install does not configure CodeMie PATH or bin shims" + +Summary: `npm install -g @codemieai/code` on Windows does not configure the CodeMie executable path and does not create/populate the expected CodeMie bin folder with command shims such as `codemie.cmd` and `codemie-claude.cmd`. The Windows curl installer does create `C:\Users\\AppData\Local\CodeMie\bin`, copies the command shim files there, and adds this folder to the user PATH. + +Description: The Windows installation behavior is inconsistent between the npm global install flow and the curl-based Windows installer. +- `npm install -g @codemieai/code` does not add a PATH entry for CodeMie on Windows. +- The npm flow does not create the CodeMie `bin` folder under the expected local CodeMie installation path. +- The npm flow does not copy command shim files such as `codemie.cmd`, `codemie-claude.cmd`, and related executable wrappers into the expected CodeMie bin folder. +- The curl-based installer correctly creates `C:\Users\\AppData\Local\CodeMie\bin`, copies shim files, and adds it to PATH. + +Root causes suspected (from ticket investigation): +- The npm prefix/bin directory logic in the postinstall script is Unix-oriented and incorrect for Windows. On Windows, npm global command shims are placed directly in the npm prefix directory, not in a nested `bin` directory. +- PATH parsing uses the Unix separator `:` instead of the Windows separator `;`. +- Windows does not use shell RC files such as `.bashrc`/`.zshrc`; the postinstall script exits without applying a persistent PATH update on Windows. +- Claude Code executes hooks defined in `~/.codemie/claude-plugin/hooks/hooks.json` via non-interactive, non-login bash (`/usr/bin/bash -c "codemie hook"`). This shell does not source `~/.bash_profile`, so PATH entries added there are not inherited — hooks fail with `codemie: command not found` even after the curl installer, and more fundamentally for npm-installed users since codemie isn't on any PATH at all. + +Acceptance criteria (from ticket): +- Windows-specific handling implemented for `npm install -g @codemieai/code`. +- Correctly determine the Windows command directory (no assumption of a Unix-style nested `bin` dir). +- PATH separator checks use `;` on Windows, keep `:` on Linux/macOS. +- Apply a persistent user PATH update on Windows using an appropriate Windows mechanism (e.g. `setx` or PowerShell/registry-based approach), not just an RC file append. +- Required shim files (`codemie.cmd`, `codemie-claude.cmd`, etc.) created/copied to the expected command directory. +- Behavior verified against the existing curl-based Windows installer flow (`install/windows/install.cmd`). +- No regression to Linux/macOS npm installation behavior. +- Claude Code hooks must be able to resolve `codemie` from a non-interactive, non-login bash subprocess after install — this requires the CodeMie bin directory to be in the actual Windows user PATH env var (not only in `~/.bash_profile`), or hooks must reference the binary by absolute path. +- Testing requirement per ticket: unit tests (Vitest) co-located at `scripts/__tests__/postinstall.test.ts`; ticket notes `postinstall.mjs` must export its helper functions before tests can be written (currently a flat script with no exports). + +--- + +## 2. Codebase Findings + +### Existing Implementations + +- `scripts/postinstall.mjs` (53 lines, plain ESM, wired via `package.json` `"postinstall": "node scripts/postinstall.mjs"`) — **Unix-only, no `process.platform` branch at all**. Contains four module-private, unexported top-level functions plus top-level executable statements: + - `getNpmBinDir()` — always computes `join(npmPrefix, 'bin')`. This is the primary root cause: on Windows, `npm install -g` places global shims directly in the prefix directory, not in a nested `bin/` subfolder, so this path is simply wrong on Windows. + - `isInPath(dir)` — splits `process.env.PATH` on `':'` only. Wrong separator on Windows (needs `';'` / `path.delimiter`). + - `getShellRcFile()` — picks `.zshrc` (if `$SHELL` contains `zsh`), else `.bash_profile` (if it exists), else `.bashrc` (if `$SHELL` contains `bash`); returns `null` if none match. On Windows there is no `$SHELL` env var, so this always returns `null`, and the script falls through to printing a manual "add this to your PATH" console message — no persistence path exists at all for npm-installed Windows users. + - `alreadyInRcFile(rcFile, dir)` — substring guard to avoid duplicate `export PATH=` appends. + - None of these functions are exported; the script runs entirely for side effects. This confirms the ticket's claim and blocks any unit testing until refactored to export the helpers. + - The script imports nothing from `src/` or `dist/` — it does not reuse the existing compiled Windows PATH utility described below, which is itself a gap. + +- `package.json` — `"bin"` field maps ~12 command names (`codemie`, `codemie-code`, `codemie-claude`, `codemie-claude-acp`, `codemie-gemini`, `codemie-opencode`, `codemie-mcp-proxy`, etc.) to files under `bin/`. This is what npm's own auto-shim mechanism uses to generate `.cmd`/`.ps1`/no-extension shims at global-install time — postinstall.mjs does not create shim files itself, it only tries (and fails, on Windows) to add a bin **directory** to PATH. + +- `bin/codemie.js`, `bin/codemie-claude.js`, etc. — plain Node ESM entry points (`#!/usr/bin/env node`) that import compiled `dist/**` output (e.g. `bin/codemie.js` → `dist/cli/index.js`, `dist/migrations/index.js`, `dist/utils/cli-updater.js`; `bin/codemie-claude.js` → `dist/agents/core/AgentCLI.js`, `dist/agents/registry.js`). No `.cmd`/`.ps1` files are checked into `bin/` as static assets — everything Windows-shim-shaped is generated at install time either by npm itself or by `install.ps1` (see below). + +- `install/windows/install.cmd` — thin bootstrapper only: downloads `install.ps1` from GitHub raw via `curl -fsSL` into `%TEMP%\codemie-install.ps1`, then runs `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ...`. No PATH/bin logic of its own. Known limitation noted in `install/README.md`: forwards args to PowerShell via `%*`, which breaks on quoted paths containing spaces. + +- `install/windows/install.ps1` — the actual Windows "portable" installer logic: + - Bin/prefix dir creation: `$InstallRoot = Join-Path $env:LOCALAPPDATA 'CodeMie'`; `$BinDir = Join-Path $InstallRoot 'bin'`; `$PrefixDir = Join-Path $InstallRoot 'npm-prefix'`; `New-Item -ItemType Directory -Force -Path $BinDir, $PrefixDir`. + - Isolates the npm global prefix: `npm config set prefix $PrefixDir --location user` (portable mode), then `npm install -g $PackageSpec --registry $RegistryUrl`. `install/README.md` notes the installer calls `npm.cmd` directly to avoid PowerShell resolving `npm` to `npm.ps1`. + - Shim generation (hand-authored, not npm's auto-shims): for each command in the commands list, writes a `.cmd` file to `$BinDir\.cmd` that `call`s through to the real npm-generated shim at `$PrefixDir\.cmd` (falling back to `$PrefixDir\node_modules\.bin\.cmd`) via `$shim | Set-Content -Path $shimPath -Encoding ASCII`. This confirms shims in `$BinDir` are wrapper-of-a-wrapper, layered on top of npm's own shim mechanism, not a replacement for it. + - PATH persistence (`Add-UserPath $BinDir` function, lines ~96-118): reads `[Environment]::GetEnvironmentVariable('Path','User')`, splits on `;`, checks membership case-insensitively (`-icontains`) to avoid duplicate entries, appends, writes back via `[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')`. Note: this uses the .NET environment API (which itself ultimately writes `HKCU\Environment`), **not** `setx.exe` directly and **not** `reg add` directly. + +- `src/utils/windows-path.ts` (compiled to `dist/utils/windows-path.js`, so importable from `scripts/postinstall.mjs` since `dist` ships in the package `files`) — an **existing, already-exported** Windows PATH utility with: + - `findCommandDirectory`, `isInUserPath`, `addToUserPath`, `ensureCommandInPath`. + - Uses `reg.exe query HKCU\Environment /v PATH` and `setx.exe PATH ` (a second, independent implementation of the same PATH-persistence idea as `install.ps1`'s `.NET` approach — the two Windows PATH mechanisms in this repo are not unified). + - Includes a `validateDirectoryPath` guard that rejects shell metacharacters and requires an absolute path before touching the registry/PATH — a security pattern with no equivalent in `postinstall.mjs` today. + - Consumed today by `src/utils/native-installer.ts` (`ensureCommandInPath`, called post-install with retry/verify backoff on Windows) — this is the intended integration pattern a Windows-aware `postinstall.mjs` should likely reuse rather than reinventing PATH logic a third time. + +- `src/utils/cli-bin.ts` — Unix-only symlink-repair utility that explicitly early-returns on `win32` with the comment that "npm global layout differs (no bin/ subdirectory)" — independent corroboration, from inside this repo's own code, of the root cause described in the ticket. + +- `src/utils/paths.ts` — `getCodemieHome()`/`getCodemiePath()` resolve `CODEMIE_HOME` env var or default to `~/.codemie` on all platforms; no win32 special-case for `%LOCALAPPDATA%`. This is a distinct, unrelated path convention from the installer's own `%LOCALAPPDATA%\CodeMie` layout — the two should not be confused when implementing the fix. + +- `install/macos/install.sh` — for contrast: `auto` mode picks `npm-global` (if writable) or falls back to a user-prefix install (`$HOME/.codemie/npm-prefix`); it does **not** append to any RC file itself, only prints a manual "add to PATH" instruction and does dedup detection via `case ":$PATH:" in *":$USER_PREFIX/bin:"*)`. This is a "detect writability, fall back to user-local" precedent, but not itself a PATH-persistence precedent (unlike `postinstall.mjs`, which does append to RC files on Unix). + +- `install/README.md` — states the GUI installer wizard (a separate distribution, `CodeMie Connect_2.0.1_x64-setup.exe`) explicitly adds `%USERPROFILE%\AppData\Local\CodeMie\npm-prefix` and `%USERPROFILE%\AppData\Roaming\npm` to the current user's PATH — a third precedent for "what Windows PATH entries CodeMie needs," slightly different in scope from `install.ps1`'s `$BinDir`. + +### Architecture and Layers Affected + +- **npm lifecycle layer** — `package.json` `scripts.postinstall` → `scripts/postinstall.mjs`. This is plain, untranspiled ESM (`.mjs`) that runs outside the TypeScript build/`src/` tree and outside Vitest's current `test.include` glob. +- **npm auto-shim layer** — `package.json` `"bin"` field → npm-generated `.cmd`/no-extension/`.ps1` shims pointing at `bin/*.js`. This layer is npm's own responsibility and is not modified directly; the fix must correctly *locate* what npm already created, not recreate it. +- **Compiled TS utility layer** — `src/utils/windows-path.ts` / `src/utils/native-installer.ts` / `src/utils/paths.ts` → compiled to `dist/utils/*.js`. Importable by `postinstall.mjs` today (dist ships in package `files`) but currently unused by it. +- **Standalone curl-installer layer** — `install/windows/install.cmd` + `install/windows/install.ps1`, and `install/macos/install.sh` — a completely separate, hand-rolled "portable" install path independent of npm's bin-shim mechanism, with its own PATH-persistence implementation (.NET `SetEnvironmentVariable`) distinct from `src/utils/windows-path.ts`'s (`setx.exe`/`reg.exe`). +- **Hook execution layer** — Claude Code plugin hooks (`src/agents/plugins/claude/plugin/hooks/hooks.json`, `src/agents/plugins/gemini/extension/hooks/hooks.json`) invoke bare command strings like `"codemie hook"`. Inside this repo's own hook executor (`src/agents/plugins/codemie-code-hooks/shell-hooks-source.ts`), sync execution uses `execSync(command, {...})` (default shell, OS-dependent) and async execution explicitly uses `spawn("sh", ["-c", command], {...})` — hardcoded to POSIX `sh`, non-login/non-interactive. This is a latent Windows gap independent of the PATH bug (no `sh` binary on Windows) and should be flagged, though the ticket's primary hook-resolution failure mode (Unix non-login bash not sourcing RC files) is corroborated by `postinstall.mjs` writing only to RC files on Unix and nothing persistent at all on Windows. + +### Integration Points + +- `scripts/postinstall.mjs` currently has **no internal dependencies** — it does not import `src/utils/paths.ts`, `src/utils/windows-path.ts`, or `src/utils/exec.ts`. This is itself a design gap: `dist/utils/windows-path.js` already implements Windows PATH detection/mutation with security validation and could be reused instead of writing new logic from scratch. +- `src/utils/native-installer.ts` → `src/utils/windows-path.ts` (`ensureCommandInPath`) — existing integration pattern to mirror. +- `src/utils/windows-path.ts` → `src/utils/exec.ts`, `src/utils/logger.ts`, `src/utils/security.ts` (`sanitizeLogArgs`) — dependencies a reused/imported version of this logic would pull into `postinstall.mjs`, which currently has zero dependencies of its own. +- `install/windows/install.cmd` → `install/windows/install.ps1` (download + invoke); no shared code with `src/` or `scripts/` — the curl installer and the npm postinstall flow are today entirely independent implementations of overlapping intent, which is the structural source of the inconsistency described in the ticket. +- `bin/*.js` → `dist/**` (compiled output) — the shim source files npm uses for its own auto-generation. +- External processes invoked: `npm.cmd`/`node.exe` (by `install.ps1`), `reg.exe`/`setx.exe` (by `windows-path.ts`), shell RC files (by `postinstall.mjs` on Unix only). + +### Patterns and Conventions + +- Repo-wide convention for Windows branching: `process.platform === 'win32'` / `os.platform() === 'win32'` (used in `src/utils/exec.ts`, `src/utils/processes.ts`, `src/utils/cli-bin.ts`, `src/utils/windows-path.ts`). `postinstall.mjs` follows none of this — it has no platform branch at all, unconditionally executing Unix-only logic. +- PATH separator convention: literal `';'` in Windows-specific code (`install.ps1`, `windows-path.ts`); literal `':'` hardcoded in `postinstall.mjs` — no code path anywhere uses Node's own `path.delimiter`, which would auto-resolve to the correct separator per platform and is the idiomatic fix. +- Windows PATH persistence has **two independent existing implementations** in this repo already: `.NET Environment.SetEnvironmentVariable` (in `install.ps1`) and `reg.exe`/`setx.exe` (in `src/utils/windows-path.ts`). Any new logic in `postinstall.mjs` should reuse the latter (it's already TS, tested, and exported) rather than introducing a third variant. +- Security pattern: `validateDirectoryPath` in `windows-path.ts` rejects shell metacharacters and enforces absolute paths before any registry/PATH mutation — should be preserved if this logic is reused or ported. +- Testing convention (from `.ai-run/guides/testing/testing-patterns.md` and existing tests): dynamic `import()` of the module under test *after* `vi.mock`/`vi.spyOn` setup (static imports bypass spies due to hoisting); `vi.restoreAllMocks()` in `afterEach`; `Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })` to simulate Windows without mocking `os` (see `src/utils/__tests__/windows-path.test.ts`, the closest existing precedent). +- No existing repo test mocks raw `node:child_process` `execSync` directly — all existing precedents mock through an internal wrapper (`src/utils/exec.ts`). `postinstall.mjs` as currently written does not use `child_process` at all (only `fs`, `os`, `path`); if the fix introduces `setx`/`reg` calls directly in `postinstall.mjs` rather than importing `windows-path.ts`, this would be genuinely new test-mocking territory for the repo. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- `.ai-run/guides/development/development-practices.md` — relevant process-execution guidance: prefer `exec()` from `src/utils/exec.ts` for process spawning over raw `child_process`; avoid `execSync` in async contexts; and the mandatory "dynamic import after spy setup" testing pattern. +- `.ai-run/guides/testing/testing-patterns.md` — directly relevant Vitest conventions: dynamic `import()` after `vi.mock`/`vi.spyOn`, `vi.restoreAllMocks()` in `afterEach`, asserting both error class and error code for async failures, co-located `__tests__/*.test.ts` naming, coverage targets (80%+ overall, 90%+ for `src/utils/` and core logic). +- `.ai-run/guides/usage/project-config.md` and `.ai-run/guides/integration/external-integrations.md` — read but not directly relevant to this domain; the latter notes a Windows XDG-style path precedent (`%LOCALAPPDATA%\opencode\storage\`) unrelated to bin shims/PATH. +- `install/README.md` — documents the curl-based bootstrap installers and a separate GUI wizard; states the Windows installer "calls `npm.cmd` directly to avoid PowerShell resolving `npm` to `npm.ps1`" and that the GUI wizard adds `%USERPROFILE%\AppData\Local\CodeMie\npm-prefix` and `%USERPROFILE%\AppData\Roaming\npm` to user PATH. +- `scripts/README.md` — documents `release.sh` and `test-proxy-endpoint.js` only; no mention of `postinstall.mjs` and no documented relationship between the npm postinstall script and the curl-based installers — the two flows have evolved independently with no cross-reference in docs. + +### Architectural Decisions + +- No ADRs or explicit "DECISION:" records found anywhere in guides, `install/`, `scripts/`, or `docs/`. +- No repo-root `CHANGELOG.md` exists. +- No `NOTE:`/`HACK:`/`TODO:`/`FIXME:` markers found in `scripts/postinstall.mjs`, `install/windows/*`, or `install/macos/*` — the Unix-only limitation of `postinstall.mjs` is undocumented, not a known/flagged gap in the code itself. +- An SDLC workflow tracking artifact already exists at `docs/superpowers/tasks/2026-07-02-npm-windows-path-shims/.state.json` (branch `fix/npm-windows-path-shims`, flow `sdlc-standard`) but contains no plan/spec/decisions yet. + +### Derived Conventions + +- No guide specifically covers postinstall scripts or Windows PATH manipulation; conventions are derived from `development-practices.md`/`testing-patterns.md` general rules plus the behavior documented in `install/README.md`. +- Windows-specific conventions observed in code: call `npm.cmd` not `npm` from PowerShell; target per-user `%LOCALAPPDATA%` installs (no admin elevation); use `;`-delimited PATH parsing; persist via `.NET SetEnvironmentVariable` or `reg.exe`/`setx.exe`, never RC files (none exist on Windows). +- A refactored `postinstall.mjs` will need a decision on whether to import compiled `dist/` utilities (`getCodemiePath`, `windows-path.js` helpers, error classes) or remain dependency-free as a plain `.mjs` script — currently it has zero internal imports. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `scripts/postinstall.mjs` has **zero test coverage today** and **zero exports** — confirmed by direct read of the file (53 lines, no `export` statement) and by `Glob scripts/__tests__/**` returning no results. +- `scripts/__tests__/postinstall.test.ts` (the file the ticket requires) does **not** exist yet. +- Only one existing test touches anything under `scripts/`: `tests/scripts/test-proxy-endpoint.test.ts`, which tests `scripts/test-proxy-endpoint.js` via subprocess execution (`execFile(process.execPath, [scriptPath, ...])`) against a real local HTTP server — no fs/os/child_process mocking, since that script doesn't touch them. This is a precedent for "testing a file under scripts/" but not for the mocking style postinstall will need. +- `scripts/license-check.js` and `scripts/validate-secrets.js` also have zero test coverage. +- `src/utils/__tests__/windows-path.test.ts` already exists and is the closest precedent for testing Windows-specific PATH/registry branching logic (see below). + +### Testing Framework and Patterns + +- Vitest `^4.1.5` (`@vitest/ui ^4.1.5`), TypeScript `^5.3.3`. Run via `npm test` / `npm run test:unit` (scoped to `vitest run src`) / `npm run test:integration` (`vitest run tests/integration`) / `npm run test:coverage`. +- **Important gap**: `vitest.config.ts` `test.include` is `['src/**/*.test.ts', 'src/**/*.spec.ts', 'tests/**/*.test.ts']` — it does **not** include `scripts/**/*.test.ts`. A new `scripts/__tests__/postinstall.test.ts` file would not be picked up by `npm test`/`npm run test:unit` unless the include glob is updated (or the test file is placed/run differently). This must be addressed as part of the implementation, not just the test file itself. +- `vitest.config.ts` coverage excludes `bin/`, `tests/`, `dist/`, `node_modules/` but does not currently exclude `scripts/` — no explicit policy either way for scripts/ coverage. +- Closest relevant mocking precedents: + - `src/utils/__tests__/windows-path.test.ts` — does not mock `os`/`child_process` directly; spies on an internal `exec` wrapper (`vi.spyOn(execModule, 'exec')`) and toggles `process.platform` via `Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })` in `beforeEach`, restoring in `afterEach`. This is the most relevant existing pattern for postinstall's platform-conditional PATH logic. + - `src/utils/__tests__/cli-bin.test.ts` — mocks `fs/promises` (default export shape) and `os` via `vi.importActual` + override of `default.platform`, including a test explicitly titled "should skip on Windows platform." + - `src/cli/commands/assistants/setup/generators/__tests__/claude-agent-generator.test.ts` — factory-style `vi.mock('node:fs', () => ({...}))` and `vi.mock('node:os', () => ({ default: {...} }))` declared before imports, then dynamic import of both mocks and SUT. + - `src/agents/plugins/claude/__tests__/plugin-installer.test.ts` and `src/agents/plugins/kimi/__tests__/kimi.extension-installer.test.ts` — bare `vi.mock('fs/promises')` auto-mocks combined with dynamic `await import()`, and `vi.importActual` to preserve real module behavior while overriding a single function. +- **No existing repo test mocks raw `node:child_process` `execSync` directly** — every existing precedent mocks through the internal `src/utils/exec.ts` wrapper instead. `postinstall.mjs` today does not use `child_process` at all. If the Windows fix calls `setx`/`reg` directly (rather than importing `windows-path.ts`, which already goes through the `exec.ts` wrapper), this will be genuinely new test-mocking territory for the repo. + +### Coverage Gaps + +- `scripts/postinstall.mjs` — no tests, no exports (blocking issue called out explicitly by the ticket). +- Windows PATH/bin-dir detection logic inside `postinstall.mjs` specifically — completely untested today because it doesn't exist (the script has no platform branch). +- `vitest.config.ts` include glob does not cover `scripts/` — a structural gap that must be fixed alongside adding the new test file, or the new tests will silently not run under `npm test`. +- No precedent for mocking raw `child_process.execSync`/`setx`/`reg` invocations in this repo. + +--- + +## 5. Configuration and Environment + +### Environment Variables + +- `CODEMIE_HOME` — overrides `~/.codemie` home dir (`src/utils/paths.ts`); no Windows-specific override (same path convention on all platforms, distinct from the installer's `%LOCALAPPDATA%\CodeMie`). +- `CODEMIE_INSTALL_URL` — base URL the `.cmd` bootstrapper downloads `install.ps1` from. +- `CODEMIE_REGISTRY_URL` / `CODEMIE_SCOPE_REGISTRY_URL` / `CODEMIE_INSTALL_MODE` / `CODEMIE_NPM_PREFIX` / `CODEMIE_PACKAGE_VERSION` — consumed by `install/macos/install.sh` for registry/prefix overrides (no Windows equivalent found for these in `install.ps1`, worth checking during implementation for parity). +- `SHELL` — read by `postinstall.mjs`'s `getShellRcFile()` to choose `.zshrc` vs `.bash_profile`/`.bashrc`; not present/applicable on Windows, which is precisely why `getShellRcFile()` returns `null` there today and the script falls back to a manual console instruction with no persistence. + +### Configuration Files + +- `install/windows/install.ps1` — governs bin-dir creation (`%LOCALAPPDATA%\CodeMie\bin`), npm prefix isolation (`%LOCALAPPDATA%\CodeMie\npm-prefix`), shim generation, and PATH update for the curl-based Windows flow. +- `install/windows/install.cmd` — thin bootstrapper/downloader for `install.ps1`; no config logic of its own. +- `install/macos/install.sh` — governs npm-global vs user-prefix fallback and PATH instruction messaging for macOS/Linux curl flow. +- `scripts/postinstall.mjs` — governs npm-lifecycle PATH/bin handling; the file under repair for this ticket. +- `package.json` — `"bin"` field is the source of truth npm uses for auto-shim generation on all platforms; `"postinstall"` script wires in `postinstall.mjs`. + +### Feature Flags and Deployment Concerns + +- No application feature flags found in this domain — only CLI parameters on `install.ps1` (`-DryRun`, `-Mode portable|npm-global`). +- Windows curl installer's PATH-update mechanism (`Add-UserPath` in `install.ps1`) uses `[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')` after reading and deduping against the current user PATH — not `setx.exe` directly, contrary to a literal reading of the ticket's "e.g. setx" suggestion; `src/utils/windows-path.ts` is the component in this repo that actually uses `setx.exe`/`reg.exe`. Any implementation choice should note this distinction and ideally standardize on reusing `windows-path.ts` rather than introducing a third mechanism. +- Hook invocation shell context: `hooks.json` manifests contain bare command strings (`"codemie hook"`) with no shell wrapper specified in the manifest itself; this repo's own hook executor (`src/agents/plugins/codemie-code-hooks/shell-hooks-source.ts`) hardcodes `spawn("sh", ["-c", command], {...})` for async execution — non-login, non-interactive, and POSIX-only (no `sh` on Windows). This is a related but distinct latent gap from the PATH bug and should be flagged separately; fixing the PATH issue (getting `codemie` onto the actual persisted Windows user PATH) is necessary but the hook executor's `sh`-only invocation is a second failure mode worth a follow-up ticket if hooks are expected to work cross-platform. +- No CI/CD or Docker files reference this specific postinstall/PATH concern; the fix is scoped to `scripts/postinstall.mjs` plus (optionally) reuse of `src/utils/windows-path.ts`. + +--- + +## 6. Risk Indicators + +- **Zero test coverage today** for `scripts/postinstall.mjs`, and it has zero exports — any fix must include a refactor to export helper functions before tests can be written at all, exactly as the ticket states. +- **`vitest.config.ts` test.include does not cover `scripts/**`** — a new `scripts/__tests__/postinstall.test.ts` will not run under `npm test`/`npm run test:unit` unless the config is updated; this is easy to miss and would produce a false "tests pass" signal if overlooked. +- **Two independent, unreconciled Windows PATH-persistence implementations already exist** in this repo (`install.ps1`'s `.NET SetEnvironmentVariable` vs `src/utils/windows-path.ts`'s `setx.exe`/`reg.exe`). A third, ad-hoc implementation inside `postinstall.mjs` would compound inconsistency; reusing `windows-path.ts` is strongly preferable but requires `postinstall.mjs` (a plain `.mjs` script with zero imports today) to depend on compiled `dist/` output, which is a design decision, not a pure bug fix. +- **No existing precedent for mocking raw `node:child_process.execSync`** for `setx`/`reg` calls — if the fix does not reuse `windows-path.ts` (which already goes through the tested `exec.ts` wrapper), new test-mocking patterns will need to be established from scratch, increasing implementation and review effort. +- **Two conflicting descriptions of "the" Windows bin directory** exist across sources: `install.ps1`'s `$BinDir` (`%LOCALAPPDATA%\CodeMie\bin`, hand-authored wrapper shims), the GUI installer's PATH additions (`%LOCALAPPDATA%\CodeMie\npm-prefix` and `%APPDATA%\Roaming\npm`), and npm's own default global prefix (unmanaged, platform-default location when `npm install -g` is run directly without any portable-prefix override). The ticket's acceptance criteria imply parity with `install.ps1`'s `$BinDir` behavior specifically, but the plain `npm install -g` flow (no portable prefix override) will by default install into npm's OS-default global prefix, not `%LOCALAPPDATA%\CodeMie`. This ambiguity needs explicit resolution before implementation: should plain `npm install -g` mimic the portable/isolated-prefix pattern, or just fix PATH/bin-dir detection for whatever prefix npm is already using? +- **Hook shell-invocation gap is real but partially distinct from the PATH bug**: `shell-hooks-source.ts` hardcodes `spawn("sh", ...)`, which has no Windows equivalent regardless of PATH state. Fixing PATH alone will not make hooks work on Windows if this codepath is reached; scope of this ticket vs. a follow-up should be clarified. +- **Undocumented, unflagged limitation**: the Unix-only nature of `postinstall.mjs` carries no `TODO`/`NOTE` in-code and no mention in `scripts/README.md` — there is no existing internal signal of this gap being previously known, meaning the fix is greenfield within this file (low risk of conflicting in-flight work, but also no prior design discussion to draw from). +- **`install/windows/install.cmd` known bug** (documented in `install/README.md`): breaks on quoted paths with spaces when forwarding `%*` to PowerShell — not in scope for this ticket but adjacent, and could confuse "verify against install.cmd" acceptance-criteria testing if hit incidentally. +- **No `.ai-run/guides/` entry specific to postinstall/PATH/installer scripts** — conventions must be derived from general development-practices/testing-patterns guides plus direct code reading, increasing reliance on this analysis rather than a curated guide. + +--- + +## 7. Summary for Complexity Assessment + +This task touches a small, well-bounded surface area at the code level — primarily `scripts/postinstall.mjs` (a single ~53-line file) plus, if the recommended reuse path is taken, an integration point into the already-exported `src/utils/windows-path.ts` (and transitively `src/utils/exec.ts`, `src/utils/logger.ts`, `src/utils/security.ts`). A minimal test-configuration change to `vitest.config.ts` (`test.include`) is also required, and a new test file `scripts/__tests__/postinstall.test.ts` must be created from scratch since no precedent exists for testing a `scripts/` file with fs/os/child_process mocking. Realistic file-change count is therefore small (2-4 files: `postinstall.mjs`, `vitest.config.ts`, the new test file, and possibly a small addition to `windows-path.ts` if new helpers are needed there), but the ticket's acceptance criteria also raise an open design question — whether the npm postinstall flow should mimic the curl installer's isolated-prefix/wrapper-shim pattern (`%LOCALAPPDATA%\CodeMie\bin` with hand-authored `.cmd` wrappers) or simply fix PATH/bin-dir detection for npm's actual, unmanaged global prefix on Windows — that should be resolved before implementation sizing is finalized, since the two approaches differ meaningfully in scope. + +Technical novelty is moderate: the repo already has two independent precedents for Windows PATH persistence (`install.ps1`'s .NET API approach and `windows-path.ts`'s `setx`/`reg.exe` approach) and an established `process.platform === 'win32'` branching convention used consistently elsewhere — so the fix is not introducing a wholly new pattern, but it is introducing platform-branching into a file that currently has none, and the ticket explicitly requires an export-based refactor of a previously side-effect-only script, which is a meaningful (if small) architectural change to that one file's shape and its relationship to the compiled `dist/` tree. + +Test coverage posture is the single biggest risk factor: `scripts/postinstall.mjs` has zero tests today, `vitest.config.ts` does not even include `scripts/**` in its test glob, and no existing test in the repo mocks raw `node:child_process.execSync` (the closest precedents mock through the `exec.ts` wrapper instead). Combined with the ambiguity noted above about which installer behavior npm-install should mirror, and the adjacent-but-distinct hook-invocation gap (`spawn("sh", ...)` with no Windows equivalent) that acceptance criteria reference but may be out of this ticket's direct scope, this should be scored as low-to-moderate code complexity but moderate process/testing complexity, with the config-glob gap and design-scope ambiguity flagged as pre-implementation blockers to resolve rather than in-flight surprises. diff --git a/scripts/__tests__/postinstall.test.ts b/scripts/__tests__/postinstall.test.ts new file mode 100644 index 00000000..cfb43a0e --- /dev/null +++ b/scripts/__tests__/postinstall.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync, appendFileSync } from 'node:fs'; +import { isInUserPath, addToUserPath } from '../../dist/utils/windows-path.js'; + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + appendFileSync: vi.fn(), +})); + +vi.mock('../../dist/utils/windows-path.js', () => ({ + isInUserPath: vi.fn(), + addToUserPath: vi.fn(), +})); + +describe('postinstall', () => { + const originalPlatform = process.platform; + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.mocked(execSync).mockReset(); + vi.mocked(isInUserPath).mockClear(); + vi.mocked(addToUserPath).mockClear(); + vi.mocked(appendFileSync).mockClear(); + vi.mocked(existsSync).mockReset(); + // Default to a valid package.json shape so any test that doesn't care about + // getExpectedShimNames() specifically (e.g. runWindows/run tests focused on + // PATH persistence) doesn't trip JSON.parse on a leftover rc-file-shaped + // mock value from an earlier test. Tests that need a different return + // value (rc-file contents, custom bin fields) override it in their own body. + vi.mocked(readFileSync).mockReset(); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ bin: { codemie: './bin/codemie.js' } })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + process.exitCode = originalExitCode; + }); + + describe('getNpmPrefix', () => { + it('returns the trimmed npm prefix on success', async () => { + vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); + + const { getNpmPrefix } = await import('../postinstall.mjs'); + const result = getNpmPrefix(); + + expect(result).toBe('C:\\Users\\Test\\AppData\\Roaming\\npm'); + expect(execSync).toHaveBeenCalledWith( + 'npm config get prefix', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] } + ); + }); + + it('returns null if npm is unavailable', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('command not found'); + }); + + const { getNpmPrefix } = await import('../postinstall.mjs'); + const result = getNpmPrefix(); + + expect(result).toBeNull(); + }); + }); + + describe('getShimDir', () => { + it('returns the prefix directly on win32 (no bin/ join)', async () => { + const { getShimDir } = await import('../postinstall.mjs'); + const result = getShimDir('C:\\Users\\Test\\AppData\\Roaming\\npm', 'win32'); + + expect(result).toBe('C:\\Users\\Test\\AppData\\Roaming\\npm'); + }); + + it('returns prefix/bin on non-win32 platforms', async () => { + const { getShimDir } = await import('../postinstall.mjs'); + const result = getShimDir('/usr/local', 'darwin'); + + expect(result).toBe('/usr/local/bin'); + }); + }); + + describe('isInPath', () => { + const originalPath = process.env.PATH; + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it('detects a directory using ";" on win32', async () => { + process.env.PATH = 'C:\\Windows;C:\\Users\\Test\\AppData\\Roaming\\npm;C:\\Windows\\System32'; + + const { isInPath } = await import('../postinstall.mjs'); + expect(isInPath('C:\\Users\\Test\\AppData\\Roaming\\npm', ';')).toBe(true); + expect(isInPath('C:\\Nonexistent', ';')).toBe(false); + }); + + it('detects a directory using ":" on unix', async () => { + process.env.PATH = '/usr/local/bin:/usr/local/lib/node/bin:/usr/bin'; + + const { isInPath } = await import('../postinstall.mjs'); + expect(isInPath('/usr/local/lib/node/bin', ':')).toBe(true); + expect(isInPath('/nonexistent', ':')).toBe(false); + }); + }); + + describe('getShellRcFile', () => { + const originalShell = process.env.SHELL; + + afterEach(() => { + process.env.SHELL = originalShell; + }); + + it('returns .zshrc when SHELL contains zsh', async () => { + process.env.SHELL = '/bin/zsh'; + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toMatch(/\.zshrc$/); + }); + + it('returns .bash_profile when it exists and SHELL contains bash', async () => { + process.env.SHELL = '/bin/bash'; + vi.mocked(existsSync).mockReturnValue(true); + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toMatch(/\.bash_profile$/); + }); + + it('returns .bashrc when .bash_profile does not exist and SHELL contains bash', async () => { + process.env.SHELL = '/bin/bash'; + vi.mocked(existsSync).mockReturnValue(false); + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toMatch(/\.bashrc$/); + }); + + it('returns null when SHELL is unset (Windows)', async () => { + delete process.env.SHELL; + + const { getShellRcFile } = await import('../postinstall.mjs'); + expect(getShellRcFile()).toBeNull(); + }); + }); + + describe('alreadyInRcFile', () => { + it('returns false when the rc file does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + const { alreadyInRcFile } = await import('../postinstall.mjs'); + expect(alreadyInRcFile('/home/user/.bashrc', '/usr/local/bin')).toBe(false); + }); + + it('returns true when the rc file already contains the dir', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('export PATH="/usr/local/bin:$PATH"\n'); + + const { alreadyInRcFile } = await import('../postinstall.mjs'); + expect(alreadyInRcFile('/home/user/.bashrc', '/usr/local/bin')).toBe(true); + }); + }); + + describe('getExpectedShimNames', () => { + it('returns the keys of package.json bin field', async () => { + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ bin: { codemie: './bin/codemie.js', 'codemie-claude': './bin/codemie-claude.js' } }) + ); + + const { getExpectedShimNames } = await import('../postinstall.mjs'); + expect(getExpectedShimNames()).toEqual(['codemie', 'codemie-claude']); + }); + }); + + describe('findMissingShims', () => { + it('returns names whose .cmd file does not exist in dir', async () => { + vi.mocked(existsSync).mockImplementation((p) => !String(p).includes('codemie-claude.cmd')); + + const { findMissingShims } = await import('../postinstall.mjs'); + const result = findMissingShims('C:\\npm', ['codemie', 'codemie-claude']); + + expect(result).toEqual(['codemie-claude']); + }); + + it('returns an empty array when all shims exist', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + const { findMissingShims } = await import('../postinstall.mjs'); + const result = findMissingShims('C:\\npm', ['codemie', 'codemie-claude']); + + expect(result).toEqual([]); + }); + }); + + describe('runWindows', () => { + beforeEach(() => { + vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); + vi.mocked(existsSync).mockReturnValue(true); // all shims present by default + }); + + it('does nothing if npm prefix cannot be determined', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('npm not found'); + }); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(isInUserPath).not.toHaveBeenCalled(); + expect(process.exitCode).toBeFalsy(); + }); + + it('is a no-op when the shim dir is already in PATH', async () => { + vi.mocked(isInUserPath).mockResolvedValue(true); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(addToUserPath).not.toHaveBeenCalled(); + expect(process.exitCode).toBeFalsy(); + }); + + it('adds the shim dir to PATH when missing, and does not set a failing exit code', async () => { + vi.mocked(isInUserPath).mockResolvedValue(false); + vi.mocked(addToUserPath).mockResolvedValue({ + success: true, + pathAdded: 'C:\\Users\\Test\\AppData\\Roaming\\npm', + requiresRestart: true, + alreadyInPath: false, + }); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(addToUserPath).toHaveBeenCalledWith('C:\\Users\\Test\\AppData\\Roaming\\npm'); + expect(process.exitCode).toBeFalsy(); + }); + + it('sets exitCode 1 and prints manual instructions when addToUserPath fails', async () => { + vi.mocked(isInUserPath).mockResolvedValue(false); + vi.mocked(addToUserPath).mockResolvedValue({ + success: false, + error: 'setx failed: access denied', + requiresRestart: false, + alreadyInPath: false, + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('setx failed: access denied')); + }); + + it('warns but does not fail when expected shim files are missing', async () => { + vi.mocked(existsSync).mockReturnValue(false); // no shims found + vi.mocked(isInUserPath).mockResolvedValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ bin: { codemie: './bin/codemie.js' } })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { runWindows } = await import('../postinstall.mjs'); + await runWindows(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('codemie')); + expect(process.exitCode).toBeFalsy(); + }); + + it('degrades gracefully (no throw, exit 0) when the windows-path helper cannot be loaded or used', async () => { + vi.mocked(isInUserPath).mockRejectedValue(new Error("Cannot find module '../dist/utils/windows-path.js'")); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { runWindows } = await import('../postinstall.mjs'); + + await expect(runWindows()).resolves.toBeUndefined(); + expect(addToUserPath).not.toHaveBeenCalled(); + expect(process.exitCode).toBeFalsy(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('windows-path')); + }); + }); + + describe('runUnix', () => { + beforeEach(() => { + vi.mocked(execSync).mockReturnValue('/usr/local\n' as unknown as Buffer); + process.env.PATH = '/usr/bin:/bin'; + process.env.SHELL = '/bin/bash'; + }); + + it('does nothing if npm prefix cannot be determined', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('npm not found'); + }); + + const { runUnix } = await import('../postinstall.mjs'); + runUnix('linux'); + + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('is a no-op when the shim dir is already in PATH', async () => { + process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; + + const { runUnix } = await import('../postinstall.mjs'); + runUnix('linux'); + + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('is a no-op when the rc file already contains the dir', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('export PATH="/usr/local/bin:$PATH"\n'); + + const { runUnix } = await import('../postinstall.mjs'); + runUnix('linux'); + + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('appends to the rc file when the dir is missing from PATH and not already recorded', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('# existing rc contents\n'); + + const { runUnix } = await import('../postinstall.mjs'); + runUnix('linux'); + + expect(appendFileSync).toHaveBeenCalledWith( + expect.stringContaining('.bash_profile'), + expect.stringContaining('/usr/local/bin') + ); + }); + }); + + describe('run', () => { + it('dispatches to the windows PATH mechanism (registry) on win32, not the rc-file mechanism', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + vi.mocked(execSync).mockReturnValue('C:\\Users\\Test\\AppData\\Roaming\\npm\n' as unknown as Buffer); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(isInUserPath).mockResolvedValue(false); + vi.mocked(addToUserPath).mockResolvedValue({ + success: true, + pathAdded: 'C:\\Users\\Test\\AppData\\Roaming\\npm', + requiresRestart: true, + alreadyInPath: false, + }); + + const { run } = await import('../postinstall.mjs'); + await run(); + + expect(addToUserPath).toHaveBeenCalled(); + expect(appendFileSync).not.toHaveBeenCalled(); + }); + + it('dispatches to the rc-file mechanism on non-win32 platforms, not the windows PATH mechanism', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + vi.mocked(execSync).mockReturnValue('/usr/local\n' as unknown as Buffer); + process.env.PATH = '/usr/bin:/bin'; + process.env.SHELL = '/bin/bash'; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('# existing rc contents\n'); + + const { run } = await import('../postinstall.mjs'); + await run(); + + expect(appendFileSync).toHaveBeenCalled(); + expect(isInUserPath).not.toHaveBeenCalled(); + expect(addToUserPath).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 6fcff674..3e852b6e 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -1,52 +1,142 @@ -#!/usr/bin/env node import { execSync } from 'node:child_process'; import { existsSync, readFileSync, appendFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { homedir, platform } from 'node:os'; +import { join, delimiter, dirname, posix as pathPosix } from 'node:path'; +import { fileURLToPath } from 'node:url'; -function getNpmBinDir() { - try { - const prefix = execSync('npm config get prefix', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); - return join(prefix, 'bin'); - } catch { - return null; - } +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function getNpmPrefix() { + try { + return execSync('npm config get prefix', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +export function getShimDir(prefix, plat = platform()) { + // posix.join (not the OS-native join) so this is correct regardless of which + // OS this script itself is running on, matching how src/utils/windows-path.ts + // uses path.win32 explicitly for its own platform-specific branch. + return plat === 'win32' ? prefix : pathPosix.join(prefix, 'bin'); +} + +export function isInPath(dir, sep = delimiter) { + // sep defaults to the real path.delimiter for actual use; the parameter exists + // so both the ';' (win32) and ':' (posix) branches are deterministically + // testable regardless of which OS this script itself runs on. + return (process.env.PATH ?? '').split(sep).includes(dir); } -function getShellRcFile() { - const shell = process.env.SHELL ?? ''; - const home = homedir(); - if (shell.includes('zsh')) return join(home, '.zshrc'); - if (shell.includes('bash')) { - const bashProfile = join(home, '.bash_profile'); - return existsSync(bashProfile) ? bashProfile : join(home, '.bashrc'); - } - return null; +export function getShellRcFile() { + const shell = process.env.SHELL ?? ''; + const home = homedir(); + if (shell.includes('zsh')) return join(home, '.zshrc'); + if (shell.includes('bash')) { + const bashProfile = join(home, '.bash_profile'); + return existsSync(bashProfile) ? bashProfile : join(home, '.bashrc'); + } + return null; } -function isInPath(dir) { - return (process.env.PATH ?? '').split(':').includes(dir); +export function alreadyInRcFile(rcFile, dir) { + if (!existsSync(rcFile)) return false; + return readFileSync(rcFile, 'utf8').includes(dir); } -function alreadyInRcFile(rcFile, dir) { - if (!existsSync(rcFile)) return false; - return readFileSync(rcFile, 'utf8').includes(dir); +export function getExpectedShimNames() { + const packageJsonPath = join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return Object.keys(pkg.bin ?? {}); } -const npmBin = getNpmBinDir(); -if (!npmBin) process.exit(0); +export function findMissingShims(dir, names) { + return names.filter((name) => !existsSync(join(dir, `${name}.cmd`))); +} + +export async function runWindows() { + const prefix = getNpmPrefix(); + if (!prefix) return; + + const dir = getShimDir(prefix, 'win32'); + + const missing = findMissingShims(dir, getExpectedShimNames()); + if (missing.length > 0) { + console.warn(`\n⚠️ Expected CodeMie command shims not found in ${dir}: ${missing.join(', ')}\n`); + } + + let isInUserPath, addToUserPath; + try { + ({ isInUserPath, addToUserPath } = await import('../dist/utils/windows-path.js')); + } catch (error) { + console.warn(`\n⚠️ Could not load the windows-path PATH helper (dist/ may not be built yet): ${error.message}\n`); + return; + } -if (isInPath(npmBin)) process.exit(0); + let alreadyInPath; + try { + alreadyInPath = await isInUserPath(dir); + } catch (error) { + console.warn(`\n⚠️ Could not check the windows-path PATH helper: ${error.message}\n`); + return; + } + if (alreadyInPath) return; -const rcFile = getShellRcFile(); -if (!rcFile) { - console.log(`\n⚠️ Add to PATH manually:\n export PATH="${npmBin}:$PATH"\n`); - process.exit(0); + let result; + try { + result = await addToUserPath(dir); + } catch (error) { + console.warn(`\n⚠️ Could not use the windows-path PATH helper: ${error.message}\n`); + return; + } + + if (result.success) { + console.log(`\n✓ Added ${dir} to PATH\n Open a new terminal to use codemie\n`); + return; + } + + console.error(`\n✗ Could not update PATH automatically: ${result.error}`); + console.error(` Add manually: setx PATH "%PATH%;${dir}"`); + console.error(` (or via System Properties > Environment Variables)\n`); + process.exitCode = 1; } -if (alreadyInRcFile(rcFile, npmBin)) process.exit(0); +export function runUnix(plat = platform()) { + const prefix = getNpmPrefix(); + if (!prefix) return; + + const npmBin = getShimDir(prefix, plat); + if (isInPath(npmBin, plat === 'win32' ? ';' : ':')) return; + + const rcFile = getShellRcFile(); + if (!rcFile) { + console.log(`\n⚠️ Add to PATH manually:\n export PATH="${npmBin}:$PATH"\n`); + return; + } + + if (alreadyInRcFile(rcFile, npmBin)) return; -appendFileSync(rcFile, `\n# Added by @codemieai/code\nexport PATH="${npmBin}:$PATH"\n`); + appendFileSync(rcFile, `\n# Added by @codemieai/code\nexport PATH="${npmBin}:$PATH"\n`); -console.log(`\n✓ Added ${npmBin} to PATH in ${rcFile}`); -console.log(` Run: source ${rcFile}\n`); + console.log(`\n✓ Added ${npmBin} to PATH in ${rcFile}`); + console.log(` Run: source ${rcFile}\n`); +} + +export async function run() { + if (platform() === 'win32') { + await runWindows(); + } else { + runUnix(); + } +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + run().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/scripts/validate-secrets.js b/scripts/validate-secrets.js index e3e8833d..d0d7d33e 100755 --- a/scripts/validate-secrets.js +++ b/scripts/validate-secrets.js @@ -104,7 +104,6 @@ console.log('Running Gitleaks secrets detection...'); const gitleaks = spawn(spawnBin, args, { stdio: ['pipe', 'inherit', 'inherit'], - shell: isWindows, }); gitleaks.stdin.write(stagedDiff); diff --git a/vitest.config.ts b/vitest.config.ts index f6b4a3ae..324c3f46 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,11 +8,11 @@ const agentMaxWorkers = (() => { export default defineConfig({ test: { projects: [ - // ── Unit tests (src/) ──────────────────────────────────────────────── + // ── Unit tests (src/ + scripts/) ───────────────────────────────────── defineProject({ test: { name: 'unit', - include: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + include: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'scripts/**/*.test.ts'], globals: true, environment: 'node', testTimeout: 30_000,