| author | |
| committer | |
| log | 0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e |
| tree | eedfc33c4705b7c2034b1ac1c09f8fa6bf536488 |
| parent | 73e87f683ae1455feb663481f1fa588f01b5784b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>13 files changed, 677 insertions(+), 2 deletions(-)
apps/desktop/src/app/DesktopCliCommand.test.ts created+243| ... | @@ -0,0 +1,243 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import * as Sink from "effect/Sink"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 10 | |||
| 11 | import * as DesktopCliCommand from "./DesktopCliCommand.ts"; | ||
| 12 | import * as DesktopCliShim from "./DesktopCliShim.ts"; | ||
| 13 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 14 | |||
| 15 | const environmentFor = ( | ||
| 16 | path: Path.Path, | ||
| 17 | input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean }, | ||
| 18 | ) => | ||
| 19 | DesktopEnvironment.DesktopEnvironment.of({ | ||
| 20 | path, | ||
| 21 | platform: input.platform ?? "linux", | ||
| 22 | isPackaged: input.isPackaged ?? true, | ||
| 23 | homeDirectory: input.home, | ||
| 24 | baseDir: input.baseDir, | ||
| 25 | stateDir: path.join(input.baseDir, "userdata"), | ||
| 26 | serverRoot: "/opt/T3 Code/resources/app.asar", | ||
| 27 | appImagePath: Option.none(), | ||
| 28 | } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); | ||
| 29 | |||
| 30 | /** The service for a packaged app; the launcher is written on Install. */ | ||
| 31 | const commandIn = ( | ||
| 32 | input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, | ||
| 33 | spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], | ||
| 34 | ) => | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const path = yield* Path.Path; | ||
| 37 | const fs = yield* FileSystem.FileSystem; | ||
| 38 | const baseDir = input.baseDir ?? path.join(input.home, ".t3"); | ||
| 39 | yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true }); | ||
| 40 | const make = DesktopCliCommand.make.pipe( | ||
| 41 | Effect.provideService( | ||
| 42 | DesktopEnvironment.DesktopEnvironment, | ||
| 43 | environmentFor(path, { ...input, baseDir }), | ||
| 44 | ), | ||
| 45 | ); | ||
| 46 | return yield* spawner | ||
| 47 | ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) | ||
| 48 | : make; | ||
| 49 | }); | ||
| 50 | |||
| 51 | /** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ | ||
| 52 | const fakePowerShell = (initial: string) => { | ||
| 53 | const registry = { path: initial, failReads: false, writes: 0 }; | ||
| 54 | const spawner = ChildProcessSpawner.make((command) => | ||
| 55 | Effect.sync(() => { | ||
| 56 | const { options } = command as unknown as { | ||
| 57 | readonly options: { readonly env?: Record<string, string> }; | ||
| 58 | }; | ||
| 59 | const env = options.env ?? {}; | ||
| 60 | let exitCode = 0; | ||
| 61 | let stdout = ""; | ||
| 62 | if (env.T3_SET === "1") { | ||
| 63 | registry.path = env.T3_PATH ?? ""; | ||
| 64 | registry.writes += 1; | ||
| 65 | } else if (registry.failReads) { | ||
| 66 | exitCode = 1; | ||
| 67 | } else { | ||
| 68 | stdout = Buffer.from(registry.path, "utf8").toString("base64"); | ||
| 69 | } | ||
| 70 | return ChildProcessSpawner.makeHandle({ | ||
| 71 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 72 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | ||
| 73 | isRunning: Effect.succeed(false), | ||
| 74 | kill: () => Effect.void, | ||
| 75 | unref: Effect.succeed(Effect.void), | ||
| 76 | stdin: Sink.drain, | ||
| 77 | stdout: Stream.make(new TextEncoder().encode(stdout)), | ||
| 78 | stderr: Stream.empty, | ||
| 79 | all: Stream.empty, | ||
| 80 | getInputFd: () => Sink.drain, | ||
| 81 | getOutputFd: () => Stream.empty, | ||
| 82 | }); | ||
| 83 | }), | ||
| 84 | ); | ||
| 85 | return { registry, spawner }; | ||
| 86 | }; | ||
| 87 | |||
| 88 | it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { | ||
| 89 | it.effect("links the launcher onto PATH and removes only that link", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const fs = yield* FileSystem.FileSystem; | ||
| 92 | const path = yield* Path.Path; | ||
| 93 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 94 | const command = yield* commandIn({ home }); | ||
| 95 | const link = path.join(home, ".local", "bin", "t3"); | ||
| 96 | |||
| 97 | expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); | ||
| 98 | // Install writes the launcher itself, even when no local backend ever did. | ||
| 99 | const installed = yield* command.install; | ||
| 100 | expect(installed.installedPath).toBe(link); | ||
| 101 | expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); | ||
| 102 | expect((yield* command.install).installedPath).toBe(link); | ||
| 103 | |||
| 104 | expect((yield* command.uninstall).installedPath).toBeNull(); | ||
| 105 | expect(yield* fs.exists(link)).toBe(false); | ||
| 106 | // The launcher itself stays for setup commands. | ||
| 107 | expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); | ||
| 108 | }).pipe(Effect.scoped), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("never replaces or removes a t3 it did not create", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const fs = yield* FileSystem.FileSystem; | ||
| 114 | const path = yield* Path.Path; | ||
| 115 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 116 | const command = yield* commandIn({ home }); | ||
| 117 | const theirs = path.join(home, ".local", "bin", "t3"); | ||
| 118 | yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); | ||
| 119 | yield* fs.writeFileString(theirs, "npm's t3\n"); | ||
| 120 | // Even a broken link in the next folder is someone else's. | ||
| 121 | yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); | ||
| 122 | yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); | ||
| 123 | |||
| 124 | const error = yield* Effect.flip(command.install); | ||
| 125 | expect(error.message).toContain("Another t3 command is already installed"); | ||
| 126 | yield* command.uninstall; | ||
| 127 | expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); | ||
| 128 | expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); | ||
| 129 | }).pipe(Effect.scoped), | ||
| 130 | ); | ||
| 131 | |||
| 132 | it.effect("finds and removes a link left by a previous T3 home", () => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const fs = yield* FileSystem.FileSystem; | ||
| 135 | const path = yield* Path.Path; | ||
| 136 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 137 | const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); | ||
| 138 | const link = (yield* before.install).installedPath; | ||
| 139 | |||
| 140 | const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); | ||
| 141 | expect((yield* after.state).installedPath).toBe(link); | ||
| 142 | // Installing again points the link at this home's launcher. | ||
| 143 | expect((yield* after.install).installedPath).toBe(link); | ||
| 144 | expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); | ||
| 145 | yield* after.uninstall; | ||
| 146 | expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); | ||
| 147 | expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); | ||
| 148 | }).pipe(Effect.scoped), | ||
| 149 | ); | ||
| 150 | |||
| 151 | it.effect("does not read a large binary another t3 links to", () => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const fs = yield* FileSystem.FileSystem; | ||
| 154 | const path = yield* Path.Path; | ||
| 155 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 156 | // A big executable that happens to contain the marker text is still not ours. | ||
| 157 | const binary = path.join(home, "native-t3"); | ||
| 158 | yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); | ||
| 159 | yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); | ||
| 160 | yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); | ||
| 161 | const command = yield* commandIn({ home }); | ||
| 162 | expect((yield* command.state).installedPath).toBeNull(); | ||
| 163 | yield* command.uninstall; | ||
| 164 | expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); | ||
| 165 | }).pipe(Effect.scoped), | ||
| 166 | ); | ||
| 167 | |||
| 168 | it.effect("reports when another t3 earlier on PATH would run instead", () => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const fs = yield* FileSystem.FileSystem; | ||
| 171 | const path = yield* Path.Path; | ||
| 172 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 173 | const shadow = path.join(home, "shadow"); | ||
| 174 | yield* fs.makeDirectory(shadow); | ||
| 175 | yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); | ||
| 176 | const previous = process.env.PATH; | ||
| 177 | process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); | ||
| 178 | yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); | ||
| 179 | |||
| 180 | const command = yield* commandIn({ home }); | ||
| 181 | const installed = yield* command.install; | ||
| 182 | expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); | ||
| 183 | expect(installed.onPath).toBe(false); | ||
| 184 | yield* fs.remove(path.join(shadow, "t3")); | ||
| 185 | expect((yield* command.state).onPath).toBe(true); | ||
| 186 | }).pipe(Effect.scoped), | ||
| 187 | ); | ||
| 188 | |||
| 189 | it.effect("leaves the Windows PATH alone when it cannot be read", () => | ||
| 190 | Effect.gen(function* () { | ||
| 191 | const fs = yield* FileSystem.FileSystem; | ||
| 192 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 193 | const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools"; | ||
| 194 | const { registry, spawner } = fakePowerShell(userPath); | ||
| 195 | const command = yield* commandIn({ home, platform: "win32" }, spawner); | ||
| 196 | |||
| 197 | registry.failReads = true; | ||
| 198 | const error = yield* Effect.flip(command.install); | ||
| 199 | expect(error.message).toContain("left unchanged"); | ||
| 200 | expect(registry).toMatchObject({ path: userPath, writes: 0 }); | ||
| 201 | |||
| 202 | registry.failReads = false; | ||
| 203 | const launcherDir = DesktopCliShim.launcherPath( | ||
| 204 | environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }), | ||
| 205 | ).replace(/[\\/]t3\.cmd$/, ""); | ||
| 206 | yield* command.install; | ||
| 207 | expect(registry.path).toBe(`${userPath};${launcherDir}`); | ||
| 208 | yield* command.uninstall; | ||
| 209 | expect(registry.path).toBe(userPath); | ||
| 210 | }).pipe(Effect.scoped), | ||
| 211 | ); | ||
| 212 | |||
| 213 | it.effect("does not claim or remove a Windows PATH entry the user added", () => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const fs = yield* FileSystem.FileSystem; | ||
| 216 | const path = yield* Path.Path; | ||
| 217 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 218 | const launcherDir = path.dirname( | ||
| 219 | DesktopCliShim.launcherPath( | ||
| 220 | environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | const userPath = `C:\\Tools;${launcherDir}`; | ||
| 224 | const { registry, spawner } = fakePowerShell(userPath); | ||
| 225 | const command = yield* commandIn({ home, platform: "win32" }, spawner); | ||
| 226 | |||
| 227 | expect((yield* command.state).installedPath).toBeNull(); | ||
| 228 | yield* command.uninstall; | ||
| 229 | expect(registry.path).toBe(userPath); | ||
| 230 | }).pipe(Effect.scoped), | ||
| 231 | ); | ||
| 232 | |||
| 233 | it.effect("offers nothing for a development build", () => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const fs = yield* FileSystem.FileSystem; | ||
| 236 | const command = yield* commandIn({ | ||
| 237 | home: yield* fs.makeTempDirectoryScoped(), | ||
| 238 | isPackaged: false, | ||
| 239 | }); | ||
| 240 | expect((yield* command.state).supported).toBe(false); | ||
| 241 | }).pipe(Effect.scoped), | ||
| 242 | ); | ||
| 243 | }); | ||
apps/desktop/src/app/DesktopCliCommand.ts created+261| ... | @@ -0,0 +1,261 @@ | ||
| 1 | import type { DesktopCliCommandState } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 10 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 11 | |||
| 12 | import * as DesktopCliShim from "./DesktopCliShim.ts"; | ||
| 13 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 14 | |||
| 15 | // Settings → Install `t3` command, like VS Code's "Install 'code' command". | ||
| 16 | // The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH | ||
| 17 | // by default. Installing links it into a folder on the user's PATH, or on | ||
| 18 | // Windows adds the launcher's folder to the user's PATH. Removing undoes only | ||
| 19 | // what installing did: a link that points at one of the app's launchers, or a | ||
| 20 | // PATH entry the app recorded adding. | ||
| 21 | |||
| 22 | export class DesktopCliCommandError extends Schema.TaggedError<DesktopCliCommandError>()( | ||
| 23 | "DesktopCliCommandError", | ||
| 24 | { message: Schema.String }, | ||
| 25 | ) {} | ||
| 26 | |||
| 27 | /** User-writable folders that login shells commonly put on PATH, in preference order. */ | ||
| 28 | const unixCandidates = (home: string, platform: NodeJS.Platform) => | ||
| 29 | platform === "darwin" | ||
| 30 | ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] | ||
| 31 | : [`${home}/.local/bin`, `${home}/bin`]; | ||
| 32 | |||
| 33 | const pathEntries = (value: string | undefined, separator: string) => | ||
| 34 | (value ?? "").split(separator).filter((entry) => entry.length > 0); | ||
| 35 | |||
| 36 | /** Windows paths compare without case or a trailing separator. */ | ||
| 37 | const sameWindowsPath = (left: string, right: string) => | ||
| 38 | left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping | ||
| 42 | * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for | ||
| 43 | * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` | ||
| 44 | * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it | ||
| 45 | * opens see the change. A failed read exits nonzero rather than reading as | ||
| 46 | * empty, so a write never replaces the user's PATH wholesale. | ||
| 47 | */ | ||
| 48 | const WINDOWS_USER_PATH_SCRIPT = ` | ||
| 49 | $ErrorActionPreference = 'Stop' | ||
| 50 | if ($env:T3_SET -eq '1') { | ||
| 51 | $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') | ||
| 52 | $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString | ||
| 53 | if ($key.GetValueNames() -contains 'Path' -and $key.GetValueKind('Path') -eq 'String') { | ||
| 54 | $kind = [Microsoft.Win32.RegistryValueKind]::String | ||
| 55 | } | ||
| 56 | $key.SetValue('Path', $env:T3_PATH, $kind) | ||
| 57 | 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);' | ||
| 58 | $result = [System.UIntPtr]::Zero | ||
| 59 | [void][T3.Env]::SendMessageTimeout([System.IntPtr]0xffff, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | ||
| 60 | } else { | ||
| 61 | $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') | ||
| 62 | $value = if ($key) { $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') } else { '' } | ||
| 63 | [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value))) | ||
| 64 | } | ||
| 65 | `; | ||
| 66 | |||
| 67 | export class DesktopCliCommand extends Context.Service< | ||
| 68 | DesktopCliCommand, | ||
| 69 | { | ||
| 70 | readonly state: Effect.Effect<DesktopCliCommandState>; | ||
| 71 | readonly install: Effect.Effect<DesktopCliCommandState, DesktopCliCommandError>; | ||
| 72 | readonly uninstall: Effect.Effect<DesktopCliCommandState, DesktopCliCommandError>; | ||
| 73 | } | ||
| 74 | >()("@t3tools/desktop/app/DesktopCliCommand") {} | ||
| 75 | |||
| 76 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 77 | export const make = Effect.gen(function* () { | ||
| 78 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 79 | const fs = yield* FileSystem.FileSystem; | ||
| 80 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 81 | const path = environment.path; | ||
| 82 | const windows = environment.platform === "win32"; | ||
| 83 | const launcher = DesktopCliShim.launcherPath(environment); | ||
| 84 | const binDirectory = path.dirname(launcher); | ||
| 85 | /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ | ||
| 86 | const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); | ||
| 87 | |||
| 88 | const fail = (message: string) => new DesktopCliCommandError({ message }); | ||
| 89 | const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); | ||
| 90 | const writableDirectory = (directory: string) => | ||
| 91 | fs.access(directory, { writable: true }).pipe( | ||
| 92 | Effect.as(true), | ||
| 93 | Effect.orElseSucceed(() => false), | ||
| 94 | ); | ||
| 95 | |||
| 96 | /** | ||
| 97 | * A link to one of the app's launchers, by the marker the launcher carries. | ||
| 98 | * This also finds links made under a previous T3 home, so Remove can clean | ||
| 99 | * them up and Install does not add a second. | ||
| 100 | */ | ||
| 101 | const isOurLink = (link: string) => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | yield* fs.readLink(link); | ||
| 104 | // The launcher is a few KB; never read a large binary another `t3` links to. | ||
| 105 | const info = yield* fs.stat(link); | ||
| 106 | if (info.type !== "File" || Number(info.size) > 16_384) return false; | ||
| 107 | const content = yield* fs.readFileString(link); | ||
| 108 | return content.includes(DesktopCliShim.MARKER); | ||
| 109 | }).pipe(Effect.orElseSucceed(() => false)); | ||
| 110 | |||
| 111 | const powershell = (env: Record<string, string>) => | ||
| 112 | Effect.scoped( | ||
| 113 | Effect.gen(function* () { | ||
| 114 | const root = process.env.SystemRoot ?? process.env.WINDIR; | ||
| 115 | const handle = yield* spawner.spawn( | ||
| 116 | ChildProcess.make( | ||
| 117 | root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", | ||
| 118 | ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], | ||
| 119 | { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, | ||
| 120 | ), | ||
| 121 | ); | ||
| 122 | const [stdout, exitCode] = yield* Effect.all( | ||
| 123 | [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], | ||
| 124 | { concurrency: "unbounded" }, | ||
| 125 | ); | ||
| 126 | if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`); | ||
| 127 | return stdout; | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | const readUserPath = powershell({}).pipe( | ||
| 131 | Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), | ||
| 132 | Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), | ||
| 133 | ); | ||
| 134 | const writeUserPath = (value: string) => | ||
| 135 | powershell({ T3_SET: "1", T3_PATH: value }).pipe( | ||
| 136 | Effect.asVoid, | ||
| 137 | Effect.mapError(() => fail("Could not update your PATH.")), | ||
| 138 | ); | ||
| 139 | |||
| 140 | /** The `t3` a new shell runs, by PATH order, or none. */ | ||
| 141 | const firstOnPath = Effect.gen(function* () { | ||
| 142 | for (const directory of pathEntries(process.env.PATH, ":")) { | ||
| 143 | const candidate = path.join(directory, "t3"); | ||
| 144 | if (yield* exists(candidate)) return Option.some(candidate); | ||
| 145 | } | ||
| 146 | return Option.none<string>(); | ||
| 147 | }); | ||
| 148 | |||
| 149 | /** Where this app's command is installed now, if anywhere. */ | ||
| 150 | const installedAt = Effect.gen(function* () { | ||
| 151 | if (windows) { | ||
| 152 | if (!(yield* exists(ownedPathMarker))) return Option.none<string>(); | ||
| 153 | const entries = pathEntries(yield* readUserPath, ";"); | ||
| 154 | return entries.some((entry) => sameWindowsPath(entry, binDirectory)) | ||
| 155 | ? Option.some(launcher) | ||
| 156 | : Option.none<string>(); | ||
| 157 | } | ||
| 158 | for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { | ||
| 159 | const link = path.join(directory, "t3"); | ||
| 160 | if (yield* isOurLink(link)) return Option.some(link); | ||
| 161 | } | ||
| 162 | return Option.none<string>(); | ||
| 163 | }); | ||
| 164 | |||
| 165 | const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { | ||
| 166 | if (!environment.isPackaged) { | ||
| 167 | return { supported: false, installedPath: null, onPath: false } as const; | ||
| 168 | } | ||
| 169 | const installed = yield* installedAt; | ||
| 170 | if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; | ||
| 171 | // On Windows only terminals opened after the change see it. On Unix the | ||
| 172 | // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. | ||
| 173 | const first = yield* firstOnPath; | ||
| 174 | const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); | ||
| 175 | return { supported: true, installedPath: installed.value, onPath }; | ||
| 176 | }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); | ||
| 177 | |||
| 178 | /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ | ||
| 179 | const ensureLauncher = DesktopCliShim.install.pipe( | ||
| 180 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | ||
| 181 | Effect.provideService(FileSystem.FileSystem, fs), | ||
| 182 | Effect.flatMap( | ||
| 183 | Option.match({ | ||
| 184 | onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)), | ||
| 185 | onSome: () => Effect.void, | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | |||
| 190 | const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () { | ||
| 191 | if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); | ||
| 192 | yield* ensureLauncher; | ||
| 193 | if (windows) { | ||
| 194 | const entries = pathEntries(yield* readUserPath, ";"); | ||
| 195 | if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { | ||
| 196 | yield* writeUserPath([...entries, binDirectory].join(";")); | ||
| 197 | yield* fs | ||
| 198 | .writeFileString(ownedPathMarker, `${binDirectory}\n`) | ||
| 199 | .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it."))); | ||
| 200 | } | ||
| 201 | return yield* state; | ||
| 202 | } | ||
| 203 | const existing = yield* installedAt; | ||
| 204 | if (Option.isSome(existing)) { | ||
| 205 | const target = yield* fs.readLink(existing.value).pipe(Effect.option); | ||
| 206 | if (Option.getOrUndefined(target) === launcher) return yield* state; | ||
| 207 | // A link to a previous T3 home's launcher: point it at this one instead. | ||
| 208 | yield* fs | ||
| 209 | .remove(existing.value) | ||
| 210 | .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); | ||
| 211 | } | ||
| 212 | const onPath = pathEntries(process.env.PATH, ":"); | ||
| 213 | const candidates = unixCandidates(environment.homeDirectory, environment.platform); | ||
| 214 | // Prefer a folder already on PATH that the user can write to without admin rights. | ||
| 215 | for (const directory of [ | ||
| 216 | ...candidates.filter((candidate) => onPath.includes(candidate)), | ||
| 217 | ...candidates.filter((candidate) => !onPath.includes(candidate)), | ||
| 218 | ]) { | ||
| 219 | const link = path.join(directory, "t3"); | ||
| 220 | const created = (yield* exists(directory)) | ||
| 221 | ? yield* writableDirectory(directory) | ||
| 222 | : yield* fs.makeDirectory(directory, { recursive: true }).pipe( | ||
| 223 | Effect.as(true), | ||
| 224 | Effect.orElseSucceed(() => false), | ||
| 225 | ); | ||
| 226 | if (!created) continue; | ||
| 227 | // symlink fails if anything, even a broken link, is already there. | ||
| 228 | const linked = yield* fs.symlink(launcher, link).pipe( | ||
| 229 | Effect.as(true), | ||
| 230 | Effect.orElseSucceed(() => false), | ||
| 231 | ); | ||
| 232 | if (linked) return yield* state; | ||
| 233 | } | ||
| 234 | return yield* fail( | ||
| 235 | `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, | ||
| 236 | ); | ||
| 237 | }).pipe(Effect.withSpan("desktop.cliCommand.install")); | ||
| 238 | |||
| 239 | const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { | ||
| 240 | if (windows) { | ||
| 241 | if (yield* exists(ownedPathMarker)) { | ||
| 242 | const entries = pathEntries(yield* readUserPath, ";"); | ||
| 243 | const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory)); | ||
| 244 | if (kept.length !== entries.length) yield* writeUserPath(kept.join(";")); | ||
| 245 | yield* fs.remove(ownedPathMarker).pipe(Effect.ignore); | ||
| 246 | } | ||
| 247 | return yield* state; | ||
| 248 | } | ||
| 249 | for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { | ||
| 250 | const link = path.join(directory, "t3"); | ||
| 251 | if (yield* isOurLink(link)) { | ||
| 252 | yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); | ||
| 253 | } | ||
| 254 | } | ||
| 255 | return yield* state; | ||
| 256 | }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); | ||
| 257 | |||
| 258 | return DesktopCliCommand.of({ state, install, uninstall }); | ||
| 259 | }); | ||
| 260 | |||
| 261 | export const layer = Layer.effect(DesktopCliCommand, make); | ||
apps/desktop/src/app/DesktopCliShim.ts+10-2| ... | @@ -14,7 +14,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; | ... | @@ -14,7 +14,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; |
| 14 | // so its launcher mounts the AppImage itself instead of pointing into it. | 14 | // so its launcher mounts the AppImage itself instead of pointing into it. |
| 15 | const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); | 15 | const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); |
| 16 | 16 | ||
| 17 | const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; | 17 | export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; |
| 18 | 18 | ||
| 19 | /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ | 19 | /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ |
| 20 | const SERVER_ENTRY = "apps/server/dist/bin.mjs"; | 20 | const SERVER_ENTRY = "apps/server/dist/bin.mjs"; |
| ... | @@ -125,6 +125,14 @@ export const renderCliShim = (input: { | ... | @@ -125,6 +125,14 @@ export const renderCliShim = (input: { |
| 125 | ].join("\n"); | 125 | ].join("\n"); |
| 126 | }; | 126 | }; |
| 127 | 127 | ||
| 128 | /** Where the packaged app keeps its launcher: `<T3 home>/bin/t3`, `t3.cmd` on Windows. */ | ||
| 129 | export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => | ||
| 130 | environment.path.join( | ||
| 131 | environment.baseDir, | ||
| 132 | "bin", | ||
| 133 | environment.platform === "win32" ? "t3.cmd" : "t3", | ||
| 134 | ); | ||
| 135 | |||
| 128 | /** | 136 | /** |
| 129 | * Writes the packaged app's launcher to `<T3 home>/bin` and returns its path | 137 | * Writes the packaged app's launcher to `<T3 home>/bin` and returns its path |
| 130 | * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout | 138 | * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout |
| ... | @@ -136,7 +144,7 @@ export const install = Effect.gen(function* () { | ... | @@ -136,7 +144,7 @@ export const install = Effect.gen(function* () { |
| 136 | const fs = yield* FileSystem.FileSystem; | 144 | const fs = yield* FileSystem.FileSystem; |
| 137 | const path = environment.path; | 145 | const path = environment.path; |
| 138 | const windows = environment.platform === "win32"; | 146 | const windows = environment.platform === "win32"; |
| 139 | const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); | 147 | const shimPath = launcherPath(environment); |
| 140 | const entry = path.join(environment.serverRoot, SERVER_ENTRY); | 148 | const entry = path.join(environment.serverRoot, SERVER_ENTRY); |
| 141 | const target: CliShimTarget = windows | 149 | const target: CliShimTarget = windows |
| 142 | ? { kind: "windows", executable: process.execPath, entry } | 150 | ? { kind: "windows", executable: process.execPath, entry } |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+8| ... | @@ -75,6 +75,11 @@ import { | ... | @@ -75,6 +75,11 @@ import { |
| 75 | takeLegacyLocalStorage, | 75 | takeLegacyLocalStorage, |
| 76 | } from "./methods/legacyLocalStorage.ts"; | 76 | } from "./methods/legacyLocalStorage.ts"; |
| 77 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; | 77 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; |
| 78 | import { | ||
| 79 | getCliCommandState, | ||
| 80 | installCliCommand, | ||
| 81 | uninstallCliCommand, | ||
| 82 | } from "./methods/cliCommand.ts"; | ||
| 78 | 83 | ||
| 79 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { | 84 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| 80 | const ipc = yield* DesktopIpc.DesktopIpc; | 85 | const ipc = yield* DesktopIpc.DesktopIpc; |
| ... | @@ -149,6 +154,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -149,6 +154,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 149 | yield* ipc.handle(downloadUpdate); | 154 | yield* ipc.handle(downloadUpdate); |
| 150 | yield* ipc.handle(installUpdate); | 155 | yield* ipc.handle(installUpdate); |
| 151 | yield* ipc.handle(checkForUpdate); | 156 | yield* ipc.handle(checkForUpdate); |
| 157 | yield* ipc.handle(getCliCommandState); | ||
| 158 | yield* ipc.handle(installCliCommand); | ||
| 159 | yield* ipc.handle(uninstallCliCommand); | ||
| 152 | for (const previewMethod of PreviewIpc.methods) { | 160 | for (const previewMethod of PreviewIpc.methods) { |
| 153 | yield* ipc.handle(previewMethod); | 161 | yield* ipc.handle(previewMethod); |
| 154 | } | 162 | } |
apps/desktop/src/ipc/channels.ts+3| ... | @@ -23,6 +23,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | ... | @@ -23,6 +23,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; |
| 23 | export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 23 | export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 24 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 24 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 25 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 25 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 26 | export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; | ||
| 27 | export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; | ||
| 28 | export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; | ||
| 26 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | 29 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; |
| 27 | export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; | 30 | export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; |
| 28 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; | 31 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; |
apps/desktop/src/ipc/methods/cliCommand.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { DesktopCliCommandStateSchema } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import * as DesktopCliCommand from "../../app/DesktopCliCommand.ts"; | ||
| 6 | import * as IpcChannels from "../channels.ts"; | ||
| 7 | import { makeIpcMethod } from "../DesktopIpc.ts"; | ||
| 8 | |||
| 9 | export const getCliCommandState = makeIpcMethod({ | ||
| 10 | channel: IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL, | ||
| 11 | payload: Schema.Void, | ||
| 12 | result: DesktopCliCommandStateSchema, | ||
| 13 | handler: Effect.fn("desktop.ipc.cliCommand.getState")(function* () { | ||
| 14 | return yield* (yield* DesktopCliCommand.DesktopCliCommand).state; | ||
| 15 | }), | ||
| 16 | }); | ||
| 17 | |||
| 18 | export const installCliCommand = makeIpcMethod({ | ||
| 19 | channel: IpcChannels.CLI_COMMAND_INSTALL_CHANNEL, | ||
| 20 | payload: Schema.Void, | ||
| 21 | result: DesktopCliCommandStateSchema, | ||
| 22 | handler: Effect.fn("desktop.ipc.cliCommand.install")(function* () { | ||
| 23 | return yield* (yield* DesktopCliCommand.DesktopCliCommand).install; | ||
| 24 | }), | ||
| 25 | }); | ||
| 26 | |||
| 27 | export const uninstallCliCommand = makeIpcMethod({ | ||
| 28 | channel: IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL, | ||
| 29 | payload: Schema.Void, | ||
| 30 | result: DesktopCliCommandStateSchema, | ||
| 31 | handler: Effect.fn("desktop.ipc.cliCommand.uninstall")(function* () { | ||
| 32 | return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; | ||
| 33 | }), | ||
| 34 | }); | ||
apps/desktop/src/main.ts+2| ... | @@ -38,6 +38,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; | ... | @@ -38,6 +38,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; |
| 38 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | 38 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 39 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; | 39 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; |
| 40 | import * as DesktopClerk from "./app/DesktopClerk.ts"; | 40 | import * as DesktopClerk from "./app/DesktopClerk.ts"; |
| 41 | import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; | ||
| 41 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | 42 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 42 | import * as DesktopAssets from "./app/DesktopAssets.ts"; | 43 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 43 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; | 44 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| ... | @@ -213,6 +214,7 @@ const layerDesktopApplication = Layer.mergeAll( | ... | @@ -213,6 +214,7 @@ const layerDesktopApplication = Layer.mergeAll( |
| 213 | layerDesktopAppActivation, | 214 | layerDesktopAppActivation, |
| 214 | DesktopApplicationMenu.layer, | 215 | DesktopApplicationMenu.layer, |
| 215 | DesktopLinuxUrlHandler.layer, | 216 | DesktopLinuxUrlHandler.layer, |
| 217 | DesktopCliCommand.layer, | ||
| 216 | DesktopShellEnvironment.layer, | 218 | DesktopShellEnvironment.layer, |
| 217 | layerDesktopSsh, | 219 | layerDesktopSsh, |
| 218 | ).pipe( | 220 | ).pipe( |
apps/desktop/src/preload.ts+5| ... | @@ -268,6 +268,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -268,6 +268,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 268 | checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), | 268 | checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), |
| 269 | downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), | 269 | downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), |
| 270 | installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), | 270 | installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), |
| 271 | cliCommand: { | ||
| 272 | getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), | ||
| 273 | install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), | ||
| 274 | uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL), | ||
| 275 | }, | ||
| 271 | onUpdateState: (listener) => { | 276 | onUpdateState: (listener) => { |
| 272 | const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { | 277 | const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { |
| 273 | if (typeof state !== "object" || state === null) return; | 278 | if (typeof state !== "object" || state === null) return; |
apps/web/src/components/settings/CliCommandSettingsRow.tsx created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | import type { DesktopCliCommandState } from "@t3tools/contracts"; | ||
| 2 | import { useCallback, useEffect, useState } from "react"; | ||
| 3 | |||
| 4 | import { Button } from "../ui/button"; | ||
| 5 | import { stackedThreadToast, toastManager } from "../ui/toast"; | ||
| 6 | import { SettingsRow } from "./settingsLayout"; | ||
| 7 | import { searchableSetting } from "./settingsSearch"; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Settings → `t3` command: puts the desktop app's bundled CLI on PATH, or takes | ||
| 11 | * it off again. Hidden where the desktop build has no launcher to install. | ||
| 12 | */ | ||
| 13 | export function CliCommandSettingsRow() { | ||
| 14 | const bridge = typeof window === "undefined" ? undefined : window.desktopBridge?.cliCommand; | ||
| 15 | const [state, setState] = useState<DesktopCliCommandState | null>(null); | ||
| 16 | const [pending, setPending] = useState(false); | ||
| 17 | |||
| 18 | useEffect(() => { | ||
| 19 | if (!bridge) return; | ||
| 20 | let cancelled = false; | ||
| 21 | void bridge | ||
| 22 | .getState() | ||
| 23 | .then((next) => { | ||
| 24 | if (!cancelled) setState(next); | ||
| 25 | }) | ||
| 26 | .catch(() => undefined); | ||
| 27 | return () => { | ||
| 28 | cancelled = true; | ||
| 29 | }; | ||
| 30 | }, [bridge]); | ||
| 31 | |||
| 32 | const change = useCallback( | ||
| 33 | (action: "install" | "uninstall") => { | ||
| 34 | if (!bridge || pending) return; | ||
| 35 | setPending(true); | ||
| 36 | void bridge[action]() | ||
| 37 | .then(setState) | ||
| 38 | .catch((error: unknown) => { | ||
| 39 | toastManager.add( | ||
| 40 | stackedThreadToast({ | ||
| 41 | type: "error", | ||
| 42 | title: action === "install" ? "Could not install t3" : "Could not remove t3", | ||
| 43 | description: error instanceof Error ? error.message : "Something went wrong.", | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | }) | ||
| 47 | .finally(() => setPending(false)); | ||
| 48 | }, | ||
| 49 | [bridge, pending], | ||
| 50 | ); | ||
| 51 | |||
| 52 | if (!bridge || !state?.supported) return null; | ||
| 53 | const installed = state.installedPath !== null; | ||
| 54 | const description = !installed | ||
| 55 | ? "Run T3 Code's CLI as `t3` from any terminal." | ||
| 56 | : state.onPath | ||
| 57 | ? `Installed at ${state.installedPath}. Open a new terminal to use it.` | ||
| 58 | : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`; | ||
| 59 | |||
| 60 | return ( | ||
| 61 | <SettingsRow | ||
| 62 | {...searchableSetting("cli-command")} | ||
| 63 | description={description} | ||
| 64 | control={ | ||
| 65 | <Button | ||
| 66 | size="sm" | ||
| 67 | variant="outline" | ||
| 68 | disabled={pending} | ||
| 69 | onClick={() => change(installed ? "uninstall" : "install")} | ||
| 70 | > | ||
| 71 | {installed ? "Remove" : "Install"} | ||
| 72 | </Button> | ||
| 73 | } | ||
| 74 | /> | ||
| 75 | ); | ||
| 76 | } | ||
apps/web/src/components/settings/SettingsPanels.tsx+2| ... | @@ -55,6 +55,7 @@ import * as Equal from "effect/Equal"; | ... | @@ -55,6 +55,7 @@ import * as Equal from "effect/Equal"; |
| 55 | import * as Schema from "effect/Schema"; | 55 | import * as Schema from "effect/Schema"; |
| 56 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; | 56 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 57 | import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; | 57 | import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; |
| 58 | import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; | ||
| 58 | import { | 59 | import { |
| 59 | canCheckForUpdate, | 60 | canCheckForUpdate, |
| 60 | getDesktopUpdateButtonTooltip, | 61 | getDesktopUpdateButtonTooltip, |
| ... | @@ -509,6 +510,7 @@ function AboutVersionSection() { | ... | @@ -509,6 +510,7 @@ function AboutVersionSection() { |
| 509 | /> | 510 | /> |
| 510 | ) : null} | 511 | ) : null} |
| 511 | {showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null} | 512 | {showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null} |
| 513 | {hasDesktopBridge ? <CliCommandSettingsRow /> : null} | ||
| 512 | </> | 514 | </> |
| 513 | ); | 515 | ); |
| 514 | } | 516 | } |
apps/web/src/components/settings/settingsSearch.ts+7| ... | @@ -503,6 +503,13 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -503,6 +503,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 503 | scope: "project-defaults", | 503 | scope: "project-defaults", |
| 504 | searchTerms: ["generated thread titles source control content default provider"], | 504 | searchTerms: ["generated thread titles source control content default provider"], |
| 505 | }, | 505 | }, |
| 506 | { | ||
| 507 | id: "cli-command", | ||
| 508 | title: "t3 command", | ||
| 509 | to: "/settings/general", | ||
| 510 | searchTerms: ["cli terminal shell path install command line"], | ||
| 511 | desktopOnly: true, | ||
| 512 | }, | ||
| 506 | { | 513 | { |
| 507 | id: "privacy-policy", | 514 | id: "privacy-policy", |
| 508 | title: "Privacy policy", | 515 | title: "Privacy policy", |
docs/user/install.md+9| ... | @@ -76,6 +76,15 @@ The `.deb` updates itself like the other desktop builds. It asks for your | ... | @@ -76,6 +76,15 @@ The `.deb` updates itself like the other desktop builds. It asks for your |
| 76 | password to install each update. If your desktop has no password prompt, the | 76 | password to install each update. If your desktop has no password prompt, the |
| 77 | update fails. Download the new `.deb` and install it the same way. | 77 | update fails. Download the new `.deb` and install it the same way. |
| 78 | 78 | ||
| 79 | ### The `t3` command | ||
| 80 | |||
| 81 | The desktop app includes the `t3` command-line tool. To run it from any | ||
| 82 | terminal, open **Settings → General → About** and choose **Install** next to | ||
| 83 | **t3 command**. On macOS and Linux it adds a `t3` link to a folder on your | ||
| 84 | `PATH`; on Windows it adds the app's command folder to your `PATH`. Open a new | ||
| 85 | terminal afterwards. **Remove** takes it off again. If you already have `t3` | ||
| 86 | from npm, it stays as it is. | ||
| 87 | |||
| 79 | ### Windows Subsystem for Linux | 88 | ### Windows Subsystem for Linux |
| 80 | 89 | ||
| 81 | Choose a WSL distro in **Settings → Connections** to run agents and projects | 90 | Choose a WSL distro in **Settings → Connections** to run agents and projects |
packages/contracts/src/ipc.ts+17| ... | @@ -317,6 +317,17 @@ export const DesktopUpdateStateSchema = Schema.Struct({ | ... | @@ -317,6 +317,17 @@ export const DesktopUpdateStateSchema = Schema.Struct({ |
| 317 | canRetry: Schema.Boolean, | 317 | canRetry: Schema.Boolean, |
| 318 | }); | 318 | }); |
| 319 | 319 | ||
| 320 | /** The desktop app's `t3` command on PATH, managed from Settings. */ | ||
| 321 | export const DesktopCliCommandStateSchema = Schema.Struct({ | ||
| 322 | /** Only installed builds have a launcher to put on PATH. */ | ||
| 323 | supported: Schema.Boolean, | ||
| 324 | /** The `t3` the app installed, or null when it is not installed. */ | ||
| 325 | installedPath: Schema.NullOr(Schema.String), | ||
| 326 | /** Whether a new terminal finds it; false when the folder is not on PATH yet. */ | ||
| 327 | onPath: Schema.Boolean, | ||
| 328 | }); | ||
| 329 | export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; | ||
| 330 | |||
| 320 | export interface DesktopUpdateActionResult { | 331 | export interface DesktopUpdateActionResult { |
| 321 | accepted: boolean; | 332 | accepted: boolean; |
| 322 | completed: boolean; | 333 | completed: boolean; |
| ... | @@ -1215,6 +1226,12 @@ export interface DesktopBridge { | ... | @@ -1215,6 +1226,12 @@ export interface DesktopBridge { |
| 1215 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; | 1226 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; |
| 1216 | installUpdate: () => Promise<DesktopUpdateActionResult>; | 1227 | installUpdate: () => Promise<DesktopUpdateActionResult>; |
| 1217 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; | 1228 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; |
| 1229 | /** Settings → `t3` command. Optional: older desktop builds lack it. */ | ||
| 1230 | cliCommand?: { | ||
| 1231 | getState: () => Promise<DesktopCliCommandState>; | ||
| 1232 | install: () => Promise<DesktopCliCommandState>; | ||
| 1233 | uninstall: () => Promise<DesktopCliCommandState>; | ||
| 1234 | }; | ||
| 1218 | /** Present when the desktop shell accepts `t3 app` activation requests. */ | 1235 | /** Present when the desktop shell accepts `t3 app` activation requests. */ |
| 1219 | appActivation?: { | 1236 | appActivation?: { |
| 1220 | setReady: (ready: boolean) => Promise<void>; | 1237 | setReady: (ready: boolean) => Promise<void>; |