@celilo/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Helper to load + invoke a single named hook on a module.
3
+ *
4
+ * Centralises the boilerplate that `module run-hook` and `module remove`
5
+ * (for `on_uninstall`) and `module deploy` (for `on_install`) all share:
6
+ * looking up the module + manifest, decrypting secrets, building the
7
+ * config map, loading the capability table, and calling `invokeHook`
8
+ * with the right logger.
9
+ *
10
+ * The helper is intentionally orchestration-only (Rule 10.1): it has no
11
+ * UI / progress concerns of its own — callers wrap the returned
12
+ * promise with FuelGauge or console output as fits the surface they're
13
+ * exposing.
14
+ */
15
+ import { eq } from 'drizzle-orm';
16
+ import type { DbClient } from '../db/client';
17
+ import { modules, secrets } from '../db/schema';
18
+ import type { ModuleManifest } from '../manifest/schema';
19
+ import { decryptSecret } from '../secrets/encryption';
20
+ import { getOrCreateMasterKey } from '../secrets/master-key';
21
+ import { loadCapabilityFunctions } from './capability-loader';
22
+ import { invokeHook } from './executor';
23
+ import { loadHookConfigMap } from './load-hook-config';
24
+ import type { HookLogger, HookName, HookResult } from './types';
25
+
26
+ export interface RunNamedHookOptions {
27
+ /** Stream hook stdio to console rather than capture for a gauge. */
28
+ debug?: boolean;
29
+ /** Hook inputs (key=value pairs from the CLI, etc.). Default: empty. */
30
+ inputs?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface RunNamedHookResult extends HookResult {
34
+ /**
35
+ * True when the module's manifest does not declare a hook with this
36
+ * name. Distinguishes "the hook ran and returned success" from "no
37
+ * hook to run." Callers like `module remove` use this to skip
38
+ * gracefully when a module has no `on_uninstall` defined.
39
+ */
40
+ notDefined?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Load the module's hook definition from its manifest and run it,
45
+ * returning the executor's result.
46
+ *
47
+ * @param moduleId - DB id of the module to run the hook on.
48
+ * @param hookName - Name of the hook (e.g. `on_install`, `on_uninstall`).
49
+ * @param db - Database client.
50
+ * @param logger - Hook logger; the caller chooses gauge vs. console.
51
+ * @param options - Optional inputs / debug toggle.
52
+ * @returns Hook result, with `notDefined: true` when the manifest has
53
+ * no hook of that name.
54
+ */
55
+ export async function runNamedHook(
56
+ moduleId: string,
57
+ hookName: HookName,
58
+ db: DbClient,
59
+ logger: HookLogger,
60
+ options: RunNamedHookOptions = {},
61
+ ): Promise<RunNamedHookResult> {
62
+ const startedAt = Date.now();
63
+
64
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
65
+ if (!module) {
66
+ return {
67
+ success: false,
68
+ outputs: {},
69
+ error: `Module not found: ${moduleId}`,
70
+ duration: Date.now() - startedAt,
71
+ };
72
+ }
73
+
74
+ const manifest = module.manifestData as ModuleManifest;
75
+ const hookDef = manifest.hooks?.[hookName as keyof typeof manifest.hooks];
76
+ if (!hookDef) {
77
+ return {
78
+ success: true,
79
+ outputs: {},
80
+ duration: Date.now() - startedAt,
81
+ notDefined: true,
82
+ };
83
+ }
84
+
85
+ const configMap = await loadHookConfigMap(moduleId, db);
86
+
87
+ // Decrypt secrets.
88
+ const secretRecords = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all();
89
+ const masterKey = await getOrCreateMasterKey();
90
+ const secretMap: Record<string, string> = {};
91
+ for (const s of secretRecords) {
92
+ secretMap[s.name] = decryptSecret(
93
+ { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
94
+ masterKey,
95
+ );
96
+ }
97
+
98
+ // Capability pre-flight check: enforce manifest.requires for hooks
99
+ // that mutate state (on_install, health_check, etc.); skip it for
100
+ // teardown-style hooks where we want best-effort cleanup. The hook's
101
+ // own `defineHook({ requires, optional })` declaration still governs
102
+ // what the script actually accesses — this only controls the
103
+ // framework-level pre-flight gate, which would otherwise refuse to
104
+ // run an `on_uninstall` whenever a provider is "imported but not
105
+ // deployed" (a state the test harness produces routinely, and that
106
+ // operators producing failed half-removed modules also hit).
107
+ const enforceRequires = hookName !== 'on_uninstall';
108
+ const requiredCapabilities = enforceRequires
109
+ ? manifest.requires.capabilities.map((c) => c.name)
110
+ : [];
111
+ const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
112
+
113
+ return invokeHook(
114
+ module.sourcePath,
115
+ hookName,
116
+ manifest.celilo_contract,
117
+ hookDef,
118
+ options.inputs ?? {},
119
+ configMap,
120
+ secretMap,
121
+ logger,
122
+ {
123
+ debug: options.debug ?? false,
124
+ capabilities: capabilityFunctions,
125
+ requiredCapabilities,
126
+ },
127
+ );
128
+ }
@@ -53,6 +53,19 @@ export interface HookContext {
53
53
  [key: string]: unknown;
54
54
  }
55
55
 
56
+ /**
57
+ * Surfaced by `invokeHook` when a capability call inside the hook threw
58
+ * a `MissingProviderInputError`. Callers (notably `module-deploy.ts`)
59
+ * use this to drive the cross-module ensure interview and retry the
60
+ * hook. See `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`.
61
+ */
62
+ export interface MissingProviderInputDetails {
63
+ providerModuleId: string;
64
+ ensureId: string;
65
+ value: string;
66
+ humanContext?: string;
67
+ }
68
+
56
69
  /**
57
70
  * Result returned from hook execution
58
71
  */
@@ -64,6 +77,12 @@ export interface HookResult {
64
77
  screenshotPath?: string;
65
78
  /** Duration in milliseconds */
66
79
  duration: number;
80
+ /**
81
+ * Set when the hook failed because a capability call needs the
82
+ * framework to extend another module's config. Mutually exclusive
83
+ * with `success: true`.
84
+ */
85
+ missingProviderInput?: MissingProviderInputDetails;
67
86
  }
68
87
 
69
88
  /**
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { EnsureInputSchema, EnsureSchema } from './schema';
3
+
4
+ describe('EnsureInputSchema', () => {
5
+ test('append_to_array with config target', () => {
6
+ const result = EnsureInputSchema.safeParse({
7
+ kind: 'append_to_array',
8
+ target: 'config.additional_domains',
9
+ });
10
+ expect(result.success).toBe(true);
11
+ });
12
+
13
+ test('append_to_array with secret target', () => {
14
+ // `append_to_array` is allowed against either scope by schema; runtime
15
+ // currently only implements config but the schema is permissive so
16
+ // future modules can extend.
17
+ const result = EnsureInputSchema.safeParse({
18
+ kind: 'append_to_array',
19
+ target: 'secret.allowed_domains',
20
+ });
21
+ expect(result.success).toBe(true);
22
+ });
23
+
24
+ test('append_to_array rejects an invalid target prefix', () => {
25
+ const result = EnsureInputSchema.safeParse({
26
+ kind: 'append_to_array',
27
+ target: 'output.domains',
28
+ });
29
+ expect(result.success).toBe(false);
30
+ });
31
+
32
+ test('set_in_object requires key and prompt', () => {
33
+ const ok = EnsureInputSchema.safeParse({
34
+ kind: 'set_in_object',
35
+ target: 'secret.passwords',
36
+ key: '{{value}}',
37
+ prompt: 'Password for {{value}}',
38
+ });
39
+ expect(ok.success).toBe(true);
40
+
41
+ const missingPrompt = EnsureInputSchema.safeParse({
42
+ kind: 'set_in_object',
43
+ target: 'secret.passwords',
44
+ key: '{{value}}',
45
+ });
46
+ expect(missingPrompt.success).toBe(false);
47
+ });
48
+
49
+ test('rejects unknown kinds', () => {
50
+ const result = EnsureInputSchema.safeParse({
51
+ kind: 'mutate_universe',
52
+ target: 'config.foo',
53
+ });
54
+ expect(result.success).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe('EnsureSchema', () => {
59
+ test('full ensure block round-trips', () => {
60
+ const result = EnsureSchema.safeParse({
61
+ id: 'managed_domain',
62
+ description: 'Add a domain.',
63
+ inputs: [
64
+ { kind: 'append_to_array', target: 'config.additional_domains' },
65
+ {
66
+ kind: 'set_in_object',
67
+ target: 'secret.additional_ddns_passwords',
68
+ key: '{{value}}',
69
+ prompt: 'Password for {{value}}',
70
+ hint: 'Find it in the panel',
71
+ },
72
+ ],
73
+ post: 'redeploy_self',
74
+ });
75
+ expect(result.success).toBe(true);
76
+ if (result.success) {
77
+ expect(result.data.id).toBe('managed_domain');
78
+ expect(result.data.inputs).toHaveLength(2);
79
+ expect(result.data.post).toBe('redeploy_self');
80
+ }
81
+ });
82
+
83
+ test('id must be snake_case', () => {
84
+ const bad = EnsureSchema.safeParse({
85
+ id: 'ManagedDomain',
86
+ inputs: [{ kind: 'append_to_array', target: 'config.x' }],
87
+ });
88
+ expect(bad.success).toBe(false);
89
+ });
90
+
91
+ test('inputs cannot be empty', () => {
92
+ const bad = EnsureSchema.safeParse({
93
+ id: 'foo',
94
+ inputs: [],
95
+ });
96
+ expect(bad.success).toBe(false);
97
+ });
98
+
99
+ test('post is optional', () => {
100
+ const ok = EnsureSchema.safeParse({
101
+ id: 'foo',
102
+ inputs: [{ kind: 'append_to_array', target: 'config.x' }],
103
+ });
104
+ expect(ok.success).toBe(true);
105
+ });
106
+
107
+ test('rejects unknown post action', () => {
108
+ const bad = EnsureSchema.safeParse({
109
+ id: 'foo',
110
+ inputs: [{ kind: 'append_to_array', target: 'config.x' }],
111
+ post: 'restart_service',
112
+ });
113
+ expect(bad.success).toBe(false);
114
+ });
115
+ });
@@ -127,6 +127,69 @@ export const CapabilitySecretSchema = z.object({
127
127
  secret_ref: z.string().optional(), // Reference to provider module's own secret (e.g., $secret:tsig_key)
128
128
  });
129
129
 
130
+ /**
131
+ * Ensure-input declaration.
132
+ *
133
+ * Each `inputs` entry tells the framework how to apply one piece of the
134
+ * cross-module update. The `target:` prefix (`config.*` vs `secret.*`)
135
+ * disambiguates whether the framework should write to the module's
136
+ * config or its secrets — secret targets get the same masking and
137
+ * storage path as a regular `secrets:` declaration.
138
+ *
139
+ * Templates: `{{value}}` is replaced with the consumer-supplied value.
140
+ *
141
+ * Kinds:
142
+ * - `append_to_array`: read current config array, append `value` if not
143
+ * already present, write back. Idempotent.
144
+ * - `set_in_object`: prompt for a string, set it under `key` (a template,
145
+ * typically `"{{value}}"`) on a JSON-object config or secret.
146
+ */
147
+ export const EnsureInputSchema = z.discriminatedUnion('kind', [
148
+ z.object({
149
+ kind: z.literal('append_to_array'),
150
+ /** `config.<name>` or `secret.<name>` — pointer to the array to append to. */
151
+ target: z.string().regex(/^(config|secret)\.[A-Za-z_][A-Za-z0-9_]*$/),
152
+ }),
153
+ z.object({
154
+ kind: z.literal('set_in_object'),
155
+ /** `config.<name>` or `secret.<name>` — pointer to a JSON-object value. */
156
+ target: z.string().regex(/^(config|secret)\.[A-Za-z_][A-Za-z0-9_]*$/),
157
+ /** Template for the object key. Typically `"{{value}}"`. */
158
+ key: z.string().min(1),
159
+ /** Prompt shown to the user when collecting the per-key value. */
160
+ prompt: z.string().min(1),
161
+ /** Optional supplemental hint shown under the prompt. */
162
+ hint: z.string().optional(),
163
+ }),
164
+ ]);
165
+
166
+ export type EnsureInput = z.infer<typeof EnsureInputSchema>;
167
+
168
+ /**
169
+ * Post-action vocabulary — what the framework should do after applying
170
+ * `inputs`. Phase 3 only handles `redeploy_self`. Future modules may
171
+ * want `restart_service`, `run_hook`, etc.; add when needed.
172
+ */
173
+ export const EnsurePostSchema = z.enum(['redeploy_self']);
174
+
175
+ /**
176
+ * Ensure block — declares how the framework can "ensure" a value is
177
+ * covered by this provider's config. Consumers reference these by `id`
178
+ * via `MissingProviderInputError(ensureId, value)`. See
179
+ * `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`.
180
+ */
181
+ export const EnsureSchema = z.object({
182
+ id: z
183
+ .string()
184
+ .min(1)
185
+ .regex(/^[a-z][a-z0-9_]*$/, 'Ensure id must be snake_case'),
186
+ description: z.string().optional(),
187
+ inputs: z.array(EnsureInputSchema).min(1),
188
+ post: EnsurePostSchema.optional(),
189
+ });
190
+
191
+ export type Ensure = z.infer<typeof EnsureSchema>;
192
+
130
193
  /**
131
194
  * Capability provider
132
195
  * Module provides this capability to other modules
@@ -139,6 +202,13 @@ export const CapabilityProviderSchema = z.object({
139
202
  functions: z.array(z.string()).optional(),
140
203
  /** Zones this capability applies to. Null/undefined = zone-agnostic. */
141
204
  zones: z.array(z.string()).optional(),
205
+ /**
206
+ * Cross-module ensure points. When a consumer's capability call detects
207
+ * a value isn't covered by this provider's config, the framework looks
208
+ * up an ensure here by id and runs the matching interview against this
209
+ * module's config/secrets.
210
+ */
211
+ ensures: z.array(EnsureSchema).optional(),
142
212
  });
143
213
 
144
214
  /**
@@ -348,6 +418,12 @@ export const ModuleManifestSchema = z
348
418
  collections: z.array(AnsibleCollectionSchema).default([]),
349
419
  })
350
420
  .optional(),
421
+
422
+ e2e: z
423
+ .object({
424
+ tests_dir: z.string().min(1),
425
+ })
426
+ .optional(),
351
427
  })
352
428
  .strict();
353
429
 
@@ -441,18 +441,26 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
441
441
  }
442
442
  }
443
443
 
444
- // Read checksums and signature for database storage
445
- const checksumsJson = await readFile(join(tempDir, 'checksums.json'), 'utf-8');
446
- const ChecksumsFileSchema = z.object({
447
- files: z.record(z.string(), z.string()),
448
- });
449
- const checksumsData = parseJsonWithValidation(
450
- checksumsJson,
451
- ChecksumsFileSchema,
452
- 'package checksums.json',
453
- );
454
- checksums = checksumsData.files;
455
- signature = await readFile(join(tempDir, 'signature.sig'), 'utf-8');
444
+ // Read checksums and signature for database storage (both optional with --skip-verify)
445
+ try {
446
+ const checksumsJson = await readFile(join(tempDir, 'checksums.json'), 'utf-8');
447
+ const ChecksumsFileSchema = z.object({
448
+ files: z.record(z.string(), z.string()),
449
+ });
450
+ const checksumsData = parseJsonWithValidation(
451
+ checksumsJson,
452
+ ChecksumsFileSchema,
453
+ 'package checksums.json',
454
+ );
455
+ checksums = checksumsData.files;
456
+ } catch (err) {
457
+ if (!skipVerify) throw err;
458
+ }
459
+ try {
460
+ signature = await readFile(join(tempDir, 'signature.sig'), 'utf-8');
461
+ } catch (err) {
462
+ if (!skipVerify) throw err;
463
+ }
456
464
 
457
465
  // Use extracted directory as source
458
466
  actualSourcePath = tempDir;
@@ -1,10 +1,11 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync, execSync } from 'node:child_process';
2
2
  import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
3
3
  import { readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, join, relative } from 'node:path';
6
6
  import { create as tarCreate } from 'tar';
7
7
  import { parse as parseYaml } from 'yaml';
8
+ import { log } from '../../cli/prompts';
8
9
  import { validateModuleDirectory } from '../import';
9
10
  import { computeFileChecksum } from './checksum';
10
11
  import { signChecksums } from './signature';
@@ -25,6 +26,14 @@ export interface ModuleBuildOptions {
25
26
  sourceDir: string;
26
27
  outputPath?: string;
27
28
  masterKeyPath?: string;
29
+ /**
30
+ * Optional release metadata to stamp into the package as `release.json`.
31
+ * `celilo module publish` collects this; `celilo module package` runs
32
+ * without it (no git/CLI context) and ships a package without
33
+ * release.json — that's fine; audit treats absent metadata as
34
+ * "unknown release info" rather than as an error.
35
+ */
36
+ releaseMetadata?: import('./release-metadata').ReleaseMetadata;
28
37
  }
