@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 +20 -0
- package/README.md +10 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.js +8 -2
- package/dist/holmes/hooks/pre-tool-use.js +15 -7
- package/dist/holmes/mcp/handlers.js +4 -4
- package/dist/holmes/project/change-source.js +6 -1
- package/dist/holmes/project/root.d.ts +13 -0
- package/dist/holmes/project/root.js +18 -3
- package/package.json +2 -1
- package/scripts/install.ps1 +297 -0
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
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 =
|
|
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 [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
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.
|
|
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
|
+
}
|