Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
148 changes: 148 additions & 0 deletions apps/server/src/cloud/npmRelease.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
102 changes: 102 additions & 0 deletions apps/server/src/cloud/npmRelease.ts
Original file line number Diff line number Diff line change
@@ -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<ServerNpmRelease | null, ServerNpmReleaseError>;
}
>()("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));
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -3392,4 +3397,4 @@ export const layer = Layer.unwrap(
),
);
}),
);
).pipe(Layer.provide(NpmRelease.layer));
10 changes: 6 additions & 4 deletions apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,9 +51,11 @@ export function UpdateTrackSwitchDialog({
<AlertDialogHeader>
<AlertDialogTitle>Switch to {label}</AlertDialogTitle>
<AlertDialogDescription>
{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…"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand Down
Loading
Loading