nothumanallowed 15.1.9 → 15.1.10
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/package.json +1 -1
- package/src/commands/ui.mjs +33 -2
- package/src/constants.mjs +1 -1
- package/src/server/index.mjs +29 -4
- package/src/server/routes/config.mjs +66 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.1.
|
|
3
|
+
"version": "15.1.10",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/ui.mjs
CHANGED
|
@@ -84,11 +84,42 @@ export async function cmdUI(args) {
|
|
|
84
84
|
}
|
|
85
85
|
} catch { /* port is free */ }
|
|
86
86
|
|
|
87
|
-
//
|
|
87
|
+
// Detect if we were spawned by the self-restart helper after an npm-update.
|
|
88
|
+
// The marker file is written by /api/npm-update right before exiting.
|
|
89
|
+
// This lets us:
|
|
90
|
+
// - confirm to the user that the upgrade landed (visible in the boot log)
|
|
91
|
+
// - guarantee the ops daemon (Telegram/Discord bot) is back up
|
|
92
|
+
let restartedFromUpdate = false;
|
|
93
|
+
try {
|
|
94
|
+
const fs = await import('fs');
|
|
95
|
+
const path = await import('path');
|
|
96
|
+
const os = await import('os');
|
|
97
|
+
const markerPath = path.join(os.default.homedir(), '.nha', 'last-restart.json');
|
|
98
|
+
if (fs.existsSync(markerPath)) {
|
|
99
|
+
const stat = fs.statSync(markerPath);
|
|
100
|
+
// Consider the marker fresh only if written within the last 5 minutes
|
|
101
|
+
if (Date.now() - stat.mtimeMs < 5 * 60_000) {
|
|
102
|
+
const meta = JSON.parse(fs.readFileSync(markerPath, 'utf-8'));
|
|
103
|
+
restartedFromUpdate = true;
|
|
104
|
+
console.log(` \x1b[0;32m↻\x1b[0m Restarted after update: v${meta.fromVersion} → v${meta.toVersion}`);
|
|
105
|
+
}
|
|
106
|
+
// Always clean up so we don't repeat the message on next manual restart
|
|
107
|
+
try { fs.unlinkSync(markerPath); } catch {}
|
|
108
|
+
}
|
|
109
|
+
} catch { /* non-fatal */ }
|
|
110
|
+
|
|
111
|
+
// Auto-start ops daemon (Telegram + cron) if not already running.
|
|
112
|
+
// After a self-restart this is what brings the Telegram bot back online —
|
|
113
|
+
// critical for VM users whose only control channel is Telegram.
|
|
88
114
|
if (!isRunning()) {
|
|
89
115
|
const result = startDaemon();
|
|
90
116
|
if (result.ok) {
|
|
91
|
-
console.log(` \x1b[0;32m✓\x1b[0m PAO daemon started (PID ${result.pid})`);
|
|
117
|
+
console.log(` \x1b[0;32m✓\x1b[0m PAO daemon ${restartedFromUpdate ? 'restarted' : 'started'} (PID ${result.pid})`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` \x1b[0;31m✗\x1b[0m PAO daemon failed to start: ${result.error || 'unknown'}`);
|
|
120
|
+
if (restartedFromUpdate) {
|
|
121
|
+
console.log(` Telegram/Discord bot may be offline. Run "nha ops start" manually.`);
|
|
122
|
+
}
|
|
92
123
|
}
|
|
93
124
|
}
|
|
94
125
|
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '15.1.
|
|
8
|
+
export const VERSION = '15.1.10';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
package/src/server/index.mjs
CHANGED
|
@@ -376,15 +376,40 @@ export async function startServer({ port = 3847, host = '127.0.0.1', noBrowser =
|
|
|
376
376
|
|
|
377
377
|
const server = http.createServer(handleRequest);
|
|
378
378
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
379
|
+
// Retry the bind for up to 12 s on EADDRINUSE. The classic case is a fresh
|
|
380
|
+
// self-restart right after the previous process died: the listen socket can
|
|
381
|
+
// be stuck in TIME_WAIT (no PID owns it, so the lsof-kill above can't help).
|
|
382
|
+
// Linear backoff 600 ms × 20 = 12 s — more than enough on every kernel I've
|
|
383
|
+
// seen for a localhost listen socket to clear.
|
|
384
|
+
const tryListen = () => new Promise((resolve, reject) => {
|
|
385
|
+
const onErr = (err) => reject(err);
|
|
386
|
+
server.once('error', onErr);
|
|
382
387
|
server.listen(port, host, () => {
|
|
383
|
-
server.removeListener('error',
|
|
388
|
+
server.removeListener('error', onErr);
|
|
384
389
|
resolve(undefined);
|
|
385
390
|
});
|
|
386
391
|
});
|
|
387
392
|
|
|
393
|
+
let listenAttempts = 0;
|
|
394
|
+
const MAX_LISTEN_ATTEMPTS = 20;
|
|
395
|
+
while (true) {
|
|
396
|
+
try {
|
|
397
|
+
await tryListen();
|
|
398
|
+
break;
|
|
399
|
+
} catch (err) {
|
|
400
|
+
listenAttempts++;
|
|
401
|
+
const isAddrInUse = err && (err.code === 'EADDRINUSE' || err.code === 'EACCES');
|
|
402
|
+
if (!isAddrInUse || listenAttempts >= MAX_LISTEN_ATTEMPTS) {
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
// Brief notice every 3 s so the user knows we're not stuck
|
|
406
|
+
if (listenAttempts % 5 === 0) {
|
|
407
|
+
console.log(`\x1b[33m ! Port ${port} still busy (TIME_WAIT?), retry ${listenAttempts}/${MAX_LISTEN_ATTEMPTS}...\x1b[0m`);
|
|
408
|
+
}
|
|
409
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
388
413
|
setupWebSocket(server);
|
|
389
414
|
|
|
390
415
|
const G = '\x1b[0;32m', NC = '\x1b[0m', D = '\x1b[2m', BOLD = '\x1b[1m', Y = '\x1b[33m', R = '\x1b[31m';
|
|
@@ -146,10 +146,17 @@ export function register(router) {
|
|
|
146
146
|
}
|
|
147
147
|
} catch { /* ignore */ }
|
|
148
148
|
|
|
149
|
-
//
|
|
149
|
+
// Remember if the ops daemon (Telegram/Discord bot) was running so the
|
|
150
|
+
// newly-spawned server can restart it. Without this, a VM user who
|
|
151
|
+
// controls everything via Telegram would lose the bot until they ran
|
|
152
|
+
// `nha ops start` manually.
|
|
153
|
+
let daemonWasRunning = false;
|
|
150
154
|
try {
|
|
151
155
|
const { isRunning, stopDaemon } = await import('../../services/ops-daemon.mjs');
|
|
152
|
-
if (isRunning())
|
|
156
|
+
if (isRunning()) {
|
|
157
|
+
daemonWasRunning = true;
|
|
158
|
+
await stopDaemon();
|
|
159
|
+
}
|
|
153
160
|
} catch { /* ignore */ }
|
|
154
161
|
|
|
155
162
|
// Send the response NOW, before we kick off the restart. The client
|
|
@@ -166,62 +173,87 @@ export function register(router) {
|
|
|
166
173
|
});
|
|
167
174
|
|
|
168
175
|
// ── Schedule the self-restart ──
|
|
169
|
-
// We do this OUTSIDE the response cycle: a detached child that survives
|
|
170
|
-
// our death, watches our PID, then re-spawns with the same argv.
|
|
171
|
-
// On POSIX we use `sh -c`. On Windows we use a detached spawn directly
|
|
172
|
-
// because cmd.exe handles the "wait for PID" idiom differently.
|
|
173
176
|
const parentPid = process.pid;
|
|
174
|
-
const respawnNode = process.execPath;
|
|
175
|
-
const respawnArgs = process.argv.slice(1);
|
|
176
|
-
const
|
|
177
|
+
const respawnNode = process.execPath;
|
|
178
|
+
const respawnArgs = process.argv.slice(1);
|
|
179
|
+
const home = os.homedir();
|
|
180
|
+
const nhaDir = path.join(home, '.nha');
|
|
181
|
+
if (!fs.existsSync(nhaDir)) fs.mkdirSync(nhaDir, { recursive: true });
|
|
182
|
+
|
|
183
|
+
// Write the restart marker BEFORE spawning the helper. The new server
|
|
184
|
+
// reads this on boot to know:
|
|
185
|
+
// - the update happened (so it can show "Updated to v…" on first load)
|
|
186
|
+
// - whether the ops daemon (Telegram bot) was running and must be restarted
|
|
187
|
+
const markerPath = path.join(nhaDir, 'last-restart.json');
|
|
188
|
+
try {
|
|
189
|
+
fs.writeFileSync(
|
|
190
|
+
markerPath,
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
at: new Date().toISOString(),
|
|
193
|
+
fromVersion: VERSION,
|
|
194
|
+
toVersion: newVersion,
|
|
195
|
+
daemonWasRunning,
|
|
196
|
+
parentPid,
|
|
197
|
+
}, null, 2),
|
|
198
|
+
);
|
|
199
|
+
} catch { /* non-fatal */ }
|
|
200
|
+
|
|
201
|
+
// Open log file for the detached child — if anything goes wrong on boot
|
|
202
|
+
// we want the user to be able to read why instead of staring at a black
|
|
203
|
+
// screen. Append mode so previous restart logs survive.
|
|
204
|
+
const logPath = path.join(nhaDir, 'server.log');
|
|
205
|
+
let logFd;
|
|
206
|
+
try {
|
|
207
|
+
logFd = fs.openSync(logPath, 'a');
|
|
208
|
+
fs.writeSync(logFd, `\n[${new Date().toISOString()}] === Restart helper spawning. Parent PID: ${parentPid}. Target version: ${newVersion} ===\n`);
|
|
209
|
+
} catch { /* will fall back to 'ignore' */ }
|
|
177
210
|
|
|
178
211
|
setTimeout(() => {
|
|
179
212
|
try {
|
|
180
213
|
if (process.platform === 'win32') {
|
|
181
|
-
// Windows:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
Start-
|
|
186
|
-
|
|
214
|
+
// Windows: PowerShell waits for the parent to die, frees the port,
|
|
215
|
+
// then starts node hidden. Output is redirected to server.log.
|
|
216
|
+
const psScript = [
|
|
217
|
+
`try { Wait-Process -Id ${parentPid} -Timeout 30 } catch {};`,
|
|
218
|
+
`Start-Sleep -Milliseconds 1500;`,
|
|
219
|
+
`$logPath = "${logPath.replace(/\\/g, '\\\\')}";`,
|
|
220
|
+
`Start-Process -FilePath "${respawnNode.replace(/\\/g, '\\\\')}"`,
|
|
221
|
+
` -ArgumentList ${JSON.stringify(respawnArgs).replace(/"/g, '\\"')}`,
|
|
222
|
+
` -RedirectStandardOutput $logPath -RedirectStandardError $logPath`,
|
|
223
|
+
` -WindowStyle Hidden`,
|
|
224
|
+
].join('').replace(/\s+/g, ' ').trim();
|
|
187
225
|
spawn('powershell.exe', ['-NoProfile', '-Command', psScript], {
|
|
188
226
|
detached: true,
|
|
189
227
|
stdio: 'ignore',
|
|
190
228
|
windowsHide: true,
|
|
191
229
|
}).unref();
|
|
192
230
|
} else {
|
|
193
|
-
// POSIX:
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
const
|
|
231
|
+
// POSIX: poll for parent death (max ~12s), then wait an extra 2s
|
|
232
|
+
// for TIME_WAIT to clear on the listen socket, then exec node
|
|
233
|
+
// with stdio redirected to the log file.
|
|
234
|
+
const escArg = a => `'${a.replace(/'/g, `'\\''`)}'`;
|
|
235
|
+
const escapedArgs = respawnArgs.map(escArg).join(' ');
|
|
236
|
+
const escapedNode = escArg(respawnNode);
|
|
237
|
+
const escapedLog = escArg(logPath);
|
|
197
238
|
const sh = `
|
|
198
239
|
i=0
|
|
199
240
|
while kill -0 ${parentPid} 2>/dev/null && [ $i -lt 60 ]; do
|
|
200
241
|
sleep 0.2
|
|
201
242
|
i=$((i+1))
|
|
202
243
|
done
|
|
203
|
-
|
|
204
|
-
|
|
244
|
+
# Extra wait: lets TIME_WAIT on the listen socket release and
|
|
245
|
+
# gives the kernel a moment to fully reap the parent.
|
|
246
|
+
sleep 2
|
|
247
|
+
exec ${escapedNode} ${escapedArgs} >> ${escapedLog} 2>&1
|
|
205
248
|
`;
|
|
249
|
+
const childStdio = logFd ? ['ignore', logFd, logFd] : 'ignore';
|
|
206
250
|
spawn('sh', ['-c', sh], {
|
|
207
251
|
detached: true,
|
|
208
|
-
stdio:
|
|
252
|
+
stdio: childStdio,
|
|
209
253
|
}).unref();
|
|
210
254
|
}
|
|
211
|
-
|
|
212
|
-
// Mark the restart so the next process knows it was self-spawned
|
|
213
|
-
try {
|
|
214
|
-
const home = os.homedir();
|
|
215
|
-
const nhaDir = path.join(home, '.nha');
|
|
216
|
-
if (!fs.existsSync(nhaDir)) fs.mkdirSync(nhaDir, { recursive: true });
|
|
217
|
-
fs.writeFileSync(
|
|
218
|
-
path.join(nhaDir, 'last-restart.json'),
|
|
219
|
-
JSON.stringify({ at: new Date().toISOString(), fromVersion: VERSION, toVersion: newVersion, meta: argvJson }, null, 2),
|
|
220
|
-
);
|
|
221
|
-
} catch { /* non-fatal */ }
|
|
222
255
|
} catch { /* spawn failed — user will need to restart manually */ }
|
|
223
256
|
|
|
224
|
-
// Give the response a moment, then exit so the helper can take over.
|
|
225
257
|
setTimeout(() => process.exit(0), 200);
|
|
226
258
|
}, 600);
|
|
227
259
|
} catch (e) {
|