From f12ae8cbb139b88c29fa57fa75990130e423fb03 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 14:12:40 -0700 Subject: [PATCH 1/3] fix(updates): version checks use the configured npm registry Based on #27812 by Scott Van Vliet. Co-Authored-By: Scott Van Vliet <782486+svanvliet@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/cloud/npmRelease.test.ts | 168 ++++++++++++ apps/server/src/cloud/npmRelease.ts | 127 +++++++++ .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../src/provider/Drivers/CodexDriver.test.ts | 4 +- apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 7 +- .../settings/UpdateTrackSwitchDialog.tsx | 10 +- .../sidebar/updateEverything.logic.test.ts | 43 +++ .../sidebar/updateEverything.logic.ts | 8 +- .../components/sidebar/useUpdateEverything.ts | 19 +- apps/web/src/rpc/requestLatencyState.ts | 1 + apps/web/src/state/npmRelease.test.ts | 259 +++++++++++++++--- apps/web/src/state/npmRelease.ts | 116 ++++---- docs/user/updating.md | 9 + packages/client-runtime/src/state/server.ts | 6 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/server.ts | 23 ++ .../src/server/maintenanceResolver.test.ts | 141 +++++++++- .../src/server/maintenanceResolver.ts | 64 ++++- 22 files changed, 908 insertions(+), 115 deletions(-) create mode 100644 apps/server/src/cloud/npmRelease.test.ts create mode 100644 apps/server/src/cloud/npmRelease.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index bf2b8d49daf8d..d5a17755bffa2 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -61,6 +61,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetNpmRelease]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, diff --git a/apps/server/src/cloud/npmRelease.test.ts b/apps/server/src/cloud/npmRelease.test.ts new file mode 100644 index 0000000000000..e506af77ca91f --- /dev/null +++ b/apps/server/src/cloud/npmRelease.test.ts @@ -0,0 +1,168 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ServerNpmReleaseError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Fiber from "effect/Fiber"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as Result from "effect/Result"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as NpmRelease from "./npmRelease.ts"; + +const version = "0.1.5-nightly.20261030.1"; +const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + +const noPublicRegistry = HttpClient.make(() => + Effect.die("a configured npm lookup must not fall back to public npm"), +); +// A fake npm on PATH decides whether npm counts as installed. +const makeService = Effect.fnUntraced(function* ( + run: ProcessRunner.ProcessRunner["Service"]["run"], + publicRegistry: HttpClient.HttpClient = noPublicRegistry, + npmInstalled = true, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const npmBin = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-release-" }); + for (const name of ["npm", "npm.cmd"]) { + yield* fs.writeFileString(path.join(npmBin, name), "", { mode: 0o755 }); + } + return yield* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run }), + Effect.provideService(HttpClient.HttpClient, publicRegistry), + Effect.provideService(HostProcess.Environment, { + PATH: npmInstalled ? npmBin : "", + PATHEXT: ".CMD", + }), + ); +}, Effect.provide(NodeServices.layer)); + +it.effect.each([ + { + spec: version, + stdout: JSON.stringify({ version, t3code: { databaseMigration: 42 } }), + migration: 42, + }, + { + spec: "nightly", + stdout: JSON.stringify([{ version, t3code: { databaseMigration: 42 } }]), + migration: 42, + }, + { spec: "latest", stdout: JSON.stringify(version), migration: undefined }, +])("reads $spec through npm and retains migration compatibility metadata", (fixture) => + Effect.gen(function* () { + const calls: ProcessRunner.ProcessRunInput[] = []; + const service = yield* makeService((input) => { + calls.push(input); + return Effect.succeed(output(fixture.stdout)); + }); + const expected = { + version, + ...(fixture.migration === undefined ? {} : { databaseMigration: fixture.migration }), + }; + expect(yield* service.get({ spec: fixture.spec })).toEqual(expected); + expect(yield* service.get({ spec: fixture.spec })).toEqual(expected); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + command: expect.stringMatching(/[/\\]npm(?:\.cmd)?$/i), + args: ["view", "--global", "--json", `t3@${fixture.spec}`, "version", "t3code"], + maxOutputBytes: 256 * 1024, + }); + yield* TestClock.adjust("1 minute"); + expect(yield* service.get({ spec: fixture.spec })).toEqual(expected); + expect(calls).toHaveLength(2); + }), +); + +it.effect("treats a registry 404 as unpublished without trying another registry", () => + Effect.gen(function* () { + const service = yield* makeService(() => + Effect.succeed(output('{"error":{"code":"E404"}}', 1)), + ); + expect(yield* service.get({ spec: version })).toBeNull(); + }), +); + +const spawnFailure = () => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: 6, + cause: new Error("EACCES"), + }), + ); + +it.effect.each([ + output("not json"), + output('{"error":{"code":"E401","detail":"sensitive"}}', 1), + output('{"version":"9.9.9"}'), + output("[]"), + output("", 9009), + { ...output(JSON.stringify({ version })), stdoutTruncated: true }, + { ...output(JSON.stringify({ version })), timedOut: true }, + "spawn failure", +] as const)("reports an unavailable lookup rather than an unpublished release: %j", (result) => + Effect.gen(function* () { + const service = yield* makeService(() => + result === "spawn failure" ? spawnFailure() : Effect.succeed(result), + ); + const found = yield* service.get({ spec: version }).pipe(Effect.result); + expect(Result.isFailure(found)).toBe(true); + if (Result.isFailure(found)) { + expect(found.failure).toBeInstanceOf(ServerNpmReleaseError); + expect(found.failure.message).not.toContain("sensitive"); + } + }), +); + +it.effect("falls back to public npm only when npm is not installed", () => + Effect.gen(function* () { + const urls: string[] = []; + const service = yield* makeService( + () => Effect.die("npm is not installed"), + HttpClient.make((request) => { + urls.push(request.url); + const response = request.url.endsWith("/nightly") + ? new Response(JSON.stringify({ version, t3code: { databaseMigration: 42 } })) + : request.url.endsWith("/latest") + ? new Response(new ReadableStream()) + : new Response("{}", { status: 404 }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }), + false, + ); + expect(yield* service.get({ spec: "nightly" })).toEqual({ version, databaseMigration: 42 }); + expect(yield* service.get({ spec: "9.9.9" })).toBeNull(); + const stalled = yield* service.get({ spec: "latest" }).pipe(Effect.result, Effect.forkChild); + yield* TestClock.adjust("15 seconds"); + expect(Result.isFailure(yield* Fiber.join(stalled))).toBe(true); + expect(urls).toEqual([ + "https://registry.npmjs.org/t3/nightly", + "https://registry.npmjs.org/t3/9.9.9", + "https://registry.npmjs.org/t3/latest", + ]); + }), +); + +it.effect.each(["--registry=https://example.com", "../other", "other-package@latest", "*", ""])( + "rejects invalid release specs before spawning npm: %s", + (spec) => + Effect.gen(function* () { + const service = yield* makeService(() => Effect.die("invalid specs must not spawn npm")); + expect(Result.isFailure(yield* service.get({ spec }).pipe(Effect.result))).toBe(true); + }), +); diff --git a/apps/server/src/cloud/npmRelease.ts b/apps/server/src/cloud/npmRelease.ts new file mode 100644 index 0000000000000..7d9fa070eb04e --- /dev/null +++ b/apps/server/src/cloud/npmRelease.ts @@ -0,0 +1,127 @@ +import { + ServerNpmReleaseError, + ServerNpmReleaseInput, + type ServerNpmRelease, +} from "@t3tools/contracts"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient } from "effect/http"; + +import * as ProcessRunner from "../processRunner.ts"; + +const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)); +const Manifest = Schema.Struct({ version: Version, t3code: Schema.optionalKey(Schema.Unknown) }); +// npm prints a bare version string when the release has no `t3code` field. +const decodeManifest = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Union([Manifest, Schema.Tuple([Manifest]), Version])), +); +const decodeError = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.String }) })), +); +const decodeInput = Schema.decodeUnknownEffect(ServerNpmReleaseInput); + +export class NpmRelease extends Context.Service< + NpmRelease, + { + readonly get: ( + input: ServerNpmReleaseInput, + ) => Effect.Effect; + } +>()("t3/cloud/npmRelease") {} + +function toRelease(spec: string, json: string) { + const decoded = Option.getOrUndefined(decodeManifest(json)); + const manifest = + typeof decoded === "string" + ? { version: decoded } + : decoded && ("version" in decoded ? decoded : decoded[0]); + if (!manifest || (spec !== "latest" && spec !== "nightly" && manifest.version !== spec)) { + return Effect.fail( + new ServerNpmReleaseError({ reason: "npm returned invalid release metadata." }), + ); + } + const databaseMigration = npmReleaseDatabaseMigration(manifest); + return Effect.succeed({ + version: manifest.version, + ...(databaseMigration === undefined ? {} : { databaseMigration }), + }); +} + +export const make = Effect.fn("NpmRelease.make")(function* () { + const runner = yield* ProcessRunner.ProcessRunner; + const http = yield* HttpClient.HttpClient; + const lookupPublic = Effect.fn("NpmRelease.lookupPublic")(function* (spec: string) { + const unreachable = new ServerNpmReleaseError({ + reason: "npm is not installed and the public npm registry could not be reached.", + }); + const json = yield* Effect.gen(function* () { + const response = yield* http.get(`https://registry.npmjs.org/t3/${spec}`); + if (response.status === 404) return Option.none(); + if (response.status < 200 || response.status >= 300) return yield* unreachable; + return Option.some(yield* response.text); + }).pipe( + Effect.timeout(Duration.seconds(15)), + Effect.mapError(() => unreachable), + ); + return Option.isNone(json) ? null : yield* toRelease(spec, json.value); + }); + const cache = yield* Cache.make({ + capacity: 32, + timeToLive: Duration.minutes(1), + lookup: Effect.fn("NpmRelease.lookup")(function* (spec: string) { + // Without npm there is no configured registry to honor, so use the public one. + const npmPath = yield* resolveCommandPath("npm"); + if (npmPath === null) return yield* lookupPublic(spec); + const output = yield* runner + .run({ + command: npmPath, + // Named fields keep the output small; the full package document grows with every release. + args: ["view", "--global", "--json", `t3@${spec}`, "version", "t3code"], + timeout: Duration.seconds(15), + maxOutputBytes: 256 * 1024, + }) + .pipe( + Effect.tapError((error) => + Effect.logInfo("npm release lookup failed", { tag: error._tag }), + ), + Effect.mapError( + () => new ServerNpmReleaseError({ reason: "npm could not complete the lookup." }), + ), + ); + if (output.timedOut || output.stdoutTruncated || output.stdoutInvalidUtf8) { + return yield* new ServerNpmReleaseError({ reason: "npm returned an incomplete response." }); + } + if (output.code !== 0) { + const error = Option.getOrUndefined(decodeError(output.stdout)); + if (error?.error.code === "E404") return null; + yield* Effect.logInfo("npm release lookup failed", { + exitCode: output.code, + npmErrorCode: error?.error.code, + }); + return yield* new ServerNpmReleaseError({ reason: "npm could not read release metadata." }); + } + return yield* toRelease(spec, output.stdout); + }), + }); + return NpmRelease.of({ + get: Effect.fn("NpmRelease.get")(function* (input) { + const { spec } = yield* decodeInput(input).pipe( + Effect.mapError( + () => new ServerNpmReleaseError({ reason: "Invalid release version or track." }), + ), + ); + return yield* Cache.get(cache, spec); + }), + }); +}); + +export const layer = Layer.effect(NpmRelease, make()).pipe( + Layer.provide([ProcessRunner.layer, FetchHttpClient.layer]), +); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5e0bcfc742e08..b36fa64dc22a5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -265,6 +265,7 @@ export const make = Effect.gen(function* () { projectCloneTracking: true, ignoredFileListing: true, triage: true, + npmReleaseLookup: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index cb8b0b6a86cf3..99c1274a04973 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -33,6 +33,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectsMutate]: "orchestration", [WS_METHODS.serverProbe]: "server", [WS_METHODS.serverGetConfig]: "server", + [WS_METHODS.serverGetNpmRelease]: "server", [WS_METHODS.serverRefreshProviders]: "server", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 0aacca303e6a6..38de3c92a9570 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -667,7 +667,9 @@ it.layer(layerTest)("CodexDriver", (it) => { homePath: NodePath.join(tempDir, "codex-home"), }, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); - const capabilities = yield* instance.snapshot.resolveMaintenance(); + const capabilities = yield* instance.snapshot + .resolveMaintenance() + .pipe(Effect.provideService(HostProcess.Environment, { PATH: "" })); const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( Effect.provideServiceEffect( ProviderLatestVersions.ProviderLatestVersions, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 069a0c41adb3b..641845f322ea6 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -85,6 +85,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as NpmRelease from "./cloud/npmRelease.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -534,6 +535,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), Layer.mock(ServerSelfUpdate.ServerSelfUpdate)({}), + Layer.mock(NpmRelease.NpmRelease)({}), Layer.mock(SessionStore.SessionStore)({ cookieName: "config-test", legacyCookieName: undefined }), Layer.mock(SourceControlDiscovery.SourceControlDiscovery)({}), Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4c28ad34e48d7..92a629e1afd13 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -178,6 +178,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as NpmRelease from "./cloud/npmRelease.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; @@ -1333,6 +1334,7 @@ export const layerWsRpc = ( const providerAuth = yield* ProviderAuthService.ProviderAuthService; const providerInstallation = yield* makeProviderInstallation(); const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const npmRelease = yield* NpmRelease.NpmRelease; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const storageCleanup = yield* StorageCleanup.StorageCleanup; @@ -2481,6 +2483,7 @@ export const layerWsRpc = ( [WS_METHODS.providerInstallSubscribe]: (input) => providerInstallation.subscribe(input), [WS_METHODS.providerInstallRemove]: (input) => providerInstallation.remove(input), [WS_METHODS.serverUpdateServer]: (input) => serverSelfUpdate.update(input), + [WS_METHODS.serverGetNpmRelease]: (input) => npmRelease.get(input), [WS_METHODS.serverUpdateServerWithProgress]: (input) => ServerSelfUpdate.updateProgressStream(serverSelfUpdate, input), [WS_METHODS.serverCommitDesktopUpdate]: (input) => @@ -3272,6 +3275,7 @@ export const layer = Layer.unwrap( Effect.gen(function* () { const serverBrowser = yield* ServerBrowser.ServerBrowser; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const npmRelease = yield* NpmRelease.NpmRelease; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const peerLinks = yield* PeerLinks.PeerLinks; @@ -3346,6 +3350,7 @@ export const layer = Layer.unwrap( Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(NpmRelease.NpmRelease, npmRelease)), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // Share server-lifetime PR caches so WS mutations invalidate every client's HTTP diffs. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), @@ -3397,4 +3402,4 @@ export const layer = Layer.unwrap( ), ); }), -); +).pipe(Layer.provide(NpmRelease.layer)); diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx index f746cff28c270..83547b8712fa1 100644 --- a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -9,7 +9,7 @@ import { AsyncResult } from "effect/reactivity"; import { APP_VERSION } from "~/branding"; import { useDesktopUpdateState } from "~/state/desktopUpdate"; import { useEnvironments } from "~/state/environments"; -import { npmReleaseAtom } from "~/state/npmRelease"; +import { npmReleaseAtom, npmReleaseFailureMessage } from "~/state/npmRelease"; import { downloadLocalUpdate, installLocalUpdate } from "../sidebar/SidebarUpdatePill"; import { UpdateEverythingDialog } from "../sidebar/UpdateEverythingDialog"; import { @@ -51,9 +51,11 @@ export function UpdateTrackSwitchDialog({ Switch to {label} - {AsyncResult.isFailure(release) || AsyncResult.isSuccess(release) - ? `Could not find the latest ${label} release on npm. Try again later.` - : "Checking releases…"} + {AsyncResult.isFailure(release) + ? npmReleaseFailureMessage(release.cause) + : AsyncResult.isSuccess(release) + ? `Could not find the latest ${label} release in this environment's npm registry.` + : "Checking releases…"} diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 62aef93572c4d..dd8e7a30f438f 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -29,6 +29,7 @@ function machine( serverVersion, capabilities: { repositoryIdentity: true, + npmReleaseLookup: true, ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } @@ -103,6 +104,48 @@ describe("collectServerUpdateTargets", () => { expect(waits(true)).toEqual(["npm-service", "legacy-respawn"]); expect(waits(false)).toEqual(["archive-service", "npm-service", "legacy-respawn"]); }); + + it.each([undefined, false])( + "keeps legacy updates and channel checks without registry lookup support (%s)", + (npmReleaseLookup) => { + const hosts = [ + machine("archive-service", "0.0.41-nightly.20260914.1700", "boot-service"), + machine("npm-service", "0.0.41-nightly.20260914.1699", "boot-service"), + machine("legacy-respawn", "0.1.4-nightly.20261027.3796", "respawn"), + machine("other-track", "0.1.3", "boot-service"), + ].map((host) => ({ + ...host, + serverConfig: { + ...host.serverConfig!, + environment: { + ...host.serverConfig!.environment, + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: host.serverConfig!.environment.capabilities.serverSelfUpdate!, + ...(npmReleaseLookup === undefined ? {} : { npmReleaseLookup }), + }, + }, + }, + })); + for (const releaseArchivesPublished of [true, false]) { + const { targets, skipped, waitForNpm } = collectServerUpdateTargets( + [...hosts, machine("new-server", "0.1.4-nightly.20261027.3796", "respawn")], + "0.1.5-nightly.20261029.3873", + releaseArchivesPublished, + ); + expect(targets.map((target) => target.serverLabel)).toEqual([ + "archive-service", + "npm-service", + "legacy-respawn", + "new-server", + ]); + expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ + ["other-track", "on the stable channel"], + ]); + expect(waitForNpm.map((target) => target.serverLabel)).toEqual(["new-server"]); + } + }, + ); }); describe("collectChannelSwitchTargets", () => { diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index de21c535f517c..032758208a0e1 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -66,10 +66,9 @@ export interface SkippedMachine { /** Boot services before this build installed the server from npm, not from release archives. */ const RELEASE_ARCHIVE_SELF_UPDATE_SINCE = "0.0.41-nightly.20260914.1700"; -/** Skip current and app-hosted servers; the latter update with the app and would relaunch mid-run. - * Desktop-managed servers use their own feed. `waitForNpm` lists targets that need - * `t3@` on npm first. `releaseArchivesPublished` means the version came from the - * desktop feed: its GitHub Release goes public with the CLI archives that boot services download. */ +/** App-hosted servers update with the app; desktop-managed servers use their own feed. + * `waitForNpm` contains only hosts supporting the precheck; older hosts validate during update. + * `releaseArchivesPublished` means the desktop feed already offers this release and its archives. */ export function collectServerUpdateTargets( machines: ReadonlyArray, targetVersion: string, @@ -127,6 +126,7 @@ export function collectServerUpdateTargets( targets.push(target); if ( selfUpdate !== "desktop-managed" && + config.environment.capabilities.npmReleaseLookup === true && (!releaseArchivesPublished || selfUpdate === "respawn" || compareSemverVersions(serverVersion, RELEASE_ARCHIVE_SELF_UPDATE_SINCE) < 0) diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index d8ab3a5bce710..2849a03c5fb45 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -5,7 +5,7 @@ import { APP_VERSION } from "~/branding"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { isElectron } from "~/env"; import { useEnvironments } from "~/state/environments"; -import { useNpmReleasePublished } from "~/state/npmRelease"; +import { npmReleasePublishedAtom } from "~/state/npmRelease"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { environmentUpdateStatesAtom } from "~/state/server"; import { environmentSessionStatesAtom } from "~/state/session"; @@ -76,10 +76,15 @@ export function useUpdateEverything( } = channelSwitch ? { ...collectChannelSwitchTargets(machines, channelSwitch), waitForNpm: [] } : collectServerUpdateTargets(machines, targetVersion, localUpdateVersion !== null); - const published = useNpmReleasePublished(waitForNpm.length > 0 ? targetVersion : null); - const servers = published - ? behindServers - : behindServers.filter((server) => !waitForNpm.includes(server)); + const published = useAtomValue( + npmReleasePublishedAtom(targetVersion)( + environmentIdsKey(waitForNpm.map((server) => server.environmentId)), + ), + ); + const servers = behindServers.filter( + (server) => + !waitForNpm.includes(server) || published[waitForNpm.indexOf(server)]?.published === true, + ); const skipped: ReadonlyArray = [ ...unreachable, ...behindServers @@ -87,7 +92,9 @@ export function useUpdateEverything( .map((server) => ({ environmentId: server.environmentId, label: server.serverLabel, - reason: `t3@${targetVersion} is not on npm yet`, + reason: + published[waitForNpm.indexOf(server)]?.reason ?? + `Could not confirm t3@${targetVersion} is available in this environment's npm registry`, })), ]; // Track-only hosts save a setting and restart nothing, so they are neither listed nor counted. diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index c95ddae2afcde..eac4176d14a9c 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -30,6 +30,7 @@ const pendingRpcAckRequests = new Map(); // about a request the user never made does not say what is wrong. A sign-in wait // lasts as long as the user takes on the forge. const untrackedRpcAckMethods = new Set([ + WS_METHODS.serverGetNpmRelease, WS_METHODS.serverGetUsageSummary, WS_METHODS.serverReportClientActivity, WS_METHODS.sourceControlSignInWait, diff --git a/apps/web/src/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts index c8086e1dc57ff..ae73be492ba1e 100644 --- a/apps/web/src/state/npmRelease.test.ts +++ b/apps/web/src/state/npmRelease.test.ts @@ -1,60 +1,233 @@ -import { AtomRegistry } from "effect/reactivity"; -import * as AsyncResult from "effect/reactivity/AsyncResult"; -import { afterAll, afterEach, describe, expect, it, vi } from "vite-plus/test"; +import * as NodeAssert from "node:assert/strict"; -import { npmReleasePublishedAtom } from "./npmRelease"; +import { + EnvironmentId, + ServerConfig, + type ServerNpmRelease, + ServerNpmReleaseError, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; + +import { environmentIdsKey } from "./environmentAtomList"; +import { npmReleaseAtom, npmReleaseFailureMessage, npmReleasePublishedAtom } from "./npmRelease"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { serverEnvironment } from "./server"; + +vi.mock("./server", () => ({ + serverEnvironment: { npmRelease: vi.fn(), configValueAtom: vi.fn() }, +})); +vi.mock("./presentation", () => ({ + environmentSummaries: { connectedEnvironmentIdsAtom: Atom.make(["first", "second"]) }, +})); +vi.mock("./primaryEnvironment", () => ({ + primaryEnvironmentIdAtom: Atom.make(null), +})); const version = "0.0.46-nightly.20261012.3021"; +const first = EnvironmentId.make("first"); +const second = EnvironmentId.make("second"); +const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); -// FetchHttpClient reads globalThis.fetch once and keeps it, so every test shares this mock. -const fetchMock = vi.fn(); -vi.stubGlobal("fetch", fetchMock); +const configFor = (environmentId: EnvironmentId | null, supported = true) => + decodeServerConfig({ + environment: { + environmentId, + label: environmentId, + platform: { os: "linux", arch: "x64" }, + serverVersion: version, + capabilities: { + repositoryIdentity: true, + ...(supported ? { npmReleaseLookup: true } : {}), + }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + cwd: "/test", + keybindingsConfigPath: "/test/keybindings.json", + keybindings: [], + issues: [], + providers: [], + availableEditors: [], + observability: { + logsDirectoryPath: "/test/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: {}, + }); -async function settledCheck() { - const atom = npmReleasePublishedAtom(version); +function setup(supported = true) { + vi.mocked(serverEnvironment.configValueAtom) + .mockReset() + .mockImplementation((environmentId) => Atom.make(configFor(environmentId, supported))); + return vi.mocked(serverEnvironment.npmRelease).mockReset(); +} + +it("checks publication separately against each environment's registry", () => { + const query = setup(); + query.mockImplementation(({ environmentId }) => + Atom.make(AsyncResult.success(environmentId === first ? { version } : null)), + ); const registry = AtomRegistry.make(); - const unmount = registry.mount(atom); try { - await vi.waitFor(() => { - const result = registry.get(atom); - expect(AsyncResult.isInitial(result) || result.waiting).toBe(false); - }); - return registry.get(atom); + expect( + registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first, second]))), + ).toEqual([ + { published: true, reason: null }, + { published: false, reason: `t3@${version} is not in this environment's npm registry yet` }, + ]); + expect(query).toHaveBeenCalledWith({ environmentId: first, input: { spec: version } }); + expect(query).toHaveBeenCalledWith({ environmentId: second, input: { spec: version } }); } finally { - unmount(); registry.dispose(); } -} - -describe("npmReleasePublishedAtom", () => { - afterEach(() => { - fetchMock.mockReset(); - }); - - afterAll(() => { - vi.unstubAllGlobals(); - }); +}); - it("checks npm without tracing headers, so the browser skips the preflight npm rejects", async () => { - fetchMock.mockImplementation(async () => Response.json({ version })); +it("does not query any registry when no server needs a publication check", () => { + const query = setup(); + const registry = AtomRegistry.make(); + try { + expect(registry.get(npmReleasePublishedAtom(version)(""))).toEqual([]); + expect(query).not.toHaveBeenCalled(); + } finally { + registry.dispose(); + } +}); - const result = await settledCheck(); +it.effect( + "resolves track metadata through the primary environment and follows primary changes", + () => + Effect.gen(function* () { + const query = setup(); + query.mockImplementation(({ environmentId }) => + Atom.make( + AsyncResult.success({ version, databaseMigration: environmentId === first ? 42 : 43 }), + ), + ); + const registry = AtomRegistry.make(); + const release = npmReleaseAtom("nightly"); + NodeAssert.ok( + Atom.isWritable(primaryEnvironmentIdAtom), + "Expected the mocked primary environment atom to be writable.", + ); + registry.set(primaryEnvironmentIdAtom, first); + const unmount = registry.mount(release); + try { + expect( + yield* AtomRegistry.getResult(registry, release, { suspendOnWaiting: true }), + ).toEqual({ + version, + databaseMigration: 42, + }); + registry.set(primaryEnvironmentIdAtom, second); + expect( + yield* AtomRegistry.getResult(registry, release, { suspendOnWaiting: true }), + ).toEqual({ + version, + databaseMigration: 43, + }); + } finally { + unmount(); + registry.dispose(); + } + }), +); - expect(fetchMock).toHaveBeenCalledTimes(1); - const request = new Request(fetchMock.mock.calls[0]?.[0] ?? "", fetchMock.mock.calls[0]?.[1]); - expect(request.url).toBe(`https://registry.npmjs.org/t3/${version}`); - expect(request.headers.get("traceparent")).toBeNull(); - expect(request.headers.get("b3")).toBeNull(); - expect(AsyncResult.getOrElse(result, () => false)).toBe(true); - }); +it.effect("resolves track metadata through a supporting server when there is no primary", () => + Effect.gen(function* () { + const query = setup(); + vi.mocked(serverEnvironment.configValueAtom).mockImplementation((environmentId) => + Atom.make( + environmentId === first ? configFor(environmentId, false) : configFor(environmentId), + ), + ); + query.mockReturnValue(Atom.make(AsyncResult.success({ version }))); + const registry = AtomRegistry.make(); + const release = npmReleaseAtom("nightly"); + const unmount = registry.mount(release); + try { + expect(yield* AtomRegistry.getResult(registry, release, { suspendOnWaiting: true })).toEqual({ + version, + databaseMigration: undefined, + }); + expect(query).toHaveBeenCalledWith({ environmentId: second, input: { spec: "nightly" } }); + } finally { + unmount(); + registry.dispose(); + } + }), +); - // regression: #18709 update-everything skipped npm-installed servers - it("leaves a failed first check unknown, which callers read as unpublished", async () => { - fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); +it("keeps a failed registry check unpublished without a browser fetch", () => { + const query = setup(); + const error = new ServerNpmReleaseError({ reason: "npm could not be started on this machine." }); + query.mockReturnValue( + Atom.make( + AsyncResult.failure(Cause.fail(error)), + ), + ); + using fetch = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("Unexpected browser fetch")); + const registry = AtomRegistry.make(); + try { + expect(registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first])))).toEqual([ + { published: false, reason: error.message }, + ]); + expect(fetch).not.toHaveBeenCalled(); + } finally { + registry.dispose(); + } +}); - const result = await settledCheck(); +it.effect("does not call an unsupported RPC or fetch public npm for an older primary server", () => + Effect.gen(function* () { + const query = setup(false); + using fetch = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("Unexpected browser fetch")); + const registry = AtomRegistry.make(); + NodeAssert.ok( + Atom.isWritable(primaryEnvironmentIdAtom), + "Expected the mocked primary environment atom to be writable.", + ); + registry.set(primaryEnvironmentIdAtom, first); + const release = npmReleaseAtom("latest"); + const unmount = registry.mount(release); + try { + const error = yield* AtomRegistry.getResult(registry, release).pipe(Effect.flip); + expect(npmReleaseFailureMessage(Cause.fail(error))).toContain( + "Update it before switching tracks", + ); + expect(registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first])))).toEqual([ + { + published: false, + reason: expect.stringContaining("does not support registry lookups"), + }, + ]); + expect(query).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + } finally { + unmount(); + registry.dispose(); + } + }), +); - expect(AsyncResult.isFailure(result)).toBe(true); - expect(AsyncResult.getOrElse(result, () => false)).toBe(false); - }); +it("only displays the safe release error, not arbitrary transport diagnostics", () => { + const error = new ServerNpmReleaseError({ reason: "Install npm on the server's PATH." }); + expect(npmReleaseFailureMessage(Cause.fail(error))).toBe(error.message); + expect( + npmReleaseFailureMessage(Cause.fail(new Error("sensitive transport details"))), + ).not.toContain("sensitive"); }); diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index 09db3b68c0691..3dc384deb3ed5 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -1,58 +1,82 @@ -import { useAtomValue } from "@effect/atom-react"; -import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; +import { type ServerConfig, ServerNpmReleaseError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; -export interface NpmRelease { - readonly version: string; - /** Absent on releases published before npm manifests carried it. */ - readonly databaseMigration: number | undefined; +import { environmentAtomList } from "./environmentAtomList"; +import { environmentSummaries } from "./presentation"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { serverEnvironment } from "./server"; + +const isServerNpmReleaseError = Schema.is(ServerNpmReleaseError); + +function unsupportedLookup(config: ServerConfig | null): ServerNpmReleaseError | null { + if (config === null) { + return new ServerNpmReleaseError({ reason: "Connect to this server to check releases." }); + } + return config.environment.capabilities.npmReleaseLookup === true + ? null + : new ServerNpmReleaseError({ + reason: "This server does not support registry lookups. Update it before switching tracks.", + }); +} + +export function npmReleaseFailureMessage(cause: Cause.Cause): string { + const error = Option.getOrUndefined(Cause.findErrorOption(cause)); + return isServerNpmReleaseError(error) + ? error.message + : "Could not check this environment's npm registry. Check the connection and registry settings."; } -/** `spec` is an exact version or a dist-tag such as `nightly`. Null when npm has no such release. - * Refreshes only while mounted; the published check below keeps its own versions alive. */ export const npmReleaseAtom = Atom.family((spec: string) => - Atom.make( - Effect.gen(function* () { - const response = yield* HttpClient.get( - `https://registry.npmjs.org/t3/${encodeURIComponent(spec)}`, + Atom.make((get) => { + const primary = get(primaryEnvironmentIdAtom); + // With the desktop's local server turned off there is no primary, so a connected server answers. + const candidates = + primary === null ? get(environmentSummaries.connectedEnvironmentIdsAtom) : [primary]; + const environmentId = + candidates.find( + (id) => unsupportedLookup(get(serverEnvironment.configValueAtom(id))) === null, + ) ?? candidates[0]; + if (environmentId === undefined) { + return Effect.fail( + new ServerNpmReleaseError({ reason: "Connect to a server to check releases." }), ); - if (response.status === 404) return null; - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap((ok) => ok.json), + } + const unsupported = unsupportedLookup(get(serverEnvironment.configValueAtom(environmentId))); + if (unsupported !== null) return Effect.fail(unsupported); + return get + .result(serverEnvironment.npmRelease({ environmentId, input: { spec } })) + .pipe( + Effect.map((release) => + release === null + ? null + : { version: release.version, databaseMigration: release.databaseMigration }, + ), ); - if ( - typeof body !== "object" || - body === null || - !("version" in body) || - typeof body.version !== "string" - ) { - return null; - } - return { - version: body.version, - databaseMigration: npmReleaseDatabaseMigration(body), - } satisfies NpmRelease; - }).pipe( - // Tracing headers would make the browser send a CORS preflight, which npm rejects. - Effect.provideService(HttpClient.TracerPropagationEnabled, false), - Effect.provide(FetchHttpClient.layer), - ), - ).pipe(Atom.withRefresh("5 minutes"), Atom.withLabel(`npm-release:${spec}`)), + }).pipe(Atom.withLabel(`npm-release:${spec}`)), ); export const npmReleasePublishedAtom = Atom.family((version: string) => - Atom.make((get) => - get.result(npmReleaseAtom(version)).pipe(Effect.map((release) => release?.version === version)), - ).pipe(Atom.keepAlive, Atom.withLabel(`npm-release-published:${version}`)), -); - -const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe( - Atom.withLabel("npm-release-published:none"), + environmentAtomList(`npm-release-published:${version}`, (environmentId) => + Atom.make((get) => { + const unsupported = unsupportedLookup(get(serverEnvironment.configValueAtom(environmentId))); + if (unsupported !== null) return { published: false, reason: unsupported.message }; + const release = get( + serverEnvironment.npmRelease({ environmentId, input: { spec: version } }), + ); + if (AsyncResult.isFailure(release)) { + return { published: false, reason: npmReleaseFailureMessage(release.cause) }; + } + if (AsyncResult.isSuccess(release) && release.value === null) { + return { + published: false, + reason: `t3@${version} is not in this environment's npm registry yet`, + }; + } + return { published: AsyncResult.isSuccess(release), reason: null }; + }), + ), ); - -export function useNpmReleasePublished(version: string | null): boolean { - const result = useAtomValue(version ? npmReleasePublishedAtom(version) : NO_VERSION_ATOM); - return AsyncResult.getOrElse(result, () => false); -} diff --git a/docs/user/updating.md b/docs/user/updating.md index cfe746c187338..5e90bc3c596e8 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -143,6 +143,15 @@ choose **Update** to install it. If not, the window links to the last version that started on your computer. Download and install it to go back. Use **Copy details** to include the error in a bug report. +## Registries and proxies + +Each server checks npm-published releases of T3 Code and providers with its own +npm, so the checks use npm's configured registry, authentication, and proxy +settings. +Configure npm on the server's machine. If that registry cannot be reached, the +release shows as unavailable; T3 Code does not try the public registry instead. +Machines without npm check the public npm registry directly. + ## Update providers **Settings → Providers** shows provider updates for the selected environment. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 4c44c7df9dec9..3455b5e9e128b 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1265,6 +1265,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:trace-diagnostics", tag: WS_METHODS.serverGetTraceDiagnostics, }), + npmRelease: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:npm-release", + tag: WS_METHODS.serverGetNpmRelease, + staleTimeMs: 60_000, + refreshIntervalMs: 5 * 60_000, + }), processDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a10c6c2a6bce7..326f2f675aff2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -124,6 +124,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), + /** Server answers server.getNpmRelease using its configured npm registry. */ + npmReleaseLookup: Schema.optionalKey(Schema.Boolean), /** Manual commands must update this install, not the host's default global prefix. */ serverInstallation: ForwardCompatibleOptional(ServerInstallation), serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index edc7a9ab852a4..77d545a3f75ce 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -323,6 +323,9 @@ import { ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, ServerSelfUpdateError, + ServerNpmRelease, + ServerNpmReleaseError, + ServerNpmReleaseInput, ServerSelfUpdateInput, ServerSelfUpdateProgressEvent, ServerSelfUpdateResult, @@ -492,6 +495,7 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", + serverGetNpmRelease: "server.getNpmRelease", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", @@ -745,6 +749,12 @@ const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { error: ProviderSetupRpcError, }); +const WsServerGetNpmReleaseRpc = Rpc.make(WS_METHODS.serverGetNpmRelease, { + payload: ServerNpmReleaseInput, + success: Schema.NullOr(ServerNpmRelease), + error: Schema.Union([ServerNpmReleaseError, EnvironmentAuthorizationError]), +}); + const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { payload: ServerSelfUpdateInput, success: ServerSelfUpdateResult, @@ -1971,6 +1981,7 @@ export const WsRpcGroup = RpcGroup.make( WsProviderInstallSubscribeRpc, WsProviderInstallRemoveRpc, WsServerUpdateServerRpc, + WsServerGetNpmReleaseRpc, WsServerUpdateServerWithProgressRpc, WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ab79a9dea60f5..c36f724b8239e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -881,6 +881,29 @@ export class ServerProviderUpdateError extends Schema.TaggedError()( + "ServerNpmReleaseError", + { reason: Schema.String }, +) { + override get message(): string { + return `Could not check the configured npm registry: ${this.reason}`; + } +} + export const ServerSelfUpdateInput = Schema.Struct({ /** Exact npm version, never a dist-tag, so server and client agree on the request. */ targetVersion: TrimmedNonEmptyString, diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index ef2035089ff04..da59b77886a59 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -10,6 +10,7 @@ import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; @@ -127,7 +128,9 @@ function stdoutSpawner( }); } -it.layer(NodeServices.layer)("providerMaintenance", (it) => { +it.layer( + NodeServices.layer.pipe(Layer.merge(Layer.succeed(HostProcess.Environment, { PATH: "" }))), +)("providerMaintenance", (it) => { it.effect("prefers the installer's own latest version over the npm registry", () => resolveLatestProviderVersion({ ...manualPackageTool, latestVersion: "1.2.0" }).pipe( Effect.provide(ProviderLatestVersions.layer), @@ -200,6 +203,96 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); + it.effect.each([ + { install: "native", stdout: '"1.2.0"', exitCode: 0, latest: "1.2.0" }, + { install: "manual", stdout: '["1.2.0"]', exitCode: 0, latest: "1.2.0" }, + { install: "npm", stdout: '"1.2.0"', exitCode: 0, latest: "1.2.0" }, + { install: "native", stdout: "", exitCode: 1, latest: null }, + { install: "npm", stdout: '"1.2.0"', exitCode: 1, latest: null }, + { install: "native", stdout: "not json", exitCode: 0, latest: null }, + { install: "manual", stdout: '["1.2.0", "1.3.0"]', exitCode: 0, latest: null }, + { install: "manual", stdout: '""', exitCode: 0, latest: null }, + ])( + "uses npm's registry for $install installs (exit $exitCode, output $stdout)", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-registry-version"); + const npmBinDir = NodePath.join(tempDir, "npm-bin"); + writeExecutable(NodePath.join(npmBinDir, "npm")); + const binaryPath = + fixture.install === "npm" + ? linkIntoPackage(tempDir, "package-tool", [ + "lib", + "node_modules", + "@example", + "package-tool", + ]) + : NodePath.join(tempDir, ".local/bin/native-package-tool"); + if (fixture.install !== "npm") writeExecutable(binaryPath); + let probes = 0; + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + fixture.install === "native" ? nativePackageToolUpdate : packageToolUpdate, + { binaryPath, env: { PATH: "" } }, + ).pipe( + Effect.provideService(HostProcess.Environment, { PATH: npmBinDir }), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner( + (command, args) => { + probes++; + expect(command).toBe(NodePath.join(npmBinDir, "npm")); + expect(args).toEqual([ + "view", + "--global", + ...(fixture.install === "npm" ? ["--prefix", tempDir] : []), + "--json", + `${capabilities.packageName}@latest`, + "version", + ]); + return fixture.stdout; + }, + () => fixture.exitCode, + ), + ), + ); + const program = Effect.gen(function* () { + yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + { enableProviderUpdateChecks: false }, + ); + expect(probes).toBe(0); + const snapshot = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + ); + expect(snapshot.versionAdvisory?.latestVersion).toBe(fixture.latest); + expect(probes).toBe(1); + expect(yield* resolveLatestProviderVersion(capabilities)).toBe(fixture.latest); + expect(probes).toBe(1); + yield* invalidateLatestProviderVersion(capabilities); + expect(yield* resolveLatestProviderVersion(capabilities)).toBe(fixture.latest); + expect(probes).toBe(2); + }); + yield* program.pipe( + Effect.provideServiceEffect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([ + ["@example/package-tool", "9.9.9"], + ["@example/native-package-tool", "9.9.9"], + ]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("a configured npm lookup must not fall back to public npm"), + ), + ), + ); + }), + { skip: !symlinksSupported || windowsHost }, + ); + it("pins the advertised stable version in a pnpm-global update command", () => { const advisoryFor = ( maintenanceCapabilities: ReturnType, @@ -674,15 +767,36 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { case: "the eligible release", outdated: { "@example/package-tool": { current: "1.0.0", latest: "1.1.0" } }, exitCode: 1, + npmOutput: '"1.2.0"', expected: "1.1.0", }, - { case: "no update during cooldown", outdated: {}, exitCode: 0, expected: "1.0.0" }, { - case: "the npm fallback when the pnpm probe fails", + case: "no update during cooldown", + outdated: {}, + exitCode: 0, + npmOutput: '"1.2.0"', + expected: "1.0.0", + }, + { + case: "the public registry fallback when pnpm fails and npm is unavailable", outdated: { "@example/package-tool": { current: "1.0.0", latest: "1.1.0" } }, exitCode: 2, expected: "9.9.9", }, + { + case: "the configured npm registry when the pnpm probe fails", + outdated: {}, + exitCode: 2, + npmOutput: '"1.2.0"', + expected: "1.2.0", + }, + { + case: "an unknown version when both configured lookups fail", + outdated: {}, + exitCode: 2, + npmOutput: "not json", + expected: null, + }, ])( "advertises $case for pnpm-global installs", (fixture) => @@ -690,6 +804,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const tempDir = yield* makeTempDir("t3-pnpm-latest"); const pnpmBinDir = NodePath.join(tempDir, "pnpm-bin"); writeExecutable(NodePath.join(pnpmBinDir, "pnpm")); + if (fixture.npmOutput !== undefined) writeExecutable(NodePath.join(pnpmBinDir, "npm")); const link = linkIntoPackage(tempDir, "package-tool", [ ".local", "share", @@ -701,6 +816,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "package-tool", ]); + let npmProbes = 0; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, { binaryPath: link, env: { PATH: "" } }, @@ -709,12 +825,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, stdoutSpawner( - (_command, args) => - args[0] === "outdated" + (_command, args) => { + if (args[0] === "view") { + npmProbes++; + return fixture.npmOutput ?? ""; + } + return args[0] === "outdated" ? JSON.stringify(fixture.outdated) : JSON.stringify([ { dependencies: { "@example/package-tool": { version: "1.0.0" } } }, - ]), + ]); + }, (args) => (args[0] === "outdated" ? fixture.exitCode : 0), ), ), @@ -734,10 +855,16 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ), ); expect(provider.versionAdvisory).toMatchObject({ - status: fixture.expected === "1.0.0" ? "current" : "behind_latest", + status: + fixture.expected === null + ? "unknown" + : fixture.expected === "1.0.0" + ? "current" + : "behind_latest", currentVersion: "1.0.0", latestVersion: fixture.expected, }); + expect(npmProbes).toBe(fixture.exitCode === 2 && fixture.npmOutput !== undefined ? 1 : 0); }), { skip: !symlinksSupported || windowsHost }, ); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index c97a246ae83e3..61ff6edc0d9ac 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -712,6 +712,63 @@ function makeManualProviderMaintenanceCapabilities( }); } +const decodeNpmViewVersion = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Union([Schema.String, Schema.Tuple([Schema.String])])), +); + +const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup")(function* ( + capabilities: ProviderMaintenanceCapabilities, +) { + const packageName = capabilities.packageName; + if (!packageName || capabilities.latestVersion !== undefined) { + return capabilities; + } + const npmPath = yield* resolveCommandPath("npm"); + if (!npmPath) return capabilities; + const services = yield* Effect.context(); + const update = capabilities.update; + const prefixIndex = update?.lockKey.startsWith("npm-global:") + ? update.args.indexOf("--prefix") + : -1; + const prefix = prefixIndex >= 0 ? update?.args[prefixIndex + 1] : undefined; + const lookup = runProbe( + npmPath, + [ + "view", + "--global", + ...(prefix ? ["--prefix", prefix] : []), + "--json", + `${packageName}@latest`, + "version", + ], + {}, + ).pipe( + Effect.map((stdout) => { + const value = stdout ? Option.getOrNull(decodeNpmViewVersion(stdout.trim())) : null; + return nonEmptyString(typeof value === "string" ? value : value?.[0]); + }), + Effect.tap((version) => + version === null + ? Effect.logWarning("Configured npm version lookup failed", { packageName }) + : Effect.void, + ), + Effect.provideContext(services), + ); + return { + ...capabilities, + // npm owns registry, scoped auth and proxy configuration; failure must not retry public npm. + latestVersion: null, + lookupLatestVersion: capabilities.lookupLatestVersion + ? capabilities.lookupLatestVersion.pipe( + Effect.filterOrElse( + (version) => version !== null, + () => lookup, + ), + ) + : lookup, + }; +}); + export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( "resolveProviderMaintenanceCapabilitiesEffect", )(function* ( @@ -746,7 +803,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( env, platform: yield* HostProcess.Platform, }); -}); +}, Effect.flatMap(withConfiguredNpmVersionLookup)); export const makeCachedProviderMaintenanceResolution = Effect.fn( "makeCachedProviderMaintenanceResolution", @@ -840,10 +897,11 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack /** The installer's own answer is cached apart from the npm registry entry. */ function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) { + if (!capabilities.lookupLatestVersion) return null; const update = capabilities.update; - return capabilities.lookupLatestVersion && update + return update ? [update.lockKey, update.executable, ...update.args].join("\0") - : null; + : capabilities.packageName && `npm-registry\0${capabilities.packageName}`; } export function latestProviderVersionCacheKeys( From e30c80ebeb4787842958aec171ae65fbe519c02a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 14:44:48 -0700 Subject: [PATCH 2/3] fix(updates): label pending registry checks and clear both provider cache keys Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/ProviderRegistry.ts | 9 ++++++--- apps/web/src/state/npmRelease.test.ts | 10 ++++++++-- apps/web/src/state/npmRelease.ts | 4 +++- docs/user/updating.md | 7 ++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index efa3385d1927e..3234bfd61960a 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -44,6 +44,7 @@ import type { import { makeManualOnlyProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, + latestProviderVersionCacheKeys, } from "@t3tools/provider-core/server/maintenanceResolver"; import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; @@ -718,9 +719,11 @@ export const layer = Layer.effect( Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); - if (maintenance.packageName) { - yield* latestVersions.invalidate(maintenance.packageName); - } + yield* Effect.forEach( + latestProviderVersionCacheKeys(maintenance), + latestVersions.invalidate, + { discard: true }, + ); }).pipe(Effect.catchCause((cause) => recoverInstanceFailure(instance.instanceId, cause))), { concurrency: "unbounded", discard: true }, ); diff --git a/apps/web/src/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts index ae73be492ba1e..cbda3c44e1cd8 100644 --- a/apps/web/src/state/npmRelease.test.ts +++ b/apps/web/src/state/npmRelease.test.ts @@ -31,6 +31,7 @@ vi.mock("./primaryEnvironment", () => ({ const version = "0.0.46-nightly.20261012.3021"; const first = EnvironmentId.make("first"); const second = EnvironmentId.make("second"); +const pending = EnvironmentId.make("pending"); const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); const configFor = (environmentId: EnvironmentId | null, supported = true) => @@ -76,15 +77,20 @@ function setup(supported = true) { it("checks publication separately against each environment's registry", () => { const query = setup(); query.mockImplementation(({ environmentId }) => - Atom.make(AsyncResult.success(environmentId === first ? { version } : null)), + Atom.make( + environmentId === pending + ? AsyncResult.initial(true) + : AsyncResult.success(environmentId === first ? { version } : null), + ), ); const registry = AtomRegistry.make(); try { expect( - registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first, second]))), + registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first, second, pending]))), ).toEqual([ { published: true, reason: null }, { published: false, reason: `t3@${version} is not in this environment's npm registry yet` }, + { published: false, reason: "Still checking this environment's npm registry" }, ]); expect(query).toHaveBeenCalledWith({ environmentId: first, input: { spec: version } }); expect(query).toHaveBeenCalledWith({ environmentId: second, input: { spec: version } }); diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index 3dc384deb3ed5..d17f5e47873f7 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -76,7 +76,9 @@ export const npmReleasePublishedAtom = Atom.family((version: string) => reason: `t3@${version} is not in this environment's npm registry yet`, }; } - return { published: AsyncResult.isSuccess(release), reason: null }; + return AsyncResult.isSuccess(release) + ? { published: true, reason: null } + : { published: false, reason: "Still checking this environment's npm registry" }; }), ), ); diff --git a/docs/user/updating.md b/docs/user/updating.md index 5e90bc3c596e8..3a7ed172ddb19 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -145,9 +145,10 @@ details** to include the error in a bug report. ## Registries and proxies -Each server checks npm-published releases of T3 Code and providers with its own -npm, so the checks use npm's configured registry, authentication, and proxy -settings. +Each server checks T3 Code releases, and providers installed with npm, through +its own npm, so the checks use npm's configured registry, authentication, and +proxy settings. Providers installed with Homebrew or pnpm check through those +tools. Configure npm on the server's machine. If that registry cannot be reached, the release shows as unavailable; T3 Code does not try the public registry instead. Machines without npm check the public npm registry directly. From 1dfbfb5dc9ccda94c5586c2b2b392a26fe093c12 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 16:29:04 -0700 Subject: [PATCH 3/3] fix(updates): limit npm lookups and drop the update-everything npm precheck Servers that support the lookup update from release archives and preflight the release themselves, so checking their npm mirror only skipped servers that could update. Two concurrent npm processes at most keep a burst of distinct versions from exhausting a small server's memory. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/npmRelease.test.ts | 30 ++++++++ apps/server/src/cloud/npmRelease.ts | 21 ++++-- .../sidebar/updateEverything.logic.test.ts | 62 --------------- .../sidebar/updateEverything.logic.ts | 23 +----- .../components/sidebar/useUpdateEverything.ts | 34 +-------- apps/web/src/rpc/requestLatencyState.ts | 1 - apps/web/src/state/npmRelease.test.ts | 75 +------------------ apps/web/src/state/npmRelease.ts | 27 +------ docs/user/updating.md | 10 +-- 9 files changed, 58 insertions(+), 225 deletions(-) diff --git a/apps/server/src/cloud/npmRelease.test.ts b/apps/server/src/cloud/npmRelease.test.ts index e506af77ca91f..152309f699b96 100644 --- a/apps/server/src/cloud/npmRelease.test.ts +++ b/apps/server/src/cloud/npmRelease.test.ts @@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ServerNpmReleaseError } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Fiber from "effect/Fiber"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as Result from "effect/Result"; @@ -158,6 +160,34 @@ it.effect("falls back to public npm only when npm is not installed", () => }), ); +it.effect("runs at most two npm lookups at once", () => + Effect.gen(function* () { + const started = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + let running = 0; + let peak = 0; + const service = yield* makeService(() => + Effect.gen(function* () { + peak = Math.max(peak, ++running); + yield* Queue.offer(started, undefined); + yield* Deferred.await(release); + running--; + return output('{"error":{"code":"E404"}}', 1); + }), + ); + const lookups = yield* Effect.forEach( + ["1.0.0", "1.0.1", "1.0.2"], + (spec) => service.get({ spec }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Queue.take(started); + yield* Queue.take(started); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(lookups)).toEqual([null, null, null]); + expect(peak).toBe(2); + }), +); + it.effect.each(["--registry=https://example.com", "../other", "other-package@latest", "*", ""])( "rejects invalid release specs before spawning npm: %s", (spec) => diff --git a/apps/server/src/cloud/npmRelease.ts b/apps/server/src/cloud/npmRelease.ts index 7d9fa070eb04e..230c050d3e9a9 100644 --- a/apps/server/src/cloud/npmRelease.ts +++ b/apps/server/src/cloud/npmRelease.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { FetchHttpClient, HttpClient } from "effect/http"; import * as ProcessRunner from "../processRunner.ts"; @@ -72,6 +73,8 @@ export const make = Effect.fn("NpmRelease.make")(function* () { ); return Option.isNone(json) ? null : yield* toRelease(spec, json.value); }); + // npm loads the whole package history per lookup, so a burst of specs must not spawn one each. + const npmPermits = yield* Semaphore.make(2); const cache = yield* Cache.make({ capacity: 32, timeToLive: Duration.minutes(1), @@ -79,14 +82,16 @@ export const make = Effect.fn("NpmRelease.make")(function* () { // Without npm there is no configured registry to honor, so use the public one. const npmPath = yield* resolveCommandPath("npm"); if (npmPath === null) return yield* lookupPublic(spec); - const output = yield* runner - .run({ - command: npmPath, - // Named fields keep the output small; the full package document grows with every release. - args: ["view", "--global", "--json", `t3@${spec}`, "version", "t3code"], - timeout: Duration.seconds(15), - maxOutputBytes: 256 * 1024, - }) + const output = yield* npmPermits + .withPermits(1)( + runner.run({ + command: npmPath, + // Named fields keep the output small; the full package document grows with every release. + args: ["view", "--global", "--json", `t3@${spec}`, "version", "t3code"], + timeout: Duration.seconds(15), + maxOutputBytes: 256 * 1024, + }), + ) .pipe( Effect.tapError((error) => Effect.logInfo("npm release lookup failed", { tag: error._tag }), diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index dd8e7a30f438f..4c09823d40538 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -29,7 +29,6 @@ function machine( serverVersion, capabilities: { repositoryIdentity: true, - npmReleaseLookup: true, ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } @@ -70,7 +69,6 @@ describe("collectServerUpdateTargets", () => { machine("manual", "0.0.40", undefined), ], "0.0.50", - true, ); expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ @@ -86,66 +84,6 @@ describe("collectServerUpdateTargets", () => { ["manual", "needs a manual update"], ]); }); - - it("waits for npm only for hosts that install from it or when the release may lack archives", () => { - const hosts = [ - machine("archive-service", "0.0.41-nightly.20260914.1700", "boot-service"), - machine("npm-service", "0.0.41-nightly.20260914.1699", "boot-service"), - machine("legacy-respawn", "0.1.4-nightly.20261027.3796", "respawn"), - machine("desktop", "0.1.4-nightly.20261027.3796", "desktop-managed"), - ]; - const waits = (releaseArchivesPublished: boolean) => - collectServerUpdateTargets( - hosts, - "0.1.5-nightly.20261029.3873", - releaseArchivesPublished, - ).waitForNpm.map((target) => target.serverLabel); - - expect(waits(true)).toEqual(["npm-service", "legacy-respawn"]); - expect(waits(false)).toEqual(["archive-service", "npm-service", "legacy-respawn"]); - }); - - it.each([undefined, false])( - "keeps legacy updates and channel checks without registry lookup support (%s)", - (npmReleaseLookup) => { - const hosts = [ - machine("archive-service", "0.0.41-nightly.20260914.1700", "boot-service"), - machine("npm-service", "0.0.41-nightly.20260914.1699", "boot-service"), - machine("legacy-respawn", "0.1.4-nightly.20261027.3796", "respawn"), - machine("other-track", "0.1.3", "boot-service"), - ].map((host) => ({ - ...host, - serverConfig: { - ...host.serverConfig!, - environment: { - ...host.serverConfig!.environment, - capabilities: { - repositoryIdentity: true, - serverSelfUpdate: host.serverConfig!.environment.capabilities.serverSelfUpdate!, - ...(npmReleaseLookup === undefined ? {} : { npmReleaseLookup }), - }, - }, - }, - })); - for (const releaseArchivesPublished of [true, false]) { - const { targets, skipped, waitForNpm } = collectServerUpdateTargets( - [...hosts, machine("new-server", "0.1.4-nightly.20261027.3796", "respawn")], - "0.1.5-nightly.20261029.3873", - releaseArchivesPublished, - ); - expect(targets.map((target) => target.serverLabel)).toEqual([ - "archive-service", - "npm-service", - "legacy-respawn", - "new-server", - ]); - expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ - ["other-track", "on the stable channel"], - ]); - expect(waitForNpm.map((target) => target.serverLabel)).toEqual(["new-server"]); - } - }, - ); }); describe("collectChannelSwitchTargets", () => { diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index 032758208a0e1..cfeecfd5e31ce 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -63,24 +63,18 @@ export interface SkippedMachine { readonly reason: string; } -/** Boot services before this build installed the server from npm, not from release archives. */ -const RELEASE_ARCHIVE_SELF_UPDATE_SINCE = "0.0.41-nightly.20260914.1700"; - -/** App-hosted servers update with the app; desktop-managed servers use their own feed. - * `waitForNpm` contains only hosts supporting the precheck; older hosts validate during update. - * `releaseArchivesPublished` means the desktop feed already offers this release and its archives. */ +/** Skip current and app-hosted servers; the latter update with the app and would relaunch mid-run. + * Desktop-managed servers use their own feed. Each server's updater checks that it can install the + * release before it restarts, so no registry precheck runs here. */ export function collectServerUpdateTargets( machines: ReadonlyArray, targetVersion: string, - releaseArchivesPublished: boolean, ): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[]; - readonly waitForNpm: ServerUpdateTarget[]; } { const targets: ServerUpdateTarget[] = []; const skipped: SkippedMachine[] = []; - const waitForNpm: ServerUpdateTarget[] = []; for (const machine of machines) { const config = machine.serverConfig; if (machine.hostedByThisApp) continue; @@ -124,17 +118,8 @@ export function collectServerUpdateTargets( targetVersion, }; targets.push(target); - if ( - selfUpdate !== "desktop-managed" && - config.environment.capabilities.npmReleaseLookup === true && - (!releaseArchivesPublished || - selfUpdate === "respawn" || - compareSemverVersions(serverVersion, RELEASE_ARCHIVE_SELF_UPDATE_SINCE) < 0) - ) { - waitForNpm.push(target); - } } - return { targets, skipped, waitForNpm }; + return { targets, skipped }; } function unavailableReason(machine: UpdateEverythingMachine): string | null { diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index 2849a03c5fb45..05127d9885768 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -5,7 +5,6 @@ import { APP_VERSION } from "~/branding"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { isElectron } from "~/env"; import { useEnvironments } from "~/state/environments"; -import { npmReleasePublishedAtom } from "~/state/npmRelease"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { environmentUpdateStatesAtom } from "~/state/server"; import { environmentSessionStatesAtom } from "~/state/session"; @@ -21,7 +20,6 @@ import { collectUpdateEverythingResult, describeUpdateEverything, runUpdateEverything, - type SkippedMachine, type UpdateRunMachine, } from "./updateEverything.logic"; import { @@ -68,35 +66,9 @@ export function useUpdateEverything( canMaintain: canUpdateServer(sessionStates[index]!), updateState: updateStates[index]!, })); - // A track switch's target came from npm already. - const { - targets: behindServers, - skipped: unreachable, - waitForNpm, - } = channelSwitch - ? { ...collectChannelSwitchTargets(machines, channelSwitch), waitForNpm: [] } - : collectServerUpdateTargets(machines, targetVersion, localUpdateVersion !== null); - const published = useAtomValue( - npmReleasePublishedAtom(targetVersion)( - environmentIdsKey(waitForNpm.map((server) => server.environmentId)), - ), - ); - const servers = behindServers.filter( - (server) => - !waitForNpm.includes(server) || published[waitForNpm.indexOf(server)]?.published === true, - ); - const skipped: ReadonlyArray = [ - ...unreachable, - ...behindServers - .filter((server) => !servers.includes(server)) - .map((server) => ({ - environmentId: server.environmentId, - label: server.serverLabel, - reason: - published[waitForNpm.indexOf(server)]?.reason ?? - `Could not confirm t3@${targetVersion} is available in this environment's npm registry`, - })), - ]; + const { targets: servers, skipped } = channelSwitch + ? collectChannelSwitchTargets(machines, channelSwitch) + : collectServerUpdateTargets(machines, targetVersion); // Track-only hosts save a setting and restart nothing, so they are neither listed nor counted. const moving = servers.filter((server) => server.trackOnly !== true); const restarts = collectRestartingMachines(machines, moving, localUpdateVersion !== null); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index eac4176d14a9c..c95ddae2afcde 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -30,7 +30,6 @@ const pendingRpcAckRequests = new Map(); // about a request the user never made does not say what is wrong. A sign-in wait // lasts as long as the user takes on the forge. const untrackedRpcAckMethods = new Set([ - WS_METHODS.serverGetNpmRelease, WS_METHODS.serverGetUsageSummary, WS_METHODS.serverReportClientActivity, WS_METHODS.sourceControlSignInWait, diff --git a/apps/web/src/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts index cbda3c44e1cd8..d11ef8d76985b 100644 --- a/apps/web/src/state/npmRelease.test.ts +++ b/apps/web/src/state/npmRelease.test.ts @@ -1,11 +1,6 @@ import * as NodeAssert from "node:assert/strict"; -import { - EnvironmentId, - ServerConfig, - type ServerNpmRelease, - ServerNpmReleaseError, -} from "@t3tools/contracts"; +import { EnvironmentId, ServerConfig, ServerNpmReleaseError } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -13,8 +8,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; -import { environmentIdsKey } from "./environmentAtomList"; -import { npmReleaseAtom, npmReleaseFailureMessage, npmReleasePublishedAtom } from "./npmRelease"; +import { npmReleaseAtom, npmReleaseFailureMessage } from "./npmRelease"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { serverEnvironment } from "./server"; @@ -31,7 +25,6 @@ vi.mock("./primaryEnvironment", () => ({ const version = "0.0.46-nightly.20261012.3021"; const first = EnvironmentId.make("first"); const second = EnvironmentId.make("second"); -const pending = EnvironmentId.make("pending"); const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); const configFor = (environmentId: EnvironmentId | null, supported = true) => @@ -74,42 +67,6 @@ function setup(supported = true) { return vi.mocked(serverEnvironment.npmRelease).mockReset(); } -it("checks publication separately against each environment's registry", () => { - const query = setup(); - query.mockImplementation(({ environmentId }) => - Atom.make( - environmentId === pending - ? AsyncResult.initial(true) - : AsyncResult.success(environmentId === first ? { version } : null), - ), - ); - const registry = AtomRegistry.make(); - try { - expect( - registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first, second, pending]))), - ).toEqual([ - { published: true, reason: null }, - { published: false, reason: `t3@${version} is not in this environment's npm registry yet` }, - { published: false, reason: "Still checking this environment's npm registry" }, - ]); - expect(query).toHaveBeenCalledWith({ environmentId: first, input: { spec: version } }); - expect(query).toHaveBeenCalledWith({ environmentId: second, input: { spec: version } }); - } finally { - registry.dispose(); - } -}); - -it("does not query any registry when no server needs a publication check", () => { - const query = setup(); - const registry = AtomRegistry.make(); - try { - expect(registry.get(npmReleasePublishedAtom(version)(""))).toEqual([]); - expect(query).not.toHaveBeenCalled(); - } finally { - registry.dispose(); - } -}); - it.effect( "resolves track metadata through the primary environment and follows primary changes", () => @@ -174,28 +131,6 @@ it.effect("resolves track metadata through a supporting server when there is no }), ); -it("keeps a failed registry check unpublished without a browser fetch", () => { - const query = setup(); - const error = new ServerNpmReleaseError({ reason: "npm could not be started on this machine." }); - query.mockReturnValue( - Atom.make( - AsyncResult.failure(Cause.fail(error)), - ), - ); - using fetch = vi - .spyOn(globalThis, "fetch") - .mockRejectedValue(new Error("Unexpected browser fetch")); - const registry = AtomRegistry.make(); - try { - expect(registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first])))).toEqual([ - { published: false, reason: error.message }, - ]); - expect(fetch).not.toHaveBeenCalled(); - } finally { - registry.dispose(); - } -}); - it.effect("does not call an unsupported RPC or fetch public npm for an older primary server", () => Effect.gen(function* () { const query = setup(false); @@ -215,12 +150,6 @@ it.effect("does not call an unsupported RPC or fetch public npm for an older pri expect(npmReleaseFailureMessage(Cause.fail(error))).toContain( "Update it before switching tracks", ); - expect(registry.get(npmReleasePublishedAtom(version)(environmentIdsKey([first])))).toEqual([ - { - published: false, - reason: expect.stringContaining("does not support registry lookups"), - }, - ]); expect(query).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); } finally { diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index d17f5e47873f7..2e8159c0b9b88 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -3,9 +3,8 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { AsyncResult, Atom } from "effect/reactivity"; +import { Atom } from "effect/reactivity"; -import { environmentAtomList } from "./environmentAtomList"; import { environmentSummaries } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { serverEnvironment } from "./server"; @@ -58,27 +57,3 @@ export const npmReleaseAtom = Atom.family((spec: string) => ); }).pipe(Atom.withLabel(`npm-release:${spec}`)), ); - -export const npmReleasePublishedAtom = Atom.family((version: string) => - environmentAtomList(`npm-release-published:${version}`, (environmentId) => - Atom.make((get) => { - const unsupported = unsupportedLookup(get(serverEnvironment.configValueAtom(environmentId))); - if (unsupported !== null) return { published: false, reason: unsupported.message }; - const release = get( - serverEnvironment.npmRelease({ environmentId, input: { spec: version } }), - ); - if (AsyncResult.isFailure(release)) { - return { published: false, reason: npmReleaseFailureMessage(release.cause) }; - } - if (AsyncResult.isSuccess(release) && release.value === null) { - return { - published: false, - reason: `t3@${version} is not in this environment's npm registry yet`, - }; - } - return AsyncResult.isSuccess(release) - ? { published: true, reason: null } - : { published: false, reason: "Still checking this environment's npm registry" }; - }), - ), -); diff --git a/docs/user/updating.md b/docs/user/updating.md index 3a7ed172ddb19..206cda5ba1e27 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -22,11 +22,11 @@ restart from the update button when you are ready. A server still restarting finishes on its own, and the button keeps showing it after the desktop app restarts. -Remote desktop apps never wait for npm. When the desktop app offers the update, -background services update right away too. Other servers, including older -background services, update only after that version is published to npm. The -confirmation lists each machine it skips and why, such as a machine that is -offline or needs a manual update. Use **Settings → Environments** for those. +Each server checks that it can install the release before it restarts. If the +release is not available to a server yet, its update fails, the server keeps +running, and you can retry. The confirmation lists each machine it skips and +why, such as a machine that is offline or needs a manual update. Use +**Settings → Environments** for those. If a desktop app restarts on its old version, T3 Code tells you the update did not apply. It does not install that version again on its own, even when another