@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.
- package/CHANGELOG.md +273 -0
- package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
- package/drizzle/0001_flawless_drax.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +395 -0
- package/drizzle/meta/0001_snapshot.json +491 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +32 -0
- package/src/atomic-symlink.test.ts +47 -0
- package/src/atomic-symlink.ts +66 -0
- package/src/blob-gc-runner.test.ts +120 -0
- package/src/blob-gc-runner.ts +139 -0
- package/src/blob-gc.test.ts +182 -0
- package/src/blob-gc.ts +161 -0
- package/src/blob-hash.test.ts +70 -0
- package/src/blob-hash.ts +56 -0
- package/src/blob-store-registry.test.ts +78 -0
- package/src/blob-store-registry.ts +75 -0
- package/src/blob-store.ts +51 -0
- package/src/cache-archive.test.ts +164 -0
- package/src/cache-archive.ts +192 -0
- package/src/cache-layout.ts +64 -0
- package/src/data-dir.test.ts +41 -0
- package/src/data-dir.ts +42 -0
- package/src/e2e-install-reconcile.test.ts +121 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +594 -0
- package/src/install-controller.test.ts +257 -0
- package/src/install-controller.ts +144 -0
- package/src/install-service.test.ts +104 -0
- package/src/install-service.ts +116 -0
- package/src/install-state-store.ts +131 -0
- package/src/lockfile.test.ts +60 -0
- package/src/lockfile.ts +0 -0
- package/src/npmrc.test.ts +48 -0
- package/src/npmrc.ts +42 -0
- package/src/package-types.test.ts +293 -0
- package/src/package-types.ts +408 -0
- package/src/parse-bun-lock.test.ts +62 -0
- package/src/parse-bun-lock.ts +59 -0
- package/src/reconcile-diff.test.ts +41 -0
- package/src/reconcile-diff.ts +26 -0
- package/src/reconcile-fs.ts +199 -0
- package/src/reconciler.test.ts +289 -0
- package/src/reconciler.ts +81 -0
- package/src/registry-client.test.ts +314 -0
- package/src/registry-client.ts +0 -0
- package/src/registry-request-config.ts +63 -0
- package/src/registry-token.test.ts +124 -0
- package/src/registry-token.ts +104 -0
- package/src/resolution-root.test.ts +82 -0
- package/src/resolution-root.ts +127 -0
- package/src/resolver.test.ts +133 -0
- package/src/resolver.ts +132 -0
- package/src/router.ts +273 -0
- package/src/schema.ts +166 -0
- package/src/size-cap.test.ts +32 -0
- package/src/size-cap.ts +40 -0
- package/src/storage-migration.test.ts +318 -0
- package/src/storage-migration.ts +213 -0
- package/src/stores.ts +533 -0
- package/src/tree-gc.test.ts +184 -0
- package/src/tree-gc.ts +160 -0
- package/src/tree-retirement.ts +81 -0
- package/src/type-acquisition-route.ts +178 -0
- 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
|
+
});
|
package/src/resolver.ts
ADDED
|
@@ -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
|
+
>;
|