| author | |
| committer | |
| log | 73e87f683ae1455feb663481f1fa588f01b5784b |
| tree | 889b2f20bb4cd11459ec0f45076ccd256b839a20 |
| parent | 9f48092ec95dd9eab78931601de95be0107a930c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>6 files changed, 496 insertions(+), 25 deletions(-)
apps/desktop/src/app/DesktopCliShim.test.ts created+181| ... | @@ -0,0 +1,181 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell. | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeOS from "node:os"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | |||
| 7 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts"; | ||
| 10 | |||
| 11 | const directories: Array<string> = []; | ||
| 12 | afterEach(() => { | ||
| 13 | for (const directory of directories.splice(0)) { | ||
| 14 | NodeFS.rmSync(directory, { recursive: true, force: true }); | ||
| 15 | } | ||
| 16 | }); | ||
| 17 | |||
| 18 | const tempRoot = () => { | ||
| 19 | const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-")); | ||
| 20 | directories.push(root); | ||
| 21 | return root; | ||
| 22 | }; | ||
| 23 | |||
| 24 | const writeExecutable = (path: string, content: string) => { | ||
| 25 | NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); | ||
| 26 | NodeFS.writeFileSync(path, content, { mode: 0o755 }); | ||
| 27 | }; | ||
| 28 | |||
| 29 | /** Writes the launcher for `target` at `<root>/bin/t3`, with `<root>/home` as its T3 home. */ | ||
| 30 | const writeShim = (root: string, target: CliShimTarget) => { | ||
| 31 | const shim = NodePath.join(root, "bin", "t3"); | ||
| 32 | writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); | ||
| 33 | return shim; | ||
| 34 | }; | ||
| 35 | |||
| 36 | const run = (shim: string, args: ReadonlyArray<string>, env: NodeJS.ProcessEnv = {}) => | ||
| 37 | NodeChildProcess.spawnSync(shim, args, { | ||
| 38 | encoding: "utf8", | ||
| 39 | env: { PATH: process.env.PATH, ...env }, | ||
| 40 | }); | ||
| 41 | |||
| 42 | /** An app executable that reports its environment and arguments. */ | ||
| 43 | const REPORTER = | ||
| 44 | '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; | ||
| 45 | |||
| 46 | /** | ||
| 47 | * A stand-in AppImage whose image holds the reporter. `--appimage-mount` | ||
| 48 | * prints the mount and stays up until killed, recording that it was; with | ||
| 49 | * `canMount` false it fails like a host without FUSE. `--appimage-extract` | ||
| 50 | * unpacks into ./squashfs-root. | ||
| 51 | */ | ||
| 52 | const fakeAppImage = (canMount = true) => { | ||
| 53 | const root = tempRoot(); | ||
| 54 | const image = NodePath.join(root, "image"); | ||
| 55 | writeExecutable(NodePath.join(image, "t3code"), REPORTER); | ||
| 56 | const appImage = NodePath.join(root, "T3 Code.AppImage"); | ||
| 57 | writeExecutable( | ||
| 58 | appImage, | ||
| 59 | [ | ||
| 60 | "#!/bin/sh", | ||
| 61 | `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, | ||
| 62 | '[ "$1" = --appimage-mount ] || exit 9', | ||
| 63 | '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', | ||
| 64 | canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', | ||
| 65 | `trap 'touch "${root}/unmounted"; exit 0' TERM`, | ||
| 66 | "while :; do sleep 0.05; done", | ||
| 67 | "", | ||
| 68 | ].join("\n"), | ||
| 69 | ); | ||
| 70 | const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" }); | ||
| 71 | return { root, image, appImage, shim }; | ||
| 72 | }; | ||
| 73 | |||
| 74 | describe("renderCliShim", () => { | ||
| 75 | it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { | ||
| 76 | const { root, image, shim } = fakeAppImage(); | ||
| 77 | const result = run(shim, ["browser", "setup", "a b"]); | ||
| 78 | expect(result.status).toBe(3); | ||
| 79 | expect(result.stdout.split("\n")).toEqual([ | ||
| 80 | `node=1 cli=${shim} home=${root}/home`, | ||
| 81 | `${image}/resources/app.asar/apps/server/dist/bin.mjs`, | ||
| 82 | "browser", | ||
| 83 | "setup", | ||
| 84 | "a b", | ||
| 85 | "", | ||
| 86 | ]); | ||
| 87 | // The trap kills the mount helper as the launcher exits. | ||
| 88 | NodeChildProcess.spawnSync("sh", [ | ||
| 89 | "-c", | ||
| 90 | `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, | ||
| 91 | ]); | ||
| 92 | expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("extracts the AppImage where it cannot be mounted", () => { | ||
| 96 | const { shim } = fakeAppImage(false); | ||
| 97 | // Extract-and-run is ignored too, so the runtime never starts the app. | ||
| 98 | const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); | ||
| 99 | expect(result.status).toBe(3); | ||
| 100 | expect(result.stdout).toMatch( | ||
| 101 | /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/, | ||
| 102 | ); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("keeps the install's T3 home under sudo, unless one is set", () => { | ||
| 106 | const root = tempRoot(); | ||
| 107 | writeExecutable(NodePath.join(root, "app"), REPORTER); | ||
| 108 | const shim = writeShim(root, { | ||
| 109 | kind: "direct", | ||
| 110 | executable: NodePath.join(root, "app"), | ||
| 111 | entry: "/bin.mjs", | ||
| 112 | }); | ||
| 113 | // sudo clears the environment, so the launcher supplies its own. | ||
| 114 | expect(run(shim, []).stdout).toContain(`home=${root}/home`); | ||
| 115 | expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); | ||
| 116 | // Run by a relative path, it still names itself absolutely. | ||
| 117 | const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); | ||
| 118 | expect(relative.stdout).toContain(`cli=${shim}`); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("runs an installed app's server directly", () => { | ||
| 122 | const root = tempRoot(); | ||
| 123 | const executable = NodePath.join(root, "opt", "T3 Code", "t3code"); | ||
| 124 | writeExecutable(executable, REPORTER); | ||
| 125 | const shim = writeShim(root, { | ||
| 126 | kind: "direct", | ||
| 127 | executable, | ||
| 128 | entry: "/opt/T3's/app.asar/bin.mjs", | ||
| 129 | }); | ||
| 130 | expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([ | ||
| 131 | "/opt/T3's/app.asar/bin.mjs", | ||
| 132 | "browser", | ||
| 133 | "setup", | ||
| 134 | "", | ||
| 135 | ]); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("tells a person whose app moved to reopen it", () => { | ||
| 139 | const root = tempRoot(); | ||
| 140 | const shim = writeShim(root, { | ||
| 141 | kind: "direct", | ||
| 142 | executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), | ||
| 143 | entry: "/bin.mjs", | ||
| 144 | }); | ||
| 145 | const result = run(shim, ["--version"]); | ||
| 146 | expect(result.status).toBe(127); | ||
| 147 | expect(result.stderr).toContain("Open the app once to update this command."); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { | ||
| 151 | const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; | ||
| 152 | const script = renderCliShim({ | ||
| 153 | target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" }, | ||
| 154 | shimPath: "C:\\Users\\José\\.t3\\bin\\t3.cmd", | ||
| 155 | t3Home: "C:\\Users\\José\\.t3", | ||
| 156 | }); | ||
| 157 | const lines = script.split("\r\n"); | ||
| 158 | expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion"); | ||
| 159 | expect(lines).toContain("chcp 65001 >nul"); | ||
| 160 | expect(lines).toContain( | ||
| 161 | 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', | ||
| 162 | ); | ||
| 163 | // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. | ||
| 164 | const bare = lines.filter( | ||
| 165 | (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), | ||
| 166 | ); | ||
| 167 | expect(bare).toEqual([]); | ||
| 168 | expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"'); | ||
| 169 | }); | ||
| 170 | |||
| 171 | it("switches the Windows console to UTF-8 only when a path needs it", () => { | ||
| 172 | const ascii = renderCliShim({ | ||
| 173 | target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, | ||
| 174 | shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", | ||
| 175 | t3Home: "C:\\Users\\me\\.t3", | ||
| 176 | }); | ||
| 177 | // A Ctrl-C that ends the batch would leave the console switched. | ||
| 178 | expect(ascii).not.toContain("chcp"); | ||
| 179 | expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); | ||
| 180 | }); | ||
| 181 | }); | ||
apps/desktop/src/app/DesktopCliShim.ts created+179| ... | @@ -0,0 +1,179 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | |||
| 6 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 7 | import { makeComponentLogger } from "./DesktopObservability.ts"; | ||
| 8 | |||
| 9 | // A desktop install puts no `t3` on PATH, so commands the server asks a person | ||
| 10 | // to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small | ||
| 11 | // launcher for its bundled CLI in the T3 home, which is never on PATH and so | ||
| 12 | // never shadows another `t3`, and the server names it by absolute path in those | ||
| 13 | // commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, | ||
| 14 | // so its launcher mounts the AppImage itself instead of pointing into it. | ||
| 15 | const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); | ||
| 16 | |||
| 17 | const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; | ||
| 18 | |||
| 19 | /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ | ||
| 20 | const SERVER_ENTRY = "apps/server/dist/bin.mjs"; | ||
| 21 | |||
| 22 | const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 23 | /** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */ | ||
| 24 | const cmdText = (value: string) => value.replaceAll("%", "%%"); | ||
| 25 | const cmdWord = (value: string) => `"${cmdText(value)}"`; | ||
| 26 | |||
| 27 | const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; | ||
| 28 | |||
| 29 | export type CliShimTarget = | ||
| 30 | | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } | ||
| 31 | | { readonly kind: "direct"; readonly executable: string; readonly entry: string } | ||
| 32 | | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; | ||
| 33 | |||
| 34 | /** | ||
| 35 | * The launcher script. Electron runs the server as plain Node with | ||
| 36 | * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive. | ||
| 37 | * The launcher's own path and the app's T3 home are written in, so the command | ||
| 38 | * the server shows is absolute and `sudo`, which clears the environment, still | ||
| 39 | * runs against this install's home. | ||
| 40 | */ | ||
| 41 | export const renderCliShim = (input: { | ||
| 42 | readonly target: CliShimTarget; | ||
| 43 | readonly shimPath: string; | ||
| 44 | readonly t3Home: string; | ||
| 45 | }) => { | ||
| 46 | const { target } = input; | ||
| 47 | if (target.kind === "windows") { | ||
| 48 | const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) => | ||
| 49 | [...value].some((character) => character.codePointAt(0)! > 0x7f), | ||
| 50 | ); | ||
| 51 | const restore = utf8 ? ["chcp %t3_codepage% >nul"] : []; | ||
| 52 | return [ | ||
| 53 | "@echo off", | ||
| 54 | `rem ${MARKER}`, | ||
| 55 | // Delayed expansion would rewrite `!name!` in paths and arguments. | ||
| 56 | "setlocal EnableExtensions DisableDelayedExpansion", | ||
| 57 | // The file is UTF-8. Only a non-ASCII path needs the console switched to | ||
| 58 | // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the | ||
| 59 | // switch is left out for the common all-ASCII install. | ||
| 60 | ...(utf8 | ||
| 61 | ? [ | ||
| 62 | `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, | ||
| 63 | "chcp 65001 >nul", | ||
| 64 | ] | ||
| 65 | : []), | ||
| 66 | `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, | ||
| 67 | `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, | ||
| 68 | 'set "ELECTRON_RUN_AS_NODE=1"', | ||
| 69 | // A goto, not a parenthesized block: "Program Files (x86)" would close the block early. | ||
| 70 | `if exist ${cmdWord(target.executable)} goto run`, | ||
| 71 | ...restore, | ||
| 72 | `echo ${MOVED} 1>&2`, | ||
| 73 | "exit /b 127", | ||
| 74 | ":run", | ||
| 75 | `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`, | ||
| 76 | 'set "t3_exit=%ERRORLEVEL%"', | ||
| 77 | ...restore, | ||
| 78 | "exit /b %t3_exit%", | ||
| 79 | "", | ||
| 80 | ].join("\r\n"); | ||
| 81 | } | ||
| 82 | const header = [ | ||
| 83 | "#!/bin/sh", | ||
| 84 | `# ${MARKER}`, | ||
| 85 | `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`, | ||
| 86 | `home=${shellWord(input.t3Home)}`, | ||
| 87 | 'export T3CODE_HOME="${T3CODE_HOME:-$home}"', | ||
| 88 | "export ELECTRON_RUN_AS_NODE=1", | ||
| 89 | `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`, | ||
| 90 | 'if [ ! -x "$app" ]; then', | ||
| 91 | ` echo "${MOVED}" >&2`, | ||
| 92 | " exit 127", | ||
| 93 | "fi", | ||
| 94 | ]; | ||
| 95 | if (target.kind === "direct") { | ||
| 96 | return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n"); | ||
| 97 | } | ||
| 98 | const run = (root: string) => | ||
| 99 | `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`; | ||
| 100 | return [ | ||
| 101 | ...header, | ||
| 102 | // Extract-and-run would make the runtime start the app instead of mounting it. | ||
| 103 | "unset APPIMAGE_EXTRACT_AND_RUN", | ||
| 104 | // The AppImage runtime prints its mount point, then stays mounted until | ||
| 105 | // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. | ||
| 106 | "work=$(mktemp -d) || exit 1", | ||
| 107 | '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', | ||
| 108 | "mounter=$!", | ||
| 109 | `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, | ||
| 110 | "trap 'exit 130' INT TERM", | ||
| 111 | "mount=", | ||
| 112 | 'while kill -0 "$mounter" 2>/dev/null; do', | ||
| 113 | ' mount=$(head -n 1 "$work/mount")', | ||
| 114 | ' [ -d "$mount" ] && break', | ||
| 115 | " mount=", | ||
| 116 | " sleep 0.05", | ||
| 117 | "done", | ||
| 118 | // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works. | ||
| 119 | 'if [ -z "$mount" ]; then', | ||
| 120 | ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', | ||
| 121 | ' mount="$work/squashfs-root"', | ||
| 122 | "fi", | ||
| 123 | run("$mount"), | ||
| 124 | "", | ||
| 125 | ].join("\n"); | ||
| 126 | }; | ||
| 127 | |||
| 128 | /** | ||
| 129 | * Writes the packaged app's launcher to `<T3 home>/bin` and returns its path | ||
| 130 | * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout | ||
| 131 | * and get none. | ||
| 132 | */ | ||
| 133 | export const install = Effect.gen(function* () { | ||
| 134 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 135 | if (!environment.isPackaged) return Option.none<string>(); | ||
| 136 | const fs = yield* FileSystem.FileSystem; | ||
| 137 | const path = environment.path; | ||
| 138 | const windows = environment.platform === "win32"; | ||
| 139 | const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); | ||
| 140 | const entry = path.join(environment.serverRoot, SERVER_ENTRY); | ||
| 141 | const target: CliShimTarget = windows | ||
| 142 | ? { kind: "windows", executable: process.execPath, entry } | ||
| 143 | : Option.match(environment.appImagePath, { | ||
| 144 | onSome: (appImage) => ({ | ||
| 145 | kind: "appimage" as const, | ||
| 146 | appImage, | ||
| 147 | executableName: path.basename(process.execPath), | ||
| 148 | }), | ||
| 149 | // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. | ||
| 150 | onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), | ||
| 151 | }); | ||
| 152 | const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); | ||
| 153 | |||
| 154 | return yield* Effect.gen(function* () { | ||
| 155 | const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); | ||
| 156 | if (Option.isSome(existing) && !existing.value.includes(MARKER)) { | ||
| 157 | // Someone else's file; leave it, and let commands fall back to plain `t3`. | ||
| 158 | yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); | ||
| 159 | return Option.none<string>(); | ||
| 160 | } | ||
| 161 | if (Option.getOrUndefined(existing) !== content) { | ||
| 162 | yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); | ||
| 163 | // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. | ||
| 164 | const staging = `${shimPath}.${process.pid}.tmp`; | ||
| 165 | yield* fs.writeFileString(staging, content, { mode: 0o755 }); | ||
| 166 | yield* fs.rename(staging, shimPath); | ||
| 167 | yield* logInfo("installed t3 launcher", { shimPath }); | ||
| 168 | } | ||
| 169 | return Option.some(shimPath); | ||
| 170 | }).pipe( | ||
| 171 | // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. | ||
| 172 | Effect.catchCause((cause) => | ||
| 173 | logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( | ||
| 174 | Effect.as(Option.none<string>()), | ||
| 175 | ), | ||
| 176 | ), | ||
| 177 | Effect.withSpan("desktop.cliShim.install"), | ||
| 178 | ); | ||
| 179 | }); | ||
apps/desktop/src/backend/DesktopBackendConfiguration.ts+9-1| ... | @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; | ... | @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 17 | import serverPackageJson from "../../../server/package.json" with { type: "json" }; | 17 | import serverPackageJson from "../../../server/package.json" with { type: "json" }; |
| 18 | 18 | ||
| 19 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 19 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 20 | import * as DesktopCliShim from "../app/DesktopCliShim.ts"; | ||
| 20 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 21 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 21 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | 22 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 22 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 23 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| ... | @@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv | ... | @@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 547 | function* ( | 548 | function* ( |
| 548 | input: SharedBootstrapInput & { | 549 | input: SharedBootstrapInput & { |
| 549 | readonly resourceMonitorPath: Option.Option<string>; | 550 | readonly resourceMonitorPath: Option.Option<string>; |
| 551 | readonly cliPath: Option.Option<string>; | ||
| 550 | }, | 552 | }, |
| 551 | ): Effect.fn.Return< | 553 | ): Effect.fn.Return< |
| 552 | DesktopBackendManager.DesktopBackendStartConfig, | 554 | DesktopBackendManager.DesktopBackendStartConfig, |
| ... | @@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv | ... | @@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 595 | env: { | 597 | env: { |
| 596 | ...backendChildEnvPatch(), | 598 | ...backendChildEnvPatch(), |
| 597 | ELECTRON_RUN_AS_NODE: "1", | 599 | ELECTRON_RUN_AS_NODE: "1", |
| 600 | // The server names this launcher in commands it asks a person to run. | ||
| 601 | T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), | ||
| 598 | }, | 602 | }, |
| 599 | // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). | 603 | // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). |
| 600 | extendEnv: true, | 604 | extendEnv: true, |
| ... | @@ -912,7 +916,11 @@ export const make = Effect.gen(function* () { | ... | @@ -912,7 +916,11 @@ export const make = Effect.gen(function* () { |
| 912 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 916 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 913 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 917 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 914 | ); | 918 | ); |
| 915 | return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( | 919 | const cliPath = yield* DesktopCliShim.install.pipe( |
| 920 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 921 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | ||
| 922 | ); | ||
| 923 | return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( | ||
| 916 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 924 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), |
| 917 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | 925 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), |
| 918 | ); | 926 | ); |
apps/server/src/cli/invocation.test.ts+81-21| ... | @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, expect, it } from "@effect/vitest"; | 2 | import { assert, expect, it } from "@effect/vitest"; |
| 3 | import { | 3 | import { |
| 4 | HostProcessArguments, | 4 | HostProcessArguments, |
| 5 | HostProcessEnvironment, | ||
| 5 | HostProcessExecutablePath, | 6 | HostProcessExecutablePath, |
| 6 | HostProcessIsExecutable, | 7 | HostProcessIsExecutable, |
| 7 | HostProcessPlatform, | 8 | HostProcessPlatform, |
| ... | @@ -102,28 +103,87 @@ it("formats serve suggestions to match the launching command", () => { | ... | @@ -102,28 +103,87 @@ it("formats serve suggestions to match the launching command", () => { |
| 102 | ); | 103 | ); |
| 103 | }); | 104 | }); |
| 104 | 105 | ||
| 105 | it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => | 106 | it.layer(NodeServices.layer)("root CLI commands", (it) => { |
| 106 | Effect.gen(function* () { | 107 | /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */ |
| 107 | const command = (node: string, entry: string) => | 108 | const rootCommand = (input: { |
| 108 | resolveRootCliCommand("browser setup").pipe( | 109 | readonly node: string; |
| 109 | Effect.provideService(HostProcessExecutablePath, node), | 110 | readonly entry: string; |
| 110 | Effect.provideService(HostProcessArguments, [node, entry]), | 111 | readonly path?: string; |
| 111 | ); | 112 | readonly env?: Record<string, string>; |
| 112 | const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; | 113 | readonly executable?: boolean; |
| 113 | // sudo's secure_path already has a system Node. | 114 | }) => |
| 114 | expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup"); | 115 | resolveRootCliCommand("browser setup").pipe( |
| 115 | // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. | 116 | Effect.provideService(HostProcessExecutablePath, input.node), |
| 116 | expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe( | 117 | Effect.provideService(HostProcessArguments, [input.node, input.entry]), |
| 117 | 'sudo env "PATH=$PATH" npx t3 browser setup', | 118 | Effect.provideService(HostProcessIsExecutable, input.executable ?? false), |
| 119 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 120 | Effect.provideService(HostProcessEnvironment, { PATH: input.path ?? "", ...input.env }), | ||
| 118 | ); | 121 | ); |
| 119 | expect( | 122 | |
| 120 | yield* command( | 123 | /** A directory holding an executable `t3`, to stand in for one on PATH. */ |
| 121 | "/home/theo/.local/node/bin/node", | 124 | const pathWithT3 = Effect.gen(function* () { |
| 122 | "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", | 125 | const fs = yield* FileSystem.FileSystem; |
| 123 | ), | 126 | const path = yield* Path.Path; |
| 124 | ).toBe('sudo env "PATH=$PATH" t3 browser setup'); | 127 | const bin = yield* fs.makeTempDirectoryScoped(); |
| 125 | }), | 128 | yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 }); |
| 126 | ); | 129 | return bin; |
| 130 | }); | ||
| 131 | |||
| 132 | it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; | ||
| 135 | // sudo's secure_path already has a system Node. | ||
| 136 | expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe( | ||
| 137 | "sudo npx t3 browser setup", | ||
| 138 | ); | ||
| 139 | // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. | ||
| 140 | expect( | ||
| 141 | yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }), | ||
| 142 | ).toBe('sudo env "PATH=$PATH" npx t3 browser setup'); | ||
| 143 | expect( | ||
| 144 | yield* rootCommand({ | ||
| 145 | node: "/home/theo/.local/node/bin/node", | ||
| 146 | entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", | ||
| 147 | path: yield* pathWithT3, | ||
| 148 | }), | ||
| 149 | ).toBe('sudo env "PATH=$PATH" t3 browser setup'); | ||
| 150 | }).pipe(Effect.scoped), | ||
| 151 | ); | ||
| 152 | |||
| 153 | it.effect("names this install's launcher when t3 is not on PATH", () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const fs = yield* FileSystem.FileSystem; | ||
| 156 | const path = yield* Path.Path; | ||
| 157 | const home = yield* fs.makeTempDirectoryScoped(); | ||
| 158 | const shim = path.join(home, ".local bin", "t3"); | ||
| 159 | yield* fs.makeDirectory(path.dirname(shim), { recursive: true }); | ||
| 160 | yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 }); | ||
| 161 | const desktop = { | ||
| 162 | node: "/tmp/.mount_T3abc/t3code", | ||
| 163 | entry: "/tmp/.mount_T3abc/resources/app.asar/apps/server/dist/bin.mjs", | ||
| 164 | env: { T3CODE_CLI_PATH: shim }, | ||
| 165 | }; | ||
| 166 | // The desktop app's shim, quoted for the shell and run as root as is. | ||
| 167 | expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); | ||
| 168 | // A `t3` the person put on PATH still wins. | ||
| 169 | expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( | ||
| 170 | 'sudo env "PATH=$PATH" t3 browser setup', | ||
| 171 | ); | ||
| 172 | // A standalone binary names itself. | ||
| 173 | expect( | ||
| 174 | yield* rootCommand({ | ||
| 175 | node: "/opt/t3/t3", | ||
| 176 | entry: "/opt/t3/t3", | ||
| 177 | executable: true, | ||
| 178 | }), | ||
| 179 | ).toBe("sudo /opt/t3/t3 browser setup"); | ||
| 180 | // A stale shim path, then no launcher at all, fall back to plain `t3`. | ||
| 181 | expect( | ||
| 182 | yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), | ||
| 183 | ).toBe('sudo env "PATH=$PATH" t3 browser setup'); | ||
| 184 | }).pipe(Effect.scoped), | ||
| 185 | ); | ||
| 186 | }); | ||
| 127 | 187 | ||
| 128 | it.layer(NodeServices.layer)("manual server installation ownership", (it) => { | 188 | it.layer(NodeServices.layer)("manual server installation ownership", (it) => { |
| 129 | it.effect("recognizes runner caches for both script and executable packages", () => | 189 | it.effect("recognizes runner caches for both script and executable packages", () => |
apps/server/src/cli/invocation.ts+44-2| ... | @@ -1,15 +1,18 @@ | ... | @@ -1,15 +1,18 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; | 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Option from "effect/Option"; | ||
| 3 | import * as Path from "effect/Path"; | 4 | import * as Path from "effect/Path"; |
| 4 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 5 | 6 | ||
| 6 | import type { ServerInstallation } from "@t3tools/contracts"; | 7 | import type { ServerInstallation } from "@t3tools/contracts"; |
| 7 | import { | 8 | import { |
| 8 | HostProcessArguments, | 9 | HostProcessArguments, |
| 10 | HostProcessEnvironment, | ||
| 9 | HostProcessExecutablePath, | 11 | HostProcessExecutablePath, |
| 10 | HostProcessIsExecutable, | 12 | HostProcessIsExecutable, |
| 11 | HostProcessPlatform, | 13 | HostProcessPlatform, |
| 12 | } from "@t3tools/shared/hostProcess"; | 14 | } from "@t3tools/shared/hostProcess"; |
| 15 | import { isCommandAvailable } from "@t3tools/shared/shell"; | ||
| 13 | 16 | ||
| 14 | import packageJson from "../../package.json" with { type: "json" }; | 17 | import packageJson from "../../package.json" with { type: "json" }; |
| 15 | 18 | ||
| ... | @@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) => | ... | @@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) => |
| 146 | }), | 149 | }), |
| 147 | ); | 150 | ); |
| 148 | 151 | ||
| 152 | /** Quotes `value` for a POSIX shell when it needs it. */ | ||
| 153 | const shellWord = (value: string) => | ||
| 154 | /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 155 | |||
| 156 | /** | ||
| 157 | * The launcher a person can type to run this install when `t3` is not on | ||
| 158 | * PATH: the desktop app's `t3` shim, which the app and the shim itself name in | ||
| 159 | * `T3CODE_CLI_PATH`, or a standalone binary's own path. Script installs (a | ||
| 160 | * repo checkout) have no single launcher and keep plain `t3`. | ||
| 161 | */ | ||
| 162 | const resolveInstallLauncher = Effect.gen(function* () { | ||
| 163 | const fs = yield* FileSystem.FileSystem; | ||
| 164 | const shim = (yield* HostProcessEnvironment).T3CODE_CLI_PATH?.trim(); | ||
| 165 | if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) { | ||
| 166 | return Option.some(shim); | ||
| 167 | } | ||
| 168 | return (yield* HostProcessIsExecutable) | ||
| 169 | ? Option.some(yield* HostProcessExecutablePath) | ||
| 170 | : Option.none<string>(); | ||
| 171 | }); | ||
| 172 | |||
| 173 | /** | ||
| 174 | * `t3 <subcommand>` for a person to run on this host: `t3` when it is on PATH, | ||
| 175 | * the package runner this process came from, or else the absolute path of the | ||
| 176 | * launcher for this install, such as the one the desktop app installs. | ||
| 177 | */ | ||
| 178 | const resolveHostCliCommand = (subcommand: string) => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const command = yield* resolveCliCommand(subcommand); | ||
| 181 | if (command !== `t3 ${subcommand}`) return { command, launcher: false }; | ||
| 182 | if (yield* isCommandAvailable("t3")) return { command, launcher: false }; | ||
| 183 | const launcher = yield* resolveInstallLauncher; | ||
| 184 | return Option.isSome(launcher) | ||
| 185 | ? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true } | ||
| 186 | : { command, launcher: false }; | ||
| 187 | }); | ||
| 188 | |||
| 149 | /** | 189 | /** |
| 150 | * `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo` | 190 | * `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo` |
| 151 | * resets PATH on most distributions, which drops a user-installed Node (nvm, | 191 | * resets PATH on most distributions, which drops a user-installed Node (nvm, |
| 152 | * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries | 192 | * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries |
| 153 | * PATH through unless Node is on root's PATH too. | 193 | * PATH through unless Node is on root's PATH too. An absolute launcher needs |
| 194 | * neither. | ||
| 154 | */ | 195 | */ |
| 155 | export const resolveRootCliCommand = (subcommand: string) => | 196 | export const resolveRootCliCommand = (subcommand: string) => |
| 156 | Effect.gen(function* () { | 197 | Effect.gen(function* () { |
| 157 | const command = yield* resolveCliCommand(subcommand); | 198 | const { command, launcher } = yield* resolveHostCliCommand(subcommand); |
| 199 | if (launcher) return `sudo ${command}`; | ||
| 158 | const executablePath = yield* HostProcessExecutablePath; | 200 | const executablePath = yield* HostProcessExecutablePath; |
| 159 | const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => | 201 | const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => |
| 160 | executablePath.startsWith(`${directory}/`), | 202 | executablePath.startsWith(`${directory}/`), |
docs/user/remote-access.md+2-1| ... | @@ -213,7 +213,8 @@ sudo t3 browser setup | ... | @@ -213,7 +213,8 @@ sudo t3 browser setup |
| 213 | 213 | ||
| 214 | The server shows the exact line for how you started it, such as | 214 | The server shows the exact line for how you started it, such as |
| 215 | `sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only | 215 | `sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only |
| 216 | for your user. It allows Chrome's sandbox with an AppArmor profile and installs | 216 | for your user. Where `t3` is not on your `PATH`, such as with only the |
| 217 | desktop app installed, it names the full path of the app's own `t3` instead. It allows Chrome's sandbox with an AppArmor profile and installs | ||
| 217 | any missing libraries with apt. It is safe to run again. Without `sudo`, it | 218 | any missing libraries with apt. It is safe to run again. Without `sudo`, it |
| 218 | only reports what it would change. | 219 | only reports what it would change. |
| 219 | 220 |