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