Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,6 +53,11 @@ jobs:
- "tsconfig*.json"
- "Dockerfile*"
- ".github/workflows/**"
cli:
- "packages/cli/**"
- "package.json"
- "bun.lock"
- ".github/workflows/ci.yml"

build:
name: Build
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
});
Expand Down
63 changes: 58 additions & 5 deletions packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,92 @@ 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;
},
),
}));

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);
},
);
});
7 changes: 5 additions & 2 deletions packages/cli/src/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ 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;
}
}

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`.
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/utils/npxCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions packages/cli/src/utils/npxCommand.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
Loading