diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..e24050368b29 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,6 +1,11 @@ import { assert, it } from "@effect/vitest"; -import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; +import { + detectCliRunner, + detectServerInstall, + 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"); @@ -41,6 +46,45 @@ it("treats stable installs as direct invocations", () => { assert.isNull(detectCliRunner("")); }); +it("tells package runners, global installs, and everything else apart", () => { + assert.equal( + detectServerInstall("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + "npx", + ); + assert.equal( + detectServerInstall("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), + "pnpm-dlx", + ); + assert.equal( + detectServerInstall("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), + "bunx", + ); + assert.equal(detectServerInstall("/usr/local/lib/node_modules/t3/dist/bin.mjs"), "npm-global"); + assert.equal( + detectServerInstall("/home/theo/.nvm/versions/node/v24.13.1/lib/node_modules/t3/dist/bin.mjs"), + "npm-global", + ); + assert.equal( + detectServerInstall("C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs"), + "npm-global", + ); + assert.equal( + detectServerInstall( + "/home/theo/.local/share/pnpm/global/5/.pnpm/t3@0.0.35/node_modules/t3/dist/bin.mjs", + ), + "pnpm-global", + ); + assert.equal( + detectServerInstall("/home/theo/.bun/install/global/node_modules/t3/dist/bin.mjs"), + "bun-global", + ); + // Nothing global updates these, so no command is suggested. + assert.isNull(detectServerInstall("/srv/project/node_modules/t3/dist/bin.mjs")); + assert.isNull(detectServerInstall("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); + assert.isNull(detectServerInstall("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); + assert.isNull(detectServerInstall("")); +}); + 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"); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index e1b03552948d..58dd52086b48 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,3 +1,4 @@ +import type { ServerInstallKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { HostProcessArguments } from "@t3tools/shared/hostProcess"; @@ -36,6 +37,46 @@ export function detectCliRunner(entryPath: string): CliRunner | null { return null; } +const SERVER_INSTALL_BY_RUNNER: Record = { + npx: "npx", + "pnpm dlx": "pnpm-dlx", + bunx: "bunx", +}; + +/** + * How the CLI is installed, for clients that must tell the user how to update + * it by hand. Package runners are recognised as above. Global installs are + * recognised by each package manager's own layout, so the suggested command + * is the one that manages that install: + * + * npm /lib/node_modules/t3/... (system node, nvm, fnm, volta, + * Homebrew), or %APPDATA%/npm/node_modules/t3/... on Windows + * pnpm /global//... + * bun ~/.bun/install/global/... + * + * Pass the resolved entry script: the bin a global install runs is usually a + * symlink into the package. Anything else (repo checkouts, project-local + * installs, the pinned service runtime) returns null, since no global + * command would update the executable that gets restarted. + */ +export function detectServerInstall(entryPath: string): ServerInstallKind | null { + const runner = detectCliRunner(entryPath); + if (runner !== null) { + return SERVER_INSTALL_BY_RUNNER[runner]; + } + const path = entryPath.replaceAll("\\", "/"); + if (path.includes("/pnpm/global/")) { + return "pnpm-global"; + } + if (path.includes("/.bun/install/global/")) { + return "bun-global"; + } + if (path.includes("/lib/node_modules/") || path.includes("/npm/node_modules/")) { + return "npm-global"; + } + 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 diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 91895fd5dcfc..84a34f59b4b8 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -257,6 +258,56 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); + it.effect("reports how the server is installed only when it cannot update itself", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-install-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + + const describeWith = ( + overrides: Partial, + entryPath: string, + ) => + Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })), + Layer.provide(Layer.succeed(HostProcessArguments, ["/usr/bin/node", entryPath])), + ), + ), + ); + + const npx = yield* describeWith({}, "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"); + expect(npx.capabilities.serverSelfUpdate).toBeUndefined(); + expect(npx.capabilities.serverInstall).toBe("npx"); + + // A global install runs through a bin symlink; the install is read off + // the package it points into. + const packageDir = `${baseDir}/lib/node_modules/t3/dist`; + yield* fileSystem.makeDirectory(packageDir, { recursive: true }); + yield* fileSystem.writeFileString(`${packageDir}/bin.mjs`, ""); + yield* fileSystem.makeDirectory(`${baseDir}/bin`, { recursive: true }); + yield* fileSystem.symlink(`${packageDir}/bin.mjs`, `${baseDir}/bin/t3`); + const global = yield* describeWith({}, `${baseDir}/bin/t3`); + expect(global.capabilities.serverInstall).toBe("npm-global"); + + const checkout = yield* describeWith({}, "/home/theo/Code/t3code/apps/server/src/bin.ts"); + expect(checkout.capabilities.serverInstall).toBeUndefined(); + + // A desktop-managed server updates through the app, so it never hands out a command. + const desktop = yield* describeWith({ mode: "desktop" }, `${baseDir}/bin/t3`); + expect(desktop.capabilities.serverSelfUpdate).toBe("desktop-managed"); + expect(desktop.capabilities.serverInstall).toBeUndefined(); + }), + ); + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1010011e90cd..bda75ca759a6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -3,7 +3,11 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessArguments, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -14,6 +18,7 @@ import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { detectServerInstall } from "../cli/invocation.ts"; import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; @@ -181,11 +186,13 @@ const makeIdentity = Effect.gen(function* () { export const make = Effect.gen(function* () { const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* ServerConfig.ServerConfig; const secrets = yield* ServerSecretStore.ServerSecretStore; const identity = yield* ServerEnvironmentIdentity; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; + const processArguments = yield* HostProcessArguments; const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); @@ -201,6 +208,16 @@ export const make = Effect.gen(function* () { // the fd and correctly do not advertise. const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; + // Only a server with no self-update path hands the user a command, so only + // that server needs to say which command will land on its install. The bin + // a global install runs is a symlink into the package, so resolve it first. + const entryPath = processArguments[1] ?? ""; + const serverInstall = + serverSelfUpdate === null + ? detectServerInstall( + yield* fileSystem.realPath(entryPath).pipe(Effect.orElseSucceed(() => entryPath)), + ) + : null; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -227,6 +244,7 @@ export const make = Effect.gen(function* () { threadPullRequestLinking: true, environmentIcon: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), + ...(serverInstall === null ? {} : { serverInstall }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { serverSelfUpdateProgress: true, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0d33aec7bc20..0d20d8253b02 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -432,6 +432,7 @@ import { isServerUpdateFailureDismissed, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, + resolveServerInstall, resolveServerSelfUpdateCapability, serverUpdateGuidance, supportsDesktopAppUpdate, @@ -2247,6 +2248,7 @@ function ChatViewContent(props: ChatViewProps) { const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); + const versionMismatchInstall = resolveServerInstall(serverConfig); const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); @@ -2383,6 +2385,7 @@ function ChatViewContent(props: ChatViewProps) { selfUpdate={versionMismatchSelfUpdate} desktopAppUpdate={versionMismatchDesktopAppUpdate} threadContinuation={versionMismatchThreadContinuation} + install={versionMismatchInstall} targetVersion={versionMismatch.clientVersion} label={updateFailed ? "Retry" : "Update"} variant="ghost" diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 71b974416dd3..4cddb1cf13bb 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,4 +1,8 @@ -import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import type { + EnvironmentId, + ServerInstallKind, + ServerSelfUpdateCapability, +} from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { isAtomCommandInterrupted, @@ -80,6 +84,7 @@ export function ServerUpdateAction({ selfUpdate, desktopAppUpdate = false, threadContinuation = false, + install, targetVersion, label = "Update", variant = "outline", @@ -93,6 +98,9 @@ export function ServerUpdateAction({ readonly desktopAppUpdate?: boolean; /** The server can durably continue running provider turns after updating. */ readonly threadContinuation?: boolean; + /** How the server is installed (capabilities.serverInstall), which picks + the manual update command when it cannot update itself. */ + readonly install?: ServerInstallKind | undefined; readonly targetVersion: string; readonly label?: string; readonly variant?: ComponentProps["variant"]; @@ -185,7 +193,7 @@ export function ServerUpdateAction({ } if (selfUpdate === null) { - const command = manualServerUpdateCommand(targetVersion); + const command = manualServerUpdateCommand(targetVersion, install); return (