fix(cli): resolve npx shims on Windows (#1626)

This commit is contained in:
Miguel Ángel
2026-06-21 17:50:22 -04:00
committed by GitHub
parent 6c71cf0240
commit 25b717dd9c
6 changed files with 139 additions and 8 deletions
+30
View File
@@ -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
+3 -1
View File
@@ -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"],
});
+58 -5
View File
@@ -11,13 +11,25 @@ type SpawnCall = {
env: NodeJS.ProcessEnv | undefined;
};
const state: { calls: SpawnCall[] } = { calls: [] };
type ExecCall = {
command: string;
args: ReadonlyArray<string>;
};
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<string>) => {
state.execCalls.push({ command, args });
return Buffer.from("11.0.0");
}),
spawn: vi.fn(
(command: string, args: ReadonlyArray<string>, 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);
},
);
});
+5 -2
View File
@@ -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<void> {
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`.
+26
View File
@@ -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+/);
});
});
+17
View File
@@ -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] };
}