@celilo/cli 0.1.5 → 0.1.6

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,154 @@
1
+ /**
2
+ * Module version drift check.
3
+ *
4
+ * For each installed module, compares the installed version to the
5
+ * latest non-yanked version in the registry. A newer published version
6
+ * produces a `drift` finding per module. Network/registry failures
7
+ * for individual modules are surfaced as findings (severity: drift)
8
+ * so the user knows their audit may be incomplete, but they don't
9
+ * block the run.
10
+ *
11
+ * `registryFetcher` is injectable so tests don't hit a real registry.
12
+ */
13
+
14
+ import { compareSemver } from './cli-version';
15
+ import type { DriftFinding } from './types';
16
+
17
+ export interface InstalledModule {
18
+ id: string;
19
+ version: string;
20
+ }
21
+
22
+ export interface RegistryVersionInfo {
23
+ latest: string | null;
24
+ /** Optional: count of intermediate releases between installed and latest, for the report. */
25
+ intermediateCount?: number;
26
+ /**
27
+ * Optional: release.json `message` from each release between installed
28
+ * and latest, newest-first. Surfaced in the audit report so the user
29
+ * sees "what changed" without leaving the terminal.
30
+ */
31
+ intermediateMessages?: Array<{ version: string; message: string }>;
32
+ }
33
+
34
+ /**
35
+ * Fetches the latest registry version for a module. Returns null in
36
+ * the `.latest` field if the module isn't in the registry; throws on
37
+ * a transport error so the caller can decide between "module not
38
+ * published" and "lookup failed".
39
+ */
40
+ export type ModuleVersionFetcher = (moduleId: string) => Promise<RegistryVersionInfo>;
41
+
42
+ /**
43
+ * Bucket-key for a rejection reason: a short string that identifies
44
+ * the class of failure. Used to detect "every module failed for the
45
+ * same reason" (e.g. registry unreachable) so we can collapse to one
46
+ * aggregate finding instead of N noisy ones.
47
+ */
48
+ function errorBucketKey(reason: unknown): string {
49
+ if (reason instanceof Error) {
50
+ if (reason.name === 'TimeoutError') return 'timeout';
51
+ if (reason.message.includes('ENOTFOUND') || reason.message.includes('EAI_AGAIN')) return 'dns';
52
+ if (reason.message.includes('ECONNREFUSED')) return 'connection-refused';
53
+ if (reason.message.includes('fetch failed')) return 'fetch-failed';
54
+ return reason.name;
55
+ }
56
+ return String(reason).slice(0, 40);
57
+ }
58
+
59
+ export interface ModuleVersionsAuditDeps {
60
+ installed: InstalledModule[];
61
+ fetcher: ModuleVersionFetcher;
62
+ }
63
+
64
+ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promise<DriftFinding[]> {
65
+ const findings: DriftFinding[] = [];
66
+
67
+ // Run lookups in parallel; isolate per-module failures.
68
+ const results = await Promise.allSettled(
69
+ deps.installed.map(async (m) => ({ module: m, info: await deps.fetcher(m.id) })),
70
+ );
71
+
72
+ // If every lookup failed with the same error class (e.g. the
73
+ // registry is unreachable), emit ONE aggregate finding instead of
74
+ // N identical per-module findings — that's the user-facing signal,
75
+ // not "module X is broken".
76
+ const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[];
77
+ if (rejected.length > 0 && rejected.length === results.length) {
78
+ const errorBuckets = new Map<string, number>();
79
+ for (const r of rejected) {
80
+ const key = errorBucketKey(r.reason);
81
+ errorBuckets.set(key, (errorBuckets.get(key) ?? 0) + 1);
82
+ }
83
+ if (errorBuckets.size === 1) {
84
+ const [reason] = errorBuckets.keys();
85
+ findings.push({
86
+ category: 'module_versions',
87
+ severity: 'drift',
88
+ code: 'registry_unreachable',
89
+ message: `Registry unreachable — ${rejected.length} module${rejected.length === 1 ? '' : 's'} unchecked (${reason})`,
90
+ remediation: 'Check network connectivity to the registry, then press R to re-audit.',
91
+ actionable: false,
92
+ subject: 'system',
93
+ });
94
+ return findings;
95
+ }
96
+ }
97
+
98
+ for (let i = 0; i < results.length; i++) {
99
+ const r = results[i];
100
+ const installed = deps.installed[i];
101
+
102
+ if (r.status === 'rejected') {
103
+ findings.push({
104
+ category: 'module_versions',
105
+ severity: 'drift',
106
+ code: 'module_version_lookup_failed',
107
+ message: `${installed.id}: registry lookup failed (${String(r.reason).slice(0, 80)})`,
108
+ remediation: 'Check network connectivity to the registry, then press R to re-audit.',
109
+ actionable: false,
110
+ subject: installed.id,
111
+ });
112
+ continue;
113
+ }
114
+
115
+ const { module, info } = r.value;
116
+ if (info.latest === null) {
117
+ findings.push({
118
+ category: 'module_versions',
119
+ severity: 'drift',
120
+ code: 'module_not_in_registry',
121
+ message: `${module.id}@${module.version}: not found in registry (private/local-only build?)`,
122
+ subject: module.id,
123
+ });
124
+ continue;
125
+ }
126
+
127
+ const cmp = compareSemver(module.version, info.latest);
128
+ if (cmp >= 0) continue; // up to date or ahead
129
+
130
+ const tail =
131
+ info.intermediateCount && info.intermediateCount > 1
132
+ ? ` (${info.intermediateCount} intervening releases)`
133
+ : '';
134
+
135
+ // Format release messages as `details` lines if present.
136
+ const details =
137
+ info.intermediateMessages && info.intermediateMessages.length > 0
138
+ ? info.intermediateMessages.map((m) => ` ↳ "${m.message}" (${m.version})`).join('\n')
139
+ : undefined;
140
+
141
+ findings.push({
142
+ category: 'module_versions',
143
+ severity: 'drift',
144
+ code: 'module_version_drift',
145
+ message: `${module.id}: ${module.version} → ${info.latest}${tail}`,
146
+ details,
147
+ remediation: 'celilo system update',
148
+ actionable: true,
149
+ subject: module.id,
150
+ });
151
+ }
152
+
153
+ return findings;
154
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { DbClient } from '../../db/client';
3
+ import { auditSchema } from './schema';
4
+
5
+ const fakeDb = {} as DbClient;
6
+
7
+ const journalWith = (tags: string[]) => () => ({
8
+ version: '5',
9
+ dialect: 'sqlite',
10
+ entries: tags.map((tag, i) => ({ idx: i, tag, when: 1700000000 + i })),
11
+ });
12
+
13
+ describe('auditSchema', () => {
14
+ test('no finding when journal is missing (folder not on disk)', async () => {
15
+ const result = await auditSchema({
16
+ journal: () => null,
17
+ applied: () => [],
18
+ db: fakeDb,
19
+ });
20
+ expect(result).toEqual([]);
21
+ });
22
+
23
+ test('no finding when every journal entry is applied', async () => {
24
+ const result = await auditSchema({
25
+ journal: journalWith(['0000_init', '0001_add_zones']),
26
+ applied: () => ['0000_init', '0001_add_zones'],
27
+ db: fakeDb,
28
+ });
29
+ expect(result).toEqual([]);
30
+ });
31
+
32
+ test('blocked finding when migrations are pending', async () => {
33
+ const result = await auditSchema({
34
+ journal: journalWith(['0000_init', '0001_add_zones', '0002_backup_tables']),
35
+ applied: () => ['0000_init'],
36
+ db: fakeDb,
37
+ });
38
+
39
+ expect(result).toHaveLength(1);
40
+ expect(result[0]).toMatchObject({
41
+ category: 'schema',
42
+ severity: 'blocked',
43
+ code: 'schema_pending_migrations',
44
+ subject: 'system',
45
+ });
46
+ expect(result[0].message).toContain('2 pending');
47
+ expect(result[0].details).toContain('0001_add_zones');
48
+ expect(result[0].details).toContain('0002_backup_tables');
49
+ });
50
+
51
+ test('singular wording for exactly one pending migration', async () => {
52
+ const result = await auditSchema({
53
+ journal: journalWith(['0000_init', '0001_add_zones']),
54
+ applied: () => ['0000_init'],
55
+ db: fakeDb,
56
+ });
57
+ expect(result[0].message).toBe('1 pending DB migration');
58
+ });
59
+
60
+ test('treats applied set as a set (order-independent)', async () => {
61
+ const result = await auditSchema({
62
+ journal: journalWith(['0000_init', '0001_add_zones']),
63
+ applied: () => ['0001_add_zones', '0000_init'], // reversed
64
+ db: fakeDb,
65
+ });
66
+ expect(result).toEqual([]);
67
+ });
68
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * DB schema drift check.
3
+ *
4
+ * Compares the drizzle migrations folder against the `__drizzle_migrations`
5
+ * table. If the folder has more entries than the table records, a
6
+ * `blocked` finding is produced. In the typical celilo runtime, missing
7
+ * migrations are auto-applied at DB connect time (see
8
+ * `apps/celilo/src/db/client.ts:createDbClient`), so this check is a
9
+ * sanity net for cases where the auto-migrate path was skipped or
10
+ * failed silently.
11
+ *
12
+ * The `journalReader` and `appliedReader` deps are injectable so tests
13
+ * don't need a real drizzle journal on disk or a real DB.
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import type { DbClient } from '../../db/client';
19
+ import type { DriftFinding } from './types';
20
+
21
+ interface DrizzleJournalEntry {
22
+ idx: number;
23
+ tag: string;
24
+ when: number;
25
+ }
26
+
27
+ interface DrizzleJournal {
28
+ version: string;
29
+ dialect: string;
30
+ entries: DrizzleJournalEntry[];
31
+ }
32
+
33
+ export type JournalReader = () => DrizzleJournal | null;
34
+ export type AppliedReader = (db: DbClient) => string[];
35
+
36
+ /**
37
+ * Default journal reader: parses `<migrationsFolder>/meta/_journal.json`.
38
+ * Returns null if the journal isn't present (callers treat as "nothing to compare").
39
+ */
40
+ export function makeJournalReader(migrationsFolder: string): JournalReader {
41
+ return () => {
42
+ const journalPath = join(migrationsFolder, 'meta', '_journal.json');
43
+ if (!existsSync(journalPath)) return null;
44
+ try {
45
+ return JSON.parse(readFileSync(journalPath, 'utf-8')) as DrizzleJournal;
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Default applied-migrations reader: queries `__drizzle_migrations`.
54
+ * Returns an empty array if the table doesn't exist (fresh DB).
55
+ */
56
+ export const readAppliedMigrations: AppliedReader = (db) => {
57
+ // The drizzle migrations table tracks `hash` (which is the migration tag).
58
+ // We use a raw query because the table isn't in our schema definitions.
59
+ try {
60
+ const sqlite = (
61
+ db as unknown as { $client?: { query: (sql: string) => { all: () => unknown[] } } }
62
+ ).$client;
63
+ if (!sqlite) return [];
64
+ const rows = sqlite.query('SELECT hash FROM __drizzle_migrations').all() as Array<{
65
+ hash: string;
66
+ }>;
67
+ return rows.map((r) => r.hash);
68
+ } catch {
69
+ return [];
70
+ }
71
+ };
72
+
73
+ export interface SchemaAuditDeps {
74
+ journal: JournalReader;
75
+ applied: AppliedReader;
76
+ db: DbClient;
77
+ }
78
+
79
+ export async function auditSchema(deps: SchemaAuditDeps): Promise<DriftFinding[]> {
80
+ const journal = deps.journal();
81
+ if (!journal) return []; // No migrations folder → nothing to compare.
82
+
83
+ // drizzle stores SHA-256 hashes (not tag names) in __drizzle_migrations,
84
+ // so we can't intersect tags. The next-best signal is "did all the
85
+ // journal entries get applied?" — compare counts. The applied count
86
+ // can also exceed the journal count for old DBs with stale tracking
87
+ // rows; only flag when applied < journal.
88
+ const applied = deps.applied(deps.db);
89
+ const pendingCount = journal.entries.length - applied.length;
90
+ if (pendingCount <= 0) return [];
91
+
92
+ // We can identify *which* migrations are pending by treating the
93
+ // journal as ordered: the last `pendingCount` entries are the ones
94
+ // not yet tracked. Drizzle applies them in idx order, so the tail
95
+ // is the unapplied set.
96
+ const sortedJournal = [...journal.entries].sort((a, b) => a.idx - b.idx);
97
+ const pendingEntries = sortedJournal.slice(-pendingCount);
98
+
99
+ return [
100
+ {
101
+ category: 'schema',
102
+ severity: 'blocked',
103
+ code: 'schema_pending_migrations',
104
+ message: `${pendingCount} pending DB migration${pendingCount === 1 ? '' : 's'}`,
105
+ details: pendingEntries.map((e) => ` • ${e.tag}`).join('\n'),
106
+ remediation: [
107
+ 'Migrations apply automatically when celilo starts.',
108
+ 'Restart any long-running celilo process, or run',
109
+ 'bun run apps/celilo/src/db/migrate.ts directly.',
110
+ ].join('\n'),
111
+ actionable: false,
112
+ subject: 'system',
113
+ },
114
+ ];
115
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { auditSecretsDecryptable } from './secrets-decryptable';
3
+
4
+ describe('auditSecretsDecryptable', () => {
5
+ test('no findings when every secret decrypts', async () => {
6
+ const result = await auditSecretsDecryptable({
7
+ results: [
8
+ { scope: 'module', subject: 'caddy', name: 'acme_token', error: null },
9
+ { scope: 'system', subject: 'system', name: 'master', error: null },
10
+ ],
11
+ });
12
+ expect(result).toEqual([]);
13
+ });
14
+
15
+ test('per-secret findings when some decrypt and others fail', async () => {
16
+ const result = await auditSecretsDecryptable({
17
+ results: [
18
+ { scope: 'module', subject: 'caddy', name: 'good', error: null },
19
+ {
20
+ scope: 'module',
21
+ subject: 'iptables',
22
+ name: 'bad',
23
+ error: 'Unsupported state or unable to authenticate data',
24
+ },
25
+ ],
26
+ });
27
+ expect(result).toHaveLength(1);
28
+ expect(result[0]).toMatchObject({
29
+ category: 'secrets_decryptable',
30
+ severity: 'blocked',
31
+ subject: 'iptables',
32
+ actionable: false,
33
+ });
34
+ expect(result[0].code).toContain('module');
35
+ expect(result[0].remediation).toContain('celilo module secret set iptables bad');
36
+ });
37
+
38
+ test('collapses to "master_key_mismatch" when every secret fails identically', async () => {
39
+ const result = await auditSecretsDecryptable({
40
+ results: [
41
+ { scope: 'module', subject: 'caddy', name: 'a', error: 'bad auth tag' },
42
+ { scope: 'module', subject: 'iptables', name: 'b', error: 'bad auth tag' },
43
+ { scope: 'system', subject: 'system', name: 'c', error: 'bad auth tag' },
44
+ ],
45
+ });
46
+ expect(result).toHaveLength(1);
47
+ expect(result[0]).toMatchObject({
48
+ category: 'secrets_decryptable',
49
+ severity: 'blocked',
50
+ code: 'master_key_mismatch',
51
+ subject: 'system',
52
+ actionable: false,
53
+ });
54
+ expect(result[0].message).toContain('All 3 secrets fail to decrypt');
55
+ });
56
+
57
+ test('does NOT collapse when failures have different errors', async () => {
58
+ const result = await auditSecretsDecryptable({
59
+ results: [
60
+ { scope: 'module', subject: 'caddy', name: 'a', error: 'bad auth tag' },
61
+ { scope: 'system', subject: 'system', name: 'b', error: 'corrupted' },
62
+ ],
63
+ });
64
+ expect(result).toHaveLength(2);
65
+ expect(result.every((f) => f.code !== 'master_key_mismatch')).toBe(true);
66
+ });
67
+
68
+ test('different scopes produce different remediation text', async () => {
69
+ // Distinct error messages so the "all-fail-same-reason" collapse
70
+ // doesn't trigger — we want per-scope findings here.
71
+ const result = await auditSecretsDecryptable({
72
+ results: [
73
+ { scope: 'module', subject: 'caddy', name: 'm', error: 'bad tag' },
74
+ { scope: 'system', subject: 'system', name: 's', error: 'bad iv' },
75
+ ],
76
+ });
77
+ const moduleFinding = result.find((f) => f.subject === 'caddy');
78
+ const systemFinding = result.find((f) => f.subject === 'system');
79
+ expect(moduleFinding?.remediation).toContain('celilo module secret set');
80
+ expect(systemFinding?.remediation).toContain('celilo system secret set');
81
+ });
82
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Secrets-decryptable check.
3
+ *
4
+ * Walks every encrypted secret in the celilo DB (module secrets,
5
+ * system secrets, capability secrets) and verifies it decrypts
6
+ * cleanly with the current master key. Catches:
7
+ *
8
+ * - Master key rotated without re-encrypting existing secrets.
9
+ * - Encryption envelope corrupted by external write.
10
+ * - Algorithm mismatch (key length / GCM tag failure).
11
+ *
12
+ * When *every* secret fails with the same error, we collapse to a
13
+ * single "master key mismatch" finding instead of N identical ones —
14
+ * the user-facing signal is "the master key is wrong", not "secret
15
+ * X is broken".
16
+ */
17
+
18
+ import type { DriftFinding } from './types';
19
+
20
+ export type SecretScope = 'module' | 'system' | 'capability';
21
+
22
+ export interface SecretCheckResult {
23
+ scope: SecretScope;
24
+ /** Module ID for module/capability secrets; 'system' for system secrets. */
25
+ subject: string;
26
+ /** Secret name (e.g. `router_password`, `proxmox_api_token`). */
27
+ name: string;
28
+ /** null on success; error message on failure. */
29
+ error: string | null;
30
+ }
31
+
32
+ export interface SecretsDecryptableAuditDeps {
33
+ results: SecretCheckResult[];
34
+ }
35
+
36
+ export async function auditSecretsDecryptable(
37
+ deps: SecretsDecryptableAuditDeps,
38
+ ): Promise<DriftFinding[]> {
39
+ const failed = deps.results.filter((r) => r.error !== null);
40
+ if (failed.length === 0) return [];
41
+
42
+ // If every secret fails with the same error class, collapse to one
43
+ // finding pointing at the master key — that's the actual problem,
44
+ // not N broken records.
45
+ if (failed.length === deps.results.length && deps.results.length > 1) {
46
+ const errorBuckets = new Map<string, number>();
47
+ for (const r of failed) {
48
+ const key = (r.error ?? '').slice(0, 60);
49
+ errorBuckets.set(key, (errorBuckets.get(key) ?? 0) + 1);
50
+ }
51
+ if (errorBuckets.size === 1) {
52
+ const [reason] = errorBuckets.keys();
53
+ return [
54
+ {
55
+ category: 'secrets_decryptable',
56
+ severity: 'blocked',
57
+ code: 'master_key_mismatch',
58
+ message: `All ${failed.length} secrets fail to decrypt — master key may be wrong or rotated`,
59
+ details: reason,
60
+ remediation: [
61
+ 'Every secret in the celilo DB fails to decrypt with the',
62
+ 'current master key. Common causes:',
63
+ '',
64
+ ' 1. Master key file replaced (e.g. machine restored from',
65
+ ' a backup that includes a different master key).',
66
+ ' 2. Master key file corrupted.',
67
+ ' 3. DB restored from a backup taken under a different',
68
+ ' master key.',
69
+ '',
70
+ 'If you have the previous master key, restore it. If not,',
71
+ "you'll need to re-add every secret manually.",
72
+ ].join('\n'),
73
+ actionable: false,
74
+ subject: 'system',
75
+ },
76
+ ];
77
+ }
78
+ }
79
+
80
+ // Otherwise, per-secret findings.
81
+ return failed.map((r) => ({
82
+ category: 'secrets_decryptable' as const,
83
+ severity: 'blocked' as const,
84
+ code: `secret_decrypt_failed_${r.scope}`,
85
+ message: `${r.scope} secret "${r.name}" (${r.subject}) failed to decrypt`,
86
+ details: r.error ?? undefined,
87
+ remediation:
88
+ r.scope === 'module'
89
+ ? `Re-set the secret:\n celilo module secret set ${r.subject} ${r.name} <value>`
90
+ : r.scope === 'system'
91
+ ? `Re-set the secret:\n celilo system secret set ${r.name} <value>`
92
+ : 'Re-set the capability secret via the owning module.',
93
+ // Multi-step (need plaintext value); not a one-keypress fix.
94
+ actionable: false,
95
+ subject: r.subject,
96
+ }));
97
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { auditServicesCredentials } from './services-credentials';
3
+
4
+ describe('auditServicesCredentials', () => {
5
+ test('no findings when every service decrypts', async () => {
6
+ const result = await auditServicesCredentials({
7
+ results: [
8
+ {
9
+ serviceId: 'home-proxmox',
10
+ name: 'Home Proxmox',
11
+ providerName: 'proxmox',
12
+ error: null,
13
+ },
14
+ { serviceId: 'do', name: 'DigitalOcean', providerName: 'digitalocean', error: null },
15
+ ],
16
+ });
17
+ expect(result).toEqual([]);
18
+ });
19
+
20
+ test('blocked finding per failing service', async () => {
21
+ const result = await auditServicesCredentials({
22
+ results: [
23
+ {
24
+ serviceId: 'home-proxmox',
25
+ name: 'Home Proxmox',
26
+ providerName: 'proxmox',
27
+ error: 'Failed to decrypt: bad authentication tag',
28
+ },
29
+ ],
30
+ });
31
+ expect(result).toHaveLength(1);
32
+ expect(result[0]).toMatchObject({
33
+ category: 'services_credentials',
34
+ severity: 'blocked',
35
+ code: 'service_credentials_invalid',
36
+ subject: 'home-proxmox',
37
+ actionable: false,
38
+ });
39
+ expect(result[0].message).toContain('Home Proxmox');
40
+ expect(result[0].message).toContain('proxmox');
41
+ expect(result[0].details).toContain('bad authentication tag');
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 auditServicesCredentials({
47
+ results: [
48
+ { serviceId: 'good', name: 'Good', providerName: 'proxmox', error: null },
49
+ { serviceId: 'bad', name: 'Bad', providerName: 'proxmox', error: 'oops' },
50
+ ],
51
+ });
52
+ expect(result.map((f) => f.subject)).toEqual(['bad']);
53
+ });
54
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Services-credentials check.
3
+ *
4
+ * For each registered container service, verifies its API credentials
5
+ * are present and decryptable with the current master key. Catches
6
+ * failure modes that today only surface at deploy time:
7
+ *
8
+ * - Service registered but credentials never set (column null/empty).
9
+ * - Master key rotated; old envelope no longer decrypts.
10
+ * - Stored envelope is corrupt or doesn't match the provider's
11
+ * credential schema (e.g. Proxmox service has DigitalOcean-shaped
12
+ * credentials).
13
+ *
14
+ * Each finding is BLOCKED — `system update` would otherwise fan
15
+ * these out as opaque terraform / SSH errors. Surfacing them up
16
+ * front lets the user re-run `celilo service set-credentials`
17
+ * before any deploy work.
18
+ */
19
+
20
+ import type { DriftFinding } from './types';
21
+
22
+ export interface ServiceCredentialsResult {
23
+ /** Stable service identifier (the user-facing kebab-case ID). */
24
+ serviceId: string;
25
+ /** Display name for the finding message. */
26
+ name: string;
27
+ providerName: string;
28
+ /** null on success; error message on failure. */
29
+ error: string | null;
30
+ }
31
+
32
+ export interface ServicesCredentialsAuditDeps {
33
+ results: ServiceCredentialsResult[];
34
+ }
35
+
36
+ export async function auditServicesCredentials(
37
+ deps: ServicesCredentialsAuditDeps,
38
+ ): Promise<DriftFinding[]> {
39
+ const findings: DriftFinding[] = [];
40
+
41
+ for (const r of deps.results) {
42
+ if (r.error === null) continue;
43
+
44
+ findings.push({
45
+ category: 'services_credentials',
46
+ severity: 'blocked',
47
+ code: 'service_credentials_invalid',
48
+ message: `${r.name} (${r.providerName}): credentials missing or invalid`,
49
+ details: r.error,
50
+ remediation: [
51
+ 'Re-add the credentials for this service. For example:',
52
+ ` celilo service set-credentials ${r.serviceId}`,
53
+ 'or remove the service:',
54
+ ` celilo service remove ${r.name}`,
55
+ ].join('\n'),
56
+ // Multi-step (interactive prompts for credential values), so
57
+ // not a one-keypress remediation in the TUI.
58
+ actionable: false,
59
+ subject: r.serviceId,
60
+ });
61
+ }
62
+
63
+ return findings;
64
+ }