authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 12:42:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 12:42:53-07:00
log73e87f683ae1455feb663481f1fa588f01b5784b
tree889b2f20bb4cd11459ec0f45076ccd256b839a20
parent9f48092ec95dd9eab78931601de95be0107a930c
signature Signed by PGP key B5690EEEBB952194

fix(desktop): setup prompts name a t3 that runs on desktop installs (#16676)

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.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import { afterEach, describe, expect, it } from "vite-plus/test";
8
9import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts";
10
11const directories: Array<string> = [];
12afterEach(() => {
13 for (const directory of directories.splice(0)) {
14 NodeFS.rmSync(directory, { recursive: true, force: true });
15 }
16});
17
18const tempRoot = () => {
19 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-"));
20 directories.push(root);
21 return root;
22};
23
24const 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. */
30const 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
36const 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. */
43const 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 */
52const 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
74describe("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 @@
1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5
6import * as DesktopEnvironment from "./DesktopEnvironment.ts";
7import { 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.
15const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim");
16
17const 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). */
20const SERVER_ENTRY = "apps/server/dist/bin.mjs";
21
22const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
23/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */
24const cmdText = (value: string) => value.replaceAll("%", "%%");
25const cmdWord = (value: string) => `"${cmdText(value)}"`;
26
27const MOVED = "T3 Code has moved or been removed. Open the app once to update this command.";
28
29export 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 */
41export 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 */
133export 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";
1717import serverPackageJson from "../../../server/package.json" with { type: "json" };
1818
1919import * as DesktopBackendManager from "./DesktopBackendManager.ts";
20import * as DesktopCliShim from "../app/DesktopCliShim.ts";
2021import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
2122import * as DesktopServerExposure from "./DesktopServerExposure.ts";
2223import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
......@@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
547548 function* (
548549 input: SharedBootstrapInput & {
549550 readonly resourceMonitorPath: Option.Option<string>;
551 readonly cliPath: Option.Option<string>;
550552 },
551553 ): Effect.fn.Return<
552554 DesktopBackendManager.DesktopBackendStartConfig,
......@@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
595597 env: {
596598 ...backendChildEnvPatch(),
597599 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),
598602 },
599603 // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.).
600604 extendEnv: true,
......@@ -912,7 +916,11 @@ export const make = Effect.gen(function* () {
912916 Effect.provideService(FileSystem.FileSystem, fileSystem),
913917 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
914918 );
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(
916924 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
917925 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
918926 );
apps/server/src/cli/invocation.test.ts+81-21
......@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, expect, it } from "@effect/vitest";
33import {
44 HostProcessArguments,
5 HostProcessEnvironment,
56 HostProcessExecutablePath,
67 HostProcessIsExecutable,
78 HostProcessPlatform,
......@@ -102,28 +103,87 @@ it("formats serve suggestions to match the launching command", () => {
102103 );
103104});
104105
105it.effect("keeps a user-installed Node reachable when the command runs under sudo", () =>
106 Effect.gen(function* () {
107 const command = (node: string, entry: string) =>
108 resolveRootCliCommand("browser setup").pipe(
109 Effect.provideService(HostProcessExecutablePath, node),
110 Effect.provideService(HostProcessArguments, [node, entry]),
111 );
112 const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs";
113 // sudo's secure_path already has a system Node.
114 expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup");
115 // nvm, fnm, and tarball installs are dropped by sudo's PATH reset.
116 expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe(
117 'sudo env "PATH=$PATH" npx t3 browser setup',
106it.layer(NodeServices.layer)("root CLI commands", (it) => {
107 /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */
108 const rootCommand = (input: {
109 readonly node: string;
110 readonly entry: string;
111 readonly path?: string;
112 readonly env?: Record<string, string>;
113 readonly executable?: boolean;
114 }) =>
115 resolveRootCliCommand("browser setup").pipe(
116 Effect.provideService(HostProcessExecutablePath, input.node),
117 Effect.provideService(HostProcessArguments, [input.node, input.entry]),
118 Effect.provideService(HostProcessIsExecutable, input.executable ?? false),
119 Effect.provideService(HostProcessPlatform, "linux"),
120 Effect.provideService(HostProcessEnvironment, { PATH: input.path ?? "", ...input.env }),
118121 );
119 expect(
120 yield* command(
121 "/home/theo/.local/node/bin/node",
122 "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs",
123 ),
124 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
125 }),
126);
122
123 /** A directory holding an executable `t3`, to stand in for one on PATH. */
124 const pathWithT3 = Effect.gen(function* () {
125 const fs = yield* FileSystem.FileSystem;
126 const path = yield* Path.Path;
127 const bin = yield* fs.makeTempDirectoryScoped();
128 yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 });
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});
127187
128188it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
129189 it.effect("recognizes runner caches for both script and executable packages", () =>
apps/server/src/cli/invocation.ts+44-2
......@@ -1,15 +1,18 @@
11import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
34import * as Path from "effect/Path";
45import * as Schema from "effect/Schema";
56
67import type { ServerInstallation } from "@t3tools/contracts";
78import {
89 HostProcessArguments,
10 HostProcessEnvironment,
911 HostProcessExecutablePath,
1012 HostProcessIsExecutable,
1113 HostProcessPlatform,
1214} from "@t3tools/shared/hostProcess";
15import { isCommandAvailable } from "@t3tools/shared/shell";
1316
1417import packageJson from "../../package.json" with { type: "json" };
1518
......@@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) =>
146149 }),
147150 );
148151
152/** Quotes `value` for a POSIX shell when it needs it. */
153const 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 */
162const 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 */
178const 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
149189/**
150190 * `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo`
151191 * resets PATH on most distributions, which drops a user-installed Node (nvm,
152192 * 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.
154195 */
155196export const resolveRootCliCommand = (subcommand: string) =>
156197 Effect.gen(function* () {
157 const command = yield* resolveCliCommand(subcommand);
198 const { command, launcher } = yield* resolveHostCliCommand(subcommand);
199 if (launcher) return `sudo ${command}`;
158200 const executablePath = yield* HostProcessExecutablePath;
159201 const systemNode = ROOT_PATH_DIRECTORIES.some((directory) =>
160202 executablePath.startsWith(`${directory}/`),
docs/user/remote-access.md+2-1
......@@ -213,7 +213,8 @@ sudo t3 browser setup
213213
214214The server shows the exact line for how you started it, such as
215215`sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only
216for your user. It allows Chrome's sandbox with an AppArmor profile and installs
216for your user. Where `t3` is not on your `PATH`, such as with only the
217desktop 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
217218any missing libraries with apt. It is safe to run again. Without `sudo`, it
218219only reports what it would change.
219220