@mmmbuto/nexuscrew 0.8.2 → 0.8.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/README.md +28 -14
- package/frontend/dist/assets/index-BFyPeZsL.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/topology-cache.js +75 -0
- package/lib/proxy/federation.js +51 -5
- package/lib/server.js +2 -1
- package/lib/settings/routes.js +5 -1
- package/package.json +1 -1
- 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-BFyPeZsL.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.3"}
|
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,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { NODE_ID_RE, NODE_NAME_RE } = require('./store.js');
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
const MAX_ENTRIES = 256;
|
|
11
|
+
const MAX_HOPS = 4;
|
|
12
|
+
|
|
13
|
+
function defaultPath(home = os.homedir()) {
|
|
14
|
+
return path.join(home, '.nexuscrew', 'topology-cache.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseEntry(raw) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
19
|
+
const keys = Object.keys(raw).sort();
|
|
20
|
+
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
|
|
21
|
+
if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
|
|
22
|
+
if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
|
|
23
|
+
if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
|
|
24
|
+
if (raw.name !== raw.route[raw.route.length - 1]) return null;
|
|
25
|
+
if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
|
|
26
|
+
return { instanceId: raw.instanceId, name: raw.name, route: [...raw.route], lastSeen: raw.lastSeen };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseCache(raw) {
|
|
30
|
+
let value = raw;
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
try { value = JSON.parse(value); } catch (_) { return null; }
|
|
33
|
+
}
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
35
|
+
if (value.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.nodes) || value.nodes.length > MAX_ENTRIES) return null;
|
|
36
|
+
if (Object.keys(value).some((k) => !['schemaVersion', 'nodes'].includes(k))) return null;
|
|
37
|
+
const nodes = value.nodes.map(parseEntry);
|
|
38
|
+
if (nodes.some((x) => !x)) return null;
|
|
39
|
+
if (new Set(nodes.map((x) => x.instanceId)).size !== nodes.length) return null;
|
|
40
|
+
if (new Set(nodes.map((x) => x.route.join('/'))).size !== nodes.length) return null;
|
|
41
|
+
return { schemaVersion: SCHEMA_VERSION, nodes };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emptyCache() { return { schemaVersion: SCHEMA_VERSION, nodes: [] }; }
|
|
45
|
+
|
|
46
|
+
function loadCache(file = defaultPath()) {
|
|
47
|
+
try {
|
|
48
|
+
const st = fs.lstatSync(file);
|
|
49
|
+
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
50
|
+
return parseCache(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return e.code === 'ENOENT' ? emptyCache() : null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function atomicWriteCache(file, value) {
|
|
57
|
+
const parsed = parseCache(value);
|
|
58
|
+
if (!parsed) throw new Error('topology cache non valida');
|
|
59
|
+
try {
|
|
60
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('refusing symlink topology cache target');
|
|
61
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
fs.chmodSync(tmp, 0o600);
|
|
67
|
+
fs.renameSync(tmp, file);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { SCHEMA_VERSION, MAX_ENTRIES, MAX_HOPS, defaultPath, parseEntry, parseCache, emptyCache, loadCache, atomicWriteCache };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -4,6 +4,7 @@ const http = require('node:http');
|
|
|
4
4
|
const net = require('node:net');
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const store = require('../nodes/store.js');
|
|
7
|
+
const topologyCache = require('../nodes/topology-cache.js');
|
|
7
8
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
8
9
|
const { safeEqual } = require('../nodes/peering.js');
|
|
9
10
|
const {
|
|
@@ -53,7 +54,8 @@ function knownResource(resource) {
|
|
|
53
54
|
|| resource === '/files'
|
|
54
55
|
|| resource === '/files/download'
|
|
55
56
|
|| resource === '/files/upload'
|
|
56
|
-
|| resource === '/ws'
|
|
57
|
+
|| resource === '/ws'
|
|
58
|
+
|| /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function allowedResource(resource, method = 'GET') {
|
|
@@ -65,6 +67,8 @@ function allowedResource(resource, method = 'GET') {
|
|
|
65
67
|
if (resource === '/files/download') return method === 'GET';
|
|
66
68
|
if (resource === '/files/upload') return method === 'POST';
|
|
67
69
|
if (resource === '/ws') return method === 'GET';
|
|
70
|
+
if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
|
|
71
|
+
if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource)) return method === 'POST';
|
|
68
72
|
return false;
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -118,12 +122,13 @@ function controlledVisited(req, ingress, instanceId) {
|
|
|
118
122
|
return [...seen, instanceId];
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
async function
|
|
125
|
+
async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
|
|
122
126
|
const st = store.loadStore(nodesPath);
|
|
123
|
-
if (!st) return { instanceId: null, nodes: [] };
|
|
127
|
+
if (!st) return { instanceId: null, nodes: [], authoritative: [] };
|
|
124
128
|
const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
|
|
125
129
|
seen.add(st.nodeId);
|
|
126
130
|
const out = [];
|
|
131
|
+
const authoritative = [];
|
|
127
132
|
for (const n of st.nodes) {
|
|
128
133
|
if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
|
|
129
134
|
out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
|
|
@@ -133,6 +138,7 @@ async function collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visi
|
|
|
133
138
|
const r = await fetchImpl(u, { headers: { authorization: `Bearer ${n.token}` } });
|
|
134
139
|
const j = await r.json();
|
|
135
140
|
if (!r.ok || j.instanceId !== n.nodeId || !Array.isArray(j.nodes)) continue;
|
|
141
|
+
authoritative.push(n.name);
|
|
136
142
|
for (const child of j.nodes) {
|
|
137
143
|
if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
|
|
138
144
|
|| !store.NODE_NAME_RE.test(child.name)
|
|
@@ -151,7 +157,47 @@ async function collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visi
|
|
|
151
157
|
if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
|
|
152
158
|
ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
|
|
153
159
|
}
|
|
154
|
-
return { instanceId: st.nodeId, nodes: unique };
|
|
160
|
+
return { instanceId: st.nodeId, nodes: unique, authoritative };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function collectTopology(opts) {
|
|
164
|
+
const out = await collectTopologyDetailed(opts);
|
|
165
|
+
return { instanceId: out.instanceId, nodes: out.nodes };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Local roster: live topology plus a credential-free cache of previously seen
|
|
169
|
+
// transitive nodes. Stale entries are never returned by the peer endpoint.
|
|
170
|
+
async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
|
|
171
|
+
const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
|
|
172
|
+
const st = store.loadStore(nodesPath);
|
|
173
|
+
const directNames = new Set(((st && st.nodes) || []).map((n) => n.name));
|
|
174
|
+
const authoritative = new Set(live.authoritative);
|
|
175
|
+
const liveIds = new Set(live.nodes.map((n) => n.instanceId));
|
|
176
|
+
const liveRoutes = new Set(live.nodes.map((n) => n.route.join('/')));
|
|
177
|
+
const cacheFile = cachePath || topologyCache.defaultPath();
|
|
178
|
+
const old = topologyCache.loadCache(cacheFile) || topologyCache.emptyCache();
|
|
179
|
+
const next = new Map();
|
|
180
|
+
|
|
181
|
+
for (const entry of old.nodes) {
|
|
182
|
+
const first = entry.route[0];
|
|
183
|
+
if (!directNames.has(first)) continue;
|
|
184
|
+
if (authoritative.has(first) && !liveIds.has(entry.instanceId) && !liveRoutes.has(entry.route.join('/'))) continue;
|
|
185
|
+
next.set(entry.instanceId, entry);
|
|
186
|
+
}
|
|
187
|
+
for (const n of live.nodes) {
|
|
188
|
+
if (n.route.length > 1) next.set(n.instanceId, { instanceId: n.instanceId, name: n.name, route: [...n.route], lastSeen: now });
|
|
189
|
+
}
|
|
190
|
+
const cached = [...next.values()].sort((a, b) => a.route.join('/').localeCompare(b.route.join('/'))).slice(0, topologyCache.MAX_ENTRIES);
|
|
191
|
+
const serialized = { schemaVersion: topologyCache.SCHEMA_VERSION, nodes: cached };
|
|
192
|
+
if (JSON.stringify(serialized) !== JSON.stringify(old)) {
|
|
193
|
+
try { topologyCache.atomicWriteCache(cacheFile, serialized); } catch (_) {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const nodes = live.nodes.map((n) => ({ ...n, stale: false, lastSeen: now }));
|
|
197
|
+
for (const n of cached) {
|
|
198
|
+
if (!liveIds.has(n.instanceId) && !liveRoutes.has(n.route.join('/'))) nodes.push({ ...n, direct: false, stale: true });
|
|
199
|
+
}
|
|
200
|
+
return { instanceId: live.instanceId, nodes };
|
|
155
201
|
}
|
|
156
202
|
|
|
157
203
|
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly }) {
|
|
@@ -213,5 +259,5 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
|
|
|
213
259
|
|
|
214
260
|
module.exports = {
|
|
215
261
|
MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
216
|
-
collectTopology, peerRouter, localRouter, forwardUpgrade,
|
|
262
|
+
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
217
263
|
};
|
package/lib/server.js
CHANGED
|
@@ -100,6 +100,7 @@ function createServer(opts = {}) {
|
|
|
100
100
|
// token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
|
|
101
101
|
// il valore che il proxy inietta upstream, non esce mai verso il browser.
|
|
102
102
|
const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
103
|
+
const topologyCachePath = cfg.topologyCachePath || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
|
|
103
104
|
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
104
105
|
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
105
106
|
function resolveNode(name) {
|
|
@@ -235,7 +236,7 @@ function createServer(opts = {}) {
|
|
|
235
236
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
236
237
|
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
|
|
237
238
|
api.get('/topology', async (_req, res) => {
|
|
238
|
-
try { res.json(await federation.
|
|
239
|
+
try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
|
|
239
240
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
240
241
|
});
|
|
241
242
|
api.use('/route', federation.localRouter({
|
package/lib/settings/routes.js
CHANGED
|
@@ -49,7 +49,7 @@ const peering = require('../nodes/peering.js');
|
|
|
49
49
|
const { rotateToken } = require('../auth/token.js');
|
|
50
50
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
51
51
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
52
|
-
const { isServiceRunning, readRoles } = require('../cli/commands.js');
|
|
52
|
+
const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
|
|
53
53
|
const { configJsonPath } = require('../config.js');
|
|
54
54
|
const VERSION = require('../../package.json').version;
|
|
55
55
|
|
|
@@ -178,6 +178,7 @@ function settingsRoutes(deps = {}) {
|
|
|
178
178
|
const service = {
|
|
179
179
|
installed: fs.existsSync(svcPath),
|
|
180
180
|
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
181
|
+
boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
|
|
181
182
|
};
|
|
182
183
|
const out = {
|
|
183
184
|
roles: readRoles(configPath),
|
|
@@ -568,6 +569,9 @@ function settingsRoutes(deps = {}) {
|
|
|
568
569
|
// dall'API (la UI avvisa di riavviare a mano).
|
|
569
570
|
r.post('/service/regenerate', mutGate, (_req, res) => {
|
|
570
571
|
try {
|
|
572
|
+
if (!bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled) {
|
|
573
|
+
return send(res, 409, { error: 'boot non abilitato: usa `nexuscrew boot`' });
|
|
574
|
+
}
|
|
571
575
|
const { cfg: fileCfg } = readConfigFile(configPath);
|
|
572
576
|
const port = nodesStore.isPort(fileCfg.port) ? fileCfg.port : cfg.port;
|
|
573
577
|
const ctx = {
|