@celilo/e2e 0.7.14 → 0.7.16

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.
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { TokenAuth } from './auth';
5
+ import { ADMIN_SCOPE, TokenAuth } from './auth';
6
6
  import { createRateLimiter } from './rate-limit';
7
7
  import { startServer } from './server';
8
8
 
@@ -17,7 +17,7 @@ beforeEach(() => {
17
17
  port: 0, // let Bun pick an ephemeral port
18
18
  pathPrefix: '',
19
19
  publicUrl: 'https://example.test',
20
- auth: new TokenAuth(['valid-token']),
20
+ auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
21
21
  });
22
22
  baseUrl = `http://localhost:${server.port}`;
23
23
  });
@@ -107,9 +107,10 @@ describe('PUT /api/v1/modules/new', () => {
107
107
  const res = await publish('homebridge', '1.0.0+1', 'valid-token');
108
108
  expect(res.status).toBe(200);
109
109
 
110
- const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) =>
111
- r.json(),
112
- )) as { name: string; versions: { num: string }[] };
110
+ const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
111
+ name: string;
112
+ versions: { num: string }[];
113
+ };
113
114
  expect(meta.name).toBe('homebridge');
114
115
  expect(meta.versions).toHaveLength(1);
115
116
  expect(meta.versions[0].num).toBe('1.0.0+1');
@@ -258,9 +259,10 @@ describe('GET /api/v1/modules/{name} (metadata) — total_downloads', () => {
258
259
  await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
259
260
  await fetch(`${baseUrl}/api/v1/modules/homebridge/1.0.0+1/download`);
260
261
 
261
- const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) =>
262
- r.json(),
263
- )) as { name: string; total_downloads: number };
262
+ const meta = (await fetch(`${baseUrl}/api/v1/modules/homebridge`).then((r) => r.json())) as {
263
+ name: string;
264
+ total_downloads: number;
265
+ };
264
266
  expect(meta.total_downloads).toBe(2);
265
267
  });
266
268
  });
@@ -344,7 +346,7 @@ describe('write endpoints are rate limited', () => {
344
346
  port: 0,
345
347
  pathPrefix: '',
346
348
  publicUrl: 'https://example.test',
347
- auth: new TokenAuth(['tok']),
349
+ auth: new TokenAuth([{ token: 'tok', scope: ADMIN_SCOPE }]),
348
350
  rateLimiter: createRateLimiter({ max: 2, windowMs: 60_000 }),
349
351
  });
350
352
  try {
@@ -386,7 +388,7 @@ describe('path prefix routing', () => {
386
388
  port: 0,
387
389
  pathPrefix: '/registry',
388
390
  publicUrl: 'https://example.test/registry',
389
- auth: new TokenAuth(['valid-token']),
391
+ auth: new TokenAuth([{ token: 'valid-token', scope: ADMIN_SCOPE }]),
390
392
  });
391
393
  try {
392
394
  const base = `http://localhost:${prefixed.port}`;
@@ -400,6 +402,86 @@ describe('path prefix routing', () => {
400
402
  });
401
403
  });
402
404
 
