node-command 2.0.4 → 2.0.6

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 (3) hide show
  1. package/bin/nc.mjs +23 -37
  2. package/lib/setup.mjs +108 -10
  3. package/package.json +8 -22
package/bin/nc.mjs CHANGED
@@ -2867,31 +2867,37 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2867
2867
  const pair = paired.find(selected => selected.ncRoot === root);
2868
2868
  return pair ? pair.helperRoot === root ? `${root} (co-located protected helper)` : `${root} (paired protected helper: ${pair.helperRoot})` : root;
2869
2869
  }).join(', ')}`);
2870
- if (paired.length && (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0)) {
2871
- console.error('error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] Paired Linux removal requires explicitly invoked real/effective Root maintenance. No elevation or ordinary-script fallback was attempted; both roots and lifecycle records are preserved.');
2872
- console.error(uninstallFailureSuggestion());
2873
- process.exit(1);
2874
- return;
2875
- }
2870
+ const pairedRoots = new Set(paired.map(selected => selected.ncRoot));
2871
+ const canRemovePaired = os.platform() === 'linux' && process.getuid?.() === 0 && process.geteuid?.() === 0;
2872
+ let failed = 0;
2873
+ // A non-root caller can never execute the paired protected root. Report it
2874
+ // for explicit root maintenance, but keep going so the caller's own readable
2875
+ // tree is still removed in this invocation. No implicit sudo is attempted.
2876
+ const skippedPaired = canRemovePaired ? [] : [...pairedRoots];
2877
+ for (const root of skippedPaired) {
2878
+ console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] The paired system installation at ${root} requires explicitly invoked real/effective Root maintenance; it was not modified.`);
2879
+ console.error('suggested_action: Run `sudo npx --yes node-command@latest uninstall --yes` to remove this trusted root installation explicitly.');
2880
+ failed += 1;
2881
+ }
2882
+ const removable = install.filter(root => !skippedPaired.includes(root));
2876
2883
  if (unreadable.length > 0) {
2877
2884
  console.log(
2878
- `Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
2885
+ `Explicit root maintenance required for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
2879
2886
  );
2880
2887
  }
