| 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,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 | it("formats package runner commands from their cache entry paths", () => { | 15 | it("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 | |||
| 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,6 +1,15 @@ |
| 1 | import * as Effect from "effect/Effect"; | 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 | import packageJson from "../../package.json" with { type: "json" }; | 14 | import packageJson from "../../package.json" with { type: "json" }; |
| 6 | 15 | ||
| ... | @@ -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 | } |
| 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 | * 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 it | 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,6 +10,12 @@ import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | ||
| 12 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; | 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 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | 19 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; |
| 14 | 20 | ||
| 15 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 21 | import * 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 | }); |
| 80 | 86 | ||
| 81 | it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | 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 | 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"; |
| 15 | 15 | ||
| 16 | import packageJson from "../../package.json" with { type: "json" }; | 16 | import packageJson from "../../package.json" with { type: "json" }; |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | import { resolveServerInstallation } from "../cli/invocation.ts"; | ||
| 18 | import { readAgentActivityPublishingActive } from "../cloud/config.ts"; | 19 | import { readAgentActivityPublishingActive } from "../cloud/config.ts"; |
| 19 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; | 20 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; |
| 20 | import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; | 21 | import { 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 desktop | 201 | // 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 means | 202 | // app and its bundled server ship in one artifact, so a present fd means |
| 201 | // the app speaks the requestDesktopUpdate protocol. WSL backends never get | 203 | // 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 old | 254 | // 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" || desktopAppUpdate | 256 | ...(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 @@ |
| 1 | import { act, type ReactElement } from "react"; | 1 | import { act, type ReactElement } from "react"; |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts"; |
| 5 | import * as Cause from "effect/Cause"; | 5 | import * as Cause from "effect/Cause"; |
| 6 | import { AsyncResult } from "effect/unstable/reactivity"; | 6 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 7 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 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,11 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" |
| 9 | const testState = vi.hoisted(() => ({ | 9 | const 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 | })); |
| 14 | 15 | ||
| 15 | vi.mock("~/hooks/useCopyToClipboard", () => ({ | 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 | vi.mock("~/hooks/useSettings", () => ({ | 24 | vi.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 | }); |
| 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 | 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 @@ |
| 1 | import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; | 1 | import type { |
| 2 | EnvironmentId, | ||
| 3 | ServerInstallation, | ||
| 4 | ServerSelfUpdateCapability, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; | 6 | import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 3 | import { | 7 | import { |
| 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 | } |
| 258 | 268 | ||
| 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 !== null | 278 | 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."; |
| 27 | 28 | ||
| 28 | describe("versionSkew", () => { | 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 | 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 @@ |
| 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 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; | 7 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 3 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; | 8 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; |
| 4 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| ... | @@ -115,8 +120,17 @@ export function supportsServerUpdateThreadContinuation( | ... | @@ -115,8 +120,17 @@ export function supportsServerUpdateThreadContinuation( |
| 115 | } | 120 | } |
| 116 | 121 | ||
| 117 | /** The command to hand users whose server cannot update itself. */ | 122 | /** The command to hand users whose server cannot update itself. */ |
| 118 | export function manualServerUpdateCommand(targetVersion: string): string { | 123 | export 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 | } |
| 121 | 135 | ||
| 122 | export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { | 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,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. | | ||
| 48 | 49 | ||
| 49 | On the host, run: | 50 | On the host, run: |
| 50 | 51 |
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; |
| 15 | 15 | ||
| 16 | describe("ExecutionEnvironmentDescriptor", () => { | 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 | 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 |
| 77 | export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); | 77 | export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); |
| 78 | export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; | 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 | /** What update path a client should offer for a server: one of the RPC | 87 | /** 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's | 88 | self-update methods above, or "desktop-managed" when the backend's |
| 82 | version belongs to the T3 Code desktop app supervising it — updating the | 89 | 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, Windows | 178 | 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 the | 183 | /** 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), |