@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,234 @@
1
+ /**
2
+ * Module publish command — build and publish a module to the registry.
3
+ *
4
+ * Usage: celilo module publish <module-dir> --token <token>
5
+ * [--registry <url>] [--revision <n>]
6
+ * [--message "release note"] [--allow-dirty]
7
+ *
8
+ * The --token flag (or CELILO_PUBLISH_TOKEN env var) must contain a valid
9
+ * publish token configured on the registry server.
10
+ *
11
+ * Per CELILO_UPDATE D4 (strict-publish) the manifest's capability
12
+ * versions are validated against `CAPABILITY_CONTRACT_VERSIONS` from
13
+ * `@celilo/capabilities` before any build work; a mismatch refuses
14
+ * the publish so a stale manifest can't ship code that doesn't match
15
+ * its claimed contract.
16
+ *
17
+ * Per CELILO_UPDATE D5, every published .netapp carries a
18
+ * `release.json` with git SHA / branch / dirty flag / publish
19
+ * timestamp / CLI version / optional --message.
20
+ */
21
+
22
+ import { rm } from 'node:fs/promises';
23
+ import { readFile } from 'node:fs/promises';
24
+ import { tmpdir } from 'node:os';
25
+ import { join, resolve } from 'node:path';
26
+ import {
27
+ CAPABILITY_CONTRACT_VERSIONS,
28
+ type KnownCapabilityName,
29
+ compareProviderToRuntime,
30
+ } from '@celilo/capabilities';
31
+ import { parse as parseYaml } from 'yaml';
32
+ import { buildModule } from '../../module/packaging/build';
33
+ import {
34
+ buildReleaseMetadata,
35
+ collectGitInfo,
36
+ makeRealGitRunner,
37
+ readInstalledCliVersion,
38
+ } from '../../module/packaging/release-metadata';
39
+ import { RegistryClient } from '../../registry/client';
40
+ import { getArg, getFlag, hasFlag } from '../parser';
41
+ import type { CommandResult } from '../types';
42
+
43
+ interface ManifestCapabilityClaim {
44
+ name: string;
45
+ version: string;
46
+ }
47
+
48
+ interface ManifestForPublish {
49
+ id: string;
50
+ version: string;
51
+ provides?: { capabilities?: ManifestCapabilityClaim[] };
52
+ requires?: { capabilities?: ManifestCapabilityClaim[] };
53
+ }
54
+
55
+ function isKnownCapability(name: string): name is KnownCapabilityName {
56
+ return name in CAPABILITY_CONTRACT_VERSIONS;
57
+ }
58
+
59
+ /**
60
+ * Strict-publish: every `provides[X].version` must match the framework's
61
+ * runtime registry; every `requires[X].version` must be at most the
62
+ * framework's runtime version (consumers can require older minors of
63
+ * still-supported majors).
64
+ *
65
+ * Exported for testing.
66
+ */
67
+ export function validateCapabilityVersions(manifest: ManifestForPublish): string[] {
68
+ const errors: string[] = [];
69
+
70
+ for (const p of manifest.provides?.capabilities ?? []) {
71
+ if (!isKnownCapability(p.name)) continue;
72
+ const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
73
+ const r = compareProviderToRuntime(p.version, runtime);
74
+ if (!r.compatible) {
75
+ errors.push(
76
+ `provides[${p.name}].version is ${p.version} but framework registry is ${runtime} ` +
77
+ `(${r.reason}). Update the manifest, then retry.`,
78
+ );
79
+ }
80
+ }
81
+
82
+ for (const need of manifest.requires?.capabilities ?? []) {
83
+ if (!isKnownCapability(need.name)) continue;
84
+ const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
85
+ // Treat the runtime as the de-facto provider for publish-time validation:
86
+ // if a consumer's manifest claims it requires a contract newer than the
87
+ // framework even has registered, no in-tree provider can satisfy it.
88
+ const r = compareProviderToRuntime(need.version, runtime);
89
+ if (!r.compatible && r.reason === 'major_mismatch_higher') {
90
+ errors.push(
91
+ `requires[${need.name}].version is ${need.version} but framework registry is ${runtime}. Bump the framework before publishing, or lower the manifest version.`,
92
+ );
93
+ }
94
+ }
95
+
96
+ return errors;
97
+ }
98
+
99
+ export async function handleModulePublish(
100
+ args: string[],
101
+ flags: Record<string, string | boolean>,
102
+ ): Promise<CommandResult> {
103
+ const moduleDir = getArg(args, 0);
104
+ if (!moduleDir) {
105
+ return {
106
+ success: false,
107
+ error:
108
+ 'Module directory required\n\nUsage: celilo module publish <module-dir> --token <token>',
109
+ };
110
+ }
111
+
112
+ const token = getFlag(flags, 'token', '') || process.env.CELILO_PUBLISH_TOKEN || '';
113
+ if (!token) {
114
+ return {
115
+ success: false,
116
+ error: 'Publish token required — pass --token <token> or set CELILO_PUBLISH_TOKEN',
117
+ };
118
+ }
119
+
120
+ const registryUrl = getFlag(flags, 'registry', '');
121
+ const revisionOverride = getFlag(flags, 'revision', '');
122
+ const message = getFlag(flags, 'message', '') || null;
123
+ const allowDirty = hasFlag(flags, 'allow-dirty');
124
+
125
+ if (revisionOverride) {
126
+ const parsed = Number(revisionOverride);
127
+ if (!Number.isInteger(parsed) || parsed < 1) {
128
+ return { success: false, error: '--revision must be a positive integer' };
129
+ }
130
+ }
131
+
132
+ const resolvedDir = resolve(moduleDir);
133
+
134
+ // Read manifest
135
+ let manifest: ManifestForPublish;
136
+ try {
137
+ const manifestRaw = await readFile(join(resolvedDir, 'manifest.yml'), 'utf-8');
138
+ manifest = parseYaml(manifestRaw) as ManifestForPublish;
139
+ if (!manifest.id || !manifest.version) {
140
+ return { success: false, error: 'manifest.yml missing id or version' };
141
+ }
142
+ } catch {
143
+ return { success: false, error: `Could not read manifest.yml in ${resolvedDir}` };
144
+ }
145
+ const name = manifest.id;
146
+ const baseVersion = manifest.version;
147
+
148
+ // Strict-publish: refuse on capability version mismatch (D4).
149
+ const capErrors = validateCapabilityVersions(manifest);
150
+ if (capErrors.length > 0) {
151
+ return {
152
+ success: false,
153
+ error: [
154
+ `Capability version validation failed for ${name}@${baseVersion}:`,
155
+ ...capErrors.map((e) => ` • ${e}`),
156
+ ].join('\n'),
157
+ };
158
+ }
159
+
160
+ // Collect git state. Refuse a dirty tree unless --allow-dirty.
161
+ const gitInfo = collectGitInfo(resolvedDir, makeRealGitRunner());
162
+ if (gitInfo.dirty && !allowDirty) {
163
+ return {
164
+ success: false,
165
+ error: [
166
+ `Working tree at ${resolvedDir} has uncommitted changes.`,
167
+ 'Refusing to publish — commit (or stash) your changes, or pass --allow-dirty to override.',
168
+ ].join('\n'),
169
+ };
170
+ }
171
+
172
+ // Determine package revision: --revision flag, or query the registry for next rev
173
+ let revision: number;
174
+ if (revisionOverride) {
175
+ revision = Number(revisionOverride);
176
+ } else {
177
+ const client = new RegistryClient(registryUrl || undefined);
178
+ try {
179
+ const entries = await client.getIndex(name);
180
+ const existingRevs = entries
181
+ .filter((e) => e.vers.startsWith(`${baseVersion}+`))
182
+ .map((e) => Number(e.vers.split('+')[1]))
183
+ .filter((n) => Number.isInteger(n));
184
+ revision = existingRevs.length > 0 ? Math.max(...existingRevs) + 1 : 1;
185
+ } catch {
186
+ // Registry unreachable — start at +1
187
+ revision = 1;
188
+ }
189
+ }
190
+
191
+ const version = `${baseVersion}+${revision}`;
192
+
193
+ // Assemble release metadata for the .netapp.
194
+ const releaseMetadata = buildReleaseMetadata({
195
+ moduleId: name,
196
+ version,
197
+ git: gitInfo,
198
+ cliVersion: readInstalledCliVersion(),
199
+ message,
200
+ });
201
+
202
+ // Build the .netapp into a temp dir
203
+ const tmpPath = join(tmpdir(), `${name}-${version}-${Date.now()}.netapp`);
204
+ console.log(`Building ${name}@${version}...`);
205
+
206
+ const buildResult = await buildModule({
207
+ sourceDir: resolvedDir,
208
+ outputPath: tmpPath,
209
+ releaseMetadata,
210
+ });
211
+ if (!buildResult.success || !buildResult.packagePath) {
212
+ return { success: false, error: buildResult.error ?? 'Build failed' };
213
+ }
214
+
215
+ // Publish
216
+ const client = new RegistryClient(registryUrl || undefined);
217
+ try {
218
+ console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`);
219
+ await client.publish({ name, version, netappPath: buildResult.packagePath, token });
220
+ } catch (err) {
221
+ return {
222
+ success: false,
223
+ error: `Publish failed: ${err instanceof Error ? err.message : String(err)}`,
224
+ };
225
+ } finally {
226
+ await rm(tmpPath, { force: true });
227
+ }
228
+
229
+ return {
230
+ success: true,
231
+ message: `Published ${name}@${version}${message ? ` — "${message}"` : ''}`,
232
+ data: { name, version, releaseMetadata },
233
+ };
234
+ }
@@ -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
+ }
@@ -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
+ }