@celilo/cli 0.11.0 → 0.12.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.
Files changed (137) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +52 -6
  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 +13 -5
  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/api/protocol.test.ts +1 -1
  13. package/src/api/remote-client.test.ts +1 -1
  14. package/src/api/serve.ts +5 -5
  15. package/src/capabilities/public-web-publish.test.ts +209 -0
  16. package/src/capabilities/validation.test.ts +1 -1
  17. package/src/capabilities/well-known.test.ts +1 -1
  18. package/src/capabilities/well-known.ts +2 -2
  19. package/src/cli/command-tree-parser.ts +3 -1
  20. package/src/cli/commands/api.ts +1 -1
  21. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  22. package/src/cli/commands/apt-upgrade.ts +63 -0
  23. package/src/cli/commands/backup-pull.ts +1 -1
  24. package/src/cli/commands/commands-json.ts +29 -0
  25. package/src/cli/commands/completion.ts +1 -1
  26. package/src/cli/commands/events.ts +1 -1
  27. package/src/cli/commands/module-changeset.ts +1 -1
  28. package/src/cli/commands/module-import.ts +1 -1
  29. package/src/cli/commands/module-list.ts +16 -2
  30. package/src/cli/commands/module-publish.ts +1 -1
  31. package/src/cli/commands/module-remove.ts +26 -1
  32. package/src/cli/commands/module-types.ts +1 -1
  33. package/src/cli/commands/module-upgrade.ts +1 -1
  34. package/src/cli/commands/module-version.ts +1 -1
  35. package/src/cli/commands/module-where.test.ts +26 -0
  36. package/src/cli/commands/module-where.ts +130 -0
  37. package/src/cli/commands/proxmox-node-list.ts +1 -1
  38. package/src/cli/commands/publish/alpha.test.ts +1 -1
  39. package/src/cli/commands/publish/alpha.ts +3 -3
  40. package/src/cli/commands/publish/changesets.ts +1 -1
  41. package/src/cli/commands/publish/global-install.ts +6 -2
  42. package/src/cli/commands/publish/helpers.ts +21 -14
  43. package/src/cli/commands/publish/index.ts +4 -4
  44. package/src/cli/commands/publish/plan.ts +3 -3
  45. package/src/cli/commands/publish/preflight.ts +2 -2
  46. package/src/cli/commands/publish/types.ts +2 -2
  47. package/src/cli/commands/publish/workspace.test.ts +1 -1
  48. package/src/cli/commands/publish/workspace.ts +2 -2
  49. package/src/cli/commands/registry-owner.test.ts +166 -0
  50. package/src/cli/commands/registry-owner.ts +124 -0
  51. package/src/cli/commands/registry-token.test.ts +109 -0
  52. package/src/cli/commands/registry-token.ts +194 -0
  53. package/src/cli/commands/restore.ts +1 -1
  54. package/src/cli/commands/service-add-proxmox.ts +1 -1
  55. package/src/cli/commands/service-list.ts +15 -2
  56. package/src/cli/commands/subscribers-list.ts +1 -1
  57. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  58. package/src/cli/commands/system-apply-config.ts +1 -1
  59. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  60. package/src/cli/commands/system-init.ts +2 -2
  61. package/src/cli/commands/token.test.ts +26 -0
  62. package/src/cli/commands/token.ts +160 -0
  63. package/src/cli/completion.ts +21 -0
  64. package/src/cli/generate-zsh-completion.test.ts +22 -4
  65. package/src/cli/generate-zsh-completion.ts +7 -3
  66. package/src/cli/index.ts +170 -7
  67. package/src/cli/parser.ts +1 -1
  68. package/src/cli/restore-command.test.ts +1 -1
  69. package/src/db/client.ts +1 -1
  70. package/src/db/schema.ts +50 -7
  71. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  72. package/src/hooks/capability-loader.ts +100 -10
  73. package/src/hooks/define-hook.test.ts +24 -0
  74. package/src/hooks/executor.test.ts +1 -1
  75. package/src/hooks/executor.ts +2 -2
  76. package/src/hooks/types.ts +1 -1
  77. package/src/manifest/contracts/v1.ts +2 -2
  78. package/src/manifest/schema.ts +13 -13
  79. package/src/manifest/template-validator.ts +1 -1
  80. package/src/module/packaging/build.ts +19 -0
  81. package/src/module/packaging/workspace-deps.test.ts +94 -0
  82. package/src/module/packaging/workspace-deps.ts +185 -0
  83. package/src/module/versioning/changeset-version.ts +1 -1
  84. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  85. package/src/registry/client.test.ts +86 -0
  86. package/src/registry/client.ts +67 -1
  87. package/src/services/api-access.ts +1 -1
  88. package/src/services/aspect-approvals.ts +1 -1
  89. package/src/services/aspect-runner.ts +3 -3
  90. package/src/services/aspect-template-resolver.test.ts +1 -1
  91. package/src/services/aspect-template-resolver.ts +1 -1
  92. package/src/services/build-bus/delivery-events.ts +1 -1
  93. package/src/services/build-bus/fan-out.ts +1 -1
  94. package/src/services/build-bus/hook-dispatch.ts +1 -1
  95. package/src/services/build-bus/receiver-server.ts +1 -1
  96. package/src/services/build-bus/status.test.ts +1 -1
  97. package/src/services/build-bus/status.ts +1 -1
  98. package/src/services/build-bus/subscriber-store.ts +1 -1
  99. package/src/services/bus-interview.ts +1 -1
  100. package/src/services/celilo-events.ts +1 -1
  101. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  102. package/src/services/cross-module-read.ts +1 -1
  103. package/src/services/deploy-posture.ts +1 -1
  104. package/src/services/deployed-systems.test.ts +1 -1
  105. package/src/services/deployed-systems.ts +4 -4
  106. package/src/services/dns-provider-backfill.ts +2 -2
  107. package/src/services/events-daemon.ts +1 -1
  108. package/src/services/machine-pool.ts +3 -3
  109. package/src/services/module-deploy.ts +93 -8
  110. package/src/services/module-subscriptions.ts +1 -1
  111. package/src/services/module-types-generator.ts +1 -1
  112. package/src/services/module-validator/git-hygiene.ts +1 -1
  113. package/src/services/port-forwards.test.ts +92 -0
  114. package/src/services/port-forwards.ts +86 -0
  115. package/src/services/proxmox-reconcile.ts +2 -2
  116. package/src/services/public-web-republish.test.ts +189 -0
  117. package/src/services/public-web-republish.ts +84 -0
  118. package/src/services/restore-from-file.ts +1 -1
  119. package/src/services/restore-preflight.ts +1 -1
  120. package/src/services/system-identity.ts +1 -1
  121. package/src/services/system-init.test.ts +2 -2
  122. package/src/services/system-init.ts +2 -2
  123. package/src/services/web-route-cleanup.test.ts +250 -0
  124. package/src/services/web-route-cleanup.ts +144 -0
  125. package/src/templates/generator.ts +2 -2
  126. package/src/types/infrastructure.ts +1 -1
  127. package/src/variables/computed/computed-integration.test.ts +1 -1
  128. package/src/variables/computed/evaluate.ts +1 -1
  129. package/src/variables/computed/parse.ts +1 -1
  130. package/src/variables/context.test.ts +1 -1
  131. package/src/variables/context.ts +5 -5
  132. package/src/variables/lxc-nameserver.test.ts +1 -1
  133. package/src/variables/resolver.ts +2 -2
  134. package/src/variables/types.ts +1 -1
  135. package/src/api/protocol.ts +0 -159
  136. package/src/api/remote-client.ts +0 -218
  137. package/src/cli/command-registry.ts +0 -1488
