@mmmbuto/nexuscrew 0.8.9 → 0.8.11
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 +48 -20
- package/frontend/dist/assets/index-DoUIJ4GM.js +91 -0
- package/frontend/dist/assets/index-DrrRAIg6.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +287 -147
- package/lib/cli/doctor.js +37 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/cli/service.js +52 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +12 -1
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +22 -14
- package/lib/nodes/peering.js +72 -10
- package/lib/nodes/store.js +21 -18
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +68 -6
- package/lib/server.js +36 -31
- package/lib/settings/routes.js +202 -95
- package/lib/update/runner.js +4 -1
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-9Drd8g0k.css +0 -32
- package/frontend/dist/assets/index-DTmT7yhV.js +0 -91
package/lib/cli/commands.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Minimal public CLI plus internal service/MCP entry points. [M6][R1]
|
|
3
3
|
// serve = foreground HTTP (+ --pidfile lifecycle su Termux/manuale).
|
|
4
4
|
// start/stop/status = per-platform: linux (systemctl --user), mac (launchctl),
|
|
5
5
|
// termux (nohup serve --pidfile + pidfile verificato; status boot-script vs running).
|
|
@@ -8,23 +8,16 @@ const fs = require('node:fs');
|
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const net = require('node:net');
|
|
10
10
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
11
|
-
const { installPath: serviceInstallPath } = require('./service.js');
|
|
11
|
+
const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
|
|
12
12
|
const { fleetInstallPath } = require('./fleet-service.js');
|
|
13
13
|
const pidf = require('./pidfile.js');
|
|
14
14
|
const { runInit } = require('./init.js');
|
|
15
15
|
const { rotateToken } = require('../auth/token.js');
|
|
16
16
|
const urlmod = require('./url.js');
|
|
17
17
|
const { doctor } = require('./doctor.js');
|
|
18
|
-
const nodesCmds = require('../nodes/commands.js');
|
|
19
18
|
const nodesStore = require('../nodes/store.js');
|
|
20
19
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
21
20
|
|
|
22
|
-
// Flag CLI che consumano il token successivo (forma "--k v", oltre a "--k=v").
|
|
23
|
-
// Solo flag esclusivi dei subcomandi nodes/node: non collidono con gli altri.
|
|
24
|
-
const VALUE_FLAGS = new Set([
|
|
25
|
-
'ssh', 'ssh-port', 'remote-port', 'key', 'local-port', 'node-id', 'rendezvous', 'published-port', 'token',
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
21
|
const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
29
22
|
|
|
30
23
|
Usage:
|
|
@@ -32,6 +25,9 @@ Usage:
|
|
|
32
25
|
nexuscrew show start when needed and open the authenticated PWA
|
|
33
26
|
nexuscrew show token print the clickable authenticated URL
|
|
34
27
|
nexuscrew boot enable startup at boot (use: boot off|status)
|
|
28
|
+
nexuscrew status show service, port, roles and node status
|
|
29
|
+
nexuscrew stop stop the background service
|
|
30
|
+
nexuscrew restart restart the background service
|
|
35
31
|
nexuscrew doctor run local diagnostics
|
|
36
32
|
nexuscrew help show this help
|
|
37
33
|
nexuscrew version show the installed version
|
|
@@ -153,57 +149,180 @@ function start(opts = {}) {
|
|
|
153
149
|
throw new Error(`start: platform ${platform} non supportata`);
|
|
154
150
|
}
|
|
155
151
|
|
|
156
|
-
function
|
|
152
|
+
function stopManagedTunnels(opts = {}) {
|
|
153
|
+
const home = opts.home || require('node:os').homedir();
|
|
154
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
155
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
156
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
157
|
+
const stopped = [];
|
|
158
|
+
for (const node of (st && st.nodes) || []) {
|
|
159
|
+
if (node.direction === 'inbound') continue;
|
|
160
|
+
try { nodesTunnel.stopTunnel({ home, name: node.name }); stopped.push(node.name); } catch (_) {}
|
|
161
|
+
}
|
|
162
|
+
if (st && st.rendezvous) {
|
|
163
|
+
try { nodesTunnel.stopTunnel({ home, name: nodesTunnel.REVERSE_NAME }); stopped.push(nodesTunnel.REVERSE_NAME); } catch (_) {}
|
|
164
|
+
}
|
|
165
|
+
return stopped;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function refusePairedPortRelocation(opts, from, to) {
|
|
169
|
+
if (Number(from) === Number(to)) return;
|
|
170
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
171
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
172
|
+
if (nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath))) {
|
|
173
|
+
throw new Error(`port ${from} is busy and paired peers exist; refusing automatic move to ${to}. Free the configured port or reconnect peers intentionally.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Runtime ownership is independent from boot persistence. With boot disabled,
|
|
178
|
+
// smartUp intentionally launches `serve --pidfile` on Linux/macOS too; lifecycle
|
|
179
|
+
// commands must therefore inspect both the service manager and that portable
|
|
180
|
+
// pidfile instead of assuming the platform selects exactly one owner.
|
|
181
|
+
function managedRuntimeState(opts = {}) {
|
|
157
182
|
const platform = opts.platform || detectPlatform();
|
|
158
183
|
const execImpl = opts.execImpl || execFileSync;
|
|
159
|
-
const log = opts.log || console.log;
|
|
160
|
-
|
|
161
184
|
if (platform === 'linux') {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
try {
|
|
186
|
+
const service = String(execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }) || '').trim();
|
|
187
|
+
return { supported: true, running: service === 'active', service: service || 'inactive' };
|
|
188
|
+
} catch (_) { return { supported: true, running: false, service: 'inactive' }; }
|
|
165
189
|
}
|
|
166
190
|
if (platform === 'mac') {
|
|
167
|
-
const
|
|
191
|
+
const service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
168
192
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return { platform, stopped: true };
|
|
173
|
-
} catch (e) {
|
|
174
|
-
const reason = String(e.message || e);
|
|
175
|
-
log(`stop: launchctl bootout fallito: ${reason}`);
|
|
176
|
-
return { platform, stopped: false, reason };
|
|
177
|
-
}
|
|
193
|
+
execImpl('launchctl', ['print', service], { stdio: 'ignore' });
|
|
194
|
+
return { supported: true, running: true, service };
|
|
195
|
+
} catch (_) { return { supported: true, running: false, service }; }
|
|
178
196
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
return { supported: false, running: false, service: 'portable-only' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function portableRuntimeState(opts = {}) {
|
|
201
|
+
const home = opts.home || require('node:os').homedir();
|
|
202
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
203
|
+
const meta = pidf.readPidfile(pidPath);
|
|
204
|
+
if (!meta) return { running: false, pidPath, reason: 'no pidfile' };
|
|
205
|
+
if (pidf.isAlive(meta)) return { running: true, pidPath, pid: meta.pid, meta };
|
|
206
|
+
pidf.cleanStale(pidPath);
|
|
207
|
+
return { running: false, pidPath, reason: 'stale pidfile' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveRuntimeOwner(opts = {}) {
|
|
211
|
+
const platform = opts.platform || detectPlatform();
|
|
212
|
+
const managed = managedRuntimeState({ ...opts, platform });
|
|
213
|
+
const portable = portableRuntimeState(opts);
|
|
214
|
+
let owner = 'stopped';
|
|
215
|
+
if (managed.running && portable.running) owner = 'conflict';
|
|
216
|
+
else if (managed.running) owner = 'managed';
|
|
217
|
+
else if (portable.running) owner = 'portable';
|
|
218
|
+
return {
|
|
219
|
+
platform, owner,
|
|
220
|
+
managedRunning: managed.running,
|
|
221
|
+
portableRunning: portable.running,
|
|
222
|
+
service: managed.service,
|
|
223
|
+
portablePid: portable.pid,
|
|
224
|
+
pidPath: portable.pidPath,
|
|
225
|
+
stalePidfile: portable.reason === 'stale pidfile',
|
|
226
|
+
portableReason: portable.reason,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function waitForPidExit(pid, timeoutMs = 2000) {
|
|
231
|
+
const deadline = Date.now() + timeoutMs;
|
|
232
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
233
|
+
const exited = () => {
|
|
234
|
+
if (!pidf.pidExists(pid)) return true;
|
|
235
|
+
// A test-owned child can remain as a zombie until this synchronous caller
|
|
236
|
+
// returns to the event loop. It no longer owns sockets and is exited for
|
|
237
|
+
// lifecycle purposes. Linux/Termux expose the state in /proc; macOS has no
|
|
238
|
+
// equivalent cheap path and normally removes a non-child immediately.
|
|
239
|
+
try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
|
|
240
|
+
catch (_) { return false; }
|
|
241
|
+
};
|
|
242
|
+
while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
|
|
243
|
+
return exited();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function stopPortableRuntime(opts = {}) {
|
|
247
|
+
const home = opts.home || require('node:os').homedir();
|
|
248
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
249
|
+
const result = pidf.killPidfile(pidPath);
|
|
250
|
+
if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
|
|
251
|
+
return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
|
|
186
252
|
}
|
|
187
|
-
|
|
253
|
+
return result;
|
|
188
254
|
}
|
|
189
255
|
|
|
190
|
-
|
|
191
|
-
function isServiceRunning(opts = {}) {
|
|
256
|
+
function stop(opts = {}) {
|
|
192
257
|
const platform = opts.platform || detectPlatform();
|
|
193
258
|
const execImpl = opts.execImpl || execFileSync;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
259
|
+
const log = opts.log || console.log;
|
|
260
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`stop: platform ${platform} non supportata`);
|
|
261
|
+
|
|
262
|
+
const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
263
|
+
const stoppedOwners = [];
|
|
264
|
+
const errors = [];
|
|
265
|
+
|
|
266
|
+
// The shared tmux server can live in the NexusCrew systemd cgroup. If the
|
|
267
|
+
// effective KillMode cannot be proven safe, stop must not mutate *any*
|
|
268
|
+
// runtime owner or detached tunnel: callers can then fix the unit and retry
|
|
269
|
+
// without ending up in a partially stopped state.
|
|
270
|
+
if (before.managedRunning && platform === 'linux') {
|
|
271
|
+
try {
|
|
272
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const reason = String(error.message || error);
|
|
275
|
+
log(`stop: annullato — protezione tmux non verificata: ${reason}`);
|
|
276
|
+
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
277
|
+
}
|
|
197
278
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
279
|
+
|
|
280
|
+
if (before.managedRunning) {
|
|
281
|
+
try {
|
|
282
|
+
if (platform === 'linux') {
|
|
283
|
+
execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
|
|
284
|
+
log('stop: systemctl --user stop nexuscrew');
|
|
285
|
+
} else {
|
|
286
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
287
|
+
// bootout unloads KeepAlive, so a voluntary stop stays stopped.
|
|
288
|
+
execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
|
|
289
|
+
log(`stop: launchctl bootout ${label}`);
|
|
290
|
+
}
|
|
291
|
+
stoppedOwners.push('managed');
|
|
292
|
+
} catch (error) { errors.push(String(error.message || error)); }
|
|
201
293
|
}
|
|
294
|
+
|
|
295
|
+
if (before.portableRunning) {
|
|
296
|
+
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
297
|
+
if (portable.killed) {
|
|
298
|
+
stoppedOwners.push('portable');
|
|
299
|
+
log(`stop: killed portable pid ${portable.pid}`);
|
|
300
|
+
} else errors.push(portable.reason || 'portable stop failed');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Detached tunnel supervisors are outside both server owners. A real stop
|
|
304
|
+
// closes them once; the next start restores only autostart:true links.
|
|
305
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
202
306
|
if (platform === 'termux') {
|
|
203
|
-
|
|
204
|
-
return !!(meta && pidf.isAlive(meta));
|
|
307
|
+
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
205
308
|
}
|
|
206
|
-
|
|
309
|
+
|
|
310
|
+
if (errors.length) {
|
|
311
|
+
const reason = errors.join('; ');
|
|
312
|
+
log(`stop: incompleto — ${reason}`);
|
|
313
|
+
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
314
|
+
}
|
|
315
|
+
const alreadyStopped = before.owner === 'stopped';
|
|
316
|
+
if (alreadyStopped) log(`stop: ${before.portableReason || 'already stopped'}`);
|
|
317
|
+
return {
|
|
318
|
+
platform, owner: before.owner, stopped: true, stoppedOwners, alreadyStopped,
|
|
319
|
+
reason: alreadyStopped ? (before.portableReason || 'already stopped') : undefined,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// running-check per-platform (no log) — riusato da smart-up / token rotate / update.
|
|
324
|
+
function isServiceRunning(opts = {}) {
|
|
325
|
+
return resolveRuntimeOwner(opts).owner !== 'stopped';
|
|
207
326
|
}
|
|
208
327
|
|
|
209
328
|
function bootState(opts = {}) {
|
|
@@ -274,12 +393,13 @@ function quickSummary(result, opts = {}) {
|
|
|
274
393
|
let cached = [];
|
|
275
394
|
try { cached = (require('../nodes/topology-cache.js').loadCache(opts.topologyCachePath || require('../nodes/topology-cache.js').defaultPath(home)) || {}).nodes || []; } catch (_) {}
|
|
276
395
|
const direct = (st && st.nodes) || [];
|
|
277
|
-
const
|
|
396
|
+
const managed = direct.filter((n) => n.direction !== 'inbound');
|
|
397
|
+
const online = managed.filter((n) => nodesTunnel.readTunnelState(home, n.name).status === 'up').length;
|
|
278
398
|
const known = direct.length + cached.filter((n) => !direct.some((d) => d.nodeId && d.nodeId === n.instanceId)).length;
|
|
279
399
|
log(`NexusCrew ${require('../../package.json').version}`);
|
|
280
400
|
log(`server ${result.running ? '● running' : '○ stopped'} · 127.0.0.1:${result.port}`);
|
|
281
401
|
log(`boot ${bootState({ ...opts, platform }).enabled ? 'on' : 'off'} · ${platform}`);
|
|
282
|
-
log(`nodes ${known} known · ${online}/${
|
|
402
|
+
log(`nodes ${known} known · ${online}/${managed.length} hub connections up`);
|
|
283
403
|
log('');
|
|
284
404
|
log('open nexuscrew show');
|
|
285
405
|
log('link nexuscrew show token');
|
|
@@ -304,25 +424,18 @@ function status(opts = {}) {
|
|
|
304
424
|
const { configPath, configDir } = urlmod.resolvePaths(opts);
|
|
305
425
|
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
306
426
|
|
|
307
|
-
const
|
|
427
|
+
const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
428
|
+
const out = {
|
|
429
|
+
platform, service: runtime.service, running: runtime.owner !== 'stopped',
|
|
430
|
+
runtimeOwner: runtime.owner, managedRunning: runtime.managedRunning,
|
|
431
|
+
portableRunning: runtime.portableRunning, portablePid: runtime.portablePid,
|
|
432
|
+
port: null, url: null, roles: null, nodes: [],
|
|
433
|
+
};
|
|
308
434
|
|
|
309
|
-
if (platform === '
|
|
310
|
-
try {
|
|
311
|
-
const s = execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }).trim();
|
|
312
|
-
out.service = s; out.running = s === 'active';
|
|
313
|
-
} catch (_) { out.service = 'inactive'; out.running = false; }
|
|
314
|
-
} else if (platform === 'mac') {
|
|
315
|
-
out.service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
316
|
-
try {
|
|
317
|
-
execImpl('launchctl', ['print', out.service], { stdio: 'ignore' });
|
|
318
|
-
out.running = true;
|
|
319
|
-
} catch (_) { out.running = false; }
|
|
320
|
-
} else if (platform === 'termux') {
|
|
435
|
+
if (platform === 'termux') {
|
|
321
436
|
// boot-script installed vs server running (pidfile vivo)
|
|
322
437
|
const bootScript = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
323
438
|
out.bootScriptInstalled = fs.existsSync(bootScript);
|
|
324
|
-
const meta = pidf.readPidfile(pidf.defaultPidfilePath(home));
|
|
325
|
-
out.running = !!(meta && pidf.isAlive(meta));
|
|
326
439
|
out.service = out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script';
|
|
327
440
|
}
|
|
328
441
|
|
|
@@ -341,8 +454,10 @@ function status(opts = {}) {
|
|
|
341
454
|
return {
|
|
342
455
|
name: red.name, roles: red.roles,
|
|
343
456
|
remotePort: red.remotePort, localPort: red.localPort,
|
|
344
|
-
hasToken: red.hasToken,
|
|
345
|
-
tunnel:
|
|
457
|
+
direction: red.direction, shared: red.shared, hasToken: red.hasToken,
|
|
458
|
+
tunnel: n.direction === 'inbound'
|
|
459
|
+
? { status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false }
|
|
460
|
+
: nodesTunnel.readTunnelState(home, n.name),
|
|
346
461
|
};
|
|
347
462
|
});
|
|
348
463
|
}
|
|
@@ -354,6 +469,7 @@ function status(opts = {}) {
|
|
|
354
469
|
log(`platform: ${out.platform}`);
|
|
355
470
|
log(`service: ${out.service}`);
|
|
356
471
|
log(`running: ${out.running}`);
|
|
472
|
+
log(`runtime: ${out.runtimeOwner}${out.portablePid ? ` (pid ${out.portablePid})` : ''}`);
|
|
357
473
|
log(`port: ${out.port}`);
|
|
358
474
|
log(`url: ${out.url}`);
|
|
359
475
|
log(`roles: client=${out.roles.client} node=${out.roles.node}`);
|
|
@@ -368,23 +484,59 @@ function restart(opts = {}) {
|
|
|
368
484
|
const platform = opts.platform || detectPlatform();
|
|
369
485
|
const execImpl = opts.execImpl || execFileSync;
|
|
370
486
|
const log = opts.log || console.log;
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
487
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`restart: platform ${platform} non supportata`);
|
|
488
|
+
const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
489
|
+
|
|
490
|
+
// A managed owner can use the service manager's atomic restart. In a
|
|
491
|
+
// conflict, first remove only the stray portable owner, then keep managed as
|
|
492
|
+
// the single authority.
|
|
493
|
+
if (before.managedRunning) {
|
|
494
|
+
if (platform === 'linux') {
|
|
495
|
+
try {
|
|
496
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
497
|
+
} catch (error) {
|
|
498
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (before.portableRunning) {
|
|
502
|
+
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
503
|
+
if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
if (platform === 'linux') {
|
|
507
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
508
|
+
execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
|
|
509
|
+
log('restart: systemctl --user restart nexuscrew');
|
|
510
|
+
} else {
|
|
511
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
512
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
513
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
514
|
+
log(`restart: launchctl kickstart -k ${label}`);
|
|
515
|
+
}
|
|
516
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: true };
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
|
|
519
|
+
}
|
|
386
520
|
}
|
|
387
|
-
|
|
521
|
+
|
|
522
|
+
if (before.portableRunning) {
|
|
523
|
+
const stopped = stop({ ...opts, platform, execImpl, log });
|
|
524
|
+
if (!stopped.stopped) return { platform, owner: before.owner, restarted: false, reason: stopped.reason };
|
|
525
|
+
const started = (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
|
|
526
|
+
const ok = !!(started && started.started !== false);
|
|
527
|
+
if (ok) log('restart: portable runtime started');
|
|
528
|
+
return { platform, owner: before.owner, runtimeOwner: 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// `restart` on a stopped runtime follows the explicit boot owner; with boot
|
|
532
|
+
// disabled it starts a portable background runtime.
|
|
533
|
+
const useManaged = platform !== 'termux' && bootState({ ...opts, platform, execImpl }).enabled;
|
|
534
|
+
const started = useManaged
|
|
535
|
+
? start({ ...opts, platform, execImpl, spawnImpl: opts.spawnImpl, log })
|
|
536
|
+
: (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
|
|
537
|
+
const ok = !!(started && started.started !== false);
|
|
538
|
+
if (ok) log(`restart: ${useManaged ? 'managed' : 'portable'} runtime started`);
|
|
539
|
+
return { platform, owner: before.owner, runtimeOwner: useManaged ? 'managed' : 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
|
|
388
540
|
}
|
|
389
541
|
|
|
390
542
|
function wizardComplete(configPath) {
|
|
@@ -446,6 +598,8 @@ async function waitForNexusCrew(port, token, opts = {}) {
|
|
|
446
598
|
function startPortable(opts = {}) {
|
|
447
599
|
const spawnImpl = opts.spawnImpl || spawn;
|
|
448
600
|
const home = opts.home || require('node:os').homedir();
|
|
601
|
+
const existing = portableRuntimeState({ home });
|
|
602
|
+
if (existing.running) return { started: false, reason: 'already running', pid: existing.pid, portable: true };
|
|
449
603
|
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
450
604
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
451
605
|
const logFd = fs.openSync(logPath, 'a');
|
|
@@ -502,6 +656,7 @@ async function smartUp(opts = {}) {
|
|
|
502
656
|
if (!initialized) {
|
|
503
657
|
const requested = opts.port || urlmod.loadPort(opts);
|
|
504
658
|
const selected = await findAvailablePort(requested, opts);
|
|
659
|
+
refusePairedPortRelocation(opts, requested, selected);
|
|
505
660
|
runInitImpl({ ...opts, port: selected, log: quiet, platform, installBoot: false, printUrl: false });
|
|
506
661
|
port = selected;
|
|
507
662
|
initialized = true;
|
|
@@ -519,19 +674,24 @@ async function smartUp(opts = {}) {
|
|
|
519
674
|
let token = urlmod.readToken(tokenPath);
|
|
520
675
|
let running = await probe(port, token, opts);
|
|
521
676
|
let portableAttempted = false;
|
|
522
|
-
|
|
523
|
-
if (!running &&
|
|
677
|
+
let runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
678
|
+
if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
|
|
524
679
|
|
|
525
680
|
if (!running) {
|
|
526
681
|
if (!(await (opts.portAvailableImpl || portAvailable)(port, '127.0.0.1'))) {
|
|
682
|
+
const previousPort = port;
|
|
527
683
|
port = await findAvailablePort(port + 1, opts);
|
|
684
|
+
refusePairedPortRelocation(opts, previousPort, port);
|
|
528
685
|
runInitImpl({ ...opts, port, log: quiet, platform, installBoot: persistent, printUrl: false });
|
|
529
686
|
token = urlmod.readToken(tokenPath);
|
|
530
687
|
} else {
|
|
531
688
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
689
|
+
runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
690
|
+
const started = runtime.owner === 'conflict'
|
|
691
|
+
? restartImpl({ ...opts, platform, log: quiet })
|
|
692
|
+
: persistent
|
|
693
|
+
? (runtime.managedRunning ? restartImpl({ ...opts, platform, log: quiet }) : startImpl({ ...opts, platform, log: quiet }))
|
|
694
|
+
: (runtime.portableRunning ? restartImpl({ ...opts, platform, log: quiet }) : portableStart({ ...opts, platform }));
|
|
535
695
|
if (started && started.started === false) {
|
|
536
696
|
portableStart({ ...opts, platform }); portableAttempted = true;
|
|
537
697
|
}
|
|
@@ -572,17 +732,34 @@ function url(opts = {}) {
|
|
|
572
732
|
function tokenRotate(opts = {}) {
|
|
573
733
|
const log = opts.log || console.log;
|
|
574
734
|
const platform = opts.platform || detectPlatform();
|
|
735
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
575
736
|
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
576
737
|
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
577
738
|
if (readonly) {
|
|
578
739
|
log('token rotate: READONLY, rotazione bloccata');
|
|
579
740
|
return { rotated: false, reason: 'readonly' };
|
|
580
741
|
}
|
|
742
|
+
const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
743
|
+
const running = runtime.owner !== 'stopped';
|
|
744
|
+
if (runtime.managedRunning && platform === 'linux') {
|
|
745
|
+
try {
|
|
746
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
747
|
+
} catch (error) {
|
|
748
|
+
const reason = String(error.message || error);
|
|
749
|
+
log(`token rotate: annullata — protezione tmux non verificata: ${reason}`);
|
|
750
|
+
return { rotated: false, tokenWritten: false, restarted: false, running, reason };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
581
753
|
rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
|
|
582
754
|
log(`token: nuovo segreto scritto (${tokenPath})`);
|
|
583
|
-
const running = isServiceRunning({ ...opts, platform });
|
|
584
755
|
if (running) {
|
|
585
|
-
restart({ ...opts, platform, log });
|
|
756
|
+
const restarted = (opts.restartImpl || restart)({ ...opts, platform, execImpl, log });
|
|
757
|
+
if (!restarted || restarted.restarted !== true) {
|
|
758
|
+
const reason = (restarted && restarted.reason) || 'esito restart non verificato';
|
|
759
|
+
log(`token rotate: INCOMPLETA — nuovo token scritto ma restart fallito: ${reason}`);
|
|
760
|
+
log('token rotate: non e\' possibile confermare l\'invalidazione del vecchio token; correggi il servizio e ripeti il restart');
|
|
761
|
+
return { rotated: false, tokenWritten: true, restarted: false, running, reason };
|
|
762
|
+
}
|
|
586
763
|
log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
|
|
587
764
|
} else {
|
|
588
765
|
// il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
|
|
@@ -594,8 +771,8 @@ function tokenRotate(opts = {}) {
|
|
|
594
771
|
log('token rotate: servizio non attivo (il nuovo token varra\' al prossimo start)');
|
|
595
772
|
}
|
|
596
773
|
}
|
|
597
|
-
log('usa `nexuscrew
|
|
598
|
-
return { rotated: true, running };
|
|
774
|
+
log('usa `nexuscrew show token` per il nuovo URL (il token non si stampa qui)');
|
|
775
|
+
return { rotated: true, tokenWritten: true, restarted: running, running };
|
|
599
776
|
}
|
|
600
777
|
|
|
601
778
|
// logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
|
|
@@ -642,12 +819,17 @@ function update(opts = {}) {
|
|
|
642
819
|
}
|
|
643
820
|
const running = isServiceRunning({ ...opts, platform });
|
|
644
821
|
if (running) {
|
|
645
|
-
restart({ ...opts, platform, log });
|
|
822
|
+
const restarted = (opts.restartImpl || restart)({ ...opts, platform, log });
|
|
823
|
+
if (!restarted || restarted.restarted !== true) {
|
|
824
|
+
const reason = (restarted && restarted.reason) || 'esito restart non verificato';
|
|
825
|
+
log(`update: pacchetto installato ma restart fallito — ${reason}`);
|
|
826
|
+
return { updated: true, running, restarted: false, reason, code: 1 };
|
|
827
|
+
}
|
|
646
828
|
log('update: servizio riavviato sul nuovo codice');
|
|
647
829
|
} else {
|
|
648
830
|
log('update: servizio non attivo (nessun restart)');
|
|
649
831
|
}
|
|
650
|
-
return { updated: true, running, code: 0 };
|
|
832
|
+
return { updated: true, running, restarted: running, code: 0 };
|
|
651
833
|
}
|
|
652
834
|
|
|
653
835
|
// B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
|
|
@@ -703,7 +885,7 @@ function dispatch(argv, opts = {}) {
|
|
|
703
885
|
log(require('../../package.json').version);
|
|
704
886
|
return { code: 0 };
|
|
705
887
|
}
|
|
706
|
-
const { flags, rest } = parseFlags(argv
|
|
888
|
+
const { flags, rest } = parseFlags(argv);
|
|
707
889
|
const cmd = rest[0];
|
|
708
890
|
|
|
709
891
|
// help esplicito
|
|
@@ -742,6 +924,18 @@ function dispatch(argv, opts = {}) {
|
|
|
742
924
|
log(require('../../package.json').version);
|
|
743
925
|
return { code: 0 };
|
|
744
926
|
}
|
|
927
|
+
if (cmd === 'status') {
|
|
928
|
+
status({ ...opts, log });
|
|
929
|
+
return { code: 0 };
|
|
930
|
+
}
|
|
931
|
+
if (cmd === 'stop') {
|
|
932
|
+
const result = stop({ ...opts, log });
|
|
933
|
+
return { code: result.stopped || ['not running', 'stale pidfile'].includes(result.reason) ? 0 : 1 };
|
|
934
|
+
}
|
|
935
|
+
if (cmd === 'restart') {
|
|
936
|
+
const result = restart({ ...opts, log });
|
|
937
|
+
return { code: result.restarted ? 0 : 1 };
|
|
938
|
+
}
|
|
745
939
|
// Internal runtime commands used by service managers and MCP clients. They
|
|
746
940
|
// are intentionally omitted from HELP and are not configuration surfaces.
|
|
747
941
|
if (cmd === 'serve') {
|
|
@@ -766,62 +960,6 @@ function dispatch(argv, opts = {}) {
|
|
|
766
960
|
return { code: 1 };
|
|
767
961
|
}
|
|
768
962
|
|
|
769
|
-
// nodes add|list|remove|test|up|down|restart|set-token (design §3, §4).
|
|
770
|
-
function dispatchNodes(rest, flags, opts) {
|
|
771
|
-
const log = opts.log;
|
|
772
|
-
const sub = rest[1];
|
|
773
|
-
const name = rest[2];
|
|
774
|
-
const common = { ...opts, name };
|
|
775
|
-
switch (sub) {
|
|
776
|
-
case 'add':
|
|
777
|
-
return { code: nodesCmds.nodesAdd({
|
|
778
|
-
...common, ssh: flags.ssh, sshPort: flags['ssh-port'], remotePort: flags['remote-port'],
|
|
779
|
-
key: flags.key, localPort: flags['local-port'], nodeId: flags['node-id'],
|
|
780
|
-
}).code };
|
|
781
|
-
case undefined:
|
|
782
|
-
case 'list':
|
|
783
|
-
return { code: nodesCmds.nodesList({ ...opts, json: !!flags.json }).code };
|
|
784
|
-
case 'remove':
|
|
785
|
-
return { code: nodesCmds.nodesRemove(common).code };
|
|
786
|
-
case 'up':
|
|
787
|
-
return { code: nodesCmds.nodesUp(common).code };
|
|
788
|
-
case 'down':
|
|
789
|
-
return { code: nodesCmds.nodesDown(common).code };
|
|
790
|
-
case 'restart':
|
|
791
|
-
return { code: nodesCmds.nodesRestart(common).code };
|
|
792
|
-
case 'set-token':
|
|
793
|
-
return { code: nodesCmds.nodesSetToken(common).code };
|
|
794
|
-
case 'test': {
|
|
795
|
-
// async: tieni vivo il processo finche' il probe non risolve, poi exit.
|
|
796
|
-
const exit = typeof opts.exit === 'function' ? opts.exit : ((c) => process.exit(c));
|
|
797
|
-
nodesCmds.nodesTest(common)
|
|
798
|
-
.then((r) => exit(r.code))
|
|
799
|
-
.catch((e) => { log(`nodes test: error — ${e && e.message ? e.message : e}`); exit(1); });
|
|
800
|
-
return { code: 0, keepAlive: true };
|
|
801
|
-
}
|
|
802
|
-
default:
|
|
803
|
-
log('usage: nexuscrew nodes add|list|remove|test|up|down|restart|set-token <name>');
|
|
804
|
-
return { code: 1 };
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// node on|off — ruolo "nodo raggiungibile" (reverse tunnel). §4.
|
|
809
|
-
function dispatchNode(rest, flags, opts) {
|
|
810
|
-
const log = opts.log;
|
|
811
|
-
const sub = rest[1];
|
|
812
|
-
if (sub === 'on') {
|
|
813
|
-
return { code: nodesCmds.nodeOn({
|
|
814
|
-
...opts, rendezvousSsh: flags.rendezvous,
|
|
815
|
-
publishedPort: flags['published-port'], key: flags.key,
|
|
816
|
-
}).code };
|
|
817
|
-
}
|
|
818
|
-
if (sub === 'off') {
|
|
819
|
-
return { code: nodesCmds.nodeOff({ ...opts }).code };
|
|
820
|
-
}
|
|
821
|
-
log('usage: nexuscrew node on|off');
|
|
822
|
-
return { code: 1 };
|
|
823
|
-
}
|
|
824
|
-
|
|
825
963
|
module.exports = {
|
|
826
964
|
dispatch, serve, start, stop, status, parseFlags, HELP,
|
|
827
965
|
smartUp, url, tokenRotate, logs, doctor, update, restart,
|
|
@@ -829,5 +967,7 @@ module.exports = {
|
|
|
829
967
|
servicePinsLegacyPort,
|
|
830
968
|
isServiceRunning, readRoles,
|
|
831
969
|
bootState, bootOn, bootOff, quickSummary,
|
|
970
|
+
stopManagedTunnels,
|
|
971
|
+
managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
|
|
832
972
|
runFleetBoot, dispatchFleetBoot,
|
|
833
973
|
};
|