@newrelic/preflight 1.0.6 → 1.0.8

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.
@@ -4,17 +4,20 @@
4
4
  * Dynamically imported from collector-script.ts when argv[2] is install/uninstall,
5
5
  * so commander and other heavy deps are never loaded on the hot hook path.
6
6
  */
7
- import { execSync, execFileSync } from 'node:child_process';
7
+ import { execFileSync } from 'node:child_process';
8
8
  import { existsSync, copyFileSync, realpathSync } from 'node:fs';
9
- import { dirname, join, resolve } from 'node:path';
9
+ import { createInterface } from 'node:readline/promises';
10
+ import { dirname, join, relative, resolve, sep } from 'node:path';
10
11
  import { homedir } from 'node:os';
11
12
  import { Command } from 'commander';
13
+ import { createLogger } from '../shared/index.js';
12
14
  import { mergeSettings, removeSettings, mergeMcpConfig, removeMcpConfig, detectSettingsPath, detectMcpConfigPath, generateNrConfig, } from './install-helper.js';
13
15
  import { isWsl, resolveWindowsHome } from './platform.js';
14
16
  import { validateConfigFile, DEFAULT_STORAGE_PATH, ConfigFileSchema } from '../config.js';
15
17
  import { migrateStoragePath } from './migrate.js';
16
18
  import { installSchedule, removeSchedule, getScheduleStatus, removeDashboardDaemon, getDashboardDaemonStatus, resolveBinaryPath, } from './schedule.js';
17
- import { readJsonFile, readJsonFileStrict, writeJsonFile } from './json-utils.js';
19
+ import { readJsonFileStrict, writeJsonFile, errMsg } from './json-utils.js';
20
+ const logger = createLogger('cli');
18
21
  const NR_CONFIG_PATH = resolve(DEFAULT_STORAGE_PATH, 'config.json');
19
22
  // ---------------------------------------------------------------------------
20
23
  // Platform persistence helpers — read/write platformTarget in config.json
@@ -31,7 +34,7 @@ function clearSavedPlatform() {
31
34
  writeJsonFile(NR_CONFIG_PATH, rest, DEFAULT_STORAGE_PATH);
32
35
  }
33
36
  catch (err) {
34
- eprint(`\n⚠ Could not clear saved platform target: ${err instanceof Error ? err.message : String(err)}`);
37
+ eprint(`\n⚠ Could not clear saved platform target: ${errMsg(err)}`);
35
38
  eprint(' The next install may use the stale platform target. Fix the issue and re-run uninstall.');
36
39
  }
37
40
  }
@@ -110,13 +113,7 @@ function eprint(msg = '') {
110
113
  // PATH verification
111
114
  // ---------------------------------------------------------------------------
112
115
  export function verifyBinaryOnPath() {
113
- try {
114
- execSync('which preflight', { stdio: 'pipe' });
115
- return true;
116
- }
117
- catch {
118
- return false;
119
- }
116
+ return resolveBinaryPath() !== null;
120
117
  }
121
118
  function printPathWarning() {
122
119
  print('\n⚠ preflight is not on your PATH.');
@@ -154,10 +151,54 @@ function handleUpdate() {
154
151
  print('✗ Could not locate the repo root. Run this command from within the cloned repo or after npm link.');
155
152
  process.exit(1);
156
153
  }
154
+ let gitRoot;
155
+ try {
156
+ gitRoot = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--show-toplevel'], {
157
+ stdio: 'pipe',
158
+ env: { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined },
159
+ })
160
+ .toString()
161
+ .trim();
162
+ }
163
+ catch (err) {
164
+ if (err.code === 'ENOENT') {
165
+ print('✗ git is not installed or not found on PATH.');
166
+ print(' Install git (https://git-scm.com) then retry: preflight update');
167
+ }
168
+ else {
169
+ print('✗ preflight was installed via a package manager, not cloned from source.');
170
+ print(' (If your .git directory is missing or corrupt, re-clone the repo instead.)');
171
+ print(' To update, reinstall using your package manager, e.g.:');
172
+ print(' npm install -g @newrelic/preflight@latest');
173
+ print(' pnpm add -g @newrelic/preflight@latest');
174
+ }
175
+ process.exit(1);
176
+ }
177
+ // If repoRoot sits below a node_modules directory within the git tree,
178
+ // preflight is installed as a dependency — not a source clone.
179
+ // path.relative() normalises separators on all platforms (robust on Windows).
180
+ if (relative(gitRoot, repoRoot).split(sep).includes('node_modules')) {
181
+ print('✗ preflight was installed via a package manager, not cloned from source.');
182
+ print(' To update, reinstall using your package manager, e.g.:');
183
+ print(' npm install -g @newrelic/preflight@latest');
184
+ print(' pnpm add -g @newrelic/preflight@latest');
185
+ process.exit(1);
186
+ }
157
187
  print(`Updating Preflight from ${repoRoot}...\n`);
