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.
Files changed (2) hide show
  1. package/bin/nc.mjs +161 -8
  2. 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 enrollment is
1586
- * redeemed directly. Enrollment always goes through POST /api/enroll with the
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
- if (positionals.length !== 0 && positionals.length !== 2) {
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 = 600
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 not spin forever.
1911
- const lifetimeSec = Number(expires_in) > 0 ? Number(expires_in) : 600;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Node Command agent CLI — connect this machine to your AI assistant fleet",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",