@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.
@@ -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-B9RXe5E4.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-C-JOkuIc.css">
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.16"}
1
+ {"version":"0.8.18"}
@@ -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 per-platform. execImpl/spawnImpl per test.
85
- function start(opts = {}) {
86
- const platform = opts.platform || detectPlatform();
87
- const execImpl = opts.execImpl || execFileSync;
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();
@@ -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
+ };
@@ -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
  `;