@@ -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
@@ -177,7 +177,9 @@ export class CommandTreeParser {
177
177
  'usage',
178
178
  'options',
179
179
  'examples',
180
- 'commands',
180
+ // NB: 'commands' is intentionally NOT excluded — it's a real command
181
+ // (`celilo commands`). The "Commands:" section header is filtered earlier
182
+ // by the section-header regex, so it never reaches this check.
181
183
  'description',
182
184
  'example',
183
185
  'for',
@@ -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';
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { handleAptUpgrade } from './apt-upgrade';
3
+
4
+ describe('handleAptUpgrade', () => {
5
+ test('runs the three steps in order when each succeeds', async () => {
6
+ const seen: string[][] = [];
7
+ const result = await handleAptUpgrade([], {}, (argv) => {
8
+ seen.push(argv);
9
+ return { status: 0 };
10
+ });
11
+
12
+ expect(result.success).toBe(true);
13
+ expect(seen).toEqual([
14
+ ['sudo', 'apt-get', 'update'],
15
+ ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
16
+ ['/usr/local/bin/celilo', 'system', 'migrate'],
17
+ ]);
18
+ });
19
+
20
+ test('stops at the first failing step and does not run later ones', async () => {
21
+ const seen: string[][] = [];
22
+ const result = await handleAptUpgrade([], {}, (argv) => {
23
+ seen.push(argv);
24
+ // Fail the apt upgrade step (index 1).
25
+ return { status: argv.includes('install') ? 100 : 0 };
26
+ });
27
+
28
+ expect(result.success).toBe(false);
29
+ if (!result.success) expect(result.error).toContain('apt-get upgrade');
30
+ // update + install ran; migrate did NOT.
31
+ expect(seen).toHaveLength(2);
32
+ });
33
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `celilo apt-upgrade` — upgrade the deb-installed celilo packages and apply
3
+ * pending DB migrations. The management server (celilo-bootstrap) installs
4
+ * celilo via apt, so keeping it current means the apt chain, not `bun update -g`
5
+ * (that path is `system update`'s self-update, for npm-global installs).
6
+ *
7
+ * Steps (ISS-0100 — the postinst does NOT auto-apply migrations):
8
+ * 1. apt-get update
9
+ * 2. apt-get -y --only-upgrade install celilo celilo-bootstrap
10
+ * 3. a FRESH `celilo system migrate` — spawned as the just-installed binary so
11
+ * the new version's migrations run, not the ones loaded in this process.
12
+ *
13
+ * This is the RW target behind the MCP's `celilo_apt_upgrade` tool. It runs as
14
+ * the celilo user (via api-serve); the apt steps sudo to root, gated by the
15
+ * scoped /etc/sudoers.d/celilo-apt-upgrade grant that celilo-bootstrap ships.
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import type { CommandResult } from '../types';
20
+
21
+ /** Wrapper the deb installs; the fresh migrate step runs the upgraded binary. */
22
+ const CELILO_BIN = '/usr/local/bin/celilo';
23
+
24
+ /** One command to run in the chain — argv plus a human label for output. */
25
+ interface Step {
26
+ label: string;
27
+ argv: string[];
28
+ }
29
+
30
+ const STEPS: Step[] = [
31
+ { label: 'apt-get update', argv: ['sudo', 'apt-get', 'update'] },
32
+ {
33
+ label: 'apt-get upgrade celilo, celilo-bootstrap',
34
+ argv: ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
35
+ },
36
+ { label: 'apply DB migrations', argv: [CELILO_BIN, 'system', 'migrate'] },
37
+ ];
38
+
39
+ /** Run one argv, inheriting stdio so its output streams through api-serve. */
40
+ export type StepRunner = (argv: string[]) => { status: number | null };
41
+
42
+ const defaultRunner: StepRunner = (argv) => spawnSync(argv[0], argv.slice(1), { stdio: 'inherit' });
43
+
44
+ export async function handleAptUpgrade(
45
+ _args: string[],
46
+ _flags: Record<string, string | boolean>,
47
+ runStep: StepRunner = defaultRunner,
48
+ ): Promise<CommandResult> {
49
+ for (const step of STEPS) {
50
+ process.stdout.write(`\n▸ ${step.label}\n`);
51
+ const { status } = runStep(step.argv);
52
+ if (status !== 0) {
53
+ return {
54
+ success: false,
55
+ error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Nothing further was run.`,
56
+ };
57
+ }
58
+ }
59
+ return {
60
+ success: true,
61
+ message: 'celilo apt packages upgraded and migrations applied.',
62
+ };
63
+ }
@@ -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';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Commands Command
3
+ *
4
+ * Serializes the CLI command registry (`COMMANDS`) as JSON so an external
5
+ * consumer — `@celilo/mcp` above all — can fetch the *live* surface of whatever
6
+ * celilo version this server runs and generate its tool set from it, rather than
7
+ * compiling a copy that drifts (design D3). The registry is already structured
8
+ * data; this just prints it.
9
+ */
10
+
11
+ import { COMMANDS } from '@celilo/core';
12
+ import type { CommandResult } from '../types';
13
+
14
+ /**
15
+ * Handle `celilo commands [--json]`.
16
+ *
17
+ * The only useful output is the JSON tree, so we emit it whether or not `--json`
18
+ * is passed; the flag exists to make the intent explicit and completable.
19
+ */
20
+ export async function handleCommands(
21
+ _args: string[],
22
+ _flags: Record<string, boolean | string> = {},
23
+ ): Promise<CommandResult> {
24
+ return {
25
+ success: true,
26
+ message: JSON.stringify(COMMANDS),
27
+ rawOutput: true,
28
+ };
29
+ }
@@ -3,7 +3,7 @@
3
3
  * Generate shell completion scripts for bash/zsh
4
4
  */
5
5
 
6
- import { COMMANDS } from '../command-registry';
6
+ import { COMMANDS } from '@celilo/core';
7
7
  import { generateBashCompletion, generateFishCompletion } from '../completion';
8
8
  import { generateRichZshCompletion } from '../generate-zsh-completion';
9
9
  import { celiloIntro } from '../prompts';
@@ -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
@@ -1,20 +1,34 @@
1
1
  import { getDb } from '../../db/client';
2
2
  import { modules } from '../../db/schema';
3
+ import { hasFlag } from '../parser';
3
4
  import type { CommandResult } from '../types';
4
5
 
5
6
  /**
6
7
  * Handle module list command
7
8
  *
8
- * Usage: celilo module list
9
+ * Usage: celilo module list [--json]
9
10
  *
10
11
  * @returns Command result
11
12
  */
12
- export async function handleModuleList(): Promise<CommandResult> {
13
+ export async function handleModuleList(
14
+ flags: Record<string, string | boolean> = {},
15
+ ): Promise<CommandResult> {
13
16
  const db = getDb();
14
17
 
15
18
  // Query all modules
16
19
  const moduleRows = db.select().from(modules).all();
17
20
 
21
+ // Stable machine-readable roster — the backbone the MCP composite
22
+ // troubleshooting tools correlate audit findings against (ce-77i.5).
23
+ if (hasFlag(flags, 'json')) {
24
+ return {
25
+ success: true,
26
+ message: JSON.stringify({ modules: moduleRows }, null, 2),
27
+ rawOutput: true,
28
+ data: moduleRows,
29
+ };
30
+ }
31
+
18
32
  if (moduleRows.length === 0) {
19
33
  return {
20
34
  success: true,
@@ -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
+ });