@mmmbuto/nexuscrew 0.8.9 → 0.8.10
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 +39 -18
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +12 -1
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +22 -14
- package/lib/nodes/peering.js +72 -10
- package/lib/nodes/store.js +21 -18
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +68 -6
- package/lib/server.js +36 -31
- package/lib/settings/routes.js +202 -95
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-9Drd8g0k.css +0 -32
- package/frontend/dist/assets/index-DTmT7yhV.js +0 -91
package/lib/cli/commands.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Minimal public CLI plus internal service/MCP entry points. [M6][R1]
|
|
3
3
|
// serve = foreground HTTP (+ --pidfile lifecycle su Termux/manuale).
|
|
4
4
|
// start/stop/status = per-platform: linux (systemctl --user), mac (launchctl),
|
|
5
5
|
// termux (nohup serve --pidfile + pidfile verificato; status boot-script vs running).
|
|
@@ -15,16 +15,9 @@ const { runInit } = require('./init.js');
|
|
|
15
15
|
const { rotateToken } = require('../auth/token.js');
|
|
16
16
|
const urlmod = require('./url.js');
|
|
17
17
|
const { doctor } = require('./doctor.js');
|
|
18
|
-
const nodesCmds = require('../nodes/commands.js');
|
|
19
18
|
const nodesStore = require('../nodes/store.js');
|
|
20
19
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
21
20
|
|
|
22
|
-
// Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
|
|
23
|
-
// Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
|
|
24
|
-
const VALUE_FLAGS = new Set([
|
|
25
|
-
'ssh', 'ssh-port', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
21
|
const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
29
22
|
|
|
30
23
|
Usage:
|
|
@@ -32,6 +25,9 @@ Usage:
|
|
|
32
25
|
nexuscrew show start when needed and open the authenticated PWA
|
|
33
26
|
nexuscrew show token print the clickable authenticated URL
|
|
34
27
|
nexuscrew boot enable startup at boot (use: boot off|status)
|
|
28
|
+
nexuscrew status show service, port, roles and node status
|
|
29
|
+
nexuscrew stop stop the background service
|
|
30
|
+
nexuscrew restart restart the background service
|
|
35
31
|
nexuscrew doctor run local diagnostics
|
|
36
32
|
nexuscrew help show this help
|
|
37
33
|
nexuscrew version show the installed version
|
|
@@ -153,57 +149,166 @@ function start(opts = {}) {
|
|
|
153
149
|
throw new Error(`start: platform ${platform} non supportata`);
|
|
154
150
|
}
|
|
155
151
|
|
|
156
|
-
function
|
|
152
|
+
function stopManagedTunnels(opts = {}) {
|
|
153
|
+
const home = opts.home || require('node:os').homedir();
|
|
154
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
155
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
156
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
157
|
+
const stopped = [];
|
|
158
|
+
for (const node of (st && st.nodes) || []) {
|
|
159
|
+
if (node.direction === 'inbound') continue;
|
|
160
|
+
try { nodesTunnel.stopTunnel({ home, name: node.name }); stopped.push(node.name); } catch (_) {}
|
|
161
|
+
}
|
|
162
|
+
if (st && st.rendezvous) {
|
|
163
|
+
try { nodesTunnel.stopTunnel({ home, name: nodesTunnel.REVERSE_NAME }); stopped.push(nodesTunnel.REVERSE_NAME); } catch (_) {}
|
|
164
|
+
}
|
|
165
|
+
return stopped;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function refusePairedPortRelocation(opts, from, to) {
|
|
169
|
+
if (Number(from) === Number(to)) return;
|
|
170
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
171
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
172
|
+
if (nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath))) {
|
|
173
|
+
throw new Error(`port ${from} is busy and paired peers exist; refusing automatic move to ${to}. Free the configured port or reconnect peers intentionally.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Runtime ownership is independent from boot persistence. With boot disabled,
|
|
178
|
+
// smartUp intentionally launches `serve --pidfile` on Linux/macOS too; lifecycle
|
|
179
|
+
// commands must therefore inspect both the service manager and that portable
|
|
180
|
+
// pidfile instead of assuming the platform selects exactly one owner.
|
|
181
|
+
function managedRuntimeState(opts = {}) {
|
|
157
182
|
const platform = opts.platform || detectPlatform();
|
|
158
183
|
const execImpl = opts.execImpl || execFileSync;
|
|
159
|
-
const log = opts.log || console.log;
|
|
160
|
-
|
|
161
184
|
if (platform === 'linux') {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
try {
|
|
186
|
+
const service = String(execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }) || '').trim();
|
|
187
|
+
return { supported: true, running: service === 'active', service: service || 'inactive' };
|
|
188
|
+
} catch (_) { return { supported: true, running: false, service: 'inactive' }; }
|
|
165
189
|
}
|
|
166
190
|
if (platform === 'mac') {
|
|
167
|
-
const
|
|
191
|
+
const service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
168
192
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return { platform, stopped: true };
|
|
173
|
-
} catch (e) {
|
|
174
|
-
const reason = String(e.message || e);
|
|
175
|
-
log(`stop: launchctl bootout fallito: ${reason}`);
|
|
176
|
-
return { platform, stopped: false, reason };
|
|
177
|
-
}
|
|
193
|
+
execImpl('launchctl', ['print', service], { stdio: 'ignore' });
|
|
194
|
+
return { supported: true, running: true, service };
|
|
195
|
+
} catch (_) { return { supported: true, running: false, service }; }
|
|
178
196
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
return { supported: false, running: false, service: 'portable-only' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function portableRuntimeState(opts = {}) {
|
|
201
|
+
const home = opts.home || require('node:os').homedir();
|
|
202
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
203
|
+
const meta = pidf.readPidfile(pidPath);
|
|
204
|
+
if (!meta) return { running: false, pidPath, reason: 'no pidfile' };
|
|
205
|
+
if (pidf.isAlive(meta)) return { running: true, pidPath, pid: meta.pid, meta };
|
|
206
|
+
pidf.cleanStale(pidPath);
|
|
207
|
+
return { running: false, pidPath, reason: 'stale pidfile' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveRuntimeOwner(opts = {}) {
|
|
211
|
+
const platform = opts.platform || detectPlatform();
|
|
212
|
+
const managed = managedRuntimeState({ ...opts, platform });
|
|
213
|
+
const portable = portableRuntimeState(opts);
|
|
214
|
+
let owner = 'stopped';
|
|
215
|
+
if (managed.running && portable.running) owner = 'conflict';
|
|
216
|
+
else if (managed.running) owner = 'managed';
|
|
217
|
+
else if (portable.running) owner = 'portable';
|
|
218
|
+
return {
|
|
219
|
+
platform, owner,
|
|
220
|
+
managedRunning: managed.running,
|
|
221
|
+
portableRunning: portable.running,
|
|
222
|
+
service: managed.service,
|
|
223
|
+
portablePid: portable.pid,
|
|
224
|
+
pidPath: portable.pidPath,
|
|
225
|
+
stalePidfile: portable.reason === 'stale pidfile',
|
|
226
|
+
portableReason: portable.reason,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function waitForPidExit(pid, timeoutMs = 2000) {
|
|
231
|
+
const deadline = Date.now() + timeoutMs;
|
|
232
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
233
|
+
const exited = () => {
|
|
234
|
+
if (!pidf.pidExists(pid)) return true;
|
|
235
|
+
// A test-owned child can remain as a zombie until this synchronous caller
|
|
236
|
+
// returns to the event loop. It no longer owns sockets and is exited for
|
|
237
|
+
// lifecycle purposes. Linux/Termux expose the state in /proc; macOS has no
|
|
238
|
+
// equivalent cheap path and normally removes a non-child immediately.
|
|
239
|
+
try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
|
|
240
|
+
catch (_) { return false; }
|
|
241
|
+
};
|
|
242
|
+
while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
|
|
243
|
+
return exited();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function stopPortableRuntime(opts = {}) {
|
|
247
|
+
const home = opts.home || require('node:os').homedir();
|
|
248
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
249
|
+
const result = pidf.killPidfile(pidPath);
|
|
250
|
+
if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
|
|
251
|
+
return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
|
|
186
252
|
}
|
|
187
|
-
|
|
253
|
+
return result;
|
|
188
254
|
}
|
|
189
255
|
|
|
190
|
-
|
|
191
|
-
function isServiceRunning(opts = {}) {
|
|
256
|
+
function stop(opts = {}) {
|
|
192
257
|
const platform = opts.platform || detectPlatform();
|
|
193
258
|
const execImpl = opts.execImpl || execFileSync;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
259
|
+
const log = opts.log || console.log;
|
|
260
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`stop: platform ${platform} non supportata`);
|
|
261
|
+
|
|
262
|
+
const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
263
|
+
const stoppedOwners = [];
|
|
264
|
+
const errors = [];
|
|
265
|
+
|
|
266
|
+
if (before.managedRunning) {
|
|
267
|
+
try {
|
|
268
|
+
if (platform === 'linux') {
|
|
269
|
+
execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
|
|
270
|
+
log('stop: systemctl --user stop nexuscrew');
|
|
271
|
+
} else {
|
|
272
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
273
|
+
// bootout unloads KeepAlive, so a voluntary stop stays stopped.
|
|
274
|
+
execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
|
|
275
|
+
log(`stop: launchctl bootout ${label}`);
|
|
276
|
+
}
|
|
277
|
+
stoppedOwners.push('managed');
|
|
278
|
+
} catch (error) { errors.push(String(error.message || error)); }
|
|
197
279
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
280
|
+
|
|
281
|
+
if (before.portableRunning) {
|
|
282
|
+
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
283
|
+
if (portable.killed) {
|
|
284
|
+
stoppedOwners.push('portable');
|
|
285
|
+
log(`stop: killed portable pid ${portable.pid}`);
|
|
286
|
+
} else errors.push(portable.reason || 'portable stop failed');
|
|
201
287
|
}
|
|
288
|
+
|
|
289
|
+
// Detached tunnel supervisors are outside both server owners. A real stop
|
|
290
|
+
// closes them once; the next start restores only autostart:true links.
|
|
291
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
202
292
|
if (platform === 'termux') {
|
|
203
|
-
|
|
204
|
-
return !!(meta && pidf.isAlive(meta));
|
|
293
|
+
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
205
294
|
}
|
|
206
|
-
|
|
295
|
+
|
|
296
|
+
if (errors.length) {
|
|
297
|
+
const reason = errors.join('; ');
|
|
298
|
+
log(`stop: incompleto — ${reason}`);
|
|
299
|
+
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
300
|
+
}
|
|
301
|
+
const alreadyStopped = before.owner === 'stopped';
|
|
302
|
+
if (alreadyStopped) log(`stop: ${before.portableReason || 'already stopped'}`);
|
|
303
|
+
return {
|
|
304
|
+
platform, owner: before.owner, stopped: true, stoppedOwners, alreadyStopped,
|
|
305
|
+
reason: alreadyStopped ? (before.portableReason || 'already stopped') : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// running-check per-platform (no log) — riusato da smart-up / token rotate / update.
|
|
310
|
+
function isServiceRunning(opts = {}) {
|
|
311
|
+
return resolveRuntimeOwner(opts).owner !== 'stopped';
|
|
207
312
|
}
|
|
208
313
|
|
|
209
314
|
function bootState(opts = {}) {
|
|
@@ -274,12 +379,13 @@ function quickSummary(result, opts = {}) {
|
|
|
274
379
|
let cached = [];
|
|
275
380
|
try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
|
|
276
381
|
const direct = (st && st.nodes) || [];
|
|
277
|
-
const
|
|
382
|
+
const managed = direct.filter((n) => n.direction !== 'inbound');
|
|
383
|
+
const online = managed.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
|
|
278
384
|
const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
|
|
279
385
|
log(`NexusCrew ${require('../../package.json').version}`);
|
|
280
386
|
log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
|
|
281
387
|
log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
|
|
282
|
-
log(`nodes ${known} known · ${online}/${
|
|
388
|
+
log(`nodes ${known} known · ${online}/${managed.length} hub connections up`);
|
|
283
389
|
log('');
|
|
284
390
|
log('open nexuscrew show');
|
|
285
391
|
log('link nexuscrew show token');
|
|
@@ -304,25 +410,18 @@ function status(opts = {}) {
|
|
|
304
410
|
const { configPath, configDir } = urlmod.resolvePaths(opts);
|
|
305
411
|
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
306
412
|
|
|
307
|
-
const
|
|
413
|
+
const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
414
|
+
const out = {
|
|
415
|
+
platform, service: runtime.service, running: runtime.owner !== 'stopped',
|
|
416
|
+
runtimeOwner: runtime.owner, managedRunning: runtime.managedRunning,
|
|
417
|
+
portableRunning: runtime.portableRunning, portablePid: runtime.portablePid,
|
|
418
|
+
port: null, url: null, roles: null, nodes: [],
|
|
419
|
+
};
|
|
308
420
|
|
|
309
|
-
if (platform === '
|
|
310
|
-
try {
|
|
311
|
-
const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim();
|
|
312
|
-
out.service = s; out.running = s === 'active';
|
|
313
|
-
} catch (_) { out.service = 'inactive'; out.running = false; }
|
|
314
|
-
} else if (platform === 'mac') {
|
|
315
|
-
out.service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
316
|
-
try {
|
|
317
|
-
execImpl('launchctl', ['print', out.service], { stdio: 'ignore' });
|
|
318
|
-
out.running = true;
|
|
319
|
-
} catch (_) { out.running = false; }
|
|
320
|
-
} else if (platform === 'termux') {
|
|
421
|
+
if (platform === 'termux') {
|
|
321
422
|
// boot-script installed vs server running (pidfile vivo)
|
|
322
423
|
const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
323
424
|
out.bootScriptInstalled = fs.existsSync(bootScript);
|
|
324
|
-
const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
|
|
325
|
-
out.running = !!(meta && pidf.isAlive(meta));
|
|
326
425
|
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
327
426
|
}
|
|
328
427
|
|
|
@@ -341,8 +440,10 @@ function status(opts = {}) {
|
|
|
341
440
|
return {
|
|
342
441
|
name: red.name, roles: red.roles,
|
|
343
442
|
remotePort: red.remotePort, localPort: red.localPort,
|
|
344
|
-
hasToken: red.hasToken,
|
|
345
|
-
tunnel:
|
|
443
|
+
direction: red.direction, shared: red.shared, hasToken: red.hasToken,
|
|
444
|
+
tunnel: n.direction === 'inbound'
|
|
445
|
+
? { status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false }
|
|
446
|
+
: nodesTunnel.readTunnelState(home, n.name),
|
|
346
447
|
};
|
|
347
448
|
});
|
|
348
449
|
}
|
|
@@ -354,6 +455,7 @@ function status(opts = {}) {
|
|
|
354
455
|
log(`platform: ${out.platform}`);
|
|
355
456
|
log(`service: ${out.service}`);
|
|
356
457
|
log(`running: ${out.running}`);
|
|
458
|
+
log(`runtime: ${out.runtimeOwner}${out.portablePid ? ` (pid ${out.portablePid})` : ''}`);
|
|
357
459
|
log(`port: ${out.port}`);
|
|
358
460
|
log(`url: ${out.url}`);
|
|
359
461
|
log(`roles: client=${out.roles.client} node=${out.roles.node}`);
|
|
@@ -368,23 +470,50 @@ function restart(opts = {}) {
|
|
|
368
470
|
const platform = opts.platform || detectPlatform();
|
|
369
471
|
const execImpl = opts.execImpl || execFileSync;
|
|
370
472
|
const log = opts.log || console.log;
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
473
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`restart: platform ${platform} non supportata`);
|
|
474
|
+
const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
475
|
+
|
|
476
|
+
// A managed owner can use the service manager's atomic restart. In a
|
|
477
|
+
// conflict, first remove only the stray portable owner, then keep managed as
|
|
478
|
+
// the single authority.
|
|
479
|
+
if (before.managedRunning) {
|
|
480
|
+
if (before.portableRunning) {
|
|
481
|
+
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
482
|
+
if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
if (platform === 'linux') {
|
|
486
|
+
execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
|
|
487
|
+
log('restart: systemctl --user restart nexuscrew');
|
|
488
|
+
} else {
|
|
489
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
490
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
491
|
+
log(`restart: launchctl kickstart -k ${label}`);
|
|
492
|
+
}
|
|
493
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: true };
|
|
494
|
+
} catch (error) {
|
|
495
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
|
|
496
|
+
}
|
|
386
497
|
}
|
|
387
|
-
|
|
498
|
+
|
|
499
|
+
if (before.portableRunning) {
|
|
500
|
+
const stopped = stop({ ...opts, platform, execImpl, log });
|
|
501
|
+
if (!stopped.stopped) return { platform, owner: before.owner, restarted: false, reason: stopped.reason };
|
|
502
|
+
const started = (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
|
|
503
|
+
const ok = !!(started && started.started !== false);
|
|
504
|
+
if (ok) log('restart: portable runtime started');
|
|
505
|
+
return { platform, owner: before.owner, runtimeOwner: 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// `restart` on a stopped runtime follows the explicit boot owner; with boot
|
|
509
|
+
// disabled it starts a portable background runtime.
|
|
510
|
+
const useManaged = platform !== 'termux' && bootState({ ...opts, platform, execImpl }).enabled;
|
|
511
|
+
const started = useManaged
|
|
512
|
+
? start({ ...opts, platform, execImpl, spawnImpl: opts.spawnImpl, log })
|
|
513
|
+
: (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
|
|
514
|
+
const ok = !!(started && started.started !== false);
|
|
515
|
+
if (ok) log(`restart: ${useManaged ? 'managed' : 'portable'} runtime started`);
|
|
516
|
+
return { platform, owner: before.owner, runtimeOwner: useManaged ? 'managed' : 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
|
|
388
517
|
}
|
|
389
518
|
|
|
390
519
|
function wizardComplete(configPath) {
|
|
@@ -446,6 +575,8 @@ async function waitForNexusCrew(port, token, opts = {}) {
|
|
|
446
575
|
function startPortable(opts = {}) {
|
|
447
576
|
const spawnImpl = opts.spawnImpl || spawn;
|
|
448
577
|
const home = opts.home || require('node:os').homedir();
|
|
578
|
+
const existing = portableRuntimeState({ home });
|
|
579
|
+
if (existing.running) return { started: false, reason: 'already running', pid: existing.pid, portable: true };
|
|
449
580
|
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
450
581
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
451
582
|
const logFd = fs.openSync(logPath, 'a');
|
|
@@ -502,6 +633,7 @@ async function smartUp(opts = {}) {
|
|
|
502
633
|
if (!initialized) {
|
|
503
634
|
const requested = opts.port || urlmod.loadPort(opts);
|
|
504
635
|
const selected = await findAvailablePort(requested, opts);
|
|
636
|
+
refusePairedPortRelocation(opts, requested, selected);
|
|
505
637
|
runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
|
|
506
638
|
port = selected;
|
|
507
639
|
initialized = true;
|
|
@@ -519,19 +651,24 @@ async function smartUp(opts = {}) {
|
|
|
519
651
|
let token = urlmod.readToken(tokenPath);
|
|
520
652
|
let running = await probe(port, token, opts);
|
|
521
653
|
let portableAttempted = false;
|
|
522
|
-
|
|
523
|
-
if (!running &&
|
|
654
|
+
let runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
655
|
+
if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
|
|
524
656
|
|
|
525
657
|
if (!running) {
|
|
526
658
|
if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
|
|
659
|
+
const previousPort = port;
|
|
527
660
|
port = await findAvailablePort(port + 1, opts);
|
|
661
|
+
refusePairedPortRelocation(opts, previousPort, port);
|
|
528
662
|
runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
|
|
529
663
|
token = urlmod.readToken(tokenPath);
|
|
530
664
|
} else {
|
|
531
665
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
666
|
+
runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
667
|
+
const started = runtime.owner === 'conflict'
|
|
668
|
+
? restartImpl({ ...opts, platform, log: quiet })
|
|
669
|
+
: persistent
|
|
670
|
+
? (runtime.managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
|
|
671
|
+
: (runtime.portableRunning ? restartImpl({ ...opts, platform, log: quiet }) : portableStart({ ...opts, platform }));
|
|
535
672
|
if (started && started.started === false) {
|
|
536
673
|
portableStart({ ...opts, platform }); portableAttempted = true;
|
|
537
674
|
}
|
|
@@ -594,7 +731,7 @@ function tokenRotate(opts = {}) {
|
|
|
594
731
|
log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
|
|
595
732
|
}
|
|
596
733
|
}
|
|
597
|
-
log('usa `nexuscrew
|
|
734
|
+
log('usa `nexuscrew show token` per il nuovo URL (il token non si stampa qui)');
|
|
598
735
|
return { rotated: true, running };
|
|
599
736
|
}
|
|
600
737
|
|
|
@@ -703,7 +840,7 @@ function dispatch(argv, opts = {}) {
|
|
|
703
840
|
log(require('../../package.json').version);
|
|
704
841
|
return { code: 0 };
|
|
705
842
|
}
|
|
706
|
-
const { flags, rest } = parseFlags(argv
|
|
843
|
+
const { flags, rest } = parseFlags(argv);
|
|
707
844
|
const cmd = rest[0];
|
|
708
845
|
|
|
709
846
|
// help esplicito
|
|
@@ -742,6 +879,18 @@ function dispatch(argv, opts = {}) {
|
|
|
742
879
|
log(require('../../package.json').version);
|
|
743
880
|
return { code: 0 };
|
|
744
881
|
}
|
|
882
|
+
if (cmd === 'status') {
|
|
883
|
+
status({ ...opts, log });
|
|
884
|
+
return { code: 0 };
|
|
885
|
+
}
|
|
886
|
+
if (cmd === 'stop') {
|
|
887
|
+
const result = stop({ ...opts, log });
|
|
888
|
+
return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
|
|
889
|
+
}
|
|
890
|
+
if (cmd === 'restart') {
|
|
891
|
+
const result = restart({ ...opts, log });
|
|
892
|
+
return { code: result.restarted ? 0 : 1 };
|
|
893
|
+
}
|
|
745
894
|
// Internal runtime commands used by service managers and MCP clients. They
|
|
746
895
|
// are intentionally omitted from HELP and are not configuration surfaces.
|
|
747
896
|
if (cmd === 'serve') {
|
|
@@ -766,62 +915,6 @@ function dispatch(argv, opts = {}) {
|
|
|
766
915
|
return { code: 1 };
|
|
767
916
|
}
|
|
768
917
|
|
|
769
|
-
// nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
|
|
770
|
-
function dispatchNodes(rest, flags, opts) {
|
|
771
|
-
const log = opts.log;
|
|
772
|
-
const sub = rest[1];
|
|
773
|
-
const name = rest[2];
|
|
774
|
-
const common = { ...opts, name };
|
|
775
|
-
switch (sub) {
|
|
776
|
-
case 'add':
|
|
777
|
-
return { code: nodesCmds.nodesAdd({
|
|
778
|
-
...common, ssh: flags.ssh, sshPort: flags['ssh-port'], remotePort: flags['remote-port'],
|
|
779
|
-
key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
|
|
780
|
-
}).code };
|
|
781
|
-
case undefined:
|
|
782
|
-
case 'list':
|
|
783
|
-
return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
|
|
784
|
-
case 'remove':
|
|
785
|
-
return { code: nodesCmds.nodesRemove(common).code };
|
|
786
|
-
case 'up':
|
|
787
|
-
return { code: nodesCmds.nodesUp(common).code };
|
|
788
|
-
case 'down':
|
|
789
|
-
return { code: nodesCmds.nodesDown(common).code };
|
|
790
|
-
case 'restart':
|
|
791
|
-
return { code: nodesCmds.nodesRestart(common).code };
|
|
792
|
-
case 'set-token':
|
|
793
|
-
return { code: nodesCmds.nodesSetToken(common).code };
|
|
794
|
-
case 'test': {
|
|
795
|
-
// async: tieni vivo il processo finche' il probe non risolve, poi exit.
|
|
796
|
-
const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
|
|
797
|
-
nodesCmds.nodesTest(common)
|
|
798
|
-
.then((r) => exit(r.code))
|
|
799
|
-
.catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
|
|
800
|
-
return { code: 0, keepAlive: true };
|
|
801
|
-
}
|
|
802
|
-
default:
|
|
803
|
-
log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
|
|
804
|
-
return { code: 1 };
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
|
|
809
|
-
function dispatchNode(rest, flags, opts) {
|
|
810
|
-
const log = opts.log;
|
|
811
|
-
const sub = rest[1];
|
|
812
|
-
if (sub === 'on') {
|
|
813
|
-
return { code: nodesCmds.nodeOn({
|
|
814
|
-
...opts, rendezvousSsh: flags.rendezvous,
|
|
815
|
-
publishedPort: flags['published-port'], key: flags.key,
|
|
816
|
-
}).code };
|
|
817
|
-
}
|
|
818
|
-
if (sub === 'off') {
|
|
819
|
-
return { code: nodesCmds.nodeOff({ ...opts }).code };
|
|
820
|
-
}
|
|
821
|
-
log('usage: nexuscrew node on|off');
|
|
822
|
-
return { code: 1 };
|
|
823
|
-
}
|
|
824
|
-
|
|
825
918
|
module.exports = {
|
|
826
919
|
dispatch, serve, start, stop, status, parseFlags, HELP,
|
|
827
920
|
smartUp, url, tokenRotate, logs, doctor, update, restart,
|
|
@@ -829,5 +922,7 @@ module.exports = {
|
|
|
829
922
|
servicePinsLegacyPort,
|
|
830
923
|
isServiceRunning, readRoles,
|
|
831
924
|
bootState, bootOn, bootOff, quickSummary,
|
|
925
|
+
stopManagedTunnels,
|
|
926
|
+
managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
|
|
832
927
|
runFleetBoot, dispatchFleetBoot,
|
|
833
928
|
};
|
package/lib/cli/doctor.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// nexuscrew doctor: auto-diagnosi (design §3, §7). [A2]
|
|
3
3
|
// Struttura ESTENSIBILE: una lista di check-fn, ognuna ritorna
|
|
4
|
-
// { name, ok, warn?, detail? }.
|
|
5
|
-
//
|
|
4
|
+
// { name, ok, warn?, detail? }. SSH è un requisito locale; la policy del
|
|
5
|
+
// server remoto si prova soltanto tentando il forwarding reale.
|
|
6
6
|
// Exit code: 0 se tutti ok (i warn non falliscono), 1 se almeno un check è FAIL.
|
|
7
7
|
// Nessun segreto nell'output (mai il token, solo il path + i permessi).
|
|
8
8
|
const fs = require('node:fs');
|
|
@@ -87,22 +87,24 @@ function checkBoot(platform, home, execImpl) {
|
|
|
87
87
|
// ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
|
|
88
88
|
function checkSshClient(existsImpl) {
|
|
89
89
|
return existsImpl('ssh')
|
|
90
|
-
? { name: '
|
|
91
|
-
: { name: '
|
|
90
|
+
? { name: 'OpenSSH transport', ok: true, detail: 'ssh presente · USATO dal supervisor NexusCrew' }
|
|
91
|
+
: { name: 'OpenSSH transport', ok: false, detail: 'ssh non trovato su PATH; autossh da solo non funziona senza OpenSSH' };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
function checkAutossh(existsImpl) {
|
|
95
|
+
return existsImpl('autossh')
|
|
96
|
+
? { name: 'autossh', ok: true, detail: 'presente · NON usato (retry gia gestito dal supervisor NexusCrew)' }
|
|
97
|
+
: { name: 'autossh', ok: true, warn: true, detail: 'assente · opzionale, non necessario con SSH supervisionato' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Versione locale informativa. `permitlisten` e' una policy del server sshd e
|
|
101
|
+
// NON puo' essere certificata guardando `ssh -V` sul client: Share la verifica
|
|
102
|
+
// con un vero -R + health autenticato.
|
|
97
103
|
function checkSshPermitlisten(sshVersionImpl) {
|
|
98
104
|
const tun = require('../nodes/tunnel.js');
|
|
99
105
|
const v = tun.readSshVersion(sshVersionImpl);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, warn: true, detail: 'versione ssh non determinabile — verifica prima di abilitare il ruolo node' };
|
|
103
|
-
}
|
|
104
|
-
if (supp) return { name: 'OpenSSH permitlisten (>=7.8)', ok: true, detail: v.raw };
|
|
105
|
-
return { name: 'OpenSSH permitlisten (>=7.8)', ok: false, detail: `OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato (ruolo node non abilitabile)` };
|
|
106
|
+
if (!v) return { name: 'OpenSSH version', ok: true, warn: true, detail: 'versione non determinabile; Share verra verificato a runtime sul server' };
|
|
107
|
+
return { name: 'OpenSSH version', ok: true, detail: `${v.raw} · policy reverse verificata a runtime` };
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
function checkTokenPerms(tokenPath) {
|
|
@@ -141,6 +143,7 @@ function doctor(opts = {}) {
|
|
|
141
143
|
checkBoot(platform, home, execImpl),
|
|
142
144
|
checkTokenPerms(tokenPath),
|
|
143
145
|
checkSshClient(existsImpl),
|
|
146
|
+
checkAutossh(existsImpl),
|
|
144
147
|
checkSshPermitlisten(opts.sshVersion),
|
|
145
148
|
];
|
|
146
149
|
|
|
@@ -156,5 +159,5 @@ function doctor(opts = {}) {
|
|
|
156
159
|
module.exports = {
|
|
157
160
|
doctor, nodeMajor,
|
|
158
161
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
159
|
-
checkSshClient, checkSshPermitlisten,
|
|
162
|
+
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
160
163
|
};
|
package/lib/cli/init.js
CHANGED
|
@@ -115,13 +115,13 @@ function runInit(opts = {}) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// Fleet app defaults: soltanto i
|
|
118
|
+
// Fleet app defaults: soltanto i quattro client nativi. Provider cloud/Z.AI sono
|
|
119
119
|
// disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
|
|
120
120
|
const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
|
|
121
121
|
if (!dryRun && !fs.existsSync(fleetDefsPath)) {
|
|
122
122
|
try {
|
|
123
123
|
writeFleet(fleetDefsPath, defaultDefinitions());
|
|
124
|
-
actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native)`);
|
|
124
|
+
actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
|
|
125
125
|
} catch (e) {
|
|
126
126
|
actions.push(`WARN: fleet defaults non creati: ${e.message}`);
|
|
127
127
|
}
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -19,9 +19,10 @@ function readPidfile(p) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
|
|
22
|
-
function writePidfile(p, pid, cmd) {
|
|
22
|
+
function writePidfile(p, pid, cmd, extra = {}) {
|
|
23
23
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
24
|
-
const
|
|
24
|
+
const safeExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
|
|
25
|
+
const meta = JSON.stringify({ pid, cmd: cmd || '', startTs: Date.now(), ...safeExtra });
|
|
25
26
|
fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
|
|
26
27
|
}
|
|
27
28
|
|
package/lib/decks/store.js
CHANGED
|
@@ -9,6 +9,7 @@ const MAX_DECKS = 24;
|
|
|
9
9
|
const MAX_TILES = 9;
|
|
10
10
|
const NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
11
11
|
const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
|
|
12
|
+
const OWNER_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
12
13
|
|
|
13
14
|
function validNodeRoute(node) {
|
|
14
15
|
if (!NODE_RE.test(node)) return false;
|
|
@@ -48,15 +49,17 @@ function parseLayout(raw) {
|
|
|
48
49
|
for (const t of c.tiles) {
|
|
49
50
|
if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
|
|
50
51
|
if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
|
|
52
|
+
if (t.ownerId !== undefined && (typeof t.ownerId !== 'string' || !OWNER_ID_RE.test(t.ownerId))) return null;
|
|
51
53
|
const height = Number(t.height);
|
|
52
54
|
const fontSize = Number(t.fontSize);
|
|
53
55
|
if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
|
|
54
56
|
if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
|
|
55
|
-
const key = t.node ? `${t.node}:${t.session}` : t.session;
|
|
57
|
+
const key = t.ownerId ? `${t.ownerId}:${t.session}` : t.node ? `${t.node}:${t.session}` : t.session;
|
|
56
58
|
if (seen.has(key) || ++count > MAX_TILES) return null;
|
|
57
59
|
seen.add(key);
|
|
58
60
|
const tile = { session: t.session, height, fontSize };
|
|
59
61
|
if (t.node) tile.node = t.node;
|
|
62
|
+
if (t.ownerId) tile.ownerId = t.ownerId;
|
|
60
63
|
tiles.push(tile);
|
|
61
64
|
}
|
|
62
65
|
columns.push({ width, tiles });
|
|
@@ -118,6 +121,6 @@ function loadOrCreate(p) {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
module.exports = {
|
|
121
|
-
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
|
|
124
|
+
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE, OWNER_ID_RE,
|
|
122
125
|
defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
|
|
123
126
|
};
|