| author | |
| committer | |
| log | 6ab2f16809e584449831aa5dba0b0e5ca14a2a54 |
| tree | 0166add0826904398c4a7f3e162300da407fa54f |
| parent | 6efcf3e10c02935780af6cc06f7146b8c9731ee2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 46 | 46 | import * as ExternalLauncher from "../process/externalLauncher.ts"; |
| 47 | 47 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 48 | 48 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 49 | import { resolveCliCommand } from "./invocation.ts"; | |
| 49 | 50 | import { |
| 50 | 51 | bootServiceLayer, |
| 51 | 52 | offerServiceDuringOnboarding, |
| ... | ... | @@ -526,10 +527,11 @@ const connectLinkCommand = Command.make("link", { |
| 526 | 527 | yield* Console.log("T3 Connect\n"); |
| 527 | 528 | const linked = yield* linkEnvironmentForConnect(flags); |
| 528 | 529 | if (linked) { |
| 530 | const serveCommand = yield* resolveCliCommand("serve"); | |
| 529 | 531 | yield* Console.log( |
| 530 | 532 | flags.publishOnly |
| 531 | 533 | ? `✓ 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.`, | |
| 533 | 535 | ); |
| 534 | 536 | } |
| 535 | 537 | }), |
| ... | ... | @@ -691,10 +693,15 @@ export const connectCommand = Command.make("connect", { |
| 691 | 693 | // Connect itself already succeeded; a boot-service failure must not |
| 692 | 694 | // fail the command, just tell the user what happened and move on. |
| 693 | 695 | 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"); | |
| 694 | 703 | 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.`, | |
| 698 | 705 | ); |
| 699 | 706 | }), |
| 700 | 707 | ), |
apps/server/src/cli/invocation.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; | |
| 4 | ||
| 5 | it("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 | ||
| 37 | it("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 | ||
| 44 | it("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 | ||
| 49 | it("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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | ||
| 3 | import { HostProcessArguments } from "@t3tools/shared/hostProcess"; | |
| 4 | ||
| 5 | import packageJson from "../../package.json" with { type: "json" }; | |
| 6 | ||
| 7 | export 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 | */ | |
| 21 | export 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 | */ | |
| 45 | export 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 | */ | |
| 55 | export 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. */ | |
| 68 | export const resolveCliCommand = (subcommand: string) => | |
| 69 | Effect.map(HostProcessArguments, (processArguments) => | |
| 70 | formatCliCommand({ | |
| 71 | subcommand, | |
| 72 | entryPath: processArguments[1] ?? "", | |
| 73 | version: packageJson.version, | |
| 74 | }), | |
| 75 | ); |