Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 11 additions & 2 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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: [
Expand Down
21 changes: 19 additions & 2 deletions package-lock.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "dyad",
"version": "0.42.0",
"version": "0.43.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dyad",
"version": "0.42.0",
"version": "0.43.0-beta.1",
"license": "MIT",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions rules/e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions rules/native-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 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.
146 changes: 146 additions & 0 deletions src/ipc/utils/pty_command_runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
normalizePtyOutput,
PtyCommandExecutionError,
runPtyCommand,
} from "./pty_command_runner";

const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
}));

vi.mock("node-pty", () => ({
spawn: spawnMock,
}));

interface MockPtyController {
emitData(data: string): void;
emitExit(event: { exitCode: number; signal?: number }): void;
pty: {
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: {
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),
};
},
),
},
};
}

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");
});
});

describe("runPtyCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});

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("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 rejection = expect(promise).rejects.toMatchObject({
exitCode: null,
message: "Command 'npx sfw' timed out after 25ms",
output: "still running",
} satisfies Partial<PtyCommandExecutionError>);

await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(controller.pty.kill).toHaveBeenCalledTimes(1);
});
});
Loading
Loading