nc-agent 0.1.2 → 0.1.4

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/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # nc-agent
2
+
3
+ Node Command device pairing and agent installer.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ # Interactive RFC 8628 pairing (8-character code displayed in terminal)
9
+ npx nc-agent
10
+
11
+ # Direct pairing with host ID and enrollment token
12
+ npx nc-agent <host_id> <enrollment_token>
13
+
14
+ # Uninstall the agent from the canonical system directory
15
+ npx nc-agent uninstall
16
+ ```
package/bin/nc.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  findRemovalTargets,
16
16
  getNcRootForMode,
17
17
  installerEnvForMode,
18
+ isElevated,
18
19
  modeWarningFor,
19
20
  runUninstallerScript,
20
21
  uninstallFailureSuggestion,
@@ -324,21 +325,24 @@ export async function downloadToFile(targetUrl, destinationPath, extraHeaders =
324
325
  }
325
326
 
326
327
  /**
327
- * Determines the installation root (NC_ROOT) dynamically based on OS:
328
- * - Windows: %LOCALAPPDATA%\nc
328
+ * Determines the canonical installation root (NC_ROOT) dynamically based on
329
+ * OS:
330
+ * - Windows: %ProgramData%\NC (the machine-wide root; user-space
331
+ * %LOCALAPPDATA%\nc is never a default — see getNcRootForMode('user'))
329
332
  * - macOS: ~/Library/Application Support/nc
330
333
  * - Linux (root): /opt/nc
331
334
  * - Linux (non-root): ~/.local/share/nc
335
+ * An explicit NC_ROOT always wins. Probes are injectable for deterministic
336
+ * tests.
337
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [options]
332
338
  * @returns {string}
333
339
  */
334
- export function getNcRoot() {
335
- if (process.env.NC_ROOT) {
336
- return process.env.NC_ROOT;
340
+ export function getNcRoot({ platform = os.platform(), env = process.env } = {}) {
341
+ if (env.NC_ROOT) {
342
+ return env.NC_ROOT;
337
343
  }
338
- const platform = os.platform();
339
344
  if (platform === 'win32') {
340
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
341
- return path.join(localAppData, 'nc');
345
+ return path.join(env.ProgramData || 'C:\\ProgramData', 'NC');
342
346
  }
343
347
  if (platform === 'darwin') {
344
348
  return path.join(os.homedir(), 'Library', 'Application Support', 'nc');
@@ -1375,6 +1379,7 @@ function printCliHelp() {
1375
1379
  'Usage:',
1376
1380
  ' nodecommand pair this machine via the browser device flow',
1377
1381
  ' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
1382
+ ' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
1378
1383
  ' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
1379
1384
  ' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
1380
1385
  ' nodecommand --diagnostics --apply write the diagnostics bundle (.tar.gz) for a support ticket',
@@ -1406,6 +1411,10 @@ async function main() {
1406
1411
  await runSetup(subArgs);
1407
1412
  return;
1408
1413
  }
1414
+ if (subcommand === 'uninstall') {
1415
+ await runUninstall(subArgs);
1416
+ return;
1417
+ }
1409
1418
  if (subcommand === 'remove') {
1410
1419
  await runRemove(subArgs);
1411
1420
  return;
@@ -1525,11 +1534,53 @@ function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed
1525
1534
  console.log('status: agent credentials verified online');
1526
1535
  }
1527
1536
 
1537
+ /**
1538
+ * Guidance printed when Windows UAC elevation for a system-wide install is
1539
+ * unavailable (headless shell) or was declined by the operator.
1540
+ */
1541
+ const WINDOWS_ELEVATION_INSTALL_DECLINED =
1542
+ 'Elevation unavailable or declined. Please run as Administrator in an elevated shell for a system-wide install at C:\\ProgramData\\NC.';
1543
+
1544
+ /**
1545
+ * Guidance printed when Windows UAC elevation for uninstallation is
1546
+ * unavailable (headless shell) or was declined by the operator.
1547
+ */
1548
+ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
1549
+ 'Elevation unavailable or declined. Please run as Administrator in an elevated shell to uninstall the agent from C:\\ProgramData\\NC.';
1550
+
1551
+ /**
1552
+ * Re-runs the current CLI invocation elevated through the Windows UAC prompt
1553
+ * (`Start-Process -Verb RunAs -Wait`; stdio stays inherited so the elevated
1554
+ * console is visible) and returns the spawnSync result. Never throws: a
1555
+ * missing powershell or a declined prompt resolves to null/non-zero.
1556
+ */
1557
+ function requestWindowsElevation() {
1558
+ const args = [...process.argv.slice(1)];
1559
+ try {
1560
+ return child_process.spawnSync(
1561
+ 'powershell',
1562
+ [
1563
+ '-NoProfile',
1564
+ '-Command',
1565
+ `Start-Process -FilePath "${process.execPath}" -ArgumentList ${args
1566
+ .map((a) => `"${String(a).replace(/"/g, '`"')}"`)
1567
+ .join(',')} -Verb RunAs -Wait`
1568
+ ],
1569
+ { stdio: 'inherit' }
1570
+ );
1571
+ } catch {
1572
+ return null;
1573
+ }
1574
+ }
1575
+
1528
1576
  /**
1529
1577
  * `nodecommand setup [host_id enrollment_token]`.
1530
1578
  *
1531
- * Mode is automatic (never prompted): elevated processes install system-wide,
1532
- * everything else installs per-user with a single-line capability notice.
1579
+ * Mode is automatic (never prompted): elevated processes install machine-wide.
1580
+ * POSIX: a non-root admin requests sudo; a refusal falls back to a per-user
1581
+ * install with a single-line capability notice. Windows: a standard shell
1582
+ * requests UAC elevation instead and hard-stops (exit 1) when elevation is
1583
+ * unavailable or declined — never a silent %LOCALAPPDATA% degradation.
1533
1584
  * Without arguments a fresh enrollment is requested via the browser device
1534
1585
  * flow; with `host_id` + `token` (dashboard flow) the supplied enrollment is
1535
1586
  * redeemed directly. Enrollment always goes through POST /api/enroll with the
@@ -1563,10 +1614,21 @@ export async function runSetup(args = []) {
1563
1614
  }
1564
1615
  let installMode = decideInstallMode();
1565
1616
  // A non-root admin must not silently fall back to a per-user install: the
1566
- // install root, service scope and privileged helper all differ. Request sudo
1567
- // (the password prompt IS the admin request) and re-run elevated so the
1568
- // install lands machine-wide at the canonical system root.
1617
+ // install root, service scope and privileged helper all differ. POSIX
1618
+ // requests sudo and re-runs elevated; Windows requests UAC (Start-Process
1619
+ // -Verb RunAs) so the install lands machine-wide at the canonical system
1620
+ // root. A refused or unavailable Windows elevation is a hard stop: the CLI
1621
+ // never silently degrades a system install into %LOCALAPPDATA%.
1569
1622
  if (installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
1623
+ if (os.platform() === 'win32') {
1624
+ if (process.stdin.isTTY) {
1625
+ console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
1626
+ const elevated = requestWindowsElevation();
1627
+ if (elevated && elevated.status === 0) process.exit(0);
1628
+ }
1629
+ console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
1630
+ process.exit(1);
1631
+ }
1570
1632
  console.log('Admin rights required for a system-wide install. Requesting sudo…');
1571
1633
  const elevated = child_process.spawnSync(
1572
1634
  'sudo',
@@ -1636,30 +1698,47 @@ export function confirmPrompt(question, openInterface = null) {
1636
1698
  }
1637
1699
 
1638
1700
  /**
1639
- * `nodecommand remove [--yes]`. Idempotent: no install found means success
1640
- * (exit 0). Otherwise the uninstaller living inside each installed tree is
1641
- * executed (never a downloaded copy) and its exit status is reported.
1701
+ * Shared engine for `remove`/`uninstall`: idempotent (no install found means
1702
+ * success, exit 0); otherwise the uninstaller living inside each installed
1703
+ * tree is executed (never a downloaded copy) and its exit status is reported.
1704
+ * Target discovery is centered on the canonical root (candidateInstallRoots
1705
+ * lists it first) and treats an unreadable system tree as installed, so a
1706
+ * non-root shell routes it to the elevated uninstaller instead of reporting
1707
+ * "nothing to remove".
1642
1708
  * @param {string[]} args
1709
+ * @param {{ command: string, emptyMessage: string, elevateOnWindows?: boolean, completionMessage: string }} options
1643
1710
  */
