From 73e87f683ae1455feb663481f1fa588f01b5784b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:42:53 -0700 Subject: [PATCH] fix(desktop): setup prompts name a t3 that runs on desktop installs (#16676) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/app/DesktopCliShim.test.ts | 181 ++++++++++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 179 +++++++++++++++++ .../backend/DesktopBackendConfiguration.ts | 10 +- apps/server/src/cli/invocation.test.ts | 102 ++++++++-- apps/server/src/cli/invocation.ts | 46 ++++- docs/user/remote-access.md | 3 +- 6 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCliShim.test.ts create mode 100644 apps/desktop/src/app/DesktopCliShim.ts diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5a1bb109e4da1520c1ba911a0d6efe806448a36 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -0,0 +1,181 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts"; + +const directories: Array = []; +afterEach(() => { + for (const directory of directories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +const tempRoot = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-")); + directories.push(root); + return root; +}; + +const writeExecutable = (path: string, content: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content, { mode: 0o755 }); +}; + +/** Writes the launcher for `target` at `/bin/t3`, with `/home` as its T3 home. */ +const writeShim = (root: string, target: CliShimTarget) => { + const shim = NodePath.join(root, "bin", "t3"); + writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); + return shim; +}; + +const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = {}) => + NodeChildProcess.spawnSync(shim, args, { + encoding: "utf8", + env: { PATH: process.env.PATH, ...env }, + }); + +/** An app executable that reports its environment and arguments. */ +const REPORTER = + '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; + +/** + * A stand-in AppImage whose image holds the reporter. `--appimage-mount` + * prints the mount and stays up until killed, recording that it was; with + * `canMount` false it fails like a host without FUSE. `--appimage-extract` + * unpacks into ./squashfs-root. + */ +const fakeAppImage = (canMount = true) => { + const root = tempRoot(); + const image = NodePath.join(root, "image"); + writeExecutable(NodePath.join(image, "t3code"), REPORTER); + const appImage = NodePath.join(root, "T3 Code.AppImage"); + writeExecutable( + appImage, + [ + "#!/bin/sh", + `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, + '[ "$1" = --appimage-mount ] || exit 9', + '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', + canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', + `trap 'touch "${root}/unmounted"; exit 0' TERM`, + "while :; do sleep 0.05; done", + "", + ].join("\n"), + ); + const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" }); + return { root, image, appImage, shim }; +}; + +describe("renderCliShim", () => { + it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { + const { root, image, shim } = fakeAppImage(); + const result = run(shim, ["browser", "setup", "a b"]); + expect(result.status).toBe(3); + expect(result.stdout.split("\n")).toEqual([ + `node=1 cli=${shim} home=${root}/home`, + `${image}/resources/app.asar/apps/server/dist/bin.mjs`, + "browser", + "setup", + "a b", + "", + ]); + // The trap kills the mount helper as the launcher exits. + NodeChildProcess.spawnSync("sh", [ + "-c", + `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, + ]); + expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); + }); + + it("extracts the AppImage where it cannot be mounted", () => { + const { shim } = fakeAppImage(false); + // Extract-and-run is ignored too, so the runtime never starts the app. + const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); + expect(result.status).toBe(3); + expect(result.stdout).toMatch( + /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/, + ); + }); + + it("keeps the install's T3 home under sudo, unless one is set", () => { + const root = tempRoot(); + writeExecutable(NodePath.join(root, "app"), REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "app"), + entry: "/bin.mjs", + }); + // sudo clears the environment, so the launcher supplies its own. + expect(run(shim, []).stdout).toContain(`home=${root}/home`); + expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); + // Run by a relative path, it still names itself absolutely. + const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); + expect(relative.stdout).toContain(`cli=${shim}`); + }); + + it("runs an installed app's server directly", () => { + const root = tempRoot(); + const executable = NodePath.join(root, "opt", "T3 Code", "t3code"); + writeExecutable(executable, REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable, + entry: "/opt/T3's/app.asar/bin.mjs", + }); + expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([ + "/opt/T3's/app.asar/bin.mjs", + "browser", + "setup", + "", + ]); + }); + + it("tells a person whose app moved to reopen it", () => { + const root = tempRoot(); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), + entry: "/bin.mjs", + }); + const result = run(shim, ["--version"]); + expect(result.status).toBe(127); + expect(result.stderr).toContain("Open the app once to update this command."); + }); + + it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { + const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; + const script = renderCliShim({ + target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" }, + shimPath: "C:\\Users\\José\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\José\\.t3", + }); + const lines = script.split("\r\n"); + expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion"); + expect(lines).toContain("chcp 65001 >nul"); + expect(lines).toContain( + 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', + ); + // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. + const bare = lines.filter( + (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), + ); + expect(bare).toEqual([]); + expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"'); + }); + + it("switches the Windows console to UTF-8 only when a path needs it", () => { + const ascii = renderCliShim({ + target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, + shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\me\\.t3", + }); + // A Ctrl-C that ends the batch would leave the console switched. + expect(ascii).not.toContain("chcp"); + expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); + }); +}); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7dd42e2bf50a6a861d045f143d477f40ebc7123 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -0,0 +1,179 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// A desktop install puts no `t3` on PATH, so commands the server asks a person +// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small +// launcher for its bundled CLI in the T3 home, which is never on PATH and so +// never shadows another `t3`, and the server names it by absolute path in those +// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, +// so its launcher mounts the AppImage itself instead of pointing into it. +const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); + +const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; + +/** Server entry inside the app, relative to its server root (an asar archive when packaged). */ +const SERVER_ENTRY = "apps/server/dist/bin.mjs"; + +const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; +/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */ +const cmdText = (value: string) => value.replaceAll("%", "%%"); +const cmdWord = (value: string) => `"${cmdText(value)}"`; + +const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; + +export type CliShimTarget = + | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } + | { readonly kind: "direct"; readonly executable: string; readonly entry: string } + | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; + +/** + * The launcher script. Electron runs the server as plain Node with + * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive. + * The launcher's own path and the app's T3 home are written in, so the command + * the server shows is absolute and `sudo`, which clears the environment, still + * runs against this install's home. + */ +export const renderCliShim = (input: { + readonly target: CliShimTarget; + readonly shimPath: string; + readonly t3Home: string; +}) => { + const { target } = input; + if (target.kind === "windows") { + const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) => + [...value].some((character) => character.codePointAt(0)! > 0x7f), + ); + const restore = utf8 ? ["chcp %t3_codepage% >nul"] : []; + return [ + "@echo off", + `rem ${MARKER}`, + // Delayed expansion would rewrite `!name!` in paths and arguments. + "setlocal EnableExtensions DisableDelayedExpansion", + // The file is UTF-8. Only a non-ASCII path needs the console switched to + // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the + // switch is left out for the common all-ASCII install. + ...(utf8 + ? [ + `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, + "chcp 65001 >nul", + ] + : []), + `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, + `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, + 'set "ELECTRON_RUN_AS_NODE=1"', + // A goto, not a parenthesized block: "Program Files (x86)" would close the block early. + `if exist ${cmdWord(target.executable)} goto run`, + ...restore, + `echo ${MOVED} 1>&2`, + "exit /b 127", + ":run", + `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`, + 'set "t3_exit=%ERRORLEVEL%"', + ...restore, + "exit /b %t3_exit%", + "", + ].join("\r\n"); + } + const header = [ + "#!/bin/sh", + `# ${MARKER}`, + `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`, + `home=${shellWord(input.t3Home)}`, + 'export T3CODE_HOME="${T3CODE_HOME:-$home}"', + "export ELECTRON_RUN_AS_NODE=1", + `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`, + 'if [ ! -x "$app" ]; then', + ` echo "${MOVED}" >&2`, + " exit 127", + "fi", + ]; + if (target.kind === "direct") { + return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n"); + } + const run = (root: string) => + `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`; + return [ + ...header, + // Extract-and-run would make the runtime start the app instead of mounting it. + "unset APPIMAGE_EXTRACT_AND_RUN", + // The AppImage runtime prints its mount point, then stays mounted until + // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + "work=$(mktemp -d) || exit 1", + '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', + "mounter=$!", + `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, + "trap 'exit 130' INT TERM", + "mount=", + 'while kill -0 "$mounter" 2>/dev/null; do', + ' mount=$(head -n 1 "$work/mount")', + ' [ -d "$mount" ] && break', + " mount=", + " sleep 0.05", + "done", + // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works. + 'if [ -z "$mount" ]; then', + ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', + ' mount="$work/squashfs-root"', + "fi", + run("$mount"), + "", + ].join("\n"); +}; + +/** + * Writes the packaged app's launcher to `/bin` and returns its path + * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout + * and get none. + */ +export const install = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (!environment.isPackaged) return Option.none(); + const fs = yield* FileSystem.FileSystem; + const path = environment.path; + const windows = environment.platform === "win32"; + const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); + const entry = path.join(environment.serverRoot, SERVER_ENTRY); + const target: CliShimTarget = windows + ? { kind: "windows", executable: process.execPath, entry } + : Option.match(environment.appImagePath, { + onSome: (appImage) => ({ + kind: "appimage" as const, + appImage, + executableName: path.basename(process.execPath), + }), + // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. + onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), + }); + const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); + + return yield* Effect.gen(function* () { + const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); + if (Option.isSome(existing) && !existing.value.includes(MARKER)) { + // Someone else's file; leave it, and let commands fall back to plain `t3`. + yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); + return Option.none(); + } + if (Option.getOrUndefined(existing) !== content) { + yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); + // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. + const staging = `${shimPath}.${process.pid}.tmp`; + yield* fs.writeFileString(staging, content, { mode: 0o755 }); + yield* fs.rename(staging, shimPath); + yield* logInfo("installed t3 launcher", { shimPath }); + } + return Option.some(shimPath); + }).pipe( + // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. + Effect.catchCause((cause) => + logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan("desktop.cliShim.install"), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 289e3fb321f065abf7ac07956e7e15b9c2316d3d..be923b47c5afd3d930fc386233426a1bf77b0008 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import serverPackageJson from "../../../server/package.json" with { type: "json" }; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopCliShim from "../app/DesktopCliShim.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly cliPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + // The server names this launcher in commands it asks a person to run. + T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, @@ -912,7 +916,11 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + const cliPath = yield* DesktopCliShim.install.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 56e4b9c0643471f3c8b2e9494661fb8b1f803aeb..bf2dcba2b9f4725820de9ad83cbe83dd54aa96b3 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; import { HostProcessArguments, + HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, HostProcessPlatform, @@ -102,28 +103,87 @@ it("formats serve suggestions to match the launching command", () => { ); }); -it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => - Effect.gen(function* () { - const command = (node: string, entry: string) => - resolveRootCliCommand("browser setup").pipe( - Effect.provideService(HostProcessExecutablePath, node), - Effect.provideService(HostProcessArguments, [node, entry]), - ); - const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; - // sudo's secure_path already has a system Node. - expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup"); - // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. - expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe( - 'sudo env "PATH=$PATH" npx t3 browser setup', +it.layer(NodeServices.layer)("root CLI commands", (it) => { + /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */ + const rootCommand = (input: { + readonly node: string; + readonly entry: string; + readonly path?: string; + readonly env?: Record; + readonly executable?: boolean; + }) => + resolveRootCliCommand("browser setup").pipe( + Effect.provideService(HostProcessExecutablePath, input.node), + Effect.provideService(HostProcessArguments, [input.node, input.entry]), + Effect.provideService(HostProcessIsExecutable, input.executable ?? false), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { PATH: input.path ?? "", ...input.env }), ); - expect( - yield* command( - "/home/theo/.local/node/bin/node", - "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", - ), - ).toBe('sudo env "PATH=$PATH" t3 browser setup'); - }), -); + + /** A directory holding an executable `t3`, to stand in for one on PATH. */ + const pathWithT3 = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bin = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + return bin; + }); + + it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => + Effect.gen(function* () { + const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; + // sudo's secure_path already has a system Node. + expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe( + "sudo npx t3 browser setup", + ); + // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. + expect( + yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }), + ).toBe('sudo env "PATH=$PATH" npx t3 browser setup'); + expect( + yield* rootCommand({ + node: "/home/theo/.local/node/bin/node", + entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", + path: yield* pathWithT3, + }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + }).pipe(Effect.scoped), + ); + + it.effect("names this install's launcher when t3 is not on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const shim = path.join(home, ".local bin", "t3"); + yield* fs.makeDirectory(path.dirname(shim), { recursive: true }); + yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 }); + const desktop = { + node: "/tmp/.mount_T3abc/t3code", + entry: "/tmp/.mount_T3abc/resources/app.asar/apps/server/dist/bin.mjs", + env: { T3CODE_CLI_PATH: shim }, + }; + // The desktop app's shim, quoted for the shell and run as root as is. + expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); + // A `t3` the person put on PATH still wins. + expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( + 'sudo env "PATH=$PATH" t3 browser setup', + ); + // A standalone binary names itself. + expect( + yield* rootCommand({ + node: "/opt/t3/t3", + entry: "/opt/t3/t3", + executable: true, + }), + ).toBe("sudo /opt/t3/t3 browser setup"); + // A stale shim path, then no launcher at all, fall back to plain `t3`. + expect( + yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + }).pipe(Effect.scoped), + ); +}); it.layer(NodeServices.layer)("manual server installation ownership", (it) => { it.effect("recognizes runner caches for both script and executable packages", () => diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index cbfc4dff8b19a2ebd984276107bd8b35bd5dc0de..59843abd7289d2ac21a7a7128ff8f9e602f43947 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,15 +1,18 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { ServerInstallation } from "@t3tools/contracts"; import { HostProcessArguments, + HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; @@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) => }), ); +/** Quotes `value` for a POSIX shell when it needs it. */ +const shellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`; + +/** + * The launcher a person can type to run this install when `t3` is not on + * PATH: the desktop app's `t3` shim, which the app and the shim itself name in + * `T3CODE_CLI_PATH`, or a standalone binary's own path. Script installs (a + * repo checkout) have no single launcher and keep plain `t3`. + */ +const resolveInstallLauncher = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const shim = (yield* HostProcessEnvironment).T3CODE_CLI_PATH?.trim(); + if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) { + return Option.some(shim); + } + return (yield* HostProcessIsExecutable) + ? Option.some(yield* HostProcessExecutablePath) + : Option.none(); +}); + +/** + * `t3 ` for a person to run on this host: `t3` when it is on PATH, + * the package runner this process came from, or else the absolute path of the + * launcher for this install, such as the one the desktop app installs. + */ +const resolveHostCliCommand = (subcommand: string) => + Effect.gen(function* () { + const command = yield* resolveCliCommand(subcommand); + if (command !== `t3 ${subcommand}`) return { command, launcher: false }; + if (yield* isCommandAvailable("t3")) return { command, launcher: false }; + const launcher = yield* resolveInstallLauncher; + return Option.isSome(launcher) + ? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true } + : { command, launcher: false }; + }); + /** * `t3 ` as root, for setup a person runs once on the host. `sudo` * resets PATH on most distributions, which drops a user-installed Node (nvm, * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries - * PATH through unless Node is on root's PATH too. + * PATH through unless Node is on root's PATH too. An absolute launcher needs + * neither. */ export const resolveRootCliCommand = (subcommand: string) => Effect.gen(function* () { - const command = yield* resolveCliCommand(subcommand); + const { command, launcher } = yield* resolveHostCliCommand(subcommand); + if (launcher) return `sudo ${command}`; const executablePath = yield* HostProcessExecutablePath; const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => executablePath.startsWith(`${directory}/`), diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 491470bfa2f07c4eca7a38e81b97d6e9a29899ec..87c8ee9bea672a050a2f462eb7700e1b16c6e8cd 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -213,7 +213,8 @@ sudo t3 browser setup The server shows the exact line for how you started it, such as `sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only -for your user. It allows Chrome's sandbox with an AppArmor profile and installs +for your user. Where `t3` is not on your `PATH`, such as with only the +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 any missing libraries with apt. It is safe to run again. Without `sudo`, it only reports what it would change. -- 2.54.0