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.
Files changed (2) hide show
  1. package/bin/nc.mjs +136 -13
  2. 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 shape emitted by the dashboard and
1408
- * the control-plane install_command (`npx node-command <host_id> <token>`).
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>` (the form the
1466
- // dashboard and install_command emit) to the `setup <host_id> <token>`
1467
- // path instead of silently ignoring the arguments in the device flow.
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 enrollment is
1616
- * 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
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
- 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) {
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 = 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
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 not spin forever.
1941
- 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;
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.1",
4
- "description": "Node Command agent CLI \u2014 connect this machine to your AI assistant fleet",
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": "H\u00fcseyin Hadi \u00c7\u0131\u011f <huseyincig@users.noreply.github.com>"
37
+ "author": "Hüseyin Hadi Çığ <huseyincig@users.noreply.github.com>"
38
38
  }