1644
- export async function runRemove(args = []) {
1711
+ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = false, completionMessage }) {
1645
1712
  if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
1646
1713
  printCliHelp();
1647
1714
  return;
1648
1715
  }
1649
- assertKnownFlags(args, ['--yes', '-y', '--help', '-h'], 'remove');
1716
+ assertKnownFlags(args, ['--yes', '-y', '--help', '-h'], command);
1650
1717
  const stray = args.filter((a) => !a.startsWith('-') && a !== 'help');
1651
1718
  if (stray.length > 0) {
1652
- console.error(`error: [UNKNOWN_ARGUMENT] \`remove\` takes no positional arguments (got: ${stray.join(', ')}).`);
1653
- console.error('suggested_action: Run `nodecommand remove --yes` (or plain `nodecommand remove` to be asked).');
1719
+ console.error(`error: [UNKNOWN_ARGUMENT] \`${command}\` takes no positional arguments (got: ${stray.join(', ')}).`);
1720
+ console.error(`suggested_action: Run \`nodecommand ${command} --yes\` (or plain \`nodecommand ${command}\` to be asked).`);
1654
1721
  process.exit(1);
1655
1722
  }
1656
1723
  const yes = args.includes('--yes') || args.includes('-y');
1657
1724
  const { install, unreadable } = findRemovalTargets();
