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
121 changes: 121 additions & 0 deletions e2e-tests/package_manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ const upgradePnpmTestSkipIfWindows = testWithConfigSkipIfWindows({
postLaunchHook: restorePackageManagerCache,
});

const realPnpmStrictBuildsTestSkipIfWindows = testWithConfigSkipIfWindows({
preLaunchHook: async ({ userDataDir, fakeLlmPort }) => {
execFileSync("pnpm", ["--version"], { encoding: "utf8" });
await configurePackageManagerCache(userDataDir);
process.env.DYAD_DEFAULT_APPROVE_BUILDS_URL = `http://localhost:${fakeLlmPort}/api/default-approve-builds.txt`;
},
postLaunchHook: restorePackageManagerCache,
});

async function openMinimalBuildChat(po: PageObject) {
await po.setUp();
await po.page.evaluate(
Expand Down Expand Up @@ -252,6 +261,62 @@ async function openMinimalBuildChat(po: PageObject) {
};
}

async function getCurrentAppId(po: PageObject): Promise<number> {
const appPath = await po.appManagement.getCurrentAppPath();
const currentAppName = await po.appManagement.getCurrentAppName();
const apps = await po.page.evaluate(async () => {
return (window as any).electron.ipcRenderer.invoke("list-apps", undefined);
});
const matchingApp = apps.apps.find(
(app: { id: number; name: string; resolvedPath?: string }) =>
app.resolvedPath === appPath || app.name === currentAppName,
);
if (!matchingApp) {
throw new Error(`Could not find current app ${currentAppName}`);
}
return matchingApp.id;
}

async function addLocalDependencyWithIgnoredBuild(appPath: string) {
const dependencyDir = path.join(appPath, "packages", "fake-build-dep");
await fs.mkdir(dependencyDir, { recursive: true });
await fs.writeFile(
path.join(dependencyDir, "package.json"),
`${JSON.stringify(
{
name: "fake-build-dep",
version: "1.0.0",
scripts: {
postinstall: "node postinstall.js",
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(dependencyDir, "postinstall.js"),
[
'require("node:fs").writeFileSync(',
' require("node:path").join(__dirname, "postinstall-ran.txt"),',
' "yes",',
");",
"",
].join("\n"),
);

const packageJsonPath = path.join(appPath, "package.json");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
packageJson.dependencies = {
...packageJson.dependencies,
"fake-build-dep": "file:./packages/fake-build-dep",
};
await fs.writeFile(
packageJsonPath,
`${JSON.stringify(packageJson, null, 2)}\n`,
);
}

function extendSocketFirewallTestTimeout(testInfo: TestInfo) {
testInfo.setTimeout(SOCKET_FIREWALL_TEST_TIMEOUT);
}
Expand Down Expand Up @@ -476,3 +541,59 @@ upgradePnpmTestSkipIfWindows(
await expect(warningBanner).toBeHidden({ timeout: Timeout.MEDIUM });
},
);

realPnpmStrictBuildsTestSkipIfWindows(
"custom pnpm install auto-denies ignored builds and recovers preview",
async ({ po }, testInfo) => {
testInfo.setTimeout(SOCKET_FIREWALL_TEST_TIMEOUT);

await po.setUp();
await po.importApp("minimal");
await po.previewPanel.expectPreviewIframeIsVisible(
SOCKET_FIREWALL_TEST_TIMEOUT,
);

const appPath = await po.appManagement.getCurrentAppPath();
const appId = await getCurrentAppId(po);
await addLocalDependencyWithIgnoredBuild(appPath);

await po.page.evaluate(
async ({ appId }) => {
await (window as any).electron.ipcRenderer.invoke(
"update-app-commands",
{
appId,
installCommand: "pnpm --config.strictDepBuilds=true install",
startCommand: "pnpm run dev -- --host 127.0.0.1",
},
);
},
{ appId },
);

await po.previewPanel.clickRebuild();
await expect(po.previewPanel.locateLoadingAppPreview()).toBeVisible({
timeout: Timeout.MEDIUM,
});
await po.previewPanel.expectPreviewIframeIsVisible(
SOCKET_FIREWALL_TEST_TIMEOUT,
);

const pnpmWorkspaceConfig = await fs.readFile(
path.join(appPath, "pnpm-workspace.yaml"),
"utf8",
);
expect(pnpmWorkspaceConfig).toContain(
"fake-build-dep: false # dyad-auto-denied",
);
await expect(async () => {
const modulesConfig = await fs.readFile(
path.join(appPath, "node_modules", ".modules.yaml"),
"utf8",
);
expect(modulesConfig).not.toContain(
"fake-build-dep@file:packages/fake-build-dep",
);
}).toPass({ timeout: Timeout.EXTRA_LONG });
},
);
187 changes: 187 additions & 0 deletions plans/pnpm-auto-deny-ignored-builds.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/__tests__/app_upgrade_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ vi.mock("../ipc/utils/git_utils", () => ({
gitCommit: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("../ipc/utils/telemetry", () => ({
sendTelemetryEvent: vi.fn(),
}));

describe("isComponentTaggerUpgradeNeeded Heuristics", () => {
const mockPath = "/mock/app";

Expand Down
48 changes: 36 additions & 12 deletions src/ipc/handlers/app_upgrade_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
isComponentTaggerUpgradeNeeded,
applyComponentTagger,
simpleSpawnWithDeniedPnpmBuildSelfHeal,
} from "../utils/app_upgrade_utils";
import fs from "node:fs";
import path from "node:path";
Expand Down Expand Up @@ -87,12 +88,23 @@ async function applyCapacitor({
appPath: string;
}) {
// Install Capacitor dependencies
await simpleSpawn({
command: `pnpm ${PNPM_PM_ON_FAIL_IGNORE_ARG} add --ignore-workspace-root-check @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 || npm install @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 --legacy-peer-deps`,
cwd: appPath,
successMessage: "Capacitor dependencies installed successfully",
errorPrefix: "Failed to install Capacitor dependencies",
});
try {
await simpleSpawnWithDeniedPnpmBuildSelfHeal({
command: `pnpm ${PNPM_PM_ON_FAIL_IGNORE_ARG} add --ignore-workspace-root-check @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4`,
cwd: appPath,
successMessage: "Capacitor dependencies installed successfully with pnpm",
errorPrefix: "Failed to install Capacitor dependencies via pnpm",
});
} catch (pnpmErr) {
logger.info("pnpm install failed, falling back to npm", pnpmErr);
await simpleSpawn({
command:
"npm install @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 --legacy-peer-deps",
cwd: appPath,
successMessage: "Capacitor dependencies installed successfully with npm",
errorPrefix: "Failed to install Capacitor dependencies",
});
}

// Initialize Capacitor
await simpleSpawn({
Expand All @@ -105,12 +117,24 @@ async function applyCapacitor({
// Intentionally omit PNPM_INSTALL_POLICY_ARGS because:
// 1. confirmModulesPurge will almost never be needed for capacitor (i.e. user would need to switch from npm to pnpm and not triggered a rebuild).
// 2. strictBuildDeps should be kept true (default value) in case capacitor has native deps.
await simpleSpawn({
command: `pnpm ${PNPM_PM_ON_FAIL_IGNORE_ARG} install --prod=false || npm install --include=dev --legacy-peer-deps`,
cwd: appPath,
successMessage: "Development dependencies installed successfully",
errorPrefix: "Failed to install development dependencies",
});
try {
await simpleSpawnWithDeniedPnpmBuildSelfHeal({
command: `pnpm ${PNPM_PM_ON_FAIL_IGNORE_ARG} install --prod=false`,
cwd: appPath,
successMessage:
"Development dependencies installed successfully with pnpm",
errorPrefix: "Failed to install development dependencies via pnpm",
});
} catch (pnpmErr) {
logger.info("pnpm install failed, falling back to npm", pnpmErr);
await simpleSpawn({
command: "npm install --include=dev --legacy-peer-deps",
cwd: appPath,
successMessage:
"Development dependencies installed successfully with npm",
errorPrefix: "Failed to install development dependencies",
});
}

// Add iOS and Android platforms
await simpleSpawn({
Expand Down
61 changes: 60 additions & 1 deletion src/ipc/processors/executeAddDependency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@ const {
commitPnpmAllowBuildsConfigIfChangedMock,
ensureSocketFirewallInstalledMock,
getPnpmMinimumReleaseAgeSupportMock,
readPnpmIgnoredBuildsMock,
recordDeniedPnpmBuildsMock,
runCommandMock,
sendTelemetryEventMock,
readEffectiveSettingsMock,
dbUpdateSetMock,
dbUpdateWhereMock,
} = vi.hoisted(() => ({
commitPnpmAllowBuildsConfigIfChangedMock: vi.fn(),
ensureSocketFirewallInstalledMock: vi.fn(),
getPnpmMinimumReleaseAgeSupportMock: vi.fn(),
readPnpmIgnoredBuildsMock: vi.fn(),
recordDeniedPnpmBuildsMock: vi.fn(),
runCommandMock: vi.fn(),
sendTelemetryEventMock: vi.fn(),
readEffectiveSettingsMock: vi.fn(),
dbUpdateSetMock: vi.fn(),
dbUpdateWhereMock: vi.fn(),
Expand Down Expand Up @@ -58,10 +64,16 @@ vi.mock("@/ipc/utils/socket_firewall", async () => {
commitPnpmAllowBuildsConfigIfChangedMock,
ensureSocketFirewallInstalled: ensureSocketFirewallInstalledMock,
getPnpmMinimumReleaseAgeSupport: getPnpmMinimumReleaseAgeSupportMock,
readPnpmIgnoredBuilds: readPnpmIgnoredBuildsMock,
recordDeniedPnpmBuilds: recordDeniedPnpmBuildsMock,
runCommand: runCommandMock,
};
});

vi.mock("@/ipc/utils/telemetry", () => ({
sendTelemetryEvent: sendTelemetryEventMock,
}));

describe("executeAddDependency", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -74,7 +86,11 @@ describe("executeAddDependency", () => {
minimumReleaseAgeSupported: true,
version: "10.16.0",
});
commitPnpmAllowBuildsConfigIfChangedMock.mockResolvedValue(undefined);
commitPnpmAllowBuildsConfigIfChangedMock.mockResolvedValue({
promotedPackages: [],
});
readPnpmIgnoredBuildsMock.mockResolvedValue([]);
recordDeniedPnpmBuildsMock.mockResolvedValue({ deniedBuilds: [] });
readEffectiveSettingsMock.mockResolvedValue({
blockUnsafeNpmPackages: true,
});
Expand Down Expand Up @@ -578,4 +594,47 @@ describe("executeAddDependency", () => {
'<dyad-add-dependency packages="react-safe">installed &lt;react&gt;</dyad-add-dependency>',
});
});

it("records denied pnpm builds and surfaces the security-policy note", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
});
runCommandMock.mockResolvedValueOnce({
stdout: "installed via pnpm",
stderr: "",
});
readPnpmIgnoredBuildsMock.mockResolvedValue([
{ packageName: "core-js", packageSpec: "core-js@3.49.0" },
]);
recordDeniedPnpmBuildsMock.mockResolvedValue({
deniedBuilds: [{ packageName: "core-js", packageSpec: "core-js@3.49.0" }],
});

const result = await executeAddDependency({
packages: ["core-js"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="core-js"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
});

expect(result.installResults).toContain("installed via pnpm");
expect(result.installResults).toContain(
"Note: build scripts for core-js were not run (Dyad security policy).",
);
expect(sendTelemetryEventMock).toHaveBeenCalledWith(
"pnpm:build-auto-denied",
{
packages: ["core-js@3.49.0"],
source: "add-dependency",
},
);
expect(dbUpdateSetMock).toHaveBeenCalledWith({
content: expect.stringContaining(
"Note: build scripts for core-js were not run",
),
});
});
});
Loading
Loading