@mmmbuto/nexuscrew 0.8.16 → 0.8.18
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 +285 -407
- package/frontend/dist/assets/index-BkAGUEmR.css +32 -0
- package/frontend/dist/assets/index-CyN8rOAl.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -269
- package/lib/cli/fleet-service.js +4 -0
- package/lib/cli/runtime-lifecycle.js +299 -0
- package/lib/cli/service.js +4 -1
- package/lib/fleet/builtin.js +56 -434
- package/lib/fleet/launch.js +227 -0
- package/lib/fleet/runtime.js +269 -0
- package/lib/mcp/cells.js +154 -0
- package/lib/mcp/server.js +11 -422
- package/lib/mcp/tools.js +300 -0
- package/lib/runtime/env.js +38 -1
- package/lib/settings/pairing-coordinator.js +325 -0
- package/lib/settings/public-peering-routes.js +126 -0
- package/lib/settings/routes.js +16 -396
- package/package.json +1 -1
- package/frontend/dist/assets/index-B9RXe5E4.js +0 -91
- package/frontend/dist/assets/index-C-JOkuIc.css +0 -32
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-CyN8rOAl.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BkAGUEmR.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.18"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -17,6 +17,13 @@ const urlmod = require('./url.js');
|
|
|
17
17
|
const { doctor } = require('./doctor.js');
|
|
18
18
|
const nodesStore = require('../nodes/store.js');
|
|
19
19
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
20
|
+
// Service/portable runtime ownership + start/stop lifecycle (Phase 3 extraction).
|
|
21
|
+
const runtimeLifecycle = require('./runtime-lifecycle.js');
|
|
22
|
+
const {
|
|
23
|
+
start, stop, isServiceRunning, bootState, stopManagedTunnels,
|
|
24
|
+
managedRuntimeState, portableRuntimeState, resolveRuntimeOwner,
|
|
25
|
+
refusePairedPortRelocation, stopPortableRuntime, unlinkRegular,
|
|
26
|
+
} = runtimeLifecycle;
|
|
20
27
|
|
|
21
28
|
const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
22
29
|
|
|
@@ -81,275 +88,10 @@ function serve(opts = {}) {
|
|
|
81
88
|
serverStart(opts);
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
// start
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const spawnImpl = opts.spawnImpl || spawn;
|
|
89
|
-
const log = opts.log || console.log;
|
|
90
|
-
|
|
91
|
-
if (platform === 'linux') {
|
|
92
|
-
execImpl('systemctl', ['--user', 'start', 'nexuscrew'], { stdio: 'ignore' });
|
|
93
|
-
log('start: systemctl --user start nexuscrew');
|
|
94
|
-
return { platform, started: true };
|
|
95
|
-
}
|
|
96
|
-
if (platform === 'mac') {
|
|
97
|
-
const domain = `gui/${opts.uid || uid()}`;
|
|
98
|
-
const label = `${domain}/com.mmmbuto.nexuscrew`;
|
|
99
|
-
try {
|
|
100
|
-
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
101
|
-
} catch (_) {
|
|
102
|
-
try {
|
|
103
|
-
const home = opts.home || require('node:os').homedir();
|
|
104
|
-
const plist = opts.installPath || serviceInstallPath('mac', home);
|
|
105
|
-
execImpl('launchctl', ['bootstrap', domain, plist], { stdio: 'ignore' });
|
|
106
|
-
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
107
|
-
} catch (e) {
|
|
108
|
-
const reason = String(e.message || e);
|
|
109
|
-
log(`start: launchctl fallito: ${reason}`);
|
|
110
|
-
return { platform, started: false, reason };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
log(`start: launchctl kickstart ${label}`);
|
|
114
|
-
return { platform, started: true };
|
|
115
|
-
}
|
|
116
|
-
if (platform === 'termux') {
|
|
117
|
-
const home = opts.home || require('node:os').homedir();
|
|
118
|
-
// already-running?
|
|
119
|
-
const pidPath = pidf.defaultPidfilePath(opts.home);
|
|
120
|
-
const meta = pidf.readPidfile(pidPath);
|
|
121
|
-
if (meta && pidf.isAlive(meta)) {
|
|
122
|
-
log(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
|
|
123
|
-
return { platform, started: false, reason: 'already running' };
|
|
124
|
-
}
|
|
125
|
-
pidf.cleanStale(pidPath);
|
|
126
|
-
const repoBin = path.join(repoRoot(), 'bin', 'nexuscrew.js');
|
|
127
|
-
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
128
|
-
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
129
|
-
const logFd = fs.openSync(logPath, 'a');
|
|
130
|
-
const resolved = urlmod.resolvePaths(opts);
|
|
131
|
-
const childEnv = {
|
|
132
|
-
...process.env,
|
|
133
|
-
HOME: home,
|
|
134
|
-
NEXUSCREW_CONFIG_FILE: resolved.configPath,
|
|
135
|
-
NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
|
|
136
|
-
};
|
|
137
|
-
// config.json is authoritative after automatic port fallback.
|
|
138
|
-
delete childEnv.NEXUSCREW_PORT;
|
|
139
|
-
let child;
|
|
140
|
-
try {
|
|
141
|
-
child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
|
|
142
|
-
detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
|
|
143
|
-
});
|
|
144
|
-
} finally { try { fs.closeSync(logFd); } catch (_) {} }
|
|
145
|
-
if (child && typeof child.unref === 'function') child.unref();
|
|
146
|
-
log(`start: nohup ${nodeBin()} ${repoBin} serve --pidfile (>> ${logPath})`);
|
|
147
|
-
return { platform, started: true, pid: child && child.pid };
|
|
148
|
-
}
|
|
149
|
-
throw new Error(`start: platform ${platform} non supportata`);
|
|
150
|
-
}
|
|
151
|
-
|
|
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
|
-
// Also recover supervisors whose node was already removed by an older or
|
|
166
|
-
// interrupted runtime. The pidfile verifier prevents broad process kills.
|
|
167
|
-
const recovered = nodesTunnel.reconcileTunnelSupervisors({ home, configuredNames: [] });
|
|
168
|
-
for (const name of [...recovered.stopped, ...recovered.cleaned]) {
|
|
169
|
-
if (!stopped.includes(name)) stopped.push(name);
|
|
170
|
-
}
|
|
171
|
-
return stopped;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function refusePairedPortRelocation(opts, from, to) {
|
|
175
|
-
if (Number(from) === Number(to)) return;
|
|
176
|
-
const { configDir } = urlmod.resolvePaths(opts);
|
|
177
|
-
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
178
|
-
if (nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath))) {
|
|
179
|
-
throw new Error(`port ${from} is busy and paired peers exist; refusing automatic move to ${to}. Free the configured port or reconnect peers intentionally.`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Runtime ownership is independent from boot persistence. With boot disabled,
|
|
184
|
-
// smartUp intentionally launches `serve --pidfile` on Linux/macOS too; lifecycle
|
|
185
|
-
// commands must therefore inspect both the service manager and that portable
|
|
186
|
-
// pidfile instead of assuming the platform selects exactly one owner.
|
|
187
|
-
function managedRuntimeState(opts = {}) {
|
|
188
|
-
const platform = opts.platform || detectPlatform();
|
|
189
|
-
const execImpl = opts.execImpl || execFileSync;
|
|
190
|
-
if (platform === 'linux') {
|
|
191
|
-
try {
|
|
192
|
-
const service = String(execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }) || '').trim();
|
|
193
|
-
return { supported: true, running: service === 'active', service: service || 'inactive' };
|
|
194
|
-
} catch (_) { return { supported: true, running: false, service: 'inactive' }; }
|
|
195
|
-
}
|
|
196
|
-
if (platform === 'mac') {
|
|
197
|
-
const service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
198
|
-
try {
|
|
199
|
-
execImpl('launchctl', ['print', service], { stdio: 'ignore' });
|
|
200
|
-
return { supported: true, running: true, service };
|
|
201
|
-
} catch (_) { return { supported: true, running: false, service }; }
|
|
202
|
-
}
|
|
203
|
-
return { supported: false, running: false, service: 'portable-only' };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function portableRuntimeState(opts = {}) {
|
|
207
|
-
const home = opts.home || require('node:os').homedir();
|
|
208
|
-
const pidPath = pidf.defaultPidfilePath(home);
|
|
209
|
-
const meta = pidf.readPidfile(pidPath);
|
|
210
|
-
if (!meta) return { running: false, pidPath, reason: 'no pidfile' };
|
|
211
|
-
if (pidf.isAlive(meta)) return { running: true, pidPath, pid: meta.pid, meta };
|
|
212
|
-
pidf.cleanStale(pidPath);
|
|
213
|
-
return { running: false, pidPath, reason: 'stale pidfile' };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function resolveRuntimeOwner(opts = {}) {
|
|
217
|
-
const platform = opts.platform || detectPlatform();
|
|
218
|
-
const managed = managedRuntimeState({ ...opts, platform });
|
|
219
|
-
const portable = portableRuntimeState(opts);
|
|
220
|
-
let owner = 'stopped';
|
|
221
|
-
if (managed.running && portable.running) owner = 'conflict';
|
|
222
|
-
else if (managed.running) owner = 'managed';
|
|
223
|
-
else if (portable.running) owner = 'portable';
|
|
224
|
-
return {
|
|
225
|
-
platform, owner,
|
|
226
|
-
managedRunning: managed.running,
|
|
227
|
-
portableRunning: portable.running,
|
|
228
|
-
service: managed.service,
|
|
229
|
-
portablePid: portable.pid,
|
|
230
|
-
pidPath: portable.pidPath,
|
|
231
|
-
stalePidfile: portable.reason === 'stale pidfile',
|
|
232
|
-
portableReason: portable.reason,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function waitForPidExit(pid, timeoutMs = 2000) {
|
|
237
|
-
const deadline = Date.now() + timeoutMs;
|
|
238
|
-
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
239
|
-
const exited = () => {
|
|
240
|
-
if (!pidf.pidExists(pid)) return true;
|
|
241
|
-
// A test-owned child can remain as a zombie until this synchronous caller
|
|
242
|
-
// returns to the event loop. It no longer owns sockets and is exited for
|
|
243
|
-
// lifecycle purposes. Linux/Termux expose the state in /proc; macOS has no
|
|
244
|
-
// equivalent cheap path and normally removes a non-child immediately.
|
|
245
|
-
try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
|
|
246
|
-
catch (_) { return false; }
|
|
247
|
-
};
|
|
248
|
-
while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
|
|
249
|
-
return exited();
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function stopPortableRuntime(opts = {}) {
|
|
253
|
-
const home = opts.home || require('node:os').homedir();
|
|
254
|
-
const pidPath = pidf.defaultPidfilePath(home);
|
|
255
|
-
const result = pidf.killPidfile(pidPath);
|
|
256
|
-
if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
|
|
257
|
-
return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
|
|
258
|
-
}
|
|
259
|
-
return result;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function stop(opts = {}) {
|
|
263
|
-
const platform = opts.platform || detectPlatform();
|
|
264
|
-
const execImpl = opts.execImpl || execFileSync;
|
|
265
|
-
const log = opts.log || console.log;
|
|
266
|
-
if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`stop: platform ${platform} non supportata`);
|
|
267
|
-
|
|
268
|
-
const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
269
|
-
const stoppedOwners = [];
|
|
270
|
-
const errors = [];
|
|
271
|
-
|
|
272
|
-
// The shared tmux server can live in the NexusCrew systemd cgroup. If the
|
|
273
|
-
// effective KillMode cannot be proven safe, stop must not mutate *any*
|
|
274
|
-
// runtime owner or detached tunnel: callers can then fix the unit and retry
|
|
275
|
-
// without ending up in a partially stopped state.
|
|
276
|
-
if (before.managedRunning && platform === 'linux') {
|
|
277
|
-
try {
|
|
278
|
-
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
279
|
-
} catch (error) {
|
|
280
|
-
const reason = String(error.message || error);
|
|
281
|
-
log(`stop: annullato — protezione tmux non verificata: ${reason}`);
|
|
282
|
-
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (before.managedRunning) {
|
|
287
|
-
try {
|
|
288
|
-
if (platform === 'linux') {
|
|
289
|
-
execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
|
|
290
|
-
log('stop: systemctl --user stop nexuscrew');
|
|
291
|
-
} else {
|
|
292
|
-
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
293
|
-
// bootout unloads KeepAlive, so a voluntary stop stays stopped.
|
|
294
|
-
execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
|
|
295
|
-
log(`stop: launchctl bootout ${label}`);
|
|
296
|
-
}
|
|
297
|
-
stoppedOwners.push('managed');
|
|
298
|
-
} catch (error) { errors.push(String(error.message || error)); }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (before.portableRunning) {
|
|
302
|
-
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
303
|
-
if (portable.killed) {
|
|
304
|
-
stoppedOwners.push('portable');
|
|
305
|
-
log(`stop: killed portable pid ${portable.pid}`);
|
|
306
|
-
} else errors.push(portable.reason || 'portable stop failed');
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Detached tunnel supervisors are outside both server owners. A real stop
|
|
310
|
-
// closes them once; the next start restores only autostart:true links.
|
|
311
|
-
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
312
|
-
if (platform === 'termux') {
|
|
313
|
-
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (errors.length) {
|
|
317
|
-
const reason = errors.join('; ');
|
|
318
|
-
log(`stop: incompleto — ${reason}`);
|
|
319
|
-
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
320
|
-
}
|
|
321
|
-
const alreadyStopped = before.owner === 'stopped';
|
|
322
|
-
if (alreadyStopped) log(`stop: ${before.portableReason || 'already stopped'}`);
|
|
323
|
-
return {
|
|
324
|
-
platform, owner: before.owner, stopped: true, stoppedOwners, alreadyStopped,
|
|
325
|
-
reason: alreadyStopped ? (before.portableReason || 'already stopped') : undefined,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// running-check per-platform (no log) — riusato da smart-up / token rotate / update.
|
|
330
|
-
function isServiceRunning(opts = {}) {
|
|
331
|
-
return resolveRuntimeOwner(opts).owner !== 'stopped';
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function bootState(opts = {}) {
|
|
335
|
-
const platform = opts.platform || detectPlatform();
|
|
336
|
-
const execImpl = opts.execImpl || execFileSync;
|
|
337
|
-
const home = opts.home || require('node:os').homedir();
|
|
338
|
-
if (platform === 'linux') {
|
|
339
|
-
try { return { platform, enabled: execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'enabled' }; }
|
|
340
|
-
catch (_) { return { platform, enabled: false }; }
|
|
341
|
-
}
|
|
342
|
-
try { return { platform, enabled: fs.lstatSync(serviceInstallPath(platform, home)).isFile() }; }
|
|
343
|
-
catch (_) { return { platform, enabled: false }; }
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function unlinkRegular(target) {
|
|
347
|
-
try {
|
|
348
|
-
const st = fs.lstatSync(target);
|
|
349
|
-
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe boot target: ${target}`);
|
|
350
|
-
fs.unlinkSync(target); return true;
|
|
351
|
-
} catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
|
352
|
-
}
|
|
91
|
+
// start/stop/runtime-ownership lifecycle (start, stop, stopManagedTunnels,
|
|
92
|
+
// refusePairedPortRelocation, managedRuntimeState, portableRuntimeState,
|
|
93
|
+
// resolveRuntimeOwner, waitForPidExit, stopPortableRuntime, isServiceRunning,
|
|
94
|
+
// bootState, unlinkRegular) moved to ./runtime-lifecycle.js (Phase 3).
|
|
353
95
|
|
|
354
96
|
async function bootOn(opts = {}) {
|
|
355
97
|
const platform = opts.platform || detectPlatform();
|
package/lib/cli/fleet-service.js
CHANGED
|
@@ -117,6 +117,10 @@ function generateFleetTermux(opts) {
|
|
|
117
117
|
# NexusCrew fleet boot companion (Termux) - avvia le celle boot:true
|
|
118
118
|
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
119
119
|
export HOME=/data/data/com.termux/files/home
|
|
120
|
+
export PREFIX=/data/data/com.termux/files/usr
|
|
121
|
+
export TMPDIR=$PREFIX/tmp
|
|
122
|
+
export TMUX_TMPDIR=$PREFIX/var/run
|
|
123
|
+
mkdir -p "$TMPDIR" "$TMUX_TMPDIR"
|
|
120
124
|
cd -- ${repoQ}
|
|
121
125
|
exec ${nodeQ} ${entryQ} fleet-boot >> "$HOME/.nexuscrew/fleet-boot.log" 2>&1
|
|
122
126
|
`;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Service/portable runtime ownership and start/stop lifecycle. Extracted from
|
|
3
|
+
// commands.js (Phase 3 modularization) without behavior change: identical opts
|
|
4
|
+
// seams, return objects, log text, platform branches, tmux-survival fail-closed
|
|
5
|
+
// logic, tunnel cleanup and pidfile handling. No public CLI surface changes.
|
|
6
|
+
const { execFileSync, spawn } = require('node:child_process');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
10
|
+
const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
|
|
11
|
+
const pidf = require('./pidfile.js');
|
|
12
|
+
const urlmod = require('./url.js');
|
|
13
|
+
const nodesStore = require('../nodes/store.js');
|
|
14
|
+
const nodesTunnel = require('../nodes/tunnel.js');
|
|
15
|
+
|
|
16
|
+
// start per-platform. execImpl/spawnImpl per test.
|
|
17
|
+
function start(opts = {}) {
|
|
18
|
+
const platform = opts.platform || detectPlatform();
|
|
19
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
20
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
21
|
+
const log = opts.log || console.log;
|
|
22
|
+
|
|
23
|
+
if (platform === 'linux') {
|
|
24
|
+
execImpl('systemctl', ['--user', 'start', 'nexuscrew'], { stdio: 'ignore' });
|
|
25
|
+
log('start: systemctl --user start nexuscrew');
|
|
26
|
+
return { platform, started: true };
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'mac') {
|
|
29
|
+
const domain = `gui/${opts.uid || uid()}`;
|
|
30
|
+
const label = `${domain}/com.mmmbuto.nexuscrew`;
|
|
31
|
+
try {
|
|
32
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
33
|
+
} catch (_) {
|
|
34
|
+
try {
|
|
35
|
+
const home = opts.home || require('node:os').homedir();
|
|
36
|
+
const plist = opts.installPath || serviceInstallPath('mac', home);
|
|
37
|
+
execImpl('launchctl', ['bootstrap', domain, plist], { stdio: 'ignore' });
|
|
38
|
+
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
const reason = String(e.message || e);
|
|
41
|
+
log(`start: launchctl fallito: ${reason}`);
|
|
42
|
+
return { platform, started: false, reason };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
log(`start: launchctl kickstart ${label}`);
|
|
46
|
+
return { platform, started: true };
|
|
47
|
+
}
|
|
48
|
+
if (platform === 'termux') {
|
|
49
|
+
const home = opts.home || require('node:os').homedir();
|
|
50
|
+
// already-running?
|
|
51
|
+
const pidPath = pidf.defaultPidfilePath(opts.home);
|
|
52
|
+
const meta = pidf.readPidfile(pidPath);
|
|
53
|
+
if (meta && pidf.isAlive(meta)) {
|
|
54
|
+
log(`already running (pid ${meta.pid}) — pidfile ${pidPath}`);
|
|
55
|
+
return { platform, started: false, reason: 'already running' };
|
|
56
|
+
}
|
|
57
|
+
pidf.cleanStale(pidPath);
|
|
58
|
+
const repoBin = path.join(repoRoot(), 'bin', 'nexuscrew.js');
|
|
59
|
+
const logPath = path.join(home, '.nexuscrew', 'nexuscrew.log');
|
|
60
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
61
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
62
|
+
const resolved = urlmod.resolvePaths(opts);
|
|
63
|
+
const childEnv = {
|
|
64
|
+
...process.env,
|
|
65
|
+
HOME: home,
|
|
66
|
+
NEXUSCREW_CONFIG_FILE: resolved.configPath,
|
|
67
|
+
NEXUSCREW_TOKEN_FILE: resolved.tokenPath,
|
|
68
|
+
};
|
|
69
|
+
// config.json is authoritative after automatic port fallback.
|
|
70
|
+
delete childEnv.NEXUSCREW_PORT;
|
|
71
|
+
let child;
|
|
72
|
+
try {
|
|
73
|
+
child = spawnImpl(nodeBin(), [repoBin, 'serve', '--pidfile'], {
|
|
74
|
+
detached: true, stdio: ['ignore', logFd, logFd], env: childEnv,
|
|
75
|
+
});
|
|
76
|
+
} finally { try { fs.closeSync(logFd); } catch (_) {} }
|
|
77
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
78
|
+
log(`start: nohup ${nodeBin()} ${repoBin} serve --pidfile (>> ${logPath})`);
|
|
79
|
+
return { platform, started: true, pid: child && child.pid };
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`start: platform ${platform} non supportata`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stopManagedTunnels(opts = {}) {
|
|
85
|
+
const home = opts.home || require('node:os').homedir();
|
|
86
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
87
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
88
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
89
|
+
const stopped = [];
|
|
90
|
+
for (const node of (st && st.nodes) || []) {
|
|
91
|
+
if (node.direction === 'inbound') continue;
|
|
92
|
+
try { nodesTunnel.stopTunnel({ home, name: node.name }); stopped.push(node.name); } catch (_) {}
|
|
93
|
+
}
|
|
94
|
+
if (st && st.rendezvous) {
|
|
95
|
+
try { nodesTunnel.stopTunnel({ home, name: nodesTunnel.REVERSE_NAME }); stopped.push(nodesTunnel.REVERSE_NAME); } catch (_) {}
|
|
96
|
+
}
|
|
97
|
+
// Also recover supervisors whose node was already removed by an older or
|
|
98
|
+
// interrupted runtime. The pidfile verifier prevents broad process kills.
|
|
99
|
+
const recovered = nodesTunnel.reconcileTunnelSupervisors({ home, configuredNames: [] });
|
|
100
|
+
for (const name of [...recovered.stopped, ...recovered.cleaned]) {
|
|
101
|
+
if (!stopped.includes(name)) stopped.push(name);
|
|
102
|
+
}
|
|
103
|
+
return stopped;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function refusePairedPortRelocation(opts, from, to) {
|
|
107
|
+
if (Number(from) === Number(to)) return;
|
|
108
|
+
const { configDir } = urlmod.resolvePaths(opts);
|
|
109
|
+
const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
|
|
110
|
+
if (nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath))) {
|
|
111
|
+
throw new Error(`port ${from} is busy and paired peers exist; refusing automatic move to ${to}. Free the configured port or reconnect peers intentionally.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Runtime ownership is independent from boot persistence. With boot disabled,
|
|
116
|
+
// smartUp intentionally launches `serve --pidfile` on Linux/macOS too; lifecycle
|
|
117
|
+
// commands must therefore inspect both the service manager and that portable
|
|
118
|
+
// pidfile instead of assuming the platform selects exactly one owner.
|
|
119
|
+
function managedRuntimeState(opts = {}) {
|
|
120
|
+
const platform = opts.platform || detectPlatform();
|
|
121
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
122
|
+
if (platform === 'linux') {
|
|
123
|
+
try {
|
|
124
|
+
const service = String(execImpl('systemctl', ['--user', 'is-active', 'nexuscrew'], { encoding: 'utf8' }) || '').trim();
|
|
125
|
+
return { supported: true, running: service === 'active', service: service || 'inactive' };
|
|
126
|
+
} catch (_) { return { supported: true, running: false, service: 'inactive' }; }
|
|
127
|
+
}
|
|
128
|
+
if (platform === 'mac') {
|
|
129
|
+
const service = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
130
|
+
try {
|
|
131
|
+
execImpl('launchctl', ['print', service], { stdio: 'ignore' });
|
|
132
|
+
return { supported: true, running: true, service };
|
|
133
|
+
} catch (_) { return { supported: true, running: false, service }; }
|
|
134
|
+
}
|
|
135
|
+
return { supported: false, running: false, service: 'portable-only' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function portableRuntimeState(opts = {}) {
|
|
139
|
+
const home = opts.home || require('node:os').homedir();
|
|
140
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
141
|
+
const meta = pidf.readPidfile(pidPath);
|
|
142
|
+
if (!meta) return { running: false, pidPath, reason: 'no pidfile' };
|
|
143
|
+
if (pidf.isAlive(meta)) return { running: true, pidPath, pid: meta.pid, meta };
|
|
144
|
+
pidf.cleanStale(pidPath);
|
|
145
|
+
return { running: false, pidPath, reason: 'stale pidfile' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveRuntimeOwner(opts = {}) {
|
|
149
|
+
const platform = opts.platform || detectPlatform();
|
|
150
|
+
const managed = managedRuntimeState({ ...opts, platform });
|
|
151
|
+
const portable = portableRuntimeState(opts);
|
|
152
|
+
let owner = 'stopped';
|
|
153
|
+
if (managed.running && portable.running) owner = 'conflict';
|
|
154
|
+
else if (managed.running) owner = 'managed';
|
|
155
|
+
else if (portable.running) owner = 'portable';
|
|
156
|
+
return {
|
|
157
|
+
platform, owner,
|
|
158
|
+
managedRunning: managed.running,
|
|
159
|
+
portableRunning: portable.running,
|
|
160
|
+
service: managed.service,
|
|
161
|
+
portablePid: portable.pid,
|
|
162
|
+
pidPath: portable.pidPath,
|
|
163
|
+
stalePidfile: portable.reason === 'stale pidfile',
|
|
164
|
+
portableReason: portable.reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function waitForPidExit(pid, timeoutMs = 2000) {
|
|
169
|
+
const deadline = Date.now() + timeoutMs;
|
|
170
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
171
|
+
const exited = () => {
|
|
172
|
+
if (!pidf.pidExists(pid)) return true;
|
|
173
|
+
// A test-owned child can remain as a zombie until this synchronous caller
|
|
174
|
+
// returns to the event loop. It no longer owns sockets and is exited for
|
|
175
|
+
// lifecycle purposes. Linux/Termux expose the state in /proc; macOS has no
|
|
176
|
+
// equivalent cheap path and normally removes a non-child immediately.
|
|
177
|
+
try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
|
|
178
|
+
catch (_) { return false; }
|
|
179
|
+
};
|
|
180
|
+
while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
|
|
181
|
+
return exited();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stopPortableRuntime(opts = {}) {
|
|
185
|
+
const home = opts.home || require('node:os').homedir();
|
|
186
|
+
const pidPath = pidf.defaultPidfilePath(home);
|
|
187
|
+
const result = pidf.killPidfile(pidPath);
|
|
188
|
+
if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
|
|
189
|
+
return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stop(opts = {}) {
|
|
195
|
+
const platform = opts.platform || detectPlatform();
|
|
196
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
197
|
+
const log = opts.log || console.log;
|
|
198
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`stop: platform ${platform} non supportata`);
|
|
199
|
+
|
|
200
|
+
const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
201
|
+
const stoppedOwners = [];
|
|
202
|
+
const errors = [];
|
|
203
|
+
|
|
204
|
+
// The shared tmux server can live in the NexusCrew systemd cgroup. If the
|
|
205
|
+
// effective KillMode cannot be proven safe, stop must not mutate *any*
|
|
206
|
+
// runtime owner or detached tunnel: callers can then fix the unit and retry
|
|
207
|
+
// without ending up in a partially stopped state.
|
|
208
|
+
if (before.managedRunning && platform === 'linux') {
|
|
209
|
+
try {
|
|
210
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const reason = String(error.message || error);
|
|
213
|
+
log(`stop: annullato — protezione tmux non verificata: ${reason}`);
|
|
214
|
+
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (before.managedRunning) {
|
|
219
|
+
try {
|
|
220
|
+
if (platform === 'linux') {
|
|
221
|
+
execImpl('systemctl', ['--user', 'stop', 'nexuscrew'], { stdio: 'ignore' });
|
|
222
|
+
log('stop: systemctl --user stop nexuscrew');
|
|
223
|
+
} else {
|
|
224
|
+
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
225
|
+
// bootout unloads KeepAlive, so a voluntary stop stays stopped.
|
|
226
|
+
execImpl('launchctl', ['bootout', label], { stdio: 'ignore' });
|
|
227
|
+
log(`stop: launchctl bootout ${label}`);
|
|
228
|
+
}
|
|
229
|
+
stoppedOwners.push('managed');
|
|
230
|
+
} catch (error) { errors.push(String(error.message || error)); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (before.portableRunning) {
|
|
234
|
+
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
235
|
+
if (portable.killed) {
|
|
236
|
+
stoppedOwners.push('portable');
|
|
237
|
+
log(`stop: killed portable pid ${portable.pid}`);
|
|
238
|
+
} else errors.push(portable.reason || 'portable stop failed');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Detached tunnel supervisors are outside both server owners. A real stop
|
|
242
|
+
// closes them once; the next start restores only autostart:true links.
|
|
243
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
244
|
+
if (platform === 'termux') {
|
|
245
|
+
try { execImpl('termux-wake-lock-release', [], { stdio: 'ignore' }); } catch (_) {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (errors.length) {
|
|
249
|
+
const reason = errors.join('; ');
|
|
250
|
+
log(`stop: incompleto — ${reason}`);
|
|
251
|
+
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
252
|
+
}
|
|
253
|
+
const alreadyStopped = before.owner === 'stopped';
|
|
254
|
+
if (alreadyStopped) log(`stop: ${before.portableReason || 'already stopped'}`);
|
|
255
|
+
return {
|
|
256
|
+
platform, owner: before.owner, stopped: true, stoppedOwners, alreadyStopped,
|
|
257
|
+
reason: alreadyStopped ? (before.portableReason || 'already stopped') : undefined,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// running-check per-platform (no log) — riusato da smart-up / token rotate / update.
|
|
262
|
+
function isServiceRunning(opts = {}) {
|
|
263
|
+
return resolveRuntimeOwner(opts).owner !== 'stopped';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function bootState(opts = {}) {
|
|
267
|
+
const platform = opts.platform || detectPlatform();
|
|
268
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
269
|
+
const home = opts.home || require('node:os').homedir();
|
|
270
|
+
if (platform === 'linux') {
|
|
271
|
+
try { return { platform, enabled: execImpl('systemctl', ['--user', 'is-enabled', 'nexuscrew'], { encoding: 'utf8' }).trim() === 'enabled' }; }
|
|
272
|
+
catch (_) { return { platform, enabled: false }; }
|
|
273
|
+
}
|
|
274
|
+
try { return { platform, enabled: fs.lstatSync(serviceInstallPath(platform, home)).isFile() }; }
|
|
275
|
+
catch (_) { return { platform, enabled: false }; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function unlinkRegular(target) {
|
|
279
|
+
try {
|
|
280
|
+
const st = fs.lstatSync(target);
|
|
281
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe boot target: ${target}`);
|
|
282
|
+
fs.unlinkSync(target); return true;
|
|
283
|
+
} catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
start,
|
|
288
|
+
stopManagedTunnels,
|
|
289
|
+
refusePairedPortRelocation,
|
|
290
|
+
managedRuntimeState,
|
|
291
|
+
portableRuntimeState,
|
|
292
|
+
resolveRuntimeOwner,
|
|
293
|
+
waitForPidExit,
|
|
294
|
+
stopPortableRuntime,
|
|
295
|
+
stop,
|
|
296
|
+
isServiceRunning,
|
|
297
|
+
bootState,
|
|
298
|
+
unlinkRegular,
|
|
299
|
+
};
|
package/lib/cli/service.js
CHANGED
|
@@ -142,10 +142,13 @@ function generateTermux(ctx) {
|
|
|
142
142
|
# NexusCrew boot (Termux) - loopback, localhost del telefono
|
|
143
143
|
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
144
144
|
export HOME=/data/data/com.termux/files/home
|
|
145
|
+
export PREFIX=/data/data/com.termux/files/usr
|
|
146
|
+
export TMPDIR=$PREFIX/tmp
|
|
147
|
+
export TMUX_TMPDIR=$PREFIX/var/run
|
|
145
148
|
export LANG=\${LANG:-en_US.UTF-8}
|
|
146
149
|
export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
|
|
147
150
|
termux-wake-lock 2>/dev/null || true
|
|
148
|
-
mkdir -p "$HOME/.nexuscrew"
|
|
151
|
+
mkdir -p "$HOME/.nexuscrew" "$TMPDIR" "$TMUX_TMPDIR"
|
|
149
152
|
cd -- "$HOME/.nexuscrew"
|
|
150
153
|
exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
|
|
151
154
|
`;
|