@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.
- package/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-show.ts +1 -1
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +3 -3
- package/src/templates/generator.ts +43 -2
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- 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
|
+
}
|
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/manifest/schema.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
-
'
|
|
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
|
package/src/module/import.ts
CHANGED
|
@@ -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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
|
144
|
-
// source
|
|
145
|
-
//
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
}
|