@celilo/cli 0.5.0-alpha.8 → 0.5.0-alpha.9
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/package.json +1 -1
- package/src/api-clients/proxmox.test.ts +78 -0
- package/src/api-clients/proxmox.ts +96 -1
- package/src/cli/command-registry.ts +32 -3
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/events.ts +8 -3
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +10 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +275 -260
- package/src/cli/commands/service-reconfigure.ts +171 -153
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/completion.ts +15 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/service-credential.ts +54 -0
- package/src/services/bus-interview.ts +232 -0
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +6 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +30 -0
- package/src/templates/generator.ts +86 -31
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* or applies defaults for non-interactive use.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import * as p from '@clack/prompts';
|
|
9
8
|
import { getDb } from '../../db/client';
|
|
9
|
+
import { askConfirm, askSelect, askText, withInterviewSession } from '../../services/bus-interview';
|
|
10
10
|
import {
|
|
11
11
|
autoDetectSSHKeys,
|
|
12
12
|
getDefaultConfiguration,
|
|
@@ -14,9 +14,8 @@ import {
|
|
|
14
14
|
isSystemInitialized,
|
|
15
15
|
loadExistingConfiguration,
|
|
16
16
|
} from '../../services/system-init';
|
|
17
|
-
import { celiloIntro, celiloOutro
|
|
17
|
+
import { celiloIntro, celiloOutro } from '../prompts';
|
|
18
18
|
import type { CommandResult } from '../types';
|
|
19
|
-
import { validateRequired } from '../validators';
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* Parse key=value pairs from positional arguments
|
|
@@ -163,139 +162,131 @@ async function initInteractive(cliOverrides: Record<string, string> = {}): Promi
|
|
|
163
162
|
// Helper: skip prompt if value already provided via CLI
|
|
164
163
|
const has = (key: string) => key in cliOverrides;
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const key = detectedKeys[0];
|
|
203
|
-
const keyPreview = `${key.keyType} ...${key.content.slice(-20)}`;
|
|
204
|
-
const useDetected = await promptConfirm({
|
|
205
|
-
message: `Auto-detected SSH key (${key.filename}: ${keyPreview}) - Use it?`,
|
|
206
|
-
initialValue: true,
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
if (useDetected) {
|
|
210
|
-
overrides['ssh.public_key'] = key.content;
|
|
211
|
-
keySelected = true;
|
|
212
|
-
}
|
|
213
|
-
} else if (detectedKeys.length > 1) {
|
|
214
|
-
// Multiple keys: selection menu
|
|
215
|
-
const PASTE_OPTION = '__paste__';
|
|
216
|
-
const selected = await p.select({
|
|
217
|
-
message: `Found ${detectedKeys.length} SSH keys in ~/.ssh/`,
|
|
218
|
-
options: [
|
|
219
|
-
...detectedKeys.map((key) => ({
|
|
220
|
-
value: key.content,
|
|
221
|
-
label: key.filename,
|
|
222
|
-
hint: `${key.keyType} ...${key.content.slice(-20)}`,
|
|
223
|
-
})),
|
|
224
|
-
{ value: PASTE_OPTION, label: 'Paste a different key' },
|
|
225
|
-
],
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
if (p.isCancel(selected)) {
|
|
229
|
-
p.cancel('Operation cancelled');
|
|
230
|
-
return { success: false, error: 'Cancelled by user' };
|
|
165
|
+
// Every prompt below is a bus interview (ISS-0127), so `system init` is
|
|
166
|
+
// drivable headlessly via `celilo events respond --values` / `events reply`.
|
|
167
|
+
// `withInterviewSession` renders bus questions locally when stdin is a TTY.
|
|
168
|
+
const scope = 'system-init';
|
|
169
|
+
|
|
170
|
+
return withInterviewSession(async () => {
|
|
171
|
+
await celiloIntro('🎛️ Welcome to Celilo System Setup');
|
|
172
|
+
|
|
173
|
+
// Network and DNS addressing are intentionally NOT prompted here.
|
|
174
|
+
// Network topology is no longer owned by `system init`
|
|
175
|
+
// (v2/NETWORK_CONFIG_TO_FIREWALL.md): the `internal` zone and DNS are
|
|
176
|
+
// discovered when celilo-mgmt is deployed, and `dmz`/`app`/`secure`
|
|
177
|
+
// come from a firewall module. The only thing left to capture
|
|
178
|
+
// interactively is the SSH key celilo uses to reach managed machines.
|
|
179
|
+
|
|
180
|
+
// SSH Key — skip if provided via CLI
|
|
181
|
+
if (!has('ssh.public_key')) {
|
|
182
|
+
const detectedKeys = autoDetectSSHKeys();
|
|
183
|
+
const existingSshKey = defaults['ssh.public_key'];
|
|
184
|
+
|
|
185
|
+
// Fresh box, no key in ~/.ssh and no previously-saved key — point the
|
|
186
|
+
// user at ssh-keygen rather than making them paste at a blank prompt.
|
|
187
|
+
// They can also skip this branch by passing ssh.public_key=... on the CLI.
|
|
188
|
+
if (detectedKeys.length === 0 && !existingSshKey) {
|
|
189
|
+
console.log('No SSH key found in ~/.ssh/');
|
|
190
|
+
console.log();
|
|
191
|
+
console.log('Celilo uses an SSH key to reach managed machines');
|
|
192
|
+
console.log('(Proxmox hosts, VPS, Raspberry Pi, etc.). Generate one:');
|
|
193
|
+
console.log();
|
|
194
|
+
console.log(' ssh-keygen -t ed25519 -C "<your-email>"');
|
|
195
|
+
console.log();
|
|
196
|
+
console.log('Then re-run: celilo system init');
|
|
197
|
+
console.log();
|
|
198
|
+
console.log('Or, to paste a key from elsewhere without generating one:');
|
|
199
|
+
console.log(' celilo system init ssh.public_key="ssh-ed25519 AAAA..."');
|
|
200
|
+
return { success: false, error: 'No SSH key available — run ssh-keygen first' };
|
|
231
201
|
}
|
|
232
202
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
203
|
+
let keySelected = false;
|
|
204
|
+
|
|
205
|
+
if (detectedKeys.length === 1) {
|
|
206
|
+
// Single key: confirm
|
|
207
|
+
const key = detectedKeys[0];
|
|
208
|
+
const keyPreview = `${key.keyType} ...${key.content.slice(-20)}`;
|
|
209
|
+
const useDetected = await askConfirm({
|
|
210
|
+
scope,
|
|
211
|
+
key: 'ssh_use_detected',
|
|
212
|
+
message: `Auto-detected SSH key (${key.filename}: ${keyPreview}) - Use it?`,
|
|
213
|
+
defaultValue: true,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (useDetected) {
|
|
217
|
+
overrides['ssh.public_key'] = key.content;
|
|
218
|
+
keySelected = true;
|
|
219
|
+
}
|
|
220
|
+
} else if (detectedKeys.length > 1) {
|
|
221
|
+
// Multiple keys: selection menu
|
|
222
|
+
const PASTE_OPTION = '__paste__';
|
|
223
|
+
const selected = await askSelect({
|
|
224
|
+
scope,
|
|
225
|
+
key: 'ssh_key_choice',
|
|
226
|
+
message: `Found ${detectedKeys.length} SSH keys in ~/.ssh/`,
|
|
227
|
+
options: [
|
|
228
|
+
...detectedKeys.map((key) => ({
|
|
229
|
+
value: key.content,
|
|
230
|
+
label: key.filename,
|
|
231
|
+
hint: `${key.keyType} ...${key.content.slice(-20)}`,
|
|
232
|
+
})),
|
|
233
|
+
{ value: PASTE_OPTION, label: 'Paste a different key' },
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (selected !== PASTE_OPTION) {
|
|
238
|
+
overrides['ssh.public_key'] = selected;
|
|
239
|
+
keySelected = true;
|
|
240
|
+
}
|
|
236
241
|
}
|
|
237
|
-
}
|
|
238
242
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
message: 'SSH public key (press Enter to keep existing)',
|
|
264
|
-
defaultValue: String(existingSshKey),
|
|
265
|
-
placeholder: String(existingSshKey),
|
|
266
|
-
validate: () => undefined,
|
|
267
|
-
});
|
|
268
|
-
if (!overrides['ssh.public_key']) {
|
|
269
|
-
overrides['ssh.public_key'] = existingSshKey;
|
|
243
|
+
// Manual prompt if no key selected from detection
|
|
244
|
+
if (!keySelected) {
|
|
245
|
+
const entered = await askText({
|
|
246
|
+
scope,
|
|
247
|
+
key: 'ssh_public_key',
|
|
248
|
+
message: existingSshKey
|
|
249
|
+
? 'SSH public key (press Enter to keep existing)'
|
|
250
|
+
: 'SSH public key',
|
|
251
|
+
defaultValue: existingSshKey ? String(existingSshKey) : undefined,
|
|
252
|
+
// String(undefined) === 'undefined' (truthy), so guard explicitly —
|
|
253
|
+
// otherwise the prompt shows "undefined" as the placeholder text.
|
|
254
|
+
placeholder: existingSshKey ? String(existingSshKey) : 'ssh-ed25519 AAAA...',
|
|
255
|
+
required: !existingSshKey,
|
|
256
|
+
});
|
|
257
|
+
overrides['ssh.public_key'] = entered || existingSshKey;
|
|
258
|
+
} else if (existingSshKey) {
|
|
259
|
+
const entered = await askText({
|
|
260
|
+
scope,
|
|
261
|
+
key: 'ssh_public_key',
|
|
262
|
+
message: 'SSH public key (press Enter to keep existing)',
|
|
263
|
+
defaultValue: String(existingSshKey),
|
|
264
|
+
placeholder: String(existingSshKey),
|
|
265
|
+
});
|
|
266
|
+
overrides['ssh.public_key'] = entered || existingSshKey;
|
|
270
267
|
}
|
|
271
|
-
} else {
|
|
272
|
-
overrides['ssh.public_key'] = await promptText({
|
|
273
|
-
message: 'SSH public key',
|
|
274
|
-
placeholder: 'ssh-ed25519 AAAA...',
|
|
275
|
-
validate: validateRequired('SSH public key'),
|
|
276
|
-
});
|
|
277
268
|
}
|
|
278
|
-
}
|
|
279
269
|
|
|
280
|
-
|
|
281
|
-
|
|
270
|
+
// Apply configuration (called for its DB side effects).
|
|
271
|
+
initializeSystem(db, overrides);
|
|
282
272
|
|
|
283
|
-
|
|
273
|
+
await celiloOutro('✅ System initialization complete!');
|
|
284
274
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
275
|
+
console.log('\nNext steps:');
|
|
276
|
+
console.log(' 1. Configure infrastructure:');
|
|
277
|
+
console.log(' Container services:');
|
|
278
|
+
console.log(' - celilo service add proxmox');
|
|
279
|
+
console.log(' - celilo service add digitalocean');
|
|
280
|
+
console.log(' Existing hardware:');
|
|
281
|
+
console.log(' - celilo machine add');
|
|
282
|
+
console.log('');
|
|
283
|
+
console.log(' 2. Import a module: celilo module import <path>');
|
|
284
|
+
console.log(' 3. Configure module: celilo module config set <module-id> <key> <value>');
|
|
285
|
+
console.log(' 4. Generate infra: celilo module generate <module-id>');
|
|
296
286
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
message: 'System initialized successfully',
|
|
290
|
+
};
|
|
291
|
+
});
|
|
301
292
|
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -40,6 +40,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
40
40
|
'machine',
|
|
41
41
|
'module',
|
|
42
42
|
'package',
|
|
43
|
+
'proxmox',
|
|
43
44
|
'publish',
|
|
44
45
|
'restore',
|
|
45
46
|
'service',
|
|
@@ -244,6 +245,20 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
// Service subcommands
|
|
248
|
+
// Proxmox subcommands
|
|
249
|
+
if (command === 'proxmox' && currentIndex === 1) {
|
|
250
|
+
return filterSuggestions(['node'], args[1] || '');
|
|
251
|
+
}
|
|
252
|
+
if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
|
|
253
|
+
return filterSuggestions(['list'], args[2] || '');
|
|
254
|
+
}
|
|
255
|
+
// proxmox node list <service-id> — proxmox services only
|
|
256
|
+
if (command === 'proxmox' && args[1] === 'node' && args[2] === 'list' && currentIndex === 3) {
|
|
257
|
+
const services = await listContainerServices();
|
|
258
|
+
const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
|
|
259
|
+
return filterSuggestions(serviceIds, args[3] || '');
|
|
260
|
+
}
|
|
261
|
+
|
|
247
262
|
if (command === 'service' && currentIndex === 1) {
|
|
248
263
|
const subcommands = ['add', 'list', 'verify', 'reconfigure', 'remove', 'config'];
|
|
249
264
|
return filterSuggestions(subcommands, args[1] || '');
|
package/src/cli/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/mo
|
|
|
66
66
|
import { handleModuleUpgrade } from './commands/module-upgrade';
|
|
67
67
|
import { moduleVerify } from './commands/module-verify';
|
|
68
68
|
import { handlePackage } from './commands/package';
|
|
69
|
+
import { handleProxmoxNodeList } from './commands/proxmox-node-list';
|
|
69
70
|
import { main as runPublish } from './commands/publish';
|
|
70
71
|
import { handleSecretList } from './commands/secret-list';
|
|
71
72
|
import { handleSecretSet } from './commands/secret-set';
|
|
@@ -175,6 +176,7 @@ Commands:
|
|
|
175
176
|
machine Manage machine pool (bring-your-own-hardware)
|
|
176
177
|
system Manage system configuration
|
|
177
178
|
ipam Manage IP address and VMID allocations and reservations
|
|
179
|
+
proxmox Proxmox cluster introspection (proxmox node list)
|
|
178
180
|
publish Publish workspace packages to npm and modules to celilo.computer
|
|
179
181
|
subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
|
|
180
182
|
completion Generate shell completion scripts (bash/zsh)
|
|
@@ -1794,6 +1796,29 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1794
1796
|
};
|
|
1795
1797
|
}
|
|
1796
1798
|
|
|
1799
|
+
if (parsed.command === 'proxmox') {
|
|
1800
|
+
if (!parsed.subcommand) {
|
|
1801
|
+
return {
|
|
1802
|
+
success: false,
|
|
1803
|
+
error: 'Proxmox subcommand required (node)\n\nRun "celilo proxmox --help" for usage',
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
if (parsed.subcommand === 'node') {
|
|
1807
|
+
const nodeSubcommand = parsed.args[0];
|
|
1808
|
+
if (nodeSubcommand === 'list') {
|
|
1809
|
+
return handleProxmoxNodeList(parsed.args.slice(1), parsed.flags);
|
|
1810
|
+
}
|
|
1811
|
+
return {
|
|
1812
|
+
success: false,
|
|
1813
|
+
error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
return {
|
|
1817
|
+
success: false,
|
|
1818
|
+
error: `Unknown proxmox subcommand: ${parsed.subcommand}\n\nRun "celilo proxmox --help" for usage`,
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1797
1822
|
if (parsed.command === 'ipam') {
|
|
1798
1823
|
// Handle ipam --help
|
|
1799
1824
|
if (parsed.flags.help || parsed.flags.h) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-credential secret resolution (ISS-0127, D7).
|
|
3
|
+
*
|
|
4
|
+
* Service credentials (a Proxmox API token, a DigitalOcean API token, an SSH
|
|
5
|
+
* password) are a special kind of secret: at `service add` time the service
|
|
6
|
+
* does not exist yet, so there is no encrypted store to write the value to
|
|
7
|
+
* out-of-band and no safe sink for a bus reply. Per design decision D7 these
|
|
8
|
+
* secrets therefore travel by **flag or env var**, never over the event bus.
|
|
9
|
+
*
|
|
10
|
+
* The resolution order is: explicit `--<flag>` → `$ENV` → (only when stdin is
|
|
11
|
+
* a TTY) a local clack `password` prompt → otherwise a fail-fast error naming
|
|
12
|
+
* the flag and env var. This keeps a zero-TTY `service add` possible while the
|
|
13
|
+
* credential never lands on the bus, and the local password prompt is the one
|
|
14
|
+
* clack input the recurrence gate (D6) allow-lists — precisely because a
|
|
15
|
+
* flag/env path always exists alongside it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { promptPassword } from './prompts';
|
|
19
|
+
|
|
20
|
+
export interface ServiceCredentialSpec {
|
|
21
|
+
/** Human-readable field name used in the prompt + error, e.g. "API token secret". */
|
|
22
|
+
field: string;
|
|
23
|
+
/** The flag value (already resolved from `flags[<flag>]`), if the operator passed `--<flag>`. */
|
|
24
|
+
flagValue: string | boolean | undefined;
|
|
25
|
+
/** The CLI flag the operator would pass, e.g. `api-token-secret`. */
|
|
26
|
+
flag: string;
|
|
27
|
+
/** The environment variable that supplies the value headlessly, e.g. `PROXMOX_API_TOKEN_SECRET`. */
|
|
28
|
+
envVar: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a service-credential secret from flag → env → (TTY) clack password →
|
|
33
|
+
* error. Returns the secret value as a string. Throws an actionable error when
|
|
34
|
+
* no value is available on a non-TTY run.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveServiceCredential(spec: ServiceCredentialSpec): Promise<string> {
|
|
37
|
+
if (typeof spec.flagValue === 'string' && spec.flagValue.trim() !== '') {
|
|
38
|
+
return spec.flagValue.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fromEnv = process.env[spec.envVar];
|
|
42
|
+
if (fromEnv && fromEnv.trim() !== '') {
|
|
43
|
+
return fromEnv.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.stdin.isTTY) {
|
|
47
|
+
return promptPassword({
|
|
48
|
+
message: `${spec.field}:`,
|
|
49
|
+
validate: (val) => (!val || val.trim() === '' ? `${spec.field} is required` : undefined),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error(`${spec.field} required: pass --${spec.flag} or set $${spec.envVar} (no TTY)`);
|
|
54
|
+
}
|
|
@@ -23,6 +23,13 @@ export const EVENT_TYPES = {
|
|
|
23
23
|
secretRequired: (module: string, key: string) => `secret.required.${module}.${key}`,
|
|
24
24
|
ensureRequired: (provider: string, ensureId: string) => `ensure.required.${provider}.${ensureId}`,
|
|
25
25
|
aspectRequired: (module: string, role: string) => `aspect.required.${module}.${role}`,
|
|
26
|
+
/**
|
|
27
|
+
* Generic interview family (ISS-0127). Unlike the module-scoped families
|
|
28
|
+
* above, this carries a free-form `scope` (e.g. `service:proxmox-home-lab`)
|
|
29
|
+
* so non-deploy operator commands can ask questions over the same bus the
|
|
30
|
+
* deploy interview uses — making them headlessly drivable.
|
|
31
|
+
*/
|
|
32
|
+
interviewRequired: (scope: string, key: string) => `interview.required.${scope}.${key}`,
|
|
26
33
|
} as const;
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -182,6 +189,58 @@ export interface EnsureReply {
|
|
|
182
189
|
acknowledged?: true;
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The four shapes a generic interview question can take. The responder
|
|
194
|
+
* renders by `kind`: `text` is a free-text prompt (with type coercion +
|
|
195
|
+
* pattern validation), `confirm` is a yes/no, `select` is a single choice
|
|
196
|
+
* from `options`, `multiselect` is zero-or-more choices from `options`.
|
|
197
|
+
*/
|
|
198
|
+
export type InterviewKind = 'text' | 'confirm' | 'select' | 'multiselect';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Payload for `interview.required.<scope>.<key>` (ISS-0127). The generic
|
|
202
|
+
* counterpart to `ConfigRequiredPayload`: it carries everything a responder
|
|
203
|
+
* needs to render any of the four `kind`s, plus a `scope`/`key` pair that
|
|
204
|
+
* gives the question a stable identity a headless responder can answer by.
|
|
205
|
+
*
|
|
206
|
+
* Storage of the answer is always the emitting command's job (mirroring the
|
|
207
|
+
* deploy families), so responders never need to know what `scope:key` means —
|
|
208
|
+
* they just render and reply.
|
|
209
|
+
*/
|
|
210
|
+
export interface InterviewRequiredPayload {
|
|
211
|
+
/** Stable namespace for the question, e.g. `service:proxmox-home-lab`. */
|
|
212
|
+
scope: string;
|
|
213
|
+
/** Stable identifier within the scope, e.g. `default_target_node`. */
|
|
214
|
+
key: string;
|
|
215
|
+
kind: InterviewKind;
|
|
216
|
+
message: string;
|
|
217
|
+
required: boolean;
|
|
218
|
+
description?: string;
|
|
219
|
+
/** For `text`/`select`: the value used if the operator just hits Enter. For `confirm`: `'true'` | `'false'`. */
|
|
220
|
+
defaultValue?: string;
|
|
221
|
+
/** `text` only — grayed-out hint shown in the input field. */
|
|
222
|
+
placeholder?: string;
|
|
223
|
+
/** `select`/`multiselect` only — the choices. */
|
|
224
|
+
options?: Array<{ value: string; label: string; hint?: string }>;
|
|
225
|
+
/** `text` only — how to coerce the typed value. Defaults to `'string'`. */
|
|
226
|
+
type?: 'string' | 'integer' | 'number' | 'boolean';
|
|
227
|
+
/** `text` only — regex the typed value must match. */
|
|
228
|
+
pattern?: string;
|
|
229
|
+
/** Set on re-emits after a previous reply failed validation. */
|
|
230
|
+
previousError?: string;
|
|
231
|
+
/** 1-indexed attempt counter. Bounds prevent infinite re-emit loops. */
|
|
232
|
+
attempt?: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Reply to an `interview.required.*` query. `value`'s runtime shape follows
|
|
237
|
+
* the payload's `kind`: `string` (text/select), `string[]` (multiselect),
|
|
238
|
+
* `boolean` (confirm), or `number` (text with `type: integer|number`).
|
|
239
|
+
*/
|
|
240
|
+
export interface InterviewReply {
|
|
241
|
+
value: unknown;
|
|
242
|
+
}
|
|
243
|
+
|
|
185
244
|
/**
|
|
186
245
|
* Emit a query event on the bus and wait for a responder to reply.
|
|
187
246
|
*
|
|
@@ -237,3 +296,176 @@ export async function busInterviewGuarded<TReply>(
|
|
|
237
296
|
await ensureResponderForInterview(type);
|
|
238
297
|
return busInterview<TReply>(type, payload, ownerBus);
|
|
239
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Ask a single generic interview question over the bus and return the
|
|
302
|
+
* responder's answer (ISS-0127). The generic counterpart to the deploy's
|
|
303
|
+
* config/secret/ensure interview: any operator command can call this to make
|
|
304
|
+
* its prompts headlessly drivable instead of calling clack directly.
|
|
305
|
+
*
|
|
306
|
+
* The return type is `unknown` because the runtime shape depends on
|
|
307
|
+
* `payload.kind`; prefer the typed wrappers (`askText`, `askSelect`,
|
|
308
|
+
* `askMultiselect`, `askConfirm`) at call sites — they narrow it for you.
|
|
309
|
+
*
|
|
310
|
+
* Guarded like the deploy families: a non-TTY caller with no responder
|
|
311
|
+
* listening fails fast instead of hanging forever.
|
|
312
|
+
*/
|
|
313
|
+
export async function askInterview(
|
|
314
|
+
payload: InterviewRequiredPayload,
|
|
315
|
+
ownerBus?: Bus,
|
|
316
|
+
): Promise<unknown> {
|
|
317
|
+
const reply = await busInterviewGuarded<InterviewReply>(
|
|
318
|
+
EVENT_TYPES.interviewRequired(payload.scope, payload.key),
|
|
319
|
+
payload,
|
|
320
|
+
ownerBus,
|
|
321
|
+
);
|
|
322
|
+
return reply.value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Run `fn` with a terminal-responder active when stdin is a TTY (ISS-0127).
|
|
327
|
+
*
|
|
328
|
+
* Every operator command that asks questions over the bus must, on a TTY,
|
|
329
|
+
* run a responder itself to render those questions — `askInterview`'s guard
|
|
330
|
+
* (`ensureResponderForInterview`) assumes one is already up when stdin is a
|
|
331
|
+
* TTY (it skips the no-responder check). On a non-TTY run no responder is
|
|
332
|
+
* started; the headless responder (`events respond --values` / `events
|
|
333
|
+
* reply`) answers instead, and the guard fails fast if none is listening.
|
|
334
|
+
*
|
|
335
|
+
* This is the shared lifecycle every migrated command wraps its interview in,
|
|
336
|
+
* so the start/close boilerplate lives in exactly one place. The dynamic
|
|
337
|
+
* import keeps clack out of the non-TTY path's module graph.
|
|
338
|
+
*/
|
|
339
|
+
export async function withInterviewSession<T>(fn: () => Promise<T>): Promise<T> {
|
|
340
|
+
const responder = process.stdin.isTTY
|
|
341
|
+
? (await import('./terminal-responder')).startTerminalResponder()
|
|
342
|
+
: null;
|
|
343
|
+
try {
|
|
344
|
+
return await fn();
|
|
345
|
+
} finally {
|
|
346
|
+
responder?.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Ask for a free-text value over the bus. Returns the coerced answer as a
|
|
352
|
+
* string (the responder coerces `integer`/`number`/`boolean` per `type`, but
|
|
353
|
+
* the value still arrives as a JSON scalar; callers that want a number should
|
|
354
|
+
* pass `type` and read it as such — here we narrow to the string form that
|
|
355
|
+
* the existing `promptText` call sites expect).
|
|
356
|
+
*/
|
|
357
|
+
export async function askText(opts: {
|
|
358
|
+
scope: string;
|
|
359
|
+
key: string;
|
|
360
|
+
message: string;
|
|
361
|
+
description?: string;
|
|
362
|
+
defaultValue?: string;
|
|
363
|
+
placeholder?: string;
|
|
364
|
+
required?: boolean;
|
|
365
|
+
type?: 'string' | 'integer' | 'number' | 'boolean';
|
|
366
|
+
pattern?: string;
|
|
367
|
+
}): Promise<string> {
|
|
368
|
+
const value = await askInterview({
|
|
369
|
+
scope: opts.scope,
|
|
370
|
+
key: opts.key,
|
|
371
|
+
kind: 'text',
|
|
372
|
+
message: opts.message,
|
|
373
|
+
required: opts.required ?? false,
|
|
374
|
+
description: opts.description,
|
|
375
|
+
defaultValue: opts.defaultValue,
|
|
376
|
+
placeholder: opts.placeholder,
|
|
377
|
+
type: opts.type,
|
|
378
|
+
pattern: opts.pattern,
|
|
379
|
+
});
|
|
380
|
+
if (typeof value === 'string') return value;
|
|
381
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
382
|
+
throw new Error(
|
|
383
|
+
`askText(${opts.scope}.${opts.key}): expected a string/number/boolean reply, got ${typeof value}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Ask the operator to pick one of `options` over the bus. Returns the chosen
|
|
389
|
+
* option's `value`.
|
|
390
|
+
*/
|
|
391
|
+
export async function askSelect(opts: {
|
|
392
|
+
scope: string;
|
|
393
|
+
key: string;
|
|
394
|
+
message: string;
|
|
395
|
+
options: Array<{ value: string; label: string; hint?: string }>;
|
|
396
|
+
defaultValue?: string;
|
|
397
|
+
description?: string;
|
|
398
|
+
}): Promise<string> {
|
|
399
|
+
const value = await askInterview({
|
|
400
|
+
scope: opts.scope,
|
|
401
|
+
key: opts.key,
|
|
402
|
+
kind: 'select',
|
|
403
|
+
message: opts.message,
|
|
404
|
+
required: true,
|
|
405
|
+
options: opts.options,
|
|
406
|
+
defaultValue: opts.defaultValue,
|
|
407
|
+
description: opts.description,
|
|
408
|
+
});
|
|
409
|
+
if (typeof value !== 'string') {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`askSelect(${opts.scope}.${opts.key}): expected a string reply, got ${typeof value}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return value;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Ask the operator to pick zero-or-more of `options` over the bus. Returns the
|
|
419
|
+
* chosen options' `value`s.
|
|
420
|
+
*/
|
|
421
|
+
export async function askMultiselect(opts: {
|
|
422
|
+
scope: string;
|
|
423
|
+
key: string;
|
|
424
|
+
message: string;
|
|
425
|
+
options: Array<{ value: string; label: string; hint?: string }>;
|
|
426
|
+
required?: boolean;
|
|
427
|
+
description?: string;
|
|
428
|
+
}): Promise<string[]> {
|
|
429
|
+
const value = await askInterview({
|
|
430
|
+
scope: opts.scope,
|
|
431
|
+
key: opts.key,
|
|
432
|
+
kind: 'multiselect',
|
|
433
|
+
message: opts.message,
|
|
434
|
+
required: opts.required ?? false,
|
|
435
|
+
options: opts.options,
|
|
436
|
+
description: opts.description,
|
|
437
|
+
});
|
|
438
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`askMultiselect(${opts.scope}.${opts.key}): expected a string[] reply, got ${typeof value}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return value as string[];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Ask a yes/no question over the bus. Returns the operator's boolean answer.
|
|
448
|
+
*/
|
|
449
|
+
export async function askConfirm(opts: {
|
|
450
|
+
scope: string;
|
|
451
|
+
key: string;
|
|
452
|
+
message: string;
|
|
453
|
+
defaultValue?: boolean;
|
|
454
|
+
description?: string;
|
|
455
|
+
}): Promise<boolean> {
|
|
456
|
+
const value = await askInterview({
|
|
457
|
+
scope: opts.scope,
|
|
458
|
+
key: opts.key,
|
|
459
|
+
kind: 'confirm',
|
|
460
|
+
message: opts.message,
|
|
461
|
+
required: true,
|
|
462
|
+
defaultValue: opts.defaultValue === undefined ? undefined : String(opts.defaultValue),
|
|
463
|
+
description: opts.description,
|
|
464
|
+
});
|
|
465
|
+
if (typeof value !== 'boolean') {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`askConfirm(${opts.scope}.${opts.key}): expected a boolean reply, got ${typeof value}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return value;
|
|
471
|
+
}
|