@celilo/cli 0.1.4 → 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 (161) 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 +9 -8
  5. package/src/ansible/inventory.ts +9 -7
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +45 -12
  8. package/src/capabilities/registration.test.ts +6 -6
  9. package/src/capabilities/well-known.test.ts +2 -2
  10. package/src/capabilities/well-known.ts +5 -5
  11. package/src/cli/cli.test.ts +2 -2
  12. package/src/cli/command-registry.ts +146 -3
  13. package/src/cli/command-tree-parser.test.ts +1 -1
  14. package/src/cli/command-tree-parser.ts +9 -8
  15. package/src/cli/commands/hook-run.ts +15 -66
  16. package/src/cli/commands/module-audit.ts +14 -44
  17. package/src/cli/commands/module-deploy.ts +4 -1
  18. package/src/cli/commands/module-import-registry.test.ts +115 -0
  19. package/src/cli/commands/module-import.ts +106 -22
  20. package/src/cli/commands/module-publish.test.ts +235 -0
  21. package/src/cli/commands/module-publish.ts +234 -0
  22. package/src/cli/commands/module-remove.ts +82 -2
  23. package/src/cli/commands/module-search.ts +57 -0
  24. package/src/cli/commands/module-secret-get.ts +59 -0
  25. package/src/cli/commands/module-show.ts +1 -1
  26. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  27. package/src/cli/commands/module-verify.test.ts +59 -0
  28. package/src/cli/commands/module-verify.ts +53 -0
  29. package/src/cli/commands/status.ts +30 -20
  30. package/src/cli/commands/system-audit.test.ts +138 -0
  31. package/src/cli/commands/system-audit.ts +571 -0
  32. package/src/cli/commands/system-update.ts +391 -0
  33. package/src/cli/completion.ts +15 -1
  34. package/src/cli/fuel-gauge.ts +68 -3
  35. package/src/cli/generate-zsh-completion.ts +13 -3
  36. package/src/cli/index.ts +112 -5
  37. package/src/cli/parser.ts +11 -0
  38. package/src/cli/prompts.ts +36 -5
  39. package/src/cli/tui/audit-state.test.ts +246 -0
  40. package/src/cli/tui/audit-state.ts +525 -0
  41. package/src/cli/tui/audit-tui.test.tsx +135 -0
  42. package/src/cli/tui/audit-tui.tsx +624 -0
  43. package/src/cli/tui/celebration.tsx +29 -0
  44. package/src/cli/tui/clipboard.test.ts +94 -0
  45. package/src/cli/tui/clipboard.ts +101 -0
  46. package/src/cli/tui/icons.ts +22 -0
  47. package/src/cli/tui/keybar.tsx +65 -0
  48. package/src/cli/tui/keymap.test.ts +105 -0
  49. package/src/cli/tui/keymap.ts +70 -0
  50. package/src/cli/tui/modals/analyzing.tsx +75 -0
  51. package/src/cli/tui/modals/celebration.tsx +44 -0
  52. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  53. package/src/cli/tui/modals/remediate.tsx +44 -0
  54. package/src/cli/tui/modals.test.ts +137 -0
  55. package/src/cli/tui/mouse.test.ts +78 -0
  56. package/src/cli/tui/mouse.ts +114 -0
  57. package/src/cli/tui/panes/categories.tsx +62 -0
  58. package/src/cli/tui/panes/command-log.tsx +87 -0
  59. package/src/cli/tui/panes/detail.tsx +175 -0
  60. package/src/cli/tui/panes/findings.tsx +97 -0
  61. package/src/cli/tui/panes/summary.tsx +64 -0
  62. package/src/cli/tui/spawn.ts +130 -0
  63. package/src/cli/tui/theme.ts +42 -0
  64. package/src/cli/tui/wrap.test.ts +43 -0
  65. package/src/cli/tui/wrap.ts +45 -0
  66. package/src/cli/types.ts +5 -0
  67. package/src/db/client.ts +55 -2
  68. package/src/db/schema.test.ts +3 -3
  69. package/src/db/schema.ts +26 -17
  70. package/src/hooks/capability-loader.ts +135 -72
  71. package/src/hooks/define-hook.test.ts +11 -3
  72. package/src/hooks/executor.ts +22 -1
  73. package/src/hooks/load-hook-config.test.ts +165 -0
  74. package/src/hooks/load-hook-config.ts +60 -0
  75. package/src/hooks/logger.ts +42 -12
  76. package/src/hooks/run-named-hook.ts +128 -0
  77. package/src/hooks/types.ts +19 -0
  78. package/src/manifest/ensure-schema.test.ts +115 -0
  79. package/src/manifest/schema.ts +76 -0
  80. package/src/manifest/template-validator.test.ts +1 -1
  81. package/src/manifest/template-validator.ts +1 -1
  82. package/src/manifest/validate.test.ts +1 -1
  83. package/src/module/import.ts +20 -12
  84. package/src/module/packaging/build.ts +121 -25
  85. package/src/module/packaging/release-metadata.test.ts +103 -0
  86. package/src/module/packaging/release-metadata.ts +145 -0
  87. package/src/registry/client.test.ts +228 -0
  88. package/src/registry/client.ts +157 -0
  89. package/src/services/audit/backups.test.ts +233 -0
  90. package/src/services/audit/backups.ts +128 -0
  91. package/src/services/audit/capability-abi.test.ts +153 -0
  92. package/src/services/audit/capability-abi.ts +204 -0
  93. package/src/services/audit/cli-version.test.ts +60 -0
  94. package/src/services/audit/cli-version.ts +87 -0
  95. package/src/services/audit/health.test.ts +84 -0
  96. package/src/services/audit/health.ts +43 -0
  97. package/src/services/audit/index.test.ts +99 -0
  98. package/src/services/audit/index.ts +118 -0
  99. package/src/services/audit/machines-reachable.test.ts +87 -0
  100. package/src/services/audit/machines-reachable.ts +87 -0
  101. package/src/services/audit/module-configs.test.ts +131 -0
  102. package/src/services/audit/module-configs.ts +80 -0
  103. package/src/services/audit/module-versions.test.ts +99 -0
  104. package/src/services/audit/module-versions.ts +154 -0
  105. package/src/services/audit/schema.test.ts +68 -0
  106. package/src/services/audit/schema.ts +115 -0
  107. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  108. package/src/services/audit/secrets-decryptable.ts +97 -0
  109. package/src/services/audit/services-credentials.test.ts +54 -0
  110. package/src/services/audit/services-credentials.ts +64 -0
  111. package/src/services/audit/services-reachable.test.ts +60 -0
  112. package/src/services/audit/services-reachable.ts +64 -0
  113. package/src/services/audit/terraform-plan.test.ts +127 -0
  114. package/src/services/audit/terraform-plan.ts +153 -0
  115. package/src/services/audit/types.test.ts +36 -0
  116. package/src/services/audit/types.ts +90 -0
  117. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  118. package/src/services/audit/unconfigured-modules.ts +71 -0
  119. package/src/services/audit/undeployed-modules.test.ts +66 -0
  120. package/src/services/audit/undeployed-modules.ts +72 -0
  121. package/src/services/build-stream.ts +122 -122
  122. package/src/services/config-interview.ts +407 -2
  123. package/src/services/deploy-ansible.ts +73 -7
  124. package/src/services/deploy-planner.ts +5 -5
  125. package/src/services/deploy-preflight.ts +45 -4
  126. package/src/services/deploy-terraform.ts +31 -24
  127. package/src/services/deploy-validation.ts +167 -23
  128. package/src/services/dns-auto-register.ts +4 -4
  129. package/src/services/ensure-interview.test.ts +245 -0
  130. package/src/services/health-runner.ts +110 -38
  131. package/src/services/infrastructure-variable-resolver.test.ts +1 -1
  132. package/src/services/infrastructure-variable-resolver.ts +3 -3
  133. package/src/services/module-build.ts +11 -13
  134. package/src/services/module-deploy.ts +372 -61
  135. package/src/services/proxmox-state-recovery.ts +6 -6
  136. package/src/services/ssh-key-manager.test.ts +1 -1
  137. package/src/services/ssh-key-manager.ts +3 -2
  138. package/src/services/terraform-env.ts +62 -0
  139. package/src/services/update/dep-graph.test.ts +214 -0
  140. package/src/services/update/dep-graph.ts +215 -0
  141. package/src/services/update/orchestrator.test.ts +463 -0
  142. package/src/services/update/orchestrator.ts +359 -0
  143. package/src/services/update/progress.ts +49 -0
  144. package/src/services/update/self-update.test.ts +68 -0
  145. package/src/services/update/self-update.ts +57 -0
  146. package/src/services/update/types.ts +94 -0
  147. package/src/templates/generator.test.ts +3 -3
  148. package/src/templates/generator.ts +43 -2
  149. package/src/test-utils/completion-harness.test.ts +1 -1
  150. package/src/test-utils/completion-harness.ts +4 -4
  151. package/src/variables/capability-self-ref.test.ts +203 -0
  152. package/src/variables/context.test.ts +31 -31
  153. package/src/variables/context.ts +65 -17
  154. package/src/variables/declarative-derivation.test.ts +306 -0
  155. package/src/variables/declarative-derivation.ts +4 -2
  156. package/src/variables/parser.test.ts +64 -9
  157. package/src/variables/parser.ts +47 -6
  158. package/src/variables/resolver.test.ts +14 -14
  159. package/src/variables/resolver.ts +27 -9
  160. package/src/variables/types.ts +1 -1
  161. package/tsconfig.json +1 -0