405
+ // ── scoped publish tokens (ISS-0140) ──────────────────────────────────────────
406
+
407
+ describe('POST /api/v1/modules/tokens/mint + scoped publish', () => {
408
+ async function mint(repo: string, scope: string, token: string): Promise<Response> {
409
+ return fetch(`${baseUrl}/api/v1/modules/tokens/mint`, {
410
+ method: 'POST',
411
+ headers: token ? { Authorization: token, 'Content-Type': 'application/json' } : {},
412
+ body: JSON.stringify({ repo, scope }),
413
+ });
414
+ }
415
+
416
+ test('admin token mints a scoped token; non-admin / no token is 401', async () => {
417
+ const noTok = await mint('celilo/lunacycle', 'lunacycle', '');
418
+ expect(noTok.status).toBe(401);
419
+
420
+ const ok = await mint('celilo/lunacycle', 'lunacycle', 'valid-token');
421
+ expect(ok.status).toBe(200);
422
+ const body = (await ok.json()) as { ok: boolean; token: string; scope: string };
423
+ expect(body.ok).toBe(true);
424
+ expect(body.scope).toBe('lunacycle');
425
+ expect(body.token.length).toBeGreaterThan(10);
426
+ });
427
+
428
+ test('a minted scoped token publishes ONLY its package', async () => {
429
+ const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
430
+ token: string;
431
+ };
432
+
433
+ // Can publish its own package…
434
+ const ok = await publish('lunacycle', '1.0.0+1', minted.token);
435
+ expect(ok.status).toBe(200);
436
+
437
+ // …but NOT another module.
438
+ const denied = await publish('caddy', '1.0.0+1', minted.token);
439
+ expect(denied.status).toBe(401);
440
+ });
441
+
442
+ test('a scoped token cannot mint (not admin)', async () => {
443
+ const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
444
+ token: string;
445
+ };
446
+ const reMint = await mint('celilo/caddy', 'caddy', minted.token);
447
+ expect(reMint.status).toBe(401);
448
+ });
449
+
450
+ test('re-minting rotates: the old token stops working, the new one publishes', async () => {
451
+ const first = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
452
+ token: string;
453
+ };
454
+ const second = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
455
+ token: string;
456
+ };
457
+ expect(second.token).not.toBe(first.token);
458
+
459
+ const oldDenied = await publish('lunacycle', '2.0.0+1', first.token);
460
+ expect(oldDenied.status).toBe(401);
461
+ const newOk = await publish('lunacycle', '2.0.0+1', second.token);
462
+ expect(newOk.status).toBe(200);
463
+ });
464
+
465
+ test('revoke disables the token', async () => {
466
+ const minted = (await (await mint('celilo/lunacycle', 'lunacycle', 'valid-token')).json()) as {
467
+ token: string;
468
+ };
469
+ const revoke = await fetch(`${baseUrl}/api/v1/modules/tokens/revoke`, {
470
+ method: 'POST',
471
+ headers: { Authorization: 'valid-token', 'Content-Type': 'application/json' },
472
+ body: JSON.stringify({ repo: 'celilo/lunacycle' }),
473
+ });
474
+ expect(revoke.status).toBe(200);
475
+ const denied = await publish('lunacycle', '1.0.0+1', minted.token);
476
+ expect(denied.status).toBe(401);
477
+ });
478
+
479
+ test('mint rejects an invalid scope (not a valid package name)', async () => {
480
+ const res = await mint('celilo/lunacycle', 'Bad_Scope', 'valid-token');
481
+ expect(res.status).toBe(400);
482
+ });
483
+ });
484
+
403
485
  // ─── helpers ──────────────────────────────────────────────────────────────────
404
486
 
405
487
  async function publish(
@@ -19,19 +19,19 @@
19
19
  * can publish-then-import against a live registry.
20
20
  */
21
21
 
22
- import { readFileSync } from 'node:fs';
23
22
  import { join } from 'node:path';
24
23
  import { TokenAuth } from './auth';
25
- import { landingResponse } from './landing';
26
24
  import {
25
+ type BootstrapEntry,
27
26
  bootstrapIndexEntry,
28
27
  packageBootstrapModule,
29
28
  scanBootstrapDir,
30
29
  scanUploadsDir,
31
- type BootstrapEntry,
32
30
  } from './bootstrap';
33
- import { clientIp, createRateLimiter, type RateLimiter } from './rate-limit';
34
- import { RegistryStorage, type IndexEntry } from './storage';
31
+ import { landingResponse } from './landing';
32
+ import { type RateLimiter, clientIp, createRateLimiter } from './rate-limit';
33
+ import { ScopedTokenStore, fileScopedTokenPersistence } from './scoped-token-store';
34
+ import { type IndexEntry, RegistryStorage } from './storage';
35
35
  import { isValidName, isValidVersion, validateNameAndVersion } from './validation';
36
36
 
37
37
  /** Max total publish body size. Real modules are a few MB; 100MB is generous. */
@@ -70,14 +70,26 @@ interface ServerOptions {
70
70
  * to slow a bad actor to a crawl.
71
71
  */
72
72
  rateLimiter?: RateLimiter;
73
+ /**
74
+ * Store of minted, per-repo package-scoped tokens (build-bus Phase 3,
75
+ * ISS-0140). Defaults to a JSON file in `dataDir`. Its persisted hashes are
76
+ * loaded into `auth` at startup so minted tokens survive a restart.
77
+ */
78
+ scopedTokenStore?: ScopedTokenStore;
73
79
  }
74
80
 
75
81
  export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve> {
76
82
  const { dataDir, port, pathPrefix, publicUrl, auth } = options;
77
83
  const storage = new RegistryStorage(dataDir);
84
+ const scopedTokenStore =
85
+ options.scopedTokenStore ??
86
+ new ScopedTokenStore(fileScopedTokenPersistence(join(dataDir, 'scoped-tokens.json')));
87
+ // Load persisted minted tokens into the in-memory authorizer.
88
+ for (const entry of scopedTokenStore.list()) {
89
+ auth.addHashed(entry.hash, entry.scope);
90
+ }
78
91
  const bootstrapCacheDir = options.bootstrapCacheDir ?? join(dataDir, '.bootstrap-cache');
79
- const rateLimiter =
80
- options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 });
92
+ const rateLimiter = options.rateLimiter ?? createRateLimiter({ max: 30, windowMs: 60_000 });
81
93
 
82
94
  /**
83
95
  * Rescan every request so .netapps dropped into bootstrapUploadsDir at
@@ -122,15 +134,24 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
122
134
  return err('Unauthorized — provide a valid publish token in the Authorization header', 401);
123
135
  }
124
136
 
125
- function requireAuth(req: Request): boolean {
126
- return auth.verify(req.headers.get('Authorization') ?? '');
137
+ /** True when the request's token may publish/yank `pkg` (admin or exact scope). */
138
+ function authorizePackage(req: Request, pkg: string): boolean {
139
+ return auth.authorize(req.headers.get('Authorization') ?? '', pkg);
140
+ }
141
+
142
+ /** True when the request carries an admin (`*`-scoped) token — required to mint. */
143
+ function authorizeAdmin(req: Request): boolean {
144
+ return auth.isAdmin(req.headers.get('Authorization') ?? '');
127
145
  }
128
146
 
129
147
  /**
130
148
  * Rate-limit check for write endpoints. Returns a 429 response when
131
149
  * exceeded (with Retry-After), null when allowed.
132
150
  */
133
- function rateLimitOrNull(req: Request, srv: { requestIP(r: Request): { address: string } | null }): Response | null {
151
+ function rateLimitOrNull(
152
+ req: Request,
153
+ srv: { requestIP(r: Request): { address: string } | null },
154
+ ): Response | null {
134
155
  const ip = clientIp(req, srv);
135
156
  const result = rateLimiter.take(ip);
136
157
  if (result.ok) return null;
@@ -214,12 +235,15 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
214
235
  // celilo CLI reads manifest.yml#description and includes it). When
215
236
  // not provided we leave it undefined; the search endpoint falls
216
237
  // back to bootstrap data or empty.
217
- const description =
218
- typeof meta.description === 'string' ? meta.description : undefined;
238
+ const description = typeof meta.description === 'string' ? meta.description : undefined;
219
239
 
220
240
  const validation = validateNameAndVersion(name, vers);
221
241
  if (!validation.ok) return err(validation.message);
222
242
 
243
+ // Scope check AFTER the name is known: a package-scoped token may publish
244
+ // only its own package; an admin token publishes anything (ISS-0140).
245
+ if (!authorizePackage(req, name)) return unauthorized();
246
+
223
247
  const fileOffset = 4 + metaLen;
224
248
  const fileLen = buf.readUInt32LE(fileOffset);
225
249
  if (buf.length < fileOffset + 4 + fileLen) return err('Request body truncated (file)');
@@ -253,6 +277,51 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
253
277
  return Response.json({ ok: true });
254
278
  }
255
279
 
280
+ /**
281
+ * Mint a per-repo, package-scoped publish token (ISS-0140). Admin-only. The
282
+ * `registry_publish` capability calls this on behalf of `source_forge.
283
+ * registerRepo`. Reconciled: re-minting for the same repo rotates the token.
284
+ * The raw token is returned ONCE — the caller sets it as the repo's Actions
285
+ * secret; the server keeps only its hash.
286
+ */
287
+ async function handleMintToken(req: Request): Promise<Response> {
288
+ if (!authorizeAdmin(req)) return unauthorized();
289
+ let body: { repo?: unknown; scope?: unknown };
290
+ try {
291
+ body = (await req.json()) as { repo?: unknown; scope?: unknown };
292
+ } catch {
293
+ return err('Invalid JSON body');
294
+ }
295
+ const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
296
+ const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
297
+ if (!repo) return err('repo is required');
298
+ if (!scope || !isValidName(scope)) return err('scope must be a valid package name');
299
+
300
+ const { token, entry, revokedHashes } = scopedTokenStore.mint(repo, scope);
301
+ for (const h of revokedHashes) auth.removeHashed(h);
302
+ auth.addHashed(entry.hash, entry.scope);
303
+ console.log(`[registry] minted scoped token for ${repo} → ${scope}`);
304
+ return Response.json({ ok: true, token, scope, repo });
305
+ }
306
+
307
+ /** Revoke all scoped tokens for a repo (ISS-0140). Admin-only. */
308
+ async function handleRevokeToken(req: Request): Promise<Response> {
309
+ if (!authorizeAdmin(req)) return unauthorized();
310
+ let body: { repo?: unknown };
311
+ try {
312
+ body = (await req.json()) as { repo?: unknown };
313
+ } catch {
314
+ return err('Invalid JSON body');
315
+ }
316
+ const repo = typeof body.repo === 'string' ? body.repo.trim() : '';
317
+ if (!repo) return err('repo is required');
318
+
319
+ const removed = scopedTokenStore.revoke(repo);
320
+ for (const h of removed) auth.removeHashed(h);
321
+ console.log(`[registry] revoked ${removed.length} scoped token(s) for ${repo}`);
322
+ return Response.json({ ok: true, repo, revoked: removed.length });
323
+ }
324
+
256
325
  const server = Bun.serve({
257
326
  port,
258
327
  async fetch(req, srv) {
@@ -273,10 +342,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
273
342
  // - suffix='/' → request was prefix + '/', or just '/'
274
343
  // - suffix='/index.html'
275
344
  // See apps/celilo/designs/REGISTRY_BROWSE_UI.md (decision D2).
276
- if (
277
- method === 'GET' &&
278
- (suffix === '' || suffix === '/' || suffix === '/index.html')
279
- ) {
345
+ if (method === 'GET' && (suffix === '' || suffix === '/' || suffix === '/index.html')) {
280
346
  return landingResponse(publicUrl);
281
347
  }
282
348
 
@@ -329,10 +395,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
329
395
  // 3. Empty string (older publishes that pre-date the
330
396
  // description capture and haven't been republished).
331
397
  const bootstrapEntry = bootstrap.get(name);
332
- const description =
333
- latest?.description ??
334
- bootstrapEntry?.description ??
335
- '';
398
+ const description = latest?.description ?? bootstrapEntry?.description ?? '';
336
399
  return {
337
400
  name,
338
401
  max_version: latest?.vers ?? '0.0.0',
@@ -359,14 +422,26 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
359
422
  }
360
423
 
361
424
  // Publish — rate-limit before even checking auth so a bad-token flood
362
- // can't crowd out legitimate traffic on the shared buckets.
425
+ // can't crowd out legitimate traffic on the shared buckets. The scope
426
+ // check happens inside handlePublish, once the package name is known.
363
427
  if (method === 'PUT' && suffix === `${apiBase}/new`) {
364
428
  const rl = rateLimitOrNull(req, srv);
365
429
  if (rl) return rl;
366
- if (!requireAuth(req)) return unauthorized();
367
430
  return handlePublish(req);
368
431
  }
369
432
 
433
+ // Mint / revoke per-repo scoped publish tokens (admin-only — ISS-0140).
434
+ if (method === 'POST' && suffix === `${apiBase}/tokens/mint`) {
435
+ const rl = rateLimitOrNull(req, srv);
436
+ if (rl) return rl;
437
+ return handleMintToken(req);
438
+ }
439
+ if (method === 'POST' && suffix === `${apiBase}/tokens/revoke`) {
440
+ const rl = rateLimitOrNull(req, srv);
441
+ if (rl) return rl;
442
+ return handleRevokeToken(req);
443
+ }
444
+
370
445
  if (suffix.startsWith(`${apiBase}/`)) {
371
446
  const modulePath = suffix.slice(apiBase.length + 1);
372
447
  // URL-decode path segments — module versions use a literal '+' to
@@ -387,10 +462,7 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
387
462
  // endpoint — see notes there.
388
463
  const latest = entries.filter((v) => !v.yanked).at(-1) ?? entries.at(-1);
389
464
  const bootstrapEntry = resolveBootstrap().get(name);
390
- const description =
391
- latest?.description ??
392
- bootstrapEntry?.description ??
393
- '';
465
+ const description = latest?.description ?? bootstrapEntry?.description ?? '';
394
466
  return Response.json({
395
467
  name,
396
468
  description,
@@ -434,9 +506,9 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
434
506
  if (method === 'DELETE' && parts.length === 3 && parts[2] === 'yank') {
435
507
  const rl = rateLimitOrNull(req, srv);
436
508
  if (rl) return rl;
437
- if (!requireAuth(req)) return unauthorized();
438
509
  const [name, version] = parts;
439
510
  if (!isValidName(name) || !isValidVersion(version)) return notFound();
511
+ if (!authorizePackage(req, name)) return unauthorized();
440
512
  return setYanked(name, version, true);
441
513
  }
442
514
 
@@ -444,9 +516,9 @@ export function startServer(options: ServerOptions): ReturnType<typeof Bun.serve
444
516
  if (method === 'PUT' && parts.length === 3 && parts[2] === 'unyank') {
445
517
  const rl = rateLimitOrNull(req, srv);
446
518
  if (rl) return rl;
447
- if (!requireAuth(req)) return unauthorized();
448
519
  const [name, version] = parts;
449
520
  if (!isValidName(name) || !isValidVersion(version)) return notFound();
521
+ if (!authorizePackage(req, name)) return unauthorized();
450
522
  return setYanked(name, version, false);
451
523
  }
452
524
  }
@@ -464,8 +536,7 @@ export function startFromEnv(): void {
464
536
  const port = Number(process.env.PORT ?? 3000);
465
537
  const pathPrefix = (process.env.PATH_PREFIX ?? '').replace(/\/$/, '');
466
538
  const publicUrl =
467
- process.env.PUBLIC_URL ??
468
- `https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`;
539
+ process.env.PUBLIC_URL ?? `https://${process.env.DOMAIN ?? 'celilo.computer'}${pathPrefix}`;
469
540
  const bootstrapModulesDir = process.env.BOOTSTRAP_MODULES_DIR || undefined;
470
541
  const bootstrapUploadsDir = process.env.BOOTSTRAP_UPLOADS_DIR || undefined;
471
542
  const bootstrapCacheDir = process.env.BOOTSTRAP_CACHE_DIR || undefined;
@@ -485,7 +556,9 @@ export function startFromEnv(): void {
485
556
  const bootstrapBits = [
486
557
  bootstrapModulesDir ? `modulesDir=${bootstrapModulesDir}` : '',
487
558
  bootstrapUploadsDir ? `uploadsDir=${bootstrapUploadsDir}` : '',
488
- ].filter(Boolean).join(', ');
559
+ ]
560
+ .filter(Boolean)
561
+ .join(', ');
489
562
  console.log(
490
563
  `[registry] listening on :${server.port} (prefix=${pathPrefix || '(none)'}, publicUrl=${publicUrl}${
491
564
  bootstrapBits ? `, bootstrap=[${bootstrapBits}]` : ''
@@ -13,9 +13,14 @@
13
13
  * @celilo/capabilities → (none)
14
14
  * @celilo/cli-display → (none)
15
15
  *
16
- * So 5 workspace packages need to land in the simulator. We rely on
17
- * `bun pm pack` to rewrite `workspace:^` deps to concrete versions, so
18
- * the published tarballs resolve cleanly without real npm.
16
+ * So 5 workspace packages need to land in the simulator. We rewrite each
17
+ * package's `workspace:^` deps to concrete `^<workspace-version>` ranges
18
+ * OURSELVES before `bun pm pack` — we do NOT rely on bun's lock-based
19
+ * expansion, which goes stale after a cross-caret version bump and pins
20
+ * dependents to an unresolvable old range (ISS-0103: a `0.3→0.4`
21
+ * capabilities bump left the lock at 0.3, so packed cli pinned
22
+ * `^0.3.0-alpha.0`, excluding the 0.4.x the sim served). package.json is
23
+ * restored after packing so `workspace:^` stays in source.
19
24
  *
20
25
  * Output directory is typically `packages/e2e/.npm-registry-cache/` —
21
26
  * staged into the npm-registry-sim Docker image at build time.
@@ -25,11 +30,73 @@
25
30
  */
26
31
 
27
32
  import { execSync } from 'node:child_process';
28
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
33
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
29
34
  import { join } from 'node:path';
30
35
 
31
36
  const REPO_ROOT = join(import.meta.dir, '..', '..', '..');
32
37
 
38
+ interface PackageJson {
39
+ name?: string;
40
+ version?: string;
41
+ dependencies?: Record<string, string>;
42
+ devDependencies?: Record<string, string>;
43
+ peerDependencies?: Record<string, string>;
44
+ optionalDependencies?: Record<string, string>;
45
+ }
46
+
47
+ export interface WorkspaceRewrite {
48
+ depName: string;
49
+ oldSpec: string;
50
+ newSpec: string;
51
+ }
52
+
53
+ const DEP_BUCKETS = [
54
+ 'dependencies',
55
+ 'devDependencies',
56
+ 'peerDependencies',
57
+ 'optionalDependencies',
58
+ ] as const;
59
+
60
+ /**
61
+ * Rewrite `workspace:` deps in `pkg` to concrete ranges using the
62
+ * name→version map, mutating `pkg`. Mirrors the publish flow's
63
+ * `rewriteWorkspaceDeps`:
64
+ * workspace:^ → ^<version>
65
+ * workspace:~ → ~<version>
66
+ * workspace:* / workspace: → * (any version)
67
+ * workspace:X.Y.Z → X.Y.Z (explicit, passes through)
68
+ *
69
+ * We do this ourselves rather than trust `bun pm pack`'s lock-based
70
+ * expansion, which goes stale after a cross-caret version bump (ISS-0103).
71
+ * Only deps present in `versions` (our own @celilo/* set) are touched.
72
+ * Returns the rewrites applied.
73
+ */
74
+ export function rewriteWorkspacePins(
75
+ pkg: PackageJson,
76
+ versions: Map<string, string>,
77
+ ): WorkspaceRewrite[] {
78
+ const rewrites: WorkspaceRewrite[] = [];
79
+ for (const bucket of DEP_BUCKETS) {
80
+ const deps = pkg[bucket];
81
+ if (!deps) continue;
82
+ for (const [name, spec] of Object.entries(deps)) {
83
+ if (!spec.startsWith('workspace:')) continue;
84
+ const version = versions.get(name);
85
+ if (!version) continue;
86
+ const range = spec.slice('workspace:'.length);
87
+ let newSpec: string;
88
+ if (range === '*' || range === '') newSpec = '*';
89
+ else if (range === '^') newSpec = `^${version}`;
90
+ else if (range === '~') newSpec = `~${version}`;
91
+ else newSpec = range;
92
+ if (newSpec === spec) continue;
93
+ deps[name] = newSpec;
94
+ rewrites.push({ depName: name, oldSpec: spec, newSpec });
95
+ }
96
+ }
97
+ return rewrites;
98
+ }
99
+
33
100
  /**
34
101
  * Workspace packages that install.sh's transitive @celilo/* closure depends
35
102
  * on. Hardcoded by name → relative path so a typo in package.json or a future
@@ -63,27 +130,46 @@ function parseArgs(): CliArgs {
63
130
  return { destination };
64
131
  }
65
132
 
66
- function pack(pkgPath: string, destination: string): string {
133
+ function pack(pkgPath: string, destination: string, versions: Map<string, string>): string {
67
134
  const fullPath = join(REPO_ROOT, pkgPath);
68
- if (!existsSync(join(fullPath, 'package.json'))) {
135
+ const pkgJsonPath = join(fullPath, 'package.json');
136
+ if (!existsSync(pkgJsonPath)) {
69
137
  throw new Error(`No package.json in ${fullPath}`);
70
138
  }
71
- // Capture filenames before/after so we can identify the tarball this
72
- // pack call produced even when the destination already contains tarballs
73
- // from earlier packages in the loop.
74
- const before = new Set(readdirSync(destination).filter((f) => f.endsWith('.tgz')));
75
- execSync(`bun pm pack --destination=${destination}`, {
76
- cwd: fullPath,
77
- stdio: 'inherit',
78
- });
79
- const after = readdirSync(destination).filter((f) => f.endsWith('.tgz'));
80
- const produced = after.filter((f) => !before.has(f));
81
- if (produced.length !== 1) {
82
- throw new Error(
83
- `Expected exactly one new tarball from packing ${pkgPath}, got ${produced.length}: ${produced.join(', ')}`,
84
- );
139
+
140
+ // Rewrite workspace:^ deps to concrete ranges ourselves (see header /
141
+ // ISS-0103), then restore the original so workspace:^ stays in source.
142
+ const originalPkgJson = readFileSync(pkgJsonPath, 'utf-8');
143
+ const parsed = JSON.parse(originalPkgJson) as PackageJson;
144
+ const rewrites = rewriteWorkspacePins(parsed, versions);
145
+ if (rewrites.length > 0) {
146
+ const trailingNl = originalPkgJson.endsWith('\n') ? '\n' : '';
147
+ writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + trailingNl);
148
+ for (const r of rewrites) {
149
+ console.log(` rewrote ${r.depName}: ${r.oldSpec} → ${r.newSpec}`);
150
+ }
151
+ }
152
+
153
+ try {
154
+ // Capture filenames before/after so we can identify the tarball this
155
+ // pack call produced even when the destination already contains tarballs
156
+ // from earlier packages in the loop.
157
+ const before = new Set(readdirSync(destination).filter((f) => f.endsWith('.tgz')));
158
+ execSync(`bun pm pack --destination=${destination}`, {
159
+ cwd: fullPath,
160
+ stdio: 'inherit',
161
+ });
162
+ const after = readdirSync(destination).filter((f) => f.endsWith('.tgz'));
163
+ const produced = after.filter((f) => !before.has(f));
164
+ if (produced.length !== 1) {
165
+ throw new Error(
166
+ `Expected exactly one new tarball from packing ${pkgPath}, got ${produced.length}: ${produced.join(', ')}`,
167
+ );
168
+ }
169
+ return produced[0];
170
+ } finally {
171
+ writeFileSync(pkgJsonPath, originalPkgJson);
85
172
  }
86
- return produced[0];
87
173
  }
88
174
 
89
175
  function workspaceVersion(pkgPath: string): string {
@@ -104,11 +190,17 @@ function main(): void {
104
190
  console.log(`Packing @celilo/* packages → ${destination}`);
105
191
  console.log('');
106
192
 
193
+ // The name→version map used to rewrite each package's workspace:^ deps to
194
+ // concrete ranges that resolve against the co-packed versions (ISS-0103).
195
+ const versions = new Map<string, string>(
196
+ PACKAGES_TO_PACK.map(({ name, path }) => [name, workspaceVersion(path)]),
197
+ );
198
+
107
199
  const results: Array<{ name: string; version: string; tarball: string }> = [];
108
200
  for (const { name, path } of PACKAGES_TO_PACK) {
109
201
  const version = workspaceVersion(path);
110
202
  console.log(`▸ ${name}@${version} (${path})`);
111
- const tarball = pack(path, destination);
203
+ const tarball = pack(path, destination, versions);
112
204
  results.push({ name, version, tarball });
113
205
  console.log(` → ${tarball}`);
114
206
  console.log('');
@@ -16,7 +16,7 @@ import type {
16
16
  ProxyOptions,
17
17
  SocksProxyHandle,
18
18
  } from './types';
19
- import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
19
+ import { CeliloCommandError, internalNatIp, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
20
20
 
21
21
  /** Package root — where docker/, config/, simulators/ live */
22
22
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -137,7 +137,7 @@ const SHARED_CONTAINERS = new Set([
137
137
  * set (useful for --keep debugging).
138
138
  */
139
139
  async function scrubDnsZones(): Promise<void> {
140
- const zones = ['iamtheinternet.org', 'lunacycle.net'];
140
+ const zones = ['iamtheinternet.org', 'example.net'];
141
141
  const composeDir = PACKAGE_ROOT;
142
142
 
143
143
  for (const zone of zones) {
@@ -675,7 +675,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
675
675
  async deployFirewall(opts = {}): Promise<void> {
676
676
  const zones = opts.zones ?? ['dmz', 'app', 'secure'];
677
677
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
678
- const natIp = opts.natIp ?? '192.168.0.253';
678
+ const natIp = opts.natIp ?? internalNatIp();
679
679
  const providedNetworks = zones.map((zone) => ({
680
680
  zone,
681
681
  subnet: ZONE_SUBNETS[zone],
@@ -950,7 +950,7 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
950
950
  async deployFirewall(opts = {}): Promise<void> {
951
951
  const zones = opts.zones ?? ['dmz', 'app', 'secure'];
952
952
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
953
- const natIp = opts.natIp ?? '192.168.0.253';
953
+ const natIp = opts.natIp ?? internalNatIp();
954
954
  const providedNetworks = zones.map((zone) => ({
955
955
  zone,
956
956
  subnet: ZONE_SUBNETS[zone],