@celilo/cli 0.1.5 → 0.1.7

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 (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -0,0 +1,233 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModuleManifest } from '../../manifest/schema';
3
+ import { type InstalledModuleBackupInfo, auditBackups } from './backups';
4
+
5
+ const NOW = new Date('2026-04-25T00:00:00Z').getTime();
6
+ const HOUR = 60 * 60 * 1000;
7
+ const ONE_DAY = 24 * HOUR;
8
+
9
+ type Schedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
10
+
11
+ function makeModule(
12
+ id: string,
13
+ opts: {
14
+ hasBackupHook: boolean;
15
+ lastSuccessfulBackupAt: number | null;
16
+ schedule?: Schedule;
17
+ },
18
+ ): InstalledModuleBackupInfo {
19
+ const manifest = {
20
+ id,
21
+ name: id,
22
+ version: '1.0.0',
23
+ celilo_contract: '1.0',
24
+ hooks: opts.hasBackupHook ? { on_backup: { script: './backup.ts' } } : {},
25
+ backup: opts.schedule ? { schedule: opts.schedule } : undefined,
26
+ } as unknown as ModuleManifest;
27
+ return { id, manifest, lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt };
28
+ }
29
+
30
+ describe('auditBackups', () => {
31
+ test('skips modules without an on_backup hook', async () => {
32
+ const result = await auditBackups({
33
+ modules: [makeModule('caddy', { hasBackupHook: false, lastSuccessfulBackupAt: null })],
34
+ now: () => NOW,
35
+ });
36
+ expect(result).toEqual([]);
37
+ });
38
+
39
+ test('reports missing backup regardless of schedule (still want at least one)', async () => {
40
+ const result = await auditBackups({
41
+ modules: [
42
+ makeModule('lunacycle', {
43
+ hasBackupHook: true,
44
+ lastSuccessfulBackupAt: null,
45
+ schedule: 'manual',
46
+ }),
47
+ ],
48
+ now: () => NOW,
49
+ });
50
+
51
+ expect(result).toHaveLength(1);
52
+ expect(result[0]).toMatchObject({
53
+ category: 'backups',
54
+ severity: 'drift',
55
+ code: 'backup_missing',
56
+ subject: 'lunacycle',
57
+ });
58
+ expect(result[0].message).toContain('manual');
59
+ });
60
+
61
+ test('manual schedule: never flags stale (user-driven cadence)', async () => {
62
+ const result = await auditBackups({
63
+ modules: [
64
+ makeModule('lunacycle', {
65
+ hasBackupHook: true,
66
+ lastSuccessfulBackupAt: NOW - 365 * ONE_DAY, // 1 year old
67
+ schedule: 'manual',
68
+ }),
69
+ ],
70
+ now: () => NOW,
71
+ });
72
+ expect(result).toEqual([]);
73
+ });
74
+
75
+ test('no schedule declared → treated as manual (no stale flag)', async () => {
76
+ const result = await auditBackups({
77
+ modules: [
78
+ makeModule('lunacycle', {
79
+ hasBackupHook: true,
80
+ lastSuccessfulBackupAt: NOW - 365 * ONE_DAY,
81
+ }),
82
+ ],
83
+ now: () => NOW,
84
+ });
85
+ expect(result).toEqual([]);
86
+ });
87
+
88
+ test('daily schedule: 26h-old is stale', async () => {
89
+ const result = await auditBackups({
90
+ modules: [
91
+ makeModule('authentik', {
92
+ hasBackupHook: true,
93
+ lastSuccessfulBackupAt: NOW - 26 * HOUR,
94
+ schedule: 'daily',
95
+ }),
96
+ ],
97
+ now: () => NOW,
98
+ });
99
+ expect(result).toHaveLength(1);
100
+ expect(result[0].code).toBe('backup_stale');
101
+ expect(result[0].message).toContain('daily');
102
+ });
103
+
104
+ test('daily schedule: 24h-old is fresh (within grace)', async () => {
105
+ const result = await auditBackups({
106
+ modules: [
107
+ makeModule('authentik', {
108
+ hasBackupHook: true,
109
+ lastSuccessfulBackupAt: NOW - 24 * HOUR,
110
+ schedule: 'daily',
111
+ }),
112
+ ],
113
+ now: () => NOW,
114
+ });
115
+ expect(result).toEqual([]);
116
+ });
117
+
118
+ test('hourly schedule: 3h-old is stale', async () => {
119
+ const result = await auditBackups({
120
+ modules: [
121
+ makeModule('chat', {
122
+ hasBackupHook: true,
123
+ lastSuccessfulBackupAt: NOW - 3 * HOUR,
124
+ schedule: 'hourly',
125
+ }),
126
+ ],
127
+ now: () => NOW,
128
+ });
129
+ expect(result).toHaveLength(1);
130
+ expect(result[0].code).toBe('backup_stale');
131
+ });
132
+
133
+ test('weekly schedule: 5d-old is fresh', async () => {
134
+ const result = await auditBackups({
135
+ modules: [
136
+ makeModule('archive', {
137
+ hasBackupHook: true,
138
+ lastSuccessfulBackupAt: NOW - 5 * ONE_DAY,
139
+ schedule: 'weekly',
140
+ }),
141
+ ],
142
+ now: () => NOW,
143
+ });
144
+ expect(result).toEqual([]);
145
+ });
146
+
147
+ test('weekly schedule: 10d-old is stale', async () => {
148
+ const result = await auditBackups({
149
+ modules: [
150
+ makeModule('archive', {
151
+ hasBackupHook: true,
152
+ lastSuccessfulBackupAt: NOW - 10 * ONE_DAY,
153
+ schedule: 'weekly',
154
+ }),
155
+ ],
156
+ now: () => NOW,
157
+ });
158
+ expect(result).toHaveLength(1);
159
+ expect(result[0].code).toBe('backup_stale');
160
+ });
161
+
162
+ test('monthly schedule: 30d-old is fresh', async () => {
163
+ const result = await auditBackups({
164
+ modules: [
165
+ makeModule('cold-store', {
166
+ hasBackupHook: true,
167
+ lastSuccessfulBackupAt: NOW - 30 * ONE_DAY,
168
+ schedule: 'monthly',
169
+ }),
170
+ ],
171
+ now: () => NOW,
172
+ });
173
+ expect(result).toEqual([]);
174
+ });
175
+
176
+ test('monthly schedule: 35d-old is stale', async () => {
177
+ const result = await auditBackups({
178
+ modules: [
179
+ makeModule('cold-store', {
180
+ hasBackupHook: true,
181
+ lastSuccessfulBackupAt: NOW - 35 * ONE_DAY,
182
+ schedule: 'monthly',
183
+ }),
184
+ ],
185
+ now: () => NOW,
186
+ });
187
+ expect(result).toHaveLength(1);
188
+ expect(result[0].code).toBe('backup_stale');
189
+ });
190
+
191
+ test('staleAfterMs override forces threshold for every module', async () => {
192
+ const result = await auditBackups({
193
+ modules: [
194
+ makeModule('lunacycle', {
195
+ hasBackupHook: true,
196
+ lastSuccessfulBackupAt: NOW - 2 * ONE_DAY,
197
+ schedule: 'monthly', // would normally be fresh
198
+ }),
199
+ ],
200
+ staleAfterMs: ONE_DAY,
201
+ now: () => NOW,
202
+ });
203
+ expect(result).toHaveLength(1);
204
+ expect(result[0].code).toBe('backup_stale');
205
+ });
206
+
207
+ test('mixed report across multiple modules', async () => {
208
+ const result = await auditBackups({
209
+ modules: [
210
+ makeModule('caddy', { hasBackupHook: false, lastSuccessfulBackupAt: null }), // skipped
211
+ makeModule('lunacycle', {
212
+ hasBackupHook: true,
213
+ lastSuccessfulBackupAt: NOW - 3 * HOUR,
214
+ schedule: 'daily',
215
+ }), // fresh
216
+ makeModule('authentik', {
217
+ hasBackupHook: true,
218
+ lastSuccessfulBackupAt: null,
219
+ schedule: 'daily',
220
+ }), // missing
221
+ makeModule('homebridge', {
222
+ hasBackupHook: true,
223
+ lastSuccessfulBackupAt: NOW - 30 * ONE_DAY,
224
+ schedule: 'weekly',
225
+ }), // stale
226
+ ],
227
+ now: () => NOW,
228
+ });
229
+
230
+ expect(result).toHaveLength(2);
231
+ expect(result.map((f) => f.subject).sort()).toEqual(['authentik', 'homebridge']);
232
+ });
233
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Backup freshness drift check.
3
+ *
4
+ * For each installed module that declares an `on_backup` hook,
5
+ * decides whether the most recent successful backup is too old based
6
+ * on the module's declared `manifest.backup.schedule`. Each schedule
7
+ * tier has a threshold with a small grace window so a slightly-late
8
+ * scheduled run doesn't flag drift on every audit.
9
+ *
10
+ * Modules without an `on_backup` hook are skipped — there's nothing
11
+ * to back up. Modules whose schedule is `manual` (or unset) skip the
12
+ * staleness check (the user decides cadence) but still get a
13
+ * `backup_missing` finding if no backup has ever been recorded.
14
+ *
15
+ * Time is injected so tests can pin "now" deterministically.
16
+ */
17
+
18
+ import type { ModuleManifest } from '../../manifest/schema';
19
+ import type { DriftFinding } from './types';
20
+
21
+ export interface InstalledModuleBackupInfo {
22
+ id: string;
23
+ manifest: ModuleManifest;
24
+ /** Most recent successful backup timestamp (ms since epoch), or null. */
25
+ lastSuccessfulBackupAt: number | null;
26
+ }
27
+
28
+ export interface BackupsAuditDeps {
29
+ modules: InstalledModuleBackupInfo[];
30
+ /**
31
+ * Test-only override: forces this threshold for every module,
32
+ * ignoring `manifest.backup.schedule`. Production code never sets
33
+ * this — schedule-based thresholds are the right behavior.
34
+ */
35
+ staleAfterMs?: number;
36
+ /** Defaults to `Date.now()`. */
37
+ now?: () => number;
38
+ }
39
+
40
+ const HOUR = 60 * 60 * 1000;
41
+ const DAY = 24 * HOUR;
42
+
43
+ /**
44
+ * Schedule → stale threshold. Each tier gets a grace window so a
45
+ * legitimately-just-late run doesn't trip the audit:
46
+ *
47
+ * - hourly → 2h (1h cadence + 1h grace)
48
+ * - daily → 25h (24h + 1h grace)
49
+ * - weekly → 8d (7d + 1d grace)
50
+ * - monthly → 32d (~30d + 2d grace)
51
+ * - manual → null (no staleness check; user-driven cadence)
52
+ *
53
+ * `undefined` (no `backup:` block in manifest) is treated as
54
+ * `manual` — author opted out of declaring a cadence.
55
+ */
56
+ const SCHEDULE_THRESHOLDS = {
57
+ hourly: 2 * HOUR,
58
+ daily: 25 * HOUR,
59
+ weekly: 8 * DAY,
60
+ monthly: 32 * DAY,
61
+ manual: null,
62
+ } as const;
63
+
64
+ type ScheduleKey = keyof typeof SCHEDULE_THRESHOLDS;
65
+
66
+ function moduleHasBackupHook(manifest: ModuleManifest): boolean {
67
+ return Boolean(manifest.hooks?.on_backup);
68
+ }
69
+
70
+ function scheduleFor(manifest: ModuleManifest): ScheduleKey {
71
+ const s = manifest.backup?.schedule;
72
+ if (s === 'hourly' || s === 'daily' || s === 'weekly' || s === 'monthly') return s;
73
+ return 'manual';
74
+ }
75
+
76
+ function thresholdFor(manifest: ModuleManifest, override: number | undefined): number | null {
77
+ if (override !== undefined) return override;
78
+ return SCHEDULE_THRESHOLDS[scheduleFor(manifest)];
79
+ }
80
+
81
+ function formatAge(ms: number): string {
82
+ const days = Math.floor(ms / DAY);
83
+ if (days >= 1) return `${days}d`;
84
+ const hours = Math.floor(ms / HOUR);
85
+ if (hours >= 1) return `${hours}h`;
86
+ const mins = Math.floor(ms / (60 * 1000));
87
+ return `${mins}m`;
88
+ }
89
+
90
+ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding[]> {
91
+ const now = (deps.now ?? Date.now)();
92
+ const findings: DriftFinding[] = [];
93
+
94
+ for (const m of deps.modules) {
95
+ if (!moduleHasBackupHook(m.manifest)) continue;
96
+
97
+ if (m.lastSuccessfulBackupAt === null) {
98
+ findings.push({
99
+ category: 'backups',
100
+ severity: 'drift',
101
+ code: 'backup_missing',
102
+ message: `${m.id}: no successful backup recorded (schedule: ${scheduleFor(m.manifest)})`,
103
+ remediation: `celilo backup create ${m.id} --force`,
104
+ actionable: true,
105
+ subject: m.id,
106
+ });
107
+ continue;
108
+ }
109
+
110
+ const threshold = thresholdFor(m.manifest, deps.staleAfterMs);
111
+ if (threshold === null) continue; // manual cadence — staleness is user-defined
112
+
113
+ const age = now - m.lastSuccessfulBackupAt;
114
+ if (age > threshold) {
115
+ findings.push({
116
+ category: 'backups',
117
+ severity: 'drift',
118
+ code: 'backup_stale',
119
+ message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${scheduleFor(m.manifest)}, threshold: ${formatAge(threshold)})`,
120
+ remediation: `celilo backup create ${m.id} --force`,
121
+ actionable: true,
122
+ subject: m.id,
123
+ });
124
+ }
125
+ }
126
+
127
+ return findings;
128
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModuleManifest } from '../../manifest/schema';
3
+ import { type InstalledCapabilityModule, auditCapabilityAbi } from './capability-abi';
4
+
5
+ function makeModule(
6
+ id: string,
7
+ opts: {
8
+ provides?: Array<{ name: string; version: string }>;
9
+ requires?: Array<{ name: string; version: string }>;
10
+ optional?: Array<{ name: string; version: string }>;
11
+ } = {},
12
+ ): InstalledCapabilityModule {
13
+ const manifest = {
14
+ id,
15
+ name: id,
16
+ version: '1.0.0',
17
+ celilo_contract: '1.0',
18
+ provides: opts.provides
19
+ ? {
20
+ capabilities: opts.provides.map((p) => ({ ...p, data: {}, functions: [] })),
21
+ }
22
+ : { capabilities: [] },
23
+ requires: opts.requires ? { capabilities: opts.requires } : { capabilities: [] },
24
+ optional: opts.optional ? { capabilities: opts.optional } : undefined,
25
+ } as unknown as ModuleManifest;
26
+ return { id, manifest };
27
+ }
28
+
29
+ const RUNTIME = {
30
+ public_web: '2.0.0',
31
+ idp: '1.0.0',
32
+ dns_registrar: '3.0.0',
33
+ firewall: '1.0.0',
34
+ dhcp_server: '1.0.0',
35
+ dns_internal: '1.0.0',
36
+ };
37
+
38
+ describe('auditCapabilityAbi', () => {
39
+ test('no findings when every provider claim matches runtime exactly', async () => {
40
+ const result = await auditCapabilityAbi({
41
+ modules: [
42
+ makeModule('caddy', { provides: [{ name: 'public_web', version: '2.0.0' }] }),
43
+ makeModule('namecheap', { provides: [{ name: 'dns_registrar', version: '3.0.0' }] }),
44
+ ],
45
+ contractVersions: RUNTIME,
46
+ });
47
+ expect(result).toEqual([]);
48
+ });
49
+
50
+ test('blocked when a provider claims a major-version older than runtime', async () => {
51
+ const result = await auditCapabilityAbi({
52
+ modules: [makeModule('caddy', { provides: [{ name: 'public_web', version: '1.0.0' }] })],
53
+ contractVersions: RUNTIME,
54
+ });
55
+
56
+ expect(result).toHaveLength(1);
57
+ expect(result[0]).toMatchObject({
58
+ category: 'capability_abi',
59
+ severity: 'blocked',
60
+ code: 'capability_abi_provider_mismatch',
61
+ subject: 'caddy',
62
+ actionable: false,
63
+ });
64
+ expect(result[0].details).toContain('cd modules/caddy');
65
+ expect(result[0].details).toContain('Application Binary Interface');
66
+ });
67
+
68
+ test('blocked when a provider claims a major-version newer than runtime', async () => {
69
+ const result = await auditCapabilityAbi({
70
+ modules: [makeModule('caddy', { provides: [{ name: 'public_web', version: '3.0.0' }] })],
71
+ contractVersions: RUNTIME,
72
+ });
73
+
74
+ expect(result).toHaveLength(1);
75
+ expect(result[0].actionable).toBe(false);
76
+ expect(result[0].details).toContain('Upgrade celilo');
77
+ });
78
+
79
+ test('skips capabilities not in the runtime registry (third-party)', async () => {
80
+ const result = await auditCapabilityAbi({
81
+ modules: [
82
+ makeModule('odd-module', { provides: [{ name: 'custom_metric', version: '99.0.0' }] }),
83
+ ],
84
+ contractVersions: RUNTIME,
85
+ });
86
+ expect(result).toEqual([]);
87
+ });
88
+
89
+ test('blocked when consumer requires a major-version mismatch with the deployed provider', async () => {
90
+ const result = await auditCapabilityAbi({
91
+ modules: [
92
+ makeModule('namecheap', { provides: [{ name: 'dns_registrar', version: '3.0.0' }] }),
93
+ makeModule('caddy', { requires: [{ name: 'dns_registrar', version: '2.0.0' }] }),
94
+ ],
95
+ contractVersions: RUNTIME,
96
+ });
97
+
98
+ const caddyFinding = result.find(
99
+ (f) => f.subject === 'caddy' && f.code === 'capability_abi_consumer_mismatch',
100
+ );
101
+ expect(caddyFinding).toBeDefined();
102
+ expect(caddyFinding?.message).toContain('caddy requires dns_registrar@2.0.0');
103
+ expect(caddyFinding?.message).toContain('namecheap provides 3.0.0');
104
+ });
105
+
106
+ test('blocked when consumer requires a newer minor than provider has', async () => {
107
+ const result = await auditCapabilityAbi({
108
+ modules: [
109
+ makeModule('namecheap', { provides: [{ name: 'dns_registrar', version: '3.0.0' }] }),
110
+ makeModule('lunacycle', { requires: [{ name: 'dns_registrar', version: '3.5.0' }] }),
111
+ ],
112
+ contractVersions: { ...RUNTIME, dns_registrar: '3.5.0' },
113
+ });
114
+
115
+ const finding = result.find((f) => f.subject === 'lunacycle');
116
+ expect(finding).toBeDefined();
117
+ expect(finding?.actionable).toBe(false);
118
+ expect(finding?.details).toContain('Upgrade namecheap');
119
+ });
120
+
121
+ test('no consumer finding when no provider is installed (deploy preflight handles it)', async () => {
122
+ const result = await auditCapabilityAbi({
123
+ modules: [makeModule('lunacycle', { requires: [{ name: 'idp', version: '1.0.0' }] })],
124
+ contractVersions: RUNTIME,
125
+ });
126
+ expect(result.find((f) => f.code === 'capability_abi_consumer_mismatch')).toBeUndefined();
127
+ });
128
+
129
+ test('compatible: consumer requires older minor than provider has (additive)', async () => {
130
+ const result = await auditCapabilityAbi({
131
+ modules: [
132
+ makeModule('namecheap', { provides: [{ name: 'dns_registrar', version: '3.5.0' }] }),
133
+ makeModule('caddy', { requires: [{ name: 'dns_registrar', version: '3.0.0' }] }),
134
+ ],
135
+ contractVersions: { ...RUNTIME, dns_registrar: '3.5.0' },
136
+ });
137
+ expect(result).toEqual([]);
138
+ });
139
+
140
+ test('also checks optional capabilities (they cause findings on mismatch)', async () => {
141
+ const result = await auditCapabilityAbi({
142
+ modules: [
143
+ makeModule('namecheap', { provides: [{ name: 'dns_registrar', version: '3.0.0' }] }),
144
+ makeModule('caddy', { optional: [{ name: 'dns_registrar', version: '2.0.0' }] }),
145
+ ],
146
+ contractVersions: RUNTIME,
147
+ });
148
+
149
+ expect(
150
+ result.some((f) => f.subject === 'caddy' && f.code === 'capability_abi_consumer_mismatch'),
151
+ ).toBe(true);
152
+ });
153
+ });