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 3c5f499a1cb3..e3b6c49e3ab8 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; + // 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).awaitUserShellDiscovery.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..2dc59c58899e 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -24,6 +24,7 @@ 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", appImagePath: Option.none(), } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -48,21 +49,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 +96,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 +109,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,11 +177,24 @@ 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")); + // 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"); env.PATH = path.dirname(launcher); @@ -149,40 +207,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 +316,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 +330,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 +348,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; @@ -274,6 +396,21 @@ 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" }, inherited.spawner); + yield* reader.install; + expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); }).pipe(Effect.scoped), ); @@ -294,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 de4832885079..b5306a6bb5a8 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,79 @@ 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, + }); + } + } + // 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)) { + 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; + yield* logInfo("installed the t3 command on first launch", { + installedPath: installed.installedPath, + onPath: installed.onPath, + }); + }, + ).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", { + reason: error._tag === "DesktopCliCommandError" ? error.message : "state-write-failed", + }), + ), + 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/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/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/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 124dbbeae89e..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,6 +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); + + // 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); }), ); 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", 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 {