node-command 1.2.2 → 2.0.0

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.
@@ -0,0 +1,587 @@
1
+ // nodecommand --diagnostics bundle builder (CLI side).
2
+ //
3
+ // Self-contained by design: this CLI ships as a dependency-free binary, so
4
+ // it cannot import the agent tree or the control plane. The redaction
5
+ // vocabulary below is aligned VERBATIM with the central sanitizer in
6
+ // apps/control-plane/lib/feedback-service.mjs (REDACT_PATTERNS /
7
+ // redactSecrets) and with redactDiagnosticsSecrets in
8
+ // apps/agent/core/diagnostics-ops.mjs; a parity test asserts all three
9
+ // agree on a shared fixture set.
10
+ //
11
+ // Default is dry-run/preview (rule #43): `nodecommand --diagnostics`
12
+ // prints an inventory summary and writes nothing. Add `--apply` to write
13
+ // the `.tar.gz` archive, optionally with `--output <path>`.
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import zlib from 'node:zlib';
19
+ import child_process from 'node:child_process';
20
+ import { candidateInstallRoots, decideInstallMode, getNcRootForMode, probeInstallRoot } from './setup.mjs';
21
+
22
+ export const CLI_DIAGNOSTICS_VERSION = '1.0.0';
23
+ export const CLI_DIAGNOSTICS_MAX_LOG_BYTES = 65536;
24
+ export const CLI_DIAGNOSTICS_MAX_LOG_LINES = 500;
25
+
26
+ // PEM block pattern rebuilt from `-{5}` fragments so no literal
27
+ // five-dash private-key header sits in source (credential scanners flag
28
+ // the header itself). Semantics are identical to the PEM arm of
29
+ // redactSecrets in lib/feedback-service.mjs:61-73 — pasted PEM blocks
30
+ // still match; the shared parity test pins cross-sanitizer equality.
31
+ const PEM_PRIVATE_KEY_BLOCK_RE = new RegExp(
32
+ '-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}[^-]*-{5}END [A-Z ]*PRIVATE KEY-{5}',
33
+ 'g'
34
+ );
35
+
36
+ const REDACT_PATTERNS = [
37
+ /Authorization\s*:\s*Bearer\s+[A-Za-z0-9\-._~+/=]+/gi,
38
+ /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
39
+ /\btoken\s*[:=]\s*['"]?[^'"\s;,}]+['"]?/gi,
40
+ /(access_token|refresh_token|api_key|apikey|client_secret|enrollment_token|private_key)\s*[:=]\s*['"]?[^'"\s,}]+['"]?/gi,
41
+ /(password|passwd|pwd|secret|cookie|set-cookie)\s*[:=]\s*['"]?[^'"\s;,}]+['"]?/gi,
42
+ /nc_(tok|enr|pat|rfk|code|live)_[0-9a-f]{8,}/gi,
43
+ /xox[bap]-[A-Za-z0-9-]+/g,
44
+ /\bsk-(?:proj|ant|live|test)-[A-Za-z0-9_-]+/g,
45
+ /\bsk-[A-Za-z0-9_-]{8,}/g
46
+ ];
47
+
48
+ const SENSITIVE_KEY_RE =
49
+ /(token|secret|passwd|password|cookie|hmac|private[_-]?key|api[_-]?key|auth|turnstile|stripe|session|credential|bearer|signing)/i;
50
+
51
+ /** @param {unknown} value */
52
+ export function redactCliSecrets(value) {
53
+ if (typeof value !== 'string') return value;
54
+ let out = value;
55
+ // PEM blocks are redacted WHOLE, before the shared k=v callback runs:
56
+ // that callback keys off `[:=]`, and real PEMs carry base64 `=` padding,
57
+ // which would otherwise keep the armor header + prefix visible. Same
58
+ // vocabulary as feedback-service.mjs (same shapes flagged), strictly
59
+ // stricter replacement — never looser.
60
+ PEM_PRIVATE_KEY_BLOCK_RE.lastIndex = 0;
61
+ out = out.replace(PEM_PRIVATE_KEY_BLOCK_RE, '[REDACTED]');
62
+ for (const re of REDACT_PATTERNS) {
63
+ re.lastIndex = 0;
64
+ out = out.replace(re, (m) => {
65
+ if (!/[:=]/.test(m)) return '[REDACTED]';
66
+ const key = m.split(/[:=]/, 1)[0];
67
+ return `${key}: [REDACTED]`;
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** @param {Record<string, unknown>} record */
74
+ export function redactCliEnvRecord(record) {
75
+ const out = {};
76
+ for (const [key, raw] of Object.entries(record || {})) {
77
+ const k = String(key);
78
+ if (SENSITIVE_KEY_RE.test(k)) {
79
+ out[k] = '[REDACTED]';
80
+ continue;
81
+ }
82
+ const cleaned = redactCliSecrets(typeof raw === 'string' ? raw : String(raw ?? ''));
83
+ out[k] =
84
+ typeof cleaned === 'string' && cleaned.length > 512
85
+ ? cleaned.slice(0, 512) + '…[truncated]'
86
+ : cleaned;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** @param {unknown} value @param {string} [home] */
92
+ export function foldHomeDir(value, home = '') {
93
+ if (typeof value !== 'string' || !value) return value;
94
+ let h = home;
95
+ if (!h) {
96
+ try {
97
+ h = os.homedir();
98
+ } catch {
99
+ h = '';
100
+ }
101
+ }
102
+ if (h && value.includes(h)) return value.split(h).join('~');
103
+ return value;
104
+ }
105
+
106
+ /** @param {Array<string>} [argv] */
107
+ export function parseCliDiagnosticsArgs(argv = []) {
108
+ const args = Array.isArray(argv) ? argv.map(String) : [];
109
+ let enabled = false;
110
+ let apply = false;
111
+ let output = null;
112
+ for (let i = 0; i < args.length; i++) {
113
+ const a = args[i];
114
+ if (a === '--diagnostics' || a.startsWith('--diagnostics=')) enabled = true;
115
+ if (a === '--apply') apply = true;
116
+ if (a === '--output' && i + 1 < args.length) output = args[++i];
117
+ else if (a.startsWith('--output=')) output = a.slice('--output='.length);
118
+ }
119
+ return { enabled, apply, output };
120
+ }
121
+
122
+ /**
123
+ * Best-effort service status for the platform agent units. Never throws.
124
+ * @param {string} [platform]
125
+ */
126
+ export function probeCliServiceStatus(platform = os.platform()) {
127
+ const checks = [];
128
+ const tryRun = (cmd, args) => {
129
+ try {
130
+ const out = child_process.execFileSync(cmd, args, {
131
+ timeout: 8000,
132
+ stdio: ['ignore', 'pipe', 'pipe']
133
+ });
134
+ return { ok: true, stdout: String(out || '') };
135
+ } catch (err) {
136
+ return { ok: false, stdout: String(err?.stdout || '') };
137
+ }
138
+ };
139
+ if (platform === 'linux') {
140
+ for (const args of [['is-active', 'nc-client.service'], ['--user', 'is-active', 'nc-client.service']]) {
141
+ const r = tryRun('systemctl', args);
142
+ const state = r.stdout.trim().toLowerCase();
143
+ checks.push({
144
+ unit: `systemctl ${args.join(' ')}`,
145
+ state: state === 'active' ? 'active' : r.ok ? state || 'unknown' : 'unknown',
146
+ detail: r.ok ? null : 'probe failed or unit absent'
147
+ });
148
+ }
149
+ } else if (platform === 'darwin') {
150
+ const r = tryRun('launchctl', ['print', 'system/com.nc.client']);
151
+ checks.push({
152
+ unit: 'system/com.nc.client',
153
+ state: r.ok ? 'active' : 'unknown',
154
+ detail: r.ok ? null : 'probe failed or daemon absent'
155
+ });
156
+ } else if (platform === 'win32') {
157
+ const r = tryRun('sc.exe', ['query', 'NCClient']);
158
+ checks.push({
159
+ unit: 'NCClient',
160
+ state: /RUNNING/.test(r.stdout) ? 'active' : /1060|FAILED/.test(r.stdout) ? 'absent' : 'unknown',
161
+ detail: r.ok ? null : 'probe failed or service absent'
162
+ });
163
+ } else {
164
+ checks.push({ unit: String(platform), state: 'unknown', detail: 'unsupported platform for service probe' });
165
+ }
166
+ return checks;
167
+ }
168
+
169
+ /**
170
+ * @param {string} file
171
+ * @param {number} [maxBytes]
172
+ */
173
+ export function readCliLogTail(file, maxBytes = CLI_DIAGNOSTICS_MAX_LOG_BYTES) {
174
+ try {
175
+ let text = '';
176
+ let truncated = false;
177
+ try {
178
+ const size = fs.statSync(file).size;
179
+ if (size > maxBytes) {
180
+ const fd = fs.openSync(file, 'r');
181
+ try {
182
+ const start = size - maxBytes;
183
+ const buf = Buffer.alloc(maxBytes);
184
+ const n = fs.readSync(fd, buf, 0, maxBytes, start);
185
+ text = buf.subarray(0, n).toString('utf8');
186
+ const nl = text.indexOf('\n');
187
+ if (nl >= 0) text = text.slice(nl + 1);
188
+ truncated = true;
189
+ } finally {
190
+ fs.closeSync(fd);
191
+ }
192
+ } else {
193
+ text = fs.readFileSync(file, 'utf8');
194
+ }
195
+ } catch (err) {
196
+ const code = err?.code;
197
+ return {
198
+ content: '',
199
+ lines_returned: 0,
200
+ truncated: false,
201
+ missing: true,
202
+ error: code === 'ENOENT' ? 'LOG_SOURCE_NOT_FOUND' : code === 'EACCES' ? 'LOG_PERMISSION_DENIED' : 'LOG_READ_FAILED'
203
+ };
204
+ }
205
+ let rows = text.split(/\r?\n/);
206
+ if (rows.length && rows[rows.length - 1] === '') rows.pop();
207
+ if (rows.length > CLI_DIAGNOSTICS_MAX_LOG_LINES) {
208
+ rows = rows.slice(-CLI_DIAGNOSTICS_MAX_LOG_LINES);
209
+ truncated = true;
210
+ }
211
+ rows = rows.map((line) => redactCliSecrets(line));
212
+ return { content: rows.join('\n'), lines_returned: rows.length, truncated, missing: false };
213
+ } catch {
214
+ return { content: '', lines_returned: 0, truncated: false, missing: true, error: 'LOG_READ_FAILED' };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Collect the CLI-side diagnostics bundle. Every section is guarded so a
220
+ * permission error or missing file degrades to a note, never a crash.
221
+ * @param {object} [options]
222
+ * @param {string} [options.ncRoot]
223
+ * @param {string} [options.platform]
224
+ * @param {string} [options.home]
225
+ * @param {Record<string, string|undefined>} [options.env]
226
+ * @param {string} [options.cliVersion]
227
+ * @param {string} [options.baseUrl]
228
+ */
229
+ export function collectCliDiagnostics({
230
+ ncRoot = null,
231
+ platform = os.platform(),
232
+ home = '',
233
+ env = process.env,
234
+ cliVersion = '0.1.0',
235
+ baseUrl = null
236
+ } = {}) {
237
+ const notes = [];
238
+ const note = (section, error) => {
239
+ notes.push({ section, error: String(error?.message || error || 'unknown').slice(0, 300) });
240
+ };
241
+ let resolvedHome = home;
242
+ try {
243
+ resolvedHome = home || os.homedir();
244
+ } catch {
245
+ resolvedHome = home || '';
246
+ }
247
+ const fold = (v) => {
248
+ if (typeof v === 'string') return foldHomeDir(v, resolvedHome);
249
+ if (Array.isArray(v)) return v.map(fold);
250
+ if (v && typeof v === 'object') {
251
+ const out = {};
252
+ for (const [k, val] of Object.entries(v)) out[k] = fold(val);
253
+ return out;
254
+ }
255
+ return v;
256
+ };
257
+ const root = ncRoot || process.env.NC_ROOT || null;
258
+ const layoutPaths = root
259
+ ? {
260
+ root,
261
+ config: path.join(root, 'config'),
262
+ data: path.join(root, 'data'),
263
+ logs: path.join(root, 'logs'),
264
+ env_file: path.join(root, 'config', 'nc-client.env'),
265
+ device_key: path.join(root, 'config', 'nc-device-key.pem'),
266
+ enroll_state: path.join(root, 'config', 'nc-enroll-state.json'),
267
+ client_log: path.join(root, 'logs', 'client.log')
268
+ }
269
+ : {};
270
+ // -- agent files: presence + size only; key files are never opened --
271
+ const files = {};
272
+ try {
273
+ for (const [label, file] of Object.entries(layoutPaths)) {
274
+ if (label === 'root' || label === 'config' || label === 'data' || label === 'logs') continue;
275
+ try {
276
+ const st = fs.statSync(file);
277
+ files[label] = { present: true, size_bytes: st.size, path: fold(file) };
278
+ } catch (err) {
279
+ files[label] = {
280
+ present: false,
281
+ error: err?.code === 'ENOENT' ? 'not_found' : err?.code === 'EACCES' ? 'permission_denied' : 'stat_failed'
282
+ };
283
+ }
284
+ }
285
+ } catch (err) {
286
+ note('agent.files', err);
287
+ }
288
+ // -- enroll state: phase/host only, never tokens --
289
+ let pairing = null;
290
+ try {
291
+ if (layoutPaths.enroll_state && files.enroll_state?.present) {
292
+ const parsed = JSON.parse(fs.readFileSync(layoutPaths.enroll_state, 'utf8'));
293
+ pairing = {
294
+ phase: typeof parsed?.phase === 'string' ? parsed.phase : null,
295
+ host_configured: Boolean(files.env_file?.present)
296
+ };
297
+ } else {
298
+ pairing = { phase: null, host_configured: Boolean(files.env_file?.present) };
299
+ }
300
+ } catch (err) {
301
+ note('agent.pairing', err);
302
+ pairing = { phase: null, host_configured: Boolean(files.env_file?.present) };
303
+ }
304
+ // -- platform / runtime facts --
305
+ let platformFacts = {};
306
+ try {
307
+ platformFacts = {
308
+ platform,
309
+ arch: os.arch(),
310
+ release: os.release(),
311
+ type: os.type(),
312
+ cpus: os.cpus()?.length || null,
313
+ total_memory_bytes: os.totalmem(),
314
+ free_memory_bytes: os.freemem(),
315
+ uptime_seconds: Math.floor(os.uptime()),
316
+ node_version: process.version,
317
+ hostname: os.hostname()
318
+ };
319
+ } catch (err) {
320
+ note('platform', err);
321
+ }
322
+ // -- service status --
323
+ let service = [];
324
+ try {
325
+ service = probeCliServiceStatus(platform);
326
+ } catch (err) {
327
+ note('service', err);
328
+ service = [{ unit: 'probe', state: 'unknown', detail: 'service probe failed' }];
329
+ }
330
+ // -- bounded, redacted log tails --
331
+ const logs = {};
332
+ try {
333
+ const candidates = [];
334
+ if (layoutPaths.client_log) candidates.push(['client', layoutPaths.client_log]);
335
+ for (const [label, file] of candidates.slice(0, 4)) {
336
+ logs[label] = readCliLogTail(file);
337
+ }
338
+ if (!candidates.length) logs.client = { content: '', lines_returned: 0, truncated: false, missing: true, error: 'no agent root resolved' };
339
+ } catch (err) {
340
+ note('logs', err);
341
+ }
342
+ // -- config keys with secrets stripped --
343
+ let config = {};
344
+ try {
345
+ const live = {};
346
+ for (const [k, v] of Object.entries(env || {})) {
347
+ if (!k.startsWith('NC_')) continue;
348
+ if (Object.keys(live).length >= 100) break;
349
+ live[k] = v;
350
+ }
351
+ let fileKeys = {};
352
+ try {
353
+ if (layoutPaths.env_file && files.env_file?.present) {
354
+ const text = fs.readFileSync(layoutPaths.env_file, 'utf8');
355
+ const parsed = {};
356
+ for (const line of String(text).split(/\r?\n/)) {
357
+ const t = line.trim();
358
+ if (!t || t.startsWith('#')) continue;
359
+ const i = t.indexOf('=');
360
+ if (i > 0) parsed[t.slice(0, i).trim()] = t.slice(i + 1).trim();
361
+ }
362
+ fileKeys = parsed;
363
+ }
364
+ } catch (err) {
365
+ note('config.env_file', err);
366
+ }
367
+ config = { env: redactCliEnvRecord(live), env_file: redactCliEnvRecord(fileKeys) };
368
+ } catch (err) {
369
+ note('config', err);
370
+ }
371
+ // -- connectivity state (endpoints only; reachability is a live probe) --
372
+ let connectivity = { server_url: null, probe: 'not_probed', note: null };
373
+ try {
374
+ const serverUrl = String(
375
+ baseUrl || env?.NC_SERVER_URL || env?.NC_BASE_URL || 'https://agent.nodecommand.app'
376
+ ).replace(/\/$/, '');
377
+ connectivity = { server_url: redactCliSecrets(serverUrl), probe: 'not_probed', note: null };
378
+ } catch (err) {
379
+ note('connectivity', err);
380
+ }
381
+ return fold({
382
+ bundle_format: CLI_DIAGNOSTICS_VERSION,
383
+ generated_at: new Date().toISOString(),
384
+ cli_version: cliVersion,
385
+ agent: { root: root ? fold(root) : null, files, pairing },
386
+ platform: platformFacts,
387
+ service,
388
+ logs,
389
+ config,
390
+ connectivity,
391
+ collection_notes: notes,
392
+ redaction:
393
+ 'values of secret-shaped keys replaced with [REDACTED]; free-text secret patterns redacted per feedback-service vocabulary'
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Minimal ustar writer (regular files only). Mirrors packDiagnosticsTar in
399
+ * apps/agent/core/diagnostics-ops.mjs without importing it.
400
+ * @param {Record<string, string>} files
401
+ */
402
+ export function packCliTar(files) {
403
+ const blocks = [];
404
+ for (const [name, content] of Object.entries(files || {})) {
405
+ const safeName = String(name).replace(/^\/+/, '').slice(0, 100);
406
+ if (!safeName) continue;
407
+ const data = Buffer.from(String(content ?? ''), 'utf8');
408
+ const header = Buffer.alloc(512, 0);
409
+ header.write(safeName, 0, 'utf8');
410
+ header.write('0000600', 100, 'utf8');
411
+ header.write('0000000', 108, 'utf8');
412
+ header.write('0000000', 116, 'utf8');
413
+ header.write(data.length.toString(8).padStart(11, '0'), 124, 'utf8');
414
+ header.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, '0'), 136, 'utf8');
415
+ header[156] = 0x30;
416
+ header.write('ustar', 257, 'utf8');
417
+ header.write('00', 263, 'utf8');
418
+ header.write('nc', 265, 'utf8');
419
+ header.write('nc', 297, 'utf8');
420
+ let sum = 0;
421
+ for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
422
+ header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'utf8');
423
+ blocks.push(header);
424
+ if (data.length) {
425
+ blocks.push(data);
426
+ const pad = (512 - (data.length % 512)) % 512;
427
+ if (pad) blocks.push(Buffer.alloc(pad, 0));
428
+ }
429
+ }
430
+ blocks.push(Buffer.alloc(1024, 0));
431
+ return Buffer.concat(blocks);
432
+ }
433
+
434
+ /** @param {Record<string, string>} files */
435
+ export function packCliTarGz(files) {
436
+ return zlib.gzipSync(packCliTar(files));
437
+ }
438
+
439
+ /**
440
+ * @param {Record<string, unknown>} bundle
441
+ * @param {{ wrote?: string|null, dryRun?: boolean }} [options]
442
+ */
443
+ export function renderCliSummary(bundle, { wrote = null, dryRun = true } = {}) {
444
+ const b = bundle || {};
445
+ const logLines = [];
446
+ for (const [label, tail] of Object.entries(b.logs || {})) {
447
+ logLines.push(
448
+ `${label}: ${tail?.missing ? `unavailable (${tail?.error || 'missing'})` : `${tail?.lines_returned ?? 0} lines${tail?.truncated ? ' (truncated)' : ''}`}`
449
+ );
450
+ }
451
+ const envKeys = b.config?.env ? Object.keys(b.config.env).length : 0;
452
+ const fileKeys = b.config?.env_file ? Object.keys(b.config.env_file).length : 0;
453
+ const notes = Array.isArray(b.collection_notes) ? b.collection_notes : [];
454
+ const service = Array.isArray(b.service)
455
+ ? b.service.map((s) => `${s?.unit}=${s?.state}`).join(', ')
456
+ : 'unknown';
457
+ return [
458
+ 'Node Command diagnostics bundle' + (dryRun && !wrote ? ' — preview (dry-run, nothing written)' : ''),
459
+ `cli: ${b.cli_version || 'unknown'}; agent root: ${b.agent?.root || 'not resolved (set NC_ROOT)'}`,
460
+ `platform: ${b.platform?.type || process.platform} ${b.platform?.arch || ''} node ${b.platform?.node_version || process.version}`.trim(),
461
+ `service: ${service}`,
462
+ `logs: ${logLines.length ? logLines.join('; ') : 'none'}`,
463
+ `config: ${envKeys} live NC_* keys + ${fileKeys} env-file keys (secret values replaced with [REDACTED])`,
464
+ `pairing: ${b.agent?.pairing?.phase || 'no state'}; host configured: ${b.agent?.pairing?.host_configured ? 'yes' : 'no'}`,
465
+ `connectivity: ${b.connectivity?.server_url || 'unknown'} (${b.connectivity?.probe || 'not_probed'})`,
466
+ 'redaction: tokens (nc_tok_*/nc_enr_*/nc_pat_*/nc_rfk_*), HMAC keys, client secrets, Turnstile secrets, cookies, private keys → [REDACTED]; home dir folded to ~',
467
+ notes.length
468
+ ? `partial failures (${notes.length}): ${notes.map((n) => `${n?.section}: ${n?.error}`).join('; ')}`
469
+ : 'partial failures: none — all sections collected',
470
+ 'next: attach the redacted summary above to your nc_feedback report (include_diagnostics: true adds the server-side event window).',
471
+ wrote
472
+ ? `wrote: ${wrote} — attach this file to your support ticket`
473
+ : 'not written: re-run with --diagnostics --apply [--output <path>] to write the .tar.gz archive'
474
+ ].join('\n');
475
+ }
476
+
477
+ /**
478
+ * Run `nodecommand --diagnostics`. Never throws: failures resolve to
479
+ * `{ ok: false }` with a user-facing summary (no stack traces).
480
+ * @param {Array<string>} [argv]
481
+ * @param {object} [options]
482
+ * @param {string} [options.ncRoot]
483
+ * @param {Record<string, string|undefined>} [options.env]
484
+ * @param {string} [options.cliVersion]
485
+ * @param {string} [options.baseUrl]
486
+ * @param {(file: string, data: Uint8Array) => void} [options.writeFile]
487
+ * @param {string} [options.cwd]
488
+ */
489
+ /**
490
+ * Default agent root for diagnostics. Explicit roots win; otherwise the
491
+ * shared installer layout table is probed so machine-wide installs
492
+ * (`/opt/nc`, `%ProgramData%\\NC`) resolve instead of silently falling back
493
+ * to the per-user tree the agent never reads. `mode` overrides the
494
+ * privilege probe (deterministic tests); callers without one get the live
495
+ * `decideInstallMode()` fallback for the nothing-installed case.
496
+ * @param {{ env?: Record<string, string|undefined>, platform?: string, homedir?: string, mode?: 'system'|'user' }} [options]
497
+ */
498
+ export function resolveDefaultCliRoot({ env = process.env, platform = os.platform(), homedir = undefined, mode = undefined } = {}) {
499
+ const e = env || {};
500
+ if (e.NC_ROOT) return e.NC_ROOT;
501
+ const base = { env: e, platform };
502
+ if (homedir !== undefined) base.homedir = homedir;
503
+ let roots = [];
504
+ try {
505
+ roots = candidateInstallRoots(base);
506
+ } catch {
507
+ roots = [];
508
+ }
509
+ for (const r of roots) {
510
+ let status = '';
511
+ try {
512
+ status = probeInstallRoot(r);
513
+ } catch {
514
+ status = '';
515
+ }
516
+ if (status === 'install' || status === 'unreadable') return r;
517
+ }
518
+ try {
519
+ const fallbackMode = mode === 'system' || mode === 'user' ? mode : decideInstallMode({ platform });
520
+ return getNcRootForMode(fallbackMode, base);
521
+ } catch {
522
+ return roots.length ? roots[roots.length - 1] : null;
523
+ }
524
+ }
525
+
526
+ export function runCliDiagnostics(argv = [], { ncRoot = null, env = process.env, cliVersion = '0.1.0', baseUrl = null, writeFile = null, cwd = null } = {}) {
527
+ const parsed = parseCliDiagnosticsArgs(argv);
528
+ if (!parsed.enabled) return { handled: false, ok: false, summary: '', outputPath: null };
529
+ try {
530
+ const root = ncRoot || resolveDefaultCliRoot({ env });
531
+ const bundle = collectCliDiagnostics({ ncRoot: root, env, cliVersion, baseUrl });
532
+ if (!parsed.apply) {
533
+ return { handled: true, ok: true, dryRun: true, outputPath: null, bundle, summary: renderCliSummary(bundle, { dryRun: true }) };
534
+ }
535
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
536
+ let outPath = parsed.output || path.join(cwd || process.cwd(), `nodecommand-diagnostics-${stamp}.tar.gz`);
537
+ if (!outPath.endsWith('.tar.gz')) outPath += '.tar.gz';
538
+ const files = {
539
+ 'manifest.json': JSON.stringify(bundle, null, 2) + '\n',
540
+ 'summary.txt': renderCliSummary(bundle, { dryRun: false, wrote: outPath }) + '\n',
541
+ 'config-redacted.json': JSON.stringify(bundle.config || {}, null, 2) + '\n',
542
+ 'platform.json': JSON.stringify(bundle.platform || {}, null, 2) + '\n'
543
+ };
544
+ for (const [label, tail] of Object.entries(bundle.logs || {})) {
545
+ files[`logs/${label}.log`] = String(tail?.content || '');
546
+ }
547
+ const archive = packCliTarGz(files);
548
+ const writer =
549
+ writeFile ||
550
+ ((f, d) => {
551
+ fs.mkdirSync(path.dirname(f), { recursive: true });
552
+ fs.writeFileSync(f, d, { mode: 0o600 });
553
+ });
554
+ try {
555
+ writer(outPath, archive);
556
+ } catch (err) {
557
+ const hint =
558
+ err?.code === 'EACCES'
559
+ ? 'Suggested action: choose a writable --output path or re-run with permission to write there.'
560
+ : err?.code === 'ENOSPC'
561
+ ? 'Suggested action: free disk space and re-run.'
562
+ : 'Suggested action: choose a different --output path and re-run.';
563
+ return {
564
+ handled: true,
565
+ ok: false,
566
+ outputPath: null,
567
+ bundle,
568
+ summary: `diagnostics bundle collected but the archive could not be written to ${outPath} (${err?.code || err?.message || err}). ${hint}`
569
+ };
570
+ }
571
+ return {
572
+ handled: true,
573
+ ok: true,
574
+ dryRun: false,
575
+ outputPath: outPath,
576
+ bundle,
577
+ summary: renderCliSummary(bundle, { dryRun: false, wrote: outPath })
578
+ };
579
+ } catch (err) {
580
+ return {
581
+ handled: true,
582
+ ok: false,
583
+ outputPath: null,
584
+ summary: `diagnostics collection failed (${err?.message || err}). Suggested action: re-run with --diagnostics to preview; if the failure repeats, report the error text (no secrets are included) via nc_feedback.`
585
+ };
586
+ }
587
+ }