| author | |
| committer | |
| log | b70015b6db8ffafd902174f78cb260cc8082fc6e |
| tree | ee511f6607dd0dbcb384ec7ad6f8d9d666578d50 |
| parent | 91cd91c0872bd52be3b45bea70a9539d1ce6adaa |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 285 insertions(+), 6 deletions(-)
apps/server/src/bin.ts+2| ... | @@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts"; | ... | @@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts"; |
| 17 | import { projectCommand } from "./cli/project.ts"; | 17 | import { projectCommand } from "./cli/project.ts"; |
| 18 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; | 18 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 19 | import { serviceCommand } from "./cli/service.ts"; | 19 | import { serviceCommand } from "./cli/service.ts"; |
| 20 | import { uninstallCommand } from "./cli/uninstall.ts"; | ||
| 20 | import { updateCommand } from "./cli/update.ts"; | 21 | import { updateCommand } from "./cli/update.ts"; |
| 21 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; | 22 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; |
| 22 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; | 23 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; |
| ... | @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => | ... | @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 64 | projectCommand, | 65 | projectCommand, |
| 65 | serviceCommand, | 66 | serviceCommand, |
| 66 | updateCommand, | 67 | updateCommand, |
| 68 | uninstallCommand, | ||
| 67 | serviceLauncherCommand, | 69 | serviceLauncherCommand, |
| 68 | claudeHistoryCommand, | 70 | claudeHistoryCommand, |
| 69 | servicePreflightCommand, | 71 | servicePreflightCommand, |
apps/server/src/cli/uninstall.test.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | |||
| 8 | import { findOwnedLauncher } from "./uninstall.ts"; | ||
| 9 | |||
| 10 | it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { | ||
| 11 | it.effect("claims only a launcher that points into this home's runtime tree", () => | ||
| 12 | Effect.gen(function* () { | ||
| 13 | const fs = yield* FileSystem.FileSystem; | ||
| 14 | const path = yield* Path.Path; | ||
| 15 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" }); | ||
| 16 | const versionsDir = path.join(root, "runtime/versions"); | ||
| 17 | const exe = path.join(versionsDir, "1.0.0/t3"); | ||
| 18 | const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3"); | ||
| 19 | const copy = path.join(root, "copy/t3"); | ||
| 20 | for (const file of [exe, otherExe, copy]) { | ||
| 21 | yield* fs.makeDirectory(path.dirname(file), { recursive: true }); | ||
| 22 | yield* fs.writeFileString(file, ""); | ||
| 23 | } | ||
| 24 | const ours = path.join(root, "bin/t3"); | ||
| 25 | const theirs = path.join(root, "other/bin/t3"); | ||
| 26 | yield* fs.makeDirectory(path.dirname(ours), { recursive: true }); | ||
| 27 | yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); | ||
| 28 | yield* fs.symlink(exe, ours); | ||
| 29 | yield* fs.symlink(otherExe, theirs); | ||
| 30 | |||
| 31 | assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours); | ||
| 32 | assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); | ||
| 33 | assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); | ||
| 34 | assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); | ||
| 35 | }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), | ||
| 36 | ); | ||
| 37 | }); | ||
apps/server/src/cli/uninstall.ts created+222| ... | @@ -0,0 +1,222 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | // The Windows cleanup shell must outlive this process (it deletes the | ||
| 3 | // directory this executable runs from), which Effect's scoped ChildProcess | ||
| 4 | // cannot express: it kills the child when the scope closes. | ||
| 5 | import * as NodeChildProcess from "node:child_process"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | HostProcessEnvironment, | ||
| 9 | HostProcessIsExecutable, | ||
| 10 | HostProcessPlatform, | ||
| 11 | } from "@t3tools/shared/hostProcess"; | ||
| 12 | import * as Console from "effect/Console"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as FileSystem from "effect/FileSystem"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Path from "effect/Path"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; | ||
| 19 | |||
| 20 | import * as BootService from "../cloud/bootService.ts"; | ||
| 21 | import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts"; | ||
| 22 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | ||
| 23 | import { bootServiceLayer } from "./service.ts"; | ||
| 24 | import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts"; | ||
| 25 | |||
| 26 | export class CliUninstallError extends Schema.TaggedError<CliUninstallError>()( | ||
| 27 | "CliUninstallError", | ||
| 28 | { reason: Schema.String }, | ||
| 29 | ) { | ||
| 30 | override get message(): string { | ||
| 31 | return this.reason; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | /** | ||
| 36 | * What `t3 uninstall` would remove for one T3 home. Computed before anything | ||
| 37 | * is touched so the user sees the whole plan in one place. | ||
| 38 | */ | ||
| 39 | export interface UninstallPlan { | ||
| 40 | /** The background service serves this home and will be stopped and removed. */ | ||
| 41 | readonly service: boolean; | ||
| 42 | /** The `t3` launcher (symlink or `.cmd` shim) that points into this home's runtime tree. */ | ||
| 43 | readonly launcher: string | undefined; | ||
| 44 | /** `<home>/runtime`, holding every downloaded version, when it exists. */ | ||
| 45 | readonly runtimeDir: string | undefined; | ||
| 46 | /** `<home>/userdata`, which is never removed; shown so the user knows where it is. */ | ||
| 47 | readonly userdataDir: string; | ||
| 48 | } | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Finds the launcher this install left on PATH. Only a launcher that points | ||
| 52 | * into this home's `runtime/versions` is claimed: a plain copy of the | ||
| 53 | * executable, or a launcher for another home, is not ours to delete. | ||
| 54 | */ | ||
| 55 | export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: { | ||
| 56 | readonly launchedAs: string | undefined; | ||
| 57 | readonly versionsDir: string; | ||
| 58 | }) { | ||
| 59 | const fs = yield* FileSystem.FileSystem; | ||
| 60 | const path = yield* Path.Path; | ||
| 61 | const platform = yield* HostProcessPlatform; | ||
| 62 | if (input.launchedAs === undefined) return undefined; | ||
| 63 | if (platform === "win32") { | ||
| 64 | const shimPath = yield* findWindowsShim(input.launchedAs); | ||
| 65 | if (shimPath === undefined) return undefined; | ||
| 66 | const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); | ||
| 67 | const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined; | ||
| 68 | return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target) | ||
| 69 | ? shimPath | ||
| 70 | : undefined; | ||
| 71 | } | ||
| 72 | const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); | ||
| 73 | if (Option.isNone(linkTarget)) return undefined; | ||
| 74 | const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value); | ||
| 75 | return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined; | ||
| 76 | }); | ||
| 77 | |||
| 78 | const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { | ||
| 79 | readonly baseDir: string; | ||
| 80 | }) { | ||
| 81 | const fs = yield* FileSystem.FileSystem; | ||
| 82 | const path = yield* Path.Path; | ||
| 83 | const service = yield* BootService.BootService; | ||
| 84 | const status = yield* service.status; | ||
| 85 | const servesThisHome = | ||
| 86 | status.installedBaseDir !== undefined && | ||
| 87 | path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); | ||
| 88 | const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); | ||
| 89 | const runtimeDir = path.dirname(versionsDir); | ||
| 90 | const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; | ||
| 91 | const plan: UninstallPlan = { | ||
| 92 | service: status.supported && status.installed && servesThisHome, | ||
| 93 | launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }), | ||
| 94 | runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false))) | ||
| 95 | ? runtimeDir | ||
| 96 | : undefined, | ||
| 97 | userdataDir: path.join(input.baseDir, "userdata"), | ||
| 98 | }; | ||
| 99 | return plan; | ||
| 100 | }); | ||
| 101 | |||
| 102 | export const uninstallCommand = Command.make("uninstall", { | ||
| 103 | ...projectLocationFlags, | ||
| 104 | yes: Flag.boolean("yes").pipe( | ||
| 105 | Flag.withAlias("y"), | ||
| 106 | Flag.withDescription( | ||
| 107 | "Remove everything without asking. Required from a script, where there is no prompt.", | ||
| 108 | ), | ||
| 109 | Flag.withDefault(false), | ||
| 110 | ), | ||
| 111 | }).pipe( | ||
| 112 | Command.withDescription( | ||
| 113 | "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.", | ||
| 114 | ), | ||
| 115 | Command.withHandler((flags) => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 118 | const config = yield* resolveCliAuthConfig(flags, logLevel); | ||
| 119 | return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe( | ||
| 120 | Effect.provide(bootServiceLayer(config)), | ||
| 121 | ); | ||
| 122 | }), | ||
| 123 | ), | ||
| 124 | ); | ||
| 125 | |||
| 126 | const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { | ||
| 127 | readonly baseDir: string; | ||
| 128 | readonly assumeYes: boolean; | ||
| 129 | }) { | ||
| 130 | const fs = yield* FileSystem.FileSystem; | ||
| 131 | const platform = yield* HostProcessPlatform; | ||
| 132 | const environment = yield* HostProcessEnvironment; | ||
| 133 | const service = yield* BootService.BootService; | ||
| 134 | const plan = yield* planUninstall({ baseDir: input.baseDir }); | ||
| 135 | |||
| 136 | if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) { | ||
| 137 | yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); | ||
| 138 | if (!(yield* HostProcessIsExecutable)) { | ||
| 139 | yield* Console.log( | ||
| 140 | " This t3 runs from a Node script, so it was installed by npm or built from source. Remove it the same way (`npm uninstall -g t3`, or delete the checkout).", | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | |||
| 146 | yield* Console.log("This will remove:"); | ||
| 147 | if (plan.service) yield* Console.log(" the background service (stopping it first)"); | ||
| 148 | if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`); | ||
| 149 | if (plan.runtimeDir !== undefined) { | ||
| 150 | yield* Console.log(` every downloaded version under ${plan.runtimeDir}`); | ||
| 151 | } | ||
| 152 | yield* Console.log( | ||
| 153 | `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`, | ||
| 154 | ); | ||
| 155 | |||
| 156 | if (!input.assumeYes) { | ||
| 157 | if (!(process.stdin.isTTY && process.stdout.isTTY)) { | ||
| 158 | return yield* new CliUninstallError({ | ||
| 159 | reason: | ||
| 160 | "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.", | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | const confirmed = yield* Prompt.run( | ||
| 164 | Prompt.confirm({ message: "Remove t3 from this machine?", initial: false }), | ||
| 165 | ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); | ||
| 166 | if (!confirmed) { | ||
| 167 | yield* Console.log("Left as is."); | ||
| 168 | return; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | if (plan.service) { | ||
| 173 | yield* service.uninstall; | ||
| 174 | yield* Console.log("Removed the background service."); | ||
| 175 | } | ||
| 176 | if (plan.launcher !== undefined) { | ||
| 177 | yield* fs | ||
| 178 | .remove(plan.launcher, { force: true }) | ||
| 179 | .pipe( | ||
| 180 | Effect.mapError( | ||
| 181 | () => | ||
| 182 | new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }), | ||
| 183 | ), | ||
| 184 | ); | ||
| 185 | yield* Console.log(`Removed ${plan.launcher}.`); | ||
| 186 | } | ||
| 187 | if (plan.runtimeDir !== undefined) { | ||
| 188 | // This process runs from inside runtimeDir. POSIX unlinks a running | ||
| 189 | // executable fine; Windows refuses, so the tree is removed after this | ||
| 190 | // process exits by a detached shell, and the user is told either way. | ||
| 191 | if (platform === "win32") { | ||
| 192 | const runtimeDir = plan.runtimeDir; | ||
| 193 | const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe"; | ||
| 194 | yield* Effect.try({ | ||
| 195 | try: () => { | ||
| 196 | const child = NodeChildProcess.spawn( | ||
| 197 | comspec, | ||
| 198 | ["/d", "/c", `ping -n 3 127.0.0.1 >nul & rmdir /s /q "${runtimeDir}"`], | ||
| 199 | { detached: true, stdio: "ignore", windowsHide: true }, | ||
| 200 | ); | ||
| 201 | child.unref(); | ||
| 202 | }, | ||
| 203 | catch: () => | ||
| 204 | new CliUninstallError({ | ||
| 205 | reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`, | ||
| 206 | }), | ||
| 207 | }); | ||
| 208 | yield* Console.log(`${runtimeDir} will be removed once t3 exits.`); | ||
| 209 | } else { | ||
| 210 | yield* fs | ||
| 211 | .remove(plan.runtimeDir, { recursive: true, force: true }) | ||
| 212 | .pipe( | ||
| 213 | Effect.mapError( | ||
| 214 | () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }), | ||
| 215 | ), | ||
| 216 | ); | ||
| 217 | yield* Console.log(`Removed ${plan.runtimeDir}.`); | ||
| 218 | } | ||
| 219 | } | ||
| 220 | yield* Console.log(""); | ||
| 221 | yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code."); | ||
| 222 | }); | ||
apps/server/src/cli/update.ts+13-5| ... | @@ -99,6 +99,16 @@ const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( | ... | @@ -99,6 +99,16 @@ const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( |
| 99 | return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); | 99 | return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); |
| 100 | }); | 100 | }); |
| 101 | 101 | ||
| 102 | /** Whether a launcher target lives inside `<baseDir>/runtime/versions`. */ | ||
| 103 | export function launcherOwnsVersionsDir( | ||
| 104 | path: Path.Path, | ||
| 105 | versionsDir: string, | ||
| 106 | candidate: string, | ||
| 107 | ): boolean { | ||
| 108 | const relative = path.relative(versionsDir, path.resolve(candidate)); | ||
| 109 | return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); | ||
| 110 | } | ||
| 111 | |||
| 102 | /** | 112 | /** |
| 103 | * The launcher the install scripts leave behind: a symlink at `<bin>/t3` on | 113 | * The launcher the install scripts leave behind: a symlink at `<bin>/t3` on |
| 104 | * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3` | 114 | * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3` |
| ... | @@ -117,10 +127,8 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function | ... | @@ -117,10 +127,8 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function |
| 117 | const path = yield* Path.Path; | 127 | const path = yield* Path.Path; |
| 118 | const platform = yield* HostProcessPlatform; | 128 | const platform = yield* HostProcessPlatform; |
| 119 | if (input.launchedAs === undefined) return Option.none<string>(); | 129 | if (input.launchedAs === undefined) return Option.none<string>(); |
| 120 | const ownsTarget = (candidate: string) => { | 130 | const ownsTarget = (candidate: string) => |
| 121 | const relative = path.relative(input.versionsDir, path.resolve(candidate)); | 131 | launcherOwnsVersionsDir(path, input.versionsDir, candidate); |
| 122 | return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); | ||
| 123 | }; | ||
| 124 | 132 | ||
| 125 | if (platform === "win32") { | 133 | if (platform === "win32") { |
| 126 | // The shim runs the executable by absolute path, so the executable sees | 134 | // The shim runs the executable by absolute path, so the executable sees |
| ... | @@ -189,7 +197,7 @@ export const resolveLauncherPath = Effect.gen(function* () { | ... | @@ -189,7 +197,7 @@ export const resolveLauncherPath = Effect.gen(function* () { |
| 189 | * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the | 197 | * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the |
| 190 | * running executable; that is the launcher the install script wrote. | 198 | * running executable; that is the launcher the install script wrote. |
| 191 | */ | 199 | */ |
| 192 | const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( | 200 | export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( |
| 193 | executablePath: string, | 201 | executablePath: string, |
| 194 | ) { | 202 | ) { |
| 195 | const fs = yield* FileSystem.FileSystem; | 203 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/cloud/pinnedRuntime.ts+5-1| ... | @@ -51,13 +51,17 @@ export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { | ... | @@ -51,13 +51,17 @@ export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { |
| 51 | return { command: paths.entryPath, args: [] }; | 51 | return { command: paths.entryPath, args: [] }; |
| 52 | } | 52 | } |
| 53 | 53 | ||
| 54 | export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string { | ||
| 55 | return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); | ||
| 56 | } | ||
| 57 | |||
| 54 | export function pinnedRuntimePaths( | 58 | export function pinnedRuntimePaths( |
| 55 | path: Path.Path, | 59 | path: Path.Path, |
| 56 | baseDir: string, | 60 | baseDir: string, |
| 57 | version: string, | 61 | version: string, |
| 58 | platform: NodeJS.Platform, | 62 | platform: NodeJS.Platform, |
| 59 | ): PinnedRuntimePaths { | 63 | ): PinnedRuntimePaths { |
| 60 | const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); | 64 | const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version); |
| 61 | return { | 65 | return { |
| 62 | versionDir, | 66 | versionDir, |
| 63 | entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), | 67 | entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), |
docs/user/background-service.md+6| ... | @@ -59,6 +59,12 @@ yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to | ... | @@ -59,6 +59,12 @@ yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to |
| 59 | pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or | 59 | pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or |
| 60 | `--allow-downgrade` to move backwards. | 60 | `--allow-downgrade` to move backwards. |
| 61 | 61 | ||
| 62 | `t3 uninstall` reverses the install script: it shows what it found (the | ||
| 63 | background service, the `t3` launcher, every downloaded version under | ||
| 64 | `~/.t3/runtime`), asks once, and removes them. Your projects, threads, and | ||
| 65 | settings under `~/.t3/userdata` are kept; delete that directory yourself if | ||
| 66 | you want them gone too. Pass `--yes` from a script. | ||
| 67 | |||
| 62 | ## Platform support | 68 | ## Platform support |
| 63 | 69 | ||
| 64 | Linux needs systemd user services. Setup enables lingering so T3 Code starts at | 70 | Linux needs systemd user services. Setup enables lingering so T3 Code starts at |