From 0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:42:53 -0700 Subject: [PATCH] feat(desktop): install the t3 command from Settings (#16683) Co-authored-by: Claude Opus 5.5 (1M context) --- .../desktop/src/app/DesktopCliCommand.test.ts | 243 ++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 261 ++++++++++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 12 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/cliCommand.ts | 34 +++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 5 + .../settings/CliCommandSettingsRow.tsx | 76 +++++ .../components/settings/SettingsPanels.tsx | 2 + .../src/components/settings/settingsSearch.ts | 7 + docs/user/install.md | 9 + packages/contracts/src/ipc.ts | 17 ++ 13 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCliCommand.test.ts create mode 100644 apps/desktop/src/app/DesktopCliCommand.ts create mode 100644 apps/desktop/src/ipc/methods/cliCommand.ts create mode 100644 apps/web/src/components/settings/CliCommandSettingsRow.tsx diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc2e7fb962d18e3651c4dd8000b63853d0c9eff7 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -0,0 +1,243 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentFor = ( + path: Path.Path, + input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean }, +) => + DesktopEnvironment.DesktopEnvironment.of({ + path, + platform: input.platform ?? "linux", + isPackaged: input.isPackaged ?? true, + homeDirectory: input.home, + baseDir: input.baseDir, + stateDir: path.join(input.baseDir, "userdata"), + serverRoot: "/opt/T3 Code/resources/app.asar", + appImagePath: Option.none(), + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +/** The service for a packaged app; the launcher is written on Install. */ +const commandIn = ( + input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, + spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const baseDir = input.baseDir ?? path.join(input.home, ".t3"); + yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true }); + const make = DesktopCliCommand.make.pipe( + Effect.provideService( + DesktopEnvironment.DesktopEnvironment, + environmentFor(path, { ...input, baseDir }), + ), + ); + return yield* spawner + ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) + : make; + }); + +/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ +const fakePowerShell = (initial: string) => { + 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 }; + }; + const env = options.env ?? {}; + let exitCode = 0; + let stdout = ""; + if (env.T3_SET === "1") { + registry.path = env.T3_PATH ?? ""; + registry.writes += 1; + } else if (registry.failReads) { + exitCode = 1; + } else { + stdout = Buffer.from(registry.path, "utf8").toString("base64"); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { registry, spawner }; +}; + +it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { + it.effect("links the launcher onto PATH and removes only that link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const link = path.join(home, ".local", "bin", "t3"); + + expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + // Install writes the launcher itself, even when no local backend ever did. + const installed = yield* command.install; + expect(installed.installedPath).toBe(link); + expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); + expect((yield* command.install).installedPath).toBe(link); + + expect((yield* command.uninstall).installedPath).toBeNull(); + expect(yield* fs.exists(link)).toBe(false); + // The launcher itself stays for setup commands. + expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("never replaces or removes a t3 it did not create", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const theirs = path.join(home, ".local", "bin", "t3"); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.writeFileString(theirs, "npm's t3\n"); + // Even a broken link in the next folder is someone else's. + yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); + 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"); + 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")); + }).pipe(Effect.scoped), + ); + + it.effect("finds and removes a link left by a previous T3 home", () => + Effect.gen(function* () { + 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 link = (yield* before.install).installedPath; + + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); + expect((yield* after.state).installedPath).toBe(link); + // Installing again points the link at this home's launcher. + expect((yield* after.install).installedPath).toBe(link); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + 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); + }).pipe(Effect.scoped), + ); + + it.effect("does not read a large binary another t3 links to", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + // A big executable that happens to contain the marker text is still not ours. + const binary = path.join(home, "native-t3"); + yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); + yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); + yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); + const command = yield* commandIn({ home }); + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); + }).pipe(Effect.scoped), + ); + + it.effect("reports when another t3 earlier on PATH would run instead", () => + Effect.gen(function* () { + 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 previous = process.env.PATH; + process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); + yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); + expect(installed.onPath).toBe(false); + yield* fs.remove(path.join(shadow, "t3")); + expect((yield* command.state).onPath).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("leaves the Windows PATH alone when it cannot be read", () => + Effect.gen(function* () { + 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); + + registry.failReads = true; + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("left unchanged"); + expect(registry).toMatchObject({ path: userPath, writes: 0 }); + + registry.failReads = false; + const launcherDir = DesktopCliShim.launcherPath( + environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }), + ).replace(/[\\/]t3\.cmd$/, ""); + yield* command.install; + expect(registry.path).toBe(`${userPath};${launcherDir}`); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("does not claim or remove a Windows PATH entry the user added", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcherDir = path.dirname( + DesktopCliShim.launcherPath( + environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }), + ), + ); + const userPath = `C:\\Tools;${launcherDir}`; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("offers nothing for a development build", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const command = yield* commandIn({ + home: yield* fs.makeTempDirectoryScoped(), + isPackaged: false, + }); + expect((yield* command.state).supported).toBe(false); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..534af3716db7aaadb93db2ab9fc2d49ff0f08580 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -0,0 +1,261 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +// Settings → Install `t3` command, like VS Code's "Install 'code' command". +// The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH +// by default. Installing links it into a folder on the user's PATH, or on +// Windows adds the launcher's folder to the user's PATH. Removing undoes only +// what installing did: a link that points at one of the app's launchers, or a +// PATH entry the app recorded adding. + +export class DesktopCliCommandError extends Schema.TaggedError()( + "DesktopCliCommandError", + { message: Schema.String }, +) {} + +/** User-writable folders that login shells commonly put on PATH, in preference order. */ +const unixCandidates = (home: string, platform: NodeJS.Platform) => + platform === "darwin" + ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] + : [`${home}/.local/bin`, `${home}/bin`]; + +const pathEntries = (value: string | undefined, separator: string) => + (value ?? "").split(separator).filter((entry) => entry.length > 0); + +/** Windows paths compare without case or a trailing separator. */ +const sameWindowsPath = (left: string, right: string) => + left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); + +/** + * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping + * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for + * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` + * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it + * opens see the change. A failed read exits nonzero rather than reading as + * empty, so a write never replaces the user's PATH wholesale. + */ +const WINDOWS_USER_PATH_SCRIPT = ` +$ErrorActionPreference = 'Stop' +if ($env:T3_SET -eq '1') { + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') + $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + if ($key.GetValueNames() -contains 'Path' -and $key.GetValueKind('Path') -eq 'String') { + $kind = [Microsoft.Win32.RegistryValueKind]::String + } + $key.SetValue('Path', $env:T3_PATH, $kind) + Add-Type -Namespace T3 -Name Env -MemberDefinition '[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);' + $result = [System.UIntPtr]::Zero + [void][T3.Env]::SendMessageTimeout([System.IntPtr]0xffff, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) +} else { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + $value = if ($key) { $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') } else { '' } + [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value))) +} +`; + +export class DesktopCliCommand extends Context.Service< + DesktopCliCommand, + { + readonly state: Effect.Effect; + readonly install: Effect.Effect; + readonly uninstall: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopCliCommand") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = environment.path; + const windows = environment.platform === "win32"; + const launcher = DesktopCliShim.launcherPath(environment); + const binDirectory = path.dirname(launcher); + /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ + const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); + + const fail = (message: string) => new DesktopCliCommandError({ message }); + const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + const writableDirectory = (directory: string) => + fs.access(directory, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + /** + * A link to one of the app's launchers, by the marker the launcher carries. + * This also finds links made under a previous T3 home, so Remove can clean + * them up and Install does not add a second. + */ + const isOurLink = (link: string) => + Effect.gen(function* () { + yield* fs.readLink(link); + // The launcher is a few KB; never read a large binary another `t3` links to. + const info = yield* fs.stat(link); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + const content = yield* fs.readFileString(link); + return content.includes(DesktopCliShim.MARKER); + }).pipe(Effect.orElseSucceed(() => false)); + + const powershell = (env: Record) => + Effect.scoped( + Effect.gen(function* () { + const root = process.env.SystemRoot ?? process.env.WINDIR; + const handle = yield* spawner.spawn( + ChildProcess.make( + root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], + { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, + ), + ); + const [stdout, exitCode] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`); + return stdout; + }), + ); + const readUserPath = powershell({}).pipe( + Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), + Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), + ); + const writeUserPath = (value: string) => + powershell({ T3_SET: "1", T3_PATH: value }).pipe( + Effect.asVoid, + Effect.mapError(() => fail("Could not update your PATH.")), + ); + + /** The `t3` a new shell runs, by PATH order, or none. */ + const firstOnPath = Effect.gen(function* () { + for (const directory of pathEntries(process.env.PATH, ":")) { + const candidate = path.join(directory, "t3"); + if (yield* exists(candidate)) return Option.some(candidate); + } + return Option.none(); + }); + + /** Where this app's command is installed now, if anywhere. */ + const installedAt = Effect.gen(function* () { + if (windows) { + if (!(yield* exists(ownedPathMarker))) return Option.none(); + const entries = pathEntries(yield* readUserPath, ";"); + return entries.some((entry) => sameWindowsPath(entry, binDirectory)) + ? Option.some(launcher) + : Option.none(); + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) return Option.some(link); + } + return Option.none(); + }); + + const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { + if (!environment.isPackaged) { + return { supported: false, installedPath: null, onPath: false } as const; + } + const installed = yield* installedAt; + if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; + // On Windows only terminals opened after the change see it. On Unix the + // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. + const first = yield* firstOnPath; + const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); + return { supported: true, installedPath: installed.value, onPath }; + }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + + /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ + const ensureLauncher = DesktopCliShim.install.pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)), + onSome: () => Effect.void, + }), + ), + ); + + const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () { + if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); + yield* ensureLauncher; + if (windows) { + const entries = pathEntries(yield* readUserPath, ";"); + if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { + yield* writeUserPath([...entries, binDirectory].join(";")); + yield* fs + .writeFileString(ownedPathMarker, `${binDirectory}\n`) + .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it."))); + } + return yield* state; + } + const existing = yield* installedAt; + if (Option.isSome(existing)) { + const target = yield* fs.readLink(existing.value).pipe(Effect.option); + if (Option.getOrUndefined(target) === launcher) return yield* state; + // A link to a previous T3 home's launcher: point it at this one instead. + yield* fs + .remove(existing.value) + .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); + } + const onPath = pathEntries(process.env.PATH, ":"); + const candidates = unixCandidates(environment.homeDirectory, environment.platform); + // Prefer a folder already on PATH that the user can write to without admin rights. + for (const directory of [ + ...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) + : yield* fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!created) continue; + // symlink fails if anything, even a broken link, is already there. + const linked = yield* fs.symlink(launcher, link).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (linked) 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}.`, + ); + }).pipe(Effect.withSpan("desktop.cliCommand.install")); + + const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { + if (windows) { + if (yield* exists(ownedPathMarker)) { + const entries = pathEntries(yield* readUserPath, ";"); + const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory)); + if (kept.length !== entries.length) yield* writeUserPath(kept.join(";")); + yield* fs.remove(ownedPathMarker).pipe(Effect.ignore); + } + return yield* state; + } + for (const directory of unixCandidates(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}.`))); + } + } + return yield* state; + }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); + + return DesktopCliCommand.of({ state, install, uninstall }); +}); + +export const layer = Layer.effect(DesktopCliCommand, make); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index c7dd42e2bf50a6a861d045f143d477f40ebc7123..5cd942f66ced96db3b8f38fce2c72f43153ec9d1 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -14,7 +14,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // so its launcher mounts the AppImage itself instead of pointing into it. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); -const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; +export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ const SERVER_ENTRY = "apps/server/dist/bin.mjs"; @@ -125,6 +125,14 @@ export const renderCliShim = (input: { ].join("\n"); }; +/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ +export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => + environment.path.join( + environment.baseDir, + "bin", + environment.platform === "win32" ? "t3.cmd" : "t3", + ); + /** * Writes the packaged app's launcher to `/bin` and returns its path * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout @@ -136,7 +144,7 @@ export const install = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = environment.path; const windows = environment.platform === "win32"; - const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); + const shimPath = launcherPath(environment); const entry = path.join(environment.serverRoot, SERVER_ENTRY); const target: CliShimTarget = windows ? { kind: "windows", executable: process.execPath, entry } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a16535c4a5eb5f71c2af6bc37bc703953211bb7b..a67424a795a9822bb9c94f927917c0089e806c5c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -75,6 +75,11 @@ import { takeLegacyLocalStorage, } from "./methods/legacyLocalStorage.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { + getCliCommandState, + installCliCommand, + uninstallCliCommand, +} from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -149,6 +154,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); + yield* ipc.handle(getCliCommandState); + yield* ipc.handle(installCliCommand); + yield* ipc.handle(uninstallCliCommand); for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9ae71cab1df8e619a85f7fd9bc9b048e4c809b96..5b078314ac49a2545a4b466301e32fc4981e2f75 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -23,6 +23,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; 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 GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; diff --git a/apps/desktop/src/ipc/methods/cliCommand.ts b/apps/desktop/src/ipc/methods/cliCommand.ts new file mode 100644 index 0000000000000000000000000000000000000000..be9672c6825179d614af37e7d341d378232149a1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cliCommand.ts @@ -0,0 +1,34 @@ +import { DesktopCliCommandStateSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopCliCommand from "../../app/DesktopCliCommand.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getCliCommandState = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.getState")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).state; + }), +}); + +export const installCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.install")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).install; + }), +}); + +export const uninstallCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.uninstall")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2c56f6b39c8e46f104ee1c267363ffd6a453f6a3..01c3d7892c66ce952a6295eb3a8216772b1820d8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -38,6 +38,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -213,6 +214,7 @@ const layerDesktopApplication = Layer.mergeAll( layerDesktopAppActivation, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, + DesktopCliCommand.layer, DesktopShellEnvironment.layer, layerDesktopSsh, ).pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a97f224a1b414144b5e07923bb979adc4d407a07..0dd9604f98651d5d978bc418292d9749bc0326ee 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -268,6 +268,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + cliCommand: { + 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), + }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; diff --git a/apps/web/src/components/settings/CliCommandSettingsRow.tsx b/apps/web/src/components/settings/CliCommandSettingsRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f7c12345d7db30f8b0276b81212b3bc5fd1fc702 --- /dev/null +++ b/apps/web/src/components/settings/CliCommandSettingsRow.tsx @@ -0,0 +1,76 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +/** + * Settings → `t3` command: puts the desktop app's bundled CLI on PATH, or takes + * it off again. Hidden where the desktop build has no launcher to install. + */ +export function CliCommandSettingsRow() { + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge?.cliCommand; + const [state, setState] = useState(null); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!bridge) return; + let cancelled = false; + void bridge + .getState() + .then((next) => { + if (!cancelled) setState(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [bridge]); + + const change = useCallback( + (action: "install" | "uninstall") => { + if (!bridge || pending) return; + setPending(true); + void bridge[action]() + .then(setState) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: action === "install" ? "Could not install t3" : "Could not remove t3", + description: error instanceof Error ? error.message : "Something went wrong.", + }), + ); + }) + .finally(() => setPending(false)); + }, + [bridge, pending], + ); + + if (!bridge || !state?.supported) return null; + const installed = state.installedPath !== null; + const description = !installed + ? "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\`.`; + + return ( + change(installed ? "uninstall" : "install")} + > + {installed ? "Remove" : "Install"} + + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5e52b482789133c30d871f5e145a32f8c8c28338..bee57d21b2531722e0988b2227de6bdc6cf15f4a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,6 +55,7 @@ import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; +import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -509,6 +510,7 @@ function AboutVersionSection() { /> ) : null} {showNightlyMobileBeta ? : null} + {hasDesktopBridge ? : null} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 81b2d1248f19c32d325b828c03560f092525b902..853886d81fcd9f89a05ce42215ce3688f17dd159 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -503,6 +503,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, + { + id: "cli-command", + title: "t3 command", + to: "/settings/general", + searchTerms: ["cli terminal shell path install command line"], + desktopOnly: true, + }, { id: "privacy-policy", title: "Privacy policy", diff --git a/docs/user/install.md b/docs/user/install.md index 980837199e9bf11b3e0e7b2c050c746f0627f385..b9f2930776ae9ee0115c038e2dd48bc448776066 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -76,6 +76,15 @@ The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### The `t3` command + +The desktop app includes the `t3` command-line tool. To run it from any +terminal, open **Settings → General → 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. + ### Windows Subsystem for Linux Choose a WSL distro in **Settings → Connections** to run agents and projects diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e661bc2b187d5c50942ec5f91dd7811e5e281e1c..149f282194a59976ef5e4d3efd0d46822569649a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -317,6 +317,17 @@ export const DesktopUpdateStateSchema = Schema.Struct({ canRetry: Schema.Boolean, }); +/** The desktop app's `t3` command on PATH, managed from Settings. */ +export const DesktopCliCommandStateSchema = Schema.Struct({ + /** Only installed builds have a launcher to put on PATH. */ + supported: Schema.Boolean, + /** The `t3` the app installed, or null when it is not installed. */ + installedPath: Schema.NullOr(Schema.String), + /** Whether a new terminal finds it; false when the folder is not on PATH yet. */ + onPath: Schema.Boolean, +}); +export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; + export interface DesktopUpdateActionResult { accepted: boolean; completed: boolean; @@ -1215,6 +1226,12 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Settings → `t3` command. Optional: older desktop builds lack it. */ + cliCommand?: { + getState: () => Promise; + install: () => Promise; + uninstall: () => Promise; + }; /** Present when the desktop shell accepts `t3 app` activation requests. */ appActivation?: { setReady: (ready: boolean) => Promise; -- 2.54.0