@mmmbuto/nexuscrew 0.7.7 → 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 +156 -20
- 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 +415 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +39 -6
- 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 +55 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +141 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/routes.js +5 -4
- 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 +206 -12
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +7 -1
- 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-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DQrDBogT.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BBOgTCoO.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
package/frontend/dist/sw.js
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
1
|
self.addEventListener('install', () => self.skipWaiting());
|
|
2
2
|
self.addEventListener('activate', () => self.clients.claim());
|
|
3
3
|
self.addEventListener('fetch', (e) => e.respondWith(fetch(e.request)));
|
|
4
|
+
|
|
5
|
+
// Web Push del MCP bridge: payload JSON {title, body?, url?} dal server.
|
|
6
|
+
// tag fisso 'nexuscrew': le notifiche si sostituiscono invece di accumularsi.
|
|
7
|
+
self.addEventListener('push', (e) => {
|
|
8
|
+
let data = {};
|
|
9
|
+
try { data = e.data ? e.data.json() : {}; } catch (_) { /* payload non JSON: ignora */ }
|
|
10
|
+
const title = typeof data.title === 'string' && data.title ? data.title : 'NexusCrew';
|
|
11
|
+
e.waitUntil(self.registration.showNotification(title, {
|
|
12
|
+
body: typeof data.body === 'string' ? data.body : '',
|
|
13
|
+
tag: 'nexuscrew',
|
|
14
|
+
data: { url: typeof data.url === 'string' ? data.url : '/' },
|
|
15
|
+
}));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Click sulla notifica: focus di una finestra gia' aperta (deep-link via
|
|
19
|
+
// navigate) oppure apertura di una nuova su data.url (es. /#ask=<id>).
|
|
20
|
+
self.addEventListener('notificationclick', (e) => {
|
|
21
|
+
e.notification.close();
|
|
22
|
+
const url = (e.notification.data && e.notification.data.url) || '/';
|
|
23
|
+
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => {
|
|
24
|
+
for (const w of wins) {
|
|
25
|
+
if ('focus' in w) {
|
|
26
|
+
if ('navigate' in w) w.navigate(url).catch(() => {});
|
|
27
|
+
return w.focus();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return self.clients.openWindow ? self.clients.openWindow(url) : undefined;
|
|
31
|
+
}));
|
|
32
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"0.8.0"}
|
package/lib/auth/middleware.js
CHANGED
|
@@ -7,9 +7,15 @@ function bearerFrom(req) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
// 401 uniforme: nessun dettaglio su cosa è andato storto.
|
|
10
|
+
// `token` puo' essere una stringa (storico) o un holder live {get}: l'holder si
|
|
11
|
+
// rilegge ad OGNI richiesta, cosi' una rotazione (audit F7 / §4b(3)) invalida il
|
|
12
|
+
// vecchio token senza restart. backward-compat: la stringa resta accettata.
|
|
10
13
|
function requireToken(token) {
|
|
14
|
+
const read = (token && typeof token === 'object' && typeof token.get === 'function')
|
|
15
|
+
? token.get
|
|
16
|
+
: () => token;
|
|
11
17
|
return (req, res, next) => {
|
|
12
|
-
if (verify(
|
|
18
|
+
if (verify(read(), bearerFrom(req))) return next();
|
|
13
19
|
res.status(401).json({ error: 'unauthorized' });
|
|
14
20
|
};
|
|
15
21
|
}
|
package/lib/auth/token.js
CHANGED
|
@@ -53,6 +53,36 @@ function loadOrCreateToken(tokenPath) {
|
|
|
53
53
|
return tok;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Rotazione token: genera un NUOVO segreto e sostituisce il file in modo ATOMICO
|
|
57
|
+
// (tmp stessa dir -> chmod 0600 -> rename), no-follow symlink. [A2 §4b(3)]
|
|
58
|
+
// A differenza di loadOrCreateToken NON preserva l'esistente: overwrite voluto.
|
|
59
|
+
// L'invalidazione reale delle sessioni attive la fa il chiamante (restart service:
|
|
60
|
+
// il server carica il token solo allo startup). Ritorna il nuovo token (il chiamante
|
|
61
|
+
// NON deve stamparlo: si usa `nexuscrew url`).
|
|
62
|
+
function rotateToken(tokenPath) {
|
|
63
|
+
// reject symlink target preesistente (no-follow), ENOENT ok (nuovo file)
|
|
64
|
+
try {
|
|
65
|
+
const st = fs.lstatSync(tokenPath);
|
|
66
|
+
if (st.isSymbolicLink()) {
|
|
67
|
+
throw new Error(`refusing symlink token path: ${tokenPath}`);
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e.code !== 'ENOENT') throw e;
|
|
71
|
+
}
|
|
72
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
|
73
|
+
const tok = crypto.randomBytes(24).toString('base64url');
|
|
74
|
+
const tmp = tokenPath + '.tmp.' + process.pid;
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(tmp, tok + '\n', { flag: 'wx', mode: 0o600 }); // exclusive temp
|
|
77
|
+
fs.chmodSync(tmp, 0o600);
|
|
78
|
+
fs.renameSync(tmp, tokenPath); // atomic replace
|
|
79
|
+
} catch (e) {
|
|
80
|
+
try { fs.unlinkSync(tmp); } catch (_) {} // cleanup temp su failure
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
return tok;
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
function verify(expected, given) {
|
|
57
87
|
if (typeof expected !== 'string' || typeof given !== 'string') return false;
|
|
58
88
|
const a = Buffer.from(expected);
|
|
@@ -61,4 +91,4 @@ function verify(expected, given) {
|
|
|
61
91
|
return crypto.timingSafeEqual(a, b);
|
|
62
92
|
}
|
|
63
93
|
|
|
64
|
-
module.exports = { loadOrCreateToken, readTokenSafe, verify };
|
|
94
|
+
module.exports = { loadOrCreateToken, readTokenSafe, rotateToken, verify };
|
package/lib/cli/commands.js
CHANGED
|
@@ -7,29 +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
|
|
21
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)
|
|
22
48
|
|
|
23
49
|
Piattaforme: linux (systemd --user), mac (launchd), termux (nohup + pidfile).
|
|
24
|
-
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.`;
|
|
25
52
|
|
|
26
|
-
|
|
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);
|
|
27
58
|
const flags = {};
|
|
28
59
|
const rest = [];
|
|
29
|
-
for (
|
|
60
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
61
|
+
const a = argv[i];
|
|
30
62
|
if (a.startsWith('--')) {
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
+
}
|
|
33
74
|
} else rest.push(a);
|
|
34
75
|
}
|
|
35
76
|
return { flags, rest };
|
|
@@ -68,8 +109,22 @@ function start(opts = {}) {
|
|
|
68
109
|
return { platform, started: true };
|
|
69
110
|
}
|
|
70
111
|
if (platform === 'mac') {
|
|
71
|
-
const
|
|
72
|
-
|
|
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
|
+
}
|
|
73
128
|
log(`start: launchctl kickstart ${label}`);
|
|
74
129
|
return { platform, started: true };
|
|
75
130
|
}
|
|
@@ -108,9 +163,16 @@ function stop(opts = {}) {
|
|
|
108
163
|
}
|
|
109
164
|
if (platform === 'mac') {
|
|
110
165
|
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
176
|
}
|
|
115
177
|
if (platform === 'termux') {
|
|
116
178
|
// kill via pidfile verificato (no broad match) + wake-lock-release
|
|
@@ -123,13 +185,43 @@ function stop(opts = {}) {
|
|
|
123
185
|
throw new Error(`stop: platform ${platform} non supportata`);
|
|
124
186
|
}
|
|
125
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
|
+
|
|
126
216
|
function status(opts = {}) {
|
|
127
217
|
const platform = opts.platform || detectPlatform();
|
|
128
218
|
const execImpl = opts.execImpl || execFileSync;
|
|
129
219
|
const log = opts.log || console.log;
|
|
130
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');
|
|
131
223
|
|
|
132
|
-
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: [] };
|
|
133
225
|
|
|
134
226
|
if (platform === 'linux') {
|
|
135
227
|
try {
|
|
@@ -151,18 +243,204 @@ function status(opts = {}) {
|
|
|
151
243
|
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
152
244
|
}
|
|
153
245
|
|
|
154
|
-
|
|
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).
|
|
155
252
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 */ }
|
|
161
267
|
|
|
162
|
-
|
|
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
|
+
}
|
|
163
280
|
return out;
|
|
164
281
|
}
|
|
165
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
|
+
|
|
166
444
|
// B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
|
|
167
445
|
// dispatch e' sync (bin/nexuscrew.js non fa await), quindi runFleetBoot (async)
|
|
168
446
|
// viene lanciato e il processo tenuto vivo (keepAlive) finche' non termina, poi
|
|
@@ -207,14 +485,20 @@ function dispatchFleetBoot(opts) {
|
|
|
207
485
|
}
|
|
208
486
|
|
|
209
487
|
function dispatch(argv, opts = {}) {
|
|
210
|
-
const { flags, rest } = parseFlags(argv);
|
|
488
|
+
const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
|
|
211
489
|
const cmd = rest[0];
|
|
212
490
|
const log = opts.log || console.log;
|
|
213
491
|
|
|
214
|
-
|
|
492
|
+
// help esplicito
|
|
493
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
215
494
|
log(HELP);
|
|
216
495
|
return { code: 0 };
|
|
217
496
|
}
|
|
497
|
+
// nexuscrew nudo = smart-up (design §3.2)
|
|
498
|
+
if (!cmd) {
|
|
499
|
+
smartUp({ ...opts, log });
|
|
500
|
+
return { code: 0 };
|
|
501
|
+
}
|
|
218
502
|
if (cmd === 'init') {
|
|
219
503
|
runInit({ ...opts, dryRun: flags['dry-run'], port: flags.port ? Number(flags.port) : undefined, log });
|
|
220
504
|
return { code: 0 };
|
|
@@ -223,23 +507,123 @@ function dispatch(argv, opts = {}) {
|
|
|
223
507
|
serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
|
|
224
508
|
return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
|
|
225
509
|
}
|
|
226
|
-
if (cmd === 'start') {
|
|
227
|
-
start({ execImpl: opts.execImpl, spawnImpl: opts.spawnImpl, log,
|
|
228
|
-
|
|
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 };
|
|
229
514
|
}
|
|
230
|
-
if (cmd === 'stop') {
|
|
231
|
-
stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
|
|
232
|
-
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 };
|
|
233
518
|
}
|
|
234
519
|
if (cmd === 'status') {
|
|
235
|
-
status({
|
|
520
|
+
status({ ...opts, json: !!flags.json, log });
|
|
236
521
|
return { code: 0 };
|
|
237
522
|
}
|
|
523
|
+
if (cmd === 'url') {
|
|
524
|
+
url({ ...opts, qr: !!flags.qr, log });
|
|
525
|
+
return { code: 0 };
|
|
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
|
+
}
|
|
238
555
|
if (cmd === 'fleet-boot') {
|
|
239
556
|
return dispatchFleetBoot({ ...opts, log });
|
|
240
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
|
+
}
|
|
241
564
|
log(`unknown command: ${cmd}\n\n${HELP}`);
|
|
242
565
|
return { code: 1 };
|
|
243
566
|
}
|
|
244
567
|
|
|
245
|
-
|
|
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
|
+
};
|