@ngockhoale/ukit 3.4.34 → 3.4.35

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
@@ -9,6 +9,13 @@ All notable changes to UKit are documented here.
9
9
  - New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
10
10
  - Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
11
11
 
12
+ ## 3.4.35 - 2026-10-12
13
+
14
+ **Windows: `ukit update` no longer fails preflight with `npm is not runnable (spawnSync npm ENOENT)`.**
15
+ - Root cause: `update.js` (`npm root -g`, `npm install -g`) and `updateSafety.js` (`npm --version` preflight) called `spawnSync('npm', …)`. On Windows npm is `npm.cmd`, so a bare name fails with ENOENT even though npm works in PowerShell (and spawning a `.cmd` directly fails with EINVAL on Node >= 18.20/20.12/22, including 24).
16
+ - Fix: new `src/core/npmCommand.js` — on win32 npm runs through `%ComSpec% /d /s /c` (PATH/PATHEXT resolution, custom locations, paths with spaces); POSIX behavior is unchanged (direct spawn, no shell). Arguments are allowlisted so nothing cmd.exe treats specially can reach the command line. The preflight message now includes npm's first stderr line.
17
+ - Validation limit: no real Windows host was available; the cmd.exe path is covered by exact-invocation tests plus a POSIX stand-in for cmd.exe, which proves the spawn/exit-code/timeout/PATH plumbing, not real Windows behavior.
18
+
12
19
  ## 3.4.34 - 2026-10-12
13
20
 
14
21
  **Hook interference fixes (P1): task intent, operation evidence, heredoc data, containment reporting.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.34",
3
+ "version": "3.4.35",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,31 @@
1
+ import { spawnSync as defaultSpawnSync } from 'node:child_process';
2
+
3
+ // On Windows npm is `npm.cmd` (+ `npm.ps1`), not an executable. `spawnSync('npm', …)` therefore
4
+ // fails with ENOENT (CreateProcess does not apply PATHEXT to a bare name), and since the
5
+ // CVE-2024-27980 hardening (Node 18.20 / 20.12 / 21.7 / 22+, so Node 24 too) spawning a
6
+ // `.cmd`/`.bat` file directly fails with EINVAL. The supported route is cmd.exe: it resolves
7
+ // `npm` through PATH + PATHEXT (custom locations, directories with spaces) exactly like the
8
+ // user's shell. POSIX is untouched: `npm` is spawned directly, no shell.
9
+ //
10
+ // No shell string is ever built from free-form input: every npm argument must match a strict
11
+ // allowlist (package names, versions, flags), so nothing cmd.exe treats specially can pass.
12
+ const SAFE_NPM_ARG = /^[A-Za-z0-9@._/:=+~^-]+$/;
13
+
14
+ export function npmInvocation(args, { platform = process.platform, env = process.env } = {}) {
15
+ for (const arg of args) {
16
+ if (typeof arg !== 'string' || !SAFE_NPM_ARG.test(arg)) {
17
+ throw new Error(`Refusing unsafe npm argument: ${JSON.stringify(arg)}`);
18
+ }
19
+ }
20
+ if (platform !== 'win32') return { command: 'npm', args: [...args], options: {} };
21
+ return {
22
+ command: env.ComSpec || env.COMSPEC || 'cmd.exe',
23
+ args: ['/d', '/s', '/c', ['npm', ...args].join(' ')],
24
+ options: { windowsVerbatimArguments: true, windowsHide: true },
25
+ };
26
+ }
27
+
28
+ export function spawnNpm(args, options = {}, { spawnSync = defaultSpawnSync, platform = process.platform, env = process.env } = {}) {
29
+ const inv = npmInvocation(args, { platform, env });
30
+ return spawnSync(inv.command, inv.args, { ...options, ...inv.options });
31
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync as defaultSpawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { spawnNpm } from './npmCommand.js';
4
5
 
5
6
  export const UKIT_PACKAGE_NAME = '@ngockhoale/ukit';
6
7
 
@@ -50,11 +51,11 @@ export function resolveGlobalUkitBin({ spawnSync = defaultSpawnSync } = {}) {
50
51
  // a timeout as actionable ("npm query timed out — is the network/proxy wedged?")
51
52
  // instead of misattributing it to a missing file.
52
53
  function resolveGlobalUkitBinDetailed({ spawnSync = defaultSpawnSync } = {}) {
53
- const result = spawnSync('npm', ['root', '-g'], {
54
+ const result = spawnNpm(['root', '-g'], {
54
55
  encoding: 'utf8',
55
56
  timeout: NPM_QUERY_TIMEOUT_MS,
56
57
  killSignal: KILL_ON_TIMEOUT,
57
- });
58
+ }, { spawnSync });
58
59
  if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
59
60
  return { binPath: null, timedOut: result.error?.code === 'ETIMEDOUT' };
60
61
  }
@@ -113,11 +114,11 @@ export function runInstallAfterUpdate({
113
114
  }
114
115
 
115
116
  export function updateUkit({ spawnSync = defaultSpawnSync } = {}) {
116
- const result = spawnSync('npm', ['install', '-g', UKIT_PACKAGE_NAME], {
117
+ const result = spawnNpm(['install', '-g', UKIT_PACKAGE_NAME], {
117
118
  stdio: 'inherit',
118
119
  timeout: NPM_INSTALL_TIMEOUT_MS,
119
120
  killSignal: KILL_ON_TIMEOUT,
120
- });
121
+ }, { spawnSync });
121
122
 
122
123
  if (result.error) {
123
124
  throw new Error(`Failed to run npm install -g ${UKIT_PACKAGE_NAME}: ${result.error.message}`);
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { spawnSync as defaultSpawnSync } from 'node:child_process';
5
5
  import { readInstallMetadata } from './metadata.js';
6
+ import { spawnNpm } from './npmCommand.js';
6
7
  import { writeFileAtomic } from './fileOps.js';
7
8
 
8
9
  // F5: safety net around `ukit update`.
@@ -42,8 +43,9 @@ function insideProject(projectRoot, rel) {
42
43
  export async function preflightUpdate({ projectRoot, spawnSync = defaultSpawnSync, checkNpm = true } = {}) {
43
44
  const problems = [];
44
45
  if (checkNpm) {
45
- const r = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 15_000, killSignal: 'SIGKILL' });
46
- if (r.error || r.status !== 0) problems.push(`npm is not runnable (${r.error?.message ?? `exit ${r.status}`})`);
46
+ const r = spawnNpm(['--version'], { encoding: 'utf8', timeout: 15_000, killSignal: 'SIGKILL' }, { spawnSync });
47
+ const detail = typeof r.stderr === 'string' ? r.stderr.trim().split(/\r?\n/)[0] : '';
48
+ if (r.error || r.status !== 0) problems.push(`npm is not runnable (${r.error?.message ?? `exit ${r.status}`}${detail ? `: ${detail}` : ''})`);
47
49
  }
48
50
  try {
49
51
  await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));