From bf3d83f80a436626f797f0b077576d148c2c474e Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 23 May 2026 12:55:58 -0700 Subject: [PATCH] feat(git): support stashing local changes on pull from source - Add `PullFromSourceDialog` and state store to prompt for stash confirmation - Update git pull contracts, actions, and services to support the stashing flow - Stop appending `--delete-branch` in GitHub PR merge command - Extract and centralize the `node-pty` spawn-helper executable check --- src/renderer/actions/gitActions.ts | 30 ++++-- .../state/pullFromSourceDialogStore.ts | 20 ++++ src/renderer/styles.css | 9 ++ .../parts/useGitReviewActions.ts | 11 +++ src/renderer/views/MainView/MainView.tsx | 2 + .../MainView/parts/PullFromSourceDialog.tsx | 92 ++++++++++++++++++ src/shared/contracts/git.ts | 4 + src/shared/messages.ts | Bin 8969 -> 9242 bytes src/supervisor/agents/acp/session.ts | 2 + src/supervisor/git.test.ts | 51 ++++++++++ src/supervisor/git.ts | 3 +- src/supervisor/git/mergeService.ts | 83 ++++++++++++++-- src/supervisor/github.test.ts | 4 +- src/supervisor/github.ts | 2 +- src/supervisor/nodePty.ts | 38 ++++++++ src/supervisor/runtime.ts | 6 +- .../runtime/threadSessionManager.ts | 47 +-------- 17 files changed, 337 insertions(+), 67 deletions(-) create mode 100644 src/renderer/state/pullFromSourceDialogStore.ts create mode 100644 src/renderer/views/MainView/parts/PullFromSourceDialog.tsx create mode 100644 src/supervisor/nodePty.ts diff --git a/src/renderer/actions/gitActions.ts b/src/renderer/actions/gitActions.ts index a835670c..6383dce1 100644 --- a/src/renderer/actions/gitActions.ts +++ b/src/renderer/actions/gitActions.ts @@ -3,6 +3,7 @@ import { readBridge } from "@/renderer/bridge"; import { captureRendererException } from "@/renderer/diagnostics/sentry"; import { useAppStore } from "@/renderer/state/appStore"; import { usePanelStore } from "@/renderer/state/panelStore"; +import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers"; import { closeThreads } from "@/renderer/utils/shellUtils"; @@ -12,6 +13,17 @@ function captureGitActionError(error: unknown): void { captureRendererException(error, { featureArea: "git" }); } +export function openGitReviewForWorktree(projectId: string, worktreePath: string): void { + const mode = useSharedSettings.getState().gitReviewMode; + const panelStore = usePanelStore.getState(); + panelStore.setGitReviewContext({ projectId, worktreePath }); + if (mode === "panel") { + panelStore.setGitReviewAsPanel(true); + } else { + panelStore.setGitOverlayOpen(true); + } +} + export function gitSync(projectId: string, worktreePath?: string): void { const project = useAppStore.getState().projects.find((p) => p.id === projectId); if (!project) return; @@ -141,16 +153,18 @@ export function gitPullFromSource(projectId: string, worktreePath: string): void const result = await readBridge().gitPullFromSource({ worktreeLocation, sourceBranch, + preserveLocalChanges: false, }); + if (result.needsStash) { + usePullFromSourceDialogStore.getState().setDialog({ + projectId, + worktreePath, + sourceBranch, + }); + return; + } if (result.conflicting) { - const mode = useSharedSettings.getState().gitReviewMode; - const panelStore = usePanelStore.getState(); - panelStore.setGitReviewContext({ projectId, worktreePath }); - if (mode === "panel") { - panelStore.setGitReviewAsPanel(true); - } else { - panelStore.setGitOverlayOpen(true); - } + openGitReviewForWorktree(projectId, worktreePath); } } catch (error) { captureGitActionError(error); diff --git a/src/renderer/state/pullFromSourceDialogStore.ts b/src/renderer/state/pullFromSourceDialogStore.ts new file mode 100644 index 00000000..10fa5c5d --- /dev/null +++ b/src/renderer/state/pullFromSourceDialogStore.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +export interface PullFromSourceDialogState { + projectId: string; + worktreePath: string; + sourceBranch: string; + onComplete?: () => void; +} + +interface PullFromSourceDialogStore { + dialog: PullFromSourceDialogState | null; + setDialog: (dialog: PullFromSourceDialogState) => void; + closeDialog: () => void; +} + +export const usePullFromSourceDialogStore = create()((set) => ({ + dialog: null, + setDialog: (dialog) => set({ dialog }), + closeDialog: () => set({ dialog: null }), +})); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index abe4aaf7..9d843b48 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -2,6 +2,15 @@ @import "@heroui/styles"; @import "@xterm/xterm/css/xterm.css"; +:root { + --lightcode-modal-radius: 0.7rem; +} + +.modal__dialog:not(.modal__dialog--cover):not(.modal__dialog--full), +.alert-dialog__dialog:not(.alert-dialog__dialog--cover) { + border-radius: var(--lightcode-modal-radius); +} + @property --lc-chat-bottom-mask-end-alpha { syntax: ""; initial-value: 0; diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts index 6ed60f0c..a2156d06 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts @@ -8,6 +8,7 @@ import { readBridge } from "@/renderer/bridge"; import { captureProductEvent } from "@/renderer/analytics/posthog"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useGitStore } from "@/renderer/state/gitStore"; +import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { generateCommitMessageWithFallback, @@ -357,7 +358,17 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { const result = await readBridge().gitPullFromSource({ worktreeLocation: getWorktreeLocation(), sourceBranch, + preserveLocalChanges: false, }); + if (result.needsStash && worktreePath) { + usePullFromSourceDialogStore.getState().setDialog({ + projectId: project.id, + worktreePath, + sourceBranch, + onComplete: onRefresh, + }); + return; + } if (result.conflicting) { onRefresh(); return; diff --git a/src/renderer/views/MainView/MainView.tsx b/src/renderer/views/MainView/MainView.tsx index be02eba0..9c9c90b5 100644 --- a/src/renderer/views/MainView/MainView.tsx +++ b/src/renderer/views/MainView/MainView.tsx @@ -19,6 +19,7 @@ import { useBrowserSync } from "@/renderer/views/MainView/parts/RightPanel/parts import { AppOverlays } from "@/renderer/views/MainView/parts/AppOverlays"; import { WorktreeDeleteDialogs } from "@/renderer/views/MainView/parts/WorktreeDeleteDialogs"; +import { PullFromSourceDialog } from "@/renderer/views/MainView/parts/PullFromSourceDialog"; import { MainPageLayout, StalePanelCleanup } from "@/renderer/views/MainView/parts/MainPageLayout"; import { ThreadSearchOverlayHost } from "@/renderer/views/ThreadSearchOverlay/ThreadSearchOverlay"; @@ -113,6 +114,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) { + ); } diff --git a/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx b/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx new file mode 100644 index 00000000..37619d82 --- /dev/null +++ b/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { AlertDialog, toast } from "@heroui/react"; +import { buildWorktreeLocation } from "@/shared/worktree"; +import { friendlyError, msg } from "@/shared/messages"; +import { readBridge } from "@/renderer/bridge"; +import { openGitReviewForWorktree } from "@/renderer/actions/gitActions"; +import { Button } from "@/renderer/components/common/Button"; +import { useAppStore } from "@/renderer/state/appStore"; +import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore"; + +export function PullFromSourceDialog() { + const dialog = usePullFromSourceDialogStore((s) => s.dialog); + const closeDialog = usePullFromSourceDialogStore((s) => s.closeDialog); + const project = useAppStore((s) => s.projects.find((p) => p.id === dialog?.projectId)); + const [isPulling, setIsPulling] = useState(false); + + if (!dialog || !project) return null; + + const activeDialog = dialog; + const activeProject = project; + + function handleClose() { + closeDialog(); + } + + async function handleStashPullReapply() { + setIsPulling(true); + try { + const stashPreservedMessage = msg("git.pull.stashPreserved"); + const result = await readBridge().gitPullFromSource({ + worktreeLocation: buildWorktreeLocation(activeProject.location, activeDialog.worktreePath), + sourceBranch: activeDialog.sourceBranch, + preserveLocalChanges: true, + }); + closeDialog(); + if (result.conflicting) { + const detail = result.conflictFiles?.length + ? `\nConflicts:\n${result.conflictFiles.join("\n")}` + : ""; + const stashNote = result.stashPreserved ? `\n${stashPreservedMessage}` : ""; + toast.danger((result.error ?? msg("git.merge.conflicts")) + detail + stashNote); + openGitReviewForWorktree(activeDialog.projectId, activeDialog.worktreePath); + activeDialog.onComplete?.(); + return; + } + if (!result.merged) { + const fallback = msg("git.pull.failed", { detail: msg("git.merge.failed") }); + const message = result.error ?? fallback; + const stashNote = + result.stashPreserved && !message.includes(stashPreservedMessage) + ? `\n${stashPreservedMessage}` + : ""; + toast.danger(message + stashNote); + return; + } + activeDialog.onComplete?.(); + } catch (error) { + console.error("[git] stash pull from source failed", error); + toast.danger(friendlyError(error)); + } finally { + setIsPulling(false); + } + } + + const dialogContent = ( + <> + + Pull from {activeDialog.sourceBranch}? +

