node-command 2.0.2 → 2.0.3
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 +406 -124
- package/lib/setup.mjs +730 -42
- package/package.json +11 -16
package/bin/nc.mjs
CHANGED
|
@@ -12,11 +12,19 @@ import { Writable } from 'node:stream';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import {
|
|
14
14
|
canElevate,
|
|
15
|
+
captureBrowserDefaultBrand,
|
|
16
|
+
normalizeBrowserBrand,
|
|
17
|
+
browserBrandReexecArgs,
|
|
15
18
|
decideInstallMode,
|
|
16
19
|
findRemovalTargets,
|
|
17
20
|
getNcRootForMode,
|
|
18
21
|
installerEnvForMode,
|
|
19
22
|
isElevated,
|
|
23
|
+
isUserInstallRoot,
|
|
24
|
+
setupTarget,
|
|
25
|
+
maintainLinuxIdentity,
|
|
26
|
+
loadProtectedHelperProducer,
|
|
27
|
+
LINUX_PAIRED_UNINSTALL_ENTRY,
|
|
20
28
|
modeWarningFor,
|
|
21
29
|
runUninstallerScript,
|
|
22
30
|
uninstallFailureSuggestion,
|
|
@@ -29,8 +37,9 @@ const baseUrl = process.env.NC_BASE_URL || 'https://auth.nodecommand.app';
|
|
|
29
37
|
* downloads, and `/releases/*`. The auth host (`baseUrl` above) serves only
|
|
30
38
|
* browser-facing OAuth/device flow and enrollment (`/oauth/*`, `/api/enroll*`);
|
|
31
39
|
* Caddy routes `/api/clients*` solely on the agent (and dash) edge, so
|
|
32
|
-
* verification against the auth host answers 404.
|
|
33
|
-
*
|
|
40
|
+
* verification against the auth host answers 404. Fresh enrollment uses this
|
|
41
|
+
* configured API destination; installed credentials instead use their stored
|
|
42
|
+
* NC_SERVER_URL for authenticated HTTP, just like the agent runtime.
|
|
34
43
|
*/
|
|
35
44
|
export const apiBase = process.env.NC_API_URL || 'https://agent.nodecommand.app';
|
|
36
45
|
const clientId = 'nc-cli';
|
|
@@ -532,9 +541,9 @@ export function getEnrollStatePath(ncRoot = getNcRoot()) {
|
|
|
532
541
|
* cannot end up in the file. Host id/token shapes are enforced so a caller
|
|
533
542
|
* mistake (e.g. passing an enrollment token as the client token) fails here
|
|
534
543
|
* instead of producing a broken agent that crash-loops on 401.
|
|
535
|
-
* @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string }} options
|
|
544
|
+
* @param {{ serverUrl: string, hostId: string, hostToken: string, clientName?: string, browserBrand?: string }} options
|
|
536
545
|
*/
|
|
537
|
-
export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname() }) {
|
|
546
|
+
export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName = os.hostname(), browserBrand = 'unknown' }) {
|
|
538
547
|
if (typeof serverUrl !== 'string' || !serverUrl) {
|
|
539
548
|
throw new PairingError('INVALID_SERVER_URL', 'Refusing to write agent config without a server URL.');
|
|
540
549
|
}
|
|
@@ -556,6 +565,7 @@ export function buildHostEnvContent({ serverUrl, hostId, hostToken, clientName =
|
|
|
556
565
|
`NC_CLIENT_NAME=${clientName}`,
|
|
557
566
|
`NC_CLIENT_TOKEN=${hostToken}`,
|
|
558
567
|
'NC_REMOTE_EXEC_ENABLED=true',
|
|
568
|
+
`NC_BROWSER_DEFAULT_BRAND=${normalizeBrowserBrand(browserBrand)}`,
|
|
559
569
|
'NC_SESSION_ENABLED=true'
|
|
560
570
|
].join('\n') + '\n';
|
|
561
571
|
}
|
|
@@ -611,6 +621,15 @@ export function loadValidHostEnv(envPath = getEnvFilePath()) {
|
|
|
611
621
|
return env;
|
|
612
622
|
}
|
|
613
623
|
|
|
624
|
+
function assertMatchingHostIdentity(localEnv, requestedHostId) {
|
|
625
|
+
if (localEnv && requestedHostId && localEnv.NC_CLIENT_ID !== requestedHostId) {
|
|
626
|
+
throw new PairingError(
|
|
627
|
+
'HOST_ID_MISMATCH',
|
|
628
|
+
`This installation belongs to host ${localEnv.NC_CLIENT_ID}, but host ${requestedHostId} was requested. Stored credentials are unchanged; the supplied enrollment token has not been redeemed.`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
614
633
|
/**
|
|
615
634
|
* Resume marker for idempotent re-runs. Holds the host id and lifecycle
|
|
616
635
|
* phase only — never any token or key material (those live in nc-client.env
|
|
@@ -716,6 +735,52 @@ export function loadDeviceKeyPair(keyFile) {
|
|
|
716
735
|
return { privateKey: key, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
|
|
717
736
|
}
|
|
718
737
|
|
|
738
|
+
/** Read only the selected ordinary key for Root-managed paired verification. */
|
|
739
|
+
function loadRootManagedPairKey(ncRoot, ordinaryUid) {
|
|
740
|
+
const invalid = () => { throw new Error('Root-managed pair device key cannot be verified safely.'); };
|
|
741
|
+
if (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0 ||
|
|
742
|
+
!['/opt/nc', '/var/lib/nodecommand/nc'].includes(ncRoot) || !Number.isSafeInteger(ordinaryUid) || ordinaryUid <= 0) invalid();
|
|
743
|
+
let directoryFd, keyFd;
|
|
744
|
+
try {
|
|
745
|
+
// Pin each directory before opening its child. NOFOLLOW on the key alone
|
|
746
|
+
// would still follow a replaced ordinary root/config ancestor as Root.
|
|
747
|
+
const components = ncRoot === '/opt/nc' ? ['', 'opt', 'nc', 'config'] : ['', 'var', 'lib', 'nodecommand', 'nc', 'config'];
|
|
748
|
+
for (const component of components) {
|
|
749
|
+
const next = fs.openSync(component ? `/proc/self/fd/${directoryFd}/${component}` : '/',
|
|
750
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
751
|
+
if (directoryFd !== undefined) fs.closeSync(directoryFd);
|
|
752
|
+
directoryFd = next;
|
|
753
|
+
const info = fs.fstatSync(directoryFd);
|
|
754
|
+
const ordinaryRoot = ncRoot === '/var/lib/nodecommand/nc' && component === 'nc';
|
|
755
|
+
const protectedAncestor = !ordinaryRoot && component !== 'config';
|
|
756
|
+
if (!info.isDirectory() || (ordinaryRoot ? ![0, ordinaryUid].includes(info.uid) : info.uid !== (protectedAncestor ? 0 : ordinaryUid)) ||
|
|
757
|
+
(protectedAncestor && (info.gid !== 0 || (info.mode & 0o022)))) invalid();
|
|
758
|
+
}
|
|
759
|
+
keyFd = fs.openSync(`/proc/self/fd/${directoryFd}/nc-device-key.pem`,
|
|
760
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
761
|
+
const before = fs.fstatSync(keyFd);
|
|
762
|
+
if (!before.isFile() || before.uid !== ordinaryUid || before.nlink !== 1 ||
|
|
763
|
+
!Number.isSafeInteger(before.size) || before.size <= 0 || before.size > 65536) invalid();
|
|
764
|
+
const buffer = Buffer.alloc(65537);
|
|
765
|
+
let length = 0;
|
|
766
|
+
while (length < buffer.length) {
|
|
767
|
+
const count = fs.readSync(keyFd, buffer, length, buffer.length - length, null);
|
|
768
|
+
if (count === 0) break;
|
|
769
|
+
length += count;
|
|
770
|
+
}
|
|
771
|
+
const after = fs.fstatSync(keyFd);
|
|
772
|
+
if (length !== before.size || length > 65536 || !after.isFile() ||
|
|
773
|
+
['dev', 'ino', 'uid', 'gid', 'mode', 'nlink', 'size', 'mtimeMs', 'ctimeMs'].some(field => before[field] !== after[field])) invalid();
|
|
774
|
+
const privateKey = crypto.createPrivateKey(buffer.subarray(0, length));
|
|
775
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') invalid();
|
|
776
|
+
const publicKey = publicRawFromPrivateCli(privateKey);
|
|
777
|
+
return { privateKey, publicKey, thumbprint: deviceKeyThumbprintCli(publicKey) };
|
|
778
|
+
} finally {
|
|
779
|
+
if (keyFd !== undefined) fs.closeSync(keyFd);
|
|
780
|
+
if (directoryFd !== undefined) fs.closeSync(directoryFd);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
719
784
|
/**
|
|
720
785
|
* Loads the device key, generating and persisting a fresh Ed25519 key when
|
|
721
786
|
* none exists (atomic tmp-write + rename, 0600). Mirrors
|
|
@@ -852,14 +917,15 @@ export async function requestEnrollment({ baseUrl, oauthToken, platform = 'linux
|
|
|
852
917
|
/**
|
|
853
918
|
* Redeems a one-time enrollment token with Device Proof v1, binding the
|
|
854
919
|
* local Ed25519 key to the host identity. Returns the redemption payload
|
|
855
|
-
* (client_id, client_token, release).
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
* @param {{ baseUrl: string, hostId: string, enrollmentToken: string, keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
|
|
920
|
+
* (client_id, client_token, release, optional execution_initialization). The
|
|
921
|
+
* selected intent is covered by the unchanged proof's exact pathname/query.
|
|
922
|
+
* Token authentication failures do not disclose whether redemption committed.
|
|
923
|
+
* @param {{ baseUrl: string, hostId: string, enrollmentToken: string, initialPermission?: 'user'|'admin', keyPair: { privateKey: import('node:crypto').KeyObject, publicKey: string, thumbprint: string } }} options
|
|
859
924
|
*/
|
|
860
|
-
export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair }) {
|
|
925
|
+
export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPair, initialPermission = 'user' }) {
|
|
926
|
+
if (!['user', 'admin'].includes(initialPermission)) throw new PairingError('INVALID_INITIAL_PERMISSION', 'Initial execution intent must be user or admin.');
|
|
861
927
|
const origin = String(baseUrl || '').replace(/\/$/, '');
|
|
862
|
-
const redeemPath =
|
|
928
|
+
const redeemPath = `/api/enroll/redeem?initial_permission=${initialPermission}`;
|
|
863
929
|
const proof = createDeviceProofCli({
|
|
864
930
|
privateKey: keyPair.privateKey,
|
|
865
931
|
hostId,
|
|
@@ -875,7 +941,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
875
941
|
{ 'NC-Device-Proof': proof }
|
|
876
942
|
);
|
|
877
943
|
} catch (err) {
|
|
878
|
-
throw new PairingError('REDEEM_NETWORK_ERROR', `
|
|
944
|
+
throw new PairingError('REDEEM_NETWORK_ERROR', `Redemption response was not received: ${err.message}. The server may have committed; preserve local identity and use authorized recovery rather than reminting or replaying initialization.`);
|
|
879
945
|
}
|
|
880
946
|
if (res.status === 200 && res.data) {
|
|
881
947
|
const r = res.data;
|
|
@@ -891,7 +957,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
891
957
|
if (res.status === 410 && serverError === 'ENROLLMENT_ALREADY_USED') {
|
|
892
958
|
throw new PairingError(
|
|
893
959
|
'ENROLLMENT_ALREADY_USED',
|
|
894
|
-
'
|
|
960
|
+
'The legacy server refused redemption. Preserve existing host/device identity and recover only through authorized existing credentials or management; no credentials were recovered by this attempt.'
|
|
895
961
|
);
|
|
896
962
|
}
|
|
897
963
|
if (res.status === 410) {
|
|
@@ -901,7 +967,7 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
901
967
|
);
|
|
902
968
|
}
|
|
903
969
|
if (res.status === 401) {
|
|
904
|
-
throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}).
|
|
970
|
+
throw new PairingError('REDEEM_UNAUTHORIZED', `Redemption was refused (${serverError || 'unauthorized'}). No credentials or initialization were recovered. Preserve any existing identity and use authorized credential/management recovery; do not blindly remint or replay.`);
|
|
905
971
|
}
|
|
906
972
|
throw new PairingError('REDEEM_REJECTED', `Redemption failed (${serverError || `HTTP ${res.status}`}). Re-run to request a fresh enrollment.`);
|
|
907
973
|
}
|
|
@@ -912,8 +978,9 @@ export async function redeemEnrollment({ baseUrl, hostId, enrollmentToken, keyPa
|
|
|
912
978
|
* uses). Polls until the identity is accepted or the timeout expires.
|
|
913
979
|
* Success here proves the env credentials actually authenticate; only then
|
|
914
980
|
* may the CLI print a success message.
|
|
915
|
-
* Device-channel endpoints
|
|
916
|
-
*
|
|
981
|
+
* Device-channel endpoints use the supplied agent destination. Resume callers
|
|
982
|
+
* must supply the stored NC_SERVER_URL, not the fresh-enrollment API default.
|
|
983
|
+
* `baseUrl` stays accepted for backward
|
|
917
984
|
* compatibility but an explicit `apiBase` (or `NC_API_URL`/default agent
|
|
918
985
|
* origin when neither is given) wins.
|
|
919
986
|
* @param {{ baseUrl?: string, apiBase?: string, hostId: string, hostToken: string, privateKey: import('node:crypto').KeyObject, timeoutMs?: number, intervalMs?: number }} options
|
|
@@ -977,11 +1044,11 @@ export async function verifyHostRegistration({ baseUrl: baseUrlOpt = undefined,
|
|
|
977
1044
|
* (`/releases/client/<version>/bootstrap.tar.gz`) stays anonymous. The
|
|
978
1045
|
* proof binds method GET + the full request URL + the redemption-issued
|
|
979
1046
|
* client token, exactly like redeemEnrollment.
|
|
980
|
-
* @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
|
|
1047
|
+
* @param {{ ncRoot?: string, updatesUrl?: string, serverUrl?: string, release?: Record<string, unknown>|null, installMode?: 'system'|'user', rootConsent?: boolean, runInstaller?: (scriptPath: string, ncRoot: string) => void, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }} options
|
|
981
1048
|
*/
|
|
982
|
-
export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
|
|
1049
|
+
export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl = undefined, serverUrl = undefined, release = undefined, installMode = undefined, rootConsent = false, runInstaller = undefined, hostId = undefined, hostToken = undefined, privateKey = undefined } = {}) {
|
|
983
1050
|
ncRoot = ncRoot || (installMode ? getNcRootForMode(installMode) : getNcRoot());
|
|
984
|
-
const modeEnv = installMode ? installerEnvForMode(installMode) : {};
|
|
1051
|
+
const modeEnv = { ...(installMode ? installerEnvForMode(installMode) : {}), NC_ROOT_COMMAND_CONSENT: rootConsent === true ? 'yes' : 'no' };
|
|
985
1052
|
const invokeInstaller =
|
|
986
1053
|
runInstaller || ((scriptPath, root) => runInstallerScript(scriptPath, root, modeEnv));
|
|
987
1054
|
const root = ncRoot;
|
|
@@ -1076,6 +1143,21 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1076
1143
|
tarballUrl = `${defaultBaseUrl}/releases/client/${encodeURIComponent(version)}/bootstrap.tar.gz`;
|
|
1077
1144
|
}
|
|
1078
1145
|
|
|
1146
|
+
const expectedHash = activeRelease.bootstrap_sha256 ?? activeRelease.sha256;
|
|
1147
|
+
if (typeof expectedHash !== 'string' || expectedHash.length !== 64 || !/^[a-f0-9]{64}$/i.test(expectedHash)) {
|
|
1148
|
+
throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid sha256 before downloading or installing.');
|
|
1149
|
+
}
|
|
1150
|
+
const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
|
|
1151
|
+
const expectedSize =
|
|
1152
|
+
typeof expectedSizeRaw === 'number'
|
|
1153
|
+
? expectedSizeRaw
|
|
1154
|
+
: typeof expectedSizeRaw === 'string' && expectedSizeRaw.length > 0 && !/[^0-9]/.test(expectedSizeRaw)
|
|
1155
|
+
? Number(expectedSizeRaw)
|
|
1156
|
+
: NaN;
|
|
1157
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize <= 0) {
|
|
1158
|
+
throw new PairingError('INSTALL_MANIFEST_ERROR', 'Agent release manifest must provide a valid positive integer size before downloading or installing.');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1079
1161
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-install-'));
|
|
1080
1162
|
const archivePath = path.join(tempDir, 'bootstrap.tar.gz');
|
|
1081
1163
|
|
|
@@ -1103,35 +1185,25 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1103
1185
|
throw new PairingError('INSTALL_DOWNLOAD_ERROR', `Could not download the agent package: ${err.message}`);
|
|
1104
1186
|
}
|
|
1105
1187
|
|
|
1106
|
-
const expectedHash = activeRelease.bootstrap_sha256 || activeRelease.sha256;
|
|
1107
|
-
const expectedSizeRaw = activeRelease.bootstrap_size_bytes ?? activeRelease.size_bytes;
|
|
1108
|
-
const expectedSize =
|
|
1109
|
-
expectedSizeRaw === undefined || expectedSizeRaw === null ? null : Number(expectedSizeRaw);
|
|
1110
1188
|
const archiveBytes = fs.readFileSync(archivePath);
|
|
1111
|
-
if (
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
);
|
|
1120
|
-
}
|
|
1189
|
+
if (archiveBytes.length !== expectedSize) {
|
|
1190
|
+
try {
|
|
1191
|
+
fs.unlinkSync(archivePath);
|
|
1192
|
+
} catch {}
|
|
1193
|
+
throw new PairingError(
|
|
1194
|
+
'INSTALL_MANIFEST_ERROR',
|
|
1195
|
+
`Agent package integrity check failed: size mismatch for ${tarballUrl} (expected ${expectedSize} bytes, got ${archiveBytes.length}). The download was discarded; nothing was installed.`
|
|
1196
|
+
);
|
|
1121
1197
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
);
|
|
1132
|
-
}
|
|
1133
|
-
} else {
|
|
1134
|
-
console.warn('⚠️ Release manifest lacks a valid sha256; skipping archive integrity check.');
|
|
1198
|
+
const actualHash = crypto.createHash('sha256').update(archiveBytes).digest('hex');
|
|
1199
|
+
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
1200
|
+
try {
|
|
1201
|
+
fs.unlinkSync(archivePath);
|
|
1202
|
+
} catch {}
|
|
1203
|
+
throw new PairingError(
|
|
1204
|
+
'INSTALL_MANIFEST_ERROR',
|
|
1205
|
+
`Agent package integrity check failed: sha256 mismatch for ${tarballUrl}. The download was discarded; nothing was installed.`
|
|
1206
|
+
);
|
|
1135
1207
|
}
|
|
1136
1208
|
|
|
1137
1209
|
console.log('📂 Extracting agent archive...');
|
|
@@ -1169,9 +1241,9 @@ export async function downloadAndInstallAgent({ ncRoot = undefined, updatesUrl =
|
|
|
1169
1241
|
* directly. `installMode` is forwarded to the installer via NC_MODE.
|
|
1170
1242
|
* Host split: enrollment (OAuth/redeem) uses the auth origin (`baseUrl` /
|
|
1171
1243
|
* `NC_BASE_URL`); device-channel verification (`/api/clients/*`) uses the
|
|
1172
|
-
* agent origin (`apiBase` / `NC_API_URL`)
|
|
1173
|
-
*
|
|
1174
|
-
* @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
|
|
1244
|
+
* agent origin (`apiBase` / `NC_API_URL`) for fresh enrollment. Local resume
|
|
1245
|
+
* binds verification and installation requests to the stored NC_SERVER_URL.
|
|
1246
|
+
* @param {{ baseUrl?: string, apiBase?: string, oauthToken?: string|null, enrollment?: { host_id: string, enrollment_token: string, server_url?: string }|null, ncRoot?: string, serverUrl?: string, deviceName?: string, platform?: string, installMode?: 'system'|'user', rootConsent?: boolean|(() => Promise<boolean>), browserBrand?: string, verifyTimeoutMs?: number, verifyIntervalMs?: number, hooks?: { installAgent?: (options: { ncRoot: string, serverUrl: string, release?: Record<string, unknown>|null, hostId?: string, hostToken?: string, rootConsent?: boolean, privateKey?: import('node:crypto').KeyObject }) => Promise<void> } }} options
|
|
1175
1247
|
*/
|
|
1176
1248
|
export async function pairDeviceAndInstall({
|
|
1177
1249
|
baseUrl: baseUrlParam = undefined,
|
|
@@ -1183,6 +1255,8 @@ export async function pairDeviceAndInstall({
|
|
|
1183
1255
|
deviceName = os.hostname(),
|
|
1184
1256
|
platform = mapEnrollPlatform(),
|
|
1185
1257
|
installMode = undefined,
|
|
1258
|
+
rootConsent = false,
|
|
1259
|
+
browserBrand = captureBrowserDefaultBrand(),
|
|
1186
1260
|
verifyTimeoutMs = 45000,
|
|
1187
1261
|
verifyIntervalMs = 2000,
|
|
1188
1262
|
hooks = {}
|
|
@@ -1209,15 +1283,20 @@ export async function pairDeviceAndInstall({
|
|
|
1209
1283
|
const installAgent =
|
|
1210
1284
|
hooks.installAgent ||
|
|
1211
1285
|
(async ({ ncRoot: installRoot, serverUrl: installServerUrl, release: installRelease = undefined, hostId: installHostId = undefined, hostToken: installHostToken = undefined, privateKey: installPrivateKey = undefined }) => {
|
|
1212
|
-
await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
|
|
1286
|
+
await downloadAndInstallAgent({ ncRoot: installRoot, serverUrl: installServerUrl, installMode, rootConsent: selectedRootConsent, release: installRelease, hostId: installHostId, hostToken: installHostToken, privateKey: installPrivateKey });
|
|
1213
1287
|
});
|
|
1288
|
+
let selectedRootConsent = false;
|
|
1214
1289
|
|
|
1215
1290
|
// Fast path — idempotent resume: usable host credentials already on disk
|
|
1216
1291
|
// are verified over the device channel and the installer is resumed. No
|
|
1217
1292
|
// new enrollment is minted, so a re-run can never orphan a host or hit
|
|
1218
1293
|
// ENROLLMENT_ALREADY_USED for a token it no longer holds.
|
|
1219
1294
|
const localEnv = loadValidHostEnv(envPath);
|
|
1295
|
+
assertMatchingHostIdentity(localEnv, enrollment?.host_id);
|
|
1220
1296
|
if (localEnv) {
|
|
1297
|
+
// Match the agent's HTTP base normalization without dropping a supported
|
|
1298
|
+
// path prefix or redirecting installed credentials to NC_API_URL.
|
|
1299
|
+
const storedServerUrl = localEnv.NC_SERVER_URL.replace(/\/$/, '');
|
|
1221
1300
|
let localKey = null;
|
|
1222
1301
|
try {
|
|
1223
1302
|
localKey = loadDeviceKeyPair(keyPath);
|
|
@@ -1228,7 +1307,7 @@ export async function pairDeviceAndInstall({
|
|
|
1228
1307
|
const prior = readPairState(statePath);
|
|
1229
1308
|
try {
|
|
1230
1309
|
await verifyHostRegistration({
|
|
1231
|
-
apiBase:
|
|
1310
|
+
apiBase: localEnv.NC_SERVER_URL,
|
|
1232
1311
|
hostId: localEnv.NC_CLIENT_ID,
|
|
1233
1312
|
hostToken: localEnv.NC_CLIENT_TOKEN,
|
|
1234
1313
|
privateKey: localKey.privateKey,
|
|
@@ -1241,10 +1320,10 @@ export async function pairDeviceAndInstall({
|
|
|
1241
1320
|
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
|
|
1242
1321
|
}
|
|
1243
1322
|
console.log(`🔁 Resuming installation for host ${localEnv.NC_CLIENT_ID} (stored credentials verified)...`);
|
|
1244
|
-
await installAgent({ ncRoot: root, serverUrl:
|
|
1323
|
+
await installAgent({ ncRoot: root, serverUrl: storedServerUrl, hostId: localEnv.NC_CLIENT_ID, hostToken: localEnv.NC_CLIENT_TOKEN, privateKey: localKey.privateKey });
|
|
1245
1324
|
writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'installed' });
|
|
1246
1325
|
await verifyHostRegistration({
|
|
1247
|
-
apiBase:
|
|
1326
|
+
apiBase: localEnv.NC_SERVER_URL,
|
|
1248
1327
|
hostId: localEnv.NC_CLIENT_ID,
|
|
1249
1328
|
hostToken: localEnv.NC_CLIENT_TOKEN,
|
|
1250
1329
|
privateKey: localKey.privateKey,
|
|
@@ -1254,23 +1333,25 @@ export async function pairDeviceAndInstall({
|
|
|
1254
1333
|
writePairState(statePath, { host_id: localEnv.NC_CLIENT_ID, phase: 'verified' });
|
|
1255
1334
|
return { hostId: localEnv.NC_CLIENT_ID, serverUrl: localEnv.NC_SERVER_URL, resumed: true };
|
|
1256
1335
|
} catch (err) {
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
// guidance: wiping stored credentials on a transient stall would
|
|
1260
|
-
// orphan a healthy host identity. The caller reports the stored-
|
|
1261
|
-
// credentials connectivity hint via suggestedActionFor.
|
|
1336
|
+
// Failure at the bound destination is not permission to enroll on
|
|
1337
|
+
// another server or replace this installed host identity.
|
|
1262
1338
|
if (err instanceof PairingError && err.code === 'VERIFY_FAILED') {
|
|
1263
|
-
|
|
1264
|
-
// server reset): fall through to a fresh enrollment, which
|
|
1265
|
-
// overwrites the stale files below.
|
|
1266
|
-
console.warn(`⚠️ Stored host credentials no longer verify (${err.code}); requesting a fresh enrollment...`);
|
|
1267
|
-
} else {
|
|
1268
|
-
throw err;
|
|
1339
|
+
throw new PairingError('VERIFY_FAILED', 'The existing host is not registered at its stored server. Preserve host/device credentials and use the existing installation maintenance flow.');
|
|
1269
1340
|
}
|
|
1341
|
+
throw err;
|
|
1270
1342
|
}
|
|
1271
1343
|
}
|
|
1272
1344
|
}
|
|
1273
1345
|
|
|
1346
|
+
const incomplete = readPairState(statePath);
|
|
1347
|
+
if (incomplete?.phase === 'enrolled' && HOST_ID_RE.test(String(incomplete.host_id || ''))) {
|
|
1348
|
+
throw new PairingError('REDEEM_RECOVERY_REQUIRED', 'A previous redemption may have committed without a saved response. Preserve local host/device identity; recover using authorized existing credentials or target management. No automatic remint or initialization replay was attempted.');
|
|
1349
|
+
}
|
|
1350
|
+
// Authentication/preflight precede this fresh-only question. Ask before
|
|
1351
|
+
// minting the five-minute token, or before redeeming an already running one.
|
|
1352
|
+
// Reuse this one literal answer for both signed intent and local installer.
|
|
1353
|
+
selectedRootConsent = (typeof rootConsent === 'function' ? await rootConsent() : rootConsent) === true;
|
|
1354
|
+
|
|
1274
1355
|
// Fresh enrollment with the OAuth USER token (user authorization only),
|
|
1275
1356
|
// or the caller-supplied enrollment from `setup <host_id> <token>`.
|
|
1276
1357
|
let activeEnrollment = enrollment;
|
|
@@ -1292,14 +1373,17 @@ export async function pairDeviceAndInstall({
|
|
|
1292
1373
|
let agentVersion = null;
|
|
1293
1374
|
/** @type {Record<string, unknown>|null} */
|
|
1294
1375
|
let redemptionRelease = null;
|
|
1295
|
-
|
|
1376
|
+
let executionInitialization = null;
|
|
1377
|
+
{
|
|
1296
1378
|
const redemption = await redeemEnrollment({
|
|
1297
1379
|
baseUrl: origin,
|
|
1298
1380
|
hostId,
|
|
1299
1381
|
enrollmentToken: activeEnrollment.enrollment_token,
|
|
1300
|
-
keyPair
|
|
1382
|
+
keyPair,
|
|
1383
|
+
initialPermission: selectedRootConsent ? 'admin' : 'user'
|
|
1301
1384
|
});
|
|
1302
1385
|
hostToken = redemption.client_token;
|
|
1386
|
+
executionInitialization = redemption.execution_initialization ?? null;
|
|
1303
1387
|
if (redemption.release && typeof redemption.release === 'object') {
|
|
1304
1388
|
redemptionRelease = /** @type {Record<string, unknown>} */ (redemption.release);
|
|
1305
1389
|
}
|
|
@@ -1307,30 +1391,16 @@ export async function pairDeviceAndInstall({
|
|
|
1307
1391
|
agentVersion = redemption.release.version;
|
|
1308
1392
|
console.log(`📦 Release: ${agentVersion}`);
|
|
1309
1393
|
}
|
|
1310
|
-
} catch (err) {
|
|
1311
|
-
if (err instanceof PairingError && err.code === 'ENROLLMENT_ALREADY_USED') {
|
|
1312
|
-
// A previous run consumed this token and stored the credentials:
|
|
1313
|
-
// resume from them instead of failing on the single-use rule.
|
|
1314
|
-
const again = loadValidHostEnv(envPath);
|
|
1315
|
-
if (again && again.NC_CLIENT_ID === hostId) {
|
|
1316
|
-
console.log('🔁 Enrollment was already redeemed by an earlier run — resuming from stored credentials...');
|
|
1317
|
-
hostToken = again.NC_CLIENT_TOKEN;
|
|
1318
|
-
} else {
|
|
1319
|
-
throw err;
|
|
1320
|
-
}
|
|
1321
|
-
} else {
|
|
1322
|
-
throw err;
|
|
1323
|
-
}
|
|
1324
1394
|
}
|
|
1325
1395
|
|
|
1326
1396
|
// Host credentials ONLY — the OAuth token is never written here.
|
|
1327
1397
|
console.log('📝 Creating configuration file...');
|
|
1328
|
-
writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName }));
|
|
1398
|
+
writeHostEnvFile(envPath, buildHostEnvContent({ serverUrl: enrollServerUrl, hostId, hostToken, clientName: deviceName, browserBrand }));
|
|
1329
1399
|
console.log(`📝 Configuration saved to ${envPath}`);
|
|
1330
1400
|
writePairState(statePath, { host_id: hostId, phase: 'redeemed' });
|
|
1331
1401
|
|
|
1332
1402
|
console.log('🚀 Installing agent...');
|
|
1333
|
-
await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey });
|
|
1403
|
+
await installAgent({ ncRoot: root, serverUrl: enrollServerUrl, release: redemptionRelease, hostId, hostToken, privateKey: keyPair.privateKey, rootConsent: selectedRootConsent });
|
|
1334
1404
|
writePairState(statePath, { host_id: hostId, phase: 'installed' });
|
|
1335
1405
|
|
|
1336
1406
|
console.log('🔍 Verifying host registration...');
|
|
@@ -1343,7 +1413,8 @@ export async function pairDeviceAndInstall({
|
|
|
1343
1413
|
intervalMs: verifyIntervalMs
|
|
1344
1414
|
});
|
|
1345
1415
|
writePairState(statePath, { host_id: hostId, phase: 'verified' });
|
|
1346
|
-
return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion
|
|
1416
|
+
return { hostId, serverUrl: enrollServerUrl, resumed: false, version: agentVersion, rootConsent: selectedRootConsent,
|
|
1417
|
+
privilegeReadiness: selectedRootConsent ? 'pending' : 'unavailable', executionInitialization };
|
|
1347
1418
|
}
|
|
1348
1419
|
|
|
1349
1420
|
/**
|
|
@@ -1381,6 +1452,7 @@ function printCliHelp() {
|
|
|
1381
1452
|
' nodecommand pair this machine via the browser device flow',
|
|
1382
1453
|
' nodecommand <host_id> install with a dashboard host id (prompts for the token, hidden input)',
|
|
1383
1454
|
' nodecommand setup [host_id token] install the agent (auto system/user mode; no args = browser device flow)',
|
|
1455
|
+
' nodecommand [--allow-root-commands] explicitly request privileged preparation (default No; independent binding required)',
|
|
1384
1456
|
' nodecommand uninstall [--yes] remove the agent from the canonical root (alias: remove)',
|
|
1385
1457
|
' nodecommand remove [--yes] uninstall the agent (asks for confirmation unless --yes)',
|
|
1386
1458
|
' nodecommand --diagnostics preview a redacted diagnostics bundle (dry-run, writes nothing)',
|
|
@@ -1487,17 +1559,22 @@ async function main() {
|
|
|
1487
1559
|
// form the dashboard and install_command used to emit) to the `setup
|
|
1488
1560
|
// <host_id> <token>` path instead of silently ignoring the arguments in the
|
|
1489
1561
|
// device flow.
|
|
1490
|
-
|
|
1562
|
+
const enrollmentArgs = cliArgs.filter(arg => arg !== '--allow-root-commands');
|
|
1563
|
+
if (isBareEnrollmentInvocation(enrollmentArgs)) {
|
|
1491
1564
|
await runSetup(cliArgs);
|
|
1492
1565
|
return;
|
|
1493
1566
|
}
|
|
1494
1567
|
// Canonical token-free form: the single-use token is typed at a masked
|
|
1495
1568
|
// interactive prompt inside runSetup, so it never enters argv (shell
|
|
1496
1569
|
// history, `ps`, recordings, telemetry).
|
|
1497
|
-
if (isBareHostIdInvocation(
|
|
1570
|
+
if (isBareHostIdInvocation(enrollmentArgs)) {
|
|
1498
1571
|
await runSetup(cliArgs, { allowHostIdOnly: true });
|
|
1499
1572
|
return;
|
|
1500
1573
|
}
|
|
1574
|
+
if (enrollmentArgs.length === 0) {
|
|
1575
|
+
await runSetup(cliArgs);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1501
1578
|
await runDeviceFlow();
|
|
1502
1579
|
}
|
|
1503
1580
|
|
|
@@ -1509,6 +1586,8 @@ async function main() {
|
|
|
1509
1586
|
*/
|
|
1510
1587
|
export function suggestedActionFor(code) {
|
|
1511
1588
|
switch (code) {
|
|
1589
|
+
case 'HOST_ID_MISMATCH':
|
|
1590
|
+
return 'Preserve the existing credentials and use the existing host maintenance flow with its current mode/root. Check the requested host id and installation target before trying enrollment again.';
|
|
1512
1591
|
case 'ENROLL_NO_OAUTH_TOKEN':
|
|
1513
1592
|
return 'Re-run `nodecommand setup` without arguments and approve the browser device flow.';
|
|
1514
1593
|
case 'ENROLL_UNAUTHORIZED':
|
|
@@ -1519,7 +1598,10 @@ export function suggestedActionFor(code) {
|
|
|
1519
1598
|
case 'PLAN_HOST_LIMIT_EXCEEDED':
|
|
1520
1599
|
return 'Upgrade the workspace plan or remove an old host, then re-run.';
|
|
1521
1600
|
case 'ENROLLMENT_ALREADY_USED':
|
|
1522
|
-
|
|
1601
|
+
case 'REDEEM_RECOVERY_REQUIRED':
|
|
1602
|
+
case 'REDEEM_NETWORK_ERROR':
|
|
1603
|
+
case 'REDEEM_UNAUTHORIZED':
|
|
1604
|
+
return 'Preserve host/device identity. Resume only with authorized stored host credentials; otherwise use authenticated target-management recovery. Do not automatically remint or replay initialization after an uncertain redemption.';
|
|
1523
1605
|
case 'ENROLLMENT_EXPIRED':
|
|
1524
1606
|
case 'INVALID_ENROLL_TOKEN':
|
|
1525
1607
|
case 'INVALID_HOST_ID':
|
|
@@ -1584,15 +1666,53 @@ function getCliVersion() {
|
|
|
1584
1666
|
/**
|
|
1585
1667
|
* Prints the one-line install summary required after a successful setup:
|
|
1586
1668
|
* host id, install mode, CLI version, and verification status.
|
|
1587
|
-
* @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null }} options
|
|
1669
|
+
* @param {{ hostId: string, serverUrl: string, installMode?: 'system'|'user', resumed?: boolean, version?: string|null, rootConsent?: boolean, ncRoot?: string, executionInitialization?: any }} options
|
|
1588
1670
|
*/
|
|
1589
|
-
function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null }) {
|
|
1671
|
+
export function printSetupSuccess({ hostId, serverUrl, installMode = undefined, resumed = false, version = null, rootConsent = false, ncRoot = undefined, executionInitialization = null }) {
|
|
1590
1672
|
console.log(`\n✅ Setup complete for host ${hostId}${resumed ? ' (resumed)' : ''}.`);
|
|
1591
1673
|
if (installMode) console.log(`installed as ${installMode}`);
|
|
1592
1674
|
if (version) console.log(`agent version: ${version}`);
|
|
1593
1675
|
console.log(`cli version: ${getCliVersion()}`);
|
|
1594
1676
|
console.log(`server: ${serverUrl}`);
|
|
1595
1677
|
console.log('status: agent credentials verified online');
|
|
1678
|
+
if (ncRoot) console.log(`installation root: ${ncRoot}`);
|
|
1679
|
+
console.log('OS execution identity: not verified by this credential check; native service/readiness verification required');
|
|
1680
|
+
console.log('agent runtime: installer-selected; not verified by this credential check (customer global Node/PATH unchanged)');
|
|
1681
|
+
if (resumed) {
|
|
1682
|
+
console.log('execution permission: existing choice preserved; no privilege maintenance performed');
|
|
1683
|
+
} else {
|
|
1684
|
+
console.log(`root command consent: ${rootConsent === true ? 'Yes' : 'No'}`);
|
|
1685
|
+
console.log(`local privilege readiness: ${rootConsent === true ? 'pending independent management approval and protected helper binding' : 'unavailable (not requested)'}`);
|
|
1686
|
+
const receipt = executionInitialization;
|
|
1687
|
+
const fields = ['outcome', 'reason', 'subject_user_id', 'permission'];
|
|
1688
|
+
const reasons = { requested: ['admin_requested'], preserved: ['existing_permission_preserved', 'existing_host_preserved'],
|
|
1689
|
+
defaulted: ['default_user', 'verified_origin_unavailable', 'origin_authority_changed', 'origin_not_owner', 'origin_inactive'] };
|
|
1690
|
+
const closed = receipt && typeof receipt === 'object' && !Array.isArray(receipt) && Object.keys(receipt).length === fields.length &&
|
|
1691
|
+
fields.every(field => Object.hasOwn(receipt, field)) && typeof receipt.outcome === 'string' && typeof receipt.reason === 'string' &&
|
|
1692
|
+
Object.hasOwn(reasons, receipt.outcome) && reasons[receipt.outcome].includes(receipt.reason) &&
|
|
1693
|
+
(receipt.subject_user_id === null || (typeof receipt.subject_user_id === 'string' && receipt.subject_user_id.length > 0)) &&
|
|
1694
|
+
(receipt.subject_user_id !== null || receipt.permission === null);
|
|
1695
|
+
const permission = closed ? receipt.permission : null;
|
|
1696
|
+
const projectionFields = ['requested_permission', 'applied_permission', 'permission_generation', 'permission_revision', 'applied_revision', 'enforcement_state', 'changed_by', 'updated_at'];
|
|
1697
|
+
const projected = permission && typeof permission === 'object' && !Array.isArray(permission) &&
|
|
1698
|
+
Object.keys(permission).length === projectionFields.length && projectionFields.every(field => Object.hasOwn(permission, field)) &&
|
|
1699
|
+
['user', 'admin'].includes(permission.requested_permission) && [null, 'user', 'admin'].includes(permission.applied_permission) &&
|
|
1700
|
+
[permission.permission_revision, permission.applied_revision].every(value => value === null || (Number.isSafeInteger(value) && value >= 0)) &&
|
|
1701
|
+
[permission.permission_generation, permission.changed_by].every(value => value === null || typeof value === 'string') &&
|
|
1702
|
+
typeof permission.enforcement_state === 'string' && (permission.updated_at === null || typeof permission.updated_at === 'string' || Number.isFinite(permission.updated_at));
|
|
1703
|
+
if (!closed || (permission !== null && !projected)) {
|
|
1704
|
+
console.log('personal execution initialization: unknown (server receipt unavailable or invalid); no User/Admin initialization claimed');
|
|
1705
|
+
} else {
|
|
1706
|
+
console.log(`personal execution initialization: ${receipt.outcome} (${receipt.reason})`);
|
|
1707
|
+
if (!projected) console.log('personal execution permission: unknown (no verified applicable personal record reported)');
|
|
1708
|
+
else {
|
|
1709
|
+
const label = value => value === 'admin' ? 'Admin' : value === 'user' ? 'User' : 'unknown';
|
|
1710
|
+
const state = permission.enforcement_state === 'pending' ? 'pending' : permission.enforcement_state === 'applied' ? 'server-reported applied' : 'not verified';
|
|
1711
|
+
console.log(`personal execution permission: server requested ${label(permission.requested_permission)}; enforcement ${state}`);
|
|
1712
|
+
console.log(`server-reported applied personal permission: ${label(permission.applied_permission)}; local Root enforcement not verified by setup`);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1596
1716
|
}
|
|
1597
1717
|
|
|
1598
1718
|
/**
|
|
@@ -1615,17 +1735,20 @@ const WINDOWS_ELEVATION_UNINSTALL_DECLINED =
|
|
|
1615
1735
|
* console is visible) and returns the spawnSync result. Never throws: a
|
|
1616
1736
|
* missing powershell or a declined prompt resolves to null/non-zero.
|
|
1617
1737
|
*/
|
|
1618
|
-
function requestWindowsElevation() {
|
|
1619
|
-
const
|
|
1738
|
+
function requestWindowsElevation(browserBrand = undefined) {
|
|
1739
|
+
const originalArgs = [...process.argv.slice(1)];
|
|
1740
|
+
const args = browserBrand === undefined ? originalArgs : browserBrandReexecArgs(originalArgs, browserBrand);
|
|
1741
|
+
const literal = value => "'" + value.replace(/'/g, "''") + "'";
|
|
1742
|
+
const quote = value => '"' + String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
|
|
1743
|
+
const script = `$p=Start-Process -FilePath ${literal(process.execPath)} -ArgumentList ${literal(args.map(quote).join(' '))} -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
|
|
1620
1744
|
try {
|
|
1621
1745
|
return child_process.spawnSync(
|
|
1622
|
-
'powershell',
|
|
1746
|
+
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
|
1623
1747
|
[
|
|
1624
1748
|
'-NoProfile',
|
|
1625
|
-
'-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
.join(',')} -Verb RunAs -Wait`
|
|
1749
|
+
'-NonInteractive',
|
|
1750
|
+
'-EncodedCommand',
|
|
1751
|
+
Buffer.from(script, 'utf16le').toString('base64')
|
|
1629
1752
|
],
|
|
1630
1753
|
{ stdio: 'inherit' }
|
|
1631
1754
|
);
|
|
@@ -1638,7 +1761,9 @@ function requestWindowsElevation() {
|
|
|
1638
1761
|
* `nodecommand setup [host_id enrollment_token]`.
|
|
1639
1762
|
*
|
|
1640
1763
|
* Mode is automatic (never prompted): elevated processes install machine-wide.
|
|
1641
|
-
*
|
|
1764
|
+
* Fresh non-root macOS installs use the existing user LaunchAgent, including
|
|
1765
|
+
* administrator accounts; existing/conflicting state is checked first.
|
|
1766
|
+
* Linux: a non-root admin requests sudo; a refusal falls back to a per-user
|
|
1642
1767
|
* install with a single-line capability notice. Windows: a standard shell
|
|
1643
1768
|
* requests UAC elevation instead and hard-stops (exit 1) when elevation is
|
|
1644
1769
|
* unavailable or declined — never a silent %LOCALAPPDATA% degradation.
|
|
@@ -1663,13 +1788,56 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
1663
1788
|
' nodecommand setup install via the browser device flow (auto system/user mode)',
|
|
1664
1789
|
' nodecommand setup <host_id> <token> install with a dashboard enrollment token (legacy positional form)',
|
|
1665
1790
|
'',
|
|
1666
|
-
'
|
|
1667
|
-
'
|
|
1791
|
+
' nodecommand setup --maintain-identity <host_id> explicit root-only selected identity migration (NC_ROOT required)',
|
|
1792
|
+
' nodecommand setup --configure-helper-binding <host_id> <management_response.json> <signed_challenge.json>',
|
|
1793
|
+
' --activate-initial-binding explicitly activates only the local initial binding and restarts unsynchronized.',
|
|
1794
|
+
'',
|
|
1795
|
+
'macOS: fresh non-root installs use the per-user LaunchAgent without sudo;',
|
|
1796
|
+
'explicit root uses system mode. Existing roots/modes are preserved or setup stops.',
|
|
1797
|
+
'Windows/Linux retain their existing automatic system/user elevation behavior.',
|
|
1798
|
+
'Service installation authorization does not allow root commands through chat.',
|
|
1799
|
+
'New setup asks: Allow root/administrator commands through Node Command? [y/N]',
|
|
1800
|
+
'Noninteractive setup defaults to No; --allow-root-commands explicitly requests preparation.',
|
|
1801
|
+
'Admin stays unavailable until protected helper binding and management approval are verified.'
|
|
1668
1802
|
].join('\n')
|
|
1669
1803
|
);
|
|
1670
1804
|
return;
|
|
1671
1805
|
}
|
|
1672
|
-
assertKnownFlags(args, ['--help', '-h'], 'setup');
|
|
1806
|
+
assertKnownFlags(args, ['--help', '-h', '--allow-root-commands', '--maintain-identity', '--configure-helper-binding', '--activate-initial-binding'], 'setup');
|
|
1807
|
+
if (args.includes('--configure-helper-binding')) {
|
|
1808
|
+
if (positionals.length !== 3 || !process.env.NC_ROOT || args.includes('--allow-root-commands') || args.includes('--maintain-identity'))
|
|
1809
|
+
throw new PairingError('INVALID_SETUP_ARGS', 'Selected helper binding maintenance needs NC_ROOT, existing host ID, protected management response and signed challenge.');
|
|
1810
|
+
const producer = await loadProtectedHelperProducer(process.env.NC_ROOT);
|
|
1811
|
+
const files = producer.protectedHelperPaths(process.env.NC_ROOT);
|
|
1812
|
+
const selected = JSON.parse(fs.readFileSync(files.configFile, 'utf8'));
|
|
1813
|
+
assertMatchingHostIdentity({ NC_CLIENT_ID: selected.client_id }, positionals[0]);
|
|
1814
|
+
const result = await producer.configureProtectedHelperBinding({ root: process.env.NC_ROOT, bindingFile: path.resolve(positionals[1]),
|
|
1815
|
+
challengeFile: path.resolve(positionals[2]), activateLocally: args.includes('--activate-initial-binding') });
|
|
1816
|
+
console.log(JSON.stringify(result));
|
|
1817
|
+
console.log('Local binding preparation is not execution readiness. The control plane must consume fresh proof, then complete signed sync and helper ACK.');
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (args.includes('--activate-initial-binding')) throw new PairingError('INVALID_SETUP_ARGS', 'Initial binding activation requires explicit --configure-helper-binding maintenance.');
|
|
1821
|
+
if (args.includes('--maintain-identity')) {
|
|
1822
|
+
if (positionals.length !== 1 || !process.env.NC_ROOT || args.includes('--allow-root-commands')) throw new PairingError('INVALID_SETUP_ARGS', 'Selected identity maintenance needs NC_ROOT and exactly one existing host ID; root-command consent is a separate operation.');
|
|
1823
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new PairingError('INSTALL_FAILED', 'Selected identity maintenance requires an interactive operator to confirm exact-host reconnection in authenticated management; no account or service was changed.');
|
|
1824
|
+
console.log('Selected OS identity maintenance preserves the existing personal permission and does not enable root commands through chat.');
|
|
1825
|
+
const result = await maintainLinuxIdentity({ ncRoot: process.env.NC_ROOT, hostId: positionals[0], operatorUser: process.env.NC_OPERATOR_USER,
|
|
1826
|
+
authorizeMaintenance: async facts => {
|
|
1827
|
+
await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
|
|
1828
|
+
console.log(`Selected target: ${facts.client_id}; issuer ${facts.server_url}; root ${facts.ncRoot}; device SPKI sha256:${facts.device_key_id}; current OS account ${facts.old_user} (${facts.uid}:${facts.gid}).`);
|
|
1829
|
+
return confirmPrompt('Confirm this exact target is authorized for identity maintenance and is NOT held, in your authenticated management session? [y/N] ');
|
|
1830
|
+
},
|
|
1831
|
+
verifyReconnection: async facts => {
|
|
1832
|
+
await verifyHostRegistration({ apiBase: facts.server_url, hostId: facts.client_id, hostToken: facts.hostToken, privateKey: facts.privateKey });
|
|
1833
|
+
console.log(`Verify host ${facts.client_id} on ${facts.server_url}; device SPKI sha256:${facts.device_key_id}; OS identity nodecommand (${facts.uid}:${facts.gid}); root ${facts.ncRoot}.`);
|
|
1834
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
1835
|
+
return confirmPrompt('Confirm this exact host reconnected in your authenticated management session as nodecommand? [y/N] ');
|
|
1836
|
+
} });
|
|
1837
|
+
console.log(`Identity maintenance verified: ${result.client_id}, ${result.ncRoot}, nodecommand (${result.uid}:${result.gid}). Existing personal permission preserved; privileged support unavailable pending independent maintenance/binding.`);
|
|
1838
|
+
if (result.workspace) console.log(`Writable workspace: ${result.workspace}. Additional data/workspace roots require explicit authorized signed policy changes; no login-home ACLs, services or credentials were inherited.`);
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1673
1841
|
// Arity is validated BEFORE any mode/root output: an invalid invocation
|
|
1674
1842
|
// must fail without printing an installation plan it will not execute.
|
|
1675
1843
|
// One positional (host id) is valid only for the bare `nodecommand
|
|
@@ -1680,18 +1848,85 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
1680
1848
|
console.error('suggested_action: Re-run as `nodecommand setup` or `nodecommand setup <host_id> <token>`.');
|
|
1681
1849
|
process.exit(1);
|
|
1682
1850
|
}
|
|
1851
|
+
const browserBrand = captureBrowserDefaultBrand();
|
|
1683
1852
|
let installMode = decideInstallMode();
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1853
|
+
let ncRoot;
|
|
1854
|
+
let existingTarget;
|
|
1855
|
+
let existingEnv;
|
|
1856
|
+
let existingKey;
|
|
1857
|
+
let rootManagedPair;
|
|
1858
|
+
let protectedKeyContext;
|
|
1859
|
+
{
|
|
1860
|
+
try {
|
|
1861
|
+
const target = setupTarget({ mode: installMode });
|
|
1862
|
+
({ mode: installMode, ncRoot } = target);
|
|
1863
|
+
existingTarget = target.existing;
|
|
1864
|
+
rootManagedPair = target.rootManagedPair;
|
|
1865
|
+
protectedKeyContext = (process.getuid?.() === 0 || process.geteuid?.() === 0) ? target.protectedInstallation : null;
|
|
1866
|
+
if (target.existing) {
|
|
1867
|
+
const localEnv = loadValidHostEnv(getEnvFilePath(ncRoot));
|
|
1868
|
+
if (!localEnv) throw new Error(`Existing host configuration at ${ncRoot} is incomplete; refusing a new enrollment or credential overwrite.`);
|
|
1869
|
+
if (target.clientId && localEnv.NC_CLIENT_ID !== target.clientId) throw new Error('Existing ordinary host identity conflicts with the protected helper selection; explicit Root maintenance is required.');
|
|
1870
|
+
assertMatchingHostIdentity(localEnv, positionals[0]);
|
|
1871
|
+
// Missing/unreadable/invalid keys are not permission to regenerate an
|
|
1872
|
+
// existing device identity. The normal verified resume flow is kept.
|
|
1873
|
+
const keyPath = getDeviceKeyPath(ncRoot);
|
|
1874
|
+
if (!protectedKeyContext) {
|
|
1875
|
+
const keyInfo = fs.lstatSync(keyPath);
|
|
1876
|
+
if (!keyInfo.isFile() || keyInfo.isSymbolicLink() || keyInfo.size > 65536 ||
|
|
1877
|
+
(os.platform() !== 'win32' && installMode === 'user' && keyInfo.uid !== process.getuid())) {
|
|
1878
|
+
throw new Error(`Existing device key at ${ncRoot} has an unverifiable type/owner; refusing identity replacement.`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
try {
|
|
1882
|
+
existingKey = protectedKeyContext ? loadRootManagedPairKey(ncRoot, protectedKeyContext.ordinaryUid) : loadDeviceKeyPair(keyPath);
|
|
1883
|
+
} catch {
|
|
1884
|
+
throw new Error(`Existing device key at ${ncRoot} cannot be verified; preserve it for selected-installation maintenance.`);
|
|
1885
|
+
}
|
|
1886
|
+
existingEnv = localEnv;
|
|
1887
|
+
const appInfo = fs.lstatSync(path.join(ncRoot, 'app'));
|
|
1888
|
+
if (!appInfo.isDirectory() || appInfo.isSymbolicLink()) throw new Error(`Existing application at ${ncRoot} is incomplete; use selected-installation maintenance.`);
|
|
1889
|
+
}
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
if (err instanceof PairingError && err.code === 'HOST_ID_MISMATCH') {
|
|
1892
|
+
reportSetupError(err);
|
|
1893
|
+
} else {
|
|
1894
|
+
console.error(`error: [INSTALL_FAILED] ${err instanceof Error ? err.message : 'Cannot verify installation state.'}`);
|
|
1895
|
+
console.error('suggested_action: Preserve the existing mode/root; resolve conflicting state through its existing maintenance flow, with explicit administrator rights for system installs.');
|
|
1896
|
+
}
|
|
1897
|
+
process.exit(1);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (existingTarget) {
|
|
1902
|
+
// An installed tree is verification-only here. In particular, a legacy
|
|
1903
|
+
// tree without pairing-state metadata must not rerun OS installers that
|
|
1904
|
+
// can replace shared services/helpers. Repair is selected maintenance.
|
|
1905
|
+
console.log(`Verifying existing ${rootManagedPair ? 'Root-managed paired' : installMode} installation at ${ncRoot}, host: ${existingEnv.NC_CLIENT_ID}.`);
|
|
1906
|
+
if (rootManagedPair) console.log(`Local management: Root; ordinary service scope: ${rootManagedPair.serviceScope}; selected UID: ${rootManagedPair.ordinaryUid} (credential verification only; no user impersonation).`);
|
|
1907
|
+
try {
|
|
1908
|
+
await verifyHostRegistration({ apiBase: existingEnv.NC_SERVER_URL, hostId: existingEnv.NC_CLIENT_ID, hostToken: existingEnv.NC_CLIENT_TOKEN, privateKey: existingKey.privateKey });
|
|
1909
|
+
printSetupSuccess({ hostId: existingEnv.NC_CLIENT_ID, serverUrl: existingEnv.NC_SERVER_URL, installMode: rootManagedPair ? undefined : installMode, ncRoot, resumed: true });
|
|
1910
|
+
if (args.includes('--allow-root-commands')) console.warn('Existing root consent unchanged. Enabling root requires explicit selected-installation privileged maintenance and independent management approval.');
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
const code = err instanceof PairingError ? err.code : 'INSTALL_FAILED';
|
|
1913
|
+
const detail = code === 'VERIFY_FAILED' ? 'The existing host is not registered on the server; stored identity is unchanged.' : err.message;
|
|
1914
|
+
console.error(`error: [${code}] ${detail}`);
|
|
1915
|
+
console.error(`suggested_action: Preserve host/device credentials at ${ncRoot}; use this installation's maintenance flow to repair registration or connectivity. No new enrollment was requested.`);
|
|
1916
|
+
process.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
// macOS has already resolved its user/system boundary without automatic
|
|
1921
|
+
// elevation. Linux requests sudo; Windows requests UAC (Start-Process
|
|
1687
1922
|
// -Verb RunAs) so the install lands machine-wide at the canonical system
|
|
1688
1923
|
// root. A refused or unavailable Windows elevation is a hard stop: the CLI
|
|
1689
1924
|
// never silently degrades a system install into %LOCALAPPDATA%.
|
|
1690
|
-
if (installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
|
|
1925
|
+
if (os.platform() !== 'darwin' && installMode === 'user' && canElevate() && !process.env.NC_ELEVATED) {
|
|
1691
1926
|
if (os.platform() === 'win32') {
|
|
1692
1927
|
if (process.stdin.isTTY) {
|
|
1693
1928
|
console.log('Admin rights required for a system-wide install. Requesting UAC elevation…');
|
|
1694
|
-
const elevated = requestWindowsElevation();
|
|
1929
|
+
const elevated = requestWindowsElevation(browserBrand);
|
|
1695
1930
|
if (elevated && elevated.status === 0) process.exit(0);
|
|
1696
1931
|
}
|
|
1697
1932
|
console.error(WINDOWS_ELEVATION_INSTALL_DECLINED);
|
|
@@ -1704,12 +1939,24 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
1704
1939
|
{ stdio: 'inherit', env: { ...process.env, NC_ELEVATED: '1' } }
|
|
1705
1940
|
);
|
|
1706
1941
|
if (elevated && elevated.status === 0) process.exit(0);
|
|
1942
|
+
try {
|
|
1943
|
+
// A failed elevated child may already have created an installation.
|
|
1944
|
+
// Recheck before falling back rather than creating a competing agent.
|
|
1945
|
+
const target = setupTarget({ mode: installMode });
|
|
1946
|
+
if (target.existing) throw new Error(`An installation now exists at ${target.ncRoot}; preserve it through its maintenance flow.`);
|
|
1947
|
+
ncRoot = target.ncRoot;
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
console.error(`error: [INSTALL_FAILED] ${err.message}`);
|
|
1950
|
+
console.error('suggested_action: Preserve existing roots and select their maintenance flow; declining elevation does not authorize a second installation.');
|
|
1951
|
+
process.exit(1);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1707
1954
|
console.warn('Elevation unavailable or declined; continuing with a per-user install.');
|
|
1708
1955
|
}
|
|
1709
1956
|
// Mode-aware root: on win32 a system install lives in %ProgramData%\NC,
|
|
1710
1957
|
// not the %LOCALAPPDATA% tree getNcRoot() returns, so credentials must be
|
|
1711
1958
|
// written where the system installer will actually read them.
|
|
1712
|
-
|
|
1959
|
+
ncRoot = ncRoot || getNcRootForMode(installMode);
|
|
1713
1960
|
console.log(installMode === 'system' ? `Installing as system (machine-wide), root: ${ncRoot}.` : `Installing as user (per-user), root: ${ncRoot}.`);
|
|
1714
1961
|
const warning = modeWarningFor(installMode);
|
|
1715
1962
|
if (warning) console.warn(warning);
|
|
@@ -1719,6 +1966,7 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
1719
1966
|
// forwards only the host id). Masked input: the token is not echoed.
|
|
1720
1967
|
if (onePositionalOk) {
|
|
1721
1968
|
try {
|
|
1969
|
+
assertMatchingHostIdentity(loadValidHostEnv(getEnvFilePath(ncRoot)), positionals[0]);
|
|
1722
1970
|
const enrollmentToken = await promptToken({ input: process.stdin, output: process.stderr });
|
|
1723
1971
|
positionals.push(String(enrollmentToken || ''));
|
|
1724
1972
|
} catch (err) {
|
|
@@ -1727,6 +1975,7 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
1727
1975
|
}
|
|
1728
1976
|
}
|
|
1729
1977
|
|
|
1978
|
+
const rootConsent = () => promptRootCommandConsent({ explicitOptIn: args.includes('--allow-root-commands') });
|
|
1730
1979
|
if (positionals.length === 2) {
|
|
1731
1980
|
const [hostId, enrollmentToken] = positionals;
|
|
1732
1981
|
try {
|
|
@@ -1734,17 +1983,27 @@ export async function runSetup(args = [], { allowHostIdOnly = false, promptToken
|
|
|
1734
1983
|
baseUrl,
|
|
1735
1984
|
apiBase,
|
|
1736
1985
|
enrollment: { host_id: hostId, enrollment_token: enrollmentToken },
|
|
1986
|
+
browserBrand,
|
|
1737
1987
|
installMode,
|
|
1988
|
+
rootConsent,
|
|
1738
1989
|
ncRoot
|
|
1739
1990
|
});
|
|
1740
|
-
printSetupSuccess({
|
|
1991
|
+
printSetupSuccess({ ...result, installMode, ncRoot });
|
|
1741
1992
|
} catch (err) {
|
|
1742
1993
|
reportSetupError(err);
|
|
1743
1994
|
process.exit(1);
|
|
1744
1995
|
}
|
|
1745
1996
|
return;
|
|
1746
1997
|
}
|
|
1747
|
-
await runDeviceFlow({ installMode, ncRoot });
|
|
1998
|
+
await runDeviceFlow({ installMode, ncRoot, browserBrand, rootConsent });
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
export const ROOT_COMMAND_CONSENT_PROMPT = 'Allow root/administrator commands through Node Command? [y/N]';
|
|
2002
|
+
|
|
2003
|
+
export async function promptRootCommandConsent({ explicitOptIn = false, input = process.stdin, output = process.stdout, confirm = confirmPrompt } = {}) {
|
|
2004
|
+
if (explicitOptIn === true) return true;
|
|
2005
|
+
if (!input.isTTY || !output.isTTY) return false;
|
|
2006
|
+
return (await confirm(ROOT_COMMAND_CONSENT_PROMPT + ' ')) === true;
|
|
1748
2007
|
}
|
|
1749
2008
|
|
|
1750
2009
|
/**
|
|
@@ -1853,8 +2112,8 @@ export function confirmPrompt(question, openInterface = null) {
|
|
|
1853
2112
|
* tree is executed (never a downloaded copy) and its exit status is reported.
|
|
1854
2113
|
* Target discovery is centered on the canonical root (candidateInstallRoots
|
|
1855
2114
|
* lists it first) and treats an unreadable system tree as installed, so a
|
|
1856
|
-
* non-root shell
|
|
1857
|
-
* "nothing to remove".
|
|
2115
|
+
* non-root shell reports trusted maintenance as required instead of falsely
|
|
2116
|
+
* reporting "nothing to remove". Discovery never authorizes root execution.
|
|
1858
2117
|
* @param {string[]} args
|
|
1859
2118
|
* @param {{ command: string, emptyMessage: string, elevateOnWindows?: boolean, completionMessage: string }} options
|
|
1860
2119
|
*/
|
|
@@ -1871,7 +2130,14 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1871
2130
|
process.exit(1);
|
|
1872
2131
|
}
|
|
1873
2132
|
const yes = args.includes('--yes') || args.includes('-y');
|
|
1874
|
-
|
|
2133
|
+
let discovery;
|
|
2134
|
+
try { discovery = findRemovalTargets(); } catch (err) {
|
|
2135
|
+
console.error(`error: [TRUSTED_ROOT_MAINTENANCE_REQUIRED] ${err.message}`);
|
|
2136
|
+
console.error(uninstallFailureSuggestion());
|
|
2137
|
+
process.exit(1);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const { install, unreadable, paired = [] } = discovery;
|
|
1875
2141
|
const targets = [...install, ...unreadable];
|
|
1876
2142
|
if (targets.length === 0) {
|
|
1877
2143
|
console.log(emptyMessage);
|
|
@@ -1880,7 +2146,7 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1880
2146
|
// Windows: a machine-wide tree at C:\ProgramData\NC cannot be removed from
|
|
1881
2147
|
// a standard shell — request UAC (Start-Process -Verb RunAs) and re-run this
|
|
1882
2148
|
// same command elevated. Declined or headless elevation is a hard stop.
|
|
1883
|
-
if (elevateOnWindows && os.platform() === 'win32' && !isElevated()) {
|
|
2149
|
+
if (elevateOnWindows && os.platform() === 'win32' && targets.some((root) => !isUserInstallRoot(root)) && !isElevated()) {
|
|
1884
2150
|
if (process.stdin.isTTY) {
|
|
1885
2151
|
console.log('Admin rights required to remove the system-wide agent. Requesting UAC elevation…');
|
|
1886
2152
|
const elevated = requestWindowsElevation();
|
|
@@ -1889,7 +2155,16 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1889
2155
|
console.error(WINDOWS_ELEVATION_UNINSTALL_DECLINED);
|
|
1890
2156
|
process.exit(1);
|
|
1891
2157
|
}
|
|
1892
|
-
console.log(`Found Node Command installation(s): ${targets.
|
|
2158
|
+
console.log(`Found Node Command installation(s): ${targets.map(root => {
|
|
2159
|
+
const pair = paired.find(selected => selected.ncRoot === root);
|
|
2160
|
+
return pair ? pair.helperRoot === root ? `${root} (co-located protected helper)` : `${root} (paired protected helper: ${pair.helperRoot})` : root;
|
|
2161
|
+
}).join(', ')}`);
|
|
2162
|
+
if (paired.length && (os.platform() !== 'linux' || process.getuid?.() !== 0 || process.geteuid?.() !== 0)) {
|
|
2163
|
+
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.');
|
|
2164
|
+
console.error(uninstallFailureSuggestion());
|
|
2165
|
+
process.exit(1);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
1893
2168
|
if (unreadable.length > 0) {
|
|
1894
2169
|
console.log(
|
|
1895
2170
|
`Elevated removal needed for system install(s): ${unreadable.join(', ')} (not readable as the current user).`
|
|
@@ -1909,34 +2184,37 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
|
|
|
1909
2184
|
}
|
|
1910
2185
|
let failed = 0;
|
|
1911
2186
|
for (const root of install) {
|
|
1912
|
-
const
|
|
1913
|
-
|
|
2187
|
+
const pair = paired.find(selected => selected.ncRoot === root);
|
|
2188
|
+
const script = pair ? LINUX_PAIRED_UNINSTALL_ENTRY : uninstallerForRoot(root);
|
|
2189
|
+
if (!pair && !fs.existsSync(script)) {
|
|
1914
2190
|
console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
|
|
1915
|
-
console.error(
|
|
2191
|
+
console.error(uninstallFailureSuggestion());
|
|
1916
2192
|
failed += 1;
|
|
1917
2193
|
continue;
|
|
1918
2194
|
}
|
|
1919
2195
|
try {
|
|
1920
2196
|
runUninstallerScript(script);
|
|
1921
|
-
console.log(`Removed ${root} (exit 0).`);
|
|
2197
|
+
console.log(`Removed ${root}${pair && pair.helperRoot !== root ? ` and paired protected helper ${pair.helperRoot}` : ''} (exit 0).`);
|
|
1922
2198
|
} catch (err) {
|
|
1923
2199
|
const rc = typeof err?.status === 'number' ? err.status : 1;
|
|
1924
2200
|
console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
|
|
1925
2201
|
console.error(uninstallFailureSuggestion());
|
|
2202
|
+
if (pair) {
|
|
2203
|
+
process.exit(rc || 1);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
1926
2206
|
failed += 1;
|
|
1927
2207
|
}
|
|
1928
2208
|
}
|
|
1929
|
-
// Unreadable
|
|
1930
|
-
// uninstaller's presence is validated by the elevated run itself — the
|
|
1931
|
-
// in-tree script refuses non-NC directories before deleting anything.
|
|
2209
|
+
// Unreadable system roots remain visible, but never trigger implicit sudo.
|
|
1932
2210
|
for (const root of unreadable) {
|
|
1933
2211
|
const script = uninstallerForRoot(root);
|
|
1934
2212
|
try {
|
|
1935
2213
|
runUninstallerScript(script);
|
|
1936
|
-
console.log(`Removed ${root} (
|
|
2214
|
+
console.log(`Removed ${root} (exit 0).`);
|
|
1937
2215
|
} catch (err) {
|
|
1938
2216
|
const rc = typeof err?.status === 'number' ? err.status : 1;
|
|
1939
|
-
console.error(`error: [UNINSTALL_FAILED]
|
|
2217
|
+
console.error(`error: [UNINSTALL_FAILED] uninstaller for ${root} exited with status ${rc}: ${err.message}`);
|
|
1940
2218
|
console.error(uninstallFailureSuggestion());
|
|
1941
2219
|
failed += 1;
|
|
1942
2220
|
}
|
|
@@ -1953,6 +2231,8 @@ export async function runRemove(args = []) {
|
|
|
1953
2231
|
return runRemoval(args, {
|
|
1954
2232
|
command: 'remove',
|
|
1955
2233
|
emptyMessage: 'No Node Command installation found (nothing to remove).',
|
|
2234
|
+
// remove is the legacy uninstall alias and must share its UAC path.
|
|
2235
|
+
elevateOnWindows: true,
|
|
1956
2236
|
completionMessage: 'Remove complete.'
|
|
1957
2237
|
});
|
|
1958
2238
|
}
|
|
@@ -1981,10 +2261,10 @@ export async function runUninstall(args = []) {
|
|
|
1981
2261
|
/**
|
|
1982
2262
|
* Browser device flow + enroll/redeem/install/verify. Extracted verbatim from
|
|
1983
2263
|
* the historical bare-invocation path; `setup` reuses it with an explicit
|
|
1984
|
-
* install mode
|
|
1985
|
-
* @param {{ installMode?: 'system'|'user' }} [options]
|
|
2264
|
+
* install mode; plain `nodecommand` now uses the same setup preflight.
|
|
2265
|
+
* @param {{ installMode?: 'system'|'user', ncRoot?: string, browserBrand?: string, rootConsent?: boolean|(() => Promise<boolean>) }} [options]
|
|
1986
2266
|
*/
|
|
1987
|
-
async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {}) {
|
|
2267
|
+
async function runDeviceFlow({ installMode = undefined, ncRoot = undefined, browserBrand = captureBrowserDefaultBrand(), rootConsent = false } = {}) {
|
|
1988
2268
|
const deviceEndpoint = `${baseUrl}/oauth/device/code`;
|
|
1989
2269
|
let initRes;
|
|
1990
2270
|
|
|
@@ -2092,12 +2372,14 @@ async function runDeviceFlow({ installMode = undefined, ncRoot = undefined } = {
|
|
|
2092
2372
|
const oauthToken = tokenRes.data.access_token;
|
|
2093
2373
|
console.log('\n🔓 Browser approval received. Enrolling this machine...');
|
|
2094
2374
|
try {
|
|
2095
|
-
const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot });
|
|
2375
|
+
const result = await pairDeviceAndInstall({ baseUrl, apiBase, oauthToken, installMode, ncRoot, browserBrand, rootConsent });
|
|
2096
2376
|
if (installMode) {
|
|
2097
2377
|
// `setup` reports the structured success block (host, mode,
|
|
2098
2378
|
// versions, online status); bare `nodecommand` keeps the legacy
|
|
2099
2379
|
// pairing messages below.
|
|
2100
2380
|
printSetupSuccess({
|
|
2381
|
+
...result,
|
|
2382
|
+
ncRoot,
|
|
2101
2383
|
hostId: result.hostId,
|
|
2102
2384
|
serverUrl: result.serverUrl,
|
|
2103
2385
|
installMode,
|