29
38
 
30
39
  /**
@@ -37,13 +46,17 @@ export interface ModuleBuildResult {
37
46
  }
38
47
 
39
48
  /**
40
- * Files/directories to exclude from package
49
+ * Files/directories to exclude from package.
50
+ *
51
+ * `node_modules` is handled by `shouldExclude` directly (path-aware) so
52
+ * the framework's own `@celilo/*` packages can still be bundled — see
53
+ * the comment on shouldExclude for why.
41
54
  */
42
55
  const EXCLUDE_PATTERNS = [
43
56
  '.git',
44
- 'node_modules',
45
57
  '.DS_Store',
46
58
  '*.netapp',
59
+ '*.test.ts',
47
60
  'checksums.json',
48
61
  'signature.sig',
49
62
  ];
@@ -59,10 +72,38 @@ const EXCLUDE_PATTERNS = [
59
72
  const FRAMEWORK_OWNED_PATHS = new Set(['celilo/types.d.ts']);
60
73
 
61
74
  /**
62
- * Check if file should be excluded
75
+ * Check if a path inside the source dir should be excluded from the
76
+ * package.
77
+ *
78
+ * `node_modules` exclusion is path-aware: regular npm dependencies are
79
+ * skipped (size + bun re-installs them at module-import time), but the
80
+ * framework's own `@celilo/*` packages stay in. That captures the
81
+ * capabilities API the module was authored against, so a module's
82
+ * hooks aren't silently broken by a runtime that ships a different
83
+ * version of `@celilo/capabilities` than the one on npm.
63
84
  */
64
85
  function shouldExclude(filePath: string): boolean {
65
86
  if (FRAMEWORK_OWNED_PATHS.has(filePath)) return true;
87
+
88
+ const segments = filePath.split('/');
89
+
90
+ // Module's e2e/ directory at the source root is tests + their deps —
91
+ // not part of the deployed module.
92
+ if (segments[0] === 'e2e') return true;
93
+
94
+ const nmIdx = segments.indexOf('node_modules');
95
+ if (nmIdx >= 0) {
96
+ // `node_modules` itself: descend so we can pick out @celilo/capabilities.
97
+ // The capabilities scope is the only thing we ship — it's the framework
98
+ // SDK the module was authored against. Everything else is regular npm
99
+ // cruft we'd just re-install on the target (or test-only deps inside
100
+ // packages like `@celilo/e2e` we don't want to drag in).
101
+ if (nmIdx + 1 >= segments.length) return false; // node_modules dir itself
102
+ if (segments[nmIdx + 1] !== '@celilo') return true;
103
+ if (nmIdx + 2 >= segments.length) return false; // node_modules/@celilo dir itself
104
+ return segments[nmIdx + 2] !== 'capabilities';
105
+ }
106
+
66
107
  const name = basename(filePath);
67
108
  return EXCLUDE_PATTERNS.some((pattern) => {
68
109
  if (pattern.startsWith('*')) {
@@ -191,19 +232,38 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
191
232
  // Run build if manifest declares one
192
233
  const manifest = parseYaml(manifestContent);
193
234
  if (manifest.build?.command || manifest.build?.script) {
194
- const buildCmd = manifest.build.command
195
- ? `bash -c ${JSON.stringify(manifest.build.command)}`
196
- : manifest.build.script.endsWith('.sh')
197
- ? `bash ${manifest.build.script}`
198
- : `ansible-playbook ${manifest.build.script}`;
199
-
200
- console.log(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
235
+ log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
236
+ // The build runs in a staged copy of the module (buildDir), so
237
+ // relative paths that reach outside the module (e.g. sibling packages
238
+ // in a monorepo) don't resolve. Expose the ORIGINAL unstaged source
239
+ // path so build commands can find siblings via e.g.
240
+ // `$CELILO_MODULE_SOURCE_DIR/../../packages/...`. celilo-registry
241
+ // uses this to compile packages/registry-server into a single-file
242
+ // binary and drop it into the staged ansible files/ dir.
243
+ //
244
+ // Use execFileSync (not execSync) so the command string goes straight
245
+ // to bash without a /bin/sh wrapping pass. Otherwise outer-shell
246
+ // variable expansion would strip shell-only bash variables like
247
+ // $STAGE before bash ever sees them.
248
+ const buildEnv = { ...process.env, CELILO_MODULE_SOURCE_DIR: sourceDir };
201
249
  try {
202
- execSync(buildCmd, {
203
- cwd: buildDir,
204
- stdio: 'inherit',
205
- timeout: 300_000,
206
- });
250
+ if (manifest.build.command) {
251
+ execFileSync('bash', ['-c', manifest.build.command], {
252
+ cwd: buildDir,
253
+ stdio: 'inherit',
254
+ timeout: 300_000,
255
+ env: buildEnv,
256
+ });
257
+ } else {
258
+ const script = manifest.build.script as string;
259
+ const cmd = script.endsWith('.sh') ? 'bash' : 'ansible-playbook';
260
+ execFileSync(cmd, [script], {
261
+ cwd: buildDir,
262
+ stdio: 'inherit',
263
+ timeout: 300_000,
264
+ env: buildEnv,
265
+ });
266
+ }
207
267
  } catch (buildError) {
208
268
  const msg = buildError instanceof Error ? buildError.message : String(buildError);
209
269
  return { success: false, error: `Auto-build failed: Build exited with code ${msg}` };
@@ -223,6 +283,15 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
223
283
  }
224
284
  }
225
285
 
286
+ // Stamp release metadata into the build dir BEFORE computing
287
+ // checksums, so the metadata is part of the integrity-verified
288
+ // payload (a future restore can trust the SHA / version it sees).
289
+ if (options.releaseMetadata) {
290
+ const { RELEASE_METADATA_FILENAME } = await import('./release-metadata');
291
+ const metadataPath = join(buildDir, RELEASE_METADATA_FILENAME);
292
+ await writeFile(metadataPath, JSON.stringify(options.releaseMetadata, null, 2));
293
+ }
294
+
226
295
  // Compute checksums
227
296
  const checksumsData = await computeChecksums(buildDir);
228
297
  const checksumsJson = JSON.stringify(checksumsData, null, 2);
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type GitCommandRunner, buildReleaseMetadata, collectGitInfo } from './release-metadata';
3
+
4
+ describe('buildReleaseMetadata', () => {
5
+ test('produces a stable shape from injected inputs', () => {
6
+ const meta = buildReleaseMetadata({
7
+ moduleId: 'caddy',
8
+ version: '1.2.0+3',
9
+ git: { sha: 'abc1234', branch: 'main', dirty: false },
10
+ cliVersion: '0.1.5',
11
+ message: 'Fix DNS race',
12
+ publishedAt: new Date('2026-04-25T18:30:00Z'),
13
+ });
14
+
15
+ expect(meta).toEqual({
16
+ module_id: 'caddy',
17
+ version: '1.2.0+3',
18
+ git_sha: 'abc1234',
19
+ git_branch: 'main',
20
+ git_dirty: false,
21
+ published_at: '2026-04-25T18:30:00.000Z',
22
+ published_by_cli_version: '0.1.5',
23
+ message: 'Fix DNS race',
24
+ });
25
+ });
26
+
27
+ test('null message is preserved (no defaulting to empty string)', () => {
28
+ const meta = buildReleaseMetadata({
29
+ moduleId: 'x',
30
+ version: '1.0.0+1',
31
+ git: { sha: null, branch: null, dirty: false },
32
+ cliVersion: '0.1.5',
33
+ message: null,
34
+ publishedAt: new Date('2026-04-25T00:00:00Z'),
35
+ });
36
+ expect(meta.message).toBeNull();
37
+ });
38
+
39
+ test('defaults publishedAt to now when omitted', () => {
40
+ const before = Date.now();
41
+ const meta = buildReleaseMetadata({
42
+ moduleId: 'x',
43
+ version: '1.0.0+1',
44
+ git: { sha: null, branch: null, dirty: false },
45
+ cliVersion: '0.1.5',
46
+ message: null,
47
+ });
48
+ const stamped = new Date(meta.published_at).getTime();
49
+ expect(stamped).toBeGreaterThanOrEqual(before);
50
+ expect(stamped).toBeLessThanOrEqual(Date.now());
51
+ });
52
+ });
53
+
54
+ describe('collectGitInfo', () => {
55
+ test('returns nulls + clean when not in a git checkout', () => {
56
+ const run: GitCommandRunner = () => null;
57
+ expect(collectGitInfo('/tmp/x', run)).toEqual({
58
+ sha: null,
59
+ branch: null,
60
+ dirty: false,
61
+ });
62
+ });
63
+
64
+ test('reads sha + branch + clean status', () => {
65
+ const calls: string[][] = [];
66
+ const run: GitCommandRunner = (args) => {
67
+ calls.push(args);
68
+ if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234';
69
+ if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main';
70
+ if (args[0] === 'status') return '';
71
+ return null;
72
+ };
73
+
74
+ const info = collectGitInfo('/tmp/x', run);
75
+
76
+ expect(info).toEqual({ sha: 'abc1234', branch: 'main', dirty: false });
77
+ expect(calls).toEqual([
78
+ ['rev-parse', 'HEAD'],
79
+ ['rev-parse', '--abbrev-ref', 'HEAD'],
80
+ ['status', '--porcelain'],
81
+ ]);
82
+ });
83
+
84
+ test('detached HEAD reports null branch', () => {
85
+ const run: GitCommandRunner = (args) => {
86
+ if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234';
87
+ if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD';
88
+ if (args[0] === 'status') return '';
89
+ return null;
90
+ };
91
+ expect(collectGitInfo('/tmp/x', run).branch).toBeNull();
92
+ });
93
+
94
+ test('non-empty status output → dirty=true', () => {
95
+ const run: GitCommandRunner = (args) => {
96
+ if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234';
97
+ if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main';
98
+ if (args[0] === 'status') return ' M src/foo.ts\n?? new-file.txt';
99
+ return null;
100
+ };
101
+ expect(collectGitInfo('/tmp/x', run).dirty).toBe(true);
102
+ });
103
+ });