From 4ecb6fc638928022d05a20ccae37d6069924b811 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 29 Jul 2026 17:57:55 -0700 Subject: [PATCH 1/2] fix(connect): match the suggested serve command to how connect was run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx t3@nightly connect` used to end with "Start the server with `t3 serve`" — a command that fails on machines without a global install. Detect the package runner (npx / pnpm dlx / bunx) from the CLI entry path and the nightly channel from the running version, and render the suggestion to match: `npx t3@nightly serve`, `bunx t3 serve`, or plain `t3 serve` for stable installs. Detection fails closed to `t3 serve`. Co-Authored-By: Claude Fable 5 --- apps/server/src/cli/connect.ts | 15 ++++-- apps/server/src/cli/invocation.test.ts | 62 ++++++++++++++++++++++ apps/server/src/cli/invocation.ts | 73 ++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/cli/invocation.test.ts create mode 100644 apps/server/src/cli/invocation.ts diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 0369d47e4d7..ef15e650a6f 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -46,6 +46,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { resolveCliCommand } from "./invocation.ts"; import { bootServiceLayer, offerServiceDuringOnboarding, @@ -526,10 +527,11 @@ const connectLinkCommand = Command.make("link", { yield* Console.log("T3 Connect\n"); const linked = yield* linkEnvironmentForConnect(flags); if (linked) { + const serveCommand = yield* resolveCliCommand("serve"); yield* Console.log( flags.publishOnly ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).` - : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`, + : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`, ); } }), @@ -691,10 +693,15 @@ export const connectCommand = Command.make("connect", { // Connect itself already succeeded; a boot-service failure must not // fail the command, just tell the user what happened and move on. const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); + if (background) { + yield* Console.log( + "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.", + ); + return; + } + const serveCommand = yield* resolveCliCommand("serve"); yield* Console.log( - background - ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out." - : "\nNext\n Start the server with `t3 serve` to make this machine reachable.", + `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`, ); }), ), diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts new file mode 100644 index 00000000000..7330556a476 --- /dev/null +++ b/apps/server/src/cli/invocation.test.ts @@ -0,0 +1,62 @@ +import { assert, it } from "@effect/vitest"; + +import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; + +it("detects package runners from their cache entry paths", () => { + assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx"); + assert.equal( + detectCliRunner( + "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", + ), + "npx", + ); + assert.equal( + detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), + "pnpm dlx", + ); + assert.equal( + detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), + "pnpm dlx", + ); + assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); + assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); +}); + +it("treats stable installs as direct invocations", () => { + assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); + assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); + assert.isNull(detectCliRunner("")); +}); + +it("re-suggests the nightly channel only for nightly builds", () => { + assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly"); + assert.equal(suggestedPackageSpec("0.0.31"), "t3"); +}); + +it("formats serve suggestions to match the launching command", () => { + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs", + version: "0.0.31-nightly.20260729", + }), + "npx t3@nightly serve", + ); + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", + version: "0.0.31", + }), + "bunx t3 serve", + ); + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/usr/local/lib/node_modules/t3/dist/bin.mjs", + version: "0.0.31-nightly.20260729", + }), + "t3 serve", + ); +}); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts new file mode 100644 index 00000000000..a031f8cbe3b --- /dev/null +++ b/apps/server/src/cli/invocation.ts @@ -0,0 +1,73 @@ +import * as Effect from "effect/Effect"; + +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; + +import packageJson from "../../package.json" with { type: "json" }; + +export type CliRunner = "npx" | "pnpm dlx" | "bunx"; + +/** + * How the CLI was launched, judged by where its entry script lives. Each + * package runner executes out of a distinctive cache/temp layout: + * + * npx ~/.npm/_npx//node_modules/... + * pnpm dlx ~/.cache/pnpm/dlx/... or $PNPM_HOME/.pnpm/dlx/... + * bunx ~/.bun/install/cache/... or $TMPDIR/bunx--/... + * + * Global installs and repo checkouts match none of these and return null. + * Detection is best-effort; callers must fail closed to a plain `t3` command. + */ +export function detectCliRunner(entryPath: string): CliRunner | null { + if (entryPath.includes("/_npx/") || entryPath.includes("\\_npx\\")) { + return "npx"; + } + if (entryPath.includes("/pnpm/dlx/") || entryPath.includes("/.pnpm/dlx/")) { + return "pnpm dlx"; + } + if ( + entryPath.includes("/.bun/install/cache/") || + entryPath.includes("/bunx-") || + entryPath.includes("\\bunx-") + ) { + return "bunx"; + } + return null; +} + +/** + * The `t3` package spec to suggest. The literal spec the user typed (e.g. + * `t3@nightly`) is resolved away before our process starts, so re-derive it + * from the running version: nightly builds re-suggest the nightly channel, + * anything else suggests the bare package. + */ +export function suggestedPackageSpec(version: string): string { + return version.includes("-nightly.") ? "t3@nightly" : "t3"; +} + +/** + * Render a `t3 ` suggestion that matches how this process was + * launched, so copy/pasting it actually works: `npx t3 connect` suggests + * `npx t3 serve`, a global install suggests `t3 serve`, and a nightly build + * keeps the `@nightly` tag. + */ +export function formatCliCommand(input: { + readonly subcommand: string; + readonly entryPath: string; + readonly version: string; +}): string { + const runner = detectCliRunner(input.entryPath); + if (runner === null) { + return `t3 ${input.subcommand}`; + } + return `${runner} ${suggestedPackageSpec(input.version)} ${input.subcommand}`; +} + +/** `formatCliCommand` against this process's real entry path and version. */ +export const resolveCliCommand = (subcommand: string) => + Effect.map(HostProcessArguments, (processArguments) => + formatCliCommand({ + subcommand, + entryPath: processArguments[1] ?? "", + version: packageJson.version, + }), + ); From 43f87e8731f24816be234df735a6b2a2beeadaf7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 29 Jul 2026 21:50:31 -0700 Subject: [PATCH 2/2] Normalize Windows path separators in runner detection Review bots correctly flagged that pnpm dlx and bunx entry paths use backslashes on Windows and would fall back to the bare `t3` suggestion. Normalize separators once and match forward-slash segments, and add the Windows pnpm-cache/dlx layout. Co-Authored-By: Claude Fable 5 --- apps/server/src/cli/invocation.test.ts | 12 ++++++++++++ apps/server/src/cli/invocation.ts | 18 ++++++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 7330556a476..c01a2caa49b 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -18,8 +18,20 @@ it("detects package runners from their cache entry paths", () => { detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), "pnpm dlx", ); + assert.equal( + detectCliRunner( + "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", + ), + "pnpm dlx", + ); assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); + assert.equal( + detectCliRunner( + "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", + ), + "bunx", + ); }); it("treats stable installs as direct invocations", () => { diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index a031f8cbe3b..e1b03552948 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -11,24 +11,26 @@ export type CliRunner = "npx" | "pnpm dlx" | "bunx"; * package runner executes out of a distinctive cache/temp layout: * * npx ~/.npm/_npx//node_modules/... - * pnpm dlx ~/.cache/pnpm/dlx/... or $PNPM_HOME/.pnpm/dlx/... + * pnpm dlx ~/.cache/pnpm/dlx/..., $PNPM_HOME/.pnpm/dlx/..., + * or %LOCALAPPDATA%/pnpm-cache/dlx/... on Windows * bunx ~/.bun/install/cache/... or $TMPDIR/bunx--/... * * Global installs and repo checkouts match none of these and return null. * Detection is best-effort; callers must fail closed to a plain `t3` command. */ export function detectCliRunner(entryPath: string): CliRunner | null { - if (entryPath.includes("/_npx/") || entryPath.includes("\\_npx\\")) { + const path = entryPath.replaceAll("\\", "/"); + if (path.includes("/_npx/")) { return "npx"; } - if (entryPath.includes("/pnpm/dlx/") || entryPath.includes("/.pnpm/dlx/")) { - return "pnpm dlx"; - } if ( - entryPath.includes("/.bun/install/cache/") || - entryPath.includes("/bunx-") || - entryPath.includes("\\bunx-") + path.includes("/pnpm/dlx/") || + path.includes("/.pnpm/dlx/") || + path.includes("/pnpm-cache/dlx/") ) { + return "pnpm dlx"; + } + if (path.includes("/.bun/install/cache/") || path.includes("/bunx-")) { return "bunx"; } return null;