@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,257 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ManifestEntry } from "@checkstack/script-packages-common";
|
|
3
|
+
import { runInstallNow, type InstallControllerDeps } from "./install-controller";
|
|
4
|
+
import type {
|
|
5
|
+
InstallStateStore,
|
|
6
|
+
InstallerLock,
|
|
7
|
+
} from "./install-state-store";
|
|
8
|
+
|
|
9
|
+
const entry: ManifestEntry = { name: "a", version: "1.0.0", integrity: "sha-a" };
|
|
10
|
+
|
|
11
|
+
function fakeState(): {
|
|
12
|
+
store: InstallStateStore;
|
|
13
|
+
calls: string[];
|
|
14
|
+
} {
|
|
15
|
+
const calls: string[] = [];
|
|
16
|
+
return {
|
|
17
|
+
calls,
|
|
18
|
+
store: {
|
|
19
|
+
load: async () => ({
|
|
20
|
+
status: "idle",
|
|
21
|
+
lockfileHash: null,
|
|
22
|
+
manifest: [],
|
|
23
|
+
totalSizeBytes: 0,
|
|
24
|
+
lastInstalledAt: null,
|
|
25
|
+
errorMessage: null,
|
|
26
|
+
}),
|
|
27
|
+
setInstalling: async () => void calls.push("installing"),
|
|
28
|
+
setReady: async () => void calls.push("ready"),
|
|
29
|
+
setError: async (m) => void calls.push(`error:${m}`),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* SHARED single-token installer-election lock — a faithful in-memory model
|
|
36
|
+
* of the Postgres advisory lock guarding installer election across pods.
|
|
37
|
+
* ONE boolean (`heldBy`) is shared across every caller wired to the same
|
|
38
|
+
* instance, mirroring `advisory-lock.test.ts`'s `heldBy` model:
|
|
39
|
+
*
|
|
40
|
+
* - `tryInstallerLock` grants ONLY if the token is free; it then binds the
|
|
41
|
+
* token to the acquiring handle and returns a `release()` that frees it.
|
|
42
|
+
* - A concurrent caller against the SAME instance gets `null` while held.
|
|
43
|
+
*
|
|
44
|
+
* `calls?.push("released")` records releases so the always-frees invariant
|
|
45
|
+
* stays observable for the single-caller suite.
|
|
46
|
+
*/
|
|
47
|
+
function makeSharedInstallerLock(calls?: string[]): InstallerLock {
|
|
48
|
+
// `null` = free; otherwise the id of the holding handle.
|
|
49
|
+
const state: { heldBy: number | null } = { heldBy: null };
|
|
50
|
+
let nextHandleId = 0;
|
|
51
|
+
return {
|
|
52
|
+
tryInstallerLock: async () => {
|
|
53
|
+
if (state.heldBy !== null) return null;
|
|
54
|
+
const handleId = nextHandleId++;
|
|
55
|
+
state.heldBy = handleId;
|
|
56
|
+
let released = false;
|
|
57
|
+
return {
|
|
58
|
+
release: async () => {
|
|
59
|
+
if (released) return;
|
|
60
|
+
released = true;
|
|
61
|
+
// Only the holder frees the shared token (no-op otherwise).
|
|
62
|
+
if (state.heldBy === handleId) state.heldBy = null;
|
|
63
|
+
calls?.push("released");
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function baseDeps(
|
|
71
|
+
overrides: Partial<InstallControllerDeps> = {},
|
|
72
|
+
): { deps: InstallControllerDeps; emitted: string[]; stateCalls: string[] } {
|
|
73
|
+
const emitted: string[] = [];
|
|
74
|
+
const { store, calls } = fakeState();
|
|
75
|
+
const deps: InstallControllerDeps = {
|
|
76
|
+
installState: store,
|
|
77
|
+
installerLock: makeSharedInstallerLock(calls),
|
|
78
|
+
resolver: { resolve: async () => [{ entry, blob: new Uint8Array(10) }] },
|
|
79
|
+
blobStore: {
|
|
80
|
+
id: "postgres",
|
|
81
|
+
has: async () => false,
|
|
82
|
+
put: async () => {},
|
|
83
|
+
},
|
|
84
|
+
blobIndex: { record: async () => {} },
|
|
85
|
+
loadInstallInputs: async () => ({
|
|
86
|
+
packages: [{ name: "a", version: "1.0.0", enabled: true }],
|
|
87
|
+
ignoreScripts: true,
|
|
88
|
+
}),
|
|
89
|
+
sizeCap: async () => ({
|
|
90
|
+
warnBytes: 150 * 1024 * 1024,
|
|
91
|
+
blockBytes: 300 * 1024 * 1024,
|
|
92
|
+
}),
|
|
93
|
+
isMigrationInFlight: async () => false,
|
|
94
|
+
emitChanged: async ({ lockfileHash }) => void emitted.push(lockfileHash),
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
return { deps, emitted, stateCalls: calls };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("runInstallNow", () => {
|
|
101
|
+
test("installs, records ready, and emits changed", async () => {
|
|
102
|
+
const { deps, emitted, stateCalls } = baseDeps();
|
|
103
|
+
const out = await runInstallNow(deps);
|
|
104
|
+
expect(out.started).toBe(true);
|
|
105
|
+
expect(stateCalls).toContain("installing");
|
|
106
|
+
expect(stateCalls).toContain("ready");
|
|
107
|
+
expect(stateCalls).toContain("released");
|
|
108
|
+
expect(emitted).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("refuses while a storage migration is in flight", async () => {
|
|
112
|
+
const { deps } = baseDeps({ isMigrationInFlight: async () => true });
|
|
113
|
+
const out = await runInstallNow(deps);
|
|
114
|
+
expect(out.started).toBe(false);
|
|
115
|
+
expect(out.reason).toMatch(/migration/i);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("refuses when the installer lock is held by another instance", async () => {
|
|
119
|
+
const emitted: string[] = [];
|
|
120
|
+
// Model "another instance holds it": pre-acquire the shared token so the
|
|
121
|
+
// controller's own attempt is refused.
|
|
122
|
+
const sharedLock = makeSharedInstallerLock();
|
|
123
|
+
const held = await sharedLock.tryInstallerLock();
|
|
124
|
+
expect(held).not.toBeNull();
|
|
125
|
+
const { deps } = baseDeps({ installerLock: sharedLock });
|
|
126
|
+
deps.emitChanged = async ({ lockfileHash }) =>
|
|
127
|
+
void emitted.push(lockfileHash);
|
|
128
|
+
const out = await runInstallNow(deps);
|
|
129
|
+
expect(out.started).toBe(false);
|
|
130
|
+
expect(out.reason).toMatch(/another instance/i);
|
|
131
|
+
expect(emitted).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("two concurrent installers against the SAME shared lock: exactly one wins", async () => {
|
|
135
|
+
// ── Multi-instance election contention ──
|
|
136
|
+
// One shared single-token lock across BOTH callers (two pods racing
|
|
137
|
+
// `installNow`). Each pod gets its own deps/state, but they contend on
|
|
138
|
+
// the SAME advisory lock — exactly one may proceed.
|
|
139
|
+
const sharedLock = makeSharedInstallerLock();
|
|
140
|
+
|
|
141
|
+
// Widen the acquire→install→release window with a real async gap so the
|
|
142
|
+
// loser genuinely attempts acquire while the winner still holds the
|
|
143
|
+
// token (the interleaving that would double-install without a shared
|
|
144
|
+
// lock).
|
|
145
|
+
const slowResolver = {
|
|
146
|
+
resolve: async () => {
|
|
147
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
148
|
+
return [{ entry, blob: new Uint8Array(10) }];
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const a = baseDeps({ installerLock: sharedLock, resolver: slowResolver });
|
|
153
|
+
const b = baseDeps({ installerLock: sharedLock, resolver: slowResolver });
|
|
154
|
+
|
|
155
|
+
const [outA, outB] = await Promise.all([
|
|
156
|
+
runInstallNow(a.deps),
|
|
157
|
+
runInstallNow(b.deps),
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const started = [outA, outB].filter((o) => o.started);
|
|
161
|
+
const refused = [outA, outB].filter((o) => !o.started);
|
|
162
|
+
expect(started).toHaveLength(1);
|
|
163
|
+
expect(refused).toHaveLength(1);
|
|
164
|
+
expect(refused[0]!.reason).toMatch(/another instance/i);
|
|
165
|
+
|
|
166
|
+
// The winner recorded installing → ready exactly once; the loser never
|
|
167
|
+
// touched its install state (no installing/ready) and never emitted.
|
|
168
|
+
const winner = outA.started ? a : b;
|
|
169
|
+
const loser = outA.started ? b : a;
|
|
170
|
+
expect(winner.stateCalls.filter((c) => c === "installing")).toHaveLength(1);
|
|
171
|
+
expect(winner.stateCalls.filter((c) => c === "ready")).toHaveLength(1);
|
|
172
|
+
expect(winner.emitted).toHaveLength(1);
|
|
173
|
+
expect(loser.stateCalls).not.toContain("installing");
|
|
174
|
+
expect(loser.stateCalls).not.toContain("ready");
|
|
175
|
+
expect(loser.emitted).toHaveLength(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("blocks + records error when over the size cap", async () => {
|
|
179
|
+
const { deps, emitted, stateCalls } = baseDeps({
|
|
180
|
+
resolver: {
|
|
181
|
+
resolve: async () => [
|
|
182
|
+
{ entry, blob: new Uint8Array(400 * 1024 * 1024) },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const out = await runInstallNow(deps);
|
|
187
|
+
expect(out.started).toBe(false);
|
|
188
|
+
expect(out.reason).toMatch(/exceeds/i);
|
|
189
|
+
expect(stateCalls.some((c) => c.startsWith("error:"))).toBe(true);
|
|
190
|
+
expect(stateCalls).toContain("released");
|
|
191
|
+
expect(emitted).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("on a thrown install error, persists the message but logs the full stack", async () => {
|
|
195
|
+
const logged: string[] = [];
|
|
196
|
+
const boom = new Error("kaboom");
|
|
197
|
+
const { deps, stateCalls } = baseDeps({
|
|
198
|
+
resolver: {
|
|
199
|
+
resolve: async () => {
|
|
200
|
+
throw boom;
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
logger: { debug: () => {}, error: (m) => void logged.push(m) },
|
|
204
|
+
});
|
|
205
|
+
const out = await runInstallNow(deps);
|
|
206
|
+
|
|
207
|
+
expect(out.started).toBe(false);
|
|
208
|
+
// The persisted, user-facing install-state message is just the message.
|
|
209
|
+
expect(stateCalls).toContain("error:kaboom");
|
|
210
|
+
// The log carries the full stack so a non-reproducible failure is
|
|
211
|
+
// pinpointable to its throw site (not merely the message).
|
|
212
|
+
expect(logged).toHaveLength(1);
|
|
213
|
+
expect(logged[0]).toBe(`Script package install failed: ${boom.stack}`);
|
|
214
|
+
expect(logged[0]).toContain("install-controller.test");
|
|
215
|
+
expect(stateCalls).toContain("released");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("records lockfile history on a successful install", async () => {
|
|
219
|
+
const recorded: string[] = [];
|
|
220
|
+
const { deps } = baseDeps({
|
|
221
|
+
recordHistory: async ({ lockfileHash }) =>
|
|
222
|
+
void recorded.push(lockfileHash),
|
|
223
|
+
});
|
|
224
|
+
const out = await runInstallNow(deps);
|
|
225
|
+
expect(out.started).toBe(true);
|
|
226
|
+
expect(recorded).toHaveLength(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a history-record failure does NOT fail an otherwise-successful install", async () => {
|
|
230
|
+
const { deps, emitted } = baseDeps({
|
|
231
|
+
recordHistory: async () => {
|
|
232
|
+
throw new Error("history table missing");
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const out = await runInstallNow(deps);
|
|
236
|
+
expect(out.started).toBe(true);
|
|
237
|
+
// The change hook still fires; the install succeeded.
|
|
238
|
+
expect(emitted).toHaveLength(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("records error + releases lock on a resolve failure", async () => {
|
|
242
|
+
const { deps, stateCalls } = baseDeps({
|
|
243
|
+
resolver: {
|
|
244
|
+
resolve: async () => {
|
|
245
|
+
throw new Error("registry unreachable");
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const out = await runInstallNow(deps);
|
|
250
|
+
expect(out.started).toBe(false);
|
|
251
|
+
expect(out.reason).toBe("registry unreachable");
|
|
252
|
+
expect(stateCalls.some((c) => c.includes("registry unreachable"))).toBe(
|
|
253
|
+
true,
|
|
254
|
+
);
|
|
255
|
+
expect(stateCalls).toContain("released");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
2
|
+
import type {
|
|
3
|
+
ManifestEntry,
|
|
4
|
+
PackageSpec,
|
|
5
|
+
SizeCapConfig,
|
|
6
|
+
} from "@checkstack/script-packages-common";
|
|
7
|
+
import {
|
|
8
|
+
performInstall,
|
|
9
|
+
type BlobIndex,
|
|
10
|
+
type BlobPublisher,
|
|
11
|
+
type Resolver,
|
|
12
|
+
} from "./install-service";
|
|
13
|
+
import type { InstallStateStore, InstallerLock } from "./install-state-store";
|
|
14
|
+
import { evaluateSizeCap } from "./size-cap";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Coordinates one `installNow`: installer election (advisory lock), resolve
|
|
18
|
+
* + publish, size-cap enforcement, install-state persistence, and emitting
|
|
19
|
+
* `script-packages.changed`. Refuses to run while a storage migration is in
|
|
20
|
+
* flight.
|
|
21
|
+
*
|
|
22
|
+
* All collaborators are injected so the orchestration is unit-testable
|
|
23
|
+
* without a DB, a registry, or a real blob store.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface InstallControllerDeps {
|
|
27
|
+
installState: InstallStateStore;
|
|
28
|
+
installerLock: InstallerLock;
|
|
29
|
+
resolver: Resolver;
|
|
30
|
+
blobStore: BlobPublisher;
|
|
31
|
+
blobIndex: BlobIndex;
|
|
32
|
+
/** Enabled allowlist + registry ignore-scripts flag at install time. */
|
|
33
|
+
loadInstallInputs(): Promise<{
|
|
34
|
+
packages: PackageSpec[];
|
|
35
|
+
ignoreScripts: boolean;
|
|
36
|
+
}>;
|
|
37
|
+
sizeCap(): Promise<SizeCapConfig>;
|
|
38
|
+
/** True while a storage migration is in flight (blocks installs). */
|
|
39
|
+
isMigrationInFlight(): Promise<boolean>;
|
|
40
|
+
/** Emit `script-packages.changed { lockfileHash }` after a successful install. */
|
|
41
|
+
emitChanged(input: { lockfileHash: string }): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Record the just-installed manifest in lockfile history so the blob GC can
|
|
44
|
+
* compute its retained set (current + previous N). Best-effort: a failure
|
|
45
|
+
* here must not fail the install, but it must not silently throw either.
|
|
46
|
+
*/
|
|
47
|
+
recordHistory?(input: {
|
|
48
|
+
lockfileHash: string;
|
|
49
|
+
manifest: ManifestEntry[];
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
logger?: { debug(msg: string): void; error(msg: string): void };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InstallOutcome {
|
|
55
|
+
started: boolean;
|
|
56
|
+
reason?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run an install if eligible. Returns `{ started: false, reason }` when
|
|
61
|
+
* refused (migration in flight, another installer holds the lock, or the
|
|
62
|
+
* resolved size exceeds the block threshold). The advisory lock is always
|
|
63
|
+
* released.
|
|
64
|
+
*/
|
|
65
|
+
export async function runInstallNow(
|
|
66
|
+
deps: InstallControllerDeps,
|
|
67
|
+
): Promise<InstallOutcome> {
|
|
68
|
+
if (await deps.isMigrationInFlight()) {
|
|
69
|
+
return {
|
|
70
|
+
started: false,
|
|
71
|
+
reason: "A storage migration is in progress; try again once it completes.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lock = await deps.installerLock.tryInstallerLock();
|
|
76
|
+
if (!lock) {
|
|
77
|
+
return {
|
|
78
|
+
started: false,
|
|
79
|
+
reason: "Another instance is currently installing.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await deps.installState.setInstalling();
|
|
85
|
+
|
|
86
|
+
const { packages, ignoreScripts } = await deps.loadInstallInputs();
|
|
87
|
+
const result = await performInstall({
|
|
88
|
+
packages,
|
|
89
|
+
ignoreScripts,
|
|
90
|
+
resolver: deps.resolver,
|
|
91
|
+
blobStore: deps.blobStore,
|
|
92
|
+
blobIndex: deps.blobIndex,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const cap = await deps.sizeCap();
|
|
96
|
+
const verdict = evaluateSizeCap({
|
|
97
|
+
totalSizeBytes: result.totalSizeBytes,
|
|
98
|
+
cap,
|
|
99
|
+
});
|
|
100
|
+
if (verdict.level === "block") {
|
|
101
|
+
await deps.installState.setError(verdict.message);
|
|
102
|
+
return { started: false, reason: verdict.message };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await deps.installState.setReady({
|
|
106
|
+
lockfileHash: result.lockfileHash,
|
|
107
|
+
manifest: result.manifest,
|
|
108
|
+
totalSizeBytes: result.totalSizeBytes,
|
|
109
|
+
});
|
|
110
|
+
// Record the manifest in lockfile history so the blob GC's retained set
|
|
111
|
+
// (current + previous N) is computable. Best-effort: a history failure
|
|
112
|
+
// must not fail an otherwise-successful install.
|
|
113
|
+
try {
|
|
114
|
+
await deps.recordHistory?.({
|
|
115
|
+
lockfileHash: result.lockfileHash,
|
|
116
|
+
manifest: result.manifest,
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
deps.logger?.error(
|
|
120
|
+
`Failed to record lockfile history for ${result.lockfileHash}: ${extractErrorMessage(error)}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
await deps.emitChanged({ lockfileHash: result.lockfileHash });
|
|
124
|
+
|
|
125
|
+
deps.logger?.debug(
|
|
126
|
+
`Script packages installed: ${result.manifest.length} package(s), lockfile ${result.lockfileHash}.`,
|
|
127
|
+
);
|
|
128
|
+
return { started: true };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const message = extractErrorMessage(error);
|
|
131
|
+
await deps.installState.setError(message);
|
|
132
|
+
// The persisted install-state message stays user-facing (just the
|
|
133
|
+
// message), but log the full stack so a non-reproducible failure can be
|
|
134
|
+
// pinned to its exact throw site instead of guessed at.
|
|
135
|
+
const detail =
|
|
136
|
+
error !== null && typeof error === "object" && "stack" in error
|
|
137
|
+
? String((error as { stack: unknown }).stack)
|
|
138
|
+
: message;
|
|
139
|
+
deps.logger?.error(`Script package install failed: ${detail}`);
|
|
140
|
+
return { started: false, reason: message };
|
|
141
|
+
} finally {
|
|
142
|
+
await lock.release();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ManifestEntry } from "@checkstack/script-packages-common";
|
|
3
|
+
import {
|
|
4
|
+
performInstall,
|
|
5
|
+
type BlobIndex,
|
|
6
|
+
type BlobPublisher,
|
|
7
|
+
type Resolver,
|
|
8
|
+
type ResolvedPackage,
|
|
9
|
+
} from "./install-service";
|
|
10
|
+
import { computeLockfileHash } from "./lockfile";
|
|
11
|
+
|
|
12
|
+
function resolved(entry: ManifestEntry, blobLen: number): ResolvedPackage {
|
|
13
|
+
return { entry, blob: new Uint8Array(blobLen) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fakeResolver(packages: ResolvedPackage[]): Resolver {
|
|
17
|
+
return { resolve: async () => packages };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeBlobStore(seed: string[] = []): {
|
|
21
|
+
store: BlobPublisher;
|
|
22
|
+
puts: string[];
|
|
23
|
+
} {
|
|
24
|
+
const present = new Set(seed);
|
|
25
|
+
const puts: string[] = [];
|
|
26
|
+
return {
|
|
27
|
+
puts,
|
|
28
|
+
store: {
|
|
29
|
+
id: "postgres",
|
|
30
|
+
has: async ({ integrity }) => present.has(integrity),
|
|
31
|
+
put: async ({ integrity }) => {
|
|
32
|
+
present.add(integrity);
|
|
33
|
+
puts.push(integrity);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fakeIndex(): { index: BlobIndex; records: string[] } {
|
|
40
|
+
const records: string[] = [];
|
|
41
|
+
return {
|
|
42
|
+
records,
|
|
43
|
+
index: {
|
|
44
|
+
record: async ({ integrity }) => {
|
|
45
|
+
records.push(integrity);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const a: ManifestEntry = { name: "a", version: "1.0.0", integrity: "sha-a" };
|
|
52
|
+
const b: ManifestEntry = { name: "b", version: "2.0.0", integrity: "sha-b" };
|
|
53
|
+
|
|
54
|
+
describe("performInstall", () => {
|
|
55
|
+
test("publishes all blobs on a fresh store and records the index", async () => {
|
|
56
|
+
const { store, puts } = fakeBlobStore();
|
|
57
|
+
const { index, records } = fakeIndex();
|
|
58
|
+
const res = await performInstall({
|
|
59
|
+
packages: [],
|
|
60
|
+
ignoreScripts: true,
|
|
61
|
+
resolver: fakeResolver([resolved(a, 100), resolved(b, 200)]),
|
|
62
|
+
blobStore: store,
|
|
63
|
+
blobIndex: index,
|
|
64
|
+
});
|
|
65
|
+
expect(puts.sort()).toEqual(["sha-a", "sha-b"]);
|
|
66
|
+
expect(records.sort()).toEqual(["sha-a", "sha-b"]);
|
|
67
|
+
expect(res.totalSizeBytes).toBe(300);
|
|
68
|
+
expect(res.manifest).toHaveLength(2);
|
|
69
|
+
expect(res.lockfileHash).toBe(computeLockfileHash([a, b]));
|
|
70
|
+
expect(res.publishedIntegrities.sort()).toEqual(["sha-a", "sha-b"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("skips publishing blobs already in the store (delta), still in the manifest", async () => {
|
|
74
|
+
const { store, puts } = fakeBlobStore(["sha-a"]);
|
|
75
|
+
const { index, records } = fakeIndex();
|
|
76
|
+
const res = await performInstall({
|
|
77
|
+
packages: [],
|
|
78
|
+
ignoreScripts: true,
|
|
79
|
+
resolver: fakeResolver([resolved(a, 100), resolved(b, 200)]),
|
|
80
|
+
blobStore: store,
|
|
81
|
+
blobIndex: index,
|
|
82
|
+
});
|
|
83
|
+
expect(puts).toEqual(["sha-b"]); // only the new one
|
|
84
|
+
expect(records).toEqual(["sha-b"]);
|
|
85
|
+
expect(res.manifest).toHaveLength(2); // both still in the manifest
|
|
86
|
+
expect(res.totalSizeBytes).toBe(300);
|
|
87
|
+
expect(res.publishedIntegrities).toEqual(["sha-b"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("empty allowlist yields an empty, deterministic manifest", async () => {
|
|
91
|
+
const { store } = fakeBlobStore();
|
|
92
|
+
const { index } = fakeIndex();
|
|
93
|
+
const res = await performInstall({
|
|
94
|
+
packages: [],
|
|
95
|
+
ignoreScripts: true,
|
|
96
|
+
resolver: fakeResolver([]),
|
|
97
|
+
blobStore: store,
|
|
98
|
+
blobIndex: index,
|
|
99
|
+
});
|
|
100
|
+
expect(res.manifest).toEqual([]);
|
|
101
|
+
expect(res.totalSizeBytes).toBe(0);
|
|
102
|
+
expect(res.lockfileHash).toBe(computeLockfileHash([]));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ManifestEntry,
|
|
3
|
+
PackageSpec,
|
|
4
|
+
} from "@checkstack/script-packages-common";
|
|
5
|
+
import { computeLockfileHash } from "./lockfile";
|
|
6
|
+
import { blobSha256 } from "./blob-hash";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Orchestrates a single install: resolve the pinned allowlist into a
|
|
10
|
+
* manifest, publish any new content-addressed blobs to the active blob
|
|
11
|
+
* store, and return the manifest + lockfile hash + total size.
|
|
12
|
+
*
|
|
13
|
+
* The registry-facing parts (writing package.json/.npmrc, running
|
|
14
|
+
* `bun install`, parsing the lockfile, archiving cache entries) are
|
|
15
|
+
* injected as a {@link Resolver} so this orchestration is fully
|
|
16
|
+
* unit-testable without a network or a Bun subprocess. The concrete
|
|
17
|
+
* resolver lives alongside the reconciler (it shares the cache-archive
|
|
18
|
+
* logic).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ResolvedPackage {
|
|
22
|
+
entry: ManifestEntry;
|
|
23
|
+
/** Compressed, content-addressed blob bytes for this package. */
|
|
24
|
+
blob: Uint8Array;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Resolver {
|
|
28
|
+
/**
|
|
29
|
+
* Run the registry-facing resolve+install for the enabled allowlist and
|
|
30
|
+
* return the resolved packages (manifest entry + blob per package).
|
|
31
|
+
*/
|
|
32
|
+
resolve(input: {
|
|
33
|
+
packages: PackageSpec[];
|
|
34
|
+
ignoreScripts: boolean;
|
|
35
|
+
}): Promise<ResolvedPackage[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal blob-store surface the installer needs (publish-only). */
|
|
39
|
+
export interface BlobPublisher {
|
|
40
|
+
has(input: { integrity: string }): Promise<boolean>;
|
|
41
|
+
put(input: { integrity: string; bytes: Uint8Array }): Promise<void>;
|
|
42
|
+
/** Active backend id, recorded in the blob index. */
|
|
43
|
+
readonly id: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Records a published blob in the content-addressed index. */
|
|
47
|
+
export interface BlobIndex {
|
|
48
|
+
record(input: {
|
|
49
|
+
integrity: string;
|
|
50
|
+
name: string;
|
|
51
|
+
version: string;
|
|
52
|
+
backend: string;
|
|
53
|
+
sizeBytes: number;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface InstallResult {
|
|
58
|
+
lockfileHash: string;
|
|
59
|
+
manifest: ManifestEntry[];
|
|
60
|
+
totalSizeBytes: number;
|
|
61
|
+
/** Integrities that were newly published (not already in the store). */
|
|
62
|
+
publishedIntegrities: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Perform the resolve + publish step. Idempotent at the blob level: a blob
|
|
67
|
+
* already present in the active store (and any other backend, per the
|
|
68
|
+
* `has` check) is not re-published. Total size is the sum of resolved blob
|
|
69
|
+
* sizes - the figure the size guardrail checks.
|
|
70
|
+
*/
|
|
71
|
+
export async function performInstall({
|
|
72
|
+
packages,
|
|
73
|
+
ignoreScripts,
|
|
74
|
+
resolver,
|
|
75
|
+
blobStore,
|
|
76
|
+
blobIndex,
|
|
77
|
+
}: {
|
|
78
|
+
packages: PackageSpec[];
|
|
79
|
+
ignoreScripts: boolean;
|
|
80
|
+
resolver: Resolver;
|
|
81
|
+
blobStore: BlobPublisher;
|
|
82
|
+
blobIndex: BlobIndex;
|
|
83
|
+
}): Promise<InstallResult> {
|
|
84
|
+
const resolved = await resolver.resolve({ packages, ignoreScripts });
|
|
85
|
+
|
|
86
|
+
const manifest: ManifestEntry[] = [];
|
|
87
|
+
const publishedIntegrities: string[] = [];
|
|
88
|
+
let totalSizeBytes = 0;
|
|
89
|
+
|
|
90
|
+
for (const { entry, blob } of resolved) {
|
|
91
|
+
// Stamp the distributed-blob hash so every host can verify the
|
|
92
|
+
// transported bytes before extraction (the SRI `integrity` hashes the
|
|
93
|
+
// npm tarball, not our archive).
|
|
94
|
+
manifest.push({ ...entry, blobSha256: blobSha256(blob) });
|
|
95
|
+
totalSizeBytes += blob.byteLength;
|
|
96
|
+
|
|
97
|
+
if (!(await blobStore.has({ integrity: entry.integrity }))) {
|
|
98
|
+
await blobStore.put({ integrity: entry.integrity, bytes: blob });
|
|
99
|
+
await blobIndex.record({
|
|
100
|
+
integrity: entry.integrity,
|
|
101
|
+
name: entry.name,
|
|
102
|
+
version: entry.version,
|
|
103
|
+
backend: blobStore.id,
|
|
104
|
+
sizeBytes: blob.byteLength,
|
|
105
|
+
});
|
|
106
|
+
publishedIntegrities.push(entry.integrity);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
lockfileHash: computeLockfileHash(manifest),
|
|
112
|
+
manifest,
|
|
113
|
+
totalSizeBytes,
|
|
114
|
+
publishedIntegrities,
|
|
115
|
+
};
|
|
116
|
+
}
|