From 231ebdcfb19ef382ff81fa11f33f4154809b8370 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Apr 2026 13:54:09 +0700 Subject: [PATCH] fix: preserve zsh prompt in packaged desktop (#544) --- packages/desktop/electron-builder.yml | 1 + .../src/daemon/desktop-packaging.test.ts | 19 ++++++ packages/server/src/terminal/terminal.test.ts | 58 ++++++++++++++++++- packages/server/src/terminal/terminal.ts | 33 ++++++++++- 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/src/daemon/desktop-packaging.test.ts diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index b60fd1621..d6c957fb6 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -9,6 +9,7 @@ files: - dist/**/* asarUnpack: - dist/daemon/node-entrypoint-runner.js + - node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/* extraResources: - from: ../app/dist to: app-dist diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts new file mode 100644 index 000000000..0057c2395 --- /dev/null +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +describe("desktop packaging", () => { + it("unpacks server zsh shell integration files for external shells", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain( + "node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*", + ); + expect(config).not.toContain( + "node_modules/@getpaseo/server/dist/src/terminal/shell-integration/**/*", + ); + }); +}); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 6c94ff032..868ce13a3 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -11,6 +11,7 @@ import { } from "./terminal.js"; import { chmodSync, + cpSync, existsSync, mkdtempSync, mkdirSync, @@ -18,6 +19,7 @@ import { statSync, writeFileSync, } from "node:fs"; +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -208,7 +210,9 @@ describe("createTerminal", () => { expect(resolvedEnv.TERM).toBe("xterm-256color"); expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); - expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir()); + expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir"); + expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true); + expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true); }); it("uses custom name when provided", async () => { @@ -588,6 +592,58 @@ describe("terminal title", () => { await waitForTitle(session, (title) => title === "~/dev/faro", 4000); }); + it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-")); + temporaryDirs.push(homeDir); + writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n"); + + const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources"); + const inaccessiblePackagedIntegrationDir = join( + fakeAppRoot, + "app.asar", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + const unpackedIntegrationDir = join( + fakeAppRoot, + "app.asar.unpacked", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + mkdirSync(unpackedIntegrationDir, { recursive: true }); + cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true }); + writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder"); + + const env = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: homeDir, + }, + zshShellIntegrationDir: inaccessiblePackagedIntegrationDir, + }); + + const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], { + cwd: homeDir, + env, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> "); + }); + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); temporaryDirs.push(homeDir); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 6eb061fc9..d7b9a4aa2 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,7 +1,8 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; -import { chmodSync, existsSync, statSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; import { basename, dirname, join } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -85,6 +86,7 @@ export interface CreateTerminalOptions { interface BuildTerminalEnvironmentInput { shell: string; env: Record; + zshShellIntegrationDir?: string; } export interface CaptureTerminalLinesOptions { @@ -182,6 +184,33 @@ export function resolveZshShellIntegrationDir(): string { return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url)); } +function resolveExternalProcessPath(filePath: string): string { + return filePath.replace(/\.asar(?=\/|$)/, ".asar.unpacked"); +} + +function resolveZshShellIntegrationRuntimeDir(): string { + let username = "unknown"; + try { + username = userInfo().username || username; + } catch { + // keep fallback + } + return join(tmpdir(), `${username}-paseo-zsh`); +} + +function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string { + const readableSourceDir = resolveExternalProcessPath(sourceDir); + const runtimeDir = resolveZshShellIntegrationRuntimeDir(); + mkdirSync(runtimeDir, { recursive: true, mode: 0o700 }); + chmodSync(runtimeDir, 0o700); + copyFileSync(join(readableSourceDir, ".zshenv"), join(runtimeDir, ".zshenv")); + copyFileSync( + join(readableSourceDir, "paseo-integration.zsh"), + join(runtimeDir, "paseo-integration.zsh"), + ); + return runtimeDir; +} + export function buildTerminalEnvironment( input: BuildTerminalEnvironmentInput, ): Record { @@ -199,7 +228,7 @@ export function buildTerminalEnvironment( return { ...baseEnv, PASEO_ZSH_ZDOTDIR: originalZdotdir, - ZDOTDIR: resolveZshShellIntegrationDir(), + ZDOTDIR: prepareZshShellIntegrationRuntimeDir(input.zshShellIntegrationDir), }; }