@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,60 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { auditServicesReachable } from './services-reachable';
3
+
4
+ describe('auditServicesReachable', () => {
5
+ test('no findings when every service is reachable', async () => {
6
+ const result = await auditServicesReachable({
7
+ results: [
8
+ {
9
+ serviceId: 'home-proxmox',
10
+ name: 'Home Proxmox',
11
+ providerName: 'proxmox',
12
+ reachable: true,
13
+ },
14
+ { serviceId: 'do', name: 'DigitalOcean', providerName: 'digitalocean', reachable: true },
15
+ ],
16
+ });
17
+ expect(result).toEqual([]);
18
+ });
19
+
20
+ test('drift finding per unreachable service', async () => {
21
+ const result = await auditServicesReachable({
22
+ results: [
23
+ {
24
+ serviceId: 'home-proxmox',
25
+ name: 'Home Proxmox',
26
+ providerName: 'proxmox',
27
+ reachable: false,
28
+ message: 'Connection refused',
29
+ },
30
+ ],
31
+ });
32
+ expect(result).toHaveLength(1);
33
+ expect(result[0]).toMatchObject({
34
+ category: 'services_reachable',
35
+ severity: 'drift',
36
+ code: 'service_unreachable',
37
+ subject: 'home-proxmox',
38
+ actionable: false,
39
+ });
40
+ expect(result[0].message).toContain('Home Proxmox');
41
+ expect(result[0].details).toContain('Connection refused');
42
+ expect(result[0].remediation).toContain('celilo service set-credentials home-proxmox');
43
+ });
44
+
45
+ test('mixed report — only failures produce findings', async () => {
46
+ const result = await auditServicesReachable({
47
+ results: [
48
+ { serviceId: 'good', name: 'Good', providerName: 'proxmox', reachable: true },
49
+ {
50
+ serviceId: 'bad',
51
+ name: 'Bad',
52
+ providerName: 'proxmox',
53
+ reachable: false,
54
+ message: 'oops',
55
+ },
56
+ ],
57
+ });
58
+ expect(result.map((f) => f.subject)).toEqual(['bad']);
59
+ });
60
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Services-reachable check.
3
+ *
4
+ * For each container service, attempts a low-cost API ping
5
+ * (Proxmox `/version`, DigitalOcean `/v2/account`). Catches:
6
+ *
7
+ * - Service host down or behind a firewall.
8
+ * - API token revoked or expired.
9
+ * - Wrong endpoint URL.
10
+ *
11
+ * Severity is `drift` rather than `blocked` — a deploy can still
12
+ * succeed if the operator restarts the service or fixes the
13
+ * network before retrying. Surfacing here just gives the user
14
+ * earlier signal than a deploy-time timeout.
15
+ *
16
+ * Like `services_credentials`, the audit consumes pre-computed
17
+ * results so it stays unit-testable without hitting a real API.
18
+ */
19
+
20
+ import type { DriftFinding } from './types';
21
+
22
+ export interface ServiceReachableResult {
23
+ /** User-facing kebab-case service ID. */
24
+ serviceId: string;
25
+ /** Display name for the finding message. */
26
+ name: string;
27
+ providerName: string;
28
+ /** True if the API ping succeeded. */
29
+ reachable: boolean;
30
+ /** Short description of failure when `!reachable`. */
31
+ message?: string;
32
+ }
33
+
34
+ export interface ServicesReachableAuditDeps {
35
+ results: ServiceReachableResult[];
36
+ }
37
+
38
+ export async function auditServicesReachable(
39
+ deps: ServicesReachableAuditDeps,
40
+ ): Promise<DriftFinding[]> {
41
+ const findings: DriftFinding[] = [];
42
+
43
+ for (const r of deps.results) {
44
+ if (r.reachable) continue;
45
+
46
+ findings.push({
47
+ category: 'services_reachable',
48
+ severity: 'drift',
49
+ code: 'service_unreachable',
50
+ message: `${r.name} (${r.providerName}): API unreachable`,
51
+ details: r.message,
52
+ remediation: [
53
+ 'Verify the service host is up and the API endpoint is',
54
+ 'correct. If the API token was rotated, re-set it:',
55
+ ` celilo service set-credentials ${r.serviceId}`,
56
+ ].join('\n'),
57
+ // Diagnostic / interactive — no one-shot fix.
58
+ actionable: false,
59
+ subject: r.serviceId,
60
+ });
61
+ }
62
+
63
+ return findings;
64
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type TerraformPlanRunner, auditTerraformPlan, parsePlanSummary } from './terraform-plan';
3
+
4
+ describe('parsePlanSummary', () => {
5
+ test('parses a typical Plan summary line', () => {
6
+ const stdout = `
7
+ Terraform will perform the following actions:
8
+ ...
9
+ Plan: 2 to add, 1 to change, 3 to destroy.
10
+ `;
11
+ expect(parsePlanSummary(stdout)).toEqual({ add: 2, change: 1, destroy: 3 });
12
+ });
13
+
14
+ test('treats "No changes." as zero plan', () => {
15
+ expect(parsePlanSummary('No changes. Your infrastructure matches the configuration.')).toEqual({
16
+ add: 0,
17
+ change: 0,
18
+ destroy: 0,
19
+ });
20
+ });
21
+
22
+ test('strips ANSI color codes', () => {
23
+ const stdout = 'Plan: 1 to add, 0 to change, 0 to destroy.';
24
+ expect(parsePlanSummary(stdout)).toEqual({ add: 1, change: 0, destroy: 0 });
25
+ });
26
+
27
+ test('returns null when no recognized pattern is present', () => {
28
+ expect(parsePlanSummary('something went sideways')).toBeNull();
29
+ });
30
+ });
31
+
32
+ describe('auditTerraformPlan', () => {
33
+ const noChange: TerraformPlanRunner = async () => ({
34
+ exitCode: 0,
35
+ stdout: 'No changes. Your infrastructure matches the configuration.',
36
+ stderr: '',
37
+ });
38
+
39
+ test('skips modules without a terraform dir', async () => {
40
+ const result = await auditTerraformPlan({
41
+ modules: [{ id: 'caddy', terraformDir: null }],
42
+ run: noChange,
43
+ });
44
+ expect(result).toEqual([]);
45
+ });
46
+
47
+ test('no finding when plan is empty', async () => {
48
+ const result = await auditTerraformPlan({
49
+ modules: [{ id: 'caddy', terraformDir: '/tf/caddy' }],
50
+ run: noChange,
51
+ });
52
+ expect(result).toEqual([]);
53
+ });
54
+
55
+ test('drift finding for additive plan (no destroys)', async () => {
56
+ const run: TerraformPlanRunner = async () => ({
57
+ exitCode: 2,
58
+ stdout: 'Plan: 1 to add, 0 to change, 0 to destroy.',
59
+ stderr: '',
60
+ });
61
+ const result = await auditTerraformPlan({
62
+ modules: [{ id: 'caddy', terraformDir: '/tf/caddy' }],
63
+ run,
64
+ });
65
+
66
+ expect(result).toHaveLength(1);
67
+ expect(result[0]).toMatchObject({
68
+ severity: 'drift',
69
+ code: 'terraform_plan_pending',
70
+ subject: 'caddy',
71
+ });
72
+ expect(result[0].message).toContain('+1 ~0');
73
+ });
74
+
75
+ test('blocked finding for destructive plan', async () => {
76
+ const run: TerraformPlanRunner = async () => ({
77
+ exitCode: 2,
78
+ stdout: 'Plan: 0 to add, 1 to change, 2 to destroy.',
79
+ stderr: '',
80
+ });
81
+ const result = await auditTerraformPlan({
82
+ modules: [{ id: 'caddy', terraformDir: '/tf/caddy' }],
83
+ run,
84
+ });
85
+
86
+ expect(result).toHaveLength(1);
87
+ expect(result[0]).toMatchObject({
88
+ severity: 'blocked',
89
+ code: 'terraform_plan_destructive',
90
+ });
91
+ expect(result[0].message).toContain('would destroy 2 resources');
92
+ expect(result[0].remediation).toContain('--allow-destructive');
93
+ });
94
+
95
+ test('drift finding when terraform plan command fails', async () => {
96
+ const run: TerraformPlanRunner = async () => ({
97
+ exitCode: 1,
98
+ stdout: '',
99
+ stderr: 'Error: provider authentication failed',
100
+ });
101
+ const result = await auditTerraformPlan({
102
+ modules: [{ id: 'caddy', terraformDir: '/tf/caddy' }],
103
+ run,
104
+ });
105
+
106
+ expect(result).toHaveLength(1);
107
+ expect(result[0]).toMatchObject({
108
+ severity: 'drift',
109
+ code: 'terraform_plan_failed',
110
+ });
111
+ expect(result[0].details).toContain('provider authentication failed');
112
+ });
113
+
114
+ test('singular wording for exactly one destroy', async () => {
115
+ const run: TerraformPlanRunner = async () => ({
116
+ exitCode: 2,
117
+ stdout: 'Plan: 0 to add, 0 to change, 1 to destroy.',
118
+ stderr: '',
119
+ });
120
+ const result = await auditTerraformPlan({
121
+ modules: [{ id: 'caddy', terraformDir: '/tf/caddy' }],
122
+ run,
123
+ });
124
+ expect(result[0].message).toContain('would destroy 1 resource (');
125
+ expect(result[0].message).not.toContain('1 resources');
126
+ });
127
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Terraform plan drift check.
3
+ *
4
+ * For each installed module that has a generated terraform directory,
5
+ * run `terraform plan -detailed-exitcode -no-color` and parse the
6
+ * summary line ("Plan: 1 to add, 0 to change, 2 to destroy."). A plan
7
+ * that includes destructive operations (delete/replace) produces a
8
+ * `blocked` finding — `system update` would otherwise silently let
9
+ * Terraform delete an LXC the user didn't expect to lose. A
10
+ * non-destructive plan (additions / in-place changes only) produces
11
+ * a `drift` finding.
12
+ *
13
+ * The shell runner is injectable so unit tests assert on parsing
14
+ * logic without invoking the real `terraform` binary.
15
+ */
16
+
17
+ import type { DriftFinding } from './types';
18
+
19
+ export interface TerraformPlanModule {
20
+ id: string;
21
+ /** Path to the module's generated terraform dir, or null if not yet generated. */
22
+ terraformDir: string | null;
23
+ /**
24
+ * Provider credentials to inject as `TF_VAR_*` env vars. Empty
25
+ * for machine-pool deployments; populated from container-service
26
+ * credentials for Proxmox/DigitalOcean modules. Mirrors what
27
+ * `module deploy` injects so the audit's plan check sees the same
28
+ * world the deployer would.
29
+ */
30
+ envVars?: Record<string, string>;
31
+ }
32
+
33
+ export interface TerraformPlanRunResult {
34
+ exitCode: number;
35
+ stdout: string;
36
+ stderr: string;
37
+ }
38
+
39
+ export type TerraformPlanRunner = (
40
+ terraformDir: string,
41
+ envVars?: Record<string, string>,
42
+ ) => Promise<TerraformPlanRunResult>;
43
+
44
+ export interface TerraformPlanAuditDeps {
45
+ modules: TerraformPlanModule[];
46
+ run: TerraformPlanRunner;
47
+ }
48
+
49
+ /**
50
+ * Parses the typical "Plan: N to add, M to change, K to destroy."
51
+ * line in `terraform plan` output. Returns null if no Plan: line is
52
+ * found (e.g., empty plan, or Terraform output format changed).
53
+ */
54
+ export interface PlanSummary {
55
+ add: number;
56
+ change: number;
57
+ destroy: number;
58
+ }
59
+
60
+ // Built from a string at runtime so biome's "control character in
61
+ // regex literal" rule doesn't flag the embedded ESC byte. ANSI CSI
62
+ // sequences are `ESC [ params m`.
63
+ const ANSI_COLOR_RE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g');
64
+
65
+ export function parsePlanSummary(stdout: string): PlanSummary | null {
66
+ // Terraform may colorize even with -no-color in some setups; strip ANSI defensively.
67
+ const stripped = stdout.replace(ANSI_COLOR_RE, '');
68
+ const match = stripped.match(
69
+ /Plan:\s+(\d+)\s+to\s+add,\s+(\d+)\s+to\s+change,\s+(\d+)\s+to\s+destroy\b/,
70
+ );
71
+ if (!match) {
72
+ // Empty plan — terraform sometimes prints "No changes." instead.
73
+ if (/No changes\.|Your infrastructure matches the configuration/.test(stripped)) {
74
+ return { add: 0, change: 0, destroy: 0 };
75
+ }
76
+ return null;
77
+ }
78
+ return {
79
+ add: Number.parseInt(match[1], 10),
80
+ change: Number.parseInt(match[2], 10),
81
+ destroy: Number.parseInt(match[3], 10),
82
+ };
83
+ }
84
+
85
+ export async function auditTerraformPlan(deps: TerraformPlanAuditDeps): Promise<DriftFinding[]> {
86
+ const findings: DriftFinding[] = [];
87
+
88
+ for (const m of deps.modules) {
89
+ if (m.terraformDir === null) continue;
90
+
91
+ const result = await deps.run(m.terraformDir, m.envVars);
92
+
93
+ // Exit code 0 = no changes; 2 = changes pending; anything else = error
94
+ if (result.exitCode !== 0 && result.exitCode !== 2) {
95
+ findings.push({
96
+ category: 'terraform_plan',
97
+ severity: 'drift',
98
+ code: 'terraform_plan_failed',
99
+ message: `${m.id}: terraform plan failed (exit ${result.exitCode})`,
100
+ details: (result.stderr || result.stdout).slice(0, 500),
101
+ remediation: [
102
+ 'Inspect the error above, then re-run terraform plan in',
103
+ m.terraformDir,
104
+ 'to debug. Common causes: missing provider credentials,',
105
+ 'broken state, expired tokens.',
106
+ ].join('\n'),
107
+ // Investigatory; no single celilo command resolves it.
108
+ actionable: false,
109
+ subject: m.id,
110
+ });
111
+ continue;
112
+ }
113
+
114
+ const summary = parsePlanSummary(result.stdout);
115
+ if (!summary || (summary.add === 0 && summary.change === 0 && summary.destroy === 0)) {
116
+ // Plan is empty or unparseable-but-non-error — no drift.
117
+ continue;
118
+ }
119
+
120
+ if (summary.destroy > 0) {
121
+ findings.push({
122
+ category: 'terraform_plan',
123
+ severity: 'blocked',
124
+ code: 'terraform_plan_destructive',
125
+ message: `${m.id}: terraform plan would destroy ${summary.destroy} resource${summary.destroy === 1 ? '' : 's'} (+${summary.add} ~${summary.change})`,
126
+ remediation: [
127
+ 'Review the plan carefully. If the destruction is intended,',
128
+ 'run:',
129
+ ' celilo system update --allow-destructive',
130
+ 'Otherwise investigate why the plan wants to destroy',
131
+ 'resources before proceeding.',
132
+ ].join('\n'),
133
+ // The opt-in flag and review step make this a deliberately
134
+ // non-one-click finding — surfacing the modal would let users
135
+ // accidentally fire a destructive update.
136
+ actionable: false,
137
+ subject: m.id,
138
+ });
139
+ } else {
140
+ findings.push({
141
+ category: 'terraform_plan',
142
+ severity: 'drift',
143
+ code: 'terraform_plan_pending',
144
+ message: `${m.id}: terraform plan has +${summary.add} ~${summary.change} changes pending`,
145
+ remediation: `celilo module deploy ${m.id}`,
146
+ actionable: true,
147
+ subject: m.id,
148
+ });
149
+ }
150
+ }
151
+
152
+ return findings;
153
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type DriftFinding, computeVerdict } from './types';
3
+
4
+ const drift: DriftFinding = {
5
+ category: 'module_versions',
6
+ severity: 'drift',
7
+ code: 'module_version_drift',
8
+ message: 'caddy: 1.2.0 → 1.3.0 available',
9
+ subject: 'caddy',
10
+ };
11
+
12
+ const blocked: DriftFinding = {
13
+ category: 'capability_abi',
14
+ severity: 'blocked',
15
+ code: 'capability_abi_mismatch',
16
+ message: 'lunacycle requires public_web@2 but caddy provides 1',
17
+ subject: 'lunacycle',
18
+ };
19
+
20
+ describe('computeVerdict', () => {
21
+ test('READY when no findings', () => {
22
+ expect(computeVerdict([])).toBe('READY');
23
+ });
24
+
25
+ test('DRIFT when only drift-severity findings', () => {
26
+ expect(computeVerdict([drift, { ...drift, subject: 'iptables' }])).toBe('DRIFT');
27
+ });
28
+
29
+ test('BLOCKED when any finding is blocked', () => {
30
+ expect(computeVerdict([drift, blocked])).toBe('BLOCKED');
31
+ });
32
+
33
+ test('BLOCKED takes precedence regardless of order', () => {
34
+ expect(computeVerdict([blocked, drift])).toBe('BLOCKED');
35
+ });
36
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Types for the system-wide drift audit (CELILO_UPDATE D6).
3
+ *
4
+ * `runAudit` returns a `SystemAuditReport` aggregating findings from
5
+ * each drift category. Each finding has a category (one of
6
+ * `DriftCategory`), a severity (`drift` or `blocked`), a stable
7
+ * machine-readable code, and a human-readable message + suggested
8
+ * remediation.
9
+ *
10
+ * The overall verdict is computed from the findings:
11
+ * - any `blocked` finding → BLOCKED
12
+ * - any `drift` finding (no blocked) → DRIFT
13
+ * - no findings → READY
14
+ */
15
+
16
+ export type DriftCategory =
17
+ | 'cli_version'
18
+ | 'schema'
19
+ | 'capability_abi'
20
+ | 'terraform_plan'
21
+ | 'module_versions'
22
+ | 'module_configs'
23
+ | 'health'
24
+ | 'backups'
25
+ | 'undeployed_modules'
26
+ | 'unconfigured_modules'
27
+ | 'services_credentials'
28
+ | 'secrets_decryptable'
29
+ | 'services_reachable'
30
+ | 'machines_reachable';
31
+
32
+ export type DriftSeverity = 'drift' | 'blocked';
33
+
34
+ export type AuditVerdict = 'READY' | 'DRIFT' | 'BLOCKED';
35
+
36
+ /**
37
+ * A single drift finding produced by a category check.
38
+ *
39
+ * - `category`: which check produced this
40
+ * - `severity`: whether it gates `system update` or just informs
41
+ * - `code`: stable machine-readable identifier (e.g. `module_version_drift`),
42
+ * intended for `--json` consumers
43
+ * - `message`: short, human-readable description
44
+ * - `details`: optional multi-line elaboration (e.g. release messages
45
+ * between two versions, or step-by-step guidance for non-actionable
46
+ * findings)
47
+ * - `remediation`: suggested next action — either a runnable
48
+ * `celilo …` command (when `actionable: true`) or human-facing
49
+ * guidance prose (when `actionable: false`)
50
+ * - `actionable`: when `true`, `remediation` is a runnable
51
+ * `celilo …` command and the TUI surfaces a one-keypress
52
+ * "Remediate" modal. When `false`, `remediation` is descriptive
53
+ * guidance and the modal is suppressed (the user has to do code
54
+ * work, edit a config file, or run something outside celilo).
55
+ * Default is `false` — conservative, since a non-runnable
56
+ * remediation surfaced as runnable would crash on submit.
57
+ * - `subject`: the entity the finding is about — usually a module ID,
58
+ * capability name, or `'system'`. Lets the UI group findings.
59
+ */
60
+ export interface DriftFinding {
61
+ category: DriftCategory;
62
+ severity: DriftSeverity;
63
+ code: string;
64
+ message: string;
65
+ details?: string;
66
+ remediation?: string;
67
+ actionable?: boolean;
68
+ subject: string;
69
+ }
70
+
71
+ /**
72
+ * The aggregated report. Stable JSON shape — do not break consumers.
73
+ *
74
+ * `version` is bumped on incompatible schema changes to this object.
75
+ */
76
+ export interface SystemAuditReport {
77
+ version: 1;
78
+ verdict: AuditVerdict;
79
+ generatedAt: string; // ISO-8601 UTC
80
+ findings: DriftFinding[];
81
+ }
82
+
83
+ /**
84
+ * Compute the overall verdict from a set of findings.
85
+ */
86
+ export function computeVerdict(findings: DriftFinding[]): AuditVerdict {
87
+ if (findings.some((f) => f.severity === 'blocked')) return 'BLOCKED';
88
+ if (findings.length > 0) return 'DRIFT';
89
+ return 'READY';
90
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { auditUnconfiguredModules } from './unconfigured-modules';
3
+
4
+ describe('auditUnconfiguredModules', () => {
5
+ test('flags IMPORTED module with zero config rows', async () => {
6
+ const result = await auditUnconfiguredModules({
7
+ modules: [{ id: 'authentik', state: 'IMPORTED', configCount: 0 }],
8
+ });
9
+ expect(result).toHaveLength(1);
10
+ expect(result[0]).toMatchObject({
11
+ category: 'unconfigured_modules',
12
+ severity: 'drift',
13
+ code: 'module_unconfigured',
14
+ actionable: false,
15
+ subject: 'authentik',
16
+ });
17
+ expect(result[0].details).toContain('celilo module config get authentik');
18
+ });
19
+
20
+ test('skips module with at least one config row', async () => {
21
+ const result = await auditUnconfiguredModules({
22
+ modules: [{ id: 'authentik', state: 'IMPORTED', configCount: 3 }],
23
+ });
24
+ expect(result).toEqual([]);
25
+ });
26
+
27
+ test('skips deployed modules even with zero config rows', async () => {
28
+ const result = await auditUnconfiguredModules({
29
+ modules: [
30
+ { id: 'caddy', state: 'INSTALLED', configCount: 0 },
31
+ { id: 'iptables', state: 'VERIFIED', configCount: 0 },
32
+ ],
33
+ });
34
+ expect(result).toEqual([]);
35
+ });
36
+
37
+ test('mixed report — only flags qualifying modules', async () => {
38
+ const result = await auditUnconfiguredModules({
39
+ modules: [
40
+ { id: 'caddy', state: 'INSTALLED', configCount: 0 }, // skip — deployed
41
+ { id: 'authentik', state: 'IMPORTED', configCount: 0 }, // flag
42
+ { id: 'iptables', state: 'IMPORTED', configCount: 5 }, // skip — has configs
43
+ { id: 'lunacycle', state: 'VALIDATED', configCount: 0 }, // flag
44
+ ],
45
+ });
46
+ expect(result.map((f) => f.subject).sort()).toEqual(['authentik', 'lunacycle']);
47
+ });
48
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Unconfigured-modules check.
3
+ *
4
+ * Modules that are imported (or further along the lifecycle) but
5
+ * have *zero* config rows in the DB — i.e. the user has never run
6
+ * the configuration interview. Even if all of the module's required
7
+ * config has defaults, never having gone through the interview is a
8
+ * useful signal: it means the user hasn't reviewed what's there.
9
+ *
10
+ * Reported per-module rather than rolled into `module_configs`
11
+ * findings so the user sees "X has never been configured" as a
12
+ * single category, not as N individual missing-required findings.
13
+ *
14
+ * `INSTALLED` and `VERIFIED` modules are skipped — they were
15
+ * configured at deploy time, even if rows have since been deleted.
16
+ * This check is for *new* imports that haven't been touched yet.
17
+ */
18
+
19
+ import type { DriftFinding } from './types';
20
+
21
+ export interface UnconfiguredModule {
22
+ id: string;
23
+ /** Lifecycle state from the modules table (IMPORTED, VALIDATED, …). */
24
+ state: string;
25
+ /** Total number of config rows recorded for this module. */
26
+ configCount: number;
27
+ }
28
+
29
+ export interface UnconfiguredModulesAuditDeps {
30
+ modules: UnconfiguredModule[];
31
+ }
32
+
33
+ const ALREADY_DEPLOYED = new Set(['INSTALLED', 'VERIFIED']);
34
+
35
+ export async function auditUnconfiguredModules(
36
+ deps: UnconfiguredModulesAuditDeps,
37
+ ): Promise<DriftFinding[]> {
38
+ const findings: DriftFinding[] = [];
39
+
40
+ for (const m of deps.modules) {
41
+ if (ALREADY_DEPLOYED.has(m.state)) continue;
42
+ if (m.configCount > 0) continue;
43
+
44
+ findings.push({
45
+ category: 'unconfigured_modules',
46
+ severity: 'drift',
47
+ code: 'module_unconfigured',
48
+ message: `${m.id}: never configured (no config rows in DB)`,
49
+ details: [
50
+ 'Module was imported but the configuration interview has not',
51
+ 'been run. Defaults will apply for any unset values when you',
52
+ 'deploy, but reviewing what knobs exist first is recommended.',
53
+ '',
54
+ 'To review:',
55
+ ` celilo module config get ${m.id}`,
56
+ '',
57
+ 'To set values manually:',
58
+ ` celilo module config set ${m.id} <key> <value>`,
59
+ '',
60
+ 'Or just run `module deploy` — it triggers the interview',
61
+ 'automatically if required values are missing.',
62
+ ].join('\n'),
63
+ // Not a one-shot — there's no single celilo command to "configure"
64
+ // a module. Surfacing the modal would mislead.
65
+ actionable: false,
66
+ subject: m.id,
67
+ });
68
+ }
69
+
70
+ return findings;
71
+ }