node-command 2.0.6 → 2.0.8

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 CHANGED
@@ -21,8 +21,10 @@ import {
21
21
  getNcRootForMode,
22
22
  installerEnvForMode,
23
23
  isElevated,
24
+ isPartialUserRemovalCandidate,
24
25
  isUserInstallRoot,
25
26
  probeInstallRoot,
27
+ removePartialUserRoot,
26
28
  resolveExecutable,
27
29
  setupTarget,
28
30
  unreadableSystemInstallations,
@@ -708,6 +710,37 @@ export function runInstallerScript(scriptPath, ncRoot, extraEnv = {}) {
708
710
  });
709
711
  }
710
712
 
713
+ /**
714
+ * Best-effort evidence when the agent installer reports a Node.js runtime
715
+ * failure. The Linux installer suppresses the portable runtime's loader error
716
+ * and prints only "Node.js 24+ required (portable runtime failed to run)";
717
+ * replay the binary to surface the real reason (e.g. a missing `libatomic`
718
+ * dependency of Node.js 25+ official Linux builds). Diagnostic only: it never
719
+ * changes the installer's runtime requirement or substitutes a downgrade.
720
+ * @param {string} ncRoot
721
+ * @returns {string|null}
722
+ */
723
+ function portableRuntimeFailureDetail(ncRoot) {
724
+ const bin = path.join(ncRoot, 'tools', 'node', 'bin', 'node');
725
+ try {
726
+ const info = fs.lstatSync(bin);
727
+ if (!info.isFile() || info.isSymbolicLink()) return null;
728
+ // Probe only a runtime this account owns: never execute a planted binary
729
+ // from an arbitrary/attacker-writable install tree.
730
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) return null;
731
+ } catch {
732
+ return null;
733
+ }
734
+ const probe = child_process.spawnSync(bin, ['-p', 'process.versions.node'], {
735
+ encoding: 'utf8',
736
+ timeout: 5000,
737
+ env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' }
738
+ });
739
+ const detail = String(probe.stderr || '').trim() || (probe.error?.message || '');
740
+ if (!detail) return null;
741
+ return sanitizeForDisplay(detail.split(/\r?\n/).filter(Boolean).slice(-3).join(' '), 300);
742
+ }
743
+
711
744
  /**
712
745
  * Zero-touch device pairing library.
713
746
  *
@@ -1740,7 +1773,11 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
1740
1773
  invokeInstaller(scriptPath, root);
1741
1774
  } catch (err) {
1742
1775
  if (err instanceof PairingError) throw err;
1743
- throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${sanitizeForDisplay(err.message, 300)}. Re-running resumes from the stored host credentials.`);
1776
+ const runtimeDetail = installMode === 'user' ? portableRuntimeFailureDetail(root) : null;
1777
+ const hint = runtimeDetail
1778
+ ? ` The bundled Node.js runtime at ${path.join(root, 'tools', 'node', 'bin', 'node')} also fails to run here: ${runtimeDetail}. Install the distribution runtime dependency (Node.js 25+ official Linux builds require libatomic) or a working Node.js 24+ system runtime, then re-run.`
1779
+ : '';
1780
+ throw new PairingError('INSTALL_FAILED', `Agent installer failed: ${sanitizeForDisplay(err.message, 300)}.${hint} Re-running resumes from the stored host credentials.`);
1744
1781
  }
1745
1782
  } finally {
1746
1783
  console.log('🧹 Cleaning up temporary directory...');
@@ -2440,7 +2477,12 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
2440
2477
  // existing device identity. The normal verified resume flow is kept.
2441
2478
  const keyPath = getDeviceKeyPath(root);
2442
2479
  if (!protectedContext) {
2443
- const keyInfo = fs.lstatSync(keyPath);
2480
+ let keyInfo;
2481
+ try {
2482
+ keyInfo = fs.lstatSync(keyPath);
2483
+ } catch (err) {
2484
+ throw new Error(`Existing device key at ${root} is missing or cannot be verified; preserve it for selected-installation maintenance.`, { cause: err });
2485
+ }
2444
2486
  if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
2445
2487
  (os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
2446
2488
  throw new Error(`Existing device key at ${root} has an unverifiable type/owner; refusing identity replacement.`);
@@ -2452,8 +2494,23 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
2452
2494
  throw new Error(`Existing device key at ${root} cannot be verified; preserve it for selected-installation maintenance.`);
2453
2495
  }
2454
2496
  existingEnv = localEnv;
2455
- const appInfo = fs.lstatSync(path.join(root, 'app'));
2456
- if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${root} is incomplete; use selected-installation maintenance.`);
2497
+ // A failed per-user install can leave config + device key while the
2498
+ // application directory was never staged. That is repairable from the
2499
+ // stored identity exactly like a missing launcher; it must not surface as
2500
+ // a raw ENOENT and block every retry.
2501
+ let appInfo = null;
2502
+ try {
2503
+ appInfo = fs.lstatSync(path.join(root, 'app'));
2504
+ } catch (err) {
2505
+ if (err?.code !== 'ENOENT') throw new Error(`Existing application at ${root} cannot be verified; use selected-installation maintenance.`, { cause: err });
2506
+ }
2507
+ if (!appInfo || !appInfo.isDirectory() || appInfo.isSymbolicLink()) {
2508
+ if (repairUserApplication && canRepairUserApplication({ mode: installMode, protectedContext })) {
2509
+ needsUserAppRepair = true;
2510
+ return;
2511
+ }
2512
+ throw new Error(`Existing application at ${root} is incomplete: the app/ directory is missing or unverifiable. Repair or reinstall the application from the existing root (config/nc-client.env and config/nc-device-key.pem are preserved); no new enrollment was attempted.`);
2513
+ }
2457
2514
  const launcherPath = path.join(root, 'app', 'launcher.mjs');
2458
2515
  let launcherInfo;
2459
2516
  try {
@@ -2900,7 +2957,14 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2900
2957
  for (const root of removable) {
2901
2958
  const pair = paired.find(selected => selected.ncRoot === root);
2902
2959
  const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
2903
- if (!pair && !fs.existsSync(script)) {
2960
+ // A failed per-user install can leave its own tree (config plus partial
2961
+ // payload) without the packaged uninstaller. Only the canonical user root
2962
+ // is eligible, and the bounded removal revalidates ancestry, ownership and
2963
+ // every top-level entry. System/protected roots never use this fallback.
2964
+ const scriptExists = pair || fs.existsSync(script);
2965
+ const boundedUserTree = !scriptExists &&
2966
+ (command === 'uninstall' || command === 'remove') && isPartialUserRemovalCandidate(root);
2967
+ if (!scriptExists && !boundedUserTree) {
2904
2968
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
2905
2969
  console.error(uninstallFailureSuggestion());
2906
2970
  failed += 1;
@@ -2915,6 +2979,17 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2915
2979
  continue;
2916
2980
  }
2917
2981
  try {
2982
+ if (boundedUserTree) {
2983
+ try {
2984
+ removePartialUserRoot(root);
2985
+ console.log(`Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
2986
+ } catch (err) {
2987
+ console.error(`error: [USER_TREE_REMOVAL_REFUSED] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
2988
+ console.error('suggested_action: The tree was left untouched. Repair it with `nodecommand --user`, or remove it yourself after confirming it is your own Node Command directory.');
2989
+ failed += 1;
2990
+ }
2991
+ continue;
2992
+ }
2918
2993
  runUninstallerScript(script);
2919
2994
  console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
2920
2995
  } catch (err) {
package/lib/setup.mjs CHANGED
@@ -429,6 +429,102 @@ export function probeRemovalRoot(root, io = {}) {
429
429
  }
430
430
  }
431
431
 
432
+ /** Top-level entries a per-user Node Command tree may contain. A removal never
433
+ * touches anything else, so a user-owned foreign directory cannot be wiped by
434
+ * a misdetected install. */
435
+ const USER_INSTALL_ENTRIES = new Set([
436
+ 'app',
437
+ 'backups',
438
+ 'bin',
439
+ 'config',
440
+ 'data',
441
+ 'install-darwin.sh',
442
+ 'install-linux.sh',
443
+ 'install-macos.sh',
444
+ 'lib',
445
+ 'libexec',
446
+ 'logs',
447
+ 'releases',
448
+ 'tmp',
449
+ 'tools',
450
+ 'uninstall.sh'
451
+ ]);
452
+
453
+ /**
454
+ * True when `root` is the canonical per-user tree for the current account and
455
+ * is not a protected system root. This is the cheap path/ownership gate; the
456
+ * tree itself is validated again inside `removePartialUserRoot`.
457
+ * @param {string} root
458
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, uid?: number }} [options]
459
+ */
460
+ export function isPartialUserRemovalCandidate(root, { platform = os.platform(), env = process.env, homedir = os.homedir(), uid = process.getuid?.() } = {}) {
461
+ // `uninstall.sh` is a POSIX entry; Windows keeps its own uninstaller contract.
462
+ if (platform === 'win32') return false;
463
+ if (!Number.isSafeInteger(uid) || uid <= 0) return false;
464
+ const canonical = { ...env };
465
+ delete canonical.NC_ROOT;
466
+ const resolved = path.resolve(root);
467
+ const userRoot = path.resolve(getNcRootForMode('user', { platform, env: canonical, homedir }));
468
+ if (resolved !== userRoot) return false;
469
+ const systemRoots = [
470
+ getNcRootForMode('system', { platform, env: canonical, homedir }),
471
+ ...(platform === 'linux' ? ['/opt/nc', '/var/lib/nodecommand/nc'] : platform === 'darwin' ? ['/opt/nc'] : [])
472
+ ].map((candidate) => path.resolve(candidate));
473
+ return !systemRoots.some(
474
+ (candidate) => resolved === candidate || resolved.startsWith(candidate + path.sep) || candidate.startsWith(resolved + path.sep)
475
+ );
476
+ }
477
+
478
+ /**
479
+ * Bounded removal of a per-user Node Command tree whose packaged `uninstall.sh`
480
+ * is missing (a partial/failed install). Only the canonical per-user root is
481
+ * eligible; system/protected roots always require their packaged uninstaller.
482
+ * Refuses symlinked ancestors or entries, foreign ownership, unexpected
483
+ * top-level entries, and a missing/unreadable `config/nc-client.env` marker.
484
+ * The caller must hold the install lock. Credentials are removed only because
485
+ * this function is reached from an explicit uninstall/remove command.
486
+ * @param {string} root
487
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, uid?: number, io?: typeof fs }} [options]
488
+ */
489
+ export function removePartialUserRoot(root, { platform = os.platform(), env = process.env, homedir = os.homedir(), uid = process.getuid?.(), io = fs } = {}) {
490
+ const refused = (reason) => Object.assign(
491
+ new Error(`Refusing bounded removal of ${root}: ${reason}.`),
492
+ { code: 'USER_TREE_REMOVAL_REFUSED' }
493
+ );
494
+ if (!isPartialUserRemovalCandidate(root, { platform, env, homedir, uid })) {
495
+ throw refused('it is not the canonical per-user Node Command root');
496
+ }
497
+ const resolved = path.resolve(root);
498
+ try {
499
+ if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
500
+ const rootInfo = io.lstatSync(resolved);
501
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw refused('the root is not a regular directory');
502
+ if (rootInfo.uid !== uid) throw refused('the root is not owned by the current user');
503
+ const entries = io.readdirSync(resolved);
504
+ for (const name of entries) {
505
+ if (!USER_INSTALL_ENTRIES.has(name)) throw refused(`unexpected top-level entry "${name}"`);
506
+ const info = io.lstatSync(path.join(resolved, name));
507
+ if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
508
+ if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by the current user`);
509
+ }
510
+ for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
511
+ const info = io.lstatSync(path.join(resolved, file));
512
+ if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
513
+ throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
514
+ }
515
+ if (info.uid !== uid) throw refused(`the ${file} marker is not owned by the current user`);
516
+ }
517
+ io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
518
+ for (const name of entries) {
519
+ io.rmSync(path.join(resolved, name), { recursive: true, force: true });
520
+ }
521
+ io.rmdirSync(resolved);
522
+ } catch (err) {
523
+ if (err?.code === 'USER_TREE_REMOVAL_REFUSED') throw err;
524
+ throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
525
+ }
526
+ }
527
+
432
528
  /**
433
529
  * Canonical machine-wide roots that exist but cannot be inspected by the
434
530
  * current unprivileged caller. Setup must escalate before deciding anything
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">=20"
8
8
  },
9
9
  "bin": {
10
- "nc": "./bin/nc.mjs",
11
- "nodecommand": "./bin/nc.mjs",
12
- "nc-agent": "./bin/nc.mjs",
13
- "node-command": "./bin/nc.mjs"
10
+ "nc": "bin/nc.mjs",
11
+ "nodecommand": "bin/nc.mjs",
12
+ "nc-agent": "bin/nc.mjs",
13
+ "node-command": "bin/nc.mjs"
14
14
  },
15
15
  "files": [
16
16
  "bin/",