diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 048effdd9a..1f3ead16e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -279,11 +279,31 @@ jobs: needs: [preflight, release] runs-on: ubuntu-24.04 steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout uses: actions/checkout@v6 with: ref: main fetch-depth: 0 + token: ${{ steps.app_token.outputs.token }} + persist-credentials: true + + - id: app_bot + name: Resolve GitHub App bot identity + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + APP_SLUG: ${{ steps.app_token.outputs.app-slug }} + run: | + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" + echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -320,8 +340,8 @@ jobs: exit 0 fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "${{ steps.app_bot.outputs.name }}" + git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock git commit -m "chore(release): prepare $RELEASE_TAG" diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts deleted file mode 100644 index 8853248b24..0000000000 --- a/apps/desktop/src/fixPath.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readPathFromLoginShell } from "@t3tools/shared/shell"; - -export function fixPath(): void { - if (process.platform !== "darwin") return; - - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); - if (result) { - process.env.PATH = result; - } - } catch { - // Keep inherited PATH if shell lookup fails. - } -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f530941811..d65a954455 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,7 +28,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; -import { fixPath } from "./fixPath"; +import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { createInitialDesktopUpdateState, @@ -44,7 +44,7 @@ import { } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -fixPath(); +syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts new file mode 100644 index 0000000000..69e73da0aa --- /dev/null +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; + +import { syncShellEnvironment } from "./syncShellEnvironment"; + +describe("syncShellEnvironment", () => { + it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + }); + + it("preserves an inherited SSH_AUTH_SOCK value", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/login-shell.sock", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); + }); + + it("preserves inherited values when the login shell omits them", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); + }); + + it("does nothing outside macOS", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + })); + + syncShellEnvironment(env, { + platform: "linux", + readEnvironment, + }); + + expect(readEnvironment).not.toHaveBeenCalled(); + expect(env.PATH).toBe("/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); + }); +}); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts new file mode 100644 index 0000000000..a473563930 --- /dev/null +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -0,0 +1,29 @@ +import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell"; + +export function syncShellEnvironment( + env: NodeJS.ProcessEnv = process.env, + options: { + platform?: NodeJS.Platform; + readEnvironment?: ShellEnvironmentReader; + } = {}, +): void { + if ((options.platform ?? process.platform) !== "darwin") return; + + try { + const shell = env.SHELL?.trim() || "/bin/zsh"; + const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ + "PATH", + "SSH_AUTH_SOCK", + ]); + + if (shellEnvironment.PATH) { + env.PATH = shellEnvironment.PATH; + } + + if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { + env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + } + } catch { + // Keep inherited environment if shell lookup fails. + } +} diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index 85e9010123..daa58d0f12 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.9' +const PACKAGE_VERSION = '2.12.10' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5075fec212..883eecaeec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -290,7 +290,7 @@ export default function ChatView({ threadId }: ChatViewProps) { // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. + // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); @@ -3977,7 +3977,7 @@ export default function ChatView({ threadId }: ChatViewProps) { disabled={isSendBusy || isConnecting} onClick={() => void onImplementPlanInNewThread()} > - Implement in new thread + Implement in a new thread diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f08ed212a1..d48873f7a6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -51,7 +51,7 @@ export function resolveThreadRowClassName(input: { isSelected: boolean; }): string { const baseClassName = - "h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; + "h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; if (input.isSelected && input.isActive) { return cn( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5aed608d65..c241b82ff2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -26,7 +26,7 @@ import { type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, @@ -993,7 +993,6 @@ export default function Sidebar() { if (!api) return; const thread = threads.find((t) => t.id === threadId); if (!thread) return; - const threadProject = projects.find((project) => project.id === thread.projectId); // When bulk-deleting, exclude the other threads being deleted so // getOrphanedWorktreePathForThread correctly detects that no surviving @@ -1104,7 +1103,7 @@ export default function Sidebar() { ], ); - const { copyToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: (ctx) => { toastManager.add({ type: "success", @@ -1120,21 +1119,40 @@ export default function Sidebar() { }); }, }); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; + const thread = threads.find((t) => t.id === threadId); + if (!thread) return; + const threadWorkspacePath = + thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "delete", label: "Delete", destructive: true }, ], position, ); - const thread = threads.find((t) => t.id === threadId); - if (!thread) return; if (clicked === "rename") { setRenamingThreadId(threadId); @@ -1147,8 +1165,20 @@ export default function Sidebar() { markThreadUnread(threadId); return; } + if (clicked === "copy-path") { + if (!threadWorkspacePath) { + toastManager.add({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }); + return; + } + copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); + return; + } if (clicked === "copy-thread-id") { - copyToClipboard(threadId, { threadId }); + copyThreadIdToClipboard(threadId, { threadId }); return; } if (clicked !== "delete") return; @@ -1165,7 +1195,15 @@ export default function Sidebar() { } await deleteThread(threadId); }, - [appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads], + [ + appSettings.confirmThreadDelete, + copyPathToClipboard, + copyThreadIdToClipboard, + deleteThread, + markThreadUnread, + projectCwdById, + threads, + ], ); const handleMultiSelectContextMenu = useCallback( @@ -1783,7 +1821,7 @@ export default function Sidebar() { + Projects + , + ); +} + +describe("sidebar interactive cursors", () => { + it("uses a pointer cursor for menu buttons by default", () => { + const html = renderSidebarButton(); + + expect(html).toContain('data-slot="sidebar-menu-button"'); + expect(html).toContain("cursor-pointer"); + }); + + it("lets project drag handles override the default pointer cursor", () => { + const html = renderSidebarButton("cursor-grab"); + + expect(html).toContain("cursor-grab"); + expect(html).not.toContain("cursor-pointer"); + }); + + it("uses a pointer cursor for menu actions", () => { + const html = renderToStaticMarkup( + + + + , + ); + + expect(html).toContain('data-slot="sidebar-menu-action"'); + expect(html).toContain("cursor-pointer"); + }); + + it("uses a pointer cursor for submenu buttons", () => { + const html = renderToStaticMarkup( + }>Show more, + ); + + expect(html).toContain('data-slot="sidebar-menu-sub-button"'); + expect(html).toContain("cursor-pointer"); + }); +}); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 24091ca4f9..69dd58757e 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -750,7 +750,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -834,7 +834,7 @@ function SidebarMenuAction({ }) { const defaultProps = { className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", // Increases the hit area of the button on mobile. "after:-inset-2 after:absolute md:after:hidden", "peer-data-[size=sm]/menu-button:top-1", @@ -946,7 +946,7 @@ function SidebarMenuSubButton({ }) { const defaultProps = { className: cn( - "-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "-translate-x-px flex h-7 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index f659725c73..e2393eefff 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell"; +import { + extractPathFromShellOutput, + readEnvironmentFromLoginShell, + readPathFromLoginShell, +} from "./shell"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -32,7 +36,7 @@ describe("readPathFromLoginShell", () => { args: ReadonlyArray, options: { encoding: "utf8"; timeout: number }, ) => string - >(() => "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n"); + >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n"); expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b"); expect(execFile).toHaveBeenCalledTimes(1); @@ -49,9 +53,76 @@ describe("readPathFromLoginShell", () => { expect(shell).toBe("/opt/homebrew/bin/fish"); expect(args).toHaveLength(2); expect(args?.[0]).toBe("-ilc"); - expect(args?.[1]).toContain("printenv PATH"); - expect(args?.[1]).toContain("__T3CODE_PATH_START__"); - expect(args?.[1]).toContain("__T3CODE_PATH_END__"); + expect(args?.[1]).toContain("printenv PATH || true"); + expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); + expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); }); }); + +describe("readEnvironmentFromLoginShell", () => { + it("extracts multiple environment variables from a login shell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "/tmp/secretive.sock", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ); + + expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({ + PATH: "/a:/b", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }); + expect(execFile).toHaveBeenCalledTimes(1); + }); + + it("omits environment variables that are missing or empty", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ); + + expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({ + PATH: "/a:/b", + }); + }); + + it("preserves surrounding whitespace in captured values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + ["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join( + "\n", + ), + ); + + expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({ + CUSTOM_VAR: " padded value ", + }); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e6029c4432..f1d60bf334 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -2,11 +2,7 @@ import { execFileSync } from "node:child_process"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; -const PATH_CAPTURE_COMMAND = [ - `printf '%s\n' '${PATH_CAPTURE_START}'`, - "printenv PATH", - `printf '%s\n' '${PATH_CAPTURE_END}'`, -].join("; "); +const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; type ExecFileSyncLike = ( file: string, @@ -30,9 +26,81 @@ export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = execFileSync, ): string | undefined { - const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], { + return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; +} + +function envCaptureStart(name: string): string { + return `__T3CODE_ENV_${name}_START__`; +} + +function envCaptureEnd(name: string): string { + return `__T3CODE_ENV_${name}_END__`; +} + +function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { + return names + .map((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `printf '%s\\n' '${envCaptureStart(name)}'`, + `printenv ${name} || true`, + `printf '%s\\n' '${envCaptureEnd(name)}'`, + ].join("; "); + }) + .join("; "); +} + +function extractEnvironmentValue(output: string, name: string): string | undefined { + const startMarker = envCaptureStart(name); + const endMarker = envCaptureEnd(name); + const startIndex = output.indexOf(startMarker); + if (startIndex === -1) return undefined; + + const valueStartIndex = startIndex + startMarker.length; + const endIndex = output.indexOf(endMarker, valueStartIndex); + if (endIndex === -1) return undefined; + + let value = output.slice(valueStartIndex, endIndex); + if (value.startsWith("\n")) { + value = value.slice(1); + } + if (value.endsWith("\n")) { + value = value.slice(0, -1); + } + + return value.length > 0 ? value : undefined; +} + +export type ShellEnvironmentReader = ( + shell: string, + names: ReadonlyArray, + execFile?: ExecFileSyncLike, +) => Partial>; + +export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( + shell, + names, + execFile = execFileSync, +) => { + if (names.length === 0) { + return {}; + } + + const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { encoding: "utf8", timeout: 5000, }); - return extractPathFromShellOutput(output) ?? undefined; -} + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } + } + + return environment; +};