From 11f3fbf07591bab880e3a6a86317a86012265249 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 16:33:39 -0800 Subject: [PATCH 1/7] feat(desktop): a fresh install sets up the t3 command A new desktop install links the `t3` command on first launch, after it has read the login shell's PATH. An app that ran before (it left its launcher or settings) never gets it automatically, because its user may have removed it or chosen another `t3`; Settings still offers Install. - New links go only into ~/.local/bin or ~/bin, never Homebrew or system folders. Our own older link is updated where it is. - It never installs ahead of another `t3`, and Remove sticks. - When the folder is off PATH, Add to PATH in the welcome setup and in Settings appends one marked line to the shell's startup file, once. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopApp.ts | 13 ++ .../desktop/src/app/DesktopCliCommand.test.ts | 157 +++++++++++-- apps/desktop/src/app/DesktopCliCommand.ts | 206 ++++++++++++++++-- apps/desktop/src/app/DesktopCliShim.ts | 4 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/cliCommand.ts | 9 + apps/desktop/src/preload.ts | 1 + .../components/onboarding/WelcomeWizard.tsx | 30 +++ .../settings/CliCommandSettingsRow.tsx | 53 +++-- docs/user/install.md | 14 +- packages/contracts/src/ipc.ts | 3 + packages/shared/src/shell.ts | 2 +- 13 files changed, 434 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 3c5f499a1cb3..92e2e7e61b5b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,6 +15,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; @@ -192,6 +193,18 @@ const bootstrap = Effect.gen(function* () { ); yield* snapShot.initialize; + // A fresh install has no cached shell PATH yet; without it, another t3 on PATH goes unseen. + // If the login shell gives none, a later launch tries again. + const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; + yield* Effect.forkScoped( + (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitDiscovery.pipe( + Effect.flatMap((found) => + found || environment.platform === "win32" + ? installCli + : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), + ), + ), + ); if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index ebb32bda43ce..c706ef220118 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -24,7 +24,9 @@ const environmentFor = ( homeDirectory: input.home, baseDir: input.baseDir, stateDir: path.join(input.baseDir, "userdata"), + desktopSettingsPath: path.join(input.baseDir, "userdata", "desktop-settings.json"), serverRoot: "/opt/T3 Code/resources/app.asar", + appVersion: "1.2.3", appImagePath: Option.none(), } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -48,21 +50,22 @@ const commandIn = ( DesktopEnvironment.DesktopEnvironment, environmentFor(path, { ...input, baseDir }), ), - Effect.provideService(HostProcess.Environment, input.env ?? { PATH: "" }), + Effect.provideService(HostProcess.Environment, input.env ?? { PATH: "", SHELL: "/bin/zsh" }), ); return yield* spawner ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) : make; }); -const fakePowerShell = (initial: string) => { +/** `inherited` stands in for the app's own environment, which `extendEnv` passes on. */ +const fakePowerShell = (initial: string, inherited: Record = {}) => { const registry = { path: initial, failReads: false, writes: 0 }; const spawner = ChildProcessSpawner.make((command) => Effect.sync(() => { const { options } = command as unknown as { - readonly options: { readonly env?: Record }; + readonly options: { readonly env?: Record; readonly extendEnv?: boolean }; }; - const env = options.env ?? {}; + const env = { ...(options.extendEnv ? inherited : {}), ...options.env }; let exitCode = 0; let stdout = ""; if (env.T3_SET === "1") { @@ -94,10 +97,11 @@ const fakePowerShell = (initial: string) => { it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("links the launcher onto PATH and removes only that link", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); const link = path.join(home, ".local", "bin", "t3"); expect(yield* command.state).toEqual({ @@ -106,24 +110,66 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { onPath: false, launcherPath: null, }); + yield* command.installOnFirstLaunch; + expect((yield* command.state).installedPath).toBe(link); const installed = yield* command.install; expect(installed.installedPath).toBe(link); expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); + expect(installed.pathSetup).toEqual({ file: "~/.zshrc", added: false }); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); + // Add to PATH appends one line, once, and keeps a final line with no newline intact. + const zshrc = path.join(home, ".zshrc"); + yield* fs.writeFileString(zshrc, "# mine"); + // A folder already on PATH needs nothing. + env.PATH = path.join(home, ".local", "bin"); + yield* command.addToPath; + expect(yield* fs.readFileString(zshrc)).toBe("# mine"); + env.PATH = ""; + yield* Effect.all([command.addToPath, command.addToPath], { concurrency: "unbounded" }); + expect((yield* command.addToPath).pathSetup).toEqual({ file: "~/.zshrc", added: true }); + expect(yield* fs.readFileString(zshrc)).toBe( + '# mine\n# Added by T3 Code\nexport PATH="$PATH:$HOME/.local/bin"\n', + ); + expect((yield* command.uninstall).installedPath).toBeNull(); expect(yield* fs.exists(link)).toBe(false); expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + // Removed stays removed: first launch never installs again. + yield* command.installOnFirstLaunch; + expect(yield* fs.exists(link)).toBe(false); + + // Also when first launch was put off (no shell PATH yet) and the user removed it meanwhile. + const laterHome = yield* fs.makeTempDirectoryScoped(); + const later = yield* commandIn({ home: laterHome, env }); + yield* later.install; + yield* later.uninstall; + yield* later.installOnFirstLaunch; + expect(yield* fs.exists(path.join(laterHome, ".local", "bin", "t3"))).toBe(false); + + // An app that ran before left its settings or launcher. Its user may have removed t3 or + // chosen another, so first launch leaves it to Settings, where Install still works. + for (const left of ["userdata/desktop-settings.json", "bin/t3"]) { + const upgradedHome = yield* fs.makeTempDirectoryScoped(); + const leftFile = path.join(upgradedHome, ".t3", left); + yield* fs.makeDirectory(path.dirname(leftFile), { recursive: true }); + yield* fs.writeFileString(leftFile, `#!/bin/sh\n# ${DesktopCliShim.MARKER}\n`); + const upgraded = yield* commandIn({ home: upgradedHome, env }); + yield* upgraded.installOnFirstLaunch; + const upgradedLink = path.join(upgradedHome, ".local", "bin", "t3"); + expect(yield* fs.exists(upgradedLink)).toBe(false); + expect((yield* upgraded.install).installedPath).toBe(upgradedLink); + } }).pipe(Effect.scoped), ); it.effect("never replaces or removes a t3 it did not create", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const env: NodeJS.ProcessEnv = { PATH: "" }; const command = yield* commandIn({ home, env }); const theirs = path.join(home, ".local", "bin", "t3"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); @@ -132,7 +178,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); const error = yield* Effect.flip(command.install); - expect(error.message).toContain("Another t3 command is already installed"); + expect(error.message).toContain("Another t3 command is already in"); yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); @@ -149,40 +195,102 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("finds and removes a link left by a previous T3 home", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); + const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3"), env }); const link = (yield* before.install).installedPath; - const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); - expect((yield* after.state).installedPath).toBe(link); + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3"), env }); + // A first launch leaves another home's command alone; only Install takes it over. + yield* after.installOnFirstLaunch; + expect(yield* fs.readLink(link!)).toBe(path.join(home, "old-t3", "bin", "t3")); + // On PATH, it runs the other home's launcher. Settings offers Install (not Remove, and not + // "another t3 runs first"), which updates that link in place. + env.PATH = path.dirname(link!); + expect(yield* after.state).toMatchObject({ installedPath: null, onPath: false }); + expect((yield* after.state).shadowedBy).toBeUndefined(); + env.PATH = ""; + // Off PATH, Add to PATH would expose the other home's command, not this one. + expect((yield* after.state).pathSetup).toBeUndefined(); + yield* Effect.flip(after.addToPath); + expect(yield* fs.exists(path.join(home, ".zshrc"))).toBe(false); + // Behind another t3 the install fails, and the old link still works. + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + env.PATH = shadow; + yield* Effect.flip(after.install); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "old-t3", "bin", "t3")); + // Install takes it over, also with its folder first on PATH. + env.PATH = path.dirname(link!); expect((yield* after.install).installedPath).toBe(link); expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + env.PATH = ""; yield* after.uninstall; expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); + + // A folder already on PATH wins, even written with a trailing slash. + env.PATH = `${path.join(home, "bin")}/`; + expect((yield* after.install).installedPath).toBe(path.join(home, "bin", "t3")); + + // An old link of ours is updated where it is, not moved to a folder that may be off PATH. + yield* fs.writeFileString(path.join(home, "bin", "t3.staging"), "mine"); + yield* fs.remove(path.join(home, "bin", "t3")); + yield* fs.symlink(path.join(home, "old-t3", "bin", "t3"), path.join(home, "bin", "t3")); + env.PATH = ""; + expect((yield* after.install).installedPath).toBe(path.join(home, "bin", "t3")); + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe( + path.join(home, "new-t3", "bin", "t3"), + ); + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.readFileString(path.join(home, "bin", "t3.staging"))).toBe("mine"); }).pipe(Effect.scoped), ); it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - const env = { PATH: [shadow, path.join(home, ".local", "bin")].join(":") }; + env.PATH = [shadow, `${path.join(home, ".local", "bin")}/`].join(":"); const command = yield* commandIn({ home, env }); const theirs = path.join(shadow, "t3"); expect((yield* command.state).shadowedBy).toBe(theirs); const error = yield* Effect.flip(command.install); expect(error.message).toContain(theirs); + yield* command.installOnFirstLaunch; expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.readFileString(theirs)).toBe("#!/bin/sh\n"); + + // Its folder is already on PATH behind theirs; adding it again would change which t3 runs. + yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); + yield* fs.symlink( + path.join(home, ".t3", "bin", "t3"), + path.join(home, ".local", "bin", "t3"), + ); + const behind = yield* command.state; + expect(behind.installedPath).toBe(path.join(home, ".local", "bin", "t3")); + expect(behind.pathSetup).toBeUndefined(); + // Off PATH behind theirs, adding the folder would still put ours first. + env.PATH = shadow; + expect((yield* command.state).pathSetup).toBeUndefined(); + yield* Effect.flip(command.addToPath); + expect(yield* fs.exists(path.join(home, ".zshrc"))).toBe(false); + env.PATH = [shadow, `${path.join(home, ".local", "bin")}/`].join(":"); + yield* fs.remove(path.join(home, ".local", "bin", "t3")); + // A first-launch install that failed is not retried behind the user's back. yield* fs.remove(theirs); + yield* command.installOnFirstLaunch; + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); const installed = yield* command.install; expect(installed).toMatchObject({ onPath: true, @@ -196,6 +304,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { "skips a t3 directory or a file this user cannot execute", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); @@ -209,7 +318,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - const env = { PATH: [folder, plain, shadow, path.join(home, ".local", "bin")].join(":") }; + env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); const command = yield* commandIn({ home, env }); expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); @@ -227,11 +336,12 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const launcher = path.join(home, ".t3", "bin", "t3"); - const env = { PATH: [path.dirname(launcher), path.join(home, ".local", "bin")].join(":") }; + env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); const command = yield* commandIn({ home, env }); const installed = yield* command.install; @@ -255,11 +365,12 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools"; const { registry, spawner } = fakePowerShell(userPath); - const command = yield* commandIn({ home, platform: "win32" }, spawner); + const command = yield* commandIn({ home, platform: "win32", env }, spawner); registry.failReads = true; const error = yield* Effect.flip(command.install); @@ -274,11 +385,27 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(registry.path).toBe(`${userPath};${launcherDir}`); yield* command.uninstall; expect(registry.path).toBe(userPath); + + // A `;` in the folder would split it into two PATH entries, so nothing is written. + const split = fakePowerShell(userPath); + const semicolon = yield* commandIn( + { home, baseDir: `${home}\\T3;Home`, platform: "win32" }, + split.spawner, + ); + expect((yield* Effect.flip(semicolon.install)).message).toContain('has a ";"'); + expect(split.registry).toMatchObject({ path: userPath, writes: 0 }); + + // An inherited T3_SET=1 never turns a read into a write of the whole PATH. + const inherited = fakePowerShell(userPath, { T3_SET: "1", T3_PATH: "C:\\other" }); + const reader = yield* commandIn({ home, platform: "win32", env }, inherited.spawner); + yield* reader.install; + expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); }).pipe(Effect.scoped), ); it.effect("does not claim or remove a Windows PATH entry the user added", () => Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); @@ -289,7 +416,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { ); const userPath = `C:\\Tools;${launcherDir}`; const { registry, spawner } = fakePowerShell(userPath); - const command = yield* commandIn({ home, platform: "win32" }, spawner); + const command = yield* commandIn({ home, platform: "win32", env }, spawner); expect((yield* command.state).installedPath).toBeNull(); yield* command.uninstall; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index de4832885079..26fc61d1d291 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -8,23 +8,34 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell"; + import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-cli-command"); export class DesktopCliCommandError extends Schema.TaggedError()( "DesktopCliCommandError", { message: Schema.String }, ) {} -const unixCandidates = (home: string, platform: NodeJS.Platform) => +const userCandidates = (home: string) => [`${home}/.local/bin`, `${home}/bin`]; + +// Older builds also linked into these macOS folders. Such a link is found, updated in place, and +// removed, but no new link goes there. +const knownLocations = (home: string, platform: NodeJS.Platform) => platform === "darwin" - ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] - : [`${home}/.local/bin`, `${home}/bin`]; + ? [...userCandidates(home), "/opt/homebrew/bin", "/usr/local/bin"] + : userCandidates(home); const pathEntries = (value: string | undefined, separator: string) => (value ?? "").split(separator).filter((entry) => entry.length > 0); @@ -59,6 +70,10 @@ export class DesktopCliCommand extends Context.Service< readonly state: Effect.Effect; readonly install: Effect.Effect; readonly uninstall: Effect.Effect; + /** Adds the installed command's folder to PATH in the user's shell startup file. */ + readonly addToPath: Effect.Effect; + /** Installs the command on a fresh install's first launch; removing it in Settings sticks. */ + readonly installOnFirstLaunch: Effect.Effect; } >()("@t3tools/desktop/app/DesktopCliCommand") {} @@ -74,9 +89,14 @@ export const make = Effect.gen(function* () { const launcher = DesktopCliShim.launcherPath(environment); const binDirectory = path.dirname(launcher); const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); + const firstLaunchMarker = path.join(environment.stateDir, "cli-command-first-launch"); const fail = (message: string) => new DesktopCliCommandError({ message }); const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + // Read before this launch writes either one: an app that ran before left its launcher or + // settings. Its user may have removed t3 or chosen another, so only Settings installs there. + const freshInstall = + !(yield* exists(launcher)) && !(yield* exists(environment.desktopSettingsPath)); const writableDirectory = (directory: string) => fs.access(directory, { writable: true }).pipe( Effect.as(true), @@ -97,8 +117,8 @@ export const make = Effect.gen(function* () { // PATH can reach the regular launcher directly; removal still requires isOurLink. const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")( + // Another T3 home's link is marked too, so compare where it leads, not the marker alone. function* (candidate: string) { - if (yield* isOurLink(candidate)) return true; const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]); if (resolved !== ours) return false; const info = yield* fs.stat(candidate); @@ -127,7 +147,8 @@ export const make = Effect.gen(function* () { return stdout; }), ); - const readUserPath = powershell({}).pipe( + // Explicit, so an inherited T3_SET=1 can never turn a read into a PATH write. + const readUserPath = powershell({ T3_SET: "0" }).pipe( Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), ); @@ -164,6 +185,13 @@ export const make = Effect.gen(function* () { return first; }); + /** A link to this home's launcher; another home's link is marked too. */ + const leadsHere = (link: string) => + fs.readLink(link).pipe( + Effect.map((target) => target === launcher), + Effect.orElseSucceed(() => false), + ); + const installedAt = Effect.gen(function* () { if (windows) { if (!(yield* exists(ownedPathMarker))) return Option.none(); @@ -172,31 +200,77 @@ export const make = Effect.gen(function* () { ? Option.some(launcher) : Option.none(); } - for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + for (const directory of knownLocations(environment.homeDirectory, environment.platform)) { const link = path.join(directory, "t3"); if (yield* isOurLink(link)) return Option.some(link); } return Option.none(); }); + const startupLine = (installedPath: string) => { + const setup = pathSetup({ + directory: path.dirname(installedPath), + home: environment.homeDirectory, + // A Finder or desktop launch may not set SHELL. + shell: env.SHELL || readUserLoginShell(), + platform: environment.platform, + configHome: env.XDG_CONFIG_HOME, + zdotdir: env.ZDOTDIR, + }); + if (setup === undefined) return undefined; + const file = setup.file.startsWith("~/") + ? path.join(environment.homeDirectory, setup.file.slice(2)) + : setup.file; + return { ...setup, path: file }; + }; + const hasLine = (setup: { readonly path: string; readonly line: string }) => + fs.readFileString(setup.path).pipe( + Effect.map((content) => content.split("\n").some((line) => line.trim() === setup.line)), + Effect.orElseSucceed(() => false), + ); + + const folderOnPath = (link: string) => + Effect.sync(() => + pathEntries(env.PATH, ":").some((entry) => entry.replace(/\/+$/, "") === path.dirname(link)), + ); + const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } const launcherPath = (yield* exists(launcher)) ? launcher : null; - const installed = yield* installedAt; - const shadowedBy = yield* foreignFirstOnPath; + const found = yield* installedAt; + // Our link into another T3 home is not this app's command; Install updates it in place. + const installed = + windows || (Option.isSome(found) && (yield* leadsHere(found.value))) + ? found + : Option.none(); + const shadowedBy = (yield* foreignFirstOnPath).pipe( + Option.filter((first) => first !== Option.getOrUndefined(found)), + ); const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; const first = yield* firstOnPath; const onPath = windows ? Option.isSome(installed) : Option.isSome(first) && (yield* runsOurLauncher(first.value)); + // Only a folder missing from PATH, with no other t3 first: with one, the added folder (last + // on PATH) would still not run this t3. + const setup = + onPath || + Option.isSome(shadowedBy) || + Option.isNone(installed) || + (yield* folderOnPath(installed.value)) + ? undefined + : startupLine(installed.value); return { supported: true, installedPath: Option.getOrNull(installed), onPath, launcherPath, ...shadow, + ...(setup === undefined + ? {} + : { pathSetup: { file: setup.file, added: yield* hasLine(setup) } }), }; }).pipe( Effect.orElseSucceed(() => ({ @@ -222,6 +296,12 @@ export const make = Effect.gen(function* () { if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); yield* ensureLauncher; if (windows) { + // PATH would split the folder at the `;`, leaving two wrong entries behind. + if (binDirectory.includes(";")) { + return yield* fail( + `T3 Code cannot put ${binDirectory} on PATH because it has a ";". Run ${launcher} directly.`, + ); + } const entries = pathEntries(yield* readUserPath, ";"); if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { yield* writeUserPath([...entries, binDirectory].join(";")); @@ -231,26 +311,27 @@ export const make = Effect.gen(function* () { } return yield* state; } - const existing = yield* installedAt; - if (Option.isSome(existing)) { - const target = yield* fs.readLink(existing.value).pipe(Effect.option); + const candidates = userCandidates(environment.homeDirectory); + const replaced = Option.getOrUndefined(yield* installedAt); + if (replaced !== undefined) { + const target = yield* fs.readLink(replaced).pipe(Effect.option); if (Option.getOrUndefined(target) === launcher) return yield* state; - yield* fs - .remove(existing.value) - .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); } const shadowedBy = yield* foreignFirstOnPath; - if (Option.isSome(shadowedBy)) { + // Our own link into another T3 home runs first too; it is the one this install updates. + if (Option.isSome(shadowedBy) && shadowedBy.value !== replaced) { return yield* fail( `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, ); } - const onPath = pathEntries(env.PATH, ":"); - const candidates = unixCandidates(environment.homeDirectory, environment.platform); - for (const directory of [ + // An old link of ours is updated where it is, so a t3 that works now keeps working. It is + // removed only once a new link exists elsewhere. + const onPath = pathEntries(env.PATH, ":").map((entry) => entry.replace(/\/+$/, "")); + for (const directory of new Set([ + ...(replaced === undefined ? [] : [path.dirname(replaced)]), ...candidates.filter((candidate) => onPath.includes(candidate)), ...candidates.filter((candidate) => !onPath.includes(candidate)), - ]) { + ])) { const link = path.join(directory, "t3"); const created = (yield* exists(directory)) ? yield* writableDirectory(directory) @@ -259,18 +340,38 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); if (!created) continue; - const linked = yield* fs.symlink(launcher, link).pipe( + // Our own link is swapped in place; anything else there makes symlink fail and stays. + const staging = `${link}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`; + const linked = yield* ( + link === replaced + ? fs + .symlink(launcher, staging) + .pipe( + Effect.andThen( + fs + .rename(staging, link) + .pipe(Effect.tapError(() => fs.remove(staging).pipe(Effect.ignore))), + ), + ) + : fs.symlink(launcher, link) + ).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ); - if (linked) return yield* state; + if (!linked) continue; + if (replaced !== undefined && replaced !== link) { + yield* fs.remove(replaced).pipe(Effect.ignore); + } + return yield* state; } return yield* fail( - `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, + `Another t3 command is already in ~/.local/bin and ~/bin, or neither is writable. Run the launcher directly at ${launcher}.`, ); }).pipe(Effect.withSpan("desktop.cliCommand.install")); const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { + // A first launch put off for the shell PATH must not bring back what the user removed. + yield* fs.writeFileString(firstLaunchMarker, "").pipe(Effect.ignore); if (windows) { if (yield* exists(ownedPathMarker)) { const entries = pathEntries(yield* readUserPath, ";"); @@ -280,7 +381,7 @@ export const make = Effect.gen(function* () { } return yield* state; } - for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + for (const directory of knownLocations(environment.homeDirectory, environment.platform)) { const link = path.join(directory, "t3"); if (yield* isOurLink(link)) { yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); @@ -289,7 +390,64 @@ export const make = Effect.gen(function* () { return yield* state; }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); - return DesktopCliCommand.of({ state, install, uninstall }); + // One at a time, so Settings and onboarding cannot both append the line. + const addToPathPermit = yield* Semaphore.make(1); + const addToPath: DesktopCliCommand["Service"]["addToPath"] = Effect.gen(function* () { + const installed = yield* installedAt; + if (Option.isNone(installed) || !(yield* leadsHere(installed.value))) { + return yield* fail("Install the t3 command first."); + } + const setup = startupLine(installed.value); + if (setup === undefined) return yield* fail("T3 Code cannot add t3 to this shell's PATH."); + const shadowedBy = yield* foreignFirstOnPath; + if (Option.isSome(shadowedBy)) { + return yield* fail( + `Another t3 at ${shadowedBy.value} runs first; T3 Code leaves it in place.`, + ); + } + if (!(yield* folderOnPath(installed.value)) && !(yield* hasLine(setup))) { + yield* fs.makeDirectory(path.dirname(setup.path), { recursive: true }).pipe( + Effect.andThen( + fs.writeFileString(setup.path, `\n# Added by T3 Code\n${setup.line}\n`, { flag: "a" }), + ), + Effect.mapError(() => fail(`Could not add t3 to ${setup.file}.`)), + ); + } + return yield* state; + }).pipe(addToPathPermit.withPermits(1), Effect.withSpan("desktop.cliCommand.addToPath")); + + const installOnFirstLaunch: DesktopCliCommand["Service"]["installOnFirstLaunch"] = Effect.gen( + function* () { + if (!environment.isPackaged || (yield* exists(firstLaunchMarker))) return; + // Mark first so a failed or removed install is never retried behind the user's back. + yield* fs.writeFileString(firstLaunchMarker, ""); + if (!freshInstall) { + return yield* logInfo("left the t3 command to Settings on an existing install"); + } + // Another T3 home's app owns the existing link; only Install in Settings may take it over. + const existing = Option.getOrUndefined(yield* installedAt); + if (existing !== undefined) { + const target = yield* fs.readLink(existing).pipe(Effect.option); + if (Option.getOrUndefined(target) !== launcher) { + return yield* logInfo("left another T3 Code app's t3 command in place", { + existing, + }); + } + } + const installed = yield* install; + yield* logInfo("installed the t3 command on first launch", { + installedPath: installed.installedPath, + onPath: installed.onPath, + }); + }, + ).pipe( + Effect.catch((error) => + logWarning("did not install the t3 command on first launch", { error: String(error) }), + ), + Effect.withSpan("desktop.cliCommand.installOnFirstLaunch"), + ); + + return DesktopCliCommand.of({ state, install, uninstall, addToPath, installOnFirstLaunch }); }); export const layer = Layer.effect(DesktopCliCommand, make); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 3d4b94abff46..f8bd8b5f91de 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; @@ -147,7 +148,8 @@ export const install = Effect.gen(function* () { } if (Option.getOrUndefined(existing) !== content) { yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); - const staging = `${shimPath}.${process.pid}.tmp`; + // Unique per call: backend startup and the first-launch command install can race here. + const staging = `${shimPath}.${process.pid}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`; yield* fs.writeFileString(staging, content, { mode: 0o755 }); yield* fs.rename(staging, shimPath); yield* logInfo("installed t3 launcher", { shimPath }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index db26c525c001..e3136977098f 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -80,6 +80,7 @@ import { getCliCommandState, installCliCommand, uninstallCliCommand, + addCliCommandToPath, } from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -162,6 +163,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getCliCommandState); yield* ipc.handle(installCliCommand); yield* ipc.handle(uninstallCliCommand); + yield* ipc.handle(addCliCommandToPath); for (const previewMethod of PreviewIpc.methods) { // Explicit types preserve the error/service union of these heterogeneous preview methods. yield* ipc.handle< diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 6a149e24ae69..4f5117c21cde 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -31,6 +31,7 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; +export const CLI_COMMAND_ADD_TO_PATH_CHANNEL = "desktop:cli-command-add-to-path"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; diff --git a/apps/desktop/src/ipc/methods/cliCommand.ts b/apps/desktop/src/ipc/methods/cliCommand.ts index be9672c68251..b3731b8b6f9c 100644 --- a/apps/desktop/src/ipc/methods/cliCommand.ts +++ b/apps/desktop/src/ipc/methods/cliCommand.ts @@ -32,3 +32,12 @@ export const uninstallCliCommand = makeIpcMethod({ return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; }), }); + +export const addCliCommandToPath = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_ADD_TO_PATH_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.addToPath")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).addToPath; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c44ec513378..5d14ba07140a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -285,6 +285,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL), + addToPath: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_ADD_TO_PATH_CHANNEL), }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 823b8b9042fc..c4647e7cf282 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -84,6 +84,7 @@ import { connectPairing } from "../../connection/onboarding"; import { getProviderSummary } from "../settings/providerStatus"; import { providerClients } from "../settings/providerDriverMeta"; import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; +import { useDesktopCliCommand } from "../settings/CliCommandSettingsRow"; import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; @@ -701,6 +702,7 @@ function AgentsStep({ ))} +
+ )} +
+ ); +} + function StepShell({ title, description, diff --git a/apps/web/src/components/settings/CliCommandSettingsRow.tsx b/apps/web/src/components/settings/CliCommandSettingsRow.tsx index 28e86e9fa2d3..ec38480d0f28 100644 --- a/apps/web/src/components/settings/CliCommandSettingsRow.tsx +++ b/apps/web/src/components/settings/CliCommandSettingsRow.tsx @@ -11,13 +11,19 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -export function CliCommandSettingsRow() { +const FAILURE_TITLES = { + install: "Could not install t3", + uninstall: "Could not remove t3", + addToPath: "Could not add t3 to PATH", +} as const; + +/** The desktop `t3` command's state, and its actions with a toast on failure. */ +export function useDesktopCliCommand() { const bridge = getDesktopCliCommandBridge(); const query = useEnvironmentQuery(bridge ? desktopCliCommandStateAtom : null); - const state = query.data; const [pending, setPending] = useState(false); - const change = (action: "install" | "uninstall") => { + const run = (action: keyof typeof FAILURE_TITLES) => { if (!bridge || pending) return; setPending(true); void bridge[action]() @@ -26,7 +32,7 @@ export function CliCommandSettingsRow() { toastManager.add( stackedThreadToast({ type: "error", - title: action === "install" ? "Could not install t3" : "Could not remove t3", + title: FAILURE_TITLES[action], description: error instanceof Error ? error.message : "Something went wrong.", }), ); @@ -34,7 +40,17 @@ export function CliCommandSettingsRow() { .finally(() => setPending(false)); }; - if (!bridge || !state?.supported) return null; + return { + state: bridge ? query.data : undefined, + busy: pending || query.isPending, + run, + }; +} + +export function CliCommandSettingsRow() { + const { state, busy, run } = useDesktopCliCommand(); + + if (!state?.supported) return null; const installed = state.installedPath !== null; const description = state.shadowedBy ? `Another t3 at ${state.shadowedBy} runs first in a new terminal. Remove it to use T3 Code's.` @@ -42,21 +58,30 @@ export function CliCommandSettingsRow() { ? "Run T3 Code's CLI as `t3` from any terminal." : state.onPath ? `Installed at ${state.installedPath}. Open a new terminal to use it.` - : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`; + : state.pathSetup?.added + ? `Installed at ${state.installedPath} and added to PATH in ${state.pathSetup.file}. Open a new terminal to use it.` + : `Installed at ${state.installedPath}, which is not on your PATH yet.${state.pathSetup ? "" : " Add its folder to your PATH to run `t3`."}`; return ( change(installed ? "uninstall" : "install")} - > - {installed ? "Remove" : "Install"} - +
+ {state.pathSetup && !state.pathSetup.added ? ( + + ) : null} + +
} /> ); diff --git a/docs/user/install.md b/docs/user/install.md index d26e1b13d4dc..48d96a4fef61 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -129,12 +129,14 @@ Nix manages both the desktop app and the bundled ### The `t3` command -The desktop app includes the `t3` command-line tool. To run it from any -terminal, open **Settings → About** and choose **Install** next to -**t3 command**. On macOS and Linux it adds a `t3` link to a folder on your -`PATH`; on Windows it adds the app's command folder to your `PATH`. Open a new -terminal afterwards. **Remove** takes it off again. If you already have `t3` -from npm, it stays as it is. +The desktop app includes the `t3` command-line tool. A new install sets it up +on first launch; after an update, or any time, choose **Install** next to +**t3 command** in **Settings → About**. On macOS and Linux it adds a `t3` link +in `~/.local/bin` (or `~/bin`); on Windows it adds the app's command folder to +your `PATH`. Open a new terminal afterwards. If the folder is not on your +`PATH`, **Add to PATH** in the welcome setup or **Settings → About** adds one +line to your shell's startup file. **Remove** takes it off, and the app does not +add it again. If you already have another `t3`, it stays as it is. ### Windows Subsystem for Linux diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index afb87b14bc91..29be3d6d0219 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -334,6 +334,8 @@ export const DesktopCliCommandStateSchema = Schema.Struct({ installedPath: Schema.NullOr(Schema.String), onPath: Schema.Boolean, shadowedBy: Schema.optionalKey(Schema.String), + /** For an installed `t3` that is off PATH: the shell startup file that adds it, and whether it has. */ + pathSetup: Schema.optionalKey(Schema.Struct({ file: Schema.String, added: Schema.Boolean })), /** The app's bundled `t3` launcher, which runs without Node or a PATH entry. */ launcherPath: Schema.NullOr(Schema.String), }); @@ -1142,6 +1144,7 @@ export interface DesktopBridge { getState: () => Promise; install: () => Promise; uninstall: () => Promise; + addToPath: () => Promise; }; webLinks: { setReady: (ready: boolean) => Promise; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cee692c2dd38..8a0e0be51922 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -132,7 +132,7 @@ function trimNonEmpty(value: string | null | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined; } -function readUserLoginShell(): string | undefined { +export function readUserLoginShell(): string | undefined { try { return trimNonEmpty(NodeOS.userInfo().shell); } catch { From 0c4be0ec4cb67e319dbd3783045cb33c2299f71d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 16:47:47 -0800 Subject: [PATCH 2/7] fix(desktop): auto-install t3 only with the user's own shell PATH Shell discovery falls back to another login shell when the user's own shell fails. That PATH can miss folders the user's shell adds, so a `t3` there would go unseen and the new link could run ahead of it. Discovery now also says whether the user's own shell answered, and the first-launch install waits for that; otherwise Settings offers Install. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopApp.test.ts | 1 + apps/desktop/src/app/DesktopApp.ts | 6 +-- .../DesktopBackendConfiguration.test.ts | 1 + .../src/shell/DesktopShellEnvironment.test.ts | 21 ++++++++ .../src/shell/DesktopShellEnvironment.ts | 48 +++++++++++++++---- .../DesktopTelemetryPublisher.test.ts | 1 + 6 files changed, 65 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 8b5f9f712dc1..6cb00baf897d 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -117,6 +117,7 @@ it.effect.each([false, true])( Effect.provideService(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(true), awaitDiscovery: Effect.succeed(true), + awaitUserShellDiscovery: Effect.succeed(true), updates: Stream.empty, }), Effect.provideService(DesktopAppSettings.DesktopAppSettings, { diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 92e2e7e61b5b..e3b6c49e3ab8 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -193,11 +193,11 @@ const bootstrap = Effect.gen(function* () { ); yield* snapShot.initialize; - // A fresh install has no cached shell PATH yet; without it, another t3 on PATH goes unseen. - // If the login shell gives none, a later launch tries again. + // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has + // no cached one. Without it, the command is left to Settings. const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; yield* Effect.forkScoped( - (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitDiscovery.pipe( + (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( Effect.flatMap((found) => found || environment.platform === "win32" ? installCli diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index e71d83818870..c1b9c060d547 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -27,6 +27,7 @@ const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), awaitDiscovery: Effect.succeed(prepared), + awaitUserShellDiscovery: Effect.succeed(prepared), updates: Stream.empty, }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 124dbbeae89e..55596a3f644f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -462,6 +462,27 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("says whether the user's own shell or only a fallback gave the PATH", () => + Effect.gen(function* () { + const discover = (shell: string, failing: string) => + runShellEnvironment({ + env: { SHELL: shell, PATH: "/usr/bin" }, + platform: "linux", + use: (service) => + Effect.all([service.awaitDiscovery, service.awaitUserShellDiscovery]).pipe( + Effect.map(([prepared, fromUserShell]) => prepared && !fromUserShell), + ), + handler: (command) => + command._tag === "StandardCommand" && command.command === failing + ? { output: "", exitCode: 7 } + : envOutput({ PATH: "/usr/bin" }), + }).pipe(Effect.provide(Logger.layer([]))); + // fish failed and bash answered: its PATH can miss folders fish adds. + assert.isTrue(yield* discover("/usr/bin/fish", "/usr/bin/fish")); + assert.isFalse(yield* discover("/usr/bin/fish", "none")); + }), + ); + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 37826f8ee5ab..9d9e390c9549 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import { applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, + readUserLoginShell, setWindowsPath, } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; @@ -79,6 +80,8 @@ export class DesktopShellEnvironment extends Context.Service< // Desktop owns discovery, including on cache misses; the backend receives updates over fd4. readonly installIntoProcess: Effect.Effect; readonly awaitDiscovery: Effect.Effect; + /** Whether the user's own login shell, not a fallback, gave the PATH a new terminal gets. */ + readonly awaitUserShellDiscovery: Effect.Effect; readonly updates: Stream.Stream; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} @@ -417,19 +420,24 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< - boolean, + ShellDiscovery, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; + const userShells = [config.env.SHELL?.trim(), readUserLoginShell()]; + let answeredBy: string | undefined; for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { Object.assign( shellEnvironment, yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), ); - if (shellEnvironment.PATH) break; + if (shellEnvironment.PATH) { + answeredBy = shell; + break; + } } const launchctlPath = @@ -518,24 +526,33 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - return Option.isSome(trimNonEmpty(shellEnvironment.PATH)); + const prepared = Option.isSome(trimNonEmpty(shellEnvironment.PATH)); + return { prepared, fromUserShell: prepared && userShells.includes(answeredBy) }; }, ); +/** `fromUserShell` is false when only a fallback shell answered, whose PATH can miss folders. */ +interface ShellDiscovery { + readonly prepared: boolean; + readonly fromUserShell: boolean; +} + const installShellEnvironment = ( config: ShellEnvironmentConfig, ): Effect.Effect< - boolean, + ShellDiscovery, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > => { if (config.platform === "win32") { - return installWindowsEnvironment(config); + return installWindowsEnvironment(config).pipe( + Effect.map((prepared) => ({ prepared, fromUserShell: prepared })), + ); } if (config.platform === "darwin" || config.platform === "linux") { return installPosixEnvironment(config); } - return Effect.succeed(false); + return Effect.succeed({ prepared: false, fromUserShell: false }); }; const ShellEnvironmentCache = Schema.fromJsonString( @@ -578,7 +595,7 @@ export const make = Effect.gen(function* () { const cachePath = path.join(environment.stateDir, "shell-environment-cache.json"); const changes = yield* PubSub.unbounded(); const latest = yield* Ref.make>(Option.none()); - const discovered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); const publish = Effect.fn("desktop.shellEnvironment.publish")(function* ( patch: ShellEnvironmentPatch, discovered = false, @@ -597,7 +614,11 @@ export const make = Effect.gen(function* () { const refresh = Effect.gen(function* () { // Refresh from the inherited baseline so removed cached paths and values do not survive. const fresh = { ...inherited }; - const prepared = yield* installShellEnvironment({ env: fresh, platform: environment.platform }); + const discovery = yield* installShellEnvironment({ + env: fresh, + platform: environment.platform, + }); + const { prepared } = discovery; const patch = Object.fromEntries( Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, fresh[name] ?? null]), ); @@ -616,7 +637,7 @@ export const make = Effect.gen(function* () { Effect.catch(() => Effect.logWarning("Could not cache shell environment")), ); } - return prepared; + return discovery; }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -669,7 +690,14 @@ export const make = Effect.gen(function* () { ); return DesktopShellEnvironment.of({ installIntoProcess, - awaitDiscovery: installIntoProcess.pipe(Effect.andThen(Deferred.await(discovered))), + awaitDiscovery: installIntoProcess.pipe( + Effect.andThen(Deferred.await(discovered)), + Effect.map(({ prepared }) => prepared), + ), + awaitUserShellDiscovery: installIntoProcess.pipe( + Effect.andThen(Deferred.await(discovered)), + Effect.map(({ fromUserShell }) => fromUserShell), + ), updates, }); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 9201bc01110e..e2bfabe32762 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -92,6 +92,7 @@ describe("DesktopTelemetryPublisher", () => { Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(true), awaitDiscovery: Effect.succeed(true), + awaitUserShellDiscovery: Effect.succeed(true), updates: Stream.make({ version: 1, type: "shellEnvironment", From 7a6a6859843265dfcdb049bbee42ab5f227035cf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 16:50:51 -0800 Subject: [PATCH 3/7] fix(desktop): a fresh install leaves t3 to Settings beside another t3 Someone else's `t3` in ~/.local/bin, even off PATH (for example from the npx prompt before its folder is on PATH), made first launch link a second `t3` in ~/bin. First launch now leaves the command to Settings when either folder holds a `t3` that is not ours. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.test.ts | 16 ++++++++++------ apps/desktop/src/app/DesktopCliCommand.ts | 7 +++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index c706ef220118..d8e63271b632 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -26,7 +26,6 @@ const environmentFor = ( stateDir: path.join(input.baseDir, "userdata"), desktopSettingsPath: path.join(input.baseDir, "userdata", "desktop-settings.json"), serverRoot: "/opt/T3 Code/resources/app.asar", - appVersion: "1.2.3", appImagePath: Option.none(), } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -183,6 +182,13 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + // A fresh install beside someone else's t3, even off PATH, adds no second t3 in ~/bin. + const besideHome = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(besideHome, ".local", "bin"), { recursive: true }); + yield* fs.writeFileString(path.join(besideHome, ".local", "bin", "t3"), "npm's t3\n"); + yield* (yield* commandIn({ home: besideHome })).installOnFirstLaunch; + expect(yield* fs.exists(path.join(besideHome, "bin", "t3"))).toBe(false); + const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); env.PATH = path.dirname(launcher); @@ -365,12 +371,11 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools"; const { registry, spawner } = fakePowerShell(userPath); - const command = yield* commandIn({ home, platform: "win32", env }, spawner); + const command = yield* commandIn({ home, platform: "win32" }, spawner); registry.failReads = true; const error = yield* Effect.flip(command.install); @@ -397,7 +402,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { // An inherited T3_SET=1 never turns a read into a write of the whole PATH. const inherited = fakePowerShell(userPath, { T3_SET: "1", T3_PATH: "C:\\other" }); - const reader = yield* commandIn({ home, platform: "win32", env }, inherited.spawner); + const reader = yield* commandIn({ home, platform: "win32" }, inherited.spawner); yield* reader.install; expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); }).pipe(Effect.scoped), @@ -405,7 +410,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("does not claim or remove a Windows PATH entry the user added", () => Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "", SHELL: "/bin/zsh" }; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); @@ -416,7 +420,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { ); const userPath = `C:\\Tools;${launcherDir}`; const { registry, spawner } = fakePowerShell(userPath); - const command = yield* commandIn({ home, platform: "win32", env }, spawner); + const command = yield* commandIn({ home, platform: "win32" }, spawner); expect((yield* command.state).installedPath).toBeNull(); yield* command.uninstall; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 26fc61d1d291..3e5fb0ede507 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -434,6 +434,13 @@ export const make = Effect.gen(function* () { }); } } + // A t3 that is not ours where we would link, even off PATH, means the user has one already. + for (const directory of userCandidates(environment.homeDirectory)) { + const other = path.join(directory, "t3"); + if ((yield* exists(other)) && !(yield* isOurLink(other))) { + return yield* logInfo("left the t3 command to Settings beside another t3", { other }); + } + } const installed = yield* install; yield* logInfo("installed the t3 command on first launch", { installedPath: installed.installedPath, From f8896fa08ed99a528c2b5984c29182a0c1c4a923 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:01:26 -0800 Subject: [PATCH 4/7] fix(desktop): a dangling link beside us also leaves t3 to Settings Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.test.ts | 18 ++++++++++++------ apps/desktop/src/app/DesktopCliCommand.ts | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index d8e63271b632..e60bd11d95b2 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -182,12 +182,18 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); - // A fresh install beside someone else's t3, even off PATH, adds no second t3 in ~/bin. - const besideHome = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(besideHome, ".local", "bin"), { recursive: true }); - yield* fs.writeFileString(path.join(besideHome, ".local", "bin", "t3"), "npm's t3\n"); - yield* (yield* commandIn({ home: besideHome })).installOnFirstLaunch; - expect(yield* fs.exists(path.join(besideHome, "bin", "t3"))).toBe(false); + // A fresh install beside someone else's t3 (even off PATH or a dangling link) adds no second + // t3 in ~/bin. + for (const dangling of [false, true]) { + const besideHome = yield* fs.makeTempDirectoryScoped(); + const other = path.join(besideHome, ".local", "bin", "t3"); + yield* fs.makeDirectory(path.dirname(other), { recursive: true }); + yield* dangling + ? fs.symlink(path.join(besideHome, "gone"), other) + : fs.writeFileString(other, "npm's t3\n"); + yield* (yield* commandIn({ home: besideHome })).installOnFirstLaunch; + expect(yield* fs.exists(path.join(besideHome, "bin", "t3"))).toBe(false); + } const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 3e5fb0ede507..f9c290c1bb3a 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -437,7 +437,9 @@ export const make = Effect.gen(function* () { // A t3 that is not ours where we would link, even off PATH, means the user has one already. for (const directory of userCandidates(environment.homeDirectory)) { const other = path.join(directory, "t3"); - if ((yield* exists(other)) && !(yield* isOurLink(other))) { + const present = + (yield* exists(other)) || Option.isSome(yield* fs.readLink(other).pipe(Effect.option)); + if (present && !(yield* isOurLink(other))) { return yield* logInfo("left the t3 command to Settings beside another t3", { other }); } } From 2e323c72337e1dba38693484cf3600062292d595 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:09:42 -0800 Subject: [PATCH 5/7] fix(desktop): on Windows, a standalone t3.cmd also leaves t3 to Settings Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.test.ts | 10 ++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 15 +++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index e60bd11d95b2..2dc59c58899e 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -431,6 +431,16 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* command.state).installedPath).toBeNull(); yield* command.uninstall; expect(registry.path).toBe(userPath); + + // A fresh install beside a standalone t3.cmd (from `t3 install`), even off PATH, leaves + // PATH alone. + const besideHome = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(besideHome, ".local", "bin"), { recursive: true }); + yield* fs.writeFileString(path.join(besideHome, ".local", "bin", "t3.cmd"), "@echo off\r\n"); + const beside = fakePowerShell("C:\\Tools"); + yield* (yield* commandIn({ home: besideHome, platform: "win32" }, beside.spawner)) + .installOnFirstLaunch; + expect(beside.registry).toMatchObject({ path: "C:\\Tools", writes: 0 }); }).pipe(Effect.scoped), ); }); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index f9c290c1bb3a..b22662fb3953 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -434,13 +434,16 @@ export const make = Effect.gen(function* () { }); } } - // A t3 that is not ours where we would link, even off PATH, means the user has one already. + // A t3 that is not ours in these folders, even off PATH, means the user has one already. On + // Windows the app never writes there, so any t3 there is someone else's. for (const directory of userCandidates(environment.homeDirectory)) { - const other = path.join(directory, "t3"); - const present = - (yield* exists(other)) || Option.isSome(yield* fs.readLink(other).pipe(Effect.option)); - if (present && !(yield* isOurLink(other))) { - return yield* logInfo("left the t3 command to Settings beside another t3", { other }); + for (const name of windows ? ["t3.cmd", "t3.exe"] : ["t3"]) { + const other = path.join(directory, name); + const present = + (yield* exists(other)) || Option.isSome(yield* fs.readLink(other).pipe(Effect.option)); + if (present && (windows || !(yield* isOurLink(other)))) { + return yield* logInfo("left the t3 command to Settings beside another t3", { other }); + } } } const installed = yield* install; From fbdce484420a8af8609c77b52c133c6615c0bc19 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:36:45 -0800 Subject: [PATCH 6/7] fix(desktop): first-launch failure logs a bounded reason Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index b22662fb3953..b5306a6bb5a8 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -453,8 +453,11 @@ export const make = Effect.gen(function* () { }); }, ).pipe( + // Our own messages are short and name only paths; anything else is logged as a category. Effect.catch((error) => - logWarning("did not install the t3 command on first launch", { error: String(error) }), + logWarning("did not install the t3 command on first launch", { + reason: error._tag === "DesktopCliCommandError" ? error.message : "state-write-failed", + }), ), Effect.withSpan("desktop.cliCommand.installOnFirstLaunch"), ); From 5276559de283c59aef6abe732dffb24b71119edc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 18:09:13 -0800 Subject: [PATCH 7/7] test(desktop): fold the user-shell discovery checks into the fallback-shell case Co-Authored-By: Claude Opus 5.5 --- .../src/shell/DesktopShellEnvironment.test.ts | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 55596a3f644f..0c7869194323 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -444,10 +444,13 @@ describe("DesktopShellEnvironment", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; const commands: string[] = []; - yield* runShellEnvironment({ + const discovery = (service: DesktopShellEnvironment.DesktopShellEnvironment["Service"]) => + Effect.all([service.awaitDiscovery, service.awaitUserShellDiscovery]); + const answered = yield* runShellEnvironment({ env, platform: "linux", userShell: "/usr/local/bin/fish", + use: (service) => discovery(service).pipe(Effect.map(([, fromUserShell]) => fromUserShell)), handler: (command) => { if (command._tag !== "StandardCommand") return ""; commands.push(command.command); @@ -459,27 +462,23 @@ describe("DesktopShellEnvironment", () => { assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]); assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); - }), - ); + // The account's own login shell answered. + assert.isTrue(answered); - it.effect("says whether the user's own shell or only a fallback gave the PATH", () => - Effect.gen(function* () { - const discover = (shell: string, failing: string) => - runShellEnvironment({ - env: { SHELL: shell, PATH: "/usr/bin" }, - platform: "linux", - use: (service) => - Effect.all([service.awaitDiscovery, service.awaitUserShellDiscovery]).pipe( - Effect.map(([prepared, fromUserShell]) => prepared && !fromUserShell), - ), - handler: (command) => - command._tag === "StandardCommand" && command.command === failing - ? { output: "", exitCode: 7 } - : envOutput({ PATH: "/usr/bin" }), - }).pipe(Effect.provide(Logger.layer([]))); - // fish failed and bash answered: its PATH can miss folders fish adds. - assert.isTrue(yield* discover("/usr/bin/fish", "/usr/bin/fish")); - assert.isFalse(yield* discover("/usr/bin/fish", "none")); + // Only the platform fallback answered: its PATH can miss folders the user's shell adds. + const fallbackOnly = yield* runShellEnvironment({ + env: { SHELL: "/usr/bin/fish", PATH: "/usr/bin" }, + platform: "linux", + use: (service) => + discovery(service).pipe( + Effect.map(([prepared, fromUserShell]) => prepared && !fromUserShell), + ), + handler: (command) => + command._tag === "StandardCommand" && command.command === "/usr/bin/fish" + ? { output: "", exitCode: 7 } + : envOutput({ PATH: "/usr/bin" }), + }).pipe(Effect.provide(Logger.layer([]))); + assert.isTrue(fallbackOnly); }), );