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/bin/nc.mjs ADDED
@@ -0,0 +1,1826 @@
1
+ #!/usr/bin/env node
2
+
3
+ import https from 'node:https';
4
+ import http from 'node:http';
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import child_process from 'node:child_process';
10
+ import readline from 'node:readline';
11
+ import { fileURLToPath } from 'node:url';
12
+ import {
13
+ decideInstallMode,
14
+ findRemovalTargets,
15
+ getNcRootForMode,
16
+ installerEnvForMode,
17
+ modeWarningFor,
18
+ runUninstallerScript,
19
+ uninstallFailureSuggestion,
20
+ uninstallerForRoot
21
+ } from '../lib/setup.mjs';
22
+
23
+ const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
24
+ /**
25
+ * Agent API origin for device-channel endpoints (`/api/clients/*`), package
26
+ * downloads, and `/releases/*`. The auth host (`baseUrl` above) serves only
27
+ * browser-facing OAuth/device flow and enrollment (`/oauth/*`, `/api/enroll*`);
28
+ * Caddy routes `/api/clients*` solely on the agent (and dash) edge, so
29
+ * verification against the auth host answers 404. `NC_SERVER_URL` is NOT
30
+ * this value: it is the agent WS/server address written to the client env.
31
+ */
32
+ export const apiBase = process.env.NC_API_URL || 'https://agent.nodecommand.app';
33
+ const clientId = 'nc-cli';
34
+
35
+ export const DEFAULT_CLI_REQUEST_TIMEOUT_MS = 30_000;
36
+ export const DEFAULT_CLI_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MiB
37
+
38
+ export function getCliRequestTimeoutMs() {
39
+ const envVal = Number(process.env.NC_CLI_TIMEOUT_MS || process.env.NC_TIMEOUT_MS);
40
+ return Number.isFinite(envVal) && envVal > 0 ? envVal : DEFAULT_CLI_REQUEST_TIMEOUT_MS;
41
+ }
42
+
43
+ export function getCliMaxResponseBytes() {
44
+ const envVal = Number(process.env.NC_CLI_MAX_RESPONSE_BYTES);
45
+ return Number.isFinite(envVal) && envVal > 0 ? envVal : DEFAULT_CLI_MAX_RESPONSE_BYTES;
46
+ }
47
+
48
+ /**
49
+ * Sends a JSON HTTP request using node:https or node:http with timeout and response cap guards (NC-AUD-010).
50
+ * @param {string} method
51
+ * @param {string} targetUrl
52
+ * @param {Record<string, unknown>|null} body
53
+ * @param {Record<string, string>} [extraHeaders]
54
+ * @param {{ timeoutMs?: number, maxResponseBytes?: number, signal?: AbortSignal }} [options]
55
+ * @returns {Promise<{ status: number, headers: import('node:http').IncomingHttpHeaders, data: any }>}
56
+ */
57
+ export function requestJson(method, targetUrl, body, extraHeaders = {}, options = {}) {
58
+ const timeoutMs = options.timeoutMs ?? getCliRequestTimeoutMs();
59
+ const maxBytes = options.maxResponseBytes ?? getCliMaxResponseBytes();
60
+
61
+ return new Promise((resolve, reject) => {
62
+ const u = new URL(targetUrl);
63
+ const client = u.protocol === 'http:' ? http : https;
64
+ const payload = body === null || body === undefined ? null : JSON.stringify(body);
65
+ const signal = options.signal ?? (timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined);
66
+
67
+ let settled = false;
68
+ const fail = (err) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ reject(err);
72
+ };
73
+
74
+ const req = client.request(
75
+ u,
76
+ {
77
+ method,
78
+ signal,
79
+ headers: {
80
+ ...(payload !== null
81
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
82
+ : {}),
83
+ Accept: 'application/json',
84
+ 'User-Agent': 'nc-cli/0.1.0',
85
+ ...extraHeaders
86
+ }
87
+ },
88
+ (res) => {
89
+ let totalBytes = 0;
90
+ let raw = '';
91
+ res.setEncoding('utf8');
92
+ res.on('data', (chunk) => {
93
+ if (settled) return;
94
+ totalBytes += Buffer.byteLength(chunk, 'utf8');
95
+ if (totalBytes > maxBytes) {
96
+ const err = new Error(`Response exceeded maximum allowed size of ${maxBytes} bytes`);
97
+ res.destroy(err);
98
+ req.destroy(err);
99
+ fail(err);
100
+ return;
101
+ }
102
+ raw += chunk;
103
+ });
104
+ res.on('end', () => {
105
+ if (settled) return;
106
+ settled = true;
107
+ let data;
108
+ try {
109
+ data = JSON.parse(raw);
110
+ } catch {
111
+ data = { raw };
112
+ }
113
+ resolve({
114
+ status: res.statusCode || 0,
115
+ headers: res.headers,
116
+ data
117
+ });
118
+ });
119
+ res.on('error', fail);
120
+ }
121
+ );
122
+
123
+ req.on('error', fail);
124
+ if (payload !== null) req.write(payload);
125
+ req.end();
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Sends a JSON POST request using node:https or node:http.
131
+ * @param {string} targetUrl
132
+ * @param {Record<string, unknown>} body
133
+ * @param {Record<string, string>} [extraHeaders]
134
+ * @param {{ timeoutMs?: number, maxResponseBytes?: number, signal?: AbortSignal }} [options]
135
+ * @returns {Promise<{ status: number, headers: import('node:http').IncomingHttpHeaders, data: any }>}
136
+ */
137
+ function postJson(targetUrl, body, extraHeaders = {}, options = {}) {
138
+ return requestJson('POST', targetUrl, body, extraHeaders, options);
139
+ }
140
+
141
+ /**
142
+ * Sends a JSON GET request using node:https or node:http.
143
+ * @param {string} targetUrl
144
+ * @param {Record<string, string>} [extraHeaders]
145
+ * @param {{ timeoutMs?: number, maxResponseBytes?: number, signal?: AbortSignal }} [options]
146
+ * @returns {Promise<{ status: number, headers: import('node:http').IncomingHttpHeaders, data: any }>}
147
+ */
148
+ function getJson(targetUrl, extraHeaders = {}, options = {}) {
149
+ return requestJson('GET', targetUrl, null, extraHeaders, options);
150
+ }
151
+
152
+ /**
153
+ * True when two absolute URLs share scheme + host + port (redirect guard).
154
+ * Device credentials are only forwarded on same-origin redirects so a token
155
+ * issued for the agent edge never leaks to a third-party host.
156
+ * @param {string} a
157
+ * @param {string} b
158
+ */
159
+ function isSameOrigin(a, b) {
160
+ try {
161
+ const ua = new URL(String(a));
162
+ const ub = new URL(String(b));
163
+ return ua.protocol === ub.protocol && ua.host === ub.host;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * True for per-client device-authenticated package URLs
171
+ * (`/api/clients/<id>/update/...`: bootstrap, package, windows-exe). These
172
+ * endpoints run the control-plane clientAuth path (NC-Device bearer +
173
+ * Device Proof v1, see apps/agent/core/enroll-bootstrap.mjs and
174
+ * apps/control-plane/server.mjs clientAuth) and answer 401 without them.
175
+ * The public fallback (`/releases/client/<version>/bootstrap.tar.gz`) is
176
+ * anonymous and must NEVER receive device headers.
177
+ * @param {string} targetUrl
178
+ */
179
+ export function isDeviceAuthenticatedPackageUrl(targetUrl) {
180
+ try {
181
+ const u = new URL(String(targetUrl), 'http://nc.invalid');
182
+ return (u.pathname || '').startsWith('/api/clients/');
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Builds the device-identity headers for one package download. Parameters
190
+ * mirror redeemEnrollment exactly: the same createDeviceProofCli with
191
+ * method GET, the full request URL, and the redemption-issued client token
192
+ * as credential (same canonical string the agent updater uses via
193
+ * authenticatedHeaders/deviceAuthHeaders).
194
+ * @param {{ hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject }} device
195
+ * @param {string} absoluteUrl Full request URL the proof is bound to
196
+ */
197
+ export function buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, absoluteUrl) {
198
+ const proof = createDeviceProofCli({
199
+ privateKey,
200
+ hostId,
201
+ method: 'GET',
202
+ url: String(absoluteUrl),
203
+ credential: String(hostToken)
204
+ });
205
+ return {
206
+ Authorization: `NC-Device ${String(hostToken)}`,
207
+ 'NC-Device-Proof': proof
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Performs an HTTP GET request, following redirects up to maxRedirects.
213
+ * Device headers (when supplied) are forwarded only on same-origin
214
+ * redirects; cross-origin redirects drop them so credentials stay scoped
215
+ * to the original edge host.
216
+ * @param {string} targetUrl
217
+ * @param {number} [maxRedirects=5]
218
+ * @param {Record<string, string>} [extraHeaders]
219
+ * @returns {Promise<import('node:http').IncomingMessage>}
220
+ */
221
+ function httpGet(targetUrl, maxRedirects = 5, extraHeaders = {}) {
222
+ if (typeof maxRedirects === 'object' && maxRedirects !== null) {
223
+ extraHeaders = /** @type {Record<string, string>} */ (maxRedirects);
224
+ maxRedirects = 5;
225
+ }
226
+ return new Promise((resolve, reject) => {
227
+ if (maxRedirects < 0) {
228
+ return reject(new Error(`Too many redirects while requesting ${targetUrl}`));
229
+ }
230
+ const u = new URL(targetUrl);
231
+ const client = u.protocol === 'http:' ? http : https;
232
+
233
+ const req = client.get(
234
+ u,
235
+ {
236
+ headers: {
237
+ 'User-Agent': 'nc-cli/0.1.0',
238
+ Accept: '*/*',
239
+ ...extraHeaders
240
+ }
241
+ },
242
+ (res) => {
243
+ if (
244
+ res.statusCode &&
245
+ res.statusCode >= 300 &&
246
+ res.statusCode < 400 &&
247
+ res.headers.location
248
+ ) {
249
+ const redirectUrl = new URL(res.headers.location, targetUrl).toString();
250
+ res.resume();
251
+ const forwarded = isSameOrigin(targetUrl, redirectUrl) ? extraHeaders : {};
252
+ return resolve(httpGet(redirectUrl, maxRedirects - 1, forwarded));
253
+ }
254
+
255
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
256
+ res.resume();
257
+ return reject(new Error(`Request to ${targetUrl} failed with status ${res.statusCode}`));
258
+ }
259
+
260
+ resolve(res);
261
+ }
262
+ );
263
+
264
+ req.on('error', reject);
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Fetches and parses a JSON endpoint. The update manifest (`/api/updates`)
270
+ * is admin-only and never takes device headers, so this stays anonymous;
271
+ * device identity is only attached to per-client package downloads.
272
+ * @param {string} targetUrl
273
+ * @param {Record<string, string>} [extraHeaders]
274
+ * @returns {Promise<any>}
275
+ */
276
+ async function fetchJson(targetUrl, extraHeaders = {}) {
277
+ const res = await httpGet(targetUrl, 5, extraHeaders);
278
+ return new Promise((resolve, reject) => {
279
+ let raw = '';
280
+ res.setEncoding('utf8');
281
+ res.on('data', (chunk) => {
282
+ raw += chunk;
283
+ });
284
+ res.on('end', () => {
285
+ try {
286
+ resolve(JSON.parse(raw));
287
+ } catch (err) {
288
+ reject(new Error(`Failed to parse JSON from ${targetUrl}: ${err.message}`));
289
+ }
290
+ });
291
+ res.on('error', reject);
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Downloads a remote file stream directly to disk.
297
+ * @param {string} targetUrl
298
+ * @param {string} destinationPath
299
+ * @param {Record<string, string>} [extraHeaders] Device headers for `/api/clients/...` package URLs; empty for public `/releases/...` URLs.
300
+ * @returns {Promise<string>}
301
+ */
302
+ async function downloadToFile(targetUrl, destinationPath, extraHeaders = {}) {
303
+ const res = await httpGet(targetUrl, 5, extraHeaders);
304
+ return new Promise((resolve, reject) => {
305
+ const fileStream = fs.createWriteStream(destinationPath);
306
+ let resolved = false;
307
+
308
+ const onError = (err) => {
309
+ if (resolved) return;
310
+ resolved = true;
311
+ fileStream.destroy();
312
+ try {
313
+ fs.unlinkSync(destinationPath);
314
+ } catch {}
315
+ reject(err);
316
+ };
317
+
318
+ res.on('error', onError);
319
+ fileStream.on('error', onError);
320
+
321
+ fileStream.on('finish', () => {
322
+ if (resolved) return;
323
+ resolved = true;
324
+ fileStream.close(() => resolve(destinationPath));
325
+ });
326
+
327
+ res.pipe(fileStream);
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Determines the installation root (NC_ROOT) dynamically based on OS:
333
+ * - Windows: %LOCALAPPDATA%\nc
334
+ * - macOS: ~/Library/Application Support/nc
335
+ * - Linux (root): /opt/nc
336
+ * - Linux (non-root): ~/.local/share/nc
337
+ * @returns {string}
338
+ */
339
+ export function getNcRoot() {
340
+ if (process.env.NC_ROOT) {
341
+ return process.env.NC_ROOT;
342
+ }
343
+ const platform = os.platform();
344
+ if (platform === 'win32') {
345
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
346
+ return path.join(localAppData, 'nc');
347
+ }
348
+ if (platform === 'darwin') {
349
+ return path.join(os.homedir(), 'Library', 'Application Support', 'nc');
350
+ }
351
+ // Linux and other POSIX: root vs non-root
352
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
353
+ if (isRoot) {
354
+ return '/opt/nc';
355
+ }
356
+ return path.join(os.homedir(), '.local', 'share', 'nc');
357
+ }
358
+
359
+ /**
360
+ * Resolves the tar executable command name or path.
361
+ * @returns {string}
362
+ */
363
+ function getTarCommand() {
364
+ if (os.platform() === 'win32') {
365
+ const sysTar = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
366
+ if (fs.existsSync(sysTar)) {
367
+ return sysTar;
368
+ }
369
+ return 'tar.exe';
370
+ }
371
+ return 'tar';
372
+ }
373
+
374
+ /**
375
+ * Extracts a tarball archive to target directory using system tar.
376
+ * @param {string} archivePath
377
+ * @param {string} targetDir
378
+ */
379
+ function extractTarball(archivePath, targetDir) {
380
+ const tarCmd = getTarCommand();
381
+ try {
382
+ child_process.execFileSync(tarCmd, ['-xzf', archivePath, '-C', targetDir], {
383
+ stdio: ['ignore', 'pipe', 'pipe']
384
+ });
385
+ } catch (err) {
386
+ const details = err.stderr ? err.stderr.toString().trim() : err.message;
387
+ throw new Error(`Failed to extract tarball (${tarCmd}): ${details}`, { cause: err });
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Locates the platform installation script inside the extracted tarball.
393
+ * @param {string} tempDir
394
+ * @returns {string}
395
+ */
396
+ export function findInstallerScript(tempDir) {
397
+ const platform = os.platform();
398
+ if (platform === 'win32') {
399
+ const defaultWin = path.join(tempDir, 'install', 'install-windows.ps1');
400
+ if (fs.existsSync(defaultWin)) return defaultWin;
401
+ const fallbackWin = path.join(tempDir, 'install-windows.ps1');
402
+ if (fs.existsSync(fallbackWin)) return fallbackWin;
403
+ return defaultWin;
404
+ }
405
+ if (platform === 'darwin') {
406
+ const darwinScript = path.join(tempDir, 'install', 'install-darwin.sh');
407
+ if (fs.existsSync(darwinScript)) return darwinScript;
408
+ const macosScript = path.join(tempDir, 'install', 'install-macos.sh');
409
+ if (fs.existsSync(macosScript)) return macosScript;
410
+ const fallbackDarwin = path.join(tempDir, 'install-darwin.sh');
411
+ if (fs.existsSync(fallbackDarwin)) return fallbackDarwin;
412
+ const fallbackMacos = path.join(tempDir, 'install-macos.sh');
413
+ if (fs.existsSync(fallbackMacos)) return fallbackMacos;
414
+ return darwinScript;
415
+ }
416
+ // Linux and other POSIX
417
+ const defaultLinux = path.join(tempDir, 'install', 'install-linux.sh');
418
+ if (fs.existsSync(defaultLinux)) return defaultLinux;
419
+ const fallbackLinux = path.join(tempDir, 'install-linux.sh');
420
+ if (fs.existsSync(fallbackLinux)) return fallbackLinux;
421
+ return defaultLinux;
422
+ }
423
+
424
+ /**
425
+ * Executes the OS-specific installer script with NC_ROOT passed in environment.
426
+ * Extra env (e.g. NC_MODE from installerEnvForMode) is merged on top so the
427
+ * installer layout matches the CLI mode decision.
428
+ * @param {string} scriptPath
429
+ * @param {string} ncRoot
430
+ * @param {Record<string, string>} [extraEnv]
431
+ */
432
+ export function runInstallerScript(scriptPath, ncRoot, extraEnv = {}) {
433
+ const platform = os.platform();
434
+ const installEnv = {
435
+ ...process.env,
436
+ NC_ROOT: ncRoot,
437
+ ...extraEnv
438
+ };
439
+
440
+ if (platform === 'win32') {
441
+ child_process.execFileSync(
442
+ 'powershell.exe',
443
+ ['-ExecutionPolicy', 'Bypass', '-File', scriptPath],
444
+ {
445
+ env: installEnv,
446
+ stdio: 'inherit'
447
+ }
448
+ );
449
+ } else if (platform === 'darwin') {
450
+ child_process.execFileSync('bash', [scriptPath], {
451
+ env: installEnv,
452
+ stdio: 'inherit'
453
+ });
454
+ } else {
455
+ // Linux and other POSIX
456
+ child_process.execFileSync('bash', [scriptPath], {
457
+ env: installEnv,
458
+ stdio: 'inherit'
459
+ });
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Zero-touch device pairing library.
465
+ *
466
+ * The OAuth device-code flow yields a USER access token. That token
467
+ * authenticates the user for exactly one privileged call (POST /api/enroll)
468
+ * and must NEVER become agent credentials: it is kept in memory, never
469
+ * written to disk, and never placed in nc-client.env. Enrollment mints a
470
+ * canonical host identity (nc_<64hex>); redemption binds a locally generated
471
+ * Ed25519 device key to it via Device Proof v1 and returns the host token
472
+ * (nc_tok_<64hex>). Only those host credentials are persisted.
473
+ *
474
+ * The Ed25519 / Device Proof v1 construction below mirrors
475
+ * apps/agent/core/device-proof.mjs and apps/control-plane/lib/device-proof.mjs
476
+ * exactly (same canonical string, same header names). It is duplicated rather
477
+ * than imported because this CLI ships as a dependency-free binary; the CLI
478
+ * pairing test verifies every proof it mints against the control-plane
479
+ * verifier so the copies cannot drift silently.
480
+ */
481
+
482
+ /**
483
+ * Pairing failure with a stable machine-readable code. Messages never contain
484
+ * secrets (no OAuth token, host token, enrollment token, or private key).
485
+ */
486
+ export class PairingError extends Error {
487
+ /** @param {string} code @param {string} message */
488
+ constructor(code, message) {
489
+ super(message);
490
+ this.name = 'PairingError';
491
+ this.code = code;
492
+ }
493
+ }
494
+
495
+ export const HOST_ID_RE = /^nc_[0-9a-f]{64}$/;
496
+ export const HOST_TOKEN_RE = /^nc_tok_[0-9a-f]{64}$/;
497
+ export const ENROLL_TOKEN_RE = /^nc_enr_[0-9a-f]{64}$/;
498
+ const DEVICE_PUBKEY_RE = /^[A-Za-z0-9_-]{43}$/;
499
+ const DEVICE_NONCE_RE = /^[0-9a-f]{32}$/;
500
+
501
+ /** @param {number} ms */
502
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
503
+
504
+ /**
505
+ * Maps node platform names to the enrollment API platform contract.
506
+ * @param {string} [platform]
507
+ */
508
+ export function mapEnrollPlatform(platform = os.platform()) {
509
+ if (platform === 'win32') return 'windows';
510
+ if (platform === 'darwin') return 'macos';
511
+ return 'linux';
512
+ }
513
+
514
+ /** @param {string} [ncRoot] */
515
+ export function getEnvFilePath(ncRoot = getNcRoot()) {
516
+ return path.join(ncRoot, 'config', 'nc-client.env');
517
+ }
518
+
519
+ /** @param {string} [ncRoot] */
520
+ export function getDeviceKeyPath(ncRoot = getNcRoot()) {
521
+ return path.join(ncRoot, 'config', 'nc-device-key.pem');
522
+ }
523
+
524
+ /** @param {string} [ncRoot] */
525
+ export function getEnrollStatePath(ncRoot = getNcRoot()) {
526
+ return path.join(ncRoot, 'config', 'nc-enroll-state.json');
527
+ }
528
+
529
+ /**
530
+ * Builds the canonical agent env file content from HOST credentials only.
531
+ * There is deliberately no parameter for the OAuth token: by construction it
532
+ * cannot end up in the file. Host id/token shapes are enforced so a caller
533
+ * mistake (e.g. passing an enrollment token as the client token) fails here
534
+ * instead of producing a broken agent that crash-loops on 401.
535
+ * @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string }} options
536
+ */
537
+ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname() }) {
538
+ if (typeof serverUrl !== 'string' || !serverUrl) {
539
+ throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
540
+ }
541
+ if (!HOST_ID_RE.test(String(hostId || ''))) {
542
+ throw new PairingError(
543
+ 'INVALID_HOST_ID',
544
+ 'Refusing to write agent config: host identity is not a canonical nc_<64hex> id. The OAuth access token must never be used as NC_CLIENT_ID.'
545
+ );
546
+ }
547
+ if (!HOST_TOKEN_RE.test(String(hostToken || ''))) {
548
+ throw new PairingError(
549
+ 'INVALID_HOST_TOKEN',
550
+ 'Refusing to write agent config: host token is not a canonical nc_tok_<64hex> token. The OAuth access token must never be used as NC_CLIENT_TOKEN.'
551
+ );
552
+ }
553
+ return [
554
+ `NC_SERVER_URL=${serverUrl}`,
555
+ `NC_CLIENT_ID=${hostId}`,
556
+ `NC_CLIENT_NAME=${clientName}`,
557
+ `NC_CLIENT_TOKEN=${hostToken}`,
558
+ 'NC_REMOTE_EXEC_ENABLED=true',
559
+ 'NC_SESSION_ENABLED=true'
560
+ ].join('\n') + '\n';
561
+ }
562
+
563
+ /**
564
+ * Writes the env file with owner-only permissions.
565
+ * @param {string} envPath
566
+ * @param {string} content
567
+ */
568
+ export function writeHostEnvFile(envPath, content) {
569
+ fs.mkdirSync(path.dirname(envPath), { recursive: true, mode: 0o750 });
570
+ fs.writeFileSync(envPath, content, { mode: 0o600 });
571
+ if (os.platform() !== 'win32') {
572
+ try {
573
+ fs.chmodSync(envPath, 0o600);
574
+ } catch {}
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Parses a KEY=VALUE env file into a record (no interpolation, no execution).
580
+ * @param {string} text
581
+ */
582
+ export function parseEnvFile(text) {
583
+ const out = {};
584
+ for (const line of String(text || '').split('\n')) {
585
+ const trimmed = line.trim();
586
+ if (!trimmed || trimmed.startsWith('#')) continue;
587
+ const eq = trimmed.indexOf('=');
588
+ if (eq <= 0) continue;
589
+ out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
590
+ }
591
+ return out;
592
+ }
593
+
594
+ /**
595
+ * Reads and validates locally persisted host credentials. Returns null when
596
+ * there is nothing usable (missing file, unparsable, or wrong shapes) so the
597
+ * caller can fall through to a fresh enrollment.
598
+ * @param {string} [envPath]
599
+ */
600
+ export function loadValidHostEnv(envPath = getEnvFilePath()) {
601
+ let text;
602
+ try {
603
+ text = fs.readFileSync(envPath, 'utf8');
604
+ } catch {
605
+ return null;
606
+ }
607
+ const env = parseEnvFile(text);
608
+ if (!HOST_ID_RE.test(String(env.NC_CLIENT_ID || ''))) return null;
609
+ if (!HOST_TOKEN_RE.test(String(env.NC_CLIENT_TOKEN || ''))) return null;
610
+ if (typeof env.NC_SERVER_URL !== 'string' || !env.NC_SERVER_URL) return null;
611
+ return env;
612
+ }
613
+
614
+ /**
615
+ * Resume marker for idempotent re-runs. Holds the host id and lifecycle
616
+ * phase only — never any token or key material (those live in nc-client.env
617
+ * and nc-device-key.pem with 0600).
618
+ *
619
+ * Security tradeoff (documented choice): a re-run after a post-redemption
620
+ * failure resumes from locally persisted, already-issued credentials instead
621
+ * of minting a fresh enrollment. This keeps single-use redemption strict
622
+ * (no re-redemption grace window, no 410 special-casing server-side) and
623
+ * avoids orphaning a quota-counted host on every retry. The cost is that a
624
+ * cloned NC_ROOT directory carries resumable pairing state — acceptable
625
+ * because the same directory already holds the private device key.
626
+ * @param {string} statePath
627
+ * @param {{ host_id: string, phase: string }} state
628
+ */
629
+ export function writePairState(statePath, state) {
630
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o750 });
631
+ const body = JSON.stringify(
632
+ { version: 1, host_id: state.host_id, phase: state.phase, updated_at: new Date().toISOString() },
633
+ null,
634
+ 2
635
+ ) + '\n';
636
+ fs.writeFileSync(statePath, body, { mode: 0o600 });
637
+ if (os.platform() !== 'win32') {
638
+ try {
639
+ fs.chmodSync(statePath, 0o600);
640
+ } catch {}
641
+ }
642
+ }
643
+
644
+ /**
645
+ * @param {string} [statePath]
646
+ * @returns {{ host_id?: string, phase?: string }|null}
647
+ */
648
+ export function readPairState(statePath = getEnrollStatePath()) {
649
+ try {
650
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
651
+ if (!parsed || typeof parsed !== 'object') return null;
652
+ return parsed;
653
+ } catch {
654
+ return null;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * @param {import('node:crypto').KeyObject} privateKey
660
+ */
661
+ function publicRawFromPrivateCli(privateKey) {
662
+ const jwk = crypto.createPublicKey(privateKey).export({ format: 'jwk' });
663
+ const x = String(jwk?.x || '');
664
+ if (jwk?.kty !== 'OKP' || jwk?.crv !== 'Ed25519' || !DEVICE_PUBKEY_RE.test(x)) {
665
+ throw new PairingError('KEY_INVALID', 'Device key is not a valid Ed25519 key.');
666
+ }
667
+ if (Buffer.from(x, 'base64url').length !== 32) {
668
+ throw new PairingError('KEY_INVALID', 'Device key is not a valid Ed25519 key.');
669
+ }
670
+ return x;
671
+ }
672
+
673
+ /** @param {unknown} publicKey */
674
+ export function deviceKeyThumbprintCli(publicKey) {
675
+ if (!DEVICE_PUBKEY_RE.test(String(publicKey || ''))) {
676
+ throw new PairingError('KEY_INVALID', 'Device public key has an unexpected shape.');
677
+ }
678
+ return crypto
679
+ .createHash('sha256')
680
+ .update(Buffer.from(String(publicKey), 'base64url'))
681
+ .digest('base64url');
682
+ }
683
+
684
+ /**
685
+ * Loads an existing Ed25519 device key. Throws KEY_NOT_FOUND when absent
686
+ * (caller generates) and KEY_INVALID when present but unusable — never
687
+ * silently overwrites, so a damaged key is reported, not hidden.
688
+ * @param {string} keyFile
689
+ */
690
+ export function loadDeviceKeyPair(keyFile) {
691
+ let pem;
692
+ try {
693
+ pem = fs.readFileSync(keyFile, 'utf8');
694
+ } catch (err) {
695
+ throw new PairingError(
696
+ 'KEY_NOT_FOUND',
697
+ `Device key not found at ${keyFile}. It will be generated during enrollment.`
698
+ );
699
+ }
700
+ let key;
701
+ try {
702
+ key = crypto.createPrivateKey(pem);
703
+ } catch {
704
+ throw new PairingError(
705
+ 'KEY_INVALID',
706
+ `Device key at ${keyFile} is unreadable. Remove the file to re-enroll, or restore it from backup.`
707
+ );
708
+ }
709
+ if (key.asymmetricKeyType !== 'ed25519') {
710
+ throw new PairingError(
711
+ 'KEY_INVALID',
712
+ `Device key at ${keyFile} is not an Ed25519 key. Remove the file to re-enroll, or restore it from backup.`
713
+ );
714
+ }
715
+ const publicKey = publicRawFromPrivateCli(key);
716
+ return { privateKey: key, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
717
+ }
718
+
719
+ /**
720
+ * Loads the device key, generating and persisting a fresh Ed25519 key when
721
+ * none exists (atomic tmp-write + rename, 0600). Mirrors
722
+ * apps/agent/core/device-proof.mjs ensureDeviceKey.
723
+ * @param {string} keyFile
724
+ */
725
+ export function ensureDeviceKeyPair(keyFile) {
726
+ try {
727
+ return loadDeviceKeyPair(keyFile);
728
+ } catch (err) {
729
+ if (err instanceof PairingError && err.code === 'KEY_NOT_FOUND') {
730
+ try {
731
+ fs.mkdirSync(path.dirname(keyFile), { recursive: true, mode: 0o750 });
732
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
733
+ const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
734
+ const tmp = `${keyFile}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
735
+ fs.writeFileSync(tmp, pem, { mode: 0o600, flag: 'wx' });
736
+ try {
737
+ fs.chmodSync(tmp, 0o600);
738
+ } catch {}
739
+ try {
740
+ fs.renameSync(tmp, keyFile);
741
+ } catch (renameErr) {
742
+ try {
743
+ fs.unlinkSync(tmp);
744
+ } catch {}
745
+ throw renameErr;
746
+ }
747
+ return loadDeviceKeyPair(keyFile);
748
+ } catch (genErr) {
749
+ if (genErr instanceof PairingError) throw genErr;
750
+ throw new PairingError(
751
+ 'KEY_GENERATION_FAILED',
752
+ `Could not generate the device key at ${keyFile}: ${genErr.message}`
753
+ );
754
+ }
755
+ }
756
+ throw err;
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Builds a Device Proof v1 header value binding the given credential to this
762
+ * request. Canonical string is identical to the agent implementation:
763
+ * NC-DEVICE-PROOF-V1 + client_id/method/target/credential_sha256/iat/nonce.
764
+ * @param {{ privateKey: import('node:crypto').KeyObject, hostId: string, method: string, url: string, credential: string }} options
765
+ */
766
+ export function createDeviceProofCli({ privateKey, hostId, method, url, credential }) {
767
+ const id = String(hostId || '');
768
+ const m = String(method || '').toUpperCase();
769
+ if (!HOST_ID_RE.test(id)) throw new PairingError('INVALID_HOST_ID', 'Cannot prove identity for a non-canonical host id.');
770
+ if (!/^[A-Z]+$/.test(m)) throw new PairingError('DEVICE_PROOF_FAILED', `Cannot sign device proof for method "${m}".`);
771
+ let target;
772
+ try {
773
+ const u = /^https?:\/\//i.test(String(url)) ? new URL(String(url)) : new URL(String(url), 'http://nc.invalid');
774
+ if (u.hash) throw new Error('hash');
775
+ target = (u.pathname || '/') + (u.search || '');
776
+ } catch {
777
+ throw new PairingError('DEVICE_PROOF_FAILED', 'Cannot sign device proof for an invalid request target.');
778
+ }
779
+ if (typeof credential !== 'string' || !credential) {
780
+ throw new PairingError('DEVICE_PROOF_FAILED', 'Cannot sign device proof without a credential.');
781
+ }
782
+ const iat = Math.floor(Date.now() / 1000);
783
+ const nonce = crypto.randomBytes(16).toString('hex');
784
+ if (!DEVICE_NONCE_RE.test(nonce)) throw new PairingError('DEVICE_PROOF_FAILED', 'Could not mint device-proof nonce.');
785
+ const credHash = crypto.createHash('sha256').update(credential, 'utf8').digest('hex');
786
+ const canonical = [
787
+ 'NC-DEVICE-PROOF-V1',
788
+ `client_id:${id}`,
789
+ `method:${m}`,
790
+ `target:${target}`,
791
+ `credential_sha256:${credHash}`,
792
+ `iat:${String(iat)}`,
793
+ `nonce:${nonce}`,
794
+ ''
795
+ ].join('\n');
796
+ let signature;
797
+ try {
798
+ signature = crypto.sign(null, Buffer.from(canonical, 'utf8'), privateKey).toString('base64url');
799
+ } catch (err) {
800
+ throw new PairingError('DEVICE_PROOF_FAILED', `Could not sign device proof: ${err.message}`);
801
+ }
802
+ return `v1.${iat}.${nonce}.${signature}`;
803
+ }
804
+
805
+ /**
806
+ * Requests a single-use enrollment with the OAuth USER token as bearer
807
+ * authorization. The token authenticates the user; the response carries a
808
+ * fresh host identity plus a one-time enrollment token.
809
+ * @param {{ baseUrl: string, oauthToken: string, platform?: string, name?: string, tenantId?: string|null }} options
810
+ */
811
+ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux', name = undefined, tenantId = null }) {
812
+ if (!oauthToken || typeof oauthToken !== 'string') {
813
+ throw new PairingError('ENROLL_NO_OAUTH_TOKEN', 'Cannot request enrollment without a sign-in token. Re-run the device flow.');
814
+ }
815
+ const origin = String(baseUrl || '').replace(/\/$/, '');
816
+ const body = { platform, ...(name ? { name } : {}), ...(tenantId ? { tenant_id: tenantId } : {}) };
817
+ let res;
818
+ try {
819
+ res = await postJson(`${origin}/api/enroll`, body, { Authorization: `Bearer ${oauthToken}` });
820
+ } catch (err) {
821
+ throw new PairingError('ENROLL_NETWORK_ERROR', `Could not reach the enrollment endpoint: ${err.message}`);
822
+ }
823
+ if (res.status === 201 && res.data?.host_id && res.data?.enrollment_token) {
824
+ if (!HOST_ID_RE.test(String(res.data.host_id)) || !ENROLL_TOKEN_RE.test(String(res.data.enrollment_token))) {
825
+ throw new PairingError('ENROLL_BAD_RESPONSE', 'Enrollment endpoint returned a malformed identity. Aborting.');
826
+ }
827
+ return res.data;
828
+ }
829
+ const serverError = res.data?.error;
830
+ const serverMessage = typeof res.data?.message === 'string' ? `: ${res.data.message}` : '';
831
+ if (res.status === 401) {
832
+ throw new PairingError('ENROLL_UNAUTHORIZED', 'Sign-in expired or was revoked. Re-run the device flow to sign in again.');
833
+ }
834
+ if (res.status === 403 && serverError === 'TWO_FACTOR_REQUIRED') {
835
+ throw new PairingError('ENROLL_TWO_FACTOR_REQUIRED', 'This account requires a two-factor code to enroll a machine. Approve pairing from the dashboard instead.');
836
+ }
837
+ if (res.status === 403) {
838
+ throw new PairingError('ENROLL_FORBIDDEN', `Enrollment was refused (${serverError || 'forbidden'})${serverMessage}.`);
839
+ }
840
+ if (res.status === 409 && serverError === 'PLAN_HOST_LIMIT_EXCEEDED') {
841
+ throw new PairingError('ENROLL_QUOTA_EXCEEDED', `Plan host quota reached${serverMessage}. Upgrade the workspace plan or remove an old host, then re-run.`);
842
+ }
843
+ if (res.status === 409) {
844
+ throw new PairingError('ENROLL_CONFLICT', `Enrollment was refused (${serverError || 'conflict'})${serverMessage}.`);
845
+ }
846
+ if (res.status === 429) {
847
+ throw new PairingError('ENROLL_RATE_LIMITED', 'Enrollment is rate limited. Wait a minute and re-run.');
848
+ }
849
+ throw new PairingError('ENROLL_REJECTED', `Enrollment was rejected (${serverError || `HTTP ${res.status}`})${serverMessage}.`);
850
+ }
851
+
852
+ /**
853
+ * Redeems a one-time enrollment token with Device Proof v1, binding the
854
+ * local Ed25519 key to the host identity. Returns the redemption payload
855
+ * (client_id, client_token, release). A 410 ENROLLMENT_ALREADY_USED means a
856
+ * previous run already consumed this token — the caller resumes from local
857
+ * credentials instead of retrying redemption.
858
+ * @param {{ baseUrl: string, hostId: string, enrollmentToken: string, keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
859
+ */
860
+ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair }) {
861
+ const origin = String(baseUrl || '').replace(/\/$/, '');
862
+ const redeemPath = '/api/enroll/redeem';
863
+ const proof = createDeviceProofCli({
864
+ privateKey: keyPair.privateKey,
865
+ hostId,
866
+ method: 'POST',
867
+ url: origin + redeemPath,
868
+ credential: enrollmentToken
869
+ });
870
+ let res;
871
+ try {
872
+ res = await postJson(
873
+ origin + redeemPath,
874
+ { host_id: hostId, token: enrollmentToken, device_public_key: keyPair.publicKey },
875
+ { 'NC-Device-Proof': proof }
876
+ );
877
+ } catch (err) {
878
+ throw new PairingError('REDEEM_NETWORK_ERROR', `Could not reach the redemption endpoint: ${err.message}`);
879
+ }
880
+ if (res.status === 200 && res.data) {
881
+ const r = res.data;
882
+ if (r.client_id !== hostId || r.token_type !== 'NC-Device' || r.device_key_thumbprint !== keyPair.thumbprint) {
883
+ throw new PairingError('REDEEM_BINDING_MISMATCH', 'Redemption response is not bound to this device key. Aborting.');
884
+ }
885
+ if (!HOST_TOKEN_RE.test(String(r.client_token || ''))) {
886
+ throw new PairingError('REDEEM_BAD_TOKEN', 'Redemption returned a malformed host token. Aborting.');
887
+ }
888
+ return r;
889
+ }
890
+ const serverError = res.data?.error;
891
+ if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
892
+ throw new PairingError(
893
+ 'ENROLLMENT_ALREADY_USED',
894
+ 'This enrollment was already redeemed (a previous run consumed it). Re-running resumes from the locally stored host credentials.'
895
+ );
896
+ }
897
+ if (res.status === 410) {
898
+ throw new PairingError(
899
+ 'ENROLLMENT_EXPIRED',
900
+ `This enrollment is no longer valid (${serverError || 'expired'}). Re-run to request a fresh one.`
901
+ );
902
+ }
903
+ if (res.status === 401) {
904
+ throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}). Re-run to request a fresh enrollment.`);
905
+ }
906
+ throw new PairingError('REDEEM_REJECTED', `Redemption failed (${serverError || `HTTP ${res.status}`}). Re-run to request a fresh enrollment.`);
907
+ }
908
+
909
+ /**
910
+ * Verifies the redeemed host identity against the server using the device
911
+ * channel (the same authenticateClient + Device Proof path the agent itself
912
+ * uses). Polls until the identity is accepted or the timeout expires.
913
+ * Success here proves the env credentials actually authenticate; only then
914
+ * may the CLI print a success message.
915
+ * Device-channel endpoints live on the agent edge (`apiBase`/`NC_API_URL`),
916
+ * never on the auth host. `baseUrl` stays accepted for backward
917
+ * compatibility but an explicit `apiBase` (or `NC_API_URL`/default agent
918
+ * origin when neither is given) wins.
919
+ * @param {{ baseUrl?: string, apiBase?: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, timeoutMs?: number, intervalMs?: number }} options
920
+ */
921
+ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined, apiBase: apiBaseOpt = undefined, hostId, hostToken, privateKey, timeoutMs = 45000, intervalMs = 2000 }) {
922
+ const origin = String(apiBaseOpt ?? baseUrlOpt ?? process.env.NC_API_URL ?? apiBase).replace(/\/$/, '');
923
+ const target = `/api/clients/${encodeURIComponent(hostId)}/policy`;
924
+ const deadline = Date.now() + Math.max(1000, Number(timeoutMs) || 45000);
925
+ const step = Math.max(250, Math.min(Number(intervalMs) || 2000, deadline - Date.now()));
926
+ let lastError = 'no attempt yet';
927
+ for (;;) {
928
+ const proof = createDeviceProofCli({ privateKey, hostId, method: 'GET', url: origin + target, credential: hostToken });
929
+ try {
930
+ const res = await getJson(origin + target, {
931
+ Authorization: `NC-Device ${hostToken}`,
932
+ 'NC-Device-Proof': proof
933
+ });
934
+ if (res.status === 200 && res.data?.policy) return { ok: true, hostId };
935
+ lastError = res.data?.error || `HTTP ${res.status}`;
936
+ // A missing client row will never appear by waiting: fail fast so the
937
+ // user gets the real cause instead of a timeout.
938
+ if (res.status === 404) {
939
+ throw new PairingError('VERIFY_FAILED', `Host ${hostId} is not registered on the server (${lastError}). Re-run to enroll again.`);
940
+ }
941
+ } catch (err) {
942
+ if (err instanceof PairingError) throw err;
943
+ lastError = err.message;
944
+ }
945
+ if (Date.now() >= deadline) {
946
+ throw new PairingError(
947
+ 'VERIFY_TIMEOUT',
948
+ `Could not confirm host ${hostId} on the server in time (last: ${lastError}). The credentials are stored; check connectivity and re-run to verify.`
949
+ );
950
+ }
951
+ await sleep(Math.min(step, Math.max(250, deadline - Date.now())));
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Downloads, verifies, extracts, and installs the agent package. Writes NO
957
+ * credentials — the env file must already exist before this runs.
958
+ *
959
+ * When `release` (the redemption `release` payload) is supplied, the
960
+ * admin-only GET /api/updates manifest is NEVER fetched: the release already
961
+ * carries concrete per-client bootstrap/package URLs plus integrity pins,
962
+ * and the CLI is not an admin so the manifest would answer 401 anyway.
963
+ * Relative URLs resolve against `serverUrl`/`NC_SERVER_URL`. Without a
964
+ * release the legacy manifest fetch is attempted, and an admin-auth refusal
965
+ * surfaces an explanatory error pointing at enrollment-based setup.
966
+ * The /api/updates endpoint stays admin-only (Caddy and control-plane are
967
+ * intentionally untouched): opening it on the agent edge would only trade a
968
+ * 404 for a 401 while widening the security surface.
969
+ * Device identity (`hostId`/`hostToken`/`privateKey`) is attached ONLY to
970
+ * per-client package URLs (`/api/clients/<id>/update/...`, same
971
+ * Authorization + Device Proof the agent updater sends). The public fallback
972
+ * (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
973
+ * proof binds method GET + the full request URL + the redemption-issued
974
+ * client token, exactly like redeemEnrollment.
975
+ * @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
976
+ */
977
+ export async function downloadAndInstallAgent({ ncRoot = getNcRoot(), updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
978
+ const modeEnv = installMode ? installerEnvForMode(installMode) : {};
979
+ const invokeInstaller =
980
+ runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
981
+ const root = ncRoot || getNcRoot();
982
+ console.log(`📍 Installation root: ${root}`);
983
+
984
+ // Package/release resolution stays on the agent edge: an explicit
985
+ // serverUrl (usually the enrollment server_url) wins, then NC_SERVER_URL,
986
+ // then the agent API origin (NC_API_URL/apiBase). The auth host is never
987
+ // used here. NC_SERVER_URL remains the agent server address and is read
988
+ // before the API origin so existing installs keep resolving as before.
989
+ const defaultBaseUrl = serverUrl || process.env.NC_SERVER_URL || process.env.NC_API_URL || apiBase;
990
+ /** @type {Record<string, any>} */
991
+ let activeRelease;
992
+ if (release && typeof release === 'object') {
993
+ // Redemption release wins: skip the admin-only manifest entirely, so its
994
+ // URL is never logged as a misleading signal.
995
+ activeRelease = /** @type {Record<string, any>} */ (release);
996
+ const releaseVersion = activeRelease.version ? ` ${activeRelease.version}` : '';
997
+ console.log(`📦 Using release${releaseVersion} from enrollment redemption; skipping update manifest.`);
998
+ } else {
999
+ const manifestOrigin = String(process.env.NC_API_URL || apiBase).replace(/\/$/, '');
1000
+ const manifestUrl = updatesUrl || process.env.NC_UPDATES_URL || `${manifestOrigin}/api/updates`;
1001
+ console.log(`📡 Fetching update manifest from ${manifestUrl}...`);
1002
+ let manifestData;
1003
+ try {
1004
+ manifestData = await fetchJson(manifestUrl);
1005
+ } catch (err) {
1006
+ const detail = err instanceof Error ? err.message : String(err);
1007
+ if (/status 401\b/i.test(detail)) {
1008
+ throw new PairingError(
1009
+ 'INSTALL_MANIFEST_ERROR',
1010
+ `Update manifest refused with ADMIN_AUTH_REQUIRED (${detail}). The manifest endpoint is admin-only; run with an enrollment (setup <host_id> <token>) so the release ships with the redemption, then re-run.`
1011
+ );
1012
+ }
1013
+ throw new PairingError('INSTALL_MANIFEST_ERROR', `Could not fetch the agent release manifest: ${detail}`);
1014
+ }
1015
+ if (manifestData && typeof manifestData === 'object' && manifestData.error === 'ADMIN_AUTH_REQUIRED') {
1016
+ throw new PairingError(
1017
+ 'INSTALL_MANIFEST_ERROR',
1018
+ 'Update manifest refused with ADMIN_AUTH_REQUIRED. The manifest endpoint is admin-only; run with an enrollment (setup <host_id> <token>) so the release ships with the redemption, then re-run.'
1019
+ );
1020
+ }
1021
+ activeRelease = manifestData?.release || manifestData || {};
1022
+ }
1023
+ const version = activeRelease.version;
1024
+
1025
+ const usableUrl = (value) =>
1026
+ typeof value === 'string' && value && !value.includes('{client_id}') ? value : null;
1027
+ let tarballUrl =
1028
+ usableUrl(activeRelease.bootstrap_tar_gz_url) ||
1029
+ usableUrl(activeRelease.bootstrap_url) ||
1030
+ usableUrl(activeRelease.download_url) ||
1031
+ usableUrl(activeRelease.url) ||
1032
+ usableUrl(activeRelease.package_url);
1033
+
1034
+ if (tarballUrl) {
1035
+ if (!tarballUrl.startsWith('http://') && !tarballUrl.startsWith('https://')) {
1036
+ tarballUrl = new URL(tarballUrl, defaultBaseUrl).toString();
1037
+ }
1038
+ } else {
1039
+ if (!version) {
1040
+ throw new PairingError('INSTALL_MANIFEST_ERROR', 'Unable to determine agent version from update manifest.');
1041
+ }
1042
+ tarballUrl = `${defaultBaseUrl}/releases/client/${encodeURIComponent(version)}/bootstrap.tar.gz`;
1043
+ }
1044
+
1045
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
1046
+ const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
1047
+
1048
+ const cleanup = () => {
1049
+ try {
1050
+ fs.rmSync(tempDir, { recursive: true, force: true });
1051
+ } catch {}
1052
+ };
1053
+
1054
+ // Identity decision per endpoint: device headers only for the
1055
+ // device-authenticated per-client URLs; public release URLs stay bare so
1056
+ // no credential is ever sent where it is not required (or valid).
1057
+ let downloadHeaders = {};
1058
+ if (isDeviceAuthenticatedPackageUrl(tarballUrl)) {
1059
+ if (typeof hostId === 'string' && typeof hostToken === 'string' && hostId && hostToken && privateKey) {
1060
+ downloadHeaders = buildDeviceDownloadHeaders({ hostId, hostToken, privateKey }, tarballUrl);
1061
+ }
1062
+ }
1063
+
1064
+ try {
1065
+ console.log(`⬇️ Downloading agent package from ${tarballUrl}...`);
1066
+ try {
1067
+ await downloadToFile(tarballUrl, archivePath, downloadHeaders);
1068
+ } catch (err) {
1069
+ throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
1070
+ }
1071
+
1072
+ const expectedHash = activeRelease.bootstrap_sha256 || activeRelease.sha256;
1073
+ const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
1074
+ const expectedSize =
1075
+ expectedSizeRaw === undefined || expectedSizeRaw === null ? null : Number(expectedSizeRaw);
1076
+ const archiveBytes = fs.readFileSync(archivePath);
1077
+ if (expectedSize !== null && Number.isFinite(expectedSize)) {
1078
+ if (archiveBytes.length !== expectedSize) {
1079
+ try {
1080
+ fs.unlinkSync(archivePath);
1081
+ } catch {}
1082
+ throw new PairingError(
1083
+ 'INSTALL_MANIFEST_ERROR',
1084
+ `Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
1085
+ );
1086
+ }
1087
+ }
1088
+ if (expectedHash && /^[a-f0-9]{64}$/i.test(String(expectedHash))) {
1089
+ const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
1090
+ if (actualHash.toLowerCase() !== String(expectedHash).toLowerCase()) {
1091
+ try {
1092
+ fs.unlinkSync(archivePath);
1093
+ } catch {}
1094
+ throw new PairingError(
1095
+ 'INSTALL_MANIFEST_ERROR',
1096
+ `Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
1097
+ );
1098
+ }
1099
+ } else {
1100
+ console.warn('⚠️ Release manifest lacks a valid sha256; skipping archive integrity check.');
1101
+ }
1102
+
1103
+ console.log('📂 Extracting agent archive...');
1104
+ try {
1105
+ extractTarball(archivePath, tempDir);
1106
+ } catch (err) {
1107
+ throw new PairingError('INSTALL_EXTRACT_ERROR', `Could not extract the agent package: ${err.message}`);
1108
+ }
1109
+
1110
+ console.log('🚀 Running installation script...');
1111
+ const scriptPath = findInstallerScript(tempDir);
1112
+ if (!fs.existsSync(scriptPath)) {
1113
+ throw new PairingError('INSTALL_SCRIPT_MISSING', `Installation script not found: ${scriptPath}.`);
1114
+ }
1115
+
1116
+ try {
1117
+ invokeInstaller(scriptPath, root);
1118
+ } catch (err) {
1119
+ if (err instanceof PairingError) throw err;
1120
+ throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${err.message}. Re-running resumes from the stored host credentials.`);
1121
+ }
1122
+ } finally {
1123
+ console.log('🧹 Cleaning up temporary directory...');
1124
+ cleanup();
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Full zero-touch pairing chain: enroll (OAuth user auth) -> device key ->
1130
+ * redeem (Device Proof) -> host env -> install -> device-auth verification.
1131
+ * Prints success ONLY after verification. Re-runs resume from locally stored
1132
+ * host credentials instead of minting a fresh enrollment. When `enrollment`
1133
+ * (host_id + enrollment_token, e.g. from `setup <host_id> <token>`) is given,
1134
+ * the OAuth enrollment step is skipped and the supplied token is redeemed
1135
+ * directly. `installMode` is forwarded to the installer via NC_MODE.
1136
+ * Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
1137
+ * `NC_BASE_URL`); device-channel verification (`/api/clients/*`) uses the
1138
+ * agent origin (`apiBase` / `NC_API_URL`). `NC_SERVER_URL` stays the agent
1139
+ * server address and never selects the verify host.
1140
+ * @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
1141
+ */
1142
+ export async function pairDeviceAndInstall({
1143
+ baseUrl: baseUrlParam = undefined,
1144
+ apiBase: apiBaseParam = undefined,
1145
+ oauthToken = null,
1146
+ enrollment = null,
1147
+ ncRoot = getNcRoot(),
1148
+ serverUrl = undefined,
1149
+ deviceName = os.hostname(),
1150
+ platform = mapEnrollPlatform(),
1151
+ installMode = undefined,
1152
+ verifyTimeoutMs = 45000,
1153
+ verifyIntervalMs = 2000,
1154
+ hooks = {}
1155
+ } = {}) {
1156
+ if (!enrollment && (!oauthToken || typeof oauthToken !== 'string')) {
1157
+ throw new PairingError('ENROLL_NO_OAUTH_TOKEN', 'Cannot pair without a sign-in token. Re-run the device flow.');
1158
+ }
1159
+ if (enrollment) {
1160
+ if (!HOST_ID_RE.test(String(enrollment.host_id || ''))) {
1161
+ throw new PairingError('INVALID_HOST_ID', 'The supplied host id is not a canonical nc_<64hex> id. Re-run setup without arguments for a fresh enrollment.');
1162
+ }
1163
+ if (!ENROLL_TOKEN_RE.test(String(enrollment.enrollment_token || ''))) {
1164
+ throw new PairingError('INVALID_ENROLL_TOKEN', 'The supplied enrollment token is not a canonical nc_enr_<64hex> token. Re-run setup without arguments for a fresh enrollment.');
1165
+ }
1166
+ }
1167
+ const root = ncRoot || getNcRoot();
1168
+ const origin = String(baseUrlParam ?? process.env.NC_BASE_URL ?? baseUrl).replace(/\/$/, '');
1169
+ const apiOrigin = String(apiBaseParam ?? process.env.NC_API_URL ?? apiBase).replace(/\/$/, '');
1170
+ const defaultServerUrl = serverUrl || process.env.NC_SERVER_URL || 'https://agent.nodecommand.app';
1171
+ const envPath = getEnvFilePath(root);
1172
+ const keyPath = getDeviceKeyPath(root);
1173
+ const statePath = getEnrollStatePath(root);
1174
+
1175
+ const installAgent =
1176
+ hooks.installAgent ||
1177
+ (async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
1178
+ await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
1179
+ });
1180
+
1181
+ // Fast path — idempotent resume: usable host credentials already on disk
1182
+ // are verified over the device channel and the installer is resumed. No
1183
+ // new enrollment is minted, so a re-run can never orphan a host or hit
1184
+ // ENROLLMENT_ALREADY_USED for a token it no longer holds.
1185
+ const localEnv = loadValidHostEnv(envPath);
1186
+ if (localEnv) {
1187
+ let localKey = null;
1188
+ try {
1189
+ localKey = loadDeviceKeyPair(keyPath);
1190
+ } catch (err) {
1191
+ if (!(err instanceof PairingError) || err.code !== 'KEY_NOT_FOUND') throw err;
1192
+ }
1193
+ if (localKey) {
1194
+ const prior = readPairState(statePath);
1195
+ try {
1196
+ await verifyHostRegistration({
1197
+ apiBase: apiOrigin,
1198
+ hostId: localEnv.NC_CLIENT_ID,
1199
+ hostToken: localEnv.NC_CLIENT_TOKEN,
1200
+ privateKey: localKey.privateKey,
1201
+ timeoutMs: Math.min(10000, verifyTimeoutMs),
1202
+ intervalMs: verifyIntervalMs
1203
+ });
1204
+ const phase = prior?.phase;
1205
+ if (phase === 'installed' || phase === 'verified') {
1206
+ writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1207
+ return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1208
+ }
1209
+ console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
1210
+ await installAgent({ ncRoot: root, serverUrl: localEnv.NC_SERVER_URL, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
1211
+ writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
1212
+ await verifyHostRegistration({
1213
+ apiBase: apiOrigin,
1214
+ hostId: localEnv.NC_CLIENT_ID,
1215
+ hostToken: localEnv.NC_CLIENT_TOKEN,
1216
+ privateKey: localKey.privateKey,
1217
+ timeoutMs: verifyTimeoutMs,
1218
+ intervalMs: verifyIntervalMs
1219
+ });
1220
+ writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
1221
+ return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
1222
+ } catch (err) {
1223
+ if (err instanceof PairingError && (err.code === 'VERIFY_TIMEOUT' || err.code === 'VERIFY_FAILED')) {
1224
+ // Stored credentials no longer authenticate (rotated, revoked, or
1225
+ // server reset): fall through to a fresh enrollment, which
1226
+ // overwrites the stale files below.
1227
+ console.warn(`⚠️ Stored host credentials no longer verify (${err.code}); requesting a fresh enrollment...`);
1228
+ } else {
1229
+ throw err;
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ // Fresh enrollment with the OAuth USER token (user authorization only),
1236
+ // or the caller-supplied enrollment from `setup <host_id> <token>`.
1237
+ let activeEnrollment = enrollment;
1238
+ if (!activeEnrollment) {
1239
+ console.log('🔐 Requesting enrollment for this machine...');
1240
+ activeEnrollment = await requestEnrollment({ baseUrl: origin, oauthToken, platform, name: deviceName });
1241
+ } else {
1242
+ console.log(`🔐 Using supplied enrollment for host ${activeEnrollment.host_id}...`);
1243
+ }
1244
+ const hostId = activeEnrollment.host_id;
1245
+ const enrollServerUrl = typeof activeEnrollment.server_url === 'string' && activeEnrollment.server_url ? activeEnrollment.server_url : defaultServerUrl;
1246
+ writePairState(statePath, { host_id: hostId, phase: 'enrolled' });
1247
+ console.log(`🆔 Enrollment created for host ${hostId}; generating device key...`);
1248
+
1249
+ const keyPair = ensureDeviceKeyPair(keyPath);
1250
+
1251
+ console.log('🤝 Redeeming enrollment and binding device key...');
1252
+ let hostToken;
1253
+ let agentVersion = null;
1254
+ /** @type {Record<string, unknown>|null} */
1255
+ let redemptionRelease = null;
1256
+ try {
1257
+ const redemption = await redeemEnrollment({
1258
+ baseUrl: origin,
1259
+ hostId,
1260
+ enrollmentToken: activeEnrollment.enrollment_token,
1261
+ keyPair
1262
+ });
1263
+ hostToken = redemption.client_token;
1264
+ if (redemption.release && typeof redemption.release === 'object') {
1265
+ redemptionRelease = /** @type {Record<string, unknown>} */ (redemption.release);
1266
+ }
1267
+ if (redemption.release?.version) {
1268
+ agentVersion = redemption.release.version;
1269
+ console.log(`📦 Release: ${agentVersion}`);
1270
+ }
1271
+ } catch (err) {
1272
+ if (err instanceof PairingError && err.code === 'ENROLLMENT_ALREADY_USED') {
1273
+ // A previous run consumed this token and stored the credentials:
1274
+ // resume from them instead of failing on the single-use rule.
1275
+ const again = loadValidHostEnv(envPath);
1276
+ if (again && again.NC_CLIENT_ID === hostId) {
1277
+ console.log('🔁 Enrollment was already redeemed by an earlier run — resuming from stored credentials...');
1278
+ hostToken = again.NC_CLIENT_TOKEN;
1279
+ } else {
1280
+ throw err;
1281
+ }
1282
+ } else {
1283
+ throw err;
1284
+ }
1285
+ }
1286
+
1287
+ // Host credentials ONLY — the OAuth token is never written here.
1288
+ console.log('📝 Creating configuration file...');
1289
+ writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName }));
1290
+ console.log(`📝 Configuration saved to ${envPath}`);
1291
+ writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
1292
+
1293
+ console.log('🚀 Installing agent...');
1294
+ await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey });
1295
+ writePairState(statePath, { host_id: hostId, phase: 'installed' });
1296
+
1297
+ console.log('🔍 Verifying host registration...');
1298
+ await verifyHostRegistration({
1299
+ apiBase: apiOrigin,
1300
+ hostId,
1301
+ hostToken,
1302
+ privateKey: keyPair.privateKey,
1303
+ timeoutMs: verifyTimeoutMs,
1304
+ intervalMs: verifyIntervalMs
1305
+ });
1306
+ writePairState(statePath, { host_id: hostId, phase: 'verified' });
1307
+ return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion };
1308
+ }
1309
+
1310
+ /**
1311
+ * Prints a styled ASCII box in the terminal.
1312
+ * @param {Array<{ text: string, rawLength?: number }>} lines
1313
+ */
1314
+ function printBox(lines) {
1315
+ const bold = '\x1b[1m';
1316
+ const cyan = '\x1b[36m';
1317
+ const reset = '\x1b[0m';
1318
+
1319
+ const maxLen = lines.reduce((max, line) => {
1320
+ const len = line.rawLength !== undefined ? line.rawLength : line.text.length;
1321
+ return Math.max(max, len);
1322
+ }, 0);
1323
+
1324
+ const innerWidth = Math.max(maxLen + 4, 52);
1325
+ const horizontal = '─'.repeat(innerWidth);
1326
+
1327
+ console.log(`\n${cyan}┌${horizontal}┐${reset}`);
1328
+ for (const line of lines) {
1329
+ const len = line.rawLength !== undefined ? line.rawLength : line.text.length;
1330
+ const padRight = ' '.repeat(Math.max(0, innerWidth - len - 2));
1331
+ console.log(`${cyan}│${reset} ${line.text}${padRight}${cyan}│${reset}`);
1332
+ }
1333
+ console.log(`${cyan}└${horizontal}┘${reset}\n`);
1334
+ }
1335
+
1336
+ function printCliHelp() {
1337
+ console.log(
1338
+ [
1339
+ 'nodecommand (nc) — Node Command device pairing and diagnostics',
1340
+ '',
1341
+ 'Usage:',
1342
+ ' nodecommand pair this machine via the browser device flow',
1343
+ ' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
1344
+ ' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
1345
+ ' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
1346
+ ' nodecommand --diagnostics --apply write the diagnostics bundle (.tar.gz) for a support ticket',
1347
+ ' nodecommand --diagnostics --apply --output <path> write the bundle to a chosen path',
1348
+ ' nodecommand --help show this help',
1349
+ '',
1350
+ 'Environment:',
1351
+ ' NC_BASE_URL auth host for the browser OAuth/device flow and enrollment',
1352
+ ' (/oauth/*, /api/enroll*) (default https://auth.nodecommand.app)',
1353
+ ' NC_API_URL agent API host for device-channel endpoints (/api/clients/*),',
1354
+ ' package downloads and /releases/* (default https://agent.nodecommand.app)',
1355
+ ' NC_SERVER_URL agent server address written to the client env (distinct from NC_API_URL)',
1356
+ '',
1357
+ 'Diagnostics redaction: tokens (nc_tok_*/nc_enr_*/nc_pat_*/nc_rfk_*), HMAC keys,',
1358
+ 'client secrets, cookies and private keys are replaced with [REDACTED]; the',
1359
+ 'device private key is never opened. Attach the bundle to a support ticket',
1360
+ 'alongside an nc_feedback report (include_diagnostics: true adds the',
1361
+ 'server-side event window).'
1362
+ ].join('\n')
1363
+ );
1364
+ }
1365
+
1366
+ async function main() {
1367
+ const cliArgs = process.argv.slice(2);
1368
+ // Subcommands route FIRST: otherwise `setup --diagnostics` or a stray flag
1369
+ // would be swallowed by the global flag handlers instead of the command.
1370
+ const [subcommand, ...subArgs] = cliArgs;
1371
+ if (subcommand === 'setup') {
1372
+ await runSetup(subArgs);
1373
+ return;
1374
+ }
1375
+ if (subcommand === 'remove') {
1376
+ await runRemove(subArgs);
1377
+ return;
1378
+ }
1379
+ if (cliArgs.includes('--help') || cliArgs.includes('-h')) {
1380
+ printCliHelp();
1381
+ return;
1382
+ }
1383
+ if (cliArgs.includes('--diagnostics') || cliArgs.some((a) => a.startsWith('--diagnostics='))) {
1384
+ // Redacted support bundle. Preview by default; only --apply writes.
1385
+ // Failures stay user-facing (no stack traces): the runner resolves
1386
+ // { ok: false } with a suggested_action-style next step.
1387
+ let result;
1388
+ try {
1389
+ const { runCliDiagnostics } = await import('../lib/diagnostics-bundle.mjs');
1390
+ result = runCliDiagnostics(cliArgs, { baseUrl });
1391
+ } catch (err) {
1392
+ console.error(`\n❌ Diagnostics failed to start: ${err.message}. Suggested action: verify the CLI install is intact and re-run.`);
1393
+ process.exit(1);
1394
+ }
1395
+ console.log(result.summary);
1396
+ if (!result.ok) process.exit(1);
1397
+ return;
1398
+ }
1399
+ await runDeviceFlow();
1400
+ }
1401
+
1402
+ /**
1403
+ * Maps a pairing failure code to the next step shown as `suggested_action`.
1404
+ * Every setup/remove failure prints both `error:` and `suggested_action:`
1405
+ * lines so the CLI never exits silently.
1406
+ * @param {string} code
1407
+ */
1408
+ export function suggestedActionFor(code) {
1409
+ switch (code) {
1410
+ case 'ENROLL_NO_OAUTH_TOKEN':
1411
+ return 'Re-run `nodecommand setup` without arguments and approve the browser device flow.';
1412
+ case 'ENROLL_UNAUTHORIZED':
1413
+ return 'Re-run `nodecommand setup` for a fresh sign-in; the previous grant expired or was revoked.';
1414
+ case 'ENROLL_TWO_FACTOR_REQUIRED':
1415
+ return 'Approve pairing from the dashboard, or re-run with a two-factor code.';
1416
+ case 'ENROLL_QUOTA_EXCEEDED':
1417
+ case 'PLAN_HOST_LIMIT_EXCEEDED':
1418
+ return 'Upgrade the workspace plan or remove an old host, then re-run.';
1419
+ case 'ENROLLMENT_ALREADY_USED':
1420
+ return 'Re-run `nodecommand setup` without arguments; it resumes from the stored host credentials.';
1421
+ case 'ENROLLMENT_EXPIRED':
1422
+ case 'INVALID_ENROLL_TOKEN':
1423
+ case 'INVALID_HOST_ID':
1424
+ return 'Re-run `nodecommand setup` without arguments for a fresh enrollment.';
1425
+ case 'INSTALL_MANIFEST_ERROR':
1426
+ return 'The update manifest is admin-only; run with an enrollment (setup <host_id> <token>) so the release ships with the redemption, or check network connectivity to the agent release host and re-run; stored credentials are reused.';
1427
+ case 'INSTALL_DOWNLOAD_ERROR':
1428
+ return 'The agent package endpoint requires the device credential issued at redemption; re-run `nodecommand setup <host_id> <token>` to redeem a fresh enrollment, then re-run; stored credentials are reused.';
1429
+ case 'INSTALL_FAILED':
1430
+ case 'INSTALL_EXTRACT_ERROR':
1431
+ case 'INSTALL_INTEGRITY_ERROR':
1432
+ return 'The agent install did not complete; check network connectivity to the agent release host and re-run; stored credentials are reused.';
1433
+ case 'VERIFY_TIMEOUT':
1434
+ return 'Check connectivity to the server and re-run to verify; credentials are already stored.';
1435
+ default:
1436
+ return 'Re-run the command; use `nodecommand --help` for usage.';
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * Rejects unknown flags before any work happens: a typo like `--ys` must
1442
+ * never be silently treated as "no flags". Always exits with both an
1443
+ * `error:` line and a `suggested_action:` line.
1444
+ * @param {string[]} args
1445
+ * @param {string[]} allowed
1446
+ * @param {string} command
1447
+ */
1448
+ function assertKnownFlags(args, allowed, command) {
1449
+ const unknown = args.filter((a) => a.startsWith('-') && !allowed.includes(a));
1450
+ if (unknown.length === 0) return;
1451
+ console.error(`error: [UNKNOWN_FLAG] \`${command}\` does not recognize ${unknown.join(', ')}.`);
1452
+ console.error(`suggested_action: Allowed flags: ${allowed.join(', ')}. Run \`nodecommand ${command} --help\`.`);
1453
+ process.exit(1);
1454
+ }
1455
+
1456
+ /**
1457
+ * Prints a setup/remove failure with a machine-readable code and a next step.
1458
+ * @param {unknown} err
1459
+ */
1460
+ function reportSetupError(err) {
1461
+ const code = err instanceof PairingError ? err.code : 'UNEXPECTED_ERROR';
1462
+ const message = err instanceof Error ? err.message : String(err);
1463
+ console.error(`error: [${code}] ${message}`);
1464
+ console.error(`suggested_action: ${suggestedActionFor(code)}`);
1465
+ }
1466
+
1467
+ /**
1468
+ * Resolves the CLI version from the sibling package.json (falls back to the
1469
+ * release the User-Agent header already advertises).
1470
+ */
1471
+ function getCliVersion() {
1472
+ try {
1473
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
1474
+ const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1475
+ if (typeof parsed.version === 'string' && parsed.version) return parsed.version;
1476
+ } catch {}
1477
+ return '0.1.0';
1478
+ }
1479
+
1480
+ /**
1481
+ * Prints the one-line install summary required after a successful setup:
1482
+ * host id, install mode, CLI version, and verification status.
1483
+ * @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null }} options
1484
+ */
1485
+ function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null }) {
1486
+ console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
1487
+ if (installMode) console.log(`installed as ${installMode}`);
1488
+ if (version) console.log(`agent version: ${version}`);
1489
+ console.log(`cli version: ${getCliVersion()}`);
1490
+ console.log(`server: ${serverUrl}`);
1491
+ console.log('status: agent credentials verified online');
1492
+ }
1493
+
1494
+ /**
1495
+ * `nodecommand setup [host_id enrollment_token]`.
1496
+ *
1497
+ * Mode is automatic (never prompted): elevated processes install system-wide,
1498
+ * everything else installs per-user with a single-line capability notice.
1499
+ * Without arguments a fresh enrollment is requested via the browser device
1500
+ * flow; with `host_id` + `token` (dashboard flow) the supplied enrollment is
1501
+ * redeemed directly. Enrollment always goes through POST /api/enroll with the
1502
+ * OAuth user grant — the same tested chain as the default pairing flow — not
1503
+ * through the dashboard shell snippets, because this CLI already embeds
1504
+ * download, install, and device-auth verification.
1505
+ * @param {string[]} args
1506
+ */
1507
+ export async function runSetup(args = []) {
1508
+ const positionals = args.filter((a) => !a.startsWith('-'));
1509
+ if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
1510
+ console.log(
1511
+ [
1512
+ 'Usage:',
1513
+ ' nodecommand setup install via the browser device flow (auto system/user mode)',
1514
+ ' nodecommand setup <host_id> <token> install with a dashboard enrollment token',
1515
+ '',
1516
+ 'The install mode is chosen automatically: administrator/root installs',
1517
+ 'machine-wide (system), anything else installs per-user (user).'
1518
+ ].join('\n')
1519
+ );
1520
+ return;
1521
+ }
1522
+ assertKnownFlags(args, ['--help', '-h'], 'setup');
1523
+ // Arity is validated BEFORE any mode/root output: an invalid invocation
1524
+ // must fail without printing an installation plan it will not execute.
1525
+ if (positionals.length !== 0 && positionals.length !== 2) {
1526
+ console.error('error: [INVALID_SETUP_ARGS] setup takes either no arguments or `<host_id> <token>`.');
1527
+ console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
1528
+ process.exit(1);
1529
+ }
1530
+ const installMode = decideInstallMode();
1531
+ // Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
1532
+ // not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
1533
+ // written where the system installer will actually read them.
1534
+ const ncRoot = getNcRootForMode(installMode);
1535
+ console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
1536
+ const warning = modeWarningFor(installMode);
1537
+ if (warning) console.warn(warning);
1538
+
1539
+ if (positionals.length === 2) {
1540
+ const [hostId, enrollmentToken] = positionals;
1541
+ try {
1542
+ const result = await pairDeviceAndInstall({
1543
+ baseUrl,
1544
+ apiBase,
1545
+ enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
1546
+ installMode,
1547
+ ncRoot
1548
+ });
1549
+ printSetupSuccess({ hostId: result.hostId, serverUrl: result.serverUrl, installMode, resumed: result.resumed, version: result.version });
1550
+ } catch (err) {
1551
+ reportSetupError(err);
1552
+ process.exit(1);
1553
+ }
1554
+ return;
1555
+ }
1556
+ await runDeviceFlow({ installMode, ncRoot });
1557
+ }
1558
+
1559
+ /**
1560
+ * Asks a yes/no question on the terminal. Resolves false on EOF/empty. The
1561
+ * first resolution wins: readline emits 'close' synchronously inside
1562
+ * rl.close(), so a naive close-handler would overwrite a real answer.
1563
+ * @param {string} question
1564
+ * @param {() => import('node:readline').Interface} [openInterface]
1565
+ */
1566
+ export function confirmPrompt(question, openInterface = null) {
1567
+ return new Promise((resolve) => {
1568
+ let settled = false;
1569
+ const settle = (value) => {
1570
+ if (settled) return;
1571
+ settled = true;
1572
+ resolve(value);
1573
+ };
1574
+ const open =
1575
+ openInterface || (() => readline.createInterface({ input: process.stdin, output: process.stdout }));
1576
+ const rl = open();
1577
+ rl.on('close', () => settle(false));
1578
+ rl.question(question, (answer) => {
1579
+ const yes = /^y(es)?$/i.test(String(answer || '').trim());
1580
+ // Settle BEFORE close(): readline emits 'close' synchronously from
1581
+ // rl.close(), and the first resolution must be the real answer.
1582
+ settle(yes);
1583
+ try {
1584
+ rl.close();
1585
+ } catch {}
1586
+ });
1587
+ });
1588
+ }
1589
+
1590
+ /**
1591
+ * `nodecommand remove [--yes]`. Idempotent: no install found means success
1592
+ * (exit 0). Otherwise the uninstaller living inside each installed tree is
1593
+ * executed (never a downloaded copy) and its exit status is reported.
1594
+ * @param {string[]} args
1595
+ */
1596
+ export async function runRemove(args = []) {
1597
+ if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
1598
+ printCliHelp();
1599
+ return;
1600
+ }
1601
+ assertKnownFlags(args, ['--yes', '-y', '--help', '-h'], 'remove');
1602
+ const stray = args.filter((a) => !a.startsWith('-') && a !== 'help');
1603
+ if (stray.length > 0) {
1604
+ console.error(`error: [UNKNOWN_ARGUMENT] \`remove\` takes no positional arguments (got: ${stray.join(', ')}).`);
1605
+ console.error('suggested_action: Run `nodecommand remove --yes` (or plain `nodecommand remove` to be asked).');
1606
+ process.exit(1);
1607
+ }
1608
+ const yes = args.includes('--yes') || args.includes('-y');
1609
+ const { install, unreadable } = findRemovalTargets();
1610
+ const targets = [...install, ...unreadable];
1611
+ if (targets.length === 0) {
1612
+ console.log('No Node Command installation found (nothing to remove).');
1613
+ return;
1614
+ }
1615
+ console.log(`Found Node Command installation(s): ${targets.join(', ')}`);
1616
+ if (unreadable.length > 0) {
1617
+ console.log(
1618
+ `Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
1619
+ );
1620
+ }
1621
+ if (!yes) {
1622
+ if (!process.stdin.isTTY) {
1623
+ console.error('error: [CONFIRMATION_REQUIRED] remove needs confirmation in non-interactive shells.');
1624
+ console.error('suggested_action: Re-run as `nodecommand remove --yes`.');
1625
+ process.exit(1);
1626
+ }
1627
+ const confirmed = await confirmPrompt(`Remove ${targets.length === 1 ? 'this installation' : 'these installations'}? [y/N] `);
1628
+ if (!confirmed) {
1629
+ console.log('Aborted (nothing removed).');
1630
+ return;
1631
+ }
1632
+ }
1633
+ let failed = 0;
1634
+ for (const root of install) {
1635
+ const script = uninstallerForRoot(root);
1636
+ if (!fs.existsSync(script)) {
1637
+ console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
1638
+ console.error('suggested_action: Remove the directory manually after stopping the agent service.');
1639
+ failed += 1;
1640
+ continue;
1641
+ }
1642
+ try {
1643
+ runUninstallerScript(script);
1644
+ console.log(`Removed ${root} (exit 0).`);
1645
+ } catch (err) {
1646
+ const rc = typeof err?.status === 'number' ? err.status : 1;
1647
+ console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
1648
+ console.error(uninstallFailureSuggestion());
1649
+ failed += 1;
1650
+ }
1651
+ }
1652
+ // Unreadable (system) roots: the local tree cannot be stat'ed, so the
1653
+ // uninstaller's presence is validated by the elevated run itself — the
1654
+ // in-tree script refuses non-NC directories before deleting anything.
1655
+ for (const root of unreadable) {
1656
+ const script = uninstallerForRoot(root);
1657
+ try {
1658
+ runUninstallerScript(script);
1659
+ console.log(`Removed ${root} (elevated, exit 0).`);
1660
+ } catch (err) {
1661
+ const rc = typeof err?.status === 'number' ? err.status : 1;
1662
+ console.error(`error: [UNINSTALL_FAILED] elevated uninstaller for ${root} exited with status ${rc}: ${err.message}`);
1663
+ console.error(uninstallFailureSuggestion());
1664
+ failed += 1;
1665
+ }
1666
+ }
1667
+ if (failed > 0) process.exit(1);
1668
+ console.log('Remove complete.');
1669
+ }
1670
+
1671
+ /**
1672
+ * Browser device flow + enroll/redeem/install/verify. Extracted verbatim from
1673
+ * the historical bare-invocation path; `setup` reuses it with an explicit
1674
+ * install mode while bare `nodecommand` keeps the previous behavior.
1675
+ * @param {{ installMode?: 'system'|'user' }} [options]
1676
+ */
1677
+ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {}) {
1678
+ const deviceEndpoint = `${baseUrl}/oauth/device/code`;
1679
+ let initRes;
1680
+
1681
+ // RFC 7636 S256 PKCE for the RFC 8628 device flow: the verifier never
1682
+ // leaves this machine except as a code_verifier at token time, so an
1683
+ // eavesdropped device_code alone cannot be exchanged by another party.
1684
+ // 32 random bytes -> 43 base64url chars (RFC 7636 §4.1: 43-128).
1685
+ const deviceCodeVerifier = crypto.randomBytes(32).toString('base64url');
1686
+ const deviceCodeChallenge = crypto
1687
+ .createHash('sha256')
1688
+ .update(deviceCodeVerifier)
1689
+ .digest('base64url');
1690
+
1691
+ try {
1692
+ initRes = await postJson(deviceEndpoint, {
1693
+ client_id: clientId,
1694
+ code_challenge: deviceCodeChallenge,
1695
+ code_challenge_method: 'S256'
1696
+ });
1697
+ } catch (err) {
1698
+ console.error(`error: [DEVICE_FLOW_CONNECT] Could not connect to ${deviceEndpoint}: ${err.message}`);
1699
+ console.error('suggested_action: Check network access to the control plane (or NC_BASE_URL), then re-run.');
1700
+ process.exit(1);
1701
+ }
1702
+
1703
+ if (initRes.status !== 200 || !initRes.data || !initRes.data.device_code) {
1704
+ const err = initRes.data?.error_description || initRes.data?.error || `HTTP ${initRes.status}`;
1705
+ console.error(`error: [DEVICE_FLOW_START] Failed to start device flow: ${err}`);
1706
+ console.error('suggested_action: Re-run the command; if it persists, verify the control plane is reachable and NC_BASE_URL is correct.');
1707
+ process.exit(1);
1708
+ }
1709
+
1710
+ const {
1711
+ device_code,
1712
+ user_code,
1713
+ verification_uri_complete,
1714
+ verification_uri,
1715
+ interval = 3
1716
+ } = initRes.data;
1717
+
1718
+ const bold = '\x1b[1m';
1719
+ const green = '\x1b[32m';
1720
+ const yellow = '\x1b[33m';
1721
+ const cyan = '\x1b[36m';
1722
+ const dim = '\x1b[2m';
1723
+ const reset = '\x1b[0m';
1724
+
1725
+ const completeUrl = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
1726
+
1727
+ printBox([
1728
+ { text: `${bold}Node Command — Device Authorization${reset}`, rawLength: 'Node Command — Device Authorization'.length },
1729
+ { text: '', rawLength: 0 },
1730
+ { text: 'To complete pairing, visit this URL in your browser:', rawLength: 'To complete pairing, visit this URL in your browser:'.length },
1731
+ { text: `${cyan}${bold}${completeUrl}${reset}`, rawLength: completeUrl.length },
1732
+ { text: '', rawLength: 0 },
1733
+ { text: `Confirmation Code: ${yellow}${bold}${user_code}${reset}`, rawLength: `Confirmation Code: ${user_code}`.length },
1734
+ { text: '', rawLength: 0 },
1735
+ { text: `${dim}Waiting for approval in browser...${reset}`, rawLength: 'Waiting for approval in browser...'.length }
1736
+ ]);
1737
+
1738
+ const pollIntervalSeconds = Number(interval) > 0 ? Number(interval) : 3;
1739
+ const pollIntervalMs = pollIntervalSeconds * 1000;
1740
+ const tokenEndpoint = `${baseUrl}/oauth/token`;
1741
+
1742
+ let isPolling = false;
1743
+ let currentIntervalMs = pollIntervalMs;
1744
+
1745
+ const poll = async () => {
1746
+ if (isPolling) return;
1747
+ isPolling = true;
1748
+
1749
+ try {
1750
+ const tokenRes = await postJson(tokenEndpoint, {
1751
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
1752
+ device_code,
1753
+ client_id: clientId,
1754
+ code_verifier: deviceCodeVerifier
1755
+ });
1756
+
1757
+ if (tokenRes.status === 200 && tokenRes.data?.access_token) {
1758
+ // The OAuth token authorizes enrollment ONLY. It stays in memory:
1759
+ // never persisted, never written to the agent env file. Success is
1760
+ // printed only after the host identity verifies over the device
1761
+ // channel inside pairDeviceAndInstall.
1762
+ const oauthToken = tokenRes.data.access_token;
1763
+ console.log('\n🔓 Browser approval received. Enrolling this machine...');
1764
+ try {
1765
+ const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot });
1766
+ if (installMode) {
1767
+ // `setup` reports the structured success block (host, mode,
1768
+ // versions, online status); bare `nodecommand` keeps the legacy
1769
+ // pairing messages below.
1770
+ printSetupSuccess({
1771
+ hostId: result.hostId,
1772
+ serverUrl: result.serverUrl,
1773
+ installMode,
1774
+ resumed: result.resumed,
1775
+ version: result.version
1776
+ });
1777
+ } else if (result.resumed) {
1778
+ console.log(`\n✅ Device pairing resumed and verified for host ${result.hostId}.`);
1779
+ } else {
1780
+ console.log(`\n✅ Device paired and verified successfully! Host: ${result.hostId}`);
1781
+ }
1782
+ if (!installMode) console.log('\n🎉 Node Command agent installed and configured successfully!');
1783
+ process.exit(0);
1784
+ } catch (pairErr) {
1785
+ const code = pairErr instanceof PairingError ? pairErr.code : 'PAIRING_FAILED';
1786
+ console.error(`\nerror: [${code}] Device pairing failed: ${pairErr.message}`);
1787
+ console.error(`suggested_action: ${suggestedActionFor(code)}`);
1788
+ process.exit(1);
1789
+ }
1790
+ }
1791
+
1792
+ if (tokenRes.status === 400 && tokenRes.data?.error === 'authorization_pending') {
1793
+ // Do nothing while pending
1794
+ } else if (tokenRes.status === 400 && tokenRes.data?.error === 'slow_down') {
1795
+ // Slow down request per RFC 8628
1796
+ currentIntervalMs += 5000;
1797
+ } else {
1798
+ // Any fatal error (expired_token, access_denied, invalid_grant, etc.)
1799
+ const errMsg = tokenRes.data?.error_description || tokenRes.data?.error || `HTTP ${tokenRes.status}`;
1800
+ console.error(`\nerror: [DEVICE_FLOW_AUTH_DENIED] Authorization failed: ${errMsg}`);
1801
+ console.error('suggested_action: Re-run the command and approve the prompt in the browser.');
1802
+ process.exit(1);
1803
+ }
1804
+ } catch (pollErr) {
1805
+ // Ephemeral network glitch during poll, continue polling
1806
+ } finally {
1807
+ isPolling = false;
1808
+ setTimeout(poll, currentIntervalMs);
1809
+ }
1810
+ };
1811
+
1812
+ setTimeout(poll, currentIntervalMs);
1813
+ }
1814
+
1815
+ const isMainModule = () => {
1816
+ if (!process.argv[1]) return false;
1817
+ try {
1818
+ return fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
1819
+ } catch {
1820
+ return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1821
+ }
1822
+ };
1823
+
1824
+ if (isMainModule()) {
1825
+ main();
1826
+ }