158
188
  try {
159
189
  print('→ git pull');
160
190
  execFileSync('git', ['pull'], { cwd: repoRoot, stdio: 'inherit' });
191
+ }
192
+ catch {
193
+ print('\n✗ git pull failed. Check the output above for details.');
194
+ print(' If the output shows diverged branches and you have no local commits to keep,');
195
+ print(' you can reset to the remote HEAD (replace <branch> with your default branch):');
196
+ print(` git -C "${repoRoot}" fetch origin`);
197
+ print(` git -C "${repoRoot}" reset --hard origin/<branch>`);
198
+ print(' WARNING: reset --hard permanently discards any local commits not yet on origin.');
199
+ process.exit(1);
200
+ }
201
+ try {
161
202
  print('\n→ npm run build');
162
203
  execFileSync('npm', ['run', 'build'], { cwd: repoRoot, stdio: 'inherit' });
163
204
  print('\n✓ Update complete.');
@@ -165,7 +206,7 @@ function handleUpdate() {
165
206
  print(' Run `preflight install` to update the MCP server key in ~/.mcp.json.');
166
207
  }
167
208
  catch {
168
- print('\n✗ Update failed. Check the output above for details.');
209
+ print('\n✗ Build failed. Check the output above for details.');
169
210
  process.exit(1);
170
211
  }
171
212
  }
@@ -178,9 +219,14 @@ function handleSchedule(options) {
178
219
  process.exit(1);
179
220
  }
180
221
  if (options.disable) {
181
- const wasInstalled = getScheduleStatus().installed;
182
- removeSchedule();
183
- print(wasInstalled ? '✓ Auto-update schedule removed.' : 'No schedule was installed.');
222
+ try {
223
+ const removed = removeSchedule();
224
+ print(removed ? '✓ Auto-update schedule removed.' : 'No schedule was installed.');
225
+ }
226
+ catch (err) {
227
+ eprint(`⚠ Could not remove schedule: ${errMsg(err)}`);
228
+ process.exitCode = 1;
229
+ }
184
230
  return;
185
231
  }