@@ -7,10 +7,14 @@
7
7
 
8
8
  import { existsSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import { eq } from 'drizzle-orm';
10
+ import { eq, ne } from 'drizzle-orm';
11
+ import { FuelGauge } from '../../cli/fuel-gauge';
11
12
  import { getDb } from '../../db/client';
12
- import { moduleInfrastructure, modules } from '../../db/schema';
13
+ import { capabilities as capabilitiesTable, moduleInfrastructure, modules } from '../../db/schema';
14
+ import { createGaugeLogger } from '../../hooks/logger';
15
+ import { runNamedHook } from '../../hooks/run-named-hook';
13
16
  import { deallocateForModule } from '../../ipam/auto-allocator';
17
+ import { ModuleManifestSchema } from '../../manifest/schema';
14
18
  import { executeBuildWithProgress } from '../../services/build-stream';
15
19
  import { getContainerService, getServiceCredentials } from '../../services/container-service';
16
20
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
@@ -61,6 +65,82 @@ export async function handleModuleRemove(
61
65
  };
62
66
  }
63
67
 
68
+ // Check if any other installed module requires a capability this module provides
69
+ const providedCapabilities = db
70
+ .select()
71
+ .from(capabilitiesTable)
72
+ .where(eq(capabilitiesTable.moduleId, moduleId))
73
+ .all();
74
+
75
+ if (providedCapabilities.length > 0) {
76
+ const providedNames = new Set(providedCapabilities.map((c) => c.capabilityName));
77
+ const otherModules = db.select().from(modules).where(ne(modules.id, moduleId)).all();
78
+
79
+ const dependents = otherModules
80
+ .filter((m) => {
81
+ const parsed = ModuleManifestSchema.safeParse(m.manifestData);
82
+ if (!parsed.success) return false;
83
+ return (parsed.data.requires?.capabilities ?? []).some((req) =>
84
+ providedNames.has(req.name),
85
+ );
86
+ })
87
+ .map((m) => m.id);
88
+
89
+ if (dependents.length > 0) {
90
+ return {
91
+ success: false,
92
+ error: `Cannot remove '${moduleId}': the following installed modules depend on its capabilities: ${dependents.join(', ')}`,
93
+ };
94
+ }
95
+ }
96
+
97
+ // Run on_uninstall hook (if defined) BEFORE terraform destroy. Hooks
98
+ // typically need the module's runtime to still be up so they can talk
99
+ // to remote services — e.g. caddy's teardown SSHes the caddy host to
100
+ // unexpose its ports + clear the Caddyfile, which can't happen after
101
+ // terraform has destroyed the LXC. Failures here are best-effort: we
102
+ // log the error and continue with removal so an operator can still
103
+ // unstick a half-broken module. Use --force to skip the confirmation
104
+ // prompt on hook failure.
105
+ const manifestForHook = module.manifestData as { hooks?: { on_uninstall?: unknown } } | undefined;
106
+ if (manifestForHook?.hooks?.on_uninstall) {
107
+ // Match build-stream's TTY detection: in non-TTY contexts (tests,
108
+ // pipes, --no-interactive flow) skipAnimation prevents the gauge's
109
+ // setInterval/raw-stdin handlers from blocking process exit.
110
+ const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
111
+ const gauge = new FuelGauge(`${moduleId}: on_uninstall`, {
112
+ skipAnimation: !isInteractive,
113
+ });
114
+ gauge.start();
115
+ const logger = createGaugeLogger(gauge, moduleId, 'on_uninstall');
116
+ const hookResult = await runNamedHook(moduleId, 'on_uninstall', db, logger, {});
117
+ if (hookResult.success) {
118
+ gauge.stop(true);
119
+ } else {
120
+ gauge.stop(false);
121
+ log.warn(`on_uninstall failed: ${hookResult.error ?? 'unknown error'}`);
122
+ if (force) {
123
+ log.info('--force set, continuing removal despite hook failure');
124
+ } else if (!isInteractive) {
125
+ // Non-TTY (tests, scripts, piped input). Don't try to prompt —
126
+ // @clack/prompts hangs on stdin in that context. Default
127
+ // behaviour: continue with removal so a hook crash doesn't
128
+ // wedge automation. Operators who want strict failure can
129
+ // re-run `celilo module run-hook <id> on_uninstall` manually.
130
+ log.warn('Non-interactive context detected; continuing removal despite hook failure');
131
+ } else {
132
+ const proceed = await promptConfirm({
133
+ message:
134
+ 'on_uninstall hook failed. Continue removing the module anyway? ' +
135
+ '(some external state may not be cleaned up)',
136
+ });
137
+ if (!proceed) {
138
+ return { success: false, error: 'Removal cancelled after on_uninstall failure' };
139
+ }
140
+ }
141
+ }
142
+ }
143
+
64
144
  // Check if module has infrastructure that needs to be destroyed
65
145
  const infra = db
66
146
  .select()
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Module search command — search the registry for modules
3
+ *
4
+ * Usage: celilo module search [<query>] [--registry <url>] [--limit <n>]
5
+ */
6
+
7
+ import { RegistryClient } from '../../registry/client';
8
+ import { getArg, getFlag } from '../parser';
9
+ import type { CommandResult } from '../types';
10
+
11
+ export async function handleModuleSearch(
12
+ args: string[],
13
+ flags: Record<string, string | boolean>,
14
+ ): Promise<CommandResult> {
15
+ const query = getArg(args, 0) ?? '';
16
+ const limit = Number(getFlag(flags, 'limit', '25'));
17
+ const registryUrl = getFlag(flags, 'registry', '');
18
+ const client = new RegistryClient(registryUrl || undefined);
19
+
20
+ let result: Awaited<ReturnType<typeof client.search>>;
21
+ try {
22
+ result = await client.search(query, limit);
23
+ } catch (err) {
24
+ return {
25
+ success: false,
26
+ error: `Registry unreachable: ${err instanceof Error ? err.message : String(err)}`,
27
+ };
28
+ }
29
+
30
+ if (result.modules.length === 0) {
31
+ return {
32
+ success: true,
33
+ message: query ? `No modules found matching "${query}"` : 'Registry is empty',
34
+ };
35
+ }
36
+
37
+ const nameWidth = Math.max(4, ...result.modules.map((m) => m.name.length));
38
+ const versionWidth = Math.max(7, ...result.modules.map((m) => m.max_version.length));
39
+
40
+ const header = `${'NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)} DESCRIPTION`;
41
+ const divider = '-'.repeat(header.length);
42
+ const rows = result.modules.map(
43
+ (m) =>
44
+ `${m.name.padEnd(nameWidth)} ${m.max_version.padEnd(versionWidth)} ${m.description || ''}`,
45
+ );
46
+
47
+ const footer =
48
+ result.total > result.modules.length
49
+ ? `\n(showing ${result.modules.length} of ${result.total} — use --limit to see more)`
50
+ : '';
51
+
52
+ return {
53
+ success: true,
54
+ message: [header, divider, ...rows].join('\n') + footer,
55
+ data: result,
56
+ };
57
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Module secret get command
3
+ * Retrieves and decrypts a module-level secret
4
+ */
5
+
6
+ import { and, eq } from 'drizzle-orm';
7
+ import { getDb } from '../../db/client';
8
+ import { secrets } from '../../db/schema';
9
+ import { decryptSecret } from '../../secrets/encryption';
10
+ import { getOrCreateMasterKey } from '../../secrets/master-key';
11
+ import { getArg } from '../parser';
12
+ import type { CommandResult } from '../types';
13
+
14
+ /**
15
+ * Handle module secret get command
16
+ *
17
+ * Usage: celilo module secret get <module-id> <key>
18
+ *
19
+ * @param args - Command arguments
20
+ * @returns Command result with decrypted secret value
21
+ */
22
+ export async function handleModuleSecretGet(args: string[]): Promise<CommandResult> {
23
+ const moduleId = getArg(args, 0);
24
+ const key = getArg(args, 1);
25
+
26
+ if (!moduleId || !key) {
27
+ return {
28
+ success: false,
29
+ error: 'Usage: celilo module secret get <module-id> <key>',
30
+ };
31
+ }
32
+
33
+ const db = getDb();
34
+ const secret = db
35
+ .select()
36
+ .from(secrets)
37
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, key)))
38
+ .get();
39
+
40
+ if (!secret) {
41
+ return {
42
+ success: false,
43
+ error: `Secret not found: ${moduleId}/${key}`,
44
+ };
45
+ }
46
+
47
+ const masterKey = await getOrCreateMasterKey();
48
+ const decrypted = decryptSecret(
49
+ { encryptedValue: secret.encryptedValue, iv: secret.iv, authTag: secret.authTag },
50
+ masterKey,
51
+ );
52
+
53
+ return {
54
+ success: true,
55
+ message: decrypted,
56
+ rawOutput: true,
57
+ data: { moduleId, key, value: decrypted },
58
+ };
59
+ }
@@ -68,7 +68,7 @@ export async function handleModuleShowConfig(args: string[]): Promise<CommandRes
68
68
  if (
69
69
  key.startsWith('inventory.') ||
70
70
  key === 'vmid' ||
71
- key === 'container_ip' ||
71
+ key === 'target_ip' ||
72
72
  key === 'gateway' ||
73
73
  key === 'vlan' ||
74
74
  key === 'subnet' ||
@@ -0,0 +1,57 @@
1
+ /**
2
+ * module terraform-unlock command
3
+ *
4
+ * Removes a stale Terraform state lock for a module.
5
+ * Only use this if you are certain no other deploy is running.
6
+ */
7
+
8
+ import { existsSync, rmSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { eq } from 'drizzle-orm';
11
+ import { getDb } from '../../db/client';
12
+ import { modules } from '../../db/schema';
13
+ import { validateRequiredArgs } from '../parser';
14
+ import { log } from '../prompts';
15
+ import type { CommandResult } from '../types';
16
+
17
+ export async function handleModuleTerraformUnlock(args: string[]): Promise<CommandResult> {
18
+ const error = validateRequiredArgs(args, 1);
19
+ if (error) {
20
+ return {
21
+ success: false,
22
+ error: 'Usage: celilo module terraform-unlock <module-id>',
23
+ };
24
+ }
25
+
26
+ const moduleId = args[0];
27
+ const db = getDb();
28
+ const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
29
+
30
+ if (!module) {
31
+ return { success: false, error: `Module not found: ${moduleId}` };
32
+ }
33
+
34
+ const lockFile = join(
35
+ module.sourcePath,
36
+ 'generated',
37
+ 'terraform',
38
+ '.terraform.tfstate.lock.info',
39
+ );
40
+
41
+ if (!existsSync(lockFile)) {
42
+ log.success(`No state lock found for ${moduleId}`);
43
+ return { success: true, message: `No state lock found for ${moduleId}` };
44
+ }
45
+
46
+ try {
47
+ rmSync(lockFile);
48
+ log.success(`State lock removed for ${moduleId}`);
49
+ log.message('You can now retry the deploy.');
50
+ return { success: true, message: `State lock removed for ${moduleId}` };
51
+ } catch (err) {
52
+ return {
53
+ success: false,
54
+ error: `Failed to remove lock file: ${err instanceof Error ? err.message : String(err)}\n\nTry manually: rm ${lockFile}`,
55
+ };
56
+ }
57
+ }
@@ -0,0 +1,59 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { moduleAudit } from './module-audit';
6
+ import { moduleVerify } from './module-verify';
7
+
8
+ describe('moduleVerify', () => {
9
+ test('returns error when module ID is missing', async () => {
10
+ const result = await moduleVerify([]);
11
+ expect(result.success).toBe(false);
12
+ if (!result.success) {
13
+ expect(result.error).toContain('Module ID is required');
14
+ expect(result.error).toContain('module verify');
15
+ }
16
+ });
17
+ });
18
+
19
+ describe('moduleAudit (deprecation alias)', () => {
20
+ let dataDir: string;
21
+ const originalDataDir = process.env.CELILO_DATA_DIR;
22
+ const originalDbPath = process.env.CELILO_DB_PATH;
23
+
24
+ beforeEach(() => {
25
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-audit-alias-'));
26
+ process.env.CELILO_DATA_DIR = dataDir;
27
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
28
+ });
29
+
30
+ afterEach(() => {
31
+ rmSync(dataDir, { recursive: true, force: true });
32
+ if (originalDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
33
+ else process.env.CELILO_DATA_DIR = originalDataDir;
34
+ if (originalDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
35
+ else process.env.CELILO_DB_PATH = originalDbPath;
36
+ });
37
+
38
+ test('writes a deprecation warning to stderr and delegates to verify', async () => {
39
+ const writes: Buffer[] = [];
40
+ const originalWrite = process.stderr.write.bind(process.stderr);
41
+ // Narrower override is fine for the duration of the test; cast to
42
+ // bypass the overload set on Process['stderr']['write'].
43
+ process.stderr.write = ((chunk: string | Buffer) => {
44
+ writes.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
45
+ return true;
46
+ }) as typeof process.stderr.write;
47
+
48
+ try {
49
+ const result = await moduleAudit([]); // no args triggers the same arg-error
50
+ expect(result.success).toBe(false);
51
+ } finally {
52
+ process.stderr.write = originalWrite;
53
+ }
54
+
55
+ const stderr = Buffer.concat(writes).toString('utf-8');
56
+ expect(stderr).toContain('deprecated');
57
+ expect(stderr).toContain('module verify');
58
+ });
59
+ });
@@ -0,0 +1,53 @@
1
+ import { auditModule } from '../../module/packaging/audit';
2
+ import type { CommandResult } from '../types';
3
+
4
+ /**
5
+ * Verify module integrity (signature + checksums).
6
+ *
7
+ * Usage: celilo module verify <module-id>
8
+ *
9
+ * Renamed from `module audit` per CELILO_UPDATE D11 — `audit` is now
10
+ * reserved for system-level drift detection (`celilo system audit`).
11
+ * The legacy `module audit` continues to work via a deprecation alias
12
+ * (see `module-audit.ts`).
13
+ *
14
+ * Returns a CommandResult so the dispatcher controls process exit
15
+ * behavior.
16
+ */
17
+ export async function moduleVerify(args: string[]): Promise<CommandResult> {
18
+ if (args.length === 0) {
19
+ return {
20
+ success: false,
21
+ error: 'Module ID is required\n\nUsage: celilo module verify <module-id>',
22
+ };
23
+ }
24
+
25
+ const moduleId = args[0];
26
+
27
+ const result = await auditModule(moduleId);
28
+
29
+ if (result.error) {
30
+ return { success: false, error: result.error };
31
+ }
32
+
33
+ if (result.success) {
34
+ return {
35
+ success: true,
36
+ message: `Module '${moduleId}' passed integrity check\n No violations found.`,
37
+ };
38
+ }
39
+
40
+ const violationLines = result.violations.map((v) => {
41
+ const icon = v.type === 'missing' ? '⚠' : v.type === 'modified' ? '✗' : '!';
42
+ return ` ${icon} [${v.type.toUpperCase()}] ${v.message}`;
43
+ });
44
+
45
+ return {
46
+ success: false,
47
+ error: [
48
+ `Module '${moduleId}' failed integrity check`,
49
+ ` Found ${result.violations.length} violation(s):`,
50
+ ...violationLines,
51
+ ].join('\n'),
52
+ };
53
+ }
@@ -13,40 +13,40 @@ import type { ModuleManifest } from '../../manifest/schema';
13
13
  import type { CommandResult } from '../types';
14
14
 
15
15
  /**
16
- * Determine module status based on configuration and filesystem
16
+ * Determine module status for display.
17
+ *
18
+ * Uses the DB state as the primary source of truth.
19
+ * Falls back to filesystem / config inspection for IMPORTED/CONFIGURED modules
20
+ * that haven't yet been deployed.
17
21
  */
18
22
  async function determineModuleStatus(
19
23
  _moduleId: string,
20
24
  manifest: ModuleManifest,
21
25
  configs: (typeof moduleConfigs.$inferSelect)[],
22
26
  generatedPath: string,
27
+ dbState: string,
23
28
  ): Promise<{
24
- status: 'IMPORTED' | 'CONFIGURED' | 'GENERATED' | 'DEPLOYED' | 'NEEDS_UPDATE';
29
+ status: 'IMPORTED' | 'CONFIGURED' | 'GENERATED' | 'DEPLOYED' | 'VERIFIED' | 'NEEDS_UPDATE';
25
30
  missingCount?: number;
26
31
  }> {
27
- // Check if generated directory exists and has recent files
32
+ // Deployed states come directly from the DB don't infer from filesystem
33
+ if (dbState === 'VERIFIED') return { status: 'VERIFIED' };
34
+ if (dbState === 'INSTALLED') return { status: 'DEPLOYED' };
35
+
36
+ // For pre-deploy states, derive from filesystem / config
28
37
  if (existsSync(generatedPath)) {
29
- // TODO: Add DEPLOYED and NEEDS_UPDATE detection
30
- // For now, if generated exists, it's GENERATED
31
38
  return { status: 'GENERATED' };
32
39
  }
33
40
 
34
- // Check if all required variables are set
35
41
  const requiredVars = manifest.variables?.owns?.filter((v) => v.required) || [];
36
42
  const configMap = new Map(configs.map((c) => [c.key, true]));
37
-
38
43
  const missingVars = requiredVars.filter((v) => !configMap.has(v.name));
39
44
 
40
- if (missingVars.length === 0 && requiredVars.length > 0) {
41
- return { status: 'CONFIGURED' };
42
- }
43
-
44
45
  if (missingVars.length > 0) {
45
46
  return { status: 'IMPORTED', missingCount: missingVars.length };
46
47
  }
47
48
 
48
- // No required variables, but some config set
49
- if (configs.length > 0) {
49
+ if (requiredVars.length > 0 || configs.length > 0) {
50
50
  return { status: 'CONFIGURED' };
51
51
  }
52
52
 
@@ -116,10 +116,16 @@ export async function handleStatus(): Promise<CommandResult> {
116
116
  manifest,
117
117
  moduleConfigsList,
118
118
  generatedPath,
119
+ module.state,
119
120
  );
120
121
 
121
122
  // Status icon
122
- const icon = statusInfo.status === 'GENERATED' ? '✓' : '⚠';
123
+ const icon =
124
+ statusInfo.status === 'VERIFIED' ||
125
+ statusInfo.status === 'DEPLOYED' ||
126
+ statusInfo.status === 'GENERATED'
127
+ ? '✓'
128
+ : '⚠';
123
129
 
124
130
  lines.push(` ${icon} ${module.id} (v${module.version})`);
125
131
  lines.push(` Status: ${statusInfo.status}`);
@@ -169,7 +175,11 @@ export async function handleStatus(): Promise<CommandResult> {
169
175
  }
170
176
 
171
177
  // Show when last generated (if applicable)
172
- if (statusInfo.status === 'GENERATED') {
178
+ if (
179
+ statusInfo.status === 'GENERATED' ||
180
+ statusInfo.status === 'DEPLOYED' ||
181
+ statusInfo.status === 'VERIFIED'
182
+ ) {
173
183
  try {
174
184
  const stats = await stat(generatedPath);
175
185
  const age = Date.now() - stats.mtimeMs;
@@ -197,11 +207,11 @@ export async function handleStatus(): Promise<CommandResult> {
197
207
 
198
208
  // Legend
199
209
  lines.push('Legend:');
200
- lines.push(' IMPORTED - Module imported, not configured');
201
- lines.push(' CONFIGURED - Configuration complete, not generated');
202
- lines.push(' GENERATED - Infrastructure code generated, not deployed');
203
- lines.push(' DEPLOYED - Running in production (not yet implemented)');
204
- lines.push(' NEEDS_UPDATE - Configuration changed, needs regeneration (not yet implemented)');
210
+ lines.push(' IMPORTED - Module imported, not configured');
211
+ lines.push(' CONFIGURED - Configuration complete, not generated');
212
+ lines.push(' GENERATED - Infrastructure code generated, not deployed');
213
+ lines.push(' DEPLOYED - Deployed (Ansible complete, health check pending)');
214
+ lines.push(' VERIFIED - Deployed and health checks passed');
205
215
 
206
216
  return {
207
217
  success: true,
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SystemAuditReport } from '../../services/audit';
3
+ import { formatReport } from './system-audit';
4
+
5
+ const baseReport = (overrides: Partial<SystemAuditReport> = {}): SystemAuditReport => ({
6
+ version: 1,
7
+ verdict: 'READY',
8
+ generatedAt: '2026-04-25T00:00:00.000Z',
9
+ findings: [],
10
+ ...overrides,
11
+ });
12
+
13
+ describe('formatReport', () => {
14
+ test('READY report calls out no drift', () => {
15
+ const out = formatReport(baseReport());
16
+ expect(out).toContain('READY');
17
+ expect(out).toContain('No drift detected');
18
+ });
19
+
20
+ test('DRIFT report lists each finding with remediation', () => {
21
+ const out = formatReport(
22
+ baseReport({
23
+ verdict: 'DRIFT',
24
+ findings: [
25
+ {
26
+ category: 'cli_version',
27
+ severity: 'drift',
28
+ code: 'cli_version_drift',
29
+ message: '@celilo/cli 0.1.5 → 0.1.7 available',
30
+ remediation: '`celilo system update` will self-update first',
31
+ subject: 'system',
32
+ },
33
+ ],
34
+ }),
35
+ );
36
+
37
+ expect(out).toContain('DRIFT (1 finding)');
38
+ expect(out).toContain('@celilo/cli 0.1.5 → 0.1.7');
39
+ expect(out).toContain('→ `celilo system update`');
40
+ });
41
+
42
+ test('BLOCKED report puts blocked findings first', () => {
43
+ const out = formatReport(
44
+ baseReport({
45
+ verdict: 'BLOCKED',
46
+ findings: [
47
+ {
48
+ category: 'cli_version',
49
+ severity: 'drift',
50
+ code: 'cli_version_drift',
51
+ message: 'cli is old',
52
+ subject: 'system',
53
+ },
54
+ {
55
+ category: 'schema',
56
+ severity: 'blocked',
57
+ code: 'schema_pending_migrations',
58
+ message: '2 pending DB migrations',
59
+ subject: 'system',
60
+ },
61
+ ],
62
+ }),
63
+ );
64
+
65
+ const blockedIdx = out.indexOf('BLOCKED • schema');
66
+ const driftIdx = out.indexOf('DRIFT • cli_version');
67
+ expect(blockedIdx).toBeGreaterThanOrEqual(0);
68
+ expect(driftIdx).toBeGreaterThan(blockedIdx);
69
+ });
70
+
71
+ test('groups findings by category', () => {
72
+ const out = formatReport(
73
+ baseReport({
74
+ verdict: 'DRIFT',
75
+ findings: [
76
+ {
77
+ category: 'module_versions',
78
+ severity: 'drift',
79
+ code: 'x',
80
+ message: 'caddy: 1.0 → 1.1',
81
+ subject: 'caddy',
82
+ },
83
+ {
84
+ category: 'module_versions',
85
+ severity: 'drift',
86
+ code: 'x',
87
+ message: 'iptables: 1.0 → 1.1',
88
+ subject: 'iptables',
89
+ },
90
+ ],
91
+ }),
92
+ );
93
+
94
+ expect(out).toContain('DRIFT • module_versions (2)');
95
+ expect(out).toContain('caddy: 1.0 → 1.1');
96
+ expect(out).toContain('iptables: 1.0 → 1.1');
97
+ });
98
+
99
+ test('renders details on indented lines', () => {
100
+ const out = formatReport(
101
+ baseReport({
102
+ verdict: 'BLOCKED',
103
+ findings: [
104
+ {
105
+ category: 'schema',
106
+ severity: 'blocked',
107
+ code: 'schema_pending_migrations',
108
+ message: '2 pending DB migrations',
109
+ details: ' • 0001_zones\n • 0002_backups',
110
+ subject: 'system',
111
+ },
112
+ ],
113
+ }),
114
+ );
115
+
116
+ expect(out).toContain('0001_zones');
117
+ expect(out).toContain('0002_backups');
118
+ });
119
+
120
+ test('singular wording for exactly one finding', () => {
121
+ const out = formatReport(
122
+ baseReport({
123
+ verdict: 'DRIFT',
124
+ findings: [
125
+ {
126
+ category: 'cli_version',
127
+ severity: 'drift',
128
+ code: 'x',
129
+ message: 'cli is old',
130
+ subject: 'system',
131
+ },
132
+ ],
133
+ }),
134
+ );
135
+ expect(out).toContain('DRIFT (1 finding)');
136
+ expect(out).not.toContain('1 findings');
137
+ });
138
+ });