@celilo/cli 0.3.30 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +6 -5
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -7,14 +7,22 @@ import { existsSync } from 'node:fs';
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { getDb } from '../../db/client';
10
+ import type { NetworkZone } from '../../db/schema';
10
11
  import {
11
12
  detectMachineInfo,
13
+ detectMachineInfoLocal,
12
14
  detectNetworkInterfaces,
15
+ detectNetworkInterfacesLocal,
13
16
  testSshConnection,
14
17
  } from '../../services/machine-detector';
15
18
  import { addMachine, getMachineByIp } from '../../services/machine-pool';
16
19
  import { loadExistingConfiguration } from '../../services/system-init';
17
20
  import { detectZoneFromIp } from '../../services/zone-detector';
21
+ import type {
22
+ DetectedMachineInfo,
23
+ MachineRole,
24
+ NetworkInterface,
25
+ } from '../../types/infrastructure';
18
26
  import { celiloIntro, celiloOutro, promptText } from '../prompts';
19
27
  import type { CommandResult } from '../types';
20
28
  import { validateIpAddress, validateRequired } from '../validators';
@@ -95,7 +103,6 @@ export async function handleMachineAdd(
95
103
  // Hybrid mode: use flags for what's provided, prompt for what's missing
96
104
  let ipAddress: string;
97
105
  let sshUser: string;
98
- let sshKeyPath: string;
99
106
 
100
107
  // IP address: from positional arg, --ip flag, or prompt
101
108
  if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) {
@@ -118,8 +125,21 @@ export async function handleMachineAdd(
118
125
  };
119
126
  }
120
127
 
