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";
1414// so its launcher mounts the AppImage itself instead of pointing into it.
1515const { 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
1919/** Server entry inside the app, relative to its server root (an asar archive when packaged). */
2020const SERVER_ENTRY = "apps/server/dist/bin.mjs";
......@@ -125,6 +125,14 @@ export const renderCliShim = (input: {
125125 ].join("\n");
126126};
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
128136/**
129137 * Writes the packaged app's launcher to `<T3 home>/bin` and returns its path
130138 * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout
......@@ -136,7 +144,7 @@ export const install = Effect.gen(function* () {
136144 const fs = yield* FileSystem.FileSystem;
137145 const path = environment.path;
138146 const windows = environment.platform === "win32";
139 const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3");
147 const shimPath = launcherPath(environment);
140148 const entry = path.join(environment.serverRoot, SERVER_ENTRY);
141149 const target: CliShimTarget = windows
142150 ? { kind: "windows", executable: process.execPath, entry }
apps/desktop/src/ipc/DesktopIpcHandlers.ts+8
......@@ -75,6 +75,11 @@ import {
7575 takeLegacyLocalStorage,
7676} from "./methods/legacyLocalStorage.ts";
7777import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
78import {
79 getCliCommandState,
80 installCliCommand,
81 uninstallCliCommand,
82} from "./methods/cliCommand.ts";
7883
7984export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
8085 const ipc = yield* DesktopIpc.DesktopIpc;
......@@ -149,6 +154,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
149154 yield* ipc.handle(downloadUpdate);
150155 yield* ipc.handle(installUpdate);
151156 yield* ipc.handle(checkForUpdate);
157 yield* ipc.handle(getCliCommandState);
158 yield* ipc.handle(installCliCommand);
159 yield* ipc.handle(uninstallCliCommand);
152160 for (const previewMethod of PreviewIpc.methods) {
153161 yield* ipc.handle(previewMethod);
154162 }
apps/desktop/src/ipc/channels.ts+3
......@@ -23,6 +23,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
2323export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
2424export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
2525export 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";
2629export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
2730export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
2831export 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";
3838import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
3939import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
4040import * as DesktopClerk from "./app/DesktopClerk.ts";
41import * as DesktopCliCommand from "./app/DesktopCliCommand.ts";
4142import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
4243import * as DesktopAssets from "./app/DesktopAssets.ts";
4344import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
......@@ -213,6 +214,7 @@ const layerDesktopApplication = Layer.mergeAll(
213214 layerDesktopAppActivation,
214215 DesktopApplicationMenu.layer,
215216 DesktopLinuxUrlHandler.layer,
217 DesktopCliCommand.layer,
216218 DesktopShellEnvironment.layer,
217219 layerDesktopSsh,
218220).pipe(
apps/desktop/src/preload.ts+5
......@@ -268,6 +268,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
268268 checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL),
269269 downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL),
270270 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 },
271276 onUpdateState: (listener) => {
272277 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
273278 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";
5555import * as Schema from "effect/Schema";
5656import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
5757import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";
58import { CliCommandSettingsRow } from "./CliCommandSettingsRow";
5859import {
5960 canCheckForUpdate,
6061 getDesktopUpdateButtonTooltip,
......@@ -509,6 +510,7 @@ function AboutVersionSection() {
509510 />
510511 ) : null}
511512 {showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null}
513 {hasDesktopBridge ? <CliCommandSettingsRow /> : null}
512514 </>
513515 );
514516}
apps/web/src/components/settings/settingsSearch.ts+7
......@@ -503,6 +503,13 @@ export const SETTINGS_SEARCH_ITEMS = [
503503 scope: "project-defaults",
504504 searchTerms: ["generated thread titles source control content default provider"],
505505 },
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 },
506513 {
507514 id: "privacy-policy",
508515 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
7676password to install each update. If your desktop has no password prompt, the
7777update 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
7988### Windows Subsystem for Linux
8089
8190Choose 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({
317317 canRetry: Schema.Boolean,
318318});
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
320331export interface DesktopUpdateActionResult {
321332 accepted: boolean;
322333 completed: boolean;
......@@ -1215,6 +1226,12 @@ export interface DesktopBridge {
12151226 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
12161227 installUpdate: () => Promise<DesktopUpdateActionResult>;
12171228 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 };
12181235 /** Present when the desktop shell accepts `t3 app` activation requests. */
12191236 appActivation?: {
12201237 setReady: (ready: boolean) => Promise<void>;