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";...@@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
17import serverPackageJson from "../../../server/package.json" with { type: "json" };17import serverPackageJson from "../../../server/package.json" with { type: "json" };
1818
19import * as DesktopBackendManager from "./DesktopBackendManager.ts";19import * as DesktopBackendManager from "./DesktopBackendManager.ts";
20import * as DesktopCliShim from "../app/DesktopCliShim.ts";
20import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";21import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
21import * as DesktopServerExposure from "./DesktopServerExposure.ts";22import * as DesktopServerExposure from "./DesktopServerExposure.ts";
22import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";23import * 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";
2import { assert, expect, it } from "@effect/vitest";2import { assert, expect, it } from "@effect/vitest";
3import {3import {
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});
104105
105it.effect("keeps a user-installed Node reachable when the command runs under sudo", () =>106it.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});
127187
128it.layer(NodeServices.layer)("manual server installation ownership", (it) => {188it.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 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
3import * as Path from "effect/Path";4import * as Path from "effect/Path";
4import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
56
6import type { ServerInstallation } from "@t3tools/contracts";7import type { ServerInstallation } from "@t3tools/contracts";
7import {8import {
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";
15import { isCommandAvailable } from "@t3tools/shared/shell";
1316
14import packageJson from "../../package.json" with { type: "json" };17import packageJson from "../../package.json" with { type: "json" };
1518
...@@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) =>...@@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) =>
146 }),149 }),
147 );150 );
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
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 carries192 * 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 */
155export const resolveRootCliCommand = (subcommand: string) =>196export 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
213213
214The server shows the exact line for how you started it, such as214The 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 only215`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 installs216for 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
217any missing libraries with apt. It is safe to run again. Without `sudo`, it218any missing libraries with apt. It is safe to run again. Without `sudo`, it
218only reports what it would change.219only reports what it would change.
219220