| author | |
| committer | |
| log | 477795697d8546a8db4903bd878a5ad3196423b9 |
| tree | 69b3a8096f413d70ebaaf42ca573809b0b0f94ac |
| parent | a23b83314ec82dca5976a412e60e27f154ea0451 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>87 files changed, 2180 insertions(+), 2124 deletions(-)
apps/desktop/scripts/dev-electron.mjs+6-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { spawn, spawnSync } from "node:child_process"; |
| 2 | 2 | import { watch } from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; | |
| 3 | 4 | import { join } from "node:path"; |
| 4 | 5 | |
| 5 | 6 | import { |
| ... | ... | @@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; |
| 33 | 34 | const restartDebounceMs = 120; |
| 34 | 35 | const childTreeGracePeriodMs = 1_200; |
| 35 | 36 | const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); |
| 37 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. | |
| 38 | const hostPlatform = NodeOS.platform(); | |
| 36 | 39 | |
| 37 | 40 | await waitForResources({ |
| 38 | 41 | baseDir: desktopDir, |
| ... | ... | @@ -57,7 +60,7 @@ const expectedExits = new WeakSet(); |
| 57 | 60 | const watchers = []; |
| 58 | 61 | |
| 59 | 62 | function killChildTreeByPid(pid, signal) { |
| 60 | if (process.platform === "win32" || typeof pid !== "number") { | |
| 63 | if (hostPlatform === "win32" || typeof pid !== "number") { | |
| 61 | 64 | return; |
| 62 | 65 | } |
| 63 | 66 | |
| ... | ... | @@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) { |
| 65 | 68 | } |
| 66 | 69 | |
| 67 | 70 | function cleanupStaleDevApps() { |
| 68 | if (process.platform === "win32") { | |
| 71 | if (hostPlatform === "win32") { | |
| 69 | 72 | return; |
| 70 | 73 | } |
| 71 | 74 | |
| ... | ... | @@ -194,7 +197,7 @@ function startWatchers() { |
| 194 | 197 | } |
| 195 | 198 | |
| 196 | 199 | function killChildTree(signal) { |
| 197 | if (process.platform === "win32") { | |
| 200 | if (hostPlatform === "win32") { | |
| 198 | 201 | return; |
| 199 | 202 | } |
| 200 | 203 |
apps/desktop/scripts/electron-launcher.mjs+6-3| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | writeFileSync, |
| 15 | 15 | } from "node:fs"; |
| 16 | 16 | import { createRequire } from "node:module"; |
| 17 | import * as NodeOS from "node:os"; | |
| 17 | 18 | import { basename, dirname, join, resolve } from "node:path"; |
| 18 | 19 | import { fileURLToPath } from "node:url"; |
| 19 | 20 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; |
| ... | ... | @@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; |
| 33 | 34 | const LAUNCHER_VERSION = 11; |
| 34 | 35 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); |
| 35 | 36 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); |
| 37 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. | |
| 38 | const hostPlatform = NodeOS.platform(); | |
| 36 | 39 | |
| 37 | 40 | function resolveDevelopmentProtocolCallbackPort() { |
| 38 | 41 | const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); |
| ... | ... | @@ -312,7 +315,7 @@ function buildMacLauncher(electronBinaryPath) { |
| 312 | 315 | } |
| 313 | 316 | |
| 314 | 317 | function isLinuxSetuidSandboxConfigured(electronBinaryPath) { |
| 315 | if (process.platform !== "linux") { | |
| 318 | if (hostPlatform !== "linux") { | |
| 316 | 319 | return true; |
| 317 | 320 | } |
| 318 | 321 | |
| ... | ... | @@ -342,7 +345,7 @@ export function resolveElectronPath() { |
| 342 | 345 | const require = createRequire(import.meta.url); |
| 343 | 346 | const electronBinaryPath = require("electron"); |
| 344 | 347 | |
| 345 | if (process.platform !== "darwin") { | |
| 348 | if (hostPlatform !== "darwin") { | |
| 346 | 349 | return electronBinaryPath; |
| 347 | 350 | } |
| 348 | 351 | |
| ... | ... | @@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) { |
| 358 | 361 | } |
| 359 | 362 | |
| 360 | 363 | export function resolveDevProtocolClient() { |
| 361 | if (process.platform !== "darwin" || !isDevelopment) { | |
| 364 | if (hostPlatform !== "darwin" || !isDevelopment) { | |
| 362 | 365 | return null; |
| 363 | 366 | } |
| 364 | 367 |
apps/desktop/scripts/ensure-electron-runtime.mjs+14-10| ... | ... | @@ -1,13 +1,17 @@ |
| 1 | 1 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 2 | 2 | import { createRequire } from "node:module"; |
| 3 | import { tmpdir } from "node:os"; | |
| 3 | import { arch, platform, tmpdir } from "node:os"; | |
| 4 | 4 | import { dirname, join } from "node:path"; |
| 5 | 5 | import { spawnSync } from "node:child_process"; |
| 6 | 6 | |
| 7 | 7 | const require = createRequire(import.meta.url); |
| 8 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. | |
| 9 | const hostPlatform = platform(); | |
| 10 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. | |
| 11 | const hostArch = arch(); | |
| 8 | 12 | |
| 9 | 13 | function getPlatformPath() { |
| 10 | switch (process.platform) { | |
| 14 | switch (hostPlatform) { | |
| 11 | 15 | case "darwin": |
| 12 | 16 | return "Electron.app/Contents/MacOS/Electron"; |
| 13 | 17 | case "freebsd": |
| ... | ... | @@ -17,12 +21,12 @@ function getPlatformPath() { |
| 17 | 21 | case "win32": |
| 18 | 22 | return "electron.exe"; |
| 19 | 23 | default: |
| 20 | throw new Error(`Electron builds are not available on platform: ${process.platform}`); | |
| 24 | throw new Error(`Electron builds are not available on platform: ${hostPlatform}`); | |
| 21 | 25 | } |
| 22 | 26 | } |
| 23 | 27 | |
| 24 | 28 | function ensureExecutable(filePath) { |
| 25 | if (process.platform !== "win32") { | |
| 29 | if (hostPlatform !== "win32") { | |
| 26 | 30 | chmodSync(filePath, 0o755); |
| 27 | 31 | } |
| 28 | 32 | } |
| ... | ... | @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) { |
| 39 | 43 | function getRequiredRuntimePaths(electronDir, platformPath) { |
| 40 | 44 | const paths = [join(electronDir, "dist", platformPath)]; |
| 41 | 45 | |
| 42 | if (process.platform === "darwin") { | |
| 46 | if (hostPlatform === "darwin") { | |
| 43 | 47 | paths.push( |
| 44 | 48 | join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), |
| 45 | 49 | join( |
| ... | ... | @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) { |
| 58 | 62 | } |
| 59 | 63 | |
| 60 | 64 | function isMachO(filePath) { |
| 61 | if (process.platform !== "darwin") { | |
| 65 | if (hostPlatform !== "darwin") { | |
| 62 | 66 | return true; |
| 63 | 67 | } |
| 64 | 68 | |
| ... | ... | @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) { |
| 76 | 80 | } |
| 77 | 81 | |
| 78 | 82 | function invalidRuntimePaths(electronDir, platformPath) { |
| 79 | if (process.platform !== "darwin") { | |
| 83 | if (hostPlatform !== "darwin") { | |
| 80 | 84 | return []; |
| 81 | 85 | } |
| 82 | 86 | |
| ... | ... | @@ -111,16 +115,16 @@ function runChecked(command, args) { |
| 111 | 115 | |
| 112 | 116 | function installElectronRuntime(electronDir, version) { |
| 113 | 117 | const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); |
| 114 | const zipPath = join(tempDir, `electron-v${version}-${process.platform}-${process.arch}.zip`); | |
| 118 | const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); | |
| 115 | 119 | |
| 116 | 120 | try { |
| 117 | 121 | runChecked("curl", [ |
| 118 | 122 | "-fsSL", |
| 119 | `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${process.platform}-${process.arch}.zip`, | |
| 123 | `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, | |
| 120 | 124 | "-o", |
| 121 | 125 | zipPath, |
| 122 | 126 | ]); |
| 123 | if (process.platform === "darwin") { | |
| 127 | if (hostPlatform === "darwin") { | |
| 124 | 128 | runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); |
| 125 | 129 | } else { |
| 126 | 130 | runChecked("python3", [ |
apps/desktop/src/app/DesktopAssets.ts+1-1| ... | ... | @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( |
| 49 | 49 | > { |
| 50 | 50 | const fileSystem = yield* FileSystem.FileSystem; |
| 51 | 51 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 52 | if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { | |
| 52 | if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { | |
| 53 | 53 | const developmentDockIconPath = environment.developmentDockIconPath; |
| 54 | 54 | const developmentDockIconExists = yield* fileSystem |
| 55 | 55 | .exists(developmentDockIconPath) |
apps/desktop/src/electron/ElectronMenu.ts+99-94| ... | ... | @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | |
| 7 | 7 | import * as Electron from "electron"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | 9 | |
| 9 | 10 | export interface ElectronMenuPosition { |
| 10 | 11 | readonly x: number; |
| ... | ... | @@ -79,109 +80,113 @@ const normalizePosition = ( |
| 79 | 80 | ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, |
| 80 | 81 | ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); |
| 81 | 82 | |
| 82 | export const layer = Layer.sync(ElectronMenu, () => { | |
| 83 | let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined; | |
| 83 | export const layer = Layer.effect( | |
| 84 | ElectronMenu, | |
| 85 | Effect.gen(function* () { | |
| 86 | const platform = yield* HostProcessPlatform; | |
| 87 | let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined; | |
| 84 | 88 | |
| 85 | const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => { | |
| 86 | if (process.platform !== "darwin") { | |
| 87 | return Option.none(); | |
| 88 | } | |
| 89 | if (destructiveMenuIconCache !== undefined) { | |
| 90 | return destructiveMenuIconCache; | |
| 91 | } | |
| 92 | ||
| 93 | try { | |
| 94 | const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ | |
| 95 | width: 12, | |
| 96 | height: 12, | |
| 97 | }); | |
| 98 | destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); | |
| 99 | } catch { | |
| 100 | destructiveMenuIconCache = Option.none(); | |
| 101 | } | |
| 102 | ||
| 103 | return destructiveMenuIconCache; | |
| 104 | }; | |
| 105 | ||
| 106 | const buildTemplate = ( | |
| 107 | entries: readonly ContextMenuItem[], | |
| 108 | complete: (selectedItemId: Option.Option<string>) => void, | |
| 109 | ): Electron.MenuItemConstructorOptions[] => { | |
| 110 | const template: Electron.MenuItemConstructorOptions[] = []; | |
| 111 | let hasInsertedDestructiveSeparator = false; | |
| 112 | ||
| 113 | for (const item of entries) { | |
| 114 | if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | |
| 115 | template.push({ type: "separator" }); | |
| 116 | hasInsertedDestructiveSeparator = true; | |
| 89 | const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => { | |
| 90 | if (platform !== "darwin") { | |
| 91 | return Option.none(); | |
| 117 | 92 | } |
| 118 | ||
| 119 | const itemOption: Electron.MenuItemConstructorOptions = { | |
| 120 | label: item.label, | |
| 121 | enabled: !item.disabled, | |
| 122 | }; | |
| 123 | if (item.children && item.children.length > 0) { | |
| 124 | itemOption.submenu = buildTemplate(item.children, complete); | |
| 125 | } else { | |
| 126 | itemOption.click = () => complete(Option.some(item.id)); | |
| 93 | if (destructiveMenuIconCache !== undefined) { | |
| 94 | return destructiveMenuIconCache; | |
| 127 | 95 | } |
| 128 | if (item.destructive && (!item.children || item.children.length === 0)) { | |
| 129 | const destructiveIcon = getDestructiveMenuIcon(); | |
| 130 | if (Option.isSome(destructiveIcon)) { | |
| 131 | itemOption.icon = destructiveIcon.value; | |
| 132 | } | |
| 96 | ||
| 97 | try { | |
| 98 | const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ | |
| 99 | width: 12, | |
| 100 | height: 12, | |
| 101 | }); | |
| 102 | destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); | |
| 103 | } catch { | |
| 104 | destructiveMenuIconCache = Option.none(); | |
| 133 | 105 | } |
| 134 | 106 | |
| 135 | template.push(itemOption); | |
| 136 | } | |
| 107 | return destructiveMenuIconCache; | |
| 108 | }; | |
| 137 | 109 | |
| 138 | return template; | |
| 139 | }; | |
| 140 | ||
| 141 | return ElectronMenu.of({ | |
| 142 | setApplicationMenu: (template) => | |
| 143 | Effect.sync(() => { | |
| 144 | Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); | |
| 145 | }), | |
| 146 | popupTemplate: (input) => | |
| 147 | Effect.sync(() => { | |
| 148 | if (input.template.length === 0) { | |
| 149 | return; | |
| 110 | const buildTemplate = ( | |
| 111 | entries: readonly ContextMenuItem[], | |
| 112 | complete: (selectedItemId: Option.Option<string>) => void, | |
| 113 | ): Electron.MenuItemConstructorOptions[] => { | |
| 114 | const template: Electron.MenuItemConstructorOptions[] = []; | |
| 115 | let hasInsertedDestructiveSeparator = false; | |
| 116 | ||
| 117 | for (const item of entries) { | |
| 118 | if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | |
| 119 | template.push({ type: "separator" }); | |
| 120 | hasInsertedDestructiveSeparator = true; | |
| 150 | 121 | } |
| 151 | Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); | |
| 152 | }), | |
| 153 | showContextMenu: (input) => | |
| 154 | Effect.callback<Option.Option<string>>((resume) => { | |
| 155 | const normalizedItems = normalizeContextMenuItems(input.items); | |
| 156 | if (normalizedItems.length === 0) { | |
| 157 | resume(Effect.succeed(Option.none())); | |
| 158 | return; | |
| 122 | ||
| 123 | const itemOption: Electron.MenuItemConstructorOptions = { | |
| 124 | label: item.label, | |
| 125 | enabled: !item.disabled, | |
| 126 | }; | |
| 127 | if (item.children && item.children.length > 0) { | |
| 128 | itemOption.submenu = buildTemplate(item.children, complete); | |
| 129 | } else { | |
| 130 | itemOption.click = () => complete(Option.some(item.id)); | |
| 159 | 131 | } |
| 132 | if (item.destructive && (!item.children || item.children.length === 0)) { | |
| 133 | const destructiveIcon = getDestructiveMenuIcon(); | |
| 134 | if (Option.isSome(destructiveIcon)) { | |
| 135 | itemOption.icon = destructiveIcon.value; | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | template.push(itemOption); | |
| 140 | } | |
| 160 | 141 | |
| 161 | let completed = false; | |
| 162 | const complete = (selectedItemId: Option.Option<string>) => { | |
| 163 | if (completed) { | |
| 142 | return template; | |
| 143 | }; | |
| 144 | ||
| 145 | return ElectronMenu.of({ | |
| 146 | setApplicationMenu: (template) => | |
| 147 | Effect.sync(() => { | |
| 148 | Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); | |
| 149 | }), | |
| 150 | popupTemplate: (input) => | |
| 151 | Effect.sync(() => { | |
| 152 | if (input.template.length === 0) { | |
| 153 | return; | |
| 154 | } | |
| 155 | Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); | |
| 156 | }), | |
| 157 | showContextMenu: (input) => | |
| 158 | Effect.callback<Option.Option<string>>((resume) => { | |
| 159 | const normalizedItems = normalizeContextMenuItems(input.items); | |
| 160 | if (normalizedItems.length === 0) { | |
| 161 | resume(Effect.succeed(Option.none())); | |
| 164 | 162 | return; |
| 165 | 163 | } |
| 166 | completed = true; | |
| 167 | resume(Effect.succeed(selectedItemId)); | |
| 168 | }; | |
| 169 | 164 | |
| 170 | const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); | |
| 171 | const popupPosition = normalizePosition(input.position); | |
| 172 | const popupOptions = Option.match(popupPosition, { | |
| 173 | onNone: (): Electron.PopupOptions => ({ | |
| 174 | window: input.window, | |
| 175 | callback: () => complete(Option.none()), | |
| 176 | }), | |
| 177 | onSome: (position): Electron.PopupOptions => ({ | |
| 178 | window: input.window, | |
| 179 | x: position.x, | |
| 180 | y: position.y, | |
| 181 | callback: () => complete(Option.none()), | |
| 182 | }), | |
| 183 | }); | |
| 184 | menu.popup(popupOptions); | |
| 185 | }), | |
| 186 | }); | |
| 187 | }); | |
| 165 | let completed = false; | |
| 166 | const complete = (selectedItemId: Option.Option<string>) => { | |
| 167 | if (completed) { | |
| 168 | return; | |
| 169 | } | |
| 170 | completed = true; | |
| 171 | resume(Effect.succeed(selectedItemId)); | |
| 172 | }; | |
| 173 | ||
| 174 | const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); | |
| 175 | const popupPosition = normalizePosition(input.position); | |
| 176 | const popupOptions = Option.match(popupPosition, { | |
| 177 | onNone: (): Electron.PopupOptions => ({ | |
| 178 | window: input.window, | |
| 179 | callback: () => complete(Option.none()), | |
| 180 | }), | |
| 181 | onSome: (position): Electron.PopupOptions => ({ | |
| 182 | window: input.window, | |
| 183 | x: position.x, | |
| 184 | y: position.y, | |
| 185 | callback: () => complete(Option.none()), | |
| 186 | }), | |
| 187 | }); | |
| 188 | menu.popup(popupOptions); | |
| 189 | }), | |
| 190 | }); | |
| 191 | }), | |
| 192 | ); |
apps/desktop/src/electron/ElectronWindow.ts+3-1| ... | ... | @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; |
| 6 | 6 | import * as Ref from "effect/Ref"; |
| 7 | 7 | |
| 8 | 8 | import * as Electron from "electron"; |
| 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 9 | 10 | |
| 10 | 11 | export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ |
| 11 | 12 | readonly cause: unknown; |
| ... | ... | @@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWind |
| 37 | 38 | ) {} |
| 38 | 39 | |
| 39 | 40 | const make = Effect.gen(function* () { |
| 41 | const platform = yield* HostProcessPlatform; | |
| 40 | 42 | const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); |
| 41 | 43 | |
| 42 | 44 | const liveMain = Ref.get(mainWindowRef).pipe( |
| ... | ... | @@ -98,7 +100,7 @@ const make = Effect.gen(function* () { |
| 98 | 100 | window.show(); |
| 99 | 101 | } |
| 100 | 102 | |
| 101 | if (process.platform === "darwin") { | |
| 103 | if (platform === "darwin") { | |
| 102 | 104 | Electron.app.focus({ steal: true }); |
| 103 | 105 | } |
| 104 | 106 |
apps/desktop/src/main.ts+5-2| ... | ... | @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; |
| 9 | 9 | import * as Electron from "electron"; |
| 10 | 10 | |
| 11 | 11 | import * as NetService from "@t3tools/shared/Net"; |
| 12 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 12 | 13 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 13 | 14 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 14 | 15 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| ... | ... | @@ -53,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap( |
| 53 | 54 | const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( |
| 54 | 55 | Effect.flatMap((app) => app.metadata), |
| 55 | 56 | ); |
| 57 | const platform = yield* HostProcessPlatform; | |
| 58 | const processArch = yield* HostProcessArchitecture; | |
| 56 | 59 | return DesktopEnvironment.layer({ |
| 57 | 60 | dirname: __dirname, |
| 58 | 61 | homeDirectory: NodeOS.homedir(), |
| 59 | platform: process.platform, | |
| 60 | processArch: process.arch, | |
| 62 | platform, | |
| 63 | processArch, | |
| 61 | 64 | ...metadata, |
| 62 | 65 | }); |
| 63 | 66 | }), |
apps/desktop/src/window/DesktopWindow.ts+14-9| ... | ... | @@ -82,9 +82,10 @@ function resolveDesktopDevServerUrl( |
| 82 | 82 | |
| 83 | 83 | function getIconOption( |
| 84 | 84 | iconPaths: DesktopAssets.DesktopIconPaths, |
| 85 | platform: NodeJS.Platform, | |
| 85 | 86 | ): { icon: string } | Record<string, never> { |
| 86 | if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle | |
| 87 | const ext = process.platform === "win32" ? "ico" : "png"; | |
| 87 | if (platform === "darwin") return {}; // macOS uses .icns from app bundle | |
| 88 | const ext = platform === "win32" ? "ico" : "png"; | |
| 88 | 89 | return Option.match(iconPaths[ext], { |
| 89 | 90 | onNone: () => ({}), |
| 90 | 91 | onSome: (icon) => ({ icon }), |
| ... | ... | @@ -106,8 +107,11 @@ export function isSameOriginRendererNavigation(input: { |
| 106 | 107 | } |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { | |
| 110 | if (process.platform === "darwin") { | |
| 110 | function getWindowTitleBarOptions( | |
| 111 | shouldUseDarkColors: boolean, | |
| 112 | platform: NodeJS.Platform, | |
| 113 | ): WindowTitleBarOptions { | |
| 114 | if (platform === "darwin") { | |
| 111 | 115 | return { |
| 112 | 116 | titleBarStyle: "hiddenInset", |
| 113 | 117 | trafficLightPosition: { x: 16, y: 18 }, |
| ... | ... | @@ -127,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO |
| 127 | 131 | function syncWindowAppearance( |
| 128 | 132 | window: Electron.BrowserWindow, |
| 129 | 133 | shouldUseDarkColors: boolean, |
| 134 | platform: NodeJS.Platform, | |
| 130 | 135 | ): Effect.Effect<void> { |
| 131 | 136 | return Effect.sync(() => { |
| 132 | 137 | if (window.isDestroyed()) { |
| ... | ... | @@ -134,7 +139,7 @@ function syncWindowAppearance( |
| 134 | 139 | } |
| 135 | 140 | |
| 136 | 141 | window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); |
| 137 | const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); | |
| 142 | const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); | |
| 138 | 143 | if (typeof titleBarOverlay === "object") { |
| 139 | 144 | window.setTitleBarOverlay(titleBarOverlay); |
| 140 | 145 | } |
| ... | ... | @@ -179,7 +184,7 @@ const make = Effect.gen(function* () { |
| 179 | 184 | ? yield* resolveDesktopDevServerUrl(environment) |
| 180 | 185 | : backendHttpUrl.href; |
| 181 | 186 | const iconPaths = yield* assets.iconPaths; |
| 182 | const iconOption = getIconOption(iconPaths); | |
| 187 | const iconOption = getIconOption(iconPaths, environment.platform); | |
| 183 | 188 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 184 | 189 | const window = yield* electronWindow.create({ |
| 185 | 190 | width: 1100, |
| ... | ... | @@ -191,7 +196,7 @@ const make = Effect.gen(function* () { |
| 191 | 196 | backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), |
| 192 | 197 | ...iconOption, |
| 193 | 198 | title: environment.displayName, |
| 194 | ...getWindowTitleBarOptions(shouldUseDarkColors), | |
| 199 | ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), | |
| 195 | 200 | webPreferences: { |
| 196 | 201 | preload: environment.preloadPath, |
| 197 | 202 | contextIsolation: true, |
| ... | ... | @@ -318,7 +323,7 @@ const make = Effect.gen(function* () { |
| 318 | 323 | }); |
| 319 | 324 | |
| 320 | 325 | const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; |
| 321 | if (process.platform === "linux") { | |
| 326 | if (environment.platform === "linux") { | |
| 322 | 327 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); |
| 323 | 328 | } |
| 324 | 329 | bindFirstRevealTrigger(revealSubscribers, () => { |
| ... | ... | @@ -408,7 +413,7 @@ const make = Effect.gen(function* () { |
| 408 | 413 | syncAppearance: Effect.gen(function* () { |
| 409 | 414 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 410 | 415 | yield* electronWindow.syncAllAppearance((window) => |
| 411 | syncWindowAppearance(window, shouldUseDarkColors), | |
| 416 | syncWindowAppearance(window, shouldUseDarkColors, environment.platform), | |
| 412 | 417 | ); |
| 413 | 418 | }).pipe(Effect.withSpan("desktop.window.syncAppearance")), |
| 414 | 419 | }); |
apps/server/scripts/cli.ts+5-3| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; |
| 19 | 19 | import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; |
| 20 | 20 | import { fromYaml } from "@t3tools/shared/schemaYaml"; |
| 21 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 21 | 22 | import serverPackageJson from "../package.json" with { type: "json" }; |
| 22 | 23 | |
| 23 | 24 | interface PackageJson { |
| ... | ... | @@ -175,6 +176,7 @@ const buildCmd = Command.make( |
| 175 | 176 | cwd: serverDir, |
| 176 | 177 | stdout: config.verbose ? "inherit" : "ignore", |
| 177 | 178 | stderr: "inherit", |
| 179 | shell: false, | |
| 178 | 180 | }), |
| 179 | 181 | ); |
| 180 | 182 | |
| ... | ... | @@ -290,15 +292,15 @@ const publishCmd = Command.make( |
| 290 | 292 | () => |
| 291 | 293 | Effect.gen(function* () { |
| 292 | 294 | const args = createVpPmPublishArgs(config); |
| 295 | const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); | |
| 293 | 296 | |
| 294 | 297 | yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); |
| 295 | 298 | yield* runCommand( |
| 296 | ChildProcess.make("vp", ["pm", ...args], { | |
| 299 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 297 | 300 | cwd: repoRoot, |
| 298 | 301 | stdout: config.verbose ? "inherit" : "ignore", |
| 299 | 302 | stderr: "inherit", |
| 300 | // Windows needs shell mode to resolve .cmd shims. | |
| 301 | shell: process.platform === "win32", | |
| 303 | shell: spawnCommand.shell, | |
| 302 | 304 | }), |
| 303 | 305 | ); |
| 304 | 306 | }), |
apps/server/scripts/cursor-acp-model-mismatch-probe.ts+5-2| ... | ... | @@ -3,6 +3,8 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; |
| 3 | 3 | import process from "node:process"; |
| 4 | 4 | import readline from "node:readline"; |
| 5 | 5 | import * as NodeTimers from "node:timers"; |
| 6 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 6 | 8 | |
| 7 | 9 | type JsonPrimitive = null | boolean | number | string; |
| 8 | 10 | type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; |
| ... | ... | @@ -128,9 +130,10 @@ class JsonRpcChild { |
| 128 | 130 | closed = false; |
| 129 | 131 | |
| 130 | 132 | constructor(bin: string, args: string[], cwd: string) { |
| 131 | this.child = spawn(bin, args, { | |
| 133 | const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); | |
| 134 | this.child = spawn(spawnCommand.command, spawnCommand.args, { | |
| 132 | 135 | cwd, |
| 133 | shell: process.platform === "win32", | |
| 136 | shell: spawnCommand.shell, | |
| 134 | 137 | stdio: ["pipe", "pipe", "pipe"], |
| 135 | 138 | env: process.env, |
| 136 | 139 | }); |
apps/server/src/bootstrap.test.ts+6-11| ... | ... | @@ -3,7 +3,7 @@ import * as NFS from "node:fs"; |
| 3 | 3 | import * as path from "node:path"; |
| 4 | 4 | import { execFileSync, spawn } from "node:child_process"; |
| 5 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { assert, it } from "@effect/vitest"; | |
| 6 | import { it } from "@effect/vitest"; | |
| 7 | 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | 9 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -11,8 +11,9 @@ import * as Effect from "effect/Effect"; |
| 11 | 11 | import * as Fiber from "effect/Fiber"; |
| 12 | 12 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | 13 | import { vi } from "vite-plus/test"; |
| 14 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 14 | 15 | |
| 15 | import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; | |
| 16 | import { readBootstrapEnvelope } from "./bootstrap.ts"; | |
| 16 | 17 | import { assertNone, assertSome } from "@effect/vitest/utils"; |
| 17 | 18 | |
| 18 | 19 | const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); |
| ... | ... | @@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); |
| 41 | 42 | const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); |
| 42 | 43 | |
| 43 | 44 | it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { |
| 44 | it.effect("uses platform-specific fd paths", () => | |
| 45 | Effect.sync(() => { | |
| 46 | assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); | |
| 47 | assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); | |
| 48 | assert.equal(resolveFdPath(3, "win32"), undefined); | |
| 49 | }), | |
| 50 | ); | |
| 51 | ||
| 52 | 45 | it.effect("reads a bootstrap envelope from a provided fd", () => |
| 53 | 46 | Effect.gen(function* () { |
| 54 | 47 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { |
| 89 | 82 | |
| 90 | 83 | openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; |
| 91 | 84 | try { |
| 92 | const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); | |
| 85 | const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { | |
| 86 | timeoutMs: 100, | |
| 87 | }).pipe(Effect.provideService(HostProcessPlatform, "linux")); | |
| 93 | 88 | assertSome(payload, { |
| 94 | 89 | mode: "desktop", |
| 95 | 90 | }); |
apps/server/src/bootstrap.ts+32-31| ... | ... | @@ -11,6 +11,7 @@ import * as Predicate from "effect/Predicate"; |
| 11 | 11 | import * as Result from "effect/Result"; |
| 12 | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 14 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 14 | 15 | |
| 15 | 16 | class BootstrapError extends Data.TaggedError("BootstrapError")<{ |
| 16 | 17 | readonly message: string; |
| ... | ... | @@ -110,36 +111,39 @@ const isFdReady = (fd: number) => |
| 110 | 111 | ); |
| 111 | 112 | |
| 112 | 113 | const makeBootstrapInputStream = (fd: number) => |
| 113 | Effect.try<Readable, BootstrapError>({ | |
| 114 | try: () => { | |
| 115 | const fdPath = resolveFdPath(fd); | |
| 116 | if (fdPath === undefined) { | |
| 117 | return makeDirectBootstrapStream(fd); | |
| 118 | } | |
| 114 | Effect.gen(function* () { | |
| 115 | const platform = yield* HostProcessPlatform; | |
| 116 | return yield* Effect.try<Readable, BootstrapError>({ | |
| 117 | try: () => { | |
| 118 | const fdPath = resolveFdPath(fd, platform); | |
| 119 | if (fdPath === undefined) { | |
| 120 | return makeDirectBootstrapStream(fd); | |
| 121 | } | |
| 119 | 122 | |
| 120 | let streamFd: number | undefined; | |
| 121 | try { | |
| 122 | streamFd = NFS.openSync(fdPath, "r"); | |
| 123 | return NFS.createReadStream("", { | |
| 124 | fd: streamFd, | |
| 125 | encoding: "utf8", | |
| 126 | autoClose: true, | |
| 127 | }); | |
| 128 | } catch (error) { | |
| 129 | if (isBootstrapFdPathDuplicationError(error)) { | |
| 130 | if (streamFd !== undefined) { | |
| 131 | NFS.closeSync(streamFd); | |
| 123 | let streamFd: number | undefined; | |
| 124 | try { | |
| 125 | streamFd = NFS.openSync(fdPath, "r"); | |
| 126 | return NFS.createReadStream("", { | |
| 127 | fd: streamFd, | |
| 128 | encoding: "utf8", | |
| 129 | autoClose: true, | |
| 130 | }); | |
| 131 | } catch (error) { | |
| 132 | if (isBootstrapFdPathDuplicationError(error)) { | |
| 133 | if (streamFd !== undefined) { | |
| 134 | NFS.closeSync(streamFd); | |
| 135 | } | |
| 136 | return makeDirectBootstrapStream(fd); | |
| 132 | 137 | } |
| 133 | return makeDirectBootstrapStream(fd); | |
| 138 | throw error; | |
| 134 | 139 | } |
| 135 | throw error; | |
| 136 | } | |
| 137 | }, | |
| 138 | catch: (error) => | |
| 139 | new BootstrapError({ | |
| 140 | message: "Failed to duplicate bootstrap fd.", | |
| 141 | cause: error, | |
| 142 | }), | |
| 140 | }, | |
| 141 | catch: (error) => | |
| 142 | new BootstrapError({ | |
| 143 | message: "Failed to duplicate bootstrap fd.", | |
| 144 | cause: error, | |
| 145 | }), | |
| 146 | }); | |
| 143 | 147 | }); |
| 144 | 148 | |
| 145 | 149 | const makeDirectBootstrapStream = (fd: number): Readable => { |
| ... | ... | @@ -165,10 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( |
| 165 | 169 | (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", |
| 166 | 170 | ); |
| 167 | 171 | |
| 168 | export function resolveFdPath( | |
| 169 | fd: number, | |
| 170 | platform: NodeJS.Platform = process.platform, | |
| 171 | ): string | undefined { | |
| 172 | function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { | |
| 172 | 173 | if (platform === "linux") { |
| 173 | 174 | return `/proc/self/fd/${fd}`; |
| 174 | 175 | } |
apps/server/src/cli/config.test.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import NodeOS from "node:os"; | |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | 2 | |
| 3 | 3 | import { assert, expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
apps/server/src/diagnostics/ProcessDiagnostics.ts+10-4| ... | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | 4 | ServerProcessSignal, |
| 5 | 5 | ServerSignalProcessResult, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | 8 | import * as Context from "effect/Context"; |
| 8 | 9 | import * as DateTime from "effect/DateTime"; |
| 9 | 10 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -277,6 +278,9 @@ const runProcess = Effect.fn("runProcess")( |
| 277 | 278 | readonly errorMessage: string; |
| 278 | 279 | }) { |
| 279 | 280 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 281 | // `ps` and `powershell.exe` are real executables; spawning through cmd.exe | |
| 282 | // shell mode would re-tokenize the PowerShell `-Command` payload (which | |
| 283 | // contains pipes) before PowerShell ever sees it. | |
| 280 | 284 | const child = yield* spawner.spawn( |
| 281 | 285 | ChildProcess.make(input.command, input.args, { |
| 282 | 286 | cwd: process.cwd(), |
| ... | ... | @@ -369,8 +373,10 @@ function readWindowsProcessRows(): Effect.Effect< |
| 369 | 373 | ); |
| 370 | 374 | } |
| 371 | 375 | |
| 372 | export const readProcessRows = (platform = process.platform) => | |
| 373 | platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); | |
| 376 | export const readProcessRows = Effect.gen(function* () { | |
| 377 | const platform = yield* HostProcessPlatform; | |
| 378 | return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); | |
| 379 | }); | |
| 374 | 380 | |
| 375 | 381 | export function aggregateProcessDiagnostics(input: { |
| 376 | 382 | readonly serverPid: number; |
| ... | ... | @@ -387,7 +393,7 @@ function assertDescendantPid( |
| 387 | 393 | return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); |
| 388 | 394 | } |
| 389 | 395 | |
| 390 | return readProcessRows().pipe( | |
| 396 | return readProcessRows.pipe( | |
| 391 | 397 | Effect.flatMap((rows) => { |
| 392 | 398 | const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); |
| 393 | 399 | const descendant = buildDescendantEntries(filteredRows, process.pid).some( |
| ... | ... | @@ -407,7 +413,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { |
| 407 | 413 | |
| 408 | 414 | const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { |
| 409 | 415 | const readAt = yield* DateTime.now; |
| 410 | const rows = yield* readProcessRows().pipe( | |
| 416 | const rows = yield* readProcessRows.pipe( | |
| 411 | 417 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 412 | 418 | ); |
| 413 | 419 | return makeResult({ serverPid: process.pid, rows, readAt }); |
apps/server/src/diagnostics/ProcessResourceMonitor.ts+1-1| ... | ... | @@ -252,7 +252,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { |
| 252 | 252 | const sampleOnce = Effect.gen(function* () { |
| 253 | 253 | const sampledAt = yield* DateTime.now; |
| 254 | 254 | const sampledAtMs = DateTime.toEpochMillis(sampledAt); |
| 255 | const rows = yield* readProcessRows().pipe( | |
| 255 | const rows = yield* readProcessRows.pipe( | |
| 256 | 256 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 257 | 257 | ); |
| 258 | 258 | const samples = collectMonitoredSamples({ |
apps/server/src/environment/Layers/ServerEnvironment.ts+11-6| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; |
| 2 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 2 | 3 | import * as Crypto from "effect/Crypto"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -11,8 +12,8 @@ import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/Serv |
| 11 | 12 | import packageJson from "../../../package.json" with { type: "json" }; |
| 12 | 13 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| 13 | 14 | |
| 14 | function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | |
| 15 | switch (process.platform) { | |
| 15 | function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { | |
| 16 | switch (platform) { | |
| 16 | 17 | case "darwin": |
| 17 | 18 | return "darwin"; |
| 18 | 19 | case "linux": |
| ... | ... | @@ -24,8 +25,10 @@ function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { |
| 24 | 25 | } |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { | |
| 28 | switch (process.arch) { | |
| 28 | function platformArch( | |
| 29 | architecture: NodeJS.Architecture, | |
| 30 | ): ExecutionEnvironmentDescriptor["platform"]["arch"] { | |
| 31 | switch (architecture) { | |
| 29 | 32 | case "arm64": |
| 30 | 33 | return "arm64"; |
| 31 | 34 | case "x64": |
| ... | ... | @@ -40,6 +43,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 40 | 43 | const path = yield* Path.Path; |
| 41 | 44 | const serverConfig = yield* ServerConfig; |
| 42 | 45 | const crypto = yield* Crypto.Crypto; |
| 46 | const hostPlatform = yield* HostProcessPlatform; | |
| 47 | const hostArchitecture = yield* HostProcessArchitecture; | |
| 43 | 48 | |
| 44 | 49 | const readPersistedEnvironmentId = Effect.gen(function* () { |
| 45 | 50 | const exists = yield* fileSystem |
| ... | ... | @@ -80,8 +85,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 80 | 85 | environmentId, |
| 81 | 86 | label, |
| 82 | 87 | platform: { |
| 83 | os: platformOs(), | |
| 84 | arch: platformArch(), | |
| 88 | os: platformOs(hostPlatform), | |
| 89 | arch: platformArch(hostArchitecture), | |
| 85 | 90 | }, |
| 86 | 91 | serverVersion: packageJson.version, |
| 87 | 92 | capabilities: { |
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts+18-21| ... | ... | @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | 6 | import { vi } from "vite-plus/test"; |
| 6 | 7 | |
| 7 | 8 | import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; |
| ... | ... | @@ -28,6 +29,16 @@ const LinuxMachineInfoLayer = Layer.merge( |
| 28 | 29 | : Effect.succeed(""), |
| 29 | 30 | }), |
| 30 | 31 | ); |
| 32 | const withHostPlatform = <ROut, E, RIn>( | |
| 33 | layer: Layer.Layer<ROut, E, RIn>, | |
| 34 | platform: NodeJS.Platform, | |
| 35 | hostname: string, | |
| 36 | ) => | |
| 37 | Layer.mergeAll( | |
| 38 | layer, | |
| 39 | Layer.succeed(HostProcessPlatform, platform), | |
| 40 | Layer.succeed(HostProcessHostname, hostname), | |
| 41 | ); | |
| 31 | 42 | |
| 32 | 43 | afterEach(() => { |
| 33 | 44 | runMock.mockReset(); |
| ... | ... | @@ -38,9 +49,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 38 | 49 | Effect.gen(function* () { |
| 39 | 50 | const result = yield* resolveServerEnvironmentLabel({ |
| 40 | 51 | cwdBaseName: "t3code", |
| 41 | platform: "win32", | |
| 42 | hostname: "macbook-pro", | |
| 43 | }).pipe(Effect.provide(TestLayer)); | |
| 52 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); | |
| 44 | 53 | |
| 45 | 54 | expect(result).toBe("macbook-pro"); |
| 46 | 55 | }), |
| ... | ... | @@ -61,9 +70,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 61 | 70 | |
| 62 | 71 | const result = yield* resolveServerEnvironmentLabel({ |
| 63 | 72 | cwdBaseName: "t3code", |
| 64 | platform: "darwin", | |
| 65 | hostname: "macbook-pro", | |
| 66 | }).pipe(Effect.provide(TestLayer)); | |
| 73 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); | |
| 67 | 74 | |
| 68 | 75 | expect(result).toBe("Julius's MacBook Pro"); |
| 69 | 76 | expect(runMock).toHaveBeenCalledWith( |
| ... | ... | @@ -80,9 +87,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 80 | 87 | Effect.gen(function* () { |
| 81 | 88 | const result = yield* resolveServerEnvironmentLabel({ |
| 82 | 89 | cwdBaseName: "t3code", |
| 83 | platform: "linux", | |
| 84 | hostname: "buildbox", | |
| 85 | }).pipe(Effect.provide(LinuxMachineInfoLayer)); | |
| 90 | }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); | |
| 86 | 91 | |
| 87 | 92 | expect(result).toBe("Build Agent 01"); |
| 88 | 93 | expect(runMock).not.toHaveBeenCalled(); |
| ... | ... | @@ -104,9 +109,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 104 | 109 | |
| 105 | 110 | const result = yield* resolveServerEnvironmentLabel({ |
| 106 | 111 | cwdBaseName: "t3code", |
| 107 | platform: "linux", | |
| 108 | hostname: "runner-01", | |
| 109 | }).pipe(Effect.provide(TestLayer)); | |
| 112 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); | |
| 110 | 113 | |
| 111 | 114 | expect(result).toBe("CI Runner"); |
| 112 | 115 | expect(runMock).toHaveBeenCalledWith( |
| ... | ... | @@ -123,9 +126,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 123 | 126 | Effect.gen(function* () { |
| 124 | 127 | const result = yield* resolveServerEnvironmentLabel({ |
| 125 | 128 | cwdBaseName: "t3code", |
| 126 | platform: "win32", | |
| 127 | hostname: "JULIUS-LAPTOP", | |
| 128 | }).pipe(Effect.provide(TestLayer)); | |
| 129 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); | |
| 129 | 130 | |
| 130 | 131 | expect(result).toBe("JULIUS-LAPTOP"); |
| 131 | 132 | }), |
| ... | ... | @@ -145,9 +146,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 145 | 146 | |
| 146 | 147 | const result = yield* resolveServerEnvironmentLabel({ |
| 147 | 148 | cwdBaseName: "t3code", |
| 148 | platform: "darwin", | |
| 149 | hostname: "macbook-pro", | |
| 150 | }).pipe(Effect.provide(TestLayer)); | |
| 149 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); | |
| 151 | 150 | |
| 152 | 151 | expect(result).toBe("macbook-pro"); |
| 153 | 152 | }), |
| ... | ... | @@ -168,9 +167,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 168 | 167 | |
| 169 | 168 | const result = yield* resolveServerEnvironmentLabel({ |
| 170 | 169 | cwdBaseName: "t3code", |
| 171 | platform: "linux", | |
| 172 | hostname: " ", | |
| 173 | }).pipe(Effect.provide(TestLayer)); | |
| 170 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); | |
| 174 | 171 | |
| 175 | 172 | expect(result).toBe("t3code"); |
| 176 | 173 | }), |
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+5-10| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | import * as OS from "node:os"; | |
| 2 | ||
| 1 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 3 | 2 | import * as Effect from "effect/Effect"; |
| 4 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 4 | import * as Option from "effect/Option"; |
| ... | ... | @@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts"; |
| 8 | 7 | |
| 9 | 8 | interface ResolveServerEnvironmentLabelInput { |
| 10 | 9 | readonly cwdBaseName: string; |
| 11 | readonly platform?: NodeJS.Platform; | |
| 12 | readonly hostname?: string | null; | |
| 13 | 10 | } |
| 14 | 11 | |
| 15 | 12 | function normalizeLabel(value: string | null | undefined): string | null { |
| ... | ... | @@ -69,9 +66,8 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( |
| 69 | 66 | return normalizeLabel(result.value.stdout); |
| 70 | 67 | }); |
| 71 | 68 | |
| 72 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( | |
| 73 | platform: NodeJS.Platform, | |
| 74 | ) { | |
| 69 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { | |
| 70 | const platform = yield* HostProcessPlatform; | |
| 75 | 71 | if (platform === "darwin") { |
| 76 | 72 | return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); |
| 77 | 73 | } |
| ... | ... | @@ -94,13 +90,12 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* |
| 94 | 90 | export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( |
| 95 | 91 | input: ResolveServerEnvironmentLabelInput, |
| 96 | 92 | ) { |
| 97 | const platform = input.platform ?? process.platform; | |
| 98 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); | |
| 93 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(); | |
| 99 | 94 | if (friendlyHostLabel) { |
| 100 | 95 | return friendlyHostLabel; |
| 101 | 96 | } |
| 102 | 97 | |
| 103 | const hostname = normalizeLabel(input.hostname ?? OS.hostname()); | |
| 98 | const hostname = normalizeLabel(yield* HostProcessHostname); | |
| 104 | 99 | if (hostname) { |
| 105 | 100 | return hostname; |
| 106 | 101 | } |
apps/server/src/os-jank.ts+54-66| ... | ... | @@ -1,21 +1,15 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Path from "effect/Path"; | |
| 1 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | 2 | import { |
| 5 | readPathFromLoginShell, | |
| 6 | readEnvironmentFromWindowsShell, | |
| 7 | resolveWindowsEnvironment, | |
| 8 | type CommandAvailabilityOptions, | |
| 9 | type WindowsShellEnvironmentReader, | |
| 10 | 3 | listLoginShellCandidates, |
| 11 | 4 | mergePathEntries, |
| 5 | readPathFromLoginShell, | |
| 12 | 6 | readPathFromLaunchctl, |
| 7 | resolveWindowsEnvironment, | |
| 13 | 8 | } from "@t3tools/shared/shell"; |
| 14 | ||
| 15 | type WindowsCommandAvailabilityChecker = ( | |
| 16 | command: string, | |
| 17 | options?: CommandAvailabilityOptions, | |
| 18 | ) => boolean; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as FileSystem from "effect/FileSystem"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | import * as NodeOS from "node:os"; | |
| 19 | 13 | |
| 20 | 14 | function logPathHydrationWarning(message: string, error?: unknown): void { |
| 21 | 15 | process.stderr.write( |
| ... | ... | @@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void { |
| 23 | 17 | ); |
| 24 | 18 | } |
| 25 | 19 | |
| 26 | export function fixPath( | |
| 27 | options: { | |
| 28 | env?: NodeJS.ProcessEnv; | |
| 29 | platform?: NodeJS.Platform; | |
| 30 | readPath?: typeof readPathFromLoginShell; | |
| 31 | readWindowsEnvironment?: WindowsShellEnvironmentReader; | |
| 32 | isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; | |
| 33 | readLaunchctlPath?: typeof readPathFromLaunchctl; | |
| 34 | userShell?: string; | |
| 35 | logWarning?: (message: string, error?: unknown) => void; | |
| 36 | } = {}, | |
| 37 | ): void { | |
| 38 | const platform = options.platform ?? process.platform; | |
| 39 | const env = options.env ?? process.env; | |
| 40 | const logWarning = options.logWarning ?? logPathHydrationWarning; | |
| 41 | const readPath = options.readPath ?? readPathFromLoginShell; | |
| 42 | ||
| 43 | try { | |
| 44 | if (platform === "win32") { | |
| 45 | const repairedEnvironment = resolveWindowsEnvironment(env, { | |
| 46 | readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, | |
| 47 | ...(options.isWindowsCommandAvailable | |
| 48 | ? { commandAvailable: options.isWindowsCommandAvailable } | |
| 49 | : {}), | |
| 50 | }); | |
| 51 | for (const [key, value] of Object.entries(repairedEnvironment)) { | |
| 52 | if (value !== undefined) { | |
| 53 | env[key] = value; | |
| 54 | } | |
| 55 | } | |
| 56 | return; | |
| 20 | function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { | |
| 21 | let shellPath: string | undefined; | |
| 22 | for (const shell of listLoginShellCandidates(platform, env.SHELL)) { | |
| 23 | try { | |
| 24 | shellPath = readPathFromLoginShell(shell); | |
| 25 | } catch (error) { | |
| 26 | logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); | |
| 57 | 27 | } |
| 58 | 28 | |
| 59 | if (platform !== "darwin" && platform !== "linux") return; | |
| 29 | if (shellPath) break; | |
| 30 | } | |
| 60 | 31 | |
| 61 | let shellPath: string | undefined; | |
| 62 | for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { | |
| 63 | try { | |
| 64 | shellPath = readPath(shell); | |
| 65 | } catch (error) { | |
| 66 | logWarning(`Failed to read PATH from login shell ${shell}.`, error); | |
| 67 | } | |
| 32 | const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; | |
| 33 | const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); | |
| 34 | if (mergedPath) { | |
| 35 | env.PATH = mergedPath; | |
| 36 | } | |
| 37 | } | |
| 68 | 38 | |
| 69 | if (shellPath) { | |
| 70 | break; | |
| 71 | } | |
| 72 | } | |
| 39 | export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< | |
| 40 | void, | |
| 41 | never, | |
| 42 | FileSystem.FileSystem | Path.Path | |
| 43 | > { | |
| 44 | const platform = yield* HostProcessPlatform; | |
| 45 | const env = yield* HostProcessEnvironment; | |
| 73 | 46 | |
| 74 | const launchctlPath = | |
| 75 | platform === "darwin" && !shellPath | |
| 76 | ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() | |
| 77 | : undefined; | |
| 78 | const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); | |
| 79 | if (mergedPath) { | |
| 80 | env.PATH = mergedPath; | |
| 47 | if (platform === "win32") { | |
| 48 | const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( | |
| 49 | Effect.catchDefect((defect) => | |
| 50 | Effect.sync(() => { | |
| 51 | logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); | |
| 52 | return {} as Partial<NodeJS.ProcessEnv>; | |
| 53 | }), | |
| 54 | ), | |
| 55 | ); | |
| 56 | for (const [key, value] of Object.entries(repairedEnvironment)) { | |
| 57 | if (value !== undefined) { | |
| 58 | env[key] = value; | |
| 59 | } | |
| 81 | 60 | } |
| 82 | } catch (error) { | |
| 83 | logWarning("Failed to hydrate PATH from the user environment.", error); | |
| 61 | return; | |
| 84 | 62 | } |
| 85 | } | |
| 63 | ||
| 64 | if (platform !== "darwin" && platform !== "linux") return; | |
| 65 | ||
| 66 | yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( | |
| 67 | Effect.catchDefect((defect) => | |
| 68 | Effect.sync(() => { | |
| 69 | logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); | |
| 70 | }), | |
| 71 | ), | |
| 72 | ); | |
| 73 | }); | |
| 86 | 74 | |
| 87 | 75 | export const expandHomePath = Effect.fn(function* (input: string) { |
| 88 | 76 | const { join } = yield* Path.Path; |
apps/server/src/preview/PortScanner.test.ts+8-186| ... | ... | @@ -1,21 +1,20 @@ |
| 1 | 1 | import * as net from "node:net"; |
| 2 | 2 | |
| 3 | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { ThreadId } from "@t3tools/contracts"; | |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | 5 | import * as Net from "@t3tools/shared/Net"; |
| 6 | 6 | import { Effect, Layer } from "effect"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | |
| 7 | import { expect } from "vite-plus/test"; | |
| 8 | 8 | |
| 9 | 9 | import { ProcessRunner } from "../processRunner.ts"; |
| 10 | 10 | import * as PortScanner from "./PortScanner.ts"; |
| 11 | ||
| 12 | const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = | |
| 13 | PortScanner.__testing; | |
| 14 | 11 | const TestProcessRunner = Layer.succeed(ProcessRunner, { |
| 15 | 12 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 16 | 13 | }); |
| 17 | 14 | const TestPortDiscoveryLive = PortScanner.layer.pipe( |
| 18 | Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)), | |
| 15 | Layer.provide( | |
| 16 | Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), | |
| 17 | ), | |
| 19 | 18 | ); |
| 20 | 19 | |
| 21 | 20 | const openServer = (port: number): Effect.Effect<net.Server | null> => |
| ... | ... | @@ -37,21 +36,6 @@ const closeServer = (server: net.Server): Effect.Effect<void> => |
| 37 | 36 | server.close(() => resume(Effect.void)); |
| 38 | 37 | }); |
| 39 | 38 | |
| 40 | const windowsPlatform = Effect.acquireRelease( | |
| 41 | Effect.sync(() => { | |
| 42 | const originalPlatform = process.platform; | |
| 43 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | |
| 44 | return originalPlatform; | |
| 45 | }), | |
| 46 | (originalPlatform) => | |
| 47 | Effect.sync(() => { | |
| 48 | Object.defineProperty(process, "platform", { | |
| 49 | value: originalPlatform, | |
| 50 | configurable: true, | |
| 51 | }); | |
| 52 | }), | |
| 53 | ); | |
| 54 | ||
| 55 | 39 | const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( |
| 56 | 40 | ports: ReadonlyArray<number>, |
| 57 | 41 | ) { |
| ... | ... | @@ -69,176 +53,15 @@ const commonDevServer = Effect.acquireRelease( |
| 69 | 53 | ({ server }) => closeServer(server), |
| 70 | 54 | ); |
| 71 | 55 | |
| 72 | describe("parsePortFromLsofName", () => { | |
| 73 | it("parses *:port", () => { | |
| 74 | expect(parsePortFromLsofName("*:5173")).toBe(5173); | |
| 75 | }); | |
| 76 | ||
| 77 | it("parses 127.0.0.1:port", () => { | |
| 78 | expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); | |
| 79 | }); | |
| 80 | ||
| 81 | it("parses localhost:port", () => { | |
| 82 | expect(parsePortFromLsofName("localhost:5173")).toBe(5173); | |
| 83 | }); | |
| 84 | ||
| 85 | it("parses [::1]:port", () => { | |
| 86 | expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); | |
| 87 | }); | |
| 88 | ||
| 89 | it("ignores non-local hosts", () => { | |
| 90 | expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); | |
| 91 | }); | |
| 92 | ||
| 93 | it("strips trailing description", () => { | |
| 94 | expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); | |
| 95 | }); | |
| 96 | ||
| 97 | it("rejects garbage", () => { | |
| 98 | expect(parsePortFromLsofName("")).toBeNull(); | |
| 99 | expect(parsePortFromLsofName("not-a-port")).toBeNull(); | |
| 100 | expect(parsePortFromLsofName("*:0")).toBeNull(); | |
| 101 | expect(parsePortFromLsofName("*:99999")).toBeNull(); | |
| 102 | }); | |
| 103 | }); | |
| 104 | ||
| 105 | describe("parseLsofOutput", () => { | |
| 106 | it("parses a typical lsof -F pcn output", () => { | |
| 107 | const sample = [ | |
| 108 | "p12345", | |
| 109 | "cnode", | |
| 110 | "n*:5173", | |
| 111 | "p67890", | |
| 112 | "cnext-server", | |
| 113 | "n127.0.0.1:3000", | |
| 114 | "n127.0.0.1:9229", // node debug port too — same process | |
| 115 | "p13579", | |
| 116 | "cChrome", | |
| 117 | "n192.168.1.10:443", // not local — ignored | |
| 118 | ].join("\n"); | |
| 119 | ||
| 120 | const servers = parseLsofOutput(sample); | |
| 121 | expect(servers).toEqual([ | |
| 122 | { | |
| 123 | host: "localhost", | |
| 124 | port: 3000, | |
| 125 | url: "http://localhost:3000", | |
| 126 | processName: "next-server", | |
| 127 | pid: 67890, | |
| 128 | terminal: null, | |
| 129 | }, | |
| 130 | { | |
| 131 | host: "localhost", | |
| 132 | port: 5173, | |
| 133 | url: "http://localhost:5173", | |
| 134 | processName: "node", | |
| 135 | pid: 12345, | |
| 136 | terminal: null, | |
| 137 | }, | |
| 138 | { | |
| 139 | host: "localhost", | |
| 140 | port: 9229, | |
| 141 | url: "http://localhost:9229", | |
| 142 | processName: "next-server", | |
| 143 | pid: 67890, | |
| 144 | terminal: null, | |
| 145 | }, | |
| 146 | ]); | |
| 147 | }); | |
| 148 | ||
| 149 | it("handles empty input", () => { | |
| 150 | expect(parseLsofOutput("")).toEqual([]); | |
| 151 | }); | |
| 152 | ||
| 153 | it("dedupes by host:port", () => { | |
| 154 | const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); | |
| 155 | const servers = parseLsofOutput(sample); | |
| 156 | expect(servers).toHaveLength(1); | |
| 157 | expect(servers[0]?.port).toBe(5173); | |
| 158 | }); | |
| 159 | ||
| 160 | it("attributes listeners to a registered terminal process", () => { | |
| 161 | const servers = parseLsofOutput( | |
| 162 | ["p12345", "cnode", "n*:5173"].join("\n"), | |
| 163 | new Map([ | |
| 164 | [ | |
| 165 | 12345, | |
| 166 | { | |
| 167 | threadId: ThreadId.make("thread-1"), | |
| 168 | terminalId: "terminal-1", | |
| 169 | }, | |
| 170 | ], | |
| 171 | ]), | |
| 172 | ); | |
| 173 | ||
| 174 | expect(servers[0]?.terminal).toEqual({ | |
| 175 | threadId: "thread-1", | |
| 176 | terminalId: "terminal-1", | |
| 177 | }); | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | describe("serversEqual", () => { | |
| 182 | const a = { | |
| 183 | host: "localhost", | |
| 184 | port: 5173, | |
| 185 | url: "http://localhost:5173", | |
| 186 | processName: "node", | |
| 187 | pid: 1, | |
| 188 | terminal: null, | |
| 189 | }; | |
| 190 | it("returns true for identical lists", () => { | |
| 191 | expect(serversEqual([a], [{ ...a }])).toBe(true); | |
| 192 | }); | |
| 193 | it("returns false for different lengths", () => { | |
| 194 | expect(serversEqual([a], [])).toBe(false); | |
| 195 | }); | |
| 196 | it("returns false for different processName", () => { | |
| 197 | expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); | |
| 198 | }); | |
| 199 | }); | |
| 200 | ||
| 201 | describe("parseWindowsListenerOutput", () => { | |
| 202 | it("parses and attributes PowerShell listener records", () => { | |
| 203 | const servers = parseWindowsListenerOutput( | |
| 204 | "0.0.0.0|5173|12345|node", | |
| 205 | new Map([ | |
| 206 | [ | |
| 207 | 12345, | |
| 208 | { | |
| 209 | threadId: ThreadId.make("thread-1"), | |
| 210 | terminalId: "terminal-1", | |
| 211 | }, | |
| 212 | ], | |
| 213 | ]), | |
| 214 | ); | |
| 215 | ||
| 216 | expect(servers).toEqual([ | |
| 217 | { | |
| 218 | host: "localhost", | |
| 219 | port: 5173, | |
| 220 | url: "http://localhost:5173", | |
| 221 | processName: "node", | |
| 222 | pid: 12345, | |
| 223 | terminal: { | |
| 224 | threadId: "thread-1", | |
| 225 | terminalId: "terminal-1", | |
| 226 | }, | |
| 227 | }, | |
| 228 | ]); | |
| 229 | }); | |
| 230 | }); | |
| 231 | ||
| 232 | 56 | /** |
| 233 | * Integration tests against a real TCP listener. We force the Windows code | |
| 234 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the | |
| 235 | * duration of the test so we don't depend on `lsof` being installed. | |
| 57 | * Integration tests against a real TCP listener. We provide the Windows host | |
| 58 | * platform so the tests exercise the TCP-probe fallback without depending on | |
| 59 | * `lsof` being installed. | |
| 236 | 60 | */ |
| 237 | 61 | effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { |
| 238 | 62 | it.effect( |
| 239 | 63 | "scan() returns a server we just opened on a curated dev port", |
| 240 | 64 | Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { |
| 241 | yield* windowsPlatform; | |
| 242 | 65 | const { port } = yield* commonDevServer; |
| 243 | 66 | const scanner = yield* PortScanner.PortDiscovery; |
| 244 | 67 | const result = yield* scanner.scan(); |
| ... | ... | @@ -251,7 +74,6 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall |
| 251 | 74 | it.effect( |
| 252 | 75 | "retain drives an immediate broadcast to subscribers", |
| 253 | 76 | Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { |
| 254 | yield* windowsPlatform; | |
| 255 | 77 | const { port } = yield* commonDevServer; |
| 256 | 78 | const received: number[] = []; |
| 257 | 79 | const scanner = yield* PortScanner.PortDiscovery; |
apps/server/src/preview/PortScanner.ts+3-9| ... | ... | @@ -12,6 +12,7 @@ |
| 12 | 12 | * polls forever, but each tick is a no-op when the retain count is zero. |
| 13 | 13 | */ |
| 14 | 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 15 | 16 | import * as Net from "@t3tools/shared/Net"; |
| 16 | 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; |
| 17 | 18 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; |
| ... | ... | @@ -182,6 +183,7 @@ const serversEqual = ( |
| 182 | 183 | const make = Effect.gen(function* PortDiscoveryMake() { |
| 183 | 184 | const net = yield* Net.NetService; |
| 184 | 185 | const processRunner = yield* ProcessRunner; |
| 186 | const hostPlatform = yield* HostProcessPlatform; | |
| 185 | 187 | const stateRef = yield* Ref.make<ScannerState>({ |
| 186 | 188 | lastSnapshot: [], |
| 187 | 189 | listeners: new Set(), |
| ... | ... | @@ -221,7 +223,7 @@ const make = Effect.gen(function* PortDiscoveryMake() { |
| 221 | 223 | terminalByProcessId.set(processId, registration.owner); |
| 222 | 224 | } |
| 223 | 225 | } |
| 224 | if (process.platform === "win32") { | |
| 226 | if (hostPlatform === "win32") { | |
| 225 | 227 | const command = |
| 226 | 228 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; |
| 227 | 229 | const listeners = yield* processRunner |
| ... | ... | @@ -359,11 +361,3 @@ const make = Effect.gen(function* PortDiscoveryMake() { |
| 359 | 361 | }).pipe(Effect.withSpan("PortDiscovery.make")); |
| 360 | 362 | |
| 361 | 363 | export const layer = Layer.effect(PortDiscovery, make); |
| 362 | ||
| 363 | /** Exposed for tests. */ | |
| 364 | export const __testing = { | |
| 365 | parseLsofOutput, | |
| 366 | parsePortFromLsofName, | |
| 367 | parseWindowsListenerOutput, | |
| 368 | serversEqual, | |
| 369 | }; |
apps/server/src/process/externalLauncher.test.ts+129-767| ... | ... | @@ -1,9 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import { assertSuccess } from "@effect/vitest/utils"; | |
| 4 | import * as Crypto from "effect/Crypto"; | |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| 6 | import * as Encoding from "effect/Encoding"; | |
| 7 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 6 | import * as Layer from "effect/Layer"; |
| 9 | 7 | import * as Path from "effect/Path"; |
| ... | ... | @@ -11,24 +9,9 @@ import * as Sink from "effect/Sink"; |
| 11 | 9 | import * as Stream from "effect/Stream"; |
| 12 | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 11 | |
| 14 | import { | |
| 15 | isCommandAvailable, | |
| 16 | launchBrowser, | |
| 17 | launchEditorProcess, | |
| 18 | resolveAvailableEditors, | |
| 19 | resolveBrowserLaunch, | |
| 20 | resolveEditorLaunch, | |
| 21 | } from "./externalLauncher.ts"; | |
| 22 | ||
| 23 | function encodeUtf16LeBase64(input: string): string { | |
| 24 | const bytes = new Uint8Array(input.length * 2); | |
| 25 | for (let index = 0; index < input.length; index += 1) { | |
| 26 | const code = input.charCodeAt(index); | |
| 27 | bytes[index * 2] = code & 0xff; | |
| 28 | bytes[index * 2 + 1] = code >>> 8; | |
| 29 | } | |
| 30 | return Encoding.encodeBase64(bytes); | |
| 31 | } | |
| 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 13 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 14 | import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; | |
| 32 | 15 | |
| 33 | 16 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { |
| 34 | 17 | return ChildProcessSpawner.makeHandle({ |
| ... | ... | @@ -49,756 +32,135 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { |
| 49 | 32 | }); |
| 50 | 33 | } |
| 51 | 34 | |
| 52 | it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { | |
| 53 | it.effect("returns commands for command-based editors", () => | |
| 54 | Effect.gen(function* () { | |
| 55 | const antigravityLaunch = yield* resolveEditorLaunch( | |
| 56 | { cwd: "/tmp/workspace", editor: "antigravity" }, | |
| 57 | "darwin", | |
| 58 | { PATH: "" }, | |
| 59 | ); | |
| 60 | assert.deepEqual(antigravityLaunch, { | |
| 61 | command: "agy", | |
| 62 | args: ["/tmp/workspace"], | |
| 63 | }); | |
| 64 | ||
| 65 | const cursorLaunch = yield* resolveEditorLaunch( | |
| 66 | { cwd: "/tmp/workspace", editor: "cursor" }, | |
| 67 | "darwin", | |
| 68 | { PATH: "" }, | |
| 69 | ); | |
| 70 | assert.deepEqual(cursorLaunch, { | |
| 71 | command: "cursor", | |
| 72 | args: ["/tmp/workspace"], | |
| 73 | }); | |
| 74 | ||
| 75 | const traeLaunch = yield* resolveEditorLaunch( | |
| 76 | { cwd: "/tmp/workspace", editor: "trae" }, | |
| 77 | "darwin", | |
| 78 | ); | |
| 79 | assert.deepEqual(traeLaunch, { | |
| 80 | command: "trae", | |
| 81 | args: ["/tmp/workspace"], | |
| 82 | }); | |
| 83 | ||
| 84 | const kiroLaunch = yield* resolveEditorLaunch( | |
| 85 | { cwd: "/tmp/workspace", editor: "kiro" }, | |
| 86 | "darwin", | |
| 87 | { PATH: "" }, | |
| 88 | ); | |
| 89 | assert.deepEqual(kiroLaunch, { | |
| 90 | command: "kiro", | |
| 91 | args: ["ide", "/tmp/workspace"], | |
| 92 | }); | |
| 93 | ||
| 94 | const vscodeLaunch = yield* resolveEditorLaunch( | |
| 95 | { cwd: "/tmp/workspace", editor: "vscode" }, | |
| 96 | "darwin", | |
| 97 | { PATH: "" }, | |
| 98 | ); | |
| 99 | assert.deepEqual(vscodeLaunch, { | |
| 100 | command: "code", | |
| 101 | args: ["/tmp/workspace"], | |
| 102 | }); | |
| 103 | ||
| 104 | const vscodeInsidersLaunch = yield* resolveEditorLaunch( | |
| 105 | { cwd: "/tmp/workspace", editor: "vscode-insiders" }, | |
| 106 | "darwin", | |
| 107 | ); | |
| 108 | assert.deepEqual(vscodeInsidersLaunch, { | |
| 109 | command: "code-insiders", | |
| 110 | args: ["/tmp/workspace"], | |
| 111 | }); | |
| 112 | ||
| 113 | const vscodiumLaunch = yield* resolveEditorLaunch( | |
| 114 | { cwd: "/tmp/workspace", editor: "vscodium" }, | |
| 115 | "darwin", | |
| 116 | ); | |
| 117 | assert.deepEqual(vscodiumLaunch, { | |
| 118 | command: "codium", | |
| 119 | args: ["/tmp/workspace"], | |
| 120 | }); | |
| 121 | ||
| 122 | const zedLaunch = yield* resolveEditorLaunch( | |
| 123 | { cwd: "/tmp/workspace", editor: "zed" }, | |
| 124 | "darwin", | |
| 125 | { PATH: "" }, | |
| 126 | ); | |
| 127 | assert.deepEqual(zedLaunch, { | |
| 128 | command: "zed", | |
| 129 | args: ["/tmp/workspace"], | |
| 130 | }); | |
| 131 | ||
| 132 | const ideaLaunch = yield* resolveEditorLaunch( | |
| 133 | { cwd: "/tmp/workspace", editor: "idea" }, | |
| 134 | "darwin", | |
| 135 | ); | |
| 136 | assert.deepEqual(ideaLaunch, { | |
| 137 | command: "idea", | |
| 138 | args: ["/tmp/workspace"], | |
| 139 | }); | |
| 140 | ||
| 141 | const aquaLaunch = yield* resolveEditorLaunch( | |
| 142 | { cwd: "/tmp/workspace", editor: "aqua" }, | |
| 143 | "darwin", | |
| 144 | ); | |
| 145 | assert.deepEqual(aquaLaunch, { | |
| 146 | command: "aqua", | |
| 147 | args: ["/tmp/workspace"], | |
| 148 | }); | |
| 149 | ||
| 150 | const clionLaunch = yield* resolveEditorLaunch( | |
| 151 | { cwd: "/tmp/workspace", editor: "clion" }, | |
| 152 | "darwin", | |
| 153 | ); | |
| 154 | assert.deepEqual(clionLaunch, { | |
| 155 | command: "clion", | |
| 156 | args: ["/tmp/workspace"], | |
| 157 | }); | |
| 158 | ||
| 159 | const datagripLaunch = yield* resolveEditorLaunch( | |
| 160 | { cwd: "/tmp/workspace", editor: "datagrip" }, | |
| 161 | "darwin", | |
| 162 | ); | |
| 163 | assert.deepEqual(datagripLaunch, { | |
| 164 | command: "datagrip", | |
| 165 | args: ["/tmp/workspace"], | |
| 166 | }); | |
| 167 | ||
| 168 | const dataspellLaunch = yield* resolveEditorLaunch( | |
| 169 | { cwd: "/tmp/workspace", editor: "dataspell" }, | |
| 170 | "darwin", | |
| 171 | ); | |
| 172 | assert.deepEqual(dataspellLaunch, { | |
| 173 | command: "dataspell", | |
| 174 | args: ["/tmp/workspace"], | |
| 175 | }); | |
| 176 | ||
| 177 | const golandLaunch = yield* resolveEditorLaunch( | |
| 178 | { cwd: "/tmp/workspace", editor: "goland" }, | |
| 179 | "darwin", | |
| 180 | ); | |
| 181 | assert.deepEqual(golandLaunch, { | |
| 182 | command: "goland", | |
| 183 | args: ["/tmp/workspace"], | |
| 184 | }); | |
| 185 | ||
| 186 | const phpstormLaunch = yield* resolveEditorLaunch( | |
| 187 | { cwd: "/tmp/workspace", editor: "phpstorm" }, | |
| 188 | "darwin", | |
| 189 | ); | |
| 190 | assert.deepEqual(phpstormLaunch, { | |
| 191 | command: "phpstorm", | |
| 192 | args: ["/tmp/workspace"], | |
| 193 | }); | |
| 194 | ||
| 195 | const pycharmLaunch = yield* resolveEditorLaunch( | |
| 196 | { cwd: "/tmp/workspace", editor: "pycharm" }, | |
| 197 | "darwin", | |
| 198 | ); | |
| 199 | assert.deepEqual(pycharmLaunch, { | |
| 200 | command: "pycharm", | |
| 201 | args: ["/tmp/workspace"], | |
| 202 | }); | |
| 203 | ||
| 204 | const riderLaunch = yield* resolveEditorLaunch( | |
| 205 | { cwd: "/tmp/workspace", editor: "rider" }, | |
| 206 | "darwin", | |
| 207 | ); | |
| 208 | assert.deepEqual(riderLaunch, { | |
| 209 | command: "rider", | |
| 210 | args: ["/tmp/workspace"], | |
| 211 | }); | |
| 212 | ||
| 213 | const rubymineLaunch = yield* resolveEditorLaunch( | |
| 214 | { cwd: "/tmp/workspace", editor: "rubymine" }, | |
| 215 | "darwin", | |
| 216 | ); | |
| 217 | assert.deepEqual(rubymineLaunch, { | |
| 218 | command: "rubymine", | |
| 219 | args: ["/tmp/workspace"], | |
| 220 | }); | |
| 221 | ||
| 222 | const rustroverLaunch = yield* resolveEditorLaunch( | |
| 223 | { cwd: "/tmp/workspace", editor: "rustrover" }, | |
| 224 | "darwin", | |
| 225 | ); | |
| 226 | assert.deepEqual(rustroverLaunch, { | |
| 227 | command: "rustrover", | |
| 228 | args: ["/tmp/workspace"], | |
| 229 | }); | |
| 230 | ||
| 231 | const webstormLaunch = yield* resolveEditorLaunch( | |
| 232 | { cwd: "/tmp/workspace", editor: "webstorm" }, | |
| 233 | "darwin", | |
| 234 | ); | |
| 235 | assert.deepEqual(webstormLaunch, { | |
| 236 | command: "webstorm", | |
| 237 | args: ["/tmp/workspace"], | |
| 238 | }); | |
| 239 | }), | |
| 240 | ); | |
| 241 | ||
| 242 | it.effect("applies launch-style-specific navigation arguments", () => | |
| 243 | Effect.gen(function* () { | |
| 244 | const lineOnly = yield* resolveEditorLaunch( | |
| 245 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, | |
| 246 | "darwin", | |
| 247 | { PATH: "" }, | |
| 248 | ); | |
| 249 | assert.deepEqual(lineOnly, { | |
| 250 | command: "cursor", | |
| 251 | args: ["--goto", "/tmp/workspace/AGENTS.md:48"], | |
| 252 | }); | |
| 253 | ||
| 254 | const lineAndColumn = yield* resolveEditorLaunch( | |
| 255 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" }, | |
| 256 | "darwin", | |
| 257 | { PATH: "" }, | |
| 258 | ); | |
| 259 | assert.deepEqual(lineAndColumn, { | |
| 260 | command: "cursor", | |
| 261 | args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 262 | }); | |
| 263 | ||
| 264 | const traeLineAndColumn = yield* resolveEditorLaunch( | |
| 265 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" }, | |
| 266 | "darwin", | |
| 267 | ); | |
| 268 | assert.deepEqual(traeLineAndColumn, { | |
| 269 | command: "trae", | |
| 270 | args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 271 | }); | |
| 272 | ||
| 273 | const kiroLineAndColumn = yield* resolveEditorLaunch( | |
| 274 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" }, | |
| 275 | "darwin", | |
| 276 | { PATH: "" }, | |
| 277 | ); | |
| 278 | assert.deepEqual(kiroLineAndColumn, { | |
| 279 | command: "kiro", | |
| 280 | args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 281 | }); | |
| 282 | ||
| 283 | const vscodeLineAndColumn = yield* resolveEditorLaunch( | |
| 284 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" }, | |
| 285 | "darwin", | |
| 286 | { PATH: "" }, | |
| 287 | ); | |
| 288 | assert.deepEqual(vscodeLineAndColumn, { | |
| 289 | command: "code", | |
| 290 | args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 291 | }); | |
| 292 | ||
| 293 | const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( | |
| 294 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" }, | |
| 295 | "darwin", | |
| 296 | ); | |
| 297 | assert.deepEqual(vscodeInsidersLineAndColumn, { | |
| 298 | command: "code-insiders", | |
| 299 | args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 300 | }); | |
| 301 | ||
| 302 | const vscodiumLineAndColumn = yield* resolveEditorLaunch( | |
| 303 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" }, | |
| 304 | "darwin", | |
| 305 | ); | |
| 306 | assert.deepEqual(vscodiumLineAndColumn, { | |
| 307 | command: "codium", | |
| 308 | args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 309 | }); | |
| 310 | ||
| 311 | const zedLineAndColumn = yield* resolveEditorLaunch( | |
| 312 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" }, | |
| 313 | "darwin", | |
| 314 | { PATH: "" }, | |
| 315 | ); | |
| 316 | assert.deepEqual(zedLineAndColumn, { | |
| 317 | command: "zed", | |
| 318 | args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"], | |
| 319 | }); | |
| 320 | ||
| 321 | const zedLineOnly = yield* resolveEditorLaunch( | |
| 322 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, | |
| 323 | "darwin", | |
| 324 | { PATH: "" }, | |
| 325 | ); | |
| 326 | assert.deepEqual(zedLineOnly, { | |
| 327 | command: "zed", | |
| 328 | args: ["/tmp/workspace/AGENTS.md:48"], | |
| 329 | }); | |
| 330 | ||
| 331 | const ideaLineOnly = yield* resolveEditorLaunch( | |
| 332 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, | |
| 333 | "darwin", | |
| 334 | ); | |
| 335 | assert.deepEqual(ideaLineOnly, { | |
| 336 | command: "idea", | |
| 337 | args: ["--line", "48", "/tmp/workspace/AGENTS.md"], | |
| 338 | }); | |
| 339 | ||
| 340 | const ideaLineAndColumn = yield* resolveEditorLaunch( | |
| 341 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" }, | |
| 342 | "darwin", | |
| 343 | ); | |
| 344 | assert.deepEqual(ideaLineAndColumn, { | |
| 345 | command: "idea", | |
| 346 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 347 | }); | |
| 348 | ||
| 349 | const aquaLineAndColumn = yield* resolveEditorLaunch( | |
| 350 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" }, | |
| 351 | "darwin", | |
| 352 | ); | |
| 353 | assert.deepEqual(aquaLineAndColumn, { | |
| 354 | command: "aqua", | |
| 355 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 356 | }); | |
| 357 | ||
| 358 | const clionLineAndColumn = yield* resolveEditorLaunch( | |
| 359 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" }, | |
| 360 | "darwin", | |
| 361 | ); | |
| 362 | assert.deepEqual(clionLineAndColumn, { | |
| 363 | command: "clion", | |
| 364 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 365 | }); | |
| 366 | ||
| 367 | const datagripLineAndColumn = yield* resolveEditorLaunch( | |
| 368 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" }, | |
| 369 | "darwin", | |
| 370 | ); | |
| 371 | assert.deepEqual(datagripLineAndColumn, { | |
| 372 | command: "datagrip", | |
| 373 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 374 | }); | |
| 375 | ||
| 376 | const dataspellLineAndColumn = yield* resolveEditorLaunch( | |
| 377 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" }, | |
| 378 | "darwin", | |
| 379 | ); | |
| 380 | assert.deepEqual(dataspellLineAndColumn, { | |
| 381 | command: "dataspell", | |
| 382 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 383 | }); | |
| 384 | ||
| 385 | const golandLineAndColumn = yield* resolveEditorLaunch( | |
| 386 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" }, | |
| 387 | "darwin", | |
| 388 | ); | |
| 389 | assert.deepEqual(golandLineAndColumn, { | |
| 390 | command: "goland", | |
| 391 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 392 | }); | |
| 393 | ||
| 394 | const phpstormLineAndColumn = yield* resolveEditorLaunch( | |
| 395 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" }, | |
| 396 | "darwin", | |
| 397 | ); | |
| 398 | assert.deepEqual(phpstormLineAndColumn, { | |
| 399 | command: "phpstorm", | |
| 400 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 401 | }); | |
| 402 | ||
| 403 | const pycharmLineAndColumn = yield* resolveEditorLaunch( | |
| 404 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" }, | |
| 405 | "darwin", | |
| 406 | ); | |
| 407 | assert.deepEqual(pycharmLineAndColumn, { | |
| 408 | command: "pycharm", | |
| 409 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 410 | }); | |
| 411 | ||
| 412 | const riderLineAndColumn = yield* resolveEditorLaunch( | |
| 413 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" }, | |
| 414 | "darwin", | |
| 415 | ); | |
| 416 | assert.deepEqual(riderLineAndColumn, { | |
| 417 | command: "rider", | |
| 418 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 419 | }); | |
| 420 | ||
| 421 | const rubymineLineAndColumn = yield* resolveEditorLaunch( | |
| 422 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" }, | |
| 423 | "darwin", | |
| 424 | ); | |
| 425 | assert.deepEqual(rubymineLineAndColumn, { | |
| 426 | command: "rubymine", | |
| 427 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 428 | }); | |
| 429 | ||
| 430 | const rustroverLineAndColumn = yield* resolveEditorLaunch( | |
| 431 | { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" }, | |
| 432 | "darwin", | |
| 433 | ); | |
| 434 | assert.deepEqual(rustroverLineAndColumn, { | |
| 435 | command: "rustrover", | |
| 436 | args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], | |
| 437 | }); | |
| 438 | ||
| 439 | const webstormLineOnly = yield* resolveEditorLaunch( | |
| 440 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" }, | |
| 441 | "darwin", | |
| 442 | ); | |
| 443 | assert.deepEqual(webstormLineOnly, { | |
| 444 | command: "webstorm", | |
| 445 | args: ["--line", "48", "/tmp/workspace/AGENTS.md"], | |
| 446 | }); | |
| 447 | }), | |
| 448 | ); | |
| 449 | ||
| 450 | it.effect("falls back to zeditor when zed is not installed", () => | |
| 451 | Effect.gen(function* () { | |
| 452 | const fs = yield* FileSystem.FileSystem; | |
| 453 | const path = yield* Path.Path; | |
| 454 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | |
| 455 | yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); | |
| 456 | yield* fs.chmod(path.join(dir, "zeditor"), 0o755); | |
| 457 | ||
| 458 | const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { | |
| 459 | PATH: dir, | |
| 460 | }); | |
| 461 | ||
| 462 | assert.deepEqual(result, { | |
| 463 | command: "zeditor", | |
| 464 | args: ["/tmp/workspace"], | |
| 465 | }); | |
| 466 | }), | |
| 467 | ); | |
| 468 | ||
| 469 | it.effect("falls back to the primary command when no alias is installed", () => | |
| 470 | Effect.gen(function* () { | |
| 471 | const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { | |
| 472 | PATH: "", | |
| 473 | }); | |
| 474 | assert.deepEqual(result, { | |
| 475 | command: "zed", | |
| 476 | args: ["/tmp/workspace"], | |
| 477 | }); | |
| 478 | }), | |
| 479 | ); | |
| 480 | ||
| 481 | it.effect("maps file-manager editor to OS open commands", () => | |
| 482 | Effect.gen(function* () { | |
| 483 | const launch1 = yield* resolveEditorLaunch( | |
| 484 | { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 485 | "darwin", | |
| 486 | { PATH: "" }, | |
| 487 | ); | |
| 488 | assert.deepEqual(launch1, { | |
| 489 | command: "open", | |
| 490 | args: ["/tmp/workspace"], | |
| 491 | }); | |
| 492 | ||
| 493 | const launch2 = yield* resolveEditorLaunch( | |
| 494 | { cwd: "C:\\workspace", editor: "file-manager" }, | |
| 495 | "win32", | |
| 496 | { PATH: "" }, | |
| 497 | ); | |
| 498 | assert.deepEqual(launch2, { | |
| 499 | command: "explorer", | |
| 500 | args: ["C:\\workspace"], | |
| 501 | }); | |
| 502 | ||
| 503 | const launch3 = yield* resolveEditorLaunch( | |
| 504 | { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 505 | "linux", | |
| 506 | { PATH: "" }, | |
| 507 | ); | |
| 508 | assert.deepEqual(launch3, { | |
| 509 | command: "xdg-open", | |
| 510 | args: ["/tmp/workspace"], | |
| 511 | }); | |
| 512 | }), | |
| 513 | ); | |
| 514 | }); | |
| 515 | ||
| 516 | it("resolveBrowserLaunch maps default browser launchers by platform", () => { | |
| 517 | const target = "https://example.com/some path?name=o'hara"; | |
| 518 | ||
| 519 | assert.deepEqual(resolveBrowserLaunch(target, "darwin").command, "open"); | |
| 520 | assert.deepEqual(resolveBrowserLaunch(target, "darwin").args, [target]); | |
| 521 | assert.deepEqual(resolveBrowserLaunch(target, "darwin").options, { | |
| 522 | detached: true, | |
| 523 | stdin: "ignore", | |
| 524 | stdout: "ignore", | |
| 525 | stderr: "ignore", | |
| 526 | }); | |
| 527 | ||
| 528 | assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open"); | |
| 529 | assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]); | |
| 530 | ||
| 531 | const windows = resolveBrowserLaunch(target, "win32", { | |
| 532 | SYSTEMROOT: "C:\\Windows", | |
| 533 | }); | |
| 534 | assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); | |
| 535 | assert.deepEqual(windows.args, [ | |
| 536 | "-NoProfile", | |
| 537 | "-NonInteractive", | |
| 538 | "-ExecutionPolicy", | |
| 539 | "Bypass", | |
| 540 | "-EncodedCommand", | |
| 541 | encodeUtf16LeBase64( | |
| 542 | "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", | |
| 35 | const testLayer = (input: { | |
| 36 | readonly platform: NodeJS.Platform; | |
| 37 | readonly env?: Record<string, string>; | |
| 38 | readonly resolveExecutable?: (command: string) => string | undefined; | |
| 39 | readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; | |
| 40 | readonly onUnref?: () => void; | |
| 41 | }) => { | |
| 42 | const spawnerLayer = Layer.succeed( | |
| 43 | ChildProcessSpawner.ChildProcessSpawner, | |
| 44 | ChildProcessSpawner.make((command) => | |
| 45 | Effect.sync(() => { | |
| 46 | assert.equal(ChildProcess.isStandardCommand(command), true); | |
| 47 | if (!ChildProcess.isStandardCommand(command)) { | |
| 48 | throw new Error("Expected a standard command"); | |
| 49 | } | |
| 50 | input.onSpawn?.(command); | |
| 51 | return makeMockDetachedHandle(input.onUnref); | |
| 52 | }), | |
| 543 | 53 | ), |
| 544 | ]); | |
| 545 | assert.deepEqual(windows.options, { | |
| 546 | detached: true, | |
| 547 | shell: false, | |
| 548 | stdin: "ignore", | |
| 549 | stdout: "ignore", | |
| 550 | stderr: "ignore", | |
| 551 | }); | |
| 552 | }); | |
| 553 | ||
| 554 | it("resolveBrowserLaunch opens through Windows from WSL when not remote", () => { | |
| 555 | const launch = resolveBrowserLaunch("https://example.com", "linux", { | |
| 556 | WSL_DISTRO_NAME: "Ubuntu", | |
| 557 | }); | |
| 558 | assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); | |
| 559 | assert.equal(launch.options.detached, true); | |
| 560 | }); | |
| 561 | ||
| 562 | it("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => { | |
| 563 | const launch = resolveBrowserLaunch("https://example.com", "linux", { | |
| 564 | WSL_DISTRO_NAME: "Ubuntu", | |
| 565 | SSH_CONNECTION: "client server", | |
| 566 | }); | |
| 567 | assert.equal(launch.command, "xdg-open"); | |
| 568 | }); | |
| 569 | ||
| 570 | it.layer(NodeServices.layer)("launchBrowser", (it) => { | |
| 571 | it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => | |
| 572 | Effect.gen(function* () { | |
| 573 | let spawnedCommand: ChildProcess.StandardCommand | undefined; | |
| 574 | let didUnref = false; | |
| 575 | ||
| 576 | const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { | |
| 577 | spawn: (command) => | |
| 578 | Effect.sync(() => { | |
| 579 | assert.equal(ChildProcess.isStandardCommand(command), true); | |
| 580 | if (!ChildProcess.isStandardCommand(command)) { | |
| 581 | throw new Error("Expected a standard command"); | |
| 582 | } | |
| 583 | spawnedCommand = command; | |
| 584 | return makeMockDetachedHandle(() => { | |
| 585 | didUnref = true; | |
| 586 | }); | |
| 587 | }), | |
| 588 | }); | |
| 589 | ||
| 590 | const result = yield* launchBrowser("https://example.com").pipe( | |
| 591 | Effect.provide(spawnerLayer), | |
| 592 | Effect.result, | |
| 593 | ); | |
| 594 | ||
| 595 | assertSuccess(result, undefined); | |
| 596 | assert.ok(spawnedCommand); | |
| 597 | const expectedLaunch = resolveBrowserLaunch("https://example.com"); | |
| 598 | assert.equal(spawnedCommand.command, expectedLaunch.command); | |
| 599 | assert.deepEqual(spawnedCommand.args, expectedLaunch.args); | |
| 600 | assert.deepEqual(spawnedCommand.options, expectedLaunch.options); | |
| 601 | assert.equal(didUnref, true); | |
| 602 | }), | |
| 603 | ); | |
| 604 | }); | |
| 605 | ||
| 606 | it.layer(NodeServices.layer)("launchEditorProcess", (it) => { | |
| 607 | it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => | |
| 608 | Effect.gen(function* () { | |
| 609 | let spawnedCommand: ChildProcess.StandardCommand | undefined; | |
| 610 | let didUnref = false; | |
| 611 | const expectedArgs = ["-e", "process.exit(0)"]; | |
| 612 | ||
| 613 | const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { | |
| 614 | spawn: (command) => | |
| 615 | Effect.sync(() => { | |
| 616 | assert.equal(ChildProcess.isStandardCommand(command), true); | |
| 617 | if (!ChildProcess.isStandardCommand(command)) { | |
| 618 | throw new Error("Expected a standard command"); | |
| 619 | } | |
| 620 | spawnedCommand = command; | |
| 621 | return makeMockDetachedHandle(() => { | |
| 622 | didUnref = true; | |
| 623 | }); | |
| 624 | }), | |
| 625 | }); | |
| 626 | ||
| 627 | const result = yield* launchEditorProcess({ | |
| 628 | command: process.execPath, | |
| 629 | args: expectedArgs, | |
| 630 | }).pipe(Effect.provide(spawnerLayer), Effect.result); | |
| 631 | ||
| 632 | assertSuccess(result, undefined); | |
| 633 | assert.ok(spawnedCommand); | |
| 634 | assert.equal(spawnedCommand.command, process.execPath); | |
| 635 | assert.deepEqual( | |
| 636 | spawnedCommand.args, | |
| 637 | process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs, | |
| 638 | ); | |
| 639 | assert.deepEqual(spawnedCommand.options, { | |
| 640 | detached: true, | |
| 641 | shell: process.platform === "win32", | |
| 642 | stdin: "ignore", | |
| 643 | stdout: "ignore", | |
| 644 | stderr: "ignore", | |
| 645 | }); | |
| 646 | assert.equal(didUnref, true); | |
| 647 | }), | |
| 648 | ); | |
| 649 | ||
| 650 | it.effect("rejects when command does not exist", () => | |
| 651 | Effect.gen(function* () { | |
| 652 | const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}); | |
| 653 | const result = yield* launchEditorProcess({ | |
| 654 | command: `t3code-no-such-command-${yield* Crypto.Crypto.pipe( | |
| 655 | Effect.flatMap((crypto) => crypto.randomUUIDv4), | |
| 656 | )}`, | |
| 657 | args: [], | |
| 658 | }).pipe(Effect.provide(spawnerLayer), Effect.result); | |
| 659 | assert.equal(result._tag, "Failure"); | |
| 660 | }), | |
| 661 | ); | |
| 662 | }); | |
| 663 | ||
| 664 | it.layer(NodeServices.layer)("isCommandAvailable", (it) => { | |
| 665 | it.effect("resolves win32 commands with PATHEXT", () => | |
| 666 | Effect.gen(function* () { | |
| 667 | const fs = yield* FileSystem.FileSystem; | |
| 668 | const path = yield* Path.Path; | |
| 669 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | |
| 670 | yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); | |
| 671 | const env = { | |
| 672 | PATH: dir, | |
| 673 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 674 | } satisfies NodeJS.ProcessEnv; | |
| 675 | assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); | |
| 676 | }), | |
| 677 | ); | |
| 678 | ||
| 679 | it("returns false when a command is not on PATH", () => { | |
| 680 | const env = { | |
| 681 | PATH: "", | |
| 682 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 683 | } satisfies NodeJS.ProcessEnv; | |
| 684 | assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); | |
| 685 | }); | |
| 686 | ||
| 687 | it.effect("does not treat bare files without executable extension as available on win32", () => | |
| 688 | Effect.gen(function* () { | |
| 689 | const fs = yield* FileSystem.FileSystem; | |
| 690 | const path = yield* Path.Path; | |
| 691 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | |
| 692 | yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); | |
| 693 | const env = { | |
| 694 | PATH: dir, | |
| 695 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 696 | } satisfies NodeJS.ProcessEnv; | |
| 697 | assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); | |
| 698 | }), | |
| 699 | 54 | ); |
| 700 | 55 | |
| 701 | it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => | |
| 702 | Effect.gen(function* () { | |
| 703 | const fs = yield* FileSystem.FileSystem; | |
| 704 | const path = yield* Path.Path; | |
| 705 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | |
| 706 | yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); | |
| 707 | const env = { | |
| 708 | PATH: dir, | |
| 709 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 710 | } satisfies NodeJS.ProcessEnv; | |
| 711 | assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); | |
| 712 | }), | |
| 56 | return Layer.mergeAll( | |
| 57 | ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), | |
| 58 | Layer.succeed(HostProcessPlatform, input.platform), | |
| 59 | Layer.succeed( | |
| 60 | SpawnExecutableResolution, | |
| 61 | (command) => input.resolveExecutable?.(command) ?? command, | |
| 62 | ), | |
| 63 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), | |
| 713 | 64 | ); |
| 714 | ||
| 715 | it.effect("uses platform-specific PATH delimiter for platform overrides", () => | |
| 716 | Effect.gen(function* () { | |
| 717 | const fs = yield* FileSystem.FileSystem; | |
| 718 | const path = yield* Path.Path; | |
| 719 | const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | |
| 720 | const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | |
| 721 | yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); | |
| 722 | yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); | |
| 723 | const env = { | |
| 724 | PATH: `${firstDir};${secondDir}`, | |
| 725 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 726 | } satisfies NodeJS.ProcessEnv; | |
| 727 | assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); | |
| 728 | }), | |
| 65 | }; | |
| 66 | ||
| 67 | it.effect("launches the default browser through the platform command", () => { | |
| 68 | let spawned: ChildProcess.StandardCommand | undefined; | |
| 69 | let didUnref = false; | |
| 70 | return Effect.gen(function* () { | |
| 71 | const launcher = yield* ExternalLauncher; | |
| 72 | ||
| 73 | yield* launcher.launchBrowser("https://example.com/some path"); | |
| 74 | ||
| 75 | assert.ok(spawned); | |
| 76 | assert.equal(spawned.command, "xdg-open"); | |
| 77 | assert.deepEqual(spawned.args, ["https://example.com/some path"]); | |
| 78 | assert.equal(spawned.options.detached, true); | |
| 79 | assert.equal(didUnref, true); | |
| 80 | }).pipe( | |
| 81 | Effect.provide( | |
| 82 | testLayer({ | |
| 83 | platform: "linux", | |
| 84 | onSpawn: (command) => { | |
| 85 | spawned = command; | |
| 86 | }, | |
| 87 | onUnref: () => { | |
| 88 | didUnref = true; | |
| 89 | }, | |
| 90 | }), | |
| 91 | ), | |
| 729 | 92 | ); |
| 730 | 93 | }); |
| 731 | 94 | |
| 732 | it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { | |
| 733 | it.effect("returns installed editors for command launches", () => | |
| 734 | Effect.gen(function* () { | |
| 735 | const fs = yield* FileSystem.FileSystem; | |
| 736 | const path = yield* Path.Path; | |
| 737 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 738 | ||
| 739 | yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); | |
| 740 | yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); | |
| 741 | yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); | |
| 742 | yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); | |
| 743 | yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); | |
| 744 | yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); | |
| 745 | yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); | |
| 746 | yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); | |
| 747 | yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); | |
| 748 | yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); | |
| 749 | yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); | |
| 750 | yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); | |
| 751 | yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); | |
| 752 | yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); | |
| 753 | yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); | |
| 754 | yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); | |
| 755 | const editors = resolveAvailableEditors("win32", { | |
| 756 | PATH: dir, | |
| 757 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 758 | }); | |
| 759 | assert.deepEqual(editors, [ | |
| 760 | "trae", | |
| 761 | "kiro", | |
| 762 | "vscode-insiders", | |
| 763 | "vscodium", | |
| 764 | "aqua", | |
| 765 | "clion", | |
| 766 | "datagrip", | |
| 767 | "dataspell", | |
| 768 | "goland", | |
| 769 | "phpstorm", | |
| 770 | "pycharm", | |
| 771 | "rider", | |
| 772 | "rubymine", | |
| 773 | "rustrover", | |
| 774 | "webstorm", | |
| 775 | "file-manager", | |
| 776 | ]); | |
| 777 | }), | |
| 778 | ); | |
| 779 | ||
| 780 | it.effect("includes zed when only the zeditor command is installed", () => | |
| 781 | Effect.gen(function* () { | |
| 782 | const fs = yield* FileSystem.FileSystem; | |
| 783 | const path = yield* Path.Path; | |
| 784 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 785 | ||
| 786 | yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); | |
| 787 | yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); | |
| 788 | yield* fs.chmod(path.join(dir, "zeditor"), 0o755); | |
| 789 | yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); | |
| 790 | ||
| 791 | const editors = resolveAvailableEditors("linux", { | |
| 792 | PATH: dir, | |
| 793 | }); | |
| 794 | assert.deepEqual(editors, ["zed", "file-manager"]); | |
| 795 | }), | |
| 796 | ); | |
| 797 | ||
| 798 | it("omits file-manager when the platform opener is unavailable", () => { | |
| 799 | const editors = resolveAvailableEditors("linux", { | |
| 800 | PATH: "", | |
| 801 | }); | |
| 802 | assert.deepEqual(editors, []); | |
| 803 | }); | |
| 804 | }); | |
| 95 | it.effect("launches an installed editor with platform-safe arguments", () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const fileSystem = yield* FileSystem.FileSystem; | |
| 98 | const path = yield* Path.Path; | |
| 99 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 100 | yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); | |
| 101 | ||
| 102 | let spawned: ChildProcess.StandardCommand | undefined; | |
| 103 | yield* Effect.gen(function* () { | |
| 104 | const launcher = yield* ExternalLauncher; | |
| 105 | yield* launcher.launchEditor({ | |
| 106 | editor: "vscode", | |
| 107 | cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", | |
| 108 | }); | |
| 109 | }).pipe( | |
| 110 | Effect.provide( | |
| 111 | testLayer({ | |
| 112 | platform: "win32", | |
| 113 | env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 114 | resolveExecutable: (command) => | |
| 115 | command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, | |
| 116 | onSpawn: (command) => { | |
| 117 | spawned = command; | |
| 118 | }, | |
| 119 | }), | |
| 120 | ), | |
| 121 | ); | |
| 122 | ||
| 123 | assert.ok(spawned); | |
| 124 | assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); | |
| 125 | assert.deepEqual(spawned.args, [ | |
| 126 | '^"--goto^"', | |
| 127 | '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', | |
| 128 | ]); | |
| 129 | assert.equal(spawned.options.shell, true); | |
| 130 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 131 | ); | |
| 132 | ||
| 133 | it.effect("discovers editors through the service API", () => | |
| 134 | Effect.gen(function* () { | |
| 135 | const fileSystem = yield* FileSystem.FileSystem; | |
| 136 | const path = yield* Path.Path; | |
| 137 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 138 | yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); | |
| 139 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); | |
| 140 | ||
| 141 | const editors = yield* Effect.gen(function* () { | |
| 142 | const launcher = yield* ExternalLauncher; | |
| 143 | return yield* launcher.resolveAvailableEditors(); | |
| 144 | }).pipe( | |
| 145 | Effect.provide( | |
| 146 | testLayer({ | |
| 147 | platform: "win32", | |
| 148 | env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 149 | }), | |
| 150 | ), | |
| 151 | ); | |
| 152 | ||
| 153 | assert.equal(editors.includes("vscode"), true); | |
| 154 | assert.equal(editors.includes("file-manager"), true); | |
| 155 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 156 | ); | |
| 157 | ||
| 158 | it.effect("rejects unknown editors through the service API", () => | |
| 159 | Effect.gen(function* () { | |
| 160 | const launcher = yield* ExternalLauncher; | |
| 161 | const result = yield* launcher | |
| 162 | .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) | |
| 163 | .pipe(Effect.result); | |
| 164 | assert.equal(result._tag, "Failure"); | |
| 165 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), | |
| 166 | ); |
apps/server/src/process/externalLauncher.ts+103-37| ... | ... | @@ -12,12 +12,16 @@ import { |
| 12 | 12 | type EditorId, |
| 13 | 13 | type LaunchEditorInput, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; | |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 16 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 17 | import * as Config from "effect/Config"; | |
| 16 | 18 | import * as Context from "effect/Context"; |
| 17 | 19 | import * as Effect from "effect/Effect"; |
| 18 | 20 | import * as Encoding from "effect/Encoding"; |
| 21 | import * as FileSystem from "effect/FileSystem"; | |
| 19 | 22 | import * as Layer from "effect/Layer"; |
| 20 | 23 | import * as Option from "effect/Option"; |
| 24 | import * as Path from "effect/Path"; | |
| 21 | 25 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | 26 | |
| 23 | 27 | // ============================== |
| ... | ... | @@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 26 | 30 | |
| 27 | 31 | export { ExternalLauncherError }; |
| 28 | 32 | export type { LaunchEditorInput }; |
| 29 | export { isCommandAvailable } from "@t3tools/shared/shell"; | |
| 30 | ||
| 31 | 33 | interface EditorLaunch { |
| 32 | 34 | readonly command: string; |
| 33 | 35 | readonly args: ReadonlyArray<string>; |
| ... | ... | @@ -61,6 +63,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { |
| 61 | 63 | stderr: "ignore", |
| 62 | 64 | } as const satisfies ChildProcess.CommandOptions; |
| 63 | 65 | |
| 66 | const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv => | |
| 67 | Object.fromEntries( | |
| 68 | Object.entries(input).flatMap(([key, value]) => | |
| 69 | Option.match(value, { | |
| 70 | onNone: () => [], | |
| 71 | onSome: (resolved) => [[key, resolved]], | |
| 72 | }), | |
| 73 | ), | |
| 74 | ); | |
| 75 | ||
| 76 | const BrowserLaunchEnvConfig = Config.all({ | |
| 77 | SYSTEMROOT: Config.string("SYSTEMROOT").pipe(Config.option), | |
| 78 | windir: Config.string("windir").pipe(Config.option), | |
| 79 | WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), | |
| 80 | WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), | |
| 81 | SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), | |
| 82 | SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), | |
| 83 | container: Config.string("container").pipe(Config.option), | |
| 84 | }).pipe(Config.map(compactEnv)); | |
| 85 | ||
| 86 | const CommandLookupEnvConfig = Config.all({ | |
| 87 | PATH: Config.string("PATH").pipe(Config.option), | |
| 88 | Path: Config.string("Path").pipe(Config.option), | |
| 89 | path: Config.string("path").pipe(Config.option), | |
| 90 | PATHEXT: Config.string("PATHEXT").pipe(Config.option), | |
| 91 | }).pipe(Config.map(compactEnv)); | |
| 92 | ||
| 93 | const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); | |
| 94 | const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); | |
| 95 | ||
| 64 | 96 | function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> { |
| 65 | 97 | const match = TARGET_WITH_POSITION_PATTERN.exec(target); |
| 66 | 98 | if (!match?.[1] || !match[2]) { |
| ... | ... | @@ -109,17 +141,17 @@ function resolveEditorArgs( |
| 109 | 141 | return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; |
| 110 | 142 | } |
| 111 | 143 | |
| 112 | function resolveAvailableCommand( | |
| 144 | const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( | |
| 113 | 145 | commands: ReadonlyArray<string>, |
| 114 | options: CommandAvailabilityOptions = {}, | |
| 115 | ): Option.Option<string> { | |
| 146 | env: NodeJS.ProcessEnv, | |
| 147 | ): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> { | |
| 116 | 148 | for (const command of commands) { |
| 117 | if (isCommandAvailable(command, options)) { | |
| 149 | if (yield* isCommandAvailable(command, { env })) { | |
| 118 | 150 | return Option.some(command); |
| 119 | 151 | } |
| 120 | 152 | } |
| 121 | 153 | return Option.none(); |
| 122 | } | |
| 154 | }); | |
| 123 | 155 | |
| 124 | 156 | function encodeUtf16LeBase64(input: string): string { |
| 125 | 157 | const bytes = new Uint8Array(input.length * 2); |
| ... | ... | @@ -135,7 +167,7 @@ function escapePowerShellStringLiteral(input: string): string { |
| 135 | 167 | return `'${input.replaceAll("'", "''")}'`; |
| 136 | 168 | } |
| 137 | 169 | |
| 138 | function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string { | |
| 170 | function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { | |
| 139 | 171 | return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; |
| 140 | 172 | } |
| 141 | 173 | |
| ... | ... | @@ -145,7 +177,7 @@ function resolveWslPowerShellPath(): string { |
| 145 | 177 | |
| 146 | 178 | function shouldUseWindowsBrowserFromWsl( |
| 147 | 179 | platform: NodeJS.Platform, |
| 148 | env: NodeJS.ProcessEnv = process.env, | |
| 180 | env: NodeJS.ProcessEnv = {}, | |
| 149 | 181 | ): boolean { |
| 150 | 182 | return ( |
| 151 | 183 | platform === "linux" && |
| ... | ... | @@ -184,10 +216,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { |
| 184 | 216 | } |
| 185 | 217 | } |
| 186 | 218 | |
| 187 | export function resolveBrowserLaunch( | |
| 219 | function buildBrowserLaunch( | |
| 188 | 220 | target: string, |
| 189 | platform: NodeJS.Platform = process.platform, | |
| 190 | env: NodeJS.ProcessEnv = process.env, | |
| 221 | platform: NodeJS.Platform, | |
| 222 | env: NodeJS.ProcessEnv = {}, | |
| 191 | 223 | ): ProcessLaunch { |
| 192 | 224 | if (platform === "darwin") { |
| 193 | 225 | return { |
| ... | ... | @@ -212,34 +244,49 @@ export function resolveBrowserLaunch( |
| 212 | 244 | }; |
| 213 | 245 | } |
| 214 | 246 | |
| 215 | export function resolveAvailableEditors( | |
| 216 | platform: NodeJS.Platform = process.platform, | |
| 217 | env: NodeJS.ProcessEnv = process.env, | |
| 218 | ): ReadonlyArray<EditorId> { | |
| 247 | const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( | |
| 248 | platform: NodeJS.Platform, | |
| 249 | env: NodeJS.ProcessEnv, | |
| 250 | ): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> { | |
| 219 | 251 | const available: EditorId[] = []; |
| 220 | 252 | |
| 221 | 253 | for (const editor of EDITORS) { |
| 222 | 254 | if (editor.commands === null) { |
| 223 | 255 | const command = fileManagerCommandForPlatform(platform); |
| 224 | if (isCommandAvailable(command, { platform, env })) { | |
| 256 | if (yield* isCommandAvailable(command, { env })) { | |
| 225 | 257 | available.push(editor.id); |
| 226 | 258 | } |
| 227 | 259 | continue; |
| 228 | 260 | } |
| 229 | 261 | |
| 230 | const command = resolveAvailableCommand(editor.commands, { platform, env }); | |
| 262 | const command = yield* resolveAvailableCommand(editor.commands, env); | |
| 231 | 263 | if (Option.isSome(command)) { |
| 232 | 264 | available.push(editor.id); |
| 233 | 265 | } |
| 234 | 266 | } |
| 235 | 267 | |
| 236 | 268 | return available; |
| 237 | } | |
| 269 | }); | |
| 270 | ||
| 271 | const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( | |
| 272 | target: string, | |
| 273 | ) { | |
| 274 | const platform = yield* HostProcessPlatform; | |
| 275 | const env = yield* readBrowserLaunchEnv; | |
| 276 | return buildBrowserLaunch(target, platform, env); | |
| 277 | }); | |
| 278 | ||
| 279 | const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { | |
| 280 | const platform = yield* HostProcessPlatform; | |
| 281 | const env = yield* readCommandLookupEnv; | |
| 282 | return yield* buildAvailableEditors(platform, env); | |
| 283 | }); | |
| 238 | 284 | |
| 239 | 285 | /** |
| 240 | 286 | * ExternalLauncherShape - Service API for browser and editor launch actions. |
| 241 | 287 | */ |
| 242 | 288 | export interface ExternalLauncherShape { |
| 289 | readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>; | |
| 243 | 290 | /** |
| 244 | 291 | * Launch a URL target in the default browser. |
| 245 | 292 | */ |
| ... | ... | @@ -264,11 +311,11 @@ export class ExternalLauncher extends Context.Service<ExternalLauncher, External |
| 264 | 311 | // Implementations |
| 265 | 312 | // ============================== |
| 266 | 313 | |
| 267 | export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( | |
| 314 | const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( | |
| 268 | 315 | input: LaunchEditorInput, |
| 269 | platform: NodeJS.Platform = process.platform, | |
| 270 | env: NodeJS.ProcessEnv = process.env, | |
| 271 | ): Effect.fn.Return<EditorLaunch, ExternalLauncherError> { | |
| 316 | ): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> { | |
| 317 | const platform = yield* HostProcessPlatform; | |
| 318 | const env = yield* readCommandLookupEnv; | |
| 272 | 319 | yield* Effect.annotateCurrentSpan({ |
| 273 | 320 | "externalLauncher.editor": input.editor, |
| 274 | 321 | "externalLauncher.cwd": input.cwd, |
| ... | ... | @@ -281,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 281 | 328 | |
| 282 | 329 | if (editorDef.commands) { |
| 283 | 330 | const command = Option.getOrElse( |
| 284 | resolveAvailableCommand(editorDef.commands, { platform, env }), | |
| 331 | yield* resolveAvailableCommand(editorDef.commands, env), | |
| 285 | 332 | () => editorDef.commands[0], |
| 286 | 333 | ); |
| 287 | 334 | return { |
| ... | ... | @@ -312,29 +359,35 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( |
| 312 | 359 | ); |
| 313 | 360 | }); |
| 314 | 361 | |
| 315 | export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( | |
| 362 | const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( | |
| 316 | 363 | target: string, |
| 317 | 364 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { |
| 318 | return yield* launchAndUnref(resolveBrowserLaunch(target), "Browser auto-open failed"); | |
| 365 | const launch = yield* resolveBrowserLaunch(target); | |
| 366 | return yield* launchAndUnref(launch, "Browser auto-open failed"); | |
| 319 | 367 | }); |
| 320 | 368 | |
| 321 | export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( | |
| 369 | const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( | |
| 322 | 370 | launch: EditorLaunch, |
| 323 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { | |
| 324 | if (!isCommandAvailable(launch.command)) { | |
| 371 | ): Effect.fn.Return< | |
| 372 | void, | |
| 373 | ExternalLauncherError, | |
| 374 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | |
| 375 | > { | |
| 376 | const env = yield* readCommandLookupEnv; | |
| 377 | if (!(yield* isCommandAvailable(launch.command, { env }))) { | |
| 325 | 378 | return yield* new ExternalLauncherError({ |
| 326 | 379 | message: `Editor command not found: ${launch.command}`, |
| 327 | 380 | }); |
| 328 | 381 | } |
| 329 | 382 | |
| 330 | const isWin32 = process.platform === "win32"; | |
| 383 | const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); | |
| 331 | 384 | yield* launchAndUnref( |
| 332 | 385 | { |
| 333 | command: launch.command, | |
| 334 | args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], | |
| 386 | command: spawnCommand.command, | |
| 387 | args: spawnCommand.args, | |
| 335 | 388 | options: { |
| 336 | 389 | detached: true, |
| 337 | shell: isWin32, | |
| 390 | shell: spawnCommand.shell, | |
| 338 | 391 | stdin: "ignore", |
| 339 | 392 | stdout: "ignore", |
| 340 | 393 | stderr: "ignore", |
| ... | ... | @@ -346,16 +399,29 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce |
| 346 | 399 | |
| 347 | 400 | const make = Effect.gen(function* () { |
| 348 | 401 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 402 | const fileSystem = yield* FileSystem.FileSystem; | |
| 403 | const path = yield* Path.Path; | |
| 404 | ||
| 405 | const provideCommandResolutionServices = <A, E, R>( | |
| 406 | effect: Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>, | |
| 407 | ) => | |
| 408 | effect.pipe( | |
| 409 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 410 | Effect.provideService(Path.Path, path), | |
| 411 | ); | |
| 349 | 412 | |
| 350 | 413 | return { |
| 414 | resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), | |
| 351 | 415 | launchBrowser: (target) => |
| 352 | 416 | launchBrowser(target).pipe( |
| 353 | 417 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 354 | 418 | ), |
| 355 | 419 | launchEditor: (input) => |
| 356 | Effect.flatMap(resolveEditorLaunch(input), (launch) => | |
| 357 | launchEditorProcess(launch).pipe( | |
| 358 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 420 | provideCommandResolutionServices( | |
| 421 | Effect.flatMap(resolveEditorLaunch(input), (launch) => | |
| 422 | launchEditorProcess(launch).pipe( | |
| 423 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 424 | ), | |
| 359 | 425 | ), |
| 360 | 426 | ), |
| 361 | 427 | } satisfies ExternalLauncherShape; |
apps/server/src/processRunner.test.ts+52-16| ... | ... | @@ -8,6 +8,8 @@ import * as Sink from "effect/Sink"; |
| 8 | 8 | import * as Stream from "effect/Stream"; |
| 9 | 9 | import { TestClock } from "effect/testing"; |
| 10 | 10 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 12 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 11 | 13 | |
| 12 | 14 | import { |
| 13 | 15 | isWindowsCommandNotFound, |
| ... | ... | @@ -21,6 +23,9 @@ import { |
| 21 | 23 | type ChildProcessCommand = { |
| 22 | 24 | readonly command: string; |
| 23 | 25 | readonly args: ReadonlyArray<string>; |
| 26 | readonly options: { | |
| 27 | readonly shell?: boolean | string; | |
| 28 | }; | |
| 24 | 29 | }; |
| 25 | 30 | |
| 26 | 31 | // Accesses private properties of ChildProcessCommand for testing purposes |
| ... | ... | @@ -68,7 +73,6 @@ const runWith = |
| 68 | 73 | Effect.flatMap((runner) => |
| 69 | 74 | runner.run({ |
| 70 | 75 | ...input, |
| 71 | shell: input.shell ?? false, | |
| 72 | 76 | }), |
| 73 | 77 | ), |
| 74 | 78 | Effect.provide( |
| ... | ... | @@ -123,6 +127,44 @@ describe("runProcess", () => { |
| 123 | 127 | }).pipe(Effect.provide(layer)); |
| 124 | 128 | }); |
| 125 | 129 | |
| 130 | it.effect("resolves and escapes Windows command shims before spawning", () => { | |
| 131 | const spawner = makeSpawner((command) => | |
| 132 | Effect.sync(() => { | |
| 133 | expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"'); | |
| 134 | expect(command.args).toEqual([ | |
| 135 | '^"repos^"', | |
| 136 | '^"pr^"', | |
| 137 | '^"list^"', | |
| 138 | '^"--source-branch^"', | |
| 139 | '^"feature^ ^&^ release^"', | |
| 140 | ]); | |
| 141 | expect(command.options.shell).toBe(true); | |
| 142 | return makeHandle({ stdout: "[]" }); | |
| 143 | }), | |
| 144 | ); | |
| 145 | ||
| 146 | return runWith(spawner)({ | |
| 147 | command: "az", | |
| 148 | args: ["repos", "pr", "list", "--source-branch", "feature & release"], | |
| 149 | env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, | |
| 150 | }).pipe( | |
| 151 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 152 | Effect.provideService(HostProcessEnvironment, { | |
| 153 | PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", | |
| 154 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 155 | }), | |
| 156 | Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => | |
| 157 | env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && | |
| 158 | env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" | |
| 159 | ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" | |
| 160 | : undefined, | |
| 161 | ), | |
| 162 | Effect.map((result) => { | |
| 163 | expect(result.stdout).toBe("[]"); | |
| 164 | }), | |
| 165 | ); | |
| 166 | }); | |
| 167 | ||
| 126 | 168 | it.effect("fails when output exceeds max buffer in default mode", () => |
| 127 | 169 | Effect.gen(function* () { |
| 128 | 170 | const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); |
| ... | ... | @@ -280,19 +322,13 @@ describe("runProcess", () => { |
| 280 | 322 | }); |
| 281 | 323 | |
| 282 | 324 | describe("isWindowsCommandNotFound", () => { |
| 283 | it("matches the localized German cmd.exe error text", () => { | |
| 284 | const originalPlatform = process.platform; | |
| 285 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | |
| 286 | ||
| 287 | try { | |
| 288 | expect( | |
| 289 | isWindowsCommandNotFound( | |
| 290 | 1, | |
| 291 | "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", | |
| 292 | ), | |
| 293 | ).toBe(true); | |
| 294 | } finally { | |
| 295 | Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); | |
| 296 | } | |
| 297 | }); | |
| 325 | it.effect("matches the localized German cmd.exe error text", () => | |
| 326 | Effect.gen(function* () { | |
| 327 | const isCommandNotFound = yield* isWindowsCommandNotFound( | |
| 328 | 1, | |
| 329 | "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", | |
| 330 | ).pipe(Effect.provideService(HostProcessPlatform, "win32")); | |
| 331 | expect(isCommandNotFound).toBe(true); | |
| 332 | }), | |
| 333 | ); | |
| 298 | 334 | }); |
apps/server/src/processRunner.ts+19-9| ... | ... | @@ -8,6 +8,8 @@ import * as PlatformError from "effect/PlatformError"; |
| 8 | 8 | import * as Scope from "effect/Scope"; |
| 9 | 9 | import * as Stream from "effect/Stream"; |
| 10 | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 12 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 11 | 13 | import { |
| 12 | 14 | collectUint8StreamText, |
| 13 | 15 | type CollectedUint8StreamText, |
| ... | ... | @@ -24,7 +26,6 @@ export interface ProcessRunInput { |
| 24 | 26 | readonly maxOutputBytes?: number | undefined; |
| 25 | 27 | readonly outputMode?: "error" | "truncate" | undefined; |
| 26 | 28 | readonly truncatedMarker?: string | undefined; |
| 27 | readonly shell?: boolean | string | undefined; | |
| 28 | 29 | /** |
| 29 | 30 | * On timeout, return a synthetic timedOut result. |
| 30 | 31 | * Partial stdout/stderr are not preserved. |
| ... | ... | @@ -109,11 +110,14 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean { |
| 109 | 110 | return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); |
| 110 | 111 | } |
| 111 | 112 | |
| 112 | export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { | |
| 113 | if (process.platform !== "win32") return false; | |
| 114 | if (code === 9009) return true; | |
| 115 | return hasWindowsCommandNotFoundMessage(stderr); | |
| 116 | } | |
| 113 | export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")( | |
| 114 | function* (code: number | null, stderr: string) { | |
| 115 | const platform = yield* HostProcessPlatform; | |
| 116 | if (platform !== "win32") return false; | |
| 117 | if (code === 9009) return true; | |
| 118 | return hasWindowsCommandNotFoundMessage(stderr); | |
| 119 | }, | |
| 120 | ); | |
| 117 | 121 | |
| 118 | 122 | const collectText = Effect.fn("processRunner.collectText")(function* (input: { |
| 119 | 123 | readonly command: string; |
| ... | ... | @@ -231,18 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( |
| 231 | 235 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 232 | 236 | const outputMode = input.outputMode ?? "error"; |
| 233 | 237 | const truncatedMarker = input.truncatedMarker ?? ""; |
| 238 | const extendEnv = input.env !== undefined; | |
| 239 | const spawnCommand = yield* resolveSpawnCommand( | |
| 240 | input.command, | |
| 241 | input.args, | |
| 242 | input.env === undefined ? {} : { env: input.env, extendEnv }, | |
| 243 | ); | |
| 234 | 244 | |
| 235 | 245 | const child = yield* spawner |
| 236 | 246 | .spawn( |
| 237 | ChildProcess.make(input.command, [...input.args], { | |
| 247 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 238 | 248 | ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), |
| 239 | 249 | ...(input.env !== undefined |
| 240 | 250 | ? { |
| 241 | 251 | env: input.env, |
| 242 | extendEnv: true, | |
| 252 | extendEnv, | |
| 243 | 253 | } |
| 244 | 254 | : {}), |
| 245 | ...(input.shell !== undefined ? { shell: input.shell } : {}), | |
| 255 | shell: spawnCommand.shell, | |
| 246 | 256 | }), |
| 247 | 257 | ) |
| 248 | 258 | .pipe( |
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+2| ... | ... | @@ -89,6 +89,8 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa |
| 89 | 89 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 90 | 90 | let cacheKey = cwd; |
| 91 | 91 | |
| 92 | // git is a real executable on every platform — no cmd.exe shell mode, which | |
| 93 | // would split paths containing spaces during cmd's re-tokenization. | |
| 92 | 94 | const topLevelResult = yield* processRunner |
| 93 | 95 | .run({ |
| 94 | 96 | command: "git", |
apps/server/src/provider/Drivers/ClaudeHome.ts+4-3| ... | ... | @@ -16,13 +16,14 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function |
| 16 | 16 | |
| 17 | 17 | export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( |
| 18 | 18 | config: Pick<ClaudeSettings, "homePath">, |
| 19 | baseEnv: NodeJS.ProcessEnv = process.env, | |
| 19 | baseEnv?: NodeJS.ProcessEnv, | |
| 20 | 20 | ): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> { |
| 21 | const resolvedBaseEnv = baseEnv ?? process.env; | |
| 21 | 22 | const homePath = config.homePath.trim(); |
| 22 | if (homePath.length === 0) return baseEnv; | |
| 23 | if (homePath.length === 0) return resolvedBaseEnv; | |
| 23 | 24 | const resolvedHomePath = yield* resolveClaudeHomePath(config); |
| 24 | 25 | return { |
| 25 | ...baseEnv, | |
| 26 | ...resolvedBaseEnv, | |
| 26 | 27 | HOME: resolvedHomePath, |
| 27 | 28 | }; |
| 28 | 29 | }); |
apps/server/src/provider/Layers/ClaudeProvider.ts+15-9| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | getProviderOptionCurrentValue, |
| 19 | 19 | getProviderOptionDescriptors, |
| 20 | 20 | } from "@t3tools/shared/model"; |
| 21 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 21 | 22 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 22 | 23 | import { |
| 23 | 24 | query as claudeQuery, |
| ... | ... | @@ -547,7 +548,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> { |
| 547 | 548 | */ |
| 548 | 549 | const probeClaudeCapabilities = ( |
| 549 | 550 | claudeSettings: ClaudeSettings, |
| 550 | environment: NodeJS.ProcessEnv = process.env, | |
| 551 | environment?: NodeJS.ProcessEnv, | |
| 551 | 552 | ) => { |
| 552 | 553 | const abort = new AbortController(); |
| 553 | 554 | return Effect.gen(function* () { |
| ... | ... | @@ -603,12 +604,15 @@ const probeClaudeCapabilities = ( |
| 603 | 604 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( |
| 604 | 605 | claudeSettings: ClaudeSettings, |
| 605 | 606 | args: ReadonlyArray<string>, |
| 606 | environment: NodeJS.ProcessEnv = process.env, | |
| 607 | environment?: NodeJS.ProcessEnv, | |
| 607 | 608 | ) { |
| 608 | 609 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 609 | const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { | |
| 610 | const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { | |
| 610 | 611 | env: claudeEnvironment, |
| 611 | shell: process.platform === "win32", | |
| 612 | }); | |
| 613 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 614 | env: claudeEnvironment, | |
| 615 | shell: spawnCommand.shell, | |
| 612 | 616 | }); |
| 613 | 617 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); |
| 614 | 618 | }); |
| ... | ... | @@ -618,12 +622,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 618 | 622 | resolveCapabilities?: ( |
| 619 | 623 | claudeSettings: ClaudeSettings, |
| 620 | 624 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, |
| 621 | environment: NodeJS.ProcessEnv = process.env, | |
| 625 | environment?: NodeJS.ProcessEnv, | |
| 622 | 626 | ): Effect.fn.Return< |
| 623 | 627 | ServerProviderDraft, |
| 624 | 628 | never, |
| 625 | 629 | ChildProcessSpawner.ChildProcessSpawner | Path.Path |
| 626 | 630 | > { |
| 631 | const resolvedEnvironment = environment ?? process.env; | |
| 627 | 632 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 628 | 633 | const allModels = providerModelsFromSettings( |
| 629 | 634 | BUILT_IN_MODELS, |
| ... | ... | @@ -648,10 +653,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 648 | 653 | }); |
| 649 | 654 | } |
| 650 | 655 | |
| 651 | const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( | |
| 652 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | |
| 653 | Effect.result, | |
| 654 | ); | |
| 656 | const versionProbe = yield* runClaudeCommand( | |
| 657 | claudeSettings, | |
| 658 | ["--version"], | |
| 659 | resolvedEnvironment, | |
| 660 | ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); | |
| 655 | 661 | |
| 656 | 662 | if (Result.isFailure(versionProbe)) { |
| 657 | 663 | const error = versionProbe.failure; |
apps/server/src/provider/Layers/CodexProvider.ts+36-14| ... | ... | @@ -7,7 +7,7 @@ import * as Result from "effect/Result"; |
| 7 | 7 | import * as Schema from "effect/Schema"; |
| 8 | 8 | import * as Scope from "effect/Scope"; |
| 9 | 9 | import * as Types from "effect/Types"; |
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | 11 | import * as CodexClient from "effect-codex-app-server/client"; |
| 12 | 12 | import * as CodexSchema from "effect-codex-app-server/schema"; |
| 13 | 13 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| ... | ... | @@ -24,6 +24,7 @@ import type { |
| 24 | 24 | import { ServerSettingsError } from "@t3tools/contracts"; |
| 25 | 25 | |
| 26 | 26 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 27 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 27 | 28 | import { |
| 28 | 29 | AUTH_PROBE_TIMEOUT_MS, |
| 29 | 30 | buildServerProvider, |
| ... | ... | @@ -33,6 +34,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; |
| 33 | 34 | import packageJson from "../../../package.json" with { type: "json" }; |
| 34 | 35 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); |
| 35 | 36 | |
| 37 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; | |
| 38 | ||
| 36 | 39 | const CODEX_PRESENTATION = { |
| 37 | 40 | displayName: "Codex", |
| 38 | 41 | showInteractionModeToggle: true, |
| ... | ... | @@ -292,17 +295,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 292 | 295 | // "CODEX_HOME points to '~/.codex_work', but that path does not exist". |
| 293 | 296 | // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. |
| 294 | 297 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; |
| 295 | const clientContext = yield* Layer.build( | |
| 296 | CodexClient.layerCommand({ | |
| 297 | command: input.binaryPath, | |
| 298 | args: ["app-server"], | |
| 299 | cwd: input.cwd, | |
| 300 | env: { | |
| 301 | ...(input.environment ?? process.env), | |
| 302 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | |
| 303 | }, | |
| 304 | }), | |
| 305 | ); | |
| 298 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 299 | const environment = { | |
| 300 | ...input.environment, | |
| 301 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | |
| 302 | }; | |
| 303 | const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { | |
| 304 | env: environment, | |
| 305 | extendEnv: true, | |
| 306 | }); | |
| 307 | const child = yield* spawner | |
| 308 | .spawn( | |
| 309 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 310 | cwd: input.cwd, | |
| 311 | env: environment, | |
| 312 | extendEnv: true, | |
| 313 | forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, | |
| 314 | shell: spawnCommand.shell, | |
| 315 | }), | |
| 316 | ) | |
| 317 | .pipe( | |
| 318 | Effect.mapError( | |
| 319 | (cause) => | |
| 320 | new CodexErrors.CodexAppServerSpawnError({ | |
| 321 | command: `${input.binaryPath} app-server`, | |
| 322 | cause, | |
| 323 | }), | |
| 324 | ), | |
| 325 | ); | |
| 326 | const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); | |
| 306 | 327 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( |
| 307 | 328 | Effect.provide(clientContext), |
| 308 | 329 | ); |
| ... | ... | @@ -449,12 +470,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 449 | 470 | CodexErrors.CodexAppServerError, |
| 450 | 471 | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope |
| 451 | 472 | > = probeCodexAppServerProvider, |
| 452 | environment: NodeJS.ProcessEnv = process.env, | |
| 473 | environment?: NodeJS.ProcessEnv, | |
| 453 | 474 | ): Effect.fn.Return< |
| 454 | 475 | ServerProviderDraft, |
| 455 | 476 | ServerSettingsError, |
| 456 | 477 | ChildProcessSpawner.ChildProcessSpawner |
| 457 | 478 | > { |
| 479 | const resolvedEnvironment = environment ?? process.env; | |
| 458 | 480 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 459 | 481 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); |
| 460 | 482 | |
| ... | ... | @@ -480,7 +502,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 480 | 502 | homePath: codexSettings.homePath, |
| 481 | 503 | cwd: process.cwd(), |
| 482 | 504 | customModels: codexSettings.customModels, |
| 483 | environment, | |
| 505 | environment: resolvedEnvironment, | |
| 484 | 506 | }).pipe( |
| 485 | 507 | Effect.scoped, |
| 486 | 508 | Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11-3| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | ThreadId, |
| 17 | 17 | TurnId, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 19 | 20 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 20 | 21 | import * as Crypto from "effect/Crypto"; |
| 21 | 22 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -720,16 +721,23 @@ export const makeCodexSessionRuntime = ( |
| 720 | 721 | // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. |
| 721 | 722 | const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; |
| 722 | 723 | const env = { |
| 723 | ...(options.environment ?? process.env), | |
| 724 | ...options.environment, | |
| 724 | 725 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), |
| 725 | 726 | }; |
| 727 | const extendEnv = options.environment === undefined; | |
| 728 | const spawnCommand = yield* resolveSpawnCommand( | |
| 729 | options.binaryPath, | |
| 730 | ["app-server", ...(options.appServerArgs ?? [])], | |
| 731 | { env, extendEnv }, | |
| 732 | ); | |
| 726 | 733 | const child = yield* spawner |
| 727 | 734 | .spawn( |
| 728 | ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { | |
| 735 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 729 | 736 | cwd: options.cwd, |
| 730 | 737 | env, |
| 738 | extendEnv, | |
| 731 | 739 | forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, |
| 732 | shell: process.platform === "win32", | |
| 740 | shell: spawnCommand.shell, | |
| 733 | 741 | }), |
| 734 | 742 | ) |
| 735 | 743 | .pipe( |
apps/server/src/provider/Layers/CursorProvider.ts+17-14| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | getProviderOptionBooleanSelectionValue, |
| 28 | 28 | getProviderOptionStringSelectionValue, |
| 29 | 29 | } from "@t3tools/shared/model"; |
| 30 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 30 | 31 | |
| 31 | 32 | import { |
| 32 | 33 | buildBooleanOptionDescriptor, |
| ... | ... | @@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( |
| 394 | 395 | |
| 395 | 396 | const makeCursorAcpProbeRuntime = ( |
| 396 | 397 | cursorSettings: CursorSettings, |
| 397 | environment: NodeJS.ProcessEnv = process.env, | |
| 398 | environment?: NodeJS.ProcessEnv, | |
| 398 | 399 | ) => |
| 399 | 400 | Effect.gen(function* () { |
| 400 | 401 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| ... | ... | @@ -407,7 +408,7 @@ const makeCursorAcpProbeRuntime = ( |
| 407 | 408 | "acp", |
| 408 | 409 | ], |
| 409 | 410 | cwd: process.cwd(), |
| 410 | env: environment, | |
| 411 | ...(environment ? { env: environment } : {}), | |
| 411 | 412 | }, |
| 412 | 413 | cwd: process.cwd(), |
| 413 | 414 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, |
| ... | ... | @@ -421,7 +422,7 @@ const makeCursorAcpProbeRuntime = ( |
| 421 | 422 | const withCursorAcpProbeRuntime = <A, E, R>( |
| 422 | 423 | cursorSettings: CursorSettings, |
| 423 | 424 | useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>, |
| 424 | environment: NodeJS.ProcessEnv = process.env, | |
| 425 | environment?: NodeJS.ProcessEnv, | |
| 425 | 426 | ) => |
| 426 | 427 | makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( |
| 427 | 428 | Effect.flatMap(useRuntime), |
| ... | ... | @@ -542,7 +543,7 @@ export function resolveCursorAcpConfigUpdates( |
| 542 | 543 | |
| 543 | 544 | const discoverCursorModelsViaListAvailableModels = ( |
| 544 | 545 | cursorSettings: CursorSettings, |
| 545 | environment: NodeJS.ProcessEnv = process.env, | |
| 546 | environment?: NodeJS.ProcessEnv, | |
| 546 | 547 | ) => |
| 547 | 548 | withCursorAcpProbeRuntime( |
| 548 | 549 | cursorSettings, |
| ... | ... | @@ -558,7 +559,7 @@ const discoverCursorModelsViaListAvailableModels = ( |
| 558 | 559 | |
| 559 | 560 | export const discoverCursorModelsViaAcp = ( |
| 560 | 561 | cursorSettings: CursorSettings, |
| 561 | environment: NodeJS.ProcessEnv = process.env, | |
| 562 | environment?: NodeJS.ProcessEnv, | |
| 562 | 563 | ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); |
| 563 | 564 | |
| 564 | 565 | export function getCursorFallbackModels( |
| ... | ... | @@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult |
| 927 | 928 | const runCursorCommand = ( |
| 928 | 929 | cursorSettings: CursorSettings, |
| 929 | 930 | args: ReadonlyArray<string>, |
| 930 | environment: NodeJS.ProcessEnv = process.env, | |
| 931 | environment?: NodeJS.ProcessEnv, | |
| 931 | 932 | ) => |
| 932 | 933 | Effect.gen(function* () { |
| 933 | 934 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 934 | const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { | |
| 935 | env: environment, | |
| 936 | shell: process.platform === "win32", | |
| 935 | const spawnCommand = yield* resolveSpawnCommand( | |
| 936 | cursorSettings.binaryPath, | |
| 937 | args, | |
| 938 | environment ? { env: environment } : {}, | |
| 939 | ); | |
| 940 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 941 | ...(environment ? { env: environment } : { extendEnv: true }), | |
| 942 | shell: spawnCommand.shell, | |
| 937 | 943 | }); |
| 938 | 944 | |
| 939 | 945 | const child = yield* spawner.spawn(command); |
| ... | ... | @@ -949,10 +955,7 @@ const runCursorCommand = ( |
| 949 | 955 | return { stdout, stderr, code: exitCode } satisfies CommandResult; |
| 950 | 956 | }).pipe(Effect.scoped); |
| 951 | 957 | |
| 952 | const runCursorAboutCommand = ( | |
| 953 | cursorSettings: CursorSettings, | |
| 954 | environment: NodeJS.ProcessEnv = process.env, | |
| 955 | ) => | |
| 958 | const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) => | |
| 956 | 959 | Effect.gen(function* () { |
| 957 | 960 | const jsonResult = yield* runCursorCommand( |
| 958 | 961 | cursorSettings, |
| ... | ... | @@ -967,7 +970,7 @@ const runCursorAboutCommand = ( |
| 967 | 970 | |
| 968 | 971 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( |
| 969 | 972 | cursorSettings: CursorSettings, |
| 970 | environment: NodeJS.ProcessEnv = process.env, | |
| 973 | environment?: NodeJS.ProcessEnv, | |
| 971 | 974 | ): Effect.fn.Return< |
| 972 | 975 | ServerProviderDraft, |
| 973 | 976 | never, |
apps/server/src/provider/Layers/GrokProvider.ts+14-9| ... | ... | @@ -15,6 +15,7 @@ import * as Result from "effect/Result"; |
| 15 | 15 | import { HttpClient } from "effect/unstable/http"; |
| 16 | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 17 | 17 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 18 | 19 | |
| 19 | 20 | import { |
| 20 | 21 | buildServerProvider, |
| ... | ... | @@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = ( |
| 149 | 150 | const runGrokVersionCommand = ( |
| 150 | 151 | grokSettings: GrokSettings, |
| 151 | 152 | environment: NodeJS.ProcessEnv = process.env, |
| 152 | ) => { | |
| 153 | const command = grokSettings.binaryPath || "grok"; | |
| 154 | return spawnAndCollect( | |
| 155 | command, | |
| 156 | ChildProcess.make(command, ["--version"], { | |
| 153 | ) => | |
| 154 | Effect.gen(function* () { | |
| 155 | const command = grokSettings.binaryPath || "grok"; | |
| 156 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { | |
| 157 | 157 | env: environment, |
| 158 | shell: process.platform === "win32", | |
| 159 | }), | |
| 160 | ); | |
| 161 | }; | |
| 158 | }); | |
| 159 | return yield* spawnAndCollect( | |
| 160 | command, | |
| 161 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 162 | env: environment, | |
| 163 | shell: spawnCommand.shell, | |
| 164 | }), | |
| 165 | ); | |
| 166 | }); | |
| 162 | 167 | |
| 163 | 168 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( |
| 164 | 169 | grokSettings: GrokSettings, |
apps/server/src/provider/Layers/OpenCodeProvider.ts+4-3| ... | ... | @@ -301,9 +301,10 @@ export const makePendingOpenCodeProvider = ( |
| 301 | 301 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( |
| 302 | 302 | openCodeSettings: OpenCodeSettings, |
| 303 | 303 | cwd: string, |
| 304 | environment: NodeJS.ProcessEnv = process.env, | |
| 304 | environment?: NodeJS.ProcessEnv, | |
| 305 | 305 | ): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> { |
| 306 | 306 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 307 | const resolvedEnvironment = environment ?? process.env; | |
| 307 | 308 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 308 | 309 | const customModels = openCodeSettings.customModels; |
| 309 | 310 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; |
| ... | ... | @@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 364 | 365 | .runOpenCodeCommand({ |
| 365 | 366 | binaryPath: openCodeSettings.binaryPath, |
| 366 | 367 | args: ["--version"], |
| 367 | environment, | |
| 368 | environment: resolvedEnvironment, | |
| 368 | 369 | }) |
| 369 | 370 | .pipe( |
| 370 | 371 | Effect.mapError( |
| ... | ... | @@ -413,7 +414,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 413 | 414 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ |
| 414 | 415 | binaryPath: openCodeSettings.binaryPath, |
| 415 | 416 | serverUrl: openCodeSettings.serverUrl, |
| 416 | environment, | |
| 417 | environment: resolvedEnvironment, | |
| 417 | 418 | }); |
| 418 | 419 | return yield* openCodeRuntime.loadOpenCodeInventory( |
| 419 | 420 | openCodeRuntime.createOpenCodeSdkClient({ |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+15-24| ... | ... | @@ -1039,7 +1039,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1039 | 1039 | |
| 1040 | 1040 | // This test intentionally avoids `mockCommandSpawnerLayer` so the real |
| 1041 | 1041 | // `probeCodexAppServerProvider` path runs — including the full |
| 1042 | // `codex app-server` RPC handshake via `CodexClient.layerCommand`. | |
| 1042 | // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. | |
| 1043 | 1043 | // We point `binaryPath` at a name that cannot exist on any machine so |
| 1044 | 1044 | // the real `ChildProcessSpawner` deterministically returns ENOENT; the |
| 1045 | 1045 | // probe wraps that as `CodexAppServerSpawnError` and |
| ... | ... | @@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1159 | 1159 | Effect.gen(function* () { |
| 1160 | 1160 | const firstMissing = `t3code_codex_first_`; |
| 1161 | 1161 | const secondMissing = `t3code_codex_second_`; |
| 1162 | const spawnedCommands: Array<string> = []; | |
| 1162 | 1163 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1163 | 1164 | decodeServerSettings( |
| 1164 | 1165 | deepMerge(encodedDefaultServerSettings, { |
| ... | ... | @@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1185 | 1186 | Layer.provideMerge(TestHttpClientLive), |
| 1186 | 1187 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 1187 | 1188 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 1188 | // `it.live` does not inherit layers from the outer `it.layer` | |
| 1189 | // wrapper, so provide `NodeServices.layer` inline. This is the | |
| 1190 | // same real `ChildProcessSpawner` + `FileSystem` + `Path` | |
| 1191 | // services that production uses. | |
| 1189 | Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => | |
| 1190 | ChildProcessSpawner.make((command) => { | |
| 1191 | spawnedCommands.push((command as { readonly command: string }).command); | |
| 1192 | return spawner.spawn(command); | |
| 1193 | }), | |
| 1194 | ), | |
| 1192 | 1195 | Layer.provideMerge(NodeServices.layer), |
| 1193 | 1196 | ); |
| 1194 | 1197 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| ... | ... | @@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1199 | 1202 | const registry = yield* ProviderRegistry; |
| 1200 | 1203 | // Boot-time probe: the default codex instance is enabled with |
| 1201 | 1204 | // `firstMissing`, so the real spawner yields ENOENT and the |
| 1202 | // snapshot should be `status: "error"`. What *distinguishes* | |
| 1203 | // the two probe runs is `checkedAt` — each probe stamps a | |
| 1204 | // fresh DateTime, so we capture it and assert it advances | |
| 1205 | // after the settings mutation. | |
| 1205 | // snapshot should be `status: "error"`. | |
| 1206 | 1206 | let initialProviders = yield* registry.getProviders; |
| 1207 | 1207 | for ( |
| 1208 | 1208 | let attempts = 0; |
| ... | ... | @@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1220 | 1220 | ); |
| 1221 | 1221 | assert.strictEqual(initialCodex?.status, "error"); |
| 1222 | 1222 | assert.strictEqual(initialCodex?.installed, false); |
| 1223 | const initialCheckedAt = initialCodex?.checkedAt; | |
| 1224 | assert.notStrictEqual(initialCheckedAt, undefined); | |
| 1225 | ||
| 1226 | // The rebuilt instance may re-probe synchronously during the | |
| 1227 | // settings update. Advance the TestClock first so `checkedAt` | |
| 1228 | // can safely act as the fresh-probe marker this assertion uses. | |
| 1229 | yield* TestClock.adjust("1 second"); | |
| 1223 | assert.deepStrictEqual(spawnedCommands, [firstMissing]); | |
| 1230 | 1224 | |
| 1231 | 1225 | // Drive a settings change. The Hydration layer's |
| 1232 | 1226 | // `SettingsWatcherLive` consumes this via `streamChanges`, |
| ... | ... | @@ -1242,8 +1236,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1242 | 1236 | }, |
| 1243 | 1237 | }); |
| 1244 | 1238 | |
| 1245 | // Poll with TestClock until `checkedAt` advances or we hit a | |
| 1246 | // generous virtual 3-second ceiling. | |
| 1239 | // Poll until the injected process boundary observes the new | |
| 1240 | // executable. This verifies the public settings-to-probe behavior | |
| 1241 | // without depending on timestamps assigned by TestClock. | |
| 1247 | 1242 | const refreshed = yield* Effect.gen(function* () { |
| 1248 | 1243 | for (let attempts = 0; attempts < 60; attempts += 1) { |
| 1249 | 1244 | const providers = yield* registry.getProviders; |
| ... | ... | @@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1251 | 1246 | if ( |
| 1252 | 1247 | codex !== undefined && |
| 1253 | 1248 | codex.status === "error" && |
| 1254 | codex.checkedAt !== initialCheckedAt | |
| 1249 | spawnedCommands.includes(secondMissing) | |
| 1255 | 1250 | ) { |
| 1256 | 1251 | return providers; |
| 1257 | 1252 | } |
| ... | ... | @@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1262 | 1257 | }); |
| 1263 | 1258 | |
| 1264 | 1259 | const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); |
| 1265 | assert.notStrictEqual( | |
| 1266 | reprobedCodex?.checkedAt, | |
| 1267 | initialCheckedAt, | |
| 1268 | "Expected a fresh probe after settings change, got the stale snapshot", | |
| 1269 | ); | |
| 1260 | assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); | |
| 1270 | 1261 | assert.strictEqual(reprobedCodex?.status, "error"); |
| 1271 | 1262 | assert.strictEqual(reprobedCodex?.installed, false); |
| 1272 | 1263 | }).pipe(Effect.provide(runtimeServices)); |
apps/server/src/provider/acp/AcpSessionRuntime.ts+9-3| ... | ... | @@ -13,6 +13,7 @@ import * as EffectAcpClient from "effect-acp/client"; |
| 13 | 13 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 14 | 14 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 15 | 15 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 16 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 16 | 17 | |
| 17 | 18 | import { |
| 18 | 19 | collectSessionConfigOptionValues, |
| ... | ... | @@ -201,12 +202,17 @@ const makeAcpSessionRuntime = ( |
| 201 | 202 | ), |
| 202 | 203 | ); |
| 203 | 204 | |
| 205 | const spawnCommand = yield* resolveSpawnCommand( | |
| 206 | options.spawn.command, | |
| 207 | options.spawn.args, | |
| 208 | options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, | |
| 209 | ); | |
| 204 | 210 | const child = yield* spawner |
| 205 | 211 | .spawn( |
| 206 | ChildProcess.make(options.spawn.command, [...options.spawn.args], { | |
| 212 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 207 | 213 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), |
| 208 | ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), | |
| 209 | shell: process.platform === "win32", | |
| 214 | ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), | |
| 215 | shell: spawnCommand.shell, | |
| 210 | 216 | }), |
| 211 | 217 | ) |
| 212 | 218 | .pipe( |
apps/server/src/provider/opencodeRuntime.ts+17-9| ... | ... | @@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 31 | 31 | import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 32 | 32 | import { collectStreamAsString } from "./providerSnapshot.ts"; |
| 33 | 33 | import * as NetService from "@t3tools/shared/Net"; |
| 34 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 35 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 34 | 36 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); |
| 35 | 37 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; |
| 36 | 38 | |
| ... | ... | @@ -276,13 +278,17 @@ function ensureRuntimeError( |
| 276 | 278 | const makeOpenCodeRuntime = Effect.gen(function* () { |
| 277 | 279 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 278 | 280 | const netService = yield* NetService.NetService; |
| 281 | const hostPlatform = yield* HostProcessPlatform; | |
| 282 | const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => | |
| 283 | resolveSpawnCommand(command, args, env ? { env } : {}); | |
| 279 | 284 | |
| 280 | 285 | const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => |
| 281 | 286 | Effect.gen(function* () { |
| 287 | const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); | |
| 282 | 288 | const child = yield* spawner.spawn( |
| 283 | ChildProcess.make(input.binaryPath, [...input.args], { | |
| 284 | shell: process.platform === "win32", | |
| 285 | env: input.environment ?? process.env, | |
| 289 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 290 | shell: spawnCommand.shell, | |
| 291 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), | |
| 286 | 292 | }), |
| 287 | 293 | ); |
| 288 | 294 | const [stdout, stderr, code] = yield* Effect.all( |
| ... | ... | @@ -290,7 +296,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 290 | 296 | { concurrency: "unbounded" }, |
| 291 | 297 | ); |
| 292 | 298 | const exitCode = Number(code); |
| 293 | if (isWindowsCommandNotFound(exitCode, stderr)) { | |
| 299 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { | |
| 294 | 300 | return yield* new OpenCodeRuntimeError({ |
| 295 | 301 | operation: "runOpenCodeCommand", |
| 296 | 302 | detail: `spawn ${input.binaryPath} ENOENT`, |
| ... | ... | @@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 334 | 340 | )); |
| 335 | 341 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; |
| 336 | 342 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; |
| 343 | const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); | |
| 337 | 344 | |
| 338 | 345 | const child = yield* spawner |
| 339 | 346 | .spawn( |
| 340 | ChildProcess.make(input.binaryPath, args, { | |
| 341 | detached: process.platform !== "win32", | |
| 342 | shell: process.platform === "win32", | |
| 347 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 348 | detached: hostPlatform !== "win32", | |
| 349 | shell: spawnCommand.shell, | |
| 343 | 350 | env: { |
| 344 | ...(input.environment ?? process.env), | |
| 351 | ...input.environment, | |
| 345 | 352 | OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, |
| 346 | 353 | }, |
| 354 | extendEnv: input.environment === undefined, | |
| 347 | 355 | }), |
| 348 | 356 | ) |
| 349 | 357 | .pipe( |
| ... | ... | @@ -359,7 +367,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 359 | 367 | ); |
| 360 | 368 | |
| 361 | 369 | const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => |
| 362 | process.platform === "win32" | |
| 370 | hostPlatform === "win32" | |
| 363 | 371 | ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) |
| 364 | 372 | : Effect.sync(() => { |
| 365 | 373 | try { |
apps/server/src/provider/providerMaintenance.test.ts+68-39| ... | ... | @@ -1,19 +1,22 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import { afterEach, expect, it } from "@effect/vitest"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | 3 | import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import os from "node:os"; | |
| 5 | import * as NodeOS from "node:os"; | |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | 9 | import * as Crypto from "effect/Crypto"; |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 11 | import { HttpClient } from "effect/unstable/http"; | |
| 10 | 12 | import { |
| 11 | clearLatestProviderVersionCacheForTests, | |
| 12 | 13 | createProviderVersionAdvisory, |
| 13 | 14 | makePackageManagedProviderMaintenanceResolver, |
| 14 | 15 | makeProviderMaintenanceCapabilities, |
| 15 | 16 | makeStaticProviderMaintenanceResolver, |
| 16 | 17 | normalizeCommandPath, |
| 18 | ProviderVersionCache, | |
| 19 | resolveLatestProviderVersion, | |
| 17 | 20 | resolveProviderMaintenanceCapabilitiesEffect, |
| 18 | 21 | } from "./providerMaintenance.ts"; |
| 19 | 22 | |
| ... | ... | @@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); |
| 21 | 24 | const makeTempDir = (name: string) => |
| 22 | 25 | Crypto.Crypto.pipe( |
| 23 | 26 | Effect.flatMap((crypto) => crypto.randomUUIDv4), |
| 24 | Effect.map((id) => path.join(os.tmpdir(), `${name}-${id}`)), | |
| 27 | Effect.map((id) => path.join(NodeOS.tmpdir(), `${name}-${id}`)), | |
| 25 | 28 | ); |
| 26 | 29 | const isNativeTestCommandPath = |
| 27 | 30 | (expectedPathSegment: string) => |
| ... | ... | @@ -65,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( |
| 65 | 68 | }), |
| 66 | 69 | ); |
| 67 | 70 | |
| 68 | afterEach(() => { | |
| 69 | clearLatestProviderVersionCacheForTests(); | |
| 70 | }); | |
| 71 | ||
| 72 | 71 | it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 72 | it.effect("reads cached versions through the injectable cache reference", () => | |
| 73 | resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( | |
| 74 | Effect.provideService( | |
| 75 | ProviderVersionCache, | |
| 76 | new Map([ | |
| 77 | [ | |
| 78 | "@example/package-tool", | |
| 79 | { | |
| 80 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 81 | version: "9.9.9", | |
| 82 | }, | |
| 83 | ], | |
| 84 | ]), | |
| 85 | ), | |
| 86 | Effect.provideService( | |
| 87 | HttpClient.HttpClient, | |
| 88 | HttpClient.make(() => | |
| 89 | Effect.die("cached provider version should not make an HTTP request"), | |
| 90 | ), | |
| 91 | ), | |
| 92 | Effect.map((version) => { | |
| 93 | expect(version).toBe("9.9.9"); | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 73 | 98 | it("marks providers with unknown current versions as unknown", () => { |
| 74 | 99 | expect( |
| 75 | 100 | createProviderVersionAdvisory({ |
| ... | ... | @@ -144,15 +169,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 144 | 169 | writeFileSync(packageToolPath, "#!/bin/sh\n"); |
| 145 | 170 | chmodSync(packageToolPath, 0o755); |
| 146 | 171 | |
| 147 | expect( | |
| 148 | packageToolUpdate.resolve({ | |
| 172 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( | |
| 173 | packageToolUpdate, | |
| 174 | { | |
| 149 | 175 | binaryPath: "package-tool", |
| 150 | platform: "darwin", | |
| 151 | 176 | env: { |
| 152 | 177 | PATH: vitePlusBinDir, |
| 153 | 178 | }, |
| 154 | }), | |
| 155 | ).toEqual({ | |
| 179 | }, | |
| 180 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); | |
| 181 | ||
| 182 | expect(capabilities).toEqual({ | |
| 156 | 183 | provider: driver("packageTool"), |
| 157 | 184 | packageName: "@example/package-tool", |
| 158 | 185 | update: { |
| ... | ... | @@ -177,16 +204,18 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 177 | 204 | mkdirSync(bunBinDir, { recursive: true }); |
| 178 | 205 | writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); |
| 179 | 206 | |
| 180 | expect( | |
| 181 | nativePackageToolUpdate.resolve({ | |
| 207 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( | |
| 208 | nativePackageToolUpdate, | |
| 209 | { | |
| 182 | 210 | binaryPath: "native-package-tool", |
| 183 | platform: "win32", | |
| 184 | 211 | env: { |
| 185 | 212 | PATH: bunBinDir, |
| 186 | 213 | PATHEXT: ".COM;.EXE;.BAT;.CMD", |
| 187 | 214 | }, |
| 188 | }), | |
| 189 | ).toEqual({ | |
| 215 | }, | |
| 216 | ).pipe(Effect.provideService(HostProcessPlatform, "win32")); | |
| 217 | ||
| 218 | expect(capabilities).toEqual({ | |
| 190 | 219 | provider: driver("nativePackageTool"), |
| 191 | 220 | packageName: "@example/native-package-tool", |
| 192 | 221 | update: { |
| ... | ... | @@ -213,15 +242,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 213 | 242 | writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); |
| 214 | 243 | chmodSync(scopedPackageToolPath, 0o755); |
| 215 | 244 | |
| 216 | expect( | |
| 217 | scopedPackageToolUpdate.resolve({ | |
| 245 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( | |
| 246 | scopedPackageToolUpdate, | |
| 247 | { | |
| 218 | 248 | binaryPath: "scoped-package-tool", |
| 219 | platform: "darwin", | |
| 220 | 249 | env: { |
| 221 | 250 | PATH: pnpmHomeDir, |
| 222 | 251 | }, |
| 223 | }), | |
| 224 | ).toEqual({ | |
| 252 | }, | |
| 253 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); | |
| 254 | ||
| 255 | expect(capabilities).toEqual({ | |
| 225 | 256 | provider: driver("scopedPackageTool"), |
| 226 | 257 | packageName: "@example/scoped-package-tool", |
| 227 | 258 | update: { |
| ... | ... | @@ -241,7 +272,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 241 | 272 | expect( |
| 242 | 273 | packageToolUpdate.resolve({ |
| 243 | 274 | binaryPath: "/opt/homebrew/bin/package-tool", |
| 244 | platform: "darwin", | |
| 245 | 275 | env: { |
| 246 | 276 | PATH: "", |
| 247 | 277 | }, |
| ... | ... | @@ -272,15 +302,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 272 | 302 | writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); |
| 273 | 303 | chmodSync(nativePackageToolPath, 0o755); |
| 274 | 304 | |
| 275 | expect( | |
| 276 | nativePackageToolUpdate.resolve({ | |
| 305 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( | |
| 306 | nativePackageToolUpdate, | |
| 307 | { | |
| 277 | 308 | binaryPath: "native-package-tool", |
| 278 | platform: "darwin", | |
| 279 | 309 | env: { |
| 280 | 310 | PATH: nativeBinDir, |
| 281 | 311 | }, |
| 282 | }), | |
| 283 | ).toEqual({ | |
| 312 | }, | |
| 313 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); | |
| 314 | ||
| 315 | expect(capabilities).toEqual({ | |
| 284 | 316 | provider: driver("nativePackageTool"), |
| 285 | 317 | packageName: "@example/native-package-tool", |
| 286 | 318 | update: { |
| ... | ... | @@ -307,15 +339,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 307 | 339 | writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); |
| 308 | 340 | chmodSync(scopedPackageToolPath, 0o755); |
| 309 | 341 | |
| 310 | expect( | |
| 311 | scopedPackageToolUpdate.resolve({ | |
| 342 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( | |
| 343 | scopedPackageToolUpdate, | |
| 344 | { | |
| 312 | 345 | binaryPath: "scoped-package-tool", |
| 313 | platform: "darwin", | |
| 314 | 346 | env: { |
| 315 | 347 | PATH: nativeBinDir, |
| 316 | 348 | }, |
| 317 | }), | |
| 318 | ).toEqual({ | |
| 349 | }, | |
| 350 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); | |
| 351 | ||
| 352 | expect(capabilities).toEqual({ | |
| 319 | 353 | provider: driver("scopedPackageTool"), |
| 320 | 354 | packageName: "@example/scoped-package-tool", |
| 321 | 355 | update: { |
| ... | ... | @@ -335,7 +369,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 335 | 369 | expect( |
| 336 | 370 | nativePackageToolUpdate.resolve({ |
| 337 | 371 | binaryPath: "/opt/homebrew/bin/native-package-tool", |
| 338 | platform: "darwin", | |
| 339 | 372 | env: { |
| 340 | 373 | PATH: "", |
| 341 | 374 | }, |
| ... | ... | @@ -359,7 +392,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 359 | 392 | expect( |
| 360 | 393 | scopedPackageToolUpdate.resolve({ |
| 361 | 394 | binaryPath: "/opt/homebrew/bin/scoped-package-tool", |
| 362 | platform: "darwin", | |
| 363 | 395 | env: { |
| 364 | 396 | PATH: "", |
| 365 | 397 | }, |
| ... | ... | @@ -401,7 +433,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 401 | 433 | |
| 402 | 434 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 403 | 435 | binaryPath: symlinkPath, |
| 404 | platform: "darwin", | |
| 405 | 436 | env: { |
| 406 | 437 | PATH: "", |
| 407 | 438 | }, |
| ... | ... | @@ -449,7 +480,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 449 | 480 | |
| 450 | 481 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 451 | 482 | binaryPath: symlinkPath, |
| 452 | platform: "darwin", | |
| 453 | 483 | env: { |
| 454 | 484 | PATH: "", |
| 455 | 485 | }, |
| ... | ... | @@ -475,7 +505,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 475 | 505 | expect( |
| 476 | 506 | packageToolUpdate.resolve({ |
| 477 | 507 | binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", |
| 478 | platform: "win32", | |
| 479 | 508 | env: { |
| 480 | 509 | PATH: "", |
| 481 | 510 | PATHEXT: ".COM;.EXE;.BAT;.CMD", |
apps/server/src/provider/providerMaintenance.ts+37-15| ... | ... | @@ -5,6 +5,8 @@ import { |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 7 | 7 | import { resolveCommandPath } from "@t3tools/shared/shell"; |
| 8 | import * as Config from "effect/Config"; | |
| 9 | import * as Context from "effect/Context"; | |
| 8 | 10 | import * as DateTime from "effect/DateTime"; |
| 9 | 11 | import * as Effect from "effect/Effect"; |
| 10 | 12 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -16,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; |
| 16 | 18 | const LATEST_VERSION_TIMEOUT_MS = 4_000; |
| 17 | 19 | const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; |
| 18 | 20 | |
| 21 | const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv => | |
| 22 | Object.fromEntries( | |
| 23 | Object.entries(input).flatMap(([key, value]) => | |
| 24 | Option.match(value, { | |
| 25 | onNone: () => [], | |
| 26 | onSome: (resolved) => [[key, resolved]], | |
| 27 | }), | |
| 28 | ), | |
| 29 | ); | |
| 30 | ||
| 31 | const CommandLookupEnvConfig = Config.all({ | |
| 32 | PATH: Config.string("PATH").pipe(Config.option), | |
| 33 | Path: Config.string("Path").pipe(Config.option), | |
| 34 | path: Config.string("path").pipe(Config.option), | |
| 35 | PATHEXT: Config.string("PATHEXT").pipe(Config.option), | |
| 36 | }).pipe(Config.map(compactEnv)); | |
| 37 | ||
| 38 | const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); | |
| 39 | ||
| 19 | 40 | export interface ProviderMaintenanceCapabilities { |
| 20 | 41 | readonly provider: ProviderDriverKind; |
| 21 | 42 | readonly packageName: string | null; |
| ... | ... | @@ -32,7 +53,7 @@ export interface ProviderMaintenanceCommandAction { |
| 32 | 53 | export interface ProviderMaintenanceCapabilityResolutionOptions { |
| 33 | 54 | readonly binaryPath?: string | null; |
| 34 | 55 | readonly env?: NodeJS.ProcessEnv; |
| 35 | readonly platform?: NodeJS.Platform; | |
| 56 | readonly resolvedCommandPath?: string | null; | |
| 36 | 57 | readonly realCommandPath?: string | null; |
| 37 | 58 | } |
| 38 | 59 | |
| ... | ... | @@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { |
| 54 | 75 | } | null; |
| 55 | 76 | } |
| 56 | 77 | |
| 57 | interface LatestVersionCacheEntry { | |
| 78 | export interface ProviderVersionCacheEntry { | |
| 58 | 79 | readonly expiresAt: number; |
| 59 | 80 | readonly version: string | null; |
| 60 | 81 | } |
| 61 | 82 | |
| 62 | const latestVersionCache = new Map<string, LatestVersionCacheEntry>(); | |
| 83 | export const ProviderVersionCache = Context.Reference<Map<string, ProviderVersionCacheEntry>>( | |
| 84 | "@t3tools/server/providerMaintenance/ProviderVersionCache", | |
| 85 | { | |
| 86 | defaultValue: () => new Map(), | |
| 87 | }, | |
| 88 | ); | |
| 63 | 89 | const NpmLatestVersionResponse = Schema.Struct({ |
| 64 | 90 | version: Schema.optional(Schema.String), |
| 65 | 91 | }); |
| 66 | 92 | |
| 67 | export function clearLatestProviderVersionCacheForTests(): void { | |
| 68 | latestVersionCache.clear(); | |
| 69 | } | |
| 70 | ||
| 71 | 93 | function nonEmptyString(value: unknown): string | null { |
| 72 | 94 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; |
| 73 | 95 | } |
| ... | ... | @@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance( |
| 251 | 273 | } |
| 252 | 274 | |
| 253 | 275 | const resolvedCommandPath = |
| 254 | resolveCommandPath(binaryPath, { | |
| 255 | ...(options?.platform ? { platform: options.platform } : {}), | |
| 256 | ...(options?.env ? { env: options.env } : {}), | |
| 257 | }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | |
| 276 | options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | |
| 258 | 277 | |
| 259 | 278 | if (resolvedCommandPath) { |
| 260 | 279 | const commandPaths = [ |
| ... | ... | @@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 335 | 354 | return resolver.resolve(options); |
| 336 | 355 | } |
| 337 | 356 | |
| 357 | const env = options?.env ?? (yield* readCommandLookupEnv); | |
| 338 | 358 | const resolvedCommandPath = |
| 339 | resolveCommandPath(binaryPath, { | |
| 340 | ...(options?.platform ? { platform: options.platform } : {}), | |
| 341 | ...(options?.env ? { env: options.env } : {}), | |
| 342 | }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | |
| 359 | (yield* resolveCommandPath(binaryPath, { env }).pipe( | |
| 360 | Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), | |
| 361 | )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | |
| 343 | 362 | if (!resolvedCommandPath) { |
| 344 | 363 | return resolver.resolve(options); |
| 345 | 364 | } |
| ... | ... | @@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 350 | 369 | .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); |
| 351 | 370 | return resolver.resolve({ |
| 352 | 371 | ...options, |
| 372 | env, | |
| 373 | resolvedCommandPath, | |
| 353 | 374 | realCommandPath, |
| 354 | 375 | }); |
| 355 | 376 | }); |
| ... | ... | @@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers |
| 430 | 451 | return null; |
| 431 | 452 | } |
| 432 | 453 | |
| 454 | const latestVersionCache = yield* ProviderVersionCache; | |
| 433 | 455 | const cached = latestVersionCache.get(packageName); |
| 434 | 456 | const now = DateTime.toEpochMillis(yield* DateTime.now); |
| 435 | 457 | if (cached && cached.expiresAt > now) { |
apps/server/src/provider/providerMaintenanceRunner.test.ts+8-7| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { afterEach, describe, it, assert } from "@effect/vitest"; | |
| 1 | import { describe, it, assert } from "@effect/vitest"; | |
| 2 | 2 | import { |
| 3 | 3 | ProviderDriverKind, |
| 4 | 4 | ProviderInstanceId, |
| ... | ... | @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 21 | 21 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; |
| 22 | 22 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| 23 | 23 | import { |
| 24 | clearLatestProviderVersionCacheForTests, | |
| 25 | 24 | makeProviderMaintenanceCapabilities, |
| 25 | ProviderVersionCache, | |
| 26 | 26 | type ProviderMaintenanceCapabilities, |
| 27 | 27 | } from "./providerMaintenance.ts"; |
| 28 | 28 | const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); |
| ... | ... | @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); |
| 35 | 35 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); |
| 36 | 36 | const encoder = new TextEncoder(); |
| 37 | 37 | |
| 38 | afterEach(() => { | |
| 39 | clearLatestProviderVersionCacheForTests(); | |
| 40 | }); | |
| 41 | ||
| 42 | 38 | function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { |
| 43 | 39 | if (provider === CURSOR_DRIVER) { |
| 44 | 40 | return makeProviderMaintenanceCapabilities({ |
| ... | ... | @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => |
| 202 | 198 | Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( |
| 203 | 199 | Effect.provide( |
| 204 | 200 | ProviderMaintenanceRunner.layer.pipe( |
| 205 | Layer.provide(Layer.succeed(ProviderRegistry, registry)), | |
| 201 | Layer.provide( | |
| 202 | Layer.mergeAll( | |
| 203 | Layer.succeed(ProviderRegistry, registry), | |
| 204 | Layer.succeed(ProviderVersionCache, new Map()), | |
| 205 | ), | |
| 206 | ), | |
| 206 | 207 | ), |
| 207 | 208 | ), |
| 208 | 209 | ); |
apps/server/src/provider/providerSnapshot.ts+1-1| ... | ... | @@ -75,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman |
| 75 | 75 | ); |
| 76 | 76 | |
| 77 | 77 | const result: CommandResult = { stdout, stderr, code: exitCode }; |
| 78 | if (isWindowsCommandNotFound(exitCode, stderr)) { | |
| 78 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { | |
| 79 | 79 | return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); |
| 80 | 80 | } |
| 81 | 81 | return result; |
apps/server/src/server.test.ts+1| ... | ... | @@ -579,6 +579,7 @@ const buildAppUnderTest = (options?: { |
| 579 | 579 | ), |
| 580 | 580 | Layer.provide( |
| 581 | 581 | Layer.mock(ExternalLauncher.ExternalLauncher)({ |
| 582 | resolveAvailableEditors: () => Effect.succeed([]), | |
| 582 | 583 | ...options?.layers?.externalLauncher, |
| 583 | 584 | }), |
| 584 | 585 | ), |
apps/server/src/server.ts+1-1| ... | ... | @@ -365,7 +365,7 @@ export const makeServerLayer = Layer.unwrap( |
| 365 | 365 | Effect.gen(function* () { |
| 366 | 366 | const config = yield* ServerConfig; |
| 367 | 367 | |
| 368 | fixPath(); | |
| 368 | yield* fixPath(); | |
| 369 | 369 | |
| 370 | 370 | const httpListeningLayer = Layer.effectDiscard( |
| 371 | 371 | Effect.gen(function* () { |
apps/server/src/startupAccess.ts+4-4| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { networkInterfaces } from "node:os"; | |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | 2 | |
| 3 | 3 | import { QrCode } from "@t3tools/shared/qrCode"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo { |
| 13 | 13 | readonly pairingUrl: string; |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | type NetworkInterfacesMap = ReturnType<typeof networkInterfaces>; | |
| 16 | type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>; | |
| 17 | 17 | |
| 18 | 18 | export const isLoopbackHost = (host: string | undefined): boolean => { |
| 19 | 19 | if (!host || host.length === 0) { |
| ... | ... | @@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || |
| 44 | 44 | |
| 45 | 45 | export const resolveHeadlessConnectionHost = ( |
| 46 | 46 | host: string | undefined, |
| 47 | interfaces: NetworkInterfacesMap = networkInterfaces(), | |
| 47 | interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), | |
| 48 | 48 | ): string => { |
| 49 | 49 | if (!host) { |
| 50 | 50 | return "localhost"; |
| ... | ... | @@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = ( |
| 71 | 71 | export const resolveHeadlessConnectionString = ( |
| 72 | 72 | host: string | undefined, |
| 73 | 73 | port: number, |
| 74 | interfaces: NetworkInterfacesMap = networkInterfaces(), | |
| 74 | interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), | |
| 75 | 75 | ): string => { |
| 76 | 76 | const connectionHost = resolveHeadlessConnectionHost(host, interfaces); |
| 77 | 77 | return `http://${formatHostForUrl(connectionHost)}:${port}`; |
apps/server/src/telemetry/Identify.ts+4-3| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 1 | 2 | import * as Crypto from "effect/Crypto"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Encoding from "effect/Encoding"; |
| 4 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 6 | import * as Path from "effect/Path"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | import { homedir } from "node:os"; | |
| 8 | ||
| 8 | 9 | import { ServerConfig } from "../config.ts"; |
| 9 | 10 | |
| 10 | 11 | const CodexAuthJsonSchema = Schema.Struct({ |
| ... | ... | @@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () { |
| 39 | 40 | const fileSystem = yield* FileSystem.FileSystem; |
| 40 | 41 | const path = yield* Path.Path; |
| 41 | 42 | |
| 42 | const authJsonPath = path.join(homedir(), ".codex", "auth.json"); | |
| 43 | const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); | |
| 43 | 44 | const authJson = yield* Effect.flatMap( |
| 44 | 45 | fileSystem.readFileString(authJsonPath), |
| 45 | 46 | Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), |
| ... | ... | @@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () { |
| 52 | 53 | const fileSystem = yield* FileSystem.FileSystem; |
| 53 | 54 | const path = yield* Path.Path; |
| 54 | 55 | |
| 55 | const claudeJsonPath = path.join(homedir(), ".claude.json"); | |
| 56 | const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); | |
| 56 | 57 | const claudeJson = yield* Effect.flatMap( |
| 57 | 58 | fileSystem.readFileString(claudeJsonPath), |
| 58 | 59 | Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), |
apps/server/src/telemetry/Layers/AnalyticsService.ts+8-3| ... | ... | @@ -7,10 +7,12 @@ |
| 7 | 7 | * @module AnalyticsServiceLive |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 10 | 11 | import * as Config from "effect/Config"; |
| 11 | 12 | import * as DateTime from "effect/DateTime"; |
| 12 | 13 | import * as Effect from "effect/Effect"; |
| 13 | 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | |
| 14 | 16 | import * as Ref from "effect/Ref"; |
| 15 | 17 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 16 | 18 | |
| ... | ... | @@ -37,6 +39,7 @@ const TelemetryEnvConfig = Config.all({ |
| 37 | 39 | maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( |
| 38 | 40 | Config.withDefault(1_000), |
| 39 | 41 | ), |
| 42 | wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), | |
| 40 | 43 | }); |
| 41 | 44 | |
| 42 | 45 | const makeAnalyticsService = Effect.gen(function* () { |
| ... | ... | @@ -46,6 +49,8 @@ const makeAnalyticsService = Effect.gen(function* () { |
| 46 | 49 | const identifier = yield* getTelemetryIdentifier; |
| 47 | 50 | const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]); |
| 48 | 51 | const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; |
| 52 | const hostPlatform = yield* HostProcessPlatform; | |
| 53 | const hostArchitecture = yield* HostProcessArchitecture; | |
| 49 | 54 | |
| 50 | 55 | const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) => |
| 51 | 56 | Effect.flatMap(DateTime.now, (now) => |
| ... | ... | @@ -87,9 +92,9 @@ const makeAnalyticsService = Effect.gen(function* () { |
| 87 | 92 | properties: { |
| 88 | 93 | ...event.properties, |
| 89 | 94 | $process_person_profile: false, |
| 90 | platform: process.platform, | |
| 91 | wsl: process.env.WSL_DISTRO_NAME, | |
| 92 | arch: process.arch, | |
| 95 | platform: hostPlatform, | |
| 96 | wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), | |
| 97 | arch: hostArchitecture, | |
| 93 | 98 | t3CodeVersion: packageJson.version, |
| 94 | 99 | clientType, |
| 95 | 100 | }, |
apps/server/src/terminal/Layers/BunPTY.ts+3-1| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | 6 | import { PtyAdapter } from "../Services/PTY.ts"; |
| 6 | 7 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; |
| 7 | 8 | |
| ... | ... | @@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess { |
| 95 | 96 | export const layer = Layer.effect( |
| 96 | 97 | PtyAdapter, |
| 97 | 98 | Effect.gen(function* () { |
| 98 | if (process.platform === "win32") { | |
| 99 | const platform = yield* HostProcessPlatform; | |
| 100 | if (platform === "win32") { | |
| 99 | 101 | return yield* Effect.die( |
| 100 | 102 | "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", |
| 101 | 103 | ); |
apps/server/src/terminal/Layers/Manager.test.ts+36-56| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | type TerminalOpenInput, |
| 9 | 9 | type TerminalRestartInput, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 11 | 12 | import * as Data from "effect/Data"; |
| 12 | 13 | import * as Duration from "effect/Duration"; |
| 13 | 14 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = ( |
| 199 | 200 | |
| 200 | 201 | interface CreateManagerOptions { |
| 201 | 202 | shellResolver?: () => string; |
| 202 | platform?: NodeJS.Platform; | |
| 203 | 203 | env?: NodeJS.ProcessEnv; |
| 204 | 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ |
| 205 | 205 | readonly hasRunningSubprocess: boolean; |
| ... | ... | @@ -240,7 +240,6 @@ const createManager = ( |
| 240 | 240 | historyLineLimit, |
| 241 | 241 | ptyAdapter, |
| 242 | 242 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), |
| 243 | ...(options.platform !== undefined ? { platform: options.platform } : {}), | |
| 244 | 243 | ...(options.env !== undefined ? { env: options.env } : {}), |
| 245 | 244 | ...(options.subprocessInspector !== undefined |
| 246 | 245 | ? { subprocessInspector: options.subprocessInspector } |
| ... | ... | @@ -270,12 +269,13 @@ const createManager = ( |
| 270 | 269 | }), |
| 271 | 270 | ); |
| 272 | 271 | |
| 272 | const withHostPlatform = (platform: NodeJS.Platform) => | |
| 273 | Layer.succeed(HostProcessPlatform, platform); | |
| 274 | ||
| 273 | 275 | it.layer( |
| 274 | 276 | Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), |
| 275 | 277 | { excludeTestServices: true }, |
| 276 | 278 | )("TerminalManager", (it) => { |
| 277 | const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; | |
| 278 | ||
| 279 | 279 | it.effect("spawns lazily and reuses running terminal per thread", () => |
| 280 | 280 | Effect.gen(function* () { |
| 281 | 281 | const { manager, ptyAdapter } = yield* createManager(); |
| ... | ... | @@ -415,8 +415,10 @@ it.layer( |
| 415 | 415 | fs.writeFileString(filePath, contents), |
| 416 | 416 | ); |
| 417 | 417 | |
| 418 | itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => | |
| 418 | it.effect("preserves non-notFound cwd stat failures", () => | |
| 419 | 419 | Effect.gen(function* () { |
| 420 | if ((yield* HostProcessPlatform) === "win32") return; | |
| 421 | ||
| 420 | 422 | const path = yield* Path.Path; |
| 421 | 423 | |
| 422 | 424 | const { manager, baseDir } = yield* createManager(); |
| ... | ... | @@ -1082,10 +1084,9 @@ it.layer( |
| 1082 | 1084 | |
| 1083 | 1085 | it.effect("retries with fallback shells when preferred shell spawn fails", () => |
| 1084 | 1086 | Effect.gen(function* () { |
| 1087 | const platform = yield* HostProcessPlatform; | |
| 1085 | 1088 | const missingShell = |
| 1086 | process.platform === "win32" | |
| 1087 | ? "C:\\definitely\\missing-shell.exe" | |
| 1088 | : "/definitely/missing-shell -l"; | |
| 1089 | platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; | |
| 1089 | 1090 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1090 | 1091 | shellResolver: () => missingShell, |
| 1091 | 1092 | }); |
| ... | ... | @@ -1096,10 +1097,10 @@ it.layer( |
| 1096 | 1097 | assert.equal(snapshot.status, "running"); |
| 1097 | 1098 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); |
| 1098 | 1099 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe( |
| 1099 | process.platform === "win32" ? missingShell : "/definitely/missing-shell", | |
| 1100 | platform === "win32" ? missingShell : "/definitely/missing-shell", | |
| 1100 | 1101 | ); |
| 1101 | 1102 | |
| 1102 | if (process.platform === "win32") { | |
| 1103 | if (platform === "win32") { | |
| 1103 | 1104 | expect( |
| 1104 | 1105 | ptyAdapter.spawnInputs.some( |
| 1105 | 1106 | (input) => |
| ... | ... | @@ -1121,13 +1122,12 @@ it.layer( |
| 1121 | 1122 | it.effect("prefers PowerShell over ComSpec for Windows terminals", () => |
| 1122 | 1123 | Effect.gen(function* () { |
| 1123 | 1124 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1124 | platform: "win32", | |
| 1125 | 1125 | env: { |
| 1126 | 1126 | ComSpec: "C:\\Windows\\System32\\cmd.exe", |
| 1127 | 1127 | PATH: "C:\\Windows\\System32", |
| 1128 | 1128 | SystemRoot: "C:\\Windows", |
| 1129 | 1129 | }, |
| 1130 | }); | |
| 1130 | }).pipe(Effect.provide(withHostPlatform("win32"))); | |
| 1131 | 1131 | |
| 1132 | 1132 | yield* manager.open(openInput()); |
| 1133 | 1133 | |
| ... | ... | @@ -1142,15 +1142,16 @@ it.layer( |
| 1142 | 1142 | |
| 1143 | 1143 | it.effect("falls back to built-in PowerShell by absolute path on Windows", () => |
| 1144 | 1144 | Effect.gen(function* () { |
| 1145 | const { manager, ptyAdapter } = yield* createManager(5, { | |
| 1146 | platform: "win32", | |
| 1145 | const ptyAdapter = new FakePtyAdapter(); | |
| 1146 | const { manager } = yield* createManager(5, { | |
| 1147 | ptyAdapter, | |
| 1148 | shellResolver: () => "C:\\missing\\custom-shell.exe", | |
| 1147 | 1149 | env: { |
| 1148 | 1150 | ComSpec: "C:\\Windows\\System32\\cmd.exe", |
| 1149 | 1151 | PATH: "C:\\Windows\\System32", |
| 1150 | 1152 | SystemRoot: "C:\\Windows", |
| 1151 | 1153 | }, |
| 1152 | shellResolver: () => "C:\\missing\\custom-shell.exe", | |
| 1153 | }); | |
| 1154 | }).pipe(Effect.provide(withHostPlatform("win32"))); | |
| 1154 | 1155 | ptyAdapter.spawnFailures.push( |
| 1155 | 1156 | new Error("spawn custom-shell.exe ENOENT"), |
| 1156 | 1157 | new Error("spawn pwsh.exe ENOENT"), |
| ... | ... | @@ -1170,46 +1171,25 @@ it.layer( |
| 1170 | 1171 | |
| 1171 | 1172 | it.effect("filters app runtime env variables from terminal sessions", () => |
| 1172 | 1173 | Effect.gen(function* () { |
| 1173 | const originalValues = new Map<string, string | undefined>(); | |
| 1174 | const setEnv = (key: string, value: string | undefined) => { | |
| 1175 | if (!originalValues.has(key)) { | |
| 1176 | originalValues.set(key, process.env[key]); | |
| 1177 | } | |
| 1178 | if (value === undefined) { | |
| 1179 | delete process.env[key]; | |
| 1180 | return; | |
| 1181 | } | |
| 1182 | process.env[key] = value; | |
| 1183 | }; | |
| 1184 | const restoreEnv = () => { | |
| 1185 | for (const [key, value] of originalValues) { | |
| 1186 | if (value === undefined) { | |
| 1187 | delete process.env[key]; | |
| 1188 | } else { | |
| 1189 | process.env[key] = value; | |
| 1190 | } | |
| 1191 | } | |
| 1192 | }; | |
| 1193 | ||
| 1194 | setEnv("PORT", "5173"); | |
| 1195 | setEnv("T3CODE_PORT", "3773"); | |
| 1196 | setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); | |
| 1197 | setEnv("TEST_TERMINAL_KEEP", "keep-me"); | |
| 1174 | const { manager, ptyAdapter } = yield* createManager(5, { | |
| 1175 | env: { | |
| 1176 | PORT: "5173", | |
| 1177 | T3CODE_PORT: "3773", | |
| 1178 | VITE_DEV_SERVER_URL: "http://localhost:5173", | |
| 1179 | TEST_TERMINAL_KEEP: "keep-me", | |
| 1180 | }, | |
| 1181 | }); | |
| 1182 | yield* manager.open(openInput()); | |
| 1183 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 1184 | expect(spawnInput).toBeDefined(); | |
| 1185 | if (!spawnInput) return; | |
| 1198 | 1186 | |
| 1199 | try { | |
| 1200 | const { manager, ptyAdapter } = yield* createManager(); | |
| 1201 | yield* manager.open(openInput()); | |
| 1202 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 1203 | expect(spawnInput).toBeDefined(); | |
| 1204 | if (!spawnInput) return; | |
| 1205 | ||
| 1206 | expect(spawnInput.env.PORT).toBeUndefined(); | |
| 1207 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | |
| 1208 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | |
| 1209 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | |
| 1210 | } finally { | |
| 1211 | restoreEnv(); | |
| 1212 | } | |
| 1187 | expect(spawnInput.env.PORT).toBeUndefined(); | |
| 1188 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | |
| 1189 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | |
| 1190 | // Arbitrary host env vars must pass through — terminals inherit the | |
| 1191 | // user's environment apart from the explicit blocklist. | |
| 1192 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | |
| 1213 | 1193 | }), |
| 1214 | 1194 | ); |
| 1215 | 1195 | |
| ... | ... | @@ -1237,7 +1217,7 @@ it.layer( |
| 1237 | 1217 | |
| 1238 | 1218 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => |
| 1239 | 1219 | Effect.gen(function* () { |
| 1240 | if (process.platform === "win32") return; | |
| 1220 | if ((yield* HostProcessPlatform) === "win32") return; | |
| 1241 | 1221 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1242 | 1222 | shellResolver: () => "/bin/zsh", |
| 1243 | 1223 | }); |
apps/server/src/terminal/Layers/Manager.ts+14-10| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | type TerminalSummary, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 13 | 14 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 14 | 15 | import * as DateTime from "effect/DateTime"; |
| 15 | 16 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -312,10 +313,7 @@ function enqueueProcessEvent( |
| 312 | 313 | return true; |
| 313 | 314 | } |
| 314 | 315 | |
| 315 | function defaultShellResolver( | |
| 316 | platform: NodeJS.Platform = process.platform, | |
| 317 | env: NodeJS.ProcessEnv = process.env, | |
| 318 | ): string { | |
| 316 | function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { | |
| 319 | 317 | if (platform === "win32") { |
| 320 | 318 | return "pwsh.exe"; |
| 321 | 319 | } |
| ... | ... | @@ -324,7 +322,7 @@ function defaultShellResolver( |
| 324 | 322 | |
| 325 | 323 | function normalizeShellCommand( |
| 326 | 324 | value: string | undefined, |
| 327 | platform: NodeJS.Platform = process.platform, | |
| 325 | platform: NodeJS.Platform, | |
| 328 | 326 | ): string | null { |
| 329 | 327 | if (!value) return null; |
| 330 | 328 | const trimmed = value.trim(); |
| ... | ... | @@ -360,7 +358,7 @@ function joinWindowsPath(...parts: ReadonlyArray<string>): string { |
| 360 | 358 | |
| 361 | 359 | function shellCandidateFromCommand( |
| 362 | 360 | command: string | null, |
| 363 | platform: NodeJS.Platform = process.platform, | |
| 361 | platform: NodeJS.Platform, | |
| 364 | 362 | ): ShellCandidate | null { |
| 365 | 363 | if (!command || command.length === 0) return null; |
| 366 | 364 | const shellName = basenameForPlatform(command, platform).toLowerCase(); |
| ... | ... | @@ -411,8 +409,8 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC |
| 411 | 409 | |
| 412 | 410 | function resolveShellCandidates( |
| 413 | 411 | shellResolver: () => string, |
| 414 | platform: NodeJS.Platform = process.platform, | |
| 415 | env: NodeJS.ProcessEnv = process.env, | |
| 412 | platform: NodeJS.Platform, | |
| 413 | env: NodeJS.ProcessEnv, | |
| 416 | 414 | ): ShellCandidate[] { |
| 417 | 415 | const requested = shellCandidateFromCommand( |
| 418 | 416 | normalizeShellCommand(shellResolver(), platform), |
| ... | ... | @@ -512,6 +510,9 @@ function windowsInspectSubprocess( |
| 512 | 510 | return Effect.gen(function* () { |
| 513 | 511 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 514 | 512 | return yield* processRunner.run({ |
| 513 | // powershell.exe is a real executable — never spawn it through cmd.exe | |
| 514 | // shell mode, which would re-tokenize the `-Command` payload (pipes, | |
| 515 | // semicolons) before PowerShell ever sees it. | |
| 515 | 516 | command: "powershell.exe", |
| 516 | 517 | args: ["-NoProfile", "-NonInteractive", "-Command", command], |
| 517 | 518 | timeout: "1500 millis", |
| ... | ... | @@ -976,7 +977,6 @@ interface TerminalManagerOptions { |
| 976 | 977 | historyLineLimit?: number; |
| 977 | 978 | ptyAdapter: PtyAdapterShape; |
| 978 | 979 | shellResolver?: () => string; |
| 979 | platform?: NodeJS.Platform; | |
| 980 | 980 | env?: NodeJS.ProcessEnv; |
| 981 | 981 | subprocessInspector?: TerminalSubprocessInspector; |
| 982 | 982 | subprocessPollIntervalMs?: number; |
| ... | ... | @@ -1014,7 +1014,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1014 | 1014 | |
| 1015 | 1015 | const logsDir = options.logsDir; |
| 1016 | 1016 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; |
| 1017 | const platform = options.platform ?? process.platform; | |
| 1017 | const platform = yield* HostProcessPlatform; | |
| 1018 | // Terminals must inherit the user's full environment (minus the blocklist | |
| 1019 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips | |
| 1020 | // things like PSModulePath, DISPLAY, proxies, and toolchain variables. | |
| 1021 | // `options.env` is the test seam. | |
| 1018 | 1022 | const baseEnv = options.env ?? process.env; |
| 1019 | 1023 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 1020 | 1024 | const processRunner = yield* ProcessRunner.ProcessRunner; |
apps/server/src/terminal/Layers/NodePTY.test.ts+57-46| ... | ... | @@ -1,47 +1,58 @@ |
| 1 | import * as FileSystem from "effect/FileSystem"; | |
| 2 | import * as Path from "effect/Path"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import { assert, it } from "@effect/vitest"; | |
| 5 | ||
| 6 | import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; | |
| 7 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | ||
| 9 | it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { | |
| 10 | it.effect("adds executable bits when helper exists but is not executable", () => | |
| 11 | Effect.gen(function* () { | |
| 12 | if (process.platform === "win32") return; | |
| 13 | ||
| 14 | const fs = yield* FileSystem.FileSystem; | |
| 15 | const path = yield* Path.Path; | |
| 16 | ||
| 17 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | |
| 18 | const helperPath = path.join(dir, "spawn-helper"); | |
| 19 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | |
| 20 | yield* fs.chmod(helperPath, 0o644); | |
| 21 | ||
| 22 | yield* ensureNodePtySpawnHelperExecutable(helperPath); | |
| 23 | ||
| 24 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; | |
| 25 | assert.equal(mode & 0o111, 0o111); | |
| 26 | }), | |
| 27 | ); | |
| 28 | ||
| 29 | it.effect("keeps executable helper as executable", () => | |
| 30 | Effect.gen(function* () { | |
| 31 | if (process.platform === "win32") return; | |
| 32 | ||
| 33 | const fs = yield* FileSystem.FileSystem; | |
| 34 | const path = yield* Path.Path; | |
| 35 | ||
| 36 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | |
| 37 | const helperPath = path.join(dir, "spawn-helper"); | |
| 38 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | |
| 39 | yield* fs.chmod(helperPath, 0o755); | |
| 40 | ||
| 41 | yield* ensureNodePtySpawnHelperExecutable(helperPath); | |
| 42 | ||
| 43 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; | |
| 44 | assert.equal(mode & 0o111, 0o111); | |
| 45 | }), | |
| 46 | ); | |
| 47 | }); | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import { vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { PtyAdapter } from "../Services/PTY.ts"; | |
| 9 | import { layer } from "./NodePTY.ts"; | |
| 10 | ||
| 11 | const spawn = vi.fn(() => ({ | |
| 12 | pid: 42, | |
| 13 | write: vi.fn(), | |
| 14 | resize: vi.fn(), | |
| 15 | kill: vi.fn(), | |
| 16 | onData: vi.fn(() => ({ dispose: vi.fn() })), | |
| 17 | onExit: vi.fn(() => ({ dispose: vi.fn() })), | |
| 18 | })); | |
| 19 | ||
| 20 | vi.mock("node-pty", () => ({ spawn })); | |
| 21 | ||
| 22 | const testLayer = layer.pipe( | |
| 23 | Layer.provide( | |
| 24 | Layer.mergeAll( | |
| 25 | NodeServices.layer, | |
| 26 | Layer.succeed(HostProcessPlatform, "win32"), | |
| 27 | Layer.succeed(HostProcessArchitecture, "x64"), | |
| 28 | ), | |
| 29 | ), | |
| 30 | ); | |
| 31 | ||
| 32 | it.effect("spawns through the public adapter with the provided host references", () => | |
| 33 | Effect.gen(function* () { | |
| 34 | const adapter = yield* PtyAdapter; | |
| 35 | const process = yield* adapter.spawn({ | |
| 36 | shell: "powershell.exe", | |
| 37 | args: ["-NoLogo"], | |
| 38 | cwd: "C:\\workspace", | |
| 39 | cols: 120, | |
| 40 | rows: 40, | |
| 41 | env: {}, | |
| 42 | }); | |
| 43 | ||
| 44 | assert.equal(process.pid, 42); | |
| 45 | assert.equal(spawn.mock.calls.length, 1); | |
| 46 | assert.deepEqual(spawn.mock.calls[0], [ | |
| 47 | "powershell.exe", | |
| 48 | ["-NoLogo"], | |
| 49 | { | |
| 50 | cwd: "C:\\workspace", | |
| 51 | cols: 120, | |
| 52 | rows: 40, | |
| 53 | env: {}, | |
| 54 | name: "xterm-color", | |
| 55 | }, | |
| 56 | ]); | |
| 57 | }).pipe(Effect.provide(testLayer)), | |
| 58 | ); |
apps/server/src/terminal/Layers/NodePTY.ts+15-9| ... | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Path from "effect/Path"; |
| 7 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | 8 | import { PtyAdapter } from "../Services/PTY.ts"; |
| 8 | 9 | import { |
| 9 | 10 | PtySpawnError, |
| ... | ... | @@ -18,13 +19,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { |
| 18 | 19 | const requireForNodePty = createRequire(import.meta.url); |
| 19 | 20 | const path = yield* Path.Path; |
| 20 | 21 | const fs = yield* FileSystem.FileSystem; |
| 22 | const platform = yield* HostProcessPlatform; | |
| 23 | const architecture = yield* HostProcessArchitecture; | |
| 21 | 24 | |
| 22 | 25 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); |
| 23 | 26 | const packageDir = path.dirname(packageJsonPath); |
| 24 | 27 | const candidates = [ |
| 25 | 28 | path.join(packageDir, "build", "Release", "spawn-helper"), |
| 26 | 29 | path.join(packageDir, "build", "Debug", "spawn-helper"), |
| 27 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), | |
| 30 | path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), | |
| 28 | 31 | ]; |
| 29 | 32 | |
| 30 | 33 | for (const candidate of candidates) { |
| ... | ... | @@ -35,16 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { |
| 35 | 38 | return null; |
| 36 | 39 | }).pipe(Effect.orElseSucceed(() => null)); |
| 37 | 40 | |
| 38 | export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { | |
| 41 | const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { | |
| 39 | 42 | const fs = yield* FileSystem.FileSystem; |
| 40 | if (process.platform === "win32") return; | |
| 41 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | |
| 43 | const platform = yield* HostProcessPlatform; | |
| 44 | if (platform === "win32") return; | |
| 45 | if (didEnsureSpawnHelperExecutable) return; | |
| 42 | 46 | |
| 43 | const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); | |
| 47 | const helperPath = yield* resolveNodePtySpawnHelperPath; | |
| 44 | 48 | if (!helperPath) return; |
| 45 | if (!explicitPath) { | |
| 46 | didEnsureSpawnHelperExecutable = true; | |
| 47 | } | |
| 49 | didEnsureSpawnHelperExecutable = true; | |
| 48 | 50 | |
| 49 | 51 | if (!(yield* fs.exists(helperPath))) { |
| 50 | 52 | return; |
| ... | ... | @@ -102,6 +104,8 @@ export const layer = Layer.effect( |
| 102 | 104 | Effect.gen(function* () { |
| 103 | 105 | const fs = yield* FileSystem.FileSystem; |
| 104 | 106 | const path = yield* Path.Path; |
| 107 | const platform = yield* HostProcessPlatform; | |
| 108 | const architecture = yield* HostProcessArchitecture; | |
| 105 | 109 | |
| 106 | 110 | const nodePty = yield* Effect.promise(() => import("node-pty")); |
| 107 | 111 | |
| ... | ... | @@ -109,6 +113,8 @@ export const layer = Layer.effect( |
| 109 | 113 | ensureNodePtySpawnHelperExecutable().pipe( |
| 110 | 114 | Effect.provideService(FileSystem.FileSystem, fs), |
| 111 | 115 | Effect.provideService(Path.Path, path), |
| 116 | Effect.provideService(HostProcessPlatform, platform), | |
| 117 | Effect.provideService(HostProcessArchitecture, architecture), | |
| 112 | 118 | Effect.orElseSucceed(() => undefined), |
| 113 | 119 | ), |
| 114 | 120 | ); |
| ... | ... | @@ -123,7 +129,7 @@ export const layer = Layer.effect( |
| 123 | 129 | cols: input.cols, |
| 124 | 130 | rows: input.rows, |
| 125 | 131 | env: input.env, |
| 126 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 132 | name: platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 127 | 133 | }), |
| 128 | 134 | catch: (cause) => |
| 129 | 135 | new PtySpawnError({ |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+12-10| ... | ... | @@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | 15 | |
| 16 | 16 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; |
| 17 | 17 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 18 | 19 | |
| 19 | 20 | import { TextGenerationError } from "@t3tools/contracts"; |
| 20 | 21 | import { type TextGenerationShape } from "./TextGeneration.ts"; |
| ... | ... | @@ -59,7 +60,7 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla |
| 59 | 60 | |
| 60 | 61 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( |
| 61 | 62 | claudeSettings: ClaudeSettings, |
| 62 | environment: NodeJS.ProcessEnv = process.env, | |
| 63 | environment?: NodeJS.ProcessEnv, | |
| 63 | 64 | ) { |
| 64 | 65 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 65 | 66 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| ... | ... | @@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 156 | 157 | : undefined; |
| 157 | 158 | |
| 158 | 159 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { |
| 159 | const command = ChildProcess.make( | |
| 160 | const spawnCommand = yield* resolveSpawnCommand( | |
| 160 | 161 | claudeSettings.binaryPath || "claude", |
| 161 | 162 | [ |
| 162 | 163 | "-p", |
| ... | ... | @@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 170 | 171 | ...(settingsJson ? ["--settings", settingsJson] : []), |
| 171 | 172 | "--dangerously-skip-permissions", |
| 172 | 173 | ], |
| 173 | { | |
| 174 | env: claudeEnvironment, | |
| 175 | cwd, | |
| 176 | shell: process.platform === "win32", | |
| 177 | stdin: { | |
| 178 | stream: Stream.encodeText(Stream.make(prompt)), | |
| 179 | }, | |
| 180 | }, | |
| 174 | { env: claudeEnvironment }, | |
| 181 | 175 | ); |
| 176 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 177 | env: claudeEnvironment, | |
| 178 | cwd, | |
| 179 | shell: spawnCommand.shell, | |
| 180 | stdin: { | |
| 181 | stream: Stream.encodeText(Stream.make(prompt)), | |
| 182 | }, | |
| 183 | }); | |
| 182 | 184 | |
| 183 | 185 | const child = yield* commandSpawner |
| 184 | 186 | .spawn(command) |
apps/server/src/textGeneration/CodexTextGeneration.ts+16-13| ... | ... | @@ -9,6 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 9 | 9 | |
| 10 | 10 | import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; |
| 11 | 11 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 12 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 12 | 13 | |
| 13 | 14 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 14 | 15 | import { ServerConfig } from "../config.ts"; |
| ... | ... | @@ -44,12 +45,13 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| 44 | 45 | */ |
| 45 | 46 | export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( |
| 46 | 47 | codexConfig: CodexSettings, |
| 47 | environment: NodeJS.ProcessEnv = process.env, | |
| 48 | environment?: NodeJS.ProcessEnv, | |
| 48 | 49 | ) { |
| 49 | 50 | const fileSystem = yield* FileSystem.FileSystem; |
| 50 | 51 | const path = yield* Path.Path; |
| 51 | 52 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 52 | 53 | const serverConfig = yield* Effect.service(ServerConfig); |
| 54 | const resolvedEnvironment = environment ?? process.env; | |
| 53 | 55 | |
| 54 | 56 | type MaterializedImageAttachments = { |
| 55 | 57 | readonly imagePaths: ReadonlyArray<string>; |
| ... | ... | @@ -180,7 +182,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 180 | 182 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? |
| 181 | 183 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 182 | 184 | const serviceTier = getCodexServiceTierOptionValue(modelSelection); |
| 183 | const command = ChildProcess.make( | |
| 185 | const spawnCommand = yield* resolveSpawnCommand( | |
| 184 | 186 | codexConfig.binaryPath || "codex", |
| 185 | 187 | [ |
| 186 | 188 | "exec", |
| ... | ... | @@ -200,18 +202,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 200 | 202 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), |
| 201 | 203 | "-", |
| 202 | 204 | ], |
| 203 | { | |
| 204 | env: { | |
| 205 | ...environment, | |
| 206 | ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), | |
| 207 | }, | |
| 208 | cwd, | |
| 209 | shell: process.platform === "win32", | |
| 210 | stdin: { | |
| 211 | stream: Stream.encodeText(Stream.make(prompt)), | |
| 212 | }, | |
| 213 | }, | |
| 205 | { env: resolvedEnvironment }, | |
| 214 | 206 | ); |
| 207 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 208 | env: { | |
| 209 | ...resolvedEnvironment, | |
| 210 | ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), | |
| 211 | }, | |
| 212 | cwd, | |
| 213 | shell: spawnCommand.shell, | |
| 214 | stdin: { | |
| 215 | stream: Stream.encodeText(Stream.make(prompt)), | |
| 216 | }, | |
| 217 | }); | |
| 215 | 218 | |
| 216 | 219 | const child = yield* commandSpawner |
| 217 | 220 | .spawn(command) |
apps/server/src/textGeneration/CursorTextGeneration.ts+3-2| ... | ... | @@ -59,9 +59,10 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { |
| 59 | 59 | */ |
| 60 | 60 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( |
| 61 | 61 | cursorSettings: CursorSettings, |
| 62 | environment: NodeJS.ProcessEnv = process.env, | |
| 62 | environment?: NodeJS.ProcessEnv, | |
| 63 | 63 | ) { |
| 64 | 64 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 65 | const resolvedEnvironment = environment ?? process.env; | |
| 65 | 66 | |
| 66 | 67 | const runCursorJson = <S extends Schema.Top>({ |
| 67 | 68 | operation, |
| ... | ... | @@ -84,7 +85,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 84 | 85 | const outputRef = yield* Ref.make(""); |
| 85 | 86 | const runtime = yield* makeCursorAcpRuntime({ |
| 86 | 87 | cursorSettings, |
| 87 | environment, | |
| 88 | environment: resolvedEnvironment, | |
| 88 | 89 | childProcessSpawner: commandSpawner, |
| 89 | 90 | cwd, |
| 90 | 91 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, |
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+3-2| ... | ... | @@ -99,10 +99,11 @@ interface SharedOpenCodeTextGenerationServerState { |
| 99 | 99 | |
| 100 | 100 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( |
| 101 | 101 | openCodeSettings: OpenCodeSettings, |
| 102 | environment: NodeJS.ProcessEnv = process.env, | |
| 102 | environment?: NodeJS.ProcessEnv, | |
| 103 | 103 | ) { |
| 104 | 104 | const serverConfig = yield* ServerConfig; |
| 105 | 105 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 106 | const resolvedEnvironment = environment ?? process.env; | |
| 106 | 107 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => |
| 107 | 108 | Scope.close(scope, Exit.void), |
| 108 | 109 | ); |
| ... | ... | @@ -208,7 +209,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 208 | 209 | openCodeRuntime |
| 209 | 210 | .startOpenCodeServerProcess({ |
| 210 | 211 | binaryPath: input.binaryPath, |
| 211 | environment, | |
| 212 | environment: resolvedEnvironment, | |
| 212 | 213 | }) |
| 213 | 214 | .pipe( |
| 214 | 215 | Effect.provideService(Scope.Scope, serverScope), |
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+10-6| ... | ... | @@ -10,6 +10,7 @@ import * as Path from "effect/Path"; |
| 10 | 10 | import * as PlatformError from "effect/PlatformError"; |
| 11 | 11 | |
| 12 | 12 | import { ServerConfig } from "../../config.ts"; |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 13 | 14 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 14 | 15 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 15 | 16 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; |
| ... | ... | @@ -75,9 +76,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb |
| 75 | 76 | }); |
| 76 | 77 | |
| 77 | 78 | const appendSeparator = (input: string) => |
| 78 | input.endsWith("/") || input.endsWith("\\") | |
| 79 | ? input | |
| 80 | : `${input}${process.platform === "win32" ? "\\" : "/"}`; | |
| 79 | Effect.map(HostProcessPlatform, (platform) => | |
| 80 | input.endsWith("/") || input.endsWith("\\") | |
| 81 | ? input | |
| 82 | : `${input}${platform === "win32" ? "\\" : "/"}`, | |
| 83 | ); | |
| 81 | 84 | |
| 82 | 85 | it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 83 | 86 | afterEach(() => { |
| ... | ... | @@ -344,12 +347,13 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 344 | 347 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); |
| 345 | 348 | yield* writeTextFile(cwd, ".config/settings.json", "{}"); |
| 346 | 349 | yield* writeTextFile(cwd, "config/settings.json", "{}"); |
| 350 | const cwdWithSeparator = yield* appendSeparator(cwd); | |
| 347 | 351 | |
| 348 | 352 | const directoryResult = yield* workspaceEntries.browse({ |
| 349 | partialPath: appendSeparator(cwd), | |
| 353 | partialPath: cwdWithSeparator, | |
| 350 | 354 | }); |
| 351 | 355 | const hiddenPrefixResult = yield* workspaceEntries.browse({ |
| 352 | partialPath: `${appendSeparator(cwd)}.c`, | |
| 356 | partialPath: `${cwdWithSeparator}.c`, | |
| 353 | 357 | }); |
| 354 | 358 | |
| 355 | 359 | expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); |
| ... | ... | @@ -402,7 +406,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 402 | 406 | vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); |
| 403 | 407 | |
| 404 | 408 | const result = yield* workspaceEntries.browse({ |
| 405 | partialPath: appendSeparator(cwd), | |
| 409 | partialPath: yield* appendSeparator(cwd), | |
| 406 | 410 | }); |
| 407 | 411 | expect(result).toEqual({ parentPath: cwd, entries: [] }); |
| 408 | 412 | }), |
apps/server/src/workspace/Layers/WorkspaceEntries.ts+6-4| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as OS from "node:os"; | |
| 2 | import * as NodeOS from "node:os"; | |
| 3 | 3 | import fsPromises from "node:fs/promises"; |
| 4 | 4 | import type { Dirent } from "node:fs"; |
| 5 | 5 | |
| ... | ... | @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; |
| 12 | 12 | import * as Path from "effect/Path"; |
| 13 | 13 | |
| 14 | 14 | import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 15 | 16 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 16 | 17 | import { |
| 17 | 18 | insertRankedSearchResult, |
| ... | ... | @@ -65,10 +66,10 @@ function toPosixPath(input: string): string { |
| 65 | 66 | |
| 66 | 67 | function expandHomePath(input: string, path: Path.Path): string { |
| 67 | 68 | if (input === "~") { |
| 68 | return OS.homedir(); | |
| 69 | return NodeOS.homedir(); | |
| 69 | 70 | } |
| 70 | 71 | if (input.startsWith("~/") || input.startsWith("~\\")) { |
| 71 | return path.join(OS.homedir(), input.slice(2)); | |
| 72 | return path.join(NodeOS.homedir(), input.slice(2)); | |
| 72 | 73 | } |
| 73 | 74 | return input; |
| 74 | 75 | } |
| ... | ... | @@ -155,7 +156,8 @@ const resolveBrowseTarget = ( |
| 155 | 156 | pathService: Path.Path, |
| 156 | 157 | ): Effect.Effect<string, WorkspaceEntriesBrowseError> => |
| 157 | 158 | Effect.gen(function* () { |
| 158 | if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { | |
| 159 | const platform = yield* HostProcessPlatform; | |
| 160 | if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { | |
| 159 | 161 | return yield* new WorkspaceEntriesBrowseError({ |
| 160 | 162 | cwd: input.cwd, |
| 161 | 163 | partialPath: input.partialPath, |
apps/server/src/workspace/Layers/WorkspacePaths.ts+3-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as OS from "node:os"; | |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string { |
| 19 | 19 | |
| 20 | 20 | function expandHomePath(input: string, path: Path.Path): string { |
| 21 | 21 | if (input === "~") { |
| 22 | return OS.homedir(); | |
| 22 | return NodeOS.homedir(); | |
| 23 | 23 | } |
| 24 | 24 | if (input.startsWith("~/") || input.startsWith("~\\")) { |
| 25 | return path.join(OS.homedir(), input.slice(2)); | |
| 25 | return path.join(NodeOS.homedir(), input.slice(2)); | |
| 26 | 26 | } |
| 27 | 27 | return input; |
| 28 | 28 | } |
apps/server/src/ws.ts+1-1| ... | ... | @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 761 | 761 | keybindings: keybindingsConfig.keybindings, |
| 762 | 762 | issues: keybindingsConfig.issues, |
| 763 | 763 | providers, |
| 764 | availableEditors: ExternalLauncher.resolveAvailableEditors(), | |
| 764 | availableEditors: yield* externalLauncher.resolveAvailableEditors(), | |
| 765 | 765 | observability: { |
| 766 | 766 | logsDirectoryPath: config.logsDir, |
| 767 | 767 | localTracingEnabled: true, |
oxlint-plugin-t3code/index.ts+2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { definePlugin } from "@oxlint/plugins"; |
| 2 | 2 | |
| 3 | import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; | |
| 3 | 4 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 4 | 5 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; |
| 5 | 6 | |
| ... | ... | @@ -8,6 +9,7 @@ export default definePlugin({ |
| 8 | 9 | name: "t3code", |
| 9 | 10 | }, |
| 10 | 11 | rules: { |
| 12 | "no-global-process-runtime": noGlobalProcessRuntime, | |
| 11 | 13 | "no-inline-schema-compile": noInlineSchemaCompile, |
| 12 | 14 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, |
| 13 | 15 | }, |
oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { assert, describe } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | |
| 4 | ||
| 5 | const rule = createOxlintRuleHarness("t3code/no-global-process-runtime"); | |
| 6 | ||
| 7 | describe("t3code/no-global-process-runtime", () => { | |
| 8 | rule.valid( | |
| 9 | "allows injected host process references", | |
| 10 | ` | |
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | ||
| 14 | export const isWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); | |
| 15 | `, | |
| 16 | ); | |
| 17 | ||
| 18 | rule.valid( | |
| 19 | "allows unrelated process members", | |
| 20 | ` | |
| 21 | process.exitCode = 1; | |
| 22 | const nodeEnv = process.env.NODE_ENV; | |
| 23 | `, | |
| 24 | ); | |
| 25 | ||
| 26 | rule.valid( | |
| 27 | "allows unrelated node os imports", | |
| 28 | ` | |
| 29 | import { tmpdir } from "node:os"; | |
| 30 | ||
| 31 | export const tempDirectory = tmpdir(); | |
| 32 | `, | |
| 33 | ); | |
| 34 | ||
| 35 | rule.invalid( | |
| 36 | "reports direct platform reads", | |
| 37 | ` | |
| 38 | export const isWindows = process.platform === "win32"; | |
| 39 | `, | |
| 40 | (output) => { | |
| 41 | assert.match(output, /Use HostProcessPlatform/); | |
| 42 | }, | |
| 43 | ); | |
| 44 | ||
| 45 | rule.invalid( | |
| 46 | "reports direct architecture reads", | |
| 47 | ` | |
| 48 | export const isArm = process.arch === "arm64"; | |
| 49 | `, | |
| 50 | (output) => { | |
| 51 | assert.match(output, /Use HostProcessArchitecture/); | |
| 52 | }, | |
| 53 | ); | |
| 54 | ||
| 55 | rule.invalid( | |
| 56 | "reports globalThis process platform reads", | |
| 57 | ` | |
| 58 | export const terminalName = globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color"; | |
| 59 | `, | |
| 60 | ); | |
| 61 | ||
| 62 | rule.invalid( | |
| 63 | "reports node os namespace platform reads", | |
| 64 | ` | |
| 65 | import * as NodeOS from "node:os"; | |
| 66 | ||
| 67 | export const isWindows = NodeOS.platform() === "win32"; | |
| 68 | `, | |
| 69 | (output) => { | |
| 70 | assert.match(output, /Use HostProcessPlatform/); | |
| 71 | }, | |
| 72 | ); | |
| 73 | ||
| 74 | rule.invalid( | |
| 75 | "reports renamed node os architecture imports", | |
| 76 | ` | |
| 77 | import { arch as hostArch } from "node:os"; | |
| 78 | ||
| 79 | export const isArm = hostArch() === "arm64"; | |
| 80 | `, | |
| 81 | (output) => { | |
| 82 | assert.match(output, /Use HostProcessArchitecture/); | |
| 83 | }, | |
| 84 | ); | |
| 85 | ||
| 86 | rule.invalid( | |
| 87 | "reports default node os platform reads", | |
| 88 | ` | |
| 89 | import os from "node:os"; | |
| 90 | ||
| 91 | export const isWindows = os.platform() === "win32"; | |
| 92 | `, | |
| 93 | ); | |
| 94 | }); |
oxlint-plugin-t3code/rules/no-global-process-runtime.ts created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | import { defineRule } from "@oxlint/plugins"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | ||
| 4 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; | |
| 5 | ||
| 6 | const RUNTIME_PROPERTIES = new Set(["platform", "arch"]); | |
| 7 | const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts"; | |
| 8 | const NODE_OS_MODULES = new Set(["node:os", "os"]); | |
| 9 | ||
| 10 | const normalizePath = (path: string) => path.replaceAll("\\", "/"); | |
| 11 | ||
| 12 | const toRepoPath = (filename: string, cwd: string) => { | |
| 13 | const normalizedFilename = normalizePath(filename); | |
| 14 | const normalizedCwd = normalizePath(cwd).replace(/\/+$/u, ""); | |
| 15 | const prefix = `${normalizedCwd}/`; | |
| 16 | return normalizedFilename.startsWith(prefix) | |
| 17 | ? normalizedFilename.slice(prefix.length) | |
| 18 | : normalizedFilename; | |
| 19 | }; | |
| 20 | ||
| 21 | const isHostProcessReferenceFile = (filename: string, cwd: string) => | |
| 22 | toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE; | |
| 23 | ||
| 24 | const isGlobalProcessObject = (node: unknown): boolean => { | |
| 25 | const expression = unwrapExpression(node); | |
| 26 | if (isIdentifier(expression, "process")) return true; | |
| 27 | if (Option.isNone(expression) || expression.value.type !== "MemberExpression") return false; | |
| 28 | ||
| 29 | const object = unwrapExpression(expression.value.object); | |
| 30 | const property = getPropertyName(expression.value.property); | |
| 31 | return ( | |
| 32 | isIdentifier(object, "globalThis") && Option.isSome(property) && property.value === "process" | |
| 33 | ); | |
| 34 | }; | |
| 35 | ||
| 36 | const message = (property: string) => | |
| 37 | `Use HostProcess${property === "arch" ? "Architecture" : "Platform"} instead of process.${property}; inject the runtime reference in Effect code and provide it explicitly in tests.`; | |
| 38 | ||
| 39 | const getLiteralStringValue = (node: unknown): Option.Option<string> => { | |
| 40 | if (typeof node !== "object" || node === null) return Option.none(); | |
| 41 | if (!("type" in node) || node.type !== "Literal") return Option.none(); | |
| 42 | if (!("value" in node) || typeof node.value !== "string") return Option.none(); | |
| 43 | return Option.some(node.value); | |
| 44 | }; | |
| 45 | ||
| 46 | export default defineRule({ | |
| 47 | meta: { | |
| 48 | type: "problem", | |
| 49 | docs: { | |
| 50 | description: | |
| 51 | "Disallow direct host runtime platform/architecture reads outside the shared host process references.", | |
| 52 | }, | |
| 53 | }, | |
| 54 | createOnce(context) { | |
| 55 | const nodeOsNamespaces = new Set<string>(); | |
| 56 | const nodeOsRuntimeImports = new Map<string, string>(); | |
| 57 | ||
| 58 | const resetBindings = () => { | |
| 59 | nodeOsNamespaces.clear(); | |
| 60 | nodeOsRuntimeImports.clear(); | |
| 61 | }; | |
| 62 | ||
| 63 | const trackImportDeclaration = (node: unknown) => { | |
| 64 | if (typeof node !== "object" || node === null) return; | |
| 65 | if (!("source" in node)) return; | |
| 66 | ||
| 67 | const source = getLiteralStringValue(node.source); | |
| 68 | if (Option.isNone(source) || !NODE_OS_MODULES.has(source.value)) return; | |
| 69 | if (!("specifiers" in node) || !Array.isArray(node.specifiers)) return; | |
| 70 | ||
| 71 | for (const specifier of node.specifiers) { | |
| 72 | if (typeof specifier !== "object" || specifier === null) continue; | |
| 73 | if (!("local" in specifier)) continue; | |
| 74 | ||
| 75 | const local = unwrapExpression(specifier.local); | |
| 76 | if (Option.isNone(local) || local.value.type !== "Identifier") continue; | |
| 77 | const localName = local.value.name; | |
| 78 | ||
| 79 | if ( | |
| 80 | specifier.type === "ImportNamespaceSpecifier" || | |
| 81 | specifier.type === "ImportDefaultSpecifier" | |
| 82 | ) { | |
| 83 | nodeOsNamespaces.add(localName); | |
| 84 | continue; | |
| 85 | } | |
| 86 | ||
| 87 | if (specifier.type !== "ImportSpecifier" || !("imported" in specifier)) continue; | |
| 88 | ||
| 89 | const imported = getPropertyName(specifier.imported); | |
| 90 | if (Option.isSome(imported) && RUNTIME_PROPERTIES.has(imported.value)) { | |
| 91 | nodeOsRuntimeImports.set(localName, imported.value); | |
| 92 | } | |
| 93 | } | |
| 94 | }; | |
| 95 | ||
| 96 | const getNodeOsRuntimeCall = (callee: unknown): Option.Option<string> => { | |
| 97 | const expression = unwrapExpression(callee); | |
| 98 | if (Option.isNone(expression)) return Option.none(); | |
| 99 | ||
| 100 | if (expression.value.type === "Identifier") { | |
| 101 | const property = nodeOsRuntimeImports.get(expression.value.name); | |
| 102 | return property === undefined ? Option.none() : Option.some(property); | |
| 103 | } | |
| 104 | ||
| 105 | if (expression.value.type !== "MemberExpression") return Option.none(); | |
| 106 | ||
| 107 | const object = unwrapExpression(expression.value.object); | |
| 108 | if (Option.isNone(object) || object.value.type !== "Identifier") return Option.none(); | |
| 109 | if (!nodeOsNamespaces.has(object.value.name)) return Option.none(); | |
| 110 | ||
| 111 | return Option.filter(getPropertyName(expression.value.property), (property) => | |
| 112 | RUNTIME_PROPERTIES.has(property), | |
| 113 | ); | |
| 114 | }; | |
| 115 | ||
| 116 | return { | |
| 117 | before: resetBindings, | |
| 118 | ImportDeclaration: trackImportDeclaration, | |
| 119 | MemberExpression(node) { | |
| 120 | if (isHostProcessReferenceFile(context.filename, context.cwd)) return; | |
| 121 | ||
| 122 | const property = getPropertyName(node.property); | |
| 123 | if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return; | |
| 124 | if (!isGlobalProcessObject(node.object)) return; | |
| 125 | ||
| 126 | context.report({ | |
| 127 | node, | |
| 128 | message: message(property.value), | |
| 129 | }); | |
| 130 | }, | |
| 131 | CallExpression(node) { | |
| 132 | if (isHostProcessReferenceFile(context.filename, context.cwd)) return; | |
| 133 | ||
| 134 | const property = getNodeOsRuntimeCall(node.callee); | |
| 135 | if (Option.isNone(property)) return; | |
| 136 | ||
| 137 | context.report({ | |
| 138 | node, | |
| 139 | message: message(property.value), | |
| 140 | }); | |
| 141 | }, | |
| 142 | }; | |
| 143 | }, | |
| 144 | }); |
packages/effect-acp/test/examples/cursor-acp-client.example.ts+1-1| ... | ... | @@ -11,7 +11,7 @@ const program = Effect.gen(function* () { |
| 11 | 11 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 12 | 12 | const command = ChildProcess.make("cursor-agent", ["acp"], { |
| 13 | 13 | cwd: process.cwd(), |
| 14 | shell: process.platform === "win32", | |
| 14 | shell: false, | |
| 15 | 15 | }); |
| 16 | 16 | const handle = yield* spawner.spawn(command); |
| 17 | 17 | const acpLayer = AcpClient.layerChildProcess(handle, { |
packages/effect-codex-app-server/src/client.test.ts+6-41| ... | ... | @@ -17,13 +17,14 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 17 | 17 | const mockPeerArgs = (path: string) => [path]; |
| 18 | 18 | |
| 19 | 19 | it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { |
| 20 | const makeHandle = () => | |
| 20 | const makeHandle = (env?: Record<string, string>) => | |
| 21 | 21 | Effect.gen(function* () { |
| 22 | 22 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 23 | 23 | const path = yield* Path.Path; |
| 24 | 24 | const peerCwd = path.join(import.meta.dirname, ".."); |
| 25 | 25 | const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { |
| 26 | 26 | cwd: peerCwd, |
| 27 | ...(env ? { env: { ...process.env, ...env } } : {}), | |
| 27 | 28 | }); |
| 28 | 29 | return yield* spawner.spawn(command); |
| 29 | 30 | }); |
| ... | ... | @@ -122,49 +123,13 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { |
| 122 | 123 | ]); |
| 123 | 124 | }), |
| 124 | 125 | ); |
| 125 | ||
| 126 | it.effect("initializes a command-backed app-server client", () => | |
| 126 | it.effect("drains child stderr so large diagnostics cannot block protocol responses", () => | |
| 127 | 127 | Effect.gen(function* () { |
| 128 | const path = yield* Path.Path; | |
| 129 | const scope = yield* Scope.make(); | |
| 130 | const clientLayer = CodexClient.layerCommand({ | |
| 131 | command: "node", | |
| 132 | args: mockPeerArgs(yield* mockPeerPath), | |
| 133 | cwd: path.join(import.meta.dirname, ".."), | |
| 128 | const handle = yield* makeHandle({ | |
| 129 | CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), | |
| 134 | 130 | }); |
| 135 | const context = yield* Layer.buildWithScope(clientLayer, scope); | |
| 136 | ||
| 137 | const initialized = yield* Effect.gen(function* () { | |
| 138 | const client = yield* CodexClient.CodexAppServerClient; | |
| 139 | return yield* client.request("initialize", { | |
| 140 | clientInfo: { | |
| 141 | name: "effect-codex-app-server-test", | |
| 142 | title: "Effect Codex App Server Test", | |
| 143 | version: "0.0.0", | |
| 144 | }, | |
| 145 | capabilities: { | |
| 146 | experimentalApi: true, | |
| 147 | optOutNotificationMethods: null, | |
| 148 | }, | |
| 149 | }); | |
| 150 | }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); | |
| 151 | ||
| 152 | assert.equal(initialized.userAgent, "mock-codex-app-server"); | |
| 153 | }), | |
| 154 | ); | |
| 155 | ||
| 156 | it.effect("drains command stderr so large diagnostics cannot block protocol responses", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const path = yield* Path.Path; | |
| 159 | 131 | const scope = yield* Scope.make(); |
| 160 | const clientLayer = CodexClient.layerCommand({ | |
| 161 | command: "node", | |
| 162 | args: mockPeerArgs(yield* mockPeerPath), | |
| 163 | cwd: path.join(import.meta.dirname, ".."), | |
| 164 | env: { | |
| 165 | CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), | |
| 166 | }, | |
| 167 | }); | |
| 132 | const clientLayer = CodexClient.layerChildProcess(handle); | |
| 168 | 133 | const context = yield* Layer.buildWithScope(clientLayer, scope); |
| 169 | 134 | |
| 170 | 135 | const initialized = yield* Effect.gen(function* () { |
packages/effect-codex-app-server/src/client.ts+1-39| ... | ... | @@ -5,7 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | 5 | import * as Scope from "effect/Scope"; |
| 6 | 6 | import * as Stdio from "effect/Stdio"; |
| 7 | 7 | import * as Stream from "effect/Stream"; |
| 8 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 8 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 9 | 9 | |
| 10 | 10 | import * as CodexRpc from "./_generated/meta.gen.ts"; |
| 11 | 11 | import * as CodexError from "./errors.ts"; |
| ... | ... | @@ -18,8 +18,6 @@ import { |
| 18 | 18 | } from "./_internal/shared.ts"; |
| 19 | 19 | import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; |
| 20 | 20 | |
| 21 | const DEFAULT_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; | |
| 22 | ||
| 23 | 21 | export interface CodexAppServerClientOptions { |
| 24 | 22 | readonly logIncoming?: boolean; |
| 25 | 23 | readonly logOutgoing?: boolean; |
| ... | ... | @@ -263,39 +261,3 @@ const makeChildProcessClient = Effect.fn( |
| 263 | 261 | yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); |
| 264 | 262 | return yield* make(makeChildStdio(handle), options, makeTerminationError(handle)); |
| 265 | 263 | }); |
| 266 | ||
| 267 | export interface CodexAppServerCommandLayerOptions extends CodexAppServerClientOptions { | |
| 268 | readonly command: string; | |
| 269 | readonly args?: ReadonlyArray<string>; | |
| 270 | readonly cwd?: string; | |
| 271 | readonly env?: Record<string, string>; | |
| 272 | } | |
| 273 | ||
| 274 | export const layerCommand = ( | |
| 275 | options: CodexAppServerCommandLayerOptions, | |
| 276 | ): Layer.Layer< | |
| 277 | CodexAppServerClient, | |
| 278 | CodexError.CodexAppServerSpawnError, | |
| 279 | ChildProcessSpawner.ChildProcessSpawner | |
| 280 | > => | |
| 281 | Layer.effect( | |
| 282 | CodexAppServerClient, | |
| 283 | Effect.gen(function* () { | |
| 284 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 285 | const command = ChildProcess.make(options.command, [...(options.args ?? [])], { | |
| 286 | ...(options.cwd ? { cwd: options.cwd } : {}), | |
| 287 | ...(options.env ? { env: { ...process.env, ...options.env } } : {}), | |
| 288 | forceKillAfter: DEFAULT_APP_SERVER_FORCE_KILL_AFTER, | |
| 289 | shell: process.platform === "win32", | |
| 290 | }); | |
| 291 | return yield* spawner.spawn(command).pipe( | |
| 292 | Effect.mapError( | |
| 293 | (cause) => | |
| 294 | new CodexError.CodexAppServerSpawnError({ | |
| 295 | command: [options.command, ...(options.args ?? [])].join(" "), | |
| 296 | cause, | |
| 297 | }), | |
| 298 | ), | |
| 299 | ); | |
| 300 | }).pipe(Effect.flatMap((handle) => makeChildProcessClient(handle, options))), | |
| 301 | ); |
packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts+9-4| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Console from "effect/Console"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 3 | 4 | |
| 4 | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 5 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -7,10 +8,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 8 | import * as CodexClient from "../../src/client.ts"; |
| 8 | 9 | |
| 9 | 10 | const program = Effect.gen(function* () { |
| 10 | const codexLayer = CodexClient.layerCommand({ | |
| 11 | command: process.env.CODEX_BIN ?? "codex", | |
| 12 | args: ["app-server"], | |
| 13 | cwd: process.cwd(), | |
| 11 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 12 | const handle = yield* spawner.spawn( | |
| 13 | ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], { | |
| 14 | cwd: process.cwd(), | |
| 15 | shell: false, | |
| 16 | }), | |
| 17 | ); | |
| 18 | const codexLayer = CodexClient.layerChildProcess(handle, { | |
| 14 | 19 | logIncoming: true, |
| 15 | 20 | logOutgoing: true, |
| 16 | 21 | }); |
packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts+8-4| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | ||
| 1 | 3 | let nextServerRequestId = 10_000; |
| 2 | 4 | let pendingSkillsListRequestId: number | string | null = null; |
| 3 | 5 | let pendingUserInputRequestId: number | null = null; |
| ... | ... | @@ -34,14 +36,16 @@ const handleMethod = (message: Record<string, unknown>) => { |
| 34 | 36 | |
| 35 | 37 | switch (method) { |
| 36 | 38 | case "initialize": { |
| 39 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone mock peer process has no Effect runtime. | |
| 40 | const platform = NodeOS.platform(); | |
| 37 | 41 | const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0); |
| 38 | 42 | if (Number.isFinite(stderrBytes) && stderrBytes > 0) { |
| 39 | 43 | process.stderr.write("x".repeat(stderrBytes), () => { |
| 40 | 44 | respond(message.id as number | string, { |
| 41 | 45 | userAgent: "mock-codex-app-server", |
| 42 | 46 | codexHome: process.cwd(), |
| 43 | platformFamily: process.platform === "win32" ? "windows" : "unix", | |
| 44 | platformOs: process.platform === "darwin" ? "macos" : process.platform, | |
| 47 | platformFamily: platform === "win32" ? "windows" : "unix", | |
| 48 | platformOs: platform === "darwin" ? "macos" : platform, | |
| 45 | 49 | }); |
| 46 | 50 | }); |
| 47 | 51 | return; |
| ... | ... | @@ -49,8 +53,8 @@ const handleMethod = (message: Record<string, unknown>) => { |
| 49 | 53 | respond(message.id as number | string, { |
| 50 | 54 | userAgent: "mock-codex-app-server", |
| 51 | 55 | codexHome: process.cwd(), |
| 52 | platformFamily: process.platform === "win32" ? "windows" : "unix", | |
| 53 | platformOs: process.platform === "darwin" ? "macos" : process.platform, | |
| 56 | platformFamily: platform === "win32" ? "windows" : "unix", | |
| 57 | platformOs: platform === "darwin" ? "macos" : platform, | |
| 54 | 58 | }); |
| 55 | 59 | return; |
| 56 | 60 | } |
packages/shared/package.json+4| ... | ... | @@ -154,6 +154,10 @@ |
| 154 | 154 | "./preview": { |
| 155 | 155 | "types": "./src/preview.ts", |
| 156 | 156 | "import": "./src/preview.ts" |
| 157 | }, | |
| 158 | "./hostProcess": { | |
| 159 | "types": "./src/hostProcess.ts", | |
| 160 | "import": "./src/hostProcess.ts" | |
| 157 | 161 | } |
| 158 | 162 | }, |
| 159 | 163 | "scripts": { |
packages/shared/src/hostProcess.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | ||
| 5 | export const HostProcessPlatform = Context.Reference<NodeJS.Platform>( | |
| 6 | "@t3tools/shared/hostProcess/HostProcessPlatform", | |
| 7 | { | |
| 8 | defaultValue: () => process.platform, | |
| 9 | }, | |
| 10 | ); | |
| 11 | ||
| 12 | export const HostProcessArchitecture = Context.Reference<NodeJS.Architecture>( | |
| 13 | "@t3tools/shared/hostProcess/HostProcessArchitecture", | |
| 14 | { | |
| 15 | defaultValue: () => process.arch, | |
| 16 | }, | |
| 17 | ); | |
| 18 | ||
| 19 | export const HostProcessHostname = Context.Reference<string>( | |
| 20 | "@t3tools/shared/hostProcess/HostProcessHostname", | |
| 21 | { | |
| 22 | defaultValue: () => NodeOS.hostname(), | |
| 23 | }, | |
| 24 | ); | |
| 25 | ||
| 26 | export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>( | |
| 27 | "@t3tools/shared/hostProcess/HostProcessEnvironment", | |
| 28 | { | |
| 29 | defaultValue: () => process.env, | |
| 30 | }, | |
| 31 | ); | |
| 32 | ||
| 33 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); |
packages/shared/src/relayClient.test.ts+34-36| ... | ... | @@ -10,15 +10,20 @@ import * as Sink from "effect/Sink"; |
| 10 | 10 | import * as Stream from "effect/Stream"; |
| 11 | 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 12 | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; | |
| 13 | 14 | |
| 14 | 15 | import { |
| 15 | 16 | RelayClientInstallError, |
| 16 | 17 | CLOUDFLARED_VERSION, |
| 17 | 18 | makeCloudflaredRelayClient, |
| 18 | resolveManagedCloudflaredPath, | |
| 19 | 19 | } from "./relayClient.ts"; |
| 20 | 20 | |
| 21 | const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); | |
| 21 | const hostRuntimeLayer = (env: Record<string, string> = {}) => | |
| 22 | Layer.mergeAll( | |
| 23 | Layer.succeed(HostProcessPlatform, "linux"), | |
| 24 | Layer.succeed(HostProcessArchitecture, "x64"), | |
| 25 | ConfigProvider.layer(ConfigProvider.fromEnv({ env })), | |
| 26 | ); | |
| 22 | 27 | |
| 23 | 28 | function makeHandle(exitCode = 0) { |
| 24 | 29 | return ChildProcessSpawner.makeHandle({ |
| ... | ... | @@ -69,18 +74,18 @@ describe("RelayClient", () => { |
| 69 | 74 | yield* fileSystem.chmod(overridePath, 0o755); |
| 70 | 75 | const manager = yield* makeCloudflaredRelayClient({ |
| 71 | 76 | baseDir, |
| 72 | platform: "linux", | |
| 73 | arch: "x64", | |
| 74 | configProvider: () => | |
| 75 | ConfigProvider.fromEnv({ | |
| 76 | env: { | |
| 77 | PATH: "", | |
| 78 | T3CODE_CLOUDFLARED_PATH: overridePath, | |
| 79 | }, | |
| 80 | }), | |
| 81 | 77 | }); |
| 82 | 78 | |
| 83 | expect(yield* manager.resolve).toEqual({ | |
| 79 | expect( | |
| 80 | yield* manager.resolve.pipe( | |
| 81 | Effect.provideService( | |
| 82 | ConfigProvider.ConfigProvider, | |
| 83 | ConfigProvider.fromEnv({ | |
| 84 | env: { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath }, | |
| 85 | }), | |
| 86 | ), | |
| 87 | ), | |
| 88 | ).toEqual({ | |
| 84 | 89 | status: "available", |
| 85 | 90 | executablePath: overridePath, |
| 86 | 91 | source: "override", |
| ... | ... | @@ -93,6 +98,7 @@ describe("RelayClient", () => { |
| 93 | 98 | NodeServices.layer, |
| 94 | 99 | makeHttpClientLayer(new Uint8Array()), |
| 95 | 100 | makeSpawnerLayer([]), |
| 101 | hostRuntimeLayer(), | |
| 96 | 102 | ), |
| 97 | 103 | ), |
| 98 | 104 | ), |
| ... | ... | @@ -107,14 +113,11 @@ describe("RelayClient", () => { |
| 107 | 113 | const bytes = new TextEncoder().encode("test-cloudflared-binary"); |
| 108 | 114 | const manager = yield* makeCloudflaredRelayClient({ |
| 109 | 115 | baseDir, |
| 110 | platform: "linux", | |
| 111 | arch: "x64", | |
| 112 | 116 | releaseAsset: { |
| 113 | 117 | url: "https://example.test/cloudflared", |
| 114 | 118 | sha256: Encoding.encodeHex(sha256(bytes)), |
| 115 | 119 | archive: "binary", |
| 116 | 120 | }, |
| 117 | configProvider: emptyConfigProvider, | |
| 118 | 121 | }); |
| 119 | 122 | |
| 120 | 123 | const progress: Array<string> = []; |
| ... | ... | @@ -125,11 +128,7 @@ describe("RelayClient", () => { |
| 125 | 128 | } |
| 126 | 129 | }), |
| 127 | 130 | ); |
| 128 | const managedPath = resolveManagedCloudflaredPath({ | |
| 129 | baseDir, | |
| 130 | platform: "linux", | |
| 131 | arch: "x64", | |
| 132 | }); | |
| 131 | const managedPath = `${baseDir}/tools/cloudflared/${CLOUDFLARED_VERSION}/linux-x64/cloudflared`; | |
| 133 | 132 | expect(installed).toEqual({ |
| 134 | 133 | status: "available", |
| 135 | 134 | executablePath: managedPath, |
| ... | ... | @@ -156,6 +155,7 @@ describe("RelayClient", () => { |
| 156 | 155 | NodeServices.layer, |
| 157 | 156 | makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), |
| 158 | 157 | makeSpawnerLayer([]), |
| 158 | hostRuntimeLayer(), | |
| 159 | 159 | ), |
| 160 | 160 | ), |
| 161 | 161 | ), |
| ... | ... | @@ -169,14 +169,11 @@ describe("RelayClient", () => { |
| 169 | 169 | }); |
| 170 | 170 | const manager = yield* makeCloudflaredRelayClient({ |
| 171 | 171 | baseDir, |
| 172 | platform: "linux", | |
| 173 | arch: "x64", | |
| 174 | 172 | releaseAsset: { |
| 175 | 173 | url: "https://example.test/cloudflared", |
| 176 | 174 | sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), |
| 177 | 175 | archive: "binary", |
| 178 | 176 | }, |
| 179 | configProvider: emptyConfigProvider, | |
| 180 | 177 | }); |
| 181 | 178 | |
| 182 | 179 | const error = yield* manager.install.pipe(Effect.flip); |
| ... | ... | @@ -189,6 +186,7 @@ describe("RelayClient", () => { |
| 189 | 186 | NodeServices.layer, |
| 190 | 187 | makeHttpClientLayer(new TextEncoder().encode("tampered")), |
| 191 | 188 | makeSpawnerLayer([]), |
| 189 | hostRuntimeLayer(), | |
| 192 | 190 | ), |
| 193 | 191 | ), |
| 194 | 192 | ), |
| ... | ... | @@ -204,14 +202,11 @@ describe("RelayClient", () => { |
| 204 | 202 | }); |
| 205 | 203 | const manager = yield* makeCloudflaredRelayClient({ |
| 206 | 204 | baseDir, |
| 207 | platform: "linux", | |
| 208 | arch: "x64", | |
| 209 | 205 | releaseAsset: { |
| 210 | 206 | url: "https://example.test/cloudflared", |
| 211 | 207 | sha256: Encoding.encodeHex(sha256(bytes)), |
| 212 | 208 | archive: "binary", |
| 213 | 209 | }, |
| 214 | configProvider: emptyConfigProvider, | |
| 215 | 210 | }); |
| 216 | 211 | |
| 217 | 212 | const [first, second] = yield* Effect.all([manager.install, manager.install], { |
| ... | ... | @@ -222,25 +217,27 @@ describe("RelayClient", () => { |
| 222 | 217 | }).pipe( |
| 223 | 218 | Effect.scoped, |
| 224 | 219 | Effect.provide( |
| 225 | Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)), | |
| 220 | Layer.mergeAll( | |
| 221 | NodeServices.layer, | |
| 222 | makeHttpClientLayer(bytes), | |
| 223 | makeSpawnerLayer(commands), | |
| 224 | hostRuntimeLayer(), | |
| 225 | ), | |
| 226 | 226 | ), |
| 227 | 227 | ); |
| 228 | 228 | }); |
| 229 | 229 | |
| 230 | it.effect("observes PATH changes after the manager has been constructed", () => | |
| 231 | Effect.gen(function* () { | |
| 230 | it.effect("observes PATH changes after the manager has been constructed", () => { | |
| 231 | const env = { PATH: "" }; | |
| 232 | return Effect.gen(function* () { | |
| 232 | 233 | const fileSystem = yield* FileSystem.FileSystem; |
| 233 | 234 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 234 | 235 | prefix: "t3-cloudflared-test-", |
| 235 | 236 | }); |
| 236 | 237 | const binDir = `${baseDir}/bin`; |
| 237 | 238 | const executablePath = `${binDir}/cloudflared`; |
| 238 | let path = ""; | |
| 239 | 239 | const manager = yield* makeCloudflaredRelayClient({ |
| 240 | 240 | baseDir, |
| 241 | platform: "linux", | |
| 242 | arch: "x64", | |
| 243 | configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), | |
| 244 | 241 | }); |
| 245 | 242 | |
| 246 | 243 | expect(yield* manager.resolve).toEqual({ |
| ... | ... | @@ -251,7 +248,7 @@ describe("RelayClient", () => { |
| 251 | 248 | yield* fileSystem.makeDirectory(binDir); |
| 252 | 249 | yield* fileSystem.writeFileString(executablePath, "cloudflared"); |
| 253 | 250 | yield* fileSystem.chmod(executablePath, 0o755); |
| 254 | path = binDir; | |
| 251 | env.PATH = binDir; | |
| 255 | 252 | |
| 256 | 253 | expect(yield* manager.resolve).toEqual({ |
| 257 | 254 | status: "available", |
| ... | ... | @@ -266,8 +263,9 @@ describe("RelayClient", () => { |
| 266 | 263 | NodeServices.layer, |
| 267 | 264 | makeHttpClientLayer(new Uint8Array()), |
| 268 | 265 | makeSpawnerLayer([]), |
| 266 | hostRuntimeLayer(env), | |
| 269 | 267 | ), |
| 270 | 268 | ), |
| 271 | ), | |
| 272 | ); | |
| 269 | ); | |
| 270 | }); | |
| 273 | 271 | }); |
packages/shared/src/relayClient.ts+4-30| ... | ... | @@ -4,7 +4,6 @@ import type { |
| 4 | 4 | RelayClientInstallProgressStage, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import * as Config from "effect/Config"; |
| 7 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 8 | 7 | import * as Context from "effect/Context"; |
| 9 | 8 | import * as Crypto from "effect/Crypto"; |
| 10 | 9 | import * as Data from "effect/Data"; |
| ... | ... | @@ -18,6 +17,7 @@ import * as PlatformError from "effect/PlatformError"; |
| 18 | 17 | import * as Semaphore from "effect/Semaphore"; |
| 19 | 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 20 | 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; | |
| 21 | 21 | |
| 22 | 22 | export const CLOUDFLARED_VERSION = "2026.5.2"; |
| 23 | 23 | export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; |
| ... | ... | @@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({ |
| 120 | 120 | |
| 121 | 121 | export interface CloudflaredRelayClientOptions { |
| 122 | 122 | readonly baseDir: string; |
| 123 | readonly platform?: NodeJS.Platform; | |
| 124 | readonly arch?: string; | |
| 125 | 123 | readonly releaseAsset?: CloudflaredReleaseAsset; |
| 126 | readonly configProvider?: () => ConfigProvider.ConfigProvider; | |
| 127 | 124 | } |
| 128 | 125 | |
| 129 | 126 | export interface RelayClientShape { |
| ... | ... | @@ -142,22 +139,6 @@ function executableFileName(platform: NodeJS.Platform): string { |
| 142 | 139 | return platform === "win32" ? "cloudflared.exe" : "cloudflared"; |
| 143 | 140 | } |
| 144 | 141 | |
| 145 | export function resolveManagedCloudflaredPath(input: { | |
| 146 | readonly baseDir: string; | |
| 147 | readonly platform: NodeJS.Platform; | |
| 148 | readonly arch: string; | |
| 149 | }): string { | |
| 150 | const separator = input.platform === "win32" ? "\\" : "/"; | |
| 151 | return [ | |
| 152 | input.baseDir.replace(/[\\/]+$/u, ""), | |
| 153 | "tools", | |
| 154 | "cloudflared", | |
| 155 | CLOUDFLARED_VERSION, | |
| 156 | `${input.platform}-${input.arch}`, | |
| 157 | executableFileName(input.platform), | |
| 158 | ].join(separator); | |
| 159 | } | |
| 160 | ||
| 161 | 142 | function resolveReleaseAsset( |
| 162 | 143 | platform: NodeJS.Platform, |
| 163 | 144 | arch: string, |
| ... | ... | @@ -205,17 +186,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 205 | 186 | const path = yield* Path.Path; |
| 206 | 187 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 207 | 188 | const installSemaphore = yield* Semaphore.make(1); |
| 208 | const platform = options.platform ?? process.platform; | |
| 209 | const arch = options.arch ?? process.arch; | |
| 189 | const platform = yield* HostProcessPlatform; | |
| 190 | const arch = yield* HostProcessArchitecture; | |
| 210 | 191 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); |
| 211 | const loadCloudflaredConfig = Effect.suspend(() => | |
| 212 | CloudflaredConfig.pipe( | |
| 213 | Effect.provideService( | |
| 214 | ConfigProvider.ConfigProvider, | |
| 215 | options.configProvider?.() ?? ConfigProvider.fromEnv(), | |
| 216 | ), | |
| 217 | ), | |
| 218 | ).pipe(Effect.orDie); | |
| 192 | const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); | |
| 219 | 193 | const managedPath = path.join( |
| 220 | 194 | options.baseDir, |
| 221 | 195 | "tools", |
packages/shared/src/shell.test.ts+237-131| ... | ... | @@ -1,7 +1,13 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it as effectIt } from "@effect/vitest"; | |
| 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 1 | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 6 | |
| 3 | 7 | import { |
| 4 | 8 | extractPathFromShellOutput, |
| 9 | CommandAvailability, | |
| 10 | type CommandAvailabilityChecker, | |
| 5 | 11 | isCommandAvailable, |
| 6 | 12 | listLoginShellCandidates, |
| 7 | 13 | mergePathEntries, |
| ... | ... | @@ -12,9 +18,23 @@ import { |
| 12 | 18 | readPathFromLoginShell, |
| 13 | 19 | resolveCommandPath, |
| 14 | 20 | resolveKnownWindowsCliDirs, |
| 21 | resolveSpawnCommand, | |
| 15 | 22 | resolveWindowsEnvironment, |
| 23 | SpawnExecutableResolution, | |
| 24 | WindowsShellEnvironment, | |
| 25 | type WindowsShellEnvironmentReader, | |
| 16 | 26 | } from "./shell.ts"; |
| 17 | 27 | |
| 28 | const withWindowsEnvironmentMocks = <A, E, R>( | |
| 29 | effect: Effect.Effect<A, E, R>, | |
| 30 | readEnvironment: WindowsShellEnvironmentReader, | |
| 31 | commandAvailable: CommandAvailabilityChecker, | |
| 32 | ) => | |
| 33 | effect.pipe( | |
| 34 | Effect.provideService(WindowsShellEnvironment, readEnvironment), | |
| 35 | Effect.provideService(CommandAvailability, commandAvailable), | |
| 36 | ); | |
| 37 | ||
| 18 | 38 | describe("extractPathFromShellOutput", () => { |
| 19 | 39 | it("extracts the path between capture markers", () => { |
| 20 | 40 | expect( |
| ... | ... | @@ -322,149 +342,235 @@ describe("resolveKnownWindowsCliDirs", () => { |
| 322 | 342 | }); |
| 323 | 343 | }); |
| 324 | 344 | |
| 325 | describe("isCommandAvailable", () => { | |
| 326 | it("returns false when PATH is empty", () => { | |
| 327 | expect( | |
| 328 | isCommandAvailable("definitely-not-installed", { | |
| 329 | platform: "win32", | |
| 330 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 331 | }), | |
| 332 | ).toBe(false); | |
| 333 | }); | |
| 345 | effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { | |
| 346 | it.effect("returns false when PATH is empty", () => | |
| 347 | Effect.gen(function* () { | |
| 348 | expect( | |
| 349 | yield* isCommandAvailable("definitely-not-installed", { | |
| 350 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 351 | }).pipe(Effect.provideService(HostProcessPlatform, "win32")), | |
| 352 | ).toBe(false); | |
| 353 | }), | |
| 354 | ); | |
| 334 | 355 | }); |
| 335 | 356 | |
| 336 | describe("resolveCommandPath", () => { | |
| 337 | it("returns the first executable resolved from PATH", () => { | |
| 338 | expect( | |
| 339 | resolveCommandPath("definitely-not-installed", { | |
| 340 | platform: "win32", | |
| 357 | effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { | |
| 358 | it.effect("fails when PATH is empty", () => | |
| 359 | Effect.gen(function* () { | |
| 360 | const result = yield* resolveCommandPath("definitely-not-installed", { | |
| 341 | 361 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, |
| 342 | }), | |
| 343 | ).toBeNull(); | |
| 344 | }); | |
| 362 | }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result); | |
| 363 | ||
| 364 | expect(result._tag).toBe("Failure"); | |
| 365 | }), | |
| 366 | ); | |
| 345 | 367 | }); |
| 346 | 368 | |
| 347 | describe("resolveWindowsEnvironment", () => { | |
| 348 | it("returns the baseline no-profile PATH patch when node is already available", () => { | |
| 349 | const readEnvironment = vi.fn( | |
| 350 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 351 | options?.loadProfile | |
| 352 | ? { PATH: "C:\\Profile\\Bin" } | |
| 353 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, | |
| 354 | ); | |
| 355 | const commandAvailable = vi.fn(() => true); | |
| 369 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { | |
| 370 | it.effect("runs Windows executables directly without a shell", () => | |
| 371 | Effect.gen(function* () { | |
| 372 | const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], { | |
| 373 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 374 | }).pipe(Effect.provideService(HostProcessPlatform, "win32")); | |
| 375 | ||
| 376 | expect(command).toEqual({ | |
| 377 | command: "node.exe", | |
| 378 | args: ["script.js", "hello & goodbye"], | |
| 379 | shell: false, | |
| 380 | }); | |
| 381 | }), | |
| 382 | ); | |
| 383 | ||
| 384 | it.effect("escapes the executable and arguments for Windows command shims", () => | |
| 385 | Effect.gen(function* () { | |
| 386 | const command = yield* resolveSpawnCommand( | |
| 387 | "vp", | |
| 388 | ["run", "value & calc", "%PATH%", 'quote"value'], | |
| 389 | { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }, | |
| 390 | ).pipe( | |
| 391 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 392 | Effect.provideService( | |
| 393 | SpawnExecutableResolution, | |
| 394 | () => "C:\\Program Files\\npm & tools\\vp.cmd", | |
| 395 | ), | |
| 396 | ); | |
| 397 | ||
| 398 | expect(command.shell).toBe(true); | |
| 399 | expect(command.command).not.toContain(" & "); | |
| 400 | expect(command.command).toContain("^&"); | |
| 401 | expect(command.args).toEqual([ | |
| 402 | '^"run^"', | |
| 403 | '^"value^ ^&^ calc^"', | |
| 404 | '^"^%PATH^%^"', | |
| 405 | '^"quote\\^"value^"', | |
| 406 | ]); | |
| 407 | }), | |
| 408 | ); | |
| 409 | ||
| 410 | it.effect("resolves against the effective environment when extending host env", () => | |
| 411 | Effect.gen(function* () { | |
| 412 | let resolvedEnvironment: NodeJS.ProcessEnv | undefined; | |
| 413 | yield* resolveSpawnCommand("codex", ["app-server"], { | |
| 414 | env: { CODEX_HOME: "C:\\Users\\tester\\.codex" }, | |
| 415 | extendEnv: true, | |
| 416 | }).pipe( | |
| 417 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 418 | Effect.provideService(HostProcessEnvironment, { | |
| 419 | PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", | |
| 420 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 421 | }), | |
| 422 | Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => { | |
| 423 | resolvedEnvironment = env; | |
| 424 | return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"; | |
| 425 | }), | |
| 426 | ); | |
| 427 | ||
| 428 | expect(resolvedEnvironment).toEqual({ | |
| 429 | PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", | |
| 430 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 431 | CODEX_HOME: "C:\\Users\\tester\\.codex", | |
| 432 | }); | |
| 433 | }), | |
| 434 | ); | |
| 435 | ||
| 436 | it.effect("does not fall back to a shell for unresolved Windows commands", () => | |
| 437 | Effect.gen(function* () { | |
| 438 | const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { | |
| 439 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 440 | }).pipe(Effect.provideService(HostProcessPlatform, "win32")); | |
| 441 | ||
| 442 | expect(command).toEqual({ | |
| 443 | command: "missing & calc", | |
| 444 | args: ["unsafe & value"], | |
| 445 | shell: false, | |
| 446 | }); | |
| 447 | }), | |
| 448 | ); | |
| 449 | }); | |
| 356 | 450 | |
| 357 | expect( | |
| 358 | resolveWindowsEnvironment( | |
| 359 | { | |
| 360 | PATH: "C:\\Windows\\System32", | |
| 361 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 362 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 363 | USERPROFILE: "C:\\Users\\testuser", | |
| 364 | }, | |
| 365 | { | |
| 451 | effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { | |
| 452 | it.effect("returns the baseline no-profile PATH patch when node is already available", () => | |
| 453 | Effect.gen(function* () { | |
| 454 | const readEnvironment = vi.fn( | |
| 455 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 456 | options?.loadProfile | |
| 457 | ? { PATH: "C:\\Profile\\Bin" } | |
| 458 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, | |
| 459 | ); | |
| 460 | const commandAvailable = vi.fn(() => Effect.succeed(true)); | |
| 461 | ||
| 462 | expect( | |
| 463 | yield* withWindowsEnvironmentMocks( | |
| 464 | resolveWindowsEnvironment({ | |
| 465 | PATH: "C:\\Windows\\System32", | |
| 466 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 467 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 468 | USERPROFILE: "C:\\Users\\testuser", | |
| 469 | }), | |
| 366 | 470 | readEnvironment, |
| 367 | 471 | commandAvailable, |
| 368 | }, | |
| 369 | ), | |
| 370 | ).toEqual({ | |
| 371 | PATH: [ | |
| 372 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 373 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 374 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 375 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 376 | "C:\\Users\\testuser\\.bun\\bin", | |
| 377 | "C:\\Users\\testuser\\scoop\\shims", | |
| 378 | "C:\\Shell\\Bin", | |
| 379 | "C:\\Windows\\System32", | |
| 380 | ].join(";"), | |
| 381 | }); | |
| 382 | expect(readEnvironment).toHaveBeenCalledTimes(1); | |
| 383 | expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); | |
| 384 | expect(commandAvailable).toHaveBeenCalledWith( | |
| 385 | "node", | |
| 386 | expect.objectContaining({ | |
| 387 | platform: "win32", | |
| 388 | }), | |
| 389 | ); | |
| 390 | }); | |
| 391 | ||
| 392 | it("loads the PowerShell profile when baseline env cannot resolve node", () => { | |
| 393 | const readEnvironment = vi.fn( | |
| 394 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 395 | options?.loadProfile | |
| 396 | ? { | |
| 397 | PATH: "C:\\Profile\\Node;C:\\Windows\\System32", | |
| 398 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 399 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 400 | } | |
| 401 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, | |
| 402 | ); | |
| 403 | const commandAvailable = vi.fn(() => false); | |
| 404 | ||
| 405 | expect( | |
| 406 | resolveWindowsEnvironment( | |
| 407 | { | |
| 408 | PATH: "C:\\Windows\\System32", | |
| 409 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 410 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 411 | USERPROFILE: "C:\\Users\\testuser", | |
| 412 | }, | |
| 413 | { | |
| 472 | ), | |
| 473 | ).toEqual({ | |
| 474 | PATH: [ | |
| 475 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 476 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 477 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 478 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 479 | "C:\\Users\\testuser\\.bun\\bin", | |
| 480 | "C:\\Users\\testuser\\scoop\\shims", | |
| 481 | "C:\\Shell\\Bin", | |
| 482 | "C:\\Windows\\System32", | |
| 483 | ].join(";"), | |
| 484 | }); | |
| 485 | expect(readEnvironment).toHaveBeenCalledTimes(1); | |
| 486 | expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); | |
| 487 | expect(commandAvailable).toHaveBeenCalledWith( | |
| 488 | "node", | |
| 489 | expect.objectContaining({ env: expect.any(Object) }), | |
| 490 | ); | |
| 491 | }), | |
| 492 | ); | |
| 493 | ||
| 494 | it.effect("loads the PowerShell profile when baseline env cannot resolve node", () => | |
| 495 | Effect.gen(function* () { | |
| 496 | const readEnvironment = vi.fn( | |
| 497 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 498 | options?.loadProfile | |
| 499 | ? { | |
| 500 | PATH: "C:\\Profile\\Node;C:\\Windows\\System32", | |
| 501 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 502 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 503 | } | |
| 504 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, | |
| 505 | ); | |
| 506 | const commandAvailable = vi.fn(() => Effect.succeed(false)); | |
| 507 | ||
| 508 | expect( | |
| 509 | yield* withWindowsEnvironmentMocks( | |
| 510 | resolveWindowsEnvironment({ | |
| 511 | PATH: "C:\\Windows\\System32", | |
| 512 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 513 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 514 | USERPROFILE: "C:\\Users\\testuser", | |
| 515 | }), | |
| 414 | 516 | readEnvironment, |
| 415 | 517 | commandAvailable, |
| 416 | }, | |
| 417 | ), | |
| 418 | ).toEqual({ | |
| 419 | PATH: [ | |
| 420 | "C:\\Profile\\Node", | |
| 421 | "C:\\Windows\\System32", | |
| 422 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 423 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 424 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 425 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 426 | "C:\\Users\\testuser\\.bun\\bin", | |
| 427 | "C:\\Users\\testuser\\scoop\\shims", | |
| 428 | "C:\\Shell\\Bin", | |
| 429 | ].join(";"), | |
| 430 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 431 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 432 | }); | |
| 433 | expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); | |
| 434 | expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { | |
| 435 | loadProfile: true, | |
| 436 | }); | |
| 437 | expect(commandAvailable).toHaveBeenCalledTimes(1); | |
| 438 | }); | |
| 439 | ||
| 440 | it("keeps the baseline env when profiled probe still does not resolve node", () => { | |
| 441 | const readEnvironment = vi.fn( | |
| 442 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 443 | options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, | |
| 444 | ); | |
| 445 | const commandAvailable = vi.fn(() => false); | |
| 446 | ||
| 447 | expect( | |
| 448 | resolveWindowsEnvironment( | |
| 518 | ), | |
| 519 | ).toEqual({ | |
| 520 | PATH: [ | |
| 521 | "C:\\Profile\\Node", | |
| 522 | "C:\\Windows\\System32", | |
| 523 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 524 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 525 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 526 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 527 | "C:\\Users\\testuser\\.bun\\bin", | |
| 528 | "C:\\Users\\testuser\\scoop\\shims", | |
| 529 | "C:\\Shell\\Bin", | |
| 530 | ].join(";"), | |
| 531 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 532 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 533 | }); | |
| 534 | expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); | |
| 535 | expect(readEnvironment).toHaveBeenNthCalledWith( | |
| 536 | 2, | |
| 537 | ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], | |
| 449 | 538 | { |
| 450 | PATH: "C:\\Windows\\System32", | |
| 451 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 452 | USERPROFILE: "C:\\Users\\testuser", | |
| 539 | loadProfile: true, | |
| 453 | 540 | }, |
| 454 | { | |
| 541 | ); | |
| 542 | expect(commandAvailable).toHaveBeenCalledTimes(1); | |
| 543 | }), | |
| 544 | ); | |
| 545 | ||
| 546 | it.effect("keeps the baseline env when profiled probe still does not resolve node", () => | |
| 547 | Effect.gen(function* () { | |
| 548 | const readEnvironment = vi.fn( | |
| 549 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 550 | options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, | |
| 551 | ); | |
| 552 | const commandAvailable = vi.fn(() => Effect.succeed(false)); | |
| 553 | ||
| 554 | expect( | |
| 555 | yield* withWindowsEnvironmentMocks( | |
| 556 | resolveWindowsEnvironment({ | |
| 557 | PATH: "C:\\Windows\\System32", | |
| 558 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 559 | USERPROFILE: "C:\\Users\\testuser", | |
| 560 | }), | |
| 455 | 561 | readEnvironment, |
| 456 | 562 | commandAvailable, |
| 457 | }, | |
| 458 | ), | |
| 459 | ).toEqual({ | |
| 460 | PATH: [ | |
| 461 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 462 | "C:\\Users\\testuser\\.bun\\bin", | |
| 463 | "C:\\Users\\testuser\\scoop\\shims", | |
| 464 | "C:\\Windows\\System32", | |
| 465 | ].join(";"), | |
| 466 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 467 | }); | |
| 468 | expect(commandAvailable).toHaveBeenCalledTimes(1); | |
| 469 | }); | |
| 563 | ), | |
| 564 | ).toEqual({ | |
| 565 | PATH: [ | |
| 566 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 567 | "C:\\Users\\testuser\\.bun\\bin", | |
| 568 | "C:\\Users\\testuser\\scoop\\shims", | |
| 569 | "C:\\Windows\\System32", | |
| 570 | ].join(";"), | |
| 571 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 572 | }); | |
| 573 | expect(commandAvailable).toHaveBeenCalledTimes(1); | |
| 574 | }), | |
| 575 | ); | |
| 470 | 576 | }); |
packages/shared/src/shell.ts+224-46| ... | ... | @@ -1,8 +1,15 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | 2 | import * as NodeOS from "node:os"; |
| 3 | import * as NodePath from "node:path"; | |
| 3 | 4 | import { execFileSync } from "node:child_process"; |
| 4 | import { accessSync, constants, statSync } from "node:fs"; | |
| 5 | import { extname, join } from "node:path"; | |
| 5 | import { accessSync, constants as fileSystemConstants, statSync } from "node:fs"; | |
| 6 | import * as Data from "effect/Data"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | ||
| 11 | import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; | |
| 12 | import * as Context from "effect/Context"; | |
| 6 | 13 | |
| 7 | 14 | const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; |
| 8 | 15 | const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; |
| ... | ... | @@ -17,11 +24,122 @@ type ExecFileSyncLike = ( |
| 17 | 24 | options: { encoding: "utf8"; timeout: number }, |
| 18 | 25 | ) => string; |
| 19 | 26 | |
| 27 | function canExecuteFile(filePath: string): boolean { | |
| 28 | try { | |
| 29 | accessSync(filePath, fileSystemConstants.X_OK); | |
| 30 | return true; | |
| 31 | } catch { | |
| 32 | return false; | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 20 | 36 | export interface CommandAvailabilityOptions { |
| 21 | readonly platform?: NodeJS.Platform; | |
| 22 | 37 | readonly env?: NodeJS.ProcessEnv; |
| 38 | readonly extendEnv?: boolean; | |
| 39 | } | |
| 40 | ||
| 41 | export type CommandAvailabilityChecker = ( | |
| 42 | command: string, | |
| 43 | options?: CommandAvailabilityOptions, | |
| 44 | ) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>; | |
| 45 | ||
| 46 | export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ | |
| 47 | readonly command: string; | |
| 48 | readonly reason: "not-found"; | |
| 49 | }> {} | |
| 50 | ||
| 51 | const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; | |
| 52 | ||
| 53 | /** | |
| 54 | * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })` | |
| 55 | * on Windows). Node joins the command and arguments with spaces and hands the | |
| 56 | * resulting string to `cmd.exe` without any quoting, so every dynamic argument | |
| 57 | * must be escaped to survive both cmd.exe parsing and the target program's | |
| 58 | * `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping. | |
| 59 | */ | |
| 60 | function escapeWindowsShellArg(arg: string): string { | |
| 61 | // Double up backslashes that precede a double quote, then escape the quote | |
| 62 | // itself so it survives CommandLineToArgvW. | |
| 63 | let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); | |
| 64 | // Double up trailing backslashes so the closing quote is not escaped away. | |
| 65 | escaped = escaped.replace(/(\\*)$/, "$1$1"); | |
| 66 | // Quote the whole argument so embedded whitespace is preserved. | |
| 67 | escaped = `"${escaped}"`; | |
| 68 | // Escape cmd.exe metacharacters so cmd passes them through verbatim. | |
| 69 | return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1"); | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Escapes arguments for shell-mode spawns: applies {@link escapeWindowsShellArg} | |
| 74 | * when the platform is `win32` (where `shell: true` routes through `cmd.exe`) | |
| 75 | * and returns the arguments untouched everywhere else. | |
| 76 | */ | |
| 77 | function sanitizeShellModeArgsForPlatform( | |
| 78 | args: ReadonlyArray<string>, | |
| 79 | platform: NodeJS.Platform, | |
| 80 | ): Array<string> { | |
| 81 | return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args]; | |
| 82 | } | |
| 83 | ||
| 84 | export interface ResolvedSpawnCommand { | |
| 85 | readonly command: string; | |
| 86 | readonly args: ReadonlyArray<string>; | |
| 87 | readonly shell: boolean; | |
| 88 | } | |
| 89 | ||
| 90 | export type SpawnExecutableResolver = ( | |
| 91 | command: string, | |
| 92 | platform: NodeJS.Platform, | |
| 93 | env: NodeJS.ProcessEnv, | |
| 94 | ) => string | undefined; | |
| 95 | ||
| 96 | function resolveSpawnExecutableWithNode( | |
| 97 | command: string, | |
| 98 | platform: NodeJS.Platform, | |
| 99 | env: NodeJS.ProcessEnv, | |
| 100 | ): string | undefined { | |
| 101 | const path = platform === "win32" ? NodePath.win32 : NodePath.posix; | |
| 102 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; | |
| 103 | const candidates = resolveCommandCandidates( | |
| 104 | command, | |
| 105 | platform, | |
| 106 | windowsPathExtensions, | |
| 107 | path.extname, | |
| 108 | ); | |
| 109 | const isExecutable = (candidate: string) => { | |
| 110 | try { | |
| 111 | if (!statSync(candidate).isFile()) return false; | |
| 112 | if (platform === "win32") { | |
| 113 | return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); | |
| 114 | } | |
| 115 | return canExecuteFile(candidate); | |
| 116 | } catch { | |
| 117 | return false; | |
| 118 | } | |
| 119 | }; | |
| 120 | ||
| 121 | if (command.includes("/") || command.includes("\\")) { | |
| 122 | return candidates.find(isExecutable); | |
| 123 | } | |
| 124 | ||
| 125 | for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { | |
| 126 | const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); | |
| 127 | if (normalizedPathEntry.length === 0) continue; | |
| 128 | for (const candidate of candidates) { | |
| 129 | const candidatePath = path.join(normalizedPathEntry, candidate); | |
| 130 | if (isExecutable(candidatePath)) return candidatePath; | |
| 131 | } | |
| 132 | } | |
| 133 | return undefined; | |
| 23 | 134 | } |
| 24 | 135 | |
| 136 | export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>( | |
| 137 | "@t3tools/shared/shell/SpawnExecutableResolution", | |
| 138 | { | |
| 139 | defaultValue: () => resolveSpawnExecutableWithNode, | |
| 140 | }, | |
| 141 | ); | |
| 142 | ||
| 25 | 143 | export interface WindowsEnvironmentProbeOptions { |
| 26 | 144 | readonly loadProfile?: boolean; |
| 27 | 145 | } |
| ... | ... | @@ -214,6 +332,20 @@ export type WindowsShellEnvironmentReader = ( |
| 214 | 332 | options?: WindowsEnvironmentProbeOptions, |
| 215 | 333 | ) => Partial<Record<string, string>>; |
| 216 | 334 | |
| 335 | export const WindowsShellEnvironment = Context.Reference<WindowsShellEnvironmentReader>( | |
| 336 | "@t3tools/shared/shell/WindowsShellEnvironment", | |
| 337 | { | |
| 338 | defaultValue: () => readEnvironmentFromWindowsShell, | |
| 339 | }, | |
| 340 | ); | |
| 341 | ||
| 342 | export const CommandAvailability = Context.Reference<CommandAvailabilityChecker>( | |
| 343 | "@t3tools/shared/shell/CommandAvailability", | |
| 344 | { | |
| 345 | defaultValue: () => isCommandAvailable, | |
| 346 | }, | |
| 347 | ); | |
| 348 | ||
| 217 | 349 | export function readEnvironmentFromWindowsShell( |
| 218 | 350 | names: ReadonlyArray<string>, |
| 219 | 351 | execFile?: ExecFileSyncLike, |
| ... | ... | @@ -334,6 +466,7 @@ function resolveCommandCandidates( |
| 334 | 466 | command: string, |
| 335 | 467 | platform: NodeJS.Platform, |
| 336 | 468 | windowsPathExtensions: ReadonlyArray<string>, |
| 469 | extname: (path: string) => string, | |
| 337 | 470 | ): ReadonlyArray<string> { |
| 338 | 471 | if (platform !== "win32") return [command]; |
| 339 | 472 | const extension = extname(command); |
| ... | ... | @@ -358,46 +491,53 @@ function resolveCommandCandidates( |
| 358 | 491 | return Array.from(new Set(candidates)); |
| 359 | 492 | } |
| 360 | 493 | |
| 361 | function isExecutableFile( | |
| 494 | const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( | |
| 362 | 495 | filePath: string, |
| 363 | 496 | platform: NodeJS.Platform, |
| 364 | 497 | windowsPathExtensions: ReadonlyArray<string>, |
| 365 | ): boolean { | |
| 366 | try { | |
| 367 | const stat = statSync(filePath); | |
| 368 | if (!stat.isFile()) return false; | |
| 369 | if (platform === "win32") { | |
| 370 | const extension = extname(filePath); | |
| 371 | if (extension.length === 0) return false; | |
| 372 | return windowsPathExtensions.includes(extension.toUpperCase()); | |
| 373 | } | |
| 374 | accessSync(filePath, constants.X_OK); | |
| 375 | return true; | |
| 376 | } catch { | |
| 377 | return false; | |
| 498 | ): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> { | |
| 499 | const fileSystem = yield* FileSystem.FileSystem; | |
| 500 | const path = yield* Path.Path; | |
| 501 | const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); | |
| 502 | if (stat === null || stat.type !== "File") return false; | |
| 503 | ||
| 504 | if (platform === "win32") { | |
| 505 | const extension = path.extname(filePath); | |
| 506 | if (extension.length === 0) return false; | |
| 507 | return windowsPathExtensions.includes(extension.toUpperCase()); | |
| 378 | 508 | } |
| 379 | } | |
| 380 | 509 | |
| 381 | export function resolveCommandPath( | |
| 510 | return canExecuteFile(filePath); | |
| 511 | }); | |
| 512 | ||
| 513 | const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( | |
| 382 | 514 | command: string, |
| 383 | options: CommandAvailabilityOptions = {}, | |
| 384 | ): string | null { | |
| 385 | const platform = options.platform ?? process.platform; | |
| 515 | options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, | |
| 516 | ): Effect.fn.Return<string, CommandResolutionError, FileSystem.FileSystem | Path.Path> { | |
| 517 | const path = yield* Path.Path; | |
| 518 | const platform = options.platform; | |
| 386 | 519 | const env = options.env ?? process.env; |
| 387 | 520 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; |
| 388 | const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); | |
| 521 | const commandCandidates = resolveCommandCandidates( | |
| 522 | command, | |
| 523 | platform, | |
| 524 | windowsPathExtensions, | |
| 525 | path.extname, | |
| 526 | ); | |
| 389 | 527 | |
| 390 | 528 | if (command.includes("/") || command.includes("\\")) { |
| 391 | 529 | for (const candidate of commandCandidates) { |
| 392 | if (isExecutableFile(candidate, platform, windowsPathExtensions)) { | |
| 530 | if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { | |
| 393 | 531 | return candidate; |
| 394 | 532 | } |
| 395 | 533 | } |
| 396 | return null; | |
| 534 | return yield* new CommandResolutionError({ command, reason: "not-found" }); | |
| 397 | 535 | } |
| 398 | 536 | |
| 399 | 537 | const pathValue = resolvePathEnvironmentVariable(env); |
| 400 | if (pathValue.length === 0) return null; | |
| 538 | if (pathValue.length === 0) { | |
| 539 | return yield* new CommandResolutionError({ command, reason: "not-found" }); | |
| 540 | } | |
| 401 | 541 | const pathEntries: string[] = []; |
| 402 | 542 | for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { |
| 403 | 543 | const pathEntry = stripWrappingQuotes(entry.trim()); |
| ... | ... | @@ -408,21 +548,65 @@ export function resolveCommandPath( |
| 408 | 548 | |
| 409 | 549 | for (const pathEntry of pathEntries) { |
| 410 | 550 | for (const candidate of commandCandidates) { |
| 411 | const candidatePath = join(pathEntry, candidate); | |
| 412 | if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { | |
| 551 | const candidatePath = path.join(pathEntry, candidate); | |
| 552 | if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { | |
| 413 | 553 | return candidatePath; |
| 414 | 554 | } |
| 415 | 555 | } |
| 416 | 556 | } |
| 417 | return null; | |
| 418 | } | |
| 557 | return yield* new CommandResolutionError({ command, reason: "not-found" }); | |
| 558 | }); | |
| 419 | 559 | |
| 420 | export function isCommandAvailable( | |
| 560 | export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( | |
| 421 | 561 | command: string, |
| 422 | 562 | options: CommandAvailabilityOptions = {}, |
| 423 | ): boolean { | |
| 424 | return resolveCommandPath(command, options) !== null; | |
| 425 | } | |
| 563 | ) { | |
| 564 | return yield* resolveCommandPathForPlatform(command, { | |
| 565 | env: options.env ?? (yield* HostProcessEnvironment), | |
| 566 | platform: yield* HostProcessPlatform, | |
| 567 | }); | |
| 568 | }); | |
| 569 | ||
| 570 | export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(function* ( | |
| 571 | command: string, | |
| 572 | args: ReadonlyArray<string>, | |
| 573 | options: CommandAvailabilityOptions = {}, | |
| 574 | ): Effect.fn.Return<ResolvedSpawnCommand> { | |
| 575 | const platform = yield* HostProcessPlatform; | |
| 576 | if (platform !== "win32") { | |
| 577 | return { command, args: [...args], shell: false }; | |
| 578 | } | |
| 579 | ||
| 580 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 581 | const env = | |
| 582 | options.env === undefined | |
| 583 | ? hostEnvironment | |
| 584 | : options.extendEnv | |
| 585 | ? { ...hostEnvironment, ...options.env } | |
| 586 | : options.env; | |
| 587 | const resolveExecutable = yield* SpawnExecutableResolution; | |
| 588 | const resolvedCommand = resolveExecutable(command, platform, env) ?? command; | |
| 589 | const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); | |
| 590 | if (extension !== ".cmd" && extension !== ".bat") { | |
| 591 | return { command: resolvedCommand, args: [...args], shell: false }; | |
| 592 | } | |
| 593 | ||
| 594 | return { | |
| 595 | command: escapeWindowsShellArg(resolvedCommand), | |
| 596 | args: sanitizeShellModeArgsForPlatform(args, platform), | |
| 597 | shell: true, | |
| 598 | }; | |
| 599 | }); | |
| 600 | ||
| 601 | export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( | |
| 602 | command: string, | |
| 603 | options: CommandAvailabilityOptions = {}, | |
| 604 | ) { | |
| 605 | return yield* resolveCommandPath(command, options).pipe( | |
| 606 | Effect.as(true), | |
| 607 | Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), | |
| 608 | ); | |
| 609 | }); | |
| 426 | 610 | |
| 427 | 611 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |
| 428 | 612 | const appData = env.APPDATA?.trim(); |
| ... | ... | @@ -437,11 +621,6 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra |
| 437 | 621 | ]; |
| 438 | 622 | } |
| 439 | 623 | |
| 440 | export interface WindowsEnvironmentResolverOptions { | |
| 441 | readonly readEnvironment?: WindowsShellEnvironmentReader; | |
| 442 | readonly commandAvailable?: typeof isCommandAvailable; | |
| 443 | } | |
| 444 | ||
| 445 | 624 | function readWindowsEnvironmentSafely( |
| 446 | 625 | readEnvironment: WindowsShellEnvironmentReader, |
| 447 | 626 | names: ReadonlyArray<string>, |
| ... | ... | @@ -467,12 +646,11 @@ function mergeWindowsEnv( |
| 467 | 646 | return nextEnv; |
| 468 | 647 | } |
| 469 | 648 | |
| 470 | export function resolveWindowsEnvironment( | |
| 649 | export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( | |
| 471 | 650 | env: NodeJS.ProcessEnv, |
| 472 | options: WindowsEnvironmentResolverOptions = {}, | |
| 473 | ): Partial<NodeJS.ProcessEnv> { | |
| 474 | const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; | |
| 475 | const commandAvailable = options.commandAvailable ?? isCommandAvailable; | |
| 651 | ): Effect.fn.Return<Partial<NodeJS.ProcessEnv>, never, FileSystem.FileSystem | Path.Path> { | |
| 652 | const readEnvironment = yield* WindowsShellEnvironment; | |
| 653 | const commandAvailable = yield* CommandAvailability; | |
| 476 | 654 | const inheritedPath = readEnvPath(env); |
| 477 | 655 | const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { |
| 478 | 656 | loadProfile: false, |
| ... | ... | @@ -483,7 +661,7 @@ export function resolveWindowsEnvironment( |
| 483 | 661 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; |
| 484 | 662 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); |
| 485 | 663 | |
| 486 | if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { | |
| 664 | if (yield* commandAvailable("node", { env: baselineEnv })) { | |
| 487 | 665 | return baselinePatch; |
| 488 | 666 | } |
| 489 | 667 | |
| ... | ... | @@ -503,4 +681,4 @@ export function resolveWindowsEnvironment( |
| 503 | 681 | return Object.keys(profiledPatch).length > 0 |
| 504 | 682 | ? { ...baselinePatch, ...profiledPatch } |
| 505 | 683 | : baselinePatch; |
| 506 | } | |
| 684 | }); |
packages/ssh/src/auth.test.ts+11-4| ... | ... | @@ -3,7 +3,9 @@ import { assert, describe, it } from "@effect/vitest"; |
| 3 | 3 | |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; | |
| 6 | 7 | import * as Path from "effect/Path"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | 9 | |
| 8 | 10 | import { |
| 9 | 11 | buildSshAskpassHelperDescriptor, |
| ... | ... | @@ -37,7 +39,6 @@ describe("ssh auth", () => { |
| 37 | 39 | authSecret: "super-secret", |
| 38 | 40 | interactiveAuth: true, |
| 39 | 41 | askpassDirectory: directory, |
| 40 | platform: "linux", | |
| 41 | 42 | baseEnv: {}, |
| 42 | 43 | }); |
| 43 | 44 | |
| ... | ... | @@ -48,15 +49,21 @@ describe("ssh auth", () => { |
| 48 | 49 | assert.equal(env.DISPLAY, "t3code"); |
| 49 | 50 | assert.equal(yield* fs.exists(askpassPath), true); |
| 50 | 51 | assert.include(yield* fs.readFileString(askpassPath), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); |
| 51 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 52 | }).pipe( | |
| 53 | Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "linux"))), | |
| 54 | Effect.scoped, | |
| 55 | ), | |
| 52 | 56 | ); |
| 53 | 57 | |
| 54 | 58 | it.effect("builds a windows askpass launcher pair", () => |
| 55 | 59 | Effect.gen(function* () { |
| 56 | 60 | const descriptor = yield* buildSshAskpassHelperDescriptor({ |
| 57 | 61 | directory: "C:\\temp\\t3code-ssh-askpass", |
| 58 | platform: "win32", | |
| 59 | }).pipe(Effect.provide(NodeServices.layer)); | |
| 62 | }).pipe( | |
| 63 | Effect.provide( | |
| 64 | Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "win32")), | |
| 65 | ), | |
| 66 | ); | |
| 60 | 67 | |
| 61 | 68 | assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd"); |
| 62 | 69 | assert.deepEqual( |
packages/ssh/src/auth.ts+16-9| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 2 | import * as Config from "effect/Config"; | |
| 1 | 3 | import * as Context from "effect/Context"; |
| 2 | 4 | import * as Effect from "effect/Effect"; |
| 3 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | |
| 5 | 8 | import * as Path from "effect/Path"; |
| 6 | 9 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 10 | |
| ... | ... | @@ -55,7 +58,6 @@ export interface SshChildEnvironmentOptions { |
| 55 | 58 | readonly baseEnv?: NodeJS.ProcessEnv; |
| 56 | 59 | readonly askpassDirectory?: string; |
| 57 | 60 | readonly authSecret?: string | null; |
| 58 | readonly platform?: NodeJS.Platform; | |
| 59 | 61 | } |
| 60 | 62 | |
| 61 | 63 | const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; |
| ... | ... | @@ -110,9 +112,8 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( |
| 110 | 112 | "ssh/auth.buildSshAskpassHelperDescriptor", |
| 111 | 113 | )(function* (input: { |
| 112 | 114 | readonly directory: string; |
| 113 | readonly platform?: NodeJS.Platform; | |
| 114 | 115 | }): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> { |
| 115 | const platform = input.platform ?? process.platform; | |
| 116 | const platform = yield* HostProcessPlatform; | |
| 116 | 117 | const path = yield* Path.Path; |
| 117 | 118 | const directory = input.directory; |
| 118 | 119 | |
| ... | ... | @@ -148,12 +149,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( |
| 148 | 149 | export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( |
| 149 | 150 | function* (input: { |
| 150 | 151 | readonly directory: string; |
| 151 | readonly platform?: NodeJS.Platform; | |
| 152 | 152 | }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { |
| 153 | 153 | const fs = yield* FileSystem.FileSystem; |
| 154 | 154 | const path = yield* Path.Path; |
| 155 | 155 | const descriptor = yield* buildSshAskpassHelperDescriptor(input); |
| 156 | const platform = input.platform ?? process.platform; | |
| 156 | const platform = yield* HostProcessPlatform; | |
| 157 | 157 | |
| 158 | 158 | yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); |
| 159 | 159 | |
| ... | ... | @@ -179,21 +179,28 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron |
| 179 | 179 | PlatformError.PlatformError, |
| 180 | 180 | FileSystem.FileSystem | Path.Path |
| 181 | 181 | > { |
| 182 | const baseEnv = { ...(input.baseEnv ?? process.env) }; | |
| 182 | const baseEnv = { ...input.baseEnv }; | |
| 183 | 183 | if (!input.interactiveAuth) { |
| 184 | 184 | return baseEnv; |
| 185 | 185 | } |
| 186 | 186 | |
| 187 | const platform = input.platform ?? process.platform; | |
| 187 | const platform = yield* HostProcessPlatform; | |
| 188 | const hostDisplay = input.baseEnv | |
| 189 | ? input.baseEnv.DISPLAY | |
| 190 | : yield* Config.string("DISPLAY").pipe( | |
| 191 | Config.option, | |
| 192 | Effect.orElseSucceed(() => Option.none<string>()), | |
| 193 | Effect.map(Option.getOrUndefined), | |
| 194 | ); | |
| 188 | 195 | const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); |
| 189 | const sshAskpass = yield* ensureSshAskpassHelpers({ directory, platform }); | |
| 196 | const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); | |
| 190 | 197 | |
| 191 | 198 | return { |
| 192 | 199 | ...baseEnv, |
| 193 | 200 | SSH_ASKPASS: sshAskpass, |
| 194 | 201 | SSH_ASKPASS_REQUIRE: "force", |
| 195 | 202 | ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }), |
| 196 | ...(platform === "win32" || baseEnv.DISPLAY ? {} : { DISPLAY: "t3code" }), | |
| 203 | ...(platform === "win32" || baseEnv.DISPLAY || hostDisplay ? {} : { DISPLAY: "t3code" }), | |
| 197 | 204 | }; |
| 198 | 205 | }); |
| 199 | 206 |
packages/ssh/src/command.ts+16-4| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Crypto from "node:crypto"; |
| 2 | 2 | |
| 3 | 3 | import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | 5 | import * as Duration from "effect/Duration"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -16,7 +17,16 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; |
| 16 | 17 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; |
| 17 | 18 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; |
| 18 | 19 | const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; |
| 19 | export const SSH_COMMAND = process.platform === "win32" ? "ssh.exe" : "ssh"; | |
| 20 | ||
| 21 | /** | |
| 22 | * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always | |
| 23 | * spawned directly — cmd.exe shell mode would re-tokenize arguments such as | |
| 24 | * identity-file paths containing spaces. | |
| 25 | */ | |
| 26 | const sshCommandForPlatform = (platform: NodeJS.Platform): string => | |
| 27 | platform === "win32" ? "ssh.exe" : "ssh"; | |
| 28 | ||
| 29 | export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform); | |
| 20 | 30 | |
| 21 | 31 | const encoder = new TextEncoder(); |
| 22 | 32 | |
| ... | ... | @@ -191,16 +201,18 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func |
| 191 | 201 | ...(input.remoteCommandArgs ?? []), |
| 192 | 202 | ]; |
| 193 | 203 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 204 | const sshCommand = yield* resolveSshCommand; | |
| 194 | 205 | yield* Effect.logDebug("ssh.command.start", { |
| 195 | 206 | ...sshTargetLogFields(target), |
| 196 | command: [SSH_COMMAND, ...args], | |
| 207 | command: [sshCommand, ...args], | |
| 197 | 208 | hasStdin: input.stdin !== undefined, |
| 198 | 209 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, |
| 199 | 210 | }); |
| 200 | 211 | const child = yield* spawner |
| 201 | 212 | .spawn( |
| 202 | ChildProcess.make(SSH_COMMAND, args, { | |
| 213 | ChildProcess.make(sshCommand, args, { | |
| 203 | 214 | env: environment, |
| 215 | extendEnv: true, | |
| 204 | 216 | stdin: { |
| 205 | 217 | stream: stdinStream(input.stdin), |
| 206 | 218 | endOnDone: true, |
| ... | ... | @@ -212,7 +224,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func |
| 212 | 224 | Effect.mapError( |
| 213 | 225 | (cause) => |
| 214 | 226 | new SshCommandError({ |
| 215 | command: [SSH_COMMAND, ...args], | |
| 227 | command: [sshCommand, ...args], | |
| 216 | 228 | exitCode: null, |
| 217 | 229 | stderr: "", |
| 218 | 230 | message: |
packages/ssh/src/config.ts+11-1| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import * as Config from "effect/Config"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Option from "effect/Option"; | |
| 5 | 7 | import * as Path from "effect/Path"; |
| 6 | 8 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 9 | |
| ... | ... | @@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { |
| 208 | 210 | export const discoverSshHosts = Effect.fnUntraced( |
| 209 | 211 | function* (input: { readonly homeDir?: string }) { |
| 210 | 212 | const path = yield* Path.Path; |
| 211 | const homeDir = input?.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? ""; | |
| 213 | const env = yield* Config.all({ | |
| 214 | home: Config.string("HOME").pipe(Config.option), | |
| 215 | userProfile: Config.string("USERPROFILE").pipe(Config.option), | |
| 216 | }); | |
| 217 | const homeDir = | |
| 218 | input?.homeDir ?? | |
| 219 | Option.getOrUndefined(env.home) ?? | |
| 220 | Option.getOrUndefined(env.userProfile) ?? | |
| 221 | ""; | |
| 212 | 222 | if (homeDir.trim().length === 0) { |
| 213 | 223 | return []; |
| 214 | 224 | } |
packages/ssh/src/tunnel.ts+5-3| ... | ... | @@ -34,9 +34,9 @@ import { |
| 34 | 34 | collectProcessOutput, |
| 35 | 35 | getLastNonEmptyOutputLine, |
| 36 | 36 | remoteStateKey, |
| 37 | resolveSshCommand, | |
| 37 | 38 | resolveSshTarget, |
| 38 | 39 | runSshCommand, |
| 39 | SSH_COMMAND, | |
| 40 | 40 | targetConnectionKey, |
| 41 | 41 | } from "./command.ts"; |
| 42 | 42 | import { |
| ... | ... | @@ -1069,7 +1069,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: |
| 1069 | 1069 | `${input.localPort}:127.0.0.1:${input.remotePort}`, |
| 1070 | 1070 | hostSpec, |
| 1071 | 1071 | ]; |
| 1072 | const tunnelCommand = [SSH_COMMAND, ...args]; | |
| 1072 | const sshCommand = yield* resolveSshCommand; | |
| 1073 | const tunnelCommand = [sshCommand, ...args]; | |
| 1073 | 1074 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1074 | 1075 | const scope = yield* Scope.Scope; |
| 1075 | 1076 | yield* Effect.logDebug("ssh.tunnel.spawn.start", { |
| ... | ... | @@ -1082,8 +1083,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: |
| 1082 | 1083 | }); |
| 1083 | 1084 | const child = yield* spawner |
| 1084 | 1085 | .spawn( |
| 1085 | ChildProcess.make(SSH_COMMAND, args, { | |
| 1086 | ChildProcess.make(sshCommand, args, { | |
| 1086 | 1087 | env: childEnvironment, |
| 1088 | extendEnv: true, | |
| 1087 | 1089 | stdin: { |
| 1088 | 1090 | stream: Stream.empty, |
| 1089 | 1091 | endOnDone: true, |
packages/tailscale/package.json+1| ... | ... | @@ -14,6 +14,7 @@ |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| 16 | 16 | "@effect/platform-node": "catalog:", |
| 17 | "@t3tools/shared": "workspace:*", | |
| 17 | 18 | "effect": "catalog:" |
| 18 | 19 | }, |
| 19 | 20 | "devDependencies": { |
packages/tailscale/src/tailscale.ts+10-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 1 | 2 | import * as Data from "effect/Data"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Option from "effect/Option"; |
| ... | ... | @@ -10,7 +11,11 @@ export const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 10 | 11 | export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; |
| 11 | 12 | export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; |
| 12 | 13 | export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; |
| 13 | const TAILSCALE_COMMAND = process.platform === "win32" ? "tailscale.exe" : "tailscale"; | |
| 14 | ||
| 15 | // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so | |
| 16 | // it is always spawned directly rather than through cmd.exe shell mode. | |
| 17 | const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => | |
| 18 | platform === "win32" ? "tailscale.exe" : "tailscale"; | |
| 14 | 19 | |
| 15 | 20 | export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ |
| 16 | 21 | readonly command: readonly string[]; |
| ... | ... | @@ -136,8 +141,9 @@ export const readTailscaleStatus: Effect.Effect< |
| 136 | 141 | > = Effect.gen(function* () { |
| 137 | 142 | const args = ["status", "--json"]; |
| 138 | 143 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 144 | const hostPlatform = yield* HostProcessPlatform; | |
| 139 | 145 | const child = yield* spawner |
| 140 | .spawn(ChildProcess.make(TAILSCALE_COMMAND, args)) | |
| 146 | .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) | |
| 141 | 147 | .pipe( |
| 142 | 148 | Effect.mapError((cause) => |
| 143 | 149 | tailscaleCommandError( |
| ... | ... | @@ -211,8 +217,9 @@ const runTailscaleCommand = ( |
| 211 | 217 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => |
| 212 | 218 | Effect.gen(function* () { |
| 213 | 219 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 220 | const hostPlatform = yield* HostProcessPlatform; | |
| 214 | 221 | const child = yield* spawner |
| 215 | .spawn(ChildProcess.make(TAILSCALE_COMMAND, args)) | |
| 222 | .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) | |
| 216 | 223 | .pipe( |
| 217 | 224 | Effect.mapError((cause) => |
| 218 | 225 | tailscaleCommandError( |
pnpm-lock.yaml+3| ... | ... | @@ -810,6 +810,9 @@ importers: |
| 810 | 810 | '@effect/platform-node': |
| 811 | 811 | specifier: 4.0.0-beta.78 |
| 812 | 812 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 813 | '@t3tools/shared': | |
| 814 | specifier: workspace:* | |
| 815 | version: link:../shared | |
| 813 | 816 | effect: |
| 814 | 817 | specifier: 4.0.0-beta.78 |
| 815 | 818 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
scripts/build-desktop-artifact.test.ts+81| ... | ... | @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | |
| 5 | 6 | import * as Option from "effect/Option"; |
| 6 | 7 | |
| 7 | 8 | import { |
| ... | ... | @@ -11,10 +12,12 @@ import { |
| 11 | 12 | resolveDesktopBuildIconAssets, |
| 12 | 13 | resolveDesktopProductName, |
| 13 | 14 | resolveDesktopUpdateChannel, |
| 15 | resolveGitHubPublishConfig, | |
| 14 | 16 | resolveMockUpdateServerPort, |
| 15 | 17 | resolveMockUpdateServerUrl, |
| 16 | 18 | } from "./build-desktop-artifact.ts"; |
| 17 | 19 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 20 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 18 | 21 | |
| 19 | 22 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 20 | 23 | it("resolves the dedicated nightly updater channel from nightly versions", () => { |
| ... | ... | @@ -41,6 +44,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 41 | 44 | }); |
| 42 | 45 | }); |
| 43 | 46 | |
| 47 | it.effect("resolves GitHub desktop publish config from Effect config", () => | |
| 48 | Effect.gen(function* () { | |
| 49 | const latestConfig = yield* resolveGitHubPublishConfig("latest").pipe( | |
| 50 | Effect.provide( | |
| 51 | ConfigProvider.layer( | |
| 52 | ConfigProvider.fromEnv({ | |
| 53 | env: { | |
| 54 | T3CODE_DESKTOP_UPDATE_REPOSITORY: "pingdotgg/t3code", | |
| 55 | }, | |
| 56 | }), | |
| 57 | ), | |
| 58 | ), | |
| 59 | ); | |
| 60 | const nightlyConfig = yield* resolveGitHubPublishConfig("nightly").pipe( | |
| 61 | Effect.provide( | |
| 62 | ConfigProvider.layer( | |
| 63 | ConfigProvider.fromEnv({ | |
| 64 | env: { | |
| 65 | GITHUB_REPOSITORY: "pingdotgg/t3code", | |
| 66 | }, | |
| 67 | }), | |
| 68 | ), | |
| 69 | ), | |
| 70 | ); | |
| 71 | ||
| 72 | assert.deepStrictEqual(latestConfig, { | |
| 73 | provider: "github", | |
| 74 | owner: "pingdotgg", | |
| 75 | repo: "t3code", | |
| 76 | releaseType: "release", | |
| 77 | }); | |
| 78 | assert.deepStrictEqual(nightlyConfig, { | |
| 79 | provider: "github", | |
| 80 | owner: "pingdotgg", | |
| 81 | repo: "t3code", | |
| 82 | releaseType: "prerelease", | |
| 83 | channel: "nightly", | |
| 84 | }); | |
| 85 | }), | |
| 86 | ); | |
| 87 | ||
| 44 | 88 | it("omits bundled workspace packages from staged desktop dependencies", () => { |
| 45 | 89 | assert.deepStrictEqual( |
| 46 | 90 | resolveDesktopRuntimeDependencies( |
| ... | ... | @@ -122,6 +166,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 122 | 166 | }), |
| 123 | 167 | ); |
| 124 | 168 | |
| 169 | it.effect("resolves default platform and architecture from host references", () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const resolved = yield* resolveBuildOptions({ | |
| 172 | platform: Option.none(), | |
| 173 | target: Option.none(), | |
| 174 | arch: Option.none(), | |
| 175 | buildVersion: Option.none(), | |
| 176 | outputDir: Option.none(), | |
| 177 | skipBuild: Option.none(), | |
| 178 | keepStage: Option.none(), | |
| 179 | signed: Option.none(), | |
| 180 | verbose: Option.none(), | |
| 181 | mockUpdates: Option.none(), | |
| 182 | mockUpdateServerPort: Option.none(), | |
| 183 | }).pipe( | |
| 184 | Effect.provide( | |
| 185 | Layer.mergeAll( | |
| 186 | Layer.succeed(HostProcessPlatform, "win32"), | |
| 187 | Layer.succeed(HostProcessArchitecture, "x64"), | |
| 188 | ConfigProvider.layer( | |
| 189 | ConfigProvider.fromEnv({ | |
| 190 | env: { | |
| 191 | PROCESSOR_ARCHITECTURE: "AMD64", | |
| 192 | PROCESSOR_ARCHITEW6432: "ARM64", | |
| 193 | }, | |
| 194 | }), | |
| 195 | ), | |
| 196 | ), | |
| 197 | ), | |
| 198 | ); | |
| 199 | ||
| 200 | assert.equal(resolved.platform, "win"); | |
| 201 | assert.equal(resolved.target, "nsis"); | |
| 202 | assert.equal(resolved.arch, "arm64"); | |
| 203 | }), | |
| 204 | ); | |
| 205 | ||
| 125 | 206 | it.effect("preserves explicit false boolean flags over true env defaults", () => |
| 126 | 207 | Effect.gen(function* () { |
| 127 | 208 | const resolved = yield* resolveBuildOptions({ |
scripts/build-desktop-artifact.ts+66-38| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | |
| 3 | 3 | import { fromYaml } from "@t3tools/shared/schemaYaml"; |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 4 | 6 | import rootPackageJson from "../package.json" with { type: "json" }; |
| 5 | 7 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; |
| 6 | 8 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; |
| ... | ... | @@ -101,14 +103,14 @@ function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Typ |
| 101 | 103 | return undefined; |
| 102 | 104 | } |
| 103 | 105 | |
| 104 | function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type { | |
| 106 | const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof BuildPlatform.Type) { | |
| 105 | 107 | const config = PLATFORM_CONFIG[platform]; |
| 106 | 108 | if (!config) { |
| 107 | 109 | return "x64"; |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | return getDefaultBuildArch(platform, process.arch, process.env, config); | |
| 111 | } | |
| 112 | return yield* getDefaultBuildArch(platform, config); | |
| 113 | }); | |
| 112 | 114 | |
| 113 | 115 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ |
| 114 | 116 | readonly message: string; |
| ... | ... | @@ -198,13 +200,21 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo |
| 198 | 200 | const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { |
| 199 | 201 | const fs = yield* FileSystem.FileSystem; |
| 200 | 202 | const path = yield* Path.Path; |
| 201 | const configured = process.env.npm_config_python ?? process.env.PYTHON; | |
| 203 | const hostPlatform = yield* HostProcessPlatform; | |
| 204 | const env = yield* Config.all({ | |
| 205 | configuredPython: Config.string("npm_config_python").pipe( | |
| 206 | Config.orElse(() => Config.string("PYTHON")), | |
| 207 | Config.option, | |
| 208 | ), | |
| 209 | localAppData: Config.string("LOCALAPPDATA").pipe(Config.option), | |
| 210 | }); | |
| 211 | const configured = Option.getOrUndefined(env.configuredPython); | |
| 202 | 212 | if (configured && (yield* fs.exists(configured))) { |
| 203 | 213 | return configured; |
| 204 | 214 | } |
| 205 | 215 | |
| 206 | if (process.platform === "win32") { | |
| 207 | const localAppData = process.env.LOCALAPPDATA; | |
| 216 | if (hostPlatform === "win32") { | |
| 217 | const localAppData = Option.getOrUndefined(env.localAppData); | |
| 208 | 218 | if (localAppData) { |
| 209 | 219 | for (const version of ["Python313", "Python312", "Python311", "Python310"]) { |
| 210 | 220 | const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); |
| ... | ... | @@ -348,21 +358,23 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 348 | 358 | const path = yield* Path.Path; |
| 349 | 359 | const repoRoot = yield* RepoRoot; |
| 350 | 360 | const env = yield* BuildEnvConfig; |
| 361 | const hostPlatform = yield* HostProcessPlatform; | |
| 351 | 362 | |
| 352 | 363 | const platform = mergeOptions( |
| 353 | 364 | input.platform, |
| 354 | 365 | env.platform, |
| 355 | detectHostBuildPlatform(process.platform), | |
| 366 | detectHostBuildPlatform(hostPlatform), | |
| 356 | 367 | ); |
| 357 | 368 | |
| 358 | 369 | if (!platform) { |
| 359 | 370 | return yield* new BuildScriptError({ |
| 360 | message: `Unsupported host platform '${process.platform}'.`, | |
| 371 | message: `Unsupported host platform '${hostPlatform}'.`, | |
| 361 | 372 | }); |
| 362 | 373 | } |
| 363 | 374 | |
| 364 | 375 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); |
| 365 | const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); | |
| 376 | const defaultArch = yield* getDefaultArch(platform); | |
| 377 | const arch = mergeOptions(input.arch, env.arch, defaultArch); | |
| 366 | 378 | const version = mergeOptions(input.buildVersion, env.version, undefined); |
| 367 | 379 | const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) |
| 368 | 380 | ? "release-mock" |
| ... | ... | @@ -622,19 +634,18 @@ export function resolveDesktopRuntimeDependencies( |
| 622 | 634 | return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); |
| 623 | 635 | } |
| 624 | 636 | |
| 625 | function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): | |
| 626 | | { | |
| 627 | readonly provider: "github"; | |
| 628 | readonly owner: string; | |
| 629 | readonly repo: string; | |
| 630 | readonly releaseType: "release" | "prerelease"; | |
| 631 | readonly channel?: "nightly"; | |
| 632 | } | |
| 633 | | undefined { | |
| 634 | const rawRepo = | |
| 635 | process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || | |
| 636 | process.env.GITHUB_REPOSITORY?.trim() || | |
| 637 | ""; | |
| 637 | export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( | |
| 638 | updateChannel: "latest" | "nightly", | |
| 639 | ) { | |
| 640 | const env = yield* Config.all({ | |
| 641 | updateRepository: Config.string("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option), | |
| 642 | githubRepository: Config.string("GITHUB_REPOSITORY").pipe(Config.option), | |
| 643 | }); | |
| 644 | const rawRepo = ( | |
| 645 | Option.getOrUndefined(env.updateRepository)?.trim() || | |
| 646 | Option.getOrUndefined(env.githubRepository)?.trim() || | |
| 647 | "" | |
| 648 | ).trim(); | |
| 638 | 649 | if (!rawRepo) return undefined; |
| 639 | 650 | |
| 640 | 651 | const [owner, repo, ...rest] = rawRepo.split("/"); |
| ... | ... | @@ -647,7 +658,7 @@ function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): |
| 647 | 658 | releaseType: updateChannel === "nightly" ? "prerelease" : "release", |
| 648 | 659 | ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), |
| 649 | 660 | }; |
| 650 | } | |
| 661 | }); | |
| 651 | 662 | |
| 652 | 663 | export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { |
| 653 | 664 | return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; |
| ... | ... | @@ -696,7 +707,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 696 | 707 | }, |
| 697 | 708 | }; |
| 698 | 709 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 699 | const publishConfig = resolveGitHubPublishConfig(updateChannel); | |
| 710 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); | |
| 700 | 711 | if (publishConfig) { |
| 701 | 712 | buildConfig.publish = [publishConfig]; |
| 702 | 713 | } else if (mockUpdates) { |
| ... | ... | @@ -780,6 +791,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 780 | 791 | const repoRoot = yield* RepoRoot; |
| 781 | 792 | const path = yield* Path.Path; |
| 782 | 793 | const fs = yield* FileSystem.FileSystem; |
| 794 | const hostPlatform = yield* HostProcessPlatform; | |
| 783 | 795 | const workspaceConfig = yield* readWorkspaceConfig(); |
| 784 | 796 | const workspaceCatalog = workspaceConfig.catalog ?? {}; |
| 785 | 797 | const workspaceOverrides = workspaceConfig.overrides ?? {}; |
| ... | ... | @@ -846,12 +858,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 846 | 858 | |
| 847 | 859 | if (!options.skipBuild) { |
| 848 | 860 | yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); |
| 861 | const spawnCommand = yield* resolveSpawnCommand("vp", ["run", "build:desktop"]); | |
| 849 | 862 | yield* runCommand( |
| 850 | ChildProcess.make({ | |
| 863 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 851 | 864 | cwd: repoRoot, |
| 852 | // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). | |
| 853 | shell: process.platform === "win32", | |
| 854 | })`vp run build:desktop`, | |
| 865 | shell: spawnCommand.shell, | |
| 866 | }), | |
| 855 | 867 | { label: "vp run build:desktop", verbose: options.verbose }, |
| 856 | 868 | ); |
| 857 | 869 | } |
| ... | ... | @@ -933,15 +945,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 933 | 945 | } |
| 934 | 946 | |
| 935 | 947 | yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); |
| 948 | const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]); | |
| 936 | 949 | yield* runCommand( |
| 937 | ChildProcess.make({ | |
| 950 | ChildProcess.make(installCommand.command, installCommand.args, { | |
| 938 | 951 | cwd: stageAppDir, |
| 939 | // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). | |
| 940 | shell: process.platform === "win32", | |
| 941 | })`vp install --prod --no-optional`, | |
| 952 | shell: installCommand.shell, | |
| 953 | }), | |
| 942 | 954 | { label: "vp install --prod --no-optional", verbose: options.verbose }, |
| 943 | 955 | ); |
| 944 | 956 | |
| 957 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") | |
| 958 | // as enabled, so copy the host env and scrub empty values instead of relying | |
| 959 | // on `extendEnv` merging. | |
| 945 | 960 | const buildEnv: NodeJS.ProcessEnv = { |
| 946 | 961 | ...process.env, |
| 947 | 962 | }; |
| ... | ... | @@ -959,7 +974,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 959 | 974 | delete buildEnv.APPLE_API_ISSUER; |
| 960 | 975 | } |
| 961 | 976 | |
| 962 | if (process.platform === "win32") { | |
| 977 | if (hostPlatform === "win32") { | |
| 963 | 978 | const python = yield* resolvePythonForNodeGyp(); |
| 964 | 979 | if (python) { |
| 965 | 980 | buildEnv.PYTHON = python; |
| ... | ... | @@ -970,7 +985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 970 | 985 | } |
| 971 | 986 | if (options.verbose) { |
| 972 | 987 | buildEnv.DEBUG = |
| 973 | buildEnv.DEBUG === undefined || buildEnv.DEBUG === "" | |
| 988 | buildEnv.DEBUG === undefined | |
| 974 | 989 | ? "electron-builder,electron-builder:*" |
| 975 | 990 | : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; |
| 976 | 991 | } |
| ... | ... | @@ -978,13 +993,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 978 | 993 | yield* Effect.log( |
| 979 | 994 | `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, |
| 980 | 995 | ); |
| 996 | const builderArgs = [ | |
| 997 | "exec", | |
| 998 | "--filter", | |
| 999 | "@t3tools/desktop", | |
| 1000 | "--", | |
| 1001 | "electron-builder", | |
| 1002 | "--projectDir", | |
| 1003 | stageAppDir, | |
| 1004 | platformConfig.cliFlag, | |
| 1005 | `--${options.arch}`, | |
| 1006 | "--publish", | |
| 1007 | "never", | |
| 1008 | ]; | |
| 1009 | const builderCommand = yield* resolveSpawnCommand("vp", builderArgs, { env: buildEnv }); | |
| 981 | 1010 | yield* runCommand( |
| 982 | ChildProcess.make({ | |
| 1011 | ChildProcess.make(builderCommand.command, builderCommand.args, { | |
| 983 | 1012 | cwd: repoRoot, |
| 984 | 1013 | env: buildEnv, |
| 985 | // Windows needs shell mode to resolve .cmd shims. | |
| 986 | shell: process.platform === "win32", | |
| 987 | })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, | |
| 1014 | shell: builderCommand.shell, | |
| 1015 | }), | |
| 988 | 1016 | { |
| 989 | 1017 | label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, |
| 990 | 1018 | verbose: options.verbose, |
scripts/dev-runner.ts+11-4| ... | ... | @@ -5,6 +5,8 @@ import * as NodeOS from "node:os"; |
| 5 | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 6 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 7 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 9 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 8 | 10 | import * as Config from "effect/Config"; |
| 9 | 11 | import * as Data from "effect/Data"; |
| 10 | 12 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -418,9 +420,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 418 | 420 | hasExplicitDevUrl: input.devUrl !== undefined, |
| 419 | 421 | }); |
| 420 | 422 | |
| 423 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 421 | 424 | const env = yield* createDevRunnerEnv({ |
| 422 | 425 | mode: input.mode, |
| 423 | baseEnv: process.env, | |
| 426 | baseEnv: hostEnvironment, | |
| 424 | 427 | serverOffset, |
| 425 | 428 | webOffset, |
| 426 | 429 | t3Home: input.t3Home, |
| ... | ... | @@ -445,14 +448,18 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 445 | 448 | return; |
| 446 | 449 | } |
| 447 | 450 | |
| 448 | const child = yield* ChildProcess.make("vp", [...MODE_ARGS[input.mode], ...input.runArgs], { | |
| 451 | const spawnCommand = yield* resolveSpawnCommand( | |
| 452 | "vp", | |
| 453 | [...MODE_ARGS[input.mode], ...input.runArgs], | |
| 454 | { env }, | |
| 455 | ); | |
| 456 | const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 449 | 457 | stdin: "inherit", |
| 450 | 458 | stdout: "inherit", |
| 451 | 459 | stderr: "inherit", |
| 452 | 460 | env, |
| 453 | 461 | extendEnv: false, |
| 454 | // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). | |
| 455 | shell: process.platform === "win32", | |
| 462 | shell: spawnCommand.shell, | |
| 456 | 463 | // Keep Vite+ in the same process group so terminal signals (Ctrl+C) |
| 457 | 464 | // reach it directly. Effect defaults to detached: true on non-Windows, |
| 458 | 465 | // which would put the runner in a new group and require manual forwarding. |
scripts/lib/build-target-arch.test.ts+52-57| ... | ... | @@ -1,61 +1,56 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | ||
| 3 | import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; | |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 6 | ||
| 7 | import { getDefaultBuildArch } from "./build-target-arch.ts"; | |
| 8 | ||
| 9 | const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<string, string> => | |
| 10 | Object.fromEntries( | |
| 11 | Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), | |
| 12 | ); | |
| 13 | ||
| 14 | const withHostRuntime = ( | |
| 15 | platform: NodeJS.Platform, | |
| 16 | arch: NodeJS.Architecture, | |
| 17 | env: Readonly<Record<string, string | undefined>> = {}, | |
| 18 | ) => | |
| 19 | Effect.provide( | |
| 20 | Layer.mergeAll( | |
| 21 | Layer.succeed(HostProcessPlatform, platform), | |
| 22 | Layer.succeed(HostProcessArchitecture, arch), | |
| 23 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })), | |
| 24 | ), | |
| 25 | ); | |
| 4 | 26 | |
| 5 | 27 | describe("build-target-arch", () => { |
| 6 | it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { | |
| 7 | // Windows-on-Arm can run an x64 Node process under emulation while still | |
| 8 | // exposing the real host CPU via PROCESSOR_ARCHITEW6432. | |
| 9 | const hostArch = resolveHostProcessArch("win32", "x64", { | |
| 10 | PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. | |
| 11 | PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. | |
| 12 | }); | |
| 13 | ||
| 14 | assert.equal(hostArch, "arm64"); | |
| 15 | }); | |
| 16 | ||
| 17 | it("falls back to x64 for native x64 Windows hosts", () => { | |
| 18 | const hostArch = resolveHostProcessArch("win32", "x64", { | |
| 19 | PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. | |
| 20 | }); | |
| 21 | ||
| 22 | assert.equal(hostArch, "x64"); | |
| 23 | }); | |
| 24 | ||
| 25 | it("keeps arm64 when the current process is already native arm64", () => { | |
| 26 | const hostArch = resolveHostProcessArch("win32", "arm64", {}); | |
| 27 | ||
| 28 | assert.equal(hostArch, "arm64"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("uses the resolved host arch when selecting the default Windows build arch", () => { | |
| 32 | // This mirrors the packaging script's default-path behavior: the current | |
| 33 | // process is x64, but the machine itself is ARM64, so the default build | |
| 34 | // target should be win-arm64 rather than win-x64. | |
| 35 | const arch = getDefaultBuildArch( | |
| 36 | "win", | |
| 37 | "x64", | |
| 38 | { | |
| 39 | PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. | |
| 40 | PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. | |
| 41 | }, | |
| 42 | { archChoices: ["x64", "arm64"] }, | |
| 43 | ); | |
| 44 | ||
| 45 | assert.equal(arch, "arm64"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("does not apply Windows host env heuristics for non-Windows targets", () => { | |
| 49 | const arch = getDefaultBuildArch( | |
| 50 | "linux", | |
| 51 | "x64", | |
| 52 | { | |
| 53 | PROCESSOR_ARCHITECTURE: "AMD64", | |
| 54 | PROCESSOR_ARCHITEW6432: "ARM64", | |
| 55 | }, | |
| 56 | { archChoices: ["x64", "arm64"] }, | |
| 57 | ); | |
| 58 | ||
| 59 | assert.equal(arch, "x64"); | |
| 60 | }); | |
| 28 | it.effect("uses the resolved host arch when selecting the default Windows build arch", () => | |
| 29 | Effect.gen(function* () { | |
| 30 | // This mirrors the packaging script's default-path behavior: the current | |
| 31 | // process is x64, but the machine itself is ARM64, so the default build | |
| 32 | // target should be win-arm64 rather than win-x64. | |
| 33 | const arch = yield* getDefaultBuildArch("win", { archChoices: ["x64", "arm64"] }).pipe( | |
| 34 | withHostRuntime("win32", "x64", { | |
| 35 | PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. | |
| 36 | PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | assert.equal(arch, "arm64"); | |
| 41 | }), | |
| 42 | ); | |
| 43 | ||
| 44 | it.effect("does not apply Windows host env heuristics for non-Windows targets", () => | |
| 45 | Effect.gen(function* () { | |
| 46 | const arch = yield* getDefaultBuildArch("linux", { archChoices: ["x64", "arm64"] }).pipe( | |
| 47 | withHostRuntime("linux", "x64", { | |
| 48 | PROCESSOR_ARCHITECTURE: "AMD64", | |
| 49 | PROCESSOR_ARCHITEW6432: "ARM64", | |
| 50 | }), | |
| 51 | ); | |
| 52 | ||
| 53 | assert.equal(arch, "x64"); | |
| 54 | }), | |
| 55 | ); | |
| 61 | 56 | }); |
scripts/lib/build-target-arch.ts+24-16| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 2 | import * as Config from "effect/Config"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | ||
| 1 | 6 | export type BuildArch = "arm64" | "x64" | "universal"; |
| 2 | 7 | export type BuildPlatform = "mac" | "linux" | "win"; |
| 3 | 8 | |
| ... | ... | @@ -5,6 +10,11 @@ interface PlatformConfig { |
| 5 | 10 | readonly archChoices: ReadonlyArray<BuildArch>; |
| 6 | 11 | } |
| 7 | 12 | |
| 13 | const WindowsProcessorArchitectureConfig = Config.all({ | |
| 14 | processorArchitecture: Config.string("PROCESSOR_ARCHITECTURE").pipe(Config.option), | |
| 15 | processorArchitectureW6432: Config.string("PROCESSOR_ARCHITEW6432").pipe(Config.option), | |
| 16 | }); | |
| 17 | ||
| 8 | 18 | function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { |
| 9 | 19 | const normalized = value?.trim().toLowerCase(); |
| 10 | 20 | if (!normalized) return undefined; |
| ... | ... | @@ -13,38 +23,36 @@ function normalizeWindowsArch(value: string | undefined): BuildArch | undefined |
| 13 | 23 | return undefined; |
| 14 | 24 | } |
| 15 | 25 | |
| 16 | export function resolveHostProcessArch( | |
| 17 | platform: NodeJS.Platform, | |
| 18 | processArch: NodeJS.Architecture, | |
| 19 | env: NodeJS.ProcessEnv, | |
| 20 | ): BuildArch | undefined { | |
| 26 | const optionToUndefined = <A>(value: Option.Option<A>): A | undefined => | |
| 27 | Option.getOrUndefined(value); | |
| 28 | ||
| 29 | const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () { | |
| 30 | const platform = yield* HostProcessPlatform; | |
| 31 | const processArch = yield* HostProcessArchitecture; | |
| 21 | 32 | if (processArch === "arm64") return "arm64"; |
| 22 | 33 | if (processArch === "x64") { |
| 23 | 34 | if (platform !== "win32") return "x64"; |
| 24 | 35 | |
| 25 | 36 | // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host |
| 26 | 37 | // still reports ARM64 via the processor environment variables. |
| 38 | const env = yield* WindowsProcessorArchitectureConfig; | |
| 27 | 39 | return ( |
| 28 | normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? | |
| 29 | normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? | |
| 40 | normalizeWindowsArch(optionToUndefined(env.processorArchitectureW6432)) ?? | |
| 41 | normalizeWindowsArch(optionToUndefined(env.processorArchitecture)) ?? | |
| 30 | 42 | "x64" |
| 31 | 43 | ); |
| 32 | 44 | } |
| 33 | 45 | return undefined; |
| 34 | } | |
| 46 | }); | |
| 35 | 47 | |
| 36 | export function getDefaultBuildArch( | |
| 48 | export const getDefaultBuildArch = Effect.fn("getDefaultBuildArch")(function* ( | |
| 37 | 49 | platform: BuildPlatform, |
| 38 | processArch: NodeJS.Architecture, | |
| 39 | env: NodeJS.ProcessEnv, | |
| 40 | 50 | platformConfig: PlatformConfig, |
| 41 | ): BuildArch { | |
| 42 | const hostPlatform: NodeJS.Platform = | |
| 43 | platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; | |
| 44 | const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); | |
| 51 | ) { | |
| 52 | const hostArch = yield* resolveHostProcessArch(); | |
| 45 | 53 | if (hostArch && platformConfig.archChoices.includes(hostArch)) { |
| 46 | 54 | return hostArch; |
| 47 | 55 | } |
| 48 | 56 | |
| 49 | 57 | return platformConfig.archChoices[0] ?? "x64"; |
| 50 | } | |
| 58 | }); |
scripts/mobile-native-static-check.ts+5-19| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 5 | 6 | import * as Console from "effect/Console"; |
| 6 | 7 | import * as Data from "effect/Data"; |
| 7 | 8 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -58,23 +59,7 @@ const commandOutputOptions = { |
| 58 | 59 | } as const; |
| 59 | 60 | |
| 60 | 61 | const commandExists = Effect.fn("commandExists")(function* (command: string) { |
| 61 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 62 | const lookupCommand = | |
| 63 | process.platform === "win32" | |
| 64 | ? ChildProcess.make("where", [command], { | |
| 65 | stdout: "ignore", | |
| 66 | stderr: "ignore", | |
| 67 | }) | |
| 68 | : ChildProcess.make("/bin/sh", ["-c", `command -v ${command}`], { | |
| 69 | stdout: "ignore", | |
| 70 | stderr: "ignore", | |
| 71 | }); | |
| 72 | ||
| 73 | return yield* spawner.spawn(lookupCommand).pipe( | |
| 74 | Effect.flatMap((child) => child.exitCode), | |
| 75 | Effect.map((exitCode) => exitCode === 0), | |
| 76 | Effect.orElseSucceed(() => false), | |
| 77 | ); | |
| 62 | return yield* isCommandAvailable(command); | |
| 78 | 63 | }); |
| 79 | 64 | |
| 80 | 65 | const warnMissingTool = (tool: NativeStaticTool, checkName: string) => |
| ... | ... | @@ -89,11 +74,12 @@ const runCommand = Effect.fn("runCommand")(function* ( |
| 89 | 74 | ) { |
| 90 | 75 | yield* Console.log(`$ ${[command, ...args].join(" ")}`); |
| 91 | 76 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 77 | const spawnCommand = yield* resolveSpawnCommand(command, args); | |
| 92 | 78 | const child = yield* spawner.spawn( |
| 93 | ChildProcess.make(command, [...args], { | |
| 79 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 94 | 80 | cwd, |
| 95 | 81 | ...commandOutputOptions, |
| 96 | shell: process.platform === "win32", | |
| 82 | shell: spawnCommand.shell, | |
| 97 | 83 | }), |
| 98 | 84 | ); |
| 99 | 85 | const exitCode = Number(yield* child.exitCode); |
vite.config.ts+2-4| ... | ... | @@ -1,12 +1,9 @@ |
| 1 | 1 | import "vite-plus/test/config"; |
| 2 | 2 | import { defineConfig } from "vite-plus"; |
| 3 | import { fileURLToPath } from "node:url"; | |
| 4 | 3 | |
| 5 | 4 | export default defineConfig({ |
| 6 | 5 | resolve: { |
| 7 | alias: { | |
| 8 | "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), | |
| 9 | }, | |
| 6 | tsconfigPaths: true, | |
| 10 | 7 | }, |
| 11 | 8 | test: { |
| 12 | 9 | environment: "node", |
| ... | ... | @@ -96,6 +93,7 @@ export default defineConfig({ |
| 96 | 93 | "typescript/require-array-sort-compare": "off", |
| 97 | 94 | "typescript/restrict-template-expressions": "off", |
| 98 | 95 | "typescript/unbound-method": "off", |
| 96 | "t3code/no-global-process-runtime": "error", | |
| 99 | 97 | "t3code/no-inline-schema-compile": "warn", |
| 100 | 98 | "t3code/no-manual-effect-runtime-in-tests": "error", |
| 101 | 99 | }, |