@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 +7 -0
- package/package.json +1 -1
- package/src/core/npmCommand.js +31 -0
- package/src/core/update.js +5 -4
- package/src/core/updateSafety.js +4 -2
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
|
@@ -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
|
+
}
|
package/src/core/update.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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}`);
|
package/src/core/updateSafety.js
CHANGED
|
@@ -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 =
|
|
46
|
-
|
|
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));
|