@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.
@@ -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-Dey9rdY-.js"></script>
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.2"}
1
+ {"version":"0.8.3"}
@@ -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; first run opens the PWA wizard
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(require('node:os').homedir(), '.nexuscrew', 'nexuscrew.log');
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 child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
131
- detached: true, stdio: ['ignore', logFd, logFd],
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 child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
356
- detached: true, stdio: ['ignore', logFd, logFd],
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
- if (servicePinsLegacyPort(platform, home, opts.installPath)) {
403
- runInitImpl({ ...opts, log: quiet, platform, printUrl: false });
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 = managedRunning
421
- ? restartImpl({ ...opts, platform, log: quiet })
422
- : startImpl({ ...opts, platform, log: quiet });
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
- startPortable({ ...opts, platform }); portableAttempted = true;
536
+ portableStart({ ...opts, platform }); portableAttempted = true;
425
537
  }
426
538
  } catch (_) {
427
- startPortable({ ...opts, platform }); portableAttempted = true;
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 { startPortable({ ...opts, platform }); portableAttempted = true; } catch (_) {}
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.log || (() => {}) }).then(() => ({ code: 0 }));
720
+ return smartUp({ ...opts, log: opts.lifecycleLog || (() => {}) }).then((r) => { quickSummary(r, { ...opts, log }); return { code: 0 }; });
609
721
  }
610
722
  if (cmd === 'show') {
611
- return smartUp({ ...opts, forceOpen: true, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
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 (dryRun) {
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 (readonly) {
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
@@ -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(os.homedir(), '.nexuscrew', 'nexuscrew.pid');
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) {
@@ -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 };
@@ -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 collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
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.collectTopology({ nodesPath })); }
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({
@@ -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 = {