authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 01:46:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 01:46:10-07:00
log6ab2f16809e584449831aa5dba0b0e5ca14a2a54
tree0166add0826904398c4a7f3e162300da407fa54f
parent6efcf3e10c02935780af6cc06f7146b8c9731ee2
signature Signed by PGP key B5690EEEBB952194

fix(connect): suggest a serve command that matches how you ran connect (#4897)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 160 insertions(+), 4 deletions(-)

apps/server/src/cli/connect.ts+11-4
......@@ -46,6 +46,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4646import * as ExternalLauncher from "../process/externalLauncher.ts";
4747import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
4848import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
49import { resolveCliCommand } from "./invocation.ts";
4950import {
5051 bootServiceLayer,
5152 offerServiceDuringOnboarding,
......@@ -526,10 +527,11 @@ const connectLinkCommand = Command.make("link", {
526527 yield* Console.log("T3 Connect\n");
527528 const linked = yield* linkEnvironmentForConnect(flags);
528529 if (linked) {
530 const serveCommand = yield* resolveCliCommand("serve");
529531 yield* Console.log(
530532 flags.publishOnly
531533 ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).`
532 : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`,
534 : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`,
533535 );
534536 }
535537 }),
......@@ -691,10 +693,15 @@ export const connectCommand = Command.make("connect", {
691693 // Connect itself already succeeded; a boot-service failure must not
692694 // fail the command, just tell the user what happened and move on.
693695 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
696 if (background) {
697 yield* Console.log(
698 "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
699 );
700 return;
701 }
702 const serveCommand = yield* resolveCliCommand("serve");
694703 yield* Console.log(
695 background
696 ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out."
697 : "\nNext\n Start the server with `t3 serve` to make this machine reachable.",
704 `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`,
698705 );
699706 }),
700707 ),
apps/server/src/cli/invocation.test.ts created+74
......@@ -0,0 +1,74 @@
1import { assert, it } from "@effect/vitest";
2
3import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts";
4
5it("detects package runners from their cache entry paths", () => {
6 assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx");
7 assert.equal(
8 detectCliRunner(
9 "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs",
10 ),
11 "npx",
12 );
13 assert.equal(
14 detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"),
15 "pnpm dlx",
16 );
17 assert.equal(
18 detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"),
19 "pnpm dlx",
20 );
21 assert.equal(
22 detectCliRunner(
23 "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs",
24 ),
25 "pnpm dlx",
26 );
27 assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx");
28 assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx");
29 assert.equal(
30 detectCliRunner(
31 "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs",
32 ),
33 "bunx",
34 );
35});
36
37it("treats stable installs as direct invocations", () => {
38 assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs"));
39 assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs"));
40 assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs"));
41 assert.isNull(detectCliRunner(""));
42});
43
44it("re-suggests the nightly channel only for nightly builds", () => {
45 assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly");
46 assert.equal(suggestedPackageSpec("0.0.31"), "t3");
47});
48
49it("formats serve suggestions to match the launching command", () => {
50 assert.equal(
51 formatCliCommand({
52 subcommand: "serve",
53 entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs",
54 version: "0.0.31-nightly.20260729",
55 }),
56 "npx t3@nightly serve",
57 );
58 assert.equal(
59 formatCliCommand({
60 subcommand: "serve",
61 entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs",
62 version: "0.0.31",
63 }),
64 "bunx t3 serve",
65 );
66 assert.equal(
67 formatCliCommand({
68 subcommand: "serve",
69 entryPath: "/usr/local/lib/node_modules/t3/dist/bin.mjs",
70 version: "0.0.31-nightly.20260729",
71 }),
72 "t3 serve",
73 );
74});
apps/server/src/cli/invocation.ts created+75
......@@ -0,0 +1,75 @@
1import * as Effect from "effect/Effect";
2
3import { HostProcessArguments } from "@t3tools/shared/hostProcess";
4
5import packageJson from "../../package.json" with { type: "json" };
6
7export type CliRunner = "npx" | "pnpm dlx" | "bunx";
8
9/**
10 * How the CLI was launched, judged by where its entry script lives. Each
11 * package runner executes out of a distinctive cache/temp layout:
12 *
13 * npx ~/.npm/_npx/<hash>/node_modules/...
14 * pnpm dlx ~/.cache/pnpm/dlx/..., $PNPM_HOME/.pnpm/dlx/...,
15 * or %LOCALAPPDATA%/pnpm-cache/dlx/... on Windows
16 * bunx ~/.bun/install/cache/... or $TMPDIR/bunx-<uid>-<spec>/...
17 *
18 * Global installs and repo checkouts match none of these and return null.
19 * Detection is best-effort; callers must fail closed to a plain `t3` command.
20 */
21export function detectCliRunner(entryPath: string): CliRunner | null {
22 const path = entryPath.replaceAll("\\", "/");
23 if (path.includes("/_npx/")) {
24 return "npx";
25 }
26 if (
27 path.includes("/pnpm/dlx/") ||
28 path.includes("/.pnpm/dlx/") ||
29 path.includes("/pnpm-cache/dlx/")
30 ) {
31 return "pnpm dlx";
32 }
33 if (path.includes("/.bun/install/cache/") || path.includes("/bunx-")) {
34 return "bunx";
35 }
36 return null;
37}
38
39/**
40 * The `t3` package spec to suggest. The literal spec the user typed (e.g.
41 * `t3@nightly`) is resolved away before our process starts, so re-derive it
42 * from the running version: nightly builds re-suggest the nightly channel,
43 * anything else suggests the bare package.
44 */
45export function suggestedPackageSpec(version: string): string {
46 return version.includes("-nightly.") ? "t3@nightly" : "t3";
47}
48
49/**
50 * Render a `t3 <subcommand>` suggestion that matches how this process was
51 * launched, so copy/pasting it actually works: `npx t3 connect` suggests
52 * `npx t3 serve`, a global install suggests `t3 serve`, and a nightly build
53 * keeps the `@nightly` tag.
54 */
55export function formatCliCommand(input: {
56 readonly subcommand: string;
57 readonly entryPath: string;
58 readonly version: string;
59}): string {
60 const runner = detectCliRunner(input.entryPath);
61 if (runner === null) {
62 return `t3 ${input.subcommand}`;
63 }
64 return `${runner} ${suggestedPackageSpec(input.version)} ${input.subcommand}`;
65}
66
67/** `formatCliCommand` against this process's real entry path and version. */
68export const resolveCliCommand = (subcommand: string) =>
69 Effect.map(HostProcessArguments, (processArguments) =>
70 formatCliCommand({
71 subcommand,
72 entryPath: processArguments[1] ?? "",
73 version: packageJson.version,
74 }),
75 );