From 5dcf517efdcf089d17a6ce42b8457dbf84c2f242 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 07:01:08 +0000 Subject: [PATCH 1/2] fix(server): match manual update commands to installed CLI --- apps/server/src/cli/invocation.test.ts | 132 +++++++++++++++++- apps/server/src/cli/invocation.ts | 71 +++++++++- .../src/environment/ServerEnvironment.test.ts | 47 +++++++ .../src/environment/ServerEnvironment.ts | 3 + apps/web/src/components/ChatView.tsx | 3 + .../components/ServerUpdateAction.test.tsx | 52 ++++++- .../web/src/components/ServerUpdateAction.tsx | 28 +++- .../chat/useAutoBalanceUpdateBanner.tsx | 1 + .../settings/ConnectionsSettings.tsx | 5 + apps/web/src/versionSkew.test.ts | 16 +++ apps/web/src/versionSkew.ts | 20 ++- docs/user/updating.md | 3 +- packages/contracts/src/environment.test.ts | 19 +++ packages/contracts/src/environment.ts | 9 ++ 14 files changed, 394 insertions(+), 15 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 067d9fa09d80..e15d0f827055 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,6 +1,16 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; -import { formatCliCommand } from "./invocation.ts"; +import { formatCliCommand, resolveServerInstallation } from "./invocation.ts"; it("formats package runner commands from their cache entry paths", () => { for (const [entryPath, expected] of [ @@ -87,3 +97,121 @@ it("formats serve suggestions to match the launching command", () => { "t3 serve", ); }); + +it.layer(NodeServices.layer)("manual server installation ownership", (it) => { + it.effect("recognizes runner caches for both script and executable packages", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + for (const [relative, kind] of [ + ["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"], + ["npm/_npx/hash/node_modules/@t3code/t3-linux-x64/t3", "npx"], + ["pnpm/dlx/hash/node_modules/t3/dist/bin.mjs", "pnpm-dlx"], + [".bun/install/cache/t3/dist/bin.mjs", "bunx"], + ] as const) { + const entry = path.join(root, relative); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.writeFileString(entry, ""); + const installation = yield* resolveServerInstallation.pipe( + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessExecutablePath, entry), + Effect.provideService(HostProcessIsExecutable, entry.endsWith("/t3")), + ); + expect(installation).toEqual({ kind }); + } + }), + ); + + it.effect("requires the npm prefix's bin to point to the running package", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const prefix = yield* fs.makeTempDirectoryScoped(); + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const entry = path.join(packageRoot, "dist/bin.mjs"); + const globalBin = path.join(prefix, "bin/t3"); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true }); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + path.join(packageRoot, "package.json"), + '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', + ); + const resolve = resolveServerInstallation.pipe( + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessIsExecutable, false), + Effect.provideService(HostProcessPlatform, "linux"), + ); + expect(yield* resolve).toBeNull(); + yield* fs.symlink(entry, globalBin); + expect(yield* resolve).toEqual({ kind: "npm-global", prefix }); + yield* fs.remove(globalBin); + yield* fs.writeFileString(globalBin, "an unrelated t3 command"); + expect(yield* resolve).toBeNull(); + expect(yield* resolve.pipe(Effect.provideService(HostProcessPlatform, "win32"))).toBeNull(); + }), + ); + + it.effect("proves the native executable belongs to the npm launcher", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const prefix = yield* fs.makeTempDirectoryScoped(); + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const launcher = path.join(packageRoot, "bin/t3.js"); + const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3"); + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.makeDirectory(path.join(prefix, "bin")); + yield* fs.writeFileString(launcher, ""); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + path.join(packageRoot, "package.json"), + '{"name":"t3","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@t3code/t3-linux-x64":"0.0.45"}}', + ); + yield* fs.symlink(launcher, path.join(prefix, "bin/t3")); + const resolve = resolveServerInstallation.pipe( + Effect.provideService(HostProcessExecutablePath, entry), + Effect.provideService(HostProcessIsExecutable, true), + Effect.provideService(HostProcessPlatform, "linux"), + ); + for (const [version, expected] of [ + ["0.0.44", null], + ["0.0.45", { kind: "npm-global", prefix }], + ]) { + yield* fs.writeFileString( + path.join(path.dirname(entry), "package.json"), + `{"name":"@t3code/t3-linux-x64","version":"${version}"}`, + ); + expect(yield* resolve).toEqual(expected); + } + }), + ); + + it.effect("leaves local, standalone, missing and unreadable installs unknown", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + for (const relative of [ + "project/node_modules/t3/dist/bin.mjs", + "project/apps/server/dist/bin.mjs", + ".t3/runtime/0.0.45/t3", + "missing/dist/bin.mjs", + ]) { + const entry = path.join(root, relative); + if (!relative.startsWith("missing")) { + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }); + yield* fs.writeFileString(entry, ""); + } + expect( + yield* resolveServerInstallation.pipe( + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessIsExecutable, false), + ), + ).toBeNull(); + } + }), + ); +}); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 1fc0e774129f..f89ae9401ba7 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,6 +1,15 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; -import { HostProcessArguments } from "@t3tools/shared/hostProcess"; +import type { ServerInstallation } from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import packageJson from "../../package.json" with { type: "json" }; @@ -36,6 +45,66 @@ function detectCliRunner(entryPath: string): CliRunner | null { return null; } +const InstallManifest = Schema.Struct({ + name: Schema.String, + version: Schema.String, + bin: Schema.optionalKey(Schema.Struct({ t3: Schema.String })), + optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), +}); +const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest)); + +/** Prove the running package and its global bin belong together before suggesting an update. */ +export const resolveServerInstallation = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const args = yield* HostProcessArguments; + const executable = yield* HostProcessIsExecutable; + const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? "")); + const runner = detectCliRunner(entry); + if (runner !== null) { + return { kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation; + } + // Windows shims and other package managers need their own ownership proof. + if (platform === "win32") return null; + const match = + /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec( + entry, + ); + if (!match) return null; + const prefix = match[1] || "/"; + if ( + prefix.includes("/node_modules/") || + /\/(?:Cellar|Caskroom)\//i.test(prefix) || + /\/mise\/installs\/(?!node\/)[^/]+\//.test(prefix) + ) + return null; + + const packageRoot = path.join(prefix, "lib/node_modules/t3"); + const manifest = yield* fs + .readFileString(path.join(packageRoot, "package.json")) + .pipe(Effect.flatMap(decodeInstallManifest)); + if (manifest.name !== "t3" || !manifest.bin) return null; + const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3)); + const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3")); + if (globalBin !== bin) return null; + if (executable) { + const nativeManifest = yield* fs + .readFileString(path.join(path.dirname(entry), "package.json")) + .pipe(Effect.flatMap(decodeInstallManifest)); + if ( + manifest.bin.t3 !== "./bin/t3.js" || + manifest.optionalDependencies?.[nativeManifest.name] !== nativeManifest.version || + nativeManifest.version !== manifest.version + ) + return null; + } else if (bin !== entry) { + return null; + } + return { kind: "npm-global", prefix } satisfies ServerInstallation; +}).pipe(Effect.orElseSucceed(() => 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 f02e8e0ec7e3..19d4ca5f65c6 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,6 +10,12 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -79,6 +85,47 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("publishes proven install ownership only for manually updated servers", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped(); + const prefix = `${baseDir}/node`; + const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`; + yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true }); + yield* fs.makeDirectory(`${prefix}/bin`, { recursive: true }); + yield* fs.writeFileString(entry, ""); + yield* fs.writeFileString( + `${prefix}/lib/node_modules/t3/package.json`, + '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}', + ); + yield* fs.symlink(entry, `${prefix}/bin/t3`); + const config = yield* makeServerConfig(baseDir); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + for (const mode of ["web", "desktop"] as const) { + const descriptor = yield* Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(emptySecretStoreLayer), + Layer.provide(ServerConfig.layer({ ...config, mode })), + ), + ), + Effect.provideService(HostProcessArguments, ["node", entry]), + Effect.provideService(HostProcessIsExecutable, false), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, {}), + ); + expect(descriptor.capabilities.serverInstallation).toEqual( + mode === "web" ? { kind: "npm-global", prefix } : undefined, + ); + expect(descriptor.capabilities.serverSelfUpdate).toBe( + mode === "web" ? undefined : "desktop-managed", + ); + } + }), + ); it.effect.each([ { name: "missing", content: undefined }, { name: "empty", content: "" }, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 16a4a93b3f20..ef66b6d5419a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { resolveServerInstallation } from "../cli/invocation.ts"; import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; @@ -196,6 +197,7 @@ export const make = Effect.gen(function* () { desktopManaged: serverConfig.mode === "desktop", launcherManaged: launcher.managed, }); + const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null; // Static is correct: the control fd is known at bootstrap, and the desktop // app and its bundled server ship in one artifact, so a present fd means // the app speaks the requestDesktopUpdate protocol. WSL backends never get @@ -248,6 +250,7 @@ export const make = Effect.gen(function* () { environmentIcon: true, projectCloneTracking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), + ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old // per-update request flag is not wired into the V2 update RPC path. ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e001dad666d8..06683feef8c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2991,6 +2991,7 @@ export default function ChatView(props: ChatViewProps) { }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]); const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation; const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); const serverUpdateState = useAtomValue( @@ -3112,6 +3113,7 @@ export default function ChatView(props: ChatViewProps) { environmentId={serverUpdateEnvironmentId} serverLabel={versionMismatchServerLabel} selfUpdate={versionMismatchSelfUpdate} + installation={versionMismatchInstallation} desktopAppUpdate={versionMismatchDesktopAppUpdate} threadContinuation={versionMismatchThreadContinuation} targetVersion={versionMismatch.clientVersion} @@ -3153,6 +3155,7 @@ export default function ChatView(props: ChatViewProps) { versionMismatchDismissKey, serverUpdateEnvironmentId, versionMismatchSelfUpdate, + versionMismatchInstallation, versionMismatchDesktopAppUpdate, versionMismatchThreadContinuation, versionMismatchServerLabel, diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 6c388bca4f98..116b7fdd21bb 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,7 +1,7 @@ import { act, type ReactElement } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { renderToStaticMarkup } from "react-dom/server"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -9,11 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), + clipboard: vi.fn(), continueThreadsAfterServerUpdate: false, })); vi.mock("~/hooks/useCopyToClipboard", () => ({ - useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }), + useCopyToClipboard: (options: { onCopy: (context: { command: string }) => void }) => ({ + copyToClipboard: (command: string, context: { command: string }) => { + testState.clipboard(command); + options.onCopy(context); + }, + }), })); vi.mock("~/hooks/useSettings", () => ({ useEnvironmentSettings: ( @@ -66,9 +72,51 @@ describe("ServerUpdateAction", () => { beforeEach(() => { testState.updateServer.mockReset(); testState.toast.mockReset(); + testState.clipboard.mockReset(); testState.continueThreadsAfterServerUpdate = false; }); + it.each([ + [ + { kind: "npm-global", prefix: "/opt/node" }, + "npm install --global --prefix '/opt/node' t3@0.0.45", + "Update command copied", + "then restart t3", + ], + [ + { kind: "npx" }, + "npx t3@0.0.45", + "Relaunch command copied", + "This does not update an installed t3 command.", + ], + [ + undefined, + "npx t3@0.0.45", + "Relaunch command copied", + "This does not update an installed t3 command.", + ], + ] satisfies ReadonlyArray)( + "copies an honest manual command for %j without invoking remote update", + (installation, command, title, guidance) => { + const action = ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: null, + installation, + targetVersion: "0.0.45", + }) as ActionElement; + action.props.onClick?.(); + expect(testState.clipboard).toHaveBeenCalledWith(command); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ + title, + description: expect.stringContaining(guidance), + }), + ); + expect(testState.updateServer).not.toHaveBeenCalled(); + }, + ); + it("reports success only after the shared update flow reconnects", async () => { testState.updateServer.mockResolvedValue( AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 82307156ba58..d828e929f932 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, + ServerInstallation, + ServerSelfUpdateCapability, +} from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { isAtomCommandInterrupted, @@ -39,6 +43,7 @@ export interface ServerUpdateTarget { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; readonly threadContinuation?: boolean; readonly targetVersion: string; @@ -188,6 +193,7 @@ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, + installation, desktopAppUpdate = false, threadContinuation = false, targetVersion, @@ -204,12 +210,16 @@ export function ServerUpdateAction({ ); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ - target: "update command", + target: installation?.kind === "npm-global" ? "update command" : "relaunch command", onCopy: ({ command }) => { toastManager.add({ type: "success", - title: "Update command copied", - description: `Run \`${command}\` on ${serverLabel} to update it.`, + title: + installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied", + description: + installation?.kind === "npm-global" + ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.` + : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`, }); }, onError: (error) => { @@ -256,8 +266,14 @@ export function ServerUpdateAction({ ); } - const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion) : null; - const actionLabel = manualCommand !== null ? "Copy update command" : label; + const manualCommand = + selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; + const actionLabel = + manualCommand !== null + ? installation?.kind === "npm-global" + ? "Copy update command" + : "Copy relaunch command" + : label; const onClick = manualCommand !== null ? () => copyToClipboard(manualCommand, { command: manualCommand }) diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx index 9784f22b7801..244638e8eebf 100644 --- a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -64,6 +64,7 @@ export function useAutoBalanceUpdateBanner( environmentId: environment.environmentId, serverLabel: environment.label, selfUpdate, + installation: environment.serverConfig?.environment.capabilities.serverInstallation, desktopAppUpdate, threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), continueThreadsAfterServerUpdate: diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e018e7941ec1..4a50af16cef5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1616,6 +1616,7 @@ function SavedBackendListRow({ environmentId={environmentId} serverLabel={`${environment.label} server`} selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} + installation={environment.serverConfig?.environment.capabilities.serverInstallation} desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} targetVersion={versionMismatch.clientVersion} @@ -1913,6 +1914,7 @@ export function ConnectionsSettings() { environmentId: environment.environmentId, serverLabel: environment.label, selfUpdate, + installation: environment.serverConfig?.environment.capabilities.serverInstallation, desktopAppUpdate, threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), continueThreadsAfterServerUpdate: @@ -3366,6 +3368,9 @@ export function ConnectionsSettings() { primaryEnvironment ? `${primaryEnvironment.label} server` : "server" } selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)} + installation={ + primaryServerConfig?.environment.capabilities.serverInstallation + } desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)} threadContinuation={supportsServerUpdateThreadContinuation( primaryServerConfig, diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index de7e98bc3531..89e19e83319b 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -15,6 +15,7 @@ import { dismissVersionMismatch, isServerUpdateFailureDismissed, isVersionMismatchDismissed, + manualServerUpdateCommand, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, resolveVersionMismatch, @@ -26,6 +27,21 @@ const MISMATCH_HINT = "Version mismatch. Try syncing the client and server to the same T3 Code version."; describe("versionSkew", () => { + it("updates only the proven npm prefix and safely quotes its path", () => { + expect(manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/node" })).toBe( + "npm install --global --prefix '/opt/node' t3@0.0.45", + ); + expect( + manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/maria's node" }), + ).toBe("npm install --global --prefix '/opt/maria'\\''s node' t3@0.0.45"); + }); + + it("keeps runner and unknown commands as relaunches", () => { + expect(manualServerUpdateCommand("0.0.45")).toBe("npx t3@0.0.45"); + expect(manualServerUpdateCommand("0.0.45", { kind: "npx" })).toBe("npx t3@0.0.45"); + expect(manualServerUpdateCommand("0.0.45", { kind: "pnpm-dlx" })).toBe("pnpm dlx t3@0.0.45"); + expect(manualServerUpdateCommand("0.0.45", { kind: "bunx" })).toBe("bunx t3@0.0.45"); + }); beforeEach(() => { branding.APP_VERSION = "0.0.34"; }); diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 2cb4c6b387cc..ca8891a84a39 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,4 +1,9 @@ -import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import type { + EnvironmentId, + ServerConfig, + ServerInstallation, + ServerSelfUpdateCapability, +} from "@t3tools/contracts"; import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; @@ -115,8 +120,17 @@ export function supportsServerUpdateThreadContinuation( } /** The command to hand users whose server cannot update itself. */ -export function manualServerUpdateCommand(targetVersion: string): string { - return `npx t3@${targetVersion}`; +export function manualServerUpdateCommand( + targetVersion: string, + installation?: ServerInstallation, +): string { + if (installation?.kind === "npm-global") { + const prefix = `'${installation.prefix.replaceAll("'", "'\\''")}'`; + return `npm install --global --prefix ${prefix} t3@${targetVersion}`; + } + const runner = + installation?.kind === "pnpm-dlx" ? "pnpm dlx" : installation?.kind === "bunx" ? "bunx" : "npx"; + return `${runner} t3@${targetVersion}`; } export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { diff --git a/docs/user/updating.md b/docs/user/updating.md index c0f05fc89351..3461dae9844c 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -44,7 +44,8 @@ The offered action depends on how the server runs: | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Update server** | Keep the client open while it installs and reconnects. Supported background services update remotely. For a desktop-hosted server, this also closes and relaunches the desktop app on the host. | | **Update the desktop app** | Update the desktop app on the machine running the server, then reopen it if needed. | -| **Copy update command** | Stop the command-line server on its host and relaunch with the copied command, keeping your usual startup options. | +| **Copy update command** | Run the command on the named host to update the detected global npm install, then restart the server with your usual options. | +| **Copy relaunch command** | Stop the command-line server on its host and relaunch with the copied command, keeping your usual subcommand and options. This does not update an installed `t3` command. | On the host, run: diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3f624adf6fe2..96e176bccba1 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -14,6 +14,25 @@ const descriptor = { } as const; describe("ExecutionEnvironmentDescriptor", () => { + it("decodes old, recognized and future manual installation descriptors", () => { + expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined(); + for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) { + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, serverInstallation: installation }, + }).capabilities.serverInstallation, + ).toEqual(installation); + } + for (const installation of [{ kind: "future-manager" }, { kind: "npm-global" }]) { + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, serverInstallation: installation }, + }).capabilities.serverInstallation, + ).toBeUndefined(); + } + }); it("requires an advertised required-worktree bootstrap capability", () => { expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined(); expect( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9a31f0888e67..0fdbbe6d9fd6 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -77,6 +77,13 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; +/** Proven ownership for a manual update; unknown installs omit this descriptor. */ +export const ServerInstallation = Schema.Union([ + Schema.Struct({ kind: Schema.Literals(["npx", "pnpm-dlx", "bunx"]) }), + Schema.Struct({ kind: Schema.Literal("npm-global"), prefix: TrimmedNonEmptyString }), +]); +export type ServerInstallation = typeof ServerInstallation.Type; + /** What update path a client should offer for a server: one of the RPC self-update methods above, or "desktop-managed" when the backend's version belongs to the T3 Code desktop app supervising it — updating the @@ -171,6 +178,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), + /** Manual commands must update this install, not the host's default global prefix. */ + serverInstallation: ForwardCompatibleOptional(ServerInstallation), /** Server can stream self-update progress before acknowledging the restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), From b9665b6fe018cdf521680d9cc1ae63ce0d196c66 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 07:22:14 +0000 Subject: [PATCH 2/2] fix(server): prioritize global installation ownership --- apps/server/src/cli/invocation.test.ts | 6 ++++-- apps/server/src/cli/invocation.ts | 16 +++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index e15d0f827055..4c20f6106afa 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -127,7 +127,8 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const prefix = yield* fs.makeTempDirectoryScoped(); + const root = yield* fs.makeTempDirectoryScoped(); + const prefix = path.join(root, "bunx-tools"); const packageRoot = path.join(prefix, "lib/node_modules/t3"); const entry = path.join(packageRoot, "dist/bin.mjs"); const globalBin = path.join(prefix, "bin/t3"); @@ -157,7 +158,8 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const prefix = yield* fs.makeTempDirectoryScoped(); + const root = yield* fs.makeTempDirectoryScoped(); + const prefix = path.join(root, "bunx-tools"); const packageRoot = path.join(prefix, "lib/node_modules/t3"); const launcher = path.join(packageRoot, "bin/t3.js"); const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3"); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index f89ae9401ba7..96c367f92ebf 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -62,17 +62,19 @@ export const resolveServerInstallation = Effect.gen(function* () { const executablePath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? "")); - const runner = detectCliRunner(entry); - if (runner !== null) { - return { kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation; - } - // Windows shims and other package managers need their own ownership proof. - if (platform === "win32") return null; const match = /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec( entry, ); - if (!match) return null; + if (!match) { + const runner = detectCliRunner(entry); + return runner === null + ? null + : ({ kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation); + } + // A global prefix can contain runner-like names; prove its ownership first. + // Windows shims and other package managers need their own ownership proof. + if (platform === "win32") return null; const prefix = match[1] || "/"; if ( prefix.includes("/node_modules/") ||