@celilo/cli 0.5.0-alpha.7 → 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.
Files changed (43) hide show
  1. package/package.json +2 -2
  2. package/src/api-clients/proxmox.test.ts +78 -0
  3. package/src/api-clients/proxmox.ts +96 -1
  4. package/src/cli/command-registry.ts +32 -3
  5. package/src/cli/commands/backup-delete.ts +10 -7
  6. package/src/cli/commands/backup-import.ts +11 -8
  7. package/src/cli/commands/backup-restore.ts +11 -8
  8. package/src/cli/commands/events.ts +8 -3
  9. package/src/cli/commands/machine-add.ts +178 -163
  10. package/src/cli/commands/machine-remove.ts +10 -7
  11. package/src/cli/commands/module-config.test.ts +78 -0
  12. package/src/cli/commands/module-config.ts +18 -3
  13. package/src/cli/commands/module-import.ts +9 -5
  14. package/src/cli/commands/module-remove.ts +20 -9
  15. package/src/cli/commands/module-status.ts +15 -0
  16. package/src/cli/commands/module-upgrade.ts +10 -6
  17. package/src/cli/commands/proxmox-node-list.ts +101 -0
  18. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  19. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  20. package/src/cli/commands/service-add-proxmox.ts +275 -260
  21. package/src/cli/commands/service-reconfigure.ts +171 -153
  22. package/src/cli/commands/service-remove.ts +19 -13
  23. package/src/cli/commands/service-verify.ts +9 -10
  24. package/src/cli/commands/storage-add-local.ts +120 -107
  25. package/src/cli/commands/storage-add-s3.ts +145 -131
  26. package/src/cli/commands/storage-remove.ts +11 -8
  27. package/src/cli/commands/system-init.ts +119 -128
  28. package/src/cli/completion.ts +15 -0
  29. package/src/cli/index.ts +25 -0
  30. package/src/cli/service-credential.ts +54 -0
  31. package/src/services/bus-interview.ts +232 -0
  32. package/src/services/deploy-validation.test.ts +52 -2
  33. package/src/services/deploy-validation.ts +27 -36
  34. package/src/services/fleet-checks.test.ts +13 -0
  35. package/src/services/fleet-checks.ts +15 -0
  36. package/src/services/module-config.ts +12 -0
  37. package/src/services/module-deploy.ts +7 -6
  38. package/src/services/placement-reconcile.test.ts +86 -0
  39. package/src/services/placement-reconcile.ts +108 -0
  40. package/src/services/programmatic-responder.ts +34 -0
  41. package/src/services/terminal-responder.ts +113 -0
  42. package/src/templates/generator.test.ts +30 -0
  43. 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, promptConfirm, promptText } from '../prompts';
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
- await celiloIntro('🎛️ Welcome to Celilo System Setup');
167
-
168
- // Network and DNS addressing are intentionally NOT prompted here.
169
- // Network topology is no longer owned by `system init`
170
- // (v2/NETWORK_CONFIG_TO_FIREWALL.md): the `internal` zone and DNS are
171
- // discovered when celilo-mgmt is deployed, and `dmz`/`app`/`secure`
172
- // come from a firewall module. The only thing left to capture
173
- // interactively is the SSH key celilo uses to reach managed machines.
174
-
175
- // SSH Key skip if provided via CLI
176
- if (!has('ssh.public_key')) {
177
- const detectedKeys = autoDetectSSHKeys();
178
- const existingSshKey = defaults['ssh.public_key'];
179
-
180
- // Fresh box, no key in ~/.ssh and no previously-saved key — point the
181
- // user at ssh-keygen rather than making them paste at a blank prompt.
182
- // They can also skip this branch by passing ssh.public_key=... on the CLI.
183
- if (detectedKeys.length === 0 && !existingSshKey) {
184
- p.cancel('No SSH key found in ~/.ssh/');
185
- console.log();
186
- console.log('Celilo uses an SSH key to reach managed machines');
187
- console.log('(Proxmox hosts, VPS, Raspberry Pi, etc.). Generate one:');
188
- console.log();
189
- console.log(' ssh-keygen -t ed25519 -C "<your-email>"');
190
- console.log();
191
- console.log('Then re-run: celilo system init');
192
- console.log();
193
- console.log('Or, to paste a key from elsewhere without generating one:');
194
- console.log(' celilo system init ssh.public_key="ssh-ed25519 AAAA..."');
195
- return { success: false, error: 'No SSH key available — run ssh-keygen first' };
196
- }
197
-
198
- let keySelected = false;
199
-
200
- if (detectedKeys.length === 1) {
201
- // Single key: confirm
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
- if (selected !== PASTE_OPTION) {
234
- overrides['ssh.public_key'] = selected as string;
235
- keySelected = true;
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
- // Manual prompt if no key selected from detection
240
- if (!keySelected) {
241
- overrides['ssh.public_key'] = await promptText({
242
- message: existingSshKey
243
- ? 'SSH public key (press Enter to keep existing)'
244
- : 'SSH public key',
245
- defaultValue: String(existingSshKey || ''),
246
- // String(undefined) === 'undefined' (truthy), so guard explicitly —
247
- // otherwise the prompt shows "undefined" as the placeholder text.
248
- placeholder: existingSshKey ? String(existingSshKey) : 'ssh-ed25519 AAAA...',
249
- validate: (value) => {
250
- if (existingSshKey && !value) {
251
- return;
252
- }
253
- if (!value) {
254
- return 'SSH public key is required';
255
- }
256
- },
257
- });
258
- if (!overrides['ssh.public_key'] && existingSshKey) {
259
- overrides['ssh.public_key'] = existingSshKey;
260
- }
261
- } else if (existingSshKey) {
262
- overrides['ssh.public_key'] = await promptText({
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
- // Apply configuration (called for its DB side effects).
281
- initializeSystem(db, overrides);
270
+ // Apply configuration (called for its DB side effects).
271
+ initializeSystem(db, overrides);
282
272
 
283
- await celiloOutro('✅ System initialization complete!');
273
+ await celiloOutro('✅ System initialization complete!');
284
274
 
285
- console.log('\nNext steps:');
286
- console.log(' 1. Configure infrastructure:');
287
- console.log(' Container services:');
288
- console.log(' - celilo service add proxmox');
289
- console.log(' - celilo service add digitalocean');
290
- console.log(' Existing hardware:');
291
- console.log(' - celilo machine add');
292
- console.log('');
293
- console.log(' 2. Import a module: celilo module import <path>');
294
- console.log(' 3. Configure module: celilo module config set <module-id> <key> <value>');
295
- console.log(' 4. Generate infra: celilo module generate <module-id>');
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
- return {
298
- success: true,
299
- message: 'System initialized successfully',
300
- };
287
+ return {
288
+ success: true,
289
+ message: 'System initialized successfully',
290
+ };
291
+ });
301
292
  }
@@ -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
+ }