@celilo/cli 1.5.0 → 1.7.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 (88) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/MODULE_PRIMITIVES.md +25 -7
  4. package/drizzle/0026_module_integrity_version.sql +20 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +187 -2
  11. package/src/capabilities/validation.ts +53 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-audit.ts +5 -2
  14. package/src/cli/commands/module-remove.ts +34 -2
  15. package/src/cli/commands/module-update.test.ts +238 -3
  16. package/src/cli/commands/module-update.ts +206 -12
  17. package/src/cli/commands/module-verify.ts +77 -13
  18. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  19. package/src/cli/commands/service-set-credentials.ts +115 -0
  20. package/src/cli/commands/system-audit.ts +17 -0
  21. package/src/cli/commands/system-doctor.ts +78 -2
  22. package/src/cli/commands/system-migrate.ts +6 -4
  23. package/src/cli/commands/system-update.ts +33 -3
  24. package/src/cli/completion.ts +16 -1
  25. package/src/cli/index.ts +11 -2
  26. package/src/cli/tui/audit-state.ts +11 -3
  27. package/src/cli/tui/audit-tui.tsx +10 -4
  28. package/src/cli/tui/icons.ts +9 -2
  29. package/src/cli/tui/modals/analyzing.tsx +3 -0
  30. package/src/db/client.ts +10 -8
  31. package/src/db/migrate.test.ts +147 -0
  32. package/src/db/migrate.ts +69 -1
  33. package/src/db/schema.ts +5 -0
  34. package/src/hooks/capability-loader.test.ts +55 -0
  35. package/src/hooks/capability-loader.ts +16 -1
  36. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  37. package/src/manifest/schema.ts +23 -0
  38. package/src/module/import.ts +56 -40
  39. package/src/module/packaging/audit.ts +103 -28
  40. package/src/module/packaging/build.ts +12 -53
  41. package/src/module/packaging/classify-module-path.test.ts +104 -0
  42. package/src/module/packaging/extract.ts +31 -3
  43. package/src/module/packaging/generated-plane.test.ts +79 -0
  44. package/src/module/packaging/generated-plane.ts +134 -0
  45. package/src/module/packaging/host-plane.test.ts +132 -0
  46. package/src/module/packaging/host-plane.ts +135 -0
  47. package/src/module/packaging/package-rules.ts +62 -0
  48. package/src/policy/module-business-baseline.ts +0 -11
  49. package/src/services/alerting/monitors.ts +54 -2
  50. package/src/services/alerting/sweep-runner.ts +38 -1
  51. package/src/services/audit/cli-version.test.ts +6 -2
  52. package/src/services/audit/cli-version.ts +20 -6
  53. package/src/services/audit/detect-without-converge.test.ts +91 -0
  54. package/src/services/audit/detect-without-converge.ts +81 -0
  55. package/src/services/audit/disk-space.test.ts +5 -2
  56. package/src/services/audit/disk-space.ts +5 -3
  57. package/src/services/audit/health.test.ts +39 -0
  58. package/src/services/audit/index.test.ts +7 -1
  59. package/src/services/audit/index.ts +12 -0
  60. package/src/services/audit/module-integrity.test.ts +146 -0
  61. package/src/services/audit/module-integrity.ts +113 -0
  62. package/src/services/audit/module-versions.ts +4 -1
  63. package/src/services/audit/schema.test.ts +7 -2
  64. package/src/services/audit/schema.ts +19 -1
  65. package/src/services/audit/terraform-plan.ts +17 -2
  66. package/src/services/audit/types.test.ts +29 -0
  67. package/src/services/audit/types.ts +30 -4
  68. package/src/services/consumer-cleanup.ts +5 -3
  69. package/src/services/container-service.test.ts +34 -0
  70. package/src/services/container-service.ts +44 -0
  71. package/src/services/deployed-systems.test.ts +101 -0
  72. package/src/services/deployed-systems.ts +43 -11
  73. package/src/services/dns-provider-backfill.ts +30 -0
  74. package/src/services/fleet-checks.test.ts +26 -0
  75. package/src/services/fleet-checks.ts +11 -1
  76. package/src/services/module-deploy.ts +109 -41
  77. package/src/services/provider-arrival.test.ts +241 -0
  78. package/src/services/provider-arrival.ts +213 -0
  79. package/src/services/restore-from-file.ts +4 -0
  80. package/src/services/update/orchestrator.test.ts +2 -0
  81. package/src/templates/generator.test.ts +35 -0
  82. package/src/templates/generator.ts +29 -1
  83. package/src/variables/context.test.ts +63 -0
  84. package/src/variables/context.ts +10 -2
  85. package/src/variables/declarative-derivation.test.ts +47 -8
  86. package/src/variables/declarative-derivation.ts +6 -4
  87. package/src/services/public-web-republish.test.ts +0 -189
  88. package/src/services/public-web-republish.ts +0 -84
