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
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
|
+
}
|