Open History to see the last commit
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.access has no execute check.
2import * as NodeFSP from "node:fs/promises";
3
4import type { DesktopCliCommandState } from "@t3tools/contracts";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Random from "effect/Random";
12import * as Schema from "effect/Schema";
13import * as Semaphore from "effect/Semaphore";
14import * as Stream from "effect/Stream";
15import * as ChildProcess from "effect/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17
18import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell";
19
20import * as DesktopCliShim from "./DesktopCliShim.ts";
21import * as DesktopEnvironment from "./DesktopEnvironment.ts";
22import { makeComponentLogger } from "./DesktopObservability.ts";
23
24const { logInfo, logWarning } = makeComponentLogger("desktop-cli-command");
25
26export class DesktopCliCommandError extends Schema.TaggedError<DesktopCliCommandError>()(
27 "DesktopCliCommandError",
28 { message: Schema.String },
29) {}
30
31const userCandidates = (home: string) => [`${home}/.local/bin`, `${home}/bin`];
32
33// Older builds also linked into these macOS folders. Such a link is found, updated in place, and
34// removed, but no new link goes there.
35const knownLocations = (home: string, platform: NodeJS.Platform) =>
36 platform === "darwin"
37 ? [...userCandidates(home), "/opt/homebrew/bin", "/usr/local/bin"]
38 : userCandidates(home);
39
40const pathEntries = (value: string | undefined, separator: string) =>
41 (value ?? "").split(separator).filter((entry) => entry.length > 0);
42
43const sameWindowsPath = (left: string, right: string) =>
44 left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase();
45
46// Preserve REG_EXPAND_SZ and %VAR% expansion; notify Explorer with WM_SETTINGCHANGE.
47// Failed reads must exit nonzero to prevent replacing PATH wholesale.
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 /** Adds the installed command's folder to PATH in the user's shell startup file. */
74 readonly addToPath: Effect.Effect<DesktopCliCommandState, DesktopCliCommandError>;
75 /** Installs the command on a fresh install's first launch; removing it in Settings sticks. */
76 readonly installOnFirstLaunch: Effect.Effect<void>;
77 }
78>()("@t3tools/desktop/app/DesktopCliCommand") {}
79
80export const make = Effect.gen(function* () {
81 const environment = yield* DesktopEnvironment.DesktopEnvironment;
82 const fs = yield* FileSystem.FileSystem;
83 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
84 // Read the live env: shell discovery patches PATH after startup, and Windows variable
85 // names are case-insensitive only on process.env, not on Config's copy of it.
86 const env = yield* HostProcess.Environment;
87 const path = environment.path;
88 const windows = environment.platform === "win32";
89 const launcher = DesktopCliShim.launcherPath(environment);
90 const binDirectory = path.dirname(launcher);
91 const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry");
92 const firstLaunchMarker = path.join(environment.stateDir, "cli-command-first-launch");
93
94 const fail = (message: string) => new DesktopCliCommandError({ message });
95 const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false));
96 // Read before this launch writes either one: an app that ran before left its launcher or
97 // settings. Its user may have removed t3 or chosen another, so only Settings installs there.
98 const freshInstall =
99 !(yield* exists(launcher)) && !(yield* exists(environment.desktopSettingsPath));
100 const writableDirectory = (directory: string) =>
101 fs.access(directory, { writable: true }).pipe(
102 Effect.as(true),
103 Effect.orElseSucceed(() => false),
104 );
105
106 const isOurLink = Effect.fn("DesktopCliCommand.isOurLink")(
107 function* (link: string) {
108 yield* fs.readLink(link);
109 // The launcher is a few KB; never read a large binary another `t3` links to.
110 const info = yield* fs.stat(link);
111 if (info.type !== "File" || Number(info.size) > 16_384) return false;
112 const content = yield* fs.readFileString(link);
113 return content.includes(DesktopCliShim.MARKER);
114 },
115 Effect.orElseSucceed(() => false),
116 );
117
118 // PATH can reach the regular launcher directly; removal still requires isOurLink.
119 const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")(
120 // Another T3 home's link is marked too, so compare where it leads, not the marker alone.
121 function* (candidate: string) {
122 const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]);
123 if (resolved !== ours) return false;
124 const info = yield* fs.stat(candidate);
125 if (info.type !== "File" || Number(info.size) > 16_384) return false;
126 return (yield* fs.readFileString(candidate)).includes(DesktopCliShim.MARKER);
127 },
128 Effect.orElseSucceed(() => false),
129 );
130
131 const powershell = (overrides: Record<string, string>) =>
132 Effect.scoped(
133 Effect.gen(function* () {
134 const root = env.SystemRoot ?? env.WINDIR;
135 const handle = yield* spawner.spawn(
136 ChildProcess.make(
137 root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe",
138 ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT],
139 { stdin: "ignore", stderr: "ignore", env: overrides, extendEnv: true },
140 ),
141 );
142 const [stdout, exitCode] = yield* Effect.all(
143 [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
144 { concurrency: "unbounded" },
145 );
146 if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`);
147 return stdout;
148 }),
149 );
150 // Explicit, so an inherited T3_SET=1 can never turn a read into a PATH write.
151 const readUserPath = powershell({ T3_SET: "0" }).pipe(
152 Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")),
153 Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")),
154 );
155 const writeUserPath = (value: string) =>
156 powershell({ T3_SET: "1", T3_PATH: value }).pipe(
157 Effect.asVoid,
158 Effect.mapError(() => fail("Could not update your PATH.")),
159 );
160
161 const isExecutableFile = Effect.fn("DesktopCliCommand.isExecutableFile")(
162 function* (target: string) {
163 if ((yield* fs.stat(target)).type !== "File") return false;
164 yield* Effect.tryPromise({
165 try: () => NodeFSP.access(target, NodeFSP.constants.X_OK),
166 catch: () => fail(`${target} is not executable.`),
167 });
168 return true;
169 },
170 Effect.orElseSucceed(() => false),
171 );
172
173 const firstOnPath = Effect.gen(function* () {
174 for (const directory of pathEntries(env.PATH, ":")) {
175 const candidate = path.join(directory, "t3");
176 if (yield* isExecutableFile(candidate)) return Option.some(candidate);
177 }
178 return Option.none<string>();
179 });
180
181 const foreignFirstOnPath = Effect.gen(function* () {
182 if (windows) return Option.none<string>();
183 const first = yield* firstOnPath;
184 if (Option.isNone(first) || (yield* runsOurLauncher(first.value))) return Option.none<string>();
185 return first;
186 });
187
188 /** A link to this home's launcher; another home's link is marked too. */
189 const leadsHere = (link: string) =>
190 fs.readLink(link).pipe(
191 Effect.map((target) => target === launcher),
192 Effect.orElseSucceed(() => false),
193 );
194
195 const installedAt = Effect.gen(function* () {
196 if (windows) {
197 if (!(yield* exists(ownedPathMarker))) return Option.none<string>();
198 const entries = pathEntries(yield* readUserPath, ";");
199 return entries.some((entry) => sameWindowsPath(entry, binDirectory))
200 ? Option.some(launcher)
201 : Option.none<string>();
202 }
203 for (const directory of knownLocations(environment.homeDirectory, environment.platform)) {
204 const link = path.join(directory, "t3");
205 if (yield* isOurLink(link)) return Option.some(link);
206 }
207 return Option.none<string>();
208 });
209
210 const startupLine = (installedPath: string) => {
211 const setup = pathSetup({
212 directory: path.dirname(installedPath),
213 home: environment.homeDirectory,
214 // A Finder or desktop launch may not set SHELL.
215 shell: env.SHELL || readUserLoginShell(),
216 platform: environment.platform,
217 configHome: env.XDG_CONFIG_HOME,
218 zdotdir: env.ZDOTDIR,
219 });
220 if (setup === undefined) return undefined;
221 const file = setup.file.startsWith("~/")
222 ? path.join(environment.homeDirectory, setup.file.slice(2))
223 : setup.file;
224 return { ...setup, path: file };
225 };
226 const hasLine = (setup: { readonly path: string; readonly line: string }) =>
227 fs.readFileString(setup.path).pipe(
228 Effect.map((content) => content.split("\n").some((line) => line.trim() === setup.line)),
229 Effect.orElseSucceed(() => false),
230 );
231
232 const folderOnPath = (link: string) =>
233 Effect.sync(() =>
234 pathEntries(env.PATH, ":").some((entry) => entry.replace(/\/+$/, "") === path.dirname(link)),
235 );
236
237 const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () {
238 if (!environment.isPackaged) {
239 return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const;
240 }
241 const launcherPath = (yield* exists(launcher)) ? launcher : null;
242 const found = yield* installedAt;
243 // Our link into another T3 home is not this app's command; Install updates it in place.
244 const installed =
245 windows || (Option.isSome(found) && (yield* leadsHere(found.value)))
246 ? found
247 : Option.none<string>();
248 const shadowedBy = (yield* foreignFirstOnPath).pipe(
249 Option.filter((first) => first !== Option.getOrUndefined(found)),
250 );
251 const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {};
252 const first = yield* firstOnPath;
253 const onPath = windows
254 ? Option.isSome(installed)
255 : Option.isSome(first) && (yield* runsOurLauncher(first.value));
256 // Only a folder missing from PATH, with no other t3 first: with one, the added folder (last
257 // on PATH) would still not run this t3.
258 const setup =
259 onPath ||
260 Option.isSome(shadowedBy) ||
261 Option.isNone(installed) ||
262 (yield* folderOnPath(installed.value))
263 ? undefined
264 : startupLine(installed.value);
265 return {
266 supported: true,
267 installedPath: Option.getOrNull(installed),
268 onPath,
269 launcherPath,
270 ...shadow,
271 ...(setup === undefined
272 ? {}
273 : { pathSetup: { file: setup.file, added: yield* hasLine(setup) } }),
274 };
275 }).pipe(
276 Effect.orElseSucceed(() => ({
277 supported: false,
278 installedPath: null,
279 onPath: false,
280 launcherPath: null,
281 })),
282 );
283
284 const ensureLauncher = DesktopCliShim.install.pipe(
285 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
286 Effect.provideService(FileSystem.FileSystem, fs),
287 Effect.flatMap(
288 Option.match({
289 onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)),
290 onSome: () => Effect.void,
291 }),
292 ),
293 );
294
295 const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () {
296 if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app.");
297 yield* ensureLauncher;
298 if (windows) {
299 // PATH would split the folder at the `;`, leaving two wrong entries behind.
300 if (binDirectory.includes(";")) {
301 return yield* fail(
302 `T3 Code cannot put ${binDirectory} on PATH because it has a ";". Run ${launcher} directly.`,
303 );
304 }
305 const entries = pathEntries(yield* readUserPath, ";");
306 if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) {
307 yield* writeUserPath([...entries, binDirectory].join(";"));
308 yield* fs
309 .writeFileString(ownedPathMarker, `${binDirectory}\n`)
310 .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it.")));
311 }
312 return yield* state;
313 }
314 const candidates = userCandidates(environment.homeDirectory);
315 const replaced = Option.getOrUndefined(yield* installedAt);
316 if (replaced !== undefined) {
317 const target = yield* fs.readLink(replaced).pipe(Effect.option);
318 if (Option.getOrUndefined(target) === launcher) return yield* state;
319 }
320 const shadowedBy = yield* foreignFirstOnPath;
321 // Our own link into another T3 home runs first too; it is the one this install updates.
322 if (Option.isSome(shadowedBy) && shadowedBy.value !== replaced) {
323 return yield* fail(
324 `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`,
325 );
326 }
327 // An old link of ours is updated where it is, so a t3 that works now keeps working. It is
328 // removed only once a new link exists elsewhere.
329 const onPath = pathEntries(env.PATH, ":").map((entry) => entry.replace(/\/+$/, ""));
330 for (const directory of new Set([
331 ...(replaced === undefined ? [] : [path.dirname(replaced)]),
332 ...candidates.filter((candidate) => onPath.includes(candidate)),
333 ...candidates.filter((candidate) => !onPath.includes(candidate)),
334 ])) {
335 const link = path.join(directory, "t3");
336 const created = (yield* exists(directory))
337 ? yield* writableDirectory(directory)
338 : yield* fs.makeDirectory(directory, { recursive: true }).pipe(
339 Effect.as(true),
340 Effect.orElseSucceed(() => false),
341 );
342 if (!created) continue;
343 // Our own link is swapped in place; anything else there makes symlink fail and stays.
344 const staging = `${link}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`;
345 const linked = yield* (
346 link === replaced
347 ? fs
348 .symlink(launcher, staging)
349 .pipe(
350 Effect.andThen(
351 fs
352 .rename(staging, link)
353 .pipe(Effect.tapError(() => fs.remove(staging).pipe(Effect.ignore))),
354 ),
355 )
356 : fs.symlink(launcher, link)
357 ).pipe(
358 Effect.as(true),
359 Effect.orElseSucceed(() => false),
360 );
361 if (!linked) continue;
362 if (replaced !== undefined && replaced !== link) {
363 yield* fs.remove(replaced).pipe(Effect.ignore);
364 }
365 return yield* state;
366 }
367 return yield* fail(
368 `Another t3 command is already in ~/.local/bin and ~/bin, or neither is writable. Run the launcher directly at ${launcher}.`,
369 );
370 }).pipe(Effect.withSpan("desktop.cliCommand.install"));
371
372 const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () {
373 // A first launch put off for the shell PATH must not bring back what the user removed.
374 yield* fs.writeFileString(firstLaunchMarker, "").pipe(Effect.ignore);
375 if (windows) {
376 if (yield* exists(ownedPathMarker)) {
377 const entries = pathEntries(yield* readUserPath, ";");
378 const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory));
379 if (kept.length !== entries.length) yield* writeUserPath(kept.join(";"));
380 yield* fs.remove(ownedPathMarker).pipe(Effect.ignore);
381 }
382 return yield* state;
383 }
384 for (const directory of knownLocations(environment.homeDirectory, environment.platform)) {
385 const link = path.join(directory, "t3");
386 if (yield* isOurLink(link)) {
387 yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`)));
388 }
389 }
390 return yield* state;
391 }).pipe(Effect.withSpan("desktop.cliCommand.uninstall"));
392
393 // One at a time, so Settings and onboarding cannot both append the line.
394 const addToPathPermit = yield* Semaphore.make(1);
395 const addToPath: DesktopCliCommand["Service"]["addToPath"] = Effect.gen(function* () {
396 const installed = yield* installedAt;
397 if (Option.isNone(installed) || !(yield* leadsHere(installed.value))) {
398 return yield* fail("Install the t3 command first.");
399 }
400 const setup = startupLine(installed.value);
401 if (setup === undefined) return yield* fail("T3 Code cannot add t3 to this shell's PATH.");
402 const shadowedBy = yield* foreignFirstOnPath;
403 if (Option.isSome(shadowedBy)) {
404 return yield* fail(
405 `Another t3 at ${shadowedBy.value} runs first; T3 Code leaves it in place.`,
406 );
407 }
408 if (!(yield* folderOnPath(installed.value)) && !(yield* hasLine(setup))) {
409 yield* fs.makeDirectory(path.dirname(setup.path), { recursive: true }).pipe(
410 Effect.andThen(
411 fs.writeFileString(setup.path, `\n# Added by T3 Code\n${setup.line}\n`, { flag: "a" }),
412 ),
413 Effect.mapError(() => fail(`Could not add t3 to ${setup.file}.`)),
414 );
415 }
416 return yield* state;
417 }).pipe(addToPathPermit.withPermits(1), Effect.withSpan("desktop.cliCommand.addToPath"));
418
419 const installOnFirstLaunch: DesktopCliCommand["Service"]["installOnFirstLaunch"] = Effect.gen(
420 function* () {
421 if (!environment.isPackaged || (yield* exists(firstLaunchMarker))) return;
422 // Mark first so a failed or removed install is never retried behind the user's back.
423 yield* fs.writeFileString(firstLaunchMarker, "");
424 if (!freshInstall) {
425 return yield* logInfo("left the t3 command to Settings on an existing install");
426 }
427 // Another T3 home's app owns the existing link; only Install in Settings may take it over.
428 const existing = Option.getOrUndefined(yield* installedAt);
429 if (existing !== undefined) {
430 const target = yield* fs.readLink(existing).pipe(Effect.option);
431 if (Option.getOrUndefined(target) !== launcher) {
432 return yield* logInfo("left another T3 Code app's t3 command in place", {
433 existing,
434 });
435 }
436 }
437 // A t3 that is not ours in these folders, even off PATH, means the user has one already. On
438 // Windows the app never writes there, so any t3 there is someone else's.
439 for (const directory of userCandidates(environment.homeDirectory)) {
440 for (const name of windows ? ["t3.cmd", "t3.exe"] : ["t3"]) {
441 const other = path.join(directory, name);
442 const present =
443 (yield* exists(other)) || Option.isSome(yield* fs.readLink(other).pipe(Effect.option));
444 if (present && (windows || !(yield* isOurLink(other)))) {
445 return yield* logInfo("left the t3 command to Settings beside another t3", { other });
446 }
447 }
448 }
449 const installed = yield* install;
450 yield* logInfo("installed the t3 command on first launch", {
451 installedPath: installed.installedPath,
452 onPath: installed.onPath,
453 });
454 },
455 ).pipe(
456 // Our own messages are short and name only paths; anything else is logged as a category.
457 Effect.catch((error) =>
458 logWarning("did not install the t3 command on first launch", {
459 reason: error._tag === "DesktopCliCommandError" ? error.message : "state-write-failed",
460 }),
461 ),
462 Effect.withSpan("desktop.cliCommand.installOnFirstLaunch"),
463 );
464
465 return DesktopCliCommand.of({ state, install, uninstall, addToPath, installOnFirstLaunch });
466});
467
468export const layer = Layer.effect(DesktopCliCommand, make);