From 33ca5dd395edf5f2a1c3a4b5c3915957776cc66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yal=C3=A7=C4=B1n=20Doksanbir?= Date: Fri, 4 Sep 2026 00:57:06 +0300 Subject: [PATCH 1/3] fix(web): copy an update command that matches how the server is installed --- apps/server/src/cli/invocation.test.ts | 29 +++++++++++- apps/server/src/cli/invocation.ts | 21 +++++++++ .../src/environment/ServerEnvironment.test.ts | 47 +++++++++++++++++++ .../src/environment/ServerEnvironment.ts | 13 ++++- apps/web/src/components/ChatView.tsx | 3 ++ .../web/src/components/ServerUpdateAction.tsx | 12 ++++- .../settings/ConnectionsSettings.tsx | 3 ++ apps/web/src/versionSkew.test.ts | 10 ++++ apps/web/src/versionSkew.ts | 37 +++++++++++++-- docs/user/updating.md | 12 +++-- packages/contracts/src/environment.ts | 10 ++++ 11 files changed, 184 insertions(+), 13 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..fce0f80f9973 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,28 @@ it("treats stable installs as direct invocations", () => { assert.isNull(detectCliRunner("")); }); +it("tells package runners, installed packages, and checkouts 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"), "global"); + assert.equal( + detectServerInstall("C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs"), + "global", + ); + 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..31c6a52f2750 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,26 @@ 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; any other entry script + * under `node_modules` is an installed package, which `npm i -g` upgrades in + * place. Repo checkouts return null: no package command updates those. + */ +export function detectServerInstall(entryPath: string): ServerInstallKind | null { + const runner = detectCliRunner(entryPath); + if (runner !== null) { + return SERVER_INSTALL_BY_RUNNER[runner]; + } + return entryPath.replaceAll("\\", "/").includes("/node_modules/") ? "global" : 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..161280c504a6 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,52 @@ 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"); + + const global = yield* describeWith({}, "/usr/local/lib/node_modules/t3/dist/bin.mjs"); + expect(global.capabilities.serverInstall).toBe("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" }, + "/usr/local/lib/node_modules/t3/dist/bin.mjs", + ); + 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..2663268ee27a 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"; @@ -186,6 +191,7 @@ export const make = Effect.gen(function* () { 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 +207,10 @@ 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. + const serverInstall = + serverSelfUpdate === null ? detectServerInstall(processArguments[1] ?? "") : null; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -227,6 +237,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 (