@checkstack/script-packages-backend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
  3. package/drizzle/0001_flawless_drax.sql +15 -0
  4. package/drizzle/meta/0000_snapshot.json +395 -0
  5. package/drizzle/meta/0001_snapshot.json +491 -0
  6. package/drizzle/meta/_journal.json +20 -0
  7. package/drizzle.config.ts +7 -0
  8. package/package.json +32 -0
  9. package/src/atomic-symlink.test.ts +47 -0
  10. package/src/atomic-symlink.ts +66 -0
  11. package/src/blob-gc-runner.test.ts +120 -0
  12. package/src/blob-gc-runner.ts +139 -0
  13. package/src/blob-gc.test.ts +182 -0
  14. package/src/blob-gc.ts +161 -0
  15. package/src/blob-hash.test.ts +70 -0
  16. package/src/blob-hash.ts +56 -0
  17. package/src/blob-store-registry.test.ts +78 -0
  18. package/src/blob-store-registry.ts +75 -0
  19. package/src/blob-store.ts +51 -0
  20. package/src/cache-archive.test.ts +164 -0
  21. package/src/cache-archive.ts +192 -0
  22. package/src/cache-layout.ts +64 -0
  23. package/src/data-dir.test.ts +41 -0
  24. package/src/data-dir.ts +42 -0
  25. package/src/e2e-install-reconcile.test.ts +121 -0
  26. package/src/hooks.ts +20 -0
  27. package/src/index.ts +594 -0
  28. package/src/install-controller.test.ts +257 -0
  29. package/src/install-controller.ts +144 -0
  30. package/src/install-service.test.ts +104 -0
  31. package/src/install-service.ts +116 -0
  32. package/src/install-state-store.ts +131 -0
  33. package/src/lockfile.test.ts +60 -0
  34. package/src/lockfile.ts +0 -0
  35. package/src/npmrc.test.ts +48 -0
  36. package/src/npmrc.ts +42 -0
  37. package/src/package-types.test.ts +293 -0
  38. package/src/package-types.ts +408 -0
  39. package/src/parse-bun-lock.test.ts +62 -0
  40. package/src/parse-bun-lock.ts +59 -0
  41. package/src/reconcile-diff.test.ts +41 -0
  42. package/src/reconcile-diff.ts +26 -0
  43. package/src/reconcile-fs.ts +199 -0
  44. package/src/reconciler.test.ts +289 -0
  45. package/src/reconciler.ts +81 -0
  46. package/src/registry-client.test.ts +314 -0
  47. package/src/registry-client.ts +0 -0
  48. package/src/registry-request-config.ts +63 -0
  49. package/src/registry-token.test.ts +124 -0
  50. package/src/registry-token.ts +104 -0
  51. package/src/resolution-root.test.ts +82 -0
  52. package/src/resolution-root.ts +127 -0
  53. package/src/resolver.test.ts +133 -0
  54. package/src/resolver.ts +132 -0
  55. package/src/router.ts +273 -0
  56. package/src/schema.ts +166 -0
  57. package/src/size-cap.test.ts +32 -0
  58. package/src/size-cap.ts +40 -0
  59. package/src/storage-migration.test.ts +318 -0
  60. package/src/storage-migration.ts +213 -0
  61. package/src/stores.ts +533 -0
  62. package/src/tree-gc.test.ts +184 -0
  63. package/src/tree-gc.ts +160 -0
  64. package/src/tree-retirement.ts +81 -0
  65. package/src/type-acquisition-route.ts +178 -0
  66. package/tsconfig.json +23 -0
