Skip to content
Merged
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
198 changes: 198 additions & 0 deletions apps/server/src/cloud/npmRelease.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
const release = yield* Deferred.make<void>();
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);
}),
);
132 changes: 132 additions & 0 deletions apps/server/src/cloud/npmRelease.ts
Original file line number Diff line number Diff line change
@@ -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<ServerNpmRelease | null, ServerNpmReleaseError>;
}
>()("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<string>();
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]),
);
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
4 changes: 3 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
import {
makeManualOnlyProviderMaintenanceCapabilities,
type ProviderMaintenanceCapabilities,
latestProviderVersionCacheKeys,
} from "@t3tools/provider-core/server/maintenanceResolver";
import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts";

Expand Down Expand Up @@ -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 },
);
Expand Down
Loading
Loading