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..152309f699b96 --- /dev/null +++ b/apps/server/src/cloud/npmRelease.test.ts @@ -0,0 +1,198 @@ +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"; +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("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) => + 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..230c050d3e9a9 --- /dev/null +++ b/apps/server/src/cloud/npmRelease.ts @@ -0,0 +1,132 @@ +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 * as Semaphore from "effect/Semaphore"; +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); + }); + // 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), + 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* 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 }), + ), + 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/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/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..4c09823d40538 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -69,7 +69,6 @@ describe("collectServerUpdateTargets", () => { machine("manual", "0.0.40", undefined), ], "0.0.50", - true, ); expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ @@ -85,24 +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"]); - }); }); describe("collectChannelSwitchTargets", () => { diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index de21c535f517c..cfeecfd5e31ce 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -63,25 +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"; - /** 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. */ + * 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; @@ -125,16 +118,8 @@ export function collectServerUpdateTargets( targetVersion, }; targets.push(target); - if ( - selfUpdate !== "desktop-managed" && - (!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 d8ab3a5bce710..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 { useNpmReleasePublished } 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,28 +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 = useNpmReleasePublished(waitForNpm.length > 0 ? targetVersion : null); - const servers = published - ? behindServers - : behindServers.filter((server) => !waitForNpm.includes(server)); - const skipped: ReadonlyArray = [ - ...unreachable, - ...behindServers - .filter((server) => !servers.includes(server)) - .map((server) => ({ - environmentId: server.environmentId, - label: server.serverLabel, - reason: `t3@${targetVersion} is not on npm yet`, - })), - ]; + 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/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts index c8086e1dc57ff..d11ef8d76985b 100644 --- a/apps/web/src/state/npmRelease.test.ts +++ b/apps/web/src/state/npmRelease.test.ts @@ -1,60 +1,168 @@ -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, 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"; -const version = "0.0.46-nightly.20261012.3021"; +import { npmReleaseAtom, npmReleaseFailureMessage } from "./npmRelease"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { serverEnvironment } from "./server"; -// FetchHttpClient reads globalThis.fetch once and keeps it, so every test shares this mock. -const fetchMock = vi.fn(); -vi.stubGlobal("fetch", fetchMock); - -async function settledCheck() { - const atom = npmReleasePublishedAtom(version); - 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); - } finally { - unmount(); - registry.dispose(); - } -} +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), +})); -describe("npmReleasePublishedAtom", () => { - afterEach(() => { - fetchMock.mockReset(); - }); +const version = "0.0.46-nightly.20261012.3021"; +const first = EnvironmentId.make("first"); +const second = EnvironmentId.make("second"); +const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); - afterAll(() => { - vi.unstubAllGlobals(); +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: {}, }); - 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(); +function setup(supported = true) { + vi.mocked(serverEnvironment.configValueAtom) + .mockReset() + .mockImplementation((environmentId) => Atom.make(configFor(environmentId, supported))); + return vi.mocked(serverEnvironment.npmRelease).mockReset(); +} - 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.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(); + } + }), +); - 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(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..2e8159c0b9b88 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -1,58 +1,59 @@ -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 { AsyncResult, Atom } from "effect/reactivity"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/reactivity"; -export interface NpmRelease { - readonly version: string; - /** Absent on releases published before npm manifests carried it. */ - readonly databaseMigration: number | undefined; +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}`)), -); - -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"), + }).pipe(Atom.withLabel(`npm-release:${spec}`)), ); - -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..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 @@ -143,6 +143,16 @@ 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 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. + ## 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(