diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 768c6c6bf..05ae4d42f 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -40,6 +40,7 @@ export interface IdleAgentSeedClient { provider: string; model: string; modeId: string; + featureValues?: Record; cwd: string; workspaceId: string; title: string; @@ -58,7 +59,11 @@ export async function createIdleAgent( const created = await client.createAgent({ provider: "opencode", model: "opencode/gpt-5-nano", - modeId: "bypassPermissions", + // OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with + // auto_accept for unattended full access — mode validation now rejects modes + // the provider doesn't define. + modeId: "build", + featureValues: { auto_accept: true }, cwd: input.cwd, workspaceId: input.workspaceId, title: input.title, diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 0a539a439..5dd113688 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -132,13 +132,20 @@ function buildCreateAgentOptions({ workspaceId: string; provider: CreateAgentRequestOptions["provider"]; }): CreateAgentRequestOptions { + // Reconcile the selected mode against the discovered modes. The mode picker + // shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale + // globally-remembered mode this workspace's provider config no longer + // defines), so the submitted mode must match that display rather than send a + // stale mode the provider would reject. + const modeOptionIds = composerState.modeOptions.map((mode) => mode.id); + const reconciledMode = modeOptionIds.includes(composerState.selectedMode) + ? composerState.selectedMode + : (modeOptionIds[0] ?? ""); return { provider, cwd: workspaceDirectory, workspaceId, - ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" - ? { modeId: composerState.selectedMode } - : {}), + ...(reconciledMode !== "" ? { modeId: reconciledMode } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 1a0338f75..4398448cc 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -87,32 +87,46 @@ function resolveAutoSubmitConfig( }; } +// Reconcile the form's selected mode against the currently discovered modes. +// The mode picker displays modeOptions[0] when the stored mode isn't in the +// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config +// no longer defines), so the submitted mode must match that display — otherwise +// we'd send a stale mode the provider rejects while the UI showed a valid one. +function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string { + if (modeOptionIds.length === 0) { + return ""; + } + return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? ""); +} + function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionsCount: number; + modeOptionIds: readonly string[]; selectedMode: string; }): { modeId: string } | Record { - const { autoSubmitConfig, modeOptionsCount, selectedMode } = input; + const { autoSubmitConfig, modeOptionIds, selectedMode } = input; if (autoSubmitConfig?.modeId) { return { modeId: autoSubmitConfig.modeId }; } - if (modeOptionsCount > 0 && selectedMode !== "") { - return { modeId: selectedMode }; + const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); + if (reconciled !== "") { + return { modeId: reconciled }; } return {}; } function resolveDraftModeId(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionsCount: number; + modeOptionIds: readonly string[]; selectedMode: string; }): string | null { - const { autoSubmitConfig, modeOptionsCount, selectedMode } = input; + const { autoSubmitConfig, modeOptionIds, selectedMode } = input; if (autoSubmitConfig?.modeId !== undefined) { return autoSubmitConfig.modeId; } - if (modeOptionsCount > 0 && selectedMode !== "") { - return selectedMode; + const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); + if (reconciled !== "") { + return reconciled; } return null; } @@ -130,7 +144,7 @@ async function submitDraftCreateRequest(input: { composerState: { selectedProvider: string | null; selectedMode: string; - modeOptions: unknown[]; + modeOptions: readonly { id: string }[]; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; featureValues: Record | undefined; @@ -163,7 +177,7 @@ async function submitDraftCreateRequest(input: { } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, - modeOptionsCount: composerState.modeOptions.length, + modeOptionIds: composerState.modeOptions.map((mode) => mode.id), selectedMode: composerState.selectedMode, }); const config = buildWorkspaceDraftAgentConfig({ @@ -202,7 +216,7 @@ function buildDraftAgentSnapshot(input: { composerState: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - modeOptions: unknown[]; + modeOptions: readonly { id: string }[]; selectedMode: string; selectedProvider: string | null; agentControls: { features?: Agent["features"] }; @@ -217,7 +231,7 @@ function buildDraftAgentSnapshot(input: { autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null); const modeId = resolveDraftModeId({ autoSubmitConfig, - modeOptionsCount: composerState.modeOptions.length, + modeOptionIds: composerState.modeOptions.map((mode) => mode.id), selectedMode: composerState.selectedMode, }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 35af5b1d9..b66f18f47 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -198,7 +198,10 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ id: "opencode", label: "OpenCode", description: "Open-source coding assistant with multi-provider model support", - defaultModeId: "build", + // No static default: OpenCode users can rename or delete any agent, + // including "build". Leaving this unset means the daemon and OpenCode + // itself decide (see normalizeOpenCodeModeId in opencode-agent.ts). + defaultModeId: null, modes: OPENCODE_MODES, voice: { enabled: true, diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 2c264501f..5b51fe521 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -58,9 +58,7 @@ test("session create forwards clientMessageId to the initial prompt run options" } as unknown as Parameters[0]["agentManager"], agentStorage: {} as Parameters[0]["agentStorage"], logger: createTestLogger(), - providerSnapshotManager: {} as Parameters< - typeof createAgentCommand - >[0]["providerSnapshotManager"], + providerSnapshotManager: createProviderSnapshotManagerStub().manager, }; await createAgentCommand(dependencies, { @@ -80,6 +78,92 @@ test("session create forwards clientMessageId to the initial prompt run options" }); }); +test("session create validates the requested mode against the provider's modes", async () => { + const snapshot = { + id: "agent-1", + provider: "opencode", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const stub = createProviderSnapshotManagerStub(); + stub.resolveCreateConfig.mockRejectedValue( + new Error("Invalid mode 'plan' for provider 'opencode'. Available modes: build, myplan"), + ); + const dependencies: Parameters[0] = { + agentManager: { + createAgent, + } as unknown as Parameters[0]["agentManager"], + agentStorage: {} as Parameters[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: stub.manager, + }; + + await expect( + createAgentCommand(dependencies, { + kind: "session", + config: { provider: "opencode", cwd: "/tmp/paseo-create-test", modeId: "plan" }, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ sessionConfig: config }), + }), + ).rejects.toThrow("Invalid mode 'plan'"); + + expect(stub.resolveCreateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "opencode", + cwd: "/tmp/paseo-create-test", + requestedMode: "plan", + }), + ); + expect(createAgent).not.toHaveBeenCalled(); +}); + +test("session create applies the resolved mode from the provider create config", async () => { + const snapshot = { + id: "agent-1", + provider: "opencode", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const stub = createProviderSnapshotManagerStub(); + stub.resolveCreateConfig.mockResolvedValue({ + modeId: "build", + featureValues: { auto_accept: true }, + }); + const dependencies: Parameters[0] = { + agentManager: { + createAgent, + getAgent: vi.fn(() => snapshot), + } as unknown as Parameters[0]["agentManager"], + agentStorage: {} as Parameters[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: stub.manager, + }; + + await createAgentCommand(dependencies, { + kind: "session", + config: { provider: "opencode", cwd: "/tmp/paseo-create-test", modeId: "build" }, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config) => ({ sessionConfig: config }), + }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + modeId: "build", + featureValues: { auto_accept: true }, + }), + undefined, + expect.anything(), + ); +}); + test("mcp create accepts provider-only internal input and leaves model undefined", async () => { const snapshot = { id: "agent-1", diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 62b06bfcd..60c4d2df6 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -217,12 +217,42 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { const trimmedPrompt = input.initialPrompt?.trim(); - const { sessionConfig, setupContinuation, createdWorkspaceId } = await input.buildSessionConfig( + const { + sessionConfig: builtSessionConfig, + setupContinuation, + createdWorkspaceId, + } = await input.buildSessionConfig( input.config, input.git, input.worktreeName, input.firstAgentContext, ); + // Validate the requested mode against the provider's modes for the resolved + // cwd. The app remembers mode preferences globally, so a saved mode can be + // stale for a workspace whose provider config no longer defines it — reject + // it here instead of letting the provider fail mid-turn. + // + // This runs after buildSessionConfig, which may already have created a + // worktree and/or workspace record — cwd (required to resolve modes) is + // only known once that step completes. If validation throws, any + // worktree/workspace buildSessionConfig created is the caller's + // responsibility to clean up (session.ts's handleCreateAgentRequest does + // this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate; + // this is a pre-existing gap for directory-only workspace creates, not + // introduced by this validation). + const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ + cwd: builtSessionConfig.cwd, + provider: builtSessionConfig.provider, + requestedMode: builtSessionConfig.modeId, + featureValues: builtSessionConfig.featureValues, + parent: null, + unattended: false, + }); + const sessionConfig: AgentSessionConfig = { + ...builtSessionConfig, + modeId: resolvedCreateConfig.modeId, + featureValues: resolvedCreateConfig.featureValues, + }; const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments); const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0; const clientMessageId = normalizeClientMessageId(input.clientMessageId); diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 0605ad02d..d1a5de6f8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -90,7 +90,7 @@ describe("OpenCode auto_accept feature", () => { expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]); }); - test("falls back to default OpenCode modes when discovery returns no modes", async () => { + test("returns no modes when discovery finds none, rather than fabricating defaults", async () => { const { runtime } = mockOpenCodeClient({ agents: [] }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { @@ -103,7 +103,9 @@ describe("OpenCode auto_accept feature", () => { force: false, }); - expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]); + // OpenCode users can rename/delete any agent, so a hardcoded fallback could + // validate a mode that doesn't exist. Empty is the honest answer. + expect(modes).toEqual([]); }); test("lists auto accept as a provider feature", async () => { @@ -179,9 +181,12 @@ describe("OpenCode auto_accept feature", () => { ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); }); - test("inherits unattended callers as build plus auto accept", () => { + test("inherits unattended callers as auto accept with OpenCode's default agent", () => { const client = new OpenCodeAgentClient(createTestLogger()); + // Unattendedness is carried by auto_accept, not by a specific agent. The + // mode stays unset so OpenCode picks its own default agent — `build` may + // not exist in the user's OpenCode config. expect( client.resolveCreateConfig({ provider: "opencode", @@ -198,10 +203,10 @@ describe("OpenCode auto_accept feature", () => { { id: "plan", label: "Plan" }, ], }), - ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + ).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); }); - test("defaults unattended creation without a parent to build plus auto accept", () => { + test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => { const client = new OpenCodeAgentClient(createTestLogger()); expect( @@ -216,7 +221,7 @@ describe("OpenCode auto_accept feature", () => { { id: "plan", label: "Plan" }, ], }), - ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + ).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); }); test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => { diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index d3b0e179f..2cccfcbee 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -238,9 +238,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { sessionID: "session-1", directory: cwd, model: { providerID: "opencode", modelID: "big-pickle" }, - agent: "build", }), ]); + // No modeId configured → no agent field: OpenCode must fall back to its + // own default agent instead of Paseo assuming any particular agent exists. + expect(openCodeClient.calls.sessionPromptAsync[0]).not.toHaveProperty("agent"); await session.close(); rmSync(cwd, { recursive: true, force: true }); @@ -512,10 +514,17 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { expect(maxActiveProviderListCalls).toBeLessThanOrEqual(4); }); - test("available modes include build and plan", async () => { + test("available modes reflect the agents OpenCode discovers", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.appAgentsResponse = { + data: [ + { name: "build", mode: "primary" }, + { name: "plan", mode: "primary" }, + ], + }; + runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(logger, undefined, { serverManager: runtime, createClient: runtime.createClient, @@ -531,6 +540,27 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 60_000); + test("available modes are empty when OpenCode discovers no agents", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + // Default TestOpenCodeClient returns no agents. Discovery failure/empty + // must not fabricate modes — OpenCode users can rename/delete any agent, + // so a hardcoded fallback could validate a mode that doesn't exist. + runtime.enqueueClient(new TestOpenCodeClient()); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession(buildConfig(cwd)); + + const modes = await session.getAvailableModes(); + + expect(modes).toEqual([]); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, 60_000); + test("custom agents defined in opencode.json appear in available modes", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 35aaa6c53..b70fdfa76 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -180,7 +180,10 @@ function resolveOpenCodeCreateConfig( : input.featureValues; if (inheritsUnattended && requestedMode === undefined) { - return { modeId: OPENCODE_BUILD_MODE_ID, featureValues }; + // Unattendedness for OpenCode is carried by auto_accept (set above), not + // by any particular agent. Leave the mode unset so OpenCode uses its own + // default agent — `build` may not exist in the user's OpenCode config. + return { modeId: undefined, featureValues }; } const resolved = resolveDefaultAgentCreateConfig({ @@ -570,16 +573,23 @@ function matchesHydratedFingerprint( return hydratedFingerprint === JSON.stringify(value); } -function normalizeOpenCodeModeId(modeId: string | null | undefined): string { +// `null` = no explicit mode. The `agent` field is then omitted from OpenCode +// prompt/command calls so OpenCode falls back to its own configured default +// agent — never assume any particular agent (even `build`) exists, since +// OpenCode users can define or delete agents at will. +function normalizeOpenCodeModeId(modeId: string | null | undefined): string | null { const trimmed = typeof modeId === "string" ? modeId.trim() : ""; if (!trimmed || trimmed === "default") { - return OPENCODE_BUILD_MODE_ID; + return null; } return trimmed; } -function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string { +function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string | undefined { const normalizedModeId = normalizeOpenCodeModeId(modeId); + if (normalizedModeId === null) { + return undefined; + } return normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID ? OPENCODE_BUILD_MODE_ID : normalizedModeId; @@ -636,11 +646,10 @@ function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] { ); // When discovery returns results, trust them exactly — don't inject hardcoded // defaults that the user may have intentionally disabled in their OpenCode config. - // Fall back to DEFAULT_MODES only when discovery produced nothing. - if (filtered.length > 0) { - return sortOpenCodeModes(filtered); - } - return sortOpenCodeModes([...DEFAULT_MODES]); + // When discovery produced nothing, return empty rather than fabricating modes: + // OpenCode users can rename or delete any agent, so a hardcoded fallback can + // validate a mode that does not actually exist (failing later at prompt time). + return sortOpenCodeModes(filtered); } function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] { @@ -1152,7 +1161,7 @@ function resolveOpenCodePersistedSessionModeId( messages: ReadonlyArray, ): string | undefined { const agent = session.agent ?? messages.map(readOpenCodeMessageAgent).find(Boolean); - return agent ? normalizeOpenCodeModeId(agent) : undefined; + return agent ? (normalizeOpenCodeModeId(agent) ?? undefined) : undefined; } function readOpenCodeMessageAgent(message: OpenCodeSessionMessage): string | undefined { @@ -1635,7 +1644,11 @@ export class OpenCodeAgentClient implements AgentClient { ); if (response.error || !response.data) { - return DEFAULT_MODES; + // Discovery failed — return an empty list rather than fabricating + // modes. OpenCode users can rename or delete any agent (including + // "build"/"plan"), so a hardcoded fallback can validate a mode that + // does not actually exist, which then fails at prompt time. + return []; } const discovered = response.data.filter(isSelectableOpenCodeAgent).map(mapOpenCodeAgentToMode); @@ -2846,7 +2859,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly sessionId: string; private readonly logger: Logger; private readonly modelContextWindowsByModelKey: ReadonlyMap; - private currentMode: string = "default"; + private currentMode: string | null = null; private autoAcceptEnabled = false; private pendingPermissions = new Map(); private abortController: AbortController | null = null; @@ -3771,7 +3784,7 @@ class OpenCodeAgentSession implements AgentSession { } this.currentMode = normalizedModeId; - this.config.modeId = normalizedModeId; + this.config.modeId = normalizedModeId ?? undefined; } async setFeature(featureId: string, value: unknown): Promise { diff --git a/patches/@opencode-ai+sdk+1.14.46.patch b/patches/@opencode-ai+sdk+1.14.46.patch new file mode 100644 index 000000000..65ce69009 --- /dev/null +++ b/patches/@opencode-ai+sdk+1.14.46.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js b/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js +index 0000000..0000000 100644 +--- a/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js ++++ b/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js +@@ -26,7 +26,13 @@ + let buffer = ""; + const abortHandler = () => { + try { +- void reader.cancel(); ++ // reader.cancel() returns a promise that can reject (e.g. it races ++ // with fetch's own abort teardown of the same stream). Swallow that ++ // rejection explicitly — otherwise it surfaces as an unhandled ++ // promise rejection in the host process. ++ void reader.cancel().catch(() => { ++ // noop ++ }); + } + catch { + // noop +diff --git a/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js b/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js +index 0000000..0000000 100644 +--- a/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js ++++ b/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js +@@ -40,7 +40,13 @@ + let buffer = ""; + const abortHandler = () => { + try { +- reader.cancel(); ++ // reader.cancel() returns a promise that can reject (e.g. it races ++ // with fetch's own abort teardown of the same stream). Swallow that ++ // rejection explicitly — otherwise it surfaces as an unhandled ++ // promise rejection in the host process. ++ void reader.cancel().catch(() => { ++ // noop ++ }); + } + catch { + // noop diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 882b8be3a..781294b6d 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -1,9 +1,12 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { join } from "node:path"; +import { join, relative } from "node:path"; // In CI we often install a single workspace (e.g. server/relay/website). Only apply patches // when the patched dependency is actually present. +// `cwd` is where patch-package must run from. Packages that npm does not hoist to the +// workspace root live in their workspace's own node_modules, and patch-package resolves +// the patch's node_modules/... paths relative to its working directory. const patchedPackages = [ { nodeModulesPath: "node_modules/react-native-markdown-display", @@ -17,44 +20,70 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+", }, + { + nodeModulesPath: "packages/server/node_modules/@opencode-ai/sdk", + patchPrefix: "@opencode-ai+sdk+", + cwd: "packages/server", + }, ]; -const installedPatchPrefixes = patchedPackages - .filter(({ nodeModulesPath }) => existsSync(nodeModulesPath)) - .map(({ patchPrefix }) => patchPrefix); +const installedPackages = patchedPackages.filter(({ nodeModulesPath }) => + existsSync(nodeModulesPath), +); -if (!existsSync("patches") || installedPatchPrefixes.length === 0) { +if (!existsSync("patches") || installedPackages.length === 0) { process.exit(0); } -const patchFilesToApply = readdirSync("patches").filter( - (file) => - file.endsWith(".patch") && - installedPatchPrefixes.some((patchPrefix) => file.startsWith(patchPrefix)), -); +const patchFiles = readdirSync("patches").filter((file) => file.endsWith(".patch")); -if (patchFilesToApply.length === 0) { +// Group patch files by the directory patch-package must run from. +const patchFilesByCwd = new Map(); +for (const { patchPrefix, cwd = "." } of installedPackages) { + const files = patchFiles.filter((file) => file.startsWith(patchPrefix)); + if (files.length === 0) { + continue; + } + const group = patchFilesByCwd.get(cwd) ?? []; + group.push(...files); + patchFilesByCwd.set(cwd, group); +} + +if (patchFilesByCwd.size === 0) { process.exit(0); } const isWindows = process.platform === "win32"; const cmd = isWindows ? "patch-package.cmd" : "patch-package"; -const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}`); -mkdirSync(tempPatchDir, { recursive: true }); -for (const patchFile of patchFilesToApply) { - copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile)); +let groupIndex = 0; +for (const [cwd, files] of patchFilesByCwd) { + groupIndex += 1; + const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${groupIndex}`); + + mkdirSync(tempPatchDir, { recursive: true }); + for (const patchFile of files) { + copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile)); + } + + let result; + try { + result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir)], { + cwd, + shell: isWindows, + stdio: "inherit", + windowsHide: true, + }); + } finally { + rmSync(tempPatchDir, { recursive: true, force: true }); + } + + if (result.error) { + console.error("postinstall-patches: patch-package failed to spawn:", result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } } -let result; -try { - result = spawnSync(cmd, ["--patch-dir", tempPatchDir], { - shell: isWindows, - stdio: "inherit", - windowsHide: true, - }); -} finally { - rmSync(tempPatchDir, { recursive: true, force: true }); -} - -process.exit(result.status ?? 1); +process.exit(0);