@holmes-lab/holmes-kit 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.1.17] - 2026-08-23
9
+
10
+ The two `main` lines — the Windows-compatibility line (12 commits, verified on Windows 11 and
11
+ independently re-verified on macOS 26.5/APFS: 156/156 suites, gate probes over the `/tmp` →
12
+ `/private/tmp` link spelling all as expected) and the mac release line (0.1.10–0.1.16) — are merged
13
+ into one linear history in this release. Full suite on the merged tree: 191/191.
14
+
15
+ ### Added
16
+ - **Windows Bootstrap Installer (REQ-253)**: `scripts/install.ps1`, a pure Windows PowerShell 5.1 script that runs *before* `npm install` and fixes what npm cannot — relocates out of protected directories (an elevated PowerShell opens in `C:\WINDOWS\System32`, where `npm install` fails with `EPERM`), substitutes a per-user `--prefix` when npm's global prefix is under `C:\Program Files\nodejs` (a second, distinct `EPERM`), gates on `engines.node`, and classifies native-build failures (`msbuild` / `python` / `node-gyp` / `network`) into the one `winget` command that fixes them. Non-interactive, idempotent, no persistent changes. Shipped in the package and documented as the recommended Windows install path. Measured end-to-end on a Windows 11 machine from `System32`: exit 0, no user input.
17
+
18
+ ### Fixed
19
+ - **Path identity is canonical on every filesystem**: `resolveProjectRoot`, the containment gate, the store/derived checks and `resolveRmSignals` all canonicalise through `canonicalPath` (`fs.realpathSync.native`), closing an 8.3-short-name bypass on Windows while keeping the macOS `/tmp → /private/tmp` and case-folding behaviour — measured on both platforms, including mixed link/real spellings against the shipped hook.
20
+ - **Drive-letter paths are absolute**: project containment no longer asks "is this POSIX-absolute" while meaning "is this absolute", so `C:\…` targets are governed on native Windows.
21
+ - **Antigravity hook commands quote their paths**: a space in the install path no longer splits the command and silently disables the gate.
22
+ - **Parser suites run on Windows**: `tsc`/`node` are spawned via `process.execPath`, `dist` is rebuilt only when stale, and `__test-support__` is excluded from `dist` and the npm package.
23
+ - **Suites repaired for fresh checkouts**: the read-compat baseline fixture is tracked through a `.gitignore` re-inclusion chain, and `packaging.test.ts` asserts the CHANGELOG section of the *current* version instead of a stale literal.
24
+
25
+ ### Changed
26
+ - **Spec renumbering**: the Windows installer slice had claimed REQ/H/A/T-SPEC-220, an id family the mac line had already assigned to *Windows Native Tier 1 Governance Parity*; the installer family is renumbered to REQ/H/A/T-SPEC-253 and re-sealed.
27
+
8
28
  ## [0.1.16] - 2026-08-23
9
29
 
10
30
  Predictable spec numbering — enforced at the door, and the corpus cleaned to match.
package/README.md CHANGED
@@ -56,6 +56,16 @@ Holmes-Kit prioritizes **CLI-based AI Coding Agents** where OS-level process hoo
56
56
  The same wrong command was run three times by a real adopter before the right one; a table beats
57
57
  prose read top-to-bottom.
58
58
 
59
+ **Windows (recommended)** — a bootstrap installer that runs *before* npm and fixes what npm cannot: it relocates out of protected folders (an elevated PowerShell opens in `C:\WINDOWS\System32`, where `npm install` fails with `EPERM`), checks the Node.js version, and, if a native module fails to build, prints the one `winget` command that installs the missing C++ toolchain instead of raw compiler output. Pure Windows PowerShell 5.1; no prompts; nothing persistent is changed.
60
+ ```powershell
61
+ # From a downloaded copy of the package (e.g. after `npm pack`, or from a checkout):
62
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1
63
+
64
+ # One-liner (once hosted):
65
+ # irm https://<host>/install.ps1 | iex
66
+ ```
67
+ Add `-DryRun` to see what it would do without installing. Exit codes: `0` ok / already installed · `2` bad argument · `3` Node.js too old · `4` npm failed (remediation printed) · `5` installed but not on `PATH` (prefix printed).
68
+
59
69
  | Which situation are you in? | Privileges | Command |
