node-command 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nc.mjs +161 -8
- package/package.json +1 -1
package/bin/nc.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import child_process from 'node:child_process';
|
|
10
10
|
import readline from 'node:readline';
|
|
11
|
+
import { Writable } from 'node:stream';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
import {
|
|
13
14
|
canElevate,
|
|
@@ -1378,6 +1379,7 @@ function printCliHelp() {
|
|
|
1378
1379
|
'',
|
|
1379
1380
|
'Usage:',
|
|
1380
1381
|
' nodecommand pair this machine via the browser device flow',
|
|
1382
|
+
' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
|
|
1381
1383
|
' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
|
|
1382
1384
|
' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
|
|
1383
1385
|
' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
|
|
@@ -1402,6 +1404,48 @@ function printCliHelp() {
|
|
|
1402
1404
|
);
|
|
1403
1405
|
}
|
|
1404
1406
|
|
|
1407
|
+
/**
|
|
1408
|
+
* True when the invocation is exactly the bare enrollment form
|
|
1409
|
+
* `nodecommand <host_id> <token>` — the legacy positional shape accepted for
|
|
1410
|
+
* backward compatibility with already-published dashboards and docs.
|
|
1411
|
+
*
|
|
1412
|
+
* Exactly two non-flag arguments with plausible shapes route to the same path
|
|
1413
|
+
* as `setup <host_id> <token>`. The guards stay deliberately loose (host id
|
|
1414
|
+
* >= 3 chars, token >= 8 chars): both values are re-validated canonically
|
|
1415
|
+
* inside pairDeviceAndInstall (HOST_ID_RE / ENROLL_TOKEN_RE), so a
|
|
1416
|
+
* malformed-but-intended enrollment surfaces the setup error codes
|
|
1417
|
+
* (INVALID_HOST_ID / INVALID_ENROLL_TOKEN) instead of silently starting the
|
|
1418
|
+
* interactive device flow. Zero or one positional argument, or any flag, is
|
|
1419
|
+
* never mapped to setup and keeps its previous behavior.
|
|
1420
|
+
* @param {string[]} args
|
|
1421
|
+
*/
|
|
1422
|
+
export function isBareEnrollmentInvocation(args = []) {
|
|
1423
|
+
if (!Array.isArray(args) || args.length !== 2) return false;
|
|
1424
|
+
const [hostId, token] = args;
|
|
1425
|
+
if (typeof hostId !== 'string' || typeof token !== 'string') return false;
|
|
1426
|
+
if (hostId.startsWith('-') || token.startsWith('-')) return false;
|
|
1427
|
+
return hostId.length >= 3 && token.length >= 8;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* True when the invocation is exactly the token-free enrollment form
|
|
1432
|
+
* `nodecommand <host_id>` — the canonical shape the control-plane
|
|
1433
|
+
* install_command emits. The single-use token is NOT accepted on the command
|
|
1434
|
+
* line for this form: runSetup prompts for it with masked (non-echoing)
|
|
1435
|
+
* input, so it never lands in argv, shell history, `ps`, terminal
|
|
1436
|
+
* recordings, or telemetry.
|
|
1437
|
+
*
|
|
1438
|
+
* Only a canonical host id (`nc_<64hex>`) qualifies: a stray single word
|
|
1439
|
+
* keeps its previous behavior (browser device flow) instead of being
|
|
1440
|
+
* misread as an enrollment.
|
|
1441
|
+
* @param {string[]} args
|
|
1442
|
+
*/
|
|
1443
|
+
export function isBareHostIdInvocation(args = []) {
|
|
1444
|
+
if (!Array.isArray(args) || args.length !== 1) return false;
|
|
1445
|
+
const [hostId] = args;
|
|
1446
|
+
return typeof hostId === 'string' && !hostId.startsWith('-') && HOST_ID_RE.test(hostId);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1405
1449
|
async function main() {
|
|
1406
1450
|
const cliArgs = process.argv.slice(2);
|
|
1407
1451
|
// Subcommands route FIRST: otherwise `setup --diagnostics` or a stray flag
|
|
@@ -1439,6 +1483,21 @@ async function main() {
|
|
|
1439
1483
|
if (!result.ok) process.exit(1);
|
|
1440
1484
|
return;
|
|
1441
1485
|
}
|
|
1486
|
+
// Bare enrollment: route `nodecommand <host_id> <token>` (legacy positional
|
|
1487
|
+
// form the dashboard and install_command used to emit) to the `setup
|
|
1488
|
+
// <host_id> <token>` path instead of silently ignoring the arguments in the
|
|
1489
|
+
// device flow.
|
|
1490
|
+
if (isBareEnrollmentInvocation(cliArgs)) {
|
|
1491
|
+
await runSetup(cliArgs);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
// Canonical token-free form: the single-use token is typed at a masked
|
|
1495
|
+
// interactive prompt inside runSetup, so it never enters argv (shell
|
|
1496
|
+
// history, `ps`, recordings, telemetry).
|
|
1497
|
+
if (isBareHostIdInvocation(cliArgs)) {
|
|
1498
|
+
await runSetup(cliArgs, { allowHostIdOnly: true });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1442
1501
|
await runDeviceFlow();
|
|
1443
1502
|
}
|
|
1444
1503
|
|
|
@@ -1465,6 +1524,8 @@ export function suggestedActionFor(code) {
|
|
|
1465
1524
|
case 'INVALID_ENROLL_TOKEN':
|
|
1466
1525
|
case 'INVALID_HOST_ID':
|
|
1467
1526
|
return 'Re-run `nodecommand setup` without arguments for a fresh enrollment.';
|
|
1527
|
+
case 'ENROLL_TOKEN_REQUIRED':
|
|
1528
|
+
return 'Re-run in an interactive terminal so the token can be typed with hidden input, or pass the token as the second argument.';
|
|
1468
1529
|
case 'INSTALL_MANIFEST_ERROR':
|
|
1469
1530
|
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.';
|
|
1470
1531
|
case 'INSTALL_DOWNLOAD_ERROR':
|
|
@@ -1582,21 +1643,25 @@ function requestWindowsElevation() {
|
|
|
1582
1643
|
* requests UAC elevation instead and hard-stops (exit 1) when elevation is
|
|
1583
1644
|
* unavailable or declined — never a silent %LOCALAPPDATA% degradation.
|
|
1584
1645
|
* Without arguments a fresh enrollment is requested via the browser device
|
|
1585
|
-
* flow; with `host_id` + `token` (dashboard flow) the supplied
|
|
1586
|
-
* redeemed directly
|
|
1646
|
+
* flow; with `host_id` + `token` (legacy dashboard flow) the supplied
|
|
1647
|
+
* enrollment is redeemed directly; with a bare canonical `host_id`
|
|
1648
|
+
* (`allowHostIdOnly`, the canonical install_command form) the single-use
|
|
1649
|
+
* token is read from a masked interactive prompt and never touches argv.
|
|
1650
|
+
* Enrollment always goes through POST /api/enroll with the
|
|
1587
1651
|
* OAuth user grant — the same tested chain as the default pairing flow — not
|
|
1588
1652
|
* through the dashboard shell snippets, because this CLI already embeds
|
|
1589
1653
|
* download, install, and device-auth verification.
|
|
1590
1654
|
* @param {string[]} args
|
|
1655
|
+
* @param {{ allowHostIdOnly?: boolean, promptToken?: (io: { input: import('node:stream').Readable, output: import('node:stream').Writable }) => Promise<string> }} [options]
|
|
1591
1656
|
*/
|
|
1592
|
-
export async function runSetup(args = []) {
|
|
1657
|
+
export async function runSetup(args = [], { allowHostIdOnly = false, promptToken = promptEnrollmentToken } = {}) {
|
|
1593
1658
|
const positionals = args.filter((a) => !a.startsWith('-'));
|
|
1594
1659
|
if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
|
|
1595
1660
|
console.log(
|
|
1596
1661
|
[
|
|
1597
1662
|
'Usage:',
|
|
1598
1663
|
' nodecommand setup install via the browser device flow (auto system/user mode)',
|
|
1599
|
-
' nodecommand setup <host_id> <token> install with a dashboard enrollment token',
|
|
1664
|
+
' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
|
|
1600
1665
|
'',
|
|
1601
1666
|
'The install mode is chosen automatically: administrator/root installs',
|
|
1602
1667
|
'machine-wide (system), anything else installs per-user (user).'
|
|
@@ -1607,7 +1672,10 @@ export async function runSetup(args = []) {
|
|
|
1607
1672
|
assertKnownFlags(args, ['--help', '-h'], 'setup');
|
|
1608
1673
|
// Arity is validated BEFORE any mode/root output: an invalid invocation
|
|
1609
1674
|
// must fail without printing an installation plan it will not execute.
|
|
1610
|
-
|
|
1675
|
+
// One positional (host id) is valid only for the bare `nodecommand
|
|
1676
|
+
// <host_id>` form; `setup <host_id>` stays a loud usage error.
|
|
1677
|
+
const onePositionalOk = allowHostIdOnly && positionals.length === 1;
|
|
1678
|
+
if (positionals.length !== 0 && positionals.length !== 2 && !onePositionalOk) {
|
|
1611
1679
|
console.error('error: [INVALID_SETUP_ARGS] setup takes either no arguments or `<host_id> <token>`.');
|
|
1612
1680
|
console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
|
|
1613
1681
|
process.exit(1);
|
|
@@ -1646,6 +1714,19 @@ export async function runSetup(args = []) {
|
|
|
1646
1714
|
const warning = modeWarningFor(installMode);
|
|
1647
1715
|
if (warning) console.warn(warning);
|
|
1648
1716
|
|
|
1717
|
+
// Canonical token-free form: prompt AFTER elevation so the token is typed
|
|
1718
|
+
// on the elevated terminal and never enters argv (a sudo/UAC re-exec
|
|
1719
|
+
// forwards only the host id). Masked input: the token is not echoed.
|
|
1720
|
+
if (onePositionalOk) {
|
|
1721
|
+
try {
|
|
1722
|
+
const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
|
|
1723
|
+
positionals.push(String(enrollmentToken || ''));
|
|
1724
|
+
} catch (err) {
|
|
1725
|
+
reportSetupError(err);
|
|
1726
|
+
process.exit(1);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1649
1730
|
if (positionals.length === 2) {
|
|
1650
1731
|
const [hostId, enrollmentToken] = positionals;
|
|
1651
1732
|
try {
|
|
@@ -1666,6 +1747,75 @@ export async function runSetup(args = []) {
|
|
|
1666
1747
|
await runDeviceFlow({ installMode, ncRoot });
|
|
1667
1748
|
}
|
|
1668
1749
|
|
|
1750
|
+
/**
|
|
1751
|
+
* Prompt text for the masked enrollment-token prompt. Printed to stderr (the
|
|
1752
|
+
* CLI's human channel) so stdout stays clean for machine payloads.
|
|
1753
|
+
*/
|
|
1754
|
+
export const ENROLLMENT_TOKEN_PROMPT = 'Enrollment token (input hidden): ';
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Reads a single-use enrollment token from an interactive terminal WITHOUT
|
|
1758
|
+
* echoing it. Node-stdlib only, no new deps: readline drives the input (line
|
|
1759
|
+
* editing, paste, Ctrl+C, raw mode) while its echo/cursor frames are written
|
|
1760
|
+
* into a sink stream, so the typed token never reaches the terminal,
|
|
1761
|
+
* scrollback, or a session recording.
|
|
1762
|
+
*
|
|
1763
|
+
* Fails closed (PairingError ENROLL_TOKEN_REQUIRED) instead of hanging when
|
|
1764
|
+
* stdin is not an interactive terminal, on EOF, on Ctrl+C, or on an empty
|
|
1765
|
+
* submission. The caller maps the code to a suggested_action line.
|
|
1766
|
+
* @param {{ input?: import('node:stream').Readable, output?: import('node:stream').Writable }} [streams]
|
|
1767
|
+
* @returns {Promise<string>} the typed token (trimmed)
|
|
1768
|
+
*/
|
|
1769
|
+
export function promptEnrollmentToken({ input = process.stdin, output = process.stderr } = {}) {
|
|
1770
|
+
return new Promise((resolve, reject) => {
|
|
1771
|
+
if (!input || input.isTTY !== true) {
|
|
1772
|
+
reject(
|
|
1773
|
+
new PairingError(
|
|
1774
|
+
'ENROLL_TOKEN_REQUIRED',
|
|
1775
|
+
'No enrollment token was provided and stdin is not an interactive terminal. Re-run in a terminal so the token can be typed with hidden input, or pass the token as the second argument.'
|
|
1776
|
+
)
|
|
1777
|
+
);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
output.write(ENROLLMENT_TOKEN_PROMPT);
|
|
1781
|
+
const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
|
|
1782
|
+
sink.isTTY = true;
|
|
1783
|
+
sink.columns = 80;
|
|
1784
|
+
let settled = false;
|
|
1785
|
+
const rl = readline.createInterface({ input, output: sink, terminal: true });
|
|
1786
|
+
const close = () => {
|
|
1787
|
+
try {
|
|
1788
|
+
rl.close();
|
|
1789
|
+
} catch {}
|
|
1790
|
+
output.write('\n');
|
|
1791
|
+
};
|
|
1792
|
+
const finish = (token) => {
|
|
1793
|
+
if (settled) return;
|
|
1794
|
+
settled = true;
|
|
1795
|
+
close();
|
|
1796
|
+
resolve(token);
|
|
1797
|
+
};
|
|
1798
|
+
const abort = (message) => {
|
|
1799
|
+
if (settled) return;
|
|
1800
|
+
settled = true;
|
|
1801
|
+
close();
|
|
1802
|
+
reject(new PairingError('ENROLL_TOKEN_REQUIRED', message));
|
|
1803
|
+
};
|
|
1804
|
+
rl.on('SIGINT', () => abort('Enrollment token entry was cancelled.'));
|
|
1805
|
+
rl.on('close', () => {
|
|
1806
|
+
if (!settled) abort('No enrollment token was entered (terminal closed). Re-run in a terminal and paste the token from the dashboard.');
|
|
1807
|
+
});
|
|
1808
|
+
rl.question('', (answer) => {
|
|
1809
|
+
const token = String(answer ?? '').trim();
|
|
1810
|
+
if (!token) {
|
|
1811
|
+
abort('No enrollment token was entered. Re-run in a terminal and paste the token shown in the dashboard.');
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
finish(token);
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1669
1819
|
/**
|
|
1670
1820
|
* Asks a yes/no question on the terminal. Resolves false on EOF/empty. The
|
|
1671
1821
|
* first resolution wins: readline emits 'close' synchronously inside
|
|
@@ -1873,7 +2023,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1873
2023
|
verification_uri_complete,
|
|
1874
2024
|
verification_uri,
|
|
1875
2025
|
interval = 3,
|
|
1876
|
-
expires_in
|
|
2026
|
+
// Platform contract: the server always returns expires_in and grants live
|
|
2027
|
+
// 300s. Keep the fallback at the mandated 300s TTL, never the old 600s.
|
|
2028
|
+
expires_in = 300
|
|
1877
2029
|
} = initRes.data;
|
|
1878
2030
|
|
|
1879
2031
|
const bold = '\x1b[1m';
|
|
@@ -1907,8 +2059,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1907
2059
|
let isPolling = false;
|
|
1908
2060
|
let currentIntervalMs = pollIntervalMs;
|
|
1909
2061
|
// Poll bounds (RFC 8628 §3.5): the device_code lives ~expires_in seconds
|
|
1910
|
-
// server-side, and consecutive network failures must
|
|
1911
|
-
|
|
2062
|
+
// server-side (300s mandated TTL), and consecutive network failures must
|
|
2063
|
+
// not spin forever. Fallback mirrors the same 300s contract.
|
|
2064
|
+
const lifetimeSec = Number(expires_in) > 0 ? Number(expires_in) : 300;
|
|
1912
2065
|
const flowDeadline = Date.now() + lifetimeSec * 1000;
|
|
1913
2066
|
let consecutiveNetErrors = 0;
|
|
1914
2067
|
const MAX_NET_ERROR_RETRIES = 10;
|