Unverified 提交 49d415dd authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

feat: run add-dependency installs in a PTY (#3167)

## Summary - replace add-dependency command execution with a cross-platform node-pty runner in the Electron main process - preserve add-dependency output and failure handling while adding PTY output normalization and command timeouts - package node-pty correctly in Electron Forge by externalizing it from Vite, rebuilding it, and unpacking its helper binaries ## Test plan - npm run fmt - npm run lint:fix - npm run ts - npm test - npm run build 🤖 Generated with Claude Code <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3167" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com> Co-authored-by: 's avatardevin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
上级 daec6206
......@@ -17,6 +17,7 @@ Detailed rules and learnings are in the `rules/` directory. Read the relevant fi
| [rules/git-workflow.md](rules/git-workflow.md) | Pushing branches, creating PRs, or dealing with fork/upstream remotes |
| [rules/base-ui-components.md](rules/base-ui-components.md) | Using TooltipTrigger, ToggleGroupItem, or other Base UI wrapper components |
| [rules/database-drizzle.md](rules/database-drizzle.md) | Modifying the database schema, generating migrations, or resolving migration conflicts |
| [rules/native-modules.md](rules/native-modules.md) | Adding Electron native modules or binaries that must survive Forge packaging/rebuild |
| [rules/typescript-strict-mode.md](rules/typescript-strict-mode.md) | Debugging type errors from `npm run ts` (tsgo) that pass normal tsc |
| [rules/openai-reasoning-models.md](rules/openai-reasoning-models.md) | Working with OpenAI reasoning model (o1/o3/o4-mini) conversation history |
| [rules/adding-settings.md](rules/adding-settings.md) | Adding a new user-facing setting or toggle to the Settings page |
......
......@@ -42,6 +42,12 @@ const ignore = (file: string) => {
if (file.startsWith("/node_modules/better-sqlite3")) {
return false;
}
if (file.startsWith("/node_modules/node-pty")) {
return false;
}
if (file.startsWith("/node_modules/node-addon-api")) {
return false;
}
if (file.startsWith("/node_modules/bindings")) {
return false;
}
......@@ -94,13 +100,16 @@ const config: ForgeConfig = {
appleIdPassword: process.env.APPLE_PASSWORD!,
teamId: process.env.APPLE_TEAM_ID!,
},
asar: true,
asar: {
// node-pty loads helper binaries like spawn-helper and winpty-agent from disk.
unpackDir: "node_modules/node-pty",
},
ignore,
extraResource: ["node_modules/dugite/git", "node_modules/@vscode"],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
},
rebuildConfig: {
extraModules: ["better-sqlite3"],
extraModules: ["better-sqlite3", "node-pty"],
force: true,
},
makers: [
......
......@@ -67,6 +67,7 @@
"lexical-beautiful-mentions": "^0.1.47",
"lucide-react": "^0.487.0",
"monaco-editor": "^0.52.2",
"node-pty": "^1.1.0",
"perfect-freehand": "^1.2.2",
"posthog-js": "^1.265.1",
"react": "^19.2.4",
......@@ -18288,6 +18289,12 @@
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-api-version": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
......@@ -18339,6 +18346,16 @@
}
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
......
......@@ -107,6 +107,7 @@
"lexical-beautiful-mentions": "^0.1.47",
"lucide-react": "^0.487.0",
"monaco-editor": "^0.52.2",
"node-pty": "^1.1.0",
"perfect-freehand": "^1.2.2",
"posthog-js": "^1.265.1",
"react": "^19.2.4",
......
......@@ -119,6 +119,7 @@ If this happens:
## Real Socket Firewall E2E tests
- If you change the add-dependency/socket-firewall command launch path (for example `spawn` vs PTY execution), proactively run `npm run e2e e2e-tests/socket_firewall.spec.ts` after `npm run build`. Unit tests and package builds do not cover the real packaged-Electron Socket Firewall flow.
- When exercising the real `sfw` binary in E2E, set fresh per-test `npm_config_cache`, `npm_config_store_dir`, and `pnpm_config_store_dir` in the launch hooks. Reused caches/stores can make Socket Firewall report that it did not detect package fetches, which turns blocked-package tests into false negatives.
- For real-path blocked-package coverage, prefer `axois` over `lodahs`. `lodahs` can resolve to `0.0.1-security` and install successfully under `pnpm`, so it does not reliably reach the blocked-package UI.
......
# Native Modules
Read this when adding Electron native dependencies such as `node-pty`, or any package that ships `.node` binaries, helper executables, or rebuild-time headers.
- This repo's `forge.config.ts` uses a deny-by-default `ignore` filter for most `node_modules` content. When adding a native dependency, explicitly allowlist the runtime package and any rebuild-time helper packages it requires (for example `node-addon-api`), or Electron Forge can fail during `Preparing native dependencies` with errors like `Cannot find module 'node-addon-api'`.
- Add native runtime packages to `vite.main.config.mts` `build.rollupOptions.external` so Vite does not bundle them into the main-process build.
- Add native runtime packages to `forge.config.ts` `rebuildConfig.extraModules` so Electron Forge rebuilds them against the packaged Electron version.
- If the package loads helper binaries from disk at runtime (for example `node-pty` loading `spawn-helper` or `winpty-agent` next to its native module), unpack the whole package directory with `packagerConfig.asar.unpackDir`; auto-unpacking `.node` files alone is not enough.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ADD_DEPENDENCY_INSTALL_TIMEOUT_MS,
CommandExecutionError,
SOCKET_FIREWALL_WARNING_MESSAGE,
} from "@/ipc/utils/socket_firewall";
......@@ -95,14 +96,15 @@ describe("executeAddDependency", () => {
});
});
it("includes socket stderr verdict details when sfw blocks a dependency", async () => {
it("uses the most relevant combined PTY output line as the display summary", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: true,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "pnpm blocked",
stderr: "Socket Firewall blocked react\nPolicy: malware",
stdout:
"Progress: resolved 12, reused 0, downloaded 0, added 0\nSocket Firewall blocked react\nPolicy: malware",
exitCode: 1,
}),
);
......@@ -130,6 +132,143 @@ describe("executeAddDependency", () => {
});
});
it("filters PTY progress noise out of expanded display details", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "npm install failed",
stdout: [
"Progress: resolved 1, reused 0, downloaded 0, added 0",
"npm warn deprecated left-pad@1.3.0: use String.prototype.padStart()",
"npm ERR! code ERESOLVE",
"npm ERR! ERESOLVE unable to resolve dependency tree",
"npm ERR! A complete log of this run can be found in:",
"npm ERR! /Users/me/.npm/_logs/2026-04-08-debug-0.log",
].join("\n"),
exitCode: 1,
}),
);
await expect(
executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
}),
).rejects.toMatchObject({
displayDetails:
"npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree",
displaySummary: "npm ERR! ERESOLVE unable to resolve dependency tree",
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
});
});
it("falls back to the error message when PTY output only contains progress noise", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: true,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "Command 'pnpm add react' was terminated by signal 15",
stdout: [
"Progress: resolved 50, reused 0, downloaded 0, added 0",
"Packages: +1",
].join("\n"),
exitCode: 0,
}),
);
await expect(
executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
}),
).rejects.toMatchObject({
displayDetails: "Command 'pnpm add react' was terminated by signal 15",
displaySummary: "Command 'pnpm add react' was terminated by signal 15",
warningMessages: [],
});
});
it("ignores npm log-noise lines and keeps the actionable npm ERR summary", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "npm install failed",
stdout: [
"npm ERR! code ERESOLVE",
"npm ERR! ERESOLVE unable to resolve dependency tree",
"npm ERR! A complete log of this run can be found in:",
"npm ERR! /Users/me/.npm/_logs/2026-04-08-debug-0.log",
].join("\n"),
exitCode: 1,
}),
);
await expect(
executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
}),
).rejects.toMatchObject({
displaySummary: "npm ERR! ERESOLVE unable to resolve dependency tree",
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
});
});
it("keeps ERR_PNPM summaries instead of falling back to progress output", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "pnpm add failed",
stdout: [
"Progress: resolved 1, reused 0, downloaded 0, added 0",
"ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/react: Not Found",
].join("\n"),
exitCode: 1,
}),
);
await expect(
executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
}),
).rejects.toMatchObject({
displaySummary:
"ERR_PNPM_FETCH_404 GET https://registry.npmjs.org/react: Not Found",
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
});
});
it("does not fall back to a direct install when the real sfw cli blocks a dependency", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: true,
......@@ -137,7 +276,7 @@ describe("executeAddDependency", () => {
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "pnpm blocked",
stderr:
stdout:
" - blocked npm package: name: axois; version: 0.0.1-security; reason: malware (critical)",
exitCode: 1,
}),
......@@ -169,7 +308,7 @@ describe("executeAddDependency", () => {
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "sfw pnpm failed",
stderr: "Socket Firewall timed out",
stdout: "Socket Firewall timed out",
exitCode: 1,
}),
);
......@@ -214,7 +353,10 @@ describe("executeAddDependency", () => {
expect(runCommandMock).toHaveBeenCalledWith(
"npm",
["install", "--legacy-peer-deps", "react"],
{ cwd: "/tmp/app" },
{
cwd: "/tmp/app",
timeoutMs: ADD_DEPENDENCY_INSTALL_TIMEOUT_MS,
},
);
expect(runCommandMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
......
......@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { Message } from "@/ipc/types";
import { readEffectiveSettings } from "@/main/settings";
import {
ADD_DEPENDENCY_INSTALL_TIMEOUT_MS,
buildAddDependencyCommand,
detectPreferredPackageManager,
ensureSocketFirewallInstalled,
......@@ -29,11 +30,71 @@ export interface ExecuteAddDependencyResult {
warningMessages: string[];
}
function getFirstNonEmptyLine(value: string): string | undefined {
const DISPLAY_SUMMARY_PATTERNS = [
/\bblocked\b/i,
/\bfailed\b/i,
/\berror\b/i,
/\bdenied\b/i,
/\btimed out\b/i,
/\btimeout\b/i,
/\betimedout\b/i,
/\bnpm err!/i,
/\berr_pnpm_[a-z0-9_]+\b/i,
/\bE[A-Z][A-Z0-9_]{2,}\b/,
];
const DISPLAY_SUMMARY_NOISE_PATTERNS = [
/^progress:/i,
/^packages:\s*[+-]?\d+/i,
/^npm (?:notice|warn)\b/i,
/^npm err!\s*(?:a complete log of this run can be found in:|this is probably not a problem with npm\.)/i,
/^npm err!\s*(?:[A-Za-z]:\\|\/).+/i,
];
function isDisplaySummaryNoise(line: string): boolean {
return DISPLAY_SUMMARY_NOISE_PATTERNS.some((pattern) => pattern.test(line));
}
function getDisplayLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
.filter(Boolean);
}
function getFilteredDisplayDetails(value: string): string | undefined {
const lines = getDisplayLines(value).filter(
(line) => !isDisplaySummaryNoise(line),
);
if (lines.length === 0) {
return undefined;
}
return lines.join("\n");
}
function getDisplaySummary(value: string): string | undefined {
const lines = getDisplayLines(value);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (
!isDisplaySummaryNoise(line) &&
DISPLAY_SUMMARY_PATTERNS.some((pattern) => pattern.test(line))
) {
return line;
}
}
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!isDisplaySummaryNoise(line)) {
return line;
}
}
return lines.at(-1);
}
export class ExecuteAddDependencyError extends Error {
......@@ -50,14 +111,17 @@ export class ExecuteAddDependencyError extends Error {
warningMessages: string[];
}) {
const message = error instanceof Error ? error.message : String(error);
const displayDetails = getCommandExecutionDisplayDetails(error) ?? message;
const commandDisplayDetails = getCommandExecutionDisplayDetails(error);
const displayDetails = commandDisplayDetails
? (getFilteredDisplayDetails(commandDisplayDetails) ?? message)
: message;
super(message);
this.name = "ExecuteAddDependencyError";
this.warningMessages = warningMessages;
this.originalError = error;
this.displayDetails = displayDetails;
this.displaySummary = getFirstNonEmptyLine(displayDetails) ?? message;
this.displaySummary = getDisplaySummary(displayDetails) ?? message;
}
}
......@@ -72,6 +136,7 @@ async function runAddDependencyCommand(
try {
const { stdout, stderr } = await runCommand(command.command, command.args, {
cwd: appPath,
timeoutMs: ADD_DEPENDENCY_INSTALL_TIMEOUT_MS,
});
return {
succeeded: true,
......
......@@ -97,14 +97,14 @@ describe("processFullResponseActions add dependency errors", () => {
} as any);
});
it("stores the socket stderr verdict in the appended error card", async () => {
it("stores the relevant combined PTY verdict in the appended error card", async () => {
executeAddDependencyMock.mockRejectedValue(
new ExecuteAddDependencyError({
error: new CommandExecutionError({
message:
"Command 'npx sfw@2.0.4 npm install --legacy-peer-deps react' exited with code 1",
stderr:
"Socket Firewall blocked react<malware>\nPolicy: malware package",
stdout:
"Progress: resolved 12, reused 0, downloaded 0, added 0\nSocket Firewall blocked react<malware>\nPolicy: malware package",
exitCode: 1,
}),
warningMessages: [],
......@@ -130,6 +130,9 @@ describe("processFullResponseActions add dependency errors", () => {
expect(contentUpdate?.content).toContain(
"Socket Firewall blocked react&lt;malware&gt;\nPolicy: malware package",
);
expect(contentUpdate?.content).not.toContain(
"Progress: resolved 12, reused 0, downloaded 0, added 0",
);
});
it("preserves warning messages when a later processing step throws", async () => {
......
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
formatDuration,
normalizePtyOutput,
PtyCommandExecutionError,
runPtyCommand,
} from "./pty_command_runner";
const { processSpawnMock, spawnMock } = vi.hoisted(() => ({
processSpawnMock: vi.fn(),
spawnMock: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>(
"node:child_process",
);
return {
...actual,
default: {
...(("default" in actual ? actual.default : actual) as Record<
string,
unknown
>),
spawn: processSpawnMock,
},
spawn: processSpawnMock,
};
});
vi.mock("node-pty", () => ({
spawn: spawnMock,
}));
interface MockPtyController {
emitData(data: string): void;
emitExit(event: { exitCode: number; signal?: number }): void;
pty: {
pid: number;
kill: ReturnType<typeof vi.fn>;
onData: ReturnType<typeof vi.fn>;
onExit: ReturnType<typeof vi.fn>;
};
}
function createMockPtyController(): MockPtyController {
const dataListeners = new Set<(data: string) => void>();
const exitListeners = new Set<
(event: { exitCode: number; signal?: number }) => void
>();
return {
emitData(data) {
for (const listener of dataListeners) {
listener(data);
}
},
emitExit(event) {
for (const listener of exitListeners) {
listener(event);
}
},
pty: {
pid: 1234,
kill: vi.fn(),
onData: vi.fn((listener: (data: string) => void) => {
dataListeners.add(listener);
return {
dispose: () => dataListeners.delete(listener),
};
}),
onExit: vi.fn(
(listener: (event: { exitCode: number; signal?: number }) => void) => {
exitListeners.add(listener);
return {
dispose: () => exitListeners.delete(listener),
};
},
),
},
};
}
async function withPlatform<T>(
platform: NodeJS.Platform,
callback: () => Promise<T>,
): Promise<T> {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
value: platform,
});
try {
return await callback();
} finally {
Object.defineProperty(process, "platform", {
configurable: true,
value: originalPlatform,
});
}
}
describe("normalizePtyOutput", () => {
it("strips ANSI sequences and keeps the last carriage-return update", () => {
expect(
normalizePtyOutput(
"\u001b]0;npm install\u0007\u001b[32mfetching\u001b[0m\rfetched\nabc\bXY\r\n",
),
).toBe("fetched\nabXY");
});
it("preserves carriage-return frames when requested", () => {
expect(
normalizePtyOutput("Progress 1\rProgress 2\rFailed to download\n", {
preserveCarriageReturnFrames: true,
}),
).toBe("Progress 1\nProgress 2\nFailed to download");
});
});
describe("formatDuration", () => {
it("formats user-facing timeout durations in readable units", () => {
expect(formatDuration(25)).toBe("25 ms");
expect(formatDuration(30_000)).toBe("30 seconds");
expect(formatDuration(10 * 60 * 1000)).toBe("10 minutes");
});
});
describe("runPtyCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
processSpawnMock.mockReturnValue({
once: vi.fn().mockReturnThis(),
unref: vi.fn(),
});
});
it("captures normalized PTY output on success", async () => {
const controller = createMockPtyController();
spawnMock.mockReturnValue(controller.pty);
const promise = runPtyCommand("npx", ["sfw", "--help"], {
cwd: "/tmp/app",
});
expect(spawnMock).toHaveBeenCalledWith(
"npx",
["sfw", "--help"],
expect.objectContaining({
cols: 160,
cwd: "/tmp/app",
encoding: "utf8",
env: process.env,
name: "xterm-color",
rows: 24,
}),
);
controller.emitData("\u001b[32mResolving\u001b[0m\rResolved\n");
controller.emitData("added 1 package\r\n");
controller.emitExit({ exitCode: 0 });
await expect(promise).resolves.toEqual({
output: "Resolved\nadded 1 package",
});
});
it("rejects with the captured output when the PTY exits non-zero", async () => {
const controller = createMockPtyController();
spawnMock.mockReturnValue(controller.pty);
const promise = runPtyCommand("pnpm", ["add", "react"]);
controller.emitData("blocked react\n");
controller.emitExit({ exitCode: 1 });
await expect(promise).rejects.toMatchObject({
exitCode: 1,
message: "Command 'pnpm add react' exited with code 1",
name: "PtyCommandExecutionError",
output: "blocked react",
} satisfies Partial<PtyCommandExecutionError>);
});
it("rejects when the PTY exits due to a signal even with exit code zero", async () => {
const controller = createMockPtyController();
spawnMock.mockReturnValue(controller.pty);
const promise = runPtyCommand("pnpm", ["add", "react"]);
controller.emitData("Progress 1\rProgress 2\r");
controller.emitExit({ exitCode: 0, signal: 15 });
await expect(promise).rejects.toMatchObject({
exitCode: 0,
message: "Command 'pnpm add react' was terminated by signal 15",
output: "Progress 1\nProgress 2",
signal: 15,
} satisfies Partial<PtyCommandExecutionError>);
});
it("kills the PTY and rejects when the command times out", async () => {
vi.useFakeTimers();
const controller = createMockPtyController();
spawnMock.mockReturnValue(controller.pty);
const promise = runPtyCommand("npx", ["sfw"], {
timeoutMs: 25,
});
controller.emitData("still running");
const handledPromise = promise.catch((error) => error);
await vi.advanceTimersByTimeAsync(25);
await expect(handledPromise).resolves.toMatchObject({
exitCode: null,
message:
"Command 'npx sfw' timed out after 25 ms. The command may be stuck. Check your network or environment and try again.",
output:
"still running\nCommand 'npx sfw' timed out after 25 ms. The command may be stuck. Check your network or environment and try again.",
} satisfies Partial<PtyCommandExecutionError>);
expect(controller.pty.kill).toHaveBeenCalledTimes(1);
});
it("uses the display-command override in PTY exit errors", async () => {
const controller = createMockPtyController();
spawnMock.mockReturnValue(controller.pty);
const promise = runPtyCommand(
"cmd.exe",
["/d", "/s", "/c", '"npx.cmd" "--yes" "sfw@2.0.4"'],
{
displayCommand: "npx --yes sfw@2.0.4",
},
);
controller.emitExit({ exitCode: 1 });
await expect(promise).rejects.toMatchObject({
message: "Command 'npx --yes sfw@2.0.4' exited with code 1",
} satisfies Partial<PtyCommandExecutionError>);
});
it("uses taskkill to terminate the PTY process tree on Windows timeouts", async () => {
await withPlatform("win32", async () => {
vi.useFakeTimers();
const controller = createMockPtyController();
controller.pty.pid = 4321;
spawnMock.mockReturnValue(controller.pty);
const promise = runPtyCommand("npx", ["sfw"], {
timeoutMs: 25,
});
const handledPromise = promise.catch((error) => error);
await vi.advanceTimersByTimeAsync(25);
await expect(handledPromise).resolves.toBeInstanceOf(
PtyCommandExecutionError,
);
expect(processSpawnMock).toHaveBeenCalledWith(
"taskkill",
["/F", "/T", "/PID", "4321"],
{
stdio: "ignore",
windowsHide: true,
},
);
expect(controller.pty.kill).not.toHaveBeenCalled();
});
});
});
import { spawn as spawnProcess } from "node:child_process";
import { spawn as spawnPty } from "node-pty";
const DEFAULT_PTY_NAME = "xterm-color";
const DEFAULT_PTY_COLS = 160;
const DEFAULT_PTY_ROWS = 24;
export const DEFAULT_PTY_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
const ANSI_OSC_PATTERN = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const ANSI_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/g;
const ANSI_SINGLE_CHAR_PATTERN = /\u001B[@-Z\\-_]/g;
export interface PtyCommandExecutionOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
cols?: number;
rows?: number;
name?: string;
displayCommand?: string;
}
export interface PtyCommandExecutionResult {
output: string;
}
export interface NormalizePtyOutputOptions {
preserveCarriageReturnFrames?: boolean;
}
export class PtyCommandExecutionError extends Error {
output: string;
exitCode: number | null;
signal?: number;
constructor({
message,
output = "",
exitCode = null,
signal,
}: {
message: string;
output?: string;
exitCode?: number | null;
signal?: number;
}) {
super(message);
this.name = "PtyCommandExecutionError";
this.output = output;
this.exitCode = exitCode;
this.signal = signal;
}
}
export interface PtyProcessLike {
pid?: number;
kill(signal?: string): void;
onData(listener: (data: string) => void): { dispose(): void };
onExit(listener: (event: { exitCode: number; signal?: number }) => void): {
dispose(): void;
};
}
interface SpawnedProcessLike {
once(event: "error", listener: () => void): SpawnedProcessLike;
unref(): void;
}
type PtySpawner = (
file: string,
args: string[],
options: {
cols: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
encoding: "utf8";
name: string;
rows: number;
},
) => PtyProcessLike;
type ProcessSpawner = (
file: string,
args: string[],
options: {
stdio: "ignore";
windowsHide: true;
},
) => SpawnedProcessLike;
function buildDisplayedCommand(command: string, args: string[]): string {
return [command, ...args].join(" ");
}
function buildTimeoutMessage(
displayedCommand: string,
timeoutMs: number,
): string {
return `Command '${displayedCommand}' timed out after ${formatDuration(timeoutMs)}. The command may be stuck. Check your network or environment and try again.`;
}
function appendCommandMessage(output: string, message: string): string {
return output ? `${output}\n${message}` : message;
}
function stripAnsiSequences(value: string): string {
return value
.replace(ANSI_OSC_PATTERN, "")
.replace(ANSI_CSI_PATTERN, "")
.replace(ANSI_SINGLE_CHAR_PATTERN, "");
}
function formatDurationUnit(value: number, unit: string): string {
if (unit === "ms") {
return `${value} ${unit}`;
}
return `${value} ${unit}${value === 1 ? "" : "s"}`;
}
export function formatDuration(durationMs: number): string {
if (durationMs < 1000) {
return formatDurationUnit(durationMs, "ms");
}
if (durationMs % (60 * 1000) === 0) {
return formatDurationUnit(durationMs / (60 * 1000), "minute");
}
if (durationMs % 1000 === 0) {
return formatDurationUnit(durationMs / 1000, "second");
}
return formatDurationUnit(Math.ceil(durationMs / 1000), "second");
}
function hasSignal(signal: number | undefined): signal is number {
return signal !== undefined && signal !== 0;
}
function buildExitMessage(
displayedCommand: string,
exitCode: number,
signal: number | undefined,
): string {
if (hasSignal(signal)) {
return `Command '${displayedCommand}' was terminated by signal ${signal}`;
}
return `Command '${displayedCommand}' exited with code ${exitCode}`;
}
function terminatePtyProcess(
ptyProcess: PtyProcessLike,
platform: NodeJS.Platform = process.platform,
processSpawner: ProcessSpawner = spawnProcess,
): void {
if (platform === "win32" && typeof ptyProcess.pid === "number") {
try {
const taskkillProcess = processSpawner(
"taskkill",
["/F", "/T", "/PID", String(ptyProcess.pid)],
{
stdio: "ignore",
windowsHide: true,
},
);
taskkillProcess.once("error", () => {
try {
ptyProcess.kill();
} catch {
// Best effort only. The timeout error remains the source of truth.
}
});
taskkillProcess.unref();
return;
} catch {
// Fall back to the PTY kill below.
}
}
ptyProcess.kill();
}
export function normalizePtyOutput(
value: string,
options: NormalizePtyOutputOptions = {},
): string {
const strippedValue = stripAnsiSequences(value).replace(/\r\n/g, "\n");
const normalizedLines: string[] = [];
let currentLine = "";
for (const character of strippedValue) {
if (character === "\r") {
if (options.preserveCarriageReturnFrames && currentLine) {
normalizedLines.push(currentLine);
}
currentLine = "";
continue;
}
if (character === "\n") {
normalizedLines.push(currentLine);
currentLine = "";
continue;
}
if (character === "\b") {
currentLine = currentLine.slice(0, -1);
continue;
}
const codePoint = character.codePointAt(0) ?? 0;
const isControlCharacter =
codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f);
if (isControlCharacter && character !== "\t") {
continue;
}
currentLine += character;
}
if (currentLine) {
normalizedLines.push(currentLine);
}
return normalizedLines.join("\n");
}
export async function runPtyCommand(
command: string,
args: string[],
options: PtyCommandExecutionOptions = {},
ptySpawner: PtySpawner = spawnPty,
): Promise<PtyCommandExecutionResult> {
return new Promise((resolve, reject) => {
const displayedCommand =
options.displayCommand ?? buildDisplayedCommand(command, args);
const timeoutMs = options.timeoutMs ?? DEFAULT_PTY_COMMAND_TIMEOUT_MS;
const outputChunks: string[] = [];
let didSettle = false;
let timeoutId: NodeJS.Timeout | undefined;
let dataSubscription: { dispose(): void } = { dispose: () => {} };
let exitSubscription: { dispose(): void } = { dispose: () => {} };
const settle = (callback: () => void) => {
if (didSettle) {
return;
}
didSettle = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
dataSubscription.dispose();
exitSubscription.dispose();
callback();
};
let ptyProcess: PtyProcessLike;
try {
ptyProcess = ptySpawner(command, args, {
cols: options.cols ?? DEFAULT_PTY_COLS,
cwd: options.cwd,
env: options.env ?? process.env,
encoding: "utf8",
name: options.name ?? DEFAULT_PTY_NAME,
rows: options.rows ?? DEFAULT_PTY_ROWS,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown PTY launch failure";
reject(
new PtyCommandExecutionError({
message: `Failed to run command '${displayedCommand}': ${message}`,
}),
);
return;
}
dataSubscription = ptyProcess.onData((chunk) => {
outputChunks.push(chunk);
});
exitSubscription = ptyProcess.onExit(({ exitCode, signal }) => {
const failed = exitCode !== 0 || hasSignal(signal);
const output = normalizePtyOutput(outputChunks.join(""), {
preserveCarriageReturnFrames: failed,
});
if (!failed) {
settle(() => resolve({ output }));
return;
}
settle(() =>
reject(
new PtyCommandExecutionError({
message: buildExitMessage(displayedCommand, exitCode, signal),
output,
exitCode,
signal,
}),
),
);
});
timeoutId = setTimeout(() => {
try {
terminatePtyProcess(ptyProcess);
} catch {
// Best effort only. The timeout error below remains the source of truth.
}
const timeoutMessage = buildTimeoutMessage(displayedCommand, timeoutMs);
const output = appendCommandMessage(
normalizePtyOutput(outputChunks.join(""), {
preserveCarriageReturnFrames: true,
}),
timeoutMessage,
);
settle(() =>
reject(
new PtyCommandExecutionError({
message: timeoutMessage,
output,
}),
),
);
}, timeoutMs);
});
}
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PtyCommandExecutionError } from "@/ipc/utils/pty_command_runner";
const { runPtyCommandMock } = vi.hoisted(() => ({
runPtyCommandMock: vi.fn(),
}));
vi.mock("@/ipc/utils/pty_command_runner", async () => {
const actual = await vi.importActual<
typeof import("@/ipc/utils/pty_command_runner")
>("@/ipc/utils/pty_command_runner");
return {
...actual,
runPtyCommand: runPtyCommandMock,
};
});
import {
buildPtyInvocation,
buildAddDependencyCommand,
detectPreferredPackageManager,
ensureSocketFirewallInstalled,
PACKAGE_MANAGER_PROBE_TIMEOUT_MS,
resolveExecutableName,
runCommand,
SOCKET_FIREWALL_PROBE_TIMEOUT_MS,
SOCKET_FIREWALL_WARNING_MESSAGE,
shouldUseCommandShell,
type CommandRunner,
type PackageManager,
} from "./socket_firewall";
async function withPlatform<T>(
platform: NodeJS.Platform,
callback: () => Promise<T>,
): Promise<T> {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
value: platform,
});
try {
return await callback();
} finally {
Object.defineProperty(process, "platform", {
configurable: true,
value: originalPlatform,
});
}
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("detectPreferredPackageManager", () => {
it("prefers pnpm when available", async () => {
const runner = vi
......@@ -16,7 +61,9 @@ describe("detectPreferredPackageManager", () => {
.mockResolvedValue({ stdout: "10.0.0", stderr: "" });
await expect(detectPreferredPackageManager(runner)).resolves.toBe("pnpm");
expect(runner).toHaveBeenCalledWith("pnpm", ["--version"]);
expect(runner).toHaveBeenCalledWith("pnpm", ["--version"], {
timeoutMs: PACKAGE_MANAGER_PROBE_TIMEOUT_MS,
});
});
it("falls back to npm when pnpm is unavailable", async () => {
......@@ -25,7 +72,9 @@ describe("detectPreferredPackageManager", () => {
.mockRejectedValue(new Error("ENOENT"));
await expect(detectPreferredPackageManager(runner)).resolves.toBe("npm");
expect(runner).toHaveBeenCalledWith("pnpm", ["--version"]);
expect(runner).toHaveBeenCalledWith("pnpm", ["--version"], {
timeoutMs: PACKAGE_MANAGER_PROBE_TIMEOUT_MS,
});
});
});
......@@ -93,12 +142,13 @@ describe("ensureSocketFirewallInstalled", () => {
available: true,
});
expect(runner).toHaveBeenCalledTimes(1);
expect(runner).toHaveBeenCalledWith("npx", [
"--prefer-offline",
"--yes",
"sfw@2.0.4",
"--help",
]);
expect(runner).toHaveBeenCalledWith(
"npx",
["--prefer-offline", "--yes", "sfw@2.0.4", "--help"],
{
timeoutMs: SOCKET_FIREWALL_PROBE_TIMEOUT_MS,
},
);
});
it("returns a warning when sfw cannot be run through npx", async () => {
......@@ -111,22 +161,70 @@ describe("ensureSocketFirewallInstalled", () => {
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
expect(runner).toHaveBeenCalledTimes(1);
expect(runner).toHaveBeenCalledWith("npx", [
"--prefer-offline",
"--yes",
"sfw@2.0.4",
"--help",
]);
expect(runner).toHaveBeenCalledWith(
"npx",
["--prefer-offline", "--yes", "sfw@2.0.4", "--help"],
{
timeoutMs: SOCKET_FIREWALL_PROBE_TIMEOUT_MS,
},
);
});
});
describe("resolveExecutableName", () => {
it("uses Windows cmd shims for package-manager commands", () => {
expect(resolveExecutableName("npx", "win32")).toBe("npx.cmd");
expect(resolveExecutableName("pnpm", "win32")).toBe("pnpm.cmd");
});
it("preserves explicit executables and Unix command names", () => {
expect(resolveExecutableName("node.exe", "win32")).toBe("node.exe");
expect(resolveExecutableName("npx", "darwin")).toBe("npx");
});
});
describe("buildPtyInvocation", () => {
it("wraps Windows .cmd shims through cmd.exe for PTY execution", () => {
expect(buildPtyInvocation("npx", ["--yes", "sfw@2.0.4"], "win32")).toEqual({
command: "cmd.exe",
args: ["/d", "/s", "/c", '"npx.cmd" "--yes" "sfw@2.0.4"'],
});
});
it("passes Unix commands directly to the PTY", () => {
expect(buildPtyInvocation("pnpm", ["add", "react"], "darwin")).toEqual({
command: "pnpm",
args: ["add", "react"],
});
});
});
describe("shouldUseCommandShell", () => {
it("uses a shell on Windows so npm-style .cmd shims can execute", () => {
expect(shouldUseCommandShell("win32")).toBe(true);
describe("runCommand", () => {
it("preserves the original command in Windows-facing PTY errors", async () => {
await withPlatform("win32", async () => {
runPtyCommandMock.mockRejectedValueOnce(
new PtyCommandExecutionError({
message: "Command 'npx --yes sfw@2.0.4' exited with code 1",
output: "npm ERR! ERESOLVE unable to resolve dependency tree",
exitCode: 1,
}),
);
await expect(
runCommand("npx", ["--yes", "sfw@2.0.4"]),
).rejects.toMatchObject({
message: "Command 'npx --yes sfw@2.0.4' exited with code 1",
stdout: "npm ERR! ERESOLVE unable to resolve dependency tree",
exitCode: 1,
});
it("avoids the shell on Unix platforms", () => {
expect(shouldUseCommandShell("darwin")).toBe(false);
expect(shouldUseCommandShell("linux")).toBe(false);
expect(runPtyCommandMock).toHaveBeenCalledWith(
"cmd.exe",
["/d", "/s", "/c", '"npx.cmd" "--yes" "sfw@2.0.4"'],
expect.objectContaining({
displayCommand: "npx --yes sfw@2.0.4",
}),
);
});
});
});
import { spawn } from "node:child_process";
import {
DEFAULT_PTY_COMMAND_TIMEOUT_MS,
PtyCommandExecutionError,
runPtyCommand,
} from "@/ipc/utils/pty_command_runner";
export const SOCKET_FIREWALL_WARNING_MESSAGE =
"the npm firewall could not be installed. Warning: can not check if npm packages are safe";
......@@ -8,10 +12,15 @@ const SOCKET_FIREWALL_NPX_ARGS = [
"--yes",
SOCKET_FIREWALL_PACKAGE,
];
const WINDOWS_BATCH_COMMAND_PATTERN = /\.(cmd|bat)$/i;
export const SOCKET_FIREWALL_PROBE_TIMEOUT_MS = 30 * 1000;
export const PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 30 * 1000;
export const ADD_DEPENDENCY_INSTALL_TIMEOUT_MS = DEFAULT_PTY_COMMAND_TIMEOUT_MS;
export interface CommandExecutionOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
}
export interface CommandExecutionResult {
......@@ -19,6 +28,10 @@ export interface CommandExecutionResult {
stderr: string;
}
function buildCommandDisplay(command: string, args: string[]): string {
return [command, ...args].join(" ");
}
export class CommandExecutionError extends Error {
stdout: string;
stderr: string;
......@@ -51,69 +64,85 @@ export type CommandRunner = (
export type PackageManager = "pnpm" | "npm";
export function shouldUseCommandShell(
export function resolveExecutableName(
command: string,
platform: NodeJS.Platform = process.platform,
): boolean {
return platform === "win32";
}
export function resolveExecutableName(command: string): string {
if (process.platform === "win32" && !command.includes(".")) {
): string {
if (platform === "win32" && !command.includes(".")) {
return `${command}.cmd`;
}
return command;
}
function quoteWindowsCmdArg(value: string): string {
return `"${value.replace(/"/g, '""')}"`;
}
export function buildPtyInvocation(
command: string,
args: string[],
platform: NodeJS.Platform = process.platform,
comSpec = process.env.ComSpec ?? "cmd.exe",
): { command: string; args: string[] } {
const resolvedCommand = resolveExecutableName(command, platform);
if (
platform === "win32" &&
WINDOWS_BATCH_COMMAND_PATTERN.test(resolvedCommand)
) {
return {
command: comSpec,
args: [
"/d",
"/s",
"/c",
[resolvedCommand, ...args].map(quoteWindowsCmdArg).join(" "),
],
};
}
return {
command: resolvedCommand,
args,
};
}
export async function runCommand(
command: string,
args: string[],
options: CommandExecutionOptions = {},
): Promise<CommandExecutionResult> {
return new Promise((resolve, reject) => {
const child = spawn(resolveExecutableName(command), args, {
try {
const invocation = buildPtyInvocation(command, args);
const { output } = await runPtyCommand(
invocation.command,
invocation.args,
{
cwd: options.cwd,
displayCommand: buildCommandDisplay(command, args),
env: options.env,
shell: shouldUseCommandShell(),
stdio: "pipe",
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
reject(
new CommandExecutionError({
message: `Failed to run command '${command} ${args.join(" ")}': ${error.message}`,
stdout,
stderr,
}),
timeoutMs: options.timeoutMs,
},
);
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
return {
stdout: output,
stderr: "",
};
} catch (error) {
if (error instanceof PtyCommandExecutionError) {
throw new CommandExecutionError({
message: error.message,
stdout: error.output,
exitCode: error.exitCode,
});
}
reject(
new CommandExecutionError({
message: `Command '${command} ${args.join(" ")}' exited with code ${code ?? "unknown"}`,
stdout,
stderr,
exitCode: code,
}),
);
});
const message = error instanceof Error ? error.message : String(error);
throw new CommandExecutionError({
message: `Failed to run command '${buildCommandDisplay(command, args)}': ${message}`,
});
}
}
export function getCommandExecutionDisplayDetails(
......@@ -143,7 +172,9 @@ export async function ensureSocketFirewallInstalled(
warningMessage?: string;
}> {
try {
await runner("npx", [...SOCKET_FIREWALL_NPX_ARGS, "--help"]);
await runner("npx", [...SOCKET_FIREWALL_NPX_ARGS, "--help"], {
timeoutMs: SOCKET_FIREWALL_PROBE_TIMEOUT_MS,
});
return { available: true };
} catch {
return {
......@@ -157,7 +188,9 @@ export async function detectPreferredPackageManager(
runner: CommandRunner = runCommand,
): Promise<PackageManager> {
try {
await runner("pnpm", ["--version"]);
await runner("pnpm", ["--version"], {
timeoutMs: PACKAGE_MANAGER_PROBE_TIMEOUT_MS,
});
return "pnpm";
} catch {
return "npm";
......
......@@ -10,7 +10,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
external: ["better-sqlite3"],
external: ["better-sqlite3", "node-pty"],
},
},
plugins: [
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论