| 1 | import type { DesktopCliCommandState } from "@t3tools/contracts"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 10 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 11 | |
| 12 | import * as DesktopCliShim from "./DesktopCliShim.ts"; |
| 13 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 14 | |
| 15 | // Settings → Install `t3` command, like VS Code's "Install 'code' command". |
| 16 | // The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH |
| 17 | // by default. Installing links it into a folder on the user's PATH, or on |
| 18 | // Windows adds the launcher's folder to the user's PATH. Removing undoes only |
| 19 | // what installing did: a link that points at one of the app's launchers, or a |
| 20 | // PATH entry the app recorded adding. |
| 21 | |
| 22 | export class DesktopCliCommandError extends Schema.TaggedError<DesktopCliCommandError>()( |
| 23 | "DesktopCliCommandError", |
| 24 | { message: Schema.String }, |
| 25 | ) {} |
| 26 | |
| 27 | /** User-writable folders that login shells commonly put on PATH, in preference order. */ |
| 28 | const unixCandidates = (home: string, platform: NodeJS.Platform) => |
| 29 | platform === "darwin" |
| 30 | ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] |
| 31 | : [`${home}/.local/bin`, `${home}/bin`]; |
| 32 | |
| 33 | const pathEntries = (value: string | undefined, separator: string) => |
| 34 | (value ?? "").split(separator).filter((entry) => entry.length > 0); |
| 35 | |
| 36 | /** Windows paths compare without case or a trailing separator. */ |
| 37 | const sameWindowsPath = (left: string, right: string) => |
| 38 | left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); |
| 39 | |
| 40 | /** |
| 41 | * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping |
| 42 | * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for |
| 43 | * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` |
| 44 | * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it |
| 45 | * opens see the change. A failed read exits nonzero rather than reading as |
| 46 | * empty, so a write never replaces the user's PATH wholesale. |
| 47 | */ |
| 48 | const WINDOWS_USER_PATH_SCRIPT = ` |
| 49 | $ErrorActionPreference = 'Stop' |
| 50 | if ($env:T3_SET -eq '1') { |
| 51 | $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') |
| 52 | $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString |
| 53 | if ($key.GetValueNames() -contains 'Path' -and $key.GetValueKind('Path') -eq 'String') { |
| 54 | $kind = [Microsoft.Win32.RegistryValueKind]::String |
| 55 | } |
| 56 | $key.SetValue('Path', $env:T3_PATH, $kind) |
| 57 | Add-Type -Namespace T3 -Name Env -MemberDefinition '[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);' |
| 58 | $result = [System.UIntPtr]::Zero |
| 59 | [void][T3.Env]::SendMessageTimeout([System.IntPtr]0xffff, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) |
| 60 | } else { |
| 61 | $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') |
| 62 | $value = if ($key) { $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') } else { '' } |
| 63 | [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value))) |
| 64 | } |
| 65 | `; |
| 66 | |
| 67 | export class DesktopCliCommand extends Context.Service< |
| 68 | DesktopCliCommand, |
| 69 | { |
| 70 | readonly state: Effect.Effect<DesktopCliCommandState>; |
| 71 | readonly install: Effect.Effect<DesktopCliCommandState, DesktopCliCommandError>; |
| 72 | readonly uninstall: Effect.Effect<DesktopCliCommandState, DesktopCliCommandError>; |
| 73 | } |
| 74 | >()("@t3tools/desktop/app/DesktopCliCommand") {} |
| 75 | |
| 76 | /** @public Service construction is part of the canonical Effect module API. */ |
| 77 | export const make = Effect.gen(function* () { |
| 78 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 79 | const fs = yield* FileSystem.FileSystem; |
| 80 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 81 | const path = environment.path; |
| 82 | const windows = environment.platform === "win32"; |
| 83 | const launcher = DesktopCliShim.launcherPath(environment); |
| 84 | const binDirectory = path.dirname(launcher); |
| 85 | /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ |
| 86 | const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); |
| 87 | |
| 88 | const fail = (message: string) => new DesktopCliCommandError({ message }); |
| 89 | const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); |
| 90 | const writableDirectory = (directory: string) => |
| 91 | fs.access(directory, { writable: true }).pipe( |
| 92 | Effect.as(true), |
| 93 | Effect.orElseSucceed(() => false), |
| 94 | ); |
| 95 | |
| 96 | /** |
| 97 | * A link to one of the app's launchers, by the marker the launcher carries. |
| 98 | * This also finds links made under a previous T3 home, so Remove can clean |
| 99 | * them up and Install does not add a second. |
| 100 | */ |
| 101 | const isOurLink = (link: string) => |
| 102 | Effect.gen(function* () { |
| 103 | yield* fs.readLink(link); |
| 104 | // The launcher is a few KB; never read a large binary another `t3` links to. |
| 105 | const info = yield* fs.stat(link); |
| 106 | if (info.type !== "File" || Number(info.size) > 16_384) return false; |
| 107 | const content = yield* fs.readFileString(link); |
| 108 | return content.includes(DesktopCliShim.MARKER); |
| 109 | }).pipe(Effect.orElseSucceed(() => false)); |
| 110 | |
| 111 | const powershell = (env: Record<string, string>) => |
| 112 | Effect.scoped( |
| 113 | Effect.gen(function* () { |
| 114 | const root = process.env.SystemRoot ?? process.env.WINDIR; |
| 115 | const handle = yield* spawner.spawn( |
| 116 | ChildProcess.make( |
| 117 | root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", |
| 118 | ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], |
| 119 | { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, |
| 120 | ), |
| 121 | ); |
| 122 | const [stdout, exitCode] = yield* Effect.all( |
| 123 | [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], |
| 124 | { concurrency: "unbounded" }, |
| 125 | ); |
| 126 | if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`); |
| 127 | return stdout; |
| 128 | }), |
| 129 | ); |
| 130 | const readUserPath = powershell({}).pipe( |
| 131 | Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), |
| 132 | Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), |
| 133 | ); |
| 134 | const writeUserPath = (value: string) => |
| 135 | powershell({ T3_SET: "1", T3_PATH: value }).pipe( |
| 136 | Effect.asVoid, |
| 137 | Effect.mapError(() => fail("Could not update your PATH.")), |
| 138 | ); |
| 139 | |
| 140 | /** The `t3` a new shell runs, by PATH order, or none. */ |
| 141 | const firstOnPath = Effect.gen(function* () { |
| 142 | for (const directory of pathEntries(process.env.PATH, ":")) { |
| 143 | const candidate = path.join(directory, "t3"); |
| 144 | if (yield* exists(candidate)) return Option.some(candidate); |
| 145 | } |
| 146 | return Option.none<string>(); |
| 147 | }); |
| 148 | |
| 149 | /** 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 | |
| 280 | export const layer = Layer.effect(DesktopCliCommand, make); |