@celilo/e2e 0.19.0 → 0.19.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
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.1.0",
40
+ "@celilo/capabilities": "^4.0.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,102 @@ 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
+
278
+ test('409 when a second module declares a glyph the first holds, naming the holder', async () => {
279
+ // module-icons D8 — the registry refuses a duplicate glyph. The error must
280
+ // name the holding module: that is everything the author needs to pick a
281
+ // distinct glyph, and it is what the design decided a refusal carries
282
+ // instead of a server-suggested replacement.
283
+ expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
284
+ 200,
285
+ );
286
+ const res = await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u22c8');
287
+ expect(res.status).toBe(409);
288
+ const body = (await res.json()) as { errors: Array<{ detail: string }> };
289
+ expect(body.errors[0]?.detail).toContain('homebridge');
290
+ // The refusal fired before any state was written: no payload, no index line.
291
+ expect((await fetch(`${baseUrl}/index/ca/dd/caddy`)).status).toBe(404);
292
+ });
293
+
294
+ test('a module republishing its own glyph across versions is allowed', async () => {
295
+ // The holder is excluded from its own check — keeping your icon across
296
+ // versions is the normal case, not a collision.
297
+ expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
298
+ 200,
299
+ );
300
+ expect((await publish('homebridge', '1.0.1+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
301
+ 200,
302
+ );
303
+ });
304
+
305
+ test('a module whose latest version drops the icon frees the glyph', async () => {
306
+ // A module holds the glyph its LATEST non-yanked entry declares — the same
307
+ // selection the browse endpoints read. Once its newest version declares
308
+ // none, the glyph is free for the next publisher.
309
+ expect((await publish('homebridge', '1.0.0+1', 'valid-token', 'Bridge', '\u22c8')).status).toBe(
310
+ 200,
311
+ );
312
+ expect((await publish('homebridge', '1.0.1+1', 'valid-token', 'Bridge')).status).toBe(200);
313
+ expect((await publish('caddy', '1.0.0+1', 'valid-token', 'Proxy', '\u22c8')).status).toBe(200);
314
+ });
315
+
220
316
  test('total_downloads reflects actual download count, sort=downloads orders by it', async () => {
221
317
  // Publish two modules, hit one's download endpoint several times.
222
318
  // The search response should show those counts and sort=downloads
@@ -490,9 +586,15 @@ async function publish(
490
586
  vers: string,
491
587
  token: string,
492
588
  description?: string,
589
+ icon?: string,
493
590
  ): Promise<Response> {
494
591
  const meta = Buffer.from(
495
- JSON.stringify({ name, vers, ...(description ? { description } : {}) }),
592
+ JSON.stringify({
593
+ name,
594
+ vers,
595
+ ...(description ? { description } : {}),
596
+ ...(icon ? { icon } : {}),
597
+ }),
496
598
  'utf-8',
497
599
  );
498
600
  const file = Buffer.from('fake netapp bytes');
@@ -769,3 +871,121 @@ describe('module-owner authorization (ce-1ch)', () => {
769
871
  expect(res.status).toBe(200);
770
872
  });
771
873
  });
874
+
875
+ // ── sweep (reclaiming disk from superseded build revisions) ──────────────────
876
+
877
+ describe('POST /api/v1/modules/sweep', () => {
878
+ function requestSweep(token: string, body: unknown = {}): Promise<Response> {
879
+ return fetch(`${baseUrl}/api/v1/modules/sweep`, {
880
+ method: 'POST',
881
+ headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
882
+ body: JSON.stringify(body),
883
+ });
884
+ }
885
+
886
+ test('no token is 401 — a sweep deletes, so it is admin-only', async () => {
887
+ const res = await requestSweep('');
888
+ expect(res.status).toBe(401);
889
+ });
890
+
891
+ test('a scoped (non-admin) token is 401', async () => {
892
+ const mint = await fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
893
+ method: 'POST',
894
+ headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
895
+ body: JSON.stringify({ repo: 'celilo/homebridge', scope: 'homebridge' }),
896
+ });
897
+ const { token } = (await mint.json()) as { token: string };
898
+
899
+ const res = await requestSweep(token);
900
+ expect(res.status).toBe(401);
901
+ });
902
+
903
+ test('removes superseded revisions and leaves the download path working', async () => {
904
+ for (const rev of [1, 2, 3]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
905
+
906
+ const res = await requestSweep('valid-token');
907
+ expect(res.status).toBe(200);
908
+ const body = (await res.json()) as { ok: boolean; removedCount: number };
909
+ expect(body.ok).toBe(true);
910
+ expect(body.removedCount).toBe(2);
911
+
912
+ // The surviving revision downloads; a swept one is gone from BOTH the
913
+ // index and the store, so nothing advertises a 404.
914
+ expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+3/download`)).status).toBe(200);
915
+ expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(404);
916
+
917
+ const index = await fetch(`${baseUrl}/index/ho/me/homebridge`);
918
+ const versions = (await index.text())
919
+ .split('\n')
920
+ .filter(Boolean)
921
+ .map((line) => (JSON.parse(line) as { vers: string }).vers);
922
+ expect(versions).toEqual(['1.0.0+3']);
923
+ });
924
+
925
+ test('dry_run reports the plan without deleting anything', async () => {
926
+ for (const rev of [1, 2]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
927
+
928
+ const res = await requestSweep('valid-token', { dry_run: true });
929
+ const body = (await res.json()) as { removedCount: number; dryRun: boolean };
930
+ expect(body.removedCount).toBe(1);
931
+ expect(body.dryRun).toBe(true);
932
+ expect((await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`)).status).toBe(200);
933
+ });
934
+
935
+ test('rejects a keep_build_revisions that would delete a whole release', async () => {
936
+ const res = await requestSweep('valid-token', { keep_build_revisions: 0 });
937
+ expect(res.status).toBe(400);
938
+ });
939
+
940
+ test('honours a larger keep_build_revisions', async () => {
941
+ for (const rev of [1, 2, 3, 4]) await publish('homebridge', `1.0.0+${rev}`, 'valid-token');
942
+ const res = await requestSweep('valid-token', { keep_build_revisions: 2 });
943
+ const body = (await res.json()) as { removedCount: number };
944
+ expect(body.removedCount).toBe(2);
945
+ });
946
+
947
+ test('coexists with a module actually named "sweep"', async () => {
948
+ await publish('sweep', '1.0.0+1', 'valid-token');
949
+ const res = await requestSweep('valid-token');
950
+ expect(res.status).toBe(200);
951
+ // …and the module is still reachable by its own GET route.
952
+ expect((await fetch(`${baseUrl}/api/v1/modules/sweep`)).status).toBe(200);
953
+ });
954
+ });
955
+
956
+ describe('POST /api/v1/modules/sweep — a damaged store', () => {
957
+ function requestSweep(token: string, body: unknown = {}): Promise<Response> {
958
+ return fetch(`${baseUrl}/api/v1/modules/sweep`, {
959
+ method: 'POST',
960
+ headers: { Authorization: token, 'Content-Type': 'application/json' },
961
+ body: JSON.stringify(body),
962
+ });
963
+ }
964
+
965
+ test('reclaims a half-written publish, freeing the version to be published again', async () => {
966
+ // handlePublish stores the package and appends the index with no rollback
967
+ // between them, so an ENOSPC or EACCES in the gap leaves an orphan. The
968
+ // immutability check reads the filesystem, so that version then cannot be
969
+ // published at all — this is the only repair path in the product.
970
+ await publish('homebridge', '1.0.0+1', 'valid-token');
971
+ const rmIndex = await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/yank`, {
972
+ method: 'DELETE',
973
+ headers: { Authorization: 'valid-token' },
974
+ });
975
+ expect(rmIndex.status).toBe(200);
976
+
977
+ // Simulate the crash: index line gone, payload left behind.
978
+ rmSync(join(dataDir, 'index'), { recursive: true, force: true });
979
+
980
+ const blocked = await publish('homebridge', '1.0.0+1', 'valid-token');
981
+ expect(blocked.status).toBe(409);
982
+
983
+ const swept = await requestSweep('valid-token');
984
+ const body = (await swept.json()) as { orphanCount: number };
985
+ expect(body.orphanCount).toBe(1);
986
+
987
+ // The version is publishable again.
988
+ const retry = await publish('homebridge', '1.0.0+1', 'valid-token');
989
+ expect(retry.status).toBe(200);
990
+ });
991
+ });
@@ -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);
@@ -331,6 +339,23 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
331
339
  return err(`Version ${name}@${vers} already exists — versions are immutable`, 409);
332
340
  }
333
341
 
342
+ // The registry refuses a glyph another module already holds (module-icons
343
+ // D8, operator ruling 2026-09-01). The module's own earlier versions are
344
+ // excluded: republishing with the icon you already have is the normal
345
+ // case, not a collision. Before any mutation — a refusal must not leave a
346
+ // stored payload behind, the same ordering discipline as the check above.
347
+ if (icon) {
348
+ const holder = [...storage.latestIcons()].find(
349
+ ([heldName, heldIcon]) => heldName !== name && heldIcon === icon,
350
+ );
351
+ if (holder) {
352
+ return err(
353
+ `icon '${icon}' is already used by module ${holder[0]} — declare a glyph no other module holds (openspec/changes/module-icons, D8)`,
354
+ 409,
355
+ );
356
+ }
357
+ }
358
+
334
359
  const cksum = storage.storePackage(name, vers, fileData);
335
360
  storage.appendIndex({
336
361
  name,
@@ -339,12 +364,63 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
339
364
  cksum: `sha256:${cksum}`,
340
365
  yanked: false,
341
366
  description,
367
+ icon,
342
368
  });
343
369
 
344
370
  console.log(`[registry] published ${name}@${vers} (${fileLen} bytes, sha256:${cksum})`);
345
371
  return Response.json({ ok: true, name, vers });
346
372
  }
347
373
 
374
+ /**
375
+ * Reclaim disk from superseded build revisions (admin-only).
376
+ *
377
+ * The counterpart yank never was: yanking flips a boolean and frees nothing,
378
+ * so before this endpoint existed every revision ever published was retained
379
+ * forever and the store grew without bound until the disk filled.
380
+ *
381
+ * Runs IN the process that owns the store, so it cannot race a concurrent
382
+ * publish's index append. `dry_run` reports the same plan without touching
383
+ * anything — the safe way to see what a policy would do on a live store.
384
+ */
385
+ async function handleSweep(req: Request): Promise<Response> {
386
+ if (!(await authorizeAdminReq(req))) return unauthorized();
387
+
388
+ let body: { keep_build_revisions?: unknown; dry_run?: unknown } = {};
389
+ try {
390
+ const text = await req.text();
391
+ if (text.trim()) body = JSON.parse(text) as typeof body;
392
+ } catch {
393
+ return err('Invalid JSON body');
394
+ }
395
+
396
+ const requested = body.keep_build_revisions;
397
+ if (requested !== undefined && (!Number.isInteger(requested) || (requested as number) < 1)) {
398
+ return err('keep_build_revisions must be an integer >= 1');
399
+ }
400
+ const keepBuildRevisions = (requested as number | undefined) ?? DEFAULT_KEEP_BUILD_REVISIONS;
401
+ const dryRun = body.dry_run === true;
402
+
403
+ const report = sweep(storage, { keepBuildRevisions }, dryRun);
404
+ console.log(
405
+ `[registry] sweep${dryRun ? ' (dry run)' : ''} keep=${keepBuildRevisions}: ` +
406
+ `${report.removedCount} superseded + ${report.orphanCount} orphaned revision(s), ` +
407
+ `${Math.round(report.reclaimedBytes / 1024 / 1024)} MB`,
408
+ );
409
+ // Both of these mean the store is damaged, not merely untidy, and the sweep
410
+ // deliberately did not act on either. Say so where an operator will see it.
411
+ if (report.danglingCount > 0) {
412
+ console.warn(
413
+ `[registry] ${report.danglingCount} index line(s) have no package file — left in place; download 404s until republished`,
414
+ );
415
+ }
416
+ if (report.unreadable.length > 0) {
417
+ console.warn(
418
+ `[registry] skipped entirely (no package files at all — check the mount and DATA_DIR): ${report.unreadable.join(', ')}`,
419
+ );
420
+ }
421
+ return Response.json({ ok: true, ...report });
422
+ }
423
+
348
424
  function setYanked(name: string, version: string, yanked: boolean): Response {
349
425
  const entries = storage.readIndex(name);
350
426
  const entry = entries.find((e) => e.vers === version);
@@ -505,10 +581,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
505
581
  // description capture and haven't been republished).
506
582
  const bootstrapEntry = bootstrap.get(name);
507
583
  const description = latest?.description ?? bootstrapEntry?.description ?? '';
584
+ // Icon follows the same order, but has no empty-string tier: absent
585
+ // means "this module declared none", and the consumer resolves it
586
+ // from its own table or a placeholder (module-icons D5).
587
+ const icon = latest?.icon ?? bootstrapEntry?.icon;
508
588
  return {
509
589
  name,
510
590
  max_version: latest?.vers ?? '0.0.0',
511
591
  description,
592
+ icon,
512
593
  total_downloads: storage.getDownloads(name),
513
594
  };
514
595
  });
@@ -551,6 +632,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
551
632
  return handleRevokeToken(req);
552
633
  }
553
634
 
635
+ // Reclaim disk from superseded build revisions (admin-only), grouped
636
+ // with the other admin endpoints. A module NAMED "sweep" is fine here:
637
+ // its metadata route is a GET, so the two never compete.
638
+ if (method === 'POST' && suffix === `${apiBase}/sweep`) {
639
+ const rl = rateLimitOrNull(req, srv);
640
+ if (rl) return rl;
641
+ return handleSweep(req);
642
+ }
643
+
554
644
  // Module-owner table management (admin-only — ce-1ch). Matched before the
555
645
  // generic `${apiBase}/{name}` handlers so a name of "owners" can't shadow
556
646
  // them.
@@ -588,9 +678,11 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
588
678
  const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
589
679
  const bootstrapEntry = resolveBootstrap().get(name);
590
680
  const description = latest?.description ?? bootstrapEntry?.description ?? '';
681
+ const icon = latest?.icon ?? bootstrapEntry?.icon;
591
682
  return Response.json({
592
683
  name,
593
684
  description,
685
+ icon,
594
686
  total_downloads: storage.getDownloads(name),
595
687
  versions: entries.map((v) => ({
596
688
  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;
@@ -115,6 +189,25 @@ export class RegistryStorage {
115
189
  return versions.length > 0 ? { name, versions } : null;
116
190
  }
117
191
 
192
+ /**
193
+ * The glyph each module holds, keyed by module name. A module holds the
194
+ * icon on its latest non-yanked entry — the same "latest" the browse
195
+ * endpoints read, so a module holds exactly the glyph a consumer would
196
+ * render for it. Modules whose latest entry declares no icon are absent.
197
+ *
198
+ * This is the set the publish-time duplicate-icon refusal compares against
199
+ * (openspec/changes/module-icons, D8): a new publish declaring a glyph in
200
+ * this map, under a different name, is rejected.
201
+ */
202
+ latestIcons(): Map<string, string> {
203
+ const held = new Map<string, string>();
204
+ for (const { name, versions } of this.listModules()) {
205
+ const latest = versions.filter((v) => !v.yanked).at(-1) ?? versions.at(-1);
206
+ if (latest?.icon) held.set(name, latest.icon);
207
+ }
208
+ return held;
209
+ }
210
+
118
211
  /**
119
212
  * Per-module download counter. Stored as a single integer in
120
213
  * `<dataDir>/downloads/<name>`. Read-modify-write, NOT atomic