@mmmbuto/nexuscrew 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -18
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +462 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +319 -0
- package/lib/cli/init.js +107 -7
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +59 -2
- package/lib/files/store.js +16 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +594 -0
- package/lib/fleet/definitions.js +410 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +78 -8
- package/lib/fs/routes.js +56 -0
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +214 -15
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
- package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
- package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
- package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
- package/frontend/dist/assets/index-DE67kR0M.js +0 -81
- package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
- package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
- package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
- package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
- package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
package/lib/cli/commands.js
CHANGED
|
@@ -7,28 +7,70 @@ const { execFileSync, spawn } = require('node:child_process');
|
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
10
|
+
const { installPath: serviceInstallPath } = require('./service.js');
|
|
10
11
|
const pidf = require('./pidfile.js');
|
|
11
12
|
const { runInit } = require('./init.js');
|
|
13
|
+
const { rotateToken } = require('../auth/token.js');
|
|
14
|
+
const urlmod = require('./url.js');
|
|
15
|
+
const { doctor } = require('./doctor.js');
|
|
16
|
+
const nodesCmds = require('../nodes/commands.js');
|
|
17
|
+
const nodesStore = require('../nodes/store.js');
|
|
18
|
+
const nodesTunnel = require('../nodes/tunnel.js');
|
|
19
|
+
|
|
20
|
+
// Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
|
|
21
|
+
// Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
|
|
22
|
+
const VALUE_FLAGS = new Set([
|
|
23
|
+
'ssh', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
|
|
24
|
+
]);
|
|
12
25
|
|
|
13
26
|
const HELP = `NexusCrew (portable) — browser tmux client.
|
|
14
27
|
|
|
15
28
|
Usage:
|
|
29
|
+
nexuscrew smart-up: init se serve -> start -> URL + QR
|
|
16
30
|
nexuscrew init [--dry-run] [--port N] setup: detect + config + token + service + URL
|
|
17
31
|
nexuscrew serve [--pidfile] HTTP server foreground (dev / ExecStart)
|
|
18
|
-
nexuscrew start
|
|
19
|
-
nexuscrew stop
|
|
20
|
-
nexuscrew status
|
|
32
|
+
nexuscrew start | up avvia il servizio (systemctl / launchctl / nohup+pidfile)
|
|
33
|
+
nexuscrew stop | down stop del servizio (service manager / pidfile verificato)
|
|
34
|
+
nexuscrew status [--json] stato: platform + service + porta + URL + ruoli + nodi
|
|
35
|
+
nexuscrew url [--qr] ristampa URL con #token (+ QR ASCII) — UNICO posto col token
|
|
36
|
+
nexuscrew token rotate ruota il token (atomico) + invalida le sessioni attive
|
|
37
|
+
nexuscrew logs [-f] journalctl-user / logfile (-f = segue)
|
|
38
|
+
nexuscrew doctor auto-diagnosi: node, tmux, PTY, service, boot, token, ssh
|
|
39
|
+
nexuscrew mcp server MCP stdio per sessioni AI (notify/ask/file/status)
|
|
40
|
+
nexuscrew update npm i -g @mmmbuto/nexuscrew@latest + restart
|
|
41
|
+
nexuscrew fleet-boot companion di boot: avvia le celle boot:true (config-driven)
|
|
42
|
+
nexuscrew nodes add <name> --ssh u@h aggiunge un nodo (genera chiave dedicata + authorized_keys)
|
|
43
|
+
nexuscrew nodes list [--json] elenca i nodi + stato tunnel (token redatti)
|
|
44
|
+
nexuscrew nodes remove|test <name> rimuove / testa un nodo (test: tunnel/health/token remoto)
|
|
45
|
+
nexuscrew nodes up|down|restart <name> lifecycle del singolo tunnel (non tocca la config)
|
|
46
|
+
nexuscrew nodes set-token <name> aggiorna il token remoto (da stdin/env, mai argv)
|
|
47
|
+
nexuscrew node on|off ruolo "nodo raggiungibile" (reverse tunnel)
|
|
21
48
|
|
|
22
49
|
Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
|
|
23
|
-
Bind loopback 127.0.0.1 — raggiungibile via tunnel SSH/VPN
|
|
50
|
+
Bind loopback 127.0.0.1 — raggiungibile via tunnel SSH/VPN.
|
|
51
|
+
Il token appare SOLO in \`url\`/\`url --qr\` — mai in status, logs, service output.`;
|
|
24
52
|
|
|
25
|
-
|
|
53
|
+
// valueFlags (Set|array opzionale): flag che consumano il token successivo nella
|
|
54
|
+
// forma "--k v". Omesso -> comportamento storico (solo "--k" bool o "--k=v").
|
|
55
|
+
function parseFlags(argv, valueFlags) {
|
|
56
|
+
const vf = valueFlags instanceof Set ? valueFlags
|
|
57
|
+
: (Array.isArray(valueFlags) ? new Set(valueFlags) : null);
|
|
26
58
|
const flags = {};
|
|
27
59
|
const rest = [];
|
|
28
|
-
for (
|
|
60
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
61
|
+
const a = argv[i];
|
|
29
62
|
if (a.startsWith('--')) {
|
|
30
|
-
const
|
|
31
|
-
|
|
63
|
+
const eq = a.indexOf('=');
|
|
64
|
+
if (eq >= 0) {
|
|
65
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
66
|
+
} else {
|
|
67
|
+
const key = a.slice(2);
|
|
68
|
+
if (vf && vf.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
69
|
+
flags[key] = argv[i + 1]; i += 1; // consuma il valore (forma "--k v")
|
|
70
|
+
} else {
|
|
71
|
+
flags[key] = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
32
74
|
} else rest.push(a);
|
|
33
75
|
}
|
|
34
76
|
return { flags, rest };
|
|
@@ -67,8 +109,22 @@ function start(opts = {}) {
|
|
|
67
109
|
return { platform, started: true };
|
|
68
110
|
}
|
|
69
111
|
if (platform === 'mac') {
|
|
70
|
-
const
|
|
71
|
-
|
|
112
|
+
const domain = `gui/${opts.uid || uid()}`;
|
|
113
|
+
const label = `${domain}/com.mmmbuto.nexuscrew`;
|
|
114
|
+
try {
|
|
115
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
116
|
+
} catch (_) {
|
|
117
|
+
try {
|
|
118
|
+
const home = opts.home || require('node:os').homedir();
|
|
119
|
+
const plist = opts.installPath || serviceInstallPath('mac', home);
|
|
120
|
+
execImpl('launchctl', ['bootstrap', domain, plist], { stdio: 'ignore' });
|
|
121
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
const reason = String(e.message || e);
|
|
124
|
+
log(`start: launchctl fallito: ${reason}`);
|
|
125
|
+
return { platform, started: false, reason };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
72
128
|
log(`start: launchctl kickstart ${label}`);
|
|
73
129
|
return { platform, started: true };
|
|
74
130
|
}
|
|
@@ -107,9 +163,16 @@ function stop(opts = {}) {
|
|
|
107
163
|
}
|
|
108
164
|
if (platform === 'mac') {
|
|
109
165
|
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
166
|
+
try {
|
|
167
|
+
// bootout scarica il job: KeepAlive non puo' rianimare uno stop volontario.
|
|
168
|
+
execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
|
|
169
|
+
log(`stop: launchctl bootout ${label}`);
|
|
170
|
+
return { platform, stopped: true };
|
|
171
|
+
} catch (e) {
|
|
172
|
+
const reason = String(e.message || e);
|
|
173
|
+
log(`stop: launchctl bootout fallito: ${reason}`);
|
|
174
|
+
return { platform, stopped: false, reason };
|
|
175
|
+
}
|
|
113
176
|
}
|
|
114
177
|
if (platform === 'termux') {
|
|
115
178
|
// kill via pidfile verificato (no broad match) + wake-lock-release
|
|
@@ -122,13 +185,43 @@ function stop(opts = {}) {
|
|
|
122
185
|
throw new Error(`stop: platform ${platform} non supportata`);
|
|
123
186
|
}
|
|
124
187
|
|
|
188
|
+
// running-check per-platform (no log) — riusato da smart-up / token rotate / update.
|
|
189
|
+
function isServiceRunning(opts = {}) {
|
|
190
|
+
const platform = opts.platform || detectPlatform();
|
|
191
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
192
|
+
if (platform === 'linux') {
|
|
193
|
+
try { return execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'active'; }
|
|
194
|
+
catch (_) { return false; }
|
|
195
|
+
}
|
|
196
|
+
if (platform === 'mac') {
|
|
197
|
+
try { execImpl('launchctl', ['print', `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); return true; }
|
|
198
|
+
catch (_) { return false; }
|
|
199
|
+
}
|
|
200
|
+
if (platform === 'termux') {
|
|
201
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
202
|
+
return !!(meta && pidf.isAlive(meta));
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
|
|
208
|
+
function readRoles(configPath) {
|
|
209
|
+
try {
|
|
210
|
+
const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
211
|
+
const r = c && c.roles;
|
|
212
|
+
return { client: !!(r && r.client), node: !!(r && r.node) };
|
|
213
|
+
} catch (_) { return { client: false, node: false }; }
|
|
214
|
+
}
|
|
215
|
+
|
|
125
216
|
function status(opts = {}) {
|
|
126
217
|
const platform = opts.platform || detectPlatform();
|
|
127
218
|
const execImpl = opts.execImpl || execFileSync;
|
|
128
219
|
const log = opts.log || console.log;
|
|
129
220
|
const home = opts.home || require('node:os').homedir();
|
|
221
|
+
const { configPath, configDir } = urlmod.resolvePaths(opts);
|
|
222
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
130
223
|
|
|
131
|
-
const out = { platform, service: null, running: null, port: null, url: null };
|
|
224
|
+
const out = { platform, service: null, running: null, port: null, url: null, roles: null, nodes: [] };
|
|
132
225
|
|
|
133
226
|
if (platform === 'linux') {
|
|
134
227
|
try {
|
|
@@ -150,27 +243,262 @@ function status(opts = {}) {
|
|
|
150
243
|
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
151
244
|
}
|
|
152
245
|
|
|
153
|
-
|
|
246
|
+
out.port = urlmod.loadPort(opts);
|
|
247
|
+
out.url = urlmod.buildUrl(out.port, null, { withToken: false }); // MAI il token in status
|
|
248
|
+
out.roles = readRoles(configPath);
|
|
249
|
+
|
|
250
|
+
// nodes[] con stato tunnel REALE. Token per-nodo SEMPRE redatti (mai in status).
|
|
251
|
+
// nodes.json assente/invalido -> nodes [] (non fa fallire status).
|
|
154
252
|
try {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
253
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
254
|
+
if (st) {
|
|
255
|
+
out.nodeId = st.nodeId;
|
|
256
|
+
out.nodes = st.nodes.map((n) => {
|
|
257
|
+
const red = nodesStore.redactNode(n); // hasToken, mai il token
|
|
258
|
+
return {
|
|
259
|
+
name: red.name, roles: red.roles,
|
|
260
|
+
remotePort: red.remotePort, localPort: red.localPort,
|
|
261
|
+
hasToken: red.hasToken,
|
|
262
|
+
tunnel: nodesTunnel.readTunnelState(home, n.name),
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
} catch (_) { /* nodes opzionale: non rompe status */ }
|
|
160
267
|
|
|
161
|
-
|
|
268
|
+
if (opts.json) {
|
|
269
|
+
log(JSON.stringify(out, null, 2));
|
|
270
|
+
} else {
|
|
271
|
+
log(`platform: ${out.platform}`);
|
|
272
|
+
log(`service: ${out.service}`);
|
|
273
|
+
log(`running: ${out.running}`);
|
|
274
|
+
log(`port: ${out.port}`);
|
|
275
|
+
log(`url: ${out.url}`);
|
|
276
|
+
log(`roles: client=${out.roles.client} node=${out.roles.node}`);
|
|
277
|
+
log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}]`).join(', ')}`);
|
|
278
|
+
if (platform === 'termux') log(`boot: ${out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script'}`);
|
|
279
|
+
}
|
|
162
280
|
return out;
|
|
163
281
|
}
|
|
164
282
|
|
|
283
|
+
// restart per-platform (shared da token rotate / update). execImpl per test.
|
|
284
|
+
function restart(opts = {}) {
|
|
285
|
+
const platform = opts.platform || detectPlatform();
|
|
286
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
287
|
+
const log = opts.log || console.log;
|
|
288
|
+
if (platform === 'linux') {
|
|
289
|
+
execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
|
|
290
|
+
log('restart: systemctl --user restart nexuscrew');
|
|
291
|
+
return { platform, restarted: true };
|
|
292
|
+
}
|
|
293
|
+
if (platform === 'mac') {
|
|
294
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
295
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
296
|
+
log(`restart: launchctl kickstart -k ${label}`);
|
|
297
|
+
return { platform, restarted: true };
|
|
298
|
+
}
|
|
299
|
+
if (platform === 'termux') {
|
|
300
|
+
stop({ execImpl, log, platform, uid: opts.uid });
|
|
301
|
+
start({ execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
|
|
302
|
+
return { platform, restarted: true };
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`restart: platform ${platform} non supportata`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// smart-up (design §3.2): nexuscrew nudo. init minimale (zero domande) se mai
|
|
308
|
+
// inizializzato -> start service (se non gia' attivo) -> stampa URL con #token + QR.
|
|
309
|
+
// Idempotente: se gia' attivo stampa solo l'URL. Seam iniettabili per test.
|
|
310
|
+
function smartUp(opts = {}) {
|
|
311
|
+
const log = opts.log || console.log;
|
|
312
|
+
const platform = opts.platform || detectPlatform();
|
|
313
|
+
const { configPath, tokenPath } = urlmod.resolvePaths(opts);
|
|
314
|
+
const runInitImpl = opts.runInitImpl || runInit;
|
|
315
|
+
const startImpl = opts.startImpl || start;
|
|
316
|
+
|
|
317
|
+
const initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
|
|
318
|
+
if (!initialized) {
|
|
319
|
+
log('smart-up: mai inizializzato -> init minimale (zero domande; il resto dal wizard UI)');
|
|
320
|
+
// printUrl:false -> init non stampa il token; l'URL/QR lo presenta smart-up sotto.
|
|
321
|
+
runInitImpl({ ...opts, log, platform, printUrl: false });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const running = isServiceRunning({ ...opts, platform });
|
|
325
|
+
if (!running) {
|
|
326
|
+
startImpl({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
|
|
327
|
+
} else {
|
|
328
|
+
log('smart-up: servizio gia\' attivo');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Output: URL base (SENZA token in chiaro — smart-up puo' girare al boot/service)
|
|
332
|
+
// + QR che INCORPORA il token (scan-to-login, killer feature Termux). Il token in
|
|
333
|
+
// chiaro resta esclusivo di `nexuscrew url` (§3, invariante "token mai in output servizio").
|
|
334
|
+
const port = urlmod.loadPort(opts);
|
|
335
|
+
const token = urlmod.readToken(tokenPath);
|
|
336
|
+
const baseUrl = urlmod.buildUrl(port, null, { withToken: false });
|
|
337
|
+
const fullUrl = urlmod.buildUrl(port, token, { withToken: true });
|
|
338
|
+
log(baseUrl);
|
|
339
|
+
if (token) {
|
|
340
|
+
log(urlmod.renderQr(fullUrl, { qrcode: opts.qrcode }));
|
|
341
|
+
log('scansiona il QR (contiene il token) o usa `nexuscrew url` per l\'URL completo');
|
|
342
|
+
}
|
|
343
|
+
return { platform, initialized, running, url: baseUrl, port };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// url [--qr]: ristampa URL completo con #token — UNICO comando che mostra il token.
|
|
347
|
+
function url(opts = {}) {
|
|
348
|
+
const log = opts.log || console.log;
|
|
349
|
+
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
350
|
+
const port = urlmod.loadPort(opts);
|
|
351
|
+
const token = urlmod.readToken(tokenPath);
|
|
352
|
+
const full = urlmod.buildUrl(port, token, { withToken: true });
|
|
353
|
+
log(full);
|
|
354
|
+
if (opts.qr) {
|
|
355
|
+
if (token) log(urlmod.renderQr(full, { qrcode: opts.qrcode }));
|
|
356
|
+
else log('url: nessun token (esegui init) — QR non generato');
|
|
357
|
+
}
|
|
358
|
+
return { url: full, port, hasToken: !!token };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// token rotate (§4b(3)): scrittura atomica nuovo token + invalidazione reale delle
|
|
362
|
+
// sessioni attive (restart se il service e' attivo). Il nuovo token NON si stampa.
|
|
363
|
+
function tokenRotate(opts = {}) {
|
|
364
|
+
const log = opts.log || console.log;
|
|
365
|
+
const platform = opts.platform || detectPlatform();
|
|
366
|
+
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
367
|
+
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
368
|
+
if (readonly) {
|
|
369
|
+
log('token rotate: READONLY, rotazione bloccata');
|
|
370
|
+
return { rotated: false, reason: 'readonly' };
|
|
371
|
+
}
|
|
372
|
+
rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
|
|
373
|
+
log(`token: nuovo segreto scritto (${tokenPath})`);
|
|
374
|
+
const running = isServiceRunning({ ...opts, platform });
|
|
375
|
+
if (running) {
|
|
376
|
+
restart({ ...opts, platform, log });
|
|
377
|
+
log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
|
|
378
|
+
} else {
|
|
379
|
+
// il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
|
|
380
|
+
// vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
|
|
381
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
382
|
+
if (meta && pidf.isAlive(meta)) {
|
|
383
|
+
log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
|
|
384
|
+
} else {
|
|
385
|
+
log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
log('usa `nexuscrew url` per il nuovo URL (il token non si stampa qui)');
|
|
389
|
+
return { rotated: true, running };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
|
|
393
|
+
function logs(opts = {}) {
|
|
394
|
+
const platform = opts.platform || detectPlatform();
|
|
395
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
396
|
+
const log = opts.log || console.log;
|
|
397
|
+
const home = opts.home || require('node:os').homedir();
|
|
398
|
+
const follow = !!opts.follow;
|
|
399
|
+
|
|
400
|
+
let bin; let args;
|
|
401
|
+
if (platform === 'linux') {
|
|
402
|
+
bin = 'journalctl';
|
|
403
|
+
args = ['--user', '-u', 'nexuscrew', '--no-pager'];
|
|
404
|
+
if (follow) args.push('-f');
|
|
405
|
+
} else {
|
|
406
|
+
// mac (launchd StandardOutPath) / termux (nohup logfile): stesso path.
|
|
407
|
+
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
408
|
+
bin = 'tail';
|
|
409
|
+
args = follow ? ['-f', logPath] : ['-n', '200', logPath];
|
|
410
|
+
}
|
|
411
|
+
log(`logs: ${bin} ${args.join(' ')}`);
|
|
412
|
+
const child = spawnImpl(bin, args, { stdio: 'inherit' });
|
|
413
|
+
// passthrough: quando il figlio esce (o subito, se non-follow), esce anche la CLI.
|
|
414
|
+
if (child && typeof child.on === 'function') {
|
|
415
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
416
|
+
}
|
|
417
|
+
return { platform, bin, args, follow, keepAlive: true };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
|
|
421
|
+
function update(opts = {}) {
|
|
422
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
423
|
+
const log = opts.log || console.log;
|
|
424
|
+
const platform = opts.platform || detectPlatform();
|
|
425
|
+
const pkg = '@mmmbuto/nexuscrew@latest';
|
|
426
|
+
try {
|
|
427
|
+
execImpl('npm', ['i', '-g', pkg], { stdio: 'inherit' });
|
|
428
|
+
log(`update: ${pkg} installato`);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
log(`update: npm install fallito — ${e && e.message ? e.message : e}`);
|
|
431
|
+
log('update: controlla npm/rete/permessi globali, poi riprova: npm i -g @mmmbuto/nexuscrew@latest');
|
|
432
|
+
return { updated: false, error: String(e && e.message ? e.message : e), code: 1 };
|
|
433
|
+
}
|
|
434
|
+
const running = isServiceRunning({ ...opts, platform });
|
|
435
|
+
if (running) {
|
|
436
|
+
restart({ ...opts, platform, log });
|
|
437
|
+
log('update: servizio riavviato sul nuovo codice');
|
|
438
|
+
} else {
|
|
439
|
+
log('update: servizio non attivo (nessun restart)');
|
|
440
|
+
}
|
|
441
|
+
return { updated: true, running, code: 0 };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
|
|
445
|
+
// dispatch e' sync (bin/nexuscrew.js non fa await), quindi runFleetBoot (async)
|
|
446
|
+
// viene lanciato e il processo tenuto vivo (keepAlive) finche' non termina, poi
|
|
447
|
+
// esce col code risultante. runFleetBoot e' la unit testabile (no process.exit);
|
|
448
|
+
// dispatchFleetBoot e' il thin glue. Seam iniettabili: selectProvider / loadConfig
|
|
449
|
+
// / cfg / exit (per test, no process.exit che uccide il runner).
|
|
450
|
+
async function runFleetBoot(opts = {}) {
|
|
451
|
+
const log = opts.log || console.log;
|
|
452
|
+
const loadConfig = opts.loadConfig || require('../config.js').loadConfig;
|
|
453
|
+
const selectProvider = opts.selectProvider || require('../fleet/provider.js').selectProvider;
|
|
454
|
+
const { bootCells } = require('../fleet/boot.js');
|
|
455
|
+
const cfg = opts.cfg || loadConfig();
|
|
456
|
+
|
|
457
|
+
const sel = await selectProvider(cfg);
|
|
458
|
+
if (sel.mode === 'external') {
|
|
459
|
+
log('fleet-boot: boot gestito dal fleet esterno (nessuna azione del companion)');
|
|
460
|
+
return { code: 0, mode: 'external' };
|
|
461
|
+
}
|
|
462
|
+
if (sel.mode !== 'builtin' || !sel.fleet || !sel.fleet.available) {
|
|
463
|
+
// disabled (o builtin unavailable): niente da avviare, non e' un errore.
|
|
464
|
+
log(`fleet-boot: nessun boot (provider ${sel.mode}${sel.reason ? ' — ' + sel.reason : ''})`);
|
|
465
|
+
return { code: 0, mode: sel.mode };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// builtin: up() per ogni cella boot:true. READONLY fa fallire le up con 403 ->
|
|
469
|
+
// raccolte in failed[] -> exit 1 (nessun short-circuit speciale, design §9d).
|
|
470
|
+
const res = await bootCells(sel.fleet, { log });
|
|
471
|
+
log(`fleet-boot: ${res.started.length} started, ${res.skipped.length} skipped, ${res.failed.length} failed`);
|
|
472
|
+
for (const f of res.failed) log(` failed: ${f.cell} — ${f.reason}`);
|
|
473
|
+
return { code: res.failed.length ? 1 : 0, mode: 'builtin', summary: res };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function dispatchFleetBoot(opts) {
|
|
477
|
+
const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
|
|
478
|
+
runFleetBoot(opts)
|
|
479
|
+
.then((r) => exit(r.code))
|
|
480
|
+
.catch((e) => {
|
|
481
|
+
(opts.log || console.log)(`fleet-boot: error — ${e && e.message ? e.message : e}`);
|
|
482
|
+
exit(1);
|
|
483
|
+
});
|
|
484
|
+
return { code: 0, keepAlive: true }; // il processo resta vivo finche' runFleetBoot non chiama exit()
|
|
485
|
+
}
|
|
486
|
+
|
|
165
487
|
function dispatch(argv, opts = {}) {
|
|
166
|
-
const { flags, rest } = parseFlags(argv);
|
|
488
|
+
const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
|
|
167
489
|
const cmd = rest[0];
|
|
168
490
|
const log = opts.log || console.log;
|
|
169
491
|
|
|
170
|
-
|
|
492
|
+
// help esplicito
|
|
493
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
171
494
|
log(HELP);
|
|
172
495
|
return { code: 0 };
|
|
173
496
|
}
|
|
497
|
+
// nexuscrew nudo = smart-up (design §3.2)
|
|
498
|
+
if (!cmd) {
|
|
499
|
+
smartUp({ ...opts, log });
|
|
500
|
+
return { code: 0 };
|
|
501
|
+
}
|
|
174
502
|
if (cmd === 'init') {
|
|
175
503
|
runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
|
|
176
504
|
return { code: 0 };
|
|
@@ -179,20 +507,123 @@ function dispatch(argv, opts = {}) {
|
|
|
179
507
|
serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
|
|
180
508
|
return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
|
|
181
509
|
}
|
|
182
|
-
if (cmd === 'start') {
|
|
183
|
-
start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log,
|
|
184
|
-
|
|
510
|
+
if (cmd === 'start' || cmd === 'up') {
|
|
511
|
+
const r = start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log,
|
|
512
|
+
platform: opts.platform, uid: opts.uid, home: opts.home, installPath: opts.installPath });
|
|
513
|
+
return { code: r.started === false ? 1 : 0 };
|
|
185
514
|
}
|
|
186
|
-
if (cmd === 'stop') {
|
|
187
|
-
stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
|
|
188
|
-
return { code: 0 };
|
|
515
|
+
if (cmd === 'stop' || cmd === 'down') {
|
|
516
|
+
const r = stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
|
|
517
|
+
return { code: r.stopped === false ? 1 : 0 };
|
|
189
518
|
}
|
|
190
519
|
if (cmd === 'status') {
|
|
191
|
-
status({
|
|
520
|
+
status({ ...opts, json: !!flags.json, log });
|
|
521
|
+
return { code: 0 };
|
|
522
|
+
}
|
|
523
|
+
if (cmd === 'url') {
|
|
524
|
+
url({ ...opts, qr: !!flags.qr, log });
|
|
192
525
|
return { code: 0 };
|
|
193
526
|
}
|
|
527
|
+
if (cmd === 'token') {
|
|
528
|
+
if (rest[1] === 'rotate') {
|
|
529
|
+
tokenRotate({ ...opts, log });
|
|
530
|
+
return { code: 0 };
|
|
531
|
+
}
|
|
532
|
+
log('usage: nexuscrew token rotate');
|
|
533
|
+
return { code: 1 };
|
|
534
|
+
}
|
|
535
|
+
if (cmd === 'logs') {
|
|
536
|
+
const follow = !!(flags.f || flags.follow) || rest.includes('-f');
|
|
537
|
+
const r = logs({ ...opts, follow, log });
|
|
538
|
+
return { code: 0, keepAlive: !!r.keepAlive };
|
|
539
|
+
}
|
|
540
|
+
if (cmd === 'doctor') {
|
|
541
|
+
const r = doctor({ ...opts, log });
|
|
542
|
+
return { code: r.code };
|
|
543
|
+
}
|
|
544
|
+
if (cmd === 'update') {
|
|
545
|
+
const r = update({ ...opts, log });
|
|
546
|
+
return { code: r.code || 0 };
|
|
547
|
+
}
|
|
548
|
+
if (cmd === 'mcp') {
|
|
549
|
+
// Server MCP stdio (bridge cella→operatore): stdout e' il canale JSON-RPC, quindi
|
|
550
|
+
// NESSUN log qui. keepAlive: resta vivo finche' il client tiene aperto stdin.
|
|
551
|
+
const startMcpImpl = opts.startMcpImpl || require('../mcp/server.js').startMcp;
|
|
552
|
+
startMcpImpl();
|
|
553
|
+
return { code: 0, keepAlive: true };
|
|
554
|
+
}
|
|
555
|
+
if (cmd === 'fleet-boot') {
|
|
556
|
+
return dispatchFleetBoot({ ...opts, log });
|
|
557
|
+
}
|
|
558
|
+
if (cmd === 'nodes') {
|
|
559
|
+
return dispatchNodes(rest, flags, { ...opts, log });
|
|
560
|
+
}
|
|
561
|
+
if (cmd === 'node') {
|
|
562
|
+
return dispatchNode(rest, flags, { ...opts, log });
|
|
563
|
+
}
|
|
194
564
|
log(`unknown command: ${cmd}\n\n${HELP}`);
|
|
195
565
|
return { code: 1 };
|
|
196
566
|
}
|
|
197
567
|
|
|
198
|
-
|
|
568
|
+
// nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
|
|
569
|
+
function dispatchNodes(rest, flags, opts) {
|
|
570
|
+
const log = opts.log;
|
|
571
|
+
const sub = rest[1];
|
|
572
|
+
const name = rest[2];
|
|
573
|
+
const common = { ...opts, name };
|
|
574
|
+
switch (sub) {
|
|
575
|
+
case 'add':
|
|
576
|
+
return { code: nodesCmds.nodesAdd({
|
|
577
|
+
...common, ssh: flags.ssh, remotePort: flags['remote-port'],
|
|
578
|
+
key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
|
|
579
|
+
}).code };
|
|
580
|
+
case undefined:
|
|
581
|
+
case 'list':
|
|
582
|
+
return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
|
|
583
|
+
case 'remove':
|
|
584
|
+
return { code: nodesCmds.nodesRemove(common).code };
|
|
585
|
+
case 'up':
|
|
586
|
+
return { code: nodesCmds.nodesUp(common).code };
|
|
587
|
+
case 'down':
|
|
588
|
+
return { code: nodesCmds.nodesDown(common).code };
|
|
589
|
+
case 'restart':
|
|
590
|
+
return { code: nodesCmds.nodesRestart(common).code };
|
|
591
|
+
case 'set-token':
|
|
592
|
+
return { code: nodesCmds.nodesSetToken(common).code };
|
|
593
|
+
case 'test': {
|
|
594
|
+
// async: tieni vivo il processo finche' il probe non risolve, poi exit.
|
|
595
|
+
const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
|
|
596
|
+
nodesCmds.nodesTest(common)
|
|
597
|
+
.then((r) => exit(r.code))
|
|
598
|
+
.catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
|
|
599
|
+
return { code: 0, keepAlive: true };
|
|
600
|
+
}
|
|
601
|
+
default:
|
|
602
|
+
log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
|
|
603
|
+
return { code: 1 };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
|
|
608
|
+
function dispatchNode(rest, flags, opts) {
|
|
609
|
+
const log = opts.log;
|
|
610
|
+
const sub = rest[1];
|
|
611
|
+
if (sub === 'on') {
|
|
612
|
+
return { code: nodesCmds.nodeOn({
|
|
613
|
+
...opts, rendezvousSsh: flags.rendezvous,
|
|
614
|
+
publishedPort: flags['published-port'], key: flags.key,
|
|
615
|
+
}).code };
|
|
616
|
+
}
|
|
617
|
+
if (sub === 'off') {
|
|
618
|
+
return { code: nodesCmds.nodeOff({ ...opts }).code };
|
|
619
|
+
}
|
|
620
|
+
log('usage: nexuscrew node on|off');
|
|
621
|
+
return { code: 1 };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
module.exports = {
|
|
625
|
+
dispatch, serve, start, stop, status, parseFlags, HELP,
|
|
626
|
+
smartUp, url, tokenRotate, logs, doctor, update, restart,
|
|
627
|
+
isServiceRunning, readRoles,
|
|
628
|
+
runFleetBoot, dispatchFleetBoot,
|
|
629
|
+
};
|