authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 09:07:47-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 09:07:47-03:00
log3b0093d7164a59bea721e2582e385e984561cd41
treed50d307714b57dd89afac6251bf0aed28aa0dd7e
parent0ce43a4eea2f21fe404639c248fb86328c410c5d
signature Signed by PGP key B5690EEEBB952194

fix(server): match manual update commands to installed cli (#15539)


14 files changed, 398 insertions(+), 15 deletions(-)

apps/server/src/cli/invocation.test.ts+132-2
......@@ -1,6 +1,16 @@
1import { assert, it } from "@effect/vitest";
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, expect, it } from "@effect/vitest";
3import {
4 HostProcessArguments,
5 HostProcessExecutablePath,
6 HostProcessIsExecutable,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
212
3import { formatCliCommand } from "./invocation.ts";
13import { formatCliCommand, resolveServerInstallation } from "./invocation.ts";
414
515it("formats package runner commands from their cache entry paths", () => {
616 for (const [entryPath, expected] of [
......@@ -87,3 +97,123 @@ it("formats serve suggestions to match the launching command", () => {
8797 "t3 serve",
8898 );
8999});
100
101it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
102 it.effect("recognizes runner caches for both script and executable packages", () =>
103 Effect.gen(function* () {
104 const fs = yield* FileSystem.FileSystem;
105 const path = yield* Path.Path;
106 const root = yield* fs.makeTempDirectoryScoped();
107 for (const [relative, kind] of [
108 ["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"],
109 ["npm/_npx/hash/node_modules/@t3code/t3-linux-x64/t3", "npx"],
110 ["pnpm/dlx/hash/node_modules/t3/dist/bin.mjs", "pnpm-dlx"],
111 [".bun/install/cache/t3/dist/bin.mjs", "bunx"],
112 ] as const) {
113 const entry = path.join(root, relative);
114 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
115 yield* fs.writeFileString(entry, "");
116 const installation = yield* resolveServerInstallation.pipe(
117 Effect.provideService(HostProcessArguments, ["node", entry]),
118 Effect.provideService(HostProcessExecutablePath, entry),
119 Effect.provideService(HostProcessIsExecutable, entry.endsWith("/t3")),
120 );
121 expect(installation).toEqual({ kind });
122 }
123 }),
124 );
125
126 it.effect("requires the npm prefix's bin to point to the running package", () =>
127 Effect.gen(function* () {
128 const fs = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;
130 const root = yield* fs.makeTempDirectoryScoped();
131 const prefix = path.join(root, "bunx-tools");
132 const packageRoot = path.join(prefix, "lib/node_modules/t3");
133 const entry = path.join(packageRoot, "dist/bin.mjs");
134 const globalBin = path.join(prefix, "bin/t3");
135 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
136 yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true });
137 yield* fs.writeFileString(entry, "");
138 yield* fs.writeFileString(
139 path.join(packageRoot, "package.json"),
140 '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
141 );
142 const resolve = resolveServerInstallation.pipe(
143 Effect.provideService(HostProcessArguments, ["node", entry]),
144 Effect.provideService(HostProcessIsExecutable, false),
145 Effect.provideService(HostProcessPlatform, "linux"),
146 );
147 expect(yield* resolve).toBeNull();
148 yield* fs.symlink(entry, globalBin);
149 expect(yield* resolve).toEqual({ kind: "npm-global", prefix });
150 yield* fs.remove(globalBin);
151 yield* fs.writeFileString(globalBin, "an unrelated t3 command");
152 expect(yield* resolve).toBeNull();
153 expect(yield* resolve.pipe(Effect.provideService(HostProcessPlatform, "win32"))).toBeNull();
154 }),
155 );
156
157 it.effect("proves the native executable belongs to the npm launcher", () =>
158 Effect.gen(function* () {
159 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161 const root = yield* fs.makeTempDirectoryScoped();
162 const prefix = path.join(root, "bunx-tools");
163 const packageRoot = path.join(prefix, "lib/node_modules/t3");
164 const launcher = path.join(packageRoot, "bin/t3.js");
165 const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3");
166 yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
167 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
168 yield* fs.makeDirectory(path.join(prefix, "bin"));
169 yield* fs.writeFileString(launcher, "");
170 yield* fs.writeFileString(entry, "");
171 yield* fs.writeFileString(
172 path.join(packageRoot, "package.json"),
173 '{"name":"t3","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@t3code/t3-linux-x64":"0.0.45"}}',
174 );
175 yield* fs.symlink(launcher, path.join(prefix, "bin/t3"));
176 const resolve = resolveServerInstallation.pipe(
177 Effect.provideService(HostProcessExecutablePath, entry),
178 Effect.provideService(HostProcessIsExecutable, true),
179 Effect.provideService(HostProcessPlatform, "linux"),
180 );
181 for (const [version, expected] of [
182 ["0.0.44", null],
183 ["0.0.45", { kind: "npm-global", prefix }],
184 ]) {
185 yield* fs.writeFileString(
186 path.join(path.dirname(entry), "package.json"),
187 `{"name":"@t3code/t3-linux-x64","version":"${version}"}`,
188 );
189 expect(yield* resolve).toEqual(expected);
190 }
191 }),
192 );
193
194 it.effect("leaves local, standalone, missing and unreadable installs unknown", () =>
195 Effect.gen(function* () {
196 const fs = yield* FileSystem.FileSystem;
197 const path = yield* Path.Path;
198 const root = yield* fs.makeTempDirectoryScoped();
199 for (const relative of [
200 "project/node_modules/t3/dist/bin.mjs",
201 "project/apps/server/dist/bin.mjs",
202 ".t3/runtime/0.0.45/t3",
203 "missing/dist/bin.mjs",
204 ]) {
205 const entry = path.join(root, relative);
206 if (!relative.startsWith("missing")) {
207 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
208 yield* fs.writeFileString(entry, "");
209 }
210 expect(
211 yield* resolveServerInstallation.pipe(
212 Effect.provideService(HostProcessArguments, ["node", entry]),
213 Effect.provideService(HostProcessIsExecutable, false),
214 ),
215 ).toBeNull();
216 }
217 }),
218 );
219});
apps/server/src/cli/invocation.ts+72-1
......@@ -1,6 +1,15 @@
11import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Schema from "effect/Schema";
25
3import { HostProcessArguments } from "@t3tools/shared/hostProcess";
6import type { ServerInstallation } from "@t3tools/contracts";
7import {
8 HostProcessArguments,
9 HostProcessExecutablePath,
10 HostProcessIsExecutable,
11 HostProcessPlatform,
12} from "@t3tools/shared/hostProcess";
413
514import packageJson from "../../package.json" with { type: "json" };
615
......@@ -36,6 +45,68 @@ function detectCliRunner(entryPath: string): CliRunner | null {
3645 return null;
3746}
3847
48const InstallManifest = Schema.Struct({
49 name: Schema.String,
50 version: Schema.String,
51 bin: Schema.optionalKey(Schema.Struct({ t3: Schema.String })),
52 optionalDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
53});
54const decodeInstallManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(InstallManifest));
55
56/** Prove the running package and its global bin belong together before suggesting an update. */
57export const resolveServerInstallation = Effect.gen(function* () {
58 const fs = yield* FileSystem.FileSystem;
59 const path = yield* Path.Path;
60 const args = yield* HostProcessArguments;
61 const executable = yield* HostProcessIsExecutable;
62 const executablePath = yield* HostProcessExecutablePath;
63 const platform = yield* HostProcessPlatform;
64 const entry = yield* fs.realPath(executable ? executablePath : (args[1] ?? ""));
65 const match =
66 /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.js|node_modules\/@t3code\/t3-[^/]+\/t3)$/.exec(
67 entry,
68 );
69 if (!match) {
70 const runner = detectCliRunner(entry);
71 return runner === null
72 ? null
73 : ({ kind: runner === "pnpm dlx" ? "pnpm-dlx" : runner } satisfies ServerInstallation);
74 }
75 // A global prefix can contain runner-like names; prove its ownership first.
76 // Windows shims and other package managers need their own ownership proof.
77 if (platform === "win32") return null;
78 const prefix = match[1] || "/";
79 if (
80 prefix.includes("/node_modules/") ||
81 /\/(?:Cellar|Caskroom)\//i.test(prefix) ||
82 /\/mise\/installs\/(?!node\/)[^/]+\//.test(prefix)
83 )
84 return null;
85
86 const packageRoot = path.join(prefix, "lib/node_modules/t3");
87 const manifest = yield* fs
88 .readFileString(path.join(packageRoot, "package.json"))
89 .pipe(Effect.flatMap(decodeInstallManifest));
90 if (manifest.name !== "t3" || !manifest.bin) return null;
91 const bin = yield* fs.realPath(path.join(packageRoot, manifest.bin.t3));
92 const globalBin = yield* fs.realPath(path.join(prefix, "bin/t3"));
93 if (globalBin !== bin) return null;
94 if (executable) {
95 const nativeManifest = yield* fs
96 .readFileString(path.join(path.dirname(entry), "package.json"))
97 .pipe(Effect.flatMap(decodeInstallManifest));
98 if (
99 manifest.bin.t3 !== "./bin/t3.js" ||
100 manifest.optionalDependencies?.[nativeManifest.name] !== nativeManifest.version ||
101 nativeManifest.version !== manifest.version
102 )
103 return null;
104 } else if (bin !== entry) {
105 return null;
106 }
107 return { kind: "npm-global", prefix } satisfies ServerInstallation;
108}).pipe(Effect.orElseSucceed(() => null));
109
39110/**
40111 * The `t3` package spec to suggest. The literal spec the user typed (e.g.
41112 * `t3@nightly`) is resolved away before our process starts, so re-derive it
apps/server/src/environment/ServerEnvironment.test.ts+47
......@@ -10,6 +10,12 @@ import * as PlatformError from "effect/PlatformError";
1010import * as Schema from "effect/Schema";
1111
1212import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
13import {
14 HostProcessArguments,
15 HostProcessEnvironment,
16 HostProcessIsExecutable,
17 HostProcessPlatform,
18} from "@t3tools/shared/hostProcess";
1319import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1420
1521import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
......@@ -79,6 +85,47 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
7985});
8086
8187it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
88 it.effect("publishes proven install ownership only for manually updated servers", () =>
89 Effect.gen(function* () {
90 const fs = yield* FileSystem.FileSystem;
91 const baseDir = yield* fs.makeTempDirectoryScoped();
92 const prefix = `${baseDir}/node`;
93 const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`;
94 yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true });
95 yield* fs.makeDirectory(`${prefix}/bin`, { recursive: true });
96 yield* fs.writeFileString(entry, "");
97 yield* fs.writeFileString(
98 `${prefix}/lib/node_modules/t3/package.json`,
99 '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
100 );
101 yield* fs.symlink(entry, `${prefix}/bin/t3`);
102 const config = yield* makeServerConfig(baseDir);
103 yield* fs.makeDirectory(config.stateDir, { recursive: true });
104 for (const mode of ["web", "desktop"] as const) {
105 const descriptor = yield* Effect.gen(function* () {
106 const environment = yield* ServerEnvironment.ServerEnvironment;
107 return yield* environment.getDescriptor;
108 }).pipe(
109 Effect.provide(
110 ServerEnvironment.layer.pipe(
111 Layer.provide(emptySecretStoreLayer),
112 Layer.provide(ServerConfig.layer({ ...config, mode })),
113 ),
114 ),
115 Effect.provideService(HostProcessArguments, ["node", entry]),
116 Effect.provideService(HostProcessIsExecutable, false),
117 Effect.provideService(HostProcessPlatform, "linux"),
118 Effect.provideService(HostProcessEnvironment, {}),
119 );
120 expect(descriptor.capabilities.serverInstallation).toEqual(
121 mode === "web" ? { kind: "npm-global", prefix } : undefined,
122 );
123 expect(descriptor.capabilities.serverSelfUpdate).toBe(
124 mode === "web" ? undefined : "desktop-managed",
125 );
126 }
127 }),
128 );
82129 it.effect.each([
83130 { name: "missing", content: undefined },
84131 { name: "empty", content: "" },
apps/server/src/environment/ServerEnvironment.ts+3
......@@ -15,6 +15,7 @@ import * as Schema from "effect/Schema";
1515
1616import packageJson from "../../package.json" with { type: "json" };
1717import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18import { resolveServerInstallation } from "../cli/invocation.ts";
1819import { readAgentActivityPublishingActive } from "../cloud/config.ts";
1920import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
2021import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
......@@ -196,6 +197,7 @@ export const make = Effect.gen(function* () {
196197 desktopManaged: serverConfig.mode === "desktop",
197198 launcherManaged: launcher.managed,
198199 });
200 const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null;
199201 // Static is correct: the control fd is known at bootstrap, and the desktop
200202 // app and its bundled server ship in one artifact, so a present fd means
201203 // the app speaks the requestDesktopUpdate protocol. WSL backends never get
......@@ -248,6 +250,7 @@ export const make = Effect.gen(function* () {
248250 environmentIcon: true,
249251 projectCloneTracking: true,
250252 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
253 ...(serverInstallation === null ? {} : { serverInstallation }),
251254 // V2 restart recovery uses the environment-owned opt-in. The old
252255 // per-update request flag is not wired into the V2 update RPC path.
253256 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
apps/web/src/components/ChatView.tsx+3
......@@ -2992,6 +2992,7 @@ export default function ChatView(props: ChatViewProps) {
29922992 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
29932993 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
29942994 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2995 const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;
29952996 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
29962997 const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
29972998 const serverUpdateState = useAtomValue(
......@@ -3113,6 +3114,7 @@ export default function ChatView(props: ChatViewProps) {
31133114 environmentId={serverUpdateEnvironmentId}
31143115 serverLabel={versionMismatchServerLabel}
31153116 selfUpdate={versionMismatchSelfUpdate}
3117 installation={versionMismatchInstallation}
31163118 desktopAppUpdate={versionMismatchDesktopAppUpdate}
31173119 threadContinuation={versionMismatchThreadContinuation}
31183120 targetVersion={versionMismatch.clientVersion}
......@@ -3154,6 +3156,7 @@ export default function ChatView(props: ChatViewProps) {
31543156 versionMismatchDismissKey,
31553157 serverUpdateEnvironmentId,
31563158 versionMismatchSelfUpdate,
3159 versionMismatchInstallation,
31573160 versionMismatchDesktopAppUpdate,
31583161 versionMismatchThreadContinuation,
31593162 versionMismatchServerLabel,
apps/web/src/components/ServerUpdateAction.test.tsx+50-2
......@@ -1,7 +1,7 @@
11import { act, type ReactElement } from "react";
22import { create, type ReactTestRenderer } from "react-test-renderer";
33import { renderToStaticMarkup } from "react-dom/server";
4import type { EnvironmentId } from "@t3tools/contracts";
4import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts";
55import * as Cause from "effect/Cause";
66import { AsyncResult } from "effect/unstable/reactivity";
77import { 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"
99const testState = vi.hoisted(() => ({
1010 updateServer: vi.fn(),
1111 toast: vi.fn(),
12 clipboard: vi.fn(),
1213 continueThreadsAfterServerUpdate: false,
1314}));
1415
1516vi.mock("~/hooks/useCopyToClipboard", () => ({
16 useCopyToClipboard: () => ({ copyToClipboard: vi.fn() }),
17 useCopyToClipboard: (options: { onCopy: (context: { command: string }) => void }) => ({
18 copyToClipboard: (command: string, context: { command: string }) => {
19 testState.clipboard(command);
20 options.onCopy(context);
21 },
22 }),
1723}));
1824vi.mock("~/hooks/useSettings", () => ({
1925 useEnvironmentSettings: (
......@@ -66,9 +72,51 @@ describe("ServerUpdateAction", () => {
6672 beforeEach(() => {
6773 testState.updateServer.mockReset();
6874 testState.toast.mockReset();
75 testState.clipboard.mockReset();
6976 testState.continueThreadsAfterServerUpdate = false;
7077 });
7178
79 it.each([
80 [
81 { kind: "npm-global", prefix: "/opt/node" },
82 "npm install --global --prefix '/opt/node' t3@0.0.45",
83 "Update command copied",
84 "then restart t3",
85 ],
86 [
87 { kind: "npx" },
88 "npx t3@0.0.45",
89 "Relaunch command copied",
90 "This does not update an installed t3 command.",
91 ],
92 [
93 undefined,
94 "npx t3@0.0.45",
95 "Relaunch command copied",
96 "This does not update an installed t3 command.",
97 ],
98 ] satisfies ReadonlyArray<readonly [ServerInstallation | undefined, string, string, string]>)(
99 "copies an honest manual command for %j without invoking remote update",
100 (installation, command, title, guidance) => {
101 const action = ServerUpdateAction({
102 environmentId: "env-test" as EnvironmentId,
103 serverLabel: "Test server",
104 selfUpdate: null,
105 installation,
106 targetVersion: "0.0.45",
107 }) as ActionElement;
108 action.props.onClick?.();
109 expect(testState.clipboard).toHaveBeenCalledWith(command);
110 expect(testState.toast).toHaveBeenCalledWith(
111 expect.objectContaining({
112 title,
113 description: expect.stringContaining(guidance),
114 }),
115 );
116 expect(testState.updateServer).not.toHaveBeenCalled();
117 },
118 );
119
72120 it("reports success only after the shared update flow reconnects", async () => {
73121 testState.updateServer.mockResolvedValue(
74122 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
apps/web/src/components/ServerUpdateAction.tsx+22-6
......@@ -1,4 +1,8 @@
1import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
1import type {
2 EnvironmentId,
3 ServerInstallation,
4 ServerSelfUpdateCapability,
5} from "@t3tools/contracts";
26import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
37import {
48 isAtomCommandInterrupted,
......@@ -39,6 +43,7 @@ export interface ServerUpdateTarget {
3943 readonly environmentId: EnvironmentId;
4044 readonly serverLabel: string;
4145 readonly selfUpdate: ServerSelfUpdateCapability | null;
46 readonly installation?: ServerInstallation | undefined;
4247 readonly desktopAppUpdate?: boolean;
4348 readonly threadContinuation?: boolean;
4449 readonly targetVersion: string;
......@@ -188,6 +193,7 @@ export function ServerUpdateAction({
188193 environmentId,
189194 serverLabel,
190195 selfUpdate,
196 installation,
191197 desktopAppUpdate = false,
192198 threadContinuation = false,
193199 targetVersion,
......@@ -204,12 +210,16 @@ export function ServerUpdateAction({
204210 );
205211 const update = useServerUpdate();
206212 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
207 target: "update command",
213 target: installation?.kind === "npm-global" ? "update command" : "relaunch command",
208214 onCopy: ({ command }) => {
209215 toastManager.add({
210216 type: "success",
211 title: "Update command copied",
212 description: `Run \`${command}\` on ${serverLabel} to update it.`,
217 title:
218 installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied",
219 description:
220 installation?.kind === "npm-global"
221 ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.`
222 : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`,
213223 });
214224 },
215225 onError: (error) => {
......@@ -256,8 +266,14 @@ export function ServerUpdateAction({
256266 );
257267 }
258268
259 const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion) : null;
260 const actionLabel = manualCommand !== null ? "Copy update command" : label;
269 const manualCommand =
270 selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null;
271 const actionLabel =
272 manualCommand !== null
273 ? installation?.kind === "npm-global"
274 ? "Copy update command"
275 : "Copy relaunch command"
276 : label;
261277 const onClick =
262278 manualCommand !== null
263279 ? () => copyToClipboard(manualCommand, { command: manualCommand })
apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx+1
......@@ -64,6 +64,7 @@ export function useAutoBalanceUpdateBanner(
6464 environmentId: environment.environmentId,
6565 serverLabel: environment.label,
6666 selfUpdate,
67 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
6768 desktopAppUpdate,
6869 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
6970 continueThreadsAfterServerUpdate:
apps/web/src/components/settings/ConnectionsSettings.tsx+5
......@@ -1616,6 +1616,7 @@ function SavedBackendListRow({
16161616 environmentId={environmentId}
16171617 serverLabel={`${environment.label} server`}
16181618 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1619 installation={environment.serverConfig?.environment.capabilities.serverInstallation}
16191620 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
16201621 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
16211622 targetVersion={versionMismatch.clientVersion}
......@@ -1913,6 +1914,7 @@ export function ConnectionsSettings() {
19131914 environmentId: environment.environmentId,
19141915 serverLabel: environment.label,
19151916 selfUpdate,
1917 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
19161918 desktopAppUpdate,
19171919 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
19181920 continueThreadsAfterServerUpdate:
......@@ -3366,6 +3368,9 @@ export function ConnectionsSettings() {
33663368 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
33673369 }
33683370 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3371 installation={
3372 primaryServerConfig?.environment.capabilities.serverInstallation
3373 }
33693374 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
33703375 threadContinuation={supportsServerUpdateThreadContinuation(
33713376 primaryServerConfig,
apps/web/src/versionSkew.test.ts+16
......@@ -15,6 +15,7 @@ import {
1515 dismissVersionMismatch,
1616 isServerUpdateFailureDismissed,
1717 isVersionMismatchDismissed,
18 manualServerUpdateCommand,
1819 resolveServerConfigVersionMismatch,
1920 resolveServerSelfUpdateCapability,
2021 resolveVersionMismatch,
......@@ -26,6 +27,21 @@ const MISMATCH_HINT =
2627 "Version mismatch. Try syncing the client and server to the same T3 Code version.";
2728
2829describe("versionSkew", () => {
30 it("updates only the proven npm prefix and safely quotes its path", () => {
31 expect(manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/node" })).toBe(
32 "npm install --global --prefix '/opt/node' t3@0.0.45",
33 );
34 expect(
35 manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/maria's node" }),
36 ).toBe("npm install --global --prefix '/opt/maria'\\''s node' t3@0.0.45");
37 });
38
39 it("keeps runner and unknown commands as relaunches", () => {
40 expect(manualServerUpdateCommand("0.0.45")).toBe("npx t3@0.0.45");
41 expect(manualServerUpdateCommand("0.0.45", { kind: "npx" })).toBe("npx t3@0.0.45");
42 expect(manualServerUpdateCommand("0.0.45", { kind: "pnpm-dlx" })).toBe("pnpm dlx t3@0.0.45");
43 expect(manualServerUpdateCommand("0.0.45", { kind: "bunx" })).toBe("bunx t3@0.0.45");
44 });
2945 beforeEach(() => {
3046 branding.APP_VERSION = "0.0.34";
3147 });
apps/web/src/versionSkew.ts+17-3
......@@ -1,4 +1,9 @@
1import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
1import type {
2 EnvironmentId,
3 ServerConfig,
4 ServerInstallation,
5 ServerSelfUpdateCapability,
6} from "@t3tools/contracts";
27import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
38import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
49import * as Schema from "effect/Schema";
......@@ -115,8 +120,17 @@ export function supportsServerUpdateThreadContinuation(
115120}
116121
117122/** The command to hand users whose server cannot update itself. */
118export function manualServerUpdateCommand(targetVersion: string): string {
119 return `npx t3@${targetVersion}`;
123export function manualServerUpdateCommand(
124 targetVersion: string,
125 installation?: ServerInstallation,
126): string {
127 if (installation?.kind === "npm-global") {
128 const prefix = `'${installation.prefix.replaceAll("'", "'\\''")}'`;
129 return `npm install --global --prefix ${prefix} t3@${targetVersion}`;
130 }
131 const runner =
132 installation?.kind === "pnpm-dlx" ? "pnpm dlx" : installation?.kind === "bunx" ? "bunx" : "npx";
133 return `${runner} t3@${targetVersion}`;
120134}
121135
122136export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {
docs/user/updating.md+2-1
......@@ -44,7 +44,8 @@ The offered action depends on how the server runs:
4444| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4545| **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. |
4646| **Update the desktop app** | Update the desktop app on the machine running the server, then reopen it if needed. |
47| **Copy update command** | Stop the command-line server on its host and relaunch with the copied command, keeping your usual startup options. |
47| **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. |
48| **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. |
4849
4950On the host, run:
5051
packages/contracts/src/environment.test.ts+19
......@@ -14,6 +14,25 @@ const descriptor = {
1414} as const;
1515
1616describe("ExecutionEnvironmentDescriptor", () => {
17 it("decodes old, recognized and future manual installation descriptors", () => {
18 expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined();
19 for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) {
20 expect(
21 decodeDescriptor({
22 ...descriptor,
23 capabilities: { ...descriptor.capabilities, serverInstallation: installation },
24 }).capabilities.serverInstallation,
25 ).toEqual(installation);
26 }
27 for (const installation of [{ kind: "future-manager" }, { kind: "npm-global" }]) {
28 expect(
29 decodeDescriptor({
30 ...descriptor,
31 capabilities: { ...descriptor.capabilities, serverInstallation: installation },
32 }).capabilities.serverInstallation,
33 ).toBeUndefined();
34 }
35 });
1736 it("requires an advertised required-worktree bootstrap capability", () => {
1837 expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined();
1938 expect(
packages/contracts/src/environment.ts+9
......@@ -77,6 +77,13 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
7777export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]);
7878export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
7979
80/** Proven ownership for a manual update; unknown installs omit this descriptor. */
81export const ServerInstallation = Schema.Union([
82 Schema.Struct({ kind: Schema.Literals(["npx", "pnpm-dlx", "bunx"]) }),
83 Schema.Struct({ kind: Schema.Literal("npm-global"), prefix: TrimmedNonEmptyString }),
84]);
85export type ServerInstallation = typeof ServerInstallation.Type;
86
8087/** What update path a client should offer for a server: one of the RPC
8188 self-update methods above, or "desktop-managed" when the backend's
8289 version belongs to the T3 Code desktop app supervising it — updating the
......@@ -171,6 +178,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
171178 servers that must be relaunched manually (dev checkouts, Windows
172179 foreground runs, pre-update servers). */
173180 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),
181 /** Manual commands must update this install, not the host's default global prefix. */
182 serverInstallation: ForwardCompatibleOptional(ServerInstallation),
174183 /** Server can stream self-update progress before acknowledging the
175184 restart. Clients fall back to server.updateServer when absent. */
176185 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),