From 4e401015d05fafc4af1b752f884f928fbfd0690f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:34:52 +0000 Subject: [PATCH 1/3] test(server): a directly started t3 executable names itself in the sudo setup command Fails on main: an unrelated t3 on PATH wins over the running standalone executable when T3CODE_CLI_PATH is unset (#22626). AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/invocation.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index ab68a6890664..9258ecd3065c 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -109,6 +109,16 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { }), ).toBe("sudo /opt/t3/t3 browser setup"); + // A standalone executable started directly (systemd, no launcher) is still this install. + expect( + yield* rootCommand({ + node: "/opt/t3/t3", + entry: "/opt/t3/t3", + executable: true, + path: yield* pathWithT3, + }), + ).toBe("sudo /opt/t3/t3 browser setup"); + expect( yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), ).toBe('sudo env "PATH=$PATH" t3 browser setup'); From 9eb06ffdc7870f921aff39b7aa53c91a85df9c55 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:37:25 +0000 Subject: [PATCH 2/3] fix(server): browser setup command names the running t3 executable, not another t3 on PATH resolveHostCliCommand trusted any `t3` on PATH unless T3CODE_CLI_PATH pointed elsewhere, so a standalone executable started directly suggested another install's CLI. Compare PATH's `t3` against this install's launcher (the desktop shim or the running executable) and use the bare `t3` only when they are the same file. Fixes pingdotgg/t3code#22626 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/invocation.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index b9868df34de5..f4dc48a5b074 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -155,18 +155,16 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); const command = yield* resolveCliCommand(subcommand); if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; + const launcher = yield* resolveInstallLauncher; + if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; const onPath = yield* resolveCommandPath("t3"); if (onPath !== null) { - const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim(); const fs = yield* FileSystem.FileSystem; const real = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => null)); - const shimReal = shim ? yield* real(shim) : null; - if (shimReal === null || shimReal === (yield* real(onPath))) { + if ((yield* real(onPath)) === ((yield* real(launcher.value)) ?? launcher.value)) { return { command: withArgs(command), launcher: false }; } } - const launcher = yield* resolveInstallLauncher; - if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; // PowerShell runs a quoted path only through the call operator. const quoted = hostShellWord(platform, launcher.value); const call = platform === "win32" ? `& ${quoted}` : quoted; From c72c2e871b727dfca84c03d9f74494f3f4292ece Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:45:54 +0000 Subject: [PATCH 3/3] fix(server): npm-launched t3 keeps the plain t3 command in setup hints Compare PATH's `t3` against the running entry (the global npm launcher when it started the executable) instead of the downloaded runtime binary, so npm installs keep `t3` and don't pin a versioned path. Windows keeps the plain command, since its `t3.cmd` shims never realpath to the executable. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/invocation.test.ts | 21 +++++++++++++++++++++ apps/server/src/cli/invocation.ts | 17 ++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 9258ecd3065c..ba3bc840e744 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -119,6 +119,27 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { }), ).toBe("sudo /opt/t3/t3 browser setup"); + // A global npm launcher runs the downloaded executable; PATH's `t3` is that launcher. + const prefix = path.join(home, "npm"); + const npmLauncher = path.join(prefix, "lib/node_modules/t3/bin/t3.mjs"); + yield* fs.makeDirectory(path.dirname(npmLauncher), { recursive: true }); + yield* fs.makeDirectory(path.join(prefix, "bin")); + yield* fs.writeFileString(npmLauncher, "", { mode: 0o755 }); + yield* fs.writeFileString( + path.join(prefix, "lib/node_modules/t3/package.json"), + `{"name":"t3","version":"${packageJson.version}"}`, + ); + yield* fs.symlink(npmLauncher, path.join(prefix, "bin/t3")); + expect( + yield* rootCommand({ + node: "/home/theo/.t3/runtime/versions/1.0.0/t3", + entry: "/home/theo/.t3/runtime/versions/1.0.0/t3", + executable: true, + path: path.join(prefix, "bin"), + env: { T3CODE_NPM_LAUNCHER_PATH: npmLauncher }, + }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + expect( yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), ).toBe('sudo env "PATH=$PATH" t3 browser setup'); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index f4dc48a5b074..2a1991fd3070 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -155,16 +155,27 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); const command = yield* resolveCliCommand(subcommand); if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; - const launcher = yield* resolveInstallLauncher; - if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; const onPath = yield* resolveCommandPath("t3"); if (onPath !== null) { + const shim = (yield* HostProcess.Environment).T3CODE_CLI_PATH?.trim(); const fs = yield* FileSystem.FileSystem; const real = (file: string) => fs.realPath(file).pipe(Effect.orElseSucceed(() => null)); - if ((yield* real(onPath)) === ((yield* real(launcher.value)) ?? launcher.value)) { + // Windows `t3.cmd` shims are text files that never realpath to the executable they run. + const own = shim + ? yield* real(shim) + : platform !== "win32" && (yield* HostProcess.IsExecutable) + ? yield* resolveEntryPath.pipe( + Effect.flatMap((entry) => + real(entry).pipe(Effect.map((resolved) => resolved ?? entry)), + ), + ) + : null; + if (own === null || own === (yield* real(onPath))) { return { command: withArgs(command), launcher: false }; } } + const launcher = yield* resolveInstallLauncher; + if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false }; // PowerShell runs a quoted path only through the call operator. const quoted = hostShellWord(platform, launcher.value); const call = platform === "win32" ? `& ${quoted}` : quoted;