@celilo/cli 0.1.0
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/README.md +1566 -0
- package/bin/celilo +16 -0
- package/drizzle/0000_complex_puma.sql +179 -0
- package/drizzle/0001_dizzy_wolfpack.sql +2 -0
- package/drizzle/0002_web_routes.sql +16 -0
- package/drizzle/0003_backup_storage.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +1151 -0
- package/drizzle/meta/0001_snapshot.json +1167 -0
- package/drizzle/meta/0002_snapshot.json +1257 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +64 -0
- package/schemas/system_config.json +106 -0
- package/src/__integration__/container-services-cli.integration.test.ts +246 -0
- package/src/ansible/dependencies.test.ts +309 -0
- package/src/ansible/dependencies.ts +896 -0
- package/src/ansible/inventory.test.ts +463 -0
- package/src/ansible/inventory.ts +445 -0
- package/src/ansible/secrets.ts +222 -0
- package/src/ansible/validation.test.ts +92 -0
- package/src/ansible/validation.ts +272 -0
- package/src/api-clients/digitalocean.ts +94 -0
- package/src/api-clients/proxmox.ts +655 -0
- package/src/capabilities/logging-wrapper.test.ts +217 -0
- package/src/capabilities/lookup.test.ts +149 -0
- package/src/capabilities/lookup.ts +89 -0
- package/src/capabilities/public-web-helpers.test.ts +198 -0
- package/src/capabilities/public-web-publish.test.ts +458 -0
- package/src/capabilities/registration.test.ts +395 -0
- package/src/capabilities/registration.ts +200 -0
- package/src/capabilities/route-validation.test.ts +121 -0
- package/src/capabilities/route-validation.ts +96 -0
- package/src/capabilities/secret-ref.test.ts +313 -0
- package/src/capabilities/secret-validation.ts +157 -0
- package/src/capabilities/secrets.test.ts +750 -0
- package/src/capabilities/secrets.ts +244 -0
- package/src/capabilities/validation.test.ts +613 -0
- package/src/capabilities/validation.ts +160 -0
- package/src/capabilities/well-known.test.ts +238 -0
- package/src/capabilities/well-known.ts +222 -0
- package/src/cli/cli.test.ts +654 -0
- package/src/cli/command-registry.ts +742 -0
- package/src/cli/command-tree-parser.test.ts +180 -0
- package/src/cli/command-tree-parser.ts +193 -0
- package/src/cli/commands/backup-create.ts +137 -0
- package/src/cli/commands/backup-delete.ts +74 -0
- package/src/cli/commands/backup-import.ts +97 -0
- package/src/cli/commands/backup-list.ts +132 -0
- package/src/cli/commands/backup-name.ts +73 -0
- package/src/cli/commands/backup-prune.ts +98 -0
- package/src/cli/commands/backup-restore.ts +122 -0
- package/src/cli/commands/capability-info.ts +121 -0
- package/src/cli/commands/capability-list.ts +47 -0
- package/src/cli/commands/completion.ts +87 -0
- package/src/cli/commands/hook-run.ts +176 -0
- package/src/cli/commands/ipam.ts +607 -0
- package/src/cli/commands/machine-add.ts +235 -0
- package/src/cli/commands/machine-earmark.ts +82 -0
- package/src/cli/commands/machine-list.ts +77 -0
- package/src/cli/commands/machine-remove.ts +90 -0
- package/src/cli/commands/machine-status.ts +131 -0
- package/src/cli/commands/module-audit.ts +51 -0
- package/src/cli/commands/module-build.ts +60 -0
- package/src/cli/commands/module-config.ts +170 -0
- package/src/cli/commands/module-deploy.ts +71 -0
- package/src/cli/commands/module-generate.ts +236 -0
- package/src/cli/commands/module-health.ts +108 -0
- package/src/cli/commands/module-import.ts +80 -0
- package/src/cli/commands/module-list.ts +43 -0
- package/src/cli/commands/module-logs.ts +73 -0
- package/src/cli/commands/module-remove.ts +162 -0
- package/src/cli/commands/module-show.ts +208 -0
- package/src/cli/commands/module-status.ts +131 -0
- package/src/cli/commands/module-types.ts +189 -0
- package/src/cli/commands/module-upgrade.ts +192 -0
- package/src/cli/commands/package.ts +68 -0
- package/src/cli/commands/secret-list.ts +99 -0
- package/src/cli/commands/secret-set.ts +134 -0
- package/src/cli/commands/service-add-digitalocean.ts +133 -0
- package/src/cli/commands/service-add-proxmox.ts +342 -0
- package/src/cli/commands/service-config-get.ts +83 -0
- package/src/cli/commands/service-config-set.ts +145 -0
- package/src/cli/commands/service-list.ts +74 -0
- package/src/cli/commands/service-reconfigure.ts +230 -0
- package/src/cli/commands/service-remove.ts +103 -0
- package/src/cli/commands/service-verify.ts +240 -0
- package/src/cli/commands/status.ts +216 -0
- package/src/cli/commands/storage-add-local.ts +106 -0
- package/src/cli/commands/storage-add-s3.ts +114 -0
- package/src/cli/commands/storage-list.ts +72 -0
- package/src/cli/commands/storage-remove.ts +54 -0
- package/src/cli/commands/storage-set-default.ts +44 -0
- package/src/cli/commands/storage-verify.ts +54 -0
- package/src/cli/commands/system-config.ts +168 -0
- package/src/cli/commands/system-init.ts +314 -0
- package/src/cli/commands/system-secret-get.ts +98 -0
- package/src/cli/commands/system-secret-set.ts +76 -0
- package/src/cli/commands/system-vault-password.ts +34 -0
- package/src/cli/completion.test.ts +37 -0
- package/src/cli/completion.ts +482 -0
- package/src/cli/fuel-gauge.test.ts +208 -0
- package/src/cli/fuel-gauge.ts +405 -0
- package/src/cli/generate-zsh-completion.test.ts +95 -0
- package/src/cli/generate-zsh-completion.ts +497 -0
- package/src/cli/index.ts +1583 -0
- package/src/cli/interactive-config.test.ts +201 -0
- package/src/cli/interactive-config.ts +62 -0
- package/src/cli/parser.test.ts +227 -0
- package/src/cli/parser.ts +244 -0
- package/src/cli/prompts.test.ts +33 -0
- package/src/cli/prompts.ts +121 -0
- package/src/cli/types.ts +38 -0
- package/src/cli/validators.test.ts +235 -0
- package/src/cli/validators.ts +188 -0
- package/src/config/env.ts +41 -0
- package/src/config/paths.test.ts +172 -0
- package/src/config/paths.ts +108 -0
- package/src/db/client.ts +190 -0
- package/src/db/migrate.ts +30 -0
- package/src/db/schema.test.ts +221 -0
- package/src/db/schema.ts +434 -0
- package/src/hooks/capability-loader-firewall.test.ts +246 -0
- package/src/hooks/capability-loader.test.ts +100 -0
- package/src/hooks/capability-loader.ts +520 -0
- package/src/hooks/define-hook.test.ts +488 -0
- package/src/hooks/executor.test.ts +462 -0
- package/src/hooks/executor.ts +469 -0
- package/src/hooks/logger.test.ts +54 -0
- package/src/hooks/logger.ts +95 -0
- package/src/hooks/test-fixtures/failing-hook.ts +13 -0
- package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
- package/src/hooks/test-fixtures/success-hook.ts +20 -0
- package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
- package/src/hooks/test-fixtures/void-hook.ts +13 -0
- package/src/hooks/types.ts +89 -0
- package/src/infrastructure/property-extractor.test.ts +194 -0
- package/src/infrastructure/property-extractor.ts +151 -0
- package/src/ipam/allocator.test.ts +442 -0
- package/src/ipam/allocator.ts +369 -0
- package/src/ipam/auto-allocator.test.ts +247 -0
- package/src/ipam/auto-allocator.ts +270 -0
- package/src/ipam/subnet-parser.test.ts +107 -0
- package/src/ipam/subnet-parser.ts +136 -0
- package/src/manifest/contracts/index.ts +61 -0
- package/src/manifest/contracts/v1.ts +118 -0
- package/src/manifest/json-schema-roundtrip.test.ts +99 -0
- package/src/manifest/schema.ts +367 -0
- package/src/manifest/template-validator.test.ts +231 -0
- package/src/manifest/template-validator.ts +322 -0
- package/src/manifest/validate.test.ts +1180 -0
- package/src/manifest/validate.ts +415 -0
- package/src/module/import.test.ts +355 -0
- package/src/module/import.ts +676 -0
- package/src/module/packaging/audit.ts +169 -0
- package/src/module/packaging/build.ts +228 -0
- package/src/module/packaging/checksum.ts +41 -0
- package/src/module/packaging/extract.ts +234 -0
- package/src/module/packaging/signature.ts +47 -0
- package/src/secrets/encryption.test.ts +284 -0
- package/src/secrets/encryption.ts +162 -0
- package/src/secrets/generators.test.ts +112 -0
- package/src/secrets/generators.ts +127 -0
- package/src/secrets/master-key.test.ts +159 -0
- package/src/secrets/master-key.ts +114 -0
- package/src/secrets/storage.test.ts +115 -0
- package/src/secrets/storage.ts +106 -0
- package/src/secrets/vault.test.ts +35 -0
- package/src/secrets/vault.ts +42 -0
- package/src/services/backup-create.ts +532 -0
- package/src/services/backup-metadata.ts +198 -0
- package/src/services/backup-restore.ts +229 -0
- package/src/services/backup-retention.ts +84 -0
- package/src/services/backup-storage.ts +281 -0
- package/src/services/build-stream.test.ts +122 -0
- package/src/services/build-stream.ts +201 -0
- package/src/services/config-interview.ts +694 -0
- package/src/services/container-service.test.ts +298 -0
- package/src/services/container-service.ts +401 -0
- package/src/services/cross-module-data-manager.test.ts +405 -0
- package/src/services/cross-module-data-manager.ts +412 -0
- package/src/services/deploy-ansible.ts +88 -0
- package/src/services/deploy-planner.ts +153 -0
- package/src/services/deploy-preflight.ts +274 -0
- package/src/services/deploy-ssh.ts +131 -0
- package/src/services/deploy-terraform.test.ts +55 -0
- package/src/services/deploy-terraform.ts +445 -0
- package/src/services/deploy-validation.ts +311 -0
- package/src/services/dns-auto-register.ts +211 -0
- package/src/services/health-runner.ts +184 -0
- package/src/services/infrastructure-selector.test.ts +485 -0
- package/src/services/infrastructure-selector.ts +245 -0
- package/src/services/infrastructure-variable-resolver.test.ts +751 -0
- package/src/services/infrastructure-variable-resolver.ts +234 -0
- package/src/services/machine-detector.ts +328 -0
- package/src/services/machine-pool.test.ts +405 -0
- package/src/services/machine-pool.ts +316 -0
- package/src/services/manifest-validation.ts +120 -0
- package/src/services/module-build.test.ts +290 -0
- package/src/services/module-build.ts +431 -0
- package/src/services/module-config.test.ts +237 -0
- package/src/services/module-config.ts +298 -0
- package/src/services/module-deploy.ts +862 -0
- package/src/services/module-types-drift.test.ts +73 -0
- package/src/services/module-types-generator.test.ts +288 -0
- package/src/services/module-types-generator.ts +189 -0
- package/src/services/proxmox-state-recovery.ts +140 -0
- package/src/services/schema-validation.ts +155 -0
- package/src/services/secret-schema-loader.test.ts +311 -0
- package/src/services/secret-schema-loader.ts +239 -0
- package/src/services/ssh-key-manager.test.ts +283 -0
- package/src/services/ssh-key-manager.ts +193 -0
- package/src/services/storage-providers/local.ts +105 -0
- package/src/services/storage-providers/s3.ts +182 -0
- package/src/services/storage-providers/types.ts +24 -0
- package/src/services/system-config-schema-types.ts +25 -0
- package/src/services/system-config-validator.test.ts +160 -0
- package/src/services/system-config-validator.ts +74 -0
- package/src/services/system-init.test.ts +153 -0
- package/src/services/system-init.ts +253 -0
- package/src/services/terraform-safety.ts +174 -0
- package/src/services/zone-detector.test.ts +110 -0
- package/src/services/zone-detector.ts +102 -0
- package/src/services/zone-policy.test.ts +97 -0
- package/src/services/zone-policy.ts +126 -0
- package/src/templates/generator.test.ts +645 -0
- package/src/templates/generator.ts +1119 -0
- package/src/templates/types.ts +62 -0
- package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
- package/src/test-utils/cli-context-interactive.test.ts +152 -0
- package/src/test-utils/cli-context-server.test.ts +66 -0
- package/src/test-utils/cli-context.test.ts +273 -0
- package/src/test-utils/cli-context.ts +677 -0
- package/src/test-utils/cli-result.test.ts +282 -0
- package/src/test-utils/cli-result.ts +241 -0
- package/src/test-utils/cli.ts +55 -0
- package/src/test-utils/completion-harness.test.ts +126 -0
- package/src/test-utils/completion-harness.ts +82 -0
- package/src/test-utils/database.test.ts +182 -0
- package/src/test-utils/database.ts +126 -0
- package/src/test-utils/filesystem.test.ts +208 -0
- package/src/test-utils/filesystem.ts +142 -0
- package/src/test-utils/fixtures.test.ts +123 -0
- package/src/test-utils/fixtures.ts +160 -0
- package/src/test-utils/golden-diff.ts +197 -0
- package/src/test-utils/index.ts +77 -0
- package/src/test-utils/integration.ts +81 -0
- package/src/test-utils/module-fixtures.ts +468 -0
- package/src/test-utils/modules.test.ts +144 -0
- package/src/test-utils/modules.ts +183 -0
- package/src/test-utils/setup-test-db.ts +90 -0
- package/src/test-utils/value-extractor.test.ts +231 -0
- package/src/test-utils/value-extractor.ts +228 -0
- package/src/types/infrastructure.ts +157 -0
- package/src/utils/shell.test.ts +365 -0
- package/src/utils/shell.ts +159 -0
- package/src/validation/schemas.ts +166 -0
- package/src/variables/ansible-resolver.test.ts +142 -0
- package/src/variables/ansible-resolver.ts +69 -0
- package/src/variables/capability-self-ref.test.ts +220 -0
- package/src/variables/context.test.ts +1265 -0
- package/src/variables/context.ts +624 -0
- package/src/variables/declarative-derivation.test.ts +743 -0
- package/src/variables/declarative-derivation.ts +200 -0
- package/src/variables/parser.test.ts +231 -0
- package/src/variables/parser.ts +76 -0
- package/src/variables/resolver.test.ts +458 -0
- package/src/variables/resolver.ts +282 -0
- package/src/variables/types.ts +59 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Configuration Interview Service
|
|
3
|
+
*
|
|
4
|
+
* Prompts users for missing required configuration during deployment
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as p from '@clack/prompts';
|
|
8
|
+
import { and, eq } from 'drizzle-orm';
|
|
9
|
+
import { log, promptPassword, promptText } from '../cli/prompts';
|
|
10
|
+
import type { DbClient } from '../db/client';
|
|
11
|
+
import { moduleConfigs, secrets } from '../db/schema';
|
|
12
|
+
import { encryptSecret } from '../secrets/encryption';
|
|
13
|
+
import { deriveSecret, generateSecret } from '../secrets/generators';
|
|
14
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
15
|
+
import type { Machine } from '../types/infrastructure';
|
|
16
|
+
import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
|
|
17
|
+
|
|
18
|
+
export interface MissingVariable {
|
|
19
|
+
name: string;
|
|
20
|
+
source: 'user' | 'secret' | 'capability' | 'system';
|
|
21
|
+
description?: string;
|
|
22
|
+
/** Variable type from manifest (string, array, etc.) */
|
|
23
|
+
type?: string;
|
|
24
|
+
/** Derivation source (e.g., "$machine:ipAddress") */
|
|
25
|
+
derive_from?: string;
|
|
26
|
+
/** For multi-select: available options */
|
|
27
|
+
options?: Array<{ value: string; label: string; hint?: string }>;
|
|
28
|
+
/** Follow-up prompt for each selected option */
|
|
29
|
+
per_selection?: {
|
|
30
|
+
key_pattern: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
type?: string;
|
|
33
|
+
derive_from?: string;
|
|
34
|
+
};
|
|
35
|
+
/** Secret auto-generation config from manifest */
|
|
36
|
+
generate?: {
|
|
37
|
+
method: string;
|
|
38
|
+
length: number;
|
|
39
|
+
encoding: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InterviewResult {
|
|
44
|
+
success: boolean;
|
|
45
|
+
configured: string[];
|
|
46
|
+
error?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a $machine: derivation from an earmarked machine
|
|
51
|
+
*
|
|
52
|
+
* @returns Resolved value, or null if not resolvable
|
|
53
|
+
*/
|
|
54
|
+
function resolveMachineDerivation(deriveFrom: string, machine: Machine): string | null {
|
|
55
|
+
const key = deriveFrom.replace('$machine:', '');
|
|
56
|
+
|
|
57
|
+
switch (key) {
|
|
58
|
+
case 'ipAddress':
|
|
59
|
+
return machine.ipAddress;
|
|
60
|
+
case 'hostname':
|
|
61
|
+
return machine.hostname;
|
|
62
|
+
case 'zone':
|
|
63
|
+
return machine.zone;
|
|
64
|
+
case 'zones':
|
|
65
|
+
// Return comma-separated list of zones from interfaces
|
|
66
|
+
if (machine.interfaces.length === 0) return null;
|
|
67
|
+
return [
|
|
68
|
+
...new Set(machine.interfaces.map((i) => i.zone).filter((z) => z !== 'unknown')),
|
|
69
|
+
].join(',');
|
|
70
|
+
default:
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a $machine:zone_ip derivation for a per_selection follow-up
|
|
77
|
+
* Looks up the interface IP for a given zone
|
|
78
|
+
*/
|
|
79
|
+
function resolveMachineZoneIp(machine: Machine, zone: string): string | null {
|
|
80
|
+
const iface = machine.interfaces.find((i) => i.zone === zone);
|
|
81
|
+
return iface?.ipAddress ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Interview user for missing required configuration
|
|
86
|
+
*
|
|
87
|
+
* @param moduleId - Module identifier
|
|
88
|
+
* @param missingVariables - Variables that need to be configured
|
|
89
|
+
* @param db - Database connection
|
|
90
|
+
* @param earmarkedMachine - Optional earmarked machine for $machine: derivation
|
|
91
|
+
* @returns Interview result
|
|
92
|
+
*/
|
|
93
|
+
export async function interviewForMissingConfig(
|
|
94
|
+
moduleId: string,
|
|
95
|
+
missingVariables: MissingVariable[],
|
|
96
|
+
db: DbClient,
|
|
97
|
+
earmarkedMachine?: Machine | null,
|
|
98
|
+
): Promise<InterviewResult> {
|
|
99
|
+
const configured: string[] = [];
|
|
100
|
+
|
|
101
|
+
log.info(`Module '${moduleId}' requires configuration. Please provide the following:`);
|
|
102
|
+
|
|
103
|
+
for (const variable of missingVariables) {
|
|
104
|
+
try {
|
|
105
|
+
// Try to derive from earmarked machine before prompting
|
|
106
|
+
if (variable.derive_from?.startsWith('$machine:') && earmarkedMachine) {
|
|
107
|
+
const derived = resolveMachineDerivation(variable.derive_from, earmarkedMachine);
|
|
108
|
+
if (derived !== null) {
|
|
109
|
+
log.info(`✓ ${variable.name} = ${derived} (from machine ${earmarkedMachine.hostname})`);
|
|
110
|
+
|
|
111
|
+
// For multi-select variables with options, also handle per_selection follow-ups
|
|
112
|
+
if (variable.options && variable.per_selection) {
|
|
113
|
+
const selectedValues = derived.split(',');
|
|
114
|
+
|
|
115
|
+
// Store the main variable
|
|
116
|
+
await db
|
|
117
|
+
.insert(moduleConfigs)
|
|
118
|
+
.values({
|
|
119
|
+
moduleId,
|
|
120
|
+
key: variable.name,
|
|
121
|
+
value: derived,
|
|
122
|
+
valueJson: null,
|
|
123
|
+
createdAt: new Date(),
|
|
124
|
+
updatedAt: new Date(),
|
|
125
|
+
})
|
|
126
|
+
.onConflictDoUpdate({
|
|
127
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
128
|
+
set: { value: derived, updatedAt: new Date() },
|
|
129
|
+
});
|
|
130
|
+
configured.push(variable.name);
|
|
131
|
+
|
|
132
|
+
// Handle per_selection follow-ups
|
|
133
|
+
for (const selectedVal of selectedValues) {
|
|
134
|
+
const followUpKey = variable.per_selection.key_pattern.replace(
|
|
135
|
+
'{value}',
|
|
136
|
+
selectedVal,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Try to derive the follow-up value from machine
|
|
140
|
+
let followUpValue: string | null = null;
|
|
141
|
+
if (variable.per_selection.derive_from === '$machine:zone_ip' && earmarkedMachine) {
|
|
142
|
+
followUpValue = resolveMachineZoneIp(earmarkedMachine, selectedVal);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (followUpValue !== null) {
|
|
146
|
+
log.info(
|
|
147
|
+
`✓ ${followUpKey} = ${followUpValue} (from machine ${earmarkedMachine.hostname})`,
|
|
148
|
+
);
|
|
149
|
+
await db
|
|
150
|
+
.insert(moduleConfigs)
|
|
151
|
+
.values({
|
|
152
|
+
moduleId,
|
|
153
|
+
key: followUpKey,
|
|
154
|
+
value: followUpValue,
|
|
155
|
+
valueJson: null,
|
|
156
|
+
createdAt: new Date(),
|
|
157
|
+
updatedAt: new Date(),
|
|
158
|
+
})
|
|
159
|
+
.onConflictDoUpdate({
|
|
160
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
161
|
+
set: { value: followUpValue, updatedAt: new Date() },
|
|
162
|
+
});
|
|
163
|
+
configured.push(followUpKey);
|
|
164
|
+
} else {
|
|
165
|
+
// Can't derive - prompt for this follow-up
|
|
166
|
+
const option = variable.options?.find((o) => o.value === selectedVal);
|
|
167
|
+
const followUpPrompt = variable.per_selection.prompt
|
|
168
|
+
.replace('{value}', selectedVal)
|
|
169
|
+
.replace('{label}', option?.label || selectedVal)
|
|
170
|
+
.replace('{hint}', option?.hint || '');
|
|
171
|
+
|
|
172
|
+
const userValue = await promptText({
|
|
173
|
+
message: followUpPrompt,
|
|
174
|
+
validate: (val) => {
|
|
175
|
+
if (!val || val.trim() === '') return 'This field is required';
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await db
|
|
180
|
+
.insert(moduleConfigs)
|
|
181
|
+
.values({
|
|
182
|
+
moduleId,
|
|
183
|
+
key: followUpKey,
|
|
184
|
+
value: userValue,
|
|
185
|
+
valueJson: null,
|
|
186
|
+
createdAt: new Date(),
|
|
187
|
+
updatedAt: new Date(),
|
|
188
|
+
})
|
|
189
|
+
.onConflictDoUpdate({
|
|
190
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
191
|
+
set: { value: userValue, updatedAt: new Date() },
|
|
192
|
+
});
|
|
193
|
+
configured.push(followUpKey);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
continue; // Already handled this variable fully
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Simple (non-multi-select) derived variable
|
|
201
|
+
await db
|
|
202
|
+
.insert(moduleConfigs)
|
|
203
|
+
.values({
|
|
204
|
+
moduleId,
|
|
205
|
+
key: variable.name,
|
|
206
|
+
value: derived,
|
|
207
|
+
valueJson: null,
|
|
208
|
+
createdAt: new Date(),
|
|
209
|
+
updatedAt: new Date(),
|
|
210
|
+
})
|
|
211
|
+
.onConflictDoUpdate({
|
|
212
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
213
|
+
set: { value: derived, updatedAt: new Date() },
|
|
214
|
+
});
|
|
215
|
+
configured.push(variable.name);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let value: string;
|
|
221
|
+
|
|
222
|
+
if (variable.source === 'secret') {
|
|
223
|
+
// Prompt for secret (masked input)
|
|
224
|
+
const message = variable.description
|
|
225
|
+
? `${variable.name} - ${variable.description}:`
|
|
226
|
+
: `${variable.name}:`;
|
|
227
|
+
|
|
228
|
+
value = await promptPassword({
|
|
229
|
+
message,
|
|
230
|
+
validate: (val) => {
|
|
231
|
+
if (!val || val.trim() === '') {
|
|
232
|
+
return 'This field is required';
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Encrypt and store secret
|
|
238
|
+
const masterKey = await getOrCreateMasterKey();
|
|
239
|
+
const encrypted = encryptSecret(value, masterKey);
|
|
240
|
+
await db
|
|
241
|
+
.insert(secrets)
|
|
242
|
+
.values({
|
|
243
|
+
moduleId,
|
|
244
|
+
name: variable.name,
|
|
245
|
+
encryptedValue: encrypted.encryptedValue,
|
|
246
|
+
iv: encrypted.iv,
|
|
247
|
+
authTag: encrypted.authTag,
|
|
248
|
+
})
|
|
249
|
+
.run();
|
|
250
|
+
|
|
251
|
+
configured.push(`${variable.name} (secret)`);
|
|
252
|
+
} else if (variable.options && variable.options.length > 0) {
|
|
253
|
+
// Multi-select prompt for variables with options
|
|
254
|
+
const message = variable.description
|
|
255
|
+
? `${variable.name} - ${variable.description}:`
|
|
256
|
+
: `${variable.name}:`;
|
|
257
|
+
|
|
258
|
+
const selected = await p.multiselect({
|
|
259
|
+
message,
|
|
260
|
+
options: variable.options.map((opt) => ({
|
|
261
|
+
value: opt.value,
|
|
262
|
+
label: opt.label,
|
|
263
|
+
hint: opt.hint,
|
|
264
|
+
})),
|
|
265
|
+
required: true,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (p.isCancel(selected)) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
configured,
|
|
272
|
+
error: `Configuration cancelled for ${variable.name}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const selectedValues = selected as string[];
|
|
277
|
+
value = selectedValues.join(',');
|
|
278
|
+
|
|
279
|
+
// Store config
|
|
280
|
+
await db
|
|
281
|
+
.insert(moduleConfigs)
|
|
282
|
+
.values({
|
|
283
|
+
moduleId,
|
|
284
|
+
key: variable.name,
|
|
285
|
+
value,
|
|
286
|
+
valueJson: null,
|
|
287
|
+
createdAt: new Date(),
|
|
288
|
+
updatedAt: new Date(),
|
|
289
|
+
})
|
|
290
|
+
.onConflictDoUpdate({
|
|
291
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
292
|
+
set: {
|
|
293
|
+
value,
|
|
294
|
+
updatedAt: new Date(),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
configured.push(variable.name);
|
|
299
|
+
|
|
300
|
+
// Handle per_selection follow-up prompts
|
|
301
|
+
if (variable.per_selection) {
|
|
302
|
+
for (const selectedVal of selectedValues) {
|
|
303
|
+
const option = variable.options?.find((o) => o.value === selectedVal);
|
|
304
|
+
const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal);
|
|
305
|
+
const followUpPrompt = variable.per_selection.prompt
|
|
306
|
+
.replace('{value}', selectedVal)
|
|
307
|
+
.replace('{label}', option?.label || selectedVal)
|
|
308
|
+
.replace('{hint}', option?.hint || '');
|
|
309
|
+
|
|
310
|
+
// Check if already configured
|
|
311
|
+
const existingFollowUp = db
|
|
312
|
+
.select()
|
|
313
|
+
.from(moduleConfigs)
|
|
314
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, followUpKey)))
|
|
315
|
+
.get();
|
|
316
|
+
|
|
317
|
+
if (!existingFollowUp || !existingFollowUp.value) {
|
|
318
|
+
const followUpValue = await promptText({
|
|
319
|
+
message: followUpPrompt,
|
|
320
|
+
validate: (val) => {
|
|
321
|
+
if (!val || val.trim() === '') return 'This field is required';
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await db
|
|
326
|
+
.insert(moduleConfigs)
|
|
327
|
+
.values({
|
|
328
|
+
moduleId,
|
|
329
|
+
key: followUpKey,
|
|
330
|
+
value: followUpValue,
|
|
331
|
+
valueJson: null,
|
|
332
|
+
createdAt: new Date(),
|
|
333
|
+
updatedAt: new Date(),
|
|
334
|
+
})
|
|
335
|
+
.onConflictDoUpdate({
|
|
336
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
337
|
+
set: { value: followUpValue, updatedAt: new Date() },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
configured.push(followUpKey);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
// Prompt for regular config (visible input)
|
|
346
|
+
const message = variable.description
|
|
347
|
+
? `${variable.name} - ${variable.description}:`
|
|
348
|
+
: `${variable.name}:`;
|
|
349
|
+
|
|
350
|
+
value = await promptText({
|
|
351
|
+
message,
|
|
352
|
+
validate: (val) => {
|
|
353
|
+
if (!val || val.trim() === '') {
|
|
354
|
+
return 'This field is required';
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Store config
|
|
360
|
+
await db
|
|
361
|
+
.insert(moduleConfigs)
|
|
362
|
+
.values({
|
|
363
|
+
moduleId,
|
|
364
|
+
key: variable.name,
|
|
365
|
+
value,
|
|
366
|
+
valueJson: null,
|
|
367
|
+
createdAt: new Date(),
|
|
368
|
+
updatedAt: new Date(),
|
|
369
|
+
})
|
|
370
|
+
.onConflictDoUpdate({
|
|
371
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
372
|
+
set: {
|
|
373
|
+
value,
|
|
374
|
+
updatedAt: new Date(),
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
configured.push(variable.name);
|
|
379
|
+
}
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return {
|
|
382
|
+
success: false,
|
|
383
|
+
configured,
|
|
384
|
+
error: `Failed to configure ${variable.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
configured,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Validate module secrets against manifest declarations
|
|
397
|
+
*
|
|
398
|
+
* Policy function (Rule 10.1) - reads database and manifest, no side effects
|
|
399
|
+
*
|
|
400
|
+
* @param moduleId - Module identifier
|
|
401
|
+
* @param db - Database connection
|
|
402
|
+
* @returns Array of missing secrets with metadata
|
|
403
|
+
*/
|
|
404
|
+
export async function validateModuleSecrets(
|
|
405
|
+
moduleId: string,
|
|
406
|
+
db: DbClient,
|
|
407
|
+
): Promise<MissingVariable[]> {
|
|
408
|
+
const missingSecrets: MissingVariable[] = [];
|
|
409
|
+
|
|
410
|
+
// Load secrets schema
|
|
411
|
+
const schema = await loadSecretsSchema(moduleId, db);
|
|
412
|
+
if (!schema) {
|
|
413
|
+
// No schema file = no secrets declared
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Check each declared secret
|
|
418
|
+
for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
|
|
419
|
+
// Check if secret exists in database
|
|
420
|
+
const existing = db
|
|
421
|
+
.select()
|
|
422
|
+
.from(secrets)
|
|
423
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secretName)))
|
|
424
|
+
.get();
|
|
425
|
+
|
|
426
|
+
if (!existing) {
|
|
427
|
+
missingSecrets.push({
|
|
428
|
+
name: secretName,
|
|
429
|
+
source: 'secret',
|
|
430
|
+
description: propertySchema.description || propertySchema.title,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return missingSecrets;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Interview user for missing module secrets with schema-aware logic
|
|
440
|
+
*
|
|
441
|
+
* Execution function (Rule 10.1) - performs I/O (prompts, database writes)
|
|
442
|
+
*
|
|
443
|
+
* Handles three secret source types:
|
|
444
|
+
* - "generated": Auto-generate, never prompt
|
|
445
|
+
* - "user_provided": Always prompt, required
|
|
446
|
+
* - "generated_optional": Prompt with "Press Enter to auto-generate"
|
|
447
|
+
*
|
|
448
|
+
* Also handles derived secrets (derive_from field)
|
|
449
|
+
*
|
|
450
|
+
* @param moduleId - Module identifier
|
|
451
|
+
* @param missingSecrets - Secrets that need to be configured
|
|
452
|
+
* @param db - Database connection
|
|
453
|
+
* @returns Interview result
|
|
454
|
+
*/
|
|
455
|
+
export async function interviewForMissingSecrets(
|
|
456
|
+
moduleId: string,
|
|
457
|
+
missingSecrets: MissingVariable[],
|
|
458
|
+
db: DbClient,
|
|
459
|
+
): Promise<InterviewResult> {
|
|
460
|
+
const configured: string[] = [];
|
|
461
|
+
|
|
462
|
+
log.info(`Module '${moduleId}' requires secrets. Configuring:`);
|
|
463
|
+
|
|
464
|
+
const masterKey = await getOrCreateMasterKey();
|
|
465
|
+
|
|
466
|
+
// Sort secrets to ensure derived secrets come after their source secrets
|
|
467
|
+
// Build dependency map
|
|
468
|
+
const metadataMap = new Map<string, Awaited<ReturnType<typeof getSecretMetadata>>>();
|
|
469
|
+
for (const variable of missingSecrets) {
|
|
470
|
+
const metadata = await getSecretMetadata(moduleId, variable.name, db);
|
|
471
|
+
metadataMap.set(variable.name, metadata);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Topological sort: ensure derived secrets come immediately after their source
|
|
475
|
+
const sorted: typeof missingSecrets = [];
|
|
476
|
+
const processed = new Set<string>();
|
|
477
|
+
|
|
478
|
+
function addWithDependents(secret: (typeof missingSecrets)[0]) {
|
|
479
|
+
if (processed.has(secret.name)) return;
|
|
480
|
+
|
|
481
|
+
processed.add(secret.name);
|
|
482
|
+
sorted.push(secret);
|
|
483
|
+
|
|
484
|
+
// Immediately add any secrets that derive from this one
|
|
485
|
+
for (const s of missingSecrets) {
|
|
486
|
+
const meta = metadataMap.get(s.name);
|
|
487
|
+
if (meta?.deriveFrom === secret.name && !processed.has(s.name)) {
|
|
488
|
+
addWithDependents(s);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Process all non-derived secrets first (in original order), each followed by its dependents
|
|
494
|
+
for (const secret of missingSecrets) {
|
|
495
|
+
const meta = metadataMap.get(secret.name);
|
|
496
|
+
if (!meta?.deriveFrom) {
|
|
497
|
+
addWithDependents(secret);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Add any remaining secrets (shouldn't happen if derivation graph is valid)
|
|
502
|
+
for (const secret of missingSecrets) {
|
|
503
|
+
if (!processed.has(secret.name)) {
|
|
504
|
+
addWithDependents(secret);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (const variable of sorted) {
|
|
509
|
+
try {
|
|
510
|
+
// Get metadata from schema (already loaded during sorting)
|
|
511
|
+
const metadata = metadataMap.get(variable.name);
|
|
512
|
+
|
|
513
|
+
// Manifest generate field takes priority over schema metadata
|
|
514
|
+
const hasManifestGenerate = !!variable.generate;
|
|
515
|
+
// If no metadata, default to user_provided (safe default)
|
|
516
|
+
const source = hasManifestGenerate ? 'generated' : metadata?.source || 'user_provided';
|
|
517
|
+
|
|
518
|
+
let value: string;
|
|
519
|
+
|
|
520
|
+
// Handle derived secrets first
|
|
521
|
+
if (metadata?.deriveFrom) {
|
|
522
|
+
// Look up source secret
|
|
523
|
+
const sourceSecret = db
|
|
524
|
+
.select()
|
|
525
|
+
.from(secrets)
|
|
526
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, metadata.deriveFrom)))
|
|
527
|
+
.get();
|
|
528
|
+
|
|
529
|
+
if (!sourceSecret) {
|
|
530
|
+
return {
|
|
531
|
+
success: false,
|
|
532
|
+
configured,
|
|
533
|
+
error: `Cannot derive ${variable.name}: source secret ${metadata.deriveFrom} not found`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Decrypt source secret
|
|
538
|
+
const decryptedSource = await import('../secrets/encryption').then((m) =>
|
|
539
|
+
m.decryptSecret(
|
|
540
|
+
{
|
|
541
|
+
encryptedValue: sourceSecret.encryptedValue,
|
|
542
|
+
iv: sourceSecret.iv,
|
|
543
|
+
authTag: sourceSecret.authTag,
|
|
544
|
+
},
|
|
545
|
+
masterKey,
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// Derive value
|
|
550
|
+
if (!metadata.deriveMethod) {
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
configured,
|
|
554
|
+
error: `Cannot derive ${variable.name}: derive_method not specified in schema`,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
value = deriveSecret({
|
|
559
|
+
sourceSecret: decryptedSource,
|
|
560
|
+
deriveMethod: metadata.deriveMethod,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
log.info(`🔗 Derived ${variable.name} from ${metadata.deriveFrom}`);
|
|
564
|
+
} else if (source === 'generated') {
|
|
565
|
+
// Auto-generate without prompting
|
|
566
|
+
// Manifest generate field takes priority over schema metadata
|
|
567
|
+
const format = variable.generate?.encoding || metadata?.format || 'base64';
|
|
568
|
+
const length = variable.generate?.length || metadata?.length || 32;
|
|
569
|
+
|
|
570
|
+
value = generateSecret({ format, length });
|
|
571
|
+
|
|
572
|
+
log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
|
|
573
|
+
} else if (source === 'user_provided') {
|
|
574
|
+
// Always prompt, required
|
|
575
|
+
// Check if we're in interactive mode
|
|
576
|
+
if (!process.stdin.isTTY) {
|
|
577
|
+
// Non-interactive: skip this secret and continue processing others
|
|
578
|
+
// The caller will handle the remaining user-provided secrets
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const message = variable.description
|
|
583
|
+
? `${variable.name} - ${variable.description}:`
|
|
584
|
+
: `${variable.name}:`;
|
|
585
|
+
|
|
586
|
+
value = await promptPassword({
|
|
587
|
+
message,
|
|
588
|
+
validate: (val) => {
|
|
589
|
+
if (!val || val.trim() === '') {
|
|
590
|
+
return 'This field is required';
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
log.info(`✓ Saved ${variable.name}`);
|
|
596
|
+
} else if (source === 'user_password') {
|
|
597
|
+
// Password the user must remember — prompt twice to confirm
|
|
598
|
+
if (!process.stdin.isTTY) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const message = variable.description
|
|
603
|
+
? `${variable.name} - ${variable.description}:`
|
|
604
|
+
: `${variable.name}:`;
|
|
605
|
+
|
|
606
|
+
value = await promptPassword({
|
|
607
|
+
message,
|
|
608
|
+
validate: (val) => {
|
|
609
|
+
if (!val || val.trim() === '') {
|
|
610
|
+
return 'This field is required';
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const confirmation = await promptPassword({
|
|
616
|
+
message: `Confirm ${variable.name}:`,
|
|
617
|
+
validate: (val) => {
|
|
618
|
+
if (!val || val.trim() === '') {
|
|
619
|
+
return 'This field is required';
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
if (value !== confirmation) {
|
|
625
|
+
log.error('Passwords do not match. Please try again.');
|
|
626
|
+
// Re-prompt by decrementing — but we're in a for-of loop, so just return error
|
|
627
|
+
return {
|
|
628
|
+
success: false,
|
|
629
|
+
configured,
|
|
630
|
+
error: `Passwords do not match for ${variable.name}. Re-run deploy to try again.`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
log.info(`✓ Saved ${variable.name}`);
|
|
635
|
+
} else if (source === 'generated_optional') {
|
|
636
|
+
// Prompt with auto-generate option
|
|
637
|
+
const message = variable.description
|
|
638
|
+
? `${variable.name} - ${variable.description} (Press Enter to auto-generate):`
|
|
639
|
+
: `${variable.name} (Press Enter to auto-generate):`;
|
|
640
|
+
|
|
641
|
+
const userValue = await promptPassword({
|
|
642
|
+
message,
|
|
643
|
+
validate: () => undefined, // Allow empty
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
if (!userValue || userValue.trim() === '') {
|
|
647
|
+
// Auto-generate
|
|
648
|
+
const format = metadata?.format || 'base64';
|
|
649
|
+
const length = metadata?.length || 32;
|
|
650
|
+
|
|
651
|
+
value = generateSecret({ format, length });
|
|
652
|
+
|
|
653
|
+
log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
|
|
654
|
+
} else {
|
|
655
|
+
// Use user-provided value
|
|
656
|
+
value = userValue;
|
|
657
|
+
log.info(`✓ Saved ${variable.name}`);
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
return {
|
|
661
|
+
success: false,
|
|
662
|
+
configured,
|
|
663
|
+
error: `Unknown secret source type: ${source}`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Encrypt and store secret
|
|
668
|
+
const encrypted = encryptSecret(value, masterKey);
|
|
669
|
+
await db
|
|
670
|
+
.insert(secrets)
|
|
671
|
+
.values({
|
|
672
|
+
moduleId,
|
|
673
|
+
name: variable.name,
|
|
674
|
+
encryptedValue: encrypted.encryptedValue,
|
|
675
|
+
iv: encrypted.iv,
|
|
676
|
+
authTag: encrypted.authTag,
|
|
677
|
+
})
|
|
678
|
+
.run();
|
|
679
|
+
|
|
680
|
+
configured.push(`${variable.name} (secret)`);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return {
|
|
683
|
+
success: false,
|
|
684
|
+
configured,
|
|
685
|
+
error: `Failed to configure ${variable.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
success: true,
|
|
692
|
+
configured,
|
|
693
|
+
};
|
|
694
|
+
}
|