121
- // SSH user: from flag, default to 'root', or prompt
122
- if (typeof flags['ssh-user'] === 'string') {
128
+ // The local management box (127.0.0.1, or explicit --local) deploys
129
+ // over Ansible's local connection — no SSH, no key, no connectivity
130
+ // test. Determine this BEFORE the SSH-user step so the local path
131
+ // never prompts (it would hang a non-interactive bootstrap postinst).
132
+ const isLocal = ipAddress === '127.0.0.1' || flags.local === true;
133
+ // An explicit --zone overrides inference (needed for the local box,
134
+ // whose 127.0.0.1 matches no zone subnet, and useful before a firewall
135
+ // has provided the target zone).
136
+ const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined;
137
+
138
+ // SSH user: irrelevant for a local machine (local connection); else
139
+ // from flag, default to 'root', or prompt.
140
+ if (isLocal) {
141
+ sshUser = 'root';
142
+ } else if (typeof flags['ssh-user'] === 'string') {
123
143
  sshUser = flags['ssh-user'];
124
144
  } else {
125
145
  sshUser = await promptText({
@@ -130,76 +150,100 @@ export async function handleMachineAdd(
130
150
  });
131
151
  }
132
152
 
133
- // SSH key: from flag, auto-detect, or error
134
- if (typeof flags['ssh-key-file'] === 'string') {
135
- sshKeyPath = flags['ssh-key-file'];
136
- const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
137
- if (!existsSync(expandedPath)) {
138
- return { success: false, error: `SSH key file not found: ${expandedPath}` };
139
- }
153
+ let detectedInfo: DetectedMachineInfo;
154
+ let zone: NetworkZone;
155
+ let interfaces: NetworkInterface[];
156
+ let role: MachineRole;
157
+ let sshKey: string;
158
+
159
+ if (isLocal) {
160
+ console.log('\nLocal machine — detecting locally (no SSH)...');
161
+ detectedInfo = await detectMachineInfoLocal();
162
+ const net = await detectNetworkInterfacesLocal();
163
+ interfaces = net.interfaces;
164
+ role = net.role;
165
+ // The box you're installing on is, by definition, on the internal LAN.
166
+ zone = zoneOverride ?? 'internal';
167
+ sshKey = ''; // local connection — no key needed
168
+ console.log(
169
+ `✓ Local: ${detectedInfo.hostname} — ${detectedInfo.hardware.cpu_cores} cores, ` +
170
+ `${detectedInfo.hardware.memory_mb} MB, ${detectedInfo.hardware.disk_gb} GB (zone ${zone}, connection local)\n`,
171
+ );
140
172
  } else {
141
- // Auto-detect SSH key from system config
142
- const detectedKeyPath = findSshPrivateKey();
143
- if (!detectedKeyPath) {
144
- return {
145
- success: false,
146
- error:
147
- 'Cannot find SSH private key.\n\n' +
148
- 'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
149
- 'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
150
- };
173
+ // SSH key: from flag, auto-detect, or error
174
+ let sshKeyPath: string;
175
+ if (typeof flags['ssh-key-file'] === 'string') {
176
+ sshKeyPath = flags['ssh-key-file'];
177
+ const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
178
+ if (!existsSync(expandedPath)) {
179
+ return { success: false, error: `SSH key file not found: ${expandedPath}` };
180
+ }
181
+ } else {
182
+ // Auto-detect SSH key from system config
183
+ const detectedKeyPath = findSshPrivateKey();
184
+ if (!detectedKeyPath) {
185
+ return {
186
+ success: false,
187
+ error:
188
+ 'Cannot find SSH private key.\n\n' +
189
+ 'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
190
+ 'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
191
+ };
192
+ }
193
+ sshKeyPath = detectedKeyPath;
194
+ console.log(`Using SSH key: ${sshKeyPath}`);
151
195
  }
152
- sshKeyPath = detectedKeyPath;
153
- console.log(`Using SSH key: ${sshKeyPath}`);
154
- }
155
196
 
156
- // Expand tilde in path
157
- const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
197
+ // Expand tilde in path
198
+ const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
158
199
 
159
- // Read SSH key content
160
- const sshKey = readFileSync(expandedKeyPath, 'utf8');
200
+ // Read SSH key content
201
+ sshKey = readFileSync(expandedKeyPath, 'utf8');
161
202
 
162
- console.log('\nTesting SSH connection...');
203
+ console.log('\nTesting SSH connection...');
163
204
 
164
- // Test SSH connectivity
165
- const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath);
166
- if (!canConnect) {
167
- return {
168
- success: false,
169
- error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`,
170
- };
171
- }
205
+ // Test SSH connectivity
206
+ const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath);
207
+ if (!canConnect) {
208
+ return {
209
+ success: false,
210
+ error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`,
211
+ };
212
+ }
172
213
 
173
- console.log('✓ SSH connection successful\n');
214
+ console.log('✓ SSH connection successful\n');
174
215
 
175
- console.log('Detecting machine information...');
216
+ console.log('Detecting machine information...');
176
217
 
177
- // Auto-detect machine info
178
- const detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath);
218
+ // Auto-detect machine info
219
+ detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath);
179
220
 
180
- console.log('✓ Machine detected:');
181
- console.log(` Hostname: ${detectedInfo.hostname}`);
182
- console.log(` OS: ${detectedInfo.osInfo}`);
183
- console.log(
184
- ` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`,
185
- );
186
- console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
187
- console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
221
+ console.log('✓ Machine detected:');
222
+ console.log(` Hostname: ${detectedInfo.hostname}`);
223
+ console.log(` OS: ${detectedInfo.osInfo}`);
224
+ console.log(
225
+ ` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`,
226
+ );
227
+ console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
228
+ console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
188
229
 
189
- // Auto-detect zone from IP
190
- console.log('Detecting network zone...');
191
- const zone = await detectZoneFromIp(ipAddress);
192
- console.log(`✓ Zone: ${zone}\n`);
230
+ // Zone: explicit override, else infer from IP.
231
+ console.log('Detecting network zone...');
232
+ zone = zoneOverride ?? (await detectZoneFromIp(ipAddress));
233
+ console.log(`✓ Zone: ${zone}\n`);
193
234
 
194
- // Detect network interfaces and classify machine
195
- console.log('Detecting network interfaces...');
196
- const { interfaces, role } = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
235
+ // Detect network interfaces and classify machine
236
+ console.log('Detecting network interfaces...');
237
+ const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
238
+ interfaces = net.interfaces;
239
+ role = net.role;
197
240
 
198
- console.log(`✓ Role: ${role}`);
199
- for (const iface of interfaces) {
200
- console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
241
+ console.log(`✓ Role: ${role}`);
242
+ for (const iface of interfaces) {
243
+ console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
244
+ }
245
+ console.log('');
201
246
  }
202
- console.log('');
203
247
 
204
248
  // Add machine to pool
205
249
  const earmark = typeof flags.earmark === 'string' ? flags.earmark : undefined;
@@ -18,14 +18,23 @@
18
18
  */
19
19
 
20
20
  import { existsSync } from 'node:fs';
21
- import { unlink } from 'node:fs/promises';
21
+ import { rm, unlink } from 'node:fs/promises';
22
22
  import { tmpdir } from 'node:os';
23
23
  import { join, resolve } from 'node:path';
24
+ import { eq } from 'drizzle-orm';
24
25
  import { getModuleStoragePath, shortenPath } from '../../config/paths';
25
26
  import { getDb } from '../../db/client';
27
+ import { modules } from '../../db/schema';
28
+ import type { ModuleManifest } from '../../manifest/schema';
26
29
  import { importModule } from '../../module/import';
27
30
  import { RegistryClient } from '../../registry/client';
28
- import { getArg, getFlag } from '../parser';
31
+ import {
32
+ checkAspectApproval,
33
+ computeAspectScopeHash,
34
+ recordAspectApproval,
35
+ } from '../../services/aspect-approvals';
36
+ import { getArg, getFlag, hasFlag } from '../parser';
37
+ import { promptConfirm } from '../prompts';
29
38
  import type { CommandResult } from '../types';
30
39
  import { generateTypesForImportedModule } from './module-types';
31
40
 
@@ -46,6 +55,124 @@ Examples:
46
55
  *
47
56
  * Exposed for tests; not exported from the package.
48
57
  */
58
+ /**
59
+ * Run the base-module-aspect approval flow after a module's primary
60
+ * import succeeds. Per v2/CELILO_BASE.md D2:
61
+ *
62
+ * - Modules without a `base_module_aspect` block: no-op.
63
+ * - Re-imports of a version that's already been approved with the
64
+ * same scope: no-op (don't re-prompt).
65
+ * - First-time import (or new version) with a declared aspect:
66
+ * show scope, prompt unless `--accept-aspects` is passed.
67
+ * - Operator declines: roll back the import (DB row + on-disk
68
+ * source dir) so re-running is a clean re-do.
69
+ *
70
+ * Returns `{ approved: true, message?: string }` on success, or
71
+ * `{ approved: false, error: string }` on decline or non-interactive
72
+ * mismatch. The caller is responsible for surfacing the error.
73
+ */
74
+ async function handleAspectApprovalAfterImport(args: {
75
+ moduleId: string;
76
+ targetPath: string;
77
+ flags: Record<string, string | boolean>;
78
+ db: ReturnType<typeof getDb>;
79
+ }): Promise<{ approved: true; message?: string } | { approved: false; error: string }> {
80
+ const { moduleId, targetPath, flags, db } = args;
81
+
82
+ const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get();
83
+ if (!moduleRow) {
84
+ // Shouldn't happen — importModule just succeeded — but fail
85
+ // loudly rather than silently no-op.
86
+ return {
87
+ approved: false,
88
+ error: `Imported module '${moduleId}' missing from DB after import.`,
89
+ };
90
+ }
91
+
92
+ const manifest = moduleRow.manifestData as ModuleManifest;
93
+ const aspect = manifest.base_module_aspect;
94
+ if (!aspect) {
95
+ // No aspect declared — nothing to approve.
96
+ return { approved: true };
97
+ }
98
+
99
+ const status = checkAspectApproval(moduleId, moduleRow.version, aspect, db);
100
+ if (status === 'approved') {
101
+ // Same version + same scope already approved (re-import of a
102
+ // previously-accepted version). Skip the prompt silently.
103
+ return { approved: true };
104
+ }
105
+
106
+ const acceptViaFlag = hasFlag(flags, 'accept-aspects');
107
+ const approver = process.env.USER ?? null;
108
+
109
+ if (acceptViaFlag) {
110
+ recordAspectApproval({
111
+ moduleId,
112
+ version: moduleRow.version,
113
+ scopeHash: computeAspectScopeHash(aspect),
114
+ approver,
115
+ db,
116
+ });
117
+ return {
118
+ approved: true,
119
+ message: `Base-module aspect approved via --accept-aspects (zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')})`,
120
+ };
121
+ }
122
+
123
+ // Interactive prompt path. Display the scope clearly so the
124
+ // operator's consent is informed.
125
+ const scopeMsg = [
126
+ `Module '${moduleId}' declares a base-module aspect:`,
127
+ ` Ansible role: ${aspect.ansible_role}`,
128
+ ` Target zones: ${aspect.applicable_zones.join(', ')}`,
129
+ ` Triggers: ${aspect.triggers.join(', ')}`,
130
+ '',
131
+ `This means: when this module's primary deploys (or one of the listed triggers fires),`,
132
+ `celilo will run the '${aspect.ansible_role}' Ansible role on every non-api_only system`,
133
+ 'in those zones.',
134
+ ].join('\n');
135
+ // Use process.stderr.write rather than the prompt UI so the
136
+ // multi-line scope block is preserved verbatim — @clack/prompts
137
+ // mangles multi-line message arguments.
138
+ process.stderr.write(`\n${scopeMsg}\n\n`);
139
+
140
+ const accepted = await promptConfirm({
141
+ message: 'Approve this base-module aspect?',
142
+ initialValue: false,
143
+ });
144
+
145
+ if (!accepted) {
146
+ // Roll back the import — DB delete cascades to configs/secrets/etc.
147
+ // We also rm the source dir so a re-import doesn't trip on the
148
+ // existing files.
149
+ db.delete(modules).where(eq(modules.id, moduleId)).run();
150
+ try {
151
+ await rm(targetPath, { recursive: true, force: true });
152
+ } catch {
153
+ // Best-effort cleanup; if rm fails the operator can clean up
154
+ // manually. Don't mask the original "approval declined" error.
155
+ }
156
+ return {
157
+ approved: false,
158
+ error:
159
+ 'Aspect approval declined; import rolled back. Re-run with --accept-aspects to skip the prompt.',
160
+ };
161
+ }
162
+
163
+ recordAspectApproval({
164
+ moduleId,
165
+ version: moduleRow.version,
166
+ scopeHash: computeAspectScopeHash(aspect),
167
+ approver,
168
+ db,
169
+ });
170
+ return {
171
+ approved: true,
172
+ message: `Base-module aspect approved (zones: ${aspect.applicable_zones.join(', ')}; triggers: ${aspect.triggers.join(', ')})`,
173
+ };
174
+ }
175
+
49
176
  export function classifyImportArg(arg: string): 'path' | 'name' {
50
177
  if (arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../')) return 'path';
51
178
  if (arg.startsWith('~/') || arg === '~') return 'path';
@@ -142,12 +269,23 @@ async function handleFileImport(
142
269
  };
143
270
  }
144
271
 
272
+ const aspectOutcome = await handleAspectApprovalAfterImport({
273
+ moduleId: result.moduleId,
274
+ targetPath: result.targetPath,
275
+ flags,
276
+ db,
277
+ });
278
+ if (!aspectOutcome.approved) {
279
+ return { success: false, error: aspectOutcome.error };
280
+ }
281
+
145
282
  const typesPath = await generateTypesForImportedModule(result.targetPath);
146
283
  const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : '';
284
+ const aspectMessage = aspectOutcome.message ? `\n${aspectOutcome.message}` : '';
147
285
 
148
286
  return {
149
287
  success: true,
150
- message: `Successfully imported module: ${result.moduleId}\nFiles copied to: ${shortenPath(result.targetPath)}${typesMessage}`,
288
+ message: `Successfully imported module: ${result.moduleId}\nFiles copied to: ${shortenPath(result.targetPath)}${typesMessage}${aspectMessage}`,
151
289
  data: {
152
290
  moduleId: result.moduleId,
153
291
  targetPath: result.targetPath,
@@ -212,13 +350,24 @@ async function handlePublicRegistryImport(
212
350
  return { success: false, error: result.error, details: result.details };
213
351
  }
214
352
 
353
+ const aspectOutcome = await handleAspectApprovalAfterImport({
354
+ moduleId: result.moduleId,
355
+ targetPath: result.targetPath,
356
+ flags,
357
+ db,
358
+ });
359
+ if (!aspectOutcome.approved) {
360
+ return { success: false, error: aspectOutcome.error };
361
+ }
362
+ const aspectMessage = aspectOutcome.message ? `\n${aspectOutcome.message}` : '';
363
+
215
364
  // Type-generation is a developer-mode aid for module AUTHORS editing
216
365
  // hook scripts in the source tree. Registry installs are black-box
217
366
  // dependencies — the operator isn't editing them — so skipping the
218
367
  // type-gen pass saves the work and keeps the install output clean.
219
368
  return {
220
369
  success: true,
221
- message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}`,
370
+ message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}${aspectMessage}`,
222
371
  data: {
223
372
  moduleId: result.moduleId,
224
373
  version: latest.vers,
@@ -16,7 +16,13 @@ import { runNamedHook } from '../../hooks/run-named-hook';
16
16
  import { deallocateForModule } from '../../ipam/auto-allocator';
17
17
  import { ModuleManifestSchema } from '../../manifest/schema';
18
18
  import { executeBuildWithProgress } from '../../services/build-stream';
19
+ import {
20
+ emitUninstallCompleted,
21
+ emitUninstallFailed,
22
+ emitUninstallStarted,
23
+ } from '../../services/celilo-events';
19
24
  import { getContainerService, getServiceCredentials } from '../../services/container-service';
25
+ import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
20
26
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
21
27
  import { log, promptConfirm } from '../prompts';
22
28
  import type { CommandResult } from '../types';
@@ -94,6 +100,57 @@ export async function handleModuleRemove(
94
100
  }
95
101
  }
96
102
 
103
+ // Pre-flight passed; we're committed to attempting the uninstall. Start
104
+ // operation tracking + emit lifecycle events from here on so backups and
105
+ // restores see the uninstall as in-flight and downstream subscribers can
106
+ // react.
107
+ const startedAt = Date.now();
108
+ const opId = startOperation(moduleId, 'uninstall');
109
+ emitUninstallStarted({ module: moduleId, startedAt });
110
+
111
+ let result: CommandResult;
112
+ try {
113
+ result = await performModuleRemove(moduleId, module, force, db);
114
+ } catch (err) {
115
+ failOperation(opId, err);
116
+ emitUninstallFailed({
117
+ module: moduleId,
118
+ startedAt,
119
+ durationMs: Date.now() - startedAt,
120
+ error: err instanceof Error ? err.message : String(err),
121
+ });
122
+ throw err;
123
+ }
124
+
125
+ const durationMs = Date.now() - startedAt;
126
+ if (result.success) {
127
+ completeOperation(opId);
128
+ emitUninstallCompleted({ module: moduleId, startedAt, durationMs });
129
+ } else {
130
+ failOperation(opId, result.error ?? 'unknown error');
131
+ emitUninstallFailed({
132
+ module: moduleId,
133
+ startedAt,
134
+ durationMs,
135
+ error: result.error ?? 'unknown error',
136
+ });
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * The actual uninstall work — on_uninstall hook, terraform destroy, DNS
143
+ * deregister, IPAM deallocate, subscription cleanup, DB delete. Split out
144
+ * so `handleModuleRemove` can wrap the call with operation tracking +
145
+ * lifecycle event emission. Pre-conditions (module exists, no dependents)
146
+ * are validated by the caller.
147
+ */
148
+ async function performModuleRemove(
149
+ moduleId: string,
150
+ module: typeof modules.$inferSelect,
151
+ force: boolean,
152
+ db: ReturnType<typeof getDb>,
153
+ ): Promise<CommandResult> {
97
154
  // Run on_uninstall hook (if defined) BEFORE terraform destroy. Hooks
98
155
  // typically need the module's runtime to still be up so they can talk
99
156
  // to remote services — e.g. caddy's teardown SSHes the caddy host to
@@ -221,23 +278,35 @@ export async function handleModuleRemove(
221
278
  log.success('Infrastructure destroyed');
222
279
  }
223
280
 
224
- // Remove DNS record (if dns_internal is available). Wrapped in a
225
- // FuelGauge with a gauge logger so the dns_internal capability calls
226
- // inside (`deleteRecord`, etc.) nest as sub-events rather than
227
- // leaking to scrollback as unindented top-level lines via
228
- // cli/prompts.log.instantEvent (which pops every pending step).
229
- const dnsGauge = new FuelGauge(`Removing ${moduleId} from DNS`, {
230
- skipAnimation: !process.stdout.isTTY,
231
- });
232
- dnsGauge.start();
233
- try {
234
- const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'auto_deregister_dns');
235
- const { autoDeregisterDns } = await import('../../services/dns-auto-register');
236
- await autoDeregisterDns(moduleId, db, dnsLogger);
237
- dnsGauge.stop(true);
238
- } catch {
239
- dnsGauge.stop(false);
240
- // Non-fatal -- continue with removal
281
+ // Capture the deployed systems BEFORE the module_systems rows are deleted
282
+ // (cascade on module removal) so the system.destroyed events (D5/D6) carry
283
+ // the host/IP each subscriber needs to deregister. One event per system.
284
+ const removedSystems = await (async () => {
285
+ try {
286
+ const { getModuleSystems } = await import('../../services/deployed-systems');
287
+ return getModuleSystems(moduleId, db);
288
+ } catch {
289
+ return [];
290
+ }
291
+ })();
292
+
293
+ // Announce teardown on the bus (D5, v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md). A
294
+ // dns_internal provider's system.destroyed.* subscription runs its
295
+ // on_system_event hook (op: deregister) to remove each host's records.
296
+ if (removedSystems.length > 0) {
297
+ try {
298
+ const { emitSystemDestroyed } = await import('../../services/celilo-events');
299
+ for (const sys of removedSystems) {
300
+ emitSystemDestroyed({
301
+ module: moduleId,
302
+ hostname: sys.hostname,
303
+ targetIp: sys.ipv4_address,
304
+ });
305
+ }
306
+ } catch (error) {
307
+ const msg = error instanceof Error ? error.message : String(error);
308
+ log.warn(`Failed to emit system.destroyed for ${moduleId}: ${msg}`);
309
+ }
241
310
  }
242
311
 
243
312
  // Deallocate IPAM resources (if any)
@@ -88,8 +88,12 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
88
88
  if (configs.length > 0) {
89
89
  const configLines = ['Configuration:'];
90
90
  for (const config of configs) {
91
- const value = config.valueJson ? JSON.stringify(JSON.parse(config.valueJson)) : config.value;
92
- configLines.push(` ${config.key}: ${value}`);
91
+ // `value` is the human-readable display form (e.g. "test-host"
92
+ // for a string, "2222" for a number, JSON-stringified for
93
+ // complex types) — populated by upsertModuleConfig alongside
94
+ // the canonical valueJson. Using it here keeps the status
95
+ // output free of JSON-quote noise around primitives.
96
+ configLines.push(` ${config.key}: ${config.value}`);
93
97
  }
94
98
  sections.push(configLines.join('\n'));
95
99
  } else {