60
70
  |---|---|---|
61
71
  | **Using it in one project** (most people) | none | `npm install --save-dev @holmes-lab/holmes-kit` |
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 3c84046-mt5fni4h
1
+ e6199ca-mt5qbc5d
@@ -93,19 +93,25 @@ const mcpConfig = (packageRoot, specsDir, launcher) => {
93
93
  * (Claude 배선이 `guardrail` 모드에서 좁은 매처를 쓰는 것과 다른 선택인데, 그쪽은 그 좁힘이
94
94
  * 무엇을 뜻하는지 문서화된 모드 선택이고 여기는 기본 배선이기 때문이다.)
95
95
  */
96
+ // A hook `command` is ONE shell string, so the path must be quoted — `buildHookPlan` in init.ts has
97
+ // always done this for the Claude wiring, and this one did not. Measured 2026-08-23 on a machine
98
+ // whose home directory contains a space: the shell split the path, the hook never ran, and the
99
+ // Antigravity gate was silently off. (The MCP config above is different: it passes `args` as an
100
+ // array, where quoting would put literal quote characters into the path.)
101
+ const hookCommand = (packageRoot, script) => `node "${path.join(packageRoot, 'bin', script)}"`;
96
102
  const hooksJson = (packageRoot) => `${JSON.stringify({
97
103
  'holmes-kit': {
98
104
  PreToolUse: [{
99
105
  matcher: '*',
100
106
  hooks: [{
101
107
  type: 'command',
102
- command: `node ${path.join(packageRoot, 'bin', 'holmes-hook-antigravity.js')}`,
108
+ command: hookCommand(packageRoot, 'holmes-hook-antigravity.js'),
103
109
  timeout: 30,
104
110
  }],
105
111
  }],
106
112
  Stop: [{
107
113
  type: 'command',
108
- command: `node ${path.join(packageRoot, 'bin', 'holmes-stop-antigravity.js')}`,
114
+ command: hookCommand(packageRoot, 'holmes-stop-antigravity.js'),
109
115
  timeout: 30,
110
116
  }],
111
117
  },
@@ -90,11 +90,14 @@ function resolveRmSignals(command, cwd) {
90
90
  let repoRoot;
91
91
  let baseReal;
92
92
  try {
93
- repoRoot = (0, node_child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() })
94
- .toString().trim();
95
- // git realpaths the toplevel (resolves symlinks like macOS /var -> /private/var); resolve the
96
- // cwd the same way so in-project containment comparisons don't spuriously fail on a symlinked path.
97
- baseReal = fs.realpathSync(cwd);
93
+ // Both sides through the ONE canonicaliser. git prints the top-level symlink-resolved, with `/`
94
+ // separators on Windows and the long name; the JS realpath kept 8.3 aliases and `path.sep`
95
+ // comparisons below never matched `C:/…` against `C:\…`. Measured 2026-08-23: `rm -rf build`
96
+ // in a real repo read as inProject=false, so a gitignored build dir was escalated as if it were
97
+ // outside the project — fail-safe in direction, wrong in fact, and a needless HITL every time.
98
+ repoRoot = (0, root_1.canonicalPath)((0, node_child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() })
99
+ .toString().trim());
100
+ baseReal = (0, root_1.canonicalPath)(cwd);
98
101
  }
99
102
  catch {
100
103
  return undefined; // not a git repo / git unavailable -> static fallback
@@ -111,7 +114,7 @@ function resolveRmSignals(command, cwd) {
111
114
  // lexical path (nothing to delete yet); realpath errors fail safe to the lexical value.
112
115
  try {
113
116
  if (fs.existsSync(resolved))
114
- resolved = fs.realpathSync(resolved);
117
+ resolved = (0, root_1.canonicalPath)(resolved);
115
118
  }
116
119
  catch { /* keep lexical */ }
117
120
  const inProject = resolved === repoRoot || resolved.startsWith(repoRoot + path.sep);
@@ -615,12 +618,17 @@ function evaluateHook(input, specsDir, opts) {
615
618
  // path under a symlinked ancestor (macOS `/tmp` → `/private/tmp` is the ordinary case) switched
616
619
  // the gate off. A bound that runs out must fail toward the gate, not away from it — so the walk
617
620
  // is not bounded by a count at all; it ends at the filesystem root, which always terminates.
621
+ // `canonicalPath` (realpathSync.native), not the JS realpath: the root arrives canonicalised by
622
+ // root.ts, which expands Windows 8.3 aliases; the JS realpath does not. Measured 2026-08-23 on
623
+ // the built hook — an in-project target spelled through `%TEMP%`'s alias (`C:\Users\SUNGNA~1\…`)
624
+ // resolved to a string the long-name root did not contain, and took the "outside → allow" exit
625
+ // below. Both sides must go through the one canonicaliser, or an alias is a free pass.
618
626
  const realOf = (abs) => {
619
627
  let head = abs;
620
628
  const tail = [];
621
629
  for (;;) {
622
630
  try {
623
- return [fs.realpathSync(head).replace(/\\/g, '/'), ...tail].join('/');
631
+ return [(0, root_1.canonicalPath)(head).replace(/\\/g, '/'), ...tail].join('/');
624
632
  }
625
633
  catch { /* walk up */ }
626
634
  const parent = path.dirname(head);
@@ -262,13 +262,13 @@ function boundNonceLedger(store) {
262
262
  // `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
263
263
  // every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
264
264
  const storeReal = (() => { try {
265
- return fs.realpathSync(store.specsRoot);
265
+ return (0, root_2.canonicalPath)(store.specsRoot);
266
266
  }
267
267
  catch {
268
268
  return store.specsRoot;
269
269
  } })();
270
270
  const derivedReal = (() => { try {
271
- return fs.realpathSync(derived);
271
+ return (0, root_2.canonicalPath)(derived);
272
272
  }
273
273
  catch {
274
274
  return derived;
@@ -301,13 +301,13 @@ function boundFindingsLedger(store, root) {
301
301
  try {
302
302
  const derived = projectRootOf(store.specsRoot);
303
303
  const storeReal = (() => { try {
304
- return fs.realpathSync(store.specsRoot);
304
+ return (0, root_2.canonicalPath)(store.specsRoot);
305
305
  }
306
306
  catch {
307
307
  return store.specsRoot;
308
308
  } })();
309
309
  const derivedReal = (() => { try {
310
- return fs.realpathSync(derived);
310
+ return (0, root_2.canonicalPath)(derived);
311
311
  }
312
312
  catch {
313
313
  return derived;
@@ -189,7 +189,12 @@ class GitChangeSource {
189
189
  this.base = base;
190
190
  this.head = head;
191
191
  this.topLevel = (0, root_1.resolveProjectRoot)(root).git?.topLevel;
192
- if (this.topLevel && fs.realpathSync(path.resolve(root)) !== this.topLevel) {
192
+ // The GIVEN root must be the top-level — `changes()` runs git against it, so a subdirectory
193
+ // (even one that walks up to the same `.ax`) would tag paths against the wrong base. Both sides
194
+ // go through ONE canonicaliser: the old `fs.realpathSync(root)` here was the JS realpath, which
195
+ // keeps Windows 8.3 aliases the top-level side had already expanded — measured 2026-08-23, the
196
+ // same directory spelled `C:\AI_PRO~1\…` threw as a mismatch.
197
+ if (this.topLevel && (0, root_1.canonicalPath)(path.resolve(root)) !== this.topLevel) {
193
198
  throw new Error(`GitChangeSource: root must be the git top-level (got ${root}, top-level is ${this.topLevel})`);
194
199
  }
195
200
  }
@@ -31,4 +31,17 @@ export interface ProjectRoot {
31
31
  }
32
32
  /** The marker directory that makes a directory a Holmes-Kit project. */
33
33
  export declare const MARKER = ".ax";
34
+ /**
35
+ * The one canonical spelling of an existing path.
36
+ *
37
+ * `fs.realpathSync.native`, not `fs.realpathSync`. The JS implementation resolves symlinks only;
38
+ * on Windows it leaves 8.3 short-name aliases (`C:\Users\SUNGNA~1`, which is what `%TEMP%` and
39
+ * `os.tmpdir()` hand out) exactly as spelled, while `git rev-parse --show-toplevel` prints the
40
+ * long name. Measured 2026-08-23: `resolveProjectRoot('C:/AI_PRO~1/AX-COD~1')` reported
41
+ * `root: C:\AI_PRO~1\AX-COD~1` beside `git.topLevel: C:\ai_project\AX-Coding-Project` for ONE
42
+ * directory, and GitChangeSource refused it as a mismatch. The native call expands both symlinks
43
+ * and 8.3 names (a strict superset, same ENOENT behaviour), so `root` keeps the promise its type
44
+ * makes: one spelling per directory, the spelling every path key is built from.
45
+ */
46
+ export declare const canonicalPath: (p: string) => string;
34
47
  export declare function resolveProjectRoot(dir: string): ProjectRoot;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MARKER = void 0;
36
+ exports.canonicalPath = exports.MARKER = void 0;
37
37
  exports.cleanSubprocessEnv = cleanSubprocessEnv;
38
38
  exports.resolveProjectRoot = resolveProjectRoot;
39
39
  // @implements A-SPEC-205
@@ -56,6 +56,21 @@ function cleanSubprocessEnv(env = process.env) {
56
56
  }
57
57
  /** The marker directory that makes a directory a Holmes-Kit project. */
58
58
  exports.MARKER = '.ax';
59
+ /**
60
+ * The one canonical spelling of an existing path.
61
+ *
62
+ * `fs.realpathSync.native`, not `fs.realpathSync`. The JS implementation resolves symlinks only;
63
+ * on Windows it leaves 8.3 short-name aliases (`C:\Users\SUNGNA~1`, which is what `%TEMP%` and
64
+ * `os.tmpdir()` hand out) exactly as spelled, while `git rev-parse --show-toplevel` prints the
65
+ * long name. Measured 2026-08-23: `resolveProjectRoot('C:/AI_PRO~1/AX-COD~1')` reported
66
+ * `root: C:\AI_PRO~1\AX-COD~1` beside `git.topLevel: C:\ai_project\AX-Coding-Project` for ONE
67
+ * directory, and GitChangeSource refused it as a mismatch. The native call expands both symlinks
68
+ * and 8.3 names (a strict superset, same ENOENT behaviour), so `root` keeps the promise its type
69
+ * makes: one spelling per directory, the spelling every path key is built from.
70
+ */
71
+ const canonicalPath = (p) => fs.realpathSync.native(p);
72
+ exports.canonicalPath = canonicalPath;
73
+ const canonical = exports.canonicalPath;
59
74
  function gitTopLevel(dir) {
60
75
  try {
61
76
  // stderr silenced: "not a git repository" is an expected branch here, not a fault worth printing.
@@ -66,7 +81,7 @@ function gitTopLevel(dir) {
66
81
  }).trim();
67
82
  // git resolves symlinks in its output; realpath ours too so comparisons are like-for-like
68
83
  // (macOS /var -> /private/var otherwise makes an identical directory look like a mismatch).
69
- return out ? fs.realpathSync(out) : undefined;
84
+ return out ? canonical(out) : undefined;
70
85
  }
71
86
  catch {
72
87
  return undefined;
@@ -75,7 +90,7 @@ function gitTopLevel(dir) {
75
90
  function resolveProjectRoot(dir) {
76
91
  let start;
77
92
  try {
78
- start = fs.realpathSync(path.resolve(dir));
93
+ start = canonical(path.resolve(dir));
79
94
  }
80
95
  catch {
81
96
  // @implements A-SPEC-189 §7 (round 11) — this is a POINTED refusal about the caller's own
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.1.16",
4
+ "version": "0.1.17",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -13,6 +13,7 @@
13
13
  "bin/",
14
14
  "dist/",
15
15
  "playbooks/",
16
+ "scripts/install.ps1",
16
17
  "docs/install-guide.md",
17
18
  "CHANGELOG.md"
18
19
  ],
@@ -0,0 +1,297 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Holmes-Kit bootstrap installer for Windows. @implements A-SPEC-253
4
+ .DESCRIPTION
5
+ Runs BEFORE npm to fix what npm cannot: a protected CWD (an elevated PowerShell opens in
6
+ C:\WINDOWS\System32 and `npm install` dies there with EPERM), an unwritable global prefix
7
+ (C:\Program Files\nodejs), a too-old Node.js, and native-build failures that print compiler
8
+ noise instead of the one command that installs the toolchain. Then it runs
9
+ `npm install -g @holmes-lab/holmes-kit`. Windows PowerShell 5.1 only; no prompts; nothing
10
+ persistent is changed.
11
+ .PARAMETER DryRun Run every check, print the npm command that WOULD run, exit 0 without running it.
12
+ .PARAMETER Version Package version to install (default: latest).
13
+ .PARAMETER Prefix Optional npm --prefix for the global install.
14
+ .EXAMPLE
15
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1 [-DryRun] [-Version 0.1.9]
16
+ .NOTES
17
+ Exit codes: 0 ok / already installed, 2 argument error, 3 Node too old, 4 npm failed, 5 not on PATH.
18
+ #>
19
+ # Non-positional on purpose: otherwise `install.ps1 foo` binds `foo` to -Version and runs a REAL
20
+ # `npm install -g @holmes-lab/holmes-kit@foo` from a typo. Stray tokens land in $Rest -> exit 2.
21
+ [CmdletBinding(PositionalBinding = $false)]
22
+ param(
23
+ [Parameter(Mandatory = $false)][switch]$DryRun,
24
+ [Parameter(Mandatory = $false)][string]$Version = 'latest',
25
+ [Parameter(Mandatory = $false)][string]$Prefix,
26
+ [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)][string[]]$Rest
27
+ )
28
+
29
+ Set-StrictMode -Version 2.0
30
+ $ErrorActionPreference = 'Stop'
31
+
32
+ # Mirrors package.json#engines.node (">=20"). Bump both together.
33
+ $script:NodeMajorFloor = 20
34
+ $script:PackageName = '@holmes-lab/holmes-kit'
35
+ $script:BinName = 'holmes-kit'
36
+
37
+ # Failure classifier. Pure: npm's combined output in, one token out. Precedence is fixed so the
38
+ # most ACTIONABLE cause wins when several signatures appear in the same transcript - an EPERM in a
39
+ # protected directory also drags node-gyp down with it, and telling the user to install MSVC
40
+ # would send them the wrong way.
41
+ function Get-InstallFailureKind([string]$NpmOutput) {
42
+ if ($null -eq $NpmOutput) { $NpmOutput = '' }
43
+ # Two EPERMs, one code. CWD (System32): relocating fixes it. GLOBAL PREFIX (C:\Program Files\
44
+ # nodejs, the Node installer's default): only --prefix fixes it. Measured 2026-08-23: after the
45
+ # relocation the install still died on `mkdir 'C:\Program Files\nodejs\node_modules\@holmes-lab'`.
46
+ # Keyed on the FAILING PATH line, not any mention of that directory: every npm stack trace names
47
+ # npm's own home under Program Files\nodejs\node_modules\npm, which is not the error.
48
+ if ($NpmOutput -match 'EPERM' -and $NpmOutput -match "(?m)^npm error path .*Program Files[\\/]+nodejs[\\/]+node_modules[\\/]+(?!npm[\\/])") { return 'eperm-prefix' }
49
+ if ($NpmOutput -match 'EPERM' -and ($NpmOutput -match 'mkdir' -or $NpmOutput -match 'System32' -or $NpmOutput -match 'Program Files')) { return 'eperm' }
50
+ if ($NpmOutput -match 'MSB\d{4}' -or $NpmOutput -match 'MSBuild' -or $NpmOutput -match 'Visual Studio' -or $NpmOutput -match 'vcvarsall') { return 'msbuild' }
51
+ if ($NpmOutput -match 'Could not find any Python' -or $NpmOutput -match 'gyp ERR! find Python') { return 'python' }
52
+ if ($NpmOutput -match 'node-gyp' -or $NpmOutput -match 'gyp ERR!') { return 'nodegyp-generic' }
53
+ if ($NpmOutput -match 'ENOTFOUND' -or $NpmOutput -match 'ETIMEDOUT' -or $NpmOutput -match 'ECONNRESET' -or $NpmOutput -match 'EAI_AGAIN') { return 'network' }
54
+ return 'none'
55
+ }
56
+
57
+ $script:Remediation = @{
58
+ 'eperm' = @'
59
+ npm was denied permission to create files in the current directory.
60
+ This usually means the shell was opened in a protected system folder (e.g. C:\WINDOWS\System32).
61
+ Re-run this installer (it relocates to your user profile automatically), or `cd` to a folder you own first.
62
+ '@
63
+ 'eperm-prefix' = @'
64
+ npm's GLOBAL prefix is inside C:\Program Files\nodejs, which only an administrator can write.
65
+ This installer passes a per-user prefix automatically; if you ran npm yourself, use one of:
66
+ npm install -g @holmes-lab/holmes-kit --prefix "$env:APPDATA\npm" (this install only)
67
+ npm config set prefix "$env:APPDATA\npm" (every global install)
68
+ and make sure that folder is on your user PATH (the Node.js installer normally adds it).
69
+ '@
70
+ 'msbuild' = @'
71
+ A native module needed a C++ compiler and none was found.
72
+ Install the Visual Studio Build Tools with the C++ workload (one command, ~2 GB):
73
+
74
+ winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
75
+
76
+ Then open a NEW terminal and re-run this installer.
77
+ '@
78
+ 'python' = @'
79
+ node-gyp could not find a Python 3 interpreter (it needs one to drive the native build).
80
+
81
+ winget install Python.Python.3.12
82
+
83
+ Then open a NEW terminal and re-run this installer.
84
+ '@
85
+ 'nodegyp-generic' = @'
86
+ A native module failed to build and no prebuilt binary matched your Node.js version.
87
+ Most often the C++ Build Tools are missing:
88
+ winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
89
+ If they are installed, try a current Node.js LTS (prebuilt binaries track LTS releases).
90
+ '@
91
+ 'network' = @'
92
+ npm could not reach the registry (DNS / timeout / connection reset).
93
+ Check your connection and proxy settings (`npm config get proxy`, `npm config get https-proxy`), then re-run.
94
+ '@
95
+ 'none' = @'
96
+ npm exited with an error this installer does not recognise. The last lines of its output are above.
97
+ '@
98
+ }
99
+
100
+ # Stage helpers
101
+ function Write-Stage([string]$Text) { Write-Host $Text }
102
+ function Write-Problem([string]$Text) { [Console]::Error.WriteLine($Text) }
103
+
104
+ function Get-NormalizedPath([string]$P) {
105
+ if ([string]::IsNullOrWhiteSpace($P)) { return $null }
106
+ $t = $P.TrimEnd('\', '/')
107
+ if ($t.Length -eq 2 -and $t[1] -eq ':') { $t = $t + '\' } # 'C:' -> 'C:\' so the drive root compares as itself
108
+ return $t.ToLowerInvariant()
109
+ }
110
+
111
+ function Test-UnderRoot([string]$Candidate, [string]$Root) {
112
+ if ($null -eq $Root) { return $false }
113
+ if ($Candidate -eq $Root) { return $true }
114
+ $prefix = $Root
115
+ if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' } # separator first: C:\Windowsx is NOT under C:\Windows
116
+ return $Candidate.StartsWith($prefix)
117
+ }
118
+
119
+ function Test-ProtectedDirectory([string]$Dir) {
120
+ $here = Get-NormalizedPath $Dir
121
+ # The drive root is protected only when $PWD IS the root (C:\) - everything on the drive is
122
+ # under it, so treating it like the other roots would relocate every project on the machine.
123
+ $driveRoot = Get-NormalizedPath ([IO.Path]::GetPathRoot($Dir))
124
+ if ($null -ne $driveRoot -and $here -eq $driveRoot) { return $true }
125
+ $roots = @($env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)
126
+ foreach ($r in $roots) {
127
+ if (Test-UnderRoot $here (Get-NormalizedPath $r)) { return $true }
128
+ }
129
+ return $false
130
+ }
131
+
132
+ function Test-WritableDirectory([string]$Dir) {
133
+ $probe = Join-Path $Dir ('.holmes-kit-write-probe-' + [Guid]::NewGuid().ToString('N'))
134
+ try {
135
+ New-Item -ItemType File -Path $probe -ErrorAction Stop | Out-Null
136
+ Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
137
+ return $true
138
+ } catch {
139
+ return $false
140
+ }
141
+ }
142
+
143
+ function Test-Elevated {
144
+ try {
145
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
146
+ $p = New-Object Security.Principal.WindowsPrincipal($id)
147
+ return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
148
+ } catch { return $false }
149
+ }
150
+
151
+ function Get-NodeVersionString {
152
+ $cmd = Get-Command node -ErrorAction SilentlyContinue
153
+ if ($null -eq $cmd) { return $null }
154
+ try { return ((& node --version) | Select-Object -First 1).Trim() } catch { return $null }
155
+ }
156
+
157
+ function Get-InstalledVersion {
158
+ $cmd = Get-Command $script:BinName -ErrorAction SilentlyContinue
159
+ if ($null -eq $cmd) { return $null }
160
+ try {
161
+ $out = (& $script:BinName --version 2>$null) | Select-Object -First 1
162
+ if ($out -match '(\d+\.\d+\.\d+[^\s]*)') { return $Matches[1] }
163
+ return $null
164
+ } catch { return $null }
165
+ }
166
+
167
+ function Get-NpmPrefix {
168
+ # Skipped in -DryRun only when npm is absent; otherwise it is a local config read, no network.
169
+ $r = Invoke-Npm @('config', 'get', 'prefix')
170
+ if ($r.Code -ne 0) { return $null }
171
+ $v = $r.Output.Trim()
172
+ if ([string]::IsNullOrWhiteSpace($v) -or $v -eq 'undefined') { return $null }
173
+ return $v
174
+ }
175
+
176
+ function Invoke-Npm([string[]]$Arguments) {
177
+ # Array arguments, never one interpolated string; npm is npm.cmd on Windows and needs the
178
+ # .cmd resolution that `&` performs. Both streams are merged so the classifier sees everything.
179
+ $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
180
+ if ($null -eq $npm) { $npm = Get-Command npm -ErrorAction SilentlyContinue }
181
+ if ($null -eq $npm) { return @{ Code = 127; Output = 'npm was not found on PATH' } }
182
+ $prev = $ErrorActionPreference
183
+ $ErrorActionPreference = 'Continue' # a native command writing to stderr must not throw here
184
+ try {
185
+ $lines = & $npm.Source @Arguments 2>&1 | ForEach-Object { "$_" }
186
+ $code = $LASTEXITCODE
187
+ } finally {
188
+ $ErrorActionPreference = $prev
189
+ }
190
+ return @{ Code = $code; Output = ($lines -join "`n") }
191
+ }
192
+
193
+ # Pipeline
194
+ function Invoke-Main {
195
+ # Stage 0 - arguments
196
+ if ($null -ne $Rest -and $Rest.Count -gt 0) {
197
+ Write-Problem ("Unrecognised argument(s): " + ($Rest -join ' '))
198
+ Write-Problem 'Usage: install.ps1 [-DryRun] [-Version <string>] [-Prefix <dir>]'
199
+ return 2
200
+ }
201
+ if ([string]::IsNullOrWhiteSpace($Version)) { $Version = 'latest' }
202
+
203
+ # Stage 1 - directory guard. Relocate, report, never ask.
204
+ $cwd = (Get-Location).Path
205
+ $protected = Test-ProtectedDirectory $cwd
206
+ $writable = $false
207
+ if (-not $protected) { $writable = Test-WritableDirectory $cwd }
208
+ if ($protected -or -not $writable) {
209
+ $home_ = $env:USERPROFILE
210
+ Write-Stage ("Relocating: {0} is a protected or read-only directory; continuing from {1}" -f $cwd, $home_)
211
+ Set-Location -LiteralPath $home_
212
+ }
213
+
214
+ # Stage 2 - elevation notice (warn, continue)
215
+ if (Test-Elevated) {
216
+ Write-Stage 'Note: this shell is elevated. A global npm install from here may land in a per-machine prefix that your normal (non-admin) terminal does not see. A regular user shell is recommended.'
217
+ }
218
+
219
+ # Stage 3 - Node version gate
220
+ $nodeV = Get-NodeVersionString
221
+ $major = -1
222
+ if ($null -ne $nodeV -and $nodeV -match '^v?(\d+)\.(\d+)\.(\d+)') { $major = [int]$Matches[1] }
223
+ if ($major -lt $script:NodeMajorFloor) {
224
+ $found = $nodeV
225
+ if ($null -eq $found) { $found = 'none' }
226
+ Write-Problem ("Node.js >= {0} is required (found {1}). Install from https://nodejs.org/" -f $script:NodeMajorFloor, $found)
227
+ return 3
228
+ }
229
+ Write-Stage ("Node.js {0} OK" -f $nodeV)
230
+
231
+ # Stage 4 - idempotence
232
+ $installed = Get-InstalledVersion
233
+ if ($null -ne $installed) {
234
+ $want = $Version
235
+ if ($want -eq 'latest' -and -not $DryRun) {
236
+ $view = Invoke-Npm @('view', $script:PackageName, 'version')
237
+ if ($view.Code -eq 0) { $want = $view.Output.Trim() }
238
+ }
239
+ if ($want -ne 'latest' -and $installed -eq $want) {
240
+ Write-Stage ("already installed ({0})" -f $installed)
241
+ return 0
242
+ }
243
+ Write-Stage ("holmes-kit {0} is installed; will install {1}" -f $installed, $Version)
244
+ }
245
+
246
+ # Stage 4b - prefix guard. A non-elevated shell cannot write the Node installer's default
247
+ # prefix (C:\Program Files\nodejs) and Stage 1 does not help. Per-user prefix for THIS call
248
+ # only; the user's npm config is never modified (REQ-253 Constraint 2).
249
+ if ([string]::IsNullOrWhiteSpace($Prefix) -and -not (Test-Elevated)) {
250
+ $cur = Get-NpmPrefix
251
+ if ($null -ne $cur -and (Test-ProtectedDirectory $cur)) {
252
+ $Prefix = Join-Path $env:APPDATA 'npm'
253
+ Write-Stage ("npm's global prefix ({0}) is not writable without elevation; using --prefix {1} for this install" -f $cur, $Prefix)
254
+ }
255
+ }
256
+
257
+ # Stage 5 - npm install
258
+ $spec = '{0}@{1}' -f $script:PackageName, $Version
259
+ $npmArgs = @('install', '-g', $spec)
260
+ $display = 'npm install -g "{0}"' -f $spec
261
+ if (-not [string]::IsNullOrWhiteSpace($Prefix)) {
262
+ $npmArgs += @('--prefix', $Prefix)
263
+ $display = $display + (' --prefix "{0}"' -f $Prefix)
264
+ }
265
+ if ($DryRun) {
266
+ Write-Stage ('DRY-RUN: ' + $display)
267
+ return 0
268
+ }
269
+ Write-Stage ('Running: ' + $display)
270
+ $r = Invoke-Npm $npmArgs
271
+ if ($r.Code -ne 0) {
272
+ $kind = Get-InstallFailureKind $r.Output
273
+ $tail = ($r.Output -split "`n") | Select-Object -Last 15
274
+ Write-Problem '--- npm output (last 15 lines) ---'
275
+ foreach ($l in $tail) { Write-Problem $l }
276
+ Write-Problem '--- what to do ---'
277
+ Write-Problem $script:Remediation[$kind]
278
+ return 4
279
+ }
280
+
281
+ # Stage 6 - PATH verification
282
+ $cmd = Get-Command $script:BinName -ErrorAction SilentlyContinue
283
+ if ($null -eq $cmd) {
284
+ $pfx = (Invoke-Npm @('config', 'get', 'prefix')).Output.Trim()
285
+ Write-Problem ("holmes-kit installed but {0} is not on PATH for this shell. Open a new terminal, or add it to your user PATH." -f $pfx)
286
+ return 5
287
+ }
288
+ $v = Get-InstalledVersion
289
+ Write-Stage ("holmes-kit {0} installed" -f $v)
290
+ return 0
291
+ }
292
+
293
+ # Dot-sourcing (`. .\install.ps1`) loads the functions for testing and does NOT run the pipeline.
294
+ if ($MyInvocation.InvocationName -ne '.') {
295
+ $code = Invoke-Main
296
+ exit $code
297
+ }