diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2dc3af06d..3dd19e48f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -364,6 +364,23 @@ jobs: printf ' * %s\n' "${SKILLS_TESTS[@]}" node --test "${SKILLS_TESTS[@]}" + test-skill-bootstrap-windows: + name: "Test: skill bootstrap (Windows, Node 24)" + needs: changes + if: needs.changes.outputs.skills == 'true' + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + - name: Exercise npm-cli bootstrap command + run: >- + node --test + skills/hyperframes-animation/scripts/package-loader.test.mjs + skills/hyperframes-creative/scripts/package-loader.test.mjs + # Guards that skills-manifest.json (the published freshness fingerprint read # by `hyperframes skills check`) was regenerated when a skill changed. Runs # `gen:skills-manifest --check`, which compares per-skill content hashes; the diff --git a/skills-manifest.json b/skills-manifest.json index 2355fbf5f7..ce290eba6b 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 17 }, "hyperframes-animation": { - "hash": "a5be218c0b0fe377", + "hash": "4252b08a4905b5ba", "files": 121 }, "hyperframes-audio": { @@ -38,7 +38,7 @@ "files": 20 }, "hyperframes-creative": { - "hash": "68d6b293b011b5e8", + "hash": "f3b201ca29eae92a", "files": 78 }, "hyperframes-keyframes": { diff --git a/skills/hyperframes-animation/scripts/package-loader.mjs b/skills/hyperframes-animation/scripts/package-loader.mjs index ce1846a12d..3b20459aae 100644 --- a/skills/hyperframes-animation/scripts/package-loader.mjs +++ b/skills/hyperframes-animation/scripts/package-loader.mjs @@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { basename, delimiter, dirname, join, parse, resolve } from "node:path"; +import { basename, delimiter, dirname, join, parse, resolve, win32 as win32Path } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -339,23 +339,54 @@ function ancestors(start) { return dirs; } +export function resolveNpmSpawnCommand( + args, + platform = process.platform, + env = process.env, + nodeExecPath = process.execPath, + pathExists = existsSync, +) { + if (platform !== "win32") { + return { cmd: "npm", args, opts: { stdio: "inherit" } }; + } + + const bundledNpmCli = win32Path.join( + win32Path.dirname(nodeExecPath), + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + const npmCli = [env.npm_execpath, bundledNpmCli].find( + (candidate) => candidate && pathExists(candidate), + ); + if (!npmCli) return null; + return { + cmd: env.npm_node_execpath || nodeExecPath, + args: [npmCli, ...args], + opts: { stdio: "inherit", windowsHide: true }, + }; +} + function bootstrapWithNpmInstall(packageNames) { const installRoot = mkdtempSync(join(tmpdir(), "hyperframes-skill-deps-")); - const installResult = spawnSync( - process.platform === "win32" ? "npm.cmd" : "npm", - [ - "install", - "--silent", - "--no-audit", - "--no-fund", - "--ignore-scripts", - "--no-save", - "--prefix", - installRoot, - ...packageNames, - ], - { stdio: "inherit" }, - ); + const npmArgs = [ + "install", + "--silent", + "--no-audit", + "--no-fund", + "--ignore-scripts", + "--no-save", + "--prefix", + installRoot, + ...packageNames, + ]; + const npmCommand = resolveNpmSpawnCommand(npmArgs); + if (!npmCommand) { + rmSync(installRoot, { recursive: true, force: true }); + throw new Error("Could not locate npm-cli.js for dependency bootstrap on Windows."); + } + const installResult = spawnSync(npmCommand.cmd, npmCommand.args, npmCommand.opts); if (installResult.error) throw installResult.error; if (installResult.status !== 0) { diff --git a/skills/hyperframes-animation/scripts/package-loader.test.mjs b/skills/hyperframes-animation/scripts/package-loader.test.mjs index 7b36a3ceb3..54abaaccf2 100644 --- a/skills/hyperframes-animation/scripts/package-loader.test.mjs +++ b/skills/hyperframes-animation/scripts/package-loader.test.mjs @@ -5,10 +5,62 @@ import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { resolveNpmSpawnCommand } from "./package-loader.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; +test("resolveNpmSpawnCommand routes Windows npm through node and npm-cli.js", () => { + const npmCli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"; + const node = "C:\\Program Files\\nodejs\\node.exe"; + const resolved = resolveNpmSpawnCommand( + ["install", "@hyperframes/producer@0.7.55", "value & calc"], + "win32", + { npm_execpath: npmCli, npm_node_execpath: node }, + node, + (path) => path === npmCli, + ); + + assert.deepEqual(resolved, { + cmd: node, + args: [npmCli, "install", "@hyperframes/producer@0.7.55", "value & calc"], + opts: { stdio: "inherit", windowsHide: true }, + }); + assert.equal(resolved.opts.shell, undefined); +}); + +test("resolveNpmSpawnCommand finds npm-cli.js beside node for direct Windows runs", () => { + const node = "C:\\Program Files\\nodejs\\node.exe"; + const npmCli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"; + const resolved = resolveNpmSpawnCommand( + ["install", "@hyperframes/producer@0.7.55"], + "win32", + {}, + node, + (path) => path === npmCli, + ); + + assert.equal(resolved?.cmd, node); + assert.deepEqual(resolved?.args, [npmCli, "install", "@hyperframes/producer@0.7.55"]); +}); + +test( + "resolveNpmSpawnCommand launches the installed npm CLI on Windows", + { skip: process.platform !== "win32" }, + () => { + const resolved = resolveNpmSpawnCommand(["--version"]); + assert.ok(resolved); + + const result = spawnSync(resolved.cmd, resolved.args, { + encoding: "utf8", + windowsHide: true, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout.trim(), /^\d+\./); + }, +); + // (a) env override wins — no ancestor lookup, exact version echoed back. test("hyperframesPackageSpec: env override wins", async () => { const prev = process.env[ENV]; diff --git a/skills/hyperframes-creative/scripts/package-loader.mjs b/skills/hyperframes-creative/scripts/package-loader.mjs index ce1846a12d..3b20459aae 100644 --- a/skills/hyperframes-creative/scripts/package-loader.mjs +++ b/skills/hyperframes-creative/scripts/package-loader.mjs @@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { basename, delimiter, dirname, join, parse, resolve } from "node:path"; +import { basename, delimiter, dirname, join, parse, resolve, win32 as win32Path } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -339,23 +339,54 @@ function ancestors(start) { return dirs; } +export function resolveNpmSpawnCommand( + args, + platform = process.platform, + env = process.env, + nodeExecPath = process.execPath, + pathExists = existsSync, +) { + if (platform !== "win32") { + return { cmd: "npm", args, opts: { stdio: "inherit" } }; + } + + const bundledNpmCli = win32Path.join( + win32Path.dirname(nodeExecPath), + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + const npmCli = [env.npm_execpath, bundledNpmCli].find( + (candidate) => candidate && pathExists(candidate), + ); + if (!npmCli) return null; + return { + cmd: env.npm_node_execpath || nodeExecPath, + args: [npmCli, ...args], + opts: { stdio: "inherit", windowsHide: true }, + }; +} + function bootstrapWithNpmInstall(packageNames) { const installRoot = mkdtempSync(join(tmpdir(), "hyperframes-skill-deps-")); - const installResult = spawnSync( - process.platform === "win32" ? "npm.cmd" : "npm", - [ - "install", - "--silent", - "--no-audit", - "--no-fund", - "--ignore-scripts", - "--no-save", - "--prefix", - installRoot, - ...packageNames, - ], - { stdio: "inherit" }, - ); + const npmArgs = [ + "install", + "--silent", + "--no-audit", + "--no-fund", + "--ignore-scripts", + "--no-save", + "--prefix", + installRoot, + ...packageNames, + ]; + const npmCommand = resolveNpmSpawnCommand(npmArgs); + if (!npmCommand) { + rmSync(installRoot, { recursive: true, force: true }); + throw new Error("Could not locate npm-cli.js for dependency bootstrap on Windows."); + } + const installResult = spawnSync(npmCommand.cmd, npmCommand.args, npmCommand.opts); if (installResult.error) throw installResult.error; if (installResult.status !== 0) { diff --git a/skills/hyperframes-creative/scripts/package-loader.test.mjs b/skills/hyperframes-creative/scripts/package-loader.test.mjs index 7b36a3ceb3..54abaaccf2 100644 --- a/skills/hyperframes-creative/scripts/package-loader.test.mjs +++ b/skills/hyperframes-creative/scripts/package-loader.test.mjs @@ -5,10 +5,62 @@ import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { resolveNpmSpawnCommand } from "./package-loader.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; +test("resolveNpmSpawnCommand routes Windows npm through node and npm-cli.js", () => { + const npmCli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"; + const node = "C:\\Program Files\\nodejs\\node.exe"; + const resolved = resolveNpmSpawnCommand( + ["install", "@hyperframes/producer@0.7.55", "value & calc"], + "win32", + { npm_execpath: npmCli, npm_node_execpath: node }, + node, + (path) => path === npmCli, + ); + + assert.deepEqual(resolved, { + cmd: node, + args: [npmCli, "install", "@hyperframes/producer@0.7.55", "value & calc"], + opts: { stdio: "inherit", windowsHide: true }, + }); + assert.equal(resolved.opts.shell, undefined); +}); + +test("resolveNpmSpawnCommand finds npm-cli.js beside node for direct Windows runs", () => { + const node = "C:\\Program Files\\nodejs\\node.exe"; + const npmCli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"; + const resolved = resolveNpmSpawnCommand( + ["install", "@hyperframes/producer@0.7.55"], + "win32", + {}, + node, + (path) => path === npmCli, + ); + + assert.equal(resolved?.cmd, node); + assert.deepEqual(resolved?.args, [npmCli, "install", "@hyperframes/producer@0.7.55"]); +}); + +test( + "resolveNpmSpawnCommand launches the installed npm CLI on Windows", + { skip: process.platform !== "win32" }, + () => { + const resolved = resolveNpmSpawnCommand(["--version"]); + assert.ok(resolved); + + const result = spawnSync(resolved.cmd, resolved.args, { + encoding: "utf8", + windowsHide: true, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout.trim(), /^\d+\./); + }, +); + // (a) env override wins — no ancestor lookup, exact version echoed back. test("hyperframesPackageSpec: env override wins", async () => { const prev = process.env[ENV];