@@ -472,7 +472,67 @@ async function renderAspectCoverage(opts: { fix: boolean }): Promise<{
472
472
  return { lines, failCount: missing.length, warnCount: unknown.length };
473
473
  }
474
474
 
475
- async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Promise<{
475
+ /**
476
+ * The fleet-section line for module integrity, one row per module.
477
+ *
478
+ * `module verify` is the detailed surface and `system audit` is the machine
479
+ * one; doctor gets the summary, because doctor is where an operator looks
480
+ * first. Same implementation behind all three.
481
+ */
482
+ async function renderModuleIntegrity(opts: {
483
+ db: ReturnType<typeof getDb>;
484
+ deep: boolean;
485
+ }): Promise<{ lines: string[]; failCount: number; warnCount: number }> {
486
+ const { auditModule } = await import('../../module/packaging/audit');
487
+ const { auditModuleIntegrity } = await import('../../services/audit/module-integrity');
488
+ const { modules: modulesTable } = await import('../../db/schema');
489
+
490
+ const installed = opts.db.select().from(modulesTable).all();
491
+ if (installed.length === 0) return { lines: [], failCount: 0, warnCount: 0 };
492
+
493
+ const results = await Promise.all(
494
+ installed.map((m) => auditModule(m.id, opts.db, { deep: opts.deep })),
495
+ );
496
+ const findings = auditModuleIntegrity({ results });
497
+
498
+ const drifted = findings.filter((f) => f.severity === 'drift');
499
+ const unmeasured = findings.filter((f) => f.severity === 'unmeasured');
500
+
501
+ if (findings.length === 0) {
502
+ const scope = opts.deep
503
+ ? 'including what is running on their hosts'
504
+ : 'installed and generated';
505
+ return {
506
+ lines: [
507
+ ` ${ANSI.green}✔${ANSI.reset} Module integrity ${ANSI.dim}— ${installed.length} module(s) match their recorded version (${scope})${ANSI.reset}`,
508
+ ],
509
+ failCount: 0,
510
+ warnCount: 0,
511
+ };
512
+ }
513
+
514
+ const lines: string[] = [
515
+ ` ${drifted.length > 0 ? `${ANSI.red}✗${ANSI.reset}` : `${ANSI.yellow}?${ANSI.reset}`} Module integrity ${ANSI.dim}— ${drifted.length} drifted, ${unmeasured.length} unmeasured, of ${installed.length} module(s)${ANSI.reset}`,
516
+ ];
517
+ // Every finding, never just the first. celilo#951 printed `drift[0]` of 19,
518
+ // so a real finding would have arrived at position 19 and never been seen.
519
+ for (const f of findings) {
520
+ lines.push(` ${ANSI.dim}${f.message}${ANSI.reset}`);
521
+ if (f.remediation) lines.push(` ${ANSI.dim}→ ${f.remediation}${ANSI.reset}`);
522
+ }
523
+ if (!opts.deep) {
524
+ lines.push(
525
+ ` ${ANSI.dim}Run \`celilo system doctor --deep\` to also ask each host what it is running.${ANSI.reset}`,
526
+ );
527
+ }
528
+ return { lines, failCount: drifted.length, warnCount: unmeasured.length };
529
+ }
530
+
531
+ async function renderFleetSection(opts: {
532
+ forced: boolean;
533
+ fix: boolean;
534
+ deep: boolean;
535
+ }): Promise<{
476
536
  lines: string[];
477
537
  failCount: number;
478
538
  warnCount: number;
@@ -518,6 +578,18 @@ async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Prom
518
578
  if (f.status === 'fail') failCount++;
519
579
  else if (f.status === 'warn') warnCount++;
520
580
  }
581
+
582
+ // Module integrity: is the code on each box the code celilo thinks it is?
583
+ // Shallow here by design — the installed tree against its baseline and the
584
+ // generated project against the installed tree are both local and take
585
+ // milliseconds. The host plane is one SSH per system and is reached with
586
+ // `--deep`, alongside aspect coverage, for the same reason
587
+ // (openspec/changes/module-integrity-rigor, D8).
588
+ const integrity = await renderModuleIntegrity({ db, deep: opts.deep });
589
+ lines.push(...integrity.lines);
590
+ failCount += integrity.failCount;
591
+ warnCount += integrity.warnCount;
592
+
521
593
  return { lines, failCount, warnCount };
522
594
  } finally {
523
595
  bus.close();
@@ -647,7 +719,11 @@ export async function handleSystemDoctor(
647
719
 
648
720
  // Fleet-runtime section (state-aware; only renders on a management
649
721
  // plane with a celilo DB, or when --fleet forces it).
650
- const fleet = await renderFleetSection({ forced: flags.fleet === true, fix });
722
+ const fleet = await renderFleetSection({
723
+ forced: flags.fleet === true,
724
+ fix,
725
+ deep: flags.deep === true,
726
+ });
651
727
  if (fleet.lines.length > 0) {
652
728
  lines.push(...fleet.lines);
653
729
  lines.push('');
@@ -108,9 +108,11 @@ export async function handleSystemMigrate(
108
108
  }
109
109
  }
110
110
 
111
- // getDb() auto-migrates on open; do it inside try so an existing DB that
112
- // predates the drizzle-authoritative change fails with an actionable message
113
- // instead of a raw migrator error.
111
+ // getDb() auto-migrates on open, and repairs a frozen `__drizzle_migrations`
112
+ // watermark itself when the declared schema is already complete. What reaches
113
+ // this catch is the case it will not guess at: schema that is only PARTLY
114
+ // there, where stamping would record migrations that never ran. Caught so it
115
+ // says what to do instead of surfacing a raw migrator error.
114
116
  let db: ReturnType<typeof getDb>;
115
117
  try {
116
118
  db = getDb();
@@ -118,7 +120,7 @@ export async function handleSystemMigrate(
118
120
  const msg = error instanceof Error ? error.message : String(error);
119
121
  return {
120
122
  success: false,
121
- error: `Migration failed: ${msg}\n\nIf this DB predates the drizzle-authoritative migration change, it needs a one-time remediation (stamp \`__drizzle_migrations\` to the latest migration + create any missing table) before the migrator can run cleanlysee ISS-0100.`,
123
+ error: `Migration failed: ${msg}\n\nThis DB's \`__drizzle_migrations\` watermark disagrees with a schema that is only partly applied, which celilo will not resolve on its own. It needs a one-time remediation by hand (create the genuinely missing objects from their migration .sql, then stamp the watermark to the latest migration) runbook in celilo#169.`,
122
124
  };
123
125
  }
124
126
 
@@ -495,6 +495,11 @@ export async function handleSystemUpdate(
495
495
  const migrationsFolder = findMigrationsFolderSafe();
496
496
  const healthResults = await runAllHealthChecks(db);
497
497
 
498
+ // Module integrity, shallow. Local and milliseconds; the host plane is one
499
+ // SSH per system and belongs to `module verify --deep`.
500
+ const { auditModule } = await import('../../module/packaging/audit');
501
+ const integrityResults = await Promise.all(upgradableModules.map((m) => auditModule(m.id, db)));
502
+
498
503
  const latestBackupByModule = new Map<string, number>();
499
504
  try {
500
505
  const successfulBackups = db
@@ -563,6 +568,14 @@ export async function handleSystemUpdate(
563
568
  configs: configsByModule.get(m.id) ?? {},
564
569
  })),
565
570
  },
571
+ moduleIntegrity: { results: integrityResults },
572
+ detectWithoutConverge: {
573
+ modules: upgradableModules.map((m) => ({
574
+ id: m.id,
575
+ state: m.state,
576
+ manifest: m.manifestData as ModuleManifest,
577
+ })),
578
+ },
566
579
  health: { results: healthResults },
567
580
  backups: {
568
581
  modules: upgradableModules.map((m) => ({
@@ -714,7 +727,7 @@ export async function handleSystemUpdate(
714
727
  // orchestrator was reacting to).
715
728
  const successfulModuleSteps = result.modules.filter((m) => m.step === 'done');
716
729
  if (result.ok && successfulModuleSteps.length > 0) {
717
- const refreshedAudit = await runAudit(rebuildAuditDepsForRerun(auditDeps, db));
730
+ const refreshedAudit = await runAudit(await rebuildAuditDepsForRerun(auditDeps, db));
718
731
  result.audit = refreshedAudit;
719
732
  }
720
733
 
@@ -747,10 +760,10 @@ type AuditDeps = Parameters<typeof runAudit>[0];
747
760
  * reflect post-upgrade reality (e.g., a module_versions drift
748
761
  * finding for a module we just upgraded is no longer reported).
749
762
  */
750
- export function rebuildAuditDepsForRerun(
763
+ export async function rebuildAuditDepsForRerun(
751
764
  original: AuditDeps,
752
765
  db: ReturnType<typeof getDb>,
753
- ): AuditDeps {
766
+ ): Promise<AuditDeps> {
754
767
  const installed = db.select().from(modules).all();
755
768
  const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
756
769
  const upgradable = installed.filter((m) => upgradeEligibleStates.has(m.state));
@@ -763,6 +776,8 @@ export function rebuildAuditDepsForRerun(
763
776
  configsByModule.set(c.moduleId, m);
764
777
  }
765
778
 
779
+ const { auditModule } = await import('../../module/packaging/audit');
780
+
766
781
  // Backup recency is unchanged across an orchestrator run (only
767
782
  // celilo-DB snapshots happen, not per-module backup writes), so
768
783
  // we look up each module's prior lastSuccessfulBackupAt by id
@@ -793,6 +808,21 @@ export function rebuildAuditDepsForRerun(
793
808
  configs: configsByModule.get(m.id) ?? {},
794
809
  })),
795
810
  },
811
+ // Re-MEASURED, not carried over. An upgrade rewrites the installed tree and
812
+ // the baseline, so the pre-upgrade result describes files that are no
813
+ // longer on disk. Reusing it would be reporting a stored claim about a
814
+ // state that has since changed, which is the exact mistake this whole
815
+ // change exists to remove.
816
+ moduleIntegrity: {
817
+ results: await Promise.all(upgradable.map((m) => auditModule(m.id, db))),
818
+ },
819
+ detectWithoutConverge: {
820
+ modules: upgradable.map((m) => ({
821
+ id: m.id,
822
+ state: m.state,
823
+ manifest: m.manifestData as ModuleManifest,
824
+ })),
825
+ },
796
826
  // Unchanged across an orchestrator run — an upgrade does not reclaim
797
827
  // abandoned operations, so re-reading them would be the same rows.
798
828
  abandonedOperations: original.abandonedOperations,
@@ -325,7 +325,15 @@ export async function getCompletions(words: string[], current: number): Promise<
325
325
  }
326
326
 
327
327
  if (command === 'service' && currentIndex === 1) {
328
- const subcommands = ['add', 'list', 'verify', 'reconfigure', 'remove', 'config'];
328
+ const subcommands = [
329
+ 'add',
330
+ 'list',
331
+ 'verify',
332
+ 'reconfigure',
333
+ 'remove',
334
+ 'config',
335
+ 'set-credentials',
336
+ ];
329
337
  return filterSuggestions(subcommands, args[1] || '');
330
338
  }
331
339
 
@@ -356,6 +364,13 @@ export async function getCompletions(words: string[], current: number): Promise<
356
364
  return filterSuggestions(serviceIds, args[2] || '');
357
365
  }
358
366
 
367
+ // Service set-credentials - complete with service IDs
368
+ if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) {
369
+ const services = await listContainerServices();
370
+ const serviceIds = services.map((s) => s.serviceId);
371
+ return filterSuggestions(serviceIds, args[2] || '');
372
+ }
373
+
359
374
  // Service config operations
360
375
  if (command === 'service' && args[1] === 'config' && currentIndex === 2) {
361
376
  const operations = ['get', 'set'];
package/src/cli/index.ts CHANGED
@@ -109,6 +109,7 @@ import { handleServiceConfigSet } from './commands/service-config-set';
109
109
  import { handleServiceList } from './commands/service-list';
110
110
  import { handleServiceReconfigure } from './commands/service-reconfigure';
111
111
  import { handleServiceRemove } from './commands/service-remove';
112
+ import { handleServiceSetCredentials } from './commands/service-set-credentials';
112
113
  import { handleServiceVerify } from './commands/service-verify';
113
114
  import { handleStatus } from './commands/status';
114
115
  import { handleSubscribersAdd } from './commands/subscribers-add';
@@ -721,6 +722,7 @@ Subcommands:
721
722
  Options:
722
723
  --zone <zone> Filter by network zone
723
724
  verify <service-id> Re-verify a container service connection
725
+ set-credentials <service-id> Update a provider endpoint or API credential
724
726
  reconfigure <service-id> Re-run configuration interview (change template, storage, etc.)
725
727
  remove <id> Remove a container service
726
728
  Options:
@@ -751,6 +753,9 @@ Examples:
751
753
  # Verify a service connection
752
754
  celilo service verify proxmox-home-lab
753
755
 
756
+ # Move a Proxmox endpoint while retaining its existing token
757
+ celilo service set-credentials proxmox-home-lab --api-url https://10.77.20.50:8006
758
+
754
759
  # Get service configuration
755
760
  celilo service config get proxmox-home-lab
756
761
  celilo service config get proxmox-home-lab name
@@ -1577,9 +1582,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1577
1582
  case 'changeset':
1578
1583
  return handleModuleChangeset(parsed.args, parsed.flags);
1579
1584
  case 'audit':
1580
- return moduleAudit(parsed.args);
1585
+ return moduleAudit(parsed.args, parsed.flags);
1581
1586
  case 'verify':
1582
- return moduleVerify(parsed.args);
1587
+ return moduleVerify(parsed.args, parsed.flags);
1583
1588
  case 'config': {
1584
1589
  // Config requires additional subcommand (set/get)
1585
1590
  const configSubcommand = parsed.args[0];
@@ -1750,6 +1755,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1750
1755
  return handleServiceVerify(parsed.args, parsed.flags);
1751
1756
  }
1752
1757
 
1758
+ if (parsed.subcommand === 'set-credentials') {
1759
+ return handleServiceSetCredentials(parsed.args, parsed.flags);
1760
+ }
1761
+
1753
1762
  if (parsed.subcommand === 'reconfigure') {
1754
1763
  return handleServiceReconfigure(parsed.args, parsed.flags);
1755
1764
  }
@@ -56,12 +56,12 @@ export type ModalState =
56
56
  /**
57
57
  * Per-category progress state — drives the analyzing modal's
58
58
  * fuel-gauges. `done` carries the verdict so the row can show the
59
- * right icon (✓ clean / ▲ drift / × blocked).
59
+ * right icon (✓ clean / ? unmeasured / ▲ drift / × blocked).
60
60
  */
61
61
  export type CategoryStatus =
62
62
  | 'pending'
63
63
  | 'running'
64
- | { kind: 'done'; verdict: 'clean' | 'drift' | 'blocked' };
64
+ | { kind: 'done'; verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' };
65
65
 
66
66
  /**
67
67
  * The full set of categories the audit emits — used to seed the
@@ -90,9 +90,13 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
90
90
  'transport_reads',
91
91
  'trusted_sources',
92
92
  'interface_classification',
93
+ 'module_integrity',
94
+ 'detect_without_converge',
93
95
  ];
94
96
 
95
97
  export const CATEGORY_LABELS: Record<DriftCategory, string> = {
98
+ module_integrity: 'Module integrity',
99
+ detect_without_converge: 'Drift without converge',
96
100
  interface_classification: 'Firewall interfaces',
97
101
  cli_version: 'CLI version',
98
102
  schema: 'Schema migrations',
@@ -181,7 +185,11 @@ export type AuditTuiAction =
181
185
  | { type: 'select-finding'; index: number }
182
186
  // Per-category audit-progress lifecycle.
183
187
  | { type: 'category-start'; category: DriftCategory }
184
- | { type: 'category-end'; category: DriftCategory; verdict: 'clean' | 'drift' | 'blocked' }
188
+ | {
189
+ type: 'category-end';
190
+ category: DriftCategory;
191
+ verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked';
192
+ }
185
193
  | { type: 'reset-category-progress' };
186
194
 
187
195
  const PANE_ORDER: PaneId[] = ['summary', 'categories', 'findings', 'detail', 'log'];
@@ -129,11 +129,17 @@ export function AuditTui({ source, theme: themeName }: Props) {
129
129
  return;
130
130
  }
131
131
  const findings = event.findings ?? [];
132
- const verdict: 'clean' | 'drift' | 'blocked' = findings.some((f) => f.severity === 'blocked')
132
+ // Same ranking as `computeVerdict`: an unmeasured category is not clean
133
+ // and is not the same statement as a measured difference (D7).
134
+ const verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' = findings.some(
135
+ (f) => f.severity === 'blocked',
136
+ )
133
137
  ? 'blocked'
134
- : findings.length > 0
135
- ? 'drift'
136
- : 'clean';
138
+ : findings.some((f) => f.severity === 'unmeasured')
139
+ ? 'unmeasured'
140
+ : findings.length > 0
141
+ ? 'drift'
142
+ : 'clean';
137
143
  dispatch({ type: 'category-end', category: event.category, verdict });
138
144
  };
139
145
 
@@ -8,12 +8,16 @@ export interface SeverityVisual {
8
8
 
9
9
  export const SEVERITY_VISUALS: Record<DriftSeverity, SeverityVisual> = {
10
10
  blocked: { icon: '×', color: 'red', label: 'BLOCKED' },
11
+ // Deliberately NOT green and NOT gray. An unmeasured check is not a pass and
12
+ // is not a reminder — it is a hole in what celilo knows.
13
+ unmeasured: { icon: '?', color: 'yellow', label: 'UNMEASURED' },
11
14
  drift: { icon: '▲', color: 'yellow', label: 'DRIFT' },
12
15
  todo: { icon: '➤', color: 'gray', label: 'TODO' },
13
16
  };
14
17
 
15
18
  export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
16
19
  BLOCKED: { icon: '×', color: 'red', label: 'BLOCKED' },
20
+ UNKNOWN: { icon: '?', color: 'yellow', label: 'UNKNOWN' },
17
21
  DRIFT: { icon: '▲', color: 'yellow', label: 'DRIFT' },
18
22
  READY: { icon: '✓', color: 'green', label: 'READY' },
19
23
  };
@@ -23,6 +27,9 @@ export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
23
27
  // what actually needs attention.
24
28
  export function severityRank(s: DriftSeverity): number {
25
29
  if (s === 'blocked') return 0;
26
- if (s === 'drift') return 1;
27
- return 2; // todo
30
+ // Above drift, for the same reason UNKNOWN outranks DRIFT: you cannot act on
31
+ // a diff you are not sure you have.
32
+ if (s === 'unmeasured') return 1;
33
+ if (s === 'drift') return 2;
34
+ return 3; // todo
28
35
  }
@@ -36,6 +36,9 @@ function StatusRow({ category, status }: { category: string; status: CategorySta
36
36
  if (status.verdict === 'clean') {
37
37
  return <Text color="green">✓ {padded}clean</Text>;
38
38
  }
39
+ if (status.verdict === 'unmeasured') {
40
+ return <Text color="yellow">? {padded}unmeasured</Text>;
41
+ }
39
42
  if (status.verdict === 'drift') {
40
43
  return <Text color="yellow">▲ {padded}drift</Text>;
41
44
  }
package/src/db/client.ts CHANGED
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
6
6
  import { drizzle } from 'drizzle-orm/bun-sqlite';
7
- import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
8
7
  import { getDbPath } from '../config/paths';
8
+ import { runMigrationsOn } from './migrate';
9
9
  import * as schema from './schema';
10
10
 
11
11
  /**
@@ -83,15 +83,17 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
83
83
  // idempotent: it applies every migration newer than the latest recorded in
84
84
  // `__drizzle_migrations` and no-ops once current.
85
85
  //
86
- // One-time caveat (ISS-0100): an existing DB from the hand-list era has a
87
- // frozen `__drizzle_migrations` watermark; it must be remediated by hand
88
- // (stamp the watermark to the latest migration + create any missing table)
89
- // BEFORE this code opens it, or migrate() re-runs already-applied migrations
90
- // and throws. `celilo system doctor` (checkSchemaDrift) detects the drift.
86
+ // A DB from the hand-list era has a frozen `__drizzle_migrations` watermark,
87
+ // so drizzle re-runs already-applied migrations and throws (celilo#169).
88
+ // runMigrationsOn repairs that itself where the schema is already complete.
89
+ // It has to happen HERE and not only in `celilo system migrate`, because
90
+ // that command reaches its own repair through getDb() this line — and so
91
+ // would die before getting there. A PARTIALLY applied schema still throws
92
+ // and still needs a human. `celilo system doctor` (checkSchemaDrift) detects
93
+ // the drift.
91
94
  if (!readonly) {
92
95
  try {
93
- const migrationsFolder = findMigrationsFolder();
94
- migrate(db, { migrationsFolder });
96
+ runMigrationsOn(db);
95
97
  } catch (error) {
96
98
  console.error('Failed to run migrations:', error);
97
99
  throw error;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The frozen-watermark recurrence gate (celilo#169).
3
+ *
4
+ * The state under test is one no correct deploy can produce, so it is seeded
5
+ * by hand rather than reached: a database written by a celilo from the
6
+ * imperative hand-list era, where schema changes were applied directly and
7
+ * `__drizzle_migrations` never recorded them. Its ledger therefore remembers
8
+ * an old migration while the tables and columns of every later one are already
9
+ * present.
10
+ *
11
+ * That is the carve-out CLAUDE.md draws around seeding state: deploying
12
+ * anything cannot reproduce this row, because current celilo has recorded
13
+ * every migration it applied since ISS-0100 made drizzle authoritative. A
14
+ * suite that starts from an empty database can only ever prove the forward
15
+ * invariant, and says nothing about the installed base.
16
+ *
17
+ * What made it a hazard: drizzle's migrator is watermark-only. It re-runs
18
+ * every migration newer than the newest ledger row, so the first `ALTER TABLE
19
+ * ... ADD` dies on `duplicate column name`, the transaction rolls back, and
20
+ * the throw happens inside `createDbClient` — so EVERY celilo command on that
21
+ * box fails at database open, not just a migrate. celilo-mgr was remediated by
22
+ * hand once. This gate is what stops the next one needing a runbook.
23
+ */
24
+
25
+ import { afterEach, describe, expect, test } from 'bun:test';
26
+ import { rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { type DbClient, createDbClient } from './client';
30
+ import { runMigrationsOn } from './migrate';
31
+ import { findSchemaDrift } from './schema-introspection';
32
+
33
+ describe('runMigrationsOn — a database from the hand-list era', () => {
34
+ const paths: string[] = [];
35
+
36
+ const freshDb = (): { db: DbClient; path: string } => {
37
+ const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
38
+ paths.push(path);
39
+ return { db: createDbClient({ path }), path };
40
+ };
41
+
42
+ afterEach(() => {
43
+ for (const path of paths.splice(0)) {
44
+ for (const suffix of ['', '-wal', '-shm']) {
45
+ rmSync(`${path}${suffix}`, { force: true });
46
+ }
47
+ }
48
+ });
49
+
50
+ /**
51
+ * Freeze the ledger to its oldest entry, leaving the schema fully applied.
52
+ * This is the hand-list-era shape: the objects exist, the ledger has
53
+ * forgotten who made them.
54
+ */
55
+ const freezeWatermark = (db: DbClient): void => {
56
+ db.$client.run(
57
+ 'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
58
+ );
59
+ };
60
+
61
+ const appliedCount = (db: DbClient): number =>
62
+ db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
63
+ ?.c ?? 0;
64
+
65
+ test('converges instead of dying on the first already-applied statement', () => {
66
+ const { db } = freshDb();
67
+ const migrationCount = appliedCount(db);
68
+ freezeWatermark(db);
69
+ expect(appliedCount(db)).toBe(1);
70
+
71
+ // Red before the ledger repair: drizzle re-runs 0001 and throws
72
+ // "duplicate column name: role", leaving the ledger frozen.
73
+ expect(() => runMigrationsOn(db)).not.toThrow();
74
+
75
+ expect(appliedCount(db)).toBe(migrationCount);
76
+ });
77
+
78
+ test('leaves the schema whole, so the drift detector goes green', () => {
79
+ const { db } = freshDb();
80
+ freezeWatermark(db);
81
+
82
+ runMigrationsOn(db);
83
+
84
+ const drift = findSchemaDrift(db.$client);
85
+ expect(drift.missingTables).toEqual([]);
86
+ expect(drift.missingColumns).toEqual([]);
87
+ });
88
+
89
+ test('is idempotent — a second pass applies nothing and still converges', () => {
90
+ const { db } = freshDb();
91
+ freezeWatermark(db);
92
+ runMigrationsOn(db);
93
+ const afterBaseline = appliedCount(db);
94
+
95
+ runMigrationsOn(db);
96
+
97
+ expect(appliedCount(db)).toBe(afterBaseline);
98
+ });
99
+
100
+ /**
101
+ * The repair must not become a blanket "assume it already ran". It fires only
102
+ * where the answer is unambiguous — the declared schema is entirely present,
103
+ * so every migration plainly did run and the ledger is what is wrong. A
104
+ * PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
105
+ * was actually in: it carried 0011's column and not 0010's table. Stamping
106
+ * there would record migrations that never ran and bury the missing schema
107
+ * for good, so it keeps failing and a human decides.
108
+ */
109
+ test('refuses to stamp when the schema is only partly there', () => {
110
+ const { db } = freshDb();
111
+ freezeWatermark(db);
112
+ db.$client.run('DROP TABLE `dns_registrations`');
113
+
114
+ expect(() => runMigrationsOn(db)).toThrow();
115
+ // The ledger is left exactly as found, so the drift is still diagnosable.
116
+ expect(appliedCount(db)).toBe(1);
117
+ });
118
+
119
+ /**
120
+ * The path that actually matters. Every celilo command opens the database
121
+ * through createDbClient, which migrates on open, so a frozen watermark
122
+ * failed there rather than anywhere an operator could aim a fix at — and
123
+ * `celilo system migrate`, the command whose whole job is repairing this,
124
+ * reached its own repair through the same open and died first.
125
+ */
126
+ test('repairs on database open, not just when migrate is called by hand', () => {
127
+ const { db, path } = freshDb();
128
+ const migrationCount = appliedCount(db);
129
+ freezeWatermark(db);
130
+ db.$client.close();
131
+
132
+ const reopened = createDbClient({ path });
133
+
134
+ expect(appliedCount(reopened)).toBe(migrationCount);
135
+ expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
136
+ });
137
+
138
+ test('a healthy database is untouched by the fallback', () => {
139
+ const { db } = freshDb();
140
+ const before = appliedCount(db);
141
+
142
+ runMigrationsOn(db);
143
+
144
+ expect(appliedCount(db)).toBe(before);
145
+ expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
146
+ });
147
+ });
package/src/db/migrate.ts CHANGED
@@ -1,14 +1,82 @@
1
+ import type { Database } from 'bun:sqlite';
1
2
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
3
+ import { readMigrationFiles } from 'drizzle-orm/migrator';
2
4
  import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
5
+ import { findSchemaDrift } from './schema-introspection';
6
+
7
+ /**
8
+ * Record every migration past the ledger's watermark as applied, running none
9
+ * of them. Only safe when the caller has already established that the schema
10
+ * the code declares is entirely present.
11
+ *
12
+ * Replaying the statements instead would be wrong, and quietly so. Migrations
13
+ * are not idempotent: `0021_dns_registration_consumers` rebuilds a table by
14
+ * copying it aside, `DROP TABLE`-ing the original and renaming the copy over
15
+ * it. Run that against a schema that is already current and it drops a live
16
+ * table. Skipping the statements that fail with "already exists" does not save
17
+ * you either, because `DROP` and `INSERT ... SELECT` do not fail that way.
18
+ *
19
+ * So the ledger is corrected and the schema is left alone.
20
+ */
21
+ function stampLedgerAsApplied(sqlite: Database, migrationsFolder: string): void {
22
+ const newest = sqlite
23
+ .query<{ created_at: number }, []>(
24
+ 'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1',
25
+ )
26
+ .get();
27
+ const watermark = Number(newest?.created_at ?? 0);
28
+
29
+ sqlite.run('BEGIN');
30
+ try {
31
+ for (const migration of readMigrationFiles({ migrationsFolder })) {
32
+ if (migration.folderMillis <= watermark) continue;
33
+ sqlite.run('INSERT INTO `__drizzle_migrations` ("hash", "created_at") VALUES (?, ?)', [
34
+ migration.hash,
35
+ migration.folderMillis,
36
+ ]);
37
+ }
38
+ sqlite.run('COMMIT');
39
+ } catch (error) {
40
+ sqlite.run('ROLLBACK');
41
+ throw error;
42
+ }
43
+ }
3
44
 
4
45
  /**
5
46
  * Apply pending drizzle migrations to an open DB. Idempotent — drizzle applies
6
47
  * only migrations newer than the latest recorded in `__drizzle_migrations`.
7
48
  * The single migration mechanism (ISS-0100); createDbClient also calls this
8
49
  * shape on open (auto-migrate).
50
+ *
51
+ * The stock migrator runs first and handles every database celilo has written
52
+ * since ISS-0100, so a healthy box takes exactly the path it took before.
53
+ *
54
+ * It fails on one database celilo did not write: one from the imperative
55
+ * hand-list era, where schema changes were applied directly and
56
+ * `__drizzle_migrations` never recorded them. Its ledger remembers an old
57
+ * migration while the objects of every later one are already there, and
58
+ * drizzle — being watermark-only — re-runs them and dies on the first
59
+ * `ALTER TABLE ... ADD`. The throw happens inside `createDbClient`, so every
60
+ * celilo command on that box fails at database open. celilo-mgr, the one box
61
+ * in that state, was remediated by hand (celilo#169); this is so the next one
62
+ * is not.
63
+ *
64
+ * The repair is only attempted when the schema the code declares is ALREADY
65
+ * COMPLETE, because that is the one case with an unambiguous answer: every
66
+ * migration has plainly run, so the ledger is what is wrong. A database missing
67
+ * some of it is the genuinely hard case — celilo-mgr had 0011's column and not
68
+ * 0010's table — and there is no safe automatic answer, so it keeps failing
69
+ * with drizzle's own error and a human decides.
9
70
  */
10
71
  export function runMigrationsOn(db: DbClient): void {
11
- migrate(db, { migrationsFolder: findMigrationsFolder() });
72
+ const migrationsFolder = findMigrationsFolder();
73
+ try {
74
+ migrate(db, { migrationsFolder });
75
+ } catch (error) {
76
+ const drift = findSchemaDrift(db.$client);
77
+ if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) throw error;
78
+ stampLedgerAsApplied(db.$client, migrationsFolder);
79
+ }
12
80
  }
13
81
 
14
82
  /**