@mmmbuto/nexuscrew 0.8.2 → 0.8.4
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/README.md +37 -16
- package/frontend/dist/assets/index-C1AFIaRR.js +90 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +157 -28
- package/lib/cli/doctor.js +1 -1
- package/lib/cli/init.js +8 -3
- package/lib/cli/pidfile.js +2 -2
- package/lib/fleet/managed.js +3 -2
- package/lib/nodes/health.js +89 -0
- package/lib/nodes/store.js +78 -1
- package/lib/nodes/topology-cache.js +75 -0
- package/lib/proxy/federation.js +117 -6
- package/lib/pty/provider.js +7 -7
- package/lib/server.js +28 -10
- package/lib/settings/routes.js +65 -6
- package/package.json +4 -8
- package/frontend/dist/assets/index-Dey9rdY-.js +0 -90
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-C1AFIaRR.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-COsoJxXQ.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.4"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -9,6 +9,7 @@ const path = require('node:path');
|
|
|
9
9
|
const net = require('node:net');
|
|
10
10
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
11
11
|
const { installPath: serviceInstallPath } = require('./service.js');
|
|
12
|
+
const { fleetInstallPath } = require('./fleet-service.js');
|
|
12
13
|
const pidf = require('./pidfile.js');
|
|
13
14
|
const { runInit } = require('./init.js');
|
|
14
15
|
const { rotateToken } = require('../auth/token.js');
|
|
@@ -27,8 +28,10 @@ const VALUE_FLAGS = new Set([
|
|
|
27
28
|
const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
28
29
|
|
|
29
30
|
Usage:
|
|
30
|
-
nexuscrew start in background;
|
|
31
|
+
nexuscrew start in background; show status and quick guide
|
|
31
32
|
nexuscrew show start when needed and open the authenticated PWA
|
|
33
|
+
nexuscrew show token print the clickable authenticated URL
|
|
34
|
+
nexuscrew boot enable startup at boot (use: boot off|status)
|
|
32
35
|
nexuscrew doctor run local diagnostics
|
|
33
36
|
nexuscrew help show this help
|
|
34
37
|
nexuscrew version show the installed version
|
|
@@ -65,7 +68,7 @@ function parseFlags(argv, valueFlags) {
|
|
|
65
68
|
function serve(opts = {}) {
|
|
66
69
|
const serverStart = opts.serverStart || require('../server.js').start;
|
|
67
70
|
if (opts.pidfile) {
|
|
68
|
-
const pidPath = pidf.defaultPidfilePath();
|
|
71
|
+
const pidPath = pidf.defaultPidfilePath(opts.home);
|
|
69
72
|
// already-running check
|
|
70
73
|
const meta = pidf.readPidfile(pidPath);
|
|
71
74
|
if (meta && pidf.isAlive(meta)) {
|
|
@@ -115,8 +118,9 @@ function start(opts = {}) {
|
|
|
115
118
|
return { platform, started: true };
|
|
116
119
|
}
|
|
117
120
|
if (platform === 'termux') {
|
|
121
|
+
const home = opts.home || require('node:os').homedir();
|
|
118
122
|
// already-running?
|
|
119
|
-
const pidPath = pidf.defaultPidfilePath();
|
|
123
|
+
const pidPath = pidf.defaultPidfilePath(opts.home);
|
|
120
124
|
const meta = pidf.readPidfile(pidPath);
|
|
121
125
|
if (meta && pidf.isAlive(meta)) {
|
|
122
126
|
log(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
|
|
@@ -124,12 +128,24 @@ function start(opts = {}) {
|
|
|
124
128
|
}
|
|
125
129
|
pidf.cleanStale(pidPath);
|
|
126
130
|
const repoBin = path.join(repoRoot(), 'bin', 'nexuscrew.js');
|
|
127
|
-
const logPath = path.join(
|
|
131
|
+
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
128
132
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
129
133
|
const logFd = fs.openSync(logPath, 'a');
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
const resolved = urlmod.resolvePaths(opts);
|
|
135
|
+
const childEnv = {
|
|
136
|
+
...process.env,
|
|
137
|
+
HOME: home,
|
|
138
|
+
NEXUSCREW_CONFIG_FILE: resolved.configPath,
|
|
139
|
+
NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
|
|
140
|
+
};
|
|
141
|
+
// config.json is authoritative after automatic port fallback.
|
|
142
|
+
delete childEnv.NEXUSCREW_PORT;
|
|
143
|
+
let child;
|
|
144
|
+
try {
|
|
145
|
+
child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
|
|
146
|
+
detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
|
|
147
|
+
});
|
|
148
|
+
} finally { try { fs.closeSync(logFd); } catch (_) {} }
|
|
133
149
|
if (child && typeof child.unref === 'function') child.unref();
|
|
134
150
|
log(`start: nohup ${nodeBin()} ${repoBin} serve --pidfile (>> ${logPath})`);
|
|
135
151
|
return { platform, started: true, pid: child && child.pid };
|
|
@@ -162,7 +178,7 @@ function stop(opts = {}) {
|
|
|
162
178
|
}
|
|
163
179
|
if (platform === 'termux') {
|
|
164
180
|
// kill via pidfile verificato (no broad match) + wake-lock-release
|
|
165
|
-
const pidPath = pidf.defaultPidfilePath();
|
|
181
|
+
const pidPath = pidf.defaultPidfilePath(opts.home);
|
|
166
182
|
const r = pidf.killPidfile(pidPath);
|
|
167
183
|
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
168
184
|
log(`stop: ${r.killed ? `killed pid ${r.pid}` : r.reason}`);
|
|
@@ -184,12 +200,93 @@ function isServiceRunning(opts = {}) {
|
|
|
184
200
|
catch (_) { return false; }
|
|
185
201
|
}
|
|
186
202
|
if (platform === 'termux') {
|
|
187
|
-
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
203
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
|
|
188
204
|
return !!(meta && pidf.isAlive(meta));
|
|
189
205
|
}
|
|
190
206
|
return false;
|
|
191
207
|
}
|
|
192
208
|
|
|
209
|
+
function bootState(opts = {}) {
|
|
210
|
+
const platform = opts.platform || detectPlatform();
|
|
211
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
212
|
+
const home = opts.home || require('node:os').homedir();
|
|
213
|
+
if (platform === 'linux') {
|
|
214
|
+
try { return { platform, enabled: execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'enabled' }; }
|
|
215
|
+
catch (_) { return { platform, enabled: false }; }
|
|
216
|
+
}
|
|
217
|
+
try { return { platform, enabled: fs.lstatSync(serviceInstallPath(platform, home)).isFile() }; }
|
|
218
|
+
catch (_) { return { platform, enabled: false }; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function unlinkRegular(target) {
|
|
222
|
+
try {
|
|
223
|
+
const st = fs.lstatSync(target);
|
|
224
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe boot target: ${target}`);
|
|
225
|
+
fs.unlinkSync(target); return true;
|
|
226
|
+
} catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function bootOn(opts = {}) {
|
|
230
|
+
const platform = opts.platform || detectPlatform();
|
|
231
|
+
const quiet = opts.lifecycleLog || (() => {});
|
|
232
|
+
const { configPath, tokenPath } = urlmod.resolvePaths(opts);
|
|
233
|
+
if (!fs.existsSync(configPath) || !urlmod.readToken(tokenPath)) {
|
|
234
|
+
const selected = await findAvailablePort(opts.port || urlmod.loadPort(opts), opts);
|
|
235
|
+
(opts.runInitImpl || runInit)({ ...opts, platform, port: selected, installBoot: false, printUrl: false, log: quiet });
|
|
236
|
+
}
|
|
237
|
+
try { pidf.killPidfile(pidf.defaultPidfilePath(opts.home)); } catch (_) {}
|
|
238
|
+
(opts.runInitImpl || runInit)({ ...opts, platform, installBoot: true, printUrl: false, log: quiet });
|
|
239
|
+
const result = await smartUp({ ...opts, platform });
|
|
240
|
+
return { ...result, boot: bootState({ ...opts, platform }).enabled };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function bootOff(opts = {}) {
|
|
244
|
+
const platform = opts.platform || detectPlatform();
|
|
245
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
246
|
+
const home = opts.home || require('node:os').homedir();
|
|
247
|
+
if (platform === 'linux') {
|
|
248
|
+
try { execImpl('systemctl', ['--user', 'disable', 'nexuscrew'], { stdio: 'ignore' }); } catch (_) {}
|
|
249
|
+
try { execImpl('systemctl', ['--user', 'disable', 'nexuscrew-fleet.service'], { stdio: 'ignore' }); } catch (_) {}
|
|
250
|
+
} else if (platform === 'mac') {
|
|
251
|
+
const domain = `gui/${opts.uid || uid()}`;
|
|
252
|
+
try { execImpl('launchctl', ['bootout', `${domain}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); } catch (_) {}
|
|
253
|
+
try { execImpl('launchctl', ['bootout', `${domain}/com.mmmbuto.nexuscrew-fleet`], { stdio: 'ignore' }); } catch (_) {}
|
|
254
|
+
unlinkRegular(serviceInstallPath(platform, home));
|
|
255
|
+
try { unlinkRegular(fleetInstallPath(platform, home)); } catch (_) {}
|
|
256
|
+
const { tokenPath } = urlmod.resolvePaths(opts); const port = urlmod.loadPort(opts); const token = urlmod.readToken(tokenPath);
|
|
257
|
+
if (!(await probeNexusCrew(port, token, opts))) {
|
|
258
|
+
startPortable({ ...opts, platform });
|
|
259
|
+
await waitForNexusCrew(port, token, opts);
|
|
260
|
+
}
|
|
261
|
+
} else if (platform === 'termux') {
|
|
262
|
+
unlinkRegular(serviceInstallPath(platform, home));
|
|
263
|
+
try { unlinkRegular(fleetInstallPath(platform, home)); } catch (_) {}
|
|
264
|
+
}
|
|
265
|
+
return { platform, enabled: false, running: isServiceRunning({ ...opts, platform }) || !!pidf.readPidfile(pidf.defaultPidfilePath(home)) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function quickSummary(result, opts = {}) {
|
|
269
|
+
const log = opts.log || console.log;
|
|
270
|
+
const platform = result.platform || opts.platform || detectPlatform();
|
|
271
|
+
const home = opts.home || require('node:os').homedir();
|
|
272
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
273
|
+
const st = nodesStore.loadStore(opts.nodesPath || path.join(configDir, 'nodes.json'));
|
|
274
|
+
let cached = [];
|
|
275
|
+
try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
|
|
276
|
+
const direct = (st && st.nodes) || [];
|
|
277
|
+
const online = direct.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
|
|
278
|
+
const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
|
|
279
|
+
log(`NexusCrew ${require('../../package.json').version}`);
|
|
280
|
+
log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
|
|
281
|
+
log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
|
|
282
|
+
log(`nodes ${known} known · ${online}/${direct.length} direct tunnels up`);
|
|
283
|
+
log('');
|
|
284
|
+
log('open nexuscrew show');
|
|
285
|
+
log('link nexuscrew show token');
|
|
286
|
+
log('boot nexuscrew boot');
|
|
287
|
+
log('check nexuscrew doctor');
|
|
288
|
+
}
|
|
289
|
+
|
|
193
290
|
// Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
|
|
194
291
|
function readRoles(configPath) {
|
|
195
292
|
try {
|
|
@@ -224,7 +321,7 @@ function status(opts = {}) {
|
|
|
224
321
|
// boot-script installed vs server running (pidfile vivo)
|
|
225
322
|
const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
226
323
|
out.bootScriptInstalled = fs.existsSync(bootScript);
|
|
227
|
-
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
324
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
|
|
228
325
|
out.running = !!(meta && pidf.isAlive(meta));
|
|
229
326
|
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
230
327
|
}
|
|
@@ -352,9 +449,22 @@ function startPortable(opts = {}) {
|
|
|
352
449
|
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
353
450
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
354
451
|
const logFd = fs.openSync(logPath, 'a');
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
452
|
+
const resolved = urlmod.resolvePaths(opts);
|
|
453
|
+
const childEnv = {
|
|
454
|
+
...process.env,
|
|
455
|
+
HOME: home,
|
|
456
|
+
NEXUSCREW_CONFIG_FILE: resolved.configPath,
|
|
457
|
+
NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
|
|
458
|
+
...(opts.filesRoot ? { NEXUSCREW_FILES_ROOT: opts.filesRoot } : {}),
|
|
459
|
+
};
|
|
460
|
+
// config.json is authoritative after automatic port fallback.
|
|
461
|
+
delete childEnv.NEXUSCREW_PORT;
|
|
462
|
+
let child;
|
|
463
|
+
try {
|
|
464
|
+
child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
|
|
465
|
+
detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
|
|
466
|
+
});
|
|
467
|
+
} finally { try { fs.closeSync(logFd); } catch (_) {} }
|
|
358
468
|
if (child && typeof child.unref === 'function') child.unref();
|
|
359
469
|
return { started: true, pid: child && child.pid, portable: true };
|
|
360
470
|
}
|
|
@@ -383,6 +493,7 @@ async function smartUp(opts = {}) {
|
|
|
383
493
|
const runInitImpl = opts.runInitImpl || runInit;
|
|
384
494
|
const startImpl = opts.startImpl || start;
|
|
385
495
|
const restartImpl = opts.restartImpl || restart;
|
|
496
|
+
const portableStart = opts.startPortableImpl || startPortable;
|
|
386
497
|
const probe = opts.probeImpl || probeNexusCrew;
|
|
387
498
|
const { configPath, tokenPath } = urlmod.resolvePaths(opts);
|
|
388
499
|
let initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
|
|
@@ -391,7 +502,7 @@ async function smartUp(opts = {}) {
|
|
|
391
502
|
if (!initialized) {
|
|
392
503
|
const requested = opts.port || urlmod.loadPort(opts);
|
|
393
504
|
const selected = await findAvailablePort(requested, opts);
|
|
394
|
-
runInitImpl({ ...opts, port: selected, log: quiet, platform, printUrl: false });
|
|
505
|
+
runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
|
|
395
506
|
port = selected;
|
|
396
507
|
initialized = true;
|
|
397
508
|
}
|
|
@@ -399,8 +510,9 @@ async function smartUp(opts = {}) {
|
|
|
399
510
|
// 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
|
|
400
511
|
// config.json forever. Regenerate once so config.json becomes authoritative.
|
|
401
512
|
const home = opts.home || require('node:os').homedir();
|
|
402
|
-
|
|
403
|
-
|
|
513
|
+
const persistent = bootState({ ...opts, platform }).enabled;
|
|
514
|
+
if (persistent && servicePinsLegacyPort(platform, home, opts.installPath)) {
|
|
515
|
+
runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
|
|
404
516
|
}
|
|
405
517
|
|
|
406
518
|
if (!port) port = urlmod.loadPort(opts);
|
|
@@ -413,29 +525,29 @@ async function smartUp(opts = {}) {
|
|
|
413
525
|
if (!running) {
|
|
414
526
|
if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
|
|
415
527
|
port = await findAvailablePort(port + 1, opts);
|
|
416
|
-
runInitImpl({ ...opts, port, log: quiet, platform, printUrl: false });
|
|
528
|
+
runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
|
|
417
529
|
token = urlmod.readToken(tokenPath);
|
|
418
530
|
} else {
|
|
419
531
|
try {
|
|
420
|
-
const started =
|
|
421
|
-
? restartImpl({ ...opts, platform, log: quiet })
|
|
422
|
-
:
|
|
532
|
+
const started = persistent
|
|
533
|
+
? (managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
|
|
534
|
+
: portableStart({ ...opts, platform });
|
|
423
535
|
if (started && started.started === false) {
|
|
424
|
-
|
|
536
|
+
portableStart({ ...opts, platform }); portableAttempted = true;
|
|
425
537
|
}
|
|
426
538
|
} catch (_) {
|
|
427
|
-
|
|
539
|
+
portableStart({ ...opts, platform }); portableAttempted = true;
|
|
428
540
|
}
|
|
429
541
|
}
|
|
430
542
|
running = await waitForNexusCrew(port, token, opts);
|
|
431
543
|
if (!running && !portableAttempted) {
|
|
432
|
-
try {
|
|
544
|
+
try { portableStart({ ...opts, platform }); portableAttempted = true; } catch (_) {}
|
|
433
545
|
running = await waitForNexusCrew(port, token, opts);
|
|
434
546
|
}
|
|
435
547
|
if (!running) throw new Error(`server did not become ready on 127.0.0.1:${port}`);
|
|
436
548
|
}
|
|
437
549
|
|
|
438
|
-
const shouldOpen = !!opts.forceOpen || !wizardComplete(configPath);
|
|
550
|
+
const shouldOpen = !opts.noOpen && (!!opts.forceOpen || !wizardComplete(configPath));
|
|
439
551
|
if (shouldOpen) openPwa(urlmod.buildUrl(port, token, { withToken: true }), { ...opts, platform });
|
|
440
552
|
return { platform, initialized, running, opened: shouldOpen, port, url: urlmod.buildUrl(port, null) };
|
|
441
553
|
}
|
|
@@ -475,7 +587,7 @@ function tokenRotate(opts = {}) {
|
|
|
475
587
|
} else {
|
|
476
588
|
// il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
|
|
477
589
|
// vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
|
|
478
|
-
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
590
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath(opts.home));
|
|
479
591
|
if (meta && pidf.isAlive(meta)) {
|
|
480
592
|
log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
|
|
481
593
|
} else {
|
|
@@ -599,16 +711,32 @@ function dispatch(argv, opts = {}) {
|
|
|
599
711
|
log(HELP);
|
|
600
712
|
return { code: 0 };
|
|
601
713
|
}
|
|
602
|
-
// Public surface: normal start, show, doctor, help, version.
|
|
714
|
+
// Public surface: normal start, show, boot, doctor, help, version.
|
|
603
715
|
if (!cmd) {
|
|
604
716
|
if (Object.keys(flags).length) {
|
|
605
717
|
log(`unknown option: --${Object.keys(flags)[0]}\n\n${HELP}`);
|
|
606
718
|
return { code: 1 };
|
|
607
719
|
}
|
|
608
|
-
return smartUp({ ...opts, log: opts.
|
|
720
|
+
return smartUp({ ...opts, log: opts.lifecycleLog || (() => {}) }).then((r) => { quickSummary(r, { ...opts, log }); return { code: 0 }; });
|
|
609
721
|
}
|
|
610
722
|
if (cmd === 'show') {
|
|
611
|
-
|
|
723
|
+
if (rest[1] === 'token') {
|
|
724
|
+
return smartUp({ ...opts, noOpen: true, log: opts.lifecycleLog || (() => {}) }).then((r) => {
|
|
725
|
+
const { tokenPath } = urlmod.resolvePaths(opts); const token = urlmod.readToken(tokenPath);
|
|
726
|
+
log(urlmod.buildUrl(r.port, token, { withToken: true })); return { code: 0 };
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (rest[1]) { log('usage: nexuscrew show [token]'); return { code: 1 }; }
|
|
730
|
+
return smartUp({ ...opts, forceOpen: true, log: opts.lifecycleLog || (() => {}) }).then(() => ({ code: 0 }));
|
|
731
|
+
}
|
|
732
|
+
if (cmd === 'boot') {
|
|
733
|
+
const sub = rest[1] || 'on';
|
|
734
|
+
if (sub === 'status') {
|
|
735
|
+
const b = bootState(opts); log(`boot: ${b.enabled ? 'on' : 'off'} · ${b.platform}`); return { code: 0 };
|
|
736
|
+
}
|
|
737
|
+
if (sub === 'off') return bootOff(opts).then((r) => { log(`boot: off · ${r.platform}`); return { code: 0 }; });
|
|
738
|
+
if (sub === 'on') return bootOn(opts).then((r) => { quickSummary(r, { ...opts, log }); return { code: r.boot ? 0 : 1 }; });
|
|
739
|
+
log('usage: nexuscrew boot [on|off|status]'); return { code: 1 };
|
|
612
740
|
}
|
|
613
741
|
if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
|
|
614
742
|
log(require('../../package.json').version);
|
|
@@ -700,5 +828,6 @@ module.exports = {
|
|
|
700
828
|
portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
|
|
701
829
|
servicePinsLegacyPort,
|
|
702
830
|
isServiceRunning, readRoles,
|
|
831
|
+
bootState, bootOn, bootOff, quickSummary,
|
|
703
832
|
runFleetBoot, dispatchFleetBoot,
|
|
704
833
|
};
|
package/lib/cli/doctor.js
CHANGED
|
@@ -51,7 +51,7 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
|
|
|
51
51
|
active = true;
|
|
52
52
|
} else if (platform === 'termux') {
|
|
53
53
|
const pidf = require('./pidfile.js');
|
|
54
|
-
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
54
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
|
|
55
55
|
active = !!(meta && pidf.isAlive(meta));
|
|
56
56
|
}
|
|
57
57
|
} catch (_) { active = false; }
|
package/lib/cli/init.js
CHANGED
|
@@ -75,6 +75,7 @@ function runInit(opts = {}) {
|
|
|
75
75
|
const tokenPath = opts.tokenPath || path.join(configDir, 'token');
|
|
76
76
|
const filesRoot = opts.filesRoot || path.join(home, 'NexusFiles');
|
|
77
77
|
const dryRun = !!opts.dryRun;
|
|
78
|
+
const installBoot = opts.installBoot !== false;
|
|
78
79
|
const log = opts.log || (() => {});
|
|
79
80
|
const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux');
|
|
80
81
|
|
|
@@ -169,7 +170,9 @@ function runInit(opts = {}) {
|
|
|
169
170
|
};
|
|
170
171
|
const content = generateService(platform, svcCtx);
|
|
171
172
|
|
|
172
|
-
if (
|
|
173
|
+
if (!installBoot) {
|
|
174
|
+
actions.push(`boot opt-in: service ${platform} non installato`);
|
|
175
|
+
} else if (dryRun) {
|
|
173
176
|
actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
|
|
174
177
|
} else if (!tmuxOk) {
|
|
175
178
|
// tmux mancante: abort before service install (config/token gia' creati) [M8]
|
|
@@ -196,7 +199,9 @@ function runInit(opts = {}) {
|
|
|
196
199
|
// principale: ogni errore del companion -> WARN action + return normale.
|
|
197
200
|
try {
|
|
198
201
|
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
199
|
-
if (
|
|
202
|
+
if (!installBoot) {
|
|
203
|
+
actions.push('fleet companion: boot opt-in, non installato');
|
|
204
|
+
} else if (readonly) {
|
|
200
205
|
actions.push('fleet companion: READONLY, non installato');
|
|
201
206
|
} else {
|
|
202
207
|
// provider mode: companion SOLO se builtin (§9b). selectProviderModeSync e'
|
|
@@ -251,7 +256,7 @@ function runInit(opts = {}) {
|
|
|
251
256
|
}
|
|
252
257
|
|
|
253
258
|
// Termux:boot best-effort detection (R4)
|
|
254
|
-
if (platform === 'termux') {
|
|
259
|
+
if (platform === 'termux' && installBoot) {
|
|
255
260
|
const bootDir = path.join(home, '.termux', 'boot');
|
|
256
261
|
const bootOk = fs.existsSync(bootDir);
|
|
257
262
|
actions.push(bootOk
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -7,8 +7,8 @@ const os = require('node:os');
|
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const { execFileSync } = require('node:child_process');
|
|
9
9
|
|
|
10
|
-
function defaultPidfilePath() {
|
|
11
|
-
return process.env.NEXUSCREW_PIDFILE || path.join(
|
|
10
|
+
function defaultPidfilePath(home = os.homedir()) {
|
|
11
|
+
return process.env.NEXUSCREW_PIDFILE || path.join(home, '.nexuscrew', 'nexuscrew.pid');
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function readPidfile(p) {
|
package/lib/fleet/managed.js
CHANGED
|
@@ -93,7 +93,7 @@ function normalizeManagedSpec(value) {
|
|
|
93
93
|
const model = value.model === undefined ? (profile.model || '') : value.model;
|
|
94
94
|
if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
|
|
95
95
|
if (profile.requiresModel && !model) return null;
|
|
96
|
-
const permissionPolicy = value.permissionPolicy === undefined ? 'standard' : value.permissionPolicy;
|
|
96
|
+
const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
|
|
97
97
|
if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
|
|
98
98
|
if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
|
|
99
99
|
const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
|
|
@@ -118,7 +118,7 @@ function defaultDefinitions() {
|
|
|
118
118
|
schemaVersion: 1,
|
|
119
119
|
engines: CATALOG.filter((p) => p.default).map((p) => ({
|
|
120
120
|
id: p.id, label: p.label, rc: !!p.rc,
|
|
121
|
-
managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: 'standard' },
|
|
121
|
+
managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: p.client === 'claude' ? 'unsafe' : 'standard' },
|
|
122
122
|
})),
|
|
123
123
|
cells: [],
|
|
124
124
|
};
|
|
@@ -370,6 +370,7 @@ function publicCatalog() {
|
|
|
370
370
|
credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
|
|
371
371
|
auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
|
|
372
372
|
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
|
|
373
|
+
permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
|
|
373
374
|
rc: !!p.rc, custom: !!p.custom, default: !!p.default,
|
|
374
375
|
}));
|
|
375
376
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/nodes/health.js — modello di salute federato a 3 dimensioni per i nodi.
|
|
3
|
+
//
|
|
4
|
+
// Sostituisce il vecchio "inbound sempre up" (lib/server.js) con una valutazione
|
|
5
|
+
// onesta che NON mente sul verde:
|
|
6
|
+
//
|
|
7
|
+
// transport — la porta forward locale (127.0.0.1:localPort) risponde.
|
|
8
|
+
// Per outbound: pidfile del tunnel + probe TCP via /federation/health.
|
|
9
|
+
// auth — la federation accetta la credenziale del nodo (200 vs 401).
|
|
10
|
+
// reachability — l'API remota risponde con payload (200 vs 5xx/network error).
|
|
11
|
+
//
|
|
12
|
+
// Inbound: il nodo ricevente NON possiede il lifecycle del peer, ma il reverse
|
|
13
|
+
// tunnel espone comunque il peer su localPort. Quindi la salute e' probeable;
|
|
14
|
+
// resta managed:false e la UI NON offre il power.
|
|
15
|
+
//
|
|
16
|
+
// Il probe federation e' costoso (fetch): cache per-processo con TTL, keyed by
|
|
17
|
+
// node name, cosi' il poll frequente della UI non re-probe la stessa porta.
|
|
18
|
+
|
|
19
|
+
const nodesTunnel = require('./tunnel.js');
|
|
20
|
+
const { probeHealth } = require('../proxy/federation.js');
|
|
21
|
+
|
|
22
|
+
const TTL_MS = 5000;
|
|
23
|
+
const cache = new Map(); // name -> { health, at }
|
|
24
|
+
|
|
25
|
+
function clearHealthCache() { cache.clear(); }
|
|
26
|
+
|
|
27
|
+
// Compatibilita' tunnel per il frontend attuale (che legge tunnel.status): deriva
|
|
28
|
+
// uno {status, managed} retro-compatibile dal model health, senza reintrodurre il
|
|
29
|
+
// verde hardcoded. inbound -> 'unknown' (la UI lo trattava come up prima: ora e'
|
|
30
|
+
// onesto). outbound down/401 -> 'down'/'degraded'.
|
|
31
|
+
function tunnelFromHealth(h) {
|
|
32
|
+
if (!h) return { status: 'unknown', managed: false };
|
|
33
|
+
if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
|
|
34
|
+
if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
|
|
35
|
+
if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
|
|
36
|
+
if (h.transport === 'down') return { status: 'down', managed: h.managed !== false };
|
|
37
|
+
return { status: 'unknown', managed: false }; // inbound / unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
|
|
41
|
+
if (!node || typeof node !== 'object') return null;
|
|
42
|
+
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}`;
|
|
43
|
+
const cached = !force && cache.get(cacheKey);
|
|
44
|
+
if (cached && (now - cached.at) < TTL_MS) return cached.health;
|
|
45
|
+
|
|
46
|
+
let health;
|
|
47
|
+
if (node.direction === 'inbound') {
|
|
48
|
+
if (!node.token) {
|
|
49
|
+
health = {
|
|
50
|
+
transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'degraded',
|
|
51
|
+
detail: 'peer inbound senza credenziale federation — re-pair', managed: false, at: now,
|
|
52
|
+
};
|
|
53
|
+
} else {
|
|
54
|
+
const probed = await probeHealth({
|
|
55
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
56
|
+
});
|
|
57
|
+
health = { ...probed, managed: false };
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
const ts = nodesTunnel.readTunnelState(home, node.name);
|
|
61
|
+
if (ts.status !== 'up') {
|
|
62
|
+
health = {
|
|
63
|
+
transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
|
|
64
|
+
detail: ts.reason ? `tunnel down (${ts.reason})` : 'tunnel down',
|
|
65
|
+
managed: ts.managed !== false, at: now,
|
|
66
|
+
};
|
|
67
|
+
} else if (!node.token) {
|
|
68
|
+
health = {
|
|
69
|
+
transport: 'up', auth: 'unknown', reachability: 'unknown', status: 'degraded',
|
|
70
|
+
detail: 'tunnel up, credenziali federation assenti (token mancante) — re-pair',
|
|
71
|
+
managed: true, at: now,
|
|
72
|
+
};
|
|
73
|
+
} else {
|
|
74
|
+
const probed = await probeHealth({
|
|
75
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
76
|
+
});
|
|
77
|
+
health = { ...probed, managed: true };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
cache.set(cacheKey, { health, at: now });
|
|
81
|
+
return health;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function nodesHealth({ nodes, home, fetchImpl, now = Date.now() }) {
|
|
85
|
+
if (!Array.isArray(nodes) || nodes.length === 0) return [];
|
|
86
|
+
return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now })));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS };
|
package/lib/nodes/store.js
CHANGED
|
@@ -93,12 +93,18 @@ function parseRoles(r) {
|
|
|
93
93
|
return { client, node };
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// label: etichetta umana opzionale per il display (1-64 char visibili, no control).
|
|
97
|
+
// NON e' lo slug di routing: puo' contenere maiuscole, spazi, punteggiatura. Il name
|
|
98
|
+
// resta l'identita' tecnica (segmento path/route); la label e' solo come l'utente la
|
|
99
|
+
// vede. Backward-compatible: record esistenti senza label -> fallback a `name`.
|
|
100
|
+
const LABEL_MAX = 64;
|
|
101
|
+
|
|
96
102
|
// parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
|
|
97
103
|
// tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
|
|
98
104
|
const NODE_KEYS = new Set([
|
|
99
105
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
100
106
|
'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
101
|
-
'direction', 'reversePort',
|
|
107
|
+
'direction', 'reversePort', 'label',
|
|
102
108
|
]);
|
|
103
109
|
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
104
110
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
@@ -117,6 +123,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
117
123
|
if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
|
|
118
124
|
const roles = parseRoles(n.roles);
|
|
119
125
|
if (!roles) return null;
|
|
126
|
+
// label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
|
|
127
|
+
// control char (newline/tab inclusi: una label su piu' righe non ha senso in
|
|
128
|
+
// UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
|
|
129
|
+
let label = null;
|
|
130
|
+
if (n.label !== undefined) {
|
|
131
|
+
if (typeof n.label !== 'string') return null;
|
|
132
|
+
label = n.label.trim();
|
|
133
|
+
if (!label || label.length > LABEL_MAX) return null;
|
|
134
|
+
if (/[\x00-\x1f\x7f]/.test(label)) return null;
|
|
135
|
+
}
|
|
120
136
|
|
|
121
137
|
const out = {
|
|
122
138
|
name: n.name,
|
|
@@ -163,6 +179,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
163
179
|
if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
|
|
164
180
|
out.nodeId = n.nodeId;
|
|
165
181
|
}
|
|
182
|
+
if (label) out.label = label;
|
|
166
183
|
return out;
|
|
167
184
|
}
|
|
168
185
|
|
|
@@ -357,6 +374,7 @@ function clearRendezvous(store) {
|
|
|
357
374
|
function redactNode(n) {
|
|
358
375
|
const out = {
|
|
359
376
|
name: n.name,
|
|
377
|
+
...(n.label ? { label: n.label } : {}),
|
|
360
378
|
...(n.ssh ? { ssh: n.ssh } : {}),
|
|
361
379
|
remotePort: n.remotePort,
|
|
362
380
|
localPort: n.localPort,
|
|
@@ -409,6 +427,63 @@ function migrateLegacyNodes(configPath, nodesPath) {
|
|
|
409
427
|
return { migrated: true, count: written.nodes.length, reason: 'migrato da config.json' };
|
|
410
428
|
}
|
|
411
429
|
|
|
430
|
+
// --- Label / slug helpers (display vs routing identity) ---------------------
|
|
431
|
+
// nodeLabel: etichetta umana di un nodo (display). Mai vuota: fallback a `name`.
|
|
432
|
+
function nodeLabel(n) {
|
|
433
|
+
if (n && typeof n.label === 'string' && n.label.trim()) return n.label.trim();
|
|
434
|
+
return (n && n.name) || '';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// validLabel: true se `v` e' una label display accettabile (stringa 1..LABEL_MAX,
|
|
438
|
+
// no control char, no solo-spazi). Riutilizzi la stessa logica strict di parseNode
|
|
439
|
+
// per validare input esterno (form UI, payload pairing) senza ricostruire un nodo.
|
|
440
|
+
function validLabel(v) {
|
|
441
|
+
if (typeof v !== 'string') return false;
|
|
442
|
+
const t = v.trim();
|
|
443
|
+
if (!t || t.length > LABEL_MAX) return false;
|
|
444
|
+
return !/[\x00-\x1f\x7f]/.test(t);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// sanitizeLabel: normalizza un input libero in una label display valida. Trim,
|
|
448
|
+
// collapse spazi, tronca a LABEL_MAX. Mai throw, mai ritorna vuoto (fallback
|
|
449
|
+
// `fallback`). Usata dove si vuole proporre una label senza fallire.
|
|
450
|
+
function sanitizeLabel(v, fallback = 'NexusCrew') {
|
|
451
|
+
const t = String(v == null ? '' : v).replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
|
|
452
|
+
return t || fallback;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// toSlug: deriva uno slug routing-safe (^[a-z0-9-]{1,32}$) da un input libero.
|
|
456
|
+
// Normalizza (NFKD + strip segni diacritici), lowercase, sostituisce run non
|
|
457
|
+
// alfanumerici con '-', trim dei bordi. Mai solleva: input povero -> 'node'.
|
|
458
|
+
// Usata dal form "Nuovo nodo" per suggerire lo slug dalla label umana senza
|
|
459
|
+
// obbligare l'utente a scrivere a mano caratteri tecnici.
|
|
460
|
+
function toSlug(input) {
|
|
461
|
+
const s = String(input == null ? '' : input)
|
|
462
|
+
.normalize('NFKD')
|
|
463
|
+
.replace(/[̀-ͯ]/g, '') // segni diacritici staccati (combining) -> ASCII
|
|
464
|
+
.toLowerCase()
|
|
465
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
466
|
+
.replace(/^-+|-+$/g, '')
|
|
467
|
+
.slice(0, 32)
|
|
468
|
+
.replace(/-+$/g, ''); // trim finale dopo il slice
|
|
469
|
+
return s || 'node';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// suggestNodeName: slug univoco dato un input libero e l'elenco dei name gia'
|
|
473
|
+
// usati. Disambigua con suffisso -2/-3/... Rende la creazione di un nodo non
|
|
474
|
+
// fallibile per collisione di slug (l'utente scrive "VPS3" e ottiene "vps3",
|
|
475
|
+
// oppure "vps3-2" se esiste gia').
|
|
476
|
+
function suggestNodeName(input, existing = []) {
|
|
477
|
+
const used = new Set(Array.isArray(existing) ? existing : []);
|
|
478
|
+
const base = toSlug(input);
|
|
479
|
+
if (!used.has(base)) return base;
|
|
480
|
+
for (let i = 2; i < 100; i += 1) {
|
|
481
|
+
const candidate = `${base.slice(0, Math.max(1, 32 - String(i).length - 1))}-${i}`;
|
|
482
|
+
if (!used.has(candidate)) return candidate;
|
|
483
|
+
}
|
|
484
|
+
return base; // fallback estremo
|
|
485
|
+
}
|
|
486
|
+
|
|
412
487
|
module.exports = {
|
|
413
488
|
// parse/validate
|
|
414
489
|
parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
@@ -420,6 +495,8 @@ module.exports = {
|
|
|
420
495
|
redactNode, redactStore,
|
|
421
496
|
// migrazione
|
|
422
497
|
migrateLegacyNodes,
|
|
498
|
+
// label / slug
|
|
499
|
+
nodeLabel, validLabel, sanitizeLabel, toSlug, suggestNodeName, LABEL_MAX,
|
|
423
500
|
// costanti
|
|
424
501
|
SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
425
502
|
};
|