@celilo/e2e 0.18.0 → 0.19.1

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.
@@ -24,9 +24,19 @@ iptables -P FORWARD ACCEPT
24
24
  # DNS
25
25
  echo "nameserver 203.0.113.1" > /etc/resolv.conf
26
26
 
27
- # Start dnsmasq DHCP server on internal network
28
- dnsmasq --conf-dir=/etc/dnsmasq.d --keep-in-foreground --log-facility=- &
29
- echo "dnsmasq DHCP server started"
27
+ # Start dnsmasq DHCP server on internal network.
28
+ #
29
+ # Skippable, because celilo can serve DHCP itself (`modules/dnsmasq-dhcp`) and
30
+ # two DHCP servers on one broadcast domain race: whichever answers a DISCOVER
31
+ # first wins, and a test asserting which one served a lease would be asserting
32
+ # a coin toss. An operator moving DHCP to celilo turns the router's off, so a
33
+ # suite that tests celilo's DHCP models that by setting ROUTER_DHCP=off.
34
+ if [ "${ROUTER_DHCP:-on}" = "off" ]; then
35
+ echo "dnsmasq DHCP server NOT started (ROUTER_DHCP=off; celilo serves DHCP here)"
36
+ else
37
+ dnsmasq --conf-dir=/etc/dnsmasq.d --keep-in-foreground --log-facility=- &
38
+ echo "dnsmasq DHCP server started"
39
+ fi
30
40
 
31
41
  # Start greenwave simulator
32
42
  if [ -f /simulator/server.ts ]; then
@@ -35,6 +35,12 @@ RUN apt-get update && apt-get install -y \
35
35
  procps \
36
36
  zsh \
37
37
  gnupg \
38
+ # The hook jail (openspec/changes/hook-process-boundary, stage 2) runs every
39
+ # hook under bubblewrap. Without it the management box runs hooks unjailed,
40
+ # which is a valid mode but not the one the e2e suite is there to exercise.
41
+ # The container needs security_opt relaxations to let it build a namespace —
42
+ # see the measured ladder in docker-compose-generator.ts.
43
+ bubblewrap \
38
44
  && rm -rf /var/lib/apt/lists/*
39
45
 
40
46
  # --- Terraform, plus an OFFLINE provider mirror -------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,7 +18,7 @@
18
18
  "celilo-e2e-infra": "./bin/e2e-infra"
19
19
  },
20
20
  "scripts": {
21
- "test": "bun test --timeout 30000 tests/",
21
+ "test": "bun test --timeout 30000 ./tests/ ./src/ ./npm-registry-server/ ./simulators/",
22
22
  "test:completion": "bun test tests/completion",
23
23
  "test:integration": "bun test tests-integration/"
24
24
  },
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "@celilo/capabilities": "^3.0.0",
40
+ "@celilo/capabilities": "^3.3.0",
41
41
  "@celilo/cli-display": "^0.2.0",
42
42
  "@celilo/event-bus": "^0.6.0",
43
43
  "@celilo/terraform-fake": "^0.3.1",
@@ -33,6 +33,8 @@ export interface BootstrapEntry {
33
33
  * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
34
34
  */
35
35
  description?: string;
36
+ /** Manifest's `icon` field, when present. Served like `description`. */
37
+ icon?: string;
36
38
  }
37
39
 
38
40
  /**
@@ -60,11 +62,14 @@ export function scanBootstrapDir(bootstrapDir: string): Map<string, BootstrapEnt
60
62
  const name = idMatch?.[1]?.trim() ?? entry.name;
61
63
  const version = versionMatch?.[1]?.trim() ?? '0.0.0';
62
64
  const description = descriptionMatch?.[1]?.trim().replace(/^['"]|['"]$/g, '');
65
+ const iconMatch = yaml.match(/^icon:\s*(.+?)\s*$/m);
66
+ const icon = iconMatch?.[1]?.trim().replace(/^['"]|['"]$/g, '');
63
67
  entries.set(name, {
64
68
  name,
65
69
  version,
66
70
  sourceDir: join(bootstrapDir, entry.name),
67
71
  description,
72
+ icon,
68
73
  });
69
74
  } catch {
70
75
  // skip malformed manifests — matches e2e server behavior
@@ -217,6 +217,64 @@ describe('GET /api/v1/modules (search)', () => {
217
217
  expect(found?.description).toBe('');
218
218
  });
219
219
 
220
+ test('icon survives publish, the sparse index and a search read', async () => {
221
+ // openspec/changes/module-icons D4 — the icon rides the same path
222
+ // `description` does: publish metadata → index entry → browse endpoints.
223
+ expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
224
+ 200,
225
+ );
226
+
227
+ const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
228
+ expect(index.status).toBe(200);
229
+ const line = JSON.parse((await index.text()).trim()) as { icon?: string };
230
+ expect(line.icon).toBe('\u22c8');
231
+
232
+ const res = await fetch(`${baseUrl}/api/v1/modules`);
233
+ const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
234
+ expect(body.modules.find((m) => m.name === 'homebridge')?.icon).toBe('\u22c8');
235
+
236
+ const detail = await fetch(`${baseUrl}/api/v1/modules/homebridge`);
237
+ expect(((await detail.json()) as { icon?: string }).icon).toBe('\u22c8');
238
+ });
239
+
240
+ test('an entry published without an icon reads back as undefined, not a throw', async () => {
241
+ // The old-entry case D4 depends on: index lines written before the field
242
+ // existed carry no `icon`, and a consumer falls back rather than failing.
243
+ //
244
+ // This test passes with or without the icon feature, so it is NOT a gate on
245
+ // it — verified by running it against the pre-#1147 tree. It is here to
246
+ // catch a future change that makes a missing `icon` throw or coerce to '',
247
+ // which is what D4's fallback chain would break on. Do not count it as
248
+ // coverage of the capture path; that is the test above.
249
+ expect((await publish('homebridge', '1.0.0+1', 'valid-token')).status).toBe(200);
250
+
251
+ const res = await fetch(`${baseUrl}/api/v1/modules`);
252
+ const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
253
+ const found = body.modules.find((m) => m.name === 'homebridge');
254
+ expect(found).toBeTruthy();
255
+ expect(found?.icon).toBeUndefined();
256
+ });
257
+
258
+ test('a multi-character icon is dropped while a valid one beside it is kept', async () => {
259
+ // Publish metadata is arbitrary client JSON. The CLI refines the value at
260
+ // manifest-parse time; the server keeps the index from carrying a string
261
+ // that would blow out a fixed-width slot on the browse page.
262
+ //
263
+ // Both halves are asserted deliberately. Checking only that the bad value
264
+ // is absent would pass just as well if the capture path were deleted
265
+ // outright — the guard and the capture would cancel out and the test would
266
+ // stay green having lost the feature. The kept glyph is what stops that.
267
+ expect(
268
+ (await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', 'not-a-glyph')).status,
269
+ ).toBe(200);
270
+ expect((await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u25cd')).status).toBe(200);
271
+
272
+ const res = await fetch(`${baseUrl}/api/v1/modules`);
273
+ const body = (await res.json()) as { modules: Array<{ name: string; icon?: string }> };
274
+ expect(body.modules.find((m) => m.name === 'homebridge')?.icon).toBeUndefined();
275
+ expect(body.modules.find((m) => m.name === 'caddy')?.icon).toBe('\u25cd');
276
+ });
277
+
220
278
  test('total_downloads reflects actual download count, sort=downloads orders by it', async () => {
221
279
  // Publish two modules, hit one's download endpoint several times.
222
280
  // The search response should show those counts and sort=downloads
@@ -490,9 +548,15 @@ async function publish(
490
548
  vers: string,
491
549
  token: string,
492
550
  description?: string,
551
+ icon?: string,
493
552
  ): Promise<Response> {
494
553
  const meta = Buffer.from(
495
- JSON.stringify({ name, vers, ...(description ? { description } : {}) }),
554
+ JSON.stringify({
555
+ name,
556
+ vers,
557
+ ...(description ? { description } : {}),
558
+ ...(icon ? { icon } : {}),
559
+ }),
496
560
  'utf-8',
497
561
  );
498
562
  const file = Buffer.from('fake netapp bytes');
@@ -769,3 +833,121 @@ describe('module-owner authorization (ce-1ch)', () => {
769
833
  expect(res.status).toBe(200);
770
834
  });
771
835
  });
836
+
837
+ // ── sweep (reclaiming disk from superseded build revisions) ──────────────────
838
+
839
+ describe('POST /api/v1/modules/sweep', () => {
840
+ function requestSweep(token: string, body: unknown = {}): Promise<Response> {
841
+ return fetch(`${baseUrl}/api/v1/modules/sweep`, {
842
+ method: 'POST',
843
+ headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
844
+ body: JSON.stringify(body),
845
+ });
846
+ }
847
+
848
+ test('no token is 401 — a sweep deletes, so it is admin-only', async () => {
849
+ const res = await requestSweep('');
850
+ expect(res.status).toBe(401);
851
+ });
852
+
853
+ test('a scoped (non-admin) token is 401', async () => {
854
+ const mint = await fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
855
+ method: 'POST',
856
+ headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
857
+ body: JSON.stringify({ repo: 'celilo/homebridge', scope: 'homebridge' }),
858
+ });
859
+ const { token } = (await mint.json()) as { token: string };
860
+
861
+ const res = await requestSweep(token);
862
+ expect(res.status).toBe(401);
863
+ });
864
+
865
+ test('removes superseded revisions and leaves the download path working', async () => {
866
+ for (const rev of [1, 2, 3]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
867
+
868
+ const res = await requestSweep('valid-token');
869
+ expect(res.status).toBe(200);
870
+ const body = (await res.json()) as { ok: boolean; removedCount: number };
871
+ expect(body.ok).toBe(true);
872
+ expect(body.removedCount).toBe(2);
873
+
874
+ // The surviving revision downloads; a swept one is gone from BOTH the
875
+ // index and the store, so nothing advertises a 404.
876
+ expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+3/download`)).status).toBe(200);
877
+ expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(404);
878
+
879
+ const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
880
+ const versions = (await index.text())
881
+ .split('\n')
882
+ .filter(Boolean)
883
+ .map((line) => (JSON.parse(line) as { vers: string }).vers);
884
+ expect(versions).toEqual(['1.0.0+3']);
885
+ });
886
+
887
+ test('dry_run reports the plan without deleting anything', async () => {
888
+ for (const rev of [1, 2]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
889
+
890
+ const res = await requestSweep('valid-token', { dry_run: true });
891
+ const body = (await res.json()) as { removedCount: number; dryRun: boolean };
892
+ expect(body.removedCount).toBe(1);
893
+ expect(body.dryRun).toBe(true);
894
+ expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(200);
895
+ });
896
+
897
+ test('rejects a keep_build_revisions that would delete a whole release', async () => {
898
+ const res = await requestSweep('valid-token', { keep_build_revisions: 0 });
899
+ expect(res.status).toBe(400);
900
+ });
901
+
902
+ test('honours a larger keep_build_revisions', async () => {
903
+ for (const rev of [1, 2, 3, 4]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
904
+ const res = await requestSweep('valid-token', { keep_build_revisions: 2 });
905
+ const body = (await res.json()) as { removedCount: number };
906
+ expect(body.removedCount).toBe(2);
907
+ });
908
+
909
+ test('coexists with a module actually named "sweep"', async () => {
910
+ await publish('sweep', '1.0.0+1', 'valid-token');
911
+ const res = await requestSweep('valid-token');
912
+ expect(res.status).toBe(200);
913
+ // …and the module is still reachable by its own GET route.
914
+ expect((await fetch(`${baseUrl}/api/v1/modules/sweep`)).status).toBe(200);
915
+ });
916
+ });
917
+
918
+ describe('POST /api/v1/modules/sweep — a damaged store', () => {
919
+ function requestSweep(token: string, body: unknown = {}): Promise<Response> {
920
+ return fetch(`${baseUrl}/api/v1/modules/sweep`, {
921
+ method: 'POST',
922
+ headers: { Authorization: token, 'Content-Type': 'application/json' },
923
+ body: JSON.stringify(body),
924
+ });
925
+ }
926
+
927
+ test('reclaims a half-written publish, freeing the version to be published again', async () => {
928
+ // handlePublish stores the package and appends the index with no rollback
929
+ // between them, so an ENOSPC or EACCES in the gap leaves an orphan. The
930
+ // immutability check reads the filesystem, so that version then cannot be
931
+ // published at all — this is the only repair path in the product.
932
+ await publish('homebridge', '1.0.0+1', 'valid-token');
933
+ const rmIndex = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
934
+ method: 'DELETE',
935
+ headers: { Authorization: 'valid-token' },
936
+ });
937
+ expect(rmIndex.status).toBe(200);
938
+
939
+ // Simulate the crash: index line gone, payload left behind.
940
+ rmSync(join(dataDir, 'index'), { recursive: true, force: true });
941
+
942
+ const blocked = await publish('homebridge', '1.0.0+1', 'valid-token');
943
+ expect(blocked.status).toBe(409);
944
+
945
+ const swept = await requestSweep('valid-token');
946
+ const body = (await swept.json()) as { orphanCount: number };
947
+ expect(body.orphanCount).toBe(1);
948
+
949
+ // The version is publishable again.
950
+ const retry = await publish('homebridge', '1.0.0+1', 'valid-token');
951
+ expect(retry.status).toBe(200);
952
+ });
953
+ });
@@ -10,6 +10,7 @@
10
10
  * PUT /api/v1/modules/new → publish (token auth)
11
11
  * DELETE /api/v1/modules/{name}/{ver}/yank → yank
12
12
  * PUT /api/v1/modules/{name}/{ver}/unyank → unyank
13
+ * POST /api/v1/modules/sweep → reclaim old revisions (admin)
13
14
  *
14
15
  * All routes can be prefixed with PATH_PREFIX (e.g. "/registry").
15
16
  *
@@ -34,6 +35,7 @@ import { ModuleOwnerStore, fileModuleOwnerPersistence } from './module-owner-sto
34
35
  import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
35
36
  import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
36
37
  import { type IndexEntry, RegistryStorage } from './storage';
38
+ import { DEFAULT_KEEP_BUILD_REVISIONS, sweep } from './sweep';
37
39
  import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
38
40
 
39
41
  /** Max total publish body size. Real modules are a few MB; 100MB is generous. */
@@ -313,6 +315,12 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
313
315
  // not provided we leave it undefined; the search endpoint falls
314
316
  // back to bootstrap data or empty.
315
317
  const description = typeof meta.description === 'string' ? meta.description : undefined;
318
+ // Icon likewise, but capped at one Unicode scalar. The CLI already refines
319
+ // it at manifest-parse time; this second check is here because the publish
320
+ // metadata is arbitrary client JSON and the value lands in a fixed-width
321
+ // slot on the browse page.
322
+ const icon =
323
+ typeof meta.icon === 'string' && [...meta.icon].length === 1 ? meta.icon : undefined;
316
324
 
317
325
  const validation = validateNameAndVersion(name, vers);
318
326
  if (!validation.ok) return err(validation.message);
@@ -339,12 +347,63 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
339
347
  cksum: `sha256:${cksum}`,
340
348
  yanked: false,
341
349
  description,
350
+ icon,
342
351
  });
343
352
 
344
353
  console.log(`[registry] published ${name}@${vers} (${fileLen} bytes, sha256:${cksum})`);
345
354
  return Response.json({ ok: true, name, vers });
346
355
  }
347
356
 
357
+ /**
358
+ * Reclaim disk from superseded build revisions (admin-only).
359
+ *
360
+ * The counterpart yank never was: yanking flips a boolean and frees nothing,
361
+ * so before this endpoint existed every revision ever published was retained
362
+ * forever and the store grew without bound until the disk filled.
363
+ *
364
+ * Runs IN the process that owns the store, so it cannot race a concurrent
365
+ * publish's index append. `dry_run` reports the same plan without touching
366
+ * anything — the safe way to see what a policy would do on a live store.
367
+ */
368
+ async function handleSweep(req: Request): Promise<Response> {
369
+ if (!(await authorizeAdminReq(req))) return unauthorized();
370
+
371
+ let body: { keep_build_revisions?: unknown; dry_run?: unknown } = {};
372
+ try {
373
+ const text = await req.text();
374
+ if (text.trim()) body = JSON.parse(text) as typeof body;
375
+ } catch {
376
+ return err('Invalid JSON body');
377
+ }
378
+
379
+ const requested = body.keep_build_revisions;
380
+ if (requested !== undefined && (!Number.isInteger(requested) || (requested as number) < 1)) {
381
+ return err('keep_build_revisions must be an integer >= 1');
382
+ }
383
+ const keepBuildRevisions = (requested as number | undefined) ?? DEFAULT_KEEP_BUILD_REVISIONS;
384
+ const dryRun = body.dry_run === true;
385
+
386
+ const report = sweep(storage, { keepBuildRevisions }, dryRun);
387
+ console.log(
388
+ `[registry] sweep${dryRun ? ' (dry run)' : ''} keep=${keepBuildRevisions}: ` +
389
+ `${report.removedCount} superseded + ${report.orphanCount} orphaned revision(s), ` +
390
+ `${Math.round(report.reclaimedBytes / 1024 / 1024)} MB`,
391
+ );
392
+ // Both of these mean the store is damaged, not merely untidy, and the sweep
393
+ // deliberately did not act on either. Say so where an operator will see it.
394
+ if (report.danglingCount > 0) {
395
+ console.warn(
396
+ `[registry] ${report.danglingCount} index line(s) have no package file — left in place; download 404s until republished`,
397
+ );
398
+ }
399
+ if (report.unreadable.length > 0) {
400
+ console.warn(
401
+ `[registry] skipped entirely (no package files at all — check the mount and DATA_DIR): ${report.unreadable.join(', ')}`,
402
+ );
403
+ }
404
+ return Response.json({ ok: true, ...report });
405
+ }
406
+
348
407
  function setYanked(name: string, version: string, yanked: boolean): Response {
349
408
  const entries = storage.readIndex(name);
350
409
  const entry = entries.find((e) => e.vers === version);
@@ -505,10 +564,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
505
564
  // description capture and haven't been republished).
506
565
  const bootstrapEntry = bootstrap.get(name);
507
566
  const description = latest?.description ?? bootstrapEntry?.description ?? '';
567
+ // Icon follows the same order, but has no empty-string tier: absent
568
+ // means "this module declared none", and the consumer resolves it
569
+ // from its own table or a placeholder (module-icons D5).
570
+ const icon = latest?.icon ?? bootstrapEntry?.icon;
508
571
  return {
509
572
  name,
510
573
  max_version: latest?.vers ?? '0.0.0',
511
574
  description,
575
+ icon,
512
576
  total_downloads: storage.getDownloads(name),
513
577
  };
514
578
  });
@@ -551,6 +615,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
551
615
  return handleRevokeToken(req);
552
616
  }
553
617
 
618
+ // Reclaim disk from superseded build revisions (admin-only), grouped
619
+ // with the other admin endpoints. A module NAMED "sweep" is fine here:
620
+ // its metadata route is a GET, so the two never compete.
621
+ if (method === 'POST' && suffix === `${apiBase}/sweep`) {
622
+ const rl = rateLimitOrNull(req, srv);
623
+ if (rl) return rl;
624
+ return handleSweep(req);
625
+ }
626
+
554
627
  // Module-owner table management (admin-only — ce-1ch). Matched before the
555
628
  // generic `${apiBase}/{name}` handlers so a name of "owners" can't shadow
556
629
  // them.
@@ -588,9 +661,11 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
588
661
  const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
589
662
  const bootstrapEntry = resolveBootstrap().get(name);
590
663
  const description = latest?.description ?? bootstrapEntry?.description ?? '';
664
+ const icon = latest?.icon ?? bootstrapEntry?.icon;
591
665
  return Response.json({
592
666
  name,
593
667
  description,
668
+ icon,
594
669
  total_downloads: storage.getDownloads(name),
595
670
  versions: entries.map((v) => ({
596
671
  num: v.vers,
@@ -8,7 +8,16 @@
8
8
  */
9
9
 
10
10
  import { createHash } from 'node:crypto';
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ renameSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
12
21
  import { dirname, join } from 'node:path';
13
22
 
14
23
  export interface IndexEntry {
@@ -25,6 +34,14 @@ export interface IndexEntry {
25
34
  * step 0). Old index files without this field still parse cleanly.
26
35
  */
27
36
  description?: string;
37
+ /**
38
+ * One monochrome glyph from the module's `manifest.yml#icon`, captured at
39
+ * publish time. Optional for the same reason `description` is: entries
40
+ * written before the field existed have none, and a consumer reading one
41
+ * without it falls back to its own table rather than failing
42
+ * (openspec/changes/module-icons, D4).
43
+ */
44
+ icon?: string;
28
45
  }
29
46
 
30
47
  export interface ModuleVersion {
@@ -79,10 +96,28 @@ export class RegistryStorage {
79
96
  writeFileSync(path, `${JSON.stringify(entry)}\n`, { flag: 'a' });
80
97
  }
81
98
 
99
+ /**
100
+ * Replace a module's whole index file.
101
+ *
102
+ * Writes a sibling temp file and renames it over the original, which is
103
+ * atomic within a directory. A plain truncating write has two failure modes
104
+ * this avoids, and the sweep meets both: a crash mid-write leaves a half-
105
+ * index, and an ENOSPC leaves an EMPTY one — losing every version of the
106
+ * module. The sweep exists precisely because the disk filled up, so "the
107
+ * write fails for want of space" is its expected environment, not a corner.
108
+ * On failure the original file is untouched.
109
+ */
82
110
  updateIndex(name: string, entries: IndexEntry[]): void {
83
111
  const path = this.indexPath(name);
84
112
  mkdirSync(dirname(path), { recursive: true });
85
- writeFileSync(path, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`);
113
+ const tmp = `${path}.tmp`;
114
+ try {
115
+ writeFileSync(tmp, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`);
116
+ renameSync(tmp, path);
117
+ } catch (err) {
118
+ rmSync(tmp, { force: true, recursive: true });
119
+ throw err;
120
+ }
86
121
  }
87
122
 
88
123
  storePackage(name: string, version: string, data: Buffer): string {
@@ -96,6 +131,45 @@ export class RegistryStorage {
96
131
  return existsSync(this.packagePath(name, version));
97
132
  }
98
133
 
134
+ /** Bytes on disk for one version's .netapp, or 0 when it is already gone. */
135
+ packageSize(name: string, version: string): number {
136
+ const path = this.packagePath(name, version);
137
+ if (!existsSync(path)) return 0;
138
+ return statSync(path).size;
139
+ }
140
+
141
+ /**
142
+ * Delete one version's payload directory (`modules/{name}/{version}/`).
143
+ * Idempotent: removing a version that is already gone is a no-op, so an
144
+ * interrupted sweep is safe to re-run.
145
+ */
146
+ removePackage(name: string, version: string): void {
147
+ rmSync(dirname(this.packagePath(name, version)), { recursive: true, force: true });
148
+ }
149
+
150
+ /**
151
+ * Every version with a payload directory on disk, whether or not the index
152
+ * still lists it. The sweep needs the on-DISK set, not the indexed one:
153
+ * removing an index line is what makes a version unreachable, and the file
154
+ * it leaves behind is exactly what has to be reclaimed afterwards.
155
+ */
156
+ storedVersions(name: string): string[] {
157
+ const dir = join(this.dataDir, 'modules', name);
158
+ if (!existsSync(dir)) return [];
159
+ return readdirSync(dir, { withFileTypes: true })
160
+ .filter((d) => d.isDirectory())
161
+ .map((d) => d.name);
162
+ }
163
+
164
+ /** Module names with a payload directory, including any absent from the index. */
165
+ storedNames(): string[] {
166
+ const dir = join(this.dataDir, 'modules');
167
+ if (!existsSync(dir)) return [];
168
+ return readdirSync(dir, { withFileTypes: true })
169
+ .filter((d) => d.isDirectory())
170
+ .map((d) => d.name);
171
+ }
172
+
99
173
  readPackage(name: string, version: string): Buffer | null {
100
174
  const path = this.packagePath(name, version);
101
175
  return existsSync(path) ? readFileSync(path) : null;