| 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,5 +1,6 @@ |
| 1 | import { spawn, spawnSync } from "node:child_process"; | 1 | import { spawn, spawnSync } from "node:child_process"; |
| 2 | import { watch } from "node:fs"; | 2 | import { watch } from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; | ||
| 3 | import { join } from "node:path"; | 4 | import { join } from "node:path"; |
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| ... | @@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; | ... | @@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; |
| 33 | const restartDebounceMs = 120; | 34 | const restartDebounceMs = 120; |
| 34 | const childTreeGracePeriodMs = 1_200; | 35 | const childTreeGracePeriodMs = 1_200; |
| 35 | const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); | 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 | await waitForResources({ | 40 | await waitForResources({ |
| 38 | baseDir: desktopDir, | 41 | baseDir: desktopDir, |
| ... | @@ -57,7 +60,7 @@ const expectedExits = new WeakSet(); | ... | @@ -57,7 +60,7 @@ const expectedExits = new WeakSet(); |
| 57 | const watchers = []; | 60 | const watchers = []; |
| 58 | 61 | ||
| 59 | function killChildTreeByPid(pid, signal) { | 62 | function killChildTreeByPid(pid, signal) { |
| 60 | if (process.platform === "win32" || typeof pid !== "number") { | 63 | if (hostPlatform === "win32" || typeof pid !== "number") { |
| 61 | return; | 64 | return; |
| 62 | } | 65 | } |
| 63 | 66 | ||
| ... | @@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) { | ... | @@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) { |
| 65 | } | 68 | } |
| 66 | 69 | ||
| 67 | function cleanupStaleDevApps() { | 70 | function cleanupStaleDevApps() { |
| 68 | if (process.platform === "win32") { | 71 | if (hostPlatform === "win32") { |
| 69 | return; | 72 | return; |
| 70 | } | 73 | } |
| 71 | 74 | ||
| ... | @@ -194,7 +197,7 @@ function startWatchers() { | ... | @@ -194,7 +197,7 @@ function startWatchers() { |
| 194 | } | 197 | } |
| 195 | 198 | ||
| 196 | function killChildTree(signal) { | 199 | function killChildTree(signal) { |
| 197 | if (process.platform === "win32") { | 200 | if (hostPlatform === "win32") { |
| 198 | return; | 201 | return; |
| 199 | } | 202 | } |
| 200 | 203 |
apps/desktop/scripts/electron-launcher.mjs+6-3| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | writeFileSync, | 14 | writeFileSync, |
| 15 | } from "node:fs"; | 15 | } from "node:fs"; |
| 16 | import { createRequire } from "node:module"; | 16 | import { createRequire } from "node:module"; |
| 17 | import * as NodeOS from "node:os"; | ||
| 17 | import { basename, dirname, join, resolve } from "node:path"; | 18 | import { basename, dirname, join, resolve } from "node:path"; |
| 18 | import { fileURLToPath } from "node:url"; | 19 | import { fileURLToPath } from "node:url"; |
| 19 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; | 20 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; |
| ... | @@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; | ... | @@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; |
| 33 | const LAUNCHER_VERSION = 11; | 34 | const LAUNCHER_VERSION = 11; |
| 34 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); | 35 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); |
| 35 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); | 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 | function resolveDevelopmentProtocolCallbackPort() { | 40 | function resolveDevelopmentProtocolCallbackPort() { |
| 38 | const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); | 41 | const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); |
| ... | @@ -312,7 +315,7 @@ function buildMacLauncher(electronBinaryPath) { | ... | @@ -312,7 +315,7 @@ function buildMacLauncher(electronBinaryPath) { |
| 312 | } | 315 | } |
| 313 | 316 | ||
| 314 | function isLinuxSetuidSandboxConfigured(electronBinaryPath) { | 317 | function isLinuxSetuidSandboxConfigured(electronBinaryPath) { |
| 315 | if (process.platform !== "linux") { | 318 | if (hostPlatform !== "linux") { |
| 316 | return true; | 319 | return true; |
| 317 | } | 320 | } |
| 318 | 321 | ||
| ... | @@ -342,7 +345,7 @@ export function resolveElectronPath() { | ... | @@ -342,7 +345,7 @@ export function resolveElectronPath() { |
| 342 | const require = createRequire(import.meta.url); | 345 | const require = createRequire(import.meta.url); |
| 343 | const electronBinaryPath = require("electron"); | 346 | const electronBinaryPath = require("electron"); |
| 344 | 347 | ||
| 345 | if (process.platform !== "darwin") { | 348 | if (hostPlatform !== "darwin") { |
| 346 | return electronBinaryPath; | 349 | return electronBinaryPath; |
| 347 | } | 350 | } |
| 348 | 351 | ||
| ... | @@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) { | ... | @@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) { |
| 358 | } | 361 | } |
| 359 | 362 | ||
| 360 | export function resolveDevProtocolClient() { | 363 | export function resolveDevProtocolClient() { |
| 361 | if (process.platform !== "darwin" || !isDevelopment) { | 364 | if (hostPlatform !== "darwin" || !isDevelopment) { |
| 362 | return null; | 365 | return null; |
| 363 | } | 366 | } |
| 364 | 367 |
apps/desktop/scripts/ensure-electron-runtime.mjs+14-10| ... | @@ -1,13 +1,17 @@ | ... | @@ -1,13 +1,17 @@ |
| 1 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | 1 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 2 | import { createRequire } from "node:module"; | 2 | import { createRequire } from "node:module"; |
| 3 | import { tmpdir } from "node:os"; | 3 | import { arch, platform, tmpdir } from "node:os"; |
| 4 | import { dirname, join } from "node:path"; | 4 | import { dirname, join } from "node:path"; |
| 5 | import { spawnSync } from "node:child_process"; | 5 | import { spawnSync } from "node:child_process"; |
| 6 | 6 | ||
| 7 | const require = createRequire(import.meta.url); | 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 | function getPlatformPath() { | 13 | function getPlatformPath() { |
| 10 | switch (process.platform) { | 14 | switch (hostPlatform) { |
| 11 | case "darwin": | 15 | case "darwin": |
| 12 | return "Electron.app/Contents/MacOS/Electron"; | 16 | return "Electron.app/Contents/MacOS/Electron"; |
| 13 | case "freebsd": | 17 | case "freebsd": |
| ... | @@ -17,12 +21,12 @@ function getPlatformPath() { | ... | @@ -17,12 +21,12 @@ function getPlatformPath() { |
| 17 | case "win32": | 21 | case "win32": |
| 18 | return "electron.exe"; | 22 | return "electron.exe"; |
| 19 | default: | 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 | function ensureExecutable(filePath) { | 28 | function ensureExecutable(filePath) { |
| 25 | if (process.platform !== "win32") { | 29 | if (hostPlatform !== "win32") { |
| 26 | chmodSync(filePath, 0o755); | 30 | chmodSync(filePath, 0o755); |
| 27 | } | 31 | } |
| 28 | } | 32 | } |
| ... | @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) { | ... | @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) { |
| 39 | function getRequiredRuntimePaths(electronDir, platformPath) { | 43 | function getRequiredRuntimePaths(electronDir, platformPath) { |
| 40 | const paths = [join(electronDir, "dist", platformPath)]; | 44 | const paths = [join(electronDir, "dist", platformPath)]; |
| 41 | 45 | ||
| 42 | if (process.platform === "darwin") { | 46 | if (hostPlatform === "darwin") { |
| 43 | paths.push( | 47 | paths.push( |
| 44 | join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), | 48 | join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), |
| 45 | join( | 49 | join( |
| ... | @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) { | ... | @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) { |
| 58 | } | 62 | } |
| 59 | 63 | ||
| 60 | function isMachO(filePath) { | 64 | function isMachO(filePath) { |
| 61 | if (process.platform !== "darwin") { | 65 | if (hostPlatform !== "darwin") { |
| 62 | return true; | 66 | return true; |
| 63 | } | 67 | } |
| 64 | 68 | ||
| ... | @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) { | ... | @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) { |
| 76 | } | 80 | } |
| 77 | 81 | ||
| 78 | function invalidRuntimePaths(electronDir, platformPath) { | 82 | function invalidRuntimePaths(electronDir, platformPath) { |
| 79 | if (process.platform !== "darwin") { | 83 | if (hostPlatform !== "darwin") { |
| 80 | return []; | 84 | return []; |
| 81 | } | 85 | } |
| 82 | 86 | ||
| ... | @@ -111,16 +115,16 @@ function runChecked(command, args) { | ... | @@ -111,16 +115,16 @@ function runChecked(command, args) { |
| 111 | 115 | ||
| 112 | function installElectronRuntime(electronDir, version) { | 116 | function installElectronRuntime(electronDir, version) { |
| 113 | const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); | 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 | try { | 120 | try { |
| 117 | runChecked("curl", [ | 121 | runChecked("curl", [ |
| 118 | "-fsSL", | 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 | "-o", | 124 | "-o", |
| 121 | zipPath, | 125 | zipPath, |
| 122 | ]); | 126 | ]); |
| 123 | if (process.platform === "darwin") { | 127 | if (hostPlatform === "darwin") { |
| 124 | runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); | 128 | runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); |
| 125 | } else { | 129 | } else { |
| 126 | runChecked("python3", [ | 130 | runChecked("python3", [ |
apps/desktop/src/app/DesktopAssets.ts+1-1| ... | @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( | ... | @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( |
| 49 | > { | 49 | > { |
| 50 | const fileSystem = yield* FileSystem.FileSystem; | 50 | const fileSystem = yield* FileSystem.FileSystem; |
| 51 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 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 | const developmentDockIconPath = environment.developmentDockIconPath; | 53 | const developmentDockIconPath = environment.developmentDockIconPath; |
| 54 | const developmentDockIconExists = yield* fileSystem | 54 | const developmentDockIconExists = yield* fileSystem |
| 55 | .exists(developmentDockIconPath) | 55 | .exists(developmentDockIconPath) |
apps/desktop/src/electron/ElectronMenu.ts+99-94| ... | @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | ||
| 7 | import * as Electron from "electron"; | 7 | import * as Electron from "electron"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | 9 | ||
| 9 | export interface ElectronMenuPosition { | 10 | export interface ElectronMenuPosition { |
| 10 | readonly x: number; | 11 | readonly x: number; |
| ... | @@ -79,109 +80,113 @@ const normalizePosition = ( | ... | @@ -79,109 +80,113 @@ const normalizePosition = ( |
| 79 | ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, | 80 | ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, |
| 80 | ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); | 81 | ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); |
| 81 | 82 | ||
| 82 | export const layer = Layer.sync(ElectronMenu, () => { | 83 | export const layer = Layer.effect( |
| 83 | let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined; | 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> => { | 89 | const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => { |
| 86 | if (process.platform !== "darwin") { | 90 | if (platform !== "darwin") { |
| 87 | return Option.none(); | 91 | 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; | ||
| 117 | } | 92 | } |
| 118 | 93 | if (destructiveMenuIconCache !== undefined) { | |
| 119 | const itemOption: Electron.MenuItemConstructorOptions = { | 94 | return destructiveMenuIconCache; |
| 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)); | ||
| 127 | } | 95 | } |
| 128 | if (item.destructive && (!item.children || item.children.length === 0)) { | 96 | |
| 129 | const destructiveIcon = getDestructiveMenuIcon(); | 97 | try { |
| 130 | if (Option.isSome(destructiveIcon)) { | 98 | const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ |
| 131 | itemOption.icon = destructiveIcon.value; | 99 | width: 12, |
| 132 | } | 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); | 107 | return destructiveMenuIconCache; |
| 136 | } | 108 | }; |
| 137 | 109 | ||
| 138 | return template; | 110 | const buildTemplate = ( |
| 139 | }; | 111 | entries: readonly ContextMenuItem[], |
| 140 | 112 | complete: (selectedItemId: Option.Option<string>) => void, | |
| 141 | return ElectronMenu.of({ | 113 | ): Electron.MenuItemConstructorOptions[] => { |
| 142 | setApplicationMenu: (template) => | 114 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 143 | Effect.sync(() => { | 115 | let hasInsertedDestructiveSeparator = false; |
| 144 | Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); | 116 | |
| 145 | }), | 117 | for (const item of entries) { |
| 146 | popupTemplate: (input) => | 118 | if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { |
| 147 | Effect.sync(() => { | 119 | template.push({ type: "separator" }); |
| 148 | if (input.template.length === 0) { | 120 | hasInsertedDestructiveSeparator = true; |
| 149 | return; | ||
| 150 | } | 121 | } |
| 151 | Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); | 122 | |
| 152 | }), | 123 | const itemOption: Electron.MenuItemConstructorOptions = { |
| 153 | showContextMenu: (input) => | 124 | label: item.label, |
| 154 | Effect.callback<Option.Option<string>>((resume) => { | 125 | enabled: !item.disabled, |
| 155 | const normalizedItems = normalizeContextMenuItems(input.items); | 126 | }; |
| 156 | if (normalizedItems.length === 0) { | 127 | if (item.children && item.children.length > 0) { |
| 157 | resume(Effect.succeed(Option.none())); | 128 | itemOption.submenu = buildTemplate(item.children, complete); |
| 158 | return; | 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; | 142 | return template; |
| 162 | const complete = (selectedItemId: Option.Option<string>) => { | 143 | }; |
| 163 | if (completed) { | 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 | return; | 162 | return; |
| 165 | } | 163 | } |
| 166 | completed = true; | ||
| 167 | resume(Effect.succeed(selectedItemId)); | ||
| 168 | }; | ||
| 169 | 164 | ||
| 170 | const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); | 165 | let completed = false; |
| 171 | const popupPosition = normalizePosition(input.position); | 166 | const complete = (selectedItemId: Option.Option<string>) => { |
| 172 | const popupOptions = Option.match(popupPosition, { | 167 | if (completed) { |
| 173 | onNone: (): Electron.PopupOptions => ({ | 168 | return; |
| 174 | window: input.window, | 169 | } |
| 175 | callback: () => complete(Option.none()), | 170 | completed = true; |
| 176 | }), | 171 | resume(Effect.succeed(selectedItemId)); |
| 177 | onSome: (position): Electron.PopupOptions => ({ | 172 | }; |
| 178 | window: input.window, | 173 | |
| 179 | x: position.x, | 174 | const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); |
| 180 | y: position.y, | 175 | const popupPosition = normalizePosition(input.position); |
| 181 | callback: () => complete(Option.none()), | 176 | const popupOptions = Option.match(popupPosition, { |
| 182 | }), | 177 | onNone: (): Electron.PopupOptions => ({ |
| 183 | }); | 178 | window: input.window, |
| 184 | menu.popup(popupOptions); | 179 | callback: () => complete(Option.none()), |
| 185 | }), | 180 | }), |
| 186 | }); | 181 | onSome: (position): Electron.PopupOptions => ({ |
| 187 | }); | 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 +6,7 @@ import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | 7 | ||
| 8 | import * as Electron from "electron"; | 8 | import * as Electron from "electron"; |
| 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 9 | 10 | ||
| 10 | export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ | 11 | export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ |
| 11 | readonly cause: unknown; | 12 | readonly cause: unknown; |
| ... | @@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWind | ... | @@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWind |
| 37 | ) {} | 38 | ) {} |
| 38 | 39 | ||
| 39 | const make = Effect.gen(function* () { | 40 | const make = Effect.gen(function* () { |
| 41 | const platform = yield* HostProcessPlatform; | ||
| 40 | const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | 42 | const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); |
| 41 | 43 | ||
| 42 | const liveMain = Ref.get(mainWindowRef).pipe( | 44 | const liveMain = Ref.get(mainWindowRef).pipe( |
| ... | @@ -98,7 +100,7 @@ const make = Effect.gen(function* () { | ... | @@ -98,7 +100,7 @@ const make = Effect.gen(function* () { |
| 98 | window.show(); | 100 | window.show(); |
| 99 | } | 101 | } |
| 100 | 102 | ||
| 101 | if (process.platform === "darwin") { | 103 | if (platform === "darwin") { |
| 102 | Electron.app.focus({ steal: true }); | 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,6 +9,7 @@ import * as Option from "effect/Option"; |
| 9 | import * as Electron from "electron"; | 9 | import * as Electron from "electron"; |
| 10 | 10 | ||
| 11 | import * as NetService from "@t3tools/shared/Net"; | 11 | import * as NetService from "@t3tools/shared/Net"; |
| 12 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 12 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | 13 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 13 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | 14 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 14 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | 15 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| ... | @@ -53,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap( | ... | @@ -53,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap( |
| 53 | const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( | 54 | const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( |
| 54 | Effect.flatMap((app) => app.metadata), | 55 | Effect.flatMap((app) => app.metadata), |
| 55 | ); | 56 | ); |
| 57 | const platform = yield* HostProcessPlatform; | ||
| 58 | const processArch = yield* HostProcessArchitecture; | ||
| 56 | return DesktopEnvironment.layer({ | 59 | return DesktopEnvironment.layer({ |
| 57 | dirname: __dirname, | 60 | dirname: __dirname, |
| 58 | homeDirectory: NodeOS.homedir(), | 61 | homeDirectory: NodeOS.homedir(), |
| 59 | platform: process.platform, | 62 | platform, |
| 60 | processArch: process.arch, | 63 | processArch, |
| 61 | ...metadata, | 64 | ...metadata, |
| 62 | }); | 65 | }); |
| 63 | }), | 66 | }), |
apps/desktop/src/window/DesktopWindow.ts+14-9| ... | @@ -82,9 +82,10 @@ function resolveDesktopDevServerUrl( | ... | @@ -82,9 +82,10 @@ function resolveDesktopDevServerUrl( |
| 82 | 82 | ||
| 83 | function getIconOption( | 83 | function getIconOption( |
| 84 | iconPaths: DesktopAssets.DesktopIconPaths, | 84 | iconPaths: DesktopAssets.DesktopIconPaths, |
| 85 | platform: NodeJS.Platform, | ||
| 85 | ): { icon: string } | Record<string, never> { | 86 | ): { icon: string } | Record<string, never> { |
| 86 | if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle | 87 | if (platform === "darwin") return {}; // macOS uses .icns from app bundle |
| 87 | const ext = process.platform === "win32" ? "ico" : "png"; | 88 | const ext = platform === "win32" ? "ico" : "png"; |
| 88 | return Option.match(iconPaths[ext], { | 89 | return Option.match(iconPaths[ext], { |
| 89 | onNone: () => ({}), | 90 | onNone: () => ({}), |
| 90 | onSome: (icon) => ({ icon }), | 91 | onSome: (icon) => ({ icon }), |
| ... | @@ -106,8 +107,11 @@ export function isSameOriginRendererNavigation(input: { | ... | @@ -106,8 +107,11 @@ export function isSameOriginRendererNavigation(input: { |
| 106 | } | 107 | } |
| 107 | } | 108 | } |
| 108 | 109 | ||
| 109 | function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { | 110 | function getWindowTitleBarOptions( |
| 110 | if (process.platform === "darwin") { | 111 | shouldUseDarkColors: boolean, |
| 112 | platform: NodeJS.Platform, | ||
| 113 | ): WindowTitleBarOptions { | ||
| 114 | if (platform === "darwin") { | ||
| 111 | return { | 115 | return { |
| 112 | titleBarStyle: "hiddenInset", | 116 | titleBarStyle: "hiddenInset", |
| 113 | trafficLightPosition: { x: 16, y: 18 }, | 117 | trafficLightPosition: { x: 16, y: 18 }, |
| ... | @@ -127,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO | ... | @@ -127,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO |
| 127 | function syncWindowAppearance( | 131 | function syncWindowAppearance( |
| 128 | window: Electron.BrowserWindow, | 132 | window: Electron.BrowserWindow, |
| 129 | shouldUseDarkColors: boolean, | 133 | shouldUseDarkColors: boolean, |
| 134 | platform: NodeJS.Platform, | ||
| 130 | ): Effect.Effect<void> { | 135 | ): Effect.Effect<void> { |
| 131 | return Effect.sync(() => { | 136 | return Effect.sync(() => { |
| 132 | if (window.isDestroyed()) { | 137 | if (window.isDestroyed()) { |
| ... | @@ -134,7 +139,7 @@ function syncWindowAppearance( | ... | @@ -134,7 +139,7 @@ function syncWindowAppearance( |
| 134 | } | 139 | } |
| 135 | 140 | ||
| 136 | window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); | 141 | window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); |
| 137 | const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); | 142 | const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); |
| 138 | if (typeof titleBarOverlay === "object") { | 143 | if (typeof titleBarOverlay === "object") { |
| 139 | window.setTitleBarOverlay(titleBarOverlay); | 144 | window.setTitleBarOverlay(titleBarOverlay); |
| 140 | } | 145 | } |
| ... | @@ -179,7 +184,7 @@ const make = Effect.gen(function* () { | ... | @@ -179,7 +184,7 @@ const make = Effect.gen(function* () { |
| 179 | ? yield* resolveDesktopDevServerUrl(environment) | 184 | ? yield* resolveDesktopDevServerUrl(environment) |
| 180 | : backendHttpUrl.href; | 185 | : backendHttpUrl.href; |
| 181 | const iconPaths = yield* assets.iconPaths; | 186 | const iconPaths = yield* assets.iconPaths; |
| 182 | const iconOption = getIconOption(iconPaths); | 187 | const iconOption = getIconOption(iconPaths, environment.platform); |
| 183 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | 188 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 184 | const window = yield* electronWindow.create({ | 189 | const window = yield* electronWindow.create({ |
| 185 | width: 1100, | 190 | width: 1100, |
| ... | @@ -191,7 +196,7 @@ const make = Effect.gen(function* () { | ... | @@ -191,7 +196,7 @@ const make = Effect.gen(function* () { |
| 191 | backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), | 196 | backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), |
| 192 | ...iconOption, | 197 | ...iconOption, |
| 193 | title: environment.displayName, | 198 | title: environment.displayName, |
| 194 | ...getWindowTitleBarOptions(shouldUseDarkColors), | 199 | ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), |
| 195 | webPreferences: { | 200 | webPreferences: { |
| 196 | preload: environment.preloadPath, | 201 | preload: environment.preloadPath, |
| 197 | contextIsolation: true, | 202 | contextIsolation: true, |
| ... | @@ -318,7 +323,7 @@ const make = Effect.gen(function* () { | ... | @@ -318,7 +323,7 @@ const make = Effect.gen(function* () { |
| 318 | }); | 323 | }); |
| 319 | 324 | ||
| 320 | const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; | 325 | const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; |
| 321 | if (process.platform === "linux") { | 326 | if (environment.platform === "linux") { |
| 322 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); | 327 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); |
| 323 | } | 328 | } |
| 324 | bindFirstRevealTrigger(revealSubscribers, () => { | 329 | bindFirstRevealTrigger(revealSubscribers, () => { |
| ... | @@ -408,7 +413,7 @@ const make = Effect.gen(function* () { | ... | @@ -408,7 +413,7 @@ const make = Effect.gen(function* () { |
| 408 | syncAppearance: Effect.gen(function* () { | 413 | syncAppearance: Effect.gen(function* () { |
| 409 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | 414 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 410 | yield* electronWindow.syncAllAppearance((window) => | 415 | yield* electronWindow.syncAllAppearance((window) => |
| 411 | syncWindowAppearance(window, shouldUseDarkColors), | 416 | syncWindowAppearance(window, shouldUseDarkColors, environment.platform), |
| 412 | ); | 417 | ); |
| 413 | }).pipe(Effect.withSpan("desktop.window.syncAppearance")), | 418 | }).pipe(Effect.withSpan("desktop.window.syncAppearance")), |
| 414 | }); | 419 | }); |
apps/server/scripts/cli.ts+5-3| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; | 18 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; |
| 19 | import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; | 19 | import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; |
| 20 | import { fromYaml } from "@t3tools/shared/schemaYaml"; | 20 | import { fromYaml } from "@t3tools/shared/schemaYaml"; |
| 21 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 21 | import serverPackageJson from "../package.json" with { type: "json" }; | 22 | import serverPackageJson from "../package.json" with { type: "json" }; |
| 22 | 23 | ||
| 23 | interface PackageJson { | 24 | interface PackageJson { |
| ... | @@ -175,6 +176,7 @@ const buildCmd = Command.make( | ... | @@ -175,6 +176,7 @@ const buildCmd = Command.make( |
| 175 | cwd: serverDir, | 176 | cwd: serverDir, |
| 176 | stdout: config.verbose ? "inherit" : "ignore", | 177 | stdout: config.verbose ? "inherit" : "ignore", |
| 177 | stderr: "inherit", | 178 | stderr: "inherit", |
| 179 | shell: false, | ||
| 178 | }), | 180 | }), |
| 179 | ); | 181 | ); |
| 180 | 182 | ||
| ... | @@ -290,15 +292,15 @@ const publishCmd = Command.make( | ... | @@ -290,15 +292,15 @@ const publishCmd = Command.make( |
| 290 | () => | 292 | () => |
| 291 | Effect.gen(function* () { | 293 | Effect.gen(function* () { |
| 292 | const args = createVpPmPublishArgs(config); | 294 | const args = createVpPmPublishArgs(config); |
| 295 | const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); | ||
| 293 | 296 | ||
| 294 | yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); | 297 | yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); |
| 295 | yield* runCommand( | 298 | yield* runCommand( |
| 296 | ChildProcess.make("vp", ["pm", ...args], { | 299 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 297 | cwd: repoRoot, | 300 | cwd: repoRoot, |
| 298 | stdout: config.verbose ? "inherit" : "ignore", | 301 | stdout: config.verbose ? "inherit" : "ignore", |
| 299 | stderr: "inherit", | 302 | stderr: "inherit", |
| 300 | // Windows needs shell mode to resolve .cmd shims. | 303 | shell: spawnCommand.shell, |
| 301 | shell: process.platform === "win32", | ||
| 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,6 +3,8 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; |
| 3 | import process from "node:process"; | 3 | import process from "node:process"; |
| 4 | import readline from "node:readline"; | 4 | import readline from "node:readline"; |
| 5 | import * as NodeTimers from "node:timers"; | 5 | import * as NodeTimers from "node:timers"; |
| 6 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 6 | 8 | ||
| 7 | type JsonPrimitive = null | boolean | number | string; | 9 | type JsonPrimitive = null | boolean | number | string; |
| 8 | type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; | 10 | type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; |
| ... | @@ -128,9 +130,10 @@ class JsonRpcChild { | ... | @@ -128,9 +130,10 @@ class JsonRpcChild { |
| 128 | closed = false; | 130 | closed = false; |
| 129 | 131 | ||
| 130 | constructor(bin: string, args: string[], cwd: string) { | 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 | cwd, | 135 | cwd, |
| 133 | shell: process.platform === "win32", | 136 | shell: spawnCommand.shell, |
| 134 | stdio: ["pipe", "pipe", "pipe"], | 137 | stdio: ["pipe", "pipe", "pipe"], |
| 135 | env: process.env, | 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,7 +3,7 @@ import * as NFS from "node:fs"; |
| 3 | import * as path from "node:path"; | 3 | import * as path from "node:path"; |
| 4 | import { execFileSync, spawn } from "node:child_process"; | 4 | import { execFileSync, spawn } from "node:child_process"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { assert, it } from "@effect/vitest"; | 6 | import { it } from "@effect/vitest"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Duration from "effect/Duration"; | 9 | import * as Duration from "effect/Duration"; |
| ... | @@ -11,8 +11,9 @@ import * as Effect from "effect/Effect"; | ... | @@ -11,8 +11,9 @@ import * as Effect from "effect/Effect"; |
| 11 | import * as Fiber from "effect/Fiber"; | 11 | import * as Fiber from "effect/Fiber"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; | 12 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | import { vi } from "vite-plus/test"; | 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 | import { assertNone, assertSome } from "@effect/vitest/utils"; | 17 | import { assertNone, assertSome } from "@effect/vitest/utils"; |
| 17 | 18 | ||
| 18 | const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); | 19 | const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); |
| ... | @@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); | ... | @@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); |
| 41 | const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); | 42 | const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); |
| 42 | 43 | ||
| 43 | it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { | 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 | it.effect("reads a bootstrap envelope from a provided fd", () => | 45 | it.effect("reads a bootstrap envelope from a provided fd", () => |
| 53 | Effect.gen(function* () { | 46 | Effect.gen(function* () { |
| 54 | const fs = yield* FileSystem.FileSystem; | 47 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { | ... | @@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { |
| 89 | 82 | ||
| 90 | openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; | 83 | openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; |
| 91 | try { | 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 | assertSome(payload, { | 88 | assertSome(payload, { |
| 94 | mode: "desktop", | 89 | mode: "desktop", |
| 95 | }); | 90 | }); |
apps/server/src/bootstrap.ts+32-31| ... | @@ -11,6 +11,7 @@ import * as Predicate from "effect/Predicate"; | ... | @@ -11,6 +11,7 @@ import * as Predicate from "effect/Predicate"; |
| 11 | import * as Result from "effect/Result"; | 11 | import * as Result from "effect/Result"; |
| 12 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 14 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 14 | 15 | ||
| 15 | class BootstrapError extends Data.TaggedError("BootstrapError")<{ | 16 | class BootstrapError extends Data.TaggedError("BootstrapError")<{ |
| 16 | readonly message: string; | 17 | readonly message: string; |
| ... | @@ -110,36 +111,39 @@ const isFdReady = (fd: number) => | ... | @@ -110,36 +111,39 @@ const isFdReady = (fd: number) => |
| 110 | ); | 111 | ); |
| 111 | 112 | ||
| 112 | const makeBootstrapInputStream = (fd: number) => | 113 | const makeBootstrapInputStream = (fd: number) => |
| 113 | Effect.try<Readable, BootstrapError>({ | 114 | Effect.gen(function* () { |
| 114 | try: () => { | 115 | const platform = yield* HostProcessPlatform; |
| 115 | const fdPath = resolveFdPath(fd); | 116 | return yield* Effect.try<Readable, BootstrapError>({ |
| 116 | if (fdPath === undefined) { | 117 | try: () => { |
| 117 | return makeDirectBootstrapStream(fd); | 118 | const fdPath = resolveFdPath(fd, platform); |
| 118 | } | 119 | if (fdPath === undefined) { |
| 120 | return makeDirectBootstrapStream(fd); | ||
| 121 | } | ||
| 119 | 122 | ||
| 120 | let streamFd: number | undefined; | 123 | let streamFd: number | undefined; |
| 121 | try { | 124 | try { |
| 122 | streamFd = NFS.openSync(fdPath, "r"); | 125 | streamFd = NFS.openSync(fdPath, "r"); |
| 123 | return NFS.createReadStream("", { | 126 | return NFS.createReadStream("", { |
| 124 | fd: streamFd, | 127 | fd: streamFd, |
| 125 | encoding: "utf8", | 128 | encoding: "utf8", |
| 126 | autoClose: true, | 129 | autoClose: true, |
| 127 | }); | 130 | }); |
| 128 | } catch (error) { | 131 | } catch (error) { |
| 129 | if (isBootstrapFdPathDuplicationError(error)) { | 132 | if (isBootstrapFdPathDuplicationError(error)) { |
| 130 | if (streamFd !== undefined) { | 133 | if (streamFd !== undefined) { |
| 131 | NFS.closeSync(streamFd); | 134 | NFS.closeSync(streamFd); |
| 135 | } | ||
| 136 | return makeDirectBootstrapStream(fd); | ||
| 132 | } | 137 | } |
| 133 | return makeDirectBootstrapStream(fd); | 138 | throw error; |
| 134 | } | 139 | } |
| 135 | throw error; | 140 | }, |
| 136 | } | 141 | catch: (error) => |
| 137 | }, | 142 | new BootstrapError({ |
| 138 | catch: (error) => | 143 | message: "Failed to duplicate bootstrap fd.", |
| 139 | new BootstrapError({ | 144 | cause: error, |
| 140 | message: "Failed to duplicate bootstrap fd.", | 145 | }), |
| 141 | cause: error, | 146 | }); |
| 142 | }), | ||
| 143 | }); | 147 | }); |
| 144 | 148 | ||
| 145 | const makeDirectBootstrapStream = (fd: number): Readable => { | 149 | const makeDirectBootstrapStream = (fd: number): Readable => { |
| ... | @@ -165,10 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( | ... | @@ -165,10 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( |
| 165 | (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", | 169 | (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", |
| 166 | ); | 170 | ); |
| 167 | 171 | ||
| 168 | export function resolveFdPath( | 172 | function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { |
| 169 | fd: number, | ||
| 170 | platform: NodeJS.Platform = process.platform, | ||
| 171 | ): string | undefined { | ||
| 172 | if (platform === "linux") { | 173 | if (platform === "linux") { |
| 173 | return `/proc/self/fd/${fd}`; | 174 | return `/proc/self/fd/${fd}`; |
| 174 | } | 175 | } |
apps/server/src/cli/config.test.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import NodeOS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | 2 | ||
| 3 | import { assert, expect, it } from "@effect/vitest"; | 3 | import { assert, expect, it } from "@effect/vitest"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
apps/server/src/diagnostics/ProcessDiagnostics.ts+10-4| ... | @@ -4,6 +4,7 @@ import type { | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | ServerProcessSignal, | 4 | ServerProcessSignal, |
| 5 | ServerSignalProcessResult, | 5 | ServerSignalProcessResult, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 8 | import * as DateTime from "effect/DateTime"; | 9 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Duration from "effect/Duration"; | 10 | import * as Duration from "effect/Duration"; |
| ... | @@ -277,6 +278,9 @@ const runProcess = Effect.fn("runProcess")( | ... | @@ -277,6 +278,9 @@ const runProcess = Effect.fn("runProcess")( |
| 277 | readonly errorMessage: string; | 278 | readonly errorMessage: string; |
| 278 | }) { | 279 | }) { |
| 279 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 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 | const child = yield* spawner.spawn( | 284 | const child = yield* spawner.spawn( |
| 281 | ChildProcess.make(input.command, input.args, { | 285 | ChildProcess.make(input.command, input.args, { |
| 282 | cwd: process.cwd(), | 286 | cwd: process.cwd(), |
| ... | @@ -369,8 +373,10 @@ function readWindowsProcessRows(): Effect.Effect< | ... | @@ -369,8 +373,10 @@ function readWindowsProcessRows(): Effect.Effect< |
| 369 | ); | 373 | ); |
| 370 | } | 374 | } |
| 371 | 375 | ||
| 372 | export const readProcessRows = (platform = process.platform) => | 376 | export const readProcessRows = Effect.gen(function* () { |
| 373 | platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); | 377 | const platform = yield* HostProcessPlatform; |
| 378 | return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); | ||
| 379 | }); | ||
| 374 | 380 | ||
| 375 | export function aggregateProcessDiagnostics(input: { | 381 | export function aggregateProcessDiagnostics(input: { |
| 376 | readonly serverPid: number; | 382 | readonly serverPid: number; |
| ... | @@ -387,7 +393,7 @@ function assertDescendantPid( | ... | @@ -387,7 +393,7 @@ function assertDescendantPid( |
| 387 | return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); | 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 | Effect.flatMap((rows) => { | 397 | Effect.flatMap((rows) => { |
| 392 | const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); | 398 | const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); |
| 393 | const descendant = buildDescendantEntries(filteredRows, process.pid).some( | 399 | const descendant = buildDescendantEntries(filteredRows, process.pid).some( |
| ... | @@ -407,7 +413,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { | ... | @@ -407,7 +413,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { |
| 407 | 413 | ||
| 408 | const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { | 414 | const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { |
| 409 | const readAt = yield* DateTime.now; | 415 | const readAt = yield* DateTime.now; |
| 410 | const rows = yield* readProcessRows().pipe( | 416 | const rows = yield* readProcessRows.pipe( |
| 411 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 417 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 412 | ); | 418 | ); |
| 413 | return makeResult({ serverPid: process.pid, rows, readAt }); | 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,7 +252,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { |
| 252 | const sampleOnce = Effect.gen(function* () { | 252 | const sampleOnce = Effect.gen(function* () { |
| 253 | const sampledAt = yield* DateTime.now; | 253 | const sampledAt = yield* DateTime.now; |
| 254 | const sampledAtMs = DateTime.toEpochMillis(sampledAt); | 254 | const sampledAtMs = DateTime.toEpochMillis(sampledAt); |
| 255 | const rows = yield* readProcessRows().pipe( | 255 | const rows = yield* readProcessRows.pipe( |
| 256 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 256 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 257 | ); | 257 | ); |
| 258 | const samples = collectMonitoredSamples({ | 258 | const samples = collectMonitoredSamples({ |
apps/server/src/environment/Layers/ServerEnvironment.ts+11-6| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; |
| 2 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | 3 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -11,8 +12,8 @@ import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/Serv | ... | @@ -11,8 +12,8 @@ import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/Serv |
| 11 | import packageJson from "../../../package.json" with { type: "json" }; | 12 | import packageJson from "../../../package.json" with { type: "json" }; |
| 12 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | 13 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| 13 | 14 | ||
| 14 | function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | 15 | function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { |
| 15 | switch (process.platform) { | 16 | switch (platform) { |
| 16 | case "darwin": | 17 | case "darwin": |
| 17 | return "darwin"; | 18 | return "darwin"; |
| 18 | case "linux": | 19 | case "linux": |
| ... | @@ -24,8 +25,10 @@ function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | ... | @@ -24,8 +25,10 @@ function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { |
| 24 | } | 25 | } |
| 25 | } | 26 | } |
| 26 | 27 | ||
| 27 | function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { | 28 | function platformArch( |
| 28 | switch (process.arch) { | 29 | architecture: NodeJS.Architecture, |
| 30 | ): ExecutionEnvironmentDescriptor["platform"]["arch"] { | ||
| 31 | switch (architecture) { | ||
| 29 | case "arm64": | 32 | case "arm64": |
| 30 | return "arm64"; | 33 | return "arm64"; |
| 31 | case "x64": | 34 | case "x64": |
| ... | @@ -40,6 +43,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function | ... | @@ -40,6 +43,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 40 | const path = yield* Path.Path; | 43 | const path = yield* Path.Path; |
| 41 | const serverConfig = yield* ServerConfig; | 44 | const serverConfig = yield* ServerConfig; |
| 42 | const crypto = yield* Crypto.Crypto; | 45 | const crypto = yield* Crypto.Crypto; |
| 46 | const hostPlatform = yield* HostProcessPlatform; | ||
| 47 | const hostArchitecture = yield* HostProcessArchitecture; | ||
| 43 | 48 | ||
| 44 | const readPersistedEnvironmentId = Effect.gen(function* () { | 49 | const readPersistedEnvironmentId = Effect.gen(function* () { |
| 45 | const exists = yield* fileSystem | 50 | const exists = yield* fileSystem |
| ... | @@ -80,8 +85,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function | ... | @@ -80,8 +85,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 80 | environmentId, | 85 | environmentId, |
| 81 | label, | 86 | label, |
| 82 | platform: { | 87 | platform: { |
| 83 | os: platformOs(), | 88 | os: platformOs(hostPlatform), |
| 84 | arch: platformArch(), | 89 | arch: platformArch(hostArchitecture), |
| 85 | }, | 90 | }, |
| 86 | serverVersion: packageJson.version, | 91 | serverVersion: packageJson.version, |
| 87 | capabilities: { | 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,6 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 5 | import { vi } from "vite-plus/test"; | 6 | import { vi } from "vite-plus/test"; |
| 6 | 7 | ||
| 7 | import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; | 8 | import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; |
| ... | @@ -28,6 +29,16 @@ const LinuxMachineInfoLayer = Layer.merge( | ... | @@ -28,6 +29,16 @@ const LinuxMachineInfoLayer = Layer.merge( |
| 28 | : Effect.succeed(""), | 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 | afterEach(() => { | 43 | afterEach(() => { |
| 33 | runMock.mockReset(); | 44 | runMock.mockReset(); |
| ... | @@ -38,9 +49,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -38,9 +49,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 38 | Effect.gen(function* () { | 49 | Effect.gen(function* () { |
| 39 | const result = yield* resolveServerEnvironmentLabel({ | 50 | const result = yield* resolveServerEnvironmentLabel({ |
| 40 | cwdBaseName: "t3code", | 51 | cwdBaseName: "t3code", |
| 41 | platform: "win32", | 52 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); |
| 42 | hostname: "macbook-pro", | ||
| 43 | }).pipe(Effect.provide(TestLayer)); | ||
| 44 | 53 | ||
| 45 | expect(result).toBe("macbook-pro"); | 54 | expect(result).toBe("macbook-pro"); |
| 46 | }), | 55 | }), |
| ... | @@ -61,9 +70,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -61,9 +70,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 61 | 70 | ||
| 62 | const result = yield* resolveServerEnvironmentLabel({ | 71 | const result = yield* resolveServerEnvironmentLabel({ |
| 63 | cwdBaseName: "t3code", | 72 | cwdBaseName: "t3code", |
| 64 | platform: "darwin", | 73 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); |
| 65 | hostname: "macbook-pro", | ||
| 66 | }).pipe(Effect.provide(TestLayer)); | ||
| 67 | 74 | ||
| 68 | expect(result).toBe("Julius's MacBook Pro"); | 75 | expect(result).toBe("Julius's MacBook Pro"); |
| 69 | expect(runMock).toHaveBeenCalledWith( | 76 | expect(runMock).toHaveBeenCalledWith( |
| ... | @@ -80,9 +87,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -80,9 +87,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 80 | Effect.gen(function* () { | 87 | Effect.gen(function* () { |
| 81 | const result = yield* resolveServerEnvironmentLabel({ | 88 | const result = yield* resolveServerEnvironmentLabel({ |
| 82 | cwdBaseName: "t3code", | 89 | cwdBaseName: "t3code", |
| 83 | platform: "linux", | 90 | }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); |
| 84 | hostname: "buildbox", | ||
| 85 | }).pipe(Effect.provide(LinuxMachineInfoLayer)); | ||
| 86 | 91 | ||
| 87 | expect(result).toBe("Build Agent 01"); | 92 | expect(result).toBe("Build Agent 01"); |
| 88 | expect(runMock).not.toHaveBeenCalled(); | 93 | expect(runMock).not.toHaveBeenCalled(); |
| ... | @@ -104,9 +109,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -104,9 +109,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 104 | 109 | ||
| 105 | const result = yield* resolveServerEnvironmentLabel({ | 110 | const result = yield* resolveServerEnvironmentLabel({ |
| 106 | cwdBaseName: "t3code", | 111 | cwdBaseName: "t3code", |
| 107 | platform: "linux", | 112 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); |
| 108 | hostname: "runner-01", | ||
| 109 | }).pipe(Effect.provide(TestLayer)); | ||
| 110 | 113 | ||
| 111 | expect(result).toBe("CI Runner"); | 114 | expect(result).toBe("CI Runner"); |
| 112 | expect(runMock).toHaveBeenCalledWith( | 115 | expect(runMock).toHaveBeenCalledWith( |
| ... | @@ -123,9 +126,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -123,9 +126,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 123 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 124 | const result = yield* resolveServerEnvironmentLabel({ | 127 | const result = yield* resolveServerEnvironmentLabel({ |
| 125 | cwdBaseName: "t3code", | 128 | cwdBaseName: "t3code", |
| 126 | platform: "win32", | 129 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); |
| 127 | hostname: "JULIUS-LAPTOP", | ||
| 128 | }).pipe(Effect.provide(TestLayer)); | ||
| 129 | 130 | ||
| 130 | expect(result).toBe("JULIUS-LAPTOP"); | 131 | expect(result).toBe("JULIUS-LAPTOP"); |
| 131 | }), | 132 | }), |
| ... | @@ -145,9 +146,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -145,9 +146,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 145 | 146 | ||
| 146 | const result = yield* resolveServerEnvironmentLabel({ | 147 | const result = yield* resolveServerEnvironmentLabel({ |
| 147 | cwdBaseName: "t3code", | 148 | cwdBaseName: "t3code", |
| 148 | platform: "darwin", | 149 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); |
| 149 | hostname: "macbook-pro", | ||
| 150 | }).pipe(Effect.provide(TestLayer)); | ||
| 151 | 150 | ||
| 152 | expect(result).toBe("macbook-pro"); | 151 | expect(result).toBe("macbook-pro"); |
| 153 | }), | 152 | }), |
| ... | @@ -168,9 +167,7 @@ describe("resolveServerEnvironmentLabel", () => { | ... | @@ -168,9 +167,7 @@ describe("resolveServerEnvironmentLabel", () => { |
| 168 | 167 | ||
| 169 | const result = yield* resolveServerEnvironmentLabel({ | 168 | const result = yield* resolveServerEnvironmentLabel({ |
| 170 | cwdBaseName: "t3code", | 169 | cwdBaseName: "t3code", |
| 171 | platform: "linux", | 170 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); |
| 172 | hostname: " ", | ||
| 173 | }).pipe(Effect.provide(TestLayer)); | ||
| 174 | 171 | ||
| 175 | expect(result).toBe("t3code"); | 172 | expect(result).toBe("t3code"); |
| 176 | }), | 173 | }), |
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+5-10| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import * as OS from "node:os"; | 1 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| ... | @@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts"; | ... | @@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts"; |
| 8 | 7 | ||
| 9 | interface ResolveServerEnvironmentLabelInput { | 8 | interface ResolveServerEnvironmentLabelInput { |
| 10 | readonly cwdBaseName: string; | 9 | readonly cwdBaseName: string; |
| 11 | readonly platform?: NodeJS.Platform; | ||
| 12 | readonly hostname?: string | null; | ||
| 13 | } | 10 | } |
| 14 | 11 | ||
| 15 | function normalizeLabel(value: string | null | undefined): string | null { | 12 | function normalizeLabel(value: string | null | undefined): string | null { |
| ... | @@ -69,9 +66,8 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( | ... | @@ -69,9 +66,8 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( |
| 69 | return normalizeLabel(result.value.stdout); | 66 | return normalizeLabel(result.value.stdout); |
| 70 | }); | 67 | }); |
| 71 | 68 | ||
| 72 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( | 69 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { |
| 73 | platform: NodeJS.Platform, | 70 | const platform = yield* HostProcessPlatform; |
| 74 | ) { | ||
| 75 | if (platform === "darwin") { | 71 | if (platform === "darwin") { |
| 76 | return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); | 72 | return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); |
| 77 | } | 73 | } |
| ... | @@ -94,13 +90,12 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* | ... | @@ -94,13 +90,12 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* |
| 94 | export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( | 90 | export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( |
| 95 | input: ResolveServerEnvironmentLabelInput, | 91 | input: ResolveServerEnvironmentLabelInput, |
| 96 | ) { | 92 | ) { |
| 97 | const platform = input.platform ?? process.platform; | 93 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(); |
| 98 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); | ||
| 99 | if (friendlyHostLabel) { | 94 | if (friendlyHostLabel) { |
| 100 | return friendlyHostLabel; | 95 | return friendlyHostLabel; |
| 101 | } | 96 | } |
| 102 | 97 | ||
| 103 | const hostname = normalizeLabel(input.hostname ?? OS.hostname()); | 98 | const hostname = normalizeLabel(yield* HostProcessHostname); |
| 104 | if (hostname) { | 99 | if (hostname) { |
| 105 | return hostname; | 100 | return hostname; |
| 106 | } | 101 | } |
apps/server/src/os-jank.ts+54-66| ... | @@ -1,21 +1,15 @@ | ... | @@ -1,21 +1,15 @@ |
| 1 | import * as NodeOS from "node:os"; | 1 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Path from "effect/Path"; | ||
| 4 | import { | 2 | import { |
| 5 | readPathFromLoginShell, | ||
| 6 | readEnvironmentFromWindowsShell, | ||
| 7 | resolveWindowsEnvironment, | ||
| 8 | type CommandAvailabilityOptions, | ||
| 9 | type WindowsShellEnvironmentReader, | ||
| 10 | listLoginShellCandidates, | 3 | listLoginShellCandidates, |
| 11 | mergePathEntries, | 4 | mergePathEntries, |
| 5 | readPathFromLoginShell, | ||
| 12 | readPathFromLaunchctl, | 6 | readPathFromLaunchctl, |
| 7 | resolveWindowsEnvironment, | ||
| 13 | } from "@t3tools/shared/shell"; | 8 | } from "@t3tools/shared/shell"; |
| 14 | 9 | import * as Effect from "effect/Effect"; | |
| 15 | type WindowsCommandAvailabilityChecker = ( | 10 | import * as FileSystem from "effect/FileSystem"; |
| 16 | command: string, | 11 | import * as Path from "effect/Path"; |
| 17 | options?: CommandAvailabilityOptions, | 12 | import * as NodeOS from "node:os"; |
| 18 | ) => boolean; | ||
| 19 | 13 | ||
| 20 | function logPathHydrationWarning(message: string, error?: unknown): void { | 14 | function logPathHydrationWarning(message: string, error?: unknown): void { |
| 21 | process.stderr.write( | 15 | process.stderr.write( |
| ... | @@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void { | ... | @@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void { |
| 23 | ); | 17 | ); |
| 24 | } | 18 | } |
| 25 | 19 | ||
| 26 | export function fixPath( | 20 | function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { |
| 27 | options: { | 21 | let shellPath: string | undefined; |
| 28 | env?: NodeJS.ProcessEnv; | 22 | for (const shell of listLoginShellCandidates(platform, env.SHELL)) { |
| 29 | platform?: NodeJS.Platform; | 23 | try { |
| 30 | readPath?: typeof readPathFromLoginShell; | 24 | shellPath = readPathFromLoginShell(shell); |
| 31 | readWindowsEnvironment?: WindowsShellEnvironmentReader; | 25 | } catch (error) { |
| 32 | isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; | 26 | logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); |
| 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; | ||
| 57 | } | 27 | } |
| 58 | 28 | ||
| 59 | if (platform !== "darwin" && platform !== "linux") return; | 29 | if (shellPath) break; |
| 30 | } | ||
| 60 | 31 | ||
| 61 | let shellPath: string | undefined; | 32 | const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; |
| 62 | for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { | 33 | const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); |
| 63 | try { | 34 | if (mergedPath) { |
| 64 | shellPath = readPath(shell); | 35 | env.PATH = mergedPath; |
| 65 | } catch (error) { | 36 | } |
| 66 | logWarning(`Failed to read PATH from login shell ${shell}.`, error); | 37 | } |
| 67 | } | ||
| 68 | 38 | ||
| 69 | if (shellPath) { | 39 | export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< |
| 70 | break; | 40 | void, |
| 71 | } | 41 | never, |
| 72 | } | 42 | FileSystem.FileSystem | Path.Path |
| 43 | > { | ||
| 44 | const platform = yield* HostProcessPlatform; | ||
| 45 | const env = yield* HostProcessEnvironment; | ||
| 73 | 46 | ||
| 74 | const launchctlPath = | 47 | if (platform === "win32") { |
| 75 | platform === "darwin" && !shellPath | 48 | const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( |
| 76 | ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() | 49 | Effect.catchDefect((defect) => |
| 77 | : undefined; | 50 | Effect.sync(() => { |
| 78 | const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); | 51 | logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); |
| 79 | if (mergedPath) { | 52 | return {} as Partial<NodeJS.ProcessEnv>; |
| 80 | env.PATH = mergedPath; | 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) { | 61 | return; |
| 83 | logWarning("Failed to hydrate PATH from the user environment.", error); | ||
| 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 | export const expandHomePath = Effect.fn(function* (input: string) { | 75 | export const expandHomePath = Effect.fn(function* (input: string) { |
| 88 | const { join } = yield* Path.Path; | 76 | const { join } = yield* Path.Path; |
apps/server/src/preview/PortScanner.test.ts+8-186| ... | @@ -1,21 +1,20 @@ | ... | @@ -1,21 +1,20 @@ |
| 1 | import * as net from "node:net"; | 1 | import * as net from "node:net"; |
| 2 | 2 | ||
| 3 | import { it as effectIt } from "@effect/vitest"; | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { ThreadId } from "@t3tools/contracts"; | 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 5 | import * as Net from "@t3tools/shared/Net"; | 5 | import * as Net from "@t3tools/shared/Net"; |
| 6 | import { Effect, Layer } from "effect"; | 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 | import { ProcessRunner } from "../processRunner.ts"; | 9 | import { ProcessRunner } from "../processRunner.ts"; |
| 10 | import * as PortScanner from "./PortScanner.ts"; | 10 | import * as PortScanner from "./PortScanner.ts"; |
| 11 | |||
| 12 | const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = | ||
| 13 | PortScanner.__testing; | ||
| 14 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | 11 | const TestProcessRunner = Layer.succeed(ProcessRunner, { |
| 15 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | 12 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 16 | }); | 13 | }); |
| 17 | const TestPortDiscoveryLive = PortScanner.layer.pipe( | 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 | const openServer = (port: number): Effect.Effect<net.Server | null> => | 20 | const openServer = (port: number): Effect.Effect<net.Server | null> => |
| ... | @@ -37,21 +36,6 @@ const closeServer = (server: net.Server): Effect.Effect<void> => | ... | @@ -37,21 +36,6 @@ const closeServer = (server: net.Server): Effect.Effect<void> => |
| 37 | server.close(() => resume(Effect.void)); | 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 | const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( | 39 | const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( |
| 56 | ports: ReadonlyArray<number>, | 40 | ports: ReadonlyArray<number>, |
| 57 | ) { | 41 | ) { |
| ... | @@ -69,176 +53,15 @@ const commonDevServer = Effect.acquireRelease( | ... | @@ -69,176 +53,15 @@ const commonDevServer = Effect.acquireRelease( |
| 69 | ({ server }) => closeServer(server), | 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 | 57 | * Integration tests against a real TCP listener. We provide the Windows host |
| 234 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the | 58 | * platform so the tests exercise the TCP-probe fallback without depending on |
| 235 | * duration of the test so we don't depend on `lsof` being installed. | 59 | * `lsof` being installed. |
| 236 | */ | 60 | */ |
| 237 | effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { | 61 | effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { |
| 238 | it.effect( | 62 | it.effect( |
| 239 | "scan() returns a server we just opened on a curated dev port", | 63 | "scan() returns a server we just opened on a curated dev port", |
| 240 | Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { | 64 | Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { |
| 241 | yield* windowsPlatform; | ||
| 242 | const { port } = yield* commonDevServer; | 65 | const { port } = yield* commonDevServer; |
| 243 | const scanner = yield* PortScanner.PortDiscovery; | 66 | const scanner = yield* PortScanner.PortDiscovery; |
| 244 | const result = yield* scanner.scan(); | 67 | const result = yield* scanner.scan(); |
| ... | @@ -251,7 +74,6 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall | ... | @@ -251,7 +74,6 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall |
| 251 | it.effect( | 74 | it.effect( |
| 252 | "retain drives an immediate broadcast to subscribers", | 75 | "retain drives an immediate broadcast to subscribers", |
| 253 | Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { | 76 | Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { |
| 254 | yield* windowsPlatform; | ||
| 255 | const { port } = yield* commonDevServer; | 77 | const { port } = yield* commonDevServer; |
| 256 | const received: number[] = []; | 78 | const received: number[] = []; |
| 257 | const scanner = yield* PortScanner.PortDiscovery; | 79 | const scanner = yield* PortScanner.PortDiscovery; |
apps/server/src/preview/PortScanner.ts+3-9| ... | @@ -12,6 +12,7 @@ | ... | @@ -12,6 +12,7 @@ |
| 12 | * polls forever, but each tick is a no-op when the retain count is zero. | 12 | * polls forever, but each tick is a no-op when the retain count is zero. |
| 13 | */ | 13 | */ |
| 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 15 | import * as Net from "@t3tools/shared/Net"; | 16 | import * as Net from "@t3tools/shared/Net"; |
| 16 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; |
| 17 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; | 18 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; |
| ... | @@ -182,6 +183,7 @@ const serversEqual = ( | ... | @@ -182,6 +183,7 @@ const serversEqual = ( |
| 182 | const make = Effect.gen(function* PortDiscoveryMake() { | 183 | const make = Effect.gen(function* PortDiscoveryMake() { |
| 183 | const net = yield* Net.NetService; | 184 | const net = yield* Net.NetService; |
| 184 | const processRunner = yield* ProcessRunner; | 185 | const processRunner = yield* ProcessRunner; |
| 186 | const hostPlatform = yield* HostProcessPlatform; | ||
| 185 | const stateRef = yield* Ref.make<ScannerState>({ | 187 | const stateRef = yield* Ref.make<ScannerState>({ |
| 186 | lastSnapshot: [], | 188 | lastSnapshot: [], |
| 187 | listeners: new Set(), | 189 | listeners: new Set(), |
| ... | @@ -221,7 +223,7 @@ const make = Effect.gen(function* PortDiscoveryMake() { | ... | @@ -221,7 +223,7 @@ const make = Effect.gen(function* PortDiscoveryMake() { |
| 221 | terminalByProcessId.set(processId, registration.owner); | 223 | terminalByProcessId.set(processId, registration.owner); |
| 222 | } | 224 | } |
| 223 | } | 225 | } |
| 224 | if (process.platform === "win32") { | 226 | if (hostPlatform === "win32") { |
| 225 | const command = | 227 | const command = |
| 226 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; | 228 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; |
| 227 | const listeners = yield* processRunner | 229 | const listeners = yield* processRunner |
| ... | @@ -359,11 +361,3 @@ const make = Effect.gen(function* PortDiscoveryMake() { | ... | @@ -359,11 +361,3 @@ const make = Effect.gen(function* PortDiscoveryMake() { |
| 359 | }).pipe(Effect.withSpan("PortDiscovery.make")); | 361 | }).pipe(Effect.withSpan("PortDiscovery.make")); |
| 360 | 362 | ||
| 361 | export const layer = Layer.effect(PortDiscovery, make); | 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,9 +1,7 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import { assertSuccess } from "@effect/vitest/utils"; | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | import * as Crypto from "effect/Crypto"; | ||
| 5 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 9 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| ... | @@ -11,24 +9,9 @@ import * as Sink from "effect/Sink"; | ... | @@ -11,24 +9,9 @@ import * as Sink from "effect/Sink"; |
| 11 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 11 | ||
| 14 | import { | 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 15 | isCommandAvailable, | 13 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 16 | launchBrowser, | 14 | import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; |
| 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 | } | ||
| 32 | 15 | ||
| 33 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { | 16 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { |
| 34 | return ChildProcessSpawner.makeHandle({ | 17 | return ChildProcessSpawner.makeHandle({ |
| ... | @@ -49,756 +32,135 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { | ... | @@ -49,756 +32,135 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { |
| 49 | }); | 32 | }); |
| 50 | } | 33 | } |
| 51 | 34 | ||
| 52 | it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { | 35 | const testLayer = (input: { |
| 53 | it.effect("returns commands for command-based editors", () => | 36 | readonly platform: NodeJS.Platform; |
| 54 | Effect.gen(function* () { | 37 | readonly env?: Record<string, string>; |
| 55 | const antigravityLaunch = yield* resolveEditorLaunch( | 38 | readonly resolveExecutable?: (command: string) => string | undefined; |
| 56 | { cwd: "/tmp/workspace", editor: "antigravity" }, | 39 | readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; |
| 57 | "darwin", | 40 | readonly onUnref?: () => void; |
| 58 | { PATH: "" }, | 41 | }) => { |
| 59 | ); | 42 | const spawnerLayer = Layer.succeed( |
| 60 | assert.deepEqual(antigravityLaunch, { | 43 | ChildProcessSpawner.ChildProcessSpawner, |
| 61 | command: "agy", | 44 | ChildProcessSpawner.make((command) => |
| 62 | args: ["/tmp/workspace"], | 45 | Effect.sync(() => { |
| 63 | }); | 46 | assert.equal(ChildProcess.isStandardCommand(command), true); |
| 64 | 47 | if (!ChildProcess.isStandardCommand(command)) { | |
| 65 | const cursorLaunch = yield* resolveEditorLaunch( | 48 | throw new Error("Expected a standard command"); |
| 66 | { cwd: "/tmp/workspace", editor: "cursor" }, | 49 | } |
| 67 | "darwin", | 50 | input.onSpawn?.(command); |
| 68 | { PATH: "" }, | 51 | return makeMockDetachedHandle(input.onUnref); |
| 69 | ); | 52 | }), |
| 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'", | ||
| 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", () => | 56 | return Layer.mergeAll( |
| 702 | Effect.gen(function* () { | 57 | ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), |
| 703 | const fs = yield* FileSystem.FileSystem; | 58 | Layer.succeed(HostProcessPlatform, input.platform), |
| 704 | const path = yield* Path.Path; | 59 | Layer.succeed( |
| 705 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | 60 | SpawnExecutableResolution, |
| 706 | yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); | 61 | (command) => input.resolveExecutable?.(command) ?? command, |
| 707 | const env = { | 62 | ), |
| 708 | PATH: dir, | 63 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), |
| 709 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | ||
| 710 | } satisfies NodeJS.ProcessEnv; | ||
| 711 | assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); | ||
| 712 | }), | ||
| 713 | ); | 64 | ); |
| 714 | 65 | }; | |
| 715 | it.effect("uses platform-specific PATH delimiter for platform overrides", () => | 66 | |
| 716 | Effect.gen(function* () { | 67 | it.effect("launches the default browser through the platform command", () => { |
| 717 | const fs = yield* FileSystem.FileSystem; | 68 | let spawned: ChildProcess.StandardCommand | undefined; |
| 718 | const path = yield* Path.Path; | 69 | let didUnref = false; |
| 719 | const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | 70 | return Effect.gen(function* () { |
| 720 | const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); | 71 | const launcher = yield* ExternalLauncher; |
| 721 | yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); | 72 | |
| 722 | yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); | 73 | yield* launcher.launchBrowser("https://example.com/some path"); |
| 723 | const env = { | 74 | |
| 724 | PATH: `${firstDir};${secondDir}`, | 75 | assert.ok(spawned); |
| 725 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | 76 | assert.equal(spawned.command, "xdg-open"); |
| 726 | } satisfies NodeJS.ProcessEnv; | 77 | assert.deepEqual(spawned.args, ["https://example.com/some path"]); |
| 727 | assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); | 78 | assert.equal(spawned.options.detached, true); |
| 728 | }), | 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) => { | 95 | it.effect("launches an installed editor with platform-safe arguments", () => |
| 733 | it.effect("returns installed editors for command launches", () => | 96 | Effect.gen(function* () { |
| 734 | Effect.gen(function* () { | 97 | const fileSystem = yield* FileSystem.FileSystem; |
| 735 | const fs = yield* FileSystem.FileSystem; | 98 | const path = yield* Path.Path; |
| 736 | const path = yield* Path.Path; | 99 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); |
| 737 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | 100 | yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); |
| 738 | 101 | ||
| 739 | yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); | 102 | let spawned: ChildProcess.StandardCommand | undefined; |
| 740 | yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); | 103 | yield* Effect.gen(function* () { |
| 741 | yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); | 104 | const launcher = yield* ExternalLauncher; |
| 742 | yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); | 105 | yield* launcher.launchEditor({ |
| 743 | yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); | 106 | editor: "vscode", |
| 744 | yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); | 107 | cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", |
| 745 | yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); | 108 | }); |
| 746 | yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); | 109 | }).pipe( |
| 747 | yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); | 110 | Effect.provide( |
| 748 | yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); | 111 | testLayer({ |
| 749 | yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); | 112 | platform: "win32", |
| 750 | yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); | 113 | env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, |
| 751 | yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); | 114 | resolveExecutable: (command) => |
| 752 | yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); | 115 | command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, |
| 753 | yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); | 116 | onSpawn: (command) => { |
| 754 | yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); | 117 | spawned = command; |
| 755 | const editors = resolveAvailableEditors("win32", { | 118 | }, |
| 756 | PATH: dir, | 119 | }), |
| 757 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | 120 | ), |
| 758 | }); | 121 | ); |
| 759 | assert.deepEqual(editors, [ | 122 | |
| 760 | "trae", | 123 | assert.ok(spawned); |
| 761 | "kiro", | 124 | assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); |
| 762 | "vscode-insiders", | 125 | assert.deepEqual(spawned.args, [ |
| 763 | "vscodium", | 126 | '^"--goto^"', |
| 764 | "aqua", | 127 | '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', |
| 765 | "clion", | 128 | ]); |
| 766 | "datagrip", | 129 | assert.equal(spawned.options.shell, true); |
| 767 | "dataspell", | 130 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 768 | "goland", | 131 | ); |
| 769 | "phpstorm", | 132 | |
| 770 | "pycharm", | 133 | it.effect("discovers editors through the service API", () => |
| 771 | "rider", | 134 | Effect.gen(function* () { |
| 772 | "rubymine", | 135 | const fileSystem = yield* FileSystem.FileSystem; |
| 773 | "rustrover", | 136 | const path = yield* Path.Path; |
| 774 | "webstorm", | 137 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); |
| 775 | "file-manager", | 138 | yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); |
| 776 | ]); | 139 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); |
| 777 | }), | 140 | |
| 778 | ); | 141 | const editors = yield* Effect.gen(function* () { |
| 779 | 142 | const launcher = yield* ExternalLauncher; | |
| 780 | it.effect("includes zed when only the zeditor command is installed", () => | 143 | return yield* launcher.resolveAvailableEditors(); |
| 781 | Effect.gen(function* () { | 144 | }).pipe( |
| 782 | const fs = yield* FileSystem.FileSystem; | 145 | Effect.provide( |
| 783 | const path = yield* Path.Path; | 146 | testLayer({ |
| 784 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | 147 | platform: "win32", |
| 785 | 148 | env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | |
| 786 | yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); | 149 | }), |
| 787 | yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); | 150 | ), |
| 788 | yield* fs.chmod(path.join(dir, "zeditor"), 0o755); | 151 | ); |
| 789 | yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); | 152 | |
| 790 | 153 | assert.equal(editors.includes("vscode"), true); | |
| 791 | const editors = resolveAvailableEditors("linux", { | 154 | assert.equal(editors.includes("file-manager"), true); |
| 792 | PATH: dir, | 155 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 793 | }); | 156 | ); |
| 794 | assert.deepEqual(editors, ["zed", "file-manager"]); | 157 | |
| 795 | }), | 158 | it.effect("rejects unknown editors through the service API", () => |
| 796 | ); | 159 | Effect.gen(function* () { |
| 797 | 160 | const launcher = yield* ExternalLauncher; | |
| 798 | it("omits file-manager when the platform opener is unavailable", () => { | 161 | const result = yield* launcher |
| 799 | const editors = resolveAvailableEditors("linux", { | 162 | .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) |
| 800 | PATH: "", | 163 | .pipe(Effect.result); |
| 801 | }); | 164 | assert.equal(result._tag, "Failure"); |
| 802 | assert.deepEqual(editors, []); | 165 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), |
| 803 | }); | 166 | ); |
| 804 | }); |
apps/server/src/process/externalLauncher.ts+103-37| ... | @@ -12,12 +12,16 @@ import { | ... | @@ -12,12 +12,16 @@ import { |
| 12 | type EditorId, | 12 | type EditorId, |
| 13 | type LaunchEditorInput, | 13 | type LaunchEditorInput, |
| 14 | } from "@t3tools/contracts"; | 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 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| 17 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 18 | import * as Encoding from "effect/Encoding"; | 20 | import * as Encoding from "effect/Encoding"; |
| 21 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Layer from "effect/Layer"; | 22 | import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; | 23 | import * as Option from "effect/Option"; |
| 24 | import * as Path from "effect/Path"; | ||
| 21 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 25 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | 26 | ||
| 23 | // ============================== | 27 | // ============================== |
| ... | @@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 26 | 30 | ||
| 27 | export { ExternalLauncherError }; | 31 | export { ExternalLauncherError }; |
| 28 | export type { LaunchEditorInput }; | 32 | export type { LaunchEditorInput }; |
| 29 | export { isCommandAvailable } from "@t3tools/shared/shell"; | ||
| 30 | |||
| 31 | interface EditorLaunch { | 33 | interface EditorLaunch { |
| 32 | readonly command: string; | 34 | readonly command: string; |
| 33 | readonly args: ReadonlyArray<string>; | 35 | readonly args: ReadonlyArray<string>; |
| ... | @@ -61,6 +63,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { | ... | @@ -61,6 +63,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { |
| 61 | stderr: "ignore", | 63 | stderr: "ignore", |
| 62 | } as const satisfies ChildProcess.CommandOptions; | 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 | function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> { | 96 | function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> { |
| 65 | const match = TARGET_WITH_POSITION_PATTERN.exec(target); | 97 | const match = TARGET_WITH_POSITION_PATTERN.exec(target); |
| 66 | if (!match?.[1] || !match[2]) { | 98 | if (!match?.[1] || !match[2]) { |
| ... | @@ -109,17 +141,17 @@ function resolveEditorArgs( | ... | @@ -109,17 +141,17 @@ function resolveEditorArgs( |
| 109 | return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; | 141 | return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; |
| 110 | } | 142 | } |
| 111 | 143 | ||
| 112 | function resolveAvailableCommand( | 144 | const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( |
| 113 | commands: ReadonlyArray<string>, | 145 | commands: ReadonlyArray<string>, |
| 114 | options: CommandAvailabilityOptions = {}, | 146 | env: NodeJS.ProcessEnv, |
| 115 | ): Option.Option<string> { | 147 | ): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> { |
| 116 | for (const command of commands) { | 148 | for (const command of commands) { |
| 117 | if (isCommandAvailable(command, options)) { | 149 | if (yield* isCommandAvailable(command, { env })) { |
| 118 | return Option.some(command); | 150 | return Option.some(command); |
| 119 | } | 151 | } |
| 120 | } | 152 | } |
| 121 | return Option.none(); | 153 | return Option.none(); |
| 122 | } | 154 | }); |
| 123 | 155 | ||
| 124 | function encodeUtf16LeBase64(input: string): string { | 156 | function encodeUtf16LeBase64(input: string): string { |
| 125 | const bytes = new Uint8Array(input.length * 2); | 157 | const bytes = new Uint8Array(input.length * 2); |
| ... | @@ -135,7 +167,7 @@ function escapePowerShellStringLiteral(input: string): string { | ... | @@ -135,7 +167,7 @@ function escapePowerShellStringLiteral(input: string): string { |
| 135 | return `'${input.replaceAll("'", "''")}'`; | 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 | return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; | 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,7 +177,7 @@ function resolveWslPowerShellPath(): string { |
| 145 | 177 | ||
| 146 | function shouldUseWindowsBrowserFromWsl( | 178 | function shouldUseWindowsBrowserFromWsl( |
| 147 | platform: NodeJS.Platform, | 179 | platform: NodeJS.Platform, |
| 148 | env: NodeJS.ProcessEnv = process.env, | 180 | env: NodeJS.ProcessEnv = {}, |
| 149 | ): boolean { | 181 | ): boolean { |
| 150 | return ( | 182 | return ( |
| 151 | platform === "linux" && | 183 | platform === "linux" && |
| ... | @@ -184,10 +216,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { | ... | @@ -184,10 +216,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { |
| 184 | } | 216 | } |
| 185 | } | 217 | } |
| 186 | 218 | ||
| 187 | export function resolveBrowserLaunch( | 219 | function buildBrowserLaunch( |
| 188 | target: string, | 220 | target: string, |
| 189 | platform: NodeJS.Platform = process.platform, | 221 | platform: NodeJS.Platform, |
| 190 | env: NodeJS.ProcessEnv = process.env, | 222 | env: NodeJS.ProcessEnv = {}, |
| 191 | ): ProcessLaunch { | 223 | ): ProcessLaunch { |
| 192 | if (platform === "darwin") { | 224 | if (platform === "darwin") { |
| 193 | return { | 225 | return { |
| ... | @@ -212,34 +244,49 @@ export function resolveBrowserLaunch( | ... | @@ -212,34 +244,49 @@ export function resolveBrowserLaunch( |
| 212 | }; | 244 | }; |
| 213 | } | 245 | } |
| 214 | 246 | ||
| 215 | export function resolveAvailableEditors( | 247 | const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( |
| 216 | platform: NodeJS.Platform = process.platform, | 248 | platform: NodeJS.Platform, |
| 217 | env: NodeJS.ProcessEnv = process.env, | 249 | env: NodeJS.ProcessEnv, |
| 218 | ): ReadonlyArray<EditorId> { | 250 | ): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> { |
| 219 | const available: EditorId[] = []; | 251 | const available: EditorId[] = []; |
| 220 | 252 | ||
| 221 | for (const editor of EDITORS) { | 253 | for (const editor of EDITORS) { |
| 222 | if (editor.commands === null) { | 254 | if (editor.commands === null) { |
| 223 | const command = fileManagerCommandForPlatform(platform); | 255 | const command = fileManagerCommandForPlatform(platform); |
| 224 | if (isCommandAvailable(command, { platform, env })) { | 256 | if (yield* isCommandAvailable(command, { env })) { |
| 225 | available.push(editor.id); | 257 | available.push(editor.id); |
| 226 | } | 258 | } |
| 227 | continue; | 259 | continue; |
| 228 | } | 260 | } |
| 229 | 261 | ||
| 230 | const command = resolveAvailableCommand(editor.commands, { platform, env }); | 262 | const command = yield* resolveAvailableCommand(editor.commands, env); |
| 231 | if (Option.isSome(command)) { | 263 | if (Option.isSome(command)) { |
| 232 | available.push(editor.id); | 264 | available.push(editor.id); |
| 233 | } | 265 | } |
| 234 | } | 266 | } |
| 235 | 267 | ||
| 236 | return available; | 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 | * ExternalLauncherShape - Service API for browser and editor launch actions. | 286 | * ExternalLauncherShape - Service API for browser and editor launch actions. |
| 241 | */ | 287 | */ |
| 242 | export interface ExternalLauncherShape { | 288 | export interface ExternalLauncherShape { |
| 289 | readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>; | ||
| 243 | /** | 290 | /** |
| 244 | * Launch a URL target in the default browser. | 291 | * Launch a URL target in the default browser. |
| 245 | */ | 292 | */ |
| ... | @@ -264,11 +311,11 @@ export class ExternalLauncher extends Context.Service<ExternalLauncher, External | ... | @@ -264,11 +311,11 @@ export class ExternalLauncher extends Context.Service<ExternalLauncher, External |
| 264 | // Implementations | 311 | // Implementations |
| 265 | // ============================== | 312 | // ============================== |
| 266 | 313 | ||
| 267 | export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( | 314 | const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 268 | input: LaunchEditorInput, | 315 | input: LaunchEditorInput, |
| 269 | platform: NodeJS.Platform = process.platform, | 316 | ): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> { |
| 270 | env: NodeJS.ProcessEnv = process.env, | 317 | const platform = yield* HostProcessPlatform; |
| 271 | ): Effect.fn.Return<EditorLaunch, ExternalLauncherError> { | 318 | const env = yield* readCommandLookupEnv; |
| 272 | yield* Effect.annotateCurrentSpan({ | 319 | yield* Effect.annotateCurrentSpan({ |
| 273 | "externalLauncher.editor": input.editor, | 320 | "externalLauncher.editor": input.editor, |
| 274 | "externalLauncher.cwd": input.cwd, | 321 | "externalLauncher.cwd": input.cwd, |
| ... | @@ -281,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( | ... | @@ -281,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 281 | 328 | ||
| 282 | if (editorDef.commands) { | 329 | if (editorDef.commands) { |
| 283 | const command = Option.getOrElse( | 330 | const command = Option.getOrElse( |
| 284 | resolveAvailableCommand(editorDef.commands, { platform, env }), | 331 | yield* resolveAvailableCommand(editorDef.commands, env), |
| 285 | () => editorDef.commands[0], | 332 | () => editorDef.commands[0], |
| 286 | ); | 333 | ); |
| 287 | return { | 334 | return { |
| ... | @@ -312,29 +359,35 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( | ... | @@ -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 | target: string, | 363 | target: string, |
| 317 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { | 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 | launch: EditorLaunch, | 370 | launch: EditorLaunch, |
| 323 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { | 371 | ): Effect.fn.Return< |
| 324 | if (!isCommandAvailable(launch.command)) { | 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 | return yield* new ExternalLauncherError({ | 378 | return yield* new ExternalLauncherError({ |
| 326 | message: `Editor command not found: ${launch.command}`, | 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 | yield* launchAndUnref( | 384 | yield* launchAndUnref( |
| 332 | { | 385 | { |
| 333 | command: launch.command, | 386 | command: spawnCommand.command, |
| 334 | args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], | 387 | args: spawnCommand.args, |
| 335 | options: { | 388 | options: { |
| 336 | detached: true, | 389 | detached: true, |
| 337 | shell: isWin32, | 390 | shell: spawnCommand.shell, |
| 338 | stdin: "ignore", | 391 | stdin: "ignore", |
| 339 | stdout: "ignore", | 392 | stdout: "ignore", |
| 340 | stderr: "ignore", | 393 | stderr: "ignore", |
| ... | @@ -346,16 +399,29 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce | ... | @@ -346,16 +399,29 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce |
| 346 | 399 | ||
| 347 | const make = Effect.gen(function* () { | 400 | const make = Effect.gen(function* () { |
| 348 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 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 | return { | 413 | return { |
| 414 | resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), | ||
| 351 | launchBrowser: (target) => | 415 | launchBrowser: (target) => |
| 352 | launchBrowser(target).pipe( | 416 | launchBrowser(target).pipe( |
| 353 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 417 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 354 | ), | 418 | ), |
| 355 | launchEditor: (input) => | 419 | launchEditor: (input) => |
| 356 | Effect.flatMap(resolveEditorLaunch(input), (launch) => | 420 | provideCommandResolutionServices( |
| 357 | launchEditorProcess(launch).pipe( | 421 | Effect.flatMap(resolveEditorLaunch(input), (launch) => |
| 358 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 422 | launchEditorProcess(launch).pipe( |
| 423 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 424 | ), | ||
| 359 | ), | 425 | ), |
| 360 | ), | 426 | ), |
| 361 | } satisfies ExternalLauncherShape; | 427 | } satisfies ExternalLauncherShape; |
apps/server/src/processRunner.test.ts+52-16| ... | @@ -8,6 +8,8 @@ import * as Sink from "effect/Sink"; | ... | @@ -8,6 +8,8 @@ import * as Sink from "effect/Sink"; |
| 8 | import * as Stream from "effect/Stream"; | 8 | import * as Stream from "effect/Stream"; |
| 9 | import { TestClock } from "effect/testing"; | 9 | import { TestClock } from "effect/testing"; |
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | 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 | import { | 14 | import { |
| 13 | isWindowsCommandNotFound, | 15 | isWindowsCommandNotFound, |
| ... | @@ -21,6 +23,9 @@ import { | ... | @@ -21,6 +23,9 @@ import { |
| 21 | type ChildProcessCommand = { | 23 | type ChildProcessCommand = { |
| 22 | readonly command: string; | 24 | readonly command: string; |
| 23 | readonly args: ReadonlyArray<string>; | 25 | readonly args: ReadonlyArray<string>; |
| 26 | readonly options: { | ||
| 27 | readonly shell?: boolean | string; | ||
| 28 | }; | ||
| 24 | }; | 29 | }; |
| 25 | 30 | ||
| 26 | // Accesses private properties of ChildProcessCommand for testing purposes | 31 | // Accesses private properties of ChildProcessCommand for testing purposes |
| ... | @@ -68,7 +73,6 @@ const runWith = | ... | @@ -68,7 +73,6 @@ const runWith = |
| 68 | Effect.flatMap((runner) => | 73 | Effect.flatMap((runner) => |
| 69 | runner.run({ | 74 | runner.run({ |
| 70 | ...input, | 75 | ...input, |
| 71 | shell: input.shell ?? false, | ||
| 72 | }), | 76 | }), |
| 73 | ), | 77 | ), |
| 74 | Effect.provide( | 78 | Effect.provide( |
| ... | @@ -123,6 +127,44 @@ describe("runProcess", () => { | ... | @@ -123,6 +127,44 @@ describe("runProcess", () => { |
| 123 | }).pipe(Effect.provide(layer)); | 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 | it.effect("fails when output exceeds max buffer in default mode", () => | 168 | it.effect("fails when output exceeds max buffer in default mode", () => |
| 127 | Effect.gen(function* () { | 169 | Effect.gen(function* () { |
| 128 | const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); | 170 | const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); |
| ... | @@ -280,19 +322,13 @@ describe("runProcess", () => { | ... | @@ -280,19 +322,13 @@ describe("runProcess", () => { |
| 280 | }); | 322 | }); |
| 281 | 323 | ||
| 282 | describe("isWindowsCommandNotFound", () => { | 324 | describe("isWindowsCommandNotFound", () => { |
| 283 | it("matches the localized German cmd.exe error text", () => { | 325 | it.effect("matches the localized German cmd.exe error text", () => |
| 284 | const originalPlatform = process.platform; | 326 | Effect.gen(function* () { |
| 285 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | 327 | const isCommandNotFound = yield* isWindowsCommandNotFound( |
| 286 | 328 | 1, | |
| 287 | try { | 329 | "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", |
| 288 | expect( | 330 | ).pipe(Effect.provideService(HostProcessPlatform, "win32")); |
| 289 | isWindowsCommandNotFound( | 331 | expect(isCommandNotFound).toBe(true); |
| 290 | 1, | 332 | }), |
| 291 | "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", | 333 | ); |
| 292 | ), | ||
| 293 | ).toBe(true); | ||
| 294 | } finally { | ||
| 295 | Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); | ||
| 296 | } | ||
| 297 | }); | ||
| 298 | }); | 334 | }); |
apps/server/src/processRunner.ts+19-9| ... | @@ -8,6 +8,8 @@ import * as PlatformError from "effect/PlatformError"; | ... | @@ -8,6 +8,8 @@ import * as PlatformError from "effect/PlatformError"; |
| 8 | import * as Scope from "effect/Scope"; | 8 | import * as Scope from "effect/Scope"; |
| 9 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 12 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 11 | import { | 13 | import { |
| 12 | collectUint8StreamText, | 14 | collectUint8StreamText, |
| 13 | type CollectedUint8StreamText, | 15 | type CollectedUint8StreamText, |
| ... | @@ -24,7 +26,6 @@ export interface ProcessRunInput { | ... | @@ -24,7 +26,6 @@ export interface ProcessRunInput { |
| 24 | readonly maxOutputBytes?: number | undefined; | 26 | readonly maxOutputBytes?: number | undefined; |
| 25 | readonly outputMode?: "error" | "truncate" | undefined; | 27 | readonly outputMode?: "error" | "truncate" | undefined; |
| 26 | readonly truncatedMarker?: string | undefined; | 28 | readonly truncatedMarker?: string | undefined; |
| 27 | readonly shell?: boolean | string | undefined; | ||
| 28 | /** | 29 | /** |
| 29 | * On timeout, return a synthetic timedOut result. | 30 | * On timeout, return a synthetic timedOut result. |
| 30 | * Partial stdout/stderr are not preserved. | 31 | * Partial stdout/stderr are not preserved. |
| ... | @@ -109,11 +110,14 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean { | ... | @@ -109,11 +110,14 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean { |
| 109 | return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); | 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 | export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")( |
| 113 | if (process.platform !== "win32") return false; | 114 | function* (code: number | null, stderr: string) { |
| 114 | if (code === 9009) return true; | 115 | const platform = yield* HostProcessPlatform; |
| 115 | return hasWindowsCommandNotFoundMessage(stderr); | 116 | if (platform !== "win32") return false; |
| 116 | } | 117 | if (code === 9009) return true; |
| 118 | return hasWindowsCommandNotFoundMessage(stderr); | ||
| 119 | }, | ||
| 120 | ); | ||
| 117 | 121 | ||
| 118 | const collectText = Effect.fn("processRunner.collectText")(function* (input: { | 122 | const collectText = Effect.fn("processRunner.collectText")(function* (input: { |
| 119 | readonly command: string; | 123 | readonly command: string; |
| ... | @@ -231,18 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( | ... | @@ -231,18 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( |
| 231 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | 235 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 232 | const outputMode = input.outputMode ?? "error"; | 236 | const outputMode = input.outputMode ?? "error"; |
| 233 | const truncatedMarker = input.truncatedMarker ?? ""; | 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 | const child = yield* spawner | 245 | const child = yield* spawner |
| 236 | .spawn( | 246 | .spawn( |
| 237 | ChildProcess.make(input.command, [...input.args], { | 247 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 238 | ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), | 248 | ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), |
| 239 | ...(input.env !== undefined | 249 | ...(input.env !== undefined |
| 240 | ? { | 250 | ? { |
| 241 | env: input.env, | 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 | .pipe( | 258 | .pipe( |
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+2| ... | @@ -89,6 +89,8 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa | ... | @@ -89,6 +89,8 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa |
| 89 | const processRunner = yield* ProcessRunner.ProcessRunner; | 89 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 90 | let cacheKey = cwd; | 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 | const topLevelResult = yield* processRunner | 94 | const topLevelResult = yield* processRunner |
| 93 | .run({ | 95 | .run({ |
| 94 | command: "git", | 96 | command: "git", |
apps/server/src/provider/Drivers/ClaudeHome.ts+4-3| ... | @@ -16,13 +16,14 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function | ... | @@ -16,13 +16,14 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function |
| 16 | 16 | ||
| 17 | export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( | 17 | export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( |
| 18 | config: Pick<ClaudeSettings, "homePath">, | 18 | config: Pick<ClaudeSettings, "homePath">, |
| 19 | baseEnv: NodeJS.ProcessEnv = process.env, | 19 | baseEnv?: NodeJS.ProcessEnv, |
| 20 | ): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> { | 20 | ): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> { |
| 21 | const resolvedBaseEnv = baseEnv ?? process.env; | ||
| 21 | const homePath = config.homePath.trim(); | 22 | const homePath = config.homePath.trim(); |
| 22 | if (homePath.length === 0) return baseEnv; | 23 | if (homePath.length === 0) return resolvedBaseEnv; |
| 23 | const resolvedHomePath = yield* resolveClaudeHomePath(config); | 24 | const resolvedHomePath = yield* resolveClaudeHomePath(config); |
| 24 | return { | 25 | return { |
| 25 | ...baseEnv, | 26 | ...resolvedBaseEnv, |
| 26 | HOME: resolvedHomePath, | 27 | HOME: resolvedHomePath, |
| 27 | }; | 28 | }; |
| 28 | }); | 29 | }); |
apps/server/src/provider/Layers/ClaudeProvider.ts+15-9| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | getProviderOptionCurrentValue, | 18 | getProviderOptionCurrentValue, |
| 19 | getProviderOptionDescriptors, | 19 | getProviderOptionDescriptors, |
| 20 | } from "@t3tools/shared/model"; | 20 | } from "@t3tools/shared/model"; |
| 21 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 21 | import { compareSemverVersions } from "@t3tools/shared/semver"; | 22 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 22 | import { | 23 | import { |
| 23 | query as claudeQuery, | 24 | query as claudeQuery, |
| ... | @@ -547,7 +548,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> { | ... | @@ -547,7 +548,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> { |
| 547 | */ | 548 | */ |
| 548 | const probeClaudeCapabilities = ( | 549 | const probeClaudeCapabilities = ( |
| 549 | claudeSettings: ClaudeSettings, | 550 | claudeSettings: ClaudeSettings, |
| 550 | environment: NodeJS.ProcessEnv = process.env, | 551 | environment?: NodeJS.ProcessEnv, |
| 551 | ) => { | 552 | ) => { |
| 552 | const abort = new AbortController(); | 553 | const abort = new AbortController(); |
| 553 | return Effect.gen(function* () { | 554 | return Effect.gen(function* () { |
| ... | @@ -603,12 +604,15 @@ const probeClaudeCapabilities = ( | ... | @@ -603,12 +604,15 @@ const probeClaudeCapabilities = ( |
| 603 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( | 604 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( |
| 604 | claudeSettings: ClaudeSettings, | 605 | claudeSettings: ClaudeSettings, |
| 605 | args: ReadonlyArray<string>, | 606 | args: ReadonlyArray<string>, |
| 606 | environment: NodeJS.ProcessEnv = process.env, | 607 | environment?: NodeJS.ProcessEnv, |
| 607 | ) { | 608 | ) { |
| 608 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | 609 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 609 | const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { | 610 | const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { |
| 610 | env: claudeEnvironment, | 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 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); | 617 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); |
| 614 | }); | 618 | }); |
| ... | @@ -618,12 +622,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -618,12 +622,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 618 | resolveCapabilities?: ( | 622 | resolveCapabilities?: ( |
| 619 | claudeSettings: ClaudeSettings, | 623 | claudeSettings: ClaudeSettings, |
| 620 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, | 624 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, |
| 621 | environment: NodeJS.ProcessEnv = process.env, | 625 | environment?: NodeJS.ProcessEnv, |
| 622 | ): Effect.fn.Return< | 626 | ): Effect.fn.Return< |
| 623 | ServerProviderDraft, | 627 | ServerProviderDraft, |
| 624 | never, | 628 | never, |
| 625 | ChildProcessSpawner.ChildProcessSpawner | Path.Path | 629 | ChildProcessSpawner.ChildProcessSpawner | Path.Path |
| 626 | > { | 630 | > { |
| 631 | const resolvedEnvironment = environment ?? process.env; | ||
| 627 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | 632 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 628 | const allModels = providerModelsFromSettings( | 633 | const allModels = providerModelsFromSettings( |
| 629 | BUILT_IN_MODELS, | 634 | BUILT_IN_MODELS, |
| ... | @@ -648,10 +653,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -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( | 656 | const versionProbe = yield* runClaudeCommand( |
| 652 | Effect.timeoutOption(DEFAULT_TIMEOUT_MS), | 657 | claudeSettings, |
| 653 | Effect.result, | 658 | ["--version"], |
| 654 | ); | 659 | resolvedEnvironment, |
| 660 | ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); | ||
| 655 | 661 | ||
| 656 | if (Result.isFailure(versionProbe)) { | 662 | if (Result.isFailure(versionProbe)) { |
| 657 | const error = versionProbe.failure; | 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 +7,7 @@ import * as Result from "effect/Result"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as Scope from "effect/Scope"; | 8 | import * as Scope from "effect/Scope"; |
| 9 | import * as Types from "effect/Types"; | 9 | import * as Types from "effect/Types"; |
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import * as CodexClient from "effect-codex-app-server/client"; | 11 | import * as CodexClient from "effect-codex-app-server/client"; |
| 12 | import * as CodexSchema from "effect-codex-app-server/schema"; | 12 | import * as CodexSchema from "effect-codex-app-server/schema"; |
| 13 | import * as CodexErrors from "effect-codex-app-server/errors"; | 13 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| ... | @@ -24,6 +24,7 @@ import type { | ... | @@ -24,6 +24,7 @@ import type { |
| 24 | import { ServerSettingsError } from "@t3tools/contracts"; | 24 | import { ServerSettingsError } from "@t3tools/contracts"; |
| 25 | 25 | ||
| 26 | import { createModelCapabilities } from "@t3tools/shared/model"; | 26 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 27 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 27 | import { | 28 | import { |
| 28 | AUTH_PROBE_TIMEOUT_MS, | 29 | AUTH_PROBE_TIMEOUT_MS, |
| 29 | buildServerProvider, | 30 | buildServerProvider, |
| ... | @@ -33,6 +34,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; | ... | @@ -33,6 +34,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; |
| 33 | import packageJson from "../../../package.json" with { type: "json" }; | 34 | import packageJson from "../../../package.json" with { type: "json" }; |
| 34 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); | 35 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); |
| 35 | 36 | ||
| 37 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; | ||
| 38 | |||
| 36 | const CODEX_PRESENTATION = { | 39 | const CODEX_PRESENTATION = { |
| 37 | displayName: "Codex", | 40 | displayName: "Codex", |
| 38 | showInteractionModeToggle: true, | 41 | showInteractionModeToggle: true, |
| ... | @@ -292,17 +295,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun | ... | @@ -292,17 +295,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 292 | // "CODEX_HOME points to '~/.codex_work', but that path does not exist". | 295 | // "CODEX_HOME points to '~/.codex_work', but that path does not exist". |
| 293 | // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. | 296 | // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. |
| 294 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; | 297 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; |
| 295 | const clientContext = yield* Layer.build( | 298 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 296 | CodexClient.layerCommand({ | 299 | const environment = { |
| 297 | command: input.binaryPath, | 300 | ...input.environment, |
| 298 | args: ["app-server"], | 301 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), |
| 299 | cwd: input.cwd, | 302 | }; |
| 300 | env: { | 303 | const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { |
| 301 | ...(input.environment ?? process.env), | 304 | env: environment, |
| 302 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | 305 | extendEnv: true, |
| 303 | }, | 306 | }); |
| 304 | }), | 307 | const child = yield* spawner |
| 305 | ); | 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 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( | 327 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( |
| 307 | Effect.provide(clientContext), | 328 | Effect.provide(clientContext), |
| 308 | ); | 329 | ); |
| ... | @@ -449,12 +470,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -449,12 +470,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 449 | CodexErrors.CodexAppServerError, | 470 | CodexErrors.CodexAppServerError, |
| 450 | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | 471 | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope |
| 451 | > = probeCodexAppServerProvider, | 472 | > = probeCodexAppServerProvider, |
| 452 | environment: NodeJS.ProcessEnv = process.env, | 473 | environment?: NodeJS.ProcessEnv, |
| 453 | ): Effect.fn.Return< | 474 | ): Effect.fn.Return< |
| 454 | ServerProviderDraft, | 475 | ServerProviderDraft, |
| 455 | ServerSettingsError, | 476 | ServerSettingsError, |
| 456 | ChildProcessSpawner.ChildProcessSpawner | 477 | ChildProcessSpawner.ChildProcessSpawner |
| 457 | > { | 478 | > { |
| 479 | const resolvedEnvironment = environment ?? process.env; | ||
| 458 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | 480 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 459 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); | 481 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); |
| 460 | 482 | ||
| ... | @@ -480,7 +502,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -480,7 +502,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 480 | homePath: codexSettings.homePath, | 502 | homePath: codexSettings.homePath, |
| 481 | cwd: process.cwd(), | 503 | cwd: process.cwd(), |
| 482 | customModels: codexSettings.customModels, | 504 | customModels: codexSettings.customModels, |
| 483 | environment, | 505 | environment: resolvedEnvironment, |
| 484 | }).pipe( | 506 | }).pipe( |
| 485 | Effect.scoped, | 507 | Effect.scoped, |
| 486 | Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), | 508 | Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11-3| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | ThreadId, | 16 | ThreadId, |
| 17 | TurnId, | 17 | TurnId, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 19 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 20 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 20 | import * as Crypto from "effect/Crypto"; | 21 | import * as Crypto from "effect/Crypto"; |
| 21 | import * as DateTime from "effect/DateTime"; | 22 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -720,16 +721,23 @@ export const makeCodexSessionRuntime = ( | ... | @@ -720,16 +721,23 @@ export const makeCodexSessionRuntime = ( |
| 720 | // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. | 721 | // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. |
| 721 | const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; | 722 | const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; |
| 722 | const env = { | 723 | const env = { |
| 723 | ...(options.environment ?? process.env), | 724 | ...options.environment, |
| 724 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | 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 | const child = yield* spawner | 733 | const child = yield* spawner |
| 727 | .spawn( | 734 | .spawn( |
| 728 | ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { | 735 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 729 | cwd: options.cwd, | 736 | cwd: options.cwd, |
| 730 | env, | 737 | env, |
| 738 | extendEnv, | ||
| 731 | forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, | 739 | forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, |
| 732 | shell: process.platform === "win32", | 740 | shell: spawnCommand.shell, |
| 733 | }), | 741 | }), |
| 734 | ) | 742 | ) |
| 735 | .pipe( | 743 | .pipe( |
apps/server/src/provider/Layers/CursorProvider.ts+17-14| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | getProviderOptionBooleanSelectionValue, | 27 | getProviderOptionBooleanSelectionValue, |
| 28 | getProviderOptionStringSelectionValue, | 28 | getProviderOptionStringSelectionValue, |
| 29 | } from "@t3tools/shared/model"; | 29 | } from "@t3tools/shared/model"; |
| 30 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 30 | 31 | ||
| 31 | import { | 32 | import { |
| 32 | buildBooleanOptionDescriptor, | 33 | buildBooleanOptionDescriptor, |
| ... | @@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( | ... | @@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( |
| 394 | 395 | ||
| 395 | const makeCursorAcpProbeRuntime = ( | 396 | const makeCursorAcpProbeRuntime = ( |
| 396 | cursorSettings: CursorSettings, | 397 | cursorSettings: CursorSettings, |
| 397 | environment: NodeJS.ProcessEnv = process.env, | 398 | environment?: NodeJS.ProcessEnv, |
| 398 | ) => | 399 | ) => |
| 399 | Effect.gen(function* () { | 400 | Effect.gen(function* () { |
| 400 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 401 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| ... | @@ -407,7 +408,7 @@ const makeCursorAcpProbeRuntime = ( | ... | @@ -407,7 +408,7 @@ const makeCursorAcpProbeRuntime = ( |
| 407 | "acp", | 408 | "acp", |
| 408 | ], | 409 | ], |
| 409 | cwd: process.cwd(), | 410 | cwd: process.cwd(), |
| 410 | env: environment, | 411 | ...(environment ? { env: environment } : {}), |
| 411 | }, | 412 | }, |
| 412 | cwd: process.cwd(), | 413 | cwd: process.cwd(), |
| 413 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, | 414 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, |
| ... | @@ -421,7 +422,7 @@ const makeCursorAcpProbeRuntime = ( | ... | @@ -421,7 +422,7 @@ const makeCursorAcpProbeRuntime = ( |
| 421 | const withCursorAcpProbeRuntime = <A, E, R>( | 422 | const withCursorAcpProbeRuntime = <A, E, R>( |
| 422 | cursorSettings: CursorSettings, | 423 | cursorSettings: CursorSettings, |
| 423 | useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>, | 424 | useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>, |
| 424 | environment: NodeJS.ProcessEnv = process.env, | 425 | environment?: NodeJS.ProcessEnv, |
| 425 | ) => | 426 | ) => |
| 426 | makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( | 427 | makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( |
| 427 | Effect.flatMap(useRuntime), | 428 | Effect.flatMap(useRuntime), |
| ... | @@ -542,7 +543,7 @@ export function resolveCursorAcpConfigUpdates( | ... | @@ -542,7 +543,7 @@ export function resolveCursorAcpConfigUpdates( |
| 542 | 543 | ||
| 543 | const discoverCursorModelsViaListAvailableModels = ( | 544 | const discoverCursorModelsViaListAvailableModels = ( |
| 544 | cursorSettings: CursorSettings, | 545 | cursorSettings: CursorSettings, |
| 545 | environment: NodeJS.ProcessEnv = process.env, | 546 | environment?: NodeJS.ProcessEnv, |
| 546 | ) => | 547 | ) => |
| 547 | withCursorAcpProbeRuntime( | 548 | withCursorAcpProbeRuntime( |
| 548 | cursorSettings, | 549 | cursorSettings, |
| ... | @@ -558,7 +559,7 @@ const discoverCursorModelsViaListAvailableModels = ( | ... | @@ -558,7 +559,7 @@ const discoverCursorModelsViaListAvailableModels = ( |
| 558 | 559 | ||
| 559 | export const discoverCursorModelsViaAcp = ( | 560 | export const discoverCursorModelsViaAcp = ( |
| 560 | cursorSettings: CursorSettings, | 561 | cursorSettings: CursorSettings, |
| 561 | environment: NodeJS.ProcessEnv = process.env, | 562 | environment?: NodeJS.ProcessEnv, |
| 562 | ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); | 563 | ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); |
| 563 | 564 | ||
| 564 | export function getCursorFallbackModels( | 565 | export function getCursorFallbackModels( |
| ... | @@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult | ... | @@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult |
| 927 | const runCursorCommand = ( | 928 | const runCursorCommand = ( |
| 928 | cursorSettings: CursorSettings, | 929 | cursorSettings: CursorSettings, |
| 929 | args: ReadonlyArray<string>, | 930 | args: ReadonlyArray<string>, |
| 930 | environment: NodeJS.ProcessEnv = process.env, | 931 | environment?: NodeJS.ProcessEnv, |
| 931 | ) => | 932 | ) => |
| 932 | Effect.gen(function* () { | 933 | Effect.gen(function* () { |
| 933 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 934 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 934 | const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { | 935 | const spawnCommand = yield* resolveSpawnCommand( |
| 935 | env: environment, | 936 | cursorSettings.binaryPath, |
| 936 | shell: process.platform === "win32", | 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 | const child = yield* spawner.spawn(command); | 945 | const child = yield* spawner.spawn(command); |
| ... | @@ -949,10 +955,7 @@ const runCursorCommand = ( | ... | @@ -949,10 +955,7 @@ const runCursorCommand = ( |
| 949 | return { stdout, stderr, code: exitCode } satisfies CommandResult; | 955 | return { stdout, stderr, code: exitCode } satisfies CommandResult; |
| 950 | }).pipe(Effect.scoped); | 956 | }).pipe(Effect.scoped); |
| 951 | 957 | ||
| 952 | const runCursorAboutCommand = ( | 958 | const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) => |
| 953 | cursorSettings: CursorSettings, | ||
| 954 | environment: NodeJS.ProcessEnv = process.env, | ||
| 955 | ) => | ||
| 956 | Effect.gen(function* () { | 959 | Effect.gen(function* () { |
| 957 | const jsonResult = yield* runCursorCommand( | 960 | const jsonResult = yield* runCursorCommand( |
| 958 | cursorSettings, | 961 | cursorSettings, |
| ... | @@ -967,7 +970,7 @@ const runCursorAboutCommand = ( | ... | @@ -967,7 +970,7 @@ const runCursorAboutCommand = ( |
| 967 | 970 | ||
| 968 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( | 971 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( |
| 969 | cursorSettings: CursorSettings, | 972 | cursorSettings: CursorSettings, |
| 970 | environment: NodeJS.ProcessEnv = process.env, | 973 | environment?: NodeJS.ProcessEnv, |
| 971 | ): Effect.fn.Return< | 974 | ): Effect.fn.Return< |
| 972 | ServerProviderDraft, | 975 | ServerProviderDraft, |
| 973 | never, | 976 | never, |
apps/server/src/provider/Layers/GrokProvider.ts+14-9| ... | @@ -15,6 +15,7 @@ import * as Result from "effect/Result"; | ... | @@ -15,6 +15,7 @@ import * as Result from "effect/Result"; |
| 15 | import { HttpClient } from "effect/unstable/http"; | 15 | import { HttpClient } from "effect/unstable/http"; |
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 17 | import { createModelCapabilities } from "@t3tools/shared/model"; | 17 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 18 | 19 | ||
| 19 | import { | 20 | import { |
| 20 | buildServerProvider, | 21 | buildServerProvider, |
| ... | @@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = ( | ... | @@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = ( |
| 149 | const runGrokVersionCommand = ( | 150 | const runGrokVersionCommand = ( |
| 150 | grokSettings: GrokSettings, | 151 | grokSettings: GrokSettings, |
| 151 | environment: NodeJS.ProcessEnv = process.env, | 152 | environment: NodeJS.ProcessEnv = process.env, |
| 152 | ) => { | 153 | ) => |
| 153 | const command = grokSettings.binaryPath || "grok"; | 154 | Effect.gen(function* () { |
| 154 | return spawnAndCollect( | 155 | const command = grokSettings.binaryPath || "grok"; |
| 155 | command, | 156 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { |
| 156 | ChildProcess.make(command, ["--version"], { | ||
| 157 | env: environment, | 157 | env: environment, |
| 158 | shell: process.platform === "win32", | 158 | }); |
| 159 | }), | 159 | return yield* spawnAndCollect( |
| 160 | ); | 160 | command, |
| 161 | }; | 161 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 162 | env: environment, | ||
| 163 | shell: spawnCommand.shell, | ||
| 164 | }), | ||
| 165 | ); | ||
| 166 | }); | ||
| 162 | 167 | ||
| 163 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | 168 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( |
| 164 | grokSettings: GrokSettings, | 169 | grokSettings: GrokSettings, |
apps/server/src/provider/Layers/OpenCodeProvider.ts+4-3| ... | @@ -301,9 +301,10 @@ export const makePendingOpenCodeProvider = ( | ... | @@ -301,9 +301,10 @@ export const makePendingOpenCodeProvider = ( |
| 301 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( | 301 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( |
| 302 | openCodeSettings: OpenCodeSettings, | 302 | openCodeSettings: OpenCodeSettings, |
| 303 | cwd: string, | 303 | cwd: string, |
| 304 | environment: NodeJS.ProcessEnv = process.env, | 304 | environment?: NodeJS.ProcessEnv, |
| 305 | ): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> { | 305 | ): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> { |
| 306 | const openCodeRuntime = yield* OpenCodeRuntime; | 306 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 307 | const resolvedEnvironment = environment ?? process.env; | ||
| 307 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | 308 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 308 | const customModels = openCodeSettings.customModels; | 309 | const customModels = openCodeSettings.customModels; |
| 309 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; | 310 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; |
| ... | @@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 364 | .runOpenCodeCommand({ | 365 | .runOpenCodeCommand({ |
| 365 | binaryPath: openCodeSettings.binaryPath, | 366 | binaryPath: openCodeSettings.binaryPath, |
| 366 | args: ["--version"], | 367 | args: ["--version"], |
| 367 | environment, | 368 | environment: resolvedEnvironment, |
| 368 | }) | 369 | }) |
| 369 | .pipe( | 370 | .pipe( |
| 370 | Effect.mapError( | 371 | Effect.mapError( |
| ... | @@ -413,7 +414,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu | ... | @@ -413,7 +414,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 413 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ | 414 | const server = yield* openCodeRuntime.connectToOpenCodeServer({ |
| 414 | binaryPath: openCodeSettings.binaryPath, | 415 | binaryPath: openCodeSettings.binaryPath, |
| 415 | serverUrl: openCodeSettings.serverUrl, | 416 | serverUrl: openCodeSettings.serverUrl, |
| 416 | environment, | 417 | environment: resolvedEnvironment, |
| 417 | }); | 418 | }); |
| 418 | return yield* openCodeRuntime.loadOpenCodeInventory( | 419 | return yield* openCodeRuntime.loadOpenCodeInventory( |
| 419 | openCodeRuntime.createOpenCodeSdkClient({ | 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,7 +1039,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1039 | 1039 | ||
| 1040 | // This test intentionally avoids `mockCommandSpawnerLayer` so the real | 1040 | // This test intentionally avoids `mockCommandSpawnerLayer` so the real |
| 1041 | // `probeCodexAppServerProvider` path runs — including the full | 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 | // We point `binaryPath` at a name that cannot exist on any machine so | 1043 | // We point `binaryPath` at a name that cannot exist on any machine so |
| 1044 | // the real `ChildProcessSpawner` deterministically returns ENOENT; the | 1044 | // the real `ChildProcessSpawner` deterministically returns ENOENT; the |
| 1045 | // probe wraps that as `CodexAppServerSpawnError` and | 1045 | // probe wraps that as `CodexAppServerSpawnError` and |
| ... | @@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1159 | Effect.gen(function* () { | 1159 | Effect.gen(function* () { |
| 1160 | const firstMissing = `t3code_codex_first_`; | 1160 | const firstMissing = `t3code_codex_first_`; |
| 1161 | const secondMissing = `t3code_codex_second_`; | 1161 | const secondMissing = `t3code_codex_second_`; |
| 1162 | const spawnedCommands: Array<string> = []; | ||
| 1162 | const serverSettings = yield* makeMutableServerSettingsService( | 1163 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1163 | decodeServerSettings( | 1164 | decodeServerSettings( |
| 1164 | deepMerge(encodedDefaultServerSettings, { | 1165 | deepMerge(encodedDefaultServerSettings, { |
| ... | @@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1185 | Layer.provideMerge(TestHttpClientLive), | 1186 | Layer.provideMerge(TestHttpClientLive), |
| 1186 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | 1187 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), |
| 1187 | Layer.provideMerge(OpenCodeRuntimeLive), | 1188 | Layer.provideMerge(OpenCodeRuntimeLive), |
| 1188 | // `it.live` does not inherit layers from the outer `it.layer` | 1189 | Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => |
| 1189 | // wrapper, so provide `NodeServices.layer` inline. This is the | 1190 | ChildProcessSpawner.make((command) => { |
| 1190 | // same real `ChildProcessSpawner` + `FileSystem` + `Path` | 1191 | spawnedCommands.push((command as { readonly command: string }).command); |
| 1191 | // services that production uses. | 1192 | return spawner.spawn(command); |
| 1193 | }), | ||
| 1194 | ), | ||
| 1192 | Layer.provideMerge(NodeServices.layer), | 1195 | Layer.provideMerge(NodeServices.layer), |
| 1193 | ); | 1196 | ); |
| 1194 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | 1197 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| ... | @@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1199 | const registry = yield* ProviderRegistry; | 1202 | const registry = yield* ProviderRegistry; |
| 1200 | // Boot-time probe: the default codex instance is enabled with | 1203 | // Boot-time probe: the default codex instance is enabled with |
| 1201 | // `firstMissing`, so the real spawner yields ENOENT and the | 1204 | // `firstMissing`, so the real spawner yields ENOENT and the |
| 1202 | // snapshot should be `status: "error"`. What *distinguishes* | 1205 | // snapshot should be `status: "error"`. |
| 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. | ||
| 1206 | let initialProviders = yield* registry.getProviders; | 1206 | let initialProviders = yield* registry.getProviders; |
| 1207 | for ( | 1207 | for ( |
| 1208 | let attempts = 0; | 1208 | let attempts = 0; |
| ... | @@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1220 | ); | 1220 | ); |
| 1221 | assert.strictEqual(initialCodex?.status, "error"); | 1221 | assert.strictEqual(initialCodex?.status, "error"); |
| 1222 | assert.strictEqual(initialCodex?.installed, false); | 1222 | assert.strictEqual(initialCodex?.installed, false); |
| 1223 | const initialCheckedAt = initialCodex?.checkedAt; | 1223 | assert.deepStrictEqual(spawnedCommands, [firstMissing]); |
| 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"); | ||
| 1230 | 1224 | ||
| 1231 | // Drive a settings change. The Hydration layer's | 1225 | // Drive a settings change. The Hydration layer's |
| 1232 | // `SettingsWatcherLive` consumes this via `streamChanges`, | 1226 | // `SettingsWatcherLive` consumes this via `streamChanges`, |
| ... | @@ -1242,8 +1236,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -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 | 1239 | // Poll until the injected process boundary observes the new |
| 1246 | // generous virtual 3-second ceiling. | 1240 | // executable. This verifies the public settings-to-probe behavior |
| 1241 | // without depending on timestamps assigned by TestClock. | ||
| 1247 | const refreshed = yield* Effect.gen(function* () { | 1242 | const refreshed = yield* Effect.gen(function* () { |
| 1248 | for (let attempts = 0; attempts < 60; attempts += 1) { | 1243 | for (let attempts = 0; attempts < 60; attempts += 1) { |
| 1249 | const providers = yield* registry.getProviders; | 1244 | const providers = yield* registry.getProviders; |
| ... | @@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1251 | if ( | 1246 | if ( |
| 1252 | codex !== undefined && | 1247 | codex !== undefined && |
| 1253 | codex.status === "error" && | 1248 | codex.status === "error" && |
| 1254 | codex.checkedAt !== initialCheckedAt | 1249 | spawnedCommands.includes(secondMissing) |
| 1255 | ) { | 1250 | ) { |
| 1256 | return providers; | 1251 | return providers; |
| 1257 | } | 1252 | } |
| ... | @@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1262 | }); | 1257 | }); |
| 1263 | 1258 | ||
| 1264 | const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); | 1259 | const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); |
| 1265 | assert.notStrictEqual( | 1260 | assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); |
| 1266 | reprobedCodex?.checkedAt, | ||
| 1267 | initialCheckedAt, | ||
| 1268 | "Expected a fresh probe after settings change, got the stale snapshot", | ||
| 1269 | ); | ||
| 1270 | assert.strictEqual(reprobedCodex?.status, "error"); | 1261 | assert.strictEqual(reprobedCodex?.status, "error"); |
| 1271 | assert.strictEqual(reprobedCodex?.installed, false); | 1262 | assert.strictEqual(reprobedCodex?.installed, false); |
| 1272 | }).pipe(Effect.provide(runtimeServices)); | 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,6 +13,7 @@ import * as EffectAcpClient from "effect-acp/client"; |
| 13 | import * as EffectAcpErrors from "effect-acp/errors"; | 13 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 14 | import type * as EffectAcpSchema from "effect-acp/schema"; | 14 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 15 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 15 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 16 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 16 | 17 | ||
| 17 | import { | 18 | import { |
| 18 | collectSessionConfigOptionValues, | 19 | collectSessionConfigOptionValues, |
| ... | @@ -201,12 +202,17 @@ const makeAcpSessionRuntime = ( | ... | @@ -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 | const child = yield* spawner | 210 | const child = yield* spawner |
| 205 | .spawn( | 211 | .spawn( |
| 206 | ChildProcess.make(options.spawn.command, [...options.spawn.args], { | 212 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 207 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), | 213 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), |
| 208 | ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), | 214 | ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), |
| 209 | shell: process.platform === "win32", | 215 | shell: spawnCommand.shell, |
| 210 | }), | 216 | }), |
| 211 | ) | 217 | ) |
| 212 | .pipe( | 218 | .pipe( |
apps/server/src/provider/opencodeRuntime.ts+17-9| ... | @@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 31 | import { isWindowsCommandNotFound } from "../processRunner.ts"; | 31 | import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 32 | import { collectStreamAsString } from "./providerSnapshot.ts"; | 32 | import { collectStreamAsString } from "./providerSnapshot.ts"; |
| 33 | import * as NetService from "@t3tools/shared/Net"; | 33 | import * as NetService from "@t3tools/shared/Net"; |
| 34 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 35 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 34 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); | 36 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); |
| 35 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; | 37 | const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; |
| 36 | 38 | ||
| ... | @@ -276,13 +278,17 @@ function ensureRuntimeError( | ... | @@ -276,13 +278,17 @@ function ensureRuntimeError( |
| 276 | const makeOpenCodeRuntime = Effect.gen(function* () { | 278 | const makeOpenCodeRuntime = Effect.gen(function* () { |
| 277 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 279 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 278 | const netService = yield* NetService.NetService; | 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 | const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => | 285 | const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => |
| 281 | Effect.gen(function* () { | 286 | Effect.gen(function* () { |
| 287 | const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); | ||
| 282 | const child = yield* spawner.spawn( | 288 | const child = yield* spawner.spawn( |
| 283 | ChildProcess.make(input.binaryPath, [...input.args], { | 289 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 284 | shell: process.platform === "win32", | 290 | shell: spawnCommand.shell, |
| 285 | env: input.environment ?? process.env, | 291 | ...(input.environment ? { env: input.environment } : { extendEnv: true }), |
| 286 | }), | 292 | }), |
| 287 | ); | 293 | ); |
| 288 | const [stdout, stderr, code] = yield* Effect.all( | 294 | const [stdout, stderr, code] = yield* Effect.all( |
| ... | @@ -290,7 +296,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -290,7 +296,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 290 | { concurrency: "unbounded" }, | 296 | { concurrency: "unbounded" }, |
| 291 | ); | 297 | ); |
| 292 | const exitCode = Number(code); | 298 | const exitCode = Number(code); |
| 293 | if (isWindowsCommandNotFound(exitCode, stderr)) { | 299 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { |
| 294 | return yield* new OpenCodeRuntimeError({ | 300 | return yield* new OpenCodeRuntimeError({ |
| 295 | operation: "runOpenCodeCommand", | 301 | operation: "runOpenCodeCommand", |
| 296 | detail: `spawn ${input.binaryPath} ENOENT`, | 302 | detail: `spawn ${input.binaryPath} ENOENT`, |
| ... | @@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 334 | )); | 340 | )); |
| 335 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; | 341 | const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; |
| 336 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; | 342 | const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; |
| 343 | const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); | ||
| 337 | 344 | ||
| 338 | const child = yield* spawner | 345 | const child = yield* spawner |
| 339 | .spawn( | 346 | .spawn( |
| 340 | ChildProcess.make(input.binaryPath, args, { | 347 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 341 | detached: process.platform !== "win32", | 348 | detached: hostPlatform !== "win32", |
| 342 | shell: process.platform === "win32", | 349 | shell: spawnCommand.shell, |
| 343 | env: { | 350 | env: { |
| 344 | ...(input.environment ?? process.env), | 351 | ...input.environment, |
| 345 | OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, | 352 | OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, |
| 346 | }, | 353 | }, |
| 354 | extendEnv: input.environment === undefined, | ||
| 347 | }), | 355 | }), |
| 348 | ) | 356 | ) |
| 349 | .pipe( | 357 | .pipe( |
| ... | @@ -359,7 +367,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { | ... | @@ -359,7 +367,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { |
| 359 | ); | 367 | ); |
| 360 | 368 | ||
| 361 | const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => | 369 | const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => |
| 362 | process.platform === "win32" | 370 | hostPlatform === "win32" |
| 363 | ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) | 371 | ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) |
| 364 | : Effect.sync(() => { | 372 | : Effect.sync(() => { |
| 365 | try { | 373 | try { |
apps/server/src/provider/providerMaintenance.test.ts+68-39| ... | @@ -1,19 +1,22 @@ | ... | @@ -1,19 +1,22 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import { afterEach, expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; | 3 | import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import os from "node:os"; | 5 | import * as NodeOS from "node:os"; |
| 6 | import path from "node:path"; | 6 | import path from "node:path"; |
| 7 | import { ProviderDriverKind } from "@t3tools/contracts"; | 7 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import { HttpClient } from "effect/unstable/http"; | ||
| 10 | import { | 12 | import { |
| 11 | clearLatestProviderVersionCacheForTests, | ||
| 12 | createProviderVersionAdvisory, | 13 | createProviderVersionAdvisory, |
| 13 | makePackageManagedProviderMaintenanceResolver, | 14 | makePackageManagedProviderMaintenanceResolver, |
| 14 | makeProviderMaintenanceCapabilities, | 15 | makeProviderMaintenanceCapabilities, |
| 15 | makeStaticProviderMaintenanceResolver, | 16 | makeStaticProviderMaintenanceResolver, |
| 16 | normalizeCommandPath, | 17 | normalizeCommandPath, |
| 18 | ProviderVersionCache, | ||
| 19 | resolveLatestProviderVersion, | ||
| 17 | resolveProviderMaintenanceCapabilitiesEffect, | 20 | resolveProviderMaintenanceCapabilitiesEffect, |
| 18 | } from "./providerMaintenance.ts"; | 21 | } from "./providerMaintenance.ts"; |
| 19 | 22 | ||
| ... | @@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); | ... | @@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); |
| 21 | const makeTempDir = (name: string) => | 24 | const makeTempDir = (name: string) => |
| 22 | Crypto.Crypto.pipe( | 25 | Crypto.Crypto.pipe( |
| 23 | Effect.flatMap((crypto) => crypto.randomUUIDv4), | 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 | const isNativeTestCommandPath = | 29 | const isNativeTestCommandPath = |
| 27 | (expectedPathSegment: string) => | 30 | (expectedPathSegment: string) => |
| ... | @@ -65,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( | ... | @@ -65,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( |
| 65 | }), | 68 | }), |
| 66 | ); | 69 | ); |
| 67 | 70 | ||
| 68 | afterEach(() => { | ||
| 69 | clearLatestProviderVersionCacheForTests(); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it.layer(NodeServices.layer)("providerMaintenance", (it) => { | 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 | it("marks providers with unknown current versions as unknown", () => { | 98 | it("marks providers with unknown current versions as unknown", () => { |
| 74 | expect( | 99 | expect( |
| 75 | createProviderVersionAdvisory({ | 100 | createProviderVersionAdvisory({ |
| ... | @@ -144,15 +169,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -144,15 +169,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 144 | writeFileSync(packageToolPath, "#!/bin/sh\n"); | 169 | writeFileSync(packageToolPath, "#!/bin/sh\n"); |
| 145 | chmodSync(packageToolPath, 0o755); | 170 | chmodSync(packageToolPath, 0o755); |
| 146 | 171 | ||
| 147 | expect( | 172 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 148 | packageToolUpdate.resolve({ | 173 | packageToolUpdate, |
| 174 | { | ||
| 149 | binaryPath: "package-tool", | 175 | binaryPath: "package-tool", |
| 150 | platform: "darwin", | ||
| 151 | env: { | 176 | env: { |
| 152 | PATH: vitePlusBinDir, | 177 | PATH: vitePlusBinDir, |
| 153 | }, | 178 | }, |
| 154 | }), | 179 | }, |
| 155 | ).toEqual({ | 180 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); |
| 181 | |||
| 182 | expect(capabilities).toEqual({ | ||
| 156 | provider: driver("packageTool"), | 183 | provider: driver("packageTool"), |
| 157 | packageName: "@example/package-tool", | 184 | packageName: "@example/package-tool", |
| 158 | update: { | 185 | update: { |
| ... | @@ -177,16 +204,18 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -177,16 +204,18 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 177 | mkdirSync(bunBinDir, { recursive: true }); | 204 | mkdirSync(bunBinDir, { recursive: true }); |
| 178 | writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); | 205 | writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); |
| 179 | 206 | ||
| 180 | expect( | 207 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 181 | nativePackageToolUpdate.resolve({ | 208 | nativePackageToolUpdate, |
| 209 | { | ||
| 182 | binaryPath: "native-package-tool", | 210 | binaryPath: "native-package-tool", |
| 183 | platform: "win32", | ||
| 184 | env: { | 211 | env: { |
| 185 | PATH: bunBinDir, | 212 | PATH: bunBinDir, |
| 186 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | 213 | PATHEXT: ".COM;.EXE;.BAT;.CMD", |
| 187 | }, | 214 | }, |
| 188 | }), | 215 | }, |
| 189 | ).toEqual({ | 216 | ).pipe(Effect.provideService(HostProcessPlatform, "win32")); |
| 217 | |||
| 218 | expect(capabilities).toEqual({ | ||
| 190 | provider: driver("nativePackageTool"), | 219 | provider: driver("nativePackageTool"), |
| 191 | packageName: "@example/native-package-tool", | 220 | packageName: "@example/native-package-tool", |
| 192 | update: { | 221 | update: { |
| ... | @@ -213,15 +242,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -213,15 +242,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 213 | writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); | 242 | writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); |
| 214 | chmodSync(scopedPackageToolPath, 0o755); | 243 | chmodSync(scopedPackageToolPath, 0o755); |
| 215 | 244 | ||
| 216 | expect( | 245 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 217 | scopedPackageToolUpdate.resolve({ | 246 | scopedPackageToolUpdate, |
| 247 | { | ||
| 218 | binaryPath: "scoped-package-tool", | 248 | binaryPath: "scoped-package-tool", |
| 219 | platform: "darwin", | ||
| 220 | env: { | 249 | env: { |
| 221 | PATH: pnpmHomeDir, | 250 | PATH: pnpmHomeDir, |
| 222 | }, | 251 | }, |
| 223 | }), | 252 | }, |
| 224 | ).toEqual({ | 253 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); |
| 254 | |||
| 255 | expect(capabilities).toEqual({ | ||
| 225 | provider: driver("scopedPackageTool"), | 256 | provider: driver("scopedPackageTool"), |
| 226 | packageName: "@example/scoped-package-tool", | 257 | packageName: "@example/scoped-package-tool", |
| 227 | update: { | 258 | update: { |
| ... | @@ -241,7 +272,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -241,7 +272,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 241 | expect( | 272 | expect( |
| 242 | packageToolUpdate.resolve({ | 273 | packageToolUpdate.resolve({ |
| 243 | binaryPath: "/opt/homebrew/bin/package-tool", | 274 | binaryPath: "/opt/homebrew/bin/package-tool", |
| 244 | platform: "darwin", | ||
| 245 | env: { | 275 | env: { |
| 246 | PATH: "", | 276 | PATH: "", |
| 247 | }, | 277 | }, |
| ... | @@ -272,15 +302,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -272,15 +302,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 272 | writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); | 302 | writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); |
| 273 | chmodSync(nativePackageToolPath, 0o755); | 303 | chmodSync(nativePackageToolPath, 0o755); |
| 274 | 304 | ||
| 275 | expect( | 305 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 276 | nativePackageToolUpdate.resolve({ | 306 | nativePackageToolUpdate, |
| 307 | { | ||
| 277 | binaryPath: "native-package-tool", | 308 | binaryPath: "native-package-tool", |
| 278 | platform: "darwin", | ||
| 279 | env: { | 309 | env: { |
| 280 | PATH: nativeBinDir, | 310 | PATH: nativeBinDir, |
| 281 | }, | 311 | }, |
| 282 | }), | 312 | }, |
| 283 | ).toEqual({ | 313 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); |
| 314 | |||
| 315 | expect(capabilities).toEqual({ | ||
| 284 | provider: driver("nativePackageTool"), | 316 | provider: driver("nativePackageTool"), |
| 285 | packageName: "@example/native-package-tool", | 317 | packageName: "@example/native-package-tool", |
| 286 | update: { | 318 | update: { |
| ... | @@ -307,15 +339,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -307,15 +339,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 307 | writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); | 339 | writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); |
| 308 | chmodSync(scopedPackageToolPath, 0o755); | 340 | chmodSync(scopedPackageToolPath, 0o755); |
| 309 | 341 | ||
| 310 | expect( | 342 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 311 | scopedPackageToolUpdate.resolve({ | 343 | scopedPackageToolUpdate, |
| 344 | { | ||
| 312 | binaryPath: "scoped-package-tool", | 345 | binaryPath: "scoped-package-tool", |
| 313 | platform: "darwin", | ||
| 314 | env: { | 346 | env: { |
| 315 | PATH: nativeBinDir, | 347 | PATH: nativeBinDir, |
| 316 | }, | 348 | }, |
| 317 | }), | 349 | }, |
| 318 | ).toEqual({ | 350 | ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); |
| 351 | |||
| 352 | expect(capabilities).toEqual({ | ||
| 319 | provider: driver("scopedPackageTool"), | 353 | provider: driver("scopedPackageTool"), |
| 320 | packageName: "@example/scoped-package-tool", | 354 | packageName: "@example/scoped-package-tool", |
| 321 | update: { | 355 | update: { |
| ... | @@ -335,7 +369,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -335,7 +369,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 335 | expect( | 369 | expect( |
| 336 | nativePackageToolUpdate.resolve({ | 370 | nativePackageToolUpdate.resolve({ |
| 337 | binaryPath: "/opt/homebrew/bin/native-package-tool", | 371 | binaryPath: "/opt/homebrew/bin/native-package-tool", |
| 338 | platform: "darwin", | ||
| 339 | env: { | 372 | env: { |
| 340 | PATH: "", | 373 | PATH: "", |
| 341 | }, | 374 | }, |
| ... | @@ -359,7 +392,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -359,7 +392,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 359 | expect( | 392 | expect( |
| 360 | scopedPackageToolUpdate.resolve({ | 393 | scopedPackageToolUpdate.resolve({ |
| 361 | binaryPath: "/opt/homebrew/bin/scoped-package-tool", | 394 | binaryPath: "/opt/homebrew/bin/scoped-package-tool", |
| 362 | platform: "darwin", | ||
| 363 | env: { | 395 | env: { |
| 364 | PATH: "", | 396 | PATH: "", |
| 365 | }, | 397 | }, |
| ... | @@ -401,7 +433,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -401,7 +433,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 401 | 433 | ||
| 402 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { | 434 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 403 | binaryPath: symlinkPath, | 435 | binaryPath: symlinkPath, |
| 404 | platform: "darwin", | ||
| 405 | env: { | 436 | env: { |
| 406 | PATH: "", | 437 | PATH: "", |
| 407 | }, | 438 | }, |
| ... | @@ -449,7 +480,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -449,7 +480,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 449 | 480 | ||
| 450 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { | 481 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 451 | binaryPath: symlinkPath, | 482 | binaryPath: symlinkPath, |
| 452 | platform: "darwin", | ||
| 453 | env: { | 483 | env: { |
| 454 | PATH: "", | 484 | PATH: "", |
| 455 | }, | 485 | }, |
| ... | @@ -475,7 +505,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { | ... | @@ -475,7 +505,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 475 | expect( | 505 | expect( |
| 476 | packageToolUpdate.resolve({ | 506 | packageToolUpdate.resolve({ |
| 477 | binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", | 507 | binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", |
| 478 | platform: "win32", | ||
| 479 | env: { | 508 | env: { |
| 480 | PATH: "", | 509 | PATH: "", |
| 481 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | 510 | PATHEXT: ".COM;.EXE;.BAT;.CMD", |
apps/server/src/provider/providerMaintenance.ts+37-15| ... | @@ -5,6 +5,8 @@ import { | ... | @@ -5,6 +5,8 @@ import { |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { compareSemverVersions } from "@t3tools/shared/semver"; | 6 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 7 | import { resolveCommandPath } from "@t3tools/shared/shell"; | 7 | import { resolveCommandPath } from "@t3tools/shared/shell"; |
| 8 | import * as Config from "effect/Config"; | ||
| 9 | import * as Context from "effect/Context"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | 10 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 12 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -16,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; | ... | @@ -16,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; |
| 16 | const LATEST_VERSION_TIMEOUT_MS = 4_000; | 18 | const LATEST_VERSION_TIMEOUT_MS = 4_000; |
| 17 | const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; | 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 | export interface ProviderMaintenanceCapabilities { | 40 | export interface ProviderMaintenanceCapabilities { |
| 20 | readonly provider: ProviderDriverKind; | 41 | readonly provider: ProviderDriverKind; |
| 21 | readonly packageName: string | null; | 42 | readonly packageName: string | null; |
| ... | @@ -32,7 +53,7 @@ export interface ProviderMaintenanceCommandAction { | ... | @@ -32,7 +53,7 @@ export interface ProviderMaintenanceCommandAction { |
| 32 | export interface ProviderMaintenanceCapabilityResolutionOptions { | 53 | export interface ProviderMaintenanceCapabilityResolutionOptions { |
| 33 | readonly binaryPath?: string | null; | 54 | readonly binaryPath?: string | null; |
| 34 | readonly env?: NodeJS.ProcessEnv; | 55 | readonly env?: NodeJS.ProcessEnv; |
| 35 | readonly platform?: NodeJS.Platform; | 56 | readonly resolvedCommandPath?: string | null; |
| 36 | readonly realCommandPath?: string | null; | 57 | readonly realCommandPath?: string | null; |
| 37 | } | 58 | } |
| 38 | 59 | ||
| ... | @@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { | ... | @@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { |
| 54 | } | null; | 75 | } | null; |
| 55 | } | 76 | } |
| 56 | 77 | ||
| 57 | interface LatestVersionCacheEntry { | 78 | export interface ProviderVersionCacheEntry { |
| 58 | readonly expiresAt: number; | 79 | readonly expiresAt: number; |
| 59 | readonly version: string | null; | 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 | const NpmLatestVersionResponse = Schema.Struct({ | 89 | const NpmLatestVersionResponse = Schema.Struct({ |
| 64 | version: Schema.optional(Schema.String), | 90 | version: Schema.optional(Schema.String), |
| 65 | }); | 91 | }); |
| 66 | 92 | ||
| 67 | export function clearLatestProviderVersionCacheForTests(): void { | ||
| 68 | latestVersionCache.clear(); | ||
| 69 | } | ||
| 70 | |||
| 71 | function nonEmptyString(value: unknown): string | null { | 93 | function nonEmptyString(value: unknown): string | null { |
| 72 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | 94 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; |
| 73 | } | 95 | } |
| ... | @@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance( | ... | @@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance( |
| 251 | } | 273 | } |
| 252 | 274 | ||
| 253 | const resolvedCommandPath = | 275 | const resolvedCommandPath = |
| 254 | resolveCommandPath(binaryPath, { | 276 | options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); |
| 255 | ...(options?.platform ? { platform: options.platform } : {}), | ||
| 256 | ...(options?.env ? { env: options.env } : {}), | ||
| 257 | }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | ||
| 258 | 277 | ||
| 259 | if (resolvedCommandPath) { | 278 | if (resolvedCommandPath) { |
| 260 | const commandPaths = [ | 279 | const commandPaths = [ |
| ... | @@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( | ... | @@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 335 | return resolver.resolve(options); | 354 | return resolver.resolve(options); |
| 336 | } | 355 | } |
| 337 | 356 | ||
| 357 | const env = options?.env ?? (yield* readCommandLookupEnv); | ||
| 338 | const resolvedCommandPath = | 358 | const resolvedCommandPath = |
| 339 | resolveCommandPath(binaryPath, { | 359 | (yield* resolveCommandPath(binaryPath, { env }).pipe( |
| 340 | ...(options?.platform ? { platform: options.platform } : {}), | 360 | Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), |
| 341 | ...(options?.env ? { env: options.env } : {}), | 361 | )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); |
| 342 | }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); | ||
| 343 | if (!resolvedCommandPath) { | 362 | if (!resolvedCommandPath) { |
| 344 | return resolver.resolve(options); | 363 | return resolver.resolve(options); |
| 345 | } | 364 | } |
| ... | @@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( | ... | @@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 350 | .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); | 369 | .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); |
| 351 | return resolver.resolve({ | 370 | return resolver.resolve({ |
| 352 | ...options, | 371 | ...options, |
| 372 | env, | ||
| 373 | resolvedCommandPath, | ||
| 353 | realCommandPath, | 374 | realCommandPath, |
| 354 | }); | 375 | }); |
| 355 | }); | 376 | }); |
| ... | @@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers | ... | @@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers |
| 430 | return null; | 451 | return null; |
| 431 | } | 452 | } |
| 432 | 453 | ||
| 454 | const latestVersionCache = yield* ProviderVersionCache; | ||
| 433 | const cached = latestVersionCache.get(packageName); | 455 | const cached = latestVersionCache.get(packageName); |
| 434 | const now = DateTime.toEpochMillis(yield* DateTime.now); | 456 | const now = DateTime.toEpochMillis(yield* DateTime.now); |
| 435 | if (cached && cached.expiresAt > now) { | 457 | if (cached && cached.expiresAt > now) { |
apps/server/src/provider/providerMaintenanceRunner.test.ts+8-7| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { afterEach, describe, it, assert } from "@effect/vitest"; | 1 | import { describe, it, assert } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 3 | ProviderDriverKind, | 3 | ProviderDriverKind, |
| 4 | ProviderInstanceId, | 4 | ProviderInstanceId, |
| ... | @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 21 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; | 21 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; |
| 22 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; | 22 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| 23 | import { | 23 | import { |
| 24 | clearLatestProviderVersionCacheForTests, | ||
| 25 | makeProviderMaintenanceCapabilities, | 24 | makeProviderMaintenanceCapabilities, |
| 25 | ProviderVersionCache, | ||
| 26 | type ProviderMaintenanceCapabilities, | 26 | type ProviderMaintenanceCapabilities, |
| 27 | } from "./providerMaintenance.ts"; | 27 | } from "./providerMaintenance.ts"; |
| 28 | const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); | 28 | const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); |
| ... | @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); | ... | @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); |
| 35 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); | 35 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); |
| 36 | const encoder = new TextEncoder(); | 36 | const encoder = new TextEncoder(); |
| 37 | 37 | ||
| 38 | afterEach(() => { | ||
| 39 | clearLatestProviderVersionCacheForTests(); | ||
| 40 | }); | ||
| 41 | |||
| 42 | function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { | 38 | function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { |
| 43 | if (provider === CURSOR_DRIVER) { | 39 | if (provider === CURSOR_DRIVER) { |
| 44 | return makeProviderMaintenanceCapabilities({ | 40 | return makeProviderMaintenanceCapabilities({ |
| ... | @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => | ... | @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => |
| 202 | Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( | 198 | Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( |
| 203 | Effect.provide( | 199 | Effect.provide( |
| 204 | ProviderMaintenanceRunner.layer.pipe( | 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,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman |
| 75 | ); | 75 | ); |
| 76 | 76 | ||
| 77 | const result: CommandResult = { stdout, stderr, code: exitCode }; | 77 | const result: CommandResult = { stdout, stderr, code: exitCode }; |
| 78 | if (isWindowsCommandNotFound(exitCode, stderr)) { | 78 | if (yield* isWindowsCommandNotFound(exitCode, stderr)) { |
| 79 | return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); | 79 | return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); |
| 80 | } | 80 | } |
| 81 | return result; | 81 | return result; |
apps/server/src/server.test.ts+1| ... | @@ -579,6 +579,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -579,6 +579,7 @@ const buildAppUnderTest = (options?: { |
| 579 | ), | 579 | ), |
| 580 | Layer.provide( | 580 | Layer.provide( |
| 581 | Layer.mock(ExternalLauncher.ExternalLauncher)({ | 581 | Layer.mock(ExternalLauncher.ExternalLauncher)({ |
| 582 | resolveAvailableEditors: () => Effect.succeed([]), | ||
| 582 | ...options?.layers?.externalLauncher, | 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,7 +365,7 @@ export const makeServerLayer = Layer.unwrap( |
| 365 | Effect.gen(function* () { | 365 | Effect.gen(function* () { |
| 366 | const config = yield* ServerConfig; | 366 | const config = yield* ServerConfig; |
| 367 | 367 | ||
| 368 | fixPath(); | 368 | yield* fixPath(); |
| 369 | 369 | ||
| 370 | const httpListeningLayer = Layer.effectDiscard( | 370 | const httpListeningLayer = Layer.effectDiscard( |
| 371 | Effect.gen(function* () { | 371 | Effect.gen(function* () { |
apps/server/src/startupAccess.ts+4-4| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { networkInterfaces } from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | 2 | ||
| 3 | import { QrCode } from "@t3tools/shared/qrCode"; | 3 | import { QrCode } from "@t3tools/shared/qrCode"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo { | ... | @@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo { |
| 13 | readonly pairingUrl: string; | 13 | readonly pairingUrl: string; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | type NetworkInterfacesMap = ReturnType<typeof networkInterfaces>; | 16 | type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>; |
| 17 | 17 | ||
| 18 | export const isLoopbackHost = (host: string | undefined): boolean => { | 18 | export const isLoopbackHost = (host: string | undefined): boolean => { |
| 19 | if (!host || host.length === 0) { | 19 | if (!host || host.length === 0) { |
| ... | @@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || | ... | @@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || |
| 44 | 44 | ||
| 45 | export const resolveHeadlessConnectionHost = ( | 45 | export const resolveHeadlessConnectionHost = ( |
| 46 | host: string | undefined, | 46 | host: string | undefined, |
| 47 | interfaces: NetworkInterfacesMap = networkInterfaces(), | 47 | interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), |
| 48 | ): string => { | 48 | ): string => { |
| 49 | if (!host) { | 49 | if (!host) { |
| 50 | return "localhost"; | 50 | return "localhost"; |
| ... | @@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = ( | ... | @@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = ( |
| 71 | export const resolveHeadlessConnectionString = ( | 71 | export const resolveHeadlessConnectionString = ( |
| 72 | host: string | undefined, | 72 | host: string | undefined, |
| 73 | port: number, | 73 | port: number, |
| 74 | interfaces: NetworkInterfacesMap = networkInterfaces(), | 74 | interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), |
| 75 | ): string => { | 75 | ): string => { |
| 76 | const connectionHost = resolveHeadlessConnectionHost(host, interfaces); | 76 | const connectionHost = resolveHeadlessConnectionHost(host, interfaces); |
| 77 | return `http://${formatHostForUrl(connectionHost)}:${port}`; | 77 | return `http://${formatHostForUrl(connectionHost)}:${port}`; |
apps/server/src/telemetry/Identify.ts+4-3| ... | @@ -1,10 +1,11 @@ | ... | @@ -1,10 +1,11 @@ |
| 1 | import * as NodeOS from "node:os"; | ||
| 1 | import * as Crypto from "effect/Crypto"; | 2 | import * as Crypto from "effect/Crypto"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Encoding from "effect/Encoding"; | 4 | import * as Encoding from "effect/Encoding"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 7 | import { homedir } from "node:os"; | 8 | |
| 8 | import { ServerConfig } from "../config.ts"; | 9 | import { ServerConfig } from "../config.ts"; |
| 9 | 10 | ||
| 10 | const CodexAuthJsonSchema = Schema.Struct({ | 11 | const CodexAuthJsonSchema = Schema.Struct({ |
| ... | @@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () { | ... | @@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () { |
| 39 | const fileSystem = yield* FileSystem.FileSystem; | 40 | const fileSystem = yield* FileSystem.FileSystem; |
| 40 | const path = yield* Path.Path; | 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 | const authJson = yield* Effect.flatMap( | 44 | const authJson = yield* Effect.flatMap( |
| 44 | fileSystem.readFileString(authJsonPath), | 45 | fileSystem.readFileString(authJsonPath), |
| 45 | Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), | 46 | Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), |
| ... | @@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () { | ... | @@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () { |
| 52 | const fileSystem = yield* FileSystem.FileSystem; | 53 | const fileSystem = yield* FileSystem.FileSystem; |
| 53 | const path = yield* Path.Path; | 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 | const claudeJson = yield* Effect.flatMap( | 57 | const claudeJson = yield* Effect.flatMap( |
| 57 | fileSystem.readFileString(claudeJsonPath), | 58 | fileSystem.readFileString(claudeJsonPath), |
| 58 | Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), | 59 | Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), |
apps/server/src/telemetry/Layers/AnalyticsService.ts+8-3| ... | @@ -7,10 +7,12 @@ | ... | @@ -7,10 +7,12 @@ |
| 7 | * @module AnalyticsServiceLive | 7 | * @module AnalyticsServiceLive |
| 8 | */ | 8 | */ |
| 9 | 9 | ||
| 10 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 10 | import * as Config from "effect/Config"; | 11 | import * as Config from "effect/Config"; |
| 11 | import * as DateTime from "effect/DateTime"; | 12 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | ||
| 14 | import * as Ref from "effect/Ref"; | 16 | import * as Ref from "effect/Ref"; |
| 15 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 17 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 16 | 18 | ||
| ... | @@ -37,6 +39,7 @@ const TelemetryEnvConfig = Config.all({ | ... | @@ -37,6 +39,7 @@ const TelemetryEnvConfig = Config.all({ |
| 37 | maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( | 39 | maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( |
| 38 | Config.withDefault(1_000), | 40 | Config.withDefault(1_000), |
| 39 | ), | 41 | ), |
| 42 | wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), | ||
| 40 | }); | 43 | }); |
| 41 | 44 | ||
| 42 | const makeAnalyticsService = Effect.gen(function* () { | 45 | const makeAnalyticsService = Effect.gen(function* () { |
| ... | @@ -46,6 +49,8 @@ const makeAnalyticsService = Effect.gen(function* () { | ... | @@ -46,6 +49,8 @@ const makeAnalyticsService = Effect.gen(function* () { |
| 46 | const identifier = yield* getTelemetryIdentifier; | 49 | const identifier = yield* getTelemetryIdentifier; |
| 47 | const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]); | 50 | const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]); |
| 48 | const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; | 51 | const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; |
| 52 | const hostPlatform = yield* HostProcessPlatform; | ||
| 53 | const hostArchitecture = yield* HostProcessArchitecture; | ||
| 49 | 54 | ||
| 50 | const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) => | 55 | const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) => |
| 51 | Effect.flatMap(DateTime.now, (now) => | 56 | Effect.flatMap(DateTime.now, (now) => |
| ... | @@ -87,9 +92,9 @@ const makeAnalyticsService = Effect.gen(function* () { | ... | @@ -87,9 +92,9 @@ const makeAnalyticsService = Effect.gen(function* () { |
| 87 | properties: { | 92 | properties: { |
| 88 | ...event.properties, | 93 | ...event.properties, |
| 89 | $process_person_profile: false, | 94 | $process_person_profile: false, |
| 90 | platform: process.platform, | 95 | platform: hostPlatform, |
| 91 | wsl: process.env.WSL_DISTRO_NAME, | 96 | wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), |
| 92 | arch: process.arch, | 97 | arch: hostArchitecture, |
| 93 | t3CodeVersion: packageJson.version, | 98 | t3CodeVersion: packageJson.version, |
| 94 | clientType, | 99 | clientType, |
| 95 | }, | 100 | }, |
apps/server/src/terminal/Layers/BunPTY.ts+3-1| ... | @@ -2,6 +2,7 @@ | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | ||
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 5 | import { PtyAdapter } from "../Services/PTY.ts"; | 6 | import { PtyAdapter } from "../Services/PTY.ts"; |
| 6 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; | 7 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; |
| 7 | 8 | ||
| ... | @@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess { | ... | @@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess { |
| 95 | export const layer = Layer.effect( | 96 | export const layer = Layer.effect( |
| 96 | PtyAdapter, | 97 | PtyAdapter, |
| 97 | Effect.gen(function* () { | 98 | Effect.gen(function* () { |
| 98 | if (process.platform === "win32") { | 99 | const platform = yield* HostProcessPlatform; |
| 100 | if (platform === "win32") { | ||
| 99 | return yield* Effect.die( | 101 | return yield* Effect.die( |
| 100 | "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", | 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,6 +8,7 @@ import { |
| 8 | type TerminalOpenInput, | 8 | type TerminalOpenInput, |
| 9 | type TerminalRestartInput, | 9 | type TerminalRestartInput, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 11 | import * as Data from "effect/Data"; | 12 | import * as Data from "effect/Data"; |
| 12 | import * as Duration from "effect/Duration"; | 13 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| ... | @@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = ( | ... | @@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = ( |
| 199 | 200 | ||
| 200 | interface CreateManagerOptions { | 201 | interface CreateManagerOptions { |
| 201 | shellResolver?: () => string; | 202 | shellResolver?: () => string; |
| 202 | platform?: NodeJS.Platform; | ||
| 203 | env?: NodeJS.ProcessEnv; | 203 | env?: NodeJS.ProcessEnv; |
| 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ | 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ |
| 205 | readonly hasRunningSubprocess: boolean; | 205 | readonly hasRunningSubprocess: boolean; |
| ... | @@ -240,7 +240,6 @@ const createManager = ( | ... | @@ -240,7 +240,6 @@ const createManager = ( |
| 240 | historyLineLimit, | 240 | historyLineLimit, |
| 241 | ptyAdapter, | 241 | ptyAdapter, |
| 242 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), | 242 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), |
| 243 | ...(options.platform !== undefined ? { platform: options.platform } : {}), | ||
| 244 | ...(options.env !== undefined ? { env: options.env } : {}), | 243 | ...(options.env !== undefined ? { env: options.env } : {}), |
| 245 | ...(options.subprocessInspector !== undefined | 244 | ...(options.subprocessInspector !== undefined |
| 246 | ? { subprocessInspector: options.subprocessInspector } | 245 | ? { subprocessInspector: options.subprocessInspector } |
| ... | @@ -270,12 +269,13 @@ const createManager = ( | ... | @@ -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 | it.layer( | 275 | it.layer( |
| 274 | Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), | 276 | Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), |
| 275 | { excludeTestServices: true }, | 277 | { excludeTestServices: true }, |
| 276 | )("TerminalManager", (it) => { | 278 | )("TerminalManager", (it) => { |
| 277 | const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; | ||
| 278 | |||
| 279 | it.effect("spawns lazily and reuses running terminal per thread", () => | 279 | it.effect("spawns lazily and reuses running terminal per thread", () => |
| 280 | Effect.gen(function* () { | 280 | Effect.gen(function* () { |
| 281 | const { manager, ptyAdapter } = yield* createManager(); | 281 | const { manager, ptyAdapter } = yield* createManager(); |
| ... | @@ -415,8 +415,10 @@ it.layer( | ... | @@ -415,8 +415,10 @@ it.layer( |
| 415 | fs.writeFileString(filePath, contents), | 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 | Effect.gen(function* () { | 419 | Effect.gen(function* () { |
| 420 | if ((yield* HostProcessPlatform) === "win32") return; | ||
| 421 | |||
| 420 | const path = yield* Path.Path; | 422 | const path = yield* Path.Path; |
| 421 | 423 | ||
| 422 | const { manager, baseDir } = yield* createManager(); | 424 | const { manager, baseDir } = yield* createManager(); |
| ... | @@ -1082,10 +1084,9 @@ it.layer( | ... | @@ -1082,10 +1084,9 @@ it.layer( |
| 1082 | 1084 | ||
| 1083 | it.effect("retries with fallback shells when preferred shell spawn fails", () => | 1085 | it.effect("retries with fallback shells when preferred shell spawn fails", () => |
| 1084 | Effect.gen(function* () { | 1086 | Effect.gen(function* () { |
| 1087 | const platform = yield* HostProcessPlatform; | ||
| 1085 | const missingShell = | 1088 | const missingShell = |
| 1086 | process.platform === "win32" | 1089 | platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; |
| 1087 | ? "C:\\definitely\\missing-shell.exe" | ||
| 1088 | : "/definitely/missing-shell -l"; | ||
| 1089 | const { manager, ptyAdapter } = yield* createManager(5, { | 1090 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1090 | shellResolver: () => missingShell, | 1091 | shellResolver: () => missingShell, |
| 1091 | }); | 1092 | }); |
| ... | @@ -1096,10 +1097,10 @@ it.layer( | ... | @@ -1096,10 +1097,10 @@ it.layer( |
| 1096 | assert.equal(snapshot.status, "running"); | 1097 | assert.equal(snapshot.status, "running"); |
| 1097 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | 1098 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); |
| 1098 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe( | 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 | expect( | 1104 | expect( |
| 1104 | ptyAdapter.spawnInputs.some( | 1105 | ptyAdapter.spawnInputs.some( |
| 1105 | (input) => | 1106 | (input) => |
| ... | @@ -1121,13 +1122,12 @@ it.layer( | ... | @@ -1121,13 +1122,12 @@ it.layer( |
| 1121 | it.effect("prefers PowerShell over ComSpec for Windows terminals", () => | 1122 | it.effect("prefers PowerShell over ComSpec for Windows terminals", () => |
| 1122 | Effect.gen(function* () { | 1123 | Effect.gen(function* () { |
| 1123 | const { manager, ptyAdapter } = yield* createManager(5, { | 1124 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1124 | platform: "win32", | ||
| 1125 | env: { | 1125 | env: { |
| 1126 | ComSpec: "C:\\Windows\\System32\\cmd.exe", | 1126 | ComSpec: "C:\\Windows\\System32\\cmd.exe", |
| 1127 | PATH: "C:\\Windows\\System32", | 1127 | PATH: "C:\\Windows\\System32", |
| 1128 | SystemRoot: "C:\\Windows", | 1128 | SystemRoot: "C:\\Windows", |
| 1129 | }, | 1129 | }, |
| 1130 | }); | 1130 | }).pipe(Effect.provide(withHostPlatform("win32"))); |
| 1131 | 1131 | ||
| 1132 | yield* manager.open(openInput()); | 1132 | yield* manager.open(openInput()); |
| 1133 | 1133 | ||
| ... | @@ -1142,15 +1142,16 @@ it.layer( | ... | @@ -1142,15 +1142,16 @@ it.layer( |
| 1142 | 1142 | ||
| 1143 | it.effect("falls back to built-in PowerShell by absolute path on Windows", () => | 1143 | it.effect("falls back to built-in PowerShell by absolute path on Windows", () => |
| 1144 | Effect.gen(function* () { | 1144 | Effect.gen(function* () { |
| 1145 | const { manager, ptyAdapter } = yield* createManager(5, { | 1145 | const ptyAdapter = new FakePtyAdapter(); |
| 1146 | platform: "win32", | 1146 | const { manager } = yield* createManager(5, { |
| 1147 | ptyAdapter, | ||
| 1148 | shellResolver: () => "C:\\missing\\custom-shell.exe", | ||
| 1147 | env: { | 1149 | env: { |
| 1148 | ComSpec: "C:\\Windows\\System32\\cmd.exe", | 1150 | ComSpec: "C:\\Windows\\System32\\cmd.exe", |
| 1149 | PATH: "C:\\Windows\\System32", | 1151 | PATH: "C:\\Windows\\System32", |
| 1150 | SystemRoot: "C:\\Windows", | 1152 | SystemRoot: "C:\\Windows", |
| 1151 | }, | 1153 | }, |
| 1152 | shellResolver: () => "C:\\missing\\custom-shell.exe", | 1154 | }).pipe(Effect.provide(withHostPlatform("win32"))); |
| 1153 | }); | ||
| 1154 | ptyAdapter.spawnFailures.push( | 1155 | ptyAdapter.spawnFailures.push( |
| 1155 | new Error("spawn custom-shell.exe ENOENT"), | 1156 | new Error("spawn custom-shell.exe ENOENT"), |
| 1156 | new Error("spawn pwsh.exe ENOENT"), | 1157 | new Error("spawn pwsh.exe ENOENT"), |
| ... | @@ -1170,46 +1171,25 @@ it.layer( | ... | @@ -1170,46 +1171,25 @@ it.layer( |
| 1170 | 1171 | ||
| 1171 | it.effect("filters app runtime env variables from terminal sessions", () => | 1172 | it.effect("filters app runtime env variables from terminal sessions", () => |
| 1172 | Effect.gen(function* () { | 1173 | Effect.gen(function* () { |
| 1173 | const originalValues = new Map<string, string | undefined>(); | 1174 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1174 | const setEnv = (key: string, value: string | undefined) => { | 1175 | env: { |
| 1175 | if (!originalValues.has(key)) { | 1176 | PORT: "5173", |
| 1176 | originalValues.set(key, process.env[key]); | 1177 | T3CODE_PORT: "3773", |
| 1177 | } | 1178 | VITE_DEV_SERVER_URL: "http://localhost:5173", |
| 1178 | if (value === undefined) { | 1179 | TEST_TERMINAL_KEEP: "keep-me", |
| 1179 | delete process.env[key]; | 1180 | }, |
| 1180 | return; | 1181 | }); |
| 1181 | } | 1182 | yield* manager.open(openInput()); |
| 1182 | process.env[key] = value; | 1183 | const spawnInput = ptyAdapter.spawnInputs[0]; |
| 1183 | }; | 1184 | expect(spawnInput).toBeDefined(); |
| 1184 | const restoreEnv = () => { | 1185 | if (!spawnInput) return; |
| 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"); | ||
| 1198 | 1186 | ||
| 1199 | try { | 1187 | expect(spawnInput.env.PORT).toBeUndefined(); |
| 1200 | const { manager, ptyAdapter } = yield* createManager(); | 1188 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); |
| 1201 | yield* manager.open(openInput()); | 1189 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); |
| 1202 | const spawnInput = ptyAdapter.spawnInputs[0]; | 1190 | // Arbitrary host env vars must pass through — terminals inherit the |
| 1203 | expect(spawnInput).toBeDefined(); | 1191 | // user's environment apart from the explicit blocklist. |
| 1204 | if (!spawnInput) return; | 1192 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); |
| 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 | } | ||
| 1213 | }), | 1193 | }), |
| 1214 | ); | 1194 | ); |
| 1215 | 1195 | ||
| ... | @@ -1237,7 +1217,7 @@ it.layer( | ... | @@ -1237,7 +1217,7 @@ it.layer( |
| 1237 | 1217 | ||
| 1238 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => | 1218 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => |
| 1239 | Effect.gen(function* () { | 1219 | Effect.gen(function* () { |
| 1240 | if (process.platform === "win32") return; | 1220 | if ((yield* HostProcessPlatform) === "win32") return; |
| 1241 | const { manager, ptyAdapter } = yield* createManager(5, { | 1221 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1242 | shellResolver: () => "/bin/zsh", | 1222 | shellResolver: () => "/bin/zsh", |
| 1243 | }); | 1223 | }); |
apps/server/src/terminal/Layers/Manager.ts+14-10| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | type TerminalSummary, | 10 | type TerminalSummary, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | 12 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 13 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 14 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 14 | import * as DateTime from "effect/DateTime"; | 15 | import * as DateTime from "effect/DateTime"; |
| 15 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| ... | @@ -312,10 +313,7 @@ function enqueueProcessEvent( | ... | @@ -312,10 +313,7 @@ function enqueueProcessEvent( |
| 312 | return true; | 313 | return true; |
| 313 | } | 314 | } |
| 314 | 315 | ||
| 315 | function defaultShellResolver( | 316 | function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { |
| 316 | platform: NodeJS.Platform = process.platform, | ||
| 317 | env: NodeJS.ProcessEnv = process.env, | ||
| 318 | ): string { | ||
| 319 | if (platform === "win32") { | 317 | if (platform === "win32") { |
| 320 | return "pwsh.exe"; | 318 | return "pwsh.exe"; |
| 321 | } | 319 | } |
| ... | @@ -324,7 +322,7 @@ function defaultShellResolver( | ... | @@ -324,7 +322,7 @@ function defaultShellResolver( |
| 324 | 322 | ||
| 325 | function normalizeShellCommand( | 323 | function normalizeShellCommand( |
| 326 | value: string | undefined, | 324 | value: string | undefined, |
| 327 | platform: NodeJS.Platform = process.platform, | 325 | platform: NodeJS.Platform, |
| 328 | ): string | null { | 326 | ): string | null { |
| 329 | if (!value) return null; | 327 | if (!value) return null; |
| 330 | const trimmed = value.trim(); | 328 | const trimmed = value.trim(); |
| ... | @@ -360,7 +358,7 @@ function joinWindowsPath(...parts: ReadonlyArray<string>): string { | ... | @@ -360,7 +358,7 @@ function joinWindowsPath(...parts: ReadonlyArray<string>): string { |
| 360 | 358 | ||
| 361 | function shellCandidateFromCommand( | 359 | function shellCandidateFromCommand( |
| 362 | command: string | null, | 360 | command: string | null, |
| 363 | platform: NodeJS.Platform = process.platform, | 361 | platform: NodeJS.Platform, |
| 364 | ): ShellCandidate | null { | 362 | ): ShellCandidate | null { |
| 365 | if (!command || command.length === 0) return null; | 363 | if (!command || command.length === 0) return null; |
| 366 | const shellName = basenameForPlatform(command, platform).toLowerCase(); | 364 | const shellName = basenameForPlatform(command, platform).toLowerCase(); |
| ... | @@ -411,8 +409,8 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC | ... | @@ -411,8 +409,8 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC |
| 411 | 409 | ||
| 412 | function resolveShellCandidates( | 410 | function resolveShellCandidates( |
| 413 | shellResolver: () => string, | 411 | shellResolver: () => string, |
| 414 | platform: NodeJS.Platform = process.platform, | 412 | platform: NodeJS.Platform, |
| 415 | env: NodeJS.ProcessEnv = process.env, | 413 | env: NodeJS.ProcessEnv, |
| 416 | ): ShellCandidate[] { | 414 | ): ShellCandidate[] { |
| 417 | const requested = shellCandidateFromCommand( | 415 | const requested = shellCandidateFromCommand( |
| 418 | normalizeShellCommand(shellResolver(), platform), | 416 | normalizeShellCommand(shellResolver(), platform), |
| ... | @@ -512,6 +510,9 @@ function windowsInspectSubprocess( | ... | @@ -512,6 +510,9 @@ function windowsInspectSubprocess( |
| 512 | return Effect.gen(function* () { | 510 | return Effect.gen(function* () { |
| 513 | const processRunner = yield* ProcessRunner.ProcessRunner; | 511 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 514 | return yield* processRunner.run({ | 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 | command: "powershell.exe", | 516 | command: "powershell.exe", |
| 516 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | 517 | args: ["-NoProfile", "-NonInteractive", "-Command", command], |
| 517 | timeout: "1500 millis", | 518 | timeout: "1500 millis", |
| ... | @@ -976,7 +977,6 @@ interface TerminalManagerOptions { | ... | @@ -976,7 +977,6 @@ interface TerminalManagerOptions { |
| 976 | historyLineLimit?: number; | 977 | historyLineLimit?: number; |
| 977 | ptyAdapter: PtyAdapterShape; | 978 | ptyAdapter: PtyAdapterShape; |
| 978 | shellResolver?: () => string; | 979 | shellResolver?: () => string; |
| 979 | platform?: NodeJS.Platform; | ||
| 980 | env?: NodeJS.ProcessEnv; | 980 | env?: NodeJS.ProcessEnv; |
| 981 | subprocessInspector?: TerminalSubprocessInspector; | 981 | subprocessInspector?: TerminalSubprocessInspector; |
| 982 | subprocessPollIntervalMs?: number; | 982 | subprocessPollIntervalMs?: number; |
| ... | @@ -1014,7 +1014,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1014,7 +1014,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1014 | 1014 | ||
| 1015 | const logsDir = options.logsDir; | 1015 | const logsDir = options.logsDir; |
| 1016 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | 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 | const baseEnv = options.env ?? process.env; | 1022 | const baseEnv = options.env ?? process.env; |
| 1019 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | 1023 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 1020 | const processRunner = yield* ProcessRunner.ProcessRunner; | 1024 | const processRunner = yield* ProcessRunner.ProcessRunner; |
apps/server/src/terminal/Layers/NodePTY.test.ts+57-46| ... | @@ -1,47 +1,58 @@ | ... | @@ -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 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | 2 | import { assert, it } from "@effect/vitest"; | |
| 9 | it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { | 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 10 | it.effect("adds executable bits when helper exists but is not executable", () => | 4 | import * as Effect from "effect/Effect"; |
| 11 | Effect.gen(function* () { | 5 | import * as Layer from "effect/Layer"; |
| 12 | if (process.platform === "win32") return; | 6 | import { vi } from "vite-plus/test"; |
| 13 | 7 | ||
| 14 | const fs = yield* FileSystem.FileSystem; | 8 | import { PtyAdapter } from "../Services/PTY.ts"; |
| 15 | const path = yield* Path.Path; | 9 | import { layer } from "./NodePTY.ts"; |
| 16 | 10 | ||
| 17 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | 11 | const spawn = vi.fn(() => ({ |
| 18 | const helperPath = path.join(dir, "spawn-helper"); | 12 | pid: 42, |
| 19 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | 13 | write: vi.fn(), |
| 20 | yield* fs.chmod(helperPath, 0o644); | 14 | resize: vi.fn(), |
| 21 | 15 | kill: vi.fn(), | |
| 22 | yield* ensureNodePtySpawnHelperExecutable(helperPath); | 16 | onData: vi.fn(() => ({ dispose: vi.fn() })), |
| 23 | 17 | onExit: vi.fn(() => ({ dispose: vi.fn() })), | |
| 24 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; | 18 | })); |
| 25 | assert.equal(mode & 0o111, 0o111); | 19 | |
| 26 | }), | 20 | vi.mock("node-pty", () => ({ spawn })); |
| 27 | ); | 21 | |
| 28 | 22 | const testLayer = layer.pipe( | |
| 29 | it.effect("keeps executable helper as executable", () => | 23 | Layer.provide( |
| 30 | Effect.gen(function* () { | 24 | Layer.mergeAll( |
| 31 | if (process.platform === "win32") return; | 25 | NodeServices.layer, |
| 32 | 26 | Layer.succeed(HostProcessPlatform, "win32"), | |
| 33 | const fs = yield* FileSystem.FileSystem; | 27 | Layer.succeed(HostProcessArchitecture, "x64"), |
| 34 | const path = yield* Path.Path; | 28 | ), |
| 35 | 29 | ), | |
| 36 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | 30 | ); |
| 37 | const helperPath = path.join(dir, "spawn-helper"); | 31 | |
| 38 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | 32 | it.effect("spawns through the public adapter with the provided host references", () => |
| 39 | yield* fs.chmod(helperPath, 0o755); | 33 | Effect.gen(function* () { |
| 40 | 34 | const adapter = yield* PtyAdapter; | |
| 41 | yield* ensureNodePtySpawnHelperExecutable(helperPath); | 35 | const process = yield* adapter.spawn({ |
| 42 | 36 | shell: "powershell.exe", | |
| 43 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; | 37 | args: ["-NoLogo"], |
| 44 | assert.equal(mode & 0o111, 0o111); | 38 | cwd: "C:\\workspace", |
| 45 | }), | 39 | cols: 120, |
| 46 | ); | 40 | rows: 40, |
| 47 | }); | 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,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 7 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | import { PtyAdapter } from "../Services/PTY.ts"; | 8 | import { PtyAdapter } from "../Services/PTY.ts"; |
| 8 | import { | 9 | import { |
| 9 | PtySpawnError, | 10 | PtySpawnError, |
| ... | @@ -18,13 +19,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { | ... | @@ -18,13 +19,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { |
| 18 | const requireForNodePty = createRequire(import.meta.url); | 19 | const requireForNodePty = createRequire(import.meta.url); |
| 19 | const path = yield* Path.Path; | 20 | const path = yield* Path.Path; |
| 20 | const fs = yield* FileSystem.FileSystem; | 21 | const fs = yield* FileSystem.FileSystem; |
| 22 | const platform = yield* HostProcessPlatform; | ||
| 23 | const architecture = yield* HostProcessArchitecture; | ||
| 21 | 24 | ||
| 22 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | 25 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); |
| 23 | const packageDir = path.dirname(packageJsonPath); | 26 | const packageDir = path.dirname(packageJsonPath); |
| 24 | const candidates = [ | 27 | const candidates = [ |
| 25 | path.join(packageDir, "build", "Release", "spawn-helper"), | 28 | path.join(packageDir, "build", "Release", "spawn-helper"), |
| 26 | path.join(packageDir, "build", "Debug", "spawn-helper"), | 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 | for (const candidate of candidates) { | 33 | for (const candidate of candidates) { |
| ... | @@ -35,16 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { | ... | @@ -35,16 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { |
| 35 | return null; | 38 | return null; |
| 36 | }).pipe(Effect.orElseSucceed(() => null)); | 39 | }).pipe(Effect.orElseSucceed(() => null)); |
| 37 | 40 | ||
| 38 | export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { | 41 | const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { |
| 39 | const fs = yield* FileSystem.FileSystem; | 42 | const fs = yield* FileSystem.FileSystem; |
| 40 | if (process.platform === "win32") return; | 43 | const platform = yield* HostProcessPlatform; |
| 41 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | 44 | if (platform === "win32") return; |
| 45 | if (didEnsureSpawnHelperExecutable) return; | ||
| 42 | 46 | ||
| 43 | const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); | 47 | const helperPath = yield* resolveNodePtySpawnHelperPath; |
| 44 | if (!helperPath) return; | 48 | if (!helperPath) return; |
| 45 | if (!explicitPath) { | 49 | didEnsureSpawnHelperExecutable = true; |
| 46 | didEnsureSpawnHelperExecutable = true; | ||
| 47 | } | ||
| 48 | 50 | ||
| 49 | if (!(yield* fs.exists(helperPath))) { | 51 | if (!(yield* fs.exists(helperPath))) { |
| 50 | return; | 52 | return; |
| ... | @@ -102,6 +104,8 @@ export const layer = Layer.effect( | ... | @@ -102,6 +104,8 @@ export const layer = Layer.effect( |
| 102 | Effect.gen(function* () { | 104 | Effect.gen(function* () { |
| 103 | const fs = yield* FileSystem.FileSystem; | 105 | const fs = yield* FileSystem.FileSystem; |
| 104 | const path = yield* Path.Path; | 106 | const path = yield* Path.Path; |
| 107 | const platform = yield* HostProcessPlatform; | ||
| 108 | const architecture = yield* HostProcessArchitecture; | ||
| 105 | 109 | ||
| 106 | const nodePty = yield* Effect.promise(() => import("node-pty")); | 110 | const nodePty = yield* Effect.promise(() => import("node-pty")); |
| 107 | 111 | ||
| ... | @@ -109,6 +113,8 @@ export const layer = Layer.effect( | ... | @@ -109,6 +113,8 @@ export const layer = Layer.effect( |
| 109 | ensureNodePtySpawnHelperExecutable().pipe( | 113 | ensureNodePtySpawnHelperExecutable().pipe( |
| 110 | Effect.provideService(FileSystem.FileSystem, fs), | 114 | Effect.provideService(FileSystem.FileSystem, fs), |
| 111 | Effect.provideService(Path.Path, path), | 115 | Effect.provideService(Path.Path, path), |
| 116 | Effect.provideService(HostProcessPlatform, platform), | ||
| 117 | Effect.provideService(HostProcessArchitecture, architecture), | ||
| 112 | Effect.orElseSucceed(() => undefined), | 118 | Effect.orElseSucceed(() => undefined), |
| 113 | ), | 119 | ), |
| 114 | ); | 120 | ); |
| ... | @@ -123,7 +129,7 @@ export const layer = Layer.effect( | ... | @@ -123,7 +129,7 @@ export const layer = Layer.effect( |
| 123 | cols: input.cols, | 129 | cols: input.cols, |
| 124 | rows: input.rows, | 130 | rows: input.rows, |
| 125 | env: input.env, | 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 | catch: (cause) => | 134 | catch: (cause) => |
| 129 | new PtySpawnError({ | 135 | new PtySpawnError({ |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+12-10| ... | @@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | 15 | ||
| 16 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; | 16 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; |
| 17 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 17 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 18 | 19 | ||
| 19 | import { TextGenerationError } from "@t3tools/contracts"; | 20 | import { TextGenerationError } from "@t3tools/contracts"; |
| 20 | import { type TextGenerationShape } from "./TextGeneration.ts"; | 21 | import { type TextGenerationShape } from "./TextGeneration.ts"; |
| ... | @@ -59,7 +60,7 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla | ... | @@ -59,7 +60,7 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla |
| 59 | 60 | ||
| 60 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( | 61 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( |
| 61 | claudeSettings: ClaudeSettings, | 62 | claudeSettings: ClaudeSettings, |
| 62 | environment: NodeJS.ProcessEnv = process.env, | 63 | environment?: NodeJS.ProcessEnv, |
| 63 | ) { | 64 | ) { |
| 64 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 65 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 65 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | 66 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| ... | @@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 156 | : undefined; | 157 | : undefined; |
| 157 | 158 | ||
| 158 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { | 159 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { |
| 159 | const command = ChildProcess.make( | 160 | const spawnCommand = yield* resolveSpawnCommand( |
| 160 | claudeSettings.binaryPath || "claude", | 161 | claudeSettings.binaryPath || "claude", |
| 161 | [ | 162 | [ |
| 162 | "-p", | 163 | "-p", |
| ... | @@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 170 | ...(settingsJson ? ["--settings", settingsJson] : []), | 171 | ...(settingsJson ? ["--settings", settingsJson] : []), |
| 171 | "--dangerously-skip-permissions", | 172 | "--dangerously-skip-permissions", |
| 172 | ], | 173 | ], |
| 173 | { | 174 | { env: claudeEnvironment }, |
| 174 | env: claudeEnvironment, | ||
| 175 | cwd, | ||
| 176 | shell: process.platform === "win32", | ||
| 177 | stdin: { | ||
| 178 | stream: Stream.encodeText(Stream.make(prompt)), | ||
| 179 | }, | ||
| 180 | }, | ||
| 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 | const child = yield* commandSpawner | 185 | const child = yield* commandSpawner |
| 184 | .spawn(command) | 186 | .spawn(command) |
apps/server/src/textGeneration/CodexTextGeneration.ts+16-13| ... | @@ -9,6 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -9,6 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 9 | 9 | ||
| 10 | import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; | 10 | import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; |
| 11 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 11 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 12 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 12 | 13 | ||
| 13 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | 14 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 14 | import { ServerConfig } from "../config.ts"; | 15 | import { ServerConfig } from "../config.ts"; |
| ... | @@ -44,12 +45,13 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); | ... | @@ -44,12 +45,13 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| 44 | */ | 45 | */ |
| 45 | export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( | 46 | export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( |
| 46 | codexConfig: CodexSettings, | 47 | codexConfig: CodexSettings, |
| 47 | environment: NodeJS.ProcessEnv = process.env, | 48 | environment?: NodeJS.ProcessEnv, |
| 48 | ) { | 49 | ) { |
| 49 | const fileSystem = yield* FileSystem.FileSystem; | 50 | const fileSystem = yield* FileSystem.FileSystem; |
| 50 | const path = yield* Path.Path; | 51 | const path = yield* Path.Path; |
| 51 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 52 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 52 | const serverConfig = yield* Effect.service(ServerConfig); | 53 | const serverConfig = yield* Effect.service(ServerConfig); |
| 54 | const resolvedEnvironment = environment ?? process.env; | ||
| 53 | 55 | ||
| 54 | type MaterializedImageAttachments = { | 56 | type MaterializedImageAttachments = { |
| 55 | readonly imagePaths: ReadonlyArray<string>; | 57 | readonly imagePaths: ReadonlyArray<string>; |
| ... | @@ -180,7 +182,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -180,7 +182,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 180 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? | 182 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? |
| 181 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | 183 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; |
| 182 | const serviceTier = getCodexServiceTierOptionValue(modelSelection); | 184 | const serviceTier = getCodexServiceTierOptionValue(modelSelection); |
| 183 | const command = ChildProcess.make( | 185 | const spawnCommand = yield* resolveSpawnCommand( |
| 184 | codexConfig.binaryPath || "codex", | 186 | codexConfig.binaryPath || "codex", |
| 185 | [ | 187 | [ |
| 186 | "exec", | 188 | "exec", |
| ... | @@ -200,18 +202,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -200,18 +202,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 200 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), | 202 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), |
| 201 | "-", | 203 | "-", |
| 202 | ], | 204 | ], |
| 203 | { | 205 | { env: resolvedEnvironment }, |
| 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 | }, | ||
| 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 | const child = yield* commandSpawner | 219 | const child = yield* commandSpawner |
| 217 | .spawn(command) | 220 | .spawn(command) |
apps/server/src/textGeneration/CursorTextGeneration.ts+3-2| ... | @@ -59,9 +59,10 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { | ... | @@ -59,9 +59,10 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { |
| 59 | */ | 59 | */ |
| 60 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( | 60 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( |
| 61 | cursorSettings: CursorSettings, | 61 | cursorSettings: CursorSettings, |
| 62 | environment: NodeJS.ProcessEnv = process.env, | 62 | environment?: NodeJS.ProcessEnv, |
| 63 | ) { | 63 | ) { |
| 64 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 64 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 65 | const resolvedEnvironment = environment ?? process.env; | ||
| 65 | 66 | ||
| 66 | const runCursorJson = <S extends Schema.Top>({ | 67 | const runCursorJson = <S extends Schema.Top>({ |
| 67 | operation, | 68 | operation, |
| ... | @@ -84,7 +85,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | ... | @@ -84,7 +85,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 84 | const outputRef = yield* Ref.make(""); | 85 | const outputRef = yield* Ref.make(""); |
| 85 | const runtime = yield* makeCursorAcpRuntime({ | 86 | const runtime = yield* makeCursorAcpRuntime({ |
| 86 | cursorSettings, | 87 | cursorSettings, |
| 87 | environment, | 88 | environment: resolvedEnvironment, |
| 88 | childProcessSpawner: commandSpawner, | 89 | childProcessSpawner: commandSpawner, |
| 89 | cwd, | 90 | cwd, |
| 90 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, | 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,10 +99,11 @@ interface SharedOpenCodeTextGenerationServerState { |
| 99 | 99 | ||
| 100 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( | 100 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( |
| 101 | openCodeSettings: OpenCodeSettings, | 101 | openCodeSettings: OpenCodeSettings, |
| 102 | environment: NodeJS.ProcessEnv = process.env, | 102 | environment?: NodeJS.ProcessEnv, |
| 103 | ) { | 103 | ) { |
| 104 | const serverConfig = yield* ServerConfig; | 104 | const serverConfig = yield* ServerConfig; |
| 105 | const openCodeRuntime = yield* OpenCodeRuntime; | 105 | const openCodeRuntime = yield* OpenCodeRuntime; |
| 106 | const resolvedEnvironment = environment ?? process.env; | ||
| 106 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | 107 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => |
| 107 | Scope.close(scope, Exit.void), | 108 | Scope.close(scope, Exit.void), |
| 108 | ); | 109 | ); |
| ... | @@ -208,7 +209,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | ... | @@ -208,7 +209,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" |
| 208 | openCodeRuntime | 209 | openCodeRuntime |
| 209 | .startOpenCodeServerProcess({ | 210 | .startOpenCodeServerProcess({ |
| 210 | binaryPath: input.binaryPath, | 211 | binaryPath: input.binaryPath, |
| 211 | environment, | 212 | environment: resolvedEnvironment, |
| 212 | }) | 213 | }) |
| 213 | .pipe( | 214 | .pipe( |
| 214 | Effect.provideService(Scope.Scope, serverScope), | 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,6 +10,7 @@ import * as Path from "effect/Path"; |
| 10 | import * as PlatformError from "effect/PlatformError"; | 10 | import * as PlatformError from "effect/PlatformError"; |
| 11 | 11 | ||
| 12 | import { ServerConfig } from "../../config.ts"; | 12 | import { ServerConfig } from "../../config.ts"; |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 13 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | 14 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 14 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | 15 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 15 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | 16 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; |
| ... | @@ -75,9 +76,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb | ... | @@ -75,9 +76,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb |
| 75 | }); | 76 | }); |
| 76 | 77 | ||
| 77 | const appendSeparator = (input: string) => | 78 | const appendSeparator = (input: string) => |
| 78 | input.endsWith("/") || input.endsWith("\\") | 79 | Effect.map(HostProcessPlatform, (platform) => |
| 79 | ? input | 80 | input.endsWith("/") || input.endsWith("\\") |
| 80 | : `${input}${process.platform === "win32" ? "\\" : "/"}`; | 81 | ? input |
| 82 | : `${input}${platform === "win32" ? "\\" : "/"}`, | ||
| 83 | ); | ||
| 81 | 84 | ||
| 82 | it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { | 85 | it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 83 | afterEach(() => { | 86 | afterEach(() => { |
| ... | @@ -344,12 +347,13 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { | ... | @@ -344,12 +347,13 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 344 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); | 347 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); |
| 345 | yield* writeTextFile(cwd, ".config/settings.json", "{}"); | 348 | yield* writeTextFile(cwd, ".config/settings.json", "{}"); |
| 346 | yield* writeTextFile(cwd, "config/settings.json", "{}"); | 349 | yield* writeTextFile(cwd, "config/settings.json", "{}"); |
| 350 | const cwdWithSeparator = yield* appendSeparator(cwd); | ||
| 347 | 351 | ||
| 348 | const directoryResult = yield* workspaceEntries.browse({ | 352 | const directoryResult = yield* workspaceEntries.browse({ |
| 349 | partialPath: appendSeparator(cwd), | 353 | partialPath: cwdWithSeparator, |
| 350 | }); | 354 | }); |
| 351 | const hiddenPrefixResult = yield* workspaceEntries.browse({ | 355 | const hiddenPrefixResult = yield* workspaceEntries.browse({ |
| 352 | partialPath: `${appendSeparator(cwd)}.c`, | 356 | partialPath: `${cwdWithSeparator}.c`, |
| 353 | }); | 357 | }); |
| 354 | 358 | ||
| 355 | expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); | 359 | expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); |
| ... | @@ -402,7 +406,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { | ... | @@ -402,7 +406,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 402 | vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); | 406 | vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); |
| 403 | 407 | ||
| 404 | const result = yield* workspaceEntries.browse({ | 408 | const result = yield* workspaceEntries.browse({ |
| 405 | partialPath: appendSeparator(cwd), | 409 | partialPath: yield* appendSeparator(cwd), |
| 406 | }); | 410 | }); |
| 407 | expect(result).toEqual({ parentPath: cwd, entries: [] }); | 411 | expect(result).toEqual({ parentPath: cwd, entries: [] }); |
| 408 | }), | 412 | }), |
apps/server/src/workspace/Layers/WorkspaceEntries.ts+6-4| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as OS from "node:os"; | 2 | import * as NodeOS from "node:os"; |
| 3 | import fsPromises from "node:fs/promises"; | 3 | import fsPromises from "node:fs/promises"; |
| 4 | import type { Dirent } from "node:fs"; | 4 | import type { Dirent } from "node:fs"; |
| 5 | 5 | ||
| ... | @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; |
| 12 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 13 | 13 | ||
| 14 | import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; | 14 | import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 15 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; | 16 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 16 | import { | 17 | import { |
| 17 | insertRankedSearchResult, | 18 | insertRankedSearchResult, |
| ... | @@ -65,10 +66,10 @@ function toPosixPath(input: string): string { | ... | @@ -65,10 +66,10 @@ function toPosixPath(input: string): string { |
| 65 | 66 | ||
| 66 | function expandHomePath(input: string, path: Path.Path): string { | 67 | function expandHomePath(input: string, path: Path.Path): string { |
| 67 | if (input === "~") { | 68 | if (input === "~") { |
| 68 | return OS.homedir(); | 69 | return NodeOS.homedir(); |
| 69 | } | 70 | } |
| 70 | if (input.startsWith("~/") || input.startsWith("~\\")) { | 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 | return input; | 74 | return input; |
| 74 | } | 75 | } |
| ... | @@ -155,7 +156,8 @@ const resolveBrowseTarget = ( | ... | @@ -155,7 +156,8 @@ const resolveBrowseTarget = ( |
| 155 | pathService: Path.Path, | 156 | pathService: Path.Path, |
| 156 | ): Effect.Effect<string, WorkspaceEntriesBrowseError> => | 157 | ): Effect.Effect<string, WorkspaceEntriesBrowseError> => |
| 157 | Effect.gen(function* () { | 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 | return yield* new WorkspaceEntriesBrowseError({ | 161 | return yield* new WorkspaceEntriesBrowseError({ |
| 160 | cwd: input.cwd, | 162 | cwd: input.cwd, |
| 161 | partialPath: input.partialPath, | 163 | partialPath: input.partialPath, |
apps/server/src/workspace/Layers/WorkspacePaths.ts+3-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as OS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string { | ... | @@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string { |
| 19 | 19 | ||
| 20 | function expandHomePath(input: string, path: Path.Path): string { | 20 | function expandHomePath(input: string, path: Path.Path): string { |
| 21 | if (input === "~") { | 21 | if (input === "~") { |
| 22 | return OS.homedir(); | 22 | return NodeOS.homedir(); |
| 23 | } | 23 | } |
| 24 | if (input.startsWith("~/") || input.startsWith("~\\")) { | 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 | return input; | 27 | return input; |
| 28 | } | 28 | } |
apps/server/src/ws.ts+1-1| ... | @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 761 | keybindings: keybindingsConfig.keybindings, | 761 | keybindings: keybindingsConfig.keybindings, |
| 762 | issues: keybindingsConfig.issues, | 762 | issues: keybindingsConfig.issues, |
| 763 | providers, | 763 | providers, |
| 764 | availableEditors: ExternalLauncher.resolveAvailableEditors(), | 764 | availableEditors: yield* externalLauncher.resolveAvailableEditors(), |
| 765 | observability: { | 765 | observability: { |
| 766 | logsDirectoryPath: config.logsDir, | 766 | logsDirectoryPath: config.logsDir, |
| 767 | localTracingEnabled: true, | 767 | localTracingEnabled: true, |
oxlint-plugin-t3code/index.ts+2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { definePlugin } from "@oxlint/plugins"; | 1 | import { definePlugin } from "@oxlint/plugins"; |
| 2 | 2 | ||
| 3 | import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; | ||
| 3 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; | 4 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 4 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; | 5 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; |
| 5 | 6 | ||
| ... | @@ -8,6 +9,7 @@ export default definePlugin({ | ... | @@ -8,6 +9,7 @@ export default definePlugin({ |
| 8 | name: "t3code", | 9 | name: "t3code", |
| 9 | }, | 10 | }, |
| 10 | rules: { | 11 | rules: { |
| 12 | "no-global-process-runtime": noGlobalProcessRuntime, | ||
| 11 | "no-inline-schema-compile": noInlineSchemaCompile, | 13 | "no-inline-schema-compile": noInlineSchemaCompile, |
| 12 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, | 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,7 +11,7 @@ const program = Effect.gen(function* () { |
| 11 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 11 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 12 | const command = ChildProcess.make("cursor-agent", ["acp"], { | 12 | const command = ChildProcess.make("cursor-agent", ["acp"], { |
| 13 | cwd: process.cwd(), | 13 | cwd: process.cwd(), |
| 14 | shell: process.platform === "win32", | 14 | shell: false, |
| 15 | }); | 15 | }); |
| 16 | const handle = yield* spawner.spawn(command); | 16 | const handle = yield* spawner.spawn(command); |
| 17 | const acpLayer = AcpClient.layerChildProcess(handle, { | 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,13 +17,14 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 17 | const mockPeerArgs = (path: string) => [path]; | 17 | const mockPeerArgs = (path: string) => [path]; |
| 18 | 18 | ||
| 19 | it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { | 19 | it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { |
| 20 | const makeHandle = () => | 20 | const makeHandle = (env?: Record<string, string>) => |
| 21 | Effect.gen(function* () { | 21 | Effect.gen(function* () { |
| 22 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 22 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 23 | const path = yield* Path.Path; | 23 | const path = yield* Path.Path; |
| 24 | const peerCwd = path.join(import.meta.dirname, ".."); | 24 | const peerCwd = path.join(import.meta.dirname, ".."); |
| 25 | const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { | 25 | const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { |
| 26 | cwd: peerCwd, | 26 | cwd: peerCwd, |
| 27 | ...(env ? { env: { ...process.env, ...env } } : {}), | ||
| 27 | }); | 28 | }); |
| 28 | return yield* spawner.spawn(command); | 29 | return yield* spawner.spawn(command); |
| 29 | }); | 30 | }); |
| ... | @@ -122,49 +123,13 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { | ... | @@ -122,49 +123,13 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { |
| 122 | ]); | 123 | ]); |
| 123 | }), | 124 | }), |
| 124 | ); | 125 | ); |
| 125 | 126 | it.effect("drains child stderr so large diagnostics cannot block protocol responses", () => | |
| 126 | it.effect("initializes a command-backed app-server client", () => | ||
| 127 | Effect.gen(function* () { | 127 | Effect.gen(function* () { |
| 128 | const path = yield* Path.Path; | 128 | const handle = yield* makeHandle({ |
| 129 | const scope = yield* Scope.make(); | 129 | CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), |
| 130 | const clientLayer = CodexClient.layerCommand({ | ||
| 131 | command: "node", | ||
| 132 | args: mockPeerArgs(yield* mockPeerPath), | ||
| 133 | cwd: path.join(import.meta.dirname, ".."), | ||
| 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 | const scope = yield* Scope.make(); | 131 | const scope = yield* Scope.make(); |
| 160 | const clientLayer = CodexClient.layerCommand({ | 132 | const clientLayer = CodexClient.layerChildProcess(handle); |
| 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 | }); | ||
| 168 | const context = yield* Layer.buildWithScope(clientLayer, scope); | 133 | const context = yield* Layer.buildWithScope(clientLayer, scope); |
| 169 | 134 | ||
| 170 | const initialized = yield* Effect.gen(function* () { | 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,7 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | import * as Scope from "effect/Scope"; | 5 | import * as Scope from "effect/Scope"; |
| 6 | import * as Stdio from "effect/Stdio"; | 6 | import * as Stdio from "effect/Stdio"; |
| 7 | import * as Stream from "effect/Stream"; | 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 | import * as CodexRpc from "./_generated/meta.gen.ts"; | 10 | import * as CodexRpc from "./_generated/meta.gen.ts"; |
| 11 | import * as CodexError from "./errors.ts"; | 11 | import * as CodexError from "./errors.ts"; |
| ... | @@ -18,8 +18,6 @@ import { | ... | @@ -18,8 +18,6 @@ import { |
| 18 | } from "./_internal/shared.ts"; | 18 | } from "./_internal/shared.ts"; |
| 19 | import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; | 19 | import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; |
| 20 | 20 | ||
| 21 | const DEFAULT_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; | ||
| 22 | |||
| 23 | export interface CodexAppServerClientOptions { | 21 | export interface CodexAppServerClientOptions { |
| 24 | readonly logIncoming?: boolean; | 22 | readonly logIncoming?: boolean; |
| 25 | readonly logOutgoing?: boolean; | 23 | readonly logOutgoing?: boolean; |
| ... | @@ -263,39 +261,3 @@ const makeChildProcessClient = Effect.fn( | ... | @@ -263,39 +261,3 @@ const makeChildProcessClient = Effect.fn( |
| 263 | yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); | 261 | yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); |
| 264 | return yield* make(makeChildStdio(handle), options, makeTerminationError(handle)); | 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,5 +1,6 @@ |
| 1 | import * as Console from "effect/Console"; | 1 | import * as Console from "effect/Console"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | 4 | ||
| 4 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | @@ -7,10 +8,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -7,10 +8,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import * as CodexClient from "../../src/client.ts"; | 8 | import * as CodexClient from "../../src/client.ts"; |
| 8 | 9 | ||
| 9 | const program = Effect.gen(function* () { | 10 | const program = Effect.gen(function* () { |
| 10 | const codexLayer = CodexClient.layerCommand({ | 11 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 11 | command: process.env.CODEX_BIN ?? "codex", | 12 | const handle = yield* spawner.spawn( |
| 12 | args: ["app-server"], | 13 | ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], { |
| 13 | cwd: process.cwd(), | 14 | cwd: process.cwd(), |
| 15 | shell: false, | ||
| 16 | }), | ||
| 17 | ); | ||
| 18 | const codexLayer = CodexClient.layerChildProcess(handle, { | ||
| 14 | logIncoming: true, | 19 | logIncoming: true, |
| 15 | logOutgoing: true, | 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,3 +1,5 @@ |
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 1 | let nextServerRequestId = 10_000; | 3 | let nextServerRequestId = 10_000; |
| 2 | let pendingSkillsListRequestId: number | string | null = null; | 4 | let pendingSkillsListRequestId: number | string | null = null; |
| 3 | let pendingUserInputRequestId: number | null = null; | 5 | let pendingUserInputRequestId: number | null = null; |
| ... | @@ -34,14 +36,16 @@ const handleMethod = (message: Record<string, unknown>) => { | ... | @@ -34,14 +36,16 @@ const handleMethod = (message: Record<string, unknown>) => { |
| 34 | 36 | ||
| 35 | switch (method) { | 37 | switch (method) { |
| 36 | case "initialize": { | 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 | const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0); | 41 | const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0); |
| 38 | if (Number.isFinite(stderrBytes) && stderrBytes > 0) { | 42 | if (Number.isFinite(stderrBytes) && stderrBytes > 0) { |
| 39 | process.stderr.write("x".repeat(stderrBytes), () => { | 43 | process.stderr.write("x".repeat(stderrBytes), () => { |
| 40 | respond(message.id as number | string, { | 44 | respond(message.id as number | string, { |
| 41 | userAgent: "mock-codex-app-server", | 45 | userAgent: "mock-codex-app-server", |
| 42 | codexHome: process.cwd(), | 46 | codexHome: process.cwd(), |
| 43 | platformFamily: process.platform === "win32" ? "windows" : "unix", | 47 | platformFamily: platform === "win32" ? "windows" : "unix", |
| 44 | platformOs: process.platform === "darwin" ? "macos" : process.platform, | 48 | platformOs: platform === "darwin" ? "macos" : platform, |
| 45 | }); | 49 | }); |
| 46 | }); | 50 | }); |
| 47 | return; | 51 | return; |
| ... | @@ -49,8 +53,8 @@ const handleMethod = (message: Record<string, unknown>) => { | ... | @@ -49,8 +53,8 @@ const handleMethod = (message: Record<string, unknown>) => { |
| 49 | respond(message.id as number | string, { | 53 | respond(message.id as number | string, { |
| 50 | userAgent: "mock-codex-app-server", | 54 | userAgent: "mock-codex-app-server", |
| 51 | codexHome: process.cwd(), | 55 | codexHome: process.cwd(), |
| 52 | platformFamily: process.platform === "win32" ? "windows" : "unix", | 56 | platformFamily: platform === "win32" ? "windows" : "unix", |
| 53 | platformOs: process.platform === "darwin" ? "macos" : process.platform, | 57 | platformOs: platform === "darwin" ? "macos" : platform, |
| 54 | }); | 58 | }); |
| 55 | return; | 59 | return; |
| 56 | } | 60 | } |
packages/shared/package.json+4| ... | @@ -154,6 +154,10 @@ | ... | @@ -154,6 +154,10 @@ |
| 154 | "./preview": { | 154 | "./preview": { |
| 155 | "types": "./src/preview.ts", | 155 | "types": "./src/preview.ts", |
| 156 | "import": "./src/preview.ts" | 156 | "import": "./src/preview.ts" |
| 157 | }, | ||
| 158 | "./hostProcess": { | ||
| 159 | "types": "./src/hostProcess.ts", | ||
| 160 | "import": "./src/hostProcess.ts" | ||
| 157 | } | 161 | } |
| 158 | }, | 162 | }, |
| 159 | "scripts": { | 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,15 +10,20 @@ import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; | 10 | import * as Stream from "effect/Stream"; |
| 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; | ||
| 13 | 14 | ||
| 14 | import { | 15 | import { |
| 15 | RelayClientInstallError, | 16 | RelayClientInstallError, |
| 16 | CLOUDFLARED_VERSION, | 17 | CLOUDFLARED_VERSION, |
| 17 | makeCloudflaredRelayClient, | 18 | makeCloudflaredRelayClient, |
| 18 | resolveManagedCloudflaredPath, | ||
| 19 | } from "./relayClient.ts"; | 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 | function makeHandle(exitCode = 0) { | 28 | function makeHandle(exitCode = 0) { |
| 24 | return ChildProcessSpawner.makeHandle({ | 29 | return ChildProcessSpawner.makeHandle({ |
| ... | @@ -69,18 +74,18 @@ describe("RelayClient", () => { | ... | @@ -69,18 +74,18 @@ describe("RelayClient", () => { |
| 69 | yield* fileSystem.chmod(overridePath, 0o755); | 74 | yield* fileSystem.chmod(overridePath, 0o755); |
| 70 | const manager = yield* makeCloudflaredRelayClient({ | 75 | const manager = yield* makeCloudflaredRelayClient({ |
| 71 | baseDir, | 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 | status: "available", | 89 | status: "available", |
| 85 | executablePath: overridePath, | 90 | executablePath: overridePath, |
| 86 | source: "override", | 91 | source: "override", |
| ... | @@ -93,6 +98,7 @@ describe("RelayClient", () => { | ... | @@ -93,6 +98,7 @@ describe("RelayClient", () => { |
| 93 | NodeServices.layer, | 98 | NodeServices.layer, |
| 94 | makeHttpClientLayer(new Uint8Array()), | 99 | makeHttpClientLayer(new Uint8Array()), |
| 95 | makeSpawnerLayer([]), | 100 | makeSpawnerLayer([]), |
| 101 | hostRuntimeLayer(), | ||
| 96 | ), | 102 | ), |
| 97 | ), | 103 | ), |
| 98 | ), | 104 | ), |
| ... | @@ -107,14 +113,11 @@ describe("RelayClient", () => { | ... | @@ -107,14 +113,11 @@ describe("RelayClient", () => { |
| 107 | const bytes = new TextEncoder().encode("test-cloudflared-binary"); | 113 | const bytes = new TextEncoder().encode("test-cloudflared-binary"); |
| 108 | const manager = yield* makeCloudflaredRelayClient({ | 114 | const manager = yield* makeCloudflaredRelayClient({ |
| 109 | baseDir, | 115 | baseDir, |
| 110 | platform: "linux", | ||
| 111 | arch: "x64", | ||
| 112 | releaseAsset: { | 116 | releaseAsset: { |
| 113 | url: "https://example.test/cloudflared", | 117 | url: "https://example.test/cloudflared", |
| 114 | sha256: Encoding.encodeHex(sha256(bytes)), | 118 | sha256: Encoding.encodeHex(sha256(bytes)), |
| 115 | archive: "binary", | 119 | archive: "binary", |
| 116 | }, | 120 | }, |
| 117 | configProvider: emptyConfigProvider, | ||
| 118 | }); | 121 | }); |
| 119 | 122 | ||
| 120 | const progress: Array<string> = []; | 123 | const progress: Array<string> = []; |
| ... | @@ -125,11 +128,7 @@ describe("RelayClient", () => { | ... | @@ -125,11 +128,7 @@ describe("RelayClient", () => { |
| 125 | } | 128 | } |
| 126 | }), | 129 | }), |
| 127 | ); | 130 | ); |
| 128 | const managedPath = resolveManagedCloudflaredPath({ | 131 | const managedPath = `${baseDir}/tools/cloudflared/${CLOUDFLARED_VERSION}/linux-x64/cloudflared`; |
| 129 | baseDir, | ||
| 130 | platform: "linux", | ||
| 131 | arch: "x64", | ||
| 132 | }); | ||
| 133 | expect(installed).toEqual({ | 132 | expect(installed).toEqual({ |
| 134 | status: "available", | 133 | status: "available", |
| 135 | executablePath: managedPath, | 134 | executablePath: managedPath, |
| ... | @@ -156,6 +155,7 @@ describe("RelayClient", () => { | ... | @@ -156,6 +155,7 @@ describe("RelayClient", () => { |
| 156 | NodeServices.layer, | 155 | NodeServices.layer, |
| 157 | makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), | 156 | makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), |
| 158 | makeSpawnerLayer([]), | 157 | makeSpawnerLayer([]), |
| 158 | hostRuntimeLayer(), | ||
| 159 | ), | 159 | ), |
| 160 | ), | 160 | ), |
| 161 | ), | 161 | ), |
| ... | @@ -169,14 +169,11 @@ describe("RelayClient", () => { | ... | @@ -169,14 +169,11 @@ describe("RelayClient", () => { |
| 169 | }); | 169 | }); |
| 170 | const manager = yield* makeCloudflaredRelayClient({ | 170 | const manager = yield* makeCloudflaredRelayClient({ |
| 171 | baseDir, | 171 | baseDir, |
| 172 | platform: "linux", | ||
| 173 | arch: "x64", | ||
| 174 | releaseAsset: { | 172 | releaseAsset: { |
| 175 | url: "https://example.test/cloudflared", | 173 | url: "https://example.test/cloudflared", |
| 176 | sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), | 174 | sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), |
| 177 | archive: "binary", | 175 | archive: "binary", |
| 178 | }, | 176 | }, |
| 179 | configProvider: emptyConfigProvider, | ||
| 180 | }); | 177 | }); |
| 181 | 178 | ||
| 182 | const error = yield* manager.install.pipe(Effect.flip); | 179 | const error = yield* manager.install.pipe(Effect.flip); |
| ... | @@ -189,6 +186,7 @@ describe("RelayClient", () => { | ... | @@ -189,6 +186,7 @@ describe("RelayClient", () => { |
| 189 | NodeServices.layer, | 186 | NodeServices.layer, |
| 190 | makeHttpClientLayer(new TextEncoder().encode("tampered")), | 187 | makeHttpClientLayer(new TextEncoder().encode("tampered")), |
| 191 | makeSpawnerLayer([]), | 188 | makeSpawnerLayer([]), |
| 189 | hostRuntimeLayer(), | ||
| 192 | ), | 190 | ), |
| 193 | ), | 191 | ), |
| 194 | ), | 192 | ), |
| ... | @@ -204,14 +202,11 @@ describe("RelayClient", () => { | ... | @@ -204,14 +202,11 @@ describe("RelayClient", () => { |
| 204 | }); | 202 | }); |
| 205 | const manager = yield* makeCloudflaredRelayClient({ | 203 | const manager = yield* makeCloudflaredRelayClient({ |
| 206 | baseDir, | 204 | baseDir, |
| 207 | platform: "linux", | ||
| 208 | arch: "x64", | ||
| 209 | releaseAsset: { | 205 | releaseAsset: { |
| 210 | url: "https://example.test/cloudflared", | 206 | url: "https://example.test/cloudflared", |
| 211 | sha256: Encoding.encodeHex(sha256(bytes)), | 207 | sha256: Encoding.encodeHex(sha256(bytes)), |
| 212 | archive: "binary", | 208 | archive: "binary", |
| 213 | }, | 209 | }, |
| 214 | configProvider: emptyConfigProvider, | ||
| 215 | }); | 210 | }); |
| 216 | 211 | ||
| 217 | const [first, second] = yield* Effect.all([manager.install, manager.install], { | 212 | const [first, second] = yield* Effect.all([manager.install, manager.install], { |
| ... | @@ -222,25 +217,27 @@ describe("RelayClient", () => { | ... | @@ -222,25 +217,27 @@ describe("RelayClient", () => { |
| 222 | }).pipe( | 217 | }).pipe( |
| 223 | Effect.scoped, | 218 | Effect.scoped, |
| 224 | Effect.provide( | 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", () => | 230 | it.effect("observes PATH changes after the manager has been constructed", () => { |
| 231 | Effect.gen(function* () { | 231 | const env = { PATH: "" }; |
| 232 | return Effect.gen(function* () { | ||
| 232 | const fileSystem = yield* FileSystem.FileSystem; | 233 | const fileSystem = yield* FileSystem.FileSystem; |
| 233 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | 234 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 234 | prefix: "t3-cloudflared-test-", | 235 | prefix: "t3-cloudflared-test-", |
| 235 | }); | 236 | }); |
| 236 | const binDir = `${baseDir}/bin`; | 237 | const binDir = `${baseDir}/bin`; |
| 237 | const executablePath = `${binDir}/cloudflared`; | 238 | const executablePath = `${binDir}/cloudflared`; |
| 238 | let path = ""; | ||
| 239 | const manager = yield* makeCloudflaredRelayClient({ | 239 | const manager = yield* makeCloudflaredRelayClient({ |
| 240 | baseDir, | 240 | baseDir, |
| 241 | platform: "linux", | ||
| 242 | arch: "x64", | ||
| 243 | configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), | ||
| 244 | }); | 241 | }); |
| 245 | 242 | ||
| 246 | expect(yield* manager.resolve).toEqual({ | 243 | expect(yield* manager.resolve).toEqual({ |
| ... | @@ -251,7 +248,7 @@ describe("RelayClient", () => { | ... | @@ -251,7 +248,7 @@ describe("RelayClient", () => { |
| 251 | yield* fileSystem.makeDirectory(binDir); | 248 | yield* fileSystem.makeDirectory(binDir); |
| 252 | yield* fileSystem.writeFileString(executablePath, "cloudflared"); | 249 | yield* fileSystem.writeFileString(executablePath, "cloudflared"); |
| 253 | yield* fileSystem.chmod(executablePath, 0o755); | 250 | yield* fileSystem.chmod(executablePath, 0o755); |
| 254 | path = binDir; | 251 | env.PATH = binDir; |
| 255 | 252 | ||
| 256 | expect(yield* manager.resolve).toEqual({ | 253 | expect(yield* manager.resolve).toEqual({ |
| 257 | status: "available", | 254 | status: "available", |
| ... | @@ -266,8 +263,9 @@ describe("RelayClient", () => { | ... | @@ -266,8 +263,9 @@ describe("RelayClient", () => { |
| 266 | NodeServices.layer, | 263 | NodeServices.layer, |
| 267 | makeHttpClientLayer(new Uint8Array()), | 264 | makeHttpClientLayer(new Uint8Array()), |
| 268 | makeSpawnerLayer([]), | 265 | makeSpawnerLayer([]), |
| 266 | hostRuntimeLayer(env), | ||
| 269 | ), | 267 | ), |
| 270 | ), | 268 | ), |
| 271 | ), | 269 | ); |
| 272 | ); | 270 | }); |
| 273 | }); | 271 | }); |
packages/shared/src/relayClient.ts+4-30| ... | @@ -4,7 +4,6 @@ import type { | ... | @@ -4,7 +4,6 @@ import type { |
| 4 | RelayClientInstallProgressStage, | 4 | RelayClientInstallProgressStage, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import * as Config from "effect/Config"; | 6 | import * as Config from "effect/Config"; |
| 7 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 8 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; | 8 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Data from "effect/Data"; | 9 | import * as Data from "effect/Data"; |
| ... | @@ -18,6 +17,7 @@ import * as PlatformError from "effect/PlatformError"; | ... | @@ -18,6 +17,7 @@ import * as PlatformError from "effect/PlatformError"; |
| 18 | import * as Semaphore from "effect/Semaphore"; | 17 | import * as Semaphore from "effect/Semaphore"; |
| 19 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; | ||
| 21 | 21 | ||
| 22 | export const CLOUDFLARED_VERSION = "2026.5.2"; | 22 | export const CLOUDFLARED_VERSION = "2026.5.2"; |
| 23 | export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; | 23 | export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; |
| ... | @@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({ | ... | @@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({ |
| 120 | 120 | ||
| 121 | export interface CloudflaredRelayClientOptions { | 121 | export interface CloudflaredRelayClientOptions { |
| 122 | readonly baseDir: string; | 122 | readonly baseDir: string; |
| 123 | readonly platform?: NodeJS.Platform; | ||
| 124 | readonly arch?: string; | ||
| 125 | readonly releaseAsset?: CloudflaredReleaseAsset; | 123 | readonly releaseAsset?: CloudflaredReleaseAsset; |
| 126 | readonly configProvider?: () => ConfigProvider.ConfigProvider; | ||
| 127 | } | 124 | } |
| 128 | 125 | ||
| 129 | export interface RelayClientShape { | 126 | export interface RelayClientShape { |
| ... | @@ -142,22 +139,6 @@ function executableFileName(platform: NodeJS.Platform): string { | ... | @@ -142,22 +139,6 @@ function executableFileName(platform: NodeJS.Platform): string { |
| 142 | return platform === "win32" ? "cloudflared.exe" : "cloudflared"; | 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 | function resolveReleaseAsset( | 142 | function resolveReleaseAsset( |
| 162 | platform: NodeJS.Platform, | 143 | platform: NodeJS.Platform, |
| 163 | arch: string, | 144 | arch: string, |
| ... | @@ -205,17 +186,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -205,17 +186,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 205 | const path = yield* Path.Path; | 186 | const path = yield* Path.Path; |
| 206 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 187 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 207 | const installSemaphore = yield* Semaphore.make(1); | 188 | const installSemaphore = yield* Semaphore.make(1); |
| 208 | const platform = options.platform ?? process.platform; | 189 | const platform = yield* HostProcessPlatform; |
| 209 | const arch = options.arch ?? process.arch; | 190 | const arch = yield* HostProcessArchitecture; |
| 210 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); | 191 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); |
| 211 | const loadCloudflaredConfig = Effect.suspend(() => | 192 | const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); |
| 212 | CloudflaredConfig.pipe( | ||
| 213 | Effect.provideService( | ||
| 214 | ConfigProvider.ConfigProvider, | ||
| 215 | options.configProvider?.() ?? ConfigProvider.fromEnv(), | ||
| 216 | ), | ||
| 217 | ), | ||
| 218 | ).pipe(Effect.orDie); | ||
| 219 | const managedPath = path.join( | 193 | const managedPath = path.join( |
| 220 | options.baseDir, | 194 | options.baseDir, |
| 221 | "tools", | 195 | "tools", |
packages/shared/src/shell.test.ts+237-131| ... | @@ -1,7 +1,13 @@ | ... | @@ -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 | import { describe, expect, it, vi } from "vite-plus/test"; | 5 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 6 | ||
| 3 | import { | 7 | import { |
| 4 | extractPathFromShellOutput, | 8 | extractPathFromShellOutput, |
| 9 | CommandAvailability, | ||
| 10 | type CommandAvailabilityChecker, | ||
| 5 | isCommandAvailable, | 11 | isCommandAvailable, |
| 6 | listLoginShellCandidates, | 12 | listLoginShellCandidates, |
| 7 | mergePathEntries, | 13 | mergePathEntries, |
| ... | @@ -12,9 +18,23 @@ import { | ... | @@ -12,9 +18,23 @@ import { |
| 12 | readPathFromLoginShell, | 18 | readPathFromLoginShell, |
| 13 | resolveCommandPath, | 19 | resolveCommandPath, |
| 14 | resolveKnownWindowsCliDirs, | 20 | resolveKnownWindowsCliDirs, |
| 21 | resolveSpawnCommand, | ||
| 15 | resolveWindowsEnvironment, | 22 | resolveWindowsEnvironment, |
| 23 | SpawnExecutableResolution, | ||
| 24 | WindowsShellEnvironment, | ||
| 25 | type WindowsShellEnvironmentReader, | ||
| 16 | } from "./shell.ts"; | 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 | describe("extractPathFromShellOutput", () => { | 38 | describe("extractPathFromShellOutput", () => { |
| 19 | it("extracts the path between capture markers", () => { | 39 | it("extracts the path between capture markers", () => { |
| 20 | expect( | 40 | expect( |
| ... | @@ -322,149 +342,235 @@ describe("resolveKnownWindowsCliDirs", () => { | ... | @@ -322,149 +342,235 @@ describe("resolveKnownWindowsCliDirs", () => { |
| 322 | }); | 342 | }); |
| 323 | }); | 343 | }); |
| 324 | 344 | ||
| 325 | describe("isCommandAvailable", () => { | 345 | effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { |
| 326 | it("returns false when PATH is empty", () => { | 346 | it.effect("returns false when PATH is empty", () => |
| 327 | expect( | 347 | Effect.gen(function* () { |
| 328 | isCommandAvailable("definitely-not-installed", { | 348 | expect( |
| 329 | platform: "win32", | 349 | yield* isCommandAvailable("definitely-not-installed", { |
| 330 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | 350 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, |
| 331 | }), | 351 | }).pipe(Effect.provideService(HostProcessPlatform, "win32")), |
| 332 | ).toBe(false); | 352 | ).toBe(false); |
| 333 | }); | 353 | }), |
| 354 | ); | ||
| 334 | }); | 355 | }); |
| 335 | 356 | ||
| 336 | describe("resolveCommandPath", () => { | 357 | effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { |
| 337 | it("returns the first executable resolved from PATH", () => { | 358 | it.effect("fails when PATH is empty", () => |
| 338 | expect( | 359 | Effect.gen(function* () { |
| 339 | resolveCommandPath("definitely-not-installed", { | 360 | const result = yield* resolveCommandPath("definitely-not-installed", { |
| 340 | platform: "win32", | ||
| 341 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, | 361 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, |
| 342 | }), | 362 | }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result); |
| 343 | ).toBeNull(); | 363 | |
| 344 | }); | 364 | expect(result._tag).toBe("Failure"); |
| 365 | }), | ||
| 366 | ); | ||
| 345 | }); | 367 | }); |
| 346 | 368 | ||
| 347 | describe("resolveWindowsEnvironment", () => { | 369 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |
| 348 | it("returns the baseline no-profile PATH patch when node is already available", () => { | 370 | it.effect("runs Windows executables directly without a shell", () => |
| 349 | const readEnvironment = vi.fn( | 371 | Effect.gen(function* () { |
| 350 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | 372 | const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], { |
| 351 | options?.loadProfile | 373 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, |
| 352 | ? { PATH: "C:\\Profile\\Bin" } | 374 | }).pipe(Effect.provideService(HostProcessPlatform, "win32")); |
| 353 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, | 375 | |
| 354 | ); | 376 | expect(command).toEqual({ |
| 355 | const commandAvailable = vi.fn(() => true); | 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( | 451 | effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { |
| 358 | resolveWindowsEnvironment( | 452 | it.effect("returns the baseline no-profile PATH patch when node is already available", () => |
| 359 | { | 453 | Effect.gen(function* () { |
| 360 | PATH: "C:\\Windows\\System32", | 454 | const readEnvironment = vi.fn( |
| 361 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | 455 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => |
| 362 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | 456 | options?.loadProfile |
| 363 | USERPROFILE: "C:\\Users\\testuser", | 457 | ? { PATH: "C:\\Profile\\Bin" } |
| 364 | }, | 458 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, |
| 365 | { | 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 | readEnvironment, | 470 | readEnvironment, |
| 367 | commandAvailable, | 471 | commandAvailable, |
| 368 | }, | 472 | ), |
| 369 | ), | 473 | ).toEqual({ |
| 370 | ).toEqual({ | 474 | PATH: [ |
| 371 | PATH: [ | 475 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 372 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | 476 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", |
| 373 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | 477 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", |
| 374 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | 478 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", |
| 375 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", | 479 | "C:\\Users\\testuser\\.bun\\bin", |
| 376 | "C:\\Users\\testuser\\.bun\\bin", | 480 | "C:\\Users\\testuser\\scoop\\shims", |
| 377 | "C:\\Users\\testuser\\scoop\\shims", | 481 | "C:\\Shell\\Bin", |
| 378 | "C:\\Shell\\Bin", | 482 | "C:\\Windows\\System32", |
| 379 | "C:\\Windows\\System32", | 483 | ].join(";"), |
| 380 | ].join(";"), | 484 | }); |
| 381 | }); | 485 | expect(readEnvironment).toHaveBeenCalledTimes(1); |
| 382 | expect(readEnvironment).toHaveBeenCalledTimes(1); | 486 | expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); |
| 383 | expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); | 487 | expect(commandAvailable).toHaveBeenCalledWith( |
| 384 | expect(commandAvailable).toHaveBeenCalledWith( | 488 | "node", |
| 385 | "node", | 489 | expect.objectContaining({ env: expect.any(Object) }), |
| 386 | expect.objectContaining({ | 490 | ); |
| 387 | platform: "win32", | 491 | }), |
| 388 | }), | 492 | ); |
| 389 | ); | 493 | |
| 390 | }); | 494 | it.effect("loads the PowerShell profile when baseline env cannot resolve node", () => |
| 391 | 495 | Effect.gen(function* () { | |
| 392 | it("loads the PowerShell profile when baseline env cannot resolve node", () => { | 496 | const readEnvironment = vi.fn( |
| 393 | const readEnvironment = vi.fn( | 497 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => |
| 394 | (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | 498 | options?.loadProfile |
| 395 | options?.loadProfile | 499 | ? { |
| 396 | ? { | 500 | PATH: "C:\\Profile\\Node;C:\\Windows\\System32", |
| 397 | PATH: "C:\\Profile\\Node;C:\\Windows\\System32", | 501 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", |
| 398 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | 502 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", |
| 399 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | 503 | } |
| 400 | } | 504 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, |
| 401 | : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, | 505 | ); |
| 402 | ); | 506 | const commandAvailable = vi.fn(() => Effect.succeed(false)); |
| 403 | const commandAvailable = vi.fn(() => false); | 507 | |
| 404 | 508 | expect( | |
| 405 | expect( | 509 | yield* withWindowsEnvironmentMocks( |
| 406 | resolveWindowsEnvironment( | 510 | resolveWindowsEnvironment({ |
| 407 | { | 511 | PATH: "C:\\Windows\\System32", |
| 408 | PATH: "C:\\Windows\\System32", | 512 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", |
| 409 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | 513 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", |
| 410 | LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | 514 | USERPROFILE: "C:\\Users\\testuser", |
| 411 | USERPROFILE: "C:\\Users\\testuser", | 515 | }), |
| 412 | }, | ||
| 413 | { | ||
| 414 | readEnvironment, | 516 | readEnvironment, |
| 415 | commandAvailable, | 517 | commandAvailable, |
| 416 | }, | 518 | ), |
| 417 | ), | 519 | ).toEqual({ |
| 418 | ).toEqual({ | 520 | PATH: [ |
| 419 | PATH: [ | 521 | "C:\\Profile\\Node", |
| 420 | "C:\\Profile\\Node", | 522 | "C:\\Windows\\System32", |
| 421 | "C:\\Windows\\System32", | 523 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 422 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | 524 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", |
| 423 | "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | 525 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", |
| 424 | "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | 526 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", |
| 425 | "C:\\Users\\testuser\\AppData\\Local\\pnpm", | 527 | "C:\\Users\\testuser\\.bun\\bin", |
| 426 | "C:\\Users\\testuser\\.bun\\bin", | 528 | "C:\\Users\\testuser\\scoop\\shims", |
| 427 | "C:\\Users\\testuser\\scoop\\shims", | 529 | "C:\\Shell\\Bin", |
| 428 | "C:\\Shell\\Bin", | 530 | ].join(";"), |
| 429 | ].join(";"), | 531 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", |
| 430 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | 532 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", |
| 431 | FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | 533 | }); |
| 432 | }); | 534 | expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); |
| 433 | expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); | 535 | expect(readEnvironment).toHaveBeenNthCalledWith( |
| 434 | expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { | 536 | 2, |
| 435 | loadProfile: true, | 537 | ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], |
| 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( | ||
| 449 | { | 538 | { |
| 450 | PATH: "C:\\Windows\\System32", | 539 | loadProfile: true, |
| 451 | APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | ||
| 452 | USERPROFILE: "C:\\Users\\testuser", | ||
| 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 | readEnvironment, | 561 | readEnvironment, |
| 456 | commandAvailable, | 562 | commandAvailable, |
| 457 | }, | 563 | ), |
| 458 | ), | 564 | ).toEqual({ |
| 459 | ).toEqual({ | 565 | PATH: [ |
| 460 | PATH: [ | 566 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", |
| 461 | "C:\\Users\\testuser\\AppData\\Roaming\\npm", | 567 | "C:\\Users\\testuser\\.bun\\bin", |
| 462 | "C:\\Users\\testuser\\.bun\\bin", | 568 | "C:\\Users\\testuser\\scoop\\shims", |
| 463 | "C:\\Users\\testuser\\scoop\\shims", | 569 | "C:\\Windows\\System32", |
| 464 | "C:\\Windows\\System32", | 570 | ].join(";"), |
| 465 | ].join(";"), | 571 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", |
| 466 | FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | 572 | }); |
| 467 | }); | 573 | expect(commandAvailable).toHaveBeenCalledTimes(1); |
| 468 | expect(commandAvailable).toHaveBeenCalledTimes(1); | 574 | }), |
| 469 | }); | 575 | ); |
| 470 | }); | 576 | }); |
packages/shared/src/shell.ts+224-46| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeOS from "node:os"; | 2 | import * as NodeOS from "node:os"; |
| 3 | import * as NodePath from "node:path"; | ||
| 3 | import { execFileSync } from "node:child_process"; | 4 | import { execFileSync } from "node:child_process"; |
| 4 | import { accessSync, constants, statSync } from "node:fs"; | 5 | import { accessSync, constants as fileSystemConstants, statSync } from "node:fs"; |
| 5 | import { extname, join } from "node:path"; | 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 | const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; | 14 | const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; |
| 8 | const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; | 15 | const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; |
| ... | @@ -17,11 +24,122 @@ type ExecFileSyncLike = ( | ... | @@ -17,11 +24,122 @@ type ExecFileSyncLike = ( |
| 17 | options: { encoding: "utf8"; timeout: number }, | 24 | options: { encoding: "utf8"; timeout: number }, |
| 18 | ) => string; | 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 | export interface CommandAvailabilityOptions { | 36 | export interface CommandAvailabilityOptions { |
| 21 | readonly platform?: NodeJS.Platform; | ||
| 22 | readonly env?: NodeJS.ProcessEnv; | 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 | export interface WindowsEnvironmentProbeOptions { | 143 | export interface WindowsEnvironmentProbeOptions { |
| 26 | readonly loadProfile?: boolean; | 144 | readonly loadProfile?: boolean; |
| 27 | } | 145 | } |
| ... | @@ -214,6 +332,20 @@ export type WindowsShellEnvironmentReader = ( | ... | @@ -214,6 +332,20 @@ export type WindowsShellEnvironmentReader = ( |
| 214 | options?: WindowsEnvironmentProbeOptions, | 332 | options?: WindowsEnvironmentProbeOptions, |
| 215 | ) => Partial<Record<string, string>>; | 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 | export function readEnvironmentFromWindowsShell( | 349 | export function readEnvironmentFromWindowsShell( |
| 218 | names: ReadonlyArray<string>, | 350 | names: ReadonlyArray<string>, |
| 219 | execFile?: ExecFileSyncLike, | 351 | execFile?: ExecFileSyncLike, |
| ... | @@ -334,6 +466,7 @@ function resolveCommandCandidates( | ... | @@ -334,6 +466,7 @@ function resolveCommandCandidates( |
| 334 | command: string, | 466 | command: string, |
| 335 | platform: NodeJS.Platform, | 467 | platform: NodeJS.Platform, |
| 336 | windowsPathExtensions: ReadonlyArray<string>, | 468 | windowsPathExtensions: ReadonlyArray<string>, |
| 469 | extname: (path: string) => string, | ||
| 337 | ): ReadonlyArray<string> { | 470 | ): ReadonlyArray<string> { |
| 338 | if (platform !== "win32") return [command]; | 471 | if (platform !== "win32") return [command]; |
| 339 | const extension = extname(command); | 472 | const extension = extname(command); |
| ... | @@ -358,46 +491,53 @@ function resolveCommandCandidates( | ... | @@ -358,46 +491,53 @@ function resolveCommandCandidates( |
| 358 | return Array.from(new Set(candidates)); | 491 | return Array.from(new Set(candidates)); |
| 359 | } | 492 | } |
| 360 | 493 | ||
| 361 | function isExecutableFile( | 494 | const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( |
| 362 | filePath: string, | 495 | filePath: string, |
| 363 | platform: NodeJS.Platform, | 496 | platform: NodeJS.Platform, |
| 364 | windowsPathExtensions: ReadonlyArray<string>, | 497 | windowsPathExtensions: ReadonlyArray<string>, |
| 365 | ): boolean { | 498 | ): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> { |
| 366 | try { | 499 | const fileSystem = yield* FileSystem.FileSystem; |
| 367 | const stat = statSync(filePath); | 500 | const path = yield* Path.Path; |
| 368 | if (!stat.isFile()) return false; | 501 | const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); |
| 369 | if (platform === "win32") { | 502 | if (stat === null || stat.type !== "File") return false; |
| 370 | const extension = extname(filePath); | 503 | |
| 371 | if (extension.length === 0) return false; | 504 | if (platform === "win32") { |
| 372 | return windowsPathExtensions.includes(extension.toUpperCase()); | 505 | const extension = path.extname(filePath); |
| 373 | } | 506 | if (extension.length === 0) return false; |
| 374 | accessSync(filePath, constants.X_OK); | 507 | return windowsPathExtensions.includes(extension.toUpperCase()); |
| 375 | return true; | ||
| 376 | } catch { | ||
| 377 | return false; | ||
| 378 | } | 508 | } |
| 379 | } | ||
| 380 | 509 | ||
| 381 | export function resolveCommandPath( | 510 | return canExecuteFile(filePath); |
| 511 | }); | ||
| 512 | |||
| 513 | const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( | ||
| 382 | command: string, | 514 | command: string, |
| 383 | options: CommandAvailabilityOptions = {}, | 515 | options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, |
| 384 | ): string | null { | 516 | ): Effect.fn.Return<string, CommandResolutionError, FileSystem.FileSystem | Path.Path> { |
| 385 | const platform = options.platform ?? process.platform; | 517 | const path = yield* Path.Path; |
| 518 | const platform = options.platform; | ||
| 386 | const env = options.env ?? process.env; | 519 | const env = options.env ?? process.env; |
| 387 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; | 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 | if (command.includes("/") || command.includes("\\")) { | 528 | if (command.includes("/") || command.includes("\\")) { |
| 391 | for (const candidate of commandCandidates) { | 529 | for (const candidate of commandCandidates) { |
| 392 | if (isExecutableFile(candidate, platform, windowsPathExtensions)) { | 530 | if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { |
| 393 | return candidate; | 531 | return candidate; |
| 394 | } | 532 | } |
| 395 | } | 533 | } |
| 396 | return null; | 534 | return yield* new CommandResolutionError({ command, reason: "not-found" }); |
| 397 | } | 535 | } |
| 398 | 536 | ||
| 399 | const pathValue = resolvePathEnvironmentVariable(env); | 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 | const pathEntries: string[] = []; | 541 | const pathEntries: string[] = []; |
| 402 | for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { | 542 | for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { |
| 403 | const pathEntry = stripWrappingQuotes(entry.trim()); | 543 | const pathEntry = stripWrappingQuotes(entry.trim()); |
| ... | @@ -408,21 +548,65 @@ export function resolveCommandPath( | ... | @@ -408,21 +548,65 @@ export function resolveCommandPath( |
| 408 | 548 | ||
| 409 | for (const pathEntry of pathEntries) { | 549 | for (const pathEntry of pathEntries) { |
| 410 | for (const candidate of commandCandidates) { | 550 | for (const candidate of commandCandidates) { |
| 411 | const candidatePath = join(pathEntry, candidate); | 551 | const candidatePath = path.join(pathEntry, candidate); |
| 412 | if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { | 552 | if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { |
| 413 | return candidatePath; | 553 | return candidatePath; |
| 414 | } | 554 | } |
| 415 | } | 555 | } |
| 416 | } | 556 | } |
| 417 | return null; | 557 | return yield* new CommandResolutionError({ command, reason: "not-found" }); |
| 418 | } | 558 | }); |
| 419 | 559 | ||
| 420 | export function isCommandAvailable( | 560 | export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( |
| 421 | command: string, | 561 | command: string, |
| 422 | options: CommandAvailabilityOptions = {}, | 562 | options: CommandAvailabilityOptions = {}, |
| 423 | ): boolean { | 563 | ) { |
| 424 | return resolveCommandPath(command, options) !== null; | 564 | return yield* resolveCommandPathForPlatform(command, { |
| 425 | } | 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 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { | 611 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |
| 428 | const appData = env.APPDATA?.trim(); | 612 | const appData = env.APPDATA?.trim(); |
| ... | @@ -437,11 +621,6 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra | ... | @@ -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 | function readWindowsEnvironmentSafely( | 624 | function readWindowsEnvironmentSafely( |
| 446 | readEnvironment: WindowsShellEnvironmentReader, | 625 | readEnvironment: WindowsShellEnvironmentReader, |
| 447 | names: ReadonlyArray<string>, | 626 | names: ReadonlyArray<string>, |
| ... | @@ -467,12 +646,11 @@ function mergeWindowsEnv( | ... | @@ -467,12 +646,11 @@ function mergeWindowsEnv( |
| 467 | return nextEnv; | 646 | return nextEnv; |
| 468 | } | 647 | } |
| 469 | 648 | ||
| 470 | export function resolveWindowsEnvironment( | 649 | export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( |
| 471 | env: NodeJS.ProcessEnv, | 650 | env: NodeJS.ProcessEnv, |
| 472 | options: WindowsEnvironmentResolverOptions = {}, | 651 | ): Effect.fn.Return<Partial<NodeJS.ProcessEnv>, never, FileSystem.FileSystem | Path.Path> { |
| 473 | ): Partial<NodeJS.ProcessEnv> { | 652 | const readEnvironment = yield* WindowsShellEnvironment; |
| 474 | const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; | 653 | const commandAvailable = yield* CommandAvailability; |
| 475 | const commandAvailable = options.commandAvailable ?? isCommandAvailable; | ||
| 476 | const inheritedPath = readEnvPath(env); | 654 | const inheritedPath = readEnvPath(env); |
| 477 | const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { | 655 | const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { |
| 478 | loadProfile: false, | 656 | loadProfile: false, |
| ... | @@ -483,7 +661,7 @@ export function resolveWindowsEnvironment( | ... | @@ -483,7 +661,7 @@ export function resolveWindowsEnvironment( |
| 483 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; | 661 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; |
| 484 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); | 662 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); |
| 485 | 663 | ||
| 486 | if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { | 664 | if (yield* commandAvailable("node", { env: baselineEnv })) { |
| 487 | return baselinePatch; | 665 | return baselinePatch; |
| 488 | } | 666 | } |
| 489 | 667 | ||
| ... | @@ -503,4 +681,4 @@ export function resolveWindowsEnvironment( | ... | @@ -503,4 +681,4 @@ export function resolveWindowsEnvironment( |
| 503 | return Object.keys(profiledPatch).length > 0 | 681 | return Object.keys(profiledPatch).length > 0 |
| 504 | ? { ...baselinePatch, ...profiledPatch } | 682 | ? { ...baselinePatch, ...profiledPatch } |
| 505 | : baselinePatch; | 683 | : baselinePatch; |
| 506 | } | 684 | }); |
packages/ssh/src/auth.test.ts+11-4| ... | @@ -3,7 +3,9 @@ import { assert, describe, it } from "@effect/vitest"; | ... | @@ -3,7 +3,9 @@ import { assert, describe, it } from "@effect/vitest"; |
| 3 | 3 | ||
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | 9 | ||
| 8 | import { | 10 | import { |
| 9 | buildSshAskpassHelperDescriptor, | 11 | buildSshAskpassHelperDescriptor, |
| ... | @@ -37,7 +39,6 @@ describe("ssh auth", () => { | ... | @@ -37,7 +39,6 @@ describe("ssh auth", () => { |
| 37 | authSecret: "super-secret", | 39 | authSecret: "super-secret", |
| 38 | interactiveAuth: true, | 40 | interactiveAuth: true, |
| 39 | askpassDirectory: directory, | 41 | askpassDirectory: directory, |
| 40 | platform: "linux", | ||
| 41 | baseEnv: {}, | 42 | baseEnv: {}, |
| 42 | }); | 43 | }); |
| 43 | 44 | ||
| ... | @@ -48,15 +49,21 @@ describe("ssh auth", () => { | ... | @@ -48,15 +49,21 @@ describe("ssh auth", () => { |
| 48 | assert.equal(env.DISPLAY, "t3code"); | 49 | assert.equal(env.DISPLAY, "t3code"); |
| 49 | assert.equal(yield* fs.exists(askpassPath), true); | 50 | assert.equal(yield* fs.exists(askpassPath), true); |
| 50 | assert.include(yield* fs.readFileString(askpassPath), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); | 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 | it.effect("builds a windows askpass launcher pair", () => | 58 | it.effect("builds a windows askpass launcher pair", () => |
| 55 | Effect.gen(function* () { | 59 | Effect.gen(function* () { |
| 56 | const descriptor = yield* buildSshAskpassHelperDescriptor({ | 60 | const descriptor = yield* buildSshAskpassHelperDescriptor({ |
| 57 | directory: "C:\\temp\\t3code-ssh-askpass", | 61 | directory: "C:\\temp\\t3code-ssh-askpass", |
| 58 | platform: "win32", | 62 | }).pipe( |
| 59 | }).pipe(Effect.provide(NodeServices.layer)); | 63 | Effect.provide( |
| 64 | Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "win32")), | ||
| 65 | ), | ||
| 66 | ); | ||
| 60 | 67 | ||
| 61 | assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd"); | 68 | assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd"); |
| 62 | assert.deepEqual( | 69 | assert.deepEqual( |
packages/ssh/src/auth.ts+16-9| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 2 | import * as Config from "effect/Config"; | ||
| 1 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | ||
| 5 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | 9 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 10 | ||
| ... | @@ -55,7 +58,6 @@ export interface SshChildEnvironmentOptions { | ... | @@ -55,7 +58,6 @@ export interface SshChildEnvironmentOptions { |
| 55 | readonly baseEnv?: NodeJS.ProcessEnv; | 58 | readonly baseEnv?: NodeJS.ProcessEnv; |
| 56 | readonly askpassDirectory?: string; | 59 | readonly askpassDirectory?: string; |
| 57 | readonly authSecret?: string | null; | 60 | readonly authSecret?: string | null; |
| 58 | readonly platform?: NodeJS.Platform; | ||
| 59 | } | 61 | } |
| 60 | 62 | ||
| 61 | const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; | 63 | const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; |
| ... | @@ -110,9 +112,8 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( | ... | @@ -110,9 +112,8 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( |
| 110 | "ssh/auth.buildSshAskpassHelperDescriptor", | 112 | "ssh/auth.buildSshAskpassHelperDescriptor", |
| 111 | )(function* (input: { | 113 | )(function* (input: { |
| 112 | readonly directory: string; | 114 | readonly directory: string; |
| 113 | readonly platform?: NodeJS.Platform; | ||
| 114 | }): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> { | 115 | }): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> { |
| 115 | const platform = input.platform ?? process.platform; | 116 | const platform = yield* HostProcessPlatform; |
| 116 | const path = yield* Path.Path; | 117 | const path = yield* Path.Path; |
| 117 | const directory = input.directory; | 118 | const directory = input.directory; |
| 118 | 119 | ||
| ... | @@ -148,12 +149,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( | ... | @@ -148,12 +149,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( |
| 148 | export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( | 149 | export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( |
| 149 | function* (input: { | 150 | function* (input: { |
| 150 | readonly directory: string; | 151 | readonly directory: string; |
| 151 | readonly platform?: NodeJS.Platform; | ||
| 152 | }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { | 152 | }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { |
| 153 | const fs = yield* FileSystem.FileSystem; | 153 | const fs = yield* FileSystem.FileSystem; |
| 154 | const path = yield* Path.Path; | 154 | const path = yield* Path.Path; |
| 155 | const descriptor = yield* buildSshAskpassHelperDescriptor(input); | 155 | const descriptor = yield* buildSshAskpassHelperDescriptor(input); |
| 156 | const platform = input.platform ?? process.platform; | 156 | const platform = yield* HostProcessPlatform; |
| 157 | 157 | ||
| 158 | yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); | 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,21 +179,28 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron |
| 179 | PlatformError.PlatformError, | 179 | PlatformError.PlatformError, |
| 180 | FileSystem.FileSystem | Path.Path | 180 | FileSystem.FileSystem | Path.Path |
| 181 | > { | 181 | > { |
| 182 | const baseEnv = { ...(input.baseEnv ?? process.env) }; | 182 | const baseEnv = { ...input.baseEnv }; |
| 183 | if (!input.interactiveAuth) { | 183 | if (!input.interactiveAuth) { |
| 184 | return baseEnv; | 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 | const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); | 195 | const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); |
| 189 | const sshAskpass = yield* ensureSshAskpassHelpers({ directory, platform }); | 196 | const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); |
| 190 | 197 | ||
| 191 | return { | 198 | return { |
| 192 | ...baseEnv, | 199 | ...baseEnv, |
| 193 | SSH_ASKPASS: sshAskpass, | 200 | SSH_ASKPASS: sshAskpass, |
| 194 | SSH_ASKPASS_REQUIRE: "force", | 201 | SSH_ASKPASS_REQUIRE: "force", |
| 195 | ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }), | 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,6 +1,7 @@ |
| 1 | import * as Crypto from "node:crypto"; | 1 | import * as Crypto from "node:crypto"; |
| 2 | 2 | ||
| 3 | import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; | 3 | import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Duration from "effect/Duration"; | 5 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -16,7 +17,16 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; | ... | @@ -16,7 +17,16 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; |
| 16 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; | 17 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; |
| 17 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; | 18 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; |
| 18 | const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; | 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 | const encoder = new TextEncoder(); | 31 | const encoder = new TextEncoder(); |
| 22 | 32 | ||
| ... | @@ -191,16 +201,18 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func | ... | @@ -191,16 +201,18 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func |
| 191 | ...(input.remoteCommandArgs ?? []), | 201 | ...(input.remoteCommandArgs ?? []), |
| 192 | ]; | 202 | ]; |
| 193 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 203 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 204 | const sshCommand = yield* resolveSshCommand; | ||
| 194 | yield* Effect.logDebug("ssh.command.start", { | 205 | yield* Effect.logDebug("ssh.command.start", { |
| 195 | ...sshTargetLogFields(target), | 206 | ...sshTargetLogFields(target), |
| 196 | command: [SSH_COMMAND, ...args], | 207 | command: [sshCommand, ...args], |
| 197 | hasStdin: input.stdin !== undefined, | 208 | hasStdin: input.stdin !== undefined, |
| 198 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, | 209 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, |
| 199 | }); | 210 | }); |
| 200 | const child = yield* spawner | 211 | const child = yield* spawner |
| 201 | .spawn( | 212 | .spawn( |
| 202 | ChildProcess.make(SSH_COMMAND, args, { | 213 | ChildProcess.make(sshCommand, args, { |
| 203 | env: environment, | 214 | env: environment, |
| 215 | extendEnv: true, | ||
| 204 | stdin: { | 216 | stdin: { |
| 205 | stream: stdinStream(input.stdin), | 217 | stream: stdinStream(input.stdin), |
| 206 | endOnDone: true, | 218 | endOnDone: true, |
| ... | @@ -212,7 +224,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func | ... | @@ -212,7 +224,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func |
| 212 | Effect.mapError( | 224 | Effect.mapError( |
| 213 | (cause) => | 225 | (cause) => |
| 214 | new SshCommandError({ | 226 | new SshCommandError({ |
| 215 | command: [SSH_COMMAND, ...args], | 227 | command: [sshCommand, ...args], |
| 216 | exitCode: null, | 228 | exitCode: null, |
| 217 | stderr: "", | 229 | stderr: "", |
| 218 | message: | 230 | message: |
packages/ssh/src/config.ts+11-1| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; | 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import * as Config from "effect/Config"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Option from "effect/Option"; | ||
| 5 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | 8 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 9 | ||
| ... | @@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { | ... | @@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { |
| 208 | export const discoverSshHosts = Effect.fnUntraced( | 210 | export const discoverSshHosts = Effect.fnUntraced( |
| 209 | function* (input: { readonly homeDir?: string }) { | 211 | function* (input: { readonly homeDir?: string }) { |
| 210 | const path = yield* Path.Path; | 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 | if (homeDir.trim().length === 0) { | 222 | if (homeDir.trim().length === 0) { |
| 213 | return []; | 223 | return []; |
| 214 | } | 224 | } |
packages/ssh/src/tunnel.ts+5-3| ... | @@ -34,9 +34,9 @@ import { | ... | @@ -34,9 +34,9 @@ import { |
| 34 | collectProcessOutput, | 34 | collectProcessOutput, |
| 35 | getLastNonEmptyOutputLine, | 35 | getLastNonEmptyOutputLine, |
| 36 | remoteStateKey, | 36 | remoteStateKey, |
| 37 | resolveSshCommand, | ||
| 37 | resolveSshTarget, | 38 | resolveSshTarget, |
| 38 | runSshCommand, | 39 | runSshCommand, |
| 39 | SSH_COMMAND, | ||
| 40 | targetConnectionKey, | 40 | targetConnectionKey, |
| 41 | } from "./command.ts"; | 41 | } from "./command.ts"; |
| 42 | import { | 42 | import { |
| ... | @@ -1069,7 +1069,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: | ... | @@ -1069,7 +1069,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: |
| 1069 | `${input.localPort}:127.0.0.1:${input.remotePort}`, | 1069 | `${input.localPort}:127.0.0.1:${input.remotePort}`, |
| 1070 | hostSpec, | 1070 | hostSpec, |
| 1071 | ]; | 1071 | ]; |
| 1072 | const tunnelCommand = [SSH_COMMAND, ...args]; | 1072 | const sshCommand = yield* resolveSshCommand; |
| 1073 | const tunnelCommand = [sshCommand, ...args]; | ||
| 1073 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1074 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1074 | const scope = yield* Scope.Scope; | 1075 | const scope = yield* Scope.Scope; |
| 1075 | yield* Effect.logDebug("ssh.tunnel.spawn.start", { | 1076 | yield* Effect.logDebug("ssh.tunnel.spawn.start", { |
| ... | @@ -1082,8 +1083,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: | ... | @@ -1082,8 +1083,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: |
| 1082 | }); | 1083 | }); |
| 1083 | const child = yield* spawner | 1084 | const child = yield* spawner |
| 1084 | .spawn( | 1085 | .spawn( |
| 1085 | ChildProcess.make(SSH_COMMAND, args, { | 1086 | ChildProcess.make(sshCommand, args, { |
| 1086 | env: childEnvironment, | 1087 | env: childEnvironment, |
| 1088 | extendEnv: true, | ||
| 1087 | stdin: { | 1089 | stdin: { |
| 1088 | stream: Stream.empty, | 1090 | stream: Stream.empty, |
| 1089 | endOnDone: true, | 1091 | endOnDone: true, |
packages/tailscale/package.json+1| ... | @@ -14,6 +14,7 @@ | ... | @@ -14,6 +14,7 @@ |
| 14 | }, | 14 | }, |
| 15 | "dependencies": { | 15 | "dependencies": { |
| 16 | "@effect/platform-node": "catalog:", | 16 | "@effect/platform-node": "catalog:", |
| 17 | "@t3tools/shared": "workspace:*", | ||
| 17 | "effect": "catalog:" | 18 | "effect": "catalog:" |
| 18 | }, | 19 | }, |
| 19 | "devDependencies": { | 20 | "devDependencies": { |
packages/tailscale/src/tailscale.ts+10-3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 1 | import * as Data from "effect/Data"; | 2 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| ... | @@ -10,7 +11,11 @@ export const DEFAULT_TAILSCALE_SERVE_PORT = 443; | ... | @@ -10,7 +11,11 @@ export const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 10 | export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; | 11 | export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; |
| 11 | export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; | 12 | export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; |
| 12 | export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; | 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 | export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ | 20 | export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ |
| 16 | readonly command: readonly string[]; | 21 | readonly command: readonly string[]; |
| ... | @@ -136,8 +141,9 @@ export const readTailscaleStatus: Effect.Effect< | ... | @@ -136,8 +141,9 @@ export const readTailscaleStatus: Effect.Effect< |
| 136 | > = Effect.gen(function* () { | 141 | > = Effect.gen(function* () { |
| 137 | const args = ["status", "--json"]; | 142 | const args = ["status", "--json"]; |
| 138 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 143 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 144 | const hostPlatform = yield* HostProcessPlatform; | ||
| 139 | const child = yield* spawner | 145 | const child = yield* spawner |
| 140 | .spawn(ChildProcess.make(TAILSCALE_COMMAND, args)) | 146 | .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) |
| 141 | .pipe( | 147 | .pipe( |
| 142 | Effect.mapError((cause) => | 148 | Effect.mapError((cause) => |
| 143 | tailscaleCommandError( | 149 | tailscaleCommandError( |
| ... | @@ -211,8 +217,9 @@ const runTailscaleCommand = ( | ... | @@ -211,8 +217,9 @@ const runTailscaleCommand = ( |
| 211 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => | 217 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => |
| 212 | Effect.gen(function* () { | 218 | Effect.gen(function* () { |
| 213 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 219 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 220 | const hostPlatform = yield* HostProcessPlatform; | ||
| 214 | const child = yield* spawner | 221 | const child = yield* spawner |
| 215 | .spawn(ChildProcess.make(TAILSCALE_COMMAND, args)) | 222 | .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) |
| 216 | .pipe( | 223 | .pipe( |
| 217 | Effect.mapError((cause) => | 224 | Effect.mapError((cause) => |
| 218 | tailscaleCommandError( | 225 | tailscaleCommandError( |
pnpm-lock.yaml+3| ... | @@ -810,6 +810,9 @@ importers: | ... | @@ -810,6 +810,9 @@ importers: |
| 810 | '@effect/platform-node': | 810 | '@effect/platform-node': |
| 811 | specifier: 4.0.0-beta.78 | 811 | specifier: 4.0.0-beta.78 |
| 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) | 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 | effect: | 816 | effect: |
| 814 | specifier: 4.0.0-beta.78 | 817 | specifier: 4.0.0-beta.78 |
| 815 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | 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,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 6 | 7 | ||
| 7 | import { | 8 | import { |
| ... | @@ -11,10 +12,12 @@ import { | ... | @@ -11,10 +12,12 @@ import { |
| 11 | resolveDesktopBuildIconAssets, | 12 | resolveDesktopBuildIconAssets, |
| 12 | resolveDesktopProductName, | 13 | resolveDesktopProductName, |
| 13 | resolveDesktopUpdateChannel, | 14 | resolveDesktopUpdateChannel, |
| 15 | resolveGitHubPublishConfig, | ||
| 14 | resolveMockUpdateServerPort, | 16 | resolveMockUpdateServerPort, |
| 15 | resolveMockUpdateServerUrl, | 17 | resolveMockUpdateServerUrl, |
| 16 | } from "./build-desktop-artifact.ts"; | 18 | } from "./build-desktop-artifact.ts"; |
| 17 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | 19 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 20 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 18 | 21 | ||
| 19 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | 22 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 20 | it("resolves the dedicated nightly updater channel from nightly versions", () => { | 23 | it("resolves the dedicated nightly updater channel from nightly versions", () => { |
| ... | @@ -41,6 +44,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -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 | it("omits bundled workspace packages from staged desktop dependencies", () => { | 88 | it("omits bundled workspace packages from staged desktop dependencies", () => { |
| 45 | assert.deepStrictEqual( | 89 | assert.deepStrictEqual( |
| 46 | resolveDesktopRuntimeDependencies( | 90 | resolveDesktopRuntimeDependencies( |
| ... | @@ -122,6 +166,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -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 | it.effect("preserves explicit false boolean flags over true env defaults", () => | 206 | it.effect("preserves explicit false boolean flags over true env defaults", () => |
| 126 | Effect.gen(function* () { | 207 | Effect.gen(function* () { |
| 127 | const resolved = yield* resolveBuildOptions({ | 208 | const resolved = yield* resolveBuildOptions({ |
scripts/build-desktop-artifact.ts+66-38| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | 2 | ||
| 3 | import { fromYaml } from "@t3tools/shared/schemaYaml"; | 3 | import { fromYaml } from "@t3tools/shared/schemaYaml"; |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 5 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 4 | import rootPackageJson from "../package.json" with { type: "json" }; | 6 | import rootPackageJson from "../package.json" with { type: "json" }; |
| 5 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; | 7 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; |
| 6 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | 8 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; |
| ... | @@ -101,14 +103,14 @@ function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Typ | ... | @@ -101,14 +103,14 @@ function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Typ |
| 101 | return undefined; | 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 | const config = PLATFORM_CONFIG[platform]; | 107 | const config = PLATFORM_CONFIG[platform]; |
| 106 | if (!config) { | 108 | if (!config) { |
| 107 | return "x64"; | 109 | return "x64"; |
| 108 | } | 110 | } |
| 109 | 111 | ||
| 110 | return getDefaultBuildArch(platform, process.arch, process.env, config); | 112 | return yield* getDefaultBuildArch(platform, config); |
| 111 | } | 113 | }); |
| 112 | 114 | ||
| 113 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ | 115 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ |
| 114 | readonly message: string; | 116 | readonly message: string; |
| ... | @@ -198,13 +200,21 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo | ... | @@ -198,13 +200,21 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo |
| 198 | const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { | 200 | const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { |
| 199 | const fs = yield* FileSystem.FileSystem; | 201 | const fs = yield* FileSystem.FileSystem; |
| 200 | const path = yield* Path.Path; | 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 | if (configured && (yield* fs.exists(configured))) { | 212 | if (configured && (yield* fs.exists(configured))) { |
| 203 | return configured; | 213 | return configured; |
| 204 | } | 214 | } |
| 205 | 215 | ||
| 206 | if (process.platform === "win32") { | 216 | if (hostPlatform === "win32") { |
| 207 | const localAppData = process.env.LOCALAPPDATA; | 217 | const localAppData = Option.getOrUndefined(env.localAppData); |
| 208 | if (localAppData) { | 218 | if (localAppData) { |
| 209 | for (const version of ["Python313", "Python312", "Python311", "Python310"]) { | 219 | for (const version of ["Python313", "Python312", "Python311", "Python310"]) { |
| 210 | const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); | 220 | const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); |
| ... | @@ -348,21 +358,23 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( | ... | @@ -348,21 +358,23 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 348 | const path = yield* Path.Path; | 358 | const path = yield* Path.Path; |
| 349 | const repoRoot = yield* RepoRoot; | 359 | const repoRoot = yield* RepoRoot; |
| 350 | const env = yield* BuildEnvConfig; | 360 | const env = yield* BuildEnvConfig; |
| 361 | const hostPlatform = yield* HostProcessPlatform; | ||
| 351 | 362 | ||
| 352 | const platform = mergeOptions( | 363 | const platform = mergeOptions( |
| 353 | input.platform, | 364 | input.platform, |
| 354 | env.platform, | 365 | env.platform, |
| 355 | detectHostBuildPlatform(process.platform), | 366 | detectHostBuildPlatform(hostPlatform), |
| 356 | ); | 367 | ); |
| 357 | 368 | ||
| 358 | if (!platform) { | 369 | if (!platform) { |
| 359 | return yield* new BuildScriptError({ | 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 | const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); | 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 | const version = mergeOptions(input.buildVersion, env.version, undefined); | 378 | const version = mergeOptions(input.buildVersion, env.version, undefined); |
| 367 | const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) | 379 | const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) |
| 368 | ? "release-mock" | 380 | ? "release-mock" |
| ... | @@ -622,19 +634,18 @@ export function resolveDesktopRuntimeDependencies( | ... | @@ -622,19 +634,18 @@ export function resolveDesktopRuntimeDependencies( |
| 622 | return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); | 634 | return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); |
| 623 | } | 635 | } |
| 624 | 636 | ||
| 625 | function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): | 637 | export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( |
| 626 | | { | 638 | updateChannel: "latest" | "nightly", |
| 627 | readonly provider: "github"; | 639 | ) { |
| 628 | readonly owner: string; | 640 | const env = yield* Config.all({ |
| 629 | readonly repo: string; | 641 | updateRepository: Config.string("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option), |
| 630 | readonly releaseType: "release" | "prerelease"; | 642 | githubRepository: Config.string("GITHUB_REPOSITORY").pipe(Config.option), |
| 631 | readonly channel?: "nightly"; | 643 | }); |
| 632 | } | 644 | const rawRepo = ( |
| 633 | | undefined { | 645 | Option.getOrUndefined(env.updateRepository)?.trim() || |
| 634 | const rawRepo = | 646 | Option.getOrUndefined(env.githubRepository)?.trim() || |
| 635 | process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || | 647 | "" |
| 636 | process.env.GITHUB_REPOSITORY?.trim() || | 648 | ).trim(); |
| 637 | ""; | ||
| 638 | if (!rawRepo) return undefined; | 649 | if (!rawRepo) return undefined; |
| 639 | 650 | ||
| 640 | const [owner, repo, ...rest] = rawRepo.split("/"); | 651 | const [owner, repo, ...rest] = rawRepo.split("/"); |
| ... | @@ -647,7 +658,7 @@ function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): | ... | @@ -647,7 +658,7 @@ function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): |
| 647 | releaseType: updateChannel === "nightly" ? "prerelease" : "release", | 658 | releaseType: updateChannel === "nightly" ? "prerelease" : "release", |
| 648 | ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), | 659 | ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), |
| 649 | }; | 660 | }; |
| 650 | } | 661 | }); |
| 651 | 662 | ||
| 652 | export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { | 663 | export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { |
| 653 | return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; | 664 | return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; |
| ... | @@ -696,7 +707,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -696,7 +707,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 696 | }, | 707 | }, |
| 697 | }; | 708 | }; |
| 698 | const updateChannel = resolveDesktopUpdateChannel(version); | 709 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 699 | const publishConfig = resolveGitHubPublishConfig(updateChannel); | 710 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| 700 | if (publishConfig) { | 711 | if (publishConfig) { |
| 701 | buildConfig.publish = [publishConfig]; | 712 | buildConfig.publish = [publishConfig]; |
| 702 | } else if (mockUpdates) { | 713 | } else if (mockUpdates) { |
| ... | @@ -780,6 +791,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -780,6 +791,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 780 | const repoRoot = yield* RepoRoot; | 791 | const repoRoot = yield* RepoRoot; |
| 781 | const path = yield* Path.Path; | 792 | const path = yield* Path.Path; |
| 782 | const fs = yield* FileSystem.FileSystem; | 793 | const fs = yield* FileSystem.FileSystem; |
| 794 | const hostPlatform = yield* HostProcessPlatform; | ||
| 783 | const workspaceConfig = yield* readWorkspaceConfig(); | 795 | const workspaceConfig = yield* readWorkspaceConfig(); |
| 784 | const workspaceCatalog = workspaceConfig.catalog ?? {}; | 796 | const workspaceCatalog = workspaceConfig.catalog ?? {}; |
| 785 | const workspaceOverrides = workspaceConfig.overrides ?? {}; | 797 | const workspaceOverrides = workspaceConfig.overrides ?? {}; |
| ... | @@ -846,12 +858,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -846,12 +858,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 846 | 858 | ||
| 847 | if (!options.skipBuild) { | 859 | if (!options.skipBuild) { |
| 848 | yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); | 860 | yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); |
| 861 | const spawnCommand = yield* resolveSpawnCommand("vp", ["run", "build:desktop"]); | ||
| 849 | yield* runCommand( | 862 | yield* runCommand( |
| 850 | ChildProcess.make({ | 863 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 851 | cwd: repoRoot, | 864 | cwd: repoRoot, |
| 852 | // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). | 865 | shell: spawnCommand.shell, |
| 853 | shell: process.platform === "win32", | 866 | }), |
| 854 | })`vp run build:desktop`, | ||
| 855 | { label: "vp run build:desktop", verbose: options.verbose }, | 867 | { label: "vp run build:desktop", verbose: options.verbose }, |
| 856 | ); | 868 | ); |
| 857 | } | 869 | } |
| ... | @@ -933,15 +945,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -933,15 +945,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 933 | } | 945 | } |
| 934 | 946 | ||
| 935 | yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); | 947 | yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); |
| 948 | const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]); | ||
| 936 | yield* runCommand( | 949 | yield* runCommand( |
| 937 | ChildProcess.make({ | 950 | ChildProcess.make(installCommand.command, installCommand.args, { |
| 938 | cwd: stageAppDir, | 951 | cwd: stageAppDir, |
| 939 | // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). | 952 | shell: installCommand.shell, |
| 940 | shell: process.platform === "win32", | 953 | }), |
| 941 | })`vp install --prod --no-optional`, | ||
| 942 | { label: "vp install --prod --no-optional", verbose: options.verbose }, | 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 | const buildEnv: NodeJS.ProcessEnv = { | 960 | const buildEnv: NodeJS.ProcessEnv = { |
| 946 | ...process.env, | 961 | ...process.env, |
| 947 | }; | 962 | }; |
| ... | @@ -959,7 +974,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -959,7 +974,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 959 | delete buildEnv.APPLE_API_ISSUER; | 974 | delete buildEnv.APPLE_API_ISSUER; |
| 960 | } | 975 | } |
| 961 | 976 | ||
| 962 | if (process.platform === "win32") { | 977 | if (hostPlatform === "win32") { |
| 963 | const python = yield* resolvePythonForNodeGyp(); | 978 | const python = yield* resolvePythonForNodeGyp(); |
| 964 | if (python) { | 979 | if (python) { |
| 965 | buildEnv.PYTHON = python; | 980 | buildEnv.PYTHON = python; |
| ... | @@ -970,7 +985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -970,7 +985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 970 | } | 985 | } |
| 971 | if (options.verbose) { | 986 | if (options.verbose) { |
| 972 | buildEnv.DEBUG = | 987 | buildEnv.DEBUG = |
| 973 | buildEnv.DEBUG === undefined || buildEnv.DEBUG === "" | 988 | buildEnv.DEBUG === undefined |
| 974 | ? "electron-builder,electron-builder:*" | 989 | ? "electron-builder,electron-builder:*" |
| 975 | : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; | 990 | : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; |
| 976 | } | 991 | } |
| ... | @@ -978,13 +993,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -978,13 +993,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 978 | yield* Effect.log( | 993 | yield* Effect.log( |
| 979 | `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, | 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 | yield* runCommand( | 1010 | yield* runCommand( |
| 982 | ChildProcess.make({ | 1011 | ChildProcess.make(builderCommand.command, builderCommand.args, { |
| 983 | cwd: repoRoot, | 1012 | cwd: repoRoot, |
| 984 | env: buildEnv, | 1013 | env: buildEnv, |
| 985 | // Windows needs shell mode to resolve .cmd shims. | 1014 | shell: builderCommand.shell, |
| 986 | shell: process.platform === "win32", | 1015 | }), |
| 987 | })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, | ||
| 988 | { | 1016 | { |
| 989 | label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, | 1017 | label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, |
| 990 | verbose: options.verbose, | 1018 | verbose: options.verbose, |
scripts/dev-runner.ts+11-4| ... | @@ -5,6 +5,8 @@ import * as NodeOS from "node:os"; | ... | @@ -5,6 +5,8 @@ import * as NodeOS from "node:os"; |
| 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 5 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import * as NetService from "@t3tools/shared/Net"; | 7 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 9 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 8 | import * as Config from "effect/Config"; | 10 | import * as Config from "effect/Config"; |
| 9 | import * as Data from "effect/Data"; | 11 | import * as Data from "effect/Data"; |
| 10 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| ... | @@ -418,9 +420,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -418,9 +420,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 418 | hasExplicitDevUrl: input.devUrl !== undefined, | 420 | hasExplicitDevUrl: input.devUrl !== undefined, |
| 419 | }); | 421 | }); |
| 420 | 422 | ||
| 423 | const hostEnvironment = yield* HostProcessEnvironment; | ||
| 421 | const env = yield* createDevRunnerEnv({ | 424 | const env = yield* createDevRunnerEnv({ |
| 422 | mode: input.mode, | 425 | mode: input.mode, |
| 423 | baseEnv: process.env, | 426 | baseEnv: hostEnvironment, |
| 424 | serverOffset, | 427 | serverOffset, |
| 425 | webOffset, | 428 | webOffset, |
| 426 | t3Home: input.t3Home, | 429 | t3Home: input.t3Home, |
| ... | @@ -445,14 +448,18 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -445,14 +448,18 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 445 | return; | 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 | stdin: "inherit", | 457 | stdin: "inherit", |
| 450 | stdout: "inherit", | 458 | stdout: "inherit", |
| 451 | stderr: "inherit", | 459 | stderr: "inherit", |
| 452 | env, | 460 | env, |
| 453 | extendEnv: false, | 461 | extendEnv: false, |
| 454 | // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). | 462 | shell: spawnCommand.shell, |
| 455 | shell: process.platform === "win32", | ||
| 456 | // Keep Vite+ in the same process group so terminal signals (Ctrl+C) | 463 | // Keep Vite+ in the same process group so terminal signals (Ctrl+C) |
| 457 | // reach it directly. Effect defaults to detached: true on non-Windows, | 464 | // reach it directly. Effect defaults to detached: true on non-Windows, |
| 458 | // which would put the runner in a new group and require manual forwarding. | 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,61 +1,56 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 3 | import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; | 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 | describe("build-target-arch", () => { | 27 | describe("build-target-arch", () => { |
| 6 | it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { | 28 | it.effect("uses the resolved host arch when selecting the default Windows build arch", () => |
| 7 | // Windows-on-Arm can run an x64 Node process under emulation while still | 29 | Effect.gen(function* () { |
| 8 | // exposing the real host CPU via PROCESSOR_ARCHITEW6432. | 30 | // This mirrors the packaging script's default-path behavior: the current |
| 9 | const hostArch = resolveHostProcessArch("win32", "x64", { | 31 | // process is x64, but the machine itself is ARM64, so the default build |
| 10 | PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. | 32 | // target should be win-arm64 rather than win-x64. |
| 11 | PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. | 33 | const arch = yield* getDefaultBuildArch("win", { archChoices: ["x64", "arm64"] }).pipe( |
| 12 | }); | 34 | withHostRuntime("win32", "x64", { |
| 13 | 35 | PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. | |
| 14 | assert.equal(hostArch, "arm64"); | 36 | PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. |
| 15 | }); | 37 | }), |
| 16 | 38 | ); | |
| 17 | it("falls back to x64 for native x64 Windows hosts", () => { | 39 | |
| 18 | const hostArch = resolveHostProcessArch("win32", "x64", { | 40 | assert.equal(arch, "arm64"); |
| 19 | PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. | 41 | }), |
| 20 | }); | 42 | ); |
| 21 | 43 | ||
| 22 | assert.equal(hostArch, "x64"); | 44 | it.effect("does not apply Windows host env heuristics for non-Windows targets", () => |
| 23 | }); | 45 | Effect.gen(function* () { |
| 24 | 46 | const arch = yield* getDefaultBuildArch("linux", { archChoices: ["x64", "arm64"] }).pipe( | |
| 25 | it("keeps arm64 when the current process is already native arm64", () => { | 47 | withHostRuntime("linux", "x64", { |
| 26 | const hostArch = resolveHostProcessArch("win32", "arm64", {}); | 48 | PROCESSOR_ARCHITECTURE: "AMD64", |
| 27 | 49 | PROCESSOR_ARCHITEW6432: "ARM64", | |
| 28 | assert.equal(hostArch, "arm64"); | 50 | }), |
| 29 | }); | 51 | ); |
| 30 | 52 | ||
| 31 | it("uses the resolved host arch when selecting the default Windows build arch", () => { | 53 | assert.equal(arch, "x64"); |
| 32 | // This mirrors the packaging script's default-path behavior: the current | 54 | }), |
| 33 | // process is x64, but the machine itself is ARM64, so the default build | 55 | ); |
| 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 | }); | ||
| 61 | }); | 56 | }); |
scripts/lib/build-target-arch.ts+24-16| ... | @@ -1,3 +1,8 @@ | ... | @@ -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 | export type BuildArch = "arm64" | "x64" | "universal"; | 6 | export type BuildArch = "arm64" | "x64" | "universal"; |
| 2 | export type BuildPlatform = "mac" | "linux" | "win"; | 7 | export type BuildPlatform = "mac" | "linux" | "win"; |
| 3 | 8 | ||
| ... | @@ -5,6 +10,11 @@ interface PlatformConfig { | ... | @@ -5,6 +10,11 @@ interface PlatformConfig { |
| 5 | readonly archChoices: ReadonlyArray<BuildArch>; | 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 | function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { | 18 | function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { |
| 9 | const normalized = value?.trim().toLowerCase(); | 19 | const normalized = value?.trim().toLowerCase(); |
| 10 | if (!normalized) return undefined; | 20 | if (!normalized) return undefined; |
| ... | @@ -13,38 +23,36 @@ function normalizeWindowsArch(value: string | undefined): BuildArch | undefined | ... | @@ -13,38 +23,36 @@ function normalizeWindowsArch(value: string | undefined): BuildArch | undefined |
| 13 | return undefined; | 23 | return undefined; |
| 14 | } | 24 | } |
| 15 | 25 | ||
| 16 | export function resolveHostProcessArch( | 26 | const optionToUndefined = <A>(value: Option.Option<A>): A | undefined => |
| 17 | platform: NodeJS.Platform, | 27 | Option.getOrUndefined(value); |
| 18 | processArch: NodeJS.Architecture, | 28 | |
| 19 | env: NodeJS.ProcessEnv, | 29 | const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () { |
| 20 | ): BuildArch | undefined { | 30 | const platform = yield* HostProcessPlatform; |
| 31 | const processArch = yield* HostProcessArchitecture; | ||
| 21 | if (processArch === "arm64") return "arm64"; | 32 | if (processArch === "arm64") return "arm64"; |
| 22 | if (processArch === "x64") { | 33 | if (processArch === "x64") { |
| 23 | if (platform !== "win32") return "x64"; | 34 | if (platform !== "win32") return "x64"; |
| 24 | 35 | ||
| 25 | // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host | 36 | // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host |
| 26 | // still reports ARM64 via the processor environment variables. | 37 | // still reports ARM64 via the processor environment variables. |
| 38 | const env = yield* WindowsProcessorArchitectureConfig; | ||
| 27 | return ( | 39 | return ( |
| 28 | normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? | 40 | normalizeWindowsArch(optionToUndefined(env.processorArchitectureW6432)) ?? |
| 29 | normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? | 41 | normalizeWindowsArch(optionToUndefined(env.processorArchitecture)) ?? |
| 30 | "x64" | 42 | "x64" |
| 31 | ); | 43 | ); |
| 32 | } | 44 | } |
| 33 | return undefined; | 45 | return undefined; |
| 34 | } | 46 | }); |
| 35 | 47 | ||
| 36 | export function getDefaultBuildArch( | 48 | export const getDefaultBuildArch = Effect.fn("getDefaultBuildArch")(function* ( |
| 37 | platform: BuildPlatform, | 49 | platform: BuildPlatform, |
| 38 | processArch: NodeJS.Architecture, | ||
| 39 | env: NodeJS.ProcessEnv, | ||
| 40 | platformConfig: PlatformConfig, | 50 | platformConfig: PlatformConfig, |
| 41 | ): BuildArch { | 51 | ) { |
| 42 | const hostPlatform: NodeJS.Platform = | 52 | const hostArch = yield* resolveHostProcessArch(); |
| 43 | platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; | ||
| 44 | const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); | ||
| 45 | if (hostArch && platformConfig.archChoices.includes(hostArch)) { | 53 | if (hostArch && platformConfig.archChoices.includes(hostArch)) { |
| 46 | return hostArch; | 54 | return hostArch; |
| 47 | } | 55 | } |
| 48 | 56 | ||
| 49 | return platformConfig.archChoices[0] ?? "x64"; | 57 | return platformConfig.archChoices[0] ?? "x64"; |
| 50 | } | 58 | }); |
scripts/mobile-native-static-check.ts+5-19| ... | @@ -2,6 +2,7 @@ | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | ||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 5 | import * as Console from "effect/Console"; | 6 | import * as Console from "effect/Console"; |
| 6 | import * as Data from "effect/Data"; | 7 | import * as Data from "effect/Data"; |
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| ... | @@ -58,23 +59,7 @@ const commandOutputOptions = { | ... | @@ -58,23 +59,7 @@ const commandOutputOptions = { |
| 58 | } as const; | 59 | } as const; |
| 59 | 60 | ||
| 60 | const commandExists = Effect.fn("commandExists")(function* (command: string) { | 61 | const commandExists = Effect.fn("commandExists")(function* (command: string) { |
| 61 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 62 | return yield* isCommandAvailable(command); |
| 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 | ); | ||
| 78 | }); | 63 | }); |
| 79 | 64 | ||
| 80 | const warnMissingTool = (tool: NativeStaticTool, checkName: string) => | 65 | const warnMissingTool = (tool: NativeStaticTool, checkName: string) => |
| ... | @@ -89,11 +74,12 @@ const runCommand = Effect.fn("runCommand")(function* ( | ... | @@ -89,11 +74,12 @@ const runCommand = Effect.fn("runCommand")(function* ( |
| 89 | ) { | 74 | ) { |
| 90 | yield* Console.log(`$ ${[command, ...args].join(" ")}`); | 75 | yield* Console.log(`$ ${[command, ...args].join(" ")}`); |
| 91 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 76 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 77 | const spawnCommand = yield* resolveSpawnCommand(command, args); | ||
| 92 | const child = yield* spawner.spawn( | 78 | const child = yield* spawner.spawn( |
| 93 | ChildProcess.make(command, [...args], { | 79 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 94 | cwd, | 80 | cwd, |
| 95 | ...commandOutputOptions, | 81 | ...commandOutputOptions, |
| 96 | shell: process.platform === "win32", | 82 | shell: spawnCommand.shell, |
| 97 | }), | 83 | }), |
| 98 | ); | 84 | ); |
| 99 | const exitCode = Number(yield* child.exitCode); | 85 | const exitCode = Number(yield* child.exitCode); |
vite.config.ts+2-4| ... | @@ -1,12 +1,9 @@ | ... | @@ -1,12 +1,9 @@ |
| 1 | import "vite-plus/test/config"; | 1 | import "vite-plus/test/config"; |
| 2 | import { defineConfig } from "vite-plus"; | 2 | import { defineConfig } from "vite-plus"; |
| 3 | import { fileURLToPath } from "node:url"; | ||
| 4 | 3 | ||
| 5 | export default defineConfig({ | 4 | export default defineConfig({ |
| 6 | resolve: { | 5 | resolve: { |
| 7 | alias: { | 6 | tsconfigPaths: true, |
| 8 | "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), | ||
| 9 | }, | ||
| 10 | }, | 7 | }, |
| 11 | test: { | 8 | test: { |
| 12 | environment: "node", | 9 | environment: "node", |
| ... | @@ -96,6 +93,7 @@ export default defineConfig({ | ... | @@ -96,6 +93,7 @@ export default defineConfig({ |
| 96 | "typescript/require-array-sort-compare": "off", | 93 | "typescript/require-array-sort-compare": "off", |
| 97 | "typescript/restrict-template-expressions": "off", | 94 | "typescript/restrict-template-expressions": "off", |
| 98 | "typescript/unbound-method": "off", | 95 | "typescript/unbound-method": "off", |
| 96 | "t3code/no-global-process-runtime": "error", | ||
| 99 | "t3code/no-inline-schema-compile": "warn", | 97 | "t3code/no-inline-schema-compile": "warn", |
| 100 | "t3code/no-manual-effect-runtime-in-tests": "error", | 98 | "t3code/no-manual-effect-runtime-in-tests": "error", |
| 101 | }, | 99 | }, |