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 /** The `t3` a new shell runs when it is not this app's, on Unix. */
150 const foreignFirstOnPath = Effect.gen(function* () {
151 if (windows) return Option.none<string>();
152 const first = yield* firstOnPath;
153 if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none<string>();
154 return first;
155 });
156
157 /** Where this app's command is installed now, if anywhere. */
158 const installedAt = Effect.gen(function* () {
159 if (windows) {
160 if (!(yield* exists(ownedPathMarker))) return Option.none<string>();
161 const entries = pathEntries(yield* readUserPath, ";");
162 return entries.some((entry) => sameWindowsPath(entry, binDirectory))
163 ? Option.some(launcher)
164 : Option.none<string>();
165 }
166 for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) {
167 const link = path.join(directory, "t3");
168 if (yield* isOurLink(link)) return Option.some(link);
169 }
170 return Option.none<string>();
171 });
172
173 const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () {
174 if (!environment.isPackaged) {
175 return { supported: false, installedPath: null, onPath: false } as const;
176 }
177 const installed = yield* installedAt;
178 const shadowedBy = yield* foreignFirstOnPath;
179 const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {};
180 if (Option.isNone(installed)) {
181 return { supported: true, installedPath: null, onPath: false, ...shadow };
182 }
183 // On Windows only terminals opened after the change see it. On Unix the
184 // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it.
185 const first = yield* firstOnPath;
186 const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value)));
187 return { supported: true, installedPath: installed.value, onPath, ...shadow };
188 }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false })));
189
190 /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */
191 const ensureLauncher = DesktopCliShim.install.pipe(
192 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
193 Effect.provideService(FileSystem.FileSystem, fs),
194 Effect.flatMap(
195 Option.match({
196 onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)),
197 onSome: () => Effect.void,
198 }),
199 ),
200 );
201
202 const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () {
203 if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app.");
204 yield* ensureLauncher;
205 if (windows) {
206 const entries = pathEntries(yield* readUserPath, ";");
207 if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) {
208 yield* writeUserPath([...entries, binDirectory].join(";"));
209 yield* fs
210 .writeFileString(ownedPathMarker, `${binDirectory}\n`)
211 .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it.")));
212 }
213 return yield* state;
214 }
215 const existing = yield* installedAt;
216 if (Option.isSome(existing)) {
217 const target = yield* fs.readLink(existing.value).pipe(Effect.option);
218 if (Option.getOrUndefined(target) === launcher) return yield* state;
219 // A link to a previous T3 home's launcher: point it at this one instead.
220 yield* fs
221 .remove(existing.value)
222 .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`)));
223 }
224 // A link behind another `t3` never runs, so installing one would only hide the problem.
225 const shadowedBy = yield* foreignFirstOnPath;
226 if (Option.isSome(shadowedBy)) {
227 return yield* fail(
228 `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`,
229 );
230 }
231 const onPath = pathEntries(process.env.PATH, ":");
232 const candidates = unixCandidates(environment.homeDirectory, environment.platform);
233 // Prefer a folder already on PATH that the user can write to without admin rights.
234 for (const directory of [
235 ...candidates.filter((candidate) => onPath.includes(candidate)),
236 ...candidates.filter((candidate) => !onPath.includes(candidate)),
237 ]) {
238 const link = path.join(directory, "t3");
239 const created = (yield* exists(directory))
240 ? yield* writableDirectory(directory)
241 : yield* fs.makeDirectory(directory, { recursive: true }).pipe(
242 Effect.as(true),
243 Effect.orElseSucceed(() => false),
244 );
245 if (!created) continue;
246 // symlink fails if anything, even a broken link, is already there.
247 const linked = yield* fs.symlink(launcher, link).pipe(
248 Effect.as(true),
249 Effect.orElseSucceed(() => false),
250 );
251 if (linked) return yield* state;
252 }
253 return yield* fail(
254 `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`,
255 );
256 }).pipe(Effect.withSpan("desktop.cliCommand.install"));
257
258 const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () {
259 if (windows) {
260 if (yield* exists(ownedPathMarker)) {
261 const entries = pathEntries(yield* readUserPath, ";");
262 const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory));
263 if (kept.length !== entries.length) yield* writeUserPath(kept.join(";"));
264 yield* fs.remove(ownedPathMarker).pipe(Effect.ignore);
265 }
266 return yield* state;
267 }
268 for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) {
269 const link = path.join(directory, "t3");
270 if (yield* isOurLink(link)) {
271 yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`)));
272 }
273 }
274 return yield* state;
275 }).pipe(Effect.withSpan("desktop.cliCommand.uninstall"));
276
277 return DesktopCliCommand.of({ state, install, uninstall });
278});
279
280export const layer = Layer.effect(DesktopCliCommand, make);