186
232
  if (options.time !== undefined) {
@@ -210,10 +256,15 @@ function handleSchedule(options) {
210
256
  // No flags — show status.
211
257
  const status = getScheduleStatus();
212
258
  if (status.installed) {
213
- const hh = String(status.hour ?? 0).padStart(2, '0');
214
- const mm = String(status.minute ?? 0).padStart(2, '0');
215
- print(`Auto-update schedule: ${hh}:${mm} daily`);
216
- print(` Binary: ${status.binaryPath ?? 'unknown'}`);
259
+ if (status.readable === false) {
260
+ print('Auto-update schedule: installed (plist unreadable — reinstall with: preflight schedule --time HH:MM)');
261
+ }
262
+ else {
263
+ const hh = String(status.hour ?? 0).padStart(2, '0');
264
+ const mm = String(status.minute ?? 0).padStart(2, '0');
265
+ print(`Auto-update schedule: ${hh}:${mm} daily`);
266
+ print(` Binary: ${status.binaryPath ?? 'unknown'}`);
267
+ }
217
268
  print(' To change: preflight schedule --time HH:MM');
218
269
  print(' To remove: preflight schedule --disable');
219
270
  }
@@ -248,13 +299,13 @@ function handleInstall(options) {
248
299
  catch (err) {
249
300
  const isSyntaxError = err instanceof SyntaxError;
250
301
  if (isSyntaxError || (inWsl && !explicitPlatform) || credentialsProvided) {
251
- eprint(`\n✗ Cannot read existing NR config to determine install target: ${err instanceof Error ? err.message : String(err)}`);
302
+ eprint(`\n✗ Cannot read existing NR config to determine install target: ${errMsg(err)}`);
252
303
  eprint(isSyntaxError
253
304
  ? ' config.json contains invalid JSON — fix or delete it, then re-run install.'
254
305
  : ' Fix file permissions then re-run install.');
255
306
  throw err;
256
307
  }
257
- eprint(`\n⚠ Could not read existing NR config to persist platform target: ${err instanceof Error ? err.message : String(err)}`);
308
+ eprint(`\n⚠ Could not read existing NR config to persist platform target: ${errMsg(err)}`);
258
309
  eprint(' Platform target not persisted — hook installation will continue. Re-run to save it.');
259
310
  skipNrConfigWrite = true;
260
311
  }
@@ -268,21 +319,21 @@ function handleInstall(options) {
268
319
  mergedMcp = mergeMcpConfig(readJsonFileStrict(mcpPath), binPath, { platform });
269
320
  }
270
321
  catch (err) {
271
- eprint(`\n✗ Failed to prepare config: ${err instanceof Error ? err.message : String(err)}`);
322
+ eprint(`\n✗ Failed to prepare config: ${errMsg(err)}`);
272
323
  throw err;
273
324
  }
274
325
  try {
275
326
  writeJsonFile(settingsPath, mergedSettings, allowedBase);
276
327
  }
277
328
  catch (err) {
278
- eprint(`\n✗ Failed to write hooks config (${settingsPath}): ${err instanceof Error ? err.message : String(err)}`);
329
+ eprint(`\n✗ Failed to write hooks config (${settingsPath}): ${errMsg(err)}`);
279
330
  throw err;
280
331
  }
281
332
  try {
282
333
  writeJsonFile(mcpPath, mergedMcp, allowedBase);
283
334
  }
284
335
  catch (err) {
285
- eprint(`\n✗ Failed to write MCP config (${mcpPath}): ${err instanceof Error ? err.message : String(err)}`);
336
+ eprint(`\n✗ Failed to write MCP config (${mcpPath}): ${errMsg(err)}`);
286
337
  throw err;
287
338
  }
288
339
  // Persist platformTarget (and credentials if provided) — only after both hook files written.
@@ -298,10 +349,10 @@ function handleInstall(options) {
298
349
  }
299
350
  catch (err) {
300
351
  if (credentialsProvided) {
301
- eprint(`\n✗ Failed to save New Relic config: ${err instanceof Error ? err.message : String(err)}`);
352
+ eprint(`\n✗ Failed to save New Relic config: ${errMsg(err)}`);
302
353
  throw err;
303
354
  }
304
- eprint(`\n⚠ Could not persist platform target: ${err instanceof Error ? err.message : String(err)}`);
355
+ eprint(`\n⚠ Could not persist platform target: ${errMsg(err)}`);
305
356
  eprint(' The next install will re-detect the target platform from scratch.');
306
357
  }
307
358
  }
@@ -344,9 +395,12 @@ function handleInstall(options) {
344
395
  print(' to check your config file for typos or unsupported fields.');
345
396
  }
346
397
  // ---------------------------------------------------------------------------
347
- // Uninstall handler
398
+ // Uninstall helpers
348
399
  // ---------------------------------------------------------------------------
349
- function handleUninstall(options) {
400
+ // Resolves which settings and MCP config paths to clean based on platform
401
+ // flags and the saved install target. Calls process.exit(1) on invalid
402
+ // flag combinations or unresolvable state.
403
+ function resolveUninstallPaths(options) {
350
404
  const scope = options.project ? 'project' : 'user';
351
405
  if (options.windowsCc && options.linuxCc) {
352
406
  print('\n ⚠ --windows-cc and --linux-cc are mutually exclusive. Pass only one.');
@@ -448,68 +502,255 @@ function handleUninstall(options) {
448
502
  settingsPathsToClean.set(detectSettingsPath(scope, null), undefined);
449
503
  mcpPathsToClean.set(detectMcpConfigPath(scope, null), undefined);
450
504
  }
451
- print('');
452
- let settingsFound = false;
505
+ return { settingsPathsToClean, mcpPathsToClean };
506
+ }
507
+ // Backs up a config file then writes the transformed result. Returns whether
508
+ // the write succeeded and any error encountered — never throws.
509
+ function backupAndWrite(path, allowedBase, transform, errorLabel) {
510
+ const backup = `${path}.backup-${Date.now()}`;
511
+ try {
512
+ const data = readJsonFileStrict(path);
513
+ copyFileSync(path, backup);
514
+ print(` Backup saved: ${backup}`);
515
+ writeJsonFile(path, transform(data), allowedBase);
516
+ return { written: true, error: null };
517
+ }
518
+ catch (err) {
519
+ // If copyFileSync and writeJsonFile both succeeded the backup is no longer
520
+ // needed (the write path handles it). If copyFileSync succeeded but
521
+ // writeJsonFile failed, the backup is the user's only recovery copy —
522
+ // preserve it. If readJsonFileStrict failed, no backup was created and the
523
+ // original is untouched, so there is nothing to recover.
524
+ const error = err instanceof Error ? err : new Error(String(err));
525
+ eprint(`\n✗ Failed to clean ${errorLabel} (${path}): ${errMsg(err)}`);
526
+ process.exitCode = 1;
527
+ return { written: false, error };
528
+ }
529
+ }
530
+ // Removes preflight hooks and MCP config entries from the given paths.
531
+ // Returns a RemovalResult — never throws. Partial completion is possible:
532
+ // removed=true even when error is non-null if at least one file succeeded.
533
+ function removeClaudeCodeConfig(settingsPathsToClean, mcpPathsToClean) {
534
+ let removed = false;
535
+ let firstError = null;
536
+ let hadSettingsFile = false;
453
537
  for (const [settingsPath, allowedBase] of settingsPathsToClean) {
454
- if (existsSync(settingsPath)) {
455
- const backup = `${settingsPath}.backup-${Date.now()}`;
456
- copyFileSync(settingsPath, backup);
457
- print(` Backup saved: ${backup}`);
458
- try {
459
- writeJsonFile(settingsPath, removeSettings(readJsonFileStrict(settingsPath)), allowedBase);
460
- }
461
- catch (err) {
462
- eprint(`\n✗ Failed to clean hooks config (${settingsPath}): ${err instanceof Error ? err.message : String(err)}`);
463
- throw err;
464
- }
465
- settingsFound = true;
538
+ if (!existsSync(settingsPath))
539
+ continue;
540
+ hadSettingsFile = true;
541
+ const { written, error } = backupAndWrite(settingsPath, allowedBase, removeSettings, 'hooks config');
542
+ if (written) {
543
+ removed = true;
466
544
  print(`✓ Hooks removed: ${settingsPath}`);
467
545
  }
546
+ else {
547
+ firstError ??= error;
548
+ }
468
549
  }
469
- if (!settingsFound) {
550
+ if (!hadSettingsFile) {
470
551
  print(`No settings file found at ${[...settingsPathsToClean.keys()].join(', ')}. Skipping hooks.`);
471
552
  }
472
- let mcpFound = false;
553
+ let hadMcpFile = false;
473
554
  for (const [mcpPath, allowedBase] of mcpPathsToClean) {
474
- if (existsSync(mcpPath)) {
475
- const backup = `${mcpPath}.backup-${Date.now()}`;
476
- copyFileSync(mcpPath, backup);
477
- print(` Backup saved: ${backup}`);
478
- try {
479
- writeJsonFile(mcpPath, removeMcpConfig(readJsonFileStrict(mcpPath)), allowedBase);
480
- }
481
- catch (err) {
482
- eprint(`\n✗ Failed to clean MCP config (${mcpPath}): ${err instanceof Error ? err.message : String(err)}`);
483
- throw err;
484
- }
485
- mcpFound = true;
555
+ if (!existsSync(mcpPath))
556
+ continue;
557
+ hadMcpFile = true;
558
+ const { written, error } = backupAndWrite(mcpPath, allowedBase, removeMcpConfig, 'MCP config');
559
+ if (written) {
560
+ removed = true;
486
561
  print(`✓ MCP server removed: ${mcpPath}`);
487
562
  }
563
+ else {
564
+ firstError ??= error;
565
+ }
488
566
  }
489
- if (!mcpFound) {
567
+ if (!hadMcpFile) {
490
568
  print(`No MCP config found at ${[...mcpPathsToClean.keys()].join(', ')}. Skipping MCP server.`);
491
569
  }
492
- // Update persisted platform after cleanup.
493
- // --windows-cc or bare: clear savedPlatform so the next install re-detects from scratch.
494
- // After --windows-cc uninstall, the user must pass --windows-cc again to reinstall
495
- // Windows CC mode re-detection has no heuristic for Windows CC intent.
496
- // --linux-cc: leave savedPlatform untouched — this only cleans Linux-side paths and must
497
- // not destroy a wsl-windows-cc record that the user still intends to use.
570
+ return { label: 'Claude Code config', removed, error: firstError, requiresRestart: true };
571
+ }
572
+ // Runs a single uninstall step. Returns a RemovalResult never throws.
573
+ // process.exitCode is set to 1 and a warning is printed on error.
574
+ function runStep(label, requiresRestart, fn) {
575
+ try {
576
+ const removed = fn();
577
+ return { label, removed, error: null, requiresRestart };
578
+ }
579
+ catch (err) {
580
+ process.exitCode = 1;
581
+ const error = err instanceof Error ? err : new Error(String(err));
582
+ logger.warn('uninstall step failed', { label, error });
583
+ eprint(`⚠ Could not remove ${label}: ${error.message}`);
584
+ return { label, removed: false, error, requiresRestart };
585
+ }
586
+ }
587
+ // Prompts the user for uninstall confirmation. Returns one of four outcomes.
588
+ async function promptConfirm(yes) {
589
+ if (yes)
590
+ return 'confirmed';
591
+ if (process.stdin.isTTY !== true)
592
+ return 'non-interactive';
593
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
594
+ try {
595
+ const answer = (await rl.question('Continue? [y/N]: ')).trim().toLowerCase();
596
+ return answer === 'y' || answer === 'yes' ? 'confirmed' : 'declined';
597
+ }
598
+ catch {
599
+ return 'stdin-closed';
600
+ }
601
+ finally {
602
+ rl.close();
603
+ }
604
+ }
605
+ // Handles a promptConfirm result: prints cancellation message and sets
606
+ // exitCode=1 on any non-confirmed outcome. Returns true only when confirmed.
607
+ function handleConfirm(result) {
608
+ if (result === 'confirmed')
609
+ return true;
610
+ const message = result === 'non-interactive'
611
+ ? 'Uninstall cancelled (non-interactive stdin — rerun with --yes to confirm).'
612
+ : result === 'stdin-closed'
613
+ ? 'Uninstall cancelled (stdin closed).'
614
+ : 'Uninstall cancelled.';
615
+ print(message);
616
+ process.exitCode = 1;
617
+ return false;
618
+ }
619
+ // ---------------------------------------------------------------------------
620
+ // Uninstall handler
621
+ // ---------------------------------------------------------------------------
622
+ async function handleUninstall(options) {
623
+ // --daemon: targeted removal of just the background dashboard daemon plist.
624
+ // Does not touch hooks, MCP config, schedules, or session history.
625
+ if (options.daemon) {
626
+ if (options.project || options.windowsCc || options.linuxCc) {
627
+ print(' ⚠ --daemon cannot be combined with --project, --windows-cc, or --linux-cc.');
628
+ print(' Use bare `preflight uninstall` to remove hooks and the daemon together.');
629
+ process.exit(1);
630
+ }
631
+ const daemonStatus = getDashboardDaemonStatus();
632
+ if (!daemonStatus.installed) {
633
+ print('No background dashboard daemon installed — nothing to remove.');
634
+ return;
635
+ }
636
+ print('preflight uninstall --daemon will remove the background dashboard daemon.\n');
637
+ if (!handleConfirm(await promptConfirm(options.yes ?? false)))
638
+ return;
639
+ const step = runStep('background dashboard daemon', false, () => removeDashboardDaemon());
640
+ if (step.error !== null) {
641
+ print('\nUninstall incomplete — see errors above.\n');
642
+ return;
643
+ }
644
+ if (!step.removed) {
645
+ process.exitCode = 1;
646
+ print('Background dashboard daemon already absent — nothing to remove.');
647
+ return;
648
+ }
649
+ print('✓ Background dashboard daemon removed.');
650
+ print(' The dashboard is now only available while Claude Code is running.');
651
+ print(' To reinstall, run: preflight setup');
652
+ return;
653
+ }
654
+ const { settingsPathsToClean, mcpPathsToClean } = resolveUninstallPaths(options);
655
+ // Build a human-readable summary of what will change, then ask for
656
+ // confirmation before touching anything.
657
+ const changeSummary = [];
658
+ let hadConfigFiles = false;
659
+ for (const settingsPath of settingsPathsToClean.keys()) {
660
+ if (existsSync(settingsPath)) {
661
+ changeSummary.push(` • Remove hooks from ${settingsPath}`);
662
+ hadConfigFiles = true;
663
+ }
664
+ }
665
+ for (const mcpPath of mcpPathsToClean.keys()) {
666
+ if (existsSync(mcpPath)) {
667
+ changeSummary.push(` • Remove MCP server from ${mcpPath}`);
668
+ hadConfigFiles = true;
669
+ }
670
+ }
671
+ const scheduleStatus = getScheduleStatus();
672
+ const daemonStatus = getDashboardDaemonStatus();
673
+ if (scheduleStatus.installed) {
674
+ const action = scheduleStatus.readable
675
+ ? 'Unload and delete'
676
+ : 'Remove (plist unreadable — label removal)';
677
+ changeSummary.push(` • ${action} auto-update schedule`);
678
+ }
679
+ if (daemonStatus.installed) {
680
+ const action = daemonStatus.readable
681
+ ? 'Unload and delete'
682
+ : 'Remove (plist unreadable — label removal)';
683
+ changeSummary.push(` • ${action} background dashboard daemon`);
684
+ }
685
+ if (changeSummary.length === 0) {
686
+ print('Nothing installed — no changes to make.');
687
+ return;
688
+ }
689
+ print('preflight uninstall will make the following changes:\n');
690
+ for (const line of changeSummary)
691
+ print(line);
692
+ print('');
693
+ print(` Your session history and config at ${DEFAULT_STORAGE_PATH} will NOT be deleted.`);
694
+ print('');
695
+ if (!handleConfirm(await promptConfirm(options.yes ?? false)))
696
+ return;
697
+ print('');
698
+ const configStep = removeClaudeCodeConfig(settingsPathsToClean, mcpPathsToClean);
498
699
  if (options.windowsCc) {
499
700
  print(' To reinstall Windows Claude Code mode, re-run: preflight install --windows-cc');
500
701
  }
501
- if (!options.linuxCc) {
702
+ const scheduleStep = runStep('auto-update schedule', false, () => {
703
+ const removed = removeSchedule();
704
+ if (removed)
705
+ print('✓ Auto-update schedule removed');
706
+ else if (scheduleStatus.installed) {
707
+ // Plist vanished between status-check and removal (TOCTOU). Throw so
708
+ // runStep captures this as an error and anyFailed reflects it.
709
+ throw new Error('Auto-update schedule already absent — may have been removed by another process');
710
+ }
711
+ return removed;
712
+ });
713
+ const daemonStep = runStep('background dashboard daemon', false, () => {
714
+ const removed = removeDashboardDaemon();
715
+ if (removed)
716
+ print('✓ Background dashboard daemon removed');
717
+ else if (daemonStatus.installed) {
718
+ // Plist vanished between status-check and removal (TOCTOU). Throw so
719
+ // runStep captures this as an error and anyFailed reflects it — without
720
+ // this, process.exitCode=1 and the success message are contradictory.
721
+ throw new Error('Background dashboard daemon already absent — may have been removed by another process');
722
+ }
723
+ return removed;
724
+ });
725
+ const steps = [configStep, scheduleStep, daemonStep];
726
+ const anyRemoved = steps.some((s) => s.removed);
727
+ const anyFailed = steps.some((s) => s.error !== null);
728
+ const requiresRestart = steps.some((s) => s.removed && s.requiresRestart);
729
+ // Clear the saved platform record when hooks/MCP config was removed (even if
730
+ // schedule/daemon cleanup also failed — those are independent). Also clear on
731
+ // schedule/daemon-only removal when config files existed on disk at status-check
732
+ // time: absent config files mean hooks were never written (or already manually
733
+ // deleted) and the platform record should be preserved for a re-install attempt.
734
+ // Skip on --linux-cc (sibling wsl-windows-cc record may still be in use).
735
+ const shouldClearPlatform = configStep.removed || (anyRemoved && !anyFailed && hadConfigFiles);
736
+ if (!options.linuxCc && shouldClearPlatform) {
502
737
  clearSavedPlatform();
503
738
  }
504
- print('\nRestart Claude Code for changes to take effect.\n');
505
- const scheduleWasInstalled = getScheduleStatus().installed;
506
- removeSchedule();
507
- if (scheduleWasInstalled)
508
- print('✓ Auto-update schedule removed');
509
- const daemonWasInstalled = getDashboardDaemonStatus().installed;
510
- removeDashboardDaemon();
511
- if (daemonWasInstalled)
512
- print('✓ Background dashboard daemon removed');
739
+ if (requiresRestart) {
740
+ if (anyFailed) {
741
+ print('\nRestart Claude Code to apply hook changes. Uninstall incomplete — see errors above.\n');
742
+ }
743
+ else {
744
+ print('\nRestart Claude Code for changes to take effect.\n');
745
+ }
746
+ }
747
+ else if (anyRemoved) {
748
+ // Schedule/daemon only — launchd unloaded immediately; no restart needed.
749
+ print(anyFailed ? '\nUninstall incomplete — see errors above.\n' : '\nUninstall complete.\n');
750
+ }
751
+ else if (anyFailed) {
752
+ print('\nUninstall incomplete — see errors above.\n');
753
+ }
513
754
  }
514
755
  // ---------------------------------------------------------------------------
515
756
  // Validate handler
@@ -556,8 +797,7 @@ function handleValidate(options) {
556
797
  async function handleDoctor(options) {
557
798
  const { runDiagnostics } = await import('./diagnostics.js');
558
799
  const configPath = options.config ?? resolve(DEFAULT_STORAGE_PATH, 'config.json');
559
- const raw = readJsonFile(configPath);
560
- const storagePath = typeof raw.storagePath === 'string' ? raw.storagePath : undefined;
800
+ const storagePath = process.env.NEW_RELIC_AI_MCP_STORAGE_PATH ?? undefined;
561
801
  print('Running diagnostics...');
562
802
  const checks = await runDiagnostics({ configPath, storagePath });
563
803
  const ICON = { ok: '✓', warn: '⚠', fail: '✗', skip: '-' };
@@ -605,6 +845,8 @@ export function createInstallProgram() {
605
845
  .option('--project', 'Remove from project-level .claude/settings.json instead of user-level')
606
846
  .option('--windows-cc', 'Remove Windows Claude Code hooks only (WSL only)')
607
847
  .option('--linux-cc', 'Remove Linux Claude Code hooks only (WSL only)')
848
+ .option('--daemon', 'Remove only the background dashboard daemon (preserves hooks, MCP config, and session history)')
849
+ .option('--yes', 'Skip the confirmation prompt (useful for scripts and CI)')
608
850
  .action(handleUninstall);
609
851
  program
610
852
  .command('setup')
@@ -615,7 +857,7 @@ export function createInstallProgram() {
615
857
  await runSetupWizard();
616
858
  }
617
859
  catch (err) {
618
- print(`\n✗ Setup failed: ${err instanceof Error ? err.message : String(err)}`);
860
+ print(`\n✗ Setup failed: ${errMsg(err)}`);
619
861
  process.exitCode = 1;
620
862
  }
621
863
  });