@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -3,10 +3,10 @@
3
3
  * Restores from a backup archive, executing the module's on_restore hook.
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
6
  import { formatSize, getBackup } from '../../services/backup-metadata';
8
7
  import { restoreModuleBackup, restoreSystemStateBackup } from '../../services/backup-restore';
9
8
  import { getBackupStorage } from '../../services/backup-storage';
9
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
10
10
  import { celiloIntro, celiloOutro } from '../prompts';
11
11
  import type { CommandResult } from '../types';
12
12
 
@@ -66,13 +66,16 @@ export async function handleBackupRestore(
66
66
 
67
67
  // Confirm unless --yes
68
68
  if (!flags.yes) {
69
- const confirmed = await p.confirm({
70
- message: 'Proceed with restore?',
71
- initialValue: false,
72
- });
73
-
74
- if (p.isCancel(confirmed) || !confirmed) {
75
- p.cancel('Restore cancelled');
69
+ const confirmed = await withInterviewSession(() =>
70
+ askConfirm({
71
+ scope: `backup-restore:${backupId}`,
72
+ key: 'proceed',
73
+ message: 'Proceed with restore?',
74
+ defaultValue: false,
75
+ }),
76
+ );
77
+
78
+ if (!confirmed) {
76
79
  return { success: false, error: 'Cancelled by user' };
77
80
  }
78
81
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `celilo dns` — operator views over celilo's DNS bookkeeping.
3
+ *
4
+ * `registrations` lists the dns_registrations ledger: every (provider,
5
+ * fqdn) the framework has registered via dns_registrar.registerHost,
6
+ * with the consumer that asked for it and when it was last re-asserted
7
+ * by the provider's refresh_registrations hook. Read-only; names only
8
+ * (module ids), never UUIDs. See
9
+ * designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B2b).
10
+ */
11
+
12
+ import { getDb } from '../../db/client';
13
+ import { listDnsRegistrations } from '../../services/dns-registrations';
14
+ import type { CommandResult } from '../types';
15
+
16
+ function formatAge(from: Date | null, now: Date): string {
17
+ if (!from) return 'never';
18
+ const mins = Math.floor((now.getTime() - from.getTime()) / 60_000);
19
+ if (mins < 1) return 'just now';
20
+ if (mins < 60) return `${mins}m ago`;
21
+ const hours = Math.floor(mins / 60);
22
+ if (hours < 48) return `${hours}h ago`;
23
+ return `${Math.floor(hours / 24)}d ago`;
24
+ }
25
+
26
+ export async function handleDnsRegistrations(
27
+ _args: string[],
28
+ flags: Record<string, string | boolean>,
29
+ ): Promise<CommandResult> {
30
+ const db = getDb();
31
+ const provider = typeof flags.provider === 'string' ? flags.provider : undefined;
32
+ const rows = listDnsRegistrations(db, { providerModuleId: provider });
33
+
34
+ if (rows.length === 0) {
35
+ const scope = provider ? ` for provider '${provider}'` : '';
36
+ return {
37
+ success: true,
38
+ message: `No DNS registrations recorded${scope}\n\nRegistrations are recorded when a module registers a hostname via the dns_registrar capability (e.g. during deploy).`,
39
+ };
40
+ }
41
+
42
+ const now = new Date();
43
+ const header = ['FQDN', 'IP', 'PROVIDER', 'CONSUMER', 'REGISTERED', 'REFRESHED'];
44
+ const table = rows.map((r) => [
45
+ r.fqdn,
46
+ r.ip ?? 'auto',
47
+ r.providerModuleId,
48
+ r.consumerModuleId,
49
+ formatAge(r.registeredAt, now),
50
+ formatAge(r.refreshedAt, now),
51
+ ]);
52
+ const widths = header.map((h, i) => Math.max(h.length, ...table.map((row) => row[i].length)));
53
+ const render = (row: string[]) => row.map((cell, i) => cell.padEnd(widths[i])).join(' ');
54
+
55
+ const lines = [render(header), ...table.map(render)];
56
+ return { success: true, message: lines.join('\n'), data: rows };
57
+ }
@@ -165,20 +165,20 @@ describe('celilo events command handlers', () => {
165
165
  });
166
166
  setupBus.close();
167
167
 
168
- const res = await handleEventsReply([String(query.id), '"lunacycle.net"'], {});
168
+ const res = await handleEventsReply([String(query.id), '"example.net"'], {});
169
169
  expect(res.success).toBe(true);
170
170
  if (!res.success) throw new Error('expected success');
171
171
  const data = res.data as { status: string; family: string; value: unknown };
172
172
  expect(data.status).toBe('replied');
173
173
  expect(data.family).toBe('config');
174
- expect(data.value).toBe('lunacycle.net');
174
+ expect(data.value).toBe('example.net');
175
175
 
176
176
  const checkBus = openBus({ dbPath, events: defineEvents({}) });
177
177
  const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
178
178
  checkBus.close();
179
179
  expect(replies).toHaveLength(1);
180
180
  expect(replies[0].replyFor).toBe(query.id);
181
- expect(replies[0].payload).toEqual({ value: 'lunacycle.net' });
181
+ expect(replies[0].payload).toEqual({ value: 'example.net' });
182
182
  expect(replies[0].emittedBy).toBe('claude-config-responder');
183
183
  });
