@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
1
2
|
import { join } from 'node:path';
|
|
2
3
|
import { log } from '../cli/prompts';
|
|
3
4
|
import { executeBuildWithProgress } from './build-stream';
|
|
@@ -288,39 +289,45 @@ async function attemptAutoImport(
|
|
|
288
289
|
* @returns Execution result
|
|
289
290
|
*/
|
|
290
291
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
292
|
+
* Detect a stale Terraform state lock and surface actionable guidance.
|
|
293
|
+
*
|
|
294
|
+
* We intentionally do NOT auto-delete: if Terraform crashed mid-apply the
|
|
295
|
+
* state file may be inconsistent, and blindly retrying could double-apply
|
|
296
|
+
* partial infrastructure changes. The user should verify state before
|
|
297
|
+
* unlocking.
|
|
293
298
|
*/
|
|
294
299
|
async function autoForceUnlock(
|
|
295
300
|
terraformDir: string,
|
|
296
301
|
errorOutput: string,
|
|
297
|
-
|
|
298
|
-
|
|
302
|
+
_terraformEnv: Record<string, string>,
|
|
303
|
+
_noInteractive?: boolean,
|
|
299
304
|
): Promise<boolean> {
|
|
300
305
|
const lockIdMatch = errorOutput.match(/ID:\s+([0-9a-f-]+)/);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
306
|
+
const lockId = lockIdMatch?.[1];
|
|
307
|
+
|
|
308
|
+
const lockFile = join(terraformDir, '.terraform.tfstate.lock.info');
|
|
309
|
+
const isLocalLock = existsSync(lockFile);
|
|
310
|
+
|
|
311
|
+
// Try to surface who holds the lock and when it was created.
|
|
312
|
+
let lockInfo = '';
|
|
313
|
+
if (isLocalLock) {
|
|
314
|
+
try {
|
|
315
|
+
const raw = JSON.parse(readFileSync(lockFile, 'utf-8')) as Record<string, string>;
|
|
316
|
+
const who = raw.Who ?? 'unknown';
|
|
317
|
+
const created = raw.Created ? new Date(raw.Created).toLocaleString() : 'unknown';
|
|
318
|
+
const op = raw.Operation ?? 'unknown';
|
|
319
|
+
lockInfo = ` Held by: ${who}\n Operation: ${op}\n Created: ${created}`;
|
|
320
|
+
} catch {
|
|
321
|
+
lockInfo = ` Lock file: ${lockFile}`;
|
|
322
|
+
}
|
|
323
|
+
} else if (lockId) {
|
|
324
|
+
lockInfo = ` Lock ID: ${lockId}`;
|
|
304
325
|
}
|
|
305
326
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const result = await executeBuildWithProgress({
|
|
310
|
-
command: 'terraform',
|
|
311
|
-
args: ['force-unlock', '-force', lockId],
|
|
312
|
-
cwd: terraformDir,
|
|
313
|
-
title: 'Removing stale state lock',
|
|
314
|
-
env: { ...terraformEnv, TF_IN_AUTOMATION: '1' },
|
|
315
|
-
noInteractive,
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
if (result.success) {
|
|
319
|
-
log.success('State lock removed');
|
|
320
|
-
return true;
|
|
321
|
-
}
|
|
327
|
+
log.error(
|
|
328
|
+
`Terraform state is locked — another deploy may be running, or a previous one crashed.\n${lockInfo}\n\nIf no other deploy is running, unlock with:\n celilo module terraform-unlock <module-id>`,
|
|
329
|
+
);
|
|
322
330
|
|
|
323
|
-
log.error(`Failed to remove state lock: ${result.error || result.output}`);
|
|
324
331
|
return false;
|
|
325
332
|
}
|
|
326
333
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Validates module readiness and auto-prepares (generate/build) if needed
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { compareConsumerToProvider } from '@celilo/capabilities';
|
|
7
8
|
import { and, eq } from 'drizzle-orm';
|
|
8
9
|
import type { DbClient } from '../db/client';
|
|
9
10
|
import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
|
|
@@ -56,14 +57,58 @@ export async function validateAndPrepareDeployment(
|
|
|
56
57
|
|
|
57
58
|
const manifest = module.manifestData as ModuleManifest;
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
+
// Check capability dependencies first — no point interviewing or generating
|
|
61
|
+
// if required provider modules aren't deployed yet.
|
|
62
|
+
if (manifest.requires?.capabilities) {
|
|
63
|
+
const missingCapabilities = await findMissingCapabilities(manifest.requires.capabilities, db);
|
|
64
|
+
|
|
65
|
+
if (missingCapabilities.length > 0) {
|
|
66
|
+
const capList = missingCapabilities.join(', ');
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: `Missing required capabilities: ${capList}\nDeploy provider modules first.\n\nSuggested order:\n${getSuggestedDeploymentOrder(missingCapabilities, moduleId)}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Provider exists — verify the provider's claimed capability version is
|
|
74
|
+
// compatible with what this module declares it requires. Catches the
|
|
75
|
+
// class of break where a capability had a major bump (e.g. dns_registrar
|
|
76
|
+
// 4.0.0 → 5.0.0) and the consumer was rebuilt against the new major but
|
|
77
|
+
// the provider on this system still ships the old one (or vice-versa).
|
|
78
|
+
const versionMismatches = await findCapabilityVersionMismatches(
|
|
79
|
+
manifest.requires.capabilities,
|
|
80
|
+
db,
|
|
81
|
+
);
|
|
82
|
+
if (versionMismatches.length > 0) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: formatVersionMismatchError(moduleId, versionMismatches),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check for missing required variables BEFORE generating — if any are missing
|
|
91
|
+
// we return them for the caller to interview, then the caller re-invokes deploy
|
|
92
|
+
// after the user has answered. This prevents generation from failing mid-way
|
|
93
|
+
// on an unresolved $self: variable.
|
|
94
|
+
const missingVariables = await findMissingRequiredVariables(moduleId, manifest, db);
|
|
95
|
+
if (missingVariables.length > 0) {
|
|
96
|
+
return {
|
|
97
|
+
success: true,
|
|
98
|
+
autoGenerated: false,
|
|
99
|
+
autoBuilt: false,
|
|
100
|
+
missingVariables,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// All variables present — safe to generate templates now.
|
|
60
105
|
const generatedPath = getGeneratedPath(module.sourcePath, moduleId);
|
|
61
106
|
const generateResult = await generateTemplates({
|
|
62
107
|
moduleId,
|
|
63
108
|
modulePath: module.sourcePath,
|
|
64
109
|
outputPath: generatedPath,
|
|
65
110
|
db,
|
|
66
|
-
skipVariableValidation: true,
|
|
111
|
+
skipVariableValidation: true,
|
|
67
112
|
});
|
|
68
113
|
|
|
69
114
|
if (!generateResult.success) {
|
|
@@ -95,29 +140,10 @@ export async function validateAndPrepareDeployment(
|
|
|
95
140
|
}
|
|
96
141
|
}
|
|
97
142
|
|
|
98
|
-
// Check capability dependencies (cannot auto-deploy dependencies)
|
|
99
|
-
if (manifest.requires?.capabilities) {
|
|
100
|
-
const missingCapabilities = await findMissingCapabilities(manifest.requires.capabilities, db);
|
|
101
|
-
|
|
102
|
-
if (missingCapabilities.length > 0) {
|
|
103
|
-
const capList = missingCapabilities.join(', ');
|
|
104
|
-
return {
|
|
105
|
-
success: false,
|
|
106
|
-
error: `Missing required capabilities: ${capList}\nDeploy provider modules first.\n\nSuggested order:\n${getSuggestedDeploymentOrder(missingCapabilities, moduleId)}`,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Check required variables are configured
|
|
112
|
-
const missingVariables = await findMissingRequiredVariables(moduleId, manifest, db);
|
|
113
|
-
|
|
114
|
-
// Return validation result with missing variables info
|
|
115
|
-
// The caller (deploy command) will decide whether to prompt or fail
|
|
116
143
|
return {
|
|
117
144
|
success: true,
|
|
118
145
|
autoGenerated,
|
|
119
146
|
autoBuilt,
|
|
120
|
-
missingVariables: missingVariables.length > 0 ? missingVariables : undefined,
|
|
121
147
|
};
|
|
122
148
|
}
|
|
123
149
|
|
|
@@ -145,6 +171,113 @@ async function findMissingCapabilities(
|
|
|
145
171
|
return missing;
|
|
146
172
|
}
|
|
147
173
|
|
|
174
|
+
interface CapabilityVersionMismatch {
|
|
175
|
+
capabilityName: string;
|
|
176
|
+
requiredVersion: string;
|
|
177
|
+
providedVersion: string;
|
|
178
|
+
providerModuleId: string;
|
|
179
|
+
reason: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find capabilities whose installed providers can't satisfy what the
|
|
184
|
+
* consumer module's manifest declares it requires.
|
|
185
|
+
*
|
|
186
|
+
* Multi-provider semantics: a capability can have several installed
|
|
187
|
+
* providers (e.g., zone-scoped firewalls, or `dns_registrar` split
|
|
188
|
+
* across an internal-zone provider like knot-unbound-internal and an
|
|
189
|
+
* external-zone provider like namecheap). The deploy passes when at
|
|
190
|
+
* LEAST ONE provider is version-compatible with the consumer — that
|
|
191
|
+
* matches the runtime behaviour of `findCapabilityProvider`, which
|
|
192
|
+
* picks the right provider per zone. The mismatch error names the
|
|
193
|
+
* "best" (closest-version) incompatible provider so the operator
|
|
194
|
+
* sees an actionable suggestion.
|
|
195
|
+
*
|
|
196
|
+
* Uses `compareConsumerToProvider` from `@celilo/capabilities`, the
|
|
197
|
+
* same helper that powers the system audit's capability_abi check —
|
|
198
|
+
* so deploy and audit verdicts stay in lockstep.
|
|
199
|
+
*/
|
|
200
|
+
async function findCapabilityVersionMismatches(
|
|
201
|
+
required: Array<{ name: string; version: string }>,
|
|
202
|
+
db: DbClient,
|
|
203
|
+
): Promise<CapabilityVersionMismatch[]> {
|
|
204
|
+
const mismatches: CapabilityVersionMismatch[] = [];
|
|
205
|
+
|
|
206
|
+
for (const cap of required) {
|
|
207
|
+
const providers = await db
|
|
208
|
+
.select()
|
|
209
|
+
.from(capabilities)
|
|
210
|
+
.where(eq(capabilities.capabilityName, cap.name))
|
|
211
|
+
.all();
|
|
212
|
+
if (providers.length === 0) continue; // Missing-provider case is handled separately.
|
|
213
|
+
|
|
214
|
+
// If any provider is compatible, the consumer is fine — runtime's
|
|
215
|
+
// zone-aware lookup will pick that one for the relevant zone.
|
|
216
|
+
// Otherwise, capture the first provider's mismatch as the canonical
|
|
217
|
+
// example for the error message.
|
|
218
|
+
let exampleMismatch: {
|
|
219
|
+
provider: { version: string; moduleId: string };
|
|
220
|
+
reason: string;
|
|
221
|
+
} | null = null;
|
|
222
|
+
let anyCompatible = false;
|
|
223
|
+
for (const p of providers) {
|
|
224
|
+
const result = compareConsumerToProvider(cap.version, p.version);
|
|
225
|
+
if (result.compatible) {
|
|
226
|
+
anyCompatible = true;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
if (!exampleMismatch) {
|
|
230
|
+
exampleMismatch = { provider: p, reason: result.reason };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (anyCompatible || !exampleMismatch) continue;
|
|
234
|
+
|
|
235
|
+
mismatches.push({
|
|
236
|
+
capabilityName: cap.name,
|
|
237
|
+
requiredVersion: cap.version,
|
|
238
|
+
providedVersion: exampleMismatch.provider.version,
|
|
239
|
+
providerModuleId: exampleMismatch.provider.moduleId,
|
|
240
|
+
reason: exampleMismatch.reason,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return mismatches;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Format a version-mismatch error message for the deploy refusal.
|
|
249
|
+
*
|
|
250
|
+
* The message names the consumer, the requirement, the actual provider
|
|
251
|
+
* version, and an actionable next step — so an operator can resolve the
|
|
252
|
+
* break without reading the source of `compareConsumerToProvider`.
|
|
253
|
+
*/
|
|
254
|
+
function formatVersionMismatchError(
|
|
255
|
+
consumerModuleId: string,
|
|
256
|
+
mismatches: CapabilityVersionMismatch[],
|
|
257
|
+
): string {
|
|
258
|
+
const lines: string[] = [`Capability version mismatch: cannot deploy '${consumerModuleId}'.`, ''];
|
|
259
|
+
for (const m of mismatches) {
|
|
260
|
+
lines.push(
|
|
261
|
+
` ${m.capabilityName}: requires ${m.requiredVersion}, but ` +
|
|
262
|
+
`'${m.providerModuleId}' provides ${m.providedVersion}`,
|
|
263
|
+
);
|
|
264
|
+
if (m.reason === 'caller_minor_too_old') {
|
|
265
|
+
lines.push(
|
|
266
|
+
` Fix: upgrade '${m.providerModuleId}' to a version that provides ` +
|
|
267
|
+
`${m.capabilityName}@${m.requiredVersion} or newer.`,
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
lines.push(
|
|
271
|
+
` Fix: the major version differs. Either update '${consumerModuleId}' ` +
|
|
272
|
+
`to require ${m.capabilityName}@${m.providedVersion} (matching the ` +
|
|
273
|
+
`installed provider's major), or rebuild '${m.providerModuleId}' ` +
|
|
274
|
+
`against ${m.capabilityName}@${m.requiredVersion}.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return lines.join('\n');
|
|
279
|
+
}
|
|
280
|
+
|
|
148
281
|
/**
|
|
149
282
|
* Check if a capability is provided by any deployed module
|
|
150
283
|
* Execution function - queries database
|
|
@@ -154,13 +287,24 @@ async function findMissingCapabilities(
|
|
|
154
287
|
* @returns True if capability is provided
|
|
155
288
|
*/
|
|
156
289
|
async function isCapabilityProvided(capabilityName: string, db: DbClient): Promise<boolean> {
|
|
157
|
-
const
|
|
290
|
+
const cap = await db
|
|
158
291
|
.select()
|
|
159
292
|
.from(capabilities)
|
|
160
293
|
.where(eq(capabilities.capabilityName, capabilityName))
|
|
161
294
|
.get();
|
|
162
295
|
|
|
163
|
-
return
|
|
296
|
+
if (!cap) return false;
|
|
297
|
+
|
|
298
|
+
// Also verify the provider module is actually deployed (VERIFIED or DEPLOYED),
|
|
299
|
+
// not just imported/configured. A capability registered by an undeployed
|
|
300
|
+
// module cannot serve hook requests at runtime.
|
|
301
|
+
const provider = await db
|
|
302
|
+
.select({ state: modules.state })
|
|
303
|
+
.from(modules)
|
|
304
|
+
.where(eq(modules.id, cap.moduleId))
|
|
305
|
+
.get();
|
|
306
|
+
|
|
307
|
+
return provider?.state === 'VERIFIED' || provider?.state === 'INSTALLED';
|
|
164
308
|
}
|
|
165
309
|
|
|
166
310
|
/**
|
|
@@ -38,15 +38,15 @@ async function getModuleHostAndIp(
|
|
|
38
38
|
|
|
39
39
|
if (!hostnameConfig?.value) return null;
|
|
40
40
|
|
|
41
|
-
// Get IP: try
|
|
42
|
-
const
|
|
41
|
+
// Get IP: try target_ip first, then machine IP
|
|
42
|
+
const targetIpConfig = db
|
|
43
43
|
.select()
|
|
44
44
|
.from(moduleConfigs)
|
|
45
45
|
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
46
46
|
.all()
|
|
47
|
-
.find((c) => c.key === '
|
|
47
|
+
.find((c) => c.key === 'target_ip');
|
|
48
48
|
|
|
49
|
-
let ip =
|
|
49
|
+
let ip = targetIpConfig?.value;
|
|
50
50
|
|
|
51
51
|
if (!ip) {
|
|
52
52
|
// Try machine IP from infrastructure assignment
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { and, eq } from 'drizzle-orm';
|
|
6
|
+
import { type DbClient, getDb } from '../db/client';
|
|
7
|
+
import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
8
|
+
import type { Ensure } from '../manifest/schema';
|
|
9
|
+
import { decryptSecret } from '../secrets/encryption';
|
|
10
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
11
|
+
import {
|
|
12
|
+
findEnsureOnProvider,
|
|
13
|
+
interviewForEnsureInputs,
|
|
14
|
+
renderEnsureRecipe,
|
|
15
|
+
} from './config-interview';
|
|
16
|
+
|
|
17
|
+
const ENSURE: Ensure = {
|
|
18
|
+
id: 'managed_domain',
|
|
19
|
+
description: 'Add a domain.',
|
|
20
|
+
inputs: [
|
|
21
|
+
{ kind: 'append_to_array', target: 'config.additional_domains' },
|
|
22
|
+
{
|
|
23
|
+
kind: 'set_in_object',
|
|
24
|
+
target: 'secret.additional_ddns_passwords',
|
|
25
|
+
key: '{{value}}',
|
|
26
|
+
prompt: 'Namecheap DDNS password for {{value}}',
|
|
27
|
+
hint: 'Advanced DNS panel',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
post: 'redeploy_self',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('interviewForEnsureInputs', () => {
|
|
34
|
+
let tempDir: string;
|
|
35
|
+
let testDb: DbClient;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-ensure-'));
|
|
39
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
40
|
+
testDb = getDb();
|
|
41
|
+
testDb
|
|
42
|
+
.insert(modules)
|
|
43
|
+
.values({
|
|
44
|
+
id: 'namecheap',
|
|
45
|
+
name: 'Namecheap',
|
|
46
|
+
sourcePath: tempDir,
|
|
47
|
+
version: '2.0.0',
|
|
48
|
+
manifestData: {
|
|
49
|
+
provides: {
|
|
50
|
+
capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
.run();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
59
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('appends to array config and sets secret object key', async () => {
|
|
63
|
+
const promptedFor: string[] = [];
|
|
64
|
+
const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
|
|
65
|
+
promptOverride: async (msg) => {
|
|
66
|
+
promptedFor.push(msg);
|
|
67
|
+
return 'super-secret-pw';
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
expect(result.alreadyApplied).toBe(false);
|
|
73
|
+
expect(promptedFor[0]).toContain('celilo.computer');
|
|
74
|
+
|
|
75
|
+
// config array
|
|
76
|
+
const configRow = testDb
|
|
77
|
+
.select()
|
|
78
|
+
.from(moduleConfigs)
|
|
79
|
+
.where(
|
|
80
|
+
and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'additional_domains')),
|
|
81
|
+
)
|
|
82
|
+
.get();
|
|
83
|
+
expect(configRow?.valueJson).toBeTruthy();
|
|
84
|
+
expect(JSON.parse(configRow?.valueJson ?? '[]')).toEqual(['celilo.computer']);
|
|
85
|
+
|
|
86
|
+
// secret JSON object
|
|
87
|
+
const secretRow = testDb
|
|
88
|
+
.select()
|
|
89
|
+
.from(secrets)
|
|
90
|
+
.where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'additional_ddns_passwords')))
|
|
91
|
+
.get();
|
|
92
|
+
expect(secretRow).toBeTruthy();
|
|
93
|
+
if (!secretRow) return;
|
|
94
|
+
const masterKey = await getOrCreateMasterKey();
|
|
95
|
+
const decoded = decryptSecret(
|
|
96
|
+
{
|
|
97
|
+
encryptedValue: secretRow.encryptedValue,
|
|
98
|
+
iv: secretRow.iv,
|
|
99
|
+
authTag: secretRow.authTag,
|
|
100
|
+
},
|
|
101
|
+
masterKey,
|
|
102
|
+
);
|
|
103
|
+
expect(JSON.parse(decoded)).toEqual({ 'celilo.computer': 'super-secret-pw' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('idempotent — re-running with same value is a no-op', async () => {
|
|
107
|
+
await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
|
|
108
|
+
promptOverride: async () => 'pw1',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let promptCount = 0;
|
|
112
|
+
const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
|
|
113
|
+
promptOverride: async () => {
|
|
114
|
+
promptCount++;
|
|
115
|
+
return 'should-not-be-used';
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
expect(result.alreadyApplied).toBe(true);
|
|
121
|
+
expect(promptCount).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('preserves existing entries when adding a second domain', async () => {
|
|
125
|
+
await interviewForEnsureInputs('namecheap', ENSURE, 'first.com', testDb, {
|
|
126
|
+
promptOverride: async () => 'pw1',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await interviewForEnsureInputs('namecheap', ENSURE, 'second.com', testDb, {
|
|
130
|
+
promptOverride: async () => 'pw2',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const configRow = testDb
|
|
134
|
+
.select()
|
|
135
|
+
.from(moduleConfigs)
|
|
136
|
+
.where(
|
|
137
|
+
and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'additional_domains')),
|
|
138
|
+
)
|
|
139
|
+
.get();
|
|
140
|
+
expect(JSON.parse(configRow?.valueJson ?? '[]')).toEqual(['first.com', 'second.com']);
|
|
141
|
+
|
|
142
|
+
const secretRow = testDb
|
|
143
|
+
.select()
|
|
144
|
+
.from(secrets)
|
|
145
|
+
.where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'additional_ddns_passwords')))
|
|
146
|
+
.get();
|
|
147
|
+
if (!secretRow) throw new Error('expected secret row');
|
|
148
|
+
const masterKey = await getOrCreateMasterKey();
|
|
149
|
+
const decoded = decryptSecret(
|
|
150
|
+
{
|
|
151
|
+
encryptedValue: secretRow.encryptedValue,
|
|
152
|
+
iv: secretRow.iv,
|
|
153
|
+
authTag: secretRow.authTag,
|
|
154
|
+
},
|
|
155
|
+
masterKey,
|
|
156
|
+
);
|
|
157
|
+
expect(JSON.parse(decoded)).toEqual({ 'first.com': 'pw1', 'second.com': 'pw2' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// The "declined confirm short-circuits" test was removed when the
|
|
161
|
+
// confirm prompt itself was removed — running `module deploy <consumer>`
|
|
162
|
+
// is the user's consent for the cross-module config its hooks imply.
|
|
163
|
+
// See config-interview.ts and CADDY_HOSTNAME_LIST.md, Decision 6.
|
|
164
|
+
|
|
165
|
+
test('non-interactive mode applies directly', async () => {
|
|
166
|
+
// In production, non-interactive mode prints a recipe and aborts
|
|
167
|
+
// before reaching this function. The function itself supports being
|
|
168
|
+
// called non-interactively for tests / scripted use, applying inputs
|
|
169
|
+
// directly. promptOverride is still required for set_in_object inputs.
|
|
170
|
+
const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
|
|
171
|
+
noInteractive: true,
|
|
172
|
+
promptOverride: async () => 'pw',
|
|
173
|
+
});
|
|
174
|
+
expect(result.success).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('findEnsureOnProvider', () => {
|
|
179
|
+
let tempDir: string;
|
|
180
|
+
let testDb: DbClient;
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-find-ensure-'));
|
|
184
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
185
|
+
testDb = getDb();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
190
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('finds ensure by id on a registered provider', () => {
|
|
194
|
+
testDb
|
|
195
|
+
.insert(modules)
|
|
196
|
+
.values({
|
|
197
|
+
id: 'namecheap',
|
|
198
|
+
name: 'Namecheap',
|
|
199
|
+
sourcePath: tempDir,
|
|
200
|
+
version: '2.0.0',
|
|
201
|
+
manifestData: {
|
|
202
|
+
provides: {
|
|
203
|
+
capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
.run();
|
|
208
|
+
|
|
209
|
+
const found = findEnsureOnProvider('namecheap', 'managed_domain', testDb);
|
|
210
|
+
expect(found?.id).toBe('managed_domain');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('returns null when module exists but ensure id does not match', () => {
|
|
214
|
+
testDb
|
|
215
|
+
.insert(modules)
|
|
216
|
+
.values({
|
|
217
|
+
id: 'namecheap',
|
|
218
|
+
name: 'Namecheap',
|
|
219
|
+
sourcePath: tempDir,
|
|
220
|
+
version: '2.0.0',
|
|
221
|
+
manifestData: { provides: { capabilities: [] } },
|
|
222
|
+
})
|
|
223
|
+
.run();
|
|
224
|
+
expect(findEnsureOnProvider('namecheap', 'managed_domain', testDb)).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('returns null when provider module does not exist', () => {
|
|
228
|
+
expect(findEnsureOnProvider('ghost', 'managed_domain', testDb)).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('renderEnsureRecipe', () => {
|
|
233
|
+
test('mentions module id, ensure id, value, and post action', () => {
|
|
234
|
+
const recipe = renderEnsureRecipe('namecheap', ENSURE, 'celilo.computer');
|
|
235
|
+
expect(recipe).toContain('namecheap');
|
|
236
|
+
expect(recipe).toContain('managed_domain');
|
|
237
|
+
expect(recipe).toContain('celilo.computer');
|
|
238
|
+
expect(recipe).toContain('celilo module deploy namecheap');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('renders the per-domain prompt template', () => {
|
|
242
|
+
const recipe = renderEnsureRecipe('namecheap', ENSURE, 'celilo.computer');
|
|
243
|
+
expect(recipe).toContain('Namecheap DDNS password for celilo.computer');
|
|
244
|
+
});
|
|
245
|
+
});
|