+ This worktree has local changes. Lightcode can temporarily stash them, pull from{" "} + {activeDialog.sourceBranch}, then re-apply your changes. +

+
+ + + + + + ); + + return ( + !open && handleClose()}> + + {dialogContent} + + + ); +} diff --git a/src/shared/contracts/git.ts b/src/shared/contracts/git.ts index a3a47b6a..4a7fc749 100644 --- a/src/shared/contracts/git.ts +++ b/src/shared/contracts/git.ts @@ -419,12 +419,16 @@ export interface GitMergeToSourceResult { export const gitPullFromSourcePayloadSchema = z.object({ worktreeLocation: projectLocationSchema, sourceBranch: z.string().min(1), + preserveLocalChanges: z.boolean().default(false), }); export type GitPullFromSourcePayload = z.infer; export interface GitPullFromSourceResult { merged: boolean; fastForward: boolean; + needsStash?: boolean; + reapplyConflicting?: boolean; + stashPreserved?: boolean; conflicting?: boolean; error?: string; conflictFiles?: string[]; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 4d179b56a1924ed6465de9ebc92d9d11baf0aecb..ad47d1e829065632663c5e000118fd0e78944cd9 100644 GIT binary patch delta 262 zcmY+9F=_)b5Jih%7Sg0Or1_|J_5f}T!G#OsE~QqQ)e5v4A<2diLTcBp>{A$Wf!xSf zuq-D)GSt0*C(_7_j*zlULrER= z90|9v^#*<95g%QYUJajO_6P`Lb6eSwWC-qk!M=A^<;vgCOD(N`GN|%D+2Jyo%DStn zb)-abM_SAav|!i82yHbyXDA+nqf9N{cpLwN5NVXwqnyObnjtIJzYnzEp*cQZ0XG|6 AN&o-= delta 16 XcmbQ`(do9Kn0@j-HlfYaI9j9uIW7h@ diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index fd496b7c..516f9f38 100644 --- a/src/supervisor/agents/acp/session.ts +++ b/src/supervisor/agents/acp/session.ts @@ -71,6 +71,7 @@ import { type AcpMapperState, } from "./canonicalMapping"; import { terminateChildProcessTree } from "@/shared/processTree"; +import { ensureNodePtySpawnHelperExecutable } from "@/supervisor/nodePty"; import { buildPosixExportPrefix, createKnownSessionRef, @@ -1234,6 +1235,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { return { terminalId }; } + ensureNodePtySpawnHelperExecutable(); const pty = spawnPty(launch.command, launch.args, { ...(launch.cwd ? { cwd: launch.cwd } : {}), env: launch.env, diff --git a/src/supervisor/git.test.ts b/src/supervisor/git.test.ts index 8f3e05cb..8a0fc7a4 100644 --- a/src/supervisor/git.test.ts +++ b/src/supervisor/git.test.ts @@ -560,6 +560,57 @@ describe("GitService.pullFromSource", () => { expect(result.conflicting).toBe(true); expect(result.conflictFiles).toEqual(["src/file.ts"]); }); + + it("asks the renderer to confirm stashing before pulling with local changes", async () => { + mockGitCommands((args) => { + if (args[0] === "status") return { stdout: " M src/file.ts\n" }; + return { stdout: "" }; + }); + + const result = await new GitService().pullFromSource(location, "main"); + + expect(result).toMatchObject({ merged: false, fastForward: false, needsStash: true }); + expect( + execFileMock.mock.calls.some( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[])[0] === "merge", + ), + ).toBe(false); + }); + + it("stashes local changes, pulls from source, and reapplies the stash when requested", async () => { + mockGitCommands((args) => { + if (args[0] === "status") return { stdout: " M src/file.ts\n" }; + return { stdout: "" }; + }); + + const result = await new GitService().pullFromSource(location, "main", true); + + expect(result).toEqual({ merged: true, fastForward: true }); + const commands = execFileMock.mock.calls.map((c: unknown[]) => (c[1] as string[]).join(" ")); + expect(commands).toContain("stash push -u -m Lightcode: before pull from main"); + expect(commands).toContain("merge --ff-only main"); + expect(commands).toContain("stash pop"); + }); + + it("reports conflicts from re-applying stashed local changes", async () => { + mockGitCommands((args) => { + if (args[0] === "status") return { stdout: " M src/file.ts\n" }; + if (args[0] === "stash" && args[1] === "pop") { + return { error: new Error("CONFLICT (content): Merge conflict in src/file.ts") }; + } + return { stdout: "" }; + }); + + const result = await new GitService().pullFromSource(location, "main", true); + + expect(result).toMatchObject({ + merged: false, + fastForward: true, + conflicting: true, + reapplyConflicting: true, + conflictFiles: ["src/file.ts"], + }); + }); }); describe("GitService.mergeToSource (non-FF path)", () => { diff --git a/src/supervisor/git.ts b/src/supervisor/git.ts index e5a8b004..290b9e3d 100644 --- a/src/supervisor/git.ts +++ b/src/supervisor/git.ts @@ -329,8 +329,9 @@ export class GitService { async pullFromSource( worktreeLocation: ProjectLocation, sourceBranch: string, + preserveLocalChanges = false, ): Promise { - return this.mergeService.pullFromSource(worktreeLocation, sourceBranch); + return this.mergeService.pullFromSource(worktreeLocation, sourceBranch, preserveLocalChanges); } async abortMerge(worktreeLocation: ProjectLocation): Promise { diff --git a/src/supervisor/git/mergeService.ts b/src/supervisor/git/mergeService.ts index 5e4ee404..c06e2e00 100644 --- a/src/supervisor/git/mergeService.ts +++ b/src/supervisor/git/mergeService.ts @@ -86,6 +86,26 @@ export class GitMergeService { async pullFromSource( worktreeLocation: ProjectLocation, sourceBranch: string, + preserveLocalChanges = false, + ): Promise { + if (await this.hasLocalChanges(worktreeLocation)) { + if (!preserveLocalChanges) { + return { + merged: false, + fastForward: false, + needsStash: true, + error: msg("git.pull.localChanges", { branch: sourceBranch }), + }; + } + return this.pullFromSourceWithStash(worktreeLocation, sourceBranch); + } + + return this.pullFromSourceClean(worktreeLocation, sourceBranch); + } + + private async pullFromSourceClean( + worktreeLocation: ProjectLocation, + sourceBranch: string, ): Promise { let canFastForward = false; try { @@ -115,16 +135,12 @@ export class GitMergeService { } catch (mergeError: unknown) { const detail = errorDetail(mergeError); if (detail.includes("CONFLICT") || detail.includes("Merge conflict")) { - const conflictFiles: string[] = []; - for (const match of detail.matchAll(/CONFLICT.*?:\s*(?:Merge conflict in\s+)?(.+)/g)) { - if (match[1]) conflictFiles.push(match[1].trim()); - } return { merged: false, fastForward: false, conflicting: true, error: msg("git.merge.conflicts"), - conflictFiles, + conflictFiles: this.extractConflictFiles(detail), }; } return { @@ -137,6 +153,44 @@ export class GitMergeService { return { merged: true, fastForward: false }; } + private async pullFromSourceWithStash( + worktreeLocation: ProjectLocation, + sourceBranch: string, + ): Promise { + await execGit(worktreeLocation, [ + "stash", + "push", + "-u", + "-m", + `Lightcode: before pull from ${sourceBranch}`, + ]); + + const result = await this.pullFromSourceClean(worktreeLocation, sourceBranch); + if (!result.merged) { + return { + ...result, + stashPreserved: true, + error: result.error ?? msg("git.pull.stashPreserved"), + }; + } + + try { + await execGit(worktreeLocation, ["stash", "pop"], { timeout: GIT_HOOK_TIMEOUT }); + } catch (stashPopError: unknown) { + return { + merged: false, + fastForward: result.fastForward, + conflicting: true, + reapplyConflicting: true, + stashPreserved: true, + error: msg("git.pull.reapplyConflicts"), + conflictFiles: this.extractConflictFiles(errorDetail(stashPopError)), + }; + } + + return result; + } + async abortMerge(worktreeLocation: ProjectLocation): Promise { await execGit(worktreeLocation, ["merge", "--abort"]); } @@ -176,6 +230,19 @@ export class GitMergeService { ); } + private async hasLocalChanges(location: ProjectLocation): Promise { + const status = await execGit(location, ["status", "--porcelain"]); + return status.trim().length > 0; + } + + private extractConflictFiles(detail: string): string[] { + const conflictFiles: string[] = []; + for (const match of detail.matchAll(/CONFLICT.*?:\s*(?:Merge conflict in\s+)?(.+)/g)) { + if (match[1]) conflictFiles.push(match[1].trim()); + } + return conflictFiles; + } + private async mergeBranchIntoSourceLocation( location: ProjectLocation, worktreeBranch: string, @@ -187,17 +254,13 @@ export class GitMergeService { } catch (mergeError: unknown) { const detail = errorDetail(mergeError); if (detail.includes("CONFLICT") || detail.includes("Merge conflict")) { - const conflictFiles: string[] = []; - for (const match of detail.matchAll(/CONFLICT.*?:\s*(?:Merge conflict in\s+)?(.+)/g)) { - if (match[1]) conflictFiles.push(match[1].trim()); - } await execGit(location, ["merge", "--abort"]).catch(() => undefined); return { merged: false, fastForward: false, newSourceCommit: "", error: msg("git.merge.conflicts"), - conflictFiles, + conflictFiles: this.extractConflictFiles(detail), }; } throw mergeError; diff --git a/src/supervisor/github.test.ts b/src/supervisor/github.test.ts index 95ca2059..0a260d4c 100644 --- a/src/supervisor/github.test.ts +++ b/src/supervisor/github.test.ts @@ -262,7 +262,7 @@ describe("GitHubService", () => { }); describe("mergePr", () => { - it("merges with the specified method and deletes branch", async () => { + it("merges with the specified method without deleting branches", async () => { execFileAsyncMock.mockResolvedValue({ stdout: "" }); await new GitHubService().mergePr(location, 42, "squash"); @@ -272,7 +272,7 @@ describe("GitHubService", () => { expect(ghArgs).toContain("merge"); expect(ghArgs).toContain("42"); expect(ghArgs).toContain("--squash"); - expect(ghArgs).toContain("--delete-branch"); + expect(ghArgs).not.toContain("--delete-branch"); expect(ghArgs).not.toContain("--admin"); }); diff --git a/src/supervisor/github.ts b/src/supervisor/github.ts index 312b3994..ffcf9be5 100644 --- a/src/supervisor/github.ts +++ b/src/supervisor/github.ts @@ -465,7 +465,7 @@ export class GitHubService { admin = false, ): Promise { try { - const args = ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"]; + const args = ["pr", "merge", String(prNumber), `--${method}`]; if (admin) args.push("--admin"); await runGh(location, args); } catch (err) { diff --git a/src/supervisor/nodePty.ts b/src/supervisor/nodePty.ts new file mode 100644 index 00000000..86989d9c --- /dev/null +++ b/src/supervisor/nodePty.ts @@ -0,0 +1,38 @@ +import { createRequire } from "node:module"; +import { chmodSync, existsSync, statSync } from "node:fs"; +import { dirname, resolve as resolvePath } from "node:path"; + +const require = createRequire(import.meta.url); + +let spawnHelperChmodAttempted = false; + +// node-pty ships a `spawn-helper` binary that posix_spawn invokes to set up +// the pty before exec. Packaged or cached installs can lose the executable bit, +// which surfaces as the opaque "posix_spawnp failed." error. +export function ensureNodePtySpawnHelperExecutable(): void { + if (spawnHelperChmodAttempted) return; + if (process.platform !== "darwin" && process.platform !== "linux") { + spawnHelperChmodAttempted = true; + return; + } + try { + const ptyPkg = require.resolve("node-pty/package.json"); + const prebuildsDir = resolvePath(dirname(ptyPkg), "prebuilds"); + const candidate = resolvePath( + prebuildsDir, + `${process.platform}-${process.arch}`, + "spawn-helper", + ); + const unpackedCandidate = candidate.replace(`${"app.asar"}/`, `${"app.asar.unpacked"}/`); + for (const path of new Set([unpackedCandidate, candidate])) { + if (!existsSync(path)) continue; + const stat = statSync(path); + if ((stat.mode & 0o111) !== 0o111) { + chmodSync(path, stat.mode | 0o111); + } + } + spawnHelperChmodAttempted = true; + } catch (err) { + console.warn("[supervisor] failed to ensure spawn-helper +x:", err); + } +} diff --git a/src/supervisor/runtime.ts b/src/supervisor/runtime.ts index abc1c3b1..fdf5d50b 100644 --- a/src/supervisor/runtime.ts +++ b/src/supervisor/runtime.ts @@ -1009,7 +1009,11 @@ export class SupervisorRuntime { } async gitPullFromSource(payload: GitPullFromSourcePayload): Promise { - return this.gitService.pullFromSource(payload.worktreeLocation, payload.sourceBranch); + return this.gitService.pullFromSource( + payload.worktreeLocation, + payload.sourceBranch, + payload.preserveLocalChanges, + ); } async ghCheckAvailable(payload: GetGitStatusPayload): Promise { diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 45290249..4c2a8450 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -1,13 +1,6 @@ -import { - accessSync, - chmodSync, - constants as fsConstants, - existsSync, - readFileSync, - statSync, -} from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs"; import { randomUUID } from "node:crypto"; -import { dirname, join, resolve as resolvePath } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { spawn } from "node-pty"; import type { SupervisorEvent } from "@/shared/ipc"; @@ -51,6 +44,7 @@ import { resolveLaunchSpec, } from "../agents/base"; import { captureSupervisorException } from "../diagnostics/sentry"; +import { ensureNodePtySpawnHelperExecutable } from "../nodePty"; import type { WindowsShellPreference } from "../shellPreference"; import { BufferedLogWriter } from "./bufferedLogWriter"; import { hookDebugSpawn } from "./hookDebug"; @@ -2091,41 +2085,6 @@ function diagnoseRelativeBinary(command: string, env: Record): s return `'${command}' was not found on PATH (${entries.length} entries searched). Check that the binary is installed and visible to the app's environment.`; } -let spawnHelperChmodAttempted = false; - -// node-pty ships a `spawn-helper` binary that posix_spawn invokes to set up -// the pty before exec. When the package is shipped under app.asar.unpacked -// the copy can land without the executable bit, and posix_spawnp rejects -// with the opaque "posix_spawnp failed." message. Heal the mode at runtime -// so existing installs recover on first shell launch. -function ensureNodePtySpawnHelperExecutable(): void { - if (spawnHelperChmodAttempted) return; - if (process.platform !== "darwin" && process.platform !== "linux") { - spawnHelperChmodAttempted = true; - return; - } - try { - const ptyPkg = require.resolve("node-pty/package.json"); - const prebuildsDir = resolvePath(dirname(ptyPkg), "prebuilds"); - const candidate = resolvePath( - prebuildsDir, - `${process.platform}-${process.arch}`, - "spawn-helper", - ); - const unpackedCandidate = candidate.replace(`${"app.asar"}/`, `${"app.asar.unpacked"}/`); - for (const path of new Set([unpackedCandidate, candidate])) { - if (!existsSync(path)) continue; - const stat = statSync(path); - if ((stat.mode & 0o111) !== 0o111) { - chmodSync(path, stat.mode | 0o111); - } - } - spawnHelperChmodAttempted = true; - } catch (err) { - console.warn("[supervisor] failed to ensure spawn-helper +x:", err); - } -} - function sanitizeEnv(source: NodeJS.ProcessEnv): Record { const out: Record = {}; for (const [key, value] of Object.entries(source)) {