@calltelemetry/cli 0.6.11 → 0.6.13

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 (103) hide show
  1. package/dist/commands/appliance.d.ts +3 -0
  2. package/dist/commands/appliance.d.ts.map +1 -0
  3. package/dist/commands/appliance.js +99 -0
  4. package/dist/commands/appliance.js.map +1 -0
  5. package/dist/commands/update.d.ts.map +1 -1
  6. package/dist/commands/update.js +19 -17
  7. package/dist/commands/update.js.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/bundle.d.ts.map +1 -1
  11. package/dist/lib/bundle.js +12 -1
  12. package/dist/lib/bundle.js.map +1 -1
  13. package/dist/lib/migration-001-cgnat.d.ts +2 -0
  14. package/dist/lib/migration-001-cgnat.d.ts.map +1 -0
  15. package/dist/lib/migration-001-cgnat.js +59 -0
  16. package/dist/lib/migration-001-cgnat.js.map +1 -0
  17. package/dist/lib/migration-002-swap.d.ts +12 -0
  18. package/dist/lib/migration-002-swap.d.ts.map +1 -0
  19. package/dist/lib/migration-002-swap.js +103 -0
  20. package/dist/lib/migration-002-swap.js.map +1 -0
  21. package/dist/lib/migration-003-nm-heal.d.ts +12 -0
  22. package/dist/lib/migration-003-nm-heal.d.ts.map +1 -0
  23. package/dist/lib/migration-003-nm-heal.js +75 -0
  24. package/dist/lib/migration-003-nm-heal.js.map +1 -0
  25. package/dist/lib/migration-004-systemd-docker.d.ts +13 -0
  26. package/dist/lib/migration-004-systemd-docker.d.ts.map +1 -0
  27. package/dist/lib/migration-004-systemd-docker.js +48 -0
  28. package/dist/lib/migration-004-systemd-docker.js.map +1 -0
  29. package/dist/lib/migration-005-ssh-keys.d.ts +13 -0
  30. package/dist/lib/migration-005-ssh-keys.d.ts.map +1 -0
  31. package/dist/lib/migration-005-ssh-keys.js +49 -0
  32. package/dist/lib/migration-005-ssh-keys.js.map +1 -0
  33. package/dist/lib/migration-006-console-loglevel.d.ts +10 -0
  34. package/dist/lib/migration-006-console-loglevel.d.ts.map +1 -0
  35. package/dist/lib/migration-006-console-loglevel.js +27 -0
  36. package/dist/lib/migration-006-console-loglevel.js.map +1 -0
  37. package/dist/lib/migration-007-nm-keyfile.d.ts +10 -0
  38. package/dist/lib/migration-007-nm-keyfile.d.ts.map +1 -0
  39. package/dist/lib/migration-007-nm-keyfile.js +30 -0
  40. package/dist/lib/migration-007-nm-keyfile.js.map +1 -0
  41. package/dist/lib/migration-008-systemd-restart.d.ts +13 -0
  42. package/dist/lib/migration-008-systemd-restart.d.ts.map +1 -0
  43. package/dist/lib/migration-008-systemd-restart.js +41 -0
  44. package/dist/lib/migration-008-systemd-restart.js.map +1 -0
  45. package/dist/lib/migration-009-bind-mount-files.d.ts +11 -0
  46. package/dist/lib/migration-009-bind-mount-files.d.ts.map +1 -0
  47. package/dist/lib/migration-009-bind-mount-files.js +86 -0
  48. package/dist/lib/migration-009-bind-mount-files.js.map +1 -0
  49. package/dist/lib/migration-010-jtapi-state.d.ts +11 -0
  50. package/dist/lib/migration-010-jtapi-state.d.ts.map +1 -0
  51. package/dist/lib/migration-010-jtapi-state.js +61 -0
  52. package/dist/lib/migration-010-jtapi-state.js.map +1 -0
  53. package/dist/lib/migration-011-docker-memory-limit.d.ts +13 -0
  54. package/dist/lib/migration-011-docker-memory-limit.d.ts.map +1 -0
  55. package/dist/lib/migration-011-docker-memory-limit.js +45 -0
  56. package/dist/lib/migration-011-docker-memory-limit.js.map +1 -0
  57. package/dist/lib/migration-012-db-pool-sizes.d.ts +12 -0
  58. package/dist/lib/migration-012-db-pool-sizes.d.ts.map +1 -0
  59. package/dist/lib/migration-012-db-pool-sizes.js +64 -0
  60. package/dist/lib/migration-012-db-pool-sizes.js.map +1 -0
  61. package/dist/lib/migrations.d.ts +34 -0
  62. package/dist/lib/migrations.d.ts.map +1 -0
  63. package/dist/lib/migrations.js +179 -0
  64. package/dist/lib/migrations.js.map +1 -0
  65. package/dist/lib/update-steps.d.ts.map +1 -1
  66. package/dist/lib/update-steps.js +12 -0
  67. package/dist/lib/update-steps.js.map +1 -1
  68. package/dist/lib/version.d.ts +1 -1
  69. package/dist/lib/version.js +1 -1
  70. package/dist/shell/commands/appliance-performance.d.ts +36 -0
  71. package/dist/shell/commands/appliance-performance.d.ts.map +1 -0
  72. package/dist/shell/commands/appliance-performance.js +221 -0
  73. package/dist/shell/commands/appliance-performance.js.map +1 -0
  74. package/dist/shell/commands/registry.d.ts.map +1 -1
  75. package/dist/shell/commands/registry.js +19 -0
  76. package/dist/shell/commands/registry.js.map +1 -1
  77. package/dist/shell/commands/users.d.ts.map +1 -1
  78. package/dist/shell/commands/users.js +42 -1
  79. package/dist/shell/commands/users.js.map +1 -1
  80. package/dist/shell/network-onboarding.d.ts +29 -7
  81. package/dist/shell/network-onboarding.d.ts.map +1 -1
  82. package/dist/shell/network-onboarding.js +332 -81
  83. package/dist/shell/network-onboarding.js.map +1 -1
  84. package/dist/ui/views/JtapiDisableView.d.ts +6 -0
  85. package/dist/ui/views/JtapiDisableView.d.ts.map +1 -0
  86. package/dist/ui/views/JtapiDisableView.js +52 -0
  87. package/dist/ui/views/JtapiDisableView.js.map +1 -0
  88. package/dist/ui/views/JtapiEnableView.d.ts +6 -0
  89. package/dist/ui/views/JtapiEnableView.d.ts.map +1 -0
  90. package/dist/ui/views/JtapiEnableView.js +40 -0
  91. package/dist/ui/views/JtapiEnableView.js.map +1 -0
  92. package/dist/ui/views/JtapiStatusView.d.ts +6 -0
  93. package/dist/ui/views/JtapiStatusView.d.ts.map +1 -0
  94. package/dist/ui/views/JtapiStatusView.js +11 -0
  95. package/dist/ui/views/JtapiStatusView.js.map +1 -0
  96. package/dist/ui/views/JtapiTroubleshootView.d.ts +6 -0
  97. package/dist/ui/views/JtapiTroubleshootView.d.ts.map +1 -0
  98. package/dist/ui/views/JtapiTroubleshootView.js +114 -0
  99. package/dist/ui/views/JtapiTroubleshootView.js.map +1 -0
  100. package/dist/ui/views/MainMenu.d.ts.map +1 -1
  101. package/dist/ui/views/MainMenu.js +11 -0
  102. package/dist/ui/views/MainMenu.js.map +1 -1
  103. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * network-onboarding.ts — First-boot setup wizard for ct shell.