@@ -0,0 +1,127 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { SafeDatabase } from "@checkstack/backend-api";
4
+ import { storePaths, resolveScriptPackagesDir } from "./data-dir";
5
+ import { readCurrentTarget } from "./atomic-symlink";
6
+ import { createInstallStateStore } from "./install-state-store";
7
+ import { scriptPackageInstallState } from "./schema";
8
+
9
+ /**
10
+ * What a script-execution call site should do about package resolution.
11
+ *
12
+ * - `none` — no packages are configured (desired hash is null). The
13
+ * caller leaves `resolutionRoot` UNSET so the runner uses
14
+ * `os.tmpdir()` exactly as before: non-package scripts are
15
+ * completely unaffected.
16
+ * - `ready` — packages are configured AND this host has materialized
17
+ * the desired tree. The caller passes `root` as the runner's
18
+ * `resolutionRoot` so `import "<pkg>"` resolves.
19
+ * - `notReady` — packages ARE configured but this host hasn't materialized
20
+ * the desired hash yet (cold start, mid-reconcile, or a
21
+ * failed reconcile). The caller MUST surface a clear error
22
+ * on any package import rather than running against a stale
23
+ * or empty tree. `reason` is a ready-to-surface message.
24
+ */
25
+ export type ResolutionRootStatus =
26
+ | { mode: "none" }
27
+ | { mode: "ready"; root: string }
28
+ | { mode: "notReady"; reason: string };
29
+
30
+ async function symlinkResolvesToTree(currentPath: string): Promise<boolean> {
31
+ // `current` is a symlink to `trees/<hash>`; require it to resolve to a
32
+ // real dir with a node_modules so we never point the runner at a
33
+ // half-built / missing tree.
34
+ const target = await readCurrentTarget(currentPath);
35
+ if (!target) return false;
36
+ const resolved = path.isAbsolute(target)
37
+ ? target
38
+ : path.join(path.dirname(currentPath), target);
39
+ try {
40
+ const info = await stat(path.join(resolved, "node_modules"));
41
+ return info.isDirectory();
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Decide the resolution-root status for a script execution on this host.
49
+ *
50
+ * @param desiredLockfileHash the install state's desired hash (null = no
51
+ * packages configured).
52
+ * @param storeRoot the package-store root (`<dataDir>/script-packages`),
53
+ * identical shape on core and satellites.
54
+ * @param hostLabel "central backend" | "satellite" — woven into the
55
+ * not-ready error so operators know which host to look at.
56
+ */
57
+ export async function resolveResolutionRoot({
58
+ desiredLockfileHash,
59
+ storeRoot,
60
+ hostLabel = "central backend",
61
+ }: {
62
+ desiredLockfileHash: string | null;
63
+ storeRoot: string;
64
+ hostLabel?: string;
65
+ }): Promise<ResolutionRootStatus> {
66
+ if (desiredLockfileHash === null) {
67
+ return { mode: "none" };
68
+ }
69
+ const paths = storePaths(storeRoot);
70
+ const currentHash = await readCurrentTarget(paths.current).then((target) =>
71
+ target ? path.basename(target) : undefined,
72
+ );
73
+ if (
74
+ currentHash === desiredLockfileHash &&
75
+ (await symlinkResolvesToTree(paths.current))
76
+ ) {
77
+ return { mode: "ready", root: paths.current };
78
+ }
79
+ return {
80
+ mode: "notReady",
81
+ reason: `npm packages not ready on this ${hostLabel} (expected ${desiredLockfileHash}${
82
+ currentHash ? `, have ${currentHash}` : ", none materialized"
83
+ }). The package set is still syncing; retry shortly.`,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Filesystem-only resolution: returns `ready` when `<store>/current`
89
+ * resolves to a materialized tree, else `none`. Host-agnostic and needs no
90
+ * DB / RPC / desired-hash, so it works on satellites (which have no script-
91
+ * packages RPC) as well as core. Execution safety does NOT depend on the
92
+ * `notReady` distinction: the runner always disables Bun auto-install, so a
93
+ * missing package errors regardless. This resolver simply points the runner
94
+ * at the synced tree when one exists.
95
+ */
96
+ export async function resolveResolutionRootFromStore(
97
+ storeRoot: string,
98
+ ): Promise<ResolutionRootStatus> {
99
+ const paths = storePaths(storeRoot);
100
+ if (await symlinkResolvesToTree(paths.current)) {
101
+ return { mode: "ready", root: paths.current };
102
+ }
103
+ return { mode: "none" };
104
+ }
105
+
106
+ /**
107
+ * Convenience for central-backend call sites: read the desired hash from the
108
+ * install state and resolve the local resolution-root status against the
109
+ * default `<dataDir>/script-packages` store. Plugins that execute user
110
+ * scripts call this per run.
111
+ */
112
+ export async function resolveResolutionRootForHost({
113
+ db,
114
+ hostLabel = "central backend",
115
+ }: {
116
+ db: SafeDatabase<{
117
+ scriptPackageInstallState: typeof scriptPackageInstallState;
118
+ }>;
119
+ hostLabel?: string;
120
+ }): Promise<ResolutionRootStatus> {
121
+ const state = await createInstallStateStore(db).load();
122
+ return resolveResolutionRoot({
123
+ desiredLockfileHash: state.lockfileHash,
124
+ storeRoot: resolveScriptPackagesDir(),
125
+ hostLabel,
126
+ });
127
+ }
@@ -0,0 +1,133 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { createCentralResolver } from "./resolver";
6
+
7
+ /**
8
+ * Hermetic resolver tests. No internet: we point the registry at an
9
+ * unreachable loopback address so `bun install` fails fast (connection
10
+ * refused), exercising the REAL resolve path through to its error.
11
+ *
12
+ * The security-relevant property under test: the scratch dir holds a
13
+ * plaintext `.npmrc` with the registry auth token, so it MUST be removed on
14
+ * EVERY exit path - including failures - not only on the success path.
15
+ */
16
+ describe("createCentralResolver scratch cleanup", () => {
17
+ let work: string;
18
+
19
+ beforeEach(async () => {
20
+ work = await mkdtemp(path.join(tmpdir(), "cs-resolver-"));
21
+ });
22
+ afterEach(async () => {
23
+ await rm(work, { recursive: true, force: true });
24
+ });
25
+
26
+ async function exists(p: string): Promise<boolean> {
27
+ try {
28
+ await stat(p);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ test("removes the token-bearing scratch dir when bun install fails", async () => {
36
+ const scratchDir = path.join(work, "scratch");
37
+ const resolver = createCentralResolver({
38
+ scratchDir,
39
+ cacheDir: path.join(work, "cache"),
40
+ registry: {
41
+ // Unreachable loopback → fast ConnectionRefused, no real network.
42
+ registryUrl: "http://127.0.0.1:1/",
43
+ scopedRegistries: [],
44
+ authToken: "super-secret-token",
45
+ },
46
+ });
47
+
48
+ await expect(
49
+ resolver.resolve({
50
+ packages: [
51
+ {
52
+ name: "definitely-not-a-real-pkg-xyz-123",
53
+ version: "1.0.0",
54
+ enabled: true,
55
+ },
56
+ ],
57
+ ignoreScripts: true,
58
+ }),
59
+ ).rejects.toThrow();
60
+
61
+ // The scratch dir (and its .npmrc token) must be gone after the failure.
62
+ expect(await exists(scratchDir)).toBe(false);
63
+ expect(await exists(path.join(scratchDir, ".npmrc"))).toBe(false);
64
+ }, 30_000);
65
+ });
66
+
67
+ describe("createCentralResolver empty allowlist", () => {
68
+ let work: string;
69
+
70
+ beforeEach(async () => {
71
+ work = await mkdtemp(path.join(tmpdir(), "cs-resolver-empty-"));
72
+ });
73
+ afterEach(async () => {
74
+ await rm(work, { recursive: true, force: true });
75
+ });
76
+
77
+ async function exists(p: string): Promise<boolean> {
78
+ try {
79
+ await stat(p);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ // Regression: with no enabled packages `bun install` writes no `bun.lock`,
87
+ // so unconditionally reading it threw ENOENT and failed "Install now". The
88
+ // resolver must short-circuit to an empty resolved set without running a
89
+ // subprocess or touching the (unreachable) registry.
90
+ test("resolves to an empty set without running bun install", async () => {
91
+ const scratchDir = path.join(work, "scratch");
92
+ const resolver = createCentralResolver({
93
+ scratchDir,
94
+ cacheDir: path.join(work, "cache"),
95
+ registry: {
96
+ // Unreachable: proves no install subprocess / network is attempted.
97
+ registryUrl: "http://127.0.0.1:1/",
98
+ scopedRegistries: [],
99
+ authToken: "super-secret-token",
100
+ },
101
+ });
102
+
103
+ const result = await resolver.resolve({
104
+ packages: [],
105
+ ignoreScripts: true,
106
+ });
107
+
108
+ expect(result).toEqual([]);
109
+ // No scratch dir was created (short-circuited before any disk write).
110
+ expect(await exists(scratchDir)).toBe(false);
111
+ });
112
+
113
+ test("treats an all-disabled allowlist as empty", async () => {
114
+ const scratchDir = path.join(work, "scratch-disabled");
115
+ const resolver = createCentralResolver({
116
+ scratchDir,
117
+ cacheDir: path.join(work, "cache-disabled"),
118
+ registry: {
119
+ registryUrl: "http://127.0.0.1:1/",
120
+ scopedRegistries: [],
121
+ authToken: "super-secret-token",
122
+ },
123
+ });
124
+
125
+ const result = await resolver.resolve({
126
+ packages: [{ name: "left-pad", version: "1.0.0", enabled: false }],
127
+ ignoreScripts: true,
128
+ });
129
+
130
+ expect(result).toEqual([]);
131
+ expect(await exists(scratchDir)).toBe(false);
132
+ });
133
+ });
@@ -0,0 +1,132 @@
1
+ import { spawn } from "bun";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { PackageSpec } from "@checkstack/script-packages-common";
5
+ import type { Resolver, ResolvedPackage } from "./install-service";
6
+ import { buildDependencies, buildStorePackageJson } from "./lockfile";
7
+ import { renderNpmrc, type NpmrcInput } from "./npmrc";
8
+ import { parseBunLock } from "./parse-bun-lock";
9
+ import { packDir } from "./cache-archive";
10
+ import { findCacheEntry } from "./cache-layout";
11
+
12
+ /**
13
+ * Concrete central-install {@link Resolver}.
14
+ *
15
+ * In an isolated scratch dir it writes the generated `package.json` +
16
+ * `.npmrc` + a `bunfig.toml` that disables auto-install, runs `bun install`
17
+ * against the (possibly internal) registry into a dedicated cache dir,
18
+ * parses the resulting `bun.lock` for the manifest, then packs each
19
+ * package's Bun cache entry into a content-addressed blob.
20
+ *
21
+ * Runs only on the elected installer. The auth token is written to the
22
+ * scratch `.npmrc` and the whole scratch dir is removed afterwards; the
23
+ * token is never logged.
24
+ */
25
+
26
+ export interface CentralResolverOptions {
27
+ /** Scratch dir for the resolve (created + removed per resolve). */
28
+ scratchDir: string;
29
+ /** Dedicated Bun cache dir to install into (the publish source). */
30
+ cacheDir: string;
31
+ /** Registry config (token already resolved from the secret store). */
32
+ registry: NpmrcInput;
33
+ }
34
+
35
+ function bunfigDisableAutoInstall(): string {
36
+ // Disable Bun auto-install so a missing package fails fast instead of
37
+ // silently fetching at import time on hosts (matches reconcile behavior).
38
+ return '[install]\nauto = "disable"\n';
39
+ }
40
+
41
+ export function createCentralResolver(
42
+ options: CentralResolverOptions,
43
+ ): Resolver {
44
+ return {
45
+ async resolve({
46
+ packages,
47
+ ignoreScripts,
48
+ }: {
49
+ packages: PackageSpec[];
50
+ ignoreScripts: boolean;
51
+ }): Promise<ResolvedPackage[]> {
52
+ const { scratchDir, cacheDir, registry } = options;
53
+
54
+ // No enabled packages → `bun install` writes no `bun.lock`, so reading
55
+ // it would throw ENOENT and fail the install. Short-circuit to an empty
56
+ // resolved set: an empty manifest yields totalSizeBytes 0 and a
57
+ // deterministic empty-lockfile hash, ending the install in `ready`/0 MB.
58
+ if (Object.keys(buildDependencies(packages)).length === 0) {
59
+ return [];
60
+ }
61
+
62
+ await rm(scratchDir, { recursive: true, force: true });
63
+ await mkdir(scratchDir, { recursive: true });
64
+ await mkdir(cacheDir, { recursive: true });
65
+
66
+ // From the moment the token-bearing `.npmrc` is written, the scratch
67
+ // dir MUST be removed on every exit path - success OR throw - so the
68
+ // plaintext registry token never lingers on disk (a throw between
69
+ // install and pack would otherwise leak it until the next resolve).
70
+ await writeFile(
71
+ path.join(scratchDir, "package.json"),
72
+ buildStorePackageJson(packages),
73
+ );
74
+ await writeFile(path.join(scratchDir, ".npmrc"), renderNpmrc(registry));
75
+ await writeFile(
76
+ path.join(scratchDir, "bunfig.toml"),
77
+ bunfigDisableAutoInstall(),
78
+ );
79
+
80
+ try {
81
+ // Plain `bun install` (no --no-save) so `bun.lock` is written - it's
82
+ // the manifest source. The scratch dir is throwaway, so saving is fine.
83
+ const installArgs = ["install"];
84
+ if (ignoreScripts) installArgs.push("--ignore-scripts");
85
+
86
+ const proc = spawn({
87
+ cmd: [process.execPath, ...installArgs],
88
+ cwd: scratchDir,
89
+ env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ });
93
+ const [stderr, exitCode] = await Promise.all([
94
+ new Response(proc.stderr).text(),
95
+ proc.exited,
96
+ ]);
97
+ if (exitCode !== 0) {
98
+ // Never include the .npmrc / token; only Bun's own stderr.
99
+ throw new Error(
100
+ `bun install failed (exit ${exitCode}): ${stderr.slice(0, 800)}`,
101
+ );
102
+ }
103
+
104
+ const lockText = await Bun.file(
105
+ path.join(scratchDir, "bun.lock"),
106
+ ).text();
107
+ const manifest = parseBunLock(lockText);
108
+
109
+ const resolved: ResolvedPackage[] = [];
110
+ for (const entry of manifest) {
111
+ const loc = await findCacheEntry({
112
+ cacheDir,
113
+ name: entry.name,
114
+ version: entry.version,
115
+ });
116
+ if (!loc) {
117
+ throw new Error(
118
+ `Resolved ${entry.name}@${entry.version} but its cache entry was not found; cannot publish blob.`,
119
+ );
120
+ }
121
+ const blob = await packDir(loc);
122
+ resolved.push({ entry, blob });
123
+ }
124
+
125
+ return resolved;
126
+ } finally {
127
+ // Best-effort: a cleanup failure must not mask the original outcome.
128
+ await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
129
+ }
130
+ },
131
+ };
132
+ }
package/src/router.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ correlationMiddleware,
5
+ resolveActor,
6
+ type Logger,
7
+ type RpcContext,
8
+ type SafeDatabase,
9
+ } from "@checkstack/backend-api";
10
+ import type { RegistryTokenStore } from "./registry-token";
11
+ import {
12
+ scriptPackagesContract,
13
+ type BlobGcSummary,
14
+ } from "@checkstack/script-packages-common";
15
+ import type { BlobStoreRegistry } from "./blob-store-registry";
16
+ import {
17
+ createPackageStore,
18
+ createRegistryConfigStore,
19
+ createSizeCapStore,
20
+ createStorageConfigStore,
21
+ createSatelliteStateStore,
22
+ createBlobGcStateStore,
23
+ } from "./stores";
24
+ import { createInstallStateStore } from "./install-state-store";
25
+ import { resolveRegistryRequestConfig } from "./registry-request-config";
26
+ import {
27
+ searchPackages as registrySearchPackages,
28
+ getPackageVersions as registryGetPackageVersions,
29
+ RegistryClientError,
30
+ } from "./registry-client";
31
+ import * as schema from "./schema";
32
+
33
+ export interface ScriptPackagesRouterDeps {
34
+ db: SafeDatabase<typeof schema>;
35
+ blobStores: BlobStoreRegistry;
36
+ logger: Logger;
37
+ /** Trigger an install (elected). Provided by the plugin (wires the resolver). */
38
+ triggerInstall(): Promise<{ started: boolean; reason?: string }>;
39
+ /**
40
+ * Kick a storage migration to `target` in the background. Provided by the
41
+ * plugin (wires the blob stores). Returns immediately; progress is polled
42
+ * via `getStorageMigrationState`.
43
+ */
44
+ triggerMigration(input: {
45
+ target: string;
46
+ }): Promise<{ started: boolean; reason?: string }>;
47
+ /**
48
+ * Run a blob-GC pass (elected via the installer advisory lock; refuses
49
+ * while an install / migration is in flight). Provided by the plugin
50
+ * (wires the blob stores + retention/grace). Returns a summary.
51
+ */
52
+ triggerBlobGc(): Promise<BlobGcSummary>;
53
+ /**
54
+ * Registry auth-token store, backed by the secrets platform's internal
55
+ * secrets. Provided by the plugin (which injects `internalSecretsRef`).
56
+ */
57
+ registryToken: RegistryTokenStore;
58
+ }
59
+
60
+ export function createScriptPackagesRouter({
61
+ db,
62
+ blobStores,
63
+ logger,
64
+ triggerInstall,
65
+ triggerMigration,
66
+ triggerBlobGc,
67
+ registryToken,
68
+ }: ScriptPackagesRouterDeps) {
69
+ const packages = createPackageStore(db);
70
+ const registry = createRegistryConfigStore(db);
71
+ const storage = createStorageConfigStore(db);
72
+ const sizeCap = createSizeCapStore(db);
73
+ const satellites = createSatelliteStateStore(db);
74
+ const installState = createInstallStateStore(db);
75
+ const blobGcState = createBlobGcStateStore(db);
76
+
77
+ const os = implement(scriptPackagesContract)
78
+ .$context<RpcContext>()
79
+ .use(correlationMiddleware)
80
+ .use(autoAuthMiddleware);
81
+
82
+ return os.router({
83
+ // ─── Allowlist ────────────────────────────────────────────────────────
84
+ listPackages: os.listPackages.handler(async () => ({
85
+ items: await packages.list(),
86
+ })),
87
+
88
+ addPackage: os.addPackage.handler(async ({ input, context }) =>
89
+ packages.upsert({
90
+ name: input.name,
91
+ version: input.version,
92
+ addedBy: resolveActor(context.user).id ?? null,
93
+ }),
94
+ ),
95
+
96
+ removePackage: os.removePackage.handler(async ({ input }) => {
97
+ await packages.remove(input.name);
98
+ return { success: true };
99
+ }),
100
+
101
+ setPackageEnabled: os.setPackageEnabled.handler(async ({ input }) =>
102
+ packages.setEnabled({ name: input.name, enabled: input.enabled }),
103
+ ),
104
+
105
+ // ─── Registry autocomplete ────────────────────────────────────────────
106
+ searchPackages: os.searchPackages.handler(async ({ input }) => {
107
+ const reqConfig = await resolveRegistryRequestConfig({
108
+ registry,
109
+ registryToken,
110
+ logger,
111
+ });
112
+ try {
113
+ const items = await registrySearchPackages({
114
+ registry: reqConfig,
115
+ text: input.text,
116
+ });
117
+ return { items };
118
+ } catch (error) {
119
+ if (error instanceof RegistryClientError) {
120
+ throw new ORPCError("BAD_GATEWAY", { message: error.message });
121
+ }
122
+ throw error;
123
+ }
124
+ }),
125
+
126
+ getPackageVersions: os.getPackageVersions.handler(async ({ input }) => {
127
+ const reqConfig = await resolveRegistryRequestConfig({
128
+ registry,
129
+ registryToken,
130
+ logger,
131
+ });
132
+ try {
133
+ return await registryGetPackageVersions({
134
+ registry: reqConfig,
135
+ name: input.name,
136
+ });
137
+ } catch (error) {
138
+ if (error instanceof RegistryClientError) {
139
+ throw new ORPCError("BAD_GATEWAY", { message: error.message });
140
+ }
141
+ throw error;
142
+ }
143
+ }),
144
+
145
+ // ─── Registry config ──────────────────────────────────────────────────
146
+ getRegistryConfig: os.getRegistryConfig.handler(async () => registry.get()),
147
+
148
+ setRegistryConfig: os.setRegistryConfig.handler(async ({ input }) => {
149
+ // Store the token (if provided) in the secrets platform's internal
150
+ // secrets and persist the stable marker as the "secret ref".
151
+ // `undefined` leaves the existing token; "" clears it.
152
+ let authSecretRef: string | null | undefined;
153
+ if (input.authToken !== undefined) {
154
+ if (input.authToken.length > 0) {
155
+ authSecretRef = await registryToken.store(input.authToken);
156
+ } else {
157
+ await registryToken.clear();
158
+ authSecretRef = null;
159
+ }
160
+ }
161
+ await registry.set({
162
+ registryUrl: input.registryUrl,
163
+ scopedRegistries: input.scopedRegistries,
164
+ ignoreScripts: input.ignoreScripts,
165
+ authSecretRef,
166
+ });
167
+ return registry.get();
168
+ }),
169
+
170
+ // ─── Install ──────────────────────────────────────────────────────────
171
+ installNow: os.installNow.handler(async () => triggerInstall()),
172
+
173
+ getSizeCapConfig: os.getSizeCapConfig.handler(async () => sizeCap.get()),
174
+ setSizeCapConfig: os.setSizeCapConfig.handler(async ({ input }) => {
175
+ await sizeCap.set(input);
176
+ return sizeCap.get();
177
+ }),
178
+
179
+ // ─── Storage ──────────────────────────────────────────────────────────
180
+ getStorageConfig: os.getStorageConfig.handler(async () => storage.get()),
181
+
182
+ listStorageBackends: os.listStorageBackends.handler(async () => ({
183
+ backends: blobStores.ids(),
184
+ })),
185
+
186
+ setStorageBackend: os.setStorageBackend.handler(async ({ input }) => {
187
+ if (!blobStores.has(input.backend)) {
188
+ throw new ORPCError("BAD_REQUEST", {
189
+ message: `Blob store backend "${input.backend}" is not available.`,
190
+ });
191
+ }
192
+ const current = await storage.get();
193
+ if (current.migrationStatus === "migrating") {
194
+ throw new ORPCError("CONFLICT", {
195
+ message:
196
+ "A storage migration is in progress; cannot change the active backend until it completes.",
197
+ });
198
+ }
199
+ // Directly setting the active backend does NOT move existing blobs. Use
200
+ // `migrateStorage` to copy blobs to the new backend. This is for an
201
+ // initial selection / when both backends already hold the blobs.
202
+ await storage.setActiveBackend(input.backend);
203
+ return storage.get();
204
+ }),
205
+
206
+ migrateStorage: os.migrateStorage.handler(async ({ input }) => {
207
+ if (!blobStores.has(input.target)) {
208
+ throw new ORPCError("BAD_REQUEST", {
209
+ message: `Target blob store backend "${input.target}" is not available.`,
210
+ });
211
+ }
212
+ return triggerMigration({ target: input.target });
213
+ }),
214
+
215
+ getStorageMigrationState: os.getStorageMigrationState.handler(async () =>
216
+ storage.get(),
217
+ ),
218
+
219
+ // ─── Garbage collection ───────────────────────────────────────────────
220
+ gcBlobs: os.gcBlobs.handler(async () => triggerBlobGc()),
221
+
222
+ getBlobGcState: os.getBlobGcState.handler(async () => blobGcState.get()),
223
+
224
+ // ─── Per-host status ──────────────────────────────────────────────────
225
+ listSatelliteSyncState: os.listSatelliteSyncState.handler(async () => ({
226
+ items: await satellites.list(),
227
+ })),
228
+
229
+ reportSatelliteSyncState: os.reportSatelliteSyncState.handler(
230
+ async ({ input }) => {
231
+ await satellites.report(input);
232
+ return { success: true };
233
+ },
234
+ ),
235
+
236
+ // ─── Authoring / runtime ──────────────────────────────────────────────
237
+ getInstallState: os.getInstallState.handler(async () => installState.load()),
238
+
239
+ getManifest: os.getManifest.handler(async ({ input }) => {
240
+ const state = await installState.load();
241
+ if (state.lockfileHash !== input.lockfileHash) {
242
+ // We only retain the current desired manifest; an older hash isn't
243
+ // reconstructable. Return empty so the caller falls back to a full
244
+ // pull against the current manifest.
245
+ return { entries: [] };
246
+ }
247
+ return { entries: state.manifest };
248
+ }),
249
+
250
+ downloadBlob: os.downloadBlob.handler(async ({ input }) => {
251
+ const storageConfig = await storage.get();
252
+ const active = storageConfig.activeBackend;
253
+ const result = await blobStores.readWithFallback({
254
+ integrity: input.integrity,
255
+ activeBackendId: active,
256
+ });
257
+ if (!result) {
258
+ throw new ORPCError("NOT_FOUND", {
259
+ message: `Blob ${input.integrity} not found in any backend.`,
260
+ });
261
+ }
262
+ return {
263
+ integrity: input.integrity,
264
+ data: Buffer.from(result.bytes).toString("base64"),
265
+ sizeBytes: result.bytes.byteLength,
266
+ };
267
+ }),
268
+ });
269
+ }
270
+
271
+ export type ScriptPackagesRouter = ReturnType<
272
+ typeof createScriptPackagesRouter
273
+ >;