nc-agent 0.0.0-stage → 0.1.0
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/LICENSE +176 -0
- package/README.md +44 -2
- package/bin/nc.mjs +1826 -0
- package/lib/diagnostics-bundle.mjs +559 -0
- package/lib/setup.mjs +309 -0
- package/package.json +21 -4
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// nodecommand setup/remove shared logic.
|
|
2
|
+
//
|
|
3
|
+
// Pure, dependency-free helpers behind the `setup` / `remove` subcommands so
|
|
4
|
+
// the mode decision table and install-root detection stay unit-testable with
|
|
5
|
+
// injected platform/privilege probes (no process mocking needed).
|
|
6
|
+
|
|
7
|
+
import child_process from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** Single-line notice printed when setup falls back to user mode. */
|
|
13
|
+
export const LIMITED_CAPS_WARNING =
|
|
14
|
+
'limited: privileged_shell_exec and container tools are off (re-run as administrator for full capabilities)';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detects Windows administrator rights via `net session` (exit 0 ⇔ admin).
|
|
18
|
+
* Never throws: any failure means "not admin".
|
|
19
|
+
* @param {(cmd: string, args: string[]) => { status: number|null }} [run]
|
|
20
|
+
*/
|
|
21
|
+
export function detectWindowsAdmin(run = null) {
|
|
22
|
+
try {
|
|
23
|
+
const runner =
|
|
24
|
+
run ||
|
|
25
|
+
((cmd, args) => {
|
|
26
|
+
try {
|
|
27
|
+
const r = child_process.spawnSync(cmd, args, { stdio: 'ignore' });
|
|
28
|
+
return { status: typeof r.status === 'number' ? r.status : 1 };
|
|
29
|
+
} catch {
|
|
30
|
+
return { status: 1 };
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return runner('net', ['session']).status === 0;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True when the current process can perform a machine-wide (system) install.
|
|
41
|
+
* Privilege probes are injectable for deterministic tests.
|
|
42
|
+
* @param {{ platform?: string, getuid?: (() => number)|null, isAdmin?: boolean|null, runAdminCheck?: (() => boolean)|null }} [options]
|
|
43
|
+
*/
|
|
44
|
+
export function isElevated({ platform = os.platform(), getuid = null, isAdmin = null, runAdminCheck = null } = {}) {
|
|
45
|
+
if (platform === 'win32') {
|
|
46
|
+
if (typeof isAdmin === 'boolean') return isAdmin;
|
|
47
|
+
if (typeof runAdminCheck === 'function') {
|
|
48
|
+
try {
|
|
49
|
+
return !!runAdminCheck();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return detectWindowsAdmin();
|
|
55
|
+
}
|
|
56
|
+
const getter = typeof getuid === 'function' ? getuid : typeof process.getuid === 'function' ? process.getuid : null;
|
|
57
|
+
if (typeof getter !== 'function') return false;
|
|
58
|
+
try {
|
|
59
|
+
return getter() === 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Automatic install-mode decision. The user is never asked: elevated
|
|
67
|
+
* processes install machine-wide, everything else installs per-user.
|
|
68
|
+
* @param {{ platform?: string, getuid?: (() => number)|null, isAdmin?: boolean|null, runAdminCheck?: (() => boolean)|null }} [options]
|
|
69
|
+
* @returns {'system'|'user'}
|
|
70
|
+
*/
|
|
71
|
+
export function decideInstallMode(options = {}) {
|
|
72
|
+
return isElevated(options) ? 'system' : 'user';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Warning text for the given mode: a single line for user mode, null for
|
|
77
|
+
* system mode (callers assert presence/absence on this, not on stdout).
|
|
78
|
+
* @param {'system'|'user'} mode
|
|
79
|
+
*/
|
|
80
|
+
export function modeWarningFor(mode) {
|
|
81
|
+
return mode === 'user' ? LIMITED_CAPS_WARNING : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Environment additions forwarded to the OS installer scripts so the
|
|
86
|
+
* privileged/unprivileged layout matches the CLI mode decision. All three
|
|
87
|
+
* installers (linux/macos/windows) honor NC_MODE; the *_INSTALL flags force
|
|
88
|
+
* the matching branch even when auto-detection would disagree (e.g. a
|
|
89
|
+
* root-owned NC_ROOT reused by a test harness).
|
|
90
|
+
* @param {'system'|'user'} mode
|
|
91
|
+
*/
|
|
92
|
+
export function installerEnvForMode(mode) {
|
|
93
|
+
if (mode === 'user') return { NC_MODE: 'user', NC_USER_INSTALL: '1' };
|
|
94
|
+
return { NC_MODE: 'system', NC_SYSTEM_INSTALL: '1' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Explicit NC_ROOT overrides the whole candidate list: when the caller names
|
|
99
|
+
* a target tree, only that tree is considered (deterministic for tests and
|
|
100
|
+
* for "remove exactly what I pointed at"). Otherwise both the machine-wide
|
|
101
|
+
* and the per-user tree are listed so `remove` finds installs made under a
|
|
102
|
+
* different privilege level than the current process.
|
|
103
|
+
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string }} [options]
|
|
104
|
+
*/
|
|
105
|
+
export function candidateInstallRoots({ platform = os.platform(), env = process.env, homedir = os.homedir() } = {}) {
|
|
106
|
+
if (env.NC_ROOT) return [env.NC_ROOT];
|
|
107
|
+
const roots = [];
|
|
108
|
+
if (platform === 'win32') {
|
|
109
|
+
const programData = env.ProgramData || 'C:\\ProgramData';
|
|
110
|
+
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
|
111
|
+
roots.push(path.join(programData, 'NC'), path.join(localAppData, 'nc'));
|
|
112
|
+
} else if (platform === 'darwin') {
|
|
113
|
+
roots.push('/opt/nc', path.join(homedir, 'Library', 'Application Support', 'nc'));
|
|
114
|
+
} else {
|
|
115
|
+
const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
|
|
116
|
+
roots.push('/opt/nc', path.join(dataHome, 'nc'));
|
|
117
|
+
}
|
|
118
|
+
return [...new Set(roots)];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* True when the directory looks like a real agent tree. Mirrors the safety
|
|
123
|
+
* guard inside the uninstall scripts (config/nc-client.env or app/).
|
|
124
|
+
* @param {string} root
|
|
125
|
+
* @param {(p: string) => boolean} [existsSync]
|
|
126
|
+
*/
|
|
127
|
+
export function isInstallRoot(root, existsSync = fs.existsSync) {
|
|
128
|
+
try {
|
|
129
|
+
return (
|
|
130
|
+
existsSync(path.join(root, 'config', 'nc-client.env')) || existsSync(path.join(root, 'app'))
|
|
131
|
+
);
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Tri-state install-tree probe. A root-owned system tree (0750/0700) is
|
|
139
|
+
* unreadable to a non-root caller: a plain boolean would report "nothing to
|
|
140
|
+
* remove" for a machine-wide install that clearly exists, and the sudo path
|
|
141
|
+
* would never be reached. Distinguishes:
|
|
142
|
+
* - 'install' markers readable → normal removal path
|
|
143
|
+
* - 'absent' root directory does not exist
|
|
144
|
+
* - 'foreign' root exists and is traversable but carries no markers
|
|
145
|
+
* - 'unreadable' root exists but markers cannot be stat'ed (EACCES/EPERM)
|
|
146
|
+
* → needs elevation; the uninstaller's own safety guard
|
|
147
|
+
* still refuses non-NC trees when run under sudo.
|
|
148
|
+
* @param {string} root
|
|
149
|
+
* @param {{ statSync?: (p: string) => { isDirectory(): boolean }, accessSync?: (p: string, mode?: number) => void }} [io]
|
|
150
|
+
* @returns {'install'|'absent'|'foreign'|'unreadable'}
|
|
151
|
+
*/
|
|
152
|
+
export function probeInstallRoot(root, io = {}) {
|
|
153
|
+
const statSync = io.statSync || fs.statSync;
|
|
154
|
+
const accessSync = io.accessSync || fs.accessSync;
|
|
155
|
+
try {
|
|
156
|
+
const st = statSync(root);
|
|
157
|
+
if (!st.isDirectory()) return 'foreign';
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return err && err.code === 'ENOENT' ? 'absent' : 'unreadable';
|
|
160
|
+
}
|
|
161
|
+
for (const marker of [path.join(root, 'config', 'nc-client.env'), path.join(root, 'app')]) {
|
|
162
|
+
try {
|
|
163
|
+
accessSync(marker);
|
|
164
|
+
return 'install';
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) return 'unreadable';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return 'foreign';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Removal targets split by accessibility. `install` roots are removed by the
|
|
174
|
+
* local flow; `unreadable` roots (system installs seen from a non-root shell)
|
|
175
|
+
* are routed to the elevated runUninstallerScript path instead of being
|
|
176
|
+
* silently reported as "nothing to remove".
|
|
177
|
+
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, io?: object }} [options]
|
|
178
|
+
* @returns {{ install: string[], unreadable: string[] }}
|
|
179
|
+
*/
|
|
180
|
+
export function findRemovalTargets({ io = {}, ...rest } = {}) {
|
|
181
|
+
const install = [];
|
|
182
|
+
const unreadable = [];
|
|
183
|
+
for (const root of candidateInstallRoots(rest)) {
|
|
184
|
+
const status = probeInstallRoot(root, io);
|
|
185
|
+
if (status === 'install') install.push(root);
|
|
186
|
+
else if (status === 'unreadable') unreadable.push(root);
|
|
187
|
+
}
|
|
188
|
+
return { install, unreadable };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Installed roots only, in candidate order.
|
|
193
|
+
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string, existsSync?: (p: string) => boolean }} [options]
|
|
194
|
+
*/
|
|
195
|
+
export function findInstallations({ existsSync = fs.existsSync, ...rest } = {}) {
|
|
196
|
+
return candidateInstallRoots(rest).filter((r) => isInstallRoot(r, existsSync));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Mode-aware install root. NC_ROOT still wins when set; otherwise the root
|
|
201
|
+
* mirrors the tree the OS installer builds for that mode — critically on
|
|
202
|
+
* win32, where system installs target %ProgramData%\NC while user installs
|
|
203
|
+
* target %LOCALAPPDATA%\nc (getNcRoot() has no notion of mode and would put
|
|
204
|
+
* an admin setup's credentials in the user tree the agent never reads).
|
|
205
|
+
* @param {'system'|'user'} mode
|
|
206
|
+
* @param {{ platform?: string, env?: NodeJS.ProcessEnv, homedir?: string }} [options]
|
|
207
|
+
*/
|
|
208
|
+
export function getNcRootForMode(mode, { platform = os.platform(), env = process.env, homedir = os.homedir() } = {}) {
|
|
209
|
+
if (env.NC_ROOT) return env.NC_ROOT;
|
|
210
|
+
if (platform === 'win32') {
|
|
211
|
+
if (mode === 'system') return path.join(env.ProgramData || 'C:\\ProgramData', 'NC');
|
|
212
|
+
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
|
|
213
|
+
return path.join(localAppData, 'nc');
|
|
214
|
+
}
|
|
215
|
+
if (mode === 'system') return '/opt/nc';
|
|
216
|
+
if (platform === 'darwin') return path.join(homedir, 'Library', 'Application Support', 'nc');
|
|
217
|
+
const dataHome = env.XDG_DATA_HOME || path.join(homedir, '.local', 'share');
|
|
218
|
+
return path.join(dataHome, 'nc');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Uninstaller script living inside an installed tree. The scripts are
|
|
223
|
+
* self-locating ($ROOT derived from their own path), so no root argument is
|
|
224
|
+
* passed — presence of the tree itself is the authorization.
|
|
225
|
+
* @param {string} root
|
|
226
|
+
* @param {string} [platform]
|
|
227
|
+
*/
|
|
228
|
+
export function uninstallerForRoot(root, platform = os.platform()) {
|
|
229
|
+
if (platform === 'win32') return path.join(root, 'uninstall.ps1');
|
|
230
|
+
return path.join(root, 'uninstall.sh');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Suggested-action line printed when an uninstaller fails. The unix text
|
|
235
|
+
* names the direct recovery command; win32 keeps the generic wording because
|
|
236
|
+
* the .ps1 uninstaller already runs with the caller's own rights (no sudo
|
|
237
|
+
* equivalent is attempted there).
|
|
238
|
+
* @param {string} [platform]
|
|
239
|
+
*/
|
|
240
|
+
export function uninstallFailureSuggestion(platform = os.platform()) {
|
|
241
|
+
const base = 'Re-run with administrator/root rights, then retry.';
|
|
242
|
+
if (platform === 'win32') return `suggested_action: ${base}`;
|
|
243
|
+
return `suggested_action: ${base} (or run: sudo nodecommand remove --yes)`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Runs one uninstaller script, forwarding the inherited environment.
|
|
248
|
+
*
|
|
249
|
+
* Unix elevation: a system-wide tree is installed by root, so its
|
|
250
|
+
* uninstaller is root-owned (mode 0700) and a non-root caller cannot even
|
|
251
|
+
* read it — plain `bash <script>` always fails there with status 126. When
|
|
252
|
+
* the caller is not root the script is therefore run via `sudo -E bash`,
|
|
253
|
+
* keeping `stdio: 'inherit'` so the sudo password prompt stays visible.
|
|
254
|
+
* When sudo itself is missing (ENOENT) the direct call is retried once so
|
|
255
|
+
* user-owned trees stay removable without sudo; any other sudo failure
|
|
256
|
+
* propagates unchanged and the caller reports the usual [UNINSTALL_FAILED]
|
|
257
|
+
* guidance — behavior never gets worse, it only starts working when sudo
|
|
258
|
+
* exists. Already-root callers run the script directly (no sudo).
|
|
259
|
+
*
|
|
260
|
+
* Security boundary: the caller (runRemove) only ever passes the
|
|
261
|
+
* uninstallerForRoot() path of a tree that already passed the
|
|
262
|
+
* isInstallRoot() check inside findInstallations() — elevation is never
|
|
263
|
+
* applied to an arbitrary path. Windows is untouched: the .ps1 uninstaller
|
|
264
|
+
* runs with the caller's own rights, so no elevation is attempted there.
|
|
265
|
+
* @param {string} scriptPath
|
|
266
|
+
* @param {string|{ platform?: string, execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [platform]
|
|
267
|
+
* @param {{ execFile?: (cmd: string, args: string[], opts?: object) => unknown, getuid?: (() => number)|null }} [options]
|
|
268
|
+
*/
|
|
269
|
+
export function runUninstallerScript(scriptPath, platform = os.platform(), options = {}) {
|
|
270
|
+
let opts = options || {};
|
|
271
|
+
if (typeof platform !== 'string') {
|
|
272
|
+
opts = platform || {};
|
|
273
|
+
platform = opts.platform || os.platform();
|
|
274
|
+
}
|
|
275
|
+
const execFile = opts.execFile || child_process.execFileSync;
|
|
276
|
+
if (platform === 'win32') {
|
|
277
|
+
execFile(
|
|
278
|
+
'powershell.exe',
|
|
279
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
|
280
|
+
{ stdio: 'inherit' }
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const getuid =
|
|
285
|
+
typeof opts.getuid === 'function'
|
|
286
|
+
? opts.getuid
|
|
287
|
+
: typeof process.getuid === 'function'
|
|
288
|
+
? () => process.getuid()
|
|
289
|
+
: null;
|
|
290
|
+
let uid = null;
|
|
291
|
+
try {
|
|
292
|
+
uid = typeof getuid === 'function' ? getuid() : null;
|
|
293
|
+
} catch {
|
|
294
|
+
uid = null;
|
|
295
|
+
}
|
|
296
|
+
if (uid === 0) {
|
|
297
|
+
execFile('bash', [scriptPath], { stdio: 'inherit' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
execFile('sudo', ['-E', 'bash', scriptPath], { stdio: 'inherit' });
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// Without a sudo binary there is nothing to elevate with: retry the
|
|
304
|
+
// historical direct call so user-owned trees stay removable. Any other
|
|
305
|
+
// sudo failure (denied, wrong password, script error) propagates.
|
|
306
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
307
|
+
execFile('bash', [scriptPath], { stdio: 'inherit' });
|
|
308
|
+
}
|
|
309
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nc-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nc-agent": "bin/nc.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node src/client/launcher.mjs",
|
|
20
|
+
"test": "node --test tests/*.mjs"
|
|
21
|
+
},
|
|
22
|
+
"description": "Node Command device pairing and agent installer. Run npx nc-agent to pair this machine via the browser device flow; the agent is downloaded from the control plane."
|
|
23
|
+
}
|