authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 12:42:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 12:42:53-07:00
log0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e
treeeedfc33c4705b7c2034b1ac1c09f8fa6bf536488
parent73e87f683ae1455feb663481f1fa588f01b5784b
signature Signed by PGP key B5690EEEBB952194

feat(desktop): install the t3 command from Settings (#16683)

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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10
11import * as DesktopCliCommand from "./DesktopCliCommand.ts";
12import * as DesktopCliShim from "./DesktopCliShim.ts";
13import * as DesktopEnvironment from "./DesktopEnvironment.ts";
14
15const 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. */
31const 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. */
52const 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
88it.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 @@
1import type { DesktopCliCommandState } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as ChildProcess from "effect/process/ChildProcess";
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
11
12import * as DesktopCliShim from "./DesktopCliShim.ts";
13import * 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
22export 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. */
28const 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
33const 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. */
37const 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 */
48const WINDOWS_USER_PATH_SCRIPT = `
49$ErrorActionPreference = 'Stop'
50if ($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
67export 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. */
77export 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
261export 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.
15const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim");15const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim");
1616
17const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI.";17export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI.";
1818
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). */
20const SERVER_ENTRY = "apps/server/dist/bin.mjs";20const 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};
127127
128/** Where the packaged app keeps its launcher: `<T3 home>/bin/t3`, `t3.cmd` on Windows. */
129export 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 path137 * 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 checkout138 * 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 = windows149 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";
77import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";77import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
78import {
79 getCliCommandState,
80 installCliCommand,
81 uninstallCliCommand,
82} from "./methods/cliCommand.ts";
7883
79export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {84export 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";
23export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";23export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
24export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";24export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
25export const UPDATE_CHECK_CHANNEL = "desktop:update-check";25export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
26export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state";
27export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install";
28export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall";
26export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";29export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
27export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";30export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
28export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";31export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
apps/desktop/src/ipc/methods/cliCommand.ts created+34
...@@ -0,0 +1,34 @@
1import { DesktopCliCommandStateSchema } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import * as DesktopCliCommand from "../../app/DesktopCliCommand.ts";
6import * as IpcChannels from "../channels.ts";
7import { makeIpcMethod } from "../DesktopIpc.ts";
8
9export 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
18export 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
27export 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";
38import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";38import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
39import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";39import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
40import * as DesktopClerk from "./app/DesktopClerk.ts";40import * as DesktopClerk from "./app/DesktopClerk.ts";
41import * as DesktopCliCommand from "./app/DesktopCliCommand.ts";
41import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";42import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
42import * as DesktopAssets from "./app/DesktopAssets.ts";43import * as DesktopAssets from "./app/DesktopAssets.ts";
43import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";44import * 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 @@
1import type { DesktopCliCommandState } from "@t3tools/contracts";
2import { useCallback, useEffect, useState } from "react";
3
4import { Button } from "../ui/button";
5import { stackedThreadToast, toastManager } from "../ui/toast";
6import { SettingsRow } from "./settingsLayout";
7import { 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 */
13export 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";
55import * as Schema from "effect/Schema";55import * as Schema from "effect/Schema";
56import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";56import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
57import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";57import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";
58import { CliCommandSettingsRow } from "./CliCommandSettingsRow";
58import {59import {
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
76password to install each update. If your desktop has no password prompt, the76password to install each update. If your desktop has no password prompt, the
77update fails. Download the new `.deb` and install it the same way.77update fails. Download the new `.deb` and install it the same way.
7878
79### The `t3` command
80
81The desktop app includes the `t3` command-line tool. To run it from any
82terminal, 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
85terminal afterwards. **Remove** takes it off again. If you already have `t3`
86from npm, it stays as it is.
87
79### Windows Subsystem for Linux88### Windows Subsystem for Linux
8089
81Choose a WSL distro in **Settings → Connections** to run agents and projects90Choose 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});
319319
320/** The desktop app's `t3` command on PATH, managed from Settings. */
321export 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});
329export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type;
330
320export interface DesktopUpdateActionResult {331export 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>;