@mmmbuto/nexuscrew 0.7.7 → 0.8.2
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 +153 -25
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -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 +502 -43
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +69 -11
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -8
- 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 +123 -0
- package/lib/files/routes.js +57 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +151 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +381 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +381 -0
- package/lib/nodes/commands.js +411 -0
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +425 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +315 -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/federation.js +217 -0
- package/lib/proxy/node-proxy.js +305 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +291 -14
- package/lib/settings/routes.js +685 -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-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
package/lib/cli/commands.js
CHANGED
|
@@ -6,30 +6,57 @@
|
|
|
6
6
|
const { execFileSync, spawn } = require('node:child_process');
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const path = require('node:path');
|
|
9
|
+
const net = require('node:net');
|
|
9
10
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
11
|
+
const { installPath: serviceInstallPath } = require('./service.js');
|
|
10
12
|
const pidf = require('./pidfile.js');
|
|
11
13
|
const { runInit } = require('./init.js');
|
|
14
|
+
const { rotateToken } = require('../auth/token.js');
|
|
15
|
+
const urlmod = require('./url.js');
|
|
16
|
+
const { doctor } = require('./doctor.js');
|
|
17
|
+
const nodesCmds = require('../nodes/commands.js');
|
|
18
|
+
const nodesStore = require('../nodes/store.js');
|
|
19
|
+
const nodesTunnel = require('../nodes/tunnel.js');
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
// Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
|
|
22
|
+
// Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
|
|
23
|
+
const VALUE_FLAGS = new Set([
|
|
24
|
+
'ssh', 'ssh-port', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
14
28
|
|
|
15
29
|
Usage:
|
|
16
|
-
nexuscrew
|
|
17
|
-
nexuscrew
|
|
18
|
-
nexuscrew
|
|
19
|
-
nexuscrew
|
|
20
|
-
nexuscrew
|
|
21
|
-
nexuscrew fleet-boot companion di boot: avvia le celle boot:true (config-driven)
|
|
30
|
+
nexuscrew start in background; first run opens the PWA wizard
|
|
31
|
+
nexuscrew show start when needed and open the authenticated PWA
|
|
32
|
+
nexuscrew doctor run local diagnostics
|
|
33
|
+
nexuscrew help show this help
|
|
34
|
+
nexuscrew version show the installed version
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
Configuration, nodes, engines, providers, models and lifecycle live in the PWA.
|
|
37
|
+
NexusCrew binds only to 127.0.0.1 and automatically selects a free port.`;
|
|
25
38
|
|
|
26
|
-
|
|
39
|
+
// valueFlags (Set|array opzionale): flag che consumano il token successivo nella
|
|
40
|
+
// forma "--k v". Omesso -> comportamento storico (solo "--k" bool o "--k=v").
|
|
41
|
+
function parseFlags(argv, valueFlags) {
|
|
42
|
+
const vf = valueFlags instanceof Set ? valueFlags
|
|
43
|
+
: (Array.isArray(valueFlags) ? new Set(valueFlags) : null);
|
|
27
44
|
const flags = {};
|
|
28
45
|
const rest = [];
|
|
29
|
-
for (
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const a = argv[i];
|
|
30
48
|
if (a.startsWith('--')) {
|
|
31
|
-
const
|
|
32
|
-
|
|
49
|
+
const eq = a.indexOf('=');
|
|
50
|
+
if (eq >= 0) {
|
|
51
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
52
|
+
} else {
|
|
53
|
+
const key = a.slice(2);
|
|
54
|
+
if (vf && vf.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
55
|
+
flags[key] = argv[i + 1]; i += 1; // consuma il valore (forma "--k v")
|
|
56
|
+
} else {
|
|
57
|
+
flags[key] = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
33
60
|
} else rest.push(a);
|
|
34
61
|
}
|
|
35
62
|
return { flags, rest };
|
|
@@ -68,8 +95,22 @@ function start(opts = {}) {
|
|
|
68
95
|
return { platform, started: true };
|
|
69
96
|
}
|
|
70
97
|
if (platform === 'mac') {
|
|
71
|
-
const
|
|
72
|
-
|
|
98
|
+
const domain = `gui/${opts.uid || uid()}`;
|
|
99
|
+
const label = `${domain}/com.mmmbuto.nexuscrew`;
|
|
100
|
+
try {
|
|
101
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
102
|
+
} catch (_) {
|
|
103
|
+
try {
|
|
104
|
+
const home = opts.home || require('node:os').homedir();
|
|
105
|
+
const plist = opts.installPath || serviceInstallPath('mac', home);
|
|
106
|
+
execImpl('launchctl', ['bootstrap', domain, plist], { stdio: 'ignore' });
|
|
107
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const reason = String(e.message || e);
|
|
110
|
+
log(`start: launchctl fallito: ${reason}`);
|
|
111
|
+
return { platform, started: false, reason };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
73
114
|
log(`start: launchctl kickstart ${label}`);
|
|
74
115
|
return { platform, started: true };
|
|
75
116
|
}
|
|
@@ -108,9 +149,16 @@ function stop(opts = {}) {
|
|
|
108
149
|
}
|
|
109
150
|
if (platform === 'mac') {
|
|
110
151
|
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
try {
|
|
153
|
+
// bootout scarica il job: KeepAlive non puo' rianimare uno stop volontario.
|
|
154
|
+
execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
|
|
155
|
+
log(`stop: launchctl bootout ${label}`);
|
|
156
|
+
return { platform, stopped: true };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
const reason = String(e.message || e);
|
|
159
|
+
log(`stop: launchctl bootout fallito: ${reason}`);
|
|
160
|
+
return { platform, stopped: false, reason };
|
|
161
|
+
}
|
|
114
162
|
}
|
|
115
163
|
if (platform === 'termux') {
|
|
116
164
|
// kill via pidfile verificato (no broad match) + wake-lock-release
|
|
@@ -123,13 +171,43 @@ function stop(opts = {}) {
|
|
|
123
171
|
throw new Error(`stop: platform ${platform} non supportata`);
|
|
124
172
|
}
|
|
125
173
|
|
|
174
|
+
// running-check per-platform (no log) — riusato da smart-up / token rotate / update.
|
|
175
|
+
function isServiceRunning(opts = {}) {
|
|
176
|
+
const platform = opts.platform || detectPlatform();
|
|
177
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
178
|
+
if (platform === 'linux') {
|
|
179
|
+
try { return execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'active'; }
|
|
180
|
+
catch (_) { return false; }
|
|
181
|
+
}
|
|
182
|
+
if (platform === 'mac') {
|
|
183
|
+
try { execImpl('launchctl', ['print', `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`], { stdio: 'ignore' }); return true; }
|
|
184
|
+
catch (_) { return false; }
|
|
185
|
+
}
|
|
186
|
+
if (platform === 'termux') {
|
|
187
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
188
|
+
return !!(meta && pidf.isAlive(meta));
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ruoli client/node dal config.json (default entrambi off — B0 li popola dal wizard UI).
|
|
194
|
+
function readRoles(configPath) {
|
|
195
|
+
try {
|
|
196
|
+
const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
197
|
+
const r = c && c.roles;
|
|
198
|
+
return { client: !!(r && r.client), node: !!(r && r.node) };
|
|
199
|
+
} catch (_) { return { client: false, node: false }; }
|
|
200
|
+
}
|
|
201
|
+
|
|
126
202
|
function status(opts = {}) {
|
|
127
203
|
const platform = opts.platform || detectPlatform();
|
|
128
204
|
const execImpl = opts.execImpl || execFileSync;
|
|
129
205
|
const log = opts.log || console.log;
|
|
130
206
|
const home = opts.home || require('node:os').homedir();
|
|
207
|
+
const { configPath, configDir } = urlmod.resolvePaths(opts);
|
|
208
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
131
209
|
|
|
132
|
-
const out = { platform, service: null, running: null, port: null, url: null };
|
|
210
|
+
const out = { platform, service: null, running: null, port: null, url: null, roles: null, nodes: [] };
|
|
133
211
|
|
|
134
212
|
if (platform === 'linux') {
|
|
135
213
|
try {
|
|
@@ -151,18 +229,315 @@ function status(opts = {}) {
|
|
|
151
229
|
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
152
230
|
}
|
|
153
231
|
|
|
154
|
-
|
|
232
|
+
out.port = urlmod.loadPort(opts);
|
|
233
|
+
out.url = urlmod.buildUrl(out.port, null, { withToken: false }); // MAI il token in status
|
|
234
|
+
out.roles = readRoles(configPath);
|
|
235
|
+
|
|
236
|
+
// nodes[] con stato tunnel REALE. Token per-nodo SEMPRE redatti (mai in status).
|
|
237
|
+
// nodes.json assente/invalido -> nodes [] (non fa fallire status).
|
|
155
238
|
try {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
239
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
240
|
+
if (st) {
|
|
241
|
+
out.nodeId = st.nodeId;
|
|
242
|
+
out.nodes = st.nodes.map((n) => {
|
|
243
|
+
const red = nodesStore.redactNode(n); // hasToken, mai il token
|
|
244
|
+
return {
|
|
245
|
+
name: red.name, roles: red.roles,
|
|
246
|
+
remotePort: red.remotePort, localPort: red.localPort,
|
|
247
|
+
hasToken: red.hasToken,
|
|
248
|
+
tunnel: nodesTunnel.readTunnelState(home, n.name),
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} catch (_) { /* nodes opzionale: non rompe status */ }
|
|
161
253
|
|
|
162
|
-
|
|
254
|
+
if (opts.json) {
|
|
255
|
+
log(JSON.stringify(out, null, 2));
|
|
256
|
+
} else {
|
|
257
|
+
log(`platform: ${out.platform}`);
|
|
258
|
+
log(`service: ${out.service}`);
|
|
259
|
+
log(`running: ${out.running}`);
|
|
260
|
+
log(`port: ${out.port}`);
|
|
261
|
+
log(`url: ${out.url}`);
|
|
262
|
+
log(`roles: client=${out.roles.client} node=${out.roles.node}`);
|
|
263
|
+
log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}]`).join(', ')}`);
|
|
264
|
+
if (platform === 'termux') log(`boot: ${out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script'}`);
|
|
265
|
+
}
|
|
163
266
|
return out;
|
|
164
267
|
}
|
|
165
268
|
|
|
269
|
+
// restart per-platform (shared da token rotate / update). execImpl per test.
|
|
270
|
+
function restart(opts = {}) {
|
|
271
|
+
const platform = opts.platform || detectPlatform();
|
|
272
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
273
|
+
const log = opts.log || console.log;
|
|
274
|
+
if (platform === 'linux') {
|
|
275
|
+
execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
|
|
276
|
+
log('restart: systemctl --user restart nexuscrew');
|
|
277
|
+
return { platform, restarted: true };
|
|
278
|
+
}
|
|
279
|
+
if (platform === 'mac') {
|
|
280
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
281
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
282
|
+
log(`restart: launchctl kickstart -k ${label}`);
|
|
283
|
+
return { platform, restarted: true };
|
|
284
|
+
}
|
|
285
|
+
if (platform === 'termux') {
|
|
286
|
+
stop({ execImpl, log, platform, uid: opts.uid });
|
|
287
|
+
start({ execImpl, spawnImpl: opts.spawnImpl, log, platform, uid: opts.uid });
|
|
288
|
+
return { platform, restarted: true };
|
|
289
|
+
}
|
|
290
|
+
throw new Error(`restart: platform ${platform} non supportata`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function wizardComplete(configPath) {
|
|
294
|
+
try {
|
|
295
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
296
|
+
return cfg && cfg.wizardDone === true;
|
|
297
|
+
} catch (_) { return false; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function servicePinsLegacyPort(platform, home, installPath) {
|
|
301
|
+
let target;
|
|
302
|
+
try { target = installPath || serviceInstallPath(platform, home); } catch (_) { return false; }
|
|
303
|
+
try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function portAvailable(port, host = '127.0.0.1') {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
const s = net.createServer();
|
|
309
|
+
s.unref();
|
|
310
|
+
s.once('error', () => resolve(false));
|
|
311
|
+
s.listen(port, host, () => s.close(() => resolve(true)));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function findAvailablePort(startPort, opts = {}) {
|
|
316
|
+
const available = opts.portAvailableImpl || portAvailable;
|
|
317
|
+
const first = Number(startPort);
|
|
318
|
+
if (!Number.isInteger(first) || first < 1 || first > 65535) throw new Error(`invalid port: ${startPort}`);
|
|
319
|
+
for (let port = first; port <= Math.min(65535, first + 200); port += 1) {
|
|
320
|
+
if (await available(port, '127.0.0.1')) return port;
|
|
321
|
+
}
|
|
322
|
+
throw new Error(`no free loopback port found from ${first}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function probeNexusCrew(port, token, opts = {}) {
|
|
326
|
+
if (!token) return false;
|
|
327
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
328
|
+
if (typeof fetchImpl !== 'function') return false;
|
|
329
|
+
try {
|
|
330
|
+
const r = await fetchImpl(`http://127.0.0.1:${port}/api/config`, {
|
|
331
|
+
headers: { authorization: `Bearer ${token}` },
|
|
332
|
+
signal: AbortSignal.timeout?.(700),
|
|
333
|
+
});
|
|
334
|
+
return r.status === 200;
|
|
335
|
+
} catch (_) { return false; }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function waitForNexusCrew(port, token, opts = {}) {
|
|
339
|
+
const probe = opts.probeImpl || probeNexusCrew;
|
|
340
|
+
const attempts = opts.waitAttempts === undefined ? 30 : opts.waitAttempts;
|
|
341
|
+
const delay = opts.waitDelayMs === undefined ? 100 : opts.waitDelayMs;
|
|
342
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
343
|
+
if (await probe(port, token, opts)) return true;
|
|
344
|
+
if (i + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function startPortable(opts = {}) {
|
|
350
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
351
|
+
const home = opts.home || require('node:os').homedir();
|
|
352
|
+
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
353
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
354
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
355
|
+
const child = spawnImpl(nodeBin(), [path.join(repoRoot(), 'bin', 'nexuscrew.js'), 'serve', '--pidfile'], {
|
|
356
|
+
detached: true, stdio: ['ignore', logFd, logFd],
|
|
357
|
+
});
|
|
358
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
359
|
+
return { started: true, pid: child && child.pid, portable: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function openPwa(fullUrl, opts = {}) {
|
|
363
|
+
if (opts.openImpl) return opts.openImpl(fullUrl);
|
|
364
|
+
const platform = opts.platform || detectPlatform();
|
|
365
|
+
const commandExists = opts.commandExists || require('./path.js').commandExists;
|
|
366
|
+
const candidates = platform === 'termux'
|
|
367
|
+
? ['termux-open-url']
|
|
368
|
+
: platform === 'mac' ? ['open'] : ['xdg-open', 'gio'];
|
|
369
|
+
const bin = candidates.find((candidate) => commandExists(candidate, opts.env || process.env));
|
|
370
|
+
if (!bin) throw new Error('no URL opener found; install termux-tools or xdg-utils');
|
|
371
|
+
const args = bin === 'gio' ? ['open', fullUrl] : [fullUrl];
|
|
372
|
+
const child = (opts.spawnImpl || spawn)(bin, args, { detached: true, stdio: 'ignore' });
|
|
373
|
+
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
374
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Normal entry point. It is deliberately quiet and returns after starting the
|
|
379
|
+
// background service. Only first-run opens the wizard; `show` always opens it.
|
|
380
|
+
async function smartUp(opts = {}) {
|
|
381
|
+
const quiet = opts.lifecycleLog || (() => {});
|
|
382
|
+
const platform = opts.platform || detectPlatform();
|
|
383
|
+
const runInitImpl = opts.runInitImpl || runInit;
|
|
384
|
+
const startImpl = opts.startImpl || start;
|
|
385
|
+
const restartImpl = opts.restartImpl || restart;
|
|
386
|
+
const probe = opts.probeImpl || probeNexusCrew;
|
|
387
|
+
const { configPath, tokenPath } = urlmod.resolvePaths(opts);
|
|
388
|
+
let initialized = fs.existsSync(configPath) && !!urlmod.readToken(tokenPath);
|
|
389
|
+
let port;
|
|
390
|
+
|
|
391
|
+
if (!initialized) {
|
|
392
|
+
const requested = opts.port || urlmod.loadPort(opts);
|
|
393
|
+
const selected = await findAvailablePort(requested, opts);
|
|
394
|
+
runInitImpl({ ...opts, port: selected, log: quiet, platform, printUrl: false });
|
|
395
|
+
port = selected;
|
|
396
|
+
initialized = true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
|
|
400
|
+
// config.json forever. Regenerate once so config.json becomes authoritative.
|
|
401
|
+
const home = opts.home || require('node:os').homedir();
|
|
402
|
+
if (servicePinsLegacyPort(platform, home, opts.installPath)) {
|
|
403
|
+
runInitImpl({ ...opts, log: quiet, platform, printUrl: false });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!port) port = urlmod.loadPort(opts);
|
|
407
|
+
let token = urlmod.readToken(tokenPath);
|
|
408
|
+
let running = await probe(port, token, opts);
|
|
409
|
+
let portableAttempted = false;
|
|
410
|
+
const managedRunning = isServiceRunning({ ...opts, platform });
|
|
411
|
+
if (!running && managedRunning) running = await waitForNexusCrew(port, token, opts);
|
|
412
|
+
|
|
413
|
+
if (!running) {
|
|
414
|
+
if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
|
|
415
|
+
port = await findAvailablePort(port + 1, opts);
|
|
416
|
+
runInitImpl({ ...opts, port, log: quiet, platform, printUrl: false });
|
|
417
|
+
token = urlmod.readToken(tokenPath);
|
|
418
|
+
} else {
|
|
419
|
+
try {
|
|
420
|
+
const started = managedRunning
|
|
421
|
+
? restartImpl({ ...opts, platform, log: quiet })
|
|
422
|
+
: startImpl({ ...opts, platform, log: quiet });
|
|
423
|
+
if (started && started.started === false) {
|
|
424
|
+
startPortable({ ...opts, platform }); portableAttempted = true;
|
|
425
|
+
}
|
|
426
|
+
} catch (_) {
|
|
427
|
+
startPortable({ ...opts, platform }); portableAttempted = true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
running = await waitForNexusCrew(port, token, opts);
|
|
431
|
+
if (!running && !portableAttempted) {
|
|
432
|
+
try { startPortable({ ...opts, platform }); portableAttempted = true; } catch (_) {}
|
|
433
|
+
running = await waitForNexusCrew(port, token, opts);
|
|
434
|
+
}
|
|
435
|
+
if (!running) throw new Error(`server did not become ready on 127.0.0.1:${port}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const shouldOpen = !!opts.forceOpen || !wizardComplete(configPath);
|
|
439
|
+
if (shouldOpen) openPwa(urlmod.buildUrl(port, token, { withToken: true }), { ...opts, platform });
|
|
440
|
+
return { platform, initialized, running, opened: shouldOpen, port, url: urlmod.buildUrl(port, null) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// url [--qr]: ristampa URL completo con #token — UNICO comando che mostra il token.
|
|
444
|
+
function url(opts = {}) {
|
|
445
|
+
const log = opts.log || console.log;
|
|
446
|
+
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
447
|
+
const port = urlmod.loadPort(opts);
|
|
448
|
+
const token = urlmod.readToken(tokenPath);
|
|
449
|
+
const full = urlmod.buildUrl(port, token, { withToken: true });
|
|
450
|
+
log(full);
|
|
451
|
+
if (opts.qr) {
|
|
452
|
+
if (token) log(urlmod.renderQr(full, { qrcode: opts.qrcode }));
|
|
453
|
+
else log('url: nessun token (esegui init) — QR non generato');
|
|
454
|
+
}
|
|
455
|
+
return { url: full, port, hasToken: !!token };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// token rotate (§4b(3)): scrittura atomica nuovo token + invalidazione reale delle
|
|
459
|
+
// sessioni attive (restart se il service e' attivo). Il nuovo token NON si stampa.
|
|
460
|
+
function tokenRotate(opts = {}) {
|
|
461
|
+
const log = opts.log || console.log;
|
|
462
|
+
const platform = opts.platform || detectPlatform();
|
|
463
|
+
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
464
|
+
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
465
|
+
if (readonly) {
|
|
466
|
+
log('token rotate: READONLY, rotazione bloccata');
|
|
467
|
+
return { rotated: false, reason: 'readonly' };
|
|
468
|
+
}
|
|
469
|
+
rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
|
|
470
|
+
log(`token: nuovo segreto scritto (${tokenPath})`);
|
|
471
|
+
const running = isServiceRunning({ ...opts, platform });
|
|
472
|
+
if (running) {
|
|
473
|
+
restart({ ...opts, platform, log });
|
|
474
|
+
log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
|
|
475
|
+
} else {
|
|
476
|
+
// il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
|
|
477
|
+
// vivo col VECCHIO token cachato allo startup: avvisa esplicitamente.
|
|
478
|
+
const meta = pidf.readPidfile(pidf.defaultPidfilePath());
|
|
479
|
+
if (meta && pidf.isAlive(meta)) {
|
|
480
|
+
log(`token rotate: ATTENZIONE — server manuale attivo (pid ${meta.pid}): il vecchio token resta valido finche' non lo riavvii`);
|
|
481
|
+
} else {
|
|
482
|
+
log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
log('usa `nexuscrew url` per il nuovo URL (il token non si stampa qui)');
|
|
486
|
+
return { rotated: true, running };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
|
|
490
|
+
function logs(opts = {}) {
|
|
491
|
+
const platform = opts.platform || detectPlatform();
|
|
492
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
493
|
+
const log = opts.log || console.log;
|
|
494
|
+
const home = opts.home || require('node:os').homedir();
|
|
495
|
+
const follow = !!opts.follow;
|
|
496
|
+
|
|
497
|
+
let bin; let args;
|
|
498
|
+
if (platform === 'linux') {
|
|
499
|
+
bin = 'journalctl';
|
|
500
|
+
args = ['--user', '-u', 'nexuscrew', '--no-pager'];
|
|
501
|
+
if (follow) args.push('-f');
|
|
502
|
+
} else {
|
|
503
|
+
// mac (launchd StandardOutPath) / termux (nohup logfile): stesso path.
|
|
504
|
+
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
505
|
+
bin = 'tail';
|
|
506
|
+
args = follow ? ['-f', logPath] : ['-n', '200', logPath];
|
|
507
|
+
}
|
|
508
|
+
log(`logs: ${bin} ${args.join(' ')}`);
|
|
509
|
+
const child = spawnImpl(bin, args, { stdio: 'inherit' });
|
|
510
|
+
// passthrough: quando il figlio esce (o subito, se non-follow), esce anche la CLI.
|
|
511
|
+
if (child && typeof child.on === 'function') {
|
|
512
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
513
|
+
}
|
|
514
|
+
return { platform, bin, args, follow, keepAlive: true };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// update: npm i -g @latest + restart se attivo. Fallimento npm -> messaggio chiaro, code 1.
|
|
518
|
+
function update(opts = {}) {
|
|
519
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
520
|
+
const log = opts.log || console.log;
|
|
521
|
+
const platform = opts.platform || detectPlatform();
|
|
522
|
+
const pkg = '@mmmbuto/nexuscrew@latest';
|
|
523
|
+
try {
|
|
524
|
+
execImpl('npm', ['i', '-g', pkg], { stdio: 'inherit' });
|
|
525
|
+
log(`update: ${pkg} installato`);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
log(`update: npm install fallito — ${e && e.message ? e.message : e}`);
|
|
528
|
+
log('update: controlla npm/rete/permessi globali, poi riprova: npm i -g @mmmbuto/nexuscrew@latest');
|
|
529
|
+
return { updated: false, error: String(e && e.message ? e.message : e), code: 1 };
|
|
530
|
+
}
|
|
531
|
+
const running = isServiceRunning({ ...opts, platform });
|
|
532
|
+
if (running) {
|
|
533
|
+
restart({ ...opts, platform, log });
|
|
534
|
+
log('update: servizio riavviato sul nuovo codice');
|
|
535
|
+
} else {
|
|
536
|
+
log('update: servizio non attivo (nessun restart)');
|
|
537
|
+
}
|
|
538
|
+
return { updated: true, running, code: 0 };
|
|
539
|
+
}
|
|
540
|
+
|
|
166
541
|
// B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
|
|
167
542
|
// dispatch e' sync (bin/nexuscrew.js non fa await), quindi runFleetBoot (async)
|
|
168
543
|
// viene lanciato e il processo tenuto vivo (keepAlive) finche' non termina, poi
|
|
@@ -207,39 +582,123 @@ function dispatchFleetBoot(opts) {
|
|
|
207
582
|
}
|
|
208
583
|
|
|
209
584
|
function dispatch(argv, opts = {}) {
|
|
210
|
-
const { flags, rest } = parseFlags(argv);
|
|
211
|
-
const cmd = rest[0];
|
|
212
585
|
const log = opts.log || console.log;
|
|
586
|
+
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
|
587
|
+
log(HELP);
|
|
588
|
+
return { code: 0 };
|
|
589
|
+
}
|
|
590
|
+
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
|
|
591
|
+
log(require('../../package.json').version);
|
|
592
|
+
return { code: 0 };
|
|
593
|
+
}
|
|
594
|
+
const { flags, rest } = parseFlags(argv, VALUE_FLAGS);
|
|
595
|
+
const cmd = rest[0];
|
|
213
596
|
|
|
214
|
-
|
|
597
|
+
// help esplicito
|
|
598
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
215
599
|
log(HELP);
|
|
216
600
|
return { code: 0 };
|
|
217
601
|
}
|
|
218
|
-
|
|
219
|
-
|
|
602
|
+
// Public surface: normal start, show, doctor, help, version.
|
|
603
|
+
if (!cmd) {
|
|
604
|
+
if (Object.keys(flags).length) {
|
|
605
|
+
log(`unknown option: --${Object.keys(flags)[0]}\n\n${HELP}`);
|
|
606
|
+
return { code: 1 };
|
|
607
|
+
}
|
|
608
|
+
return smartUp({ ...opts, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
|
|
609
|
+
}
|
|
610
|
+
if (cmd === 'show') {
|
|
611
|
+
return smartUp({ ...opts, forceOpen: true, log: opts.log || (() => {}) }).then(() => ({ code: 0 }));
|
|
612
|
+
}
|
|
613
|
+
if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
|
|
614
|
+
log(require('../../package.json').version);
|
|
220
615
|
return { code: 0 };
|
|
221
616
|
}
|
|
617
|
+
// Internal runtime commands used by service managers and MCP clients. They
|
|
618
|
+
// are intentionally omitted from HELP and are not configuration surfaces.
|
|
222
619
|
if (cmd === 'serve') {
|
|
223
620
|
serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
|
|
224
621
|
return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
|
|
225
622
|
}
|
|
226
|
-
if (cmd === '
|
|
227
|
-
|
|
228
|
-
return { code:
|
|
229
|
-
}
|
|
230
|
-
if (cmd === 'stop') {
|
|
231
|
-
stop({ execImpl: opts.execImpl, log, platform: opts.platform, uid: opts.uid });
|
|
232
|
-
return { code: 0 };
|
|
623
|
+
if (cmd === 'doctor') {
|
|
624
|
+
const r = doctor({ ...opts, log });
|
|
625
|
+
return { code: r.code };
|
|
233
626
|
}
|
|
234
|
-
if (cmd === '
|
|
235
|
-
|
|
236
|
-
|
|
627
|
+
if (cmd === 'mcp') {
|
|
628
|
+
// Server MCP stdio (bridge cella→operatore): stdout e' il canale JSON-RPC, quindi
|
|
629
|
+
// NESSUN log qui. keepAlive: resta vivo finche' il client tiene aperto stdin.
|
|
630
|
+
const startMcpImpl = opts.startMcpImpl || require('../mcp/server.js').startMcp;
|
|
631
|
+
startMcpImpl();
|
|
632
|
+
return { code: 0, keepAlive: true };
|
|
237
633
|
}
|
|
238
634
|
if (cmd === 'fleet-boot') {
|
|
239
635
|
return dispatchFleetBoot({ ...opts, log });
|
|
240
636
|
}
|
|
241
|
-
log(`
|
|
637
|
+
log(`"${cmd}" is not a public CLI command. Open the PWA with: nexuscrew show\n\n${HELP}`);
|
|
638
|
+
return { code: 1 };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
|
|
642
|
+
function dispatchNodes(rest, flags, opts) {
|
|
643
|
+
const log = opts.log;
|
|
644
|
+
const sub = rest[1];
|
|
645
|
+
const name = rest[2];
|
|
646
|
+
const common = { ...opts, name };
|
|
647
|
+
switch (sub) {
|
|
648
|
+
case 'add':
|
|
649
|
+
return { code: nodesCmds.nodesAdd({
|
|
650
|
+
...common, ssh: flags.ssh, sshPort: flags['ssh-port'], remotePort: flags['remote-port'],
|
|
651
|
+
key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
|
|
652
|
+
}).code };
|
|
653
|
+
case undefined:
|
|
654
|
+
case 'list':
|
|
655
|
+
return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
|
|
656
|
+
case 'remove':
|
|
657
|
+
return { code: nodesCmds.nodesRemove(common).code };
|
|
658
|
+
case 'up':
|
|
659
|
+
return { code: nodesCmds.nodesUp(common).code };
|
|
660
|
+
case 'down':
|
|
661
|
+
return { code: nodesCmds.nodesDown(common).code };
|
|
662
|
+
case 'restart':
|
|
663
|
+
return { code: nodesCmds.nodesRestart(common).code };
|
|
664
|
+
case 'set-token':
|
|
665
|
+
return { code: nodesCmds.nodesSetToken(common).code };
|
|
666
|
+
case 'test': {
|
|
667
|
+
// async: tieni vivo il processo finche' il probe non risolve, poi exit.
|
|
668
|
+
const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
|
|
669
|
+
nodesCmds.nodesTest(common)
|
|
670
|
+
.then((r) => exit(r.code))
|
|
671
|
+
.catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
|
|
672
|
+
return { code: 0, keepAlive: true };
|
|
673
|
+
}
|
|
674
|
+
default:
|
|
675
|
+
log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
|
|
676
|
+
return { code: 1 };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
|
|
681
|
+
function dispatchNode(rest, flags, opts) {
|
|
682
|
+
const log = opts.log;
|
|
683
|
+
const sub = rest[1];
|
|
684
|
+
if (sub === 'on') {
|
|
685
|
+
return { code: nodesCmds.nodeOn({
|
|
686
|
+
...opts, rendezvousSsh: flags.rendezvous,
|
|
687
|
+
publishedPort: flags['published-port'], key: flags.key,
|
|
688
|
+
}).code };
|
|
689
|
+
}
|
|
690
|
+
if (sub === 'off') {
|
|
691
|
+
return { code: nodesCmds.nodeOff({ ...opts }).code };
|
|
692
|
+
}
|
|
693
|
+
log('usage: nexuscrew node on|off');
|
|
242
694
|
return { code: 1 };
|
|
243
695
|
}
|
|
244
696
|
|
|
245
|
-
module.exports = {
|
|
697
|
+
module.exports = {
|
|
698
|
+
dispatch, serve, start, stop, status, parseFlags, HELP,
|
|
699
|
+
smartUp, url, tokenRotate, logs, doctor, update, restart,
|
|
700
|
+
portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
|
|
701
|
+
servicePinsLegacyPort,
|
|
702
|
+
isServiceRunning, readRoles,
|
|
703
|
+
runFleetBoot, dispatchFleetBoot,
|
|
704
|
+
};
|