2881
- if (!yes) {
2888
+ if (removable.length > 0 && !yes) {
2882
2889
  if (!process.stdin.isTTY) {
2883
2890
  console.error(`error: [CONFIRMATION_REQUIRED] ${command} needs confirmation in non-interactive shells.`);
2884
2891
  console.error(`suggested_action: Re-run as \`nodecommand ${command} --yes\`.`);
2885
2892
  process.exit(1);
2886
2893
  }
2887
- const confirmed = await confirmPrompt(`${command === 'uninstall' ? 'Uninstall' : 'Remove'} ${targets.length === 1 ? 'this installation' : 'these installations'}? [y/N] `);
2894
+ const confirmed = await confirmPrompt(`${command === 'uninstall' ? 'Uninstall' : 'Remove'} ${removable.length === 1 ? 'this installation' : 'these installations'}? [y/N] `);
2888
2895
  if (!confirmed) {
2889
2896
  console.log('Aborted (nothing removed).');
2890
2897
  return;
2891
2898
  }
2892
2899
  }
2893
- let failed = 0;
2894
- for (const root of install) {
2900
+ for (const root of removable) {
2895
2901
  const pair = paired.find(selected => selected.ncRoot === root);
2896
2902
  const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
2897
2903
  if (!pair && !fs.existsSync(script)) {
@@ -2924,33 +2930,13 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2924
2930
  lock.release();
2925
2931
  }
2926
2932
  }
2927
- // Unreadable system roots remain visible, but never trigger implicit sudo.
2933
+ // Unreadable system roots remain visible, but are never executed: an
2934
+ // ordinary caller cannot verify the tree, so removal is explicit root
2935
+ // maintenance. This deliberately adds no implicit sudo.
2928
2936
  for (const root of unreadable) {
2929
- const script = uninstallerForRoot(root);
2930
- let lock = null;
2931
- try {
2932
- lock = acquireInstallLock(root);
2933
- } catch (err) {
2934
- if (err instanceof PairingError && err.code === 'SETUP_IN_PROGRESS') {
2935
- reportSetupError(err);
2936
- failed += 1;
2937
- continue;
2938
- }
2939
- // Best effort: an unreadable system root usually cannot host a writable
2940
- // lock from an ordinary caller. The privileged uninstaller remains the
2941
- // authority; do not turn a lock-permission failure into a false refusal.
2942
- }
2943
- try {
2944
- runUninstallerScript(script);
2945
- console.log(`Removed ${root} (exit 0).`);
2946
- } catch (err) {
2947
- const rc = typeof err?.status === 'number' ? err.status : 1;
2948
- console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${sanitizeForDisplay(err.message, 300)}`);
2949
- console.error(uninstallFailureSuggestion());
2950
- failed += 1;
2951
- } finally {
2952
- lock?.release();
2953
- }
2937
+ console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] The system installation at ${root} is not readable by the current user; it was not modified.`);
2938
+ console.error('suggested_action: Run `sudo npx --yes node-command@latest uninstall --yes` to remove this trusted root installation explicitly.');
2939
+ failed += 1;
2954
2940
  }
2955
2941
  if (failed > 0) process.exit(1);
2956
2942
  console.log(completionMessage);
package/lib/setup.mjs CHANGED
@@ -391,6 +391,44 @@ export function probeInstallRoot(root, io = {}) {
391
391
  return 'foreign';
392
392
  }
393
393
 
394
+ /**
395
+ * Removal-strict install-tree probe. Removing a tree is destructive, so a
396
+ * readable `config/nc-client.env` marker is required; an `app/` directory
397
+ * alone (an incomplete or foreign tree) is never a removal target.
398
+ * Distinguishes:
399
+ * - 'install' config marker is readable
400
+ * - 'absent' root directory does not exist
401
+ * - 'foreign' root exists but is not a directory, or the config marker is
402
+ * missing / not a regular file
403
+ * - 'unreadable' root or config marker denies access (EACCES/EPERM)
404
+ * @param {string} root
405
+ * @param {{ statSync?: (p: string) => { isDirectory(): boolean, isFile?(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
406
+ * @returns {'install'|'absent'|'foreign'|'unreadable'}
407
+ */
408
+ export function probeRemovalRoot(root, io = {}) {
409
+ const statSync = io.statSync || fs.statSync;
410
+ const accessSync = io.accessSync || fs.accessSync;
411
+ try {
412
+ const st = statSync(root);
413
+ if (!st.isDirectory()) return 'foreign';
414
+ } catch (err) {
415
+ return err && err.code === 'ENOENT' ? 'absent' : 'unreadable';
416
+ }
417
+ const marker = path.join(root, 'config', 'nc-client.env');
418
+ try {
419
+ const st = statSync(marker);
420
+ if (typeof st.isFile === 'function' && !st.isFile()) return 'foreign';
421
+ } catch (err) {
422
+ return err && (err.code === 'EACCES' || err.code === 'EPERM') ? 'unreadable' : 'foreign';
423
+ }
424
+ try {
425
+ accessSync(marker, fs.constants.R_OK);
426
+ return 'install';
427
+ } catch (err) {
428
+ return err && (err.code === 'EACCES' || err.code === 'EPERM') ? 'unreadable' : 'foreign';
429
+ }
430
+ }
431
+
394
432
  /**
395
433
  * Canonical machine-wide roots that exist but cannot be inspected by the
396
434
  * current unprivileged caller. Setup must escalate before deciding anything
@@ -407,32 +445,92 @@ export function unreadableSystemInstallations({ platform = os.platform(), env =
407
445
  return probeInstallRoot(root, io) === 'unreadable' ? [root] : [];
408
446
  }
409
447
 
448
+ /**
449
+ * Every fixed path `linuxPairedInstallation()` must stat/read. A root-owned
450
+ * 0600 metadata file is lstat-able for an ordinary user but unreadable, so
451
+ * both the stat and the read permission are probed.
452
+ */
453
+ const PAIRED_PROTECTED_PATHS = [
454
+ '/opt/nc',
455
+ '/opt/nc/libexec',
456
+ '/opt/nc/libexec/privileged-helper',
457
+ '/opt/nc/libexec/privileged-helper/helper-bootstrap.json',
458
+ '/opt/nc/libexec/nc-linux-peercred',
459
+ '/opt/nc/app',
460
+ '/opt/nc/app/launcher.mjs',
461
+ '/opt/nc/config',
462
+ '/opt/nc/config/nc-client.env',
463
+ '/var/lib/nodecommand',
464
+ '/var/lib/nodecommand/nc',
465
+ '/var/lib/nodecommand/nc/config',
466
+ '/var/lib/nodecommand/nc/config/nc-client.env',
467
+ '/var/lib/nodecommand/nc/app/launcher.mjs',
468
+ '/var/lib/nc-lifecycle',
469
+ '/var/lib/nc-lifecycle/selected.json',
470
+ '/etc/systemd/system/nc-client.service',
471
+ '/etc/systemd/user/nc-client.service',
472
+ '/etc/systemd/system/nc-privileged-helper.service',
473
+ '/etc/systemd/system/nc-privileged-helper.socket',
474
+ '/etc/tmpfiles.d/nc-privileged-helper.conf'
475
+ ];
476
+
477
+ /** True when the fixed protected helper paths deny this account access. */
478
+ function pairedMetadataUnreadable(io) {
479
+ const lstat = io.lstatSync || fs.lstatSync;
480
+ const access = io.accessSync || fs.accessSync;
481
+ for (const file of PAIRED_PROTECTED_PATHS) {
482
+ try {
483
+ lstat(file);
484
+ } catch (err) {
485
+ if (err?.code === 'EACCES' || err?.code === 'EPERM') return true;
486
+ continue;
487
+ }
488
+ try {
489
+ access(file, fs.constants.R_OK);
490
+ } catch (err) {
491
+ if (err?.code === 'EACCES' || err?.code === 'EPERM') return true;
492
+ }
493
+ }
494
+ return false;
495
+ }
496
+
410
497
  /**
411
498
  * Removal targets split by accessibility. `install` roots are removed by the
412
499
  * local flow; `unreadable` roots (system installs seen from a non-root shell)
413
500
  * report explicit trusted-maintenance requirements instead of being
414
501
  * silently reported as "nothing to remove".
415
502
  * @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
416
- * @returns {{ install: string[], unreadable: string[], paired?: { ncRoot: string, helperRoot: string, clientId: string }[] }}
503
+ * @returns {{ install: string[], unreadable: string[], paired: { ncRoot: string, helperRoot: string, clientId: string }[] }}
417
504
  */
418
505
  export function findRemovalTargets({ io = {}, ...rest } = {}) {
419
506
  const install = [];
420
507
  const unreadable = [];
421
- const pair = (rest.platform || os.platform()) === 'linux' ? linuxPairedInstallation(io) : null;
508
+ let pair = null;
509
+ if ((rest.platform || os.platform()) === 'linux') {
510
+ try {
511
+ pair = linuxPairedInstallation(io);
512
+ } catch (err) {
513
+ // Protected metadata this account cannot stat or read is not a local
514
+ // removal target. Inventory discovery continues so an ordinary user can
515
+ // still remove their own readable tree; nothing here executes a root.
516
+ // Any other validation failure remains a hard stop.
517
+ if (err?.code !== 'TRUSTED_ROOT_MAINTENANCE_REQUIRED' || !pairedMetadataUnreadable(io)) throw err;
518
+ pair = null;
519
+ }
520
+ }
422
521
  const env = rest.env || process.env;
423
522
  if (pair && env.NC_ROOT && ![pair.ncRoot, pair.helperRoot].includes(env.NC_ROOT)) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: NC_ROOT conflicts with the protected paired installation.');
424
523
  const roots = candidateInstallRoots(pair ? { ...rest, env: { ...env, NC_ROOT: undefined } } : rest);
425
524
  for (const root of pair ? [...new Set([...roots, pair.ncRoot])] : roots) {
426
525
  if (root === pair?.helperRoot && root !== pair.ncRoot) continue;
427
- const status = probeInstallRoot(root, io);
428
- if (status === 'install') install.push(root);
429
- else if (status === 'unreadable') unreadable.push(root);
430
- }
431
- if (pair) {
432
- if (install.length !== 1 || install[0] !== pair.ncRoot || unreadable.length) throw new Error('TRUSTED_ROOT_MAINTENANCE_REQUIRED: conflicting paired installation inventory; preserve all roots.');
433
- return { install, unreadable, paired: [pair] };
526
+ const status = probeRemovalRoot(root, io);
527
+ if (status === 'install' && !install.includes(root)) install.push(root);
528
+ else if (status === 'unreadable' && !unreadable.includes(root)) unreadable.push(root);
434
529
  }
435
- return { install, unreadable };
530
+ // The paired relation is only actionable when its root is itself readable.
531
+ // An unverifiable/foreign paired root is reported through `unreadable`.
532
+ const paired = pair && install.includes(pair.ncRoot) ? [pair] : [];
533
+ return { install, unreadable, paired };
436
534
  }
437
535
 
438
536
  /**
package/package.json CHANGED
@@ -1,33 +1,19 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
6
9
  "bin": {
7
- "nc": "bin/nc.mjs",
8
- "nodecommand": "bin/nc.mjs",
9
- "nc-agent": "bin/nc.mjs",
10
- "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"
11
14
  },
12
15
  "files": [
13
16
  "bin/",
14
17
  "lib/"
15
- ],
16
- "description": "Node Command agent CLI — connect this machine to your AI assistant fleet",
17
- "repository": {
18
- "url": "git+https://github.com/huseyincig/NodeCommand.git",
19
- "type": "git"
20
- },
21
- "engines": {
22
- "node": ">=20"
23
- },
24
- "keywords": [
25
- "node-command",
26
- "nodecommand",
27
- "mcp",
28
- "remote",
29
- "ai-assistant",
30
- "agent",
31
- "cli"
32
18
  ]
33
19
  }