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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }