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