184
184
 
@@ -229,7 +229,7 @@ describe('celilo events command handlers', () => {
229
229
  setupBus.close();
230
230
 
231
231
  // A bare word isn't valid JSON — the operator must quote strings.
232
- const res = await handleEventsReply([String(query.id), 'lunacycle.net'], {});
232
+ const res = await handleEventsReply([String(query.id), 'example.net'], {});
233
233
  expect(res.success).toBe(false);
234
234
  if (res.success) throw new Error('expected failure');
235
235
  expect(res.error).toContain('Invalid JSON value');
@@ -36,7 +36,12 @@ import { runNamedHook } from '../../hooks/run-named-hook';
36
36
  import type { HookName } from '../../hooks/types';
37
37
  import type { ModuleManifest } from '../../manifest/schema';
38
38
  import type { EnsureRequiredPayload } from '../../services/bus-interview';
39
- import { installDaemon, readInstalledUnit, uninstallDaemon } from '../../services/events-daemon';
39
+ import {
40
+ installDaemon,
41
+ planDaemonInstall,
42
+ readInstalledUnit,
43
+ uninstallDaemon,
44
+ } from '../../services/events-daemon';
40
45
  import { getArg, hasFlag } from '../parser';
41
46
  import type { CommandResult } from '../types';
42
47
 
@@ -187,6 +192,34 @@ export async function handleEventsListSubscribers(): Promise<CommandResult> {
187
192
  }
188
193
  }
189
194
 
195
+ /**
196
+ * `celilo events resync-subscriptions` — rebuild the bus `subscribers` table
197
+ * from every deployed module's manifest. The reactive layer is registered at
198
+ * DEPLOY time and lives in the bus, which a restore/migration starts EMPTY — so
199
+ * after a cutover this re-establishes who-reacts-to-what without redeploying
200
+ * every module (ISS-0088). Idempotent.
201
+ */
202
+ export async function handleEventsResyncSubscriptions(): Promise<CommandResult> {
203
+ try {
204
+ const { resyncAllSubscriptions } = await import('../../services/module-subscriptions');
205
+ const result = resyncAllSubscriptions();
206
+ const lines = [
207
+ `Re-registered ${result.registered} subscription(s) from ${result.modules} deployed module(s).`,
208
+ ];
209
+ if (result.failures.length > 0) {
210
+ const detail = result.failures.map((f) => ` ${f.moduleId}: ${f.error}`).join('\n');
211
+ return {
212
+ success: false,
213
+ error: `${result.failures.length} module(s) failed to re-register subscriptions:\n${detail}`,
214
+ };
215
+ }
216
+ lines.push('', 'A running dispatcher will now deliver events to these handlers.');
217
+ return { success: true, message: lines.join('\n'), data: result };
218
+ } catch (err) {
219
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
220
+ }
221
+ }
222
+
190
223
  export async function handleEventsListPending(
191
224
  _args: string[],
192
225
  flags: Record<string, string | boolean>,
@@ -356,7 +389,7 @@ export async function handleEventsRepair(): Promise<CommandResult> {
356
389
  }
357
390
  }
358
391
 
359
- const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect'] as const;
392
+ const INTERVIEW_FAMILIES = ['config', 'secret', 'ensure', 'aspect', 'interview'] as const;
360
393
  type InterviewFamily = (typeof INTERVIEW_FAMILIES)[number];
361
394
 
362
395
  /** Classify a query event type into its interview family, or null if it isn't one. */
@@ -365,6 +398,7 @@ function interviewFamily(type: string): InterviewFamily | null {
365
398
  if (type.startsWith('secret.required.')) return 'secret';
366
399
  if (type.startsWith('ensure.required.')) return 'ensure';
367
400
  if (type.startsWith('aspect.required.')) return 'aspect';
401
+ if (type.startsWith('interview.required.')) return 'interview';
368
402
  return null;
369
403
  }
370
404
 
@@ -395,6 +429,10 @@ function interviewFamily(type: string): InterviewFamily | null {
395
429
  * base-module aspect, 'false' refuses it (ISS-0027).
396
430
  * Replies { consented }; the deploy records the
397
431
  * approval/denial.
432
+ * - interview.required.<scope>.<key> → value is the answer itself, shaped per
433
+ * the question's kind ('"node3"', 'true',
434
+ * '["a","b"]'); replies { value }. The generic
435
+ * operator-command interview family (ISS-0127).
398
436
  */
399
437
  export async function handleEventsReply(
400
438
  args: string[],
@@ -406,7 +444,7 @@ export async function handleEventsReply(
406
444
  return {
407
445
  success: false,
408
446
  error: `Usage: celilo events reply <query-event-id> <value-json>
409
- e.g. celilo events reply 42 '"lunacycle.net"'`,
447
+ e.g. celilo events reply 42 '"example.net"'`,
410
448
  };
411
449
  }
412
450
  const queryId = Number(idArg);
@@ -421,7 +459,7 @@ export async function handleEventsReply(
421
459
  return {
422
460
  success: false,
423
461
  error: `Invalid JSON value: ${err instanceof Error ? err.message : String(err)}
424
- Encode the answer as JSON, e.g. '"lunacycle.net"', '8080', '["a","b"]'.`,
462
+ Encode the answer as JSON, e.g. '"example.net"', '8080', '["a","b"]'.`,
425
463
  };
426
464
  }
427
465
 
@@ -443,7 +481,7 @@ export async function handleEventsReply(
443
481
  return {
444
482
  success: false,
445
483
  error: `Event ${queryId} is type '${query.type}', not an interview query.
446
- Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.*`,
484
+ Expected one of: config.required.* / secret.required.* / ensure.required.* / aspect.required.* / interview.required.*`,
447
485
  };
448
486
  }
449
487
 
@@ -463,7 +501,7 @@ export async function handleEventsReply(
463
501
  });
464
502
  }
465
503
 
466
- if (family === 'config') {
504
+ if (family === 'config' || family === 'interview') {
467
505
  bus.emitRaw(`${query.type}.reply`, { value }, { replyFor: queryId, emittedBy });
468
506
  return jsonResult({ ok: true, status: 'replied', queryId, type: query.type, family, value });
469
507
  }
@@ -778,27 +816,46 @@ function parseDurationMs(flag: string | boolean | undefined, defaultMs: number):
778
816
  }
779
817
  }
780
818
 
819
+ function daemonScopeFrom(flags: Record<string, string | boolean>): 'user' | 'system' {
820
+ return flags.system ? 'system' : 'user';
821
+ }
822
+
781
823
  /**
782
- * `celilo events install-daemon` — write a systemd user unit (Linux)
783
- * or launchd plist (macOS) that runs the dispatcher under supervision.
784
- * Writes the file but doesn't touch supervisor state the operator
785
- * runs `systemctl --user enable --now ...` themselves so any change
786
- * is visible.
824
+ * `celilo events install-daemon` — write a systemd unit (Linux) or
825
+ * launchd plist (macOS) that runs the dispatcher under supervision.
826
+ * Default is a per-user unit; `--system` writes the system-scope unit
827
+ * (/etc/systemd/system, or a /Library/LaunchDaemons LaunchDaemon on
828
+ * macOS) that survives logout/reboot and runs as the celilo state-dir
829
+ * owner — the management-plane shape. Writes the file but doesn't
830
+ * touch supervisor state — the operator (or the celilo-mgmt Ansible
831
+ * role) runs the enable/bootstrap steps themselves so any change is
832
+ * visible.
787
833
  */
788
834
  export async function handleEventsInstallDaemon(
789
835
  _args: string[],
790
836
  flags: Record<string, string | boolean>,
791
837
  ): Promise<CommandResult> {
792
838
  try {
793
- const result = installDaemon({
839
+ const options = {
794
840
  celiloPath: typeof flags['celilo-path'] === 'string' ? flags['celilo-path'] : undefined,
795
841
  pollMs: flags['poll-ms'] ? Number(flags['poll-ms']) : undefined,
796
842
  concurrency: flags.concurrency ? Number(flags.concurrency) : undefined,
797
- });
843
+ scope: daemonScopeFrom(flags),
844
+ };
845
+ // --print: render-only, raw unit content on stdout. The celilo-mgmt
846
+ // Ansible role captures this and does the root-owned write itself
847
+ // (the deb wrapper runs celilo as the unprivileged celilo user, so
848
+ // a direct --system write would EACCES on /etc/systemd/system).
849
+ if (flags.print) {
850
+ const plan = planDaemonInstall(options);
851
+ return { success: true, message: plan.unitContent, rawOutput: true, data: plan };
852
+ }
853
+ const result = installDaemon(options);
798
854
  const lines = [
799
- `Wrote ${result.platform} unit: ${shortenPath(result.unitPath)}`,
855
+ `Wrote ${result.platform} ${result.scope} unit: ${shortenPath(result.unitPath)}`,
800
856
  ` celilo: ${shortenPath(result.celiloPath)}`,
801
857
  ` bus DB: ${shortenPath(result.busDbPath)}`,
858
+ ...(result.runAsUser ? [` runs as: ${result.runAsUser}`] : []),
802
859
  '',
803
860
  'Next steps (run these yourself — install does not touch supervisor state):',
804
861
  ...result.nextSteps.map((s) => ` ${s}`),
@@ -813,13 +870,16 @@ export async function handleEventsInstallDaemon(
813
870
  }
814
871
 
815
872
  /**
816
- * `celilo events uninstall-daemon` — remove the supervisor unit file.
817
- * Symmetrical: prints the operator-facing disable steps, doesn't run
818
- * them.
873
+ * `celilo events uninstall-daemon` — remove the supervisor unit file
874
+ * (`--system` for the system-scope unit). Symmetrical: prints the
875
+ * operator-facing disable steps, doesn't run them.
819
876
  */
820
- export async function handleEventsUninstallDaemon(): Promise<CommandResult> {
877
+ export async function handleEventsUninstallDaemon(
878
+ _args: string[],
879
+ flags: Record<string, string | boolean>,
880
+ ): Promise<CommandResult> {
821
881
  try {
822
- const result = uninstallDaemon();
882
+ const result = uninstallDaemon({ scope: daemonScopeFrom(flags) });
823
883
  const lines = [
824
884
  result.removed
825
885
  ? `Removed unit: ${shortenPath(result.unitPath)}`
@@ -839,15 +899,20 @@ export async function handleEventsUninstallDaemon(): Promise<CommandResult> {
839
899
 
840
900
  /**
841
901
  * `celilo events show-daemon` — print whatever unit file is currently
842
- * installed so the operator can see exactly what the supervisor will
843
- * run, without grepping into platform-specific paths.
902
+ * installed (`--system` for the system-scope unit) so the operator can
903
+ * see exactly what the supervisor will run, without grepping into
904
+ * platform-specific paths.
844
905
  */
845
- export async function handleEventsShowDaemon(): Promise<CommandResult> {
846
- const result = readInstalledUnit();
906
+ export async function handleEventsShowDaemon(
907
+ _args: string[],
908
+ flags: Record<string, string | boolean>,
909
+ ): Promise<CommandResult> {
910
+ const scope = daemonScopeFrom(flags);
911
+ const result = readInstalledUnit({ scope });
847
912
  if (!result.exists) {
848
913
  return {
849
914
  success: true,
850
- message: `No supervisor unit installed at ${shortenPath(result.path)}\n\nRun \`celilo events install-daemon\` to create one.`,
915
+ message: `No supervisor unit installed at ${shortenPath(result.path)}\n\nRun \`celilo events install-daemon${scope === 'system' ? ' --system' : ''}\` to create one.`,
851
916
  };
852
917
  }
853
918
  return {