1658
1725
  const targets = [...install, ...unreadable];
1659
1726
  if (targets.length === 0) {
1660
- console.log('No Node Command installation found (nothing to remove).');
1727
+ console.log(emptyMessage);
1661
1728
  return;
1662
1729
  }
1730
+ // Windows: a machine-wide tree at C:\ProgramData\NC cannot be removed from
1731
+ // a standard shell — request UAC (Start-Process -Verb RunAs) and re-run this
1732
+ // same command elevated. Declined or headless elevation is a hard stop.
1733
+ if (elevateOnWindows && os.platform() === 'win32' && !isElevated()) {
1734
+ if (process.stdin.isTTY) {
1735
+ console.log('Admin rights required to remove the system-wide agent. Requesting UAC elevation…');
1736
+ const elevated = requestWindowsElevation();
1737
+ if (elevated && elevated.status === 0) process.exit(0);
1738
+ }
1739
+ console.error(WINDOWS_ELEVATION_UNINSTALL_DECLINED);
1740
+ process.exit(1);
1741
+ }
1663
1742
  console.log(`Found Node Command installation(s): ${targets.join(', ')}`);
1664
1743
  if (unreadable.length > 0) {
1665
1744
  console.log(
@@ -1668,11 +1747,11 @@ export async function runRemove(args = []) {
1668
1747
  }
1669
1748
  if (!yes) {
1670
1749
  if (!process.stdin.isTTY) {
1671
- console.error('error: [CONFIRMATION_REQUIRED] remove needs confirmation in non-interactive shells.');
1672
- console.error('suggested_action: Re-run as `nodecommand remove --yes`.');
1750
+ console.error(`error: [CONFIRMATION_REQUIRED] ${command} needs confirmation in non-interactive shells.`);
1751
+ console.error(`suggested_action: Re-run as \`nodecommand ${command} --yes\`.`);
1673
1752
  process.exit(1);
1674
1753
  }
1675
- const confirmed = await confirmPrompt(`Remove ${targets.length === 1 ? 'this installation' : 'these installations'}? [y/N] `);
1754
+ const confirmed = await confirmPrompt(`${command === 'uninstall' ? 'Uninstall' : 'Remove'} ${targets.length === 1 ? 'this installation' : 'these installations'}? [y/N] `);
1676
1755
  if (!confirmed) {
1677
1756
  console.log('Aborted (nothing removed).');
1678
1757
  return;
@@ -1713,7 +1792,40 @@ export async function runRemove(args = []) {
1713
1792
  }
1714
1793
  }
1715
1794
  if (failed > 0) process.exit(1);
1716
- console.log('Remove complete.');
1795
+ console.log(completionMessage);
1796
+ }
1797
+
1798
+ /**
1799
+ * `nodecommand remove [--yes]` — legacy name for the canonical uninstall.
1800
+ * @param {string[]} args
1801
+ */
1802
+ export async function runRemove(args = []) {
1803
+ return runRemoval(args, {
1804
+ command: 'remove',
1805
+ emptyMessage: 'No Node Command installation found (nothing to remove).',
1806
+ completionMessage: 'Remove complete.'
1807
+ });
1808
+ }
1809
+
1810
+ /**
1811
+ * `nodecommand uninstall [--yes]` (alias: `remove`). Resolves the canonical
1812
+ * install root (`getNcRoot()`), verifies an agent tree actually lives there
1813
+ * (readable markers, or an unreadable system tree that requires elevation),
1814
+ * and invokes the in-tree platform uninstaller:
1815
+ * - Windows: `powershell -NoProfile -ExecutionPolicy Bypass -File <root>\uninstall.ps1`
1816
+ * after requesting UAC (`Start-Process -Verb RunAs`) when not elevated
1817
+ * - POSIX: `sudo <root>/uninstall.sh`, run directly when already root
1818
+ * When no agent is installed the command reports the canonical root and
1819
+ * exits cleanly.
1820
+ * @param {string[]} args
1821
+ */
1822
+ export async function runUninstall(args = []) {
1823
+ return runRemoval(args, {
1824
+ command: 'uninstall',
1825
+ emptyMessage: `No active NC agent installation found at ${getNcRoot()}.`,
1826
+ elevateOnWindows: true,
1827
+ completionMessage: 'Uninstall complete.'
1828
+ });
1717
1829
  }
1718
1830
 
1719
1831
  /**
package/lib/setup.mjs CHANGED
@@ -74,17 +74,19 @@ export function decideInstallMode(options = {}) {
74
74
 
75
75
  /**
76
76
  * True when the current user CAN elevate to a machine-wide install even when
77
- * the process is not root yet: POSIX admin groups (sudo/admin/wheel) or an
78
- * already-elevated process. Used so a non-root admin is asked for sudo instead
79
- * of silently degrading to a per-user install (different root, service scope
80
- * and no privileged helper).
77
+ * the process is not root yet: an already-elevated process, a Windows
78
+ * standard shell (UAC `Start-Process -Verb RunAs` is always available), or
79
+ * POSIX admin groups (sudo/admin/wheel). Used so a non-root admin is asked
80
+ * for elevation instead of silently degrading to a per-user install
81
+ * (different root, service scope and no privileged helper).
81
82
  * @param {{ platform?: string, getuid?: (() => number)|null, isAdmin?: boolean|null, runAdminCheck?: (() => boolean)|null }} [options]
82
83
  */
83
84
  export function canElevate(options = {}) {
84
85
  if (isElevated(options)) return true;
85
86
  const platform = options.platform || os.platform();
86
- // Windows: isElevated already answers "is administrator".
87
- if (platform === 'win32') return false;
87
+ // Windows: a non-elevated process can still relaunch itself through the
88
+ // UAC prompt, so elevation is available even when not administrator.
89
+ if (platform === 'win32') return true;
88
90
  try {
89
91
  const r = child_process.spawnSync('id', ['-nG'], { encoding: 'utf8' });
90
92
  const groups = r && typeof r.stdout === 'string' ? r.stdout : '';
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "nc-agent",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Node Command device pairing and agent installer. Run npx nc-agent to pair this machine with your Node Command workspace.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
- "engines": { "node": ">=22.5.0" },
8
- "bin": { "nc-agent": "./bin/nc.mjs" },
9
- "files": ["bin/", "lib/"]
7
+ "bin": {
8
+ "nc-agent": "./bin/nc.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "lib/",
13
+ "README.md"
14
+ ]
10
15
  }