From dd7547d524930f152dd627cb6f3c64baef24e923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 21 Jun 2026 21:41:30 +0000 Subject: [PATCH] fix(cli): resolve npx shims on Windows --- .github/workflows/ci.yml | 30 +++++++++++ packages/cli/src/commands/preview.ts | 4 +- packages/cli/src/commands/skills.test.ts | 63 +++++++++++++++++++++-- packages/cli/src/commands/skills.ts | 7 ++- packages/cli/src/utils/npxCommand.test.ts | 26 ++++++++++ packages/cli/src/utils/npxCommand.ts | 17 ++++++ 6 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/utils/npxCommand.test.ts create mode 100644 packages/cli/src/utils/npxCommand.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 991336ab10..110c23d37f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: timeout-minutes: 2 outputs: code: ${{ steps.filter.outputs.code }} + cli: ${{ steps.filter.outputs.cli }} steps: # Force git-based change detection instead of the pull_request REST API. # The API path can fail the whole workflow on transient listFiles @@ -52,6 +53,11 @@ jobs: - "tsconfig*.json" - "Dockerfile*" - ".github/workflows/**" + cli: + - "packages/cli/**" + - "package.json" + - "bun.lock" + - ".github/workflows/ci.yml" build: name: Build @@ -218,6 +224,30 @@ jobs: - run: bun run --cwd packages/core build:hyperframes-runtime - run: bun run --filter '!@hyperframes/producer' test + cli-npx-shim: + name: "CLI: npx shim (${{ matrix.os }})" + needs: changes + if: needs.changes.outputs.cli == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - name: Install dependencies + if: runner.os != 'Windows' + run: bun install --frozen-lockfile --ignore-scripts + - name: Install dependencies + if: runner.os == 'Windows' + run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted + - run: bun run --cwd packages/cli test src/utils/npxCommand.test.ts src/commands/skills.test.ts + sdk-tests: name: "SDK: unit + contract + smoke" needs: changes diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 19d8557017..80749a8a6e 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -23,6 +23,7 @@ import { createRequire } from "node:module"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { isDevMode } from "../utils/env.js"; +import { buildNpxCommand } from "../utils/npxCommand.js"; import { openBrowser, parseRemoteDebuggingPort, @@ -377,7 +378,8 @@ async function runLocalStudioMode( const s = clack.spinner(); s.start("Starting studio..."); - const child = spawn("npx", ["vite"], { + const viteCommand = buildNpxCommand(["vite"]); + const child = spawn(viteCommand.command, viteCommand.args, { cwd: studioPkgPath, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index a0219a5bb8..2c51c38f05 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -11,13 +11,25 @@ type SpawnCall = { env: NodeJS.ProcessEnv | undefined; }; -const state: { calls: SpawnCall[] } = { calls: [] }; +type ExecCall = { + command: string; + args: ReadonlyArray; +}; + +const originalPlatform = process.platform; +const state: { execCalls: ExecCall[]; spawnCalls: SpawnCall[] } = { + execCalls: [], + spawnCalls: [], +}; vi.mock("node:child_process", () => ({ - execFileSync: vi.fn(() => Buffer.from("11.0.0")), + execFileSync: vi.fn((command: string, args: ReadonlyArray) => { + state.execCalls.push({ command, args }); + return Buffer.from("11.0.0"); + }), spawn: vi.fn( (command: string, args: ReadonlyArray, opts?: { env?: NodeJS.ProcessEnv }) => { - state.calls.push({ command, args, env: opts?.env }); + state.spawnCalls.push({ command, args, env: opts?.env }); const fake = new EventEmitter(); setImmediate(() => fake.emit("close", 0, null)); return fake; @@ -25,25 +37,66 @@ vi.mock("node:child_process", () => ({ ), })); +vi.mock("@clack/prompts", () => ({ + log: { + error: vi.fn(), + }, +})); + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + describe("hyperframes skills", () => { beforeEach(() => { - state.calls = []; + state.execCalls = []; + state.spawnCalls = []; vi.resetModules(); }); afterEach(() => { + setPlatform(originalPlatform); vi.restoreAllMocks(); }); it("sets GIT_CLONE_PROTECTION_ACTIVE=0 on the spawned skills CLI child (GH #316)", async () => { + setPlatform("linux"); + const { default: skillsCmd } = await import("./skills.js"); await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never); - const first = state.calls[0]; + const first = state.spawnCalls[0]; expect(first).toBeDefined(); expect(first!.command).toBe("npx"); expect(first!.args).toContain("skills"); expect(first!.args).toContain("add"); expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0"); }); + + it.each([ + ["linux", "npx", ["--version"], ["skills", "add", "heygen-com/hyperframes", "--all"]], + ["darwin", "npx", ["--version"], ["skills", "add", "heygen-com/hyperframes", "--all"]], + [ + "win32", + "cmd.exe", + ["/d", "/s", "/c", "npx.cmd", "--version"], + ["/d", "/s", "/c", "npx.cmd", "skills", "add", "heygen-com/hyperframes", "--all"], + ], + ] as const)( + "uses %s-compatible npx command for preflight and skills install", + async (platform, expectedCommand, expectedPreflightArgs, expectedInstallArgs) => { + setPlatform(platform); + + const { default: skillsCmd } = await import("./skills.js"); + await skillsCmd.run?.({ args: {}, rawArgs: [], cmd: skillsCmd } as never); + + expect(state.execCalls[0]?.command).toBe(expectedCommand); + expect(state.execCalls[0]?.args).toEqual(expectedPreflightArgs); + expect(state.spawnCalls[0]?.command).toBe(expectedCommand); + expect(state.spawnCalls[0]?.args).toEqual(expectedInstallArgs); + }, + ); }); diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index ebf24046db..c53e2208ea 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -2,10 +2,12 @@ import { defineCommand } from "citty"; import { execFileSync, spawn } from "node:child_process"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; +import { buildNpxCommand } from "../utils/npxCommand.js"; function hasNpx(): boolean { + const npx = buildNpxCommand(["--version"]); try { - execFileSync("npx", ["--version"], { stdio: "ignore", timeout: 5000 }); + execFileSync(npx.command, npx.args, { stdio: "ignore", timeout: 5000 }); return true; } catch { return false; @@ -13,8 +15,9 @@ function hasNpx(): boolean { } function runSkillsAdd(repo: string): Promise { + const npx = buildNpxCommand(["skills", "add", repo, "--all"]); return new Promise((resolve, reject) => { - const child = spawn("npx", ["skills", "add", repo, "--all"], { + const child = spawn(npx.command, npx.args, { stdio: "inherit", timeout: 120_000, // GH #316 — the upstream `skills` CLI shells out to `git clone`. diff --git a/packages/cli/src/utils/npxCommand.test.ts b/packages/cli/src/utils/npxCommand.test.ts new file mode 100644 index 0000000000..038e9b0d94 --- /dev/null +++ b/packages/cli/src/utils/npxCommand.test.ts @@ -0,0 +1,26 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { buildNpxCommand } from "./npxCommand.js"; + +describe("buildNpxCommand", () => { + it.each([ + ["linux", "npx", ["--version"]], + ["darwin", "npx", ["--version"]], + ["win32", "cmd.exe", ["/d", "/s", "/c", "npx.cmd", "--version"]], + ] as const)("builds the %s npx invocation", (platform, expectedCommand, expectedArgs) => { + expect(buildNpxCommand(["--version"], platform)).toEqual({ + command: expectedCommand, + args: expectedArgs, + }); + }); + + it("executes the host npx version check through the resolved command", () => { + const npx = buildNpxCommand(["--version"]); + const version = execFileSync(npx.command, npx.args, { + encoding: "utf8", + timeout: 10_000, + }).trim(); + + expect(version).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/packages/cli/src/utils/npxCommand.ts b/packages/cli/src/utils/npxCommand.ts new file mode 100644 index 0000000000..08141155fa --- /dev/null +++ b/packages/cli/src/utils/npxCommand.ts @@ -0,0 +1,17 @@ +export type NpxCommand = { + command: string; + args: string[]; +}; + +export function buildNpxCommand( + args: readonly string[], + platform: NodeJS.Platform = process.platform, +): NpxCommand { + if (platform === "win32") { + // npm installs npx as a .cmd shim on Windows; invoke it through cmd.exe + // instead of relying on child_process to resolve or execute the shim. + return { command: "cmd.exe", args: ["/d", "/s", "/c", "npx.cmd", ...args] }; + } + + return { command: "npx", args: [...args] }; +}