node-command 2.0.1 → 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 +136 -13
- package/package.json +3 -3
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)',
|
|
@@ -1404,8 +1406,8 @@ function printCliHelp() {
|
|
|
1404
1406
|
|
|
1405
1407
|
/**
|
|
1406
1408
|
* True when the invocation is exactly the bare enrollment form
|
|
1407
|
-
* `nodecommand <host_id> <token>` — the
|
|
1408
|
-
*
|
|
1409
|
+
* `nodecommand <host_id> <token>` — the legacy positional shape accepted for
|
|
1410
|
+
* backward compatibility with already-published dashboards and docs.
|
|
1409
1411
|
*
|
|
1410
1412
|
* Exactly two non-flag arguments with plausible shapes route to the same path
|
|
1411
1413
|
* as `setup <host_id> <token>`. The guards stay deliberately loose (host id
|
|
@@ -1425,6 +1427,25 @@ export function isBareEnrollmentInvocation(args = []) {
|
|
|
1425
1427
|
return hostId.length >= 3 && token.length >= 8;
|
|
1426
1428
|
}
|
|
1427
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
|
+
|
|
1428
1449
|
async function main() {
|
|
1429
1450
|
const cliArgs = process.argv.slice(2);
|
|
1430
1451
|
// Subcommands route FIRST: otherwise `setup --diagnostics` or a stray flag
|
|
@@ -1462,13 +1483,21 @@ async function main() {
|
|
|
1462
1483
|
if (!result.ok) process.exit(1);
|
|
1463
1484
|
return;
|
|
1464
1485
|
}
|
|
1465
|
-
// Bare enrollment: route `nodecommand <host_id> <token>` (
|
|
1466
|
-
// dashboard and install_command emit) to the `setup
|
|
1467
|
-
// path instead of silently ignoring the arguments in the
|
|
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.
|
|
1468
1490
|
if (isBareEnrollmentInvocation(cliArgs)) {
|
|
1469
1491
|
await runSetup(cliArgs);
|
|
1470
1492
|
return;
|
|
1471
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
|
+
}
|
|
1472
1501
|
await runDeviceFlow();
|
|
1473
1502
|
}
|
|
1474
1503
|
|
|
@@ -1495,6 +1524,8 @@ export function suggestedActionFor(code) {
|
|
|
1495
1524
|
case 'INVALID_ENROLL_TOKEN':
|
|
1496
1525
|
case 'INVALID_HOST_ID':
|
|
1497
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.';
|
|
1498
1529
|
case 'INSTALL_MANIFEST_ERROR':
|
|
1499
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.';
|
|
1500
1531
|
case 'INSTALL_DOWNLOAD_ERROR':
|
|
@@ -1612,21 +1643,25 @@ function requestWindowsElevation() {
|
|
|
1612
1643
|
* requests UAC elevation instead and hard-stops (exit 1) when elevation is
|
|
1613
1644
|
* unavailable or declined — never a silent %LOCALAPPDATA% degradation.
|
|
1614
1645
|
* Without arguments a fresh enrollment is requested via the browser device
|
|
1615
|
-
* flow; with `host_id` + `token` (dashboard flow) the supplied
|
|
1616
|
-
* 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
|
|
1617
1651
|
* OAuth user grant — the same tested chain as the default pairing flow — not
|
|
1618
1652
|
* through the dashboard shell snippets, because this CLI already embeds
|
|
1619
1653
|
* download, install, and device-auth verification.
|
|
1620
1654
|
* @param {string[]} args
|
|
1655
|
+
* @param {{ allowHostIdOnly?: boolean, promptToken?: (io: { input: import('node:stream').Readable, output: import('node:stream').Writable }) => Promise<string> }} [options]
|
|
1621
1656
|
*/
|
|
1622
|
-
export async function runSetup(args = []) {
|
|
1657
|
+
export async function runSetup(args = [], { allowHostIdOnly = false, promptToken = promptEnrollmentToken } = {}) {
|
|
1623
1658
|
const positionals = args.filter((a) => !a.startsWith('-'));
|
|
1624
1659
|
if (positionals.includes('help') || args.includes('--help') || args.includes('-h')) {
|
|
1625
1660
|
console.log(
|
|
1626
1661
|
[
|
|
1627
1662
|
'Usage:',
|
|
1628
1663
|
' nodecommand setup install via the browser device flow (auto system/user mode)',
|
|
1629
|
-
' 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)',
|
|
1630
1665
|
'',
|
|
1631
1666
|
'The install mode is chosen automatically: administrator/root installs',
|
|
1632
1667
|
'machine-wide (system), anything else installs per-user (user).'
|
|
@@ -1637,7 +1672,10 @@ export async function runSetup(args = []) {
|
|
|
1637
1672
|
assertKnownFlags(args, ['--help', '-h'], 'setup');
|
|
1638
1673
|
// Arity is validated BEFORE any mode/root output: an invalid invocation
|
|
1639
1674
|
// must fail without printing an installation plan it will not execute.
|
|
1640
|
-
|
|
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) {
|
|
1641
1679
|
console.error('error: [INVALID_SETUP_ARGS] setup takes either no arguments or `<host_id> <token>`.');
|
|
1642
1680
|
console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
|
|
1643
1681
|
process.exit(1);
|
|
@@ -1676,6 +1714,19 @@ export async function runSetup(args = []) {
|
|
|
1676
1714
|
const warning = modeWarningFor(installMode);
|
|
1677
1715
|
if (warning) console.warn(warning);
|
|
1678
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
|
+
|
|
1679
1730
|
if (positionals.length === 2) {
|
|
1680
1731
|
const [hostId, enrollmentToken] = positionals;
|
|
1681
1732
|
try {
|
|
@@ -1696,6 +1747,75 @@ export async function runSetup(args = []) {
|
|
|
1696
1747
|
await runDeviceFlow({ installMode, ncRoot });
|
|
1697
1748
|
}
|
|
1698
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
|
+
|
|
1699
1819
|
/**
|
|
1700
1820
|
* Asks a yes/no question on the terminal. Resolves false on EOF/empty. The
|
|
1701
1821
|
* first resolution wins: readline emits 'close' synchronously inside
|
|
@@ -1903,7 +2023,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1903
2023
|
verification_uri_complete,
|
|
1904
2024
|
verification_uri,
|
|
1905
2025
|
interval = 3,
|
|
1906
|
-
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
|
|
1907
2029
|
} = initRes.data;
|
|
1908
2030
|
|
|
1909
2031
|
const bold = '\x1b[1m';
|
|
@@ -1937,8 +2059,9 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
1937
2059
|
let isPolling = false;
|
|
1938
2060
|
let currentIntervalMs = pollIntervalMs;
|
|
1939
2061
|
// Poll bounds (RFC 8628 §3.5): the device_code lives ~expires_in seconds
|
|
1940
|
-
// server-side, and consecutive network failures must
|
|
1941
|
-
|
|
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;
|
|
1942
2065
|
const flowDeadline = Date.now() + lifetimeSec * 1000;
|
|
1943
2066
|
let consecutiveNetErrors = 0;
|
|
1944
2067
|
const MAX_NET_ERROR_RETRIES = 10;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-command",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Node Command agent CLI
|
|
3
|
+
"version": "2.0.2",
|
|
4
|
+
"description": "Node Command agent CLI — connect this machine to your AI assistant fleet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"bin": {
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
"bugs": {
|
|
35
35
|
"url": "https://github.com/huseyincig/NodeCommand/issues"
|
|
36
36
|
},
|
|
37
|
-
"author": "
|
|
37
|
+
"author": "Hüseyin Hadi Çığ <huseyincig@users.noreply.github.com>"
|
|
38
38
|
}
|