@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
@@ -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
 
@@ -40,7 +40,7 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
40
40
  source: 'user',
41
41
  },
42
42
  {
43
- name: 'container_ip',
43
+ name: 'target_ip',
44
44
  type: 'string',
45
45
  required: false,
46
46
  source: 'user',
@@ -68,7 +68,7 @@ function pathExistsInManifest(manifest: ModuleManifest, path: string): boolean {
68
68
  */
69
69
  const AUTO_ALLOCATED_VARIABLES = new Set([
70
70
  'vmid', // Auto-allocated by IPAM (container-based modules)
71
- 'container_ip', // Auto-allocated by IPAM (container-based modules)
71
+ 'target_ip', // Auto-allocated by IPAM or injected from machine infrastructure
72
72
  'vlan', // Auto-derived from zone configuration
73
73
  'gateway', // Auto-derived from zone configuration
74
74
  'target_node', // Can be auto-derived from system config
@@ -894,7 +894,7 @@ describe('validateVariableSources', () => {
894
894
  variables: {
895
895
  owns: [
896
896
  {
897
- name: 'container_ip',
897
+ name: 'target_ip',
898
898
  type: 'string' as const,
899
899
  required: true,
900
900
  source: 'user' as const,
@@ -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('*')) {
@@ -140,17 +181,44 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
140
181
  return { success: false, error: dirError };
141
182
  }
142
183
 
143
- // Copy source to a temp directory so the build doesn't pollute the
144
- // source tree and avoids workspace resolution issues (e.g., a
145
- // monorepo's root package.json triggering bun workspace linking).
184
+ // Copy source to a temp dir for building. Strategy:
185
+ // - If the source has a package.json, use `bun pm pack` to respect the
186
+ // `files` field (or .npmignore), copying only what the build needs.
187
+ // Avoids copying node_modules, build artifacts, git history.
188
+ // - If no package.json (simple modules, test fixtures), fall back to
189
+ // recursive copy with EXCLUDE_PATTERNS filter.
146
190
  const buildDir = mkdtempSync(join(tmpdir(), 'celilo-package-'));
191
+ const hasPackageJson = existsSync(join(sourceDir, 'package.json'));
147
192
 
148
193
  try {
149
- console.log('Copying source to build directory...');
150
- cpSync(sourceDir, buildDir, {
151
- recursive: true,
152
- filter: (src) => !shouldExclude(relative(sourceDir, src)),
153
- });
194
+ if (hasPackageJson) {
195
+ console.log('Staging source for build (via bun pm pack)...');
196
+ try {
197
+ execSync(`bun pm pack --destination ${buildDir}`, {
198
+ cwd: sourceDir,
199
+ stdio: 'pipe',
200
+ timeout: 60_000,
201
+ });
202
+ const tarballs = execSync(`ls ${buildDir}/*.tgz`, { encoding: 'utf-8' }).trim().split('\n');
203
+ if (tarballs.length === 0) throw new Error('bun pm pack produced no tarball');
204
+ execSync(`tar -xzf ${tarballs[0]} --strip-components=1 -C ${buildDir}`, {
205
+ timeout: 60_000,
206
+ });
207
+ rmSync(tarballs[0], { force: true });
208
+ } catch (err) {
209
+ const errMsg = err instanceof Error ? err.message : String(err);
210
+ return {
211
+ success: false,
212
+ error: `Failed to stage source for build: ${errMsg}\n\nTip: Add a "files" field to your package.json listing the files/directories needed for the build.`,
213
+ };
214
+ }
215
+ } else {
216
+ console.log('Staging source for build (no package.json, using file copy)...');
217
+ cpSync(sourceDir, buildDir, {
218
+ recursive: true,
219
+ filter: (src) => !shouldExclude(relative(sourceDir, src)),
220
+ });
221
+ }
154
222
 
155
223
  // Read manifest to get module ID
156
224
  const manifestPath = join(buildDir, 'manifest.yml');
@@ -164,19 +232,38 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
164
232
  // Run build if manifest declares one
165
233
  const manifest = parseYaml(manifestContent);
166
234
  if (manifest.build?.command || manifest.build?.script) {
167
- const buildCmd = manifest.build.command
168
- ? `bash -c ${JSON.stringify(manifest.build.command)}`
169
- : manifest.build.script.endsWith('.sh')
170
- ? `bash ${manifest.build.script}`
171
- : `ansible-playbook ${manifest.build.script}`;
172
-
173
- 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 };
174
249
  try {
175
- execSync(buildCmd, {
176
- cwd: buildDir,
177
- stdio: 'inherit',
178
- timeout: 300_000,
179
- });
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
+ }
180
267
  } catch (buildError) {
181
268
  const msg = buildError instanceof Error ? buildError.message : String(buildError);
182
269
  return { success: false, error: `Auto-build failed: Build exited with code ${msg}` };
@@ -196,6 +283,15 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
196
283
  }
197
284
  }
198
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
+
199
295
  // Compute checksums
200
296
  const checksumsData = await computeChecksums(buildDir);
201
297
  const checksumsJson = JSON.stringify(checksumsData, null, 2);
@@ -230,7 +326,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
230
326
  error: `Failed to build module: ${error instanceof Error ? error.message : 'Unknown error'}`,
231
327
  };
232
328
  } finally {
233
- // Clean up temp directory
329
+ // Clean up temp build directory
234
330
  rmSync(buildDir, { recursive: true, force: true });
235
331
  }
236
332
  }