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 @@
11import { spawn, spawnSync } from "node:child_process";
22import { watch } from "node:fs";
3import * as NodeOS from "node:os";
34import { join } from "node:path";
45
56import {
......@@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500;
3334const restartDebounceMs = 120;
3435const childTreeGracePeriodMs = 1_200;
3536const 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
3740await waitForResources({
3841 baseDir: desktopDir,
......@@ -57,7 +60,7 @@ const expectedExits = new WeakSet();
5760const watchers = [];
5861
5962function killChildTreeByPid(pid, signal) {
60 if (process.platform === "win32" || typeof pid !== "number") {
63 if (hostPlatform === "win32" || typeof pid !== "number") {
6164 return;
6265 }
6366
......@@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) {
6568}
6669
6770function cleanupStaleDevApps() {
68 if (process.platform === "win32") {
71 if (hostPlatform === "win32") {
6972 return;
7073 }
7174
......@@ -194,7 +197,7 @@ function startWatchers() {
194197}
195198
196199function killChildTree(signal) {
197 if (process.platform === "win32") {
200 if (hostPlatform === "win32") {
198201 return;
199202 }
200203
apps/desktop/scripts/electron-launcher.mjs+6-3
......@@ -14,6 +14,7 @@ import {
1414 writeFileSync,
1515} from "node:fs";
1616import { createRequire } from "node:module";
17import * as NodeOS from "node:os";
1718import { basename, dirname, join, resolve } from "node:path";
1819import { fileURLToPath } from "node:url";
1920import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
......@@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
3334const LAUNCHER_VERSION = 11;
3435const defaultIconPath = join(desktopDir, "resources", "icon.icns");
3536const 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
3740function resolveDevelopmentProtocolCallbackPort() {
3841 const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10);
......@@ -312,7 +315,7 @@ function buildMacLauncher(electronBinaryPath) {
312315}
313316
314317function isLinuxSetuidSandboxConfigured(electronBinaryPath) {
315 if (process.platform !== "linux") {
318 if (hostPlatform !== "linux") {
316319 return true;
317320 }
318321
......@@ -342,7 +345,7 @@ export function resolveElectronPath() {
342345 const require = createRequire(import.meta.url);
343346 const electronBinaryPath = require("electron");
344347
345 if (process.platform !== "darwin") {
348 if (hostPlatform !== "darwin") {
346349 return electronBinaryPath;
347350 }
348351
......@@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) {
358361}
359362
360363export function resolveDevProtocolClient() {
361 if (process.platform !== "darwin" || !isDevelopment) {
364 if (hostPlatform !== "darwin" || !isDevelopment) {
362365 return null;
363366 }
364367
apps/desktop/scripts/ensure-electron-runtime.mjs+14-10
......@@ -1,13 +1,17 @@
11import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22import { createRequire } from "node:module";
3import { tmpdir } from "node:os";
3import { arch, platform, tmpdir } from "node:os";
44import { dirname, join } from "node:path";
55import { spawnSync } from "node:child_process";
66
77const 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
913function getPlatformPath() {
10 switch (process.platform) {
14 switch (hostPlatform) {
1115 case "darwin":
1216 return "Electron.app/Contents/MacOS/Electron";
1317 case "freebsd":
......@@ -17,12 +21,12 @@ function getPlatformPath() {
1721 case "win32":
1822 return "electron.exe";
1923 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}`);
2125 }
2226}
2327
2428function ensureExecutable(filePath) {
25 if (process.platform !== "win32") {
29 if (hostPlatform !== "win32") {
2630 chmodSync(filePath, 0o755);
2731 }
2832}
......@@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) {
3943function getRequiredRuntimePaths(electronDir, platformPath) {
4044 const paths = [join(electronDir, "dist", platformPath)];
4145
42 if (process.platform === "darwin") {
46 if (hostPlatform === "darwin") {
4347 paths.push(
4448 join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"),
4549 join(
......@@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) {
5862}
5963
6064function isMachO(filePath) {
61 if (process.platform !== "darwin") {
65 if (hostPlatform !== "darwin") {
6266 return true;
6367 }
6468
......@@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) {
7680}
7781
7882function invalidRuntimePaths(electronDir, platformPath) {
79 if (process.platform !== "darwin") {
83 if (hostPlatform !== "darwin") {
8084 return [];
8185 }
8286
......@@ -111,16 +115,16 @@ function runChecked(command, args) {
111115
112116function installElectronRuntime(electronDir, version) {
113117 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
116120 try {
117121 runChecked("curl", [
118122 "-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`,
120124 "-o",
121125 zipPath,
122126 ]);
123 if (process.platform === "darwin") {
127 if (hostPlatform === "darwin") {
124128 runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]);
125129 } else {
126130 runChecked("python3", [
apps/desktop/src/app/DesktopAssets.ts+1-1
......@@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
4949> {
5050 const fileSystem = yield* FileSystem.FileSystem;
5151 const environment = yield* DesktopEnvironment.DesktopEnvironment;
52 if (environment.isDevelopment && process.platform === "darwin" && ext === "png") {
52 if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") {
5353 const developmentDockIconPath = environment.developmentDockIconPath;
5454 const developmentDockIconExists = yield* fileSystem
5555 .exists(developmentDockIconPath)
apps/desktop/src/electron/ElectronMenu.ts+99-94
......@@ -5,6 +5,7 @@ import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
66
77import * as Electron from "electron";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
89
910export interface ElectronMenuPosition {
1011 readonly x: number;
......@@ -79,109 +80,113 @@ const normalizePosition = (
7980 ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
8081 ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
8182
82export const layer = Layer.sync(ElectronMenu, () => {
83 let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
83export const layer = Layer.effect(
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> => {
86 if (process.platform !== "darwin") {
87 return Option.none();
88 }
89 if (destructiveMenuIconCache !== undefined) {
90 return destructiveMenuIconCache;
91 }
92
93 try {
94 const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
95 width: 12,
96 height: 12,
97 });
98 destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
99 } catch {
100 destructiveMenuIconCache = Option.none();
101 }
102
103 return destructiveMenuIconCache;
104 };
105
106 const buildTemplate = (
107 entries: readonly ContextMenuItem[],
108 complete: (selectedItemId: Option.Option<string>) => void,
109 ): Electron.MenuItemConstructorOptions[] => {
110 const template: Electron.MenuItemConstructorOptions[] = [];
111 let hasInsertedDestructiveSeparator = false;
112
113 for (const item of entries) {
114 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
115 template.push({ type: "separator" });
116 hasInsertedDestructiveSeparator = true;
89 const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
90 if (platform !== "darwin") {
91 return Option.none();
11792 }
118
119 const itemOption: Electron.MenuItemConstructorOptions = {
120 label: item.label,
121 enabled: !item.disabled,
122 };
123 if (item.children && item.children.length > 0) {
124 itemOption.submenu = buildTemplate(item.children, complete);
125 } else {
126 itemOption.click = () => complete(Option.some(item.id));
93 if (destructiveMenuIconCache !== undefined) {
94 return destructiveMenuIconCache;
12795 }
128 if (item.destructive && (!item.children || item.children.length === 0)) {
129 const destructiveIcon = getDestructiveMenuIcon();
130 if (Option.isSome(destructiveIcon)) {
131 itemOption.icon = destructiveIcon.value;
132 }
96
97 try {
98 const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
99 width: 12,
100 height: 12,
101 });
102 destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
103 } catch {
104 destructiveMenuIconCache = Option.none();
133105 }
134106
135 template.push(itemOption);
136 }
107 return destructiveMenuIconCache;
108 };
137109
138 return template;
139 };
140
141 return ElectronMenu.of({
142 setApplicationMenu: (template) =>
143 Effect.sync(() => {
144 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
145 }),
146 popupTemplate: (input) =>
147 Effect.sync(() => {
148 if (input.template.length === 0) {
149 return;
110 const buildTemplate = (
111 entries: readonly ContextMenuItem[],
112 complete: (selectedItemId: Option.Option<string>) => void,
113 ): Electron.MenuItemConstructorOptions[] => {
114 const template: Electron.MenuItemConstructorOptions[] = [];
115 let hasInsertedDestructiveSeparator = false;
116
117 for (const item of entries) {
118 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
119 template.push({ type: "separator" });
120 hasInsertedDestructiveSeparator = true;
150121 }
151 Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
152 }),
153 showContextMenu: (input) =>
154 Effect.callback<Option.Option<string>>((resume) => {
155 const normalizedItems = normalizeContextMenuItems(input.items);
156 if (normalizedItems.length === 0) {
157 resume(Effect.succeed(Option.none()));
158 return;
122
123 const itemOption: Electron.MenuItemConstructorOptions = {
124 label: item.label,
125 enabled: !item.disabled,
126 };
127 if (item.children && item.children.length > 0) {
128 itemOption.submenu = buildTemplate(item.children, complete);
129 } else {
130 itemOption.click = () => complete(Option.some(item.id));
159131 }
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;
162 const complete = (selectedItemId: Option.Option<string>) => {
163 if (completed) {
142 return template;
143 };
144
145 return ElectronMenu.of({
146 setApplicationMenu: (template) =>
147 Effect.sync(() => {
148 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
149 }),
150 popupTemplate: (input) =>
151 Effect.sync(() => {
152 if (input.template.length === 0) {
153 return;
154 }
155 Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
156 }),
157 showContextMenu: (input) =>
158 Effect.callback<Option.Option<string>>((resume) => {
159 const normalizedItems = normalizeContextMenuItems(input.items);
160 if (normalizedItems.length === 0) {
161 resume(Effect.succeed(Option.none()));
164162 return;
165163 }
166 completed = true;
167 resume(Effect.succeed(selectedItemId));
168 };
169164
170 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
171 const popupPosition = normalizePosition(input.position);
172 const popupOptions = Option.match(popupPosition, {
173 onNone: (): Electron.PopupOptions => ({
174 window: input.window,
175 callback: () => complete(Option.none()),
176 }),
177 onSome: (position): Electron.PopupOptions => ({
178 window: input.window,
179 x: position.x,
180 y: position.y,
181 callback: () => complete(Option.none()),
182 }),
183 });
184 menu.popup(popupOptions);
185 }),
186 });
187});
165 let completed = false;
166 const complete = (selectedItemId: Option.Option<string>) => {
167 if (completed) {
168 return;
169 }
170 completed = true;
171 resume(Effect.succeed(selectedItemId));
172 };
173
174 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
175 const popupPosition = normalizePosition(input.position);
176 const popupOptions = Option.match(popupPosition, {
177 onNone: (): Electron.PopupOptions => ({
178 window: input.window,
179 callback: () => complete(Option.none()),
180 }),
181 onSome: (position): Electron.PopupOptions => ({
182 window: input.window,
183 x: position.x,
184 y: position.y,
185 callback: () => complete(Option.none()),
186 }),
187 });
188 menu.popup(popupOptions);
189 }),
190 });
191 }),
192);
apps/desktop/src/electron/ElectronWindow.ts+3-1
......@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
66import * as Ref from "effect/Ref";
77
88import * as Electron from "electron";
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
910
1011export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{
1112 readonly cause: unknown;
......@@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWind
3738) {}
3839
3940const make = Effect.gen(function* () {
41 const platform = yield* HostProcessPlatform;
4042 const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
4143
4244 const liveMain = Ref.get(mainWindowRef).pipe(
......@@ -98,7 +100,7 @@ const make = Effect.gen(function* () {
98100 window.show();
99101 }
100102
101 if (process.platform === "darwin") {
103 if (platform === "darwin") {
102104 Electron.app.focus({ steal: true });
103105 }
104106
apps/desktop/src/main.ts+5-2
......@@ -9,6 +9,7 @@ import * as Option from "effect/Option";
99import * as Electron from "electron";
1010
1111import * as NetService from "@t3tools/shared/Net";
12import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1213import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
1314import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
1415import serverPackageJson from "../../server/package.json" with { type: "json" };
......@@ -53,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap(
5354 const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe(
5455 Effect.flatMap((app) => app.metadata),
5556 );
57 const platform = yield* HostProcessPlatform;
58 const processArch = yield* HostProcessArchitecture;
5659 return DesktopEnvironment.layer({
5760 dirname: __dirname,
5861 homeDirectory: NodeOS.homedir(),
59 platform: process.platform,
60 processArch: process.arch,
62 platform,
63 processArch,
6164 ...metadata,
6265 });
6366 }),
apps/desktop/src/window/DesktopWindow.ts+14-9
......@@ -82,9 +82,10 @@ function resolveDesktopDevServerUrl(
8282
8383function getIconOption(
8484 iconPaths: DesktopAssets.DesktopIconPaths,
85 platform: NodeJS.Platform,
8586): { icon: string } | Record<string, never> {
86 if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle
87 const ext = process.platform === "win32" ? "ico" : "png";
87 if (platform === "darwin") return {}; // macOS uses .icns from app bundle
88 const ext = platform === "win32" ? "ico" : "png";
8889 return Option.match(iconPaths[ext], {
8990 onNone: () => ({}),
9091 onSome: (icon) => ({ icon }),
......@@ -106,8 +107,11 @@ export function isSameOriginRendererNavigation(input: {
106107 }
107108}
108109
109function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions {
110 if (process.platform === "darwin") {
110function getWindowTitleBarOptions(
111 shouldUseDarkColors: boolean,
112 platform: NodeJS.Platform,
113): WindowTitleBarOptions {
114 if (platform === "darwin") {
111115 return {
112116 titleBarStyle: "hiddenInset",
113117 trafficLightPosition: { x: 16, y: 18 },
......@@ -127,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO
127131function syncWindowAppearance(
128132 window: Electron.BrowserWindow,
129133 shouldUseDarkColors: boolean,
134 platform: NodeJS.Platform,
130135): Effect.Effect<void> {
131136 return Effect.sync(() => {
132137 if (window.isDestroyed()) {
......@@ -134,7 +139,7 @@ function syncWindowAppearance(
134139 }
135140
136141 window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors));
137 const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors);
142 const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform);
138143 if (typeof titleBarOverlay === "object") {
139144 window.setTitleBarOverlay(titleBarOverlay);
140145 }
......@@ -179,7 +184,7 @@ const make = Effect.gen(function* () {
179184 ? yield* resolveDesktopDevServerUrl(environment)
180185 : backendHttpUrl.href;
181186 const iconPaths = yield* assets.iconPaths;
182 const iconOption = getIconOption(iconPaths);
187 const iconOption = getIconOption(iconPaths, environment.platform);
183188 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
184189 const window = yield* electronWindow.create({
185190 width: 1100,
......@@ -191,7 +196,7 @@ const make = Effect.gen(function* () {
191196 backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),
192197 ...iconOption,
193198 title: environment.displayName,
194 ...getWindowTitleBarOptions(shouldUseDarkColors),
199 ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform),
195200 webPreferences: {
196201 preload: environment.preloadPath,
197202 contextIsolation: true,
......@@ -318,7 +323,7 @@ const make = Effect.gen(function* () {
318323 });
319324
320325 const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
321 if (process.platform === "linux") {
326 if (environment.platform === "linux") {
322327 revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
323328 }
324329 bindFirstRevealTrigger(revealSubscribers, () => {
......@@ -408,7 +413,7 @@ const make = Effect.gen(function* () {
408413 syncAppearance: Effect.gen(function* () {
409414 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
410415 yield* electronWindow.syncAllAppearance((window) =>
411 syncWindowAppearance(window, shouldUseDarkColors),
416 syncWindowAppearance(window, shouldUseDarkColors, environment.platform),
412417 );
413418 }).pipe(Effect.withSpan("desktop.window.syncAppearance")),
414419 });
apps/server/scripts/cli.ts+5-3
......@@ -18,6 +18,7 @@ import {
1818import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
1919import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
2020import { fromYaml } from "@t3tools/shared/schemaYaml";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";
2122import serverPackageJson from "../package.json" with { type: "json" };
2223
2324interface PackageJson {
......@@ -175,6 +176,7 @@ const buildCmd = Command.make(
175176 cwd: serverDir,
176177 stdout: config.verbose ? "inherit" : "ignore",
177178 stderr: "inherit",
179 shell: false,
178180 }),
179181 );
180182
......@@ -290,15 +292,15 @@ const publishCmd = Command.make(
290292 () =>
291293 Effect.gen(function* () {
292294 const args = createVpPmPublishArgs(config);
295 const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]);
293296
294297 yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`);
295298 yield* runCommand(
296 ChildProcess.make("vp", ["pm", ...args], {
299 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
297300 cwd: repoRoot,
298301 stdout: config.verbose ? "inherit" : "ignore",
299302 stderr: "inherit",
300 // Windows needs shell mode to resolve .cmd shims.
301 shell: process.platform === "win32",
303 shell: spawnCommand.shell,
302304 }),
303305 );
304306 }),
apps/server/scripts/cursor-acp-model-mismatch-probe.ts+5-2
......@@ -3,6 +3,8 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
33import process from "node:process";
44import readline from "node:readline";
55import * as NodeTimers from "node:timers";
6import { resolveSpawnCommand } from "@t3tools/shared/shell";
7import * as Effect from "effect/Effect";
68
79type JsonPrimitive = null | boolean | number | string;
810type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
......@@ -128,9 +130,10 @@ class JsonRpcChild {
128130 closed = false;
129131
130132 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, {
132135 cwd,
133 shell: process.platform === "win32",
136 shell: spawnCommand.shell,
134137 stdio: ["pipe", "pipe", "pipe"],
135138 env: process.env,
136139 });
apps/server/src/bootstrap.test.ts+6-11
......@@ -3,7 +3,7 @@ import * as NFS from "node:fs";
33import * as path from "node:path";
44import { execFileSync, spawn } from "node:child_process";
55import * as NodeServices from "@effect/platform-node/NodeServices";
6import { assert, it } from "@effect/vitest";
6import { it } from "@effect/vitest";
77import * as FileSystem from "effect/FileSystem";
88import * as Schema from "effect/Schema";
99import * as Duration from "effect/Duration";
......@@ -11,8 +11,9 @@ import * as Effect from "effect/Effect";
1111import * as Fiber from "effect/Fiber";
1212import * as TestClock from "effect/testing/TestClock";
1313import { vi } from "vite-plus/test";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1415
15import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts";
16import { readBootstrapEnvelope } from "./bootstrap.ts";
1617import { assertNone, assertSome } from "@effect/vitest/utils";
1718
1819const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));
......@@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });
4142const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema));
4243
4344it.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
5245 it.effect("reads a bootstrap envelope from a provided fd", () =>
5346 Effect.gen(function* () {
5447 const fs = yield* FileSystem.FileSystem;
......@@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
8982
9083 openSyncInterceptor.failPath = `/proc/self/fd/${fd}`;
9184 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"));
9388 assertSome(payload, {
9489 mode: "desktop",
9590 });
apps/server/src/bootstrap.ts+32-31
......@@ -11,6 +11,7 @@ import * as Predicate from "effect/Predicate";
1111import * as Result from "effect/Result";
1212import * as Schema from "effect/Schema";
1313import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1415
1516class BootstrapError extends Data.TaggedError("BootstrapError")<{
1617 readonly message: string;
......@@ -110,36 +111,39 @@ const isFdReady = (fd: number) =>
110111 );
111112
112113const makeBootstrapInputStream = (fd: number) =>
113 Effect.try<Readable, BootstrapError>({
114 try: () => {
115 const fdPath = resolveFdPath(fd);
116 if (fdPath === undefined) {
117 return makeDirectBootstrapStream(fd);
118 }
114 Effect.gen(function* () {
115 const platform = yield* HostProcessPlatform;
116 return yield* Effect.try<Readable, BootstrapError>({
117 try: () => {
118 const fdPath = resolveFdPath(fd, platform);
119 if (fdPath === undefined) {
120 return makeDirectBootstrapStream(fd);
121 }
119122
120 let streamFd: number | undefined;
121 try {
122 streamFd = NFS.openSync(fdPath, "r");
123 return NFS.createReadStream("", {
124 fd: streamFd,
125 encoding: "utf8",
126 autoClose: true,
127 });
128 } catch (error) {
129 if (isBootstrapFdPathDuplicationError(error)) {
130 if (streamFd !== undefined) {
131 NFS.closeSync(streamFd);
123 let streamFd: number | undefined;
124 try {
125 streamFd = NFS.openSync(fdPath, "r");
126 return NFS.createReadStream("", {
127 fd: streamFd,
128 encoding: "utf8",
129 autoClose: true,
130 });
131 } catch (error) {
132 if (isBootstrapFdPathDuplicationError(error)) {
133 if (streamFd !== undefined) {
134 NFS.closeSync(streamFd);
135 }
136 return makeDirectBootstrapStream(fd);
132137 }
133 return makeDirectBootstrapStream(fd);
138 throw error;
134139 }
135 throw error;
136 }
137 },
138 catch: (error) =>
139 new BootstrapError({
140 message: "Failed to duplicate bootstrap fd.",
141 cause: error,
142 }),
140 },
141 catch: (error) =>
142 new BootstrapError({
143 message: "Failed to duplicate bootstrap fd.",
144 cause: error,
145 }),
146 });
143147 });
144148
145149const makeDirectBootstrapStream = (fd: number): Readable => {
......@@ -165,10 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose(
165169 (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM",
166170);
167171
168export function resolveFdPath(
169 fd: number,
170 platform: NodeJS.Platform = process.platform,
171): string | undefined {
172function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined {
172173 if (platform === "linux") {
173174 return `/proc/self/fd/${fd}`;
174175 }
apps/server/src/cli/config.test.ts+1-1
......@@ -1,4 +1,4 @@
1import NodeOS from "node:os";
1import * as NodeOS from "node:os";
22
33import { assert, expect, it } from "@effect/vitest";
44import * as ConfigProvider from "effect/ConfigProvider";
apps/server/src/diagnostics/ProcessDiagnostics.ts+10-4
......@@ -4,6 +4,7 @@ import type {
44 ServerProcessSignal,
55 ServerSignalProcessResult,
66} from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
78import * as Context from "effect/Context";
89import * as DateTime from "effect/DateTime";
910import * as Duration from "effect/Duration";
......@@ -277,6 +278,9 @@ const runProcess = Effect.fn("runProcess")(
277278 readonly errorMessage: string;
278279 }) {
279280 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.
280284 const child = yield* spawner.spawn(
281285 ChildProcess.make(input.command, input.args, {
282286 cwd: process.cwd(),
......@@ -369,8 +373,10 @@ function readWindowsProcessRows(): Effect.Effect<
369373 );
370374}
371375
372export const readProcessRows = (platform = process.platform) =>
373 platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
376export const readProcessRows = Effect.gen(function* () {
377 const platform = yield* HostProcessPlatform;
378 return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
379});
374380
375381export function aggregateProcessDiagnostics(input: {
376382 readonly serverPid: number;
......@@ -387,7 +393,7 @@ function assertDescendantPid(
387393 return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process."));
388394 }
389395
390 return readProcessRows().pipe(
396 return readProcessRows.pipe(
391397 Effect.flatMap((rows) => {
392398 const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid));
393399 const descendant = buildDescendantEntries(filteredRows, process.pid).some(
......@@ -407,7 +413,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
407413
408414 const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () {
409415 const readAt = yield* DateTime.now;
410 const rows = yield* readProcessRows().pipe(
416 const rows = yield* readProcessRows.pipe(
411417 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
412418 );
413419 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* () {
252252 const sampleOnce = Effect.gen(function* () {
253253 const sampledAt = yield* DateTime.now;
254254 const sampledAtMs = DateTime.toEpochMillis(sampledAt);
255 const rows = yield* readProcessRows().pipe(
255 const rows = yield* readProcessRows.pipe(
256256 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
257257 );
258258 const samples = collectMonitoredSamples({
apps/server/src/environment/Layers/ServerEnvironment.ts+11-6
......@@ -1,4 +1,5 @@
11import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
23import * as Crypto from "effect/Crypto";
34import * as Effect from "effect/Effect";
45import * as FileSystem from "effect/FileSystem";
......@@ -11,8 +12,8 @@ import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/Serv
1112import packageJson from "../../../package.json" with { type: "json" };
1213import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
1314
14function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
15 switch (process.platform) {
15function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] {
16 switch (platform) {
1617 case "darwin":
1718 return "darwin";
1819 case "linux":
......@@ -24,8 +25,10 @@ function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
2425 }
2526}
2627
27function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {
28 switch (process.arch) {
28function platformArch(
29 architecture: NodeJS.Architecture,
30): ExecutionEnvironmentDescriptor["platform"]["arch"] {
31 switch (architecture) {
2932 case "arm64":
3033 return "arm64";
3134 case "x64":
......@@ -40,6 +43,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
4043 const path = yield* Path.Path;
4144 const serverConfig = yield* ServerConfig;
4245 const crypto = yield* Crypto.Crypto;
46 const hostPlatform = yield* HostProcessPlatform;
47 const hostArchitecture = yield* HostProcessArchitecture;
4348
4449 const readPersistedEnvironmentId = Effect.gen(function* () {
4550 const exists = yield* fileSystem
......@@ -80,8 +85,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
8085 environmentId,
8186 label,
8287 platform: {
83 os: platformOs(),
84 arch: platformArch(),
88 os: platformOs(hostPlatform),
89 arch: platformArch(hostArchitecture),
8590 },
8691 serverVersion: packageJson.version,
8792 capabilities: {
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts+18-21
......@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";
5import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
56import { vi } from "vite-plus/test";
67
78import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts";
......@@ -28,6 +29,16 @@ const LinuxMachineInfoLayer = Layer.merge(
2829 : Effect.succeed(""),
2930 }),
3031);
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
3243afterEach(() => {
3344 runMock.mockReset();
......@@ -38,9 +49,7 @@ describe("resolveServerEnvironmentLabel", () => {
3849 Effect.gen(function* () {
3950 const result = yield* resolveServerEnvironmentLabel({
4051 cwdBaseName: "t3code",
41 platform: "win32",
42 hostname: "macbook-pro",
43 }).pipe(Effect.provide(TestLayer));
52 }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro")));
4453
4554 expect(result).toBe("macbook-pro");
4655 }),
......@@ -61,9 +70,7 @@ describe("resolveServerEnvironmentLabel", () => {
6170
6271 const result = yield* resolveServerEnvironmentLabel({
6372 cwdBaseName: "t3code",
64 platform: "darwin",
65 hostname: "macbook-pro",
66 }).pipe(Effect.provide(TestLayer));
73 }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro")));
6774
6875 expect(result).toBe("Julius's MacBook Pro");
6976 expect(runMock).toHaveBeenCalledWith(
......@@ -80,9 +87,7 @@ describe("resolveServerEnvironmentLabel", () => {
8087 Effect.gen(function* () {
8188 const result = yield* resolveServerEnvironmentLabel({
8289 cwdBaseName: "t3code",
83 platform: "linux",
84 hostname: "buildbox",
85 }).pipe(Effect.provide(LinuxMachineInfoLayer));
90 }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox")));
8691
8792 expect(result).toBe("Build Agent 01");
8893 expect(runMock).not.toHaveBeenCalled();
......@@ -104,9 +109,7 @@ describe("resolveServerEnvironmentLabel", () => {
104109
105110 const result = yield* resolveServerEnvironmentLabel({
106111 cwdBaseName: "t3code",
107 platform: "linux",
108 hostname: "runner-01",
109 }).pipe(Effect.provide(TestLayer));
112 }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01")));
110113
111114 expect(result).toBe("CI Runner");
112115 expect(runMock).toHaveBeenCalledWith(
......@@ -123,9 +126,7 @@ describe("resolveServerEnvironmentLabel", () => {
123126 Effect.gen(function* () {
124127 const result = yield* resolveServerEnvironmentLabel({
125128 cwdBaseName: "t3code",
126 platform: "win32",
127 hostname: "JULIUS-LAPTOP",
128 }).pipe(Effect.provide(TestLayer));
129 }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP")));
129130
130131 expect(result).toBe("JULIUS-LAPTOP");
131132 }),
......@@ -145,9 +146,7 @@ describe("resolveServerEnvironmentLabel", () => {
145146
146147 const result = yield* resolveServerEnvironmentLabel({
147148 cwdBaseName: "t3code",
148 platform: "darwin",
149 hostname: "macbook-pro",
150 }).pipe(Effect.provide(TestLayer));
149 }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro")));
151150
152151 expect(result).toBe("macbook-pro");
153152 }),
......@@ -168,9 +167,7 @@ describe("resolveServerEnvironmentLabel", () => {
168167
169168 const result = yield* resolveServerEnvironmentLabel({
170169 cwdBaseName: "t3code",
171 platform: "linux",
172 hostname: " ",
173 }).pipe(Effect.provide(TestLayer));
170 }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " ")));
174171
175172 expect(result).toBe("t3code");
176173 }),
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+5-10
......@@ -1,5 +1,4 @@
1import * as OS from "node:os";
2
1import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
32import * as Effect from "effect/Effect";
43import * as FileSystem from "effect/FileSystem";
54import * as Option from "effect/Option";
......@@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts";
87
98interface ResolveServerEnvironmentLabelInput {
109 readonly cwdBaseName: string;
11 readonly platform?: NodeJS.Platform;
12 readonly hostname?: string | null;
1310}
1411
1512function normalizeLabel(value: string | null | undefined): string | null {
......@@ -69,9 +66,8 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (
6966 return normalizeLabel(result.value.stdout);
7067});
7168
72const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* (
73 platform: NodeJS.Platform,
74) {
69const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () {
70 const platform = yield* HostProcessPlatform;
7571 if (platform === "darwin") {
7672 return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]);
7773 }
......@@ -94,13 +90,12 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function*
9490export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* (
9591 input: ResolveServerEnvironmentLabelInput,
9692) {
97 const platform = input.platform ?? process.platform;
98 const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform);
93 const friendlyHostLabel = yield* resolveFriendlyHostLabel();
9994 if (friendlyHostLabel) {
10095 return friendlyHostLabel;
10196 }
10297
103 const hostname = normalizeLabel(input.hostname ?? OS.hostname());
98 const hostname = normalizeLabel(yield* HostProcessHostname);
10499 if (hostname) {
105100 return hostname;
106101 }
apps/server/src/os-jank.ts+54-66
......@@ -1,21 +1,15 @@
1import * as NodeOS from "node:os";
2import * as Effect from "effect/Effect";
3import * as Path from "effect/Path";
1import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
42import {
5 readPathFromLoginShell,
6 readEnvironmentFromWindowsShell,
7 resolveWindowsEnvironment,
8 type CommandAvailabilityOptions,
9 type WindowsShellEnvironmentReader,
103 listLoginShellCandidates,
114 mergePathEntries,
5 readPathFromLoginShell,
126 readPathFromLaunchctl,
7 resolveWindowsEnvironment,
138} from "@t3tools/shared/shell";
14
15type WindowsCommandAvailabilityChecker = (
16 command: string,
17 options?: CommandAvailabilityOptions,
18) => boolean;
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
12import * as NodeOS from "node:os";
1913
2014function logPathHydrationWarning(message: string, error?: unknown): void {
2115 process.stderr.write(
......@@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void {
2317 );
2418}
2519
26export function fixPath(
27 options: {
28 env?: NodeJS.ProcessEnv;
29 platform?: NodeJS.Platform;
30 readPath?: typeof readPathFromLoginShell;
31 readWindowsEnvironment?: WindowsShellEnvironmentReader;
32 isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
33 readLaunchctlPath?: typeof readPathFromLaunchctl;
34 userShell?: string;
35 logWarning?: (message: string, error?: unknown) => void;
36 } = {},
37): void {
38 const platform = options.platform ?? process.platform;
39 const env = options.env ?? process.env;
40 const logWarning = options.logWarning ?? logPathHydrationWarning;
41 const readPath = options.readPath ?? readPathFromLoginShell;
42
43 try {
44 if (platform === "win32") {
45 const repairedEnvironment = resolveWindowsEnvironment(env, {
46 readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell,
47 ...(options.isWindowsCommandAvailable
48 ? { commandAvailable: options.isWindowsCommandAvailable }
49 : {}),
50 });
51 for (const [key, value] of Object.entries(repairedEnvironment)) {
52 if (value !== undefined) {
53 env[key] = value;
54 }
55 }
56 return;
20function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void {
21 let shellPath: string | undefined;
22 for (const shell of listLoginShellCandidates(platform, env.SHELL)) {
23 try {
24 shellPath = readPathFromLoginShell(shell);
25 } catch (error) {
26 logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error);
5727 }
5828
59 if (platform !== "darwin" && platform !== "linux") return;
29 if (shellPath) break;
30 }
6031
61 let shellPath: string | undefined;
62 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
63 try {
64 shellPath = readPath(shell);
65 } catch (error) {
66 logWarning(`Failed to read PATH from login shell ${shell}.`, error);
67 }
32 const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined;
33 const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform);
34 if (mergedPath) {
35 env.PATH = mergedPath;
36 }
37}
6838
69 if (shellPath) {
70 break;
71 }
72 }
39export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return<
40 void,
41 never,
42 FileSystem.FileSystem | Path.Path
43> {
44 const platform = yield* HostProcessPlatform;
45 const env = yield* HostProcessEnvironment;
7346
74 const launchctlPath =
75 platform === "darwin" && !shellPath
76 ? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
77 : undefined;
78 const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform);
79 if (mergedPath) {
80 env.PATH = mergedPath;
47 if (platform === "win32") {
48 const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe(
49 Effect.catchDefect((defect) =>
50 Effect.sync(() => {
51 logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect);
52 return {} as Partial<NodeJS.ProcessEnv>;
53 }),
54 ),
55 );
56 for (const [key, value] of Object.entries(repairedEnvironment)) {
57 if (value !== undefined) {
58 env[key] = value;
59 }
8160 }
82 } catch (error) {
83 logWarning("Failed to hydrate PATH from the user environment.", error);
61 return;
8462 }
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
8775export const expandHomePath = Effect.fn(function* (input: string) {
8876 const { join } = yield* Path.Path;
apps/server/src/preview/PortScanner.test.ts+8-186
......@@ -1,21 +1,20 @@
11import * as net from "node:net";
22
33import { it as effectIt } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
55import * as Net from "@t3tools/shared/Net";
66import { Effect, Layer } from "effect";
7import { describe, expect, it } from "vite-plus/test";
7import { expect } from "vite-plus/test";
88
99import { ProcessRunner } from "../processRunner.ts";
1010import * as PortScanner from "./PortScanner.ts";
11
12const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } =
13 PortScanner.__testing;
1411const TestProcessRunner = Layer.succeed(ProcessRunner, {
1512 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
1613});
1714const 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 ),
1918);
2019
2120const openServer = (port: number): Effect.Effect<net.Server | null> =>
......@@ -37,21 +36,6 @@ const closeServer = (server: net.Server): Effect.Effect<void> =>
3736 server.close(() => resume(Effect.void));
3837 });
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
5539const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (
5640 ports: ReadonlyArray<number>,
5741) {
......@@ -69,176 +53,15 @@ const commonDevServer = Effect.acquireRelease(
6953 ({ server }) => closeServer(server),
7054);
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
23256/**
233 * Integration tests against a real TCP listener. We force the Windows code
234 * path (TCP-probe fallback) by monkey-patching `process.platform` for the
235 * duration of the test so we don't depend on `lsof` being installed.
57 * Integration tests against a real TCP listener. We provide the Windows host
58 * platform so the tests exercise the TCP-probe fallback without depending on
59 * `lsof` being installed.
23660 */
23761effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => {
23862 it.effect(
23963 "scan() returns a server we just opened on a curated dev port",
24064 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {
241 yield* windowsPlatform;
24265 const { port } = yield* commonDevServer;
24366 const scanner = yield* PortScanner.PortDiscovery;
24467 const result = yield* scanner.scan();
......@@ -251,7 +74,6 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
25174 it.effect(
25275 "retain drives an immediate broadcast to subscribers",
25376 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {
254 yield* windowsPlatform;
25577 const { port } = yield* commonDevServer;
25678 const received: number[] = [];
25779 const scanner = yield* PortScanner.PortDiscovery;
apps/server/src/preview/PortScanner.ts+3-9
......@@ -12,6 +12,7 @@
1212 * polls forever, but each tick is a no-op when the retain count is zero.
1313 */
1414import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1516import * as Net from "@t3tools/shared/Net";
1617import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
1718import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect";
......@@ -182,6 +183,7 @@ const serversEqual = (
182183const make = Effect.gen(function* PortDiscoveryMake() {
183184 const net = yield* Net.NetService;
184185 const processRunner = yield* ProcessRunner;
186 const hostPlatform = yield* HostProcessPlatform;
185187 const stateRef = yield* Ref.make<ScannerState>({
186188 lastSnapshot: [],
187189 listeners: new Set(),
......@@ -221,7 +223,7 @@ const make = Effect.gen(function* PortDiscoveryMake() {
221223 terminalByProcessId.set(processId, registration.owner);
222224 }
223225 }
224 if (process.platform === "win32") {
226 if (hostPlatform === "win32") {
225227 const command =
226228 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }';
227229 const listeners = yield* processRunner
......@@ -359,11 +361,3 @@ const make = Effect.gen(function* PortDiscoveryMake() {
359361}).pipe(Effect.withSpan("PortDiscovery.make"));
360362
361363export 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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, it } from "@effect/vitest";
3import { assertSuccess } from "@effect/vitest/utils";
4import * as Crypto from "effect/Crypto";
3import * as ConfigProvider from "effect/ConfigProvider";
54import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
75import * as FileSystem from "effect/FileSystem";
86import * as Layer from "effect/Layer";
97import * as Path from "effect/Path";
......@@ -11,24 +9,9 @@ import * as Sink from "effect/Sink";
119import * as Stream from "effect/Stream";
1210import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1311
14import {
15 isCommandAvailable,
16 launchBrowser,
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}
12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
13import { SpawnExecutableResolution } from "@t3tools/shared/shell";
14import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts";
3215
3316function makeMockDetachedHandle(onUnref: () => void = () => undefined) {
3417 return ChildProcessSpawner.makeHandle({
......@@ -49,756 +32,135 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) {
4932 });
5033}
5134
52it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
53 it.effect("returns commands for command-based editors", () =>
54 Effect.gen(function* () {
55 const antigravityLaunch = yield* resolveEditorLaunch(
56 { cwd: "/tmp/workspace", editor: "antigravity" },
57 "darwin",
58 { PATH: "" },
59 );
60 assert.deepEqual(antigravityLaunch, {
61 command: "agy",
62 args: ["/tmp/workspace"],
63 });
64
65 const cursorLaunch = yield* resolveEditorLaunch(
66 { cwd: "/tmp/workspace", editor: "cursor" },
67 "darwin",
68 { PATH: "" },
69 );
70 assert.deepEqual(cursorLaunch, {
71 command: "cursor",
72 args: ["/tmp/workspace"],
73 });
74
75 const traeLaunch = yield* resolveEditorLaunch(
76 { cwd: "/tmp/workspace", editor: "trae" },
77 "darwin",
78 );
79 assert.deepEqual(traeLaunch, {
80 command: "trae",
81 args: ["/tmp/workspace"],
82 });
83
84 const kiroLaunch = yield* resolveEditorLaunch(
85 { cwd: "/tmp/workspace", editor: "kiro" },
86 "darwin",
87 { PATH: "" },
88 );
89 assert.deepEqual(kiroLaunch, {
90 command: "kiro",
91 args: ["ide", "/tmp/workspace"],
92 });
93
94 const vscodeLaunch = yield* resolveEditorLaunch(
95 { cwd: "/tmp/workspace", editor: "vscode" },
96 "darwin",
97 { PATH: "" },
98 );
99 assert.deepEqual(vscodeLaunch, {
100 command: "code",
101 args: ["/tmp/workspace"],
102 });
103
104 const vscodeInsidersLaunch = yield* resolveEditorLaunch(
105 { cwd: "/tmp/workspace", editor: "vscode-insiders" },
106 "darwin",
107 );
108 assert.deepEqual(vscodeInsidersLaunch, {
109 command: "code-insiders",
110 args: ["/tmp/workspace"],
111 });
112
113 const vscodiumLaunch = yield* resolveEditorLaunch(
114 { cwd: "/tmp/workspace", editor: "vscodium" },
115 "darwin",
116 );
117 assert.deepEqual(vscodiumLaunch, {
118 command: "codium",
119 args: ["/tmp/workspace"],
120 });
121
122 const zedLaunch = yield* resolveEditorLaunch(
123 { cwd: "/tmp/workspace", editor: "zed" },
124 "darwin",
125 { PATH: "" },
126 );
127 assert.deepEqual(zedLaunch, {
128 command: "zed",
129 args: ["/tmp/workspace"],
130 });
131
132 const ideaLaunch = yield* resolveEditorLaunch(
133 { cwd: "/tmp/workspace", editor: "idea" },
134 "darwin",
135 );
136 assert.deepEqual(ideaLaunch, {
137 command: "idea",
138 args: ["/tmp/workspace"],
139 });
140
141 const aquaLaunch = yield* resolveEditorLaunch(
142 { cwd: "/tmp/workspace", editor: "aqua" },
143 "darwin",
144 );
145 assert.deepEqual(aquaLaunch, {
146 command: "aqua",
147 args: ["/tmp/workspace"],
148 });
149
150 const clionLaunch = yield* resolveEditorLaunch(
151 { cwd: "/tmp/workspace", editor: "clion" },
152 "darwin",
153 );
154 assert.deepEqual(clionLaunch, {
155 command: "clion",
156 args: ["/tmp/workspace"],
157 });
158
159 const datagripLaunch = yield* resolveEditorLaunch(
160 { cwd: "/tmp/workspace", editor: "datagrip" },
161 "darwin",
162 );
163 assert.deepEqual(datagripLaunch, {
164 command: "datagrip",
165 args: ["/tmp/workspace"],
166 });
167
168 const dataspellLaunch = yield* resolveEditorLaunch(
169 { cwd: "/tmp/workspace", editor: "dataspell" },
170 "darwin",
171 );
172 assert.deepEqual(dataspellLaunch, {
173 command: "dataspell",
174 args: ["/tmp/workspace"],
175 });
176
177 const golandLaunch = yield* resolveEditorLaunch(
178 { cwd: "/tmp/workspace", editor: "goland" },
179 "darwin",
180 );
181 assert.deepEqual(golandLaunch, {
182 command: "goland",
183 args: ["/tmp/workspace"],
184 });
185
186 const phpstormLaunch = yield* resolveEditorLaunch(
187 { cwd: "/tmp/workspace", editor: "phpstorm" },
188 "darwin",
189 );
190 assert.deepEqual(phpstormLaunch, {
191 command: "phpstorm",
192 args: ["/tmp/workspace"],
193 });
194
195 const pycharmLaunch = yield* resolveEditorLaunch(
196 { cwd: "/tmp/workspace", editor: "pycharm" },
197 "darwin",
198 );
199 assert.deepEqual(pycharmLaunch, {
200 command: "pycharm",
201 args: ["/tmp/workspace"],
202 });
203
204 const riderLaunch = yield* resolveEditorLaunch(
205 { cwd: "/tmp/workspace", editor: "rider" },
206 "darwin",
207 );
208 assert.deepEqual(riderLaunch, {
209 command: "rider",
210 args: ["/tmp/workspace"],
211 });
212
213 const rubymineLaunch = yield* resolveEditorLaunch(
214 { cwd: "/tmp/workspace", editor: "rubymine" },
215 "darwin",
216 );
217 assert.deepEqual(rubymineLaunch, {
218 command: "rubymine",
219 args: ["/tmp/workspace"],
220 });
221
222 const rustroverLaunch = yield* resolveEditorLaunch(
223 { cwd: "/tmp/workspace", editor: "rustrover" },
224 "darwin",
225 );
226 assert.deepEqual(rustroverLaunch, {
227 command: "rustrover",
228 args: ["/tmp/workspace"],
229 });
230
231 const webstormLaunch = yield* resolveEditorLaunch(
232 { cwd: "/tmp/workspace", editor: "webstorm" },
233 "darwin",
234 );
235 assert.deepEqual(webstormLaunch, {
236 command: "webstorm",
237 args: ["/tmp/workspace"],
238 });
239 }),
240 );
241
242 it.effect("applies launch-style-specific navigation arguments", () =>
243 Effect.gen(function* () {
244 const lineOnly = yield* resolveEditorLaunch(
245 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
246 "darwin",
247 { PATH: "" },
248 );
249 assert.deepEqual(lineOnly, {
250 command: "cursor",
251 args: ["--goto", "/tmp/workspace/AGENTS.md:48"],
252 });
253
254 const lineAndColumn = yield* resolveEditorLaunch(
255 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" },
256 "darwin",
257 { PATH: "" },
258 );
259 assert.deepEqual(lineAndColumn, {
260 command: "cursor",
261 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
262 });
263
264 const traeLineAndColumn = yield* resolveEditorLaunch(
265 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" },
266 "darwin",
267 );
268 assert.deepEqual(traeLineAndColumn, {
269 command: "trae",
270 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
271 });
272
273 const kiroLineAndColumn = yield* resolveEditorLaunch(
274 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" },
275 "darwin",
276 { PATH: "" },
277 );
278 assert.deepEqual(kiroLineAndColumn, {
279 command: "kiro",
280 args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
281 });
282
283 const vscodeLineAndColumn = yield* resolveEditorLaunch(
284 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" },
285 "darwin",
286 { PATH: "" },
287 );
288 assert.deepEqual(vscodeLineAndColumn, {
289 command: "code",
290 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
291 });
292
293 const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch(
294 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" },
295 "darwin",
296 );
297 assert.deepEqual(vscodeInsidersLineAndColumn, {
298 command: "code-insiders",
299 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
300 });
301
302 const vscodiumLineAndColumn = yield* resolveEditorLaunch(
303 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" },
304 "darwin",
305 );
306 assert.deepEqual(vscodiumLineAndColumn, {
307 command: "codium",
308 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
309 });
310
311 const zedLineAndColumn = yield* resolveEditorLaunch(
312 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" },
313 "darwin",
314 { PATH: "" },
315 );
316 assert.deepEqual(zedLineAndColumn, {
317 command: "zed",
318 args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"],
319 });
320
321 const zedLineOnly = yield* resolveEditorLaunch(
322 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },
323 "darwin",
324 { PATH: "" },
325 );
326 assert.deepEqual(zedLineOnly, {
327 command: "zed",
328 args: ["/tmp/workspace/AGENTS.md:48"],
329 });
330
331 const ideaLineOnly = yield* resolveEditorLaunch(
332 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" },
333 "darwin",
334 );
335 assert.deepEqual(ideaLineOnly, {
336 command: "idea",
337 args: ["--line", "48", "/tmp/workspace/AGENTS.md"],
338 });
339
340 const ideaLineAndColumn = yield* resolveEditorLaunch(
341 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" },
342 "darwin",
343 );
344 assert.deepEqual(ideaLineAndColumn, {
345 command: "idea",
346 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
347 });
348
349 const aquaLineAndColumn = yield* resolveEditorLaunch(
350 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" },
351 "darwin",
352 );
353 assert.deepEqual(aquaLineAndColumn, {
354 command: "aqua",
355 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
356 });
357
358 const clionLineAndColumn = yield* resolveEditorLaunch(
359 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" },
360 "darwin",
361 );
362 assert.deepEqual(clionLineAndColumn, {
363 command: "clion",
364 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
365 });
366
367 const datagripLineAndColumn = yield* resolveEditorLaunch(
368 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" },
369 "darwin",
370 );
371 assert.deepEqual(datagripLineAndColumn, {
372 command: "datagrip",
373 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
374 });
375
376 const dataspellLineAndColumn = yield* resolveEditorLaunch(
377 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" },
378 "darwin",
379 );
380 assert.deepEqual(dataspellLineAndColumn, {
381 command: "dataspell",
382 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
383 });
384
385 const golandLineAndColumn = yield* resolveEditorLaunch(
386 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" },
387 "darwin",
388 );
389 assert.deepEqual(golandLineAndColumn, {
390 command: "goland",
391 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
392 });
393
394 const phpstormLineAndColumn = yield* resolveEditorLaunch(
395 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" },
396 "darwin",
397 );
398 assert.deepEqual(phpstormLineAndColumn, {
399 command: "phpstorm",
400 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
401 });
402
403 const pycharmLineAndColumn = yield* resolveEditorLaunch(
404 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" },
405 "darwin",
406 );
407 assert.deepEqual(pycharmLineAndColumn, {
408 command: "pycharm",
409 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
410 });
411
412 const riderLineAndColumn = yield* resolveEditorLaunch(
413 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" },
414 "darwin",
415 );
416 assert.deepEqual(riderLineAndColumn, {
417 command: "rider",
418 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
419 });
420
421 const rubymineLineAndColumn = yield* resolveEditorLaunch(
422 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" },
423 "darwin",
424 );
425 assert.deepEqual(rubymineLineAndColumn, {
426 command: "rubymine",
427 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
428 });
429
430 const rustroverLineAndColumn = yield* resolveEditorLaunch(
431 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" },
432 "darwin",
433 );
434 assert.deepEqual(rustroverLineAndColumn, {
435 command: "rustrover",
436 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
437 });
438
439 const webstormLineOnly = yield* resolveEditorLaunch(
440 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" },
441 "darwin",
442 );
443 assert.deepEqual(webstormLineOnly, {
444 command: "webstorm",
445 args: ["--line", "48", "/tmp/workspace/AGENTS.md"],
446 });
447 }),
448 );
449
450 it.effect("falls back to zeditor when zed is not installed", () =>
451 Effect.gen(function* () {
452 const fs = yield* FileSystem.FileSystem;
453 const path = yield* Path.Path;
454 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
455 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
456 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
457
458 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
459 PATH: dir,
460 });
461
462 assert.deepEqual(result, {
463 command: "zeditor",
464 args: ["/tmp/workspace"],
465 });
466 }),
467 );
468
469 it.effect("falls back to the primary command when no alias is installed", () =>
470 Effect.gen(function* () {
471 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
472 PATH: "",
473 });
474 assert.deepEqual(result, {
475 command: "zed",
476 args: ["/tmp/workspace"],
477 });
478 }),
479 );
480
481 it.effect("maps file-manager editor to OS open commands", () =>
482 Effect.gen(function* () {
483 const launch1 = yield* resolveEditorLaunch(
484 { cwd: "/tmp/workspace", editor: "file-manager" },
485 "darwin",
486 { PATH: "" },
487 );
488 assert.deepEqual(launch1, {
489 command: "open",
490 args: ["/tmp/workspace"],
491 });
492
493 const launch2 = yield* resolveEditorLaunch(
494 { cwd: "C:\\workspace", editor: "file-manager" },
495 "win32",
496 { PATH: "" },
497 );
498 assert.deepEqual(launch2, {
499 command: "explorer",
500 args: ["C:\\workspace"],
501 });
502
503 const launch3 = yield* resolveEditorLaunch(
504 { cwd: "/tmp/workspace", editor: "file-manager" },
505 "linux",
506 { PATH: "" },
507 );
508 assert.deepEqual(launch3, {
509 command: "xdg-open",
510 args: ["/tmp/workspace"],
511 });
512 }),
513 );
514});
515
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'",
35const testLayer = (input: {
36 readonly platform: NodeJS.Platform;
37 readonly env?: Record<string, string>;
38 readonly resolveExecutable?: (command: string) => string | undefined;
39 readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;
40 readonly onUnref?: () => void;
41}) => {
42 const spawnerLayer = Layer.succeed(
43 ChildProcessSpawner.ChildProcessSpawner,
44 ChildProcessSpawner.make((command) =>
45 Effect.sync(() => {
46 assert.equal(ChildProcess.isStandardCommand(command), true);
47 if (!ChildProcess.isStandardCommand(command)) {
48 throw new Error("Expected a standard command");
49 }
50 input.onSpawn?.(command);
51 return makeMockDetachedHandle(input.onUnref);
52 }),
54353 ),
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 }),
69954 );
70055
701 it.effect("appends PATHEXT for commands with non-executable extensions on win32", () =>
702 Effect.gen(function* () {
703 const fs = yield* FileSystem.FileSystem;
704 const path = yield* Path.Path;
705 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
706 yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n");
707 const env = {
708 PATH: dir,
709 PATHEXT: ".COM;.EXE;.BAT;.CMD",
710 } satisfies NodeJS.ProcessEnv;
711 assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);
712 }),
56 return Layer.mergeAll(
57 ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
58 Layer.succeed(HostProcessPlatform, input.platform),
59 Layer.succeed(
60 SpawnExecutableResolution,
61 (command) => input.resolveExecutable?.(command) ?? command,
62 ),
63 ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })),
71364 );
714
715 it.effect("uses platform-specific PATH delimiter for platform overrides", () =>
716 Effect.gen(function* () {
717 const fs = yield* FileSystem.FileSystem;
718 const path = yield* Path.Path;
719 const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
720 const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
721 yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n");
722 yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ");
723 const env = {
724 PATH: `${firstDir};${secondDir}`,
725 PATHEXT: ".COM;.EXE;.BAT;.CMD",
726 } satisfies NodeJS.ProcessEnv;
727 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
728 }),
65};
66
67it.effect("launches the default browser through the platform command", () => {
68 let spawned: ChildProcess.StandardCommand | undefined;
69 let didUnref = false;
70 return Effect.gen(function* () {
71 const launcher = yield* ExternalLauncher;
72
73 yield* launcher.launchBrowser("https://example.com/some path");
74
75 assert.ok(spawned);
76 assert.equal(spawned.command, "xdg-open");
77 assert.deepEqual(spawned.args, ["https://example.com/some path"]);
78 assert.equal(spawned.options.detached, true);
79 assert.equal(didUnref, true);
80 }).pipe(
81 Effect.provide(
82 testLayer({
83 platform: "linux",
84 onSpawn: (command) => {
85 spawned = command;
86 },
87 onUnref: () => {
88 didUnref = true;
89 },
90 }),
91 ),
72992 );
73093});
73194
732it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
733 it.effect("returns installed editors for command launches", () =>
734 Effect.gen(function* () {
735 const fs = yield* FileSystem.FileSystem;
736 const path = yield* Path.Path;
737 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
738
739 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
740 yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n");
741 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
742 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
743 yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n");
744 yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n");
745 yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n");
746 yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n");
747 yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n");
748 yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n");
749 yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n");
750 yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n");
751 yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n");
752 yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n");
753 yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n");
754 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
755 const editors = resolveAvailableEditors("win32", {
756 PATH: dir,
757 PATHEXT: ".COM;.EXE;.BAT;.CMD",
758 });
759 assert.deepEqual(editors, [
760 "trae",
761 "kiro",
762 "vscode-insiders",
763 "vscodium",
764 "aqua",
765 "clion",
766 "datagrip",
767 "dataspell",
768 "goland",
769 "phpstorm",
770 "pycharm",
771 "rider",
772 "rubymine",
773 "rustrover",
774 "webstorm",
775 "file-manager",
776 ]);
777 }),
778 );
779
780 it.effect("includes zed when only the zeditor command is installed", () =>
781 Effect.gen(function* () {
782 const fs = yield* FileSystem.FileSystem;
783 const path = yield* Path.Path;
784 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
785
786 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
787 yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n");
788 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
789 yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);
790
791 const editors = resolveAvailableEditors("linux", {
792 PATH: dir,
793 });
794 assert.deepEqual(editors, ["zed", "file-manager"]);
795 }),
796 );
797
798 it("omits file-manager when the platform opener is unavailable", () => {
799 const editors = resolveAvailableEditors("linux", {
800 PATH: "",
801 });
802 assert.deepEqual(editors, []);
803 });
804});
95it.effect("launches an installed editor with platform-safe arguments", () =>
96 Effect.gen(function* () {
97 const fileSystem = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
100 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
101
102 let spawned: ChildProcess.StandardCommand | undefined;
103 yield* Effect.gen(function* () {
104 const launcher = yield* ExternalLauncher;
105 yield* launcher.launchEditor({
106 editor: "vscode",
107 cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",
108 });
109 }).pipe(
110 Effect.provide(
111 testLayer({
112 platform: "win32",
113 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
114 resolveExecutable: (command) =>
115 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
116 onSpawn: (command) => {
117 spawned = command;
118 },
119 }),
120 ),
121 );
122
123 assert.ok(spawned);
124 assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"');
125 assert.deepEqual(spawned.args, [
126 '^"--goto^"',
127 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
128 ]);
129 assert.equal(spawned.options.shell, true);
130 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
131);
132
133it.effect("discovers editors through the service API", () =>
134 Effect.gen(function* () {
135 const fileSystem = yield* FileSystem.FileSystem;
136 const path = yield* Path.Path;
137 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
138 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
139 yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");
140
141 const editors = yield* Effect.gen(function* () {
142 const launcher = yield* ExternalLauncher;
143 return yield* launcher.resolveAvailableEditors();
144 }).pipe(
145 Effect.provide(
146 testLayer({
147 platform: "win32",
148 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
149 }),
150 ),
151 );
152
153 assert.equal(editors.includes("vscode"), true);
154 assert.equal(editors.includes("file-manager"), true);
155 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
156);
157
158it.effect("rejects unknown editors through the service API", () =>
159 Effect.gen(function* () {
160 const launcher = yield* ExternalLauncher;
161 const result = yield* launcher
162 .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" })
163 .pipe(Effect.result);
164 assert.equal(result._tag, "Failure");
165 }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))),
166);
apps/server/src/process/externalLauncher.ts+103-37
......@@ -12,12 +12,16 @@ import {
1212 type EditorId,
1313 type LaunchEditorInput,
1414} 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";
1618import * as Context from "effect/Context";
1719import * as Effect from "effect/Effect";
1820import * as Encoding from "effect/Encoding";
21import * as FileSystem from "effect/FileSystem";
1922import * as Layer from "effect/Layer";
2023import * as Option from "effect/Option";
24import * as Path from "effect/Path";
2125import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2226
2327// ==============================
......@@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2630
2731export { ExternalLauncherError };
2832export type { LaunchEditorInput };
29export { isCommandAvailable } from "@t3tools/shared/shell";
30
3133interface EditorLaunch {
3234 readonly command: string;
3335 readonly args: ReadonlyArray<string>;
......@@ -61,6 +63,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = {
6163 stderr: "ignore",
6264} 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
6496function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> {
6597 const match = TARGET_WITH_POSITION_PATTERN.exec(target);
6698 if (!match?.[1] || !match[2]) {
......@@ -109,17 +141,17 @@ function resolveEditorArgs(
109141 return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
110142}
111143
112function resolveAvailableCommand(
144const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* (
113145 commands: ReadonlyArray<string>,
114 options: CommandAvailabilityOptions = {},
115): Option.Option<string> {
146 env: NodeJS.ProcessEnv,
147): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> {
116148 for (const command of commands) {
117 if (isCommandAvailable(command, options)) {
149 if (yield* isCommandAvailable(command, { env })) {
118150 return Option.some(command);
119151 }
120152 }
121153 return Option.none();
122}
154});
123155
124156function encodeUtf16LeBase64(input: string): string {
125157 const bytes = new Uint8Array(input.length * 2);
......@@ -135,7 +167,7 @@ function escapePowerShellStringLiteral(input: string): string {
135167 return `'${input.replaceAll("'", "''")}'`;
136168}
137169
138function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string {
170function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string {
139171 return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
140172}
141173
......@@ -145,7 +177,7 @@ function resolveWslPowerShellPath(): string {
145177
146178function shouldUseWindowsBrowserFromWsl(
147179 platform: NodeJS.Platform,
148 env: NodeJS.ProcessEnv = process.env,
180 env: NodeJS.ProcessEnv = {},
149181): boolean {
150182 return (
151183 platform === "linux" &&
......@@ -184,10 +216,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
184216 }
185217}
186218
187export function resolveBrowserLaunch(
219function buildBrowserLaunch(
188220 target: string,
189 platform: NodeJS.Platform = process.platform,
190 env: NodeJS.ProcessEnv = process.env,
221 platform: NodeJS.Platform,
222 env: NodeJS.ProcessEnv = {},
191223): ProcessLaunch {
192224 if (platform === "darwin") {
193225 return {
......@@ -212,34 +244,49 @@ export function resolveBrowserLaunch(
212244 };
213245}
214246
215export function resolveAvailableEditors(
216 platform: NodeJS.Platform = process.platform,
217 env: NodeJS.ProcessEnv = process.env,
218): ReadonlyArray<EditorId> {
247const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (
248 platform: NodeJS.Platform,
249 env: NodeJS.ProcessEnv,
250): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> {
219251 const available: EditorId[] = [];
220252
221253 for (const editor of EDITORS) {
222254 if (editor.commands === null) {
223255 const command = fileManagerCommandForPlatform(platform);
224 if (isCommandAvailable(command, { platform, env })) {
256 if (yield* isCommandAvailable(command, { env })) {
225257 available.push(editor.id);
226258 }
227259 continue;
228260 }
229261
230 const command = resolveAvailableCommand(editor.commands, { platform, env });
262 const command = yield* resolveAvailableCommand(editor.commands, env);
231263 if (Option.isSome(command)) {
232264 available.push(editor.id);
233265 }
234266 }
235267
236268 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
239285/**
240286 * ExternalLauncherShape - Service API for browser and editor launch actions.
241287 */
242288export interface ExternalLauncherShape {
289 readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;
243290 /**
244291 * Launch a URL target in the default browser.
245292 */
......@@ -264,11 +311,11 @@ export class ExternalLauncher extends Context.Service<ExternalLauncher, External
264311// Implementations
265312// ==============================
266313
267export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
314const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
268315 input: LaunchEditorInput,
269 platform: NodeJS.Platform = process.platform,
270 env: NodeJS.ProcessEnv = process.env,
271): Effect.fn.Return<EditorLaunch, ExternalLauncherError> {
316): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> {
317 const platform = yield* HostProcessPlatform;
318 const env = yield* readCommandLookupEnv;
272319 yield* Effect.annotateCurrentSpan({
273320 "externalLauncher.editor": input.editor,
274321 "externalLauncher.cwd": input.cwd,
......@@ -281,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
281328
282329 if (editorDef.commands) {
283330 const command = Option.getOrElse(
284 resolveAvailableCommand(editorDef.commands, { platform, env }),
331 yield* resolveAvailableCommand(editorDef.commands, env),
285332 () => editorDef.commands[0],
286333 );
287334 return {
......@@ -312,29 +359,35 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
312359 );
313360});
314361
315export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
362const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
316363 target: string,
317364): 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");
319367});
320368
321export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
369const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
322370 launch: EditorLaunch,
323): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
324 if (!isCommandAvailable(launch.command)) {
371): Effect.fn.Return<
372 void,
373 ExternalLauncherError,
374 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
375> {
376 const env = yield* readCommandLookupEnv;
377 if (!(yield* isCommandAvailable(launch.command, { env }))) {
325378 return yield* new ExternalLauncherError({
326379 message: `Editor command not found: ${launch.command}`,
327380 });
328381 }
329382
330 const isWin32 = process.platform === "win32";
383 const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
331384 yield* launchAndUnref(
332385 {
333 command: launch.command,
334 args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args],
386 command: spawnCommand.command,
387 args: spawnCommand.args,
335388 options: {
336389 detached: true,
337 shell: isWin32,
390 shell: spawnCommand.shell,
338391 stdin: "ignore",
339392 stdout: "ignore",
340393 stderr: "ignore",
......@@ -346,16 +399,29 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce
346399
347400const make = Effect.gen(function* () {
348401 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
350413 return {
414 resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
351415 launchBrowser: (target) =>
352416 launchBrowser(target).pipe(
353417 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
354418 ),
355419 launchEditor: (input) =>
356 Effect.flatMap(resolveEditorLaunch(input), (launch) =>
357 launchEditorProcess(launch).pipe(
358 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
420 provideCommandResolutionServices(
421 Effect.flatMap(resolveEditorLaunch(input), (launch) =>
422 launchEditorProcess(launch).pipe(
423 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
424 ),
359425 ),
360426 ),
361427 } satisfies ExternalLauncherShape;
apps/server/src/processRunner.test.ts+52-16
......@@ -8,6 +8,8 @@ import * as Sink from "effect/Sink";
88import * as Stream from "effect/Stream";
99import { TestClock } from "effect/testing";
1010import { ChildProcessSpawner } from "effect/unstable/process";
11import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { SpawnExecutableResolution } from "@t3tools/shared/shell";
1113
1214import {
1315 isWindowsCommandNotFound,
......@@ -21,6 +23,9 @@ import {
2123type ChildProcessCommand = {
2224 readonly command: string;
2325 readonly args: ReadonlyArray<string>;
26 readonly options: {
27 readonly shell?: boolean | string;
28 };
2429};
2530
2631// Accesses private properties of ChildProcessCommand for testing purposes
......@@ -68,7 +73,6 @@ const runWith =
6873 Effect.flatMap((runner) =>
6974 runner.run({
7075 ...input,
71 shell: input.shell ?? false,
7276 }),
7377 ),
7478 Effect.provide(
......@@ -123,6 +127,44 @@ describe("runProcess", () => {
123127 }).pipe(Effect.provide(layer));
124128 });
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
126168 it.effect("fails when output exceeds max buffer in default mode", () =>
127169 Effect.gen(function* () {
128170 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
......@@ -280,19 +322,13 @@ describe("runProcess", () => {
280322});
281323
282324describe("isWindowsCommandNotFound", () => {
283 it("matches the localized German cmd.exe error text", () => {
284 const originalPlatform = process.platform;
285 Object.defineProperty(process, "platform", { value: "win32", configurable: true });
286
287 try {
288 expect(
289 isWindowsCommandNotFound(
290 1,
291 "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
292 ),
293 ).toBe(true);
294 } finally {
295 Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
296 }
297 });
325 it.effect("matches the localized German cmd.exe error text", () =>
326 Effect.gen(function* () {
327 const isCommandNotFound = yield* isWindowsCommandNotFound(
328 1,
329 "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
330 ).pipe(Effect.provideService(HostProcessPlatform, "win32"));
331 expect(isCommandNotFound).toBe(true);
332 }),
333 );
298334});
apps/server/src/processRunner.ts+19-9
......@@ -8,6 +8,8 @@ import * as PlatformError from "effect/PlatformError";
88import * as Scope from "effect/Scope";
99import * as Stream from "effect/Stream";
1010import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { resolveSpawnCommand } from "@t3tools/shared/shell";
1113import {
1214 collectUint8StreamText,
1315 type CollectedUint8StreamText,
......@@ -24,7 +26,6 @@ export interface ProcessRunInput {
2426 readonly maxOutputBytes?: number | undefined;
2527 readonly outputMode?: "error" | "truncate" | undefined;
2628 readonly truncatedMarker?: string | undefined;
27 readonly shell?: boolean | string | undefined;
2829 /**
2930 * On timeout, return a synthetic timedOut result.
3031 * Partial stdout/stderr are not preserved.
......@@ -109,11 +110,14 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean {
109110 return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output));
110111}
111112
112export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {
113 if (process.platform !== "win32") return false;
114 if (code === 9009) return true;
115 return hasWindowsCommandNotFoundMessage(stderr);
116}
113export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")(
114 function* (code: number | null, stderr: string) {
115 const platform = yield* HostProcessPlatform;
116 if (platform !== "win32") return false;
117 if (code === 9009) return true;
118 return hasWindowsCommandNotFoundMessage(stderr);
119 },
120);
117121
118122const collectText = Effect.fn("processRunner.collectText")(function* (input: {
119123 readonly command: string;
......@@ -231,18 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
231235 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
232236 const outputMode = input.outputMode ?? "error";
233237 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
235245 const child = yield* spawner
236246 .spawn(
237 ChildProcess.make(input.command, [...input.args], {
247 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
238248 ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}),
239249 ...(input.env !== undefined
240250 ? {
241251 env: input.env,
242 extendEnv: true,
252 extendEnv,
243253 }
244254 : {}),
245 ...(input.shell !== undefined ? { shell: input.shell } : {}),
255 shell: spawnCommand.shell,
246256 }),
247257 )
248258 .pipe(
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+2
......@@ -89,6 +89,8 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa
8989 const processRunner = yield* ProcessRunner.ProcessRunner;
9090 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.
9294 const topLevelResult = yield* processRunner
9395 .run({
9496 command: "git",
apps/server/src/provider/Drivers/ClaudeHome.ts+4-3
......@@ -16,13 +16,14 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function
1616
1717export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* (
1818 config: Pick<ClaudeSettings, "homePath">,
19 baseEnv: NodeJS.ProcessEnv = process.env,
19 baseEnv?: NodeJS.ProcessEnv,
2020): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
21 const resolvedBaseEnv = baseEnv ?? process.env;
2122 const homePath = config.homePath.trim();
22 if (homePath.length === 0) return baseEnv;
23 if (homePath.length === 0) return resolvedBaseEnv;
2324 const resolvedHomePath = yield* resolveClaudeHomePath(config);
2425 return {
25 ...baseEnv,
26 ...resolvedBaseEnv,
2627 HOME: resolvedHomePath,
2728 };
2829});
apps/server/src/provider/Layers/ClaudeProvider.ts+15-9
......@@ -18,6 +18,7 @@ import {
1818 getProviderOptionCurrentValue,
1919 getProviderOptionDescriptors,
2020} from "@t3tools/shared/model";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";
2122import { compareSemverVersions } from "@t3tools/shared/semver";
2223import {
2324 query as claudeQuery,
......@@ -547,7 +548,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
547548 */
548549const probeClaudeCapabilities = (
549550 claudeSettings: ClaudeSettings,
550 environment: NodeJS.ProcessEnv = process.env,
551 environment?: NodeJS.ProcessEnv,
551552) => {
552553 const abort = new AbortController();
553554 return Effect.gen(function* () {
......@@ -603,12 +604,15 @@ const probeClaudeCapabilities = (
603604const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
604605 claudeSettings: ClaudeSettings,
605606 args: ReadonlyArray<string>,
606 environment: NodeJS.ProcessEnv = process.env,
607 environment?: NodeJS.ProcessEnv,
607608) {
608609 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
609 const command = ChildProcess.make(claudeSettings.binaryPath, [...args], {
610 const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, {
610611 env: claudeEnvironment,
611 shell: process.platform === "win32",
612 });
613 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
614 env: claudeEnvironment,
615 shell: spawnCommand.shell,
612616 });
613617 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
614618});
......@@ -618,12 +622,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
618622 resolveCapabilities?: (
619623 claudeSettings: ClaudeSettings,
620624 ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
621 environment: NodeJS.ProcessEnv = process.env,
625 environment?: NodeJS.ProcessEnv,
622626): Effect.fn.Return<
623627 ServerProviderDraft,
624628 never,
625629 ChildProcessSpawner.ChildProcessSpawner | Path.Path
626630> {
631 const resolvedEnvironment = environment ?? process.env;
627632 const checkedAt = DateTime.formatIso(yield* DateTime.now);
628633 const allModels = providerModelsFromSettings(
629634 BUILT_IN_MODELS,
......@@ -648,10 +653,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
648653 });
649654 }
650655
651 const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe(
652 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
653 Effect.result,
654 );
656 const versionProbe = yield* runClaudeCommand(
657 claudeSettings,
658 ["--version"],
659 resolvedEnvironment,
660 ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result);
655661
656662 if (Result.isFailure(versionProbe)) {
657663 const error = versionProbe.failure;
apps/server/src/provider/Layers/CodexProvider.ts+36-14
......@@ -7,7 +7,7 @@ import * as Result from "effect/Result";
77import * as Schema from "effect/Schema";
88import * as Scope from "effect/Scope";
99import * as Types from "effect/Types";
10import { ChildProcessSpawner } from "effect/unstable/process";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1111import * as CodexClient from "effect-codex-app-server/client";
1212import * as CodexSchema from "effect-codex-app-server/schema";
1313import * as CodexErrors from "effect-codex-app-server/errors";
......@@ -24,6 +24,7 @@ import type {
2424import { ServerSettingsError } from "@t3tools/contracts";
2525
2626import { createModelCapabilities } from "@t3tools/shared/model";
27import { resolveSpawnCommand } from "@t3tools/shared/shell";
2728import {
2829 AUTH_PROBE_TIMEOUT_MS,
2930 buildServerProvider,
......@@ -33,6 +34,8 @@ import { expandHomePath } from "../../pathExpansion.ts";
3334import packageJson from "../../../package.json" with { type: "json" };
3435const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
3536
37const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
38
3639const CODEX_PRESENTATION = {
3740 displayName: "Codex",
3841 showInteractionModeToggle: true,
......@@ -292,17 +295,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
292295 // "CODEX_HOME points to '~/.codex_work', but that path does not exist".
293296 // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`.
294297 const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
295 const clientContext = yield* Layer.build(
296 CodexClient.layerCommand({
297 command: input.binaryPath,
298 args: ["app-server"],
299 cwd: input.cwd,
300 env: {
301 ...(input.environment ?? process.env),
302 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
303 },
304 }),
305 );
298 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
299 const environment = {
300 ...input.environment,
301 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
302 };
303 const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], {
304 env: environment,
305 extendEnv: true,
306 });
307 const child = yield* spawner
308 .spawn(
309 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
310 cwd: input.cwd,
311 env: environment,
312 extendEnv: true,
313 forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
314 shell: spawnCommand.shell,
315 }),
316 )
317 .pipe(
318 Effect.mapError(
319 (cause) =>
320 new CodexErrors.CodexAppServerSpawnError({
321 command: `${input.binaryPath} app-server`,
322 cause,
323 }),
324 ),
325 );
326 const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
306327 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
307328 Effect.provide(clientContext),
308329 );
......@@ -449,12 +470,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
449470 CodexErrors.CodexAppServerError,
450471 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
451472 > = probeCodexAppServerProvider,
452 environment: NodeJS.ProcessEnv = process.env,
473 environment?: NodeJS.ProcessEnv,
453474): Effect.fn.Return<
454475 ServerProviderDraft,
455476 ServerSettingsError,
456477 ChildProcessSpawner.ChildProcessSpawner
457478> {
479 const resolvedEnvironment = environment ?? process.env;
458480 const checkedAt = DateTime.formatIso(yield* DateTime.now);
459481 const emptyModels = emptyCodexModelsFromSettings(codexSettings);
460482
......@@ -480,7 +502,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
480502 homePath: codexSettings.homePath,
481503 cwd: process.cwd(),
482504 customModels: codexSettings.customModels,
483 environment,
505 environment: resolvedEnvironment,
484506 }).pipe(
485507 Effect.scoped,
486508 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
apps/server/src/provider/Layers/CodexSessionRuntime.ts+11-3
......@@ -16,6 +16,7 @@ import {
1616 ThreadId,
1717 TurnId,
1818} from "@t3tools/contracts";
19import { resolveSpawnCommand } from "@t3tools/shared/shell";
1920import { normalizeModelSlug } from "@t3tools/shared/model";
2021import * as Crypto from "effect/Crypto";
2122import * as DateTime from "effect/DateTime";
......@@ -720,16 +721,23 @@ export const makeCodexSessionRuntime = (
720721 // `CODEX_HOME=~/.codex_work` reach codex as an absolute path.
721722 const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined;
722723 const env = {
723 ...(options.environment ?? process.env),
724 ...options.environment,
724725 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
725726 };
727 const extendEnv = options.environment === undefined;
728 const spawnCommand = yield* resolveSpawnCommand(
729 options.binaryPath,
730 ["app-server", ...(options.appServerArgs ?? [])],
731 { env, extendEnv },
732 );
726733 const child = yield* spawner
727734 .spawn(
728 ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], {
735 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
729736 cwd: options.cwd,
730737 env,
738 extendEnv,
731739 forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER,
732 shell: process.platform === "win32",
740 shell: spawnCommand.shell,
733741 }),
734742 )
735743 .pipe(
apps/server/src/provider/Layers/CursorProvider.ts+17-14
......@@ -27,6 +27,7 @@ import {
2727 getProviderOptionBooleanSelectionValue,
2828 getProviderOptionStringSelectionValue,
2929} from "@t3tools/shared/model";
30import { resolveSpawnCommand } from "@t3tools/shared/shell";
3031
3132import {
3233 buildBooleanOptionDescriptor,
......@@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse(
394395
395396const makeCursorAcpProbeRuntime = (
396397 cursorSettings: CursorSettings,
397 environment: NodeJS.ProcessEnv = process.env,
398 environment?: NodeJS.ProcessEnv,
398399) =>
399400 Effect.gen(function* () {
400401 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
......@@ -407,7 +408,7 @@ const makeCursorAcpProbeRuntime = (
407408 "acp",
408409 ],
409410 cwd: process.cwd(),
410 env: environment,
411 ...(environment ? { env: environment } : {}),
411412 },
412413 cwd: process.cwd(),
413414 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
......@@ -421,7 +422,7 @@ const makeCursorAcpProbeRuntime = (
421422const withCursorAcpProbeRuntime = <A, E, R>(
422423 cursorSettings: CursorSettings,
423424 useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>,
424 environment: NodeJS.ProcessEnv = process.env,
425 environment?: NodeJS.ProcessEnv,
425426) =>
426427 makeCursorAcpProbeRuntime(cursorSettings, environment).pipe(
427428 Effect.flatMap(useRuntime),
......@@ -542,7 +543,7 @@ export function resolveCursorAcpConfigUpdates(
542543
543544const discoverCursorModelsViaListAvailableModels = (
544545 cursorSettings: CursorSettings,
545 environment: NodeJS.ProcessEnv = process.env,
546 environment?: NodeJS.ProcessEnv,
546547) =>
547548 withCursorAcpProbeRuntime(
548549 cursorSettings,
......@@ -558,7 +559,7 @@ const discoverCursorModelsViaListAvailableModels = (
558559
559560export const discoverCursorModelsViaAcp = (
560561 cursorSettings: CursorSettings,
561 environment: NodeJS.ProcessEnv = process.env,
562 environment?: NodeJS.ProcessEnv,
562563) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment);
563564
564565export function getCursorFallbackModels(
......@@ -927,13 +928,18 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult
927928const runCursorCommand = (
928929 cursorSettings: CursorSettings,
929930 args: ReadonlyArray<string>,
930 environment: NodeJS.ProcessEnv = process.env,
931 environment?: NodeJS.ProcessEnv,
931932) =>
932933 Effect.gen(function* () {
933934 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
934 const command = ChildProcess.make(cursorSettings.binaryPath, [...args], {
935 env: environment,
936 shell: process.platform === "win32",
935 const spawnCommand = yield* resolveSpawnCommand(
936 cursorSettings.binaryPath,
937 args,
938 environment ? { env: environment } : {},
939 );
940 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
941 ...(environment ? { env: environment } : { extendEnv: true }),
942 shell: spawnCommand.shell,
937943 });
938944
939945 const child = yield* spawner.spawn(command);
......@@ -949,10 +955,7 @@ const runCursorCommand = (
949955 return { stdout, stderr, code: exitCode } satisfies CommandResult;
950956 }).pipe(Effect.scoped);
951957
952const runCursorAboutCommand = (
953 cursorSettings: CursorSettings,
954 environment: NodeJS.ProcessEnv = process.env,
955) =>
958const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) =>
956959 Effect.gen(function* () {
957960 const jsonResult = yield* runCursorCommand(
958961 cursorSettings,
......@@ -967,7 +970,7 @@ const runCursorAboutCommand = (
967970
968971export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
969972 cursorSettings: CursorSettings,
970 environment: NodeJS.ProcessEnv = process.env,
973 environment?: NodeJS.ProcessEnv,
971974): Effect.fn.Return<
972975 ServerProviderDraft,
973976 never,
apps/server/src/provider/Layers/GrokProvider.ts+14-9
......@@ -15,6 +15,7 @@ import * as Result from "effect/Result";
1515import { HttpClient } from "effect/unstable/http";
1616import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1717import { createModelCapabilities } from "@t3tools/shared/model";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
1819
1920import {
2021 buildServerProvider,
......@@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = (
149150const runGrokVersionCommand = (
150151 grokSettings: GrokSettings,
151152 environment: NodeJS.ProcessEnv = process.env,
152) => {
153 const command = grokSettings.binaryPath || "grok";
154 return spawnAndCollect(
155 command,
156 ChildProcess.make(command, ["--version"], {
153) =>
154 Effect.gen(function* () {
155 const command = grokSettings.binaryPath || "grok";
156 const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], {
157157 env: environment,
158 shell: process.platform === "win32",
159 }),
160 );
161};
158 });
159 return yield* spawnAndCollect(
160 command,
161 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
162 env: environment,
163 shell: spawnCommand.shell,
164 }),
165 );
166 });
162167
163168export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
164169 grokSettings: GrokSettings,
apps/server/src/provider/Layers/OpenCodeProvider.ts+4-3
......@@ -301,9 +301,10 @@ export const makePendingOpenCodeProvider = (
301301export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
302302 openCodeSettings: OpenCodeSettings,
303303 cwd: string,
304 environment: NodeJS.ProcessEnv = process.env,
304 environment?: NodeJS.ProcessEnv,
305305): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> {
306306 const openCodeRuntime = yield* OpenCodeRuntime;
307 const resolvedEnvironment = environment ?? process.env;
307308 const checkedAt = DateTime.formatIso(yield* DateTime.now);
308309 const customModels = openCodeSettings.customModels;
309310 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
......@@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
364365 .runOpenCodeCommand({
365366 binaryPath: openCodeSettings.binaryPath,
366367 args: ["--version"],
367 environment,
368 environment: resolvedEnvironment,
368369 })
369370 .pipe(
370371 Effect.mapError(
......@@ -413,7 +414,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
413414 const server = yield* openCodeRuntime.connectToOpenCodeServer({
414415 binaryPath: openCodeSettings.binaryPath,
415416 serverUrl: openCodeSettings.serverUrl,
416 environment,
417 environment: resolvedEnvironment,
417418 });
418419 return yield* openCodeRuntime.loadOpenCodeInventory(
419420 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
10391039
10401040 // This test intentionally avoids `mockCommandSpawnerLayer` so the real
10411041 // `probeCodexAppServerProvider` path runs — including the full
1042 // `codex app-server` RPC handshake via `CodexClient.layerCommand`.
1042 // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`.
10431043 // We point `binaryPath` at a name that cannot exist on any machine so
10441044 // the real `ChildProcessSpawner` deterministically returns ENOENT; the
10451045 // probe wraps that as `CodexAppServerSpawnError` and
......@@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11591159 Effect.gen(function* () {
11601160 const firstMissing = `t3code_codex_first_`;
11611161 const secondMissing = `t3code_codex_second_`;
1162 const spawnedCommands: Array<string> = [];
11621163 const serverSettings = yield* makeMutableServerSettingsService(
11631164 decodeServerSettings(
11641165 deepMerge(encodedDefaultServerSettings, {
......@@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11851186 Layer.provideMerge(TestHttpClientLive),
11861187 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
11871188 Layer.provideMerge(OpenCodeRuntimeLive),
1188 // `it.live` does not inherit layers from the outer `it.layer`
1189 // wrapper, so provide `NodeServices.layer` inline. This is the
1190 // same real `ChildProcessSpawner` + `FileSystem` + `Path`
1191 // services that production uses.
1189 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
1190 ChildProcessSpawner.make((command) => {
1191 spawnedCommands.push((command as { readonly command: string }).command);
1192 return spawner.spawn(command);
1193 }),
1194 ),
11921195 Layer.provideMerge(NodeServices.layer),
11931196 );
11941197 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
......@@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11991202 const registry = yield* ProviderRegistry;
12001203 // Boot-time probe: the default codex instance is enabled with
12011204 // `firstMissing`, so the real spawner yields ENOENT and the
1202 // snapshot should be `status: "error"`. What *distinguishes*
1203 // the two probe runs is `checkedAt` — each probe stamps a
1204 // fresh DateTime, so we capture it and assert it advances
1205 // after the settings mutation.
1205 // snapshot should be `status: "error"`.
12061206 let initialProviders = yield* registry.getProviders;
12071207 for (
12081208 let attempts = 0;
......@@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
12201220 );
12211221 assert.strictEqual(initialCodex?.status, "error");
12221222 assert.strictEqual(initialCodex?.installed, false);
1223 const initialCheckedAt = initialCodex?.checkedAt;
1224 assert.notStrictEqual(initialCheckedAt, undefined);
1225
1226 // The rebuilt instance may re-probe synchronously during the
1227 // settings update. Advance the TestClock first so `checkedAt`
1228 // can safely act as the fresh-probe marker this assertion uses.
1229 yield* TestClock.adjust("1 second");
1223 assert.deepStrictEqual(spawnedCommands, [firstMissing]);
12301224
12311225 // Drive a settings change. The Hydration layer's
12321226 // `SettingsWatcherLive` consumes this via `streamChanges`,
......@@ -1242,8 +1236,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
12421236 },
12431237 });
12441238
1245 // Poll with TestClock until `checkedAt` advances or we hit a
1246 // generous virtual 3-second ceiling.
1239 // Poll until the injected process boundary observes the new
1240 // executable. This verifies the public settings-to-probe behavior
1241 // without depending on timestamps assigned by TestClock.
12471242 const refreshed = yield* Effect.gen(function* () {
12481243 for (let attempts = 0; attempts < 60; attempts += 1) {
12491244 const providers = yield* registry.getProviders;
......@@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
12511246 if (
12521247 codex !== undefined &&
12531248 codex.status === "error" &&
1254 codex.checkedAt !== initialCheckedAt
1249 spawnedCommands.includes(secondMissing)
12551250 ) {
12561251 return providers;
12571252 }
......@@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
12621257 });
12631258
12641259 const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex");
1265 assert.notStrictEqual(
1266 reprobedCodex?.checkedAt,
1267 initialCheckedAt,
1268 "Expected a fresh probe after settings change, got the stale snapshot",
1269 );
1260 assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);
12701261 assert.strictEqual(reprobedCodex?.status, "error");
12711262 assert.strictEqual(reprobedCodex?.installed, false);
12721263 }).pipe(Effect.provide(runtimeServices));
apps/server/src/provider/acp/AcpSessionRuntime.ts+9-3
......@@ -13,6 +13,7 @@ import * as EffectAcpClient from "effect-acp/client";
1313import * as EffectAcpErrors from "effect-acp/errors";
1414import type * as EffectAcpSchema from "effect-acp/schema";
1515import type * as EffectAcpProtocol from "effect-acp/protocol";
16import { resolveSpawnCommand } from "@t3tools/shared/shell";
1617
1718import {
1819 collectSessionConfigOptionValues,
......@@ -201,12 +202,17 @@ const makeAcpSessionRuntime = (
201202 ),
202203 );
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 );
204210 const child = yield* spawner
205211 .spawn(
206 ChildProcess.make(options.spawn.command, [...options.spawn.args], {
212 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
207213 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
208 ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}),
209 shell: process.platform === "win32",
214 ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}),
215 shell: spawnCommand.shell,
210216 }),
211217 )
212218 .pipe(
apps/server/src/provider/opencodeRuntime.ts+17-9
......@@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3131import { isWindowsCommandNotFound } from "../processRunner.ts";
3232import { collectStreamAsString } from "./providerSnapshot.ts";
3333import * as NetService from "@t3tools/shared/Net";
34import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
35import { resolveSpawnCommand } from "@t3tools/shared/shell";
3436const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);
3537const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
3638
......@@ -276,13 +278,17 @@ function ensureRuntimeError(
276278const makeOpenCodeRuntime = Effect.gen(function* () {
277279 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
278280 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
280285 const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>
281286 Effect.gen(function* () {
287 const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);
282288 const child = yield* spawner.spawn(
283 ChildProcess.make(input.binaryPath, [...input.args], {
284 shell: process.platform === "win32",
285 env: input.environment ?? process.env,
289 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
290 shell: spawnCommand.shell,
291 ...(input.environment ? { env: input.environment } : { extendEnv: true }),
286292 }),
287293 );
288294 const [stdout, stderr, code] = yield* Effect.all(
......@@ -290,7 +296,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
290296 { concurrency: "unbounded" },
291297 );
292298 const exitCode = Number(code);
293 if (isWindowsCommandNotFound(exitCode, stderr)) {
299 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
294300 return yield* new OpenCodeRuntimeError({
295301 operation: "runOpenCodeCommand",
296302 detail: `spawn ${input.binaryPath} ENOENT`,
......@@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
334340 ));
335341 const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
336342 const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];
343 const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment);
337344
338345 const child = yield* spawner
339346 .spawn(
340 ChildProcess.make(input.binaryPath, args, {
341 detached: process.platform !== "win32",
342 shell: process.platform === "win32",
347 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
348 detached: hostPlatform !== "win32",
349 shell: spawnCommand.shell,
343350 env: {
344 ...(input.environment ?? process.env),
351 ...input.environment,
345352 OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT,
346353 },
354 extendEnv: input.environment === undefined,
347355 }),
348356 )
349357 .pipe(
......@@ -359,7 +367,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
359367 );
360368
361369 const killOpenCodeProcessGroup = (signal: NodeJS.Signals) =>
362 process.platform === "win32"
370 hostPlatform === "win32"
363371 ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)
364372 : Effect.sync(() => {
365373 try {
apps/server/src/provider/providerMaintenance.test.ts+68-39
......@@ -1,19 +1,22 @@
11// @effect-diagnostics nodeBuiltinImport:off
2import { afterEach, expect, it } from "@effect/vitest";
2import { expect, it } from "@effect/vitest";
33import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
44import * as NodeServices from "@effect/platform-node/NodeServices";
5import os from "node:os";
5import * as NodeOS from "node:os";
66import path from "node:path";
77import { ProviderDriverKind } from "@t3tools/contracts";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
89import * as Crypto from "effect/Crypto";
910import * as Effect from "effect/Effect";
11import { HttpClient } from "effect/unstable/http";
1012import {
11 clearLatestProviderVersionCacheForTests,
1213 createProviderVersionAdvisory,
1314 makePackageManagedProviderMaintenanceResolver,
1415 makeProviderMaintenanceCapabilities,
1516 makeStaticProviderMaintenanceResolver,
1617 normalizeCommandPath,
18 ProviderVersionCache,
19 resolveLatestProviderVersion,
1720 resolveProviderMaintenanceCapabilitiesEffect,
1821} from "./providerMaintenance.ts";
1922
......@@ -21,7 +24,7 @@ const driver = (value: string) => ProviderDriverKind.make(value);
2124const makeTempDir = (name: string) =>
2225 Crypto.Crypto.pipe(
2326 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}`)),
2528 );
2629const isNativeTestCommandPath =
2730 (expectedPathSegment: string) =>
......@@ -65,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver(
6568 }),
6669);
6770
68afterEach(() => {
69 clearLatestProviderVersionCacheForTests();
70});
71
7271it.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
7398 it("marks providers with unknown current versions as unknown", () => {
7499 expect(
75100 createProviderVersionAdvisory({
......@@ -144,15 +169,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
144169 writeFileSync(packageToolPath, "#!/bin/sh\n");
145170 chmodSync(packageToolPath, 0o755);
146171
147 expect(
148 packageToolUpdate.resolve({
172 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
173 packageToolUpdate,
174 {
149175 binaryPath: "package-tool",
150 platform: "darwin",
151176 env: {
152177 PATH: vitePlusBinDir,
153178 },
154 }),
155 ).toEqual({
179 },
180 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
181
182 expect(capabilities).toEqual({
156183 provider: driver("packageTool"),
157184 packageName: "@example/package-tool",
158185 update: {
......@@ -177,16 +204,18 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
177204 mkdirSync(bunBinDir, { recursive: true });
178205 writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ");
179206
180 expect(
181 nativePackageToolUpdate.resolve({
207 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
208 nativePackageToolUpdate,
209 {
182210 binaryPath: "native-package-tool",
183 platform: "win32",
184211 env: {
185212 PATH: bunBinDir,
186213 PATHEXT: ".COM;.EXE;.BAT;.CMD",
187214 },
188 }),
189 ).toEqual({
215 },
216 ).pipe(Effect.provideService(HostProcessPlatform, "win32"));
217
218 expect(capabilities).toEqual({
190219 provider: driver("nativePackageTool"),
191220 packageName: "@example/native-package-tool",
192221 update: {
......@@ -213,15 +242,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
213242 writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");
214243 chmodSync(scopedPackageToolPath, 0o755);
215244
216 expect(
217 scopedPackageToolUpdate.resolve({
245 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
246 scopedPackageToolUpdate,
247 {
218248 binaryPath: "scoped-package-tool",
219 platform: "darwin",
220249 env: {
221250 PATH: pnpmHomeDir,
222251 },
223 }),
224 ).toEqual({
252 },
253 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
254
255 expect(capabilities).toEqual({
225256 provider: driver("scopedPackageTool"),
226257 packageName: "@example/scoped-package-tool",
227258 update: {
......@@ -241,7 +272,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
241272 expect(
242273 packageToolUpdate.resolve({
243274 binaryPath: "/opt/homebrew/bin/package-tool",
244 platform: "darwin",
245275 env: {
246276 PATH: "",
247277 },
......@@ -272,15 +302,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
272302 writeFileSync(nativePackageToolPath, "#!/bin/sh\n");
273303 chmodSync(nativePackageToolPath, 0o755);
274304
275 expect(
276 nativePackageToolUpdate.resolve({
305 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
306 nativePackageToolUpdate,
307 {
277308 binaryPath: "native-package-tool",
278 platform: "darwin",
279309 env: {
280310 PATH: nativeBinDir,
281311 },
282 }),
283 ).toEqual({
312 },
313 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
314
315 expect(capabilities).toEqual({
284316 provider: driver("nativePackageTool"),
285317 packageName: "@example/native-package-tool",
286318 update: {
......@@ -307,15 +339,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
307339 writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");
308340 chmodSync(scopedPackageToolPath, 0o755);
309341
310 expect(
311 scopedPackageToolUpdate.resolve({
342 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
343 scopedPackageToolUpdate,
344 {
312345 binaryPath: "scoped-package-tool",
313 platform: "darwin",
314346 env: {
315347 PATH: nativeBinDir,
316348 },
317 }),
318 ).toEqual({
349 },
350 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
351
352 expect(capabilities).toEqual({
319353 provider: driver("scopedPackageTool"),
320354 packageName: "@example/scoped-package-tool",
321355 update: {
......@@ -335,7 +369,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
335369 expect(
336370 nativePackageToolUpdate.resolve({
337371 binaryPath: "/opt/homebrew/bin/native-package-tool",
338 platform: "darwin",
339372 env: {
340373 PATH: "",
341374 },
......@@ -359,7 +392,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
359392 expect(
360393 scopedPackageToolUpdate.resolve({
361394 binaryPath: "/opt/homebrew/bin/scoped-package-tool",
362 platform: "darwin",
363395 env: {
364396 PATH: "",
365397 },
......@@ -401,7 +433,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
401433
402434 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
403435 binaryPath: symlinkPath,
404 platform: "darwin",
405436 env: {
406437 PATH: "",
407438 },
......@@ -449,7 +480,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
449480
450481 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
451482 binaryPath: symlinkPath,
452 platform: "darwin",
453483 env: {
454484 PATH: "",
455485 },
......@@ -475,7 +505,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
475505 expect(
476506 packageToolUpdate.resolve({
477507 binaryPath: "C:\\Tools\\package-tool\\package-tool.exe",
478 platform: "win32",
479508 env: {
480509 PATH: "",
481510 PATHEXT: ".COM;.EXE;.BAT;.CMD",
apps/server/src/provider/providerMaintenance.ts+37-15
......@@ -5,6 +5,8 @@ import {
55} from "@t3tools/contracts";
66import { compareSemverVersions } from "@t3tools/shared/semver";
77import { resolveCommandPath } from "@t3tools/shared/shell";
8import * as Config from "effect/Config";
9import * as Context from "effect/Context";
810import * as DateTime from "effect/DateTime";
911import * as Effect from "effect/Effect";
1012import * as FileSystem from "effect/FileSystem";
......@@ -16,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
1618const LATEST_VERSION_TIMEOUT_MS = 4_000;
1719const 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
1940export interface ProviderMaintenanceCapabilities {
2041 readonly provider: ProviderDriverKind;
2142 readonly packageName: string | null;
......@@ -32,7 +53,7 @@ export interface ProviderMaintenanceCommandAction {
3253export interface ProviderMaintenanceCapabilityResolutionOptions {
3354 readonly binaryPath?: string | null;
3455 readonly env?: NodeJS.ProcessEnv;
35 readonly platform?: NodeJS.Platform;
56 readonly resolvedCommandPath?: string | null;
3657 readonly realCommandPath?: string | null;
3758}
3859
......@@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition {
5475 } | null;
5576}
5677
57interface LatestVersionCacheEntry {
78export interface ProviderVersionCacheEntry {
5879 readonly expiresAt: number;
5980 readonly version: string | null;
6081}
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);
6389const NpmLatestVersionResponse = Schema.Struct({
6490 version: Schema.optional(Schema.String),
6591});
6692
67export function clearLatestProviderVersionCacheForTests(): void {
68 latestVersionCache.clear();
69}
70
7193function nonEmptyString(value: unknown): string | null {
7294 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
7395}
......@@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance(
251273 }
252274
253275 const resolvedCommandPath =
254 resolveCommandPath(binaryPath, {
255 ...(options?.platform ? { platform: options.platform } : {}),
256 ...(options?.env ? { env: options.env } : {}),
257 }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
276 options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
258277
259278 if (resolvedCommandPath) {
260279 const commandPaths = [
......@@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
335354 return resolver.resolve(options);
336355 }
337356
357 const env = options?.env ?? (yield* readCommandLookupEnv);
338358 const resolvedCommandPath =
339 resolveCommandPath(binaryPath, {
340 ...(options?.platform ? { platform: options.platform } : {}),
341 ...(options?.env ? { env: options.env } : {}),
342 }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
359 (yield* resolveCommandPath(binaryPath, { env }).pipe(
360 Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)),
361 )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
343362 if (!resolvedCommandPath) {
344363 return resolver.resolve(options);
345364 }
......@@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
350369 .pipe(Effect.orElseSucceed(() => resolvedCommandPath));
351370 return resolver.resolve({
352371 ...options,
372 env,
373 resolvedCommandPath,
353374 realCommandPath,
354375 });
355376});
......@@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers
430451 return null;
431452 }
432453
454 const latestVersionCache = yield* ProviderVersionCache;
433455 const cached = latestVersionCache.get(packageName);
434456 const now = DateTime.toEpochMillis(yield* DateTime.now);
435457 if (cached && cached.expiresAt > now) {
apps/server/src/provider/providerMaintenanceRunner.test.ts+8-7
......@@ -1,4 +1,4 @@
1import { afterEach, describe, it, assert } from "@effect/vitest";
1import { describe, it, assert } from "@effect/vitest";
22import {
33 ProviderDriverKind,
44 ProviderInstanceId,
......@@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2121import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
2222import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
2323import {
24 clearLatestProviderVersionCacheForTests,
2524 makeProviderMaintenanceCapabilities,
25 ProviderVersionCache,
2626 type ProviderMaintenanceCapabilities,
2727} from "./providerMaintenance.ts";
2828const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
......@@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor");
3535const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
3636const encoder = new TextEncoder();
3737
38afterEach(() => {
39 clearLatestProviderVersionCacheForTests();
40});
41
4238function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities {
4339 if (provider === CURSOR_DRIVER) {
4440 return makeProviderMaintenanceCapabilities({
......@@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) =>
202198 Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe(
203199 Effect.provide(
204200 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 ),
206207 ),
207208 ),
208209 );
apps/server/src/provider/providerSnapshot.ts+1-1
......@@ -75,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman
7575 );
7676
7777 const result: CommandResult = { stdout, stderr, code: exitCode };
78 if (isWindowsCommandNotFound(exitCode, stderr)) {
78 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
7979 return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` });
8080 }
8181 return result;
apps/server/src/server.test.ts+1
......@@ -579,6 +579,7 @@ const buildAppUnderTest = (options?: {
579579 ),
580580 Layer.provide(
581581 Layer.mock(ExternalLauncher.ExternalLauncher)({
582 resolveAvailableEditors: () => Effect.succeed([]),
582583 ...options?.layers?.externalLauncher,
583584 }),
584585 ),
apps/server/src/server.ts+1-1
......@@ -365,7 +365,7 @@ export const makeServerLayer = Layer.unwrap(
365365 Effect.gen(function* () {
366366 const config = yield* ServerConfig;
367367
368 fixPath();
368 yield* fixPath();
369369
370370 const httpListeningLayer = Layer.effectDiscard(
371371 Effect.gen(function* () {
apps/server/src/startupAccess.ts+4-4
......@@ -1,4 +1,4 @@
1import { networkInterfaces } from "node:os";
1import * as NodeOS from "node:os";
22
33import { QrCode } from "@t3tools/shared/qrCode";
44import * as Effect from "effect/Effect";
......@@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo {
1313 readonly pairingUrl: string;
1414}
1515
16type NetworkInterfacesMap = ReturnType<typeof networkInterfaces>;
16type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
1717
1818export const isLoopbackHost = (host: string | undefined): boolean => {
1919 if (!host || host.length === 0) {
......@@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" ||
4444
4545export const resolveHeadlessConnectionHost = (
4646 host: string | undefined,
47 interfaces: NetworkInterfacesMap = networkInterfaces(),
47 interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(),
4848): string => {
4949 if (!host) {
5050 return "localhost";
......@@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = (
7171export const resolveHeadlessConnectionString = (
7272 host: string | undefined,
7373 port: number,
74 interfaces: NetworkInterfacesMap = networkInterfaces(),
74 interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(),
7575): string => {
7676 const connectionHost = resolveHeadlessConnectionHost(host, interfaces);
7777 return `http://${formatHostForUrl(connectionHost)}:${port}`;
apps/server/src/telemetry/Identify.ts+4-3
......@@ -1,10 +1,11 @@
1import * as NodeOS from "node:os";
12import * as Crypto from "effect/Crypto";
23import * as Effect from "effect/Effect";
34import * as Encoding from "effect/Encoding";
45import * as FileSystem from "effect/FileSystem";
56import * as Path from "effect/Path";
67import * as Schema from "effect/Schema";
7import { homedir } from "node:os";
8
89import { ServerConfig } from "../config.ts";
910
1011const CodexAuthJsonSchema = Schema.Struct({
......@@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () {
3940 const fileSystem = yield* FileSystem.FileSystem;
4041 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");
4344 const authJson = yield* Effect.flatMap(
4445 fileSystem.readFileString(authJsonPath),
4546 Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)),
......@@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () {
5253 const fileSystem = yield* FileSystem.FileSystem;
5354 const path = yield* Path.Path;
5455
55 const claudeJsonPath = path.join(homedir(), ".claude.json");
56 const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json");
5657 const claudeJson = yield* Effect.flatMap(
5758 fileSystem.readFileString(claudeJsonPath),
5859 Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)),
apps/server/src/telemetry/Layers/AnalyticsService.ts+8-3
......@@ -7,10 +7,12 @@
77 * @module AnalyticsServiceLive
88 */
99
10import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1011import * as Config from "effect/Config";
1112import * as DateTime from "effect/DateTime";
1213import * as Effect from "effect/Effect";
1314import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
1416import * as Ref from "effect/Ref";
1517import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1618
......@@ -37,6 +39,7 @@ const TelemetryEnvConfig = Config.all({
3739 maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
3840 Config.withDefault(1_000),
3941 ),
42 wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option),
4043});
4144
4245const makeAnalyticsService = Effect.gen(function* () {
......@@ -46,6 +49,8 @@ const makeAnalyticsService = Effect.gen(function* () {
4649 const identifier = yield* getTelemetryIdentifier;
4750 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
4851 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
52 const hostPlatform = yield* HostProcessPlatform;
53 const hostArchitecture = yield* HostProcessArchitecture;
4954
5055 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
5156 Effect.flatMap(DateTime.now, (now) =>
......@@ -87,9 +92,9 @@ const makeAnalyticsService = Effect.gen(function* () {
8792 properties: {
8893 ...event.properties,
8994 $process_person_profile: false,
90 platform: process.platform,
91 wsl: process.env.WSL_DISTRO_NAME,
92 arch: process.arch,
95 platform: hostPlatform,
96 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
97 arch: hostArchitecture,
9398 t3CodeVersion: packageJson.version,
9499 clientType,
95100 },
apps/server/src/terminal/Layers/BunPTY.ts+3-1
......@@ -2,6 +2,7 @@
22
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
56import { PtyAdapter } from "../Services/PTY.ts";
67import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts";
78
......@@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess {
9596export const layer = Layer.effect(
9697 PtyAdapter,
9798 Effect.gen(function* () {
98 if (process.platform === "win32") {
99 const platform = yield* HostProcessPlatform;
100 if (platform === "win32") {
99101 return yield* Effect.die(
100102 "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.",
101103 );
apps/server/src/terminal/Layers/Manager.test.ts+36-56
......@@ -8,6 +8,7 @@ import {
88 type TerminalOpenInput,
99 type TerminalRestartInput,
1010} from "@t3tools/contracts";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1112import * as Data from "effect/Data";
1213import * as Duration from "effect/Duration";
1314import * as Effect from "effect/Effect";
......@@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = (
199200
200201interface CreateManagerOptions {
201202 shellResolver?: () => string;
202 platform?: NodeJS.Platform;
203203 env?: NodeJS.ProcessEnv;
204204 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
205205 readonly hasRunningSubprocess: boolean;
......@@ -240,7 +240,6 @@ const createManager = (
240240 historyLineLimit,
241241 ptyAdapter,
242242 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
243 ...(options.platform !== undefined ? { platform: options.platform } : {}),
244243 ...(options.env !== undefined ? { env: options.env } : {}),
245244 ...(options.subprocessInspector !== undefined
246245 ? { subprocessInspector: options.subprocessInspector }
......@@ -270,12 +269,13 @@ const createManager = (
270269 }),
271270 );
272271
272const withHostPlatform = (platform: NodeJS.Platform) =>
273 Layer.succeed(HostProcessPlatform, platform);
274
273275it.layer(
274276 Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))),
275277 { excludeTestServices: true },
276278)("TerminalManager", (it) => {
277 const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect;
278
279279 it.effect("spawns lazily and reuses running terminal per thread", () =>
280280 Effect.gen(function* () {
281281 const { manager, ptyAdapter } = yield* createManager();
......@@ -415,8 +415,10 @@ it.layer(
415415 fs.writeFileString(filePath, contents),
416416 );
417417
418 itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () =>
418 it.effect("preserves non-notFound cwd stat failures", () =>
419419 Effect.gen(function* () {
420 if ((yield* HostProcessPlatform) === "win32") return;
421
420422 const path = yield* Path.Path;
421423
422424 const { manager, baseDir } = yield* createManager();
......@@ -1082,10 +1084,9 @@ it.layer(
10821084
10831085 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
10841086 Effect.gen(function* () {
1087 const platform = yield* HostProcessPlatform;
10851088 const missingShell =
1086 process.platform === "win32"
1087 ? "C:\\definitely\\missing-shell.exe"
1088 : "/definitely/missing-shell -l";
1089 platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l";
10891090 const { manager, ptyAdapter } = yield* createManager(5, {
10901091 shellResolver: () => missingShell,
10911092 });
......@@ -1096,10 +1097,10 @@ it.layer(
10961097 assert.equal(snapshot.status, "running");
10971098 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
10981099 expect(ptyAdapter.spawnInputs[0]?.shell).toBe(
1099 process.platform === "win32" ? missingShell : "/definitely/missing-shell",
1100 platform === "win32" ? missingShell : "/definitely/missing-shell",
11001101 );
11011102
1102 if (process.platform === "win32") {
1103 if (platform === "win32") {
11031104 expect(
11041105 ptyAdapter.spawnInputs.some(
11051106 (input) =>
......@@ -1121,13 +1122,12 @@ it.layer(
11211122 it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
11221123 Effect.gen(function* () {
11231124 const { manager, ptyAdapter } = yield* createManager(5, {
1124 platform: "win32",
11251125 env: {
11261126 ComSpec: "C:\\Windows\\System32\\cmd.exe",
11271127 PATH: "C:\\Windows\\System32",
11281128 SystemRoot: "C:\\Windows",
11291129 },
1130 });
1130 }).pipe(Effect.provide(withHostPlatform("win32")));
11311131
11321132 yield* manager.open(openInput());
11331133
......@@ -1142,15 +1142,16 @@ it.layer(
11421142
11431143 it.effect("falls back to built-in PowerShell by absolute path on Windows", () =>
11441144 Effect.gen(function* () {
1145 const { manager, ptyAdapter } = yield* createManager(5, {
1146 platform: "win32",
1145 const ptyAdapter = new FakePtyAdapter();
1146 const { manager } = yield* createManager(5, {
1147 ptyAdapter,
1148 shellResolver: () => "C:\\missing\\custom-shell.exe",
11471149 env: {
11481150 ComSpec: "C:\\Windows\\System32\\cmd.exe",
11491151 PATH: "C:\\Windows\\System32",
11501152 SystemRoot: "C:\\Windows",
11511153 },
1152 shellResolver: () => "C:\\missing\\custom-shell.exe",
1153 });
1154 }).pipe(Effect.provide(withHostPlatform("win32")));
11541155 ptyAdapter.spawnFailures.push(
11551156 new Error("spawn custom-shell.exe ENOENT"),
11561157 new Error("spawn pwsh.exe ENOENT"),
......@@ -1170,46 +1171,25 @@ it.layer(
11701171
11711172 it.effect("filters app runtime env variables from terminal sessions", () =>
11721173 Effect.gen(function* () {
1173 const originalValues = new Map<string, string | undefined>();
1174 const setEnv = (key: string, value: string | undefined) => {
1175 if (!originalValues.has(key)) {
1176 originalValues.set(key, process.env[key]);
1177 }
1178 if (value === undefined) {
1179 delete process.env[key];
1180 return;
1181 }
1182 process.env[key] = value;
1183 };
1184 const restoreEnv = () => {
1185 for (const [key, value] of originalValues) {
1186 if (value === undefined) {
1187 delete process.env[key];
1188 } else {
1189 process.env[key] = value;
1190 }
1191 }
1192 };
1193
1194 setEnv("PORT", "5173");
1195 setEnv("T3CODE_PORT", "3773");
1196 setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173");
1197 setEnv("TEST_TERMINAL_KEEP", "keep-me");
1174 const { manager, ptyAdapter } = yield* createManager(5, {
1175 env: {
1176 PORT: "5173",
1177 T3CODE_PORT: "3773",
1178 VITE_DEV_SERVER_URL: "http://localhost:5173",
1179 TEST_TERMINAL_KEEP: "keep-me",
1180 },
1181 });
1182 yield* manager.open(openInput());
1183 const spawnInput = ptyAdapter.spawnInputs[0];
1184 expect(spawnInput).toBeDefined();
1185 if (!spawnInput) return;
11981186
1199 try {
1200 const { manager, ptyAdapter } = yield* createManager();
1201 yield* manager.open(openInput());
1202 const spawnInput = ptyAdapter.spawnInputs[0];
1203 expect(spawnInput).toBeDefined();
1204 if (!spawnInput) return;
1205
1206 expect(spawnInput.env.PORT).toBeUndefined();
1207 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
1208 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
1209 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
1210 } finally {
1211 restoreEnv();
1212 }
1187 expect(spawnInput.env.PORT).toBeUndefined();
1188 expect(spawnInput.env.T3CODE_PORT).toBeUndefined();
1189 expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined();
1190 // Arbitrary host env vars must pass through — terminals inherit the
1191 // user's environment apart from the explicit blocklist.
1192 expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me");
12131193 }),
12141194 );
12151195
......@@ -1237,7 +1217,7 @@ it.layer(
12371217
12381218 it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () =>
12391219 Effect.gen(function* () {
1240 if (process.platform === "win32") return;
1220 if ((yield* HostProcessPlatform) === "win32") return;
12411221 const { manager, ptyAdapter } = yield* createManager(5, {
12421222 shellResolver: () => "/bin/zsh",
12431223 });
apps/server/src/terminal/Layers/Manager.ts+14-10
......@@ -10,6 +10,7 @@ import {
1010 type TerminalSummary,
1111} from "@t3tools/contracts";
1212import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1314import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
1415import * as DateTime from "effect/DateTime";
1516import * as Effect from "effect/Effect";
......@@ -312,10 +313,7 @@ function enqueueProcessEvent(
312313 return true;
313314}
314315
315function defaultShellResolver(
316 platform: NodeJS.Platform = process.platform,
317 env: NodeJS.ProcessEnv = process.env,
318): string {
316function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
319317 if (platform === "win32") {
320318 return "pwsh.exe";
321319 }
......@@ -324,7 +322,7 @@ function defaultShellResolver(
324322
325323function normalizeShellCommand(
326324 value: string | undefined,
327 platform: NodeJS.Platform = process.platform,
325 platform: NodeJS.Platform,
328326): string | null {
329327 if (!value) return null;
330328 const trimmed = value.trim();
......@@ -360,7 +358,7 @@ function joinWindowsPath(...parts: ReadonlyArray<string>): string {
360358
361359function shellCandidateFromCommand(
362360 command: string | null,
363 platform: NodeJS.Platform = process.platform,
361 platform: NodeJS.Platform,
364362): ShellCandidate | null {
365363 if (!command || command.length === 0) return null;
366364 const shellName = basenameForPlatform(command, platform).toLowerCase();
......@@ -411,8 +409,8 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC
411409
412410function resolveShellCandidates(
413411 shellResolver: () => string,
414 platform: NodeJS.Platform = process.platform,
415 env: NodeJS.ProcessEnv = process.env,
412 platform: NodeJS.Platform,
413 env: NodeJS.ProcessEnv,
416414): ShellCandidate[] {
417415 const requested = shellCandidateFromCommand(
418416 normalizeShellCommand(shellResolver(), platform),
......@@ -512,6 +510,9 @@ function windowsInspectSubprocess(
512510 return Effect.gen(function* () {
513511 const processRunner = yield* ProcessRunner.ProcessRunner;
514512 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.
515516 command: "powershell.exe",
516517 args: ["-NoProfile", "-NonInteractive", "-Command", command],
517518 timeout: "1500 millis",
......@@ -976,7 +977,6 @@ interface TerminalManagerOptions {
976977 historyLineLimit?: number;
977978 ptyAdapter: PtyAdapterShape;
978979 shellResolver?: () => string;
979 platform?: NodeJS.Platform;
980980 env?: NodeJS.ProcessEnv;
981981 subprocessInspector?: TerminalSubprocessInspector;
982982 subprocessPollIntervalMs?: number;
......@@ -1014,7 +1014,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
10141014
10151015 const logsDir = options.logsDir;
10161016 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.
10181022 const baseEnv = options.env ?? process.env;
10191023 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
10201024 const processRunner = yield* ProcessRunner.ProcessRunner;
apps/server/src/terminal/Layers/NodePTY.test.ts+57-46
......@@ -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";
71import * as NodeServices from "@effect/platform-node/NodeServices";
8
9it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => {
10 it.effect("adds executable bits when helper exists but is not executable", () =>
11 Effect.gen(function* () {
12 if (process.platform === "win32") return;
13
14 const fs = yield* FileSystem.FileSystem;
15 const path = yield* Path.Path;
16
17 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" });
18 const helperPath = path.join(dir, "spawn-helper");
19 yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n");
20 yield* fs.chmod(helperPath, 0o644);
21
22 yield* ensureNodePtySpawnHelperExecutable(helperPath);
23
24 const mode = (yield* fs.stat(helperPath)).mode & 0o777;
25 assert.equal(mode & 0o111, 0o111);
26 }),
27 );
28
29 it.effect("keeps executable helper as executable", () =>
30 Effect.gen(function* () {
31 if (process.platform === "win32") return;
32
33 const fs = yield* FileSystem.FileSystem;
34 const path = yield* Path.Path;
35
36 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" });
37 const helperPath = path.join(dir, "spawn-helper");
38 yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n");
39 yield* fs.chmod(helperPath, 0o755);
40
41 yield* ensureNodePtySpawnHelperExecutable(helperPath);
42
43 const mode = (yield* fs.stat(helperPath)).mode & 0o777;
44 assert.equal(mode & 0o111, 0o111);
45 }),
46 );
47});
2import { assert, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { vi } from "vite-plus/test";
7
8import { PtyAdapter } from "../Services/PTY.ts";
9import { layer } from "./NodePTY.ts";
10
11const spawn = vi.fn(() => ({
12 pid: 42,
13 write: vi.fn(),
14 resize: vi.fn(),
15 kill: vi.fn(),
16 onData: vi.fn(() => ({ dispose: vi.fn() })),
17 onExit: vi.fn(() => ({ dispose: vi.fn() })),
18}));
19
20vi.mock("node-pty", () => ({ spawn }));
21
22const testLayer = layer.pipe(
23 Layer.provide(
24 Layer.mergeAll(
25 NodeServices.layer,
26 Layer.succeed(HostProcessPlatform, "win32"),
27 Layer.succeed(HostProcessArchitecture, "x64"),
28 ),
29 ),
30);
31
32it.effect("spawns through the public adapter with the provided host references", () =>
33 Effect.gen(function* () {
34 const adapter = yield* PtyAdapter;
35 const process = yield* adapter.spawn({
36 shell: "powershell.exe",
37 args: ["-NoLogo"],
38 cwd: "C:\\workspace",
39 cols: 120,
40 rows: 40,
41 env: {},
42 });
43
44 assert.equal(process.pid, 42);
45 assert.equal(spawn.mock.calls.length, 1);
46 assert.deepEqual(spawn.mock.calls[0], [
47 "powershell.exe",
48 ["-NoLogo"],
49 {
50 cwd: "C:\\workspace",
51 cols: 120,
52 rows: 40,
53 env: {},
54 name: "xterm-color",
55 },
56 ]);
57 }).pipe(Effect.provide(testLayer)),
58);
apps/server/src/terminal/Layers/NodePTY.ts+15-9
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as Path from "effect/Path";
7import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
78import { PtyAdapter } from "../Services/PTY.ts";
89import {
910 PtySpawnError,
......@@ -18,13 +19,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
1819 const requireForNodePty = createRequire(import.meta.url);
1920 const path = yield* Path.Path;
2021 const fs = yield* FileSystem.FileSystem;
22 const platform = yield* HostProcessPlatform;
23 const architecture = yield* HostProcessArchitecture;
2124
2225 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
2326 const packageDir = path.dirname(packageJsonPath);
2427 const candidates = [
2528 path.join(packageDir, "build", "Release", "spawn-helper"),
2629 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"),
2831 ];
2932
3033 for (const candidate of candidates) {
......@@ -35,16 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
3538 return null;
3639}).pipe(Effect.orElseSucceed(() => null));
3740
38export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) {
41const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () {
3942 const fs = yield* FileSystem.FileSystem;
40 if (process.platform === "win32") return;
41 if (!explicitPath && didEnsureSpawnHelperExecutable) return;
43 const platform = yield* HostProcessPlatform;
44 if (platform === "win32") return;
45 if (didEnsureSpawnHelperExecutable) return;
4246
43 const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath);
47 const helperPath = yield* resolveNodePtySpawnHelperPath;
4448 if (!helperPath) return;
45 if (!explicitPath) {
46 didEnsureSpawnHelperExecutable = true;
47 }
49 didEnsureSpawnHelperExecutable = true;
4850
4951 if (!(yield* fs.exists(helperPath))) {
5052 return;
......@@ -102,6 +104,8 @@ export const layer = Layer.effect(
102104 Effect.gen(function* () {
103105 const fs = yield* FileSystem.FileSystem;
104106 const path = yield* Path.Path;
107 const platform = yield* HostProcessPlatform;
108 const architecture = yield* HostProcessArchitecture;
105109
106110 const nodePty = yield* Effect.promise(() => import("node-pty"));
107111
......@@ -109,6 +113,8 @@ export const layer = Layer.effect(
109113 ensureNodePtySpawnHelperExecutable().pipe(
110114 Effect.provideService(FileSystem.FileSystem, fs),
111115 Effect.provideService(Path.Path, path),
116 Effect.provideService(HostProcessPlatform, platform),
117 Effect.provideService(HostProcessArchitecture, architecture),
112118 Effect.orElseSucceed(() => undefined),
113119 ),
114120 );
......@@ -123,7 +129,7 @@ export const layer = Layer.effect(
123129 cols: input.cols,
124130 rows: input.rows,
125131 env: input.env,
126 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
132 name: platform === "win32" ? "xterm-color" : "xterm-256color",
127133 }),
128134 catch: (cause) =>
129135 new PtySpawnError({
apps/server/src/textGeneration/ClaudeTextGeneration.ts+12-10
......@@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1515
1616import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";
1717import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
1819
1920import { TextGenerationError } from "@t3tools/contracts";
2021import { type TextGenerationShape } from "./TextGeneration.ts";
......@@ -59,7 +60,7 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla
5960
6061export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
6162 claudeSettings: ClaudeSettings,
62 environment: NodeJS.ProcessEnv = process.env,
63 environment?: NodeJS.ProcessEnv,
6364) {
6465 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6566 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
......@@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
156157 : undefined;
157158
158159 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
159 const command = ChildProcess.make(
160 const spawnCommand = yield* resolveSpawnCommand(
160161 claudeSettings.binaryPath || "claude",
161162 [
162163 "-p",
......@@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
170171 ...(settingsJson ? ["--settings", settingsJson] : []),
171172 "--dangerously-skip-permissions",
172173 ],
173 {
174 env: claudeEnvironment,
175 cwd,
176 shell: process.platform === "win32",
177 stdin: {
178 stream: Stream.encodeText(Stream.make(prompt)),
179 },
180 },
174 { env: claudeEnvironment },
181175 );
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
183185 const child = yield* commandSpawner
184186 .spawn(command)
apps/server/src/textGeneration/CodexTextGeneration.ts+16-13
......@@ -9,6 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
99
1010import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";
1111import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
12import { resolveSpawnCommand } from "@t3tools/shared/shell";
1213
1314import { resolveAttachmentPath } from "../attachmentStore.ts";
1415import { ServerConfig } from "../config.ts";
......@@ -44,12 +45,13 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
4445 */
4546export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (
4647 codexConfig: CodexSettings,
47 environment: NodeJS.ProcessEnv = process.env,
48 environment?: NodeJS.ProcessEnv,
4849) {
4950 const fileSystem = yield* FileSystem.FileSystem;
5051 const path = yield* Path.Path;
5152 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5253 const serverConfig = yield* Effect.service(ServerConfig);
54 const resolvedEnvironment = environment ?? process.env;
5355
5456 type MaterializedImageAttachments = {
5557 readonly imagePaths: ReadonlyArray<string>;
......@@ -180,7 +182,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
180182 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
181183 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
182184 const serviceTier = getCodexServiceTierOptionValue(modelSelection);
183 const command = ChildProcess.make(
185 const spawnCommand = yield* resolveSpawnCommand(
184186 codexConfig.binaryPath || "codex",
185187 [
186188 "exec",
......@@ -200,18 +202,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
200202 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
201203 "-",
202204 ],
203 {
204 env: {
205 ...environment,
206 ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}),
207 },
208 cwd,
209 shell: process.platform === "win32",
210 stdin: {
211 stream: Stream.encodeText(Stream.make(prompt)),
212 },
213 },
205 { env: resolvedEnvironment },
214206 );
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
216219 const child = yield* commandSpawner
217220 .spawn(command)
apps/server/src/textGeneration/CursorTextGeneration.ts+3-2
......@@ -59,9 +59,10 @@ function isTextGenerationError(error: unknown): error is TextGenerationError {
5959 */
6060export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
6161 cursorSettings: CursorSettings,
62 environment: NodeJS.ProcessEnv = process.env,
62 environment?: NodeJS.ProcessEnv,
6363) {
6464 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
65 const resolvedEnvironment = environment ?? process.env;
6566
6667 const runCursorJson = <S extends Schema.Top>({
6768 operation,
......@@ -84,7 +85,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
8485 const outputRef = yield* Ref.make("");
8586 const runtime = yield* makeCursorAcpRuntime({
8687 cursorSettings,
87 environment,
88 environment: resolvedEnvironment,
8889 childProcessSpawner: commandSpawner,
8990 cwd,
9091 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+3-2
......@@ -99,10 +99,11 @@ interface SharedOpenCodeTextGenerationServerState {
9999
100100export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
101101 openCodeSettings: OpenCodeSettings,
102 environment: NodeJS.ProcessEnv = process.env,
102 environment?: NodeJS.ProcessEnv,
103103) {
104104 const serverConfig = yield* ServerConfig;
105105 const openCodeRuntime = yield* OpenCodeRuntime;
106 const resolvedEnvironment = environment ?? process.env;
106107 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
107108 Scope.close(scope, Exit.void),
108109 );
......@@ -208,7 +209,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
208209 openCodeRuntime
209210 .startOpenCodeServerProcess({
210211 binaryPath: input.binaryPath,
211 environment,
212 environment: resolvedEnvironment,
212213 })
213214 .pipe(
214215 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";
1010import * as PlatformError from "effect/PlatformError";
1111
1212import { ServerConfig } from "../../config.ts";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1314import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
1415import * as VcsProcess from "../../vcs/VcsProcess.ts";
1516import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
......@@ -75,9 +76,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb
7576 });
7677
7778const appendSeparator = (input: string) =>
78 input.endsWith("/") || input.endsWith("\\")
79 ? input
80 : `${input}${process.platform === "win32" ? "\\" : "/"}`;
79 Effect.map(HostProcessPlatform, (platform) =>
80 input.endsWith("/") || input.endsWith("\\")
81 ? input
82 : `${input}${platform === "win32" ? "\\" : "/"}`,
83 );
8184
8285it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
8386 afterEach(() => {
......@@ -344,12 +347,13 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
344347 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" });
345348 yield* writeTextFile(cwd, ".config/settings.json", "{}");
346349 yield* writeTextFile(cwd, "config/settings.json", "{}");
350 const cwdWithSeparator = yield* appendSeparator(cwd);
347351
348352 const directoryResult = yield* workspaceEntries.browse({
349 partialPath: appendSeparator(cwd),
353 partialPath: cwdWithSeparator,
350354 });
351355 const hiddenPrefixResult = yield* workspaceEntries.browse({
352 partialPath: `${appendSeparator(cwd)}.c`,
356 partialPath: `${cwdWithSeparator}.c`,
353357 });
354358
355359 expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]);
......@@ -402,7 +406,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
402406 vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied);
403407
404408 const result = yield* workspaceEntries.browse({
405 partialPath: appendSeparator(cwd),
409 partialPath: yield* appendSeparator(cwd),
406410 });
407411 expect(result).toEqual({ parentPath: cwd, entries: [] });
408412 }),
apps/server/src/workspace/Layers/WorkspaceEntries.ts+6-4
......@@ -1,5 +1,5 @@
11// @effect-diagnostics nodeBuiltinImport:off
2import * as OS from "node:os";
2import * as NodeOS from "node:os";
33import fsPromises from "node:fs/promises";
44import type { Dirent } from "node:fs";
55
......@@ -12,6 +12,7 @@ import * as Layer from "effect/Layer";
1212import * as Path from "effect/Path";
1313
1414import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1516import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
1617import {
1718 insertRankedSearchResult,
......@@ -65,10 +66,10 @@ function toPosixPath(input: string): string {
6566
6667function expandHomePath(input: string, path: Path.Path): string {
6768 if (input === "~") {
68 return OS.homedir();
69 return NodeOS.homedir();
6970 }
7071 if (input.startsWith("~/") || input.startsWith("~\\")) {
71 return path.join(OS.homedir(), input.slice(2));
72 return path.join(NodeOS.homedir(), input.slice(2));
7273 }
7374 return input;
7475}
......@@ -155,7 +156,8 @@ const resolveBrowseTarget = (
155156 pathService: Path.Path,
156157): Effect.Effect<string, WorkspaceEntriesBrowseError> =>
157158 Effect.gen(function* () {
158 if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
159 const platform = yield* HostProcessPlatform;
160 if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
159161 return yield* new WorkspaceEntriesBrowseError({
160162 cwd: input.cwd,
161163 partialPath: input.partialPath,
apps/server/src/workspace/Layers/WorkspacePaths.ts+3-3
......@@ -1,4 +1,4 @@
1import * as OS from "node:os";
1import * as NodeOS from "node:os";
22import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";
......@@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string {
1919
2020function expandHomePath(input: string, path: Path.Path): string {
2121 if (input === "~") {
22 return OS.homedir();
22 return NodeOS.homedir();
2323 }
2424 if (input.startsWith("~/") || input.startsWith("~\\")) {
25 return path.join(OS.homedir(), input.slice(2));
25 return path.join(NodeOS.homedir(), input.slice(2));
2626 }
2727 return input;
2828}
apps/server/src/ws.ts+1-1
......@@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
761761 keybindings: keybindingsConfig.keybindings,
762762 issues: keybindingsConfig.issues,
763763 providers,
764 availableEditors: ExternalLauncher.resolveAvailableEditors(),
764 availableEditors: yield* externalLauncher.resolveAvailableEditors(),
765765 observability: {
766766 logsDirectoryPath: config.logsDir,
767767 localTracingEnabled: true,
oxlint-plugin-t3code/index.ts+2
......@@ -1,5 +1,6 @@
11import { definePlugin } from "@oxlint/plugins";
22
3import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
34import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
45import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
56
......@@ -8,6 +9,7 @@ export default definePlugin({
89 name: "t3code",
910 },
1011 rules: {
12 "no-global-process-runtime": noGlobalProcessRuntime,
1113 "no-inline-schema-compile": noInlineSchemaCompile,
1214 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
1315 },
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* () {
1111 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1212 const command = ChildProcess.make("cursor-agent", ["acp"], {
1313 cwd: process.cwd(),
14 shell: process.platform === "win32",
14 shell: false,
1515 });
1616 const handle = yield* spawner.spawn(command);
1717 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) =>
1717const mockPeerArgs = (path: string) => [path];
1818
1919it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {
20 const makeHandle = () =>
20 const makeHandle = (env?: Record<string, string>) =>
2121 Effect.gen(function* () {
2222 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2323 const path = yield* Path.Path;
2424 const peerCwd = path.join(import.meta.dirname, "..");
2525 const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), {
2626 cwd: peerCwd,
27 ...(env ? { env: { ...process.env, ...env } } : {}),
2728 });
2829 return yield* spawner.spawn(command);
2930 });
......@@ -122,49 +123,13 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {
122123 ]);
123124 }),
124125 );
125
126 it.effect("initializes a command-backed app-server client", () =>
126 it.effect("drains child stderr so large diagnostics cannot block protocol responses", () =>
127127 Effect.gen(function* () {
128 const path = yield* Path.Path;
129 const scope = yield* Scope.make();
130 const clientLayer = CodexClient.layerCommand({
131 command: "node",
132 args: mockPeerArgs(yield* mockPeerPath),
133 cwd: path.join(import.meta.dirname, ".."),
128 const handle = yield* makeHandle({
129 CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024),
134130 });
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;
159131 const scope = yield* Scope.make();
160 const clientLayer = CodexClient.layerCommand({
161 command: "node",
162 args: mockPeerArgs(yield* mockPeerPath),
163 cwd: path.join(import.meta.dirname, ".."),
164 env: {
165 CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024),
166 },
167 });
132 const clientLayer = CodexClient.layerChildProcess(handle);
168133 const context = yield* Layer.buildWithScope(clientLayer, scope);
169134
170135 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";
55import * as Scope from "effect/Scope";
66import * as Stdio from "effect/Stdio";
77import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8import { ChildProcessSpawner } from "effect/unstable/process";
99
1010import * as CodexRpc from "./_generated/meta.gen.ts";
1111import * as CodexError from "./errors.ts";
......@@ -18,8 +18,6 @@ import {
1818} from "./_internal/shared.ts";
1919import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";
2020
21const DEFAULT_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const;
22
2321export interface CodexAppServerClientOptions {
2422 readonly logIncoming?: boolean;
2523 readonly logOutgoing?: boolean;
......@@ -263,39 +261,3 @@ const makeChildProcessClient = Effect.fn(
263261 yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped);
264262 return yield* make(makeChildStdio(handle), options, makeTerminationError(handle));
265263});
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 @@
11import * as Console from "effect/Console";
22import * as Effect from "effect/Effect";
3import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
34
45import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
56import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -7,10 +8,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
78import * as CodexClient from "../../src/client.ts";
89
910const program = Effect.gen(function* () {
10 const codexLayer = CodexClient.layerCommand({
11 command: process.env.CODEX_BIN ?? "codex",
12 args: ["app-server"],
13 cwd: process.cwd(),
11 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12 const handle = yield* spawner.spawn(
13 ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], {
14 cwd: process.cwd(),
15 shell: false,
16 }),
17 );
18 const codexLayer = CodexClient.layerChildProcess(handle, {
1419 logIncoming: true,
1520 logOutgoing: true,
1621 });
packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts+8-4
......@@ -1,3 +1,5 @@
1import * as NodeOS from "node:os";
2
13let nextServerRequestId = 10_000;
24let pendingSkillsListRequestId: number | string | null = null;
35let pendingUserInputRequestId: number | null = null;
......@@ -34,14 +36,16 @@ const handleMethod = (message: Record<string, unknown>) => {
3436
3537 switch (method) {
3638 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();
3741 const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0);
3842 if (Number.isFinite(stderrBytes) && stderrBytes > 0) {
3943 process.stderr.write("x".repeat(stderrBytes), () => {
4044 respond(message.id as number | string, {
4145 userAgent: "mock-codex-app-server",
4246 codexHome: process.cwd(),
43 platformFamily: process.platform === "win32" ? "windows" : "unix",
44 platformOs: process.platform === "darwin" ? "macos" : process.platform,
47 platformFamily: platform === "win32" ? "windows" : "unix",
48 platformOs: platform === "darwin" ? "macos" : platform,
4549 });
4650 });
4751 return;
......@@ -49,8 +53,8 @@ const handleMethod = (message: Record<string, unknown>) => {
4953 respond(message.id as number | string, {
5054 userAgent: "mock-codex-app-server",
5155 codexHome: process.cwd(),
52 platformFamily: process.platform === "win32" ? "windows" : "unix",
53 platformOs: process.platform === "darwin" ? "macos" : process.platform,
56 platformFamily: platform === "win32" ? "windows" : "unix",
57 platformOs: platform === "darwin" ? "macos" : platform,
5458 });
5559 return;
5660 }
packages/shared/package.json+4
......@@ -154,6 +154,10 @@
154154 "./preview": {
155155 "types": "./src/preview.ts",
156156 "import": "./src/preview.ts"
157 },
158 "./hostProcess": {
159 "types": "./src/hostProcess.ts",
160 "import": "./src/hostProcess.ts"
157161 }
158162 },
159163 "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";
1010import * as Stream from "effect/Stream";
1111import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1212import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";
1314
1415import {
1516 RelayClientInstallError,
1617 CLOUDFLARED_VERSION,
1718 makeCloudflaredRelayClient,
18 resolveManagedCloudflaredPath,
1919} 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
2328function makeHandle(exitCode = 0) {
2429 return ChildProcessSpawner.makeHandle({
......@@ -69,18 +74,18 @@ describe("RelayClient", () => {
6974 yield* fileSystem.chmod(overridePath, 0o755);
7075 const manager = yield* makeCloudflaredRelayClient({
7176 baseDir,
72 platform: "linux",
73 arch: "x64",
74 configProvider: () =>
75 ConfigProvider.fromEnv({
76 env: {
77 PATH: "",
78 T3CODE_CLOUDFLARED_PATH: overridePath,
79 },
80 }),
8177 });
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({
8489 status: "available",
8590 executablePath: overridePath,
8691 source: "override",
......@@ -93,6 +98,7 @@ describe("RelayClient", () => {
9398 NodeServices.layer,
9499 makeHttpClientLayer(new Uint8Array()),
95100 makeSpawnerLayer([]),
101 hostRuntimeLayer(),
96102 ),
97103 ),
98104 ),
......@@ -107,14 +113,11 @@ describe("RelayClient", () => {
107113 const bytes = new TextEncoder().encode("test-cloudflared-binary");
108114 const manager = yield* makeCloudflaredRelayClient({
109115 baseDir,
110 platform: "linux",
111 arch: "x64",
112116 releaseAsset: {
113117 url: "https://example.test/cloudflared",
114118 sha256: Encoding.encodeHex(sha256(bytes)),
115119 archive: "binary",
116120 },
117 configProvider: emptyConfigProvider,
118121 });
119122
120123 const progress: Array<string> = [];
......@@ -125,11 +128,7 @@ describe("RelayClient", () => {
125128 }
126129 }),
127130 );
128 const managedPath = resolveManagedCloudflaredPath({
129 baseDir,
130 platform: "linux",
131 arch: "x64",
132 });
131 const managedPath = `${baseDir}/tools/cloudflared/${CLOUDFLARED_VERSION}/linux-x64/cloudflared`;
133132 expect(installed).toEqual({
134133 status: "available",
135134 executablePath: managedPath,
......@@ -156,6 +155,7 @@ describe("RelayClient", () => {
156155 NodeServices.layer,
157156 makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")),
158157 makeSpawnerLayer([]),
158 hostRuntimeLayer(),
159159 ),
160160 ),
161161 ),
......@@ -169,14 +169,11 @@ describe("RelayClient", () => {
169169 });
170170 const manager = yield* makeCloudflaredRelayClient({
171171 baseDir,
172 platform: "linux",
173 arch: "x64",
174172 releaseAsset: {
175173 url: "https://example.test/cloudflared",
176174 sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))),
177175 archive: "binary",
178176 },
179 configProvider: emptyConfigProvider,
180177 });
181178
182179 const error = yield* manager.install.pipe(Effect.flip);
......@@ -189,6 +186,7 @@ describe("RelayClient", () => {
189186 NodeServices.layer,
190187 makeHttpClientLayer(new TextEncoder().encode("tampered")),
191188 makeSpawnerLayer([]),
189 hostRuntimeLayer(),
192190 ),
193191 ),
194192 ),
......@@ -204,14 +202,11 @@ describe("RelayClient", () => {
204202 });
205203 const manager = yield* makeCloudflaredRelayClient({
206204 baseDir,
207 platform: "linux",
208 arch: "x64",
209205 releaseAsset: {
210206 url: "https://example.test/cloudflared",
211207 sha256: Encoding.encodeHex(sha256(bytes)),
212208 archive: "binary",
213209 },
214 configProvider: emptyConfigProvider,
215210 });
216211
217212 const [first, second] = yield* Effect.all([manager.install, manager.install], {
......@@ -222,25 +217,27 @@ describe("RelayClient", () => {
222217 }).pipe(
223218 Effect.scoped,
224219 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 ),
226226 ),
227227 );
228228 });
229229
230 it.effect("observes PATH changes after the manager has been constructed", () =>
231 Effect.gen(function* () {
230 it.effect("observes PATH changes after the manager has been constructed", () => {
231 const env = { PATH: "" };
232 return Effect.gen(function* () {
232233 const fileSystem = yield* FileSystem.FileSystem;
233234 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
234235 prefix: "t3-cloudflared-test-",
235236 });
236237 const binDir = `${baseDir}/bin`;
237238 const executablePath = `${binDir}/cloudflared`;
238 let path = "";
239239 const manager = yield* makeCloudflaredRelayClient({
240240 baseDir,
241 platform: "linux",
242 arch: "x64",
243 configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }),
244241 });
245242
246243 expect(yield* manager.resolve).toEqual({
......@@ -251,7 +248,7 @@ describe("RelayClient", () => {
251248 yield* fileSystem.makeDirectory(binDir);
252249 yield* fileSystem.writeFileString(executablePath, "cloudflared");
253250 yield* fileSystem.chmod(executablePath, 0o755);
254 path = binDir;
251 env.PATH = binDir;
255252
256253 expect(yield* manager.resolve).toEqual({
257254 status: "available",
......@@ -266,8 +263,9 @@ describe("RelayClient", () => {
266263 NodeServices.layer,
267264 makeHttpClientLayer(new Uint8Array()),
268265 makeSpawnerLayer([]),
266 hostRuntimeLayer(env),
269267 ),
270268 ),
271 ),
272 );
269 );
270 });
273271});
packages/shared/src/relayClient.ts+4-30
......@@ -4,7 +4,6 @@ import type {
44 RelayClientInstallProgressStage,
55} from "@t3tools/contracts";
66import * as Config from "effect/Config";
7import * as ConfigProvider from "effect/ConfigProvider";
87import * as Context from "effect/Context";
98import * as Crypto from "effect/Crypto";
109import * as Data from "effect/Data";
......@@ -18,6 +17,7 @@ import * as PlatformError from "effect/PlatformError";
1817import * as Semaphore from "effect/Semaphore";
1918import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
2019import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
20import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";
2121
2222export const CLOUDFLARED_VERSION = "2026.5.2";
2323export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
......@@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({
120120
121121export interface CloudflaredRelayClientOptions {
122122 readonly baseDir: string;
123 readonly platform?: NodeJS.Platform;
124 readonly arch?: string;
125123 readonly releaseAsset?: CloudflaredReleaseAsset;
126 readonly configProvider?: () => ConfigProvider.ConfigProvider;
127124}
128125
129126export interface RelayClientShape {
......@@ -142,22 +139,6 @@ function executableFileName(platform: NodeJS.Platform): string {
142139 return platform === "win32" ? "cloudflared.exe" : "cloudflared";
143140}
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
161142function resolveReleaseAsset(
162143 platform: NodeJS.Platform,
163144 arch: string,
......@@ -205,17 +186,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
205186 const path = yield* Path.Path;
206187 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
207188 const installSemaphore = yield* Semaphore.make(1);
208 const platform = options.platform ?? process.platform;
209 const arch = options.arch ?? process.arch;
189 const platform = yield* HostProcessPlatform;
190 const arch = yield* HostProcessArchitecture;
210191 const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch);
211 const loadCloudflaredConfig = Effect.suspend(() =>
212 CloudflaredConfig.pipe(
213 Effect.provideService(
214 ConfigProvider.ConfigProvider,
215 options.configProvider?.() ?? ConfigProvider.fromEnv(),
216 ),
217 ),
218 ).pipe(Effect.orDie);
192 const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie);
219193 const managedPath = path.join(
220194 options.baseDir,
221195 "tools",
packages/shared/src/shell.test.ts+237-131
......@@ -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";
15import { describe, expect, it, vi } from "vite-plus/test";
26
37import {
48 extractPathFromShellOutput,
9 CommandAvailability,
10 type CommandAvailabilityChecker,
511 isCommandAvailable,
612 listLoginShellCandidates,
713 mergePathEntries,
......@@ -12,9 +18,23 @@ import {
1218 readPathFromLoginShell,
1319 resolveCommandPath,
1420 resolveKnownWindowsCliDirs,
21 resolveSpawnCommand,
1522 resolveWindowsEnvironment,
23 SpawnExecutableResolution,
24 WindowsShellEnvironment,
25 type WindowsShellEnvironmentReader,
1626} 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
1838describe("extractPathFromShellOutput", () => {
1939 it("extracts the path between capture markers", () => {
2040 expect(
......@@ -322,149 +342,235 @@ describe("resolveKnownWindowsCliDirs", () => {
322342 });
323343});
324344
325describe("isCommandAvailable", () => {
326 it("returns false when PATH is empty", () => {
327 expect(
328 isCommandAvailable("definitely-not-installed", {
329 platform: "win32",
330 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
331 }),
332 ).toBe(false);
333 });
345effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {
346 it.effect("returns false when PATH is empty", () =>
347 Effect.gen(function* () {
348 expect(
349 yield* isCommandAvailable("definitely-not-installed", {
350 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
351 }).pipe(Effect.provideService(HostProcessPlatform, "win32")),
352 ).toBe(false);
353 }),
354 );
334355});
335356
336describe("resolveCommandPath", () => {
337 it("returns the first executable resolved from PATH", () => {
338 expect(
339 resolveCommandPath("definitely-not-installed", {
340 platform: "win32",
357effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
358 it.effect("fails when PATH is empty", () =>
359 Effect.gen(function* () {
360 const result = yield* resolveCommandPath("definitely-not-installed", {
341361 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
342 }),
343 ).toBeNull();
344 });
362 }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result);
363
364 expect(result._tag).toBe("Failure");
365 }),
366 );
345367});
346368
347describe("resolveWindowsEnvironment", () => {
348 it("returns the baseline no-profile PATH patch when node is already available", () => {
349 const readEnvironment = vi.fn(
350 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
351 options?.loadProfile
352 ? { PATH: "C:\\Profile\\Bin" }
353 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
354 );
355 const commandAvailable = vi.fn(() => true);
369effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
370 it.effect("runs Windows executables directly without a shell", () =>
371 Effect.gen(function* () {
372 const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], {
373 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
374 }).pipe(Effect.provideService(HostProcessPlatform, "win32"));
375
376 expect(command).toEqual({
377 command: "node.exe",
378 args: ["script.js", "hello & goodbye"],
379 shell: false,
380 });
381 }),
382 );
383
384 it.effect("escapes the executable and arguments for Windows command shims", () =>
385 Effect.gen(function* () {
386 const command = yield* resolveSpawnCommand(
387 "vp",
388 ["run", "value & calc", "%PATH%", 'quote"value'],
389 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
390 ).pipe(
391 Effect.provideService(HostProcessPlatform, "win32"),
392 Effect.provideService(
393 SpawnExecutableResolution,
394 () => "C:\\Program Files\\npm & tools\\vp.cmd",
395 ),
396 );
397
398 expect(command.shell).toBe(true);
399 expect(command.command).not.toContain(" & ");
400 expect(command.command).toContain("^&");
401 expect(command.args).toEqual([
402 '^"run^"',
403 '^"value^ ^&^ calc^"',
404 '^"^%PATH^%^"',
405 '^"quote\\^"value^"',
406 ]);
407 }),
408 );
409
410 it.effect("resolves against the effective environment when extending host env", () =>
411 Effect.gen(function* () {
412 let resolvedEnvironment: NodeJS.ProcessEnv | undefined;
413 yield* resolveSpawnCommand("codex", ["app-server"], {
414 env: { CODEX_HOME: "C:\\Users\\tester\\.codex" },
415 extendEnv: true,
416 }).pipe(
417 Effect.provideService(HostProcessPlatform, "win32"),
418 Effect.provideService(HostProcessEnvironment, {
419 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
420 PATHEXT: ".COM;.EXE;.BAT;.CMD",
421 }),
422 Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => {
423 resolvedEnvironment = env;
424 return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd";
425 }),
426 );
427
428 expect(resolvedEnvironment).toEqual({
429 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
430 PATHEXT: ".COM;.EXE;.BAT;.CMD",
431 CODEX_HOME: "C:\\Users\\tester\\.codex",
432 });
433 }),
434 );
435
436 it.effect("does not fall back to a shell for unresolved Windows commands", () =>
437 Effect.gen(function* () {
438 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
439 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
440 }).pipe(Effect.provideService(HostProcessPlatform, "win32"));
441
442 expect(command).toEqual({
443 command: "missing & calc",
444 args: ["unsafe & value"],
445 shell: false,
446 });
447 }),
448 );
449});
356450
357 expect(
358 resolveWindowsEnvironment(
359 {
360 PATH: "C:\\Windows\\System32",
361 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
362 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
363 USERPROFILE: "C:\\Users\\testuser",
364 },
365 {
451effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => {
452 it.effect("returns the baseline no-profile PATH patch when node is already available", () =>
453 Effect.gen(function* () {
454 const readEnvironment = vi.fn(
455 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
456 options?.loadProfile
457 ? { PATH: "C:\\Profile\\Bin" }
458 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
459 );
460 const commandAvailable = vi.fn(() => Effect.succeed(true));
461
462 expect(
463 yield* withWindowsEnvironmentMocks(
464 resolveWindowsEnvironment({
465 PATH: "C:\\Windows\\System32",
466 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
467 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
468 USERPROFILE: "C:\\Users\\testuser",
469 }),
366470 readEnvironment,
367471 commandAvailable,
368 },
369 ),
370 ).toEqual({
371 PATH: [
372 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
373 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
374 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
375 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
376 "C:\\Users\\testuser\\.bun\\bin",
377 "C:\\Users\\testuser\\scoop\\shims",
378 "C:\\Shell\\Bin",
379 "C:\\Windows\\System32",
380 ].join(";"),
381 });
382 expect(readEnvironment).toHaveBeenCalledTimes(1);
383 expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
384 expect(commandAvailable).toHaveBeenCalledWith(
385 "node",
386 expect.objectContaining({
387 platform: "win32",
388 }),
389 );
390 });
391
392 it("loads the PowerShell profile when baseline env cannot resolve node", () => {
393 const readEnvironment = vi.fn(
394 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
395 options?.loadProfile
396 ? {
397 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
398 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
399 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
400 }
401 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
402 );
403 const commandAvailable = vi.fn(() => false);
404
405 expect(
406 resolveWindowsEnvironment(
407 {
408 PATH: "C:\\Windows\\System32",
409 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
410 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
411 USERPROFILE: "C:\\Users\\testuser",
412 },
413 {
472 ),
473 ).toEqual({
474 PATH: [
475 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
476 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
477 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
478 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
479 "C:\\Users\\testuser\\.bun\\bin",
480 "C:\\Users\\testuser\\scoop\\shims",
481 "C:\\Shell\\Bin",
482 "C:\\Windows\\System32",
483 ].join(";"),
484 });
485 expect(readEnvironment).toHaveBeenCalledTimes(1);
486 expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
487 expect(commandAvailable).toHaveBeenCalledWith(
488 "node",
489 expect.objectContaining({ env: expect.any(Object) }),
490 );
491 }),
492 );
493
494 it.effect("loads the PowerShell profile when baseline env cannot resolve node", () =>
495 Effect.gen(function* () {
496 const readEnvironment = vi.fn(
497 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
498 options?.loadProfile
499 ? {
500 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
501 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
502 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
503 }
504 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
505 );
506 const commandAvailable = vi.fn(() => Effect.succeed(false));
507
508 expect(
509 yield* withWindowsEnvironmentMocks(
510 resolveWindowsEnvironment({
511 PATH: "C:\\Windows\\System32",
512 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
513 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
514 USERPROFILE: "C:\\Users\\testuser",
515 }),
414516 readEnvironment,
415517 commandAvailable,
416 },
417 ),
418 ).toEqual({
419 PATH: [
420 "C:\\Profile\\Node",
421 "C:\\Windows\\System32",
422 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
423 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
424 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
425 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
426 "C:\\Users\\testuser\\.bun\\bin",
427 "C:\\Users\\testuser\\scoop\\shims",
428 "C:\\Shell\\Bin",
429 ].join(";"),
430 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
431 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
432 });
433 expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
434 expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], {
435 loadProfile: true,
436 });
437 expect(commandAvailable).toHaveBeenCalledTimes(1);
438 });
439
440 it("keeps the baseline env when profiled probe still does not resolve node", () => {
441 const readEnvironment = vi.fn(
442 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
443 options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {},
444 );
445 const commandAvailable = vi.fn(() => false);
446
447 expect(
448 resolveWindowsEnvironment(
518 ),
519 ).toEqual({
520 PATH: [
521 "C:\\Profile\\Node",
522 "C:\\Windows\\System32",
523 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
524 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
525 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
526 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
527 "C:\\Users\\testuser\\.bun\\bin",
528 "C:\\Users\\testuser\\scoop\\shims",
529 "C:\\Shell\\Bin",
530 ].join(";"),
531 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
532 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
533 });
534 expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
535 expect(readEnvironment).toHaveBeenNthCalledWith(
536 2,
537 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
449538 {
450 PATH: "C:\\Windows\\System32",
451 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
452 USERPROFILE: "C:\\Users\\testuser",
539 loadProfile: true,
453540 },
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 }),
455561 readEnvironment,
456562 commandAvailable,
457 },
458 ),
459 ).toEqual({
460 PATH: [
461 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
462 "C:\\Users\\testuser\\.bun\\bin",
463 "C:\\Users\\testuser\\scoop\\shims",
464 "C:\\Windows\\System32",
465 ].join(";"),
466 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
467 });
468 expect(commandAvailable).toHaveBeenCalledTimes(1);
469 });
563 ),
564 ).toEqual({
565 PATH: [
566 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
567 "C:\\Users\\testuser\\.bun\\bin",
568 "C:\\Users\\testuser\\scoop\\shims",
569 "C:\\Windows\\System32",
570 ].join(";"),
571 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
572 });
573 expect(commandAvailable).toHaveBeenCalledTimes(1);
574 }),
575 );
470576});
packages/shared/src/shell.ts+224-46
......@@ -1,8 +1,15 @@
11// @effect-diagnostics nodeBuiltinImport:off
22import * as NodeOS from "node:os";
3import * as NodePath from "node:path";
34import { execFileSync } from "node:child_process";
4import { accessSync, constants, statSync } from "node:fs";
5import { extname, join } from "node:path";
5import { accessSync, constants as fileSystemConstants, statSync } from "node:fs";
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
714const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
815const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
......@@ -17,11 +24,122 @@ type ExecFileSyncLike = (
1724 options: { encoding: "utf8"; timeout: number },
1825) => 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
2036export interface CommandAvailabilityOptions {
21 readonly platform?: NodeJS.Platform;
2237 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;
23134}
24135
136export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>(
137 "@t3tools/shared/shell/SpawnExecutableResolution",
138 {
139 defaultValue: () => resolveSpawnExecutableWithNode,
140 },
141);
142
25143export interface WindowsEnvironmentProbeOptions {
26144 readonly loadProfile?: boolean;
27145}
......@@ -214,6 +332,20 @@ export type WindowsShellEnvironmentReader = (
214332 options?: WindowsEnvironmentProbeOptions,
215333) => 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
217349export function readEnvironmentFromWindowsShell(
218350 names: ReadonlyArray<string>,
219351 execFile?: ExecFileSyncLike,
......@@ -334,6 +466,7 @@ function resolveCommandCandidates(
334466 command: string,
335467 platform: NodeJS.Platform,
336468 windowsPathExtensions: ReadonlyArray<string>,
469 extname: (path: string) => string,
337470): ReadonlyArray<string> {
338471 if (platform !== "win32") return [command];
339472 const extension = extname(command);
......@@ -358,46 +491,53 @@ function resolveCommandCandidates(
358491 return Array.from(new Set(candidates));
359492}
360493
361function isExecutableFile(
494const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* (
362495 filePath: string,
363496 platform: NodeJS.Platform,
364497 windowsPathExtensions: ReadonlyArray<string>,
365): boolean {
366 try {
367 const stat = statSync(filePath);
368 if (!stat.isFile()) return false;
369 if (platform === "win32") {
370 const extension = extname(filePath);
371 if (extension.length === 0) return false;
372 return windowsPathExtensions.includes(extension.toUpperCase());
373 }
374 accessSync(filePath, constants.X_OK);
375 return true;
376 } catch {
377 return false;
498): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> {
499 const fileSystem = yield* FileSystem.FileSystem;
500 const path = yield* Path.Path;
501 const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
502 if (stat === null || stat.type !== "File") return false;
503
504 if (platform === "win32") {
505 const extension = path.extname(filePath);
506 if (extension.length === 0) return false;
507 return windowsPathExtensions.includes(extension.toUpperCase());
378508 }
379}
380509
381export function resolveCommandPath(
510 return canExecuteFile(filePath);
511});
512
513const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* (
382514 command: string,
383 options: CommandAvailabilityOptions = {},
384): string | null {
385 const platform = options.platform ?? process.platform;
515 options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform },
516): Effect.fn.Return<string, CommandResolutionError, FileSystem.FileSystem | Path.Path> {
517 const path = yield* Path.Path;
518 const platform = options.platform;
386519 const env = options.env ?? process.env;
387520 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
390528 if (command.includes("/") || command.includes("\\")) {
391529 for (const candidate of commandCandidates) {
392 if (isExecutableFile(candidate, platform, windowsPathExtensions)) {
530 if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) {
393531 return candidate;
394532 }
395533 }
396 return null;
534 return yield* new CommandResolutionError({ command, reason: "not-found" });
397535 }
398536
399537 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 }
401541 const pathEntries: string[] = [];
402542 for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) {
403543 const pathEntry = stripWrappingQuotes(entry.trim());
......@@ -408,21 +548,65 @@ export function resolveCommandPath(
408548
409549 for (const pathEntry of pathEntries) {
410550 for (const candidate of commandCandidates) {
411 const candidatePath = join(pathEntry, candidate);
412 if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
551 const candidatePath = path.join(pathEntry, candidate);
552 if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
413553 return candidatePath;
414554 }
415555 }
416556 }
417 return null;
418}
557 return yield* new CommandResolutionError({ command, reason: "not-found" });
558});
419559
420export function isCommandAvailable(
560export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* (
421561 command: string,
422562 options: CommandAvailabilityOptions = {},
423): boolean {
424 return resolveCommandPath(command, options) !== null;
425}
563) {
564 return yield* resolveCommandPathForPlatform(command, {
565 env: options.env ?? (yield* HostProcessEnvironment),
566 platform: yield* HostProcessPlatform,
567 });
568});
569
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
427611export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
428612 const appData = env.APPDATA?.trim();
......@@ -437,11 +621,6 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra
437621 ];
438622}
439623
440export interface WindowsEnvironmentResolverOptions {
441 readonly readEnvironment?: WindowsShellEnvironmentReader;
442 readonly commandAvailable?: typeof isCommandAvailable;
443}
444
445624function readWindowsEnvironmentSafely(
446625 readEnvironment: WindowsShellEnvironmentReader,
447626 names: ReadonlyArray<string>,
......@@ -467,12 +646,11 @@ function mergeWindowsEnv(
467646 return nextEnv;
468647}
469648
470export function resolveWindowsEnvironment(
649export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* (
471650 env: NodeJS.ProcessEnv,
472 options: WindowsEnvironmentResolverOptions = {},
473): Partial<NodeJS.ProcessEnv> {
474 const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell;
475 const commandAvailable = options.commandAvailable ?? isCommandAvailable;
651): Effect.fn.Return<Partial<NodeJS.ProcessEnv>, never, FileSystem.FileSystem | Path.Path> {
652 const readEnvironment = yield* WindowsShellEnvironment;
653 const commandAvailable = yield* CommandAvailability;
476654 const inheritedPath = readEnvPath(env);
477655 const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], {
478656 loadProfile: false,
......@@ -483,7 +661,7 @@ export function resolveWindowsEnvironment(
483661 const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {};
484662 const baselineEnv = mergeWindowsEnv(env, baselinePatch);
485663
486 if (commandAvailable("node", { platform: "win32", env: baselineEnv })) {
664 if (yield* commandAvailable("node", { env: baselineEnv })) {
487665 return baselinePatch;
488666 }
489667
......@@ -503,4 +681,4 @@ export function resolveWindowsEnvironment(
503681 return Object.keys(profiledPatch).length > 0
504682 ? { ...baselinePatch, ...profiledPatch }
505683 : baselinePatch;
506}
684});
packages/ssh/src/auth.test.ts+11-4
......@@ -3,7 +3,9 @@ import { assert, describe, it } from "@effect/vitest";
33
44import * as Effect from "effect/Effect";
55import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
67import * as Path from "effect/Path";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
79
810import {
911 buildSshAskpassHelperDescriptor,
......@@ -37,7 +39,6 @@ describe("ssh auth", () => {
3739 authSecret: "super-secret",
3840 interactiveAuth: true,
3941 askpassDirectory: directory,
40 platform: "linux",
4142 baseEnv: {},
4243 });
4344
......@@ -48,15 +49,21 @@ describe("ssh auth", () => {
4849 assert.equal(env.DISPLAY, "t3code");
4950 assert.equal(yield* fs.exists(askpassPath), true);
5051 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 ),
5256 );
5357
5458 it.effect("builds a windows askpass launcher pair", () =>
5559 Effect.gen(function* () {
5660 const descriptor = yield* buildSshAskpassHelperDescriptor({
5761 directory: "C:\\temp\\t3code-ssh-askpass",
58 platform: "win32",
59 }).pipe(Effect.provide(NodeServices.layer));
62 }).pipe(
63 Effect.provide(
64 Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "win32")),
65 ),
66 );
6067
6168 assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd");
6269 assert.deepEqual(
packages/ssh/src/auth.ts+16-9
......@@ -1,7 +1,10 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Config from "effect/Config";
13import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
35import * as FileSystem from "effect/FileSystem";
46import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
58import * as Path from "effect/Path";
69import * as PlatformError from "effect/PlatformError";
710
......@@ -55,7 +58,6 @@ export interface SshChildEnvironmentOptions {
5558 readonly baseEnv?: NodeJS.ProcessEnv;
5659 readonly askpassDirectory?: string;
5760 readonly authSecret?: string | null;
58 readonly platform?: NodeJS.Platform;
5961}
6062
6163const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass";
......@@ -110,9 +112,8 @@ export const buildSshAskpassHelperDescriptor = Effect.fn(
110112 "ssh/auth.buildSshAskpassHelperDescriptor",
111113)(function* (input: {
112114 readonly directory: string;
113 readonly platform?: NodeJS.Platform;
114115}): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> {
115 const platform = input.platform ?? process.platform;
116 const platform = yield* HostProcessPlatform;
116117 const path = yield* Path.Path;
117118 const directory = input.directory;
118119
......@@ -148,12 +149,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn(
148149export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(
149150 function* (input: {
150151 readonly directory: string;
151 readonly platform?: NodeJS.Platform;
152152 }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
153153 const fs = yield* FileSystem.FileSystem;
154154 const path = yield* Path.Path;
155155 const descriptor = yield* buildSshAskpassHelperDescriptor(input);
156 const platform = input.platform ?? process.platform;
156 const platform = yield* HostProcessPlatform;
157157
158158 yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true });
159159
......@@ -179,21 +179,28 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron
179179 PlatformError.PlatformError,
180180 FileSystem.FileSystem | Path.Path
181181> {
182 const baseEnv = { ...(input.baseEnv ?? process.env) };
182 const baseEnv = { ...input.baseEnv };
183183 if (!input.interactiveAuth) {
184184 return baseEnv;
185185 }
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 );
188195 const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory());
189 const sshAskpass = yield* ensureSshAskpassHelpers({ directory, platform });
196 const sshAskpass = yield* ensureSshAskpassHelpers({ directory });
190197
191198 return {
192199 ...baseEnv,
193200 SSH_ASKPASS: sshAskpass,
194201 SSH_ASKPASS_REQUIRE: "force",
195202 ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }),
196 ...(platform === "win32" || baseEnv.DISPLAY ? {} : { DISPLAY: "t3code" }),
203 ...(platform === "win32" || baseEnv.DISPLAY || hostDisplay ? {} : { DISPLAY: "t3code" }),
197204 };
198205});
199206
packages/ssh/src/command.ts+16-4
......@@ -1,6 +1,7 @@
11import * as Crypto from "node:crypto";
22
33import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
45import * as Duration from "effect/Duration";
56import * as Effect from "effect/Effect";
67import * as FileSystem from "effect/FileSystem";
......@@ -16,7 +17,16 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
1617const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
1718const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
1819const 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
2131const encoder = new TextEncoder();
2232
......@@ -191,16 +201,18 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func
191201 ...(input.remoteCommandArgs ?? []),
192202 ];
193203 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
204 const sshCommand = yield* resolveSshCommand;
194205 yield* Effect.logDebug("ssh.command.start", {
195206 ...sshTargetLogFields(target),
196 command: [SSH_COMMAND, ...args],
207 command: [sshCommand, ...args],
197208 hasStdin: input.stdin !== undefined,
198209 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
199210 });
200211 const child = yield* spawner
201212 .spawn(
202 ChildProcess.make(SSH_COMMAND, args, {
213 ChildProcess.make(sshCommand, args, {
203214 env: environment,
215 extendEnv: true,
204216 stdin: {
205217 stream: stdinStream(input.stdin),
206218 endOnDone: true,
......@@ -212,7 +224,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func
212224 Effect.mapError(
213225 (cause) =>
214226 new SshCommandError({
215 command: [SSH_COMMAND, ...args],
227 command: [sshCommand, ...args],
216228 exitCode: null,
217229 stderr: "",
218230 message:
packages/ssh/src/config.ts+11-1
......@@ -1,7 +1,9 @@
11import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
22
3import * as Config from "effect/Config";
34import * as Effect from "effect/Effect";
45import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
57import * as Path from "effect/Path";
68import * as PlatformError from "effect/PlatformError";
79
......@@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {
208210export const discoverSshHosts = Effect.fnUntraced(
209211 function* (input: { readonly homeDir?: string }) {
210212 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 "";
212222 if (homeDir.trim().length === 0) {
213223 return [];
214224 }
packages/ssh/src/tunnel.ts+5-3
......@@ -34,9 +34,9 @@ import {
3434 collectProcessOutput,
3535 getLastNonEmptyOutputLine,
3636 remoteStateKey,
37 resolveSshCommand,
3738 resolveSshTarget,
3839 runSshCommand,
39 SSH_COMMAND,
4040 targetConnectionKey,
4141} from "./command.ts";
4242import {
......@@ -1069,7 +1069,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
10691069 `${input.localPort}:127.0.0.1:${input.remotePort}`,
10701070 hostSpec,
10711071 ];
1072 const tunnelCommand = [SSH_COMMAND, ...args];
1072 const sshCommand = yield* resolveSshCommand;
1073 const tunnelCommand = [sshCommand, ...args];
10731074 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
10741075 const scope = yield* Scope.Scope;
10751076 yield* Effect.logDebug("ssh.tunnel.spawn.start", {
......@@ -1082,8 +1083,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
10821083 });
10831084 const child = yield* spawner
10841085 .spawn(
1085 ChildProcess.make(SSH_COMMAND, args, {
1086 ChildProcess.make(sshCommand, args, {
10861087 env: childEnvironment,
1088 extendEnv: true,
10871089 stdin: {
10881090 stream: Stream.empty,
10891091 endOnDone: true,
packages/tailscale/package.json+1
......@@ -14,6 +14,7 @@
1414 },
1515 "dependencies": {
1616 "@effect/platform-node": "catalog:",
17 "@t3tools/shared": "workspace:*",
1718 "effect": "catalog:"
1819 },
1920 "devDependencies": {
packages/tailscale/src/tailscale.ts+10-3
......@@ -1,3 +1,4 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as Data from "effect/Data";
23import * as Effect from "effect/Effect";
34import * as Option from "effect/Option";
......@@ -10,7 +11,11 @@ export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
1011export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
1112export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
1213export 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
1520export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{
1621 readonly command: readonly string[];
......@@ -136,8 +141,9 @@ export const readTailscaleStatus: Effect.Effect<
136141> = Effect.gen(function* () {
137142 const args = ["status", "--json"];
138143 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
144 const hostPlatform = yield* HostProcessPlatform;
139145 const child = yield* spawner
140 .spawn(ChildProcess.make(TAILSCALE_COMMAND, args))
146 .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args))
141147 .pipe(
142148 Effect.mapError((cause) =>
143149 tailscaleCommandError(
......@@ -211,8 +217,9 @@ const runTailscaleCommand = (
211217): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
212218 Effect.gen(function* () {
213219 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
220 const hostPlatform = yield* HostProcessPlatform;
214221 const child = yield* spawner
215 .spawn(ChildProcess.make(TAILSCALE_COMMAND, args))
222 .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args))
216223 .pipe(
217224 Effect.mapError((cause) =>
218225 tailscaleCommandError(
pnpm-lock.yaml+3
......@@ -810,6 +810,9 @@ importers:
810810 '@effect/platform-node':
811811 specifier: 4.0.0-beta.78
812812 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
813816 effect:
814817 specifier: 4.0.0-beta.78
815818 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";
22import { assert, it } from "@effect/vitest";
33import * as ConfigProvider from "effect/ConfigProvider";
44import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
56import * as Option from "effect/Option";
67
78import {
......@@ -11,10 +12,12 @@ import {
1112 resolveDesktopBuildIconAssets,
1213 resolveDesktopProductName,
1314 resolveDesktopUpdateChannel,
15 resolveGitHubPublishConfig,
1416 resolveMockUpdateServerPort,
1517 resolveMockUpdateServerUrl,
1618} from "./build-desktop-artifact.ts";
1719import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
20import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1821
1922it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
2023 it("resolves the dedicated nightly updater channel from nightly versions", () => {
......@@ -41,6 +44,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
4144 });
4245 });
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
4488 it("omits bundled workspace packages from staged desktop dependencies", () => {
4589 assert.deepStrictEqual(
4690 resolveDesktopRuntimeDependencies(
......@@ -122,6 +166,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
122166 }),
123167 );
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
125206 it.effect("preserves explicit false boolean flags over true env defaults", () =>
126207 Effect.gen(function* () {
127208 const resolved = yield* resolveBuildOptions({
scripts/build-desktop-artifact.ts+66-38
......@@ -1,6 +1,8 @@
11#!/usr/bin/env node
22
33import { fromYaml } from "@t3tools/shared/schemaYaml";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import { resolveSpawnCommand } from "@t3tools/shared/shell";
46import rootPackageJson from "../package.json" with { type: "json" };
57import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };
68import serverPackageJson from "../apps/server/package.json" with { type: "json" };
......@@ -101,14 +103,14 @@ function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Typ
101103 return undefined;
102104}
103105
104function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type {
106const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof BuildPlatform.Type) {
105107 const config = PLATFORM_CONFIG[platform];
106108 if (!config) {
107109 return "x64";
108110 }
109111
110 return getDefaultBuildArch(platform, process.arch, process.env, config);
111}
112 return yield* getDefaultBuildArch(platform, config);
113});
112114
113115class BuildScriptError extends Data.TaggedError("BuildScriptError")<{
114116 readonly message: string;
......@@ -198,13 +200,21 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo
198200const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () {
199201 const fs = yield* FileSystem.FileSystem;
200202 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);
202212 if (configured && (yield* fs.exists(configured))) {
203213 return configured;
204214 }
205215
206 if (process.platform === "win32") {
207 const localAppData = process.env.LOCALAPPDATA;
216 if (hostPlatform === "win32") {
217 const localAppData = Option.getOrUndefined(env.localAppData);
208218 if (localAppData) {
209219 for (const version of ["Python313", "Python312", "Python311", "Python310"]) {
210220 const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe");
......@@ -348,21 +358,23 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
348358 const path = yield* Path.Path;
349359 const repoRoot = yield* RepoRoot;
350360 const env = yield* BuildEnvConfig;
361 const hostPlatform = yield* HostProcessPlatform;
351362
352363 const platform = mergeOptions(
353364 input.platform,
354365 env.platform,
355 detectHostBuildPlatform(process.platform),
366 detectHostBuildPlatform(hostPlatform),
356367 );
357368
358369 if (!platform) {
359370 return yield* new BuildScriptError({
360 message: `Unsupported host platform '${process.platform}'.`,
371 message: `Unsupported host platform '${hostPlatform}'.`,
361372 });
362373 }
363374
364375 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);
366378 const version = mergeOptions(input.buildVersion, env.version, undefined);
367379 const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)
368380 ? "release-mock"
......@@ -622,19 +634,18 @@ export function resolveDesktopRuntimeDependencies(
622634 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");
623635}
624636
625function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):
626 | {
627 readonly provider: "github";
628 readonly owner: string;
629 readonly repo: string;
630 readonly releaseType: "release" | "prerelease";
631 readonly channel?: "nightly";
632 }
633 | undefined {
634 const rawRepo =
635 process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() ||
636 process.env.GITHUB_REPOSITORY?.trim() ||
637 "";
637export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* (
638 updateChannel: "latest" | "nightly",
639) {
640 const env = yield* Config.all({
641 updateRepository: Config.string("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option),
642 githubRepository: Config.string("GITHUB_REPOSITORY").pipe(Config.option),
643 });
644 const rawRepo = (
645 Option.getOrUndefined(env.updateRepository)?.trim() ||
646 Option.getOrUndefined(env.githubRepository)?.trim() ||
647 ""
648 ).trim();
638649 if (!rawRepo) return undefined;
639650
640651 const [owner, repo, ...rest] = rawRepo.split("/");
......@@ -647,7 +658,7 @@ function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):
647658 releaseType: updateChannel === "nightly" ? "prerelease" : "release",
648659 ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}),
649660 };
650}
661});
651662
652663export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" {
653664 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
......@@ -696,7 +707,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
696707 },
697708 };
698709 const updateChannel = resolveDesktopUpdateChannel(version);
699 const publishConfig = resolveGitHubPublishConfig(updateChannel);
710 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
700711 if (publishConfig) {
701712 buildConfig.publish = [publishConfig];
702713 } else if (mockUpdates) {
......@@ -780,6 +791,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
780791 const repoRoot = yield* RepoRoot;
781792 const path = yield* Path.Path;
782793 const fs = yield* FileSystem.FileSystem;
794 const hostPlatform = yield* HostProcessPlatform;
783795 const workspaceConfig = yield* readWorkspaceConfig();
784796 const workspaceCatalog = workspaceConfig.catalog ?? {};
785797 const workspaceOverrides = workspaceConfig.overrides ?? {};
......@@ -846,12 +858,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
846858
847859 if (!options.skipBuild) {
848860 yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts...");
861 const spawnCommand = yield* resolveSpawnCommand("vp", ["run", "build:desktop"]);
849862 yield* runCommand(
850 ChildProcess.make({
863 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
851864 cwd: repoRoot,
852 // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd).
853 shell: process.platform === "win32",
854 })`vp run build:desktop`,
865 shell: spawnCommand.shell,
866 }),
855867 { label: "vp run build:desktop", verbose: options.verbose },
856868 );
857869 }
......@@ -933,15 +945,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
933945 }
934946
935947 yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");
948 const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]);
936949 yield* runCommand(
937 ChildProcess.make({
950 ChildProcess.make(installCommand.command, installCommand.args, {
938951 cwd: stageAppDir,
939 // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd).
940 shell: process.platform === "win32",
941 })`vp install --prod --no-optional`,
952 shell: installCommand.shell,
953 }),
942954 { label: "vp install --prod --no-optional", verbose: options.verbose },
943955 );
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.
945960 const buildEnv: NodeJS.ProcessEnv = {
946961 ...process.env,
947962 };
......@@ -959,7 +974,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
959974 delete buildEnv.APPLE_API_ISSUER;
960975 }
961976
962 if (process.platform === "win32") {
977 if (hostPlatform === "win32") {
963978 const python = yield* resolvePythonForNodeGyp();
964979 if (python) {
965980 buildEnv.PYTHON = python;
......@@ -970,7 +985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
970985 }
971986 if (options.verbose) {
972987 buildEnv.DEBUG =
973 buildEnv.DEBUG === undefined || buildEnv.DEBUG === ""
988 buildEnv.DEBUG === undefined
974989 ? "electron-builder,electron-builder:*"
975990 : `${buildEnv.DEBUG},electron-builder,electron-builder:*`;
976991 }
......@@ -978,13 +993,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
978993 yield* Effect.log(
979994 `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`,
980995 );
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 });
9811010 yield* runCommand(
982 ChildProcess.make({
1011 ChildProcess.make(builderCommand.command, builderCommand.args, {
9831012 cwd: repoRoot,
9841013 env: buildEnv,
985 // Windows needs shell mode to resolve .cmd shims.
986 shell: process.platform === "win32",
987 })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`,
1014 shell: builderCommand.shell,
1015 }),
9881016 {
9891017 label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`,
9901018 verbose: options.verbose,
scripts/dev-runner.ts+11-4
......@@ -5,6 +5,8 @@ import * as NodeOS from "node:os";
55import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import * as NetService from "@t3tools/shared/Net";
8import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
9import { resolveSpawnCommand } from "@t3tools/shared/shell";
810import * as Config from "effect/Config";
911import * as Data from "effect/Data";
1012import * as Effect from "effect/Effect";
......@@ -418,9 +420,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
418420 hasExplicitDevUrl: input.devUrl !== undefined,
419421 });
420422
423 const hostEnvironment = yield* HostProcessEnvironment;
421424 const env = yield* createDevRunnerEnv({
422425 mode: input.mode,
423 baseEnv: process.env,
426 baseEnv: hostEnvironment,
424427 serverOffset,
425428 webOffset,
426429 t3Home: input.t3Home,
......@@ -445,14 +448,18 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
445448 return;
446449 }
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, {
449457 stdin: "inherit",
450458 stdout: "inherit",
451459 stderr: "inherit",
452460 env,
453461 extendEnv: false,
454 // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd).
455 shell: process.platform === "win32",
462 shell: spawnCommand.shell,
456463 // Keep Vite+ in the same process group so terminal signals (Ctrl+C)
457464 // reach it directly. Effect defaults to detached: true on non-Windows,
458465 // 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 @@
11import { assert, describe, it } from "@effect/vitest";
2
3import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts";
2import * as ConfigProvider from "effect/ConfigProvider";
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
527describe("build-target-arch", () => {
6 it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => {
7 // Windows-on-Arm can run an x64 Node process under emulation while still
8 // exposing the real host CPU via PROCESSOR_ARCHITEW6432.
9 const hostArch = resolveHostProcessArch("win32", "x64", {
10 PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64.
11 PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation.
12 });
13
14 assert.equal(hostArch, "arm64");
15 });
16
17 it("falls back to x64 for native x64 Windows hosts", () => {
18 const hostArch = resolveHostProcessArch("win32", "x64", {
19 PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64.
20 });
21
22 assert.equal(hostArch, "x64");
23 });
24
25 it("keeps arm64 when the current process is already native arm64", () => {
26 const hostArch = resolveHostProcessArch("win32", "arm64", {});
27
28 assert.equal(hostArch, "arm64");
29 });
30
31 it("uses the resolved host arch when selecting the default Windows build arch", () => {
32 // This mirrors the packaging script's default-path behavior: the current
33 // process is x64, but the machine itself is ARM64, so the default build
34 // target should be win-arm64 rather than win-x64.
35 const arch = getDefaultBuildArch(
36 "win",
37 "x64",
38 {
39 PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64.
40 PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64.
41 },
42 { archChoices: ["x64", "arm64"] },
43 );
44
45 assert.equal(arch, "arm64");
46 });
47
48 it("does not apply Windows host env heuristics for non-Windows targets", () => {
49 const arch = getDefaultBuildArch(
50 "linux",
51 "x64",
52 {
53 PROCESSOR_ARCHITECTURE: "AMD64",
54 PROCESSOR_ARCHITEW6432: "ARM64",
55 },
56 { archChoices: ["x64", "arm64"] },
57 );
58
59 assert.equal(arch, "x64");
60 });
28 it.effect("uses the resolved host arch when selecting the default Windows build arch", () =>
29 Effect.gen(function* () {
30 // This mirrors the packaging script's default-path behavior: the current
31 // process is x64, but the machine itself is ARM64, so the default build
32 // target should be win-arm64 rather than win-x64.
33 const arch = yield* getDefaultBuildArch("win", { archChoices: ["x64", "arm64"] }).pipe(
34 withHostRuntime("win32", "x64", {
35 PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64.
36 PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64.
37 }),
38 );
39
40 assert.equal(arch, "arm64");
41 }),
42 );
43
44 it.effect("does not apply Windows host env heuristics for non-Windows targets", () =>
45 Effect.gen(function* () {
46 const arch = yield* getDefaultBuildArch("linux", { archChoices: ["x64", "arm64"] }).pipe(
47 withHostRuntime("linux", "x64", {
48 PROCESSOR_ARCHITECTURE: "AMD64",
49 PROCESSOR_ARCHITEW6432: "ARM64",
50 }),
51 );
52
53 assert.equal(arch, "x64");
54 }),
55 );
6156});
scripts/lib/build-target-arch.ts+24-16
......@@ -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
16export type BuildArch = "arm64" | "x64" | "universal";
27export type BuildPlatform = "mac" | "linux" | "win";
38
......@@ -5,6 +10,11 @@ interface PlatformConfig {
510 readonly archChoices: ReadonlyArray<BuildArch>;
611}
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
818function normalizeWindowsArch(value: string | undefined): BuildArch | undefined {
919 const normalized = value?.trim().toLowerCase();
1020 if (!normalized) return undefined;
......@@ -13,38 +23,36 @@ function normalizeWindowsArch(value: string | undefined): BuildArch | undefined
1323 return undefined;
1424}
1525
16export function resolveHostProcessArch(
17 platform: NodeJS.Platform,
18 processArch: NodeJS.Architecture,
19 env: NodeJS.ProcessEnv,
20): BuildArch | undefined {
26const optionToUndefined = <A>(value: Option.Option<A>): A | undefined =>
27 Option.getOrUndefined(value);
28
29const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () {
30 const platform = yield* HostProcessPlatform;
31 const processArch = yield* HostProcessArchitecture;
2132 if (processArch === "arm64") return "arm64";
2233 if (processArch === "x64") {
2334 if (platform !== "win32") return "x64";
2435
2536 // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host
2637 // still reports ARM64 via the processor environment variables.
38 const env = yield* WindowsProcessorArchitectureConfig;
2739 return (
28 normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ??
29 normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ??
40 normalizeWindowsArch(optionToUndefined(env.processorArchitectureW6432)) ??
41 normalizeWindowsArch(optionToUndefined(env.processorArchitecture)) ??
3042 "x64"
3143 );
3244 }
3345 return undefined;
34}
46});
3547
36export function getDefaultBuildArch(
48export const getDefaultBuildArch = Effect.fn("getDefaultBuildArch")(function* (
3749 platform: BuildPlatform,
38 processArch: NodeJS.Architecture,
39 env: NodeJS.ProcessEnv,
4050 platformConfig: PlatformConfig,
41): BuildArch {
42 const hostPlatform: NodeJS.Platform =
43 platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux";
44 const hostArch = resolveHostProcessArch(hostPlatform, processArch, env);
51) {
52 const hostArch = yield* resolveHostProcessArch();
4553 if (hostArch && platformConfig.archChoices.includes(hostArch)) {
4654 return hostArch;
4755 }
4856
4957 return platformConfig.archChoices[0] ?? "x64";
50}
58});
scripts/mobile-native-static-check.ts+5-19
......@@ -2,6 +2,7 @@
22
33import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44import * as NodeServices from "@effect/platform-node/NodeServices";
5import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
56import * as Console from "effect/Console";
67import * as Data from "effect/Data";
78import * as Effect from "effect/Effect";
......@@ -58,23 +59,7 @@ const commandOutputOptions = {
5859} as const;
5960
6061const commandExists = Effect.fn("commandExists")(function* (command: string) {
61 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
62 const lookupCommand =
63 process.platform === "win32"
64 ? ChildProcess.make("where", [command], {
65 stdout: "ignore",
66 stderr: "ignore",
67 })
68 : ChildProcess.make("/bin/sh", ["-c", `command -v ${command}`], {
69 stdout: "ignore",
70 stderr: "ignore",
71 });
72
73 return yield* spawner.spawn(lookupCommand).pipe(
74 Effect.flatMap((child) => child.exitCode),
75 Effect.map((exitCode) => exitCode === 0),
76 Effect.orElseSucceed(() => false),
77 );
62 return yield* isCommandAvailable(command);
7863});
7964
8065const warnMissingTool = (tool: NativeStaticTool, checkName: string) =>
......@@ -89,11 +74,12 @@ const runCommand = Effect.fn("runCommand")(function* (
8974) {
9075 yield* Console.log(`$ ${[command, ...args].join(" ")}`);
9176 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
77 const spawnCommand = yield* resolveSpawnCommand(command, args);
9278 const child = yield* spawner.spawn(
93 ChildProcess.make(command, [...args], {
79 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
9480 cwd,
9581 ...commandOutputOptions,
96 shell: process.platform === "win32",
82 shell: spawnCommand.shell,
9783 }),
9884 );
9985 const exitCode = Number(yield* child.exitCode);
vite.config.ts+2-4
......@@ -1,12 +1,9 @@
11import "vite-plus/test/config";
22import { defineConfig } from "vite-plus";
3import { fileURLToPath } from "node:url";
43
54export default defineConfig({
65 resolve: {
7 alias: {
8 "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)),
9 },
6 tsconfigPaths: true,
107 },
118 test: {
129 environment: "node",
......@@ -96,6 +93,7 @@ export default defineConfig({
9693 "typescript/require-array-sort-compare": "off",
9794 "typescript/restrict-template-expressions": "off",
9895 "typescript/unbound-method": "off",
96 "t3code/no-global-process-runtime": "error",
9997 "t3code/no-inline-schema-compile": "warn",
10098 "t3code/no-manual-effect-runtime-in-tests": "error",
10199 },