authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-14 16:47:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-14 16:47:56-07:00
log477795697d8546a8db4903bd878a5ad3196423b9
tree69b3a8096f413d70ebaaf42ca573809b0b0f94ac
parenta23b83314ec82dca5976a412e60e27f154ea0451
signature Signed by PGP key B5690EEEBB952194

refactor: resolve host process state through Effect (#2959)

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 @@
1import { spawn, spawnSync } from "node:child_process";1import { spawn, spawnSync } from "node:child_process";
2import { watch } from "node:fs";2import { watch } from "node:fs";
3import * as NodeOS from "node:os";
3import { join } from "node:path";4import { join } from "node:path";
45
5import {6import {
...@@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500;...@@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500;
33const restartDebounceMs = 120;34const restartDebounceMs = 120;
34const childTreeGracePeriodMs = 1_200;35const childTreeGracePeriodMs = 1_200;
35const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();36const 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.
38const hostPlatform = NodeOS.platform();
3639
37await waitForResources({40await waitForResources({
38 baseDir: desktopDir,41 baseDir: desktopDir,
...@@ -57,7 +60,7 @@ const expectedExits = new WeakSet();...@@ -57,7 +60,7 @@ const expectedExits = new WeakSet();
57const watchers = [];60const watchers = [];
5861
59function killChildTreeByPid(pid, signal) {62function killChildTreeByPid(pid, signal) {
60 if (process.platform === "win32" || typeof pid !== "number") {63 if (hostPlatform === "win32" || typeof pid !== "number") {
61 return;64 return;
62 }65 }
6366
...@@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) {...@@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) {
65}68}
6669
67function cleanupStaleDevApps() {70function cleanupStaleDevApps() {
68 if (process.platform === "win32") {71 if (hostPlatform === "win32") {
69 return;72 return;
70 }73 }
7174
...@@ -194,7 +197,7 @@ function startWatchers() {...@@ -194,7 +197,7 @@ function startWatchers() {
194}197}
195198
196function killChildTree(signal) {199function killChildTree(signal) {
197 if (process.platform === "win32") {200 if (hostPlatform === "win32") {
198 return;201 return;
199 }202 }
200203
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";
16import { createRequire } from "node:module";16import { createRequire } from "node:module";
17import * as NodeOS from "node:os";
17import { basename, dirname, join, resolve } from "node:path";18import { basename, dirname, join, resolve } from "node:path";
18import { fileURLToPath } from "node:url";19import { fileURLToPath } from "node:url";
19import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";20import { 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"];
33const LAUNCHER_VERSION = 11;34const LAUNCHER_VERSION = 11;
34const defaultIconPath = join(desktopDir, "resources", "icon.icns");35const defaultIconPath = join(desktopDir, "resources", "icon.icns");
35const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");36const 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.
38const hostPlatform = NodeOS.platform();
3639
37function resolveDevelopmentProtocolCallbackPort() {40function 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}
313316
314function isLinuxSetuidSandboxConfigured(electronBinaryPath) {317function isLinuxSetuidSandboxConfigured(electronBinaryPath) {
315 if (process.platform !== "linux") {318 if (hostPlatform !== "linux") {
316 return true;319 return true;
317 }320 }
318321
...@@ -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");
344347
345 if (process.platform !== "darwin") {348 if (hostPlatform !== "darwin") {
346 return electronBinaryPath;349 return electronBinaryPath;
347 }350 }
348351
...@@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) {...@@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) {
358}361}
359362
360export function resolveDevProtocolClient() {363export function resolveDevProtocolClient() {
361 if (process.platform !== "darwin" || !isDevelopment) {364 if (hostPlatform !== "darwin" || !isDevelopment) {
362 return null;365 return null;
363 }366 }
364367
apps/desktop/scripts/ensure-electron-runtime.mjs+14-10
...@@ -1,13 +1,17 @@...@@ -1,13 +1,17 @@
1import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";1import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2import { createRequire } from "node:module";2import { createRequire } from "node:module";
3import { tmpdir } from "node:os";3import { arch, platform, tmpdir } from "node:os";
4import { dirname, join } from "node:path";4import { dirname, join } from "node:path";
5import { spawnSync } from "node:child_process";5import { spawnSync } from "node:child_process";
66
7const require = createRequire(import.meta.url);7const require = createRequire(import.meta.url);
8// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime.
9const hostPlatform = platform();
10// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime.
11const hostArch = arch();
812
9function getPlatformPath() {13function 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}
2327
24function ensureExecutable(filePath) {28function 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) {
39function getRequiredRuntimePaths(electronDir, platformPath) {43function getRequiredRuntimePaths(electronDir, platformPath) {
40 const paths = [join(electronDir, "dist", platformPath)];44 const paths = [join(electronDir, "dist", platformPath)];
4145
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}
5963
60function isMachO(filePath) {64function isMachO(filePath) {
61 if (process.platform !== "darwin") {65 if (hostPlatform !== "darwin") {
62 return true;66 return true;
63 }67 }
6468
...@@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) {...@@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) {
76}80}
7781
78function invalidRuntimePaths(electronDir, platformPath) {82function invalidRuntimePaths(electronDir, platformPath) {
79 if (process.platform !== "darwin") {83 if (hostPlatform !== "darwin") {
80 return [];84 return [];
81 }85 }
8286
...@@ -111,16 +115,16 @@ function runChecked(command, args) {...@@ -111,16 +115,16 @@ function runChecked(command, args) {
111115
112function installElectronRuntime(electronDir, version) {116function 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`);
115119
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* fileSystem54 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";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
66
7import * as Electron from "electron";7import * as Electron from "electron";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
89
9export interface ElectronMenuPosition {10export 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) })));
8182
82export const layer = Layer.sync(ElectronMenu, () => {83export 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;
8488
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 }
11893 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 }
134106
135 template.push(itemOption);107 return destructiveMenuIconCache;
136 }108 };
137109
138 return template;110 const buildTemplate = (
139 };111 entries: readonly ContextMenuItem[],
140112 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 }
160141
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 };
169164
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";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
77
8import * as Electron from "electron";8import * as Electron from "electron";
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
910
10export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{11export 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) {}
3839
39const make = Effect.gen(function* () {40const 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());
4143
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 }
100102
101 if (process.platform === "darwin") {103 if (platform === "darwin") {
102 Electron.app.focus({ steal: true });104 Electron.app.focus({ steal: true });
103 }105 }
104106
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";
9import * as Electron from "electron";9import * as Electron from "electron";
1010
11import * as NetService from "@t3tools/shared/Net";11import * as NetService from "@t3tools/shared/Net";
12import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";13import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
13import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";14import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
14import serverPackageJson from "../../server/package.json" with { type: "json" };15import 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(
8282
83function getIconOption(83function 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 bundle87 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}
108109
109function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions {110function 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
127function syncWindowAppearance(131function 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 }
135140
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 });
319324
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 {
18import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";18import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
19import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";19import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
20import { fromYaml } from "@t3tools/shared/schemaYaml";20import { fromYaml } from "@t3tools/shared/schemaYaml";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";
21import serverPackageJson from "../package.json" with { type: "json" };22import serverPackageJson from "../package.json" with { type: "json" };
2223
23interface PackageJson {24interface 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 );
180182
...@@ -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]);
293296
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";
3import process from "node:process";3import process from "node:process";
4import readline from "node:readline";4import readline from "node:readline";
5import * as NodeTimers from "node:timers";5import * as NodeTimers from "node:timers";
6import { resolveSpawnCommand } from "@t3tools/shared/shell";
7import * as Effect from "effect/Effect";
68
7type JsonPrimitive = null | boolean | number | string;9type JsonPrimitive = null | boolean | number | string;
8type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };10type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
...@@ -128,9 +130,10 @@ class JsonRpcChild {...@@ -128,9 +130,10 @@ class JsonRpcChild {
128 closed = false;130 closed = false;
129131
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";
3import * as path from "node:path";3import * as path from "node:path";
4import { execFileSync, spawn } from "node:child_process";4import { execFileSync, spawn } from "node:child_process";
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { assert, it } from "@effect/vitest";6import { it } from "@effect/vitest";
7import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as Duration from "effect/Duration";9import * 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";
11import * as Fiber from "effect/Fiber";11import * as Fiber from "effect/Fiber";
12import * as TestClock from "effect/testing/TestClock";12import * as TestClock from "effect/testing/TestClock";
13import { vi } from "vite-plus/test";13import { vi } from "vite-plus/test";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1415
15import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts";16import { readBootstrapEnvelope } from "./bootstrap.ts";
16import { assertNone, assertSome } from "@effect/vitest/utils";17import { assertNone, assertSome } from "@effect/vitest/utils";
1718
18const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));19const 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 });
41const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema));42const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema));
4243
43it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {44it.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) => {
8982
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";
11import * as Result from "effect/Result";11import * as Result from "effect/Result";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1415
15class BootstrapError extends Data.TaggedError("BootstrapError")<{16class 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 );
111112
112const makeBootstrapInputStream = (fd: number) =>113const 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 }
119122
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 });
144148
145const makeDirectBootstrapStream = (fd: number): Readable => {149const 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);
167171
168export function resolveFdPath(172function 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 @@
1import NodeOS from "node:os";1import * as NodeOS from "node:os";
22
3import { assert, expect, it } from "@effect/vitest";3import { assert, expect, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";4import * 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";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import * as Context from "effect/Context";8import * as Context from "effect/Context";
8import * as DateTime from "effect/DateTime";9import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";10import * 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}
371375
372export const readProcessRows = (platform = process.platform) =>376export const readProcessRows = Effect.gen(function* () {
373 platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();377 const platform = yield* HostProcessPlatform;
378 return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
379});
374380
375export function aggregateProcessDiagnostics(input: {381export 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 }
389395
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* () {
407413
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 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Crypto from "effect/Crypto";3import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";5import * 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
11import packageJson from "../../../package.json" with { type: "json" };12import packageJson from "../../../package.json" with { type: "json" };
12import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";13import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
1314
14function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {15function 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}
2627
27function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {28function 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;
4348
44 const readPersistedEnvironmentId = Effect.gen(function* () {49 const readPersistedEnvironmentId = Effect.gen(function* () {
45 const exists = yield* fileSystem50 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";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import { vi } from "vite-plus/test";6import { vi } from "vite-plus/test";
67
7import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts";8import { 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);
32const 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 );
3142
32afterEach(() => {43afterEach(() => {
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));
4453
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", () => {
6170
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));
6774
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));
8691
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", () => {
104109
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));
110113
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));
129130
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", () => {
145146
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));
151150
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", () => {
168167
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));
174171
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 @@
1import * as OS from "node:os";1import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2
3import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";4import * as Option from "effect/Option";
...@@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts";...@@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts";
87
9interface ResolveServerEnvironmentLabelInput {8interface ResolveServerEnvironmentLabelInput {
10 readonly cwdBaseName: string;9 readonly cwdBaseName: string;
11 readonly platform?: NodeJS.Platform;
12 readonly hostname?: string | null;
13}10}
1411
15function normalizeLabel(value: string | null | undefined): string | null {12function 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});
7168
72const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* (69const 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*
94export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* (90export 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 }
10297
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 @@
1import * as NodeOS from "node:os";1import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Effect from "effect/Effect";
3import * as Path from "effect/Path";
4import {2import {
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";
149import * as Effect from "effect/Effect";
15type WindowsCommandAvailabilityChecker = (10import * as FileSystem from "effect/FileSystem";
16 command: string,11import * as Path from "effect/Path";
17 options?: CommandAvailabilityOptions,12import * as NodeOS from "node:os";
18) => boolean;
1913
20function logPathHydrationWarning(message: string, error?: unknown): void {14function 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}
2519
26export function fixPath(20function 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 }
5828
59 if (platform !== "darwin" && platform !== "linux") return;29 if (shellPath) break;
30 }
6031
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 }
6838
69 if (shellPath) {39export 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;
7346
74 const launchctlPath =47 if (platform === "win32") {
75 platform === "darwin" && !shellPath48 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});
8674
87export const expandHomePath = Effect.fn(function* (input: string) {75export 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 @@
1import * as net from "node:net";1import * as net from "node:net";
22
3import { it as effectIt } from "@effect/vitest";3import { it as effectIt } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Net from "@t3tools/shared/Net";5import * as Net from "@t3tools/shared/Net";
6import { Effect, Layer } from "effect";6import { Effect, Layer } from "effect";
7import { describe, expect, it } from "vite-plus/test";7import { expect } from "vite-plus/test";
88
9import { ProcessRunner } from "../processRunner.ts";9import { ProcessRunner } from "../processRunner.ts";
10import * as PortScanner from "./PortScanner.ts";10import * as PortScanner from "./PortScanner.ts";
11
12const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } =
13 PortScanner.__testing;
14const TestProcessRunner = Layer.succeed(ProcessRunner, {11const 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});
17const TestPortDiscoveryLive = PortScanner.layer.pipe(14const 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);
2019
21const openServer = (port: number): Effect.Effect<net.Server | null> =>20const 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 });
3938
40const 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
55const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (39const 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);
7155
72describe("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
105describe("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
181describe("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
201describe("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 code57 * Integration tests against a real TCP listener. We provide the Windows host
234 * path (TCP-probe fallback) by monkey-patching `process.platform` for the58 * 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 */
237effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => {61effectIt.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 */
14import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";14import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
15import * as Net from "@t3tools/shared/Net";16import * as Net from "@t3tools/shared/Net";
16import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
17import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect";18import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect";
...@@ -182,6 +183,7 @@ const serversEqual = (...@@ -182,6 +183,7 @@ const serversEqual = (
182const make = Effect.gen(function* PortDiscoveryMake() {183const 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* processRunner229 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"));
360362
361export const layer = Layer.effect(PortDiscovery, make);363export const layer = Layer.effect(PortDiscovery, make);
362
363/** Exposed for tests. */
364export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import { assertSuccess } from "@effect/vitest/utils";3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";7import * 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";
11import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1311
14import {12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
15 isCommandAvailable,13import { SpawnExecutableResolution } from "@t3tools/shared/shell";
16 launchBrowser,14import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts";
17 launchEditorProcess,
18 resolveAvailableEditors,
19 resolveBrowserLaunch,
20 resolveEditorLaunch,
21} from "./externalLauncher.ts";
22
23function 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}
3215
33function makeMockDetachedHandle(onUnref: () => void = () => undefined) {16function 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}
5134
52it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {35const 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);
6447 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
516it("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
554it("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
562it("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
570it.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
606it.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
664it.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 );
70055
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 );
71465};
715 it.effect("uses platform-specific PATH delimiter for platform overrides", () =>66
716 Effect.gen(function* () {67it.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});
73194
732it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {95it.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");
738101
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",133it.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* () {
779142 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",
785148 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
790153 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 }),158it.effect("rejects unknown editors through the service API", () =>
796 );159 Effect.gen(function* () {
797160 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";
15import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
17import * as Config from "effect/Config";
16import * as Context from "effect/Context";18import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
18import * as Encoding from "effect/Encoding";20import * as Encoding from "effect/Encoding";
21import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";23import * as Option from "effect/Option";
24import * as Path from "effect/Path";
21import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";25import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2226
23// ==============================27// ==============================
...@@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2630
27export { ExternalLauncherError };31export { ExternalLauncherError };
28export type { LaunchEditorInput };32export type { LaunchEditorInput };
29export { isCommandAvailable } from "@t3tools/shared/shell";
30
31interface EditorLaunch {33interface 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;
6365
66const 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
76const 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
86const 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
93const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
94const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
95
64function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> {96function 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}
111143
112function resolveAvailableCommand(144const 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});
123155
124function encodeUtf16LeBase64(input: string): string {156function 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}
137169
138function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string {170function 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}
141173
...@@ -145,7 +177,7 @@ function resolveWslPowerShellPath(): string {...@@ -145,7 +177,7 @@ function resolveWslPowerShellPath(): string {
145177
146function shouldUseWindowsBrowserFromWsl(178function 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}
186218
187export function resolveBrowserLaunch(219function 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}
214246
215export function resolveAvailableEditors(247const 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[] = [];
220252
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 }
229261
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 }
235267
236 return available;268 return available;
237}269});
270
271const 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
279const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
280 const platform = yield* HostProcessPlatform;
281 const env = yield* readCommandLookupEnv;
282 return yield* buildAvailableEditors(platform, env);
283});
238284
239/**285/**
240 * ExternalLauncherShape - Service API for browser and editor launch actions.286 * ExternalLauncherShape - Service API for browser and editor launch actions.
241 */287 */
242export interface ExternalLauncherShape {288export 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// Implementations311// Implementations
265// ==============================312// ==============================
266313
267export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (314const 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* (
281328
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});
314361
315export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (362const 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});
320368
321export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (369const 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 }
329382
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
346399
347const make = Effect.gen(function* () {400const 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 );
349412
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";
8import * as Stream from "effect/Stream";8import * as Stream from "effect/Stream";
9import { TestClock } from "effect/testing";9import { TestClock } from "effect/testing";
10import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcessSpawner } from "effect/unstable/process";
11import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { SpawnExecutableResolution } from "@t3tools/shared/shell";
1113
12import {14import {
13 isWindowsCommandNotFound,15 isWindowsCommandNotFound,
...@@ -21,6 +23,9 @@ import {...@@ -21,6 +23,9 @@ import {
21type ChildProcessCommand = {23type 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};
2530
26// Accesses private properties of ChildProcessCommand for testing purposes31// 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 });
125129
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});
281323
282describe("isWindowsCommandNotFound", () => {324describe("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(
286328 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";
8import * as Scope from "effect/Scope";8import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { resolveSpawnCommand } from "@t3tools/shared/shell";
11import {13import {
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}
111112
112export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {113export 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);
117121
118const collectText = Effect.fn("processRunner.collectText")(function* (input: {122const 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 );
234244
235 const child = yield* spawner245 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 !== undefined249 ...(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;
9191
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* processRunner94 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
1616
17export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* (17export 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";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";
21import { compareSemverVersions } from "@t3tools/shared/semver";22import { compareSemverVersions } from "@t3tools/shared/semver";
22import {23import {
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 */
548const probeClaudeCapabilities = (549const 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 = (
603const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (604const 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.Path629 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 }
650655
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);
655661
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";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import * as Scope from "effect/Scope";8import * as Scope from "effect/Scope";
9import * as Types from "effect/Types";9import * as Types from "effect/Types";
10import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import * as CodexClient from "effect-codex-app-server/client";11import * as CodexClient from "effect-codex-app-server/client";
12import * as CodexSchema from "effect-codex-app-server/schema";12import * as CodexSchema from "effect-codex-app-server/schema";
13import * as CodexErrors from "effect-codex-app-server/errors";13import * as CodexErrors from "effect-codex-app-server/errors";
...@@ -24,6 +24,7 @@ import type {...@@ -24,6 +24,7 @@ import type {
24import { ServerSettingsError } from "@t3tools/contracts";24import { ServerSettingsError } from "@t3tools/contracts";
2525
26import { createModelCapabilities } from "@t3tools/shared/model";26import { createModelCapabilities } from "@t3tools/shared/model";
27import { resolveSpawnCommand } from "@t3tools/shared/shell";
27import {28import {
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";
33import packageJson from "../../../package.json" with { type: "json" };34import packageJson from "../../../package.json" with { type: "json" };
34const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);35const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
3536
37const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
38
36const CODEX_PRESENTATION = {39const 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.Scope471 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.ChildProcessSpawner477 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);
460482
...@@ -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";
19import { resolveSpawnCommand } from "@t3tools/shared/shell";
19import { normalizeModelSlug } from "@t3tools/shared/model";20import { normalizeModelSlug } from "@t3tools/shared/model";
20import * as Crypto from "effect/Crypto";21import * as Crypto from "effect/Crypto";
21import * as DateTime from "effect/DateTime";22import * 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* spawner733 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";
30import { resolveSpawnCommand } from "@t3tools/shared/shell";
3031
31import {32import {
32 buildBooleanOptionDescriptor,33 buildBooleanOptionDescriptor,
...@@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse(...@@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse(
394395
395const makeCursorAcpProbeRuntime = (396const 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 = (
421const withCursorAcpProbeRuntime = <A, E, R>(422const 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(
542543
543const discoverCursorModelsViaListAvailableModels = (544const 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 = (
558559
559export const discoverCursorModelsViaAcp = (560export 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);
563564
564export function getCursorFallbackModels(565export function getCursorFallbackModels(
...@@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult...@@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult
927const runCursorCommand = (928const 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 });
938944
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);
951957
952const runCursorAboutCommand = (958const 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 = (
967970
968export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (971export 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";
15import { HttpClient } from "effect/unstable/http";15import { HttpClient } from "effect/unstable/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17import { createModelCapabilities } from "@t3tools/shared/model";17import { createModelCapabilities } from "@t3tools/shared/model";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
1819
19import {20import {
20 buildServerProvider,21 buildServerProvider,
...@@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = (...@@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = (
149const runGrokVersionCommand = (150const 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 });
162167
163export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (168export 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 = (
301export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (301export 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
10391039
1040 // This test intentionally avoids `mockCommandSpawnerLayer` so the real1040 // This test intentionally avoids `mockCommandSpawnerLayer` so the real
1041 // `probeCodexAppServerProvider` path runs — including the full1041 // `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 so1043 // We point `binaryPath` at a name that cannot exist on any machine so
1044 // the real `ChildProcessSpawner` deterministically returns ENOENT; the1044 // the real `ChildProcessSpawner` deterministically returns ENOENT; the
1045 // probe wraps that as `CodexAppServerSpawnError` and1045 // 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 the1190 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 with1203 // Boot-time probe: the default codex instance is enabled with
1201 // `firstMissing`, so the real spawner yields ENOENT and the1204 // `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");
12301224
1231 // Drive a settings change. The Hydration layer's1225 // 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 });
12441238
1245 // Poll with TestClock until `checkedAt` advances or we hit a1239 // 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 !== initialCheckedAt1249 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 });
12631258
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";
13import * as EffectAcpErrors from "effect-acp/errors";13import * as EffectAcpErrors from "effect-acp/errors";
14import type * as EffectAcpSchema from "effect-acp/schema";14import type * as EffectAcpSchema from "effect-acp/schema";
15import type * as EffectAcpProtocol from "effect-acp/protocol";15import type * as EffectAcpProtocol from "effect-acp/protocol";
16import { resolveSpawnCommand } from "@t3tools/shared/shell";
1617
17import {18import {
18 collectSessionConfigOptionValues,19 collectSessionConfigOptionValues,
...@@ -201,12 +202,17 @@ const makeAcpSessionRuntime = (...@@ -201,12 +202,17 @@ const makeAcpSessionRuntime = (
201 ),202 ),
202 );203 );
203204
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* spawner210 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";
31import { isWindowsCommandNotFound } from "../processRunner.ts";31import { isWindowsCommandNotFound } from "../processRunner.ts";
32import { collectStreamAsString } from "./providerSnapshot.ts";32import { collectStreamAsString } from "./providerSnapshot.ts";
33import * as NetService from "@t3tools/shared/Net";33import * as NetService from "@t3tools/shared/Net";
34import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
35import { resolveSpawnCommand } from "@t3tools/shared/shell";
34const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);36const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);
35const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";37const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
3638
...@@ -276,13 +278,17 @@ function ensureRuntimeError(...@@ -276,13 +278,17 @@ function ensureRuntimeError(
276const makeOpenCodeRuntime = Effect.gen(function* () {278const 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 } : {});
279284
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);
337344
338 const child = yield* spawner345 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 );
360368
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:off1// @effect-diagnostics nodeBuiltinImport:off
2import { afterEach, expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";3import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import os from "node:os";5import * as NodeOS from "node:os";
6import path from "node:path";6import path from "node:path";
7import { ProviderDriverKind } from "@t3tools/contracts";7import { ProviderDriverKind } from "@t3tools/contracts";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as Crypto from "effect/Crypto";9import * as Crypto from "effect/Crypto";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import { HttpClient } from "effect/unstable/http";
10import {12import {
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";
1922
...@@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value);...@@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value);
21const makeTempDir = (name: string) =>24const 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 );
26const isNativeTestCommandPath =29const 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);
6770
68afterEach(() => {
69 clearLatestProviderVersionCacheForTests();
70});
71
72it.layer(NodeServices.layer)("providerMaintenance", (it) => {71it.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);
146171
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");
179206
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);
215244
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);
274304
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);
309341
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) => {
401433
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) => {
449480
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";
6import { compareSemverVersions } from "@t3tools/shared/semver";6import { compareSemverVersions } from "@t3tools/shared/semver";
7import { resolveCommandPath } from "@t3tools/shared/shell";7import { resolveCommandPath } from "@t3tools/shared/shell";
8import * as Config from "effect/Config";
9import * as Context from "effect/Context";
8import * as DateTime from "effect/DateTime";10import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";12import * 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;
16const LATEST_VERSION_TIMEOUT_MS = 4_000;18const LATEST_VERSION_TIMEOUT_MS = 4_000;
17const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";19const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
1820
21const 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
31const 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
38const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
39
19export interface ProviderMaintenanceCapabilities {40export 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 {
32export interface ProviderMaintenanceCapabilityResolutionOptions {53export 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}
3859
...@@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition {...@@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition {
54 } | null;75 } | null;
55}76}
5677
57interface LatestVersionCacheEntry {78export interface ProviderVersionCacheEntry {
58 readonly expiresAt: number;79 readonly expiresAt: number;
59 readonly version: string | null;80 readonly version: string | null;
60}81}
6182
62const latestVersionCache = new Map<string, LatestVersionCacheEntry>();83export const ProviderVersionCache = Context.Reference<Map<string, ProviderVersionCacheEntry>>(
84 "@t3tools/server/providerMaintenance/ProviderVersionCache",
85 {
86 defaultValue: () => new Map(),
87 },
88);
63const NpmLatestVersionResponse = Schema.Struct({89const NpmLatestVersionResponse = Schema.Struct({
64 version: Schema.optional(Schema.String),90 version: Schema.optional(Schema.String),
65});91});
6692
67export function clearLatestProviderVersionCacheForTests(): void {
68 latestVersionCache.clear();
69}
70
71function nonEmptyString(value: unknown): string | null {93function 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 }
252274
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);
258277
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 }
337356
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 }
432453
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 @@
1import { afterEach, describe, it, assert } from "@effect/vitest";1import { describe, it, assert } from "@effect/vitest";
2import {2import {
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";
21import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";21import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
22import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";22import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
23import {23import {
24 clearLatestProviderVersionCacheForTests,
25 makeProviderMaintenanceCapabilities,24 makeProviderMaintenanceCapabilities,
25 ProviderVersionCache,
26 type ProviderMaintenanceCapabilities,26 type ProviderMaintenanceCapabilities,
27} from "./providerMaintenance.ts";27} from "./providerMaintenance.ts";
28const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);28const 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");
35const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");35const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
36const encoder = new TextEncoder();36const encoder = new TextEncoder();
3737
38afterEach(() => {
39 clearLatestProviderVersionCacheForTests();
40});
41
42function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities {38function 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 );
7676
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;
367367
368 fixPath();368 yield* fixPath();
369369
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 @@
1import { networkInterfaces } from "node:os";1import * as NodeOS from "node:os";
22
3import { QrCode } from "@t3tools/shared/qrCode";3import { QrCode } from "@t3tools/shared/qrCode";
4import * as Effect from "effect/Effect";4import * 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}
1515
16type NetworkInterfacesMap = ReturnType<typeof networkInterfaces>;16type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
1717
18export const isLoopbackHost = (host: string | undefined): boolean => {18export 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" ||
4444
45export const resolveHeadlessConnectionHost = (45export 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 = (
71export const resolveHeadlessConnectionString = (71export 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 @@
1import * as NodeOS from "node:os";
1import * as Crypto from "effect/Crypto";2import * as Crypto from "effect/Crypto";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Encoding from "effect/Encoding";4import * as Encoding from "effect/Encoding";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";6import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
7import { homedir } from "node:os";8
8import { ServerConfig } from "../config.ts";9import { ServerConfig } from "../config.ts";
910
10const CodexAuthJsonSchema = Schema.Struct({11const 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;
4142
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;
5455
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 AnalyticsServiceLive7 * @module AnalyticsServiceLive
8 */8 */
99
10import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import * as Config from "effect/Config";11import * as Config from "effect/Config";
11import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
14import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
15import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";17import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1618
...@@ -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});
4144
42const makeAnalyticsService = Effect.gen(function* () {45const 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;
4954
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 @@
22
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import { PtyAdapter } from "../Services/PTY.ts";6import { PtyAdapter } from "../Services/PTY.ts";
6import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts";7import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts";
78
...@@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess {...@@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess {
95export const layer = Layer.effect(96export 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";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import * as Data from "effect/Data";12import * as Data from "effect/Data";
12import * as Duration from "effect/Duration";13import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
...@@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = (...@@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = (
199200
200interface CreateManagerOptions {201interface 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 !== undefined244 ...(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 );
272271
272const withHostPlatform = (platform: NodeJS.Platform) =>
273 Layer.succeed(HostProcessPlatform, platform);
274
273it.layer(275it.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 );
417417
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;
421423
422 const { manager, baseDir } = yield* createManager();424 const { manager, baseDir } = yield* createManager();
...@@ -1082,10 +1084,9 @@ it.layer(...@@ -1082,10 +1084,9 @@ it.layer(
10821084
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 );
11011102
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")));
11311131
1132 yield* manager.open(openInput());1132 yield* manager.open(openInput());
11331133
...@@ -1142,15 +1142,16 @@ it.layer(...@@ -1142,15 +1142,16 @@ it.layer(
11421142
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(
11701171
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");
11981186
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 );
12151195
...@@ -1237,7 +1217,7 @@ it.layer(...@@ -1237,7 +1217,7 @@ it.layer(
12371217
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";
12import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";12import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
13import { getTerminalLabel } from "@t3tools/shared/terminalLabels";14import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
14import * as DateTime from "effect/DateTime";15import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";16import * 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}
314315
315function defaultShellResolver(316function 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(
324322
325function normalizeShellCommand(323function 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 {
360358
361function shellCandidateFromCommand(359function 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
411409
412function resolveShellCandidates(410function 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
10141014
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 @@
1import * as FileSystem from "effect/FileSystem";
2import * as Path from "effect/Path";
3import * as Effect from "effect/Effect";
4import { assert, it } from "@effect/vitest";
5
6import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts";
7import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
82import { assert, it } from "@effect/vitest";
9it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => {3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10 it.effect("adds executable bits when helper exists but is not executable", () =>4import * as Effect from "effect/Effect";
11 Effect.gen(function* () {5import * as Layer from "effect/Layer";
12 if (process.platform === "win32") return;6import { vi } from "vite-plus/test";
137
14 const fs = yield* FileSystem.FileSystem;8import { PtyAdapter } from "../Services/PTY.ts";
15 const path = yield* Path.Path;9import { layer } from "./NodePTY.ts";
1610
17 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" });11const 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(),
2115 kill: vi.fn(),
22 yield* ensureNodePtySpawnHelperExecutable(helperPath);16 onData: vi.fn(() => ({ dispose: vi.fn() })),
2317 onExit: vi.fn(() => ({ dispose: vi.fn() })),
24 const mode = (yield* fs.stat(helperPath)).mode & 0o777;18}));
25 assert.equal(mode & 0o111, 0o111);19
26 }),20vi.mock("node-pty", () => ({ spawn }));
27 );21
2822const 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,
3226 Layer.succeed(HostProcessPlatform, "win32"),
33 const fs = yield* FileSystem.FileSystem;27 Layer.succeed(HostProcessArchitecture, "x64"),
34 const path = yield* Path.Path;28 ),
3529 ),
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");32it.effect("spawns through the public adapter with the provided host references", () =>
39 yield* fs.chmod(helperPath, 0o755);33 Effect.gen(function* () {
4034 const adapter = yield* PtyAdapter;
41 yield* ensureNodePtySpawnHelperExecutable(helperPath);35 const process = yield* adapter.spawn({
4236 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";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import { PtyAdapter } from "../Services/PTY.ts";8import { PtyAdapter } from "../Services/PTY.ts";
8import {9import {
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;
2124
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 ];
2932
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));
3740
38export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) {41const 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;
4246
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 }
4850
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;
105109
106 const nodePty = yield* Effect.promise(() => import("node-pty"));110 const nodePty = yield* Effect.promise(() => import("node-pty"));
107111
...@@ -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";
1515
16import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";16import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";
17import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";17import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
1819
19import { TextGenerationError } from "@t3tools/contracts";20import { TextGenerationError } from "@t3tools/contracts";
20import { type TextGenerationShape } from "./TextGeneration.ts";21import { 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
5960
60export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (61export 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;
157158
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 });
182184
183 const child = yield* commandSpawner185 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";
99
10import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";10import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";
11import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";11import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
12import { resolveSpawnCommand } from "@t3tools/shared/shell";
1213
13import { resolveAttachmentPath } from "../attachmentStore.ts";14import { resolveAttachmentPath } from "../attachmentStore.ts";
14import { ServerConfig } from "../config.ts";15import { 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 */
45export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (46export 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;
5355
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 });
215218
216 const child = yield* commandSpawner219 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 */
60export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (60export 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;
6566
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 {
9999
100export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (100export 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 openCodeRuntime209 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";
10import * as PlatformError from "effect/PlatformError";10import * as PlatformError from "effect/PlatformError";
1111
12import { ServerConfig } from "../../config.ts";12import { ServerConfig } from "../../config.ts";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
13import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";14import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
14import * as VcsProcess from "../../vcs/VcsProcess.ts";15import * as VcsProcess from "../../vcs/VcsProcess.ts";
15import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";16import { 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 });
7677
77const appendSeparator = (input: string) =>78const appendSeparator = (input: string) =>
78 input.endsWith("/") || input.endsWith("\\")79 Effect.map(HostProcessPlatform, (platform) =>
79 ? input80 input.endsWith("/") || input.endsWith("\\")
80 : `${input}${process.platform === "win32" ? "\\" : "/"}`;81 ? input
82 : `${input}${platform === "win32" ? "\\" : "/"}`,
83 );
8184
82it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {85it.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);
347351
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 });
354358
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);
403407
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:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as OS from "node:os";2import * as NodeOS from "node:os";
3import fsPromises from "node:fs/promises";3import fsPromises from "node:fs/promises";
4import type { Dirent } from "node:fs";4import type { Dirent } from "node:fs";
55
...@@ -12,6 +12,7 @@ import * as Layer from "effect/Layer";...@@ -12,6 +12,7 @@ import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";12import * as Path from "effect/Path";
1313
14import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";14import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
15import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";16import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
16import {17import {
17 insertRankedSearchResult,18 insertRankedSearchResult,
...@@ -65,10 +66,10 @@ function toPosixPath(input: string): string {...@@ -65,10 +66,10 @@ function toPosixPath(input: string): string {
6566
66function expandHomePath(input: string, path: Path.Path): string {67function 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 @@
1import * as OS from "node:os";1import * as NodeOS from "node:os";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string {...@@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string {
1919
20function expandHomePath(input: string, path: Path.Path): string {20function 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 @@
1import { definePlugin } from "@oxlint/plugins";1import { definePlugin } from "@oxlint/plugins";
22
3import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
3import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";4import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
4import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";5import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
56
...@@ -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 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-global-process-runtime");
6
7describe("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 @@
1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5
6const RUNTIME_PROPERTIES = new Set(["platform", "arch"]);
7const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts";
8const NODE_OS_MODULES = new Set(["node:os", "os"]);
9
10const normalizePath = (path: string) => path.replaceAll("\\", "/");
11
12const 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
21const isHostProcessReferenceFile = (filename: string, cwd: string) =>
22 toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE;
23
24const 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
36const 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
39const 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
46export 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) =>
17const mockPeerArgs = (path: string) => [path];17const mockPeerArgs = (path: string) => [path];
1818
19it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {19it.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 );
125126 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);
169134
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";
5import * as Scope from "effect/Scope";5import * as Scope from "effect/Scope";
6import * as Stdio from "effect/Stdio";6import * as Stdio from "effect/Stdio";
7import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
99
10import * as CodexRpc from "./_generated/meta.gen.ts";10import * as CodexRpc from "./_generated/meta.gen.ts";
11import * as CodexError from "./errors.ts";11import * 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";
19import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";19import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";
2020
21const DEFAULT_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const;
22
23export interface CodexAppServerClientOptions {21export 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
267export 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
274export 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 @@
1import * as Console from "effect/Console";1import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
34
4import * as NodeRuntime from "@effect/platform-node/NodeRuntime";5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
5import * as NodeServices from "@effect/platform-node/NodeServices";6import * 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";
7import * as CodexClient from "../../src/client.ts";8import * as CodexClient from "../../src/client.ts";
89
9const program = Effect.gen(function* () {10const 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 @@
1import * as NodeOS from "node:os";
2
1let nextServerRequestId = 10_000;3let nextServerRequestId = 10_000;
2let pendingSkillsListRequestId: number | string | null = null;4let pendingSkillsListRequestId: number | string | null = null;
3let pendingUserInputRequestId: number | null = null;5let pendingUserInputRequestId: number | null = null;
...@@ -34,14 +36,16 @@ const handleMethod = (message: Record<string, unknown>) => {...@@ -34,14 +36,16 @@ const handleMethod = (message: Record<string, unknown>) => {
3436
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 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as NodeOS from "node:os";
4
5export const HostProcessPlatform = Context.Reference<NodeJS.Platform>(
6 "@t3tools/shared/hostProcess/HostProcessPlatform",
7 {
8 defaultValue: () => process.platform,
9 },
10);
11
12export const HostProcessArchitecture = Context.Reference<NodeJS.Architecture>(
13 "@t3tools/shared/hostProcess/HostProcessArchitecture",
14 {
15 defaultValue: () => process.arch,
16 },
17);
18
19export const HostProcessHostname = Context.Reference<string>(
20 "@t3tools/shared/hostProcess/HostProcessHostname",
21 {
22 defaultValue: () => NodeOS.hostname(),
23 },
24);
25
26export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>(
27 "@t3tools/shared/hostProcess/HostProcessEnvironment",
28 {
29 defaultValue: () => process.env,
30 },
31);
32
33export 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";
10import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
11import { HttpClient, HttpClientResponse } from "effect/unstable/http";11import { HttpClient, HttpClientResponse } from "effect/unstable/http";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";
1314
14import {15import {
15 RelayClientInstallError,16 RelayClientInstallError,
16 CLOUDFLARED_VERSION,17 CLOUDFLARED_VERSION,
17 makeCloudflaredRelayClient,18 makeCloudflaredRelayClient,
18 resolveManagedCloudflaredPath,
19} from "./relayClient.ts";19} from "./relayClient.ts";
2020
21const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} });21const 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 );
2227
23function makeHandle(exitCode = 0) {28function 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 });
8278
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 });
119122
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 });
181178
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 });
216211
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 });
229229
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 });
245242
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;
255252
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";
6import * as Config from "effect/Config";6import * as Config from "effect/Config";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as Context from "effect/Context";7import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";8import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";9import * 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";
18import * as Semaphore from "effect/Semaphore";17import * as Semaphore from "effect/Semaphore";
19import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";18import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
20import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";
2121
22export const CLOUDFLARED_VERSION = "2026.5.2";22export const CLOUDFLARED_VERSION = "2026.5.2";
23export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";23export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
...@@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({...@@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({
120120
121export interface CloudflaredRelayClientOptions {121export 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}
128125
129export interface RelayClientShape {126export 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}
144141
145export 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
161function resolveReleaseAsset(142function 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
1import { describe, expect, it, vi } from "vite-plus/test";5import { describe, expect, it, vi } from "vite-plus/test";
26
3import {7import {
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";
1727
28const 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
18describe("extractPathFromShellOutput", () => {38describe("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});
324344
325describe("isCommandAvailable", () => {345effectIt.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});
335356
336describe("resolveCommandPath", () => {357effectIt.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});
346368
347describe("resolveWindowsEnvironment", () => {369effectIt.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?.loadProfile373 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});
356450
357 expect(451effectIt.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", () =>
391495 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?.loadProfile499 ? {
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
404508 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:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeOS from "node:os";2import * as NodeOS from "node:os";
3import * as NodePath from "node:path";
3import { execFileSync } from "node:child_process";4import { execFileSync } from "node:child_process";
4import { accessSync, constants, statSync } from "node:fs";5import { accessSync, constants as fileSystemConstants, statSync } from "node:fs";
5import { extname, join } from "node:path";6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10
11import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";
12import * as Context from "effect/Context";
613
7const PATH_CAPTURE_START = "__T3CODE_PATH_START__";14const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
8const PATH_CAPTURE_END = "__T3CODE_PATH_END__";15const 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;
1926
27function canExecuteFile(filePath: string): boolean {
28 try {
29 accessSync(filePath, fileSystemConstants.X_OK);
30 return true;
31 } catch {
32 return false;
33 }
34}
35
20export interface CommandAvailabilityOptions {36export interface CommandAvailabilityOptions {
21 readonly platform?: NodeJS.Platform;
22 readonly env?: NodeJS.ProcessEnv;37 readonly env?: NodeJS.ProcessEnv;
38 readonly extendEnv?: boolean;
39}
40
41export type CommandAvailabilityChecker = (
42 command: string,
43 options?: CommandAvailabilityOptions,
44) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;
45
46export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{
47 readonly command: string;
48 readonly reason: "not-found";
49}> {}
50
51const 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 */
60function 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 */
77function sanitizeShellModeArgsForPlatform(
78 args: ReadonlyArray<string>,
79 platform: NodeJS.Platform,
80): Array<string> {
81 return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args];
82}
83
84export interface ResolvedSpawnCommand {
85 readonly command: string;
86 readonly args: ReadonlyArray<string>;
87 readonly shell: boolean;
88}
89
90export type SpawnExecutableResolver = (
91 command: string,
92 platform: NodeJS.Platform,
93 env: NodeJS.ProcessEnv,
94) => string | undefined;
95
96function 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}
24135
136export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>(
137 "@t3tools/shared/shell/SpawnExecutableResolution",
138 {
139 defaultValue: () => resolveSpawnExecutableWithNode,
140 },
141);
142
25export interface WindowsEnvironmentProbeOptions {143export 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>>;
216334
335export const WindowsShellEnvironment = Context.Reference<WindowsShellEnvironmentReader>(
336 "@t3tools/shared/shell/WindowsShellEnvironment",
337 {
338 defaultValue: () => readEnvironmentFromWindowsShell,
339 },
340);
341
342export const CommandAvailability = Context.Reference<CommandAvailabilityChecker>(
343 "@t3tools/shared/shell/CommandAvailability",
344 {
345 defaultValue: () => isCommandAvailable,
346 },
347);
348
217export function readEnvironmentFromWindowsShell(349export 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}
360493
361function isExecutableFile(494const 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}
380509
381export function resolveCommandPath(510 return canExecuteFile(filePath);
511});
512
513const 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 );
389527
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 }
398536
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(
408548
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});
419559
420export function isCommandAvailable(560export 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
570export 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
601export 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});
426610
427export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {611export 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}
439623
440export interface WindowsEnvironmentResolverOptions {
441 readonly readEnvironment?: WindowsShellEnvironmentReader;
442 readonly commandAvailable?: typeof isCommandAvailable;
443}
444
445function readWindowsEnvironmentSafely(624function 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}
469648
470export function resolveWindowsEnvironment(649export 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);
485663
486 if (commandAvailable("node", { platform: "win32", env: baselineEnv })) {664 if (yield* commandAvailable("node", { env: baselineEnv })) {
487 return baselinePatch;665 return baselinePatch;
488 }666 }
489667
...@@ -503,4 +681,4 @@ export function resolveWindowsEnvironment(...@@ -503,4 +681,4 @@ export function resolveWindowsEnvironment(
503 return Object.keys(profiledPatch).length > 0681 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";
33
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
79
8import {10import {
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 });
4344
...@@ -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 );
5357
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 );
6067
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 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Config from "effect/Config";
1import * as Context from "effect/Context";3import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
5import * as Path from "effect/Path";8import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";9import * as PlatformError from "effect/PlatformError";
710
...@@ -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}
6062
61const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass";63const 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;
118119
...@@ -148,12 +149,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn(...@@ -148,12 +149,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn(
148export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(149export 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;
157157
158 yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true });158 yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true });
159159
...@@ -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.Path180 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 }
186186
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 });
190197
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});
199206
packages/ssh/src/command.ts+16-4
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Crypto from "node:crypto";1import * as Crypto from "node:crypto";
22
3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";7import * 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";
16const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;17const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
17const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;18const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
18const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;19const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
19export 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 */
26const sshCommandForPlatform = (platform: NodeJS.Platform): string =>
27 platform === "win32" ? "ssh.exe" : "ssh";
28
29export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform);
2030
21const encoder = new TextEncoder();31const encoder = new TextEncoder();
2232
...@@ -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* spawner211 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 @@
1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
22
3import * as Config from "effect/Config";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
5import * as Path from "effect/Path";7import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";8import * as PlatformError from "effect/PlatformError";
79
...@@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {...@@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {
208export const discoverSshHosts = Effect.fnUntraced(210export 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";
42import {42import {
...@@ -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* spawner1084 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 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1import * as Data from "effect/Data";2import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";4import * 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;
10export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;11export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
11export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;12export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
12export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;13export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;
13const 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.
17const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string =>
18 platform === "win32" ? "tailscale.exe" : "tailscale";
1419
15export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{20export 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* spawner145 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* spawner221 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.78811 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.78817 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";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
67
7import {8import {
...@@ -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";
17import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";19import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
20import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1821
19it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {22it.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 });
4346
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 );
124168
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 node1#!/usr/bin/env node
22
3import { fromYaml } from "@t3tools/shared/schemaYaml";3import { fromYaml } from "@t3tools/shared/schemaYaml";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import { resolveSpawnCommand } from "@t3tools/shared/shell";
4import rootPackageJson from "../package.json" with { type: "json" };6import rootPackageJson from "../package.json" with { type: "json" };
5import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };7import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };
6import serverPackageJson from "../apps/server/package.json" with { type: "json" };8import 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}
103105
104function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type {106const 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 }
109111
110 return getDefaultBuildArch(platform, process.arch, process.env, config);112 return yield* getDefaultBuildArch(platform, config);
111}113});
112114
113class BuildScriptError extends Data.TaggedError("BuildScriptError")<{115class 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
198const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () {200const 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 }
205215
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;
351362
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 );
357368
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 }
363374
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}
624636
625function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):637export 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;
639650
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});
651662
652export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" {663export 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* (
846858
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 }
934946
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 );
944956
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 }
961976
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";
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NetService from "@t3tools/shared/Net";7import * as NetService from "@t3tools/shared/Net";
8import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
9import { resolveSpawnCommand } from "@t3tools/shared/shell";
8import * as Config from "effect/Config";10import * as Config from "effect/Config";
9import * as Data from "effect/Data";11import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";12import * 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 });
420422
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 }
447450
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 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
22import * as ConfigProvider from "effect/ConfigProvider";
3import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
6
7import { getDefaultBuildArch } from "./build-target-arch.ts";
8
9const 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
14const 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 );
426
5describe("build-target-arch", () => {27describe("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 still29 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", {
1335 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 }),
1638 );
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 );
2143
22 assert.equal(hostArch, "x64");44 it.effect("does not apply Windows host env heuristics for non-Windows targets", () =>
23 });45 Effect.gen(function* () {
2446 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",
2749 PROCESSOR_ARCHITEW6432: "ARM64",
28 assert.equal(hostArch, "arm64");50 }),
29 });51 );
3052
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 current54 }),
33 // process is x64, but the machine itself is ARM64, so the default build55 );
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 @@
1import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Config from "effect/Config";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5
1export type BuildArch = "arm64" | "x64" | "universal";6export type BuildArch = "arm64" | "x64" | "universal";
2export type BuildPlatform = "mac" | "linux" | "win";7export type BuildPlatform = "mac" | "linux" | "win";
38
...@@ -5,6 +10,11 @@ interface PlatformConfig {...@@ -5,6 +10,11 @@ interface PlatformConfig {
5 readonly archChoices: ReadonlyArray<BuildArch>;10 readonly archChoices: ReadonlyArray<BuildArch>;
6}11}
712
13const WindowsProcessorArchitectureConfig = Config.all({
14 processorArchitecture: Config.string("PROCESSOR_ARCHITECTURE").pipe(Config.option),
15 processorArchitectureW6432: Config.string("PROCESSOR_ARCHITEW6432").pipe(Config.option),
16});
17
8function normalizeWindowsArch(value: string | undefined): BuildArch | undefined {18function 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}
1525
16export function resolveHostProcessArch(26const 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,29const 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";
2435
25 // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host36 // 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});
3547
36export function getDefaultBuildArch(48export 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 }
4856
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 @@
22
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
5import * as Console from "effect/Console";6import * as Console from "effect/Console";
6import * as Data from "effect/Data";7import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -58,23 +59,7 @@ const commandOutputOptions = {...@@ -58,23 +59,7 @@ const commandOutputOptions = {
58} as const;59} as const;
5960
60const commandExists = Effect.fn("commandExists")(function* (command: string) {61const 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});
7964
80const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>65const 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 @@
1import "vite-plus/test/config";1import "vite-plus/test/config";
2import { defineConfig } from "vite-plus";2import { defineConfig } from "vite-plus";
3import { fileURLToPath } from "node:url";
43
5export default defineConfig({4export 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 },