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 +80 -5
- package/lib/setup.mjs +96 -0
- package/package.json +5 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2456
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
11
|
-
"nodecommand": "
|
|
12
|
-
"nc-agent": "
|
|
13
|
-
"node-command": "
|
|
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/",
|