@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.
Files changed (39) hide show
  1. package/package.json +1 -1
  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/module-config.ts +12 -0
  33. package/src/services/module-deploy.ts +6 -1
  34. package/src/services/placement-reconcile.test.ts +86 -0
  35. package/src/services/placement-reconcile.ts +108 -0
  36. package/src/services/programmatic-responder.ts +34 -0
  37. package/src/services/terminal-responder.ts +113 -0
  38. package/src/templates/generator.test.ts +30 -0
  39. package/src/templates/generator.ts +86 -31
@@ -8,6 +8,7 @@ import { readFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { getDb } from '../../db/client';
10
10
  import type { NetworkZone } from '../../db/schema';
11
+ import { askText, withInterviewSession } from '../../services/bus-interview';
11
12
  import {
12
13
  detectMachineInfo,
13
14
  detectMachineInfoLocal,
@@ -23,9 +24,8 @@ import type {
23
24
  MachineRole,
24
25
  NetworkInterface,
25
26
  } from '../../types/infrastructure';
26
- import { celiloIntro, celiloOutro, promptText } from '../prompts';
27
+ import { celiloIntro, celiloOutro } from '../prompts';
27
28
  import type { CommandResult } from '../types';
28
- import { validateIpAddress, validateRequired } from '../validators';
29
29
 
30
30
  /**
31
31
  * Auto-detect SSH private key from system configuration
@@ -97,183 +97,198 @@ export async function handleMachineAdd(
97
97
  args: string[],
98
98
  flags: Record<string, boolean | string> = {},
99
99
  ): Promise<CommandResult> {
100
- try {
101
- celiloIntro('Add Machine to Pool');
102
-
103
- // Hybrid mode: use flags for what's provided, prompt for what's missing
104
- let ipAddress: string;
105
- let sshUser: string;
106
-
107
- // IP address: from positional arg, --ip flag, or prompt
108
- if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) {
109
- ipAddress = args[0];
110
- } else if (typeof flags.ip === 'string') {
111
- ipAddress = flags.ip;
112
- } else {
113
- ipAddress = await promptText({
114
- message: 'Machine IP address:',
115
- validate: validateIpAddress,
116
- });
117
- }
100
+ // The two prompts below are bus interviews (ISS-0127), so `machine add` is
101
+ // drivable headlessly via `celilo events respond --values` / `events reply`.
102
+ // SSH auth uses a key file (--ssh-key-file or auto-detected) — never a
103
+ // prompted password so there is no service credential to resolve here.
104
+ // `withInterviewSession` renders bus questions locally when stdin is a TTY.
105
+ const scope = 'machine-add';
118
106
 
119
- // Check for duplicate IP
120
- const existing = await getMachineByIp(ipAddress);
121
- if (existing) {
122
- return {
123
- success: false,
124
- error: `Machine with IP ${ipAddress} already exists (hostname: ${existing.hostname}, zone: ${existing.zone})`,
125
- };
126
- }
107
+ return withInterviewSession(async () => {
108
+ try {
109
+ celiloIntro('Add Machine to Pool');
127
110
 
128
- // The local management box (127.0.0.1, or explicit --local) deploys
129
- // over Ansible's local connection — no SSH, no key, no connectivity
130
- // test. Determine this BEFORE the SSH-user step so the local path
131
- // never prompts (it would hang a non-interactive bootstrap postinst).
132
- const isLocal = ipAddress === '127.0.0.1' || flags.local === true;
133
- // An explicit --zone overrides inference (needed for the local box,
134
- // whose 127.0.0.1 matches no zone subnet, and useful before a firewall
135
- // has provided the target zone).
136
- const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined;
137
-
138
- // SSH user: irrelevant for a local machine (local connection); else
139
- // from flag, default to 'root', or prompt.
140
- if (isLocal) {
141
- sshUser = 'root';
142
- } else if (typeof flags['ssh-user'] === 'string') {
143
- sshUser = flags['ssh-user'];
144
- } else {
145
- sshUser = await promptText({
146
- message: 'SSH username:',
147
- defaultValue: 'root',
148
- placeholder: 'root',
149
- validate: validateRequired('SSH username'),
150
- });
151
- }
111
+ // Hybrid mode: use flags for what's provided, prompt for what's missing
112
+ let ipAddress: string;
113
+ let sshUser: string;
152
114
 
153
- let detectedInfo: DetectedMachineInfo;
154
- let zone: NetworkZone;
155
- let interfaces: NetworkInterface[];
156
- let role: MachineRole;
157
- let sshKey: string;
158
-
159
- if (isLocal) {
160
- console.log('\nLocal machine — detecting locally (no SSH)...');
161
- detectedInfo = await detectMachineInfoLocal();
162
- const net = await detectNetworkInterfacesLocal();
163
- interfaces = net.interfaces;
164
- role = net.role;
165
- // The box you're installing on is, by definition, on the internal LAN.
166
- zone = zoneOverride ?? 'internal';
167
- sshKey = ''; // local connection — no key needed
168
- console.log(
169
- `✓ Local: ${detectedInfo.hostname} ${detectedInfo.hardware.cpu_cores} cores, ` +
170
- `${detectedInfo.hardware.memory_mb} MB, ${detectedInfo.hardware.disk_gb} GB (zone ${zone}, connection local)\n`,
171
- );
172
- } else {
173
- // SSH key: from flag, auto-detect, or error
174
- let sshKeyPath: string;
175
- if (typeof flags['ssh-key-file'] === 'string') {
176
- sshKeyPath = flags['ssh-key-file'];
177
- const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
178
- if (!existsSync(expandedPath)) {
179
- return { success: false, error: `SSH key file not found: ${expandedPath}` };
180
- }
115
+ // IP address: from positional arg, --ip flag, or prompt
116
+ if (args[0] && /^\d+\.\d+\.\d+\.\d+$/.test(args[0])) {
117
+ ipAddress = args[0];
118
+ } else if (typeof flags.ip === 'string') {
119
+ ipAddress = flags.ip;
120
+ } else {
121
+ ipAddress = await askText({
122
+ scope,
123
+ key: 'ip_address',
124
+ message: 'Machine IP address',
125
+ placeholder: 'e.g., 192.168.1.100',
126
+ required: true,
127
+ pattern: '^\\d+\\.\\d+\\.\\d+\\.\\d+$',
128
+ });
129
+ }
130
+
131
+ // Check for duplicate IP
132
+ const existing = await getMachineByIp(ipAddress);
133
+ if (existing) {
134
+ return {
135
+ success: false,
136
+ error: `Machine with IP ${ipAddress} already exists (hostname: ${existing.hostname}, zone: ${existing.zone})`,
137
+ };
138
+ }
139
+
140
+ // The local management box (127.0.0.1, or explicit --local) deploys
141
+ // over Ansible's local connection no SSH, no key, no connectivity
142
+ // test. Determine this BEFORE the SSH-user step so the local path
143
+ // never prompts (it would hang a non-interactive bootstrap postinst).
144
+ const isLocal = ipAddress === '127.0.0.1' || flags.local === true;
145
+ // An explicit --zone overrides inference (needed for the local box,
146
+ // whose 127.0.0.1 matches no zone subnet, and useful before a firewall
147
+ // has provided the target zone).
148
+ const zoneOverride = typeof flags.zone === 'string' ? (flags.zone as NetworkZone) : undefined;
149
+
150
+ // SSH user: irrelevant for a local machine (local connection); else
151
+ // from flag, default to 'root', or prompt.
152
+ if (isLocal) {
153
+ sshUser = 'root';
154
+ } else if (typeof flags['ssh-user'] === 'string') {
155
+ sshUser = flags['ssh-user'];
181
156
  } else {
182
- // Auto-detect SSH key from system config
183
- const detectedKeyPath = findSshPrivateKey();
184
- if (!detectedKeyPath) {
157
+ sshUser = await askText({
158
+ scope,
159
+ key: 'ssh_user',
160
+ message: 'SSH username',
161
+ defaultValue: 'root',
162
+ placeholder: 'root',
163
+ required: true,
164
+ });
165
+ }
166
+
167
+ let detectedInfo: DetectedMachineInfo;
168
+ let zone: NetworkZone;
169
+ let interfaces: NetworkInterface[];
170
+ let role: MachineRole;
171
+ let sshKey: string;
172
+
173
+ if (isLocal) {
174
+ console.log('\nLocal machine — detecting locally (no SSH)...');
175
+ detectedInfo = await detectMachineInfoLocal();
176
+ const net = await detectNetworkInterfacesLocal();
177
+ interfaces = net.interfaces;
178
+ role = net.role;
179
+ // The box you're installing on is, by definition, on the internal LAN.
180
+ zone = zoneOverride ?? 'internal';
181
+ sshKey = ''; // local connection — no key needed
182
+ console.log(
183
+ `✓ Local: ${detectedInfo.hostname} — ${detectedInfo.hardware.cpu_cores} cores, ` +
184
+ `${detectedInfo.hardware.memory_mb} MB, ${detectedInfo.hardware.disk_gb} GB (zone ${zone}, connection local)\n`,
185
+ );
186
+ } else {
187
+ // SSH key: from flag, auto-detect, or error
188
+ let sshKeyPath: string;
189
+ if (typeof flags['ssh-key-file'] === 'string') {
190
+ sshKeyPath = flags['ssh-key-file'];
191
+ const expandedPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
192
+ if (!existsSync(expandedPath)) {
193
+ return { success: false, error: `SSH key file not found: ${expandedPath}` };
194
+ }
195
+ } else {
196
+ // Auto-detect SSH key from system config
197
+ const detectedKeyPath = findSshPrivateKey();
198
+ if (!detectedKeyPath) {
199
+ return {
200
+ success: false,
201
+ error:
202
+ 'Cannot find SSH private key.\n\n' +
203
+ 'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
204
+ 'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
205
+ };
206
+ }
207
+ sshKeyPath = detectedKeyPath;
208
+ console.log(`Using SSH key: ${sshKeyPath}`);
209
+ }
210
+
211
+ // Expand tilde in path
212
+ const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
213
+
214
+ // Read SSH key content
215
+ sshKey = readFileSync(expandedKeyPath, 'utf8');
216
+
217
+ console.log('\nTesting SSH connection...');
218
+
219
+ // Test SSH connectivity
220
+ const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath);
221
+ if (!canConnect) {
185
222
  return {
186
223
  success: false,
187
- error:
188
- 'Cannot find SSH private key.\n\n' +
189
- 'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
190
- 'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
224
+ error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`,
191
225
  };
192
226
  }
193
- sshKeyPath = detectedKeyPath;
194
- console.log(`Using SSH key: ${sshKeyPath}`);
195
- }
196
227
 
197
- // Expand tilde in path
198
- const expandedKeyPath = sshKeyPath.replace(/^~/, process.env.HOME || '~');
228
+ console.log('✓ SSH connection successful\n');
199
229
 
200
- // Read SSH key content
201
- sshKey = readFileSync(expandedKeyPath, 'utf8');
230
+ console.log('Detecting machine information...');
202
231
 
203
- console.log('\nTesting SSH connection...');
232
+ // Auto-detect machine info
233
+ detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath);
204
234
 
205
- // Test SSH connectivity
206
- const canConnect = await testSshConnection(ipAddress, sshUser, expandedKeyPath);
207
- if (!canConnect) {
208
- return {
209
- success: false,
210
- error: `Cannot connect to ${sshUser}@${ipAddress} with provided SSH key`,
211
- };
212
- }
235
+ console.log('✓ Machine detected:');
236
+ console.log(` Hostname: ${detectedInfo.hostname}`);
237
+ console.log(` OS: ${detectedInfo.osInfo}`);
238
+ console.log(
239
+ ` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`,
240
+ );
241
+ console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
242
+ console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
213
243
 
214
- console.log('✓ SSH connection successful\n');
244
+ // Zone: explicit override, else infer from IP.
245
+ console.log('Detecting network zone...');
246
+ zone = zoneOverride ?? (await detectZoneFromIp(ipAddress));
247
+ console.log(`✓ Zone: ${zone}\n`);
215
248
 
216
- console.log('Detecting machine information...');
249
+ // Detect network interfaces and classify machine
250
+ console.log('Detecting network interfaces...');
251
+ const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
252
+ interfaces = net.interfaces;
253
+ role = net.role;
217
254
 
218
- // Auto-detect machine info
219
- detectedInfo = await detectMachineInfo(ipAddress, sshUser, expandedKeyPath);
255
+ console.log(`✓ Role: ${role}`);
256
+ for (const iface of interfaces) {
257
+ console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
258
+ }
259
+ console.log('');
260
+ }
220
261
 
221
- console.log('✓ Machine detected:');
222
- console.log(` Hostname: ${detectedInfo.hostname}`);
223
- console.log(` OS: ${detectedInfo.osInfo}`);
224
- console.log(
225
- ` CPU: ${detectedInfo.hardware.cpu_cores} cores (${detectedInfo.hardware.arch || 'unknown'})`,
262
+ // Add machine to pool
263
+ const earmark = typeof flags.earmark === 'string' ? flags.earmark : undefined;
264
+ const machine = await addMachine({
265
+ hostname: detectedInfo.hostname,
266
+ zone,
267
+ ipAddress,
268
+ sshUser,
269
+ sshKey,
270
+ hardware: detectedInfo.hardware,
271
+ role,
272
+ interfaces,
273
+ assignedModuleIds: [],
274
+ earmarkedModule: earmark || null,
275
+ });
276
+
277
+ const earmarkNote = earmark ? `\n Earmarked for: ${earmark}` : '';
278
+ const roleNote = role === 'router' ? ` (router - ${interfaces.length} interfaces)` : '';
279
+ celiloOutro(
280
+ `Machine '${detectedInfo.hostname}' added successfully!\n\nDetails:\n Zone: ${zone}${roleNote}\n IP: ${ipAddress}\n Hardware: ${detectedInfo.hardware.cpu_cores} cores, ${detectedInfo.hardware.memory_mb} MB RAM, ${detectedInfo.hardware.disk_gb} GB disk${earmarkNote}\n\nNext steps:\n - List machines: celilo machine list\n - Check status: celilo machine status ${detectedInfo.hostname}`,
226
281
  );
227
- console.log(` Memory: ${detectedInfo.hardware.memory_mb} MB`);
228
- console.log(` Disk: ${detectedInfo.hardware.disk_gb} GB\n`);
229
-
230
- // Zone: explicit override, else infer from IP.
231
- console.log('Detecting network zone...');
232
- zone = zoneOverride ?? (await detectZoneFromIp(ipAddress));
233
- console.log(`✓ Zone: ${zone}\n`);
234
-
235
- // Detect network interfaces and classify machine
236
- console.log('Detecting network interfaces...');
237
- const net = await detectNetworkInterfaces(ipAddress, sshUser, expandedKeyPath);
238
- interfaces = net.interfaces;
239
- role = net.role;
240
-
241
- console.log(`✓ Role: ${role}`);
242
- for (const iface of interfaces) {
243
- console.log(` ${iface.name}: ${iface.ipAddress} (${iface.zone})`);
244
- }
245
- console.log('');
246
- }
247
282
 
248
- // Add machine to pool
249
- const earmark = typeof flags.earmark === 'string' ? flags.earmark : undefined;
250
- const machine = await addMachine({
251
- hostname: detectedInfo.hostname,
252
- zone,
253
- ipAddress,
254
- sshUser,
255
- sshKey,
256
- hardware: detectedInfo.hardware,
257
- role,
258
- interfaces,
259
- assignedModuleIds: [],
260
- earmarkedModule: earmark || null,
261
- });
262
-
263
- const earmarkNote = earmark ? `\n Earmarked for: ${earmark}` : '';
264
- const roleNote = role === 'router' ? ` (router - ${interfaces.length} interfaces)` : '';
265
- celiloOutro(
266
- `Machine '${detectedInfo.hostname}' added successfully!\n\nDetails:\n Zone: ${zone}${roleNote}\n IP: ${ipAddress}\n Hardware: ${detectedInfo.hardware.cpu_cores} cores, ${detectedInfo.hardware.memory_mb} MB RAM, ${detectedInfo.hardware.disk_gb} GB disk${earmarkNote}\n\nNext steps:\n - List machines: celilo machine list\n - Check status: celilo machine status ${detectedInfo.hostname}`,
267
- );
268
-
269
- return {
270
- success: true,
271
- message: `Added machine: ${machine.id}`,
272
- };
273
- } catch (error) {
274
- return {
275
- success: false,
276
- error: `Failed to add machine: ${error instanceof Error ? error.message : String(error)}`,
277
- };
278
- }
283
+ return {
284
+ success: true,
285
+ message: `Added machine: ${machine.id}`,
286
+ };
287
+ } catch (error) {
288
+ return {
289
+ success: false,
290
+ error: `Failed to add machine: ${error instanceof Error ? error.message : String(error)}`,
291
+ };
292
+ }
293
+ });
279
294
  }
@@ -3,7 +3,7 @@
3
3
  * Remove a machine from the machine pool
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
6
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
7
7
  import { getMachineByHostname, getMachineByIp, removeMachine } from '../../services/machine-pool';
8
8
  import { celiloIntro, celiloOutro } from '../prompts';
9
9
  import type { CommandResult } from '../types';
@@ -61,13 +61,16 @@ export async function handleMachineRemove(
61
61
 
62
62
  // Confirm deletion
63
63
  if (!flags.force) {
64
- const confirmed = await p.confirm({
65
- message: `Remove machine '${hostname}' (${machine.ipAddress})?`,
66
- initialValue: false,
67
- });
64
+ const confirmed = await withInterviewSession(() =>
65
+ askConfirm({
66
+ scope: `machine:${hostname}`,
67
+ key: 'remove',
68
+ message: `Remove machine '${hostname}' (${machine.ipAddress})?`,
69
+ defaultValue: false,
70
+ }),
71
+ );
68
72
 
69
- if (p.isCancel(confirmed) || !confirmed) {
70
- p.cancel('Operation cancelled');
73
+ if (!confirmed) {
71
74
  return { success: false, error: 'Cancelled by user' };
72
75
  }
73
76
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Recurrence gate for ISS-0069: `config set` must never accept an
3
+ * infrastructure-managed key and then have the deploy silently override it.
4
+ * Framework-managed (`source: infrastructure`) keys are rejected at set time;
5
+ * operator-settable keys are honored.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { type DbClient, getDb } from '../../db/client';
13
+ import { modules } from '../../db/schema';
14
+ import { handleModuleConfigSet } from './module-config';
15
+
16
+ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
17
+ let tempDir: string;
18
+ let db: DbClient;
19
+
20
+ beforeEach(() => {
21
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-module-config-'));
22
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
23
+ db = getDb();
24
+ db.insert(modules)
25
+ .values({
26
+ id: 'testmod',
27
+ name: 'Test Module',
28
+ sourcePath: tempDir,
29
+ version: '1.0.0',
30
+ manifestData: {
31
+ variables: {
32
+ owns: [
33
+ { name: 'vmid', type: 'integer', source: 'infrastructure' },
34
+ { name: 'target_node', type: 'string', source: 'infrastructure' },
35
+ { name: 'app_port', type: 'integer' },
36
+ ],
37
+ },
38
+ },
39
+ })
40
+ .run();
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(tempDir, { recursive: true, force: true });
45
+ process.env.CELILO_DB_PATH = undefined;
46
+ });
47
+
48
+ test('rejects an infrastructure-managed key (no silent accept-then-override)', async () => {
49
+ const result = await handleModuleConfigSet(['testmod', 'vmid', '203']);
50
+ expect(result.success).toBe(false);
51
+ if (!result.success) {
52
+ expect(result.error).toContain('infrastructure-managed');
53
+ }
54
+ });
55
+
56
+ test('rejects target_node and points at the real lever', async () => {
57
+ const result = await handleModuleConfigSet(['testmod', 'target_node', 'node3']);
58
+ expect(result.success).toBe(false);
59
+ if (!result.success) {
60
+ expect(result.error).toContain('not operator-settable');
61
+ }
62
+ });
63
+
64
+ test('accepts a normal operator-settable key', async () => {
65
+ const result = await handleModuleConfigSet(['testmod', 'app_port', '8080']);
66
+ expect(result.success).toBe(true);
67
+ });
68
+
69
+ test('still rejects an undeclared key, and omits infra keys from the valid-keys hint', async () => {
70
+ const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
71
+ expect(result.success).toBe(false);
72
+ if (!result.success) {
73
+ expect(result.error).toContain('Invalid config key');
74
+ expect(result.error).toContain('app_port');
75
+ expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint
76
+ }
77
+ });
78
+ });
@@ -57,17 +57,32 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
57
57
  // Validate key against manifest
58
58
  const manifest = module.manifestData as Record<string, unknown>;
59
59
  const variables = manifest.variables as
60
- | { owns?: Array<{ name: string; required?: boolean; default?: string }> }
60
+ | { owns?: Array<{ name: string; required?: boolean; default?: string; source?: string }> }
61
61
  | undefined;
62
62
  const declaredVars = variables?.owns || [];
63
63
 
64
64
  // Check if key is declared in manifest
65
65
  const declaredVar = declaredVars.find((v) => v.name === key);
66
66
  if (!declaredVar) {
67
- const validKeys = declaredVars.map((v) => v.name).join(', ');
67
+ const settableKeys = declaredVars
68
+ .filter((v) => v.source !== 'infrastructure')
69
+ .map((v) => v.name)
70
+ .join(', ');
68
71
  return {
69
72
  success: false,
70
- error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${validKeys || '(none declared)'}`,
73
+ error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}`,
74
+ };
75
+ }
76
+
77
+ // ISS-0069: reject infrastructure-managed keys at SET time rather than
78
+ // accepting-then-silently-overriding them at deploy. `source: infrastructure`
79
+ // variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
80
+ // derived by the deploy (IPAM allocates vmid/IP; the container service supplies
81
+ // node/template/gateway/vlan), so a value set here would be ignored.
82
+ if (declaredVar.source === 'infrastructure') {
83
+ return {
84
+ success: false,
85
+ error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
71
86
  };
72
87
  }
73
88
 
@@ -33,8 +33,8 @@ import {
33
33
  computeAspectScopeHash,
34
34
  recordAspectApproval,
35
35
  } from '../../services/aspect-approvals';
36
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
36
37
  import { getArg, getFlag, hasFlag } from '../parser';
37
- import { promptConfirm } from '../prompts';
38
38
  import type { CommandResult } from '../types';
39
39
  import { generateTypesForImportedModule } from './module-types';
40
40
 
@@ -148,10 +148,14 @@ export async function handleAspectApprovalAfterImport(args: {
148
148
  // mangles multi-line message arguments.
149
149
  process.stderr.write(`\n${scopeMsg}\n\n`);
150
150
 
151
- const accepted = await promptConfirm({
152
- message: 'Approve this base-module aspect?',
153
- initialValue: false,
154
- });
151
+ const accepted = await withInterviewSession(() =>
152
+ askConfirm({
153
+ scope: `module-import:${moduleId}`,
154
+ key: 'approve_aspect',
155
+ message: 'Approve this base-module aspect?',
156
+ defaultValue: false,
157
+ }),
158
+ );
155
159
 
156
160
  if (!accepted) {
157
161
  // Roll back the import — DB delete cascades to configs/secrets/etc.
@@ -16,6 +16,7 @@ import { runNamedHook } from '../../hooks/run-named-hook';
16
16
  import { deallocateForModule } from '../../ipam/auto-allocator';
17
17
  import { ModuleManifestSchema } from '../../manifest/schema';
18
18
  import { executeBuildWithProgress } from '../../services/build-stream';
19
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
19
20
  import {
20
21
  emitUninstallCompleted,
21
22
  emitUninstallFailed,
@@ -24,7 +25,7 @@ import {
24
25
  import { getContainerService, getServiceCredentials } from '../../services/container-service';
25
26
  import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
26
27
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
27
- import { log, promptConfirm } from '../prompts';
28
+ import { log } from '../prompts';
28
29
  import type { CommandResult } from '../types';
29
30
 
30
31
  /**
@@ -186,11 +187,16 @@ async function performModuleRemove(
186
187
  // re-run `celilo module run-hook <id> on_uninstall` manually.
187
188
  log.warn('Non-interactive context detected; continuing removal despite hook failure');
188
189
  } else {
189
- const proceed = await promptConfirm({
190
- message:
191
- 'on_uninstall hook failed. Continue removing the module anyway? ' +
192
- '(some external state may not be cleaned up)',
193
- });
190
+ const proceed = await withInterviewSession(() =>
191
+ askConfirm({
192
+ scope: `module-remove:${moduleId}`,
193
+ key: 'continue_after_hook_failure',
194
+ message:
195
+ 'on_uninstall hook failed. Continue removing the module anyway? ' +
196
+ '(some external state may not be cleaned up)',
197
+ defaultValue: false,
198
+ }),
199
+ );
194
200
  if (!proceed) {
195
201
  return { success: false, error: 'Removal cancelled after on_uninstall failure' };
196
202
  }
@@ -211,9 +217,14 @@ async function performModuleRemove(
211
217
  if (infra && hasTerraformState) {
212
218
  // Module has infrastructure — need to destroy it
213
219
  if (!force) {
214
- const confirmed = await promptConfirm({
215
- message: `Module '${moduleId}' has deployed infrastructure. This will run terraform destroy to remove it. Continue?`,
216
- });
220
+ const confirmed = await withInterviewSession(() =>
221
+ askConfirm({
222
+ scope: `module-remove:${moduleId}`,
223
+ key: 'destroy_infrastructure',
224
+ message: `Module '${moduleId}' has deployed infrastructure. This will run terraform destroy to remove it. Continue?`,
225
+ defaultValue: false,
226
+ }),
227
+ );
217
228
 
218
229
  if (!confirmed) {
219
230
  return {
@@ -5,6 +5,8 @@
5
5
  import { eq } from 'drizzle-orm';
6
6
  import { getDb } from '../../db/client';
7
7
  import { capabilities, moduleConfigs, modules, secrets } from '../../db/schema';
8
+ import { getModuleSystems } from '../../services/deployed-systems';
9
+ import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
8
10
  import { getArg, validateRequiredArgs } from '../parser';
9
11
  import type { CommandResult } from '../types';
10
12
 
@@ -84,6 +86,19 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
84
86
  }
85
87
  sections.push(metadataLines.join('\n'));
86
88
 
89
+ // Section 1b: Placement (real node reconciled live from Proxmox — ISS-0060).
90
+ // Shows where each deployed system ACTUALLY lives, not the cached
91
+ // __infra_target_node config (which drifts). API-only modules (no deployed
92
+ // systems) skip this section; a Proxmox outage degrades to "node unknown".
93
+ const placements = await reconcilePlacement(getModuleSystems(moduleId, db));
94
+ if (placements.length > 0) {
95
+ const placementLines = ['Placement (live from Proxmox):'];
96
+ for (const p of placements) {
97
+ placementLines.push(` ${formatPlacementLine(p.system, p.resolution)}`);
98
+ }
99
+ sections.push(placementLines.join('\n'));
100
+ }
101
+
87
102
  // Section 2: Configuration
88
103
  if (configs.length > 0) {
89
104
  const configLines = ['Configuration:'];