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 @@...@@ -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
5it("formats package runner commands from their cache entry paths", () => {15it("formats package runner commands from their cache entry paths", () => {
6 for (const [entryPath, expected] of [16 for (const [entryPath, expected] of [
...@@ -87,3 +97,123 @@ it("formats serve suggestions to match the launching command", () => {...@@ -87,3 +97,123 @@ it("formats serve suggestions to match the launching command", () => {
87 "t3 serve",97 "t3 serve",
88 );98 );
89});99});
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 @@...@@ -1,6 +1,15 @@
1import * as Effect from "effect/Effect";1import * 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
5import packageJson from "../../package.json" with { type: "json" };14import packageJson from "../../package.json" with { type: "json" };
615
...@@ -36,6 +45,68 @@ function detectCliRunner(entryPath: string): CliRunner | null {...@@ -36,6 +45,68 @@ function detectCliRunner(entryPath: string): CliRunner | null {
36 return null;45 return null;
37}46}
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
39/**110/**
40 * The `t3` package spec to suggest. The literal spec the user typed (e.g.111 * 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 it112 * `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";...@@ -10,6 +10,12 @@ import * as PlatformError from "effect/PlatformError";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
1111
12import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";12import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
13import {
14 HostProcessArguments,
15 HostProcessEnvironment,
16 HostProcessIsExecutable,
17 HostProcessPlatform,
18} from "@t3tools/shared/hostProcess";
13import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";19import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
1420
15import * as ServerSecretStore from "../auth/ServerSecretStore.ts";21import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
...@@ -79,6 +85,47 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -79,6 +85,47 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
79});85});
8086
81it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {87it.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 );
82 it.effect.each([129 it.effect.each([
83 { name: "missing", content: undefined },130 { name: "missing", content: undefined },
84 { name: "empty", content: "" },131 { name: "empty", content: "" },
apps/server/src/environment/ServerEnvironment.ts+3
...@@ -15,6 +15,7 @@ import * as Schema from "effect/Schema";...@@ -15,6 +15,7 @@ import * as Schema from "effect/Schema";
1515
16import packageJson from "../../package.json" with { type: "json" };16import packageJson from "../../package.json" with { type: "json" };
17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18import { resolveServerInstallation } from "../cli/invocation.ts";
18import { readAgentActivityPublishingActive } from "../cloud/config.ts";19import { readAgentActivityPublishingActive } from "../cloud/config.ts";
19import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";20import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
20import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";21import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
...@@ -196,6 +197,7 @@ export const make = Effect.gen(function* () {...@@ -196,6 +197,7 @@ export const make = Effect.gen(function* () {
196 desktopManaged: serverConfig.mode === "desktop",197 desktopManaged: serverConfig.mode === "desktop",
197 launcherManaged: launcher.managed,198 launcherManaged: launcher.managed,
198 });199 });
200 const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null;
199 // Static is correct: the control fd is known at bootstrap, and the desktop201 // Static is correct: the control fd is known at bootstrap, and the desktop
200 // app and its bundled server ship in one artifact, so a present fd means202 // app and its bundled server ship in one artifact, so a present fd means
201 // the app speaks the requestDesktopUpdate protocol. WSL backends never get203 // the app speaks the requestDesktopUpdate protocol. WSL backends never get
...@@ -248,6 +250,7 @@ export const make = Effect.gen(function* () {...@@ -248,6 +250,7 @@ export const make = Effect.gen(function* () {
248 environmentIcon: true,250 environmentIcon: true,
249 projectCloneTracking: true,251 projectCloneTracking: true,
250 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),252 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
253 ...(serverInstallation === null ? {} : { serverInstallation }),
251 // V2 restart recovery uses the environment-owned opt-in. The old254 // V2 restart recovery uses the environment-owned opt-in. The old
252 // per-update request flag is not wired into the V2 update RPC path.255 // per-update request flag is not wired into the V2 update RPC path.
253 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate256 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
apps/web/src/components/ChatView.tsx+3
...@@ -2992,6 +2992,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2992,6 +2992,7 @@ export default function ChatView(props: ChatViewProps) {
2992 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);2992 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
2993 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;2993 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
2994 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);2994 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2995 const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;
2995 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);2996 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
2996 const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);2997 const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
2997 const serverUpdateState = useAtomValue(2998 const serverUpdateState = useAtomValue(
...@@ -3113,6 +3114,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3113,6 +3114,7 @@ export default function ChatView(props: ChatViewProps) {
3113 environmentId={serverUpdateEnvironmentId}3114 environmentId={serverUpdateEnvironmentId}
3114 serverLabel={versionMismatchServerLabel}3115 serverLabel={versionMismatchServerLabel}
3115 selfUpdate={versionMismatchSelfUpdate}3116 selfUpdate={versionMismatchSelfUpdate}
3117 installation={versionMismatchInstallation}
3116 desktopAppUpdate={versionMismatchDesktopAppUpdate}3118 desktopAppUpdate={versionMismatchDesktopAppUpdate}
3117 threadContinuation={versionMismatchThreadContinuation}3119 threadContinuation={versionMismatchThreadContinuation}
3118 targetVersion={versionMismatch.clientVersion}3120 targetVersion={versionMismatch.clientVersion}
...@@ -3154,6 +3156,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3154,6 +3156,7 @@ export default function ChatView(props: ChatViewProps) {
3154 versionMismatchDismissKey,3156 versionMismatchDismissKey,
3155 serverUpdateEnvironmentId,3157 serverUpdateEnvironmentId,
3156 versionMismatchSelfUpdate,3158 versionMismatchSelfUpdate,
3159 versionMismatchInstallation,
3157 versionMismatchDesktopAppUpdate,3160 versionMismatchDesktopAppUpdate,
3158 versionMismatchThreadContinuation,3161 versionMismatchThreadContinuation,
3159 versionMismatchServerLabel,3162 versionMismatchServerLabel,
apps/web/src/components/ServerUpdateAction.test.tsx+50-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { act, type ReactElement } from "react";1import { act, type ReactElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";5import * as Cause from "effect/Cause";
6import { AsyncResult } from "effect/unstable/reactivity";6import { AsyncResult } from "effect/unstable/reactivity";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";7import { 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"...@@ -9,11 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
9const testState = vi.hoisted(() => ({9const testState = vi.hoisted(() => ({
10 updateServer: vi.fn(),10 updateServer: vi.fn(),
11 toast: vi.fn(),11 toast: vi.fn(),
12 clipboard: vi.fn(),
12 continueThreadsAfterServerUpdate: false,13 continueThreadsAfterServerUpdate: false,
13}));14}));
1415
15vi.mock("~/hooks/useCopyToClipboard", () => ({16vi.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 }),
17}));23}));
18vi.mock("~/hooks/useSettings", () => ({24vi.mock("~/hooks/useSettings", () => ({
19 useEnvironmentSettings: (25 useEnvironmentSettings: (
...@@ -66,9 +72,51 @@ describe("ServerUpdateAction", () => {...@@ -66,9 +72,51 @@ describe("ServerUpdateAction", () => {
66 beforeEach(() => {72 beforeEach(() => {
67 testState.updateServer.mockReset();73 testState.updateServer.mockReset();
68 testState.toast.mockReset();74 testState.toast.mockReset();
75 testState.clipboard.mockReset();
69 testState.continueThreadsAfterServerUpdate = false;76 testState.continueThreadsAfterServerUpdate = false;
70 });77 });
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
72 it("reports success only after the shared update flow reconnects", async () => {120 it("reports success only after the shared update flow reconnects", async () => {
73 testState.updateServer.mockResolvedValue(121 testState.updateServer.mockResolvedValue(
74 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),122 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
apps/web/src/components/ServerUpdateAction.tsx+22-6
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";1import type {
2 EnvironmentId,
3 ServerInstallation,
4 ServerSelfUpdateCapability,
5} from "@t3tools/contracts";
2import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";6import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import {7import {
4 isAtomCommandInterrupted,8 isAtomCommandInterrupted,
...@@ -39,6 +43,7 @@ export interface ServerUpdateTarget {...@@ -39,6 +43,7 @@ export interface ServerUpdateTarget {
39 readonly environmentId: EnvironmentId;43 readonly environmentId: EnvironmentId;
40 readonly serverLabel: string;44 readonly serverLabel: string;
41 readonly selfUpdate: ServerSelfUpdateCapability | null;45 readonly selfUpdate: ServerSelfUpdateCapability | null;
46 readonly installation?: ServerInstallation | undefined;
42 readonly desktopAppUpdate?: boolean;47 readonly desktopAppUpdate?: boolean;
43 readonly threadContinuation?: boolean;48 readonly threadContinuation?: boolean;
44 readonly targetVersion: string;49 readonly targetVersion: string;
...@@ -188,6 +193,7 @@ export function ServerUpdateAction({...@@ -188,6 +193,7 @@ export function ServerUpdateAction({
188 environmentId,193 environmentId,
189 serverLabel,194 serverLabel,
190 selfUpdate,195 selfUpdate,
196 installation,
191 desktopAppUpdate = false,197 desktopAppUpdate = false,
192 threadContinuation = false,198 threadContinuation = false,
193 targetVersion,199 targetVersion,
...@@ -204,12 +210,16 @@ export function ServerUpdateAction({...@@ -204,12 +210,16 @@ export function ServerUpdateAction({
204 );210 );
205 const update = useServerUpdate();211 const update = useServerUpdate();
206 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({212 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
207 target: "update command",213 target: installation?.kind === "npm-global" ? "update command" : "relaunch command",
208 onCopy: ({ command }) => {214 onCopy: ({ command }) => {
209 toastManager.add({215 toastManager.add({
210 type: "success",216 type: "success",
211 title: "Update command copied",217 title:
212 description: `Run \`${command}\` on ${serverLabel} to update it.`,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.`,
213 });223 });
214 },224 },
215 onError: (error) => {225 onError: (error) => {
...@@ -256,8 +266,14 @@ export function ServerUpdateAction({...@@ -256,8 +266,14 @@ export function ServerUpdateAction({
256 );266 );
257 }267 }
258268
259 const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion) : null;269 const manualCommand =
260 const actionLabel = manualCommand !== null ? "Copy update command" : label;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;
261 const onClick =277 const onClick =
262 manualCommand !== null278 manualCommand !== null
263 ? () => copyToClipboard(manualCommand, { command: manualCommand })279 ? () => copyToClipboard(manualCommand, { command: manualCommand })
apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx+1
...@@ -64,6 +64,7 @@ export function useAutoBalanceUpdateBanner(...@@ -64,6 +64,7 @@ export function useAutoBalanceUpdateBanner(
64 environmentId: environment.environmentId,64 environmentId: environment.environmentId,
65 serverLabel: environment.label,65 serverLabel: environment.label,
66 selfUpdate,66 selfUpdate,
67 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
67 desktopAppUpdate,68 desktopAppUpdate,
68 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),69 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
69 continueThreadsAfterServerUpdate:70 continueThreadsAfterServerUpdate:
apps/web/src/components/settings/ConnectionsSettings.tsx+5
...@@ -1616,6 +1616,7 @@ function SavedBackendListRow({...@@ -1616,6 +1616,7 @@ function SavedBackendListRow({
1616 environmentId={environmentId}1616 environmentId={environmentId}
1617 serverLabel={`${environment.label} server`}1617 serverLabel={`${environment.label} server`}
1618 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}1618 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1619 installation={environment.serverConfig?.environment.capabilities.serverInstallation}
1619 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}1620 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1620 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}1621 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
1621 targetVersion={versionMismatch.clientVersion}1622 targetVersion={versionMismatch.clientVersion}
...@@ -1913,6 +1914,7 @@ export function ConnectionsSettings() {...@@ -1913,6 +1914,7 @@ export function ConnectionsSettings() {
1913 environmentId: environment.environmentId,1914 environmentId: environment.environmentId,
1914 serverLabel: environment.label,1915 serverLabel: environment.label,
1915 selfUpdate,1916 selfUpdate,
1917 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
1916 desktopAppUpdate,1918 desktopAppUpdate,
1917 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),1919 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
1918 continueThreadsAfterServerUpdate:1920 continueThreadsAfterServerUpdate:
...@@ -3366,6 +3368,9 @@ export function ConnectionsSettings() {...@@ -3366,6 +3368,9 @@ export function ConnectionsSettings() {
3366 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"3368 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3367 }3369 }
3368 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}3370 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3371 installation={
3372 primaryServerConfig?.environment.capabilities.serverInstallation
3373 }
3369 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}3374 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3370 threadContinuation={supportsServerUpdateThreadContinuation(3375 threadContinuation={supportsServerUpdateThreadContinuation(
3371 primaryServerConfig,3376 primaryServerConfig,
apps/web/src/versionSkew.test.ts+16
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 dismissVersionMismatch,15 dismissVersionMismatch,
16 isServerUpdateFailureDismissed,16 isServerUpdateFailureDismissed,
17 isVersionMismatchDismissed,17 isVersionMismatchDismissed,
18 manualServerUpdateCommand,
18 resolveServerConfigVersionMismatch,19 resolveServerConfigVersionMismatch,
19 resolveServerSelfUpdateCapability,20 resolveServerSelfUpdateCapability,
20 resolveVersionMismatch,21 resolveVersionMismatch,
...@@ -26,6 +27,21 @@ const MISMATCH_HINT =...@@ -26,6 +27,21 @@ const MISMATCH_HINT =
26 "Version mismatch. Try syncing the client and server to the same T3 Code version.";27 "Version mismatch. Try syncing the client and server to the same T3 Code version.";
2728
28describe("versionSkew", () => {29describe("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 });
29 beforeEach(() => {45 beforeEach(() => {
30 branding.APP_VERSION = "0.0.34";46 branding.APP_VERSION = "0.0.34";
31 });47 });
apps/web/src/versionSkew.ts+17-3
...@@ -1,4 +1,9 @@...@@ -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";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";7import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";8import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
4import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
...@@ -115,8 +120,17 @@ export function supportsServerUpdateThreadContinuation(...@@ -115,8 +120,17 @@ export function supportsServerUpdateThreadContinuation(
115}120}
116121
117/** The command to hand users whose server cannot update itself. */122/** The command to hand users whose server cannot update itself. */
118export function manualServerUpdateCommand(targetVersion: string): string {123export function manualServerUpdateCommand(
119 return `npx t3@${targetVersion}`;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}`;
120}134}
121135
122export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {136export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {
docs/user/updating.md+2-1
...@@ -44,7 +44,8 @@ The offered action depends on how the server runs:...@@ -44,7 +44,8 @@ The offered action depends on how the server runs:
44| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |44| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45| **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. |45| **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. |
46| **Update the desktop app** | Update the desktop app on the machine running the server, then reopen it if needed. |46| **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
49On the host, run:50On the host, run:
5051
packages/contracts/src/environment.test.ts+19
...@@ -14,6 +14,25 @@ const descriptor = {...@@ -14,6 +14,25 @@ const descriptor = {
14} as const;14} as const;
1515
16describe("ExecutionEnvironmentDescriptor", () => {16describe("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 });
17 it("requires an advertised required-worktree bootstrap capability", () => {36 it("requires an advertised required-worktree bootstrap capability", () => {
18 expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined();37 expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined();
19 expect(38 expect(
packages/contracts/src/environment.ts+9
...@@ -77,6 +77,13 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T...@@ -77,6 +77,13 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
77export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]);77export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]);
78export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;78export 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
80/** What update path a client should offer for a server: one of the RPC87/** What update path a client should offer for a server: one of the RPC
81 self-update methods above, or "desktop-managed" when the backend's88 self-update methods above, or "desktop-managed" when the backend's
82 version belongs to the T3 Code desktop app supervising it — updating the89 version belongs to the T3 Code desktop app supervising it — updating the
...@@ -171,6 +178,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -171,6 +178,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
171 servers that must be relaunched manually (dev checkouts, Windows178 servers that must be relaunched manually (dev checkouts, Windows
172 foreground runs, pre-update servers). */179 foreground runs, pre-update servers). */
173 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),180 serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability),
181 /** Manual commands must update this install, not the host's default global prefix. */
182 serverInstallation: ForwardCompatibleOptional(ServerInstallation),
174 /** Server can stream self-update progress before acknowledging the183 /** Server can stream self-update progress before acknowledging the
175 restart. Clients fall back to server.updateServer when absent. */184 restart. Clients fall back to server.updateServer when absent. */
176 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),185 serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),