3
3
  *
4
- * Six-step wizard that collects all configuration atomically before applying:
4
+ * Seven-step wizard that collects all configuration atomically before applying:
5
5
  * 1. Network (IP/DHCP, DNS, search domain)
6
- * 2. System Identity (hostname, location)
7
- * 3. Admin Credentials (root password)
8
- * 4. Timezone
9
- * 5. NTP Time Servers
6
+ * 2. Timezone
7
+ * 3. NTP Time Servers
8
+ * 4. System Identity (hostname, location)
9
+ * 5. Admin Credentials (root password)
10
10
  * 6. Preferences (auto-update, IPv6, JTAPI)
11
+ * 7. Performance Profile (small/medium/large)
11
12
  *
12
13
  * After collection, shows a unified summary and offers Apply/Edit/Cancel.
13
14
  * Apply phase runs all changes sequentially with progress output.
@@ -15,12 +16,13 @@
15
16
  */
16
17
  import * as readline from 'node:readline';
17
18
  import { execSync, spawnSync } from 'node:child_process';
18
- import { writeFileSync } from 'node:fs';
19
+ import { existsSync, writeFileSync } from 'node:fs';
19
20
  import chalk from 'chalk';
20
- import { savePrefs } from '../lib/prefs.js';
21
+ import { loadPrefs, savePrefs } from '../lib/prefs.js';
21
22
  import { getHostname, isValidHostname, buildFqdn, applyHostname, applyAdminPassword, isStrongPassword, } from '../lib/identity.js';
22
23
  import { getSystemTimezone, TIMEZONE_REGIONS, searchTimezones, isValidTimezone, applyTimezone, applyNtpServers, checkNtpSync, DEFAULT_NTP, } from '../lib/time.js';
23
24
  import { generateSelfSignedCerts, getCertInfo } from '../lib/certs.js';
25
+ import { PROFILES, getSystemRamMb, getSystemCpuCount, recommendedProfile, meetsRamRequirement, applyProfile, detectCurrentProfile, } from './commands/appliance-performance.js';
24
26
  export { buildFqdn } from '../lib/identity.js';
25
27
  export const NETWORK_SENTINEL = '/etc/ct-network-configured';
26
28
  const DEFAULT_DNS1 = '8.8.8.8';
@@ -99,13 +101,86 @@ export function pingGateway(gateway) {
99
101
  return null;
100
102
  }
101
103
  }
102
- /** Write the network sentinel (falls back to sudo touch). */
103
- export function writeSentinel() {
104
+ /** Test if a DNS server can resolve a well-known domain. Returns true on success. */
105
+ function testDnsServer(server) {
104
106
  try {
105
- writeFileSync(NETWORK_SENTINEL, '');
107
+ const result = spawnSync('nslookup', ['google.com', server], {
108
+ encoding: 'utf-8',
109
+ timeout: 5000,
110
+ });
111
+ return result.status === 0;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** Ask for a DNS server, test resolution, offer retry on failure. */
118
+ async function askAndTestDns(ask, label, defaultValue) {
119
+ const padded = label.length < 13 ? label.padEnd(13) : label;
120
+ while (true) {
121
+ const raw = await ask(chalk.bold(` ${padded} [${defaultValue}] : `));
122
+ const server = parseDnsField(raw, defaultValue);
123
+ if (!isValidIp(server)) {
124
+ console.log(chalk.red(` ✗ Invalid IP address`));
125
+ continue;
126
+ }
127
+ process.stdout.write(chalk.dim(` Testing DNS resolution via ${server} ... `));
128
+ if (testDnsServer(server)) {
129
+ console.log(chalk.green('✓ working'));
130
+ return server;
131
+ }
132
+ else {
133
+ console.log(chalk.yellow('✗ resolution failed'));
134
+ const retry = (await ask(chalk.yellow(' Re-enter DNS server? [Y/n]: '))).trim().toLowerCase();
135
+ if (retry === 'n') {
136
+ console.log(chalk.dim(' Continuing with unresponsive DNS — may work after config is applied'));
137
+ return server;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ /** Write the network sentinel with config state (no sensitive values). */
143
+ export function writeSentinel(state) {
144
+ const sentinel = state ? {
145
+ mode: state.mode ?? 'dhcp',
146
+ ip: state.ip,
147
+ prefix: state.prefix,
148
+ gateway: state.gateway,
149
+ dns1: state.dns1,
150
+ dns2: state.dns2,
151
+ searchDomain: state.searchDomain,
152
+ iface: state.iface,
153
+ hostname: state.hostname,
154
+ location: state.location,
155
+ timezone: state.timezone,
156
+ ntp1: state.ntp1,
157
+ ntp2: state.ntp2,
158
+ configuredAt: new Date().toISOString(),
159
+ } : { mode: 'dhcp', configuredAt: new Date().toISOString() };
160
+ const content = JSON.stringify(sentinel, null, 2);
161
+ try {
162
+ writeFileSync(NETWORK_SENTINEL, content);
106
163
  }
107
164
  catch {
108
- spawnSync('sudo', ['touch', NETWORK_SENTINEL]);
165
+ // Fall back to sudo write via temp file
166
+ const tmp = `/tmp/ct-sentinel-${Date.now()}.json`;
167
+ writeFileSync(tmp, content);
168
+ spawnSync('sudo', ['mv', tmp, NETWORK_SENTINEL]);
169
+ }
170
+ }
171
+ /** Read saved sentinel state, or null if not configured / empty. */
172
+ export function readSentinel() {
173
+ try {
174
+ if (!existsSync(NETWORK_SENTINEL))
175
+ return null;
176
+ const { readFileSync } = require('node:fs');
177
+ const raw = readFileSync(NETWORK_SENTINEL, 'utf-8').trim();
178
+ if (!raw || raw.length < 2)
179
+ return null;
180
+ return JSON.parse(raw);
181
+ }
182
+ catch {
183
+ return null;
109
184
  }
110
185
  }
111
186
  /** Apply static IP via nmcli. Returns true on success. */
@@ -149,6 +224,44 @@ export function applyDhcp(iface) {
149
224
  function makeAsker(rl) {
150
225
  return (question) => new Promise((resolve) => rl.question(question, resolve));
151
226
  }
227
+ /** Prompt for password with masked input (shows * for each character) */
228
+ function askMasked(prompt) {
229
+ return new Promise(resolve => {
230
+ const { stdin, stdout } = process;
231
+ stdout.write(prompt);
232
+ const chars = [];
233
+ const wasRaw = stdin.isRaw;
234
+ stdin.setRawMode?.(true);
235
+ stdin.resume();
236
+ const onData = (buf) => {
237
+ const ch = buf.toString('utf8');
238
+ if (ch === '\r' || ch === '\n') {
239
+ stdin.removeListener('data', onData);
240
+ stdin.setRawMode?.(wasRaw ?? false);
241
+ stdout.write('\n');
242
+ resolve(chars.join(''));
243
+ }
244
+ else if (ch === '\x7f' || ch === '\b') {
245
+ if (chars.length > 0) {
246
+ chars.pop();
247
+ stdout.write('\b \b');
248
+ }
249
+ }
250
+ else if (ch === '\x03') {
251
+ // Ctrl+C
252
+ stdin.removeListener('data', onData);
253
+ stdin.setRawMode?.(wasRaw ?? false);
254
+ stdout.write('\n');
255
+ resolve('');
256
+ }
257
+ else if (ch >= ' ') {
258
+ chars.push(ch);
259
+ stdout.write('*');
260
+ }
261
+ };
262
+ stdin.on('data', onData);
263
+ });
264
+ }
152
265
  // ── Step progress helper ─────────────────────────────────────────────────────
153
266
  function stepProgress(label, ok) {
154
267
  if (ok) {
@@ -161,7 +274,7 @@ function stepProgress(label, ok) {
161
274
  // ── Step 1: Network ──────────────────────────────────────────────────────────
162
275
  async function stepNetwork(ask, state) {
163
276
  console.log('');
164
- console.log(chalk.bold.cyan(' Step 1 of 6 — Network'));
277
+ console.log(chalk.bold.cyan(' Step 1 of 7 — Network'));
165
278
  console.log(chalk.dim(' ────────────────────────────────────────'));
166
279
  const iface = detectIface();
167
280
  state.iface = iface;
@@ -179,8 +292,11 @@ async function stepNetwork(ask, state) {
179
292
  console.log(chalk.cyan(' Network mode:'));
180
293
  console.log(chalk.dim(' 1 Keep current DHCP configuration'));
181
294
  console.log(chalk.dim(' 2 Configure static IP'));
295
+ console.log(chalk.dim(' 3 Skip setup and exit to shell'));
182
296
  console.log('');
183
297
  const mode = (await ask(chalk.bold(' Choice [1]: '))).trim() || '1';
298
+ if (mode === '3')
299
+ return 'skip';
184
300
  if (mode === '2') {
185
301
  state.mode = 'static';
186
302
  }
@@ -193,8 +309,11 @@ async function stepNetwork(ask, state) {
193
309
  console.log(chalk.cyan(' Network mode:'));
194
310
  console.log(chalk.dim(' 1 Static IP'));
195
311
  console.log(chalk.dim(' 2 DHCP (auto)'));
312
+ console.log(chalk.dim(' 3 Skip setup and exit to shell'));
196
313
  console.log('');
197
314
  const mode = (await ask(chalk.bold(' Choice [1]: '))).trim() || '1';
315
+ if (mode === '3')
316
+ return 'skip';
198
317
  if (mode === '2') {
199
318
  state.mode = 'dhcp';
200
319
  }
@@ -243,55 +362,43 @@ async function stepNetwork(ask, state) {
243
362
  console.log(chalk.green(` ✓ Subnet: /${prefix}`));
244
363
  }
245
364
  state.prefix = prefix;
246
- let gateway = '';
247
- while (!isValidGateway(gateway)) {
248
- gateway = (await ask(chalk.bold(' Gateway (e.g. 192.168.1.1) : '))).trim();
249
- if (!isValidGateway(gateway))
250
- console.log(chalk.red(' Invalid gateway — enter a valid IPv4 address'));
251
- }
252
- state.gateway = gateway;
253
- }
254
- // DNS (applies to both modes)
255
- const dns1Raw = await ask(chalk.bold(` Primary DNS [${DEFAULT_DNS1}] : `));
256
- state.dns1 = parseDnsField(dns1Raw, DEFAULT_DNS1);
257
- const dns2Raw = await ask(chalk.bold(` Secondary DNS [${DEFAULT_DNS2}] : `));
258
- state.dns2 = parseDnsField(dns2Raw, DEFAULT_DNS2);
365
+ // Gateway validate format, then test reachability with retry option
366
+ while (true) {
367
+ let gateway = '';
368
+ while (!isValidGateway(gateway)) {
369
+ gateway = (await ask(chalk.bold(' Gateway (e.g. 192.168.1.1) : '))).trim();
370
+ if (!isValidGateway(gateway))
371
+ console.log(chalk.red(' ✗ Invalid gateway enter a valid IPv4 address'));
372
+ }
373
+ process.stdout.write(chalk.dim(` Checking connectivity to ${gateway} ... `));
374
+ const latency = pingGateway(gateway);
375
+ if (latency !== null) {
376
+ console.log(chalk.green(`✓ reachable (${latency}ms)`));
377
+ state.gateway = gateway;
378
+ break;
379
+ }
380
+ else {
381
+ console.log(chalk.yellow(`✗ unreachable`));
382
+ const retry = (await ask(chalk.yellow(' Re-enter gateway? [Y/n]: '))).trim().toLowerCase();
383
+ if (retry === 'n') {
384
+ console.log(chalk.dim(' Continuing with unreachable gateway — may work after config is applied'));
385
+ state.gateway = gateway;
386
+ break;
387
+ }
388
+ }
389
+ }
390
+ }
391
+ // DNS — validate each server can resolve, with retry option
392
+ state.dns1 = await askAndTestDns(ask, 'Primary DNS', DEFAULT_DNS1);
393
+ state.dns2 = await askAndTestDns(ask, 'Secondary DNS', DEFAULT_DNS2);
259
394
  // Search domain
260
395
  const domainRaw = await ask(chalk.bold(` Search domain [${DEFAULT_SEARCH_DOMAIN}] : `));
261
396
  state.searchDomain = parseDnsField(domainRaw, DEFAULT_SEARCH_DOMAIN);
262
- // Quick connectivity check
263
- console.log('');
264
- console.log(chalk.dim(' Quick connectivity check...'));
265
- if (state.mode === 'static' && state.gateway) {
266
- const ms = pingGateway(state.gateway);
267
- if (ms !== null) {
268
- console.log(chalk.green(` ✓ Gateway ${state.gateway} reachable (${ms}ms)`));
269
- }
270
- else {
271
- console.log(chalk.yellow(` ⚠ Gateway ${state.gateway} not reachable — may work after apply`));
272
- }
273
- }
274
- // DNS resolve test
275
- try {
276
- const result = spawnSync('nslookup', ['google.com', state.dns1], {
277
- encoding: 'utf-8',
278
- timeout: 5000,
279
- });
280
- if (result.status === 0) {
281
- console.log(chalk.green(` ✓ DNS resolution via ${state.dns1} working`));
282
- }
283
- else {
284
- console.log(chalk.yellow(` ⚠ DNS resolution via ${state.dns1} failed — may work after apply`));
285
- }
286
- }
287
- catch {
288
- console.log(chalk.yellow(` ⚠ DNS check skipped — nslookup not available`));
289
- }
290
397
  }
291
398
  // ── Step 2: System Identity ──────────────────────────────────────────────────
292
399
  async function stepIdentity(ask, state) {
293
400
  console.log('');
294
- console.log(chalk.bold.cyan(' Step 4 of 6 — System Identity'));
401
+ console.log(chalk.bold.cyan(' Step 4 of 7 — System Identity'));
295
402
  console.log(chalk.dim(' ────────────────────────────────────────'));
296
403
  const currentHostname = getHostname();
297
404
  // Hostname
@@ -317,21 +424,21 @@ async function stepIdentity(ask, state) {
317
424
  console.log(chalk.dim(' config certs import <cert.pem> <key.pem>'));
318
425
  }
319
426
  // ── Step 3: Admin Credentials ────────────────────────────────────────────────
320
- async function stepAdmin(ask, state) {
427
+ async function stepAdmin(_ask, state) {
321
428
  console.log('');
322
- console.log(chalk.bold.cyan(' Step 5 of 6 — Admin Credentials'));
429
+ console.log(chalk.bold.cyan(' Step 5 of 7 — Admin Credentials'));
323
430
  console.log(chalk.dim(' ────────────────────────────────────────'));
324
431
  console.log(chalk.yellow(' Default passwords should be changed before production use.'));
325
432
  console.log(chalk.dim(' Minimum 8 characters.'));
326
433
  console.log('');
327
434
  while (true) {
328
- const pw = (await ask(chalk.bold(' New admin password: '))).trim();
435
+ const pw = (await askMasked(chalk.bold(' New admin password: '))).trim();
329
436
  const check = isStrongPassword(pw);
330
437
  if (!check.valid) {
331
438
  console.log(chalk.red(` ✗ ${check.reason}`));
332
439
  continue;
333
440
  }
334
- const confirm = (await ask(chalk.bold(' Confirm password: '))).trim();
441
+ const confirm = (await askMasked(chalk.bold(' Confirm password: '))).trim();
335
442
  if (confirm !== pw) {
336
443
  console.log(chalk.red(' ✗ Passwords do not match'));
337
444
  continue;
@@ -344,7 +451,7 @@ async function stepAdmin(ask, state) {
344
451
  // ── Step 4: Timezone ─────────────────────────────────────────────────────────
345
452
  async function stepTimezone(ask, state) {
346
453
  console.log('');
347
- console.log(chalk.bold.cyan(' Step 2 of 6 — Timezone'));
454
+ console.log(chalk.bold.cyan(' Step 2 of 7 — Timezone'));
348
455
  console.log(chalk.dim(' ────────────────────────────────────────'));
349
456
  const currentTz = getSystemTimezone();
350
457
  console.log(chalk.dim(` Current timezone: ${currentTz}`));
@@ -436,7 +543,7 @@ async function stepTimezone(ask, state) {
436
543
  // ── Step 5: NTP ──────────────────────────────────────────────────────────────
437
544
  async function stepNtp(ask, state) {
438
545
  console.log('');
439
- console.log(chalk.bold.cyan(' Step 3 of 6 — NTP Time Servers'));
546
+ console.log(chalk.bold.cyan(' Step 3 of 7 — NTP Time Servers'));
440
547
  console.log(chalk.dim(' ────────────────────────────────────────'));
441
548
  const ntp1Raw = await ask(chalk.bold(` NTP Server 1 [${DEFAULT_NTP[0]}]: `));
442
549
  state.ntp1 = parseDnsField(ntp1Raw, DEFAULT_NTP[0]);
@@ -447,7 +554,7 @@ async function stepNtp(ask, state) {
447
554
  // ── Step 6: Preferences ──────────────────────────────────────────────────────
448
555
  async function stepPreferences(ask, state) {
449
556
  console.log('');
450
- console.log(chalk.bold.cyan(' Step 6 of 6 — Preferences'));
557
+ console.log(chalk.bold.cyan(' Step 6 of 7 — Preferences'));
451
558
  console.log(chalk.dim(' ────────────────────────────────────────'));
452
559
  // Auto-updates
453
560
  const autoUpdate = (await ask(chalk.bold(' Enable automatic CLI updates? [Y/n]: '))).trim().toLowerCase();
@@ -466,6 +573,67 @@ async function stepPreferences(ask, state) {
466
573
  state.jtapi = jtapi === 'y' || jtapi === 'yes';
467
574
  console.log(state.jtapi ? chalk.green(' ✓ JTAPI greeting injection enabled') : chalk.dim(' ○ JTAPI greeting injection disabled'));
468
575
  }
576
+ // ── Step 7: Performance Profile ──────────────────────────────────────────
577
+ const PROFILE_NAMES = ['small', 'medium', 'large'];
578
+ async function stepPerformance(ask, state) {
579
+ console.log('');
580
+ console.log(chalk.bold.cyan(' Step 7 of 7 — Performance Profile'));
581
+ console.log(chalk.dim(' ────────────────────────────────────────'));
582
+ // Check if already configured
583
+ const existing = detectCurrentProfile();
584
+ if (existing) {
585
+ console.log(chalk.dim(` Current profile: ${PROFILES[existing].description}`));
586
+ }
587
+ // Detect hardware
588
+ const ramMb = getSystemRamMb();
589
+ const cpus = getSystemCpuCount();
590
+ const ramStr = ramMb > 0 ? `${(ramMb / 1024).toFixed(0)} GB` : 'unknown';
591
+ const cpuStr = cpus > 0 ? `${cpus} vCPUs` : 'unknown';
592
+ console.log(chalk.dim(` Detected: ${ramStr} RAM, ${cpuStr}`));
593
+ console.log('');
594
+ // Determine recommended profile
595
+ const rec = ramMb > 0 ? recommendedProfile(ramMb) : 'small';
596
+ console.log(chalk.cyan(' Choose a performance profile:'));
597
+ console.log('');
598
+ for (let i = 0; i < PROFILE_NAMES.length; i++) {
599
+ const name = PROFILE_NAMES[i];
600
+ const p = PROFILES[name];
601
+ const isRec = name === rec;
602
+ const label = isRec ? `${p.description} (recommended for this hardware)` : p.description;
603
+ const marker = isRec ? chalk.green(` > ${i + 1} ${label}`) : chalk.dim(` ${i + 1} ${label}`);
604
+ console.log(marker);
605
+ console.log(chalk.dim(` ${p.capacity}`));
606
+ console.log(chalk.dim(` Minimum: ${p.hardware}`));
607
+ console.log('');
608
+ }
609
+ // Default to recommended
610
+ const recIdx = PROFILE_NAMES.indexOf(rec) + 1;
611
+ const choice = (await ask(chalk.bold(` Choice [${recIdx}]: `))).trim() || String(recIdx);
612
+ const idx = parseInt(choice, 10) - 1;
613
+ if (isNaN(idx) || idx < 0 || idx >= PROFILE_NAMES.length) {
614
+ console.log(chalk.red(' ✗ Invalid selection — using recommended profile'));
615
+ state.performanceProfile = rec;
616
+ }
617
+ else {
618
+ state.performanceProfile = PROFILE_NAMES[idx];
619
+ }
620
+ const selected = state.performanceProfile;
621
+ const profile = PROFILES[selected];
622
+ // Warn if hardware doesn't meet requirements
623
+ if (ramMb > 0 && !meetsRamRequirement(ramMb, selected)) {
624
+ const ramGb = (ramMb / 1024).toFixed(1);
625
+ console.log('');
626
+ console.log(chalk.yellow(` ⚠ Your system has ${ramGb} GB RAM but ${profile.description} requires ${profile.hardware}.`));
627
+ console.log(chalk.yellow(' Performance may suffer.'));
628
+ const confirm = (await ask(chalk.bold(' Continue anyway? [y/N]: '))).trim().toLowerCase();
629
+ if (confirm !== 'y' && confirm !== 'yes') {
630
+ console.log(chalk.dim(' Falling back to recommended profile.'));
631
+ state.performanceProfile = rec;
632
+ }
633
+ }
634
+ const final = PROFILES[state.performanceProfile];
635
+ console.log(chalk.green(` ✓ Performance profile: ${final.description}`));
636
+ }
469
637
  // ── Summary ──────────────────────────────────────────────────────────────────
470
638
  /** Render a full summary of the wizard state. Exported for testing. */
471
639
  export function renderSummary(state) {
@@ -512,6 +680,15 @@ export function renderSummary(state) {
512
680
  add(` Auto-updates : ${state.autoUpdate ? chalk.green('Yes') : chalk.dim('No')}`);
513
681
  add(` IPv6 : ${state.ipv6 ? chalk.green('Yes') : chalk.dim('No')}`);
514
682
  add(` JTAPI Greetings: ${state.jtapi ? chalk.green('Yes') : chalk.dim('No')}`);
683
+ // PERFORMANCE
684
+ if (state.performanceProfile) {
685
+ const perf = PROFILES[state.performanceProfile];
686
+ add('');
687
+ add(chalk.cyan(' PERFORMANCE'));
688
+ add(chalk.dim(' ────────────────────────────────────────'));
689
+ add(` Profile : ${chalk.bold(perf.description)}`);
690
+ add(` Capacity : ${perf.capacity}`);
691
+ }
515
692
  add('');
516
693
  return lines.join('\n');
517
694
  }
@@ -563,7 +740,18 @@ async function applyWizardState(state) {
563
740
  stepProgress('Configure NTP servers', ntpOk);
564
741
  if (!ntpOk)
565
742
  allOk = false;
566
- // 7. Save preferences + write sentinel BEFORE cert gen
743
+ // 7. Performance profile
744
+ if (state.performanceProfile) {
745
+ try {
746
+ await applyProfile(state.performanceProfile);
747
+ stepProgress('Apply performance profile', true);
748
+ }
749
+ catch {
750
+ stepProgress('Apply performance profile', false);
751
+ allOk = false;
752
+ }
753
+ }
754
+ // 8. Save preferences + write sentinel BEFORE cert gen
567
755
  // (so wizard doesn't re-run even if cert generation fails)
568
756
  const prefs = {
569
757
  autoUpdate: state.autoUpdate,
@@ -682,6 +870,7 @@ const STEP_FUNCTIONS = [
682
870
  stepIdentity, // 4. Hostname + location
683
871
  stepAdmin, // 5. Admin password
684
872
  stepPreferences, // 6. Feature preferences
873
+ stepPerformance, // 7. Performance profile
685
874
  ];
686
875
  const STEP_NAMES = [
687
876
  'Network',
@@ -690,6 +879,7 @@ const STEP_NAMES = [
690
879
  'System Identity',
691
880
  'Admin Credentials',
692
881
  'Preferences',
882
+ 'Performance Profile',
693
883
  ];
694
884
  // ── Main entry point ─────────────────────────────────────────────────────────
695
885
  /**
@@ -698,32 +888,91 @@ const STEP_NAMES = [
698
888
  * Collects all configuration, then applies atomically.
699
889
  */
700
890
  export async function runNetworkOnboarding() {
891
+ const saved = readSentinel();
892
+ const isReconfigure = saved !== null;
701
893
  console.log('');
702
- console.log(chalk.bold.cyan(' Call Telemetry — First-Boot Setup'));
894
+ console.log(chalk.bold.cyan(isReconfigure
895
+ ? ' Call Telemetry — Reconfigure'
896
+ : ' Call Telemetry — First-Boot Setup'));
703
897
  console.log(chalk.dim(' ═══════════════════════════════════════'));
704
898
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
705
899
  const ask = makeAsker(rl);
706
900
  try {
707
- // Initialize state with defaults
901
+ // Initialize state restore from sentinel if reconfiguring
902
+ const prefs = loadPrefs();
708
903
  const state = {
709
- mode: 'dhcp',
710
- dns1: DEFAULT_DNS1,
711
- dns2: DEFAULT_DNS2,
712
- searchDomain: DEFAULT_SEARCH_DOMAIN,
713
- iface: 'eth0',
714
- hostname: getHostname(),
715
- location: '',
904
+ mode: saved?.mode ?? 'dhcp',
905
+ ip: saved?.ip,
906
+ prefix: saved?.prefix,
907
+ gateway: saved?.gateway,
908
+ dns1: saved?.dns1 ?? DEFAULT_DNS1,
909
+ dns2: saved?.dns2 ?? DEFAULT_DNS2,
910
+ searchDomain: saved?.searchDomain ?? DEFAULT_SEARCH_DOMAIN,
911
+ iface: saved?.iface ?? detectIface(),
912
+ hostname: saved?.hostname ?? getHostname(),
913
+ location: saved?.location ?? '',
716
914
  adminPassword: '',
717
- timezone: getSystemTimezone(),
718
- ntp1: DEFAULT_NTP[0],
719
- ntp2: DEFAULT_NTP[1],
720
- autoUpdate: true,
721
- ipv6: false,
722
- jtapi: false,
915
+ timezone: saved?.timezone ?? getSystemTimezone(),
916
+ ntp1: saved?.ntp1 ?? DEFAULT_NTP[0],
917
+ ntp2: saved?.ntp2 ?? DEFAULT_NTP[1],
918
+ autoUpdate: prefs?.autoUpdate ?? true,
919
+ ipv6: prefs?.ipv6 ?? false,
920
+ jtapi: prefs?.jtapi ?? false,
723
921
  };
724
- // Run steps 1-6 sequentially
725
- for (const stepFn of STEP_FUNCTIONS) {
726
- await stepFn(ask, state);
922
+ if (isReconfigure) {
923
+ // Show current config and let user pick what to change
924
+ console.log(renderSummary(state));
925
+ console.log(chalk.bold(' Select a section to reconfigure, or press Enter to exit:'));
926
+ console.log('');
927
+ for (let i = 0; i < STEP_NAMES.length; i++) {
928
+ console.log(chalk.dim(` ${i + 1} ${STEP_NAMES[i]}`));
929
+ }
930
+ console.log(chalk.dim(' A Reconfigure everything'));
931
+ console.log('');
932
+ const choice = (await ask(chalk.bold(' Choice [Enter=done]: '))).trim().toLowerCase();
933
+ if (choice === '') {
934
+ console.log(chalk.dim('\n No changes. Current configuration retained.\n'));
935
+ return;
936
+ }
937
+ if (choice === 'a') {
938
+ // Run all steps
939
+ for (const stepFn of STEP_FUNCTIONS) {
940
+ await stepFn(ask, state);
941
+ }
942
+ }
943
+ else {
944
+ const stepIdx = parseInt(choice, 10) - 1;
945
+ if (stepIdx >= 0 && stepIdx < STEP_FUNCTIONS.length) {
946
+ await STEP_FUNCTIONS[stepIdx](ask, state);
947
+ }
948
+ else {
949
+ console.log(chalk.red(' ✗ Invalid choice'));
950
+ return;
951
+ }
952
+ }
953
+ }
954
+ else {
955
+ // First boot — run all steps sequentially
956
+ for (const stepFn of STEP_FUNCTIONS) {
957
+ const result = await stepFn(ask, state);
958
+ // Step 1 returns 'skip' if user chose to skip setup entirely
959
+ if (result === 'skip') {
960
+ writeSentinel(state);
961
+ if (!loadPrefs()) {
962
+ savePrefs({
963
+ autoUpdate: true,
964
+ ipv6: false,
965
+ otel: false,
966
+ jtapi: false,
967
+ deploymentMode: 'docker-compose',
968
+ k8sNamespace: 'default',
969
+ k8sEnvironment: 'ct-prod',
970
+ });
971
+ }
972
+ console.log(chalk.green('\n ✓ Setup skipped. Run "ct setup" anytime to configure.\n'));
973
+ return;
974
+ }
975
+ }
727
976
  }
728
977
  // Show/edit/apply loop
729
978
  while (true) {
@@ -742,7 +991,7 @@ export async function runNetworkOnboarding() {
742
991
  console.log(chalk.dim(` ${i + 1} ${STEP_NAMES[i]}`));
743
992
  }
744
993
  console.log('');
745
- const stepChoice = (await ask(chalk.bold(' Which step? [1-6]: '))).trim();
994
+ const stepChoice = (await ask(chalk.bold(' Which step? [1-7]: '))).trim();
746
995
  const stepIdx = parseInt(stepChoice, 10) - 1;
747
996
  if (stepIdx >= 0 && stepIdx < STEP_FUNCTIONS.length) {
748
997
  await STEP_FUNCTIONS[stepIdx](ask, state);
@@ -755,6 +1004,8 @@ export async function runNetworkOnboarding() {
755
1004
  if (action === 'a' || action === '') {
756
1005
  // Apply all
757
1006
  const ok = await applyWizardState(state);
1007
+ // Save config to sentinel (no sensitive values)
1008
+ writeSentinel(state);
758
1009
  if (ok) {
759
1010
  console.log('');
760
1011
  console.log(chalk.green(' ✓ All configuration applied successfully'));