From 5f3badc97eb47d1b995aa933fb4b84b198e6770f Mon Sep 17 00:00:00 2001 From: Scott Van Vliet <782486+svanvliet@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:49:35 -0700 Subject: [PATCH] fix(updates): respect configured npm registries for version checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/cloud/npmRelease.test.ts | 148 +++++++++++ apps/server/src/cloud/npmRelease.ts | 102 ++++++++ .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + 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/state/npmRelease.test.ts | 234 ++++++++++++++---- apps/web/src/state/npmRelease.ts | 100 ++++---- docs/user/updating.md | 27 ++ 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 | 22 ++ .../src/server/maintenanceResolver.test.ts | 141 ++++++++++- .../src/server/maintenanceResolver.ts | 64 ++++- 19 files changed, 831 insertions(+), 116 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..f9abf43b2b12e --- /dev/null +++ b/apps/server/src/cloud/npmRelease.test.ts @@ -0,0 +1,148 @@ +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 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, +}); + +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* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (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: "npm", + args: ["view", "--global", "--json", `t3@${fixture.spec}`], + 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* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => Effect.succeed(output('{"error":{"code":"E404"}}', 1)), + }), + ); + expect(yield* service.get({ spec: version })).toBeNull(); + }), +); + +it.effect.each([ + output("not json"), + output('{"error":{"code":"E401","detail":"sensitive"}}', 1), + output('{"version":"9.9.9"}'), + output("[]"), + { ...output(JSON.stringify({ version })), stdoutTruncated: true }, + { ...output(JSON.stringify({ version })), timedOut: true }, +])("reports an unavailable lookup rather than an unpublished release: %j", (result) => + Effect.gen(function* () { + const service = yield* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { run: () => 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("explains how to restore an unavailable npm without a direct HTTP fallback", () => + Effect.gen(function* () { + const service = yield* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: 4, + cause: new Error("not found"), + }), + ), + }), + ); + const result = yield* service.get({ spec: version }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure.reason).toContain("npm could not be started"); + expect(result.failure.reason).toContain("server's PATH"); + } + }), +); + +it.effect("explains a missing Windows npm shim instead of reporting a registry failure", () => + Effect.gen(function* () { + const service = yield* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => Effect.succeed(output("", 9009)), + }), + Effect.provideService(HostProcess.Platform, "win32"), + ); + const result = yield* service.get({ spec: version }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure.reason).toContain("npm could not be started"); + expect(result.failure.reason).toContain("server's PATH"); + } + }), +); + +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* NpmRelease.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => 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..06b04717a19c9 --- /dev/null +++ b/apps/server/src/cloud/npmRelease.ts @@ -0,0 +1,102 @@ +import { + ServerNpmReleaseError, + ServerNpmReleaseInput, + type ServerNpmRelease, +} from "@t3tools/contracts"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; +import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; +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 * as ProcessRunner from "../processRunner.ts"; + +const Manifest = Schema.Struct({ + version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)), + t3code: Schema.optionalKey(Schema.Unknown), +}); +const decodeManifest = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Union([Manifest, Schema.Tuple([Manifest])])), +); +const decodeError = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.String }) })), +); +const decodeInput = Schema.decodeUnknownEffect(ServerNpmReleaseInput); +const NPM_UNAVAILABLE = + "npm could not be started on this machine. Install npm or make it available on the server's PATH."; + +export class NpmRelease extends Context.Service< + NpmRelease, + { + readonly get: ( + input: ServerNpmReleaseInput, + ) => Effect.Effect; + } +>()("t3/cloud/npmRelease") {} + +export const make = Effect.fn("NpmRelease.make")(function* () { + const runner = yield* ProcessRunner.ProcessRunner; + const cache = yield* Cache.make({ + capacity: 32, + timeToLive: Duration.minutes(1), + lookup: Effect.fn("NpmRelease.lookup")(function* (spec: string) { + const result = yield* runner + .run({ + command: "npm", + args: ["view", "--global", "--json", `t3@${spec}`], + timeout: Duration.seconds(15), + maxOutputBytes: 256 * 1024, + }) + .pipe( + Effect.mapError( + (error) => + new ServerNpmReleaseError({ + reason: + error._tag === "ProcessSpawnError" + ? NPM_UNAVAILABLE + : "npm could not complete the lookup.", + }), + ), + ); + if (result.timedOut || result.stdoutTruncated || result.stdoutInvalidUtf8) { + return yield* new ServerNpmReleaseError({ reason: "npm returned an incomplete response." }); + } + if (result.code !== 0) { + if (yield* isWindowsCommandNotFound(result.code, result.stderr)) { + return yield* new ServerNpmReleaseError({ reason: NPM_UNAVAILABLE }); + } + const error = Option.getOrUndefined(decodeError(result.stdout)); + if (error?.error.code === "E404") return null; + return yield* new ServerNpmReleaseError({ reason: "npm could not read release metadata." }); + } + const decoded = Option.getOrUndefined(decodeManifest(result.stdout)); + const manifest = decoded && ("version" in decoded ? decoded : decoded[0]); + if (!manifest || (spec !== "latest" && spec !== "nightly" && manifest.version !== spec)) { + return yield* new ServerNpmReleaseError({ + reason: "npm returned invalid release metadata.", + }); + } + const databaseMigration = npmReleaseDatabaseMigration(manifest); + return { + version: manifest.version, + ...(databaseMigration === undefined ? {} : { databaseMigration }), + }; + }), + }); + 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)); 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/ws.ts b/apps/server/src/ws.ts index befad768da3e5..037f5c0ff4d0c 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 ServerSettings from "./serverSettings.ts"; @@ -1330,6 +1331,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; @@ -2477,6 +2479,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) => @@ -3268,6 +3271,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; @@ -3341,6 +3345,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)), @@ -3392,4 +3397,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/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts index c8086e1dc57ff..12a42f201d8b7 100644 --- a/apps/web/src/state/npmRelease.test.ts +++ b/apps/web/src/state/npmRelease.test.ts @@ -1,60 +1,206 @@ -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("./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); +function setup(supported = true) { + vi.mocked(serverEnvironment.configValueAtom) + .mockReset() + .mockImplementation((environmentId) => + Atom.make( + 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: {}, + }), + ), + ); + return vi.mocked(serverEnvironment.npmRelease).mockReset(); +} -async function settledCheck() { - const atom = npmReleasePublishedAtom(version); +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: null }, + ]); + 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 })); +}); - const result = await settledCheck(); +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(); + } +}); - 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 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(); + } + }), +); - // 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..312f8ea9366db 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -1,58 +1,64 @@ -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 { 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)}`, - ); - if (response.status === 404) return null; - const body = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap((ok) => ok.json), + Atom.make((get) => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) return Effect.succeed(null); + 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 } }), + ); + return { + published: AsyncResult.isSuccess(release) && release.value?.version === version, + reason: AsyncResult.isFailure(release) ? npmReleaseFailureMessage(release.cause) : 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..0bce79fdfe907 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -28,6 +28,11 @@ 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. +Servers that support registry prechecks check their own npm registry before an +update is offered. Older servers still receive the update request and check +availability through their existing updater. If the release is unavailable +there, the update reports a failure that you can retry. + 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 device asks. Use the update button on that machine to retry. @@ -143,6 +148,22 @@ 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 + +Release availability checks run on the connected server, not in your browser. +Configure npm's registry, authentication, and proxy settings on that machine. +When updating multiple environments, servers that support registry prechecks +each check their own registry. Older servers validate availability during the +update rather than through another environment's registry or a browser lookup. +Track switches look up the target release using the primary environment; update +that server first if it does not support registry lookups. + +These checks require npm on the server's PATH. If npm or its configured registry +is unavailable, T3 Code cannot confirm the release; it does not retry public npm. +If npm cannot be started, the error asks you to install npm or make it available +on the server's PATH. This also applies to standalone and desktop installations: +release-track switching requires npm even when the app itself does not. + ## Update providers **Settings → Providers** shows provider updates for the selected environment. @@ -150,6 +171,12 @@ details** to include the error in a bug report. at once. Hover it to see which providers it will update. Providers that only offer a manual update command are not included. +When npm is available, npm-based provider version checks use its configured +registry, authentication, and proxy settings. Configure npm on the server's +machine, including for native provider installations. If that lookup fails, +the latest version stays unknown rather than retrying the public npm registry. +Installer-specific checks, such as Homebrew and pnpm, still take priority. + After a successful provider CLI update, T3 Code refreshes model discovery for every enabled instance of that provider using its own configuration. Connected clients receive the refreshed catalogs automatically. Provider update checks diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index dadaf2dafb7a5..9e477b54a9dfc 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1251,6 +1251,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..8fddc2dc467af 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -881,6 +881,28 @@ 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 60522e0c29f48..5ded862afbf3a 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"; @@ -125,7 +126,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), @@ -165,6 +168,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, @@ -639,15 +732,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) => @@ -655,6 +769,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", @@ -666,6 +781,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "package-tool", ]); + let npmProbes = 0; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( packageToolUpdate, { binaryPath: link, env: { PATH: "" } }, @@ -674,12 +790,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), ), ), @@ -699,10 +820,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 8a8e11e2ec0d7..febd80b9f63cc 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -711,6 +711,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* ( @@ -745,7 +802,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( env, platform: yield* HostProcess.Platform, }); -}); +}, Effect.flatMap(withConfiguredNpmVersionLookup)); export const makeCachedProviderMaintenanceResolution = Effect.fn( "makeCachedProviderMaintenanceResolution", @@ -847,10 +904,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(