@celilo/cli 0.11.0-alpha.0 → 0.12.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.
Files changed (121) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +47 -5
  4. package/MODULE_PRIMITIVES.md +164 -0
  5. package/README.md +5 -5
  6. package/drizzle/0015_port_forwards.sql +12 -0
  7. package/drizzle/meta/_journal.json +7 -0
  8. package/package.json +8 -8
  9. package/schemas/system_config.json +1 -1
  10. package/src/ansible/inventory.test.ts +1 -1
  11. package/src/ansible/inventory.ts +3 -3
  12. package/src/capabilities/public-web-publish.test.ts +209 -0
  13. package/src/capabilities/validation.test.ts +1 -1
  14. package/src/capabilities/well-known.test.ts +1 -1
  15. package/src/capabilities/well-known.ts +2 -2
  16. package/src/cli/commands/api.ts +1 -1
  17. package/src/cli/commands/backup-pull.ts +1 -1
  18. package/src/cli/commands/events.ts +1 -1
  19. package/src/cli/commands/module-changeset.ts +1 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-publish.ts +1 -1
  22. package/src/cli/commands/module-remove.ts +26 -1
  23. package/src/cli/commands/module-types.ts +1 -1
  24. package/src/cli/commands/module-upgrade.ts +1 -1
  25. package/src/cli/commands/module-version.ts +1 -1
  26. package/src/cli/commands/module-where.test.ts +26 -0
  27. package/src/cli/commands/module-where.ts +130 -0
  28. package/src/cli/commands/proxmox-node-list.ts +1 -1
  29. package/src/cli/commands/publish/alpha.test.ts +1 -1
  30. package/src/cli/commands/publish/alpha.ts +3 -3
  31. package/src/cli/commands/publish/changesets.ts +1 -1
  32. package/src/cli/commands/publish/global-install.ts +6 -2
  33. package/src/cli/commands/publish/helpers.ts +21 -14
  34. package/src/cli/commands/publish/index.ts +4 -4
  35. package/src/cli/commands/publish/plan.ts +3 -3
  36. package/src/cli/commands/publish/preflight.ts +2 -2
  37. package/src/cli/commands/publish/types.ts +2 -2
  38. package/src/cli/commands/publish/workspace.test.ts +1 -1
  39. package/src/cli/commands/publish/workspace.ts +2 -2
  40. package/src/cli/commands/registry-owner.test.ts +166 -0
  41. package/src/cli/commands/registry-owner.ts +124 -0
  42. package/src/cli/commands/registry-token.test.ts +109 -0
  43. package/src/cli/commands/registry-token.ts +194 -0
  44. package/src/cli/commands/restore.ts +1 -1
  45. package/src/cli/commands/service-add-proxmox.ts +1 -1
  46. package/src/cli/commands/subscribers-list.ts +1 -1
  47. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  48. package/src/cli/commands/system-apply-config.ts +1 -1
  49. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  50. package/src/cli/commands/system-init.ts +2 -2
  51. package/src/cli/commands/token.test.ts +26 -0
  52. package/src/cli/commands/token.ts +160 -0
  53. package/src/cli/completion.ts +19 -0
  54. package/src/cli/index.ts +155 -4
  55. package/src/cli/restore-command.test.ts +1 -1
  56. package/src/db/client.ts +1 -1
  57. package/src/db/schema.ts +50 -7
  58. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  59. package/src/hooks/capability-loader.ts +100 -10
  60. package/src/hooks/define-hook.test.ts +24 -0
  61. package/src/hooks/executor.test.ts +1 -1
  62. package/src/hooks/executor.ts +2 -2
  63. package/src/hooks/types.ts +1 -1
  64. package/src/manifest/contracts/v1.ts +2 -2
  65. package/src/manifest/schema.ts +13 -13
  66. package/src/manifest/template-validator.ts +1 -1
  67. package/src/module/packaging/build.ts +19 -0
  68. package/src/module/packaging/workspace-deps.test.ts +94 -0
  69. package/src/module/packaging/workspace-deps.ts +185 -0
  70. package/src/module/versioning/changeset-version.ts +1 -1
  71. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  72. package/src/registry/client.test.ts +86 -0
  73. package/src/registry/client.ts +67 -1
  74. package/src/services/api-access.ts +1 -1
  75. package/src/services/aspect-approvals.ts +1 -1
  76. package/src/services/aspect-runner.ts +3 -3
  77. package/src/services/aspect-template-resolver.test.ts +1 -1
  78. package/src/services/aspect-template-resolver.ts +1 -1
  79. package/src/services/build-bus/delivery-events.ts +1 -1
  80. package/src/services/build-bus/fan-out.ts +1 -1
  81. package/src/services/build-bus/hook-dispatch.ts +1 -1
  82. package/src/services/build-bus/receiver-server.ts +1 -1
  83. package/src/services/build-bus/status.test.ts +1 -1
  84. package/src/services/build-bus/status.ts +1 -1
  85. package/src/services/build-bus/subscriber-store.ts +1 -1
  86. package/src/services/bus-interview.ts +1 -1
  87. package/src/services/celilo-events.ts +1 -1
  88. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  89. package/src/services/cross-module-read.ts +1 -1
  90. package/src/services/deploy-posture.ts +1 -1
  91. package/src/services/deployed-systems.test.ts +1 -1
  92. package/src/services/deployed-systems.ts +4 -4
  93. package/src/services/dns-provider-backfill.ts +2 -2
  94. package/src/services/events-daemon.ts +1 -1
  95. package/src/services/machine-pool.ts +3 -3
  96. package/src/services/module-deploy.ts +93 -8
  97. package/src/services/module-subscriptions.ts +1 -1
  98. package/src/services/module-types-generator.ts +1 -1
  99. package/src/services/module-validator/git-hygiene.ts +1 -1
  100. package/src/services/port-forwards.test.ts +92 -0
  101. package/src/services/port-forwards.ts +86 -0
  102. package/src/services/proxmox-reconcile.ts +2 -2
  103. package/src/services/public-web-republish.test.ts +189 -0
  104. package/src/services/public-web-republish.ts +84 -0
  105. package/src/services/restore-from-file.ts +1 -1
  106. package/src/services/restore-preflight.ts +1 -1
  107. package/src/services/system-identity.ts +1 -1
  108. package/src/services/system-init.test.ts +2 -2
  109. package/src/services/system-init.ts +2 -2
  110. package/src/services/web-route-cleanup.test.ts +250 -0
  111. package/src/services/web-route-cleanup.ts +144 -0
  112. package/src/templates/generator.ts +2 -2
  113. package/src/types/infrastructure.ts +1 -1
  114. package/src/variables/computed/computed-integration.test.ts +1 -1
  115. package/src/variables/computed/evaluate.ts +1 -1
  116. package/src/variables/computed/parse.ts +1 -1
  117. package/src/variables/context.test.ts +1 -1
  118. package/src/variables/context.ts +5 -5
  119. package/src/variables/lxc-nameserver.test.ts +1 -1
  120. package/src/variables/resolver.ts +2 -2
  121. package/src/variables/types.ts +1 -1
@@ -489,3 +489,212 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
489
489
  }
490
490
  });
491
491
  });
492
+
493
+ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
494
+ beforeEach(() => {
495
+ execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
496
+ });
497
+ afterEach(() => {
498
+ execSyncSpy.mockRestore();
499
+ });
500
+
501
+ function makeRegistrar(): {
502
+ registrar: {
503
+ registerHost: (r: { fqdn: string; ip?: string }) => Promise<{ success: boolean }>;
504
+ };
505
+ calls: Array<{ fqdn: string; ip?: string }>;
506
+ } {
507
+ const calls: Array<{ fqdn: string; ip?: string }> = [];
508
+ return {
509
+ calls,
510
+ registrar: {
511
+ async registerHost(r) {
512
+ calls.push(r);
513
+ return { success: true };
514
+ },
515
+ },
516
+ };
517
+ }
518
+
519
+ test('registers the public A record at externalIp for a NEW hostname', async () => {
520
+ const { ops } = makeRouteOps();
521
+ const { registrar, calls } = makeRegistrar();
522
+ const cap = createPublicWeb({
523
+ moduleId: 'nexus',
524
+ logger: noopLogger,
525
+ config: { target_ip: '10.0.10.20/24' },
526
+ secrets: {},
527
+ routeOps: ops,
528
+ hostnames: ['www.example.com'],
529
+ caddyModuleId: 'caddy',
530
+ dnsManagedDomains: ['example.com'],
531
+ // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
532
+ dnsRegistrar: registrar as any,
533
+ externalIp: '100.100.0.100',
534
+ });
535
+
536
+ await cap.register_route({
537
+ type: 'static',
538
+ path: '/',
539
+ hostname: 'nexus.example.com',
540
+ });
541
+
542
+ expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: '100.100.0.100' }]);
543
+ });
544
+
545
+ test('skips the default hostname (caddy already registered it at install)', async () => {
546
+ const { ops } = makeRouteOps();
547
+ const { registrar, calls } = makeRegistrar();
548
+ const cap = createPublicWeb({
549
+ moduleId: 'root',
550
+ logger: noopLogger,
551
+ config: { target_ip: '10.0.10.20/24' },
552
+ secrets: {},
553
+ routeOps: ops,
554
+ hostnames: ['www.example.com'],
555
+ caddyModuleId: 'caddy',
556
+ dnsManagedDomains: ['example.com'],
557
+ // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
558
+ dnsRegistrar: registrar as any,
559
+ externalIp: '100.100.0.100',
560
+ });
561
+
562
+ await cap.register_route({ type: 'static', path: '/', hostname: 'www.example.com' });
563
+
564
+ expect(calls).toHaveLength(0);
565
+ });
566
+
567
+ // D2 / ce-phz — reachability wiring is authoritative and LOUD: a new
568
+ // hostname that can't be wired FAILS the deploy instead of silently
569
+ // reporting success (the "served but unreachable" anti-pattern).
570
+
571
+ test('fails loudly when externalIp is unknown for a NEW hostname', async () => {
572
+ const { ops } = makeRouteOps();
573
+ const { registrar, calls } = makeRegistrar();
574
+ const cap = createPublicWeb({
575
+ moduleId: 'nexus',
576
+ logger: noopLogger,
577
+ config: { target_ip: '10.0.10.20/24' },
578
+ secrets: {},
579
+ routeOps: ops,
580
+ hostnames: ['www.example.com'],
581
+ caddyModuleId: 'caddy',
582
+ dnsManagedDomains: ['example.com'],
583
+ // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
584
+ dnsRegistrar: registrar as any,
585
+ // externalIp omitted
586
+ });
587
+
588
+ await expect(
589
+ cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
590
+ ).rejects.toThrow('no known external IP');
591
+ expect(calls).toHaveLength(0);
592
+ });
593
+
594
+ test('fails loudly when no dns_registrar is available for a NEW hostname', async () => {
595
+ const { ops } = makeRouteOps();
596
+ const cap = createPublicWeb({
597
+ moduleId: 'nexus',
598
+ logger: noopLogger,
599
+ config: { target_ip: '10.0.10.20/24' },
600
+ secrets: {},
601
+ routeOps: ops,
602
+ hostnames: ['www.example.com'],
603
+ caddyModuleId: 'caddy',
604
+ dnsManagedDomains: ['example.com'],
605
+ // dnsRegistrar + externalIp both omitted
606
+ externalIp: '100.100.0.100',
607
+ });
608
+
609
+ await expect(
610
+ cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
611
+ ).rejects.toThrow('no dns_registrar capability');
612
+ });
613
+
614
+ test('fails loudly when the registrar reports the public registration failed', async () => {
615
+ const { ops } = makeRouteOps();
616
+ const registrar = {
617
+ async registerHost() {
618
+ return { success: false, error: 'DDNS auth rejected' };
619
+ },
620
+ };
621
+ const cap = createPublicWeb({
622
+ moduleId: 'nexus',
623
+ logger: noopLogger,
624
+ config: { target_ip: '10.0.10.20/24' },
625
+ secrets: {},
626
+ routeOps: ops,
627
+ hostnames: ['www.example.com'],
628
+ caddyModuleId: 'caddy',
629
+ dnsManagedDomains: ['example.com'],
630
+ // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
631
+ dnsRegistrar: registrar as any,
632
+ externalIp: '100.100.0.100',
633
+ });
634
+
635
+ await expect(
636
+ cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
637
+ ).rejects.toThrow('DDNS auth rejected');
638
+ });
639
+
640
+ test('fails loudly when an internal resolver is present but has no target IP', async () => {
641
+ const { ops } = makeRouteOps();
642
+ const { registrar } = makeRegistrar();
643
+ const dnsInternal = {
644
+ async registerRecord() {},
645
+ async deleteRecord() {},
646
+ };
647
+ const cap = createPublicWeb({
648
+ moduleId: 'nexus',
649
+ logger: noopLogger,
650
+ // No target_ip and no firewallNatIp → internalDnsIp is falsy.
651
+ config: {},
652
+ secrets: {},
653
+ routeOps: ops,
654
+ hostnames: ['www.example.com'],
655
+ caddyModuleId: 'caddy',
656
+ dnsManagedDomains: ['example.com'],
657
+ // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
658
+ dnsRegistrar: registrar as any,
659
+ externalIp: '100.100.0.100',
660
+ // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
661
+ dnsInternal: dnsInternal as any,
662
+ });
663
+
664
+ await expect(
665
+ cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
666
+ ).rejects.toThrow('no internal DNS target IP');
667
+ });
668
+
669
+ test('wires internal DNS when a resolver is present and reachable', async () => {
670
+ const { ops } = makeRouteOps();
671
+ const { registrar } = makeRegistrar();
672
+ const internalCalls: Array<{ host: string; value: string }> = [];
673
+ const dnsInternal = {
674
+ async registerRecord(r: { host: string; value: string }) {
675
+ internalCalls.push({ host: r.host, value: r.value });
676
+ },
677
+ async deleteRecord() {},
678
+ };
679
+ const cap = createPublicWeb({
680
+ moduleId: 'nexus',
681
+ logger: noopLogger,
682
+ config: { target_ip: '10.0.10.20/24' },
683
+ secrets: {},
684
+ routeOps: ops,
685
+ hostnames: ['www.example.com'],
686
+ caddyModuleId: 'caddy',
687
+ dnsManagedDomains: ['example.com'],
688
+ // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
689
+ dnsRegistrar: registrar as any,
690
+ externalIp: '100.100.0.100',
691
+ firewallNatIp: '192.168.0.253',
692
+ // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
693
+ dnsInternal: dnsInternal as any,
694
+ });
695
+
696
+ await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' });
697
+
698
+ expect(internalCalls).toEqual([{ host: 'nexus.example.com', value: '192.168.0.253' }]);
699
+ });
700
+ });
@@ -245,7 +245,7 @@ describe('Capability Access Validation', () => {
245
245
  // cross_module_read is a privilege (allow-listed to celilo-mgmt),
246
246
  // not a provider-backed capability. validateCapabilityAccess must
247
247
  // not demand a providing module for it — otherwise celilo-mgmt
248
- // can't be imported (regression: v2/NETWORK_CONFIG_TO_FIREWALL.md).
248
+ // can't be imported (regression: openspec/specs/progressive-zone-disclosure/spec.md).
249
249
  const manifest: ModuleManifest = {
250
250
  celilo_contract: '1.0',
251
251
  id: 'celilo-mgmt',
@@ -177,7 +177,7 @@ describe('Well-Known Capabilities Registry', () => {
177
177
 
178
178
  test('the internal DNS resolver lives in the dmz (ISS-0156)', () => {
179
179
  // Moved out of `internal`: the resolver must see protected-zone query
180
- // sources for source-based split-horizon views (v2/INTERNAL_DNS_ZONE_VIEWS.md).
180
+ // sources for source-based split-horizon views (openspec/specs/internal-dns-zone-views/spec.md).
181
181
  expect(WELL_KNOWN_CAPABILITIES.dns_internal.required_zone).toBe('dmz');
182
182
  });
183
183
  });
@@ -76,7 +76,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
76
76
  * dns_internal - Internal DNS resolver
77
77
  * Example: Technitium / knot-unbound for split-horizon DNS
78
78
  * Security: a PROTECTED zone (dmz), NOT `internal` (ISS-0156,
79
- * v2/INTERNAL_DNS_ZONE_VIEWS.md). The resolver must see each querying client's
79
+ * openspec/specs/internal-dns-zone-views/spec.md). The resolver must see each querying client's
80
80
  * real source IP to serve source-based split-horizon views; fw-main NATs
81
81
  * protected↔`internal`, so an `internal`-placed resolver sees every protected
82
82
  * query as fw-main's address and can't tell the zones apart. Placed in `dmz`
@@ -105,7 +105,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
105
105
  * Security: MUST be in secure zone (handles authentication)
106
106
  *
107
107
  * Note: `oidc.issuer_url` is no longer derived here. Per the firm rule in
108
- * design/TECHNICAL_DESIGN_MANIFEST_V2.md D9, well-known capability data
108
+ * openspec/changes/manifest-v2/proposal.md D9, well-known capability data
109
109
  * templates must not cross-reference other capabilities. The IDP-providing
110
110
  * module declares an explicit user-set `domain` field in its own manifest,
111
111
  * derives `auth_url` from `$self:domain`, and exposes it through
@@ -2,7 +2,7 @@
2
2
  * `celilo api ...` — manage remote-API principals (Slice 2a).
3
3
  *
4
4
  * grant / list / revoke operate on the api_principals table; authorized-keys
5
- * renders the forced-command file for the API account. See v2/API_COMMUNICATION.md.
5
+ * renders the forced-command file for the API account. See openspec/changes/replace-ssh-cli-api/proposal.md.
6
6
  */
7
7
 
8
8
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
@@ -15,7 +15,7 @@
15
15
  * celilo backup pull --storage <id> --module celilo-mgmt
16
16
  * celilo restore --from <printed-path> --force
17
17
  *
18
- * Part of P5 (v2/CELILO_MGMT_MIGRATION.md), apps/celilo/designs/P5_MIGRATION_E2E.md.
18
+ * Part of P5 (openspec/specs/management-server-backup/spec.md), apps/celilo/designs/P5_MIGRATION_E2E.md.
19
19
  */
20
20
 
21
21
  import { tmpdir } from 'node:os';
@@ -79,7 +79,7 @@ function snakeCaseKeys(obj: Record<string, unknown>): Record<string, unknown> {
79
79
 
80
80
  /**
81
81
  * `celilo events run-hook <module> <sub-name> [<event_id>]` — the generic
82
- * runner a `hook:` subscription resolves to (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md).
82
+ * runner a `hook:` subscription resolves to (openspec/specs/event-driven-hook-subscriptions/spec.md).
83
83
  *
84
84
  * The dispatcher spawns this as a fault-isolated subprocess. It re-reads the
85
85
  * module's manifest to find the named subscription's `hook` + `hook_inputs`,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `celilo module changeset [<module-dir>] --bump <major|minor|patch> [-m <msg>]`
3
- * — author a changeset for a module (ISS-0151 / v2/MODULE_VERSIONING.md).
3
+ * — author a changeset for a module (ISS-0151 / openspec/changes/module-version-semantics/proposal.md).
4
4
  *
5
5
  * Writes `.changeset/<name>.md` keyed by the MODULE id (Option A), the same
6
6
  * on-disk format `celilo module version` consumes. This is the thin authoring
@@ -57,7 +57,7 @@ Examples:
57
57
  */
58
58
  /**
59
59
  * Run the base-module-aspect approval flow after a module's primary
60
- * import succeeds. Per v2/CELILO_BASE.md D2:
60
+ * import succeeds. Per openspec/specs/base-module-aspects/spec.md D2:
61
61
  *
62
62
  * - Modules without a `base_module_aspect` block: no-op.
63
63
  * - Re-imports of a version that's already been approved with the
@@ -166,7 +166,7 @@ export async function publishOneModule(
166
166
  // Stale-check: src commits past the last manifest.yml commit. Only fires
167
167
  // when the dir is in git history; brand-new uncommitted modules pass.
168
168
  //
169
- // Skipped for changeset/pin modules (ISS-0151 / v2/MODULE_VERSIONING.md): a
169
+ // Skipped for changeset/pin modules (ISS-0151 / openspec/changes/module-version-semantics/proposal.md): a
170
170
  // changeset module's version is authored via .changeset/ + `celilo module
171
171
  // version` and ordered by +N, so source-after-manifest is normal, not drift;
172
172
  // a pin module's version is checked against its upstream resolver. The gate
@@ -24,6 +24,7 @@ import {
24
24
  } from '../../services/celilo-events';
25
25
  import { getContainerService, getServiceCredentials } from '../../services/container-service';
26
26
  import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
27
+ import { cleanupWebRoutesForModule } from '../../services/web-route-cleanup';
27
28
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
28
29
  import { log } from '../prompts';
29
30
  import type { CommandResult } from '../types';
@@ -204,6 +205,30 @@ async function performModuleRemove(
204
205
  }
205
206
  }
206
207
 
208
+ // Reclaim public_web routes BEFORE terraform destroy, so the provider host
209
+ // is still reachable for the /srv/www cleanup. Runs after on_uninstall so a
210
+ // module that tears its own routes down still wins (this then no-ops).
211
+ //
212
+ // Without this, removal deleted the rows via the FK cascade on `modules.id`
213
+ // and emitted nothing, so the provider kept serving a route for a module that
214
+ // no longer existed. See services/web-route-cleanup.ts.
215
+ try {
216
+ const routeCleanup = await cleanupWebRoutesForModule({ moduleId, db });
217
+ if (routeCleanup.routesRemoved > 0) {
218
+ const reclaimed =
219
+ routeCleanup.assetDirsRemoved.length > 0
220
+ ? ` and reclaimed ${routeCleanup.assetDirsRemoved.join(', ')}`
221
+ : '';
222
+ log.info(`Removed ${routeCleanup.routesRemoved} web route(s) for ${moduleId}${reclaimed}`);
223
+ }
224
+ for (const warning of routeCleanup.warnings) {
225
+ log.warn(warning);
226
+ }
227
+ } catch (error) {
228
+ const msg = error instanceof Error ? error.message : String(error);
229
+ log.warn(`Web route cleanup failed (continuing): ${msg}`);
230
+ }
231
+
207
232
  // Check if module has infrastructure that needs to be destroyed
208
233
  const infra = db
209
234
  .select()
@@ -301,7 +326,7 @@ async function performModuleRemove(
301
326
  }
302
327
  })();
303
328
 
304
- // Announce teardown on the bus (D5, v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md). A
329
+ // Announce teardown on the bus (D5, openspec/specs/event-driven-hook-subscriptions/spec.md). A
305
330
  // dns_internal provider's system.destroyed.* subscription runs its
306
331
  // on_system_event hook (op: deregister) to remove each host's records.
307
332
  if (removedSystems.length > 0) {
@@ -6,7 +6,7 @@
6
6
  * manifest.yml. The generated file exposes a `<ModuleName>Config`
7
7
  * interface that hook scripts import via `defineHook<ConfigT, ...>`.
8
8
  *
9
- * See `design/TECHNICAL_DESIGN_HOOK_API_V2.md` D2 for the design rationale.
9
+ * See `openspec/changes/hook-api-v2/proposal.md` D2 for the design rationale.
10
10
  *
11
11
  * Subcommands:
12
12
  * celilo module types generate <module-dir> — write types.d.ts
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `celilo module upgrade [name]` — the build-bus CD verb (ISS-0138) + the
3
- * registry-poll it drives (ISS-0139). v2/BUILD_BUS.md.
3
+ * registry-poll it drives (ISS-0139). openspec/changes/build-bus-poll-cd/proposal.md.
4
4
  *
5
5
  * module upgrade <name> one module: registry-latest → update → [backup,
6
6
  * posture-gated] → deploy → verify.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `celilo module version [<module-dir>]` — stamp a changeset-kind module's
3
- * payload version into manifest.yml (ISS-0151 / v2/MODULE_VERSIONING.md).
3
+ * payload version into manifest.yml (ISS-0151 / openspec/changes/module-version-semantics/proposal.md).
4
4
  *
5
5
  * Reads `.changeset/*.md` (keyed by the MODULE id — Option A), computes the next
6
6
  * `manifest.yml#version`, prepends a CHANGELOG entry, and deletes the consumed
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { NetworkZone } from '../../db/schema';
3
+ import { reachabilityHint } from './module-where';
4
+
5
+ describe('reachabilityHint (ce-jje — role-based, never a literal address)', () => {
6
+ test('internal is reachable directly from the LAN', () => {
7
+ expect(reachabilityHint('internal')).toContain('directly from the internal/home LAN');
8
+ });
9
+
10
+ test('external is a public zone reachable at its own address', () => {
11
+ expect(reachabilityHint('external')).toContain('public');
12
+ });
13
+
14
+ test('firewall-segmented zones route via the natIp DNAT', () => {
15
+ for (const zone of ['dmz', 'app', 'secure'] as NetworkZone[]) {
16
+ expect(reachabilityHint(zone)).toContain('natIp DNAT');
17
+ }
18
+ });
19
+
20
+ test('no hint hardcodes an IP address (CLAUDE.md network-model rule)', () => {
21
+ const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'external'];
22
+ for (const zone of zones) {
23
+ expect(reachabilityHint(zone)).not.toMatch(/\d+\.\d+\.\d+\.\d+/);
24
+ }
25
+ });
26
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Module where command — host discovery: which system(s) serve a module.
3
+ *
4
+ * Answers "which host is providing services for <module>?" without an ssh
5
+ * scavenger hunt (ce-jje). Data source: the deployed-systems layer
6
+ * (module_systems) for the addressable hosts, reconciled live against Proxmox
7
+ * for the real node. Reachability is a role-based hint derived from the zone —
8
+ * never a hardcoded address (see CLAUDE.md network-model rules).
9
+ */
10
+
11
+ import { eq } from 'drizzle-orm';
12
+ import { getDb } from '../../db/client';
13
+ import type { NetworkZone } from '../../db/schema';
14
+ import { modules } from '../../db/schema';
15
+ import { getModuleSystems } from '../../services/deployed-systems';
16
+ import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
17
+ import { getArg, hasFlag, validateRequiredArgs } from '../parser';
18
+ import type { CommandResult } from '../types';
19
+
20
+ /**
21
+ * Role-based reachability hint for a zone. Says HOW to reach a host by the
22
+ * zone's firewall posture, not a literal address (subnets are operator config).
23
+ */
24
+ export function reachabilityHint(zone: NetworkZone | string): string {
25
+ switch (zone) {
26
+ case 'internal':
27
+ return 'reachable directly from the internal/home LAN';
28
+ case 'external':
29
+ return 'public zone — reachable at its own address (cloud/VPS)';
30
+ case 'dmz':
31
+ case 'app':
32
+ case 'secure':
33
+ return `firewall-segmented (${zone}) — reach via the firewall's natIp DNAT, not routable directly from the LAN`;
34
+ default:
35
+ return `zone ${zone}`;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Handle module where command.
41
+ *
42
+ * Usage: celilo module where <id> [--json]
43
+ *
44
+ * @param args - Command arguments (module id)
45
+ * @param flags - Command flags (--json)
46
+ * @returns Command result
47
+ */
48
+ export async function handleModuleWhere(
49
+ args: string[],
50
+ flags: Record<string, string | boolean> = {},
51
+ ): Promise<CommandResult> {
52
+ const error = validateRequiredArgs(args, 1);
53
+ if (error) {
54
+ return {
55
+ success: false,
56
+ error: `${error}\n\nUsage: celilo module where <id> [--json]`,
57
+ };
58
+ }
59
+
60
+ const moduleId = getArg(args, 0);
61
+ if (!moduleId) {
62
+ return { success: false, error: 'Module ID is required' };
63
+ }
64
+
65
+ const db = getDb();
66
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
67
+
68
+ if (!module) {
69
+ // CI/build infra (the celilo-builder VM, Forgejo runners) is NOT a module
70
+ // and is not in module_systems — point the operator at where that topology
71
+ // actually lives rather than a bare "not found".
72
+ return {
73
+ success: false,
74
+ error: `Module not found: ${moduleId}\n\nHost discovery covers deployed modules only. CI/build infrastructure (the celilo-builder VM, Forgejo CI runners) is not tracked here — see openspec/specs/forgejo-runner/spec.md and openspec/changes/build-bus-poll-cd/proposal.md for that topology.`,
75
+ };
76
+ }
77
+
78
+ const systems = getModuleSystems(moduleId, db);
79
+
80
+ if (systems.length === 0) {
81
+ const message = `Module '${moduleId}' has no deployed systems (API-only module, or not yet deployed).`;
82
+ if (hasFlag(flags, 'json')) {
83
+ return {
84
+ success: true,
85
+ message: JSON.stringify({ module: moduleId, systems: [] }, null, 2),
86
+ rawOutput: true,
87
+ data: { module: moduleId, systems: [] },
88
+ };
89
+ }
90
+ return { success: true, message };
91
+ }
92
+
93
+ // Reconcile the real node live from Proxmox (never throws; degrades to
94
+ // "node unknown" on outage) — same live-placement source as module status.
95
+ const placements = await reconcilePlacement(systems);
96
+
97
+ const data = placements.map(({ system, resolution }) => ({
98
+ name: system.name,
99
+ hostname: system.hostname,
100
+ ipv4_address: system.ipv4_address,
101
+ zone: system.zone,
102
+ vmid: system.infrastructure.vmid ?? null,
103
+ infra_type: system.infrastructure.type,
104
+ placement: formatPlacementLine(system, resolution),
105
+ reachability: reachabilityHint(system.zone),
106
+ }));
107
+
108
+ if (hasFlag(flags, 'json')) {
109
+ return {
110
+ success: true,
111
+ message: JSON.stringify({ module: moduleId, systems: data }, null, 2),
112
+ rawOutput: true,
113
+ data: { module: moduleId, systems: data },
114
+ };
115
+ }
116
+
117
+ const lines = [`Module '${moduleId}' is served by:`, ''];
118
+ for (const sys of data) {
119
+ lines.push(` ${sys.hostname} (${sys.ipv4_address})`);
120
+ lines.push(` placement: ${sys.placement}`);
121
+ lines.push(` reachability: ${sys.reachability}`);
122
+ lines.push('');
123
+ }
124
+
125
+ return {
126
+ success: true,
127
+ message: lines.join('\n').trimEnd(),
128
+ data: { module: moduleId, systems: data },
129
+ };
130
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `celilo proxmox node list` — live per-node capacity from the Proxmox cluster
3
- * (ISS-0060, Phase 1 of v2/PROXMOX_CAPACITY_AND_LIFECYCLE.md).
3
+ * (ISS-0060, Phase 1 of openspec/changes/proxmox-capacity-lifecycle/proposal.md).
4
4
  *
5
5
  * Reads reality from the Proxmox API via `ProxmoxClient`, never a cached DB
6
6
  * value — the foundation for capacity-aware placement (ISS-0061) and
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Alpha-helper tests — v2/PUBLILO_CLI.md Phase 3 payoff.
2
+ * Alpha-helper tests — openspec/changes/publilo-cli/proposal.md Phase 3 payoff.
3
3
  *
4
4
  * The pure functions in alpha.ts (parsePackageSpec, stripAlphaSuffix,
5
5
  * isAlphaVersion, pickNextAlphaN, decideAlphaSkip) need no I/O to
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Alpha-tagging logic — `--alpha` publish + `--promote` graduation.
3
3
  *
4
- * The semantics live in `v2/FIRST_PUBLILO_ALPHA_TAGGING.md`; this
4
+ * The semantics live in `openspec/specs/alpha-publish-tagging/spec.md`; this
5
5
  * module is the implementation surface those flags drive. Per
6
- * v2/PUBLILO_CLI.md Phase 3, the alpha-specific helpers live here
6
+ * openspec/changes/publilo-cli/proposal.md Phase 3, the alpha-specific helpers live here
7
7
  * (not in helpers.ts) so it's clear where to look when extending the
8
- * model — e.g. to add `--beta` once `v2/BUILD_BUS.md`'s validation
8
+ * model — e.g. to add `--beta` once `openspec/changes/build-bus-poll-cd/proposal.md`'s validation
9
9
  * signal lands.
10
10
  *
11
11
  * Two-layer design for testability:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @changesets/cli integration — v2/PUBLILO_CLI.md Phase 4.
2
+ * @changesets/cli integration — openspec/changes/publilo-cli/proposal.md Phase 4.
3
3
  *
4
4
  * Changesets is the "what versions does this publish produce" piece
5
5
  * of the workflow. During dev, the operator runs `bun changeset` to
@@ -17,7 +17,7 @@
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { existsSync, readFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
- import { PACKAGES, readGlobalInstalledVersion, readPkg } from './helpers';
20
+ import { getPublishPackages, readGlobalInstalledVersion, readPkg } from './helpers';
21
21
  import type { GlobalUpdateItem, PackageJson } from './types';
22
22
 
23
23
  // ─── Planner ───────────────────────────────────────────────────────
@@ -97,7 +97,11 @@ export function planGlobalUpdate(input: PlanGlobalUpdateInput): GlobalUpdateItem
97
97
  return [];
98
98
  }
99
99
 
100
- const ourNames = new Set(PACKAGES.map((p) => readPkg(p).name).filter((n): n is string => !!n));
100
+ const ourNames = new Set(
101
+ getPublishPackages()
102
+ .map((p) => readPkg(p).name)
103
+ .filter((n): n is string => !!n),
104
+ );
101
105
  const installed = Object.keys(globalPkg.dependencies ?? {}).filter((n) => ourNames.has(n));
102
106
 
103
107
  return decideGlobalUpdates({