nothumanallowed 15.1.8 → 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 +124 -22
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';
|
|
@@ -88,80 +88,182 @@ export function register(router) {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
// POST /api/npm-update
|
|
91
|
+
//
|
|
92
|
+
// Two-phase update:
|
|
93
|
+
// 1. Sync part: run `npm install -g nothumanallowed@latest --prefer-online`
|
|
94
|
+
// with cache bypass. Bypass is critical — without --prefer-online, npm
|
|
95
|
+
// can install the same version it had cached locally and the user sees
|
|
96
|
+
// "nothing changed" (the classic stale-manifest trap).
|
|
97
|
+
// 2. Async part: spawn a DETACHED helper that watches our PID, waits until
|
|
98
|
+
// we exit, sleeps 1 s so the port frees, then re-spawns this process
|
|
99
|
+
// with the SAME argv. The newly installed code runs because node will
|
|
100
|
+
// re-read the entry script from disk on the next invocation.
|
|
101
|
+
//
|
|
102
|
+
// The response is sent BEFORE the parent exits, so the client knows what
|
|
103
|
+
// version to expect and can poll /api/health for the restart to complete.
|
|
91
104
|
router.post('/api/npm-update', async (req, res) => {
|
|
92
105
|
try {
|
|
93
|
-
const { exec } = await import('child_process');
|
|
106
|
+
const { exec, spawn } = await import('child_process');
|
|
94
107
|
const { promisify } = await import('util');
|
|
95
108
|
const execAsync = promisify(exec);
|
|
96
109
|
|
|
97
|
-
//
|
|
98
|
-
let cmd = 'npm install -g nothumanallowed@latest';
|
|
99
|
-
|
|
100
|
-
// On macOS/Linux, check if we need special handling for permission
|
|
110
|
+
// Permission preflight on POSIX
|
|
101
111
|
if (process.platform !== 'win32') {
|
|
102
112
|
try {
|
|
103
|
-
// Check if npm global dir is writable by current user
|
|
104
113
|
const { stdout: prefix } = await execAsync('npm config get prefix', { timeout: 5000 });
|
|
105
114
|
const globalDir = prefix.trim();
|
|
106
115
|
await fs.promises.access(path.join(globalDir, 'lib'), fs.constants.W_OK);
|
|
107
116
|
} catch {
|
|
108
|
-
// Global dir not writable — return error with clear instructions
|
|
109
117
|
sendJSON(res, 200, {
|
|
110
118
|
success: false,
|
|
111
119
|
error: 'EACCES: permission denied',
|
|
112
|
-
message: 'Permission denied. Run: sudo npm install -g nothumanallowed@latest'
|
|
120
|
+
message: 'Permission denied. Run: sudo npm install -g nothumanallowed@latest --prefer-online',
|
|
113
121
|
});
|
|
114
122
|
return;
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
// Clean the metadata cache first — defeats the stale-cache trap that
|
|
127
|
+
// makes `npm install -g <pkg>` silently install an older version.
|
|
128
|
+
try { await execAsync('npm cache clean --force', { timeout: 10_000 }); } catch { /* non-fatal */ }
|
|
129
|
+
|
|
130
|
+
const cmd = 'npm install -g nothumanallowed@latest --registry=https://registry.npmjs.org/ --prefer-online --no-fund --no-audit';
|
|
118
131
|
const { stdout, stderr } = await execAsync(cmd, {
|
|
119
132
|
timeout: 120_000,
|
|
120
133
|
env: { ...process.env, NODE_ENV: 'production' },
|
|
121
134
|
});
|
|
122
135
|
|
|
123
|
-
//
|
|
136
|
+
// Resolve the version that actually landed on disk
|
|
124
137
|
let newVersion = '';
|
|
125
138
|
try {
|
|
126
|
-
// Extract version from npm install output (e.g. "+ nothumanallowed@14.4.18")
|
|
127
139
|
const verMatch = stdout.match(/nothumanallowed@(\d+\.\d+\.\d+)/);
|
|
128
140
|
if (verMatch) {
|
|
129
141
|
newVersion = verMatch[1];
|
|
130
142
|
} else {
|
|
131
|
-
// Fallback: check registry
|
|
132
143
|
const regResp = await fetch('https://registry.npmjs.org/nothumanallowed/latest');
|
|
133
144
|
const regData = await regResp.json();
|
|
134
145
|
newVersion = regData.version || '';
|
|
135
146
|
}
|
|
136
147
|
} catch { /* ignore */ }
|
|
137
148
|
|
|
138
|
-
//
|
|
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;
|
|
139
154
|
try {
|
|
140
155
|
const { isRunning, stopDaemon } = await import('../../services/ops-daemon.mjs');
|
|
141
156
|
if (isRunning()) {
|
|
157
|
+
daemonWasRunning = true;
|
|
142
158
|
await stopDaemon();
|
|
143
|
-
const { execSync: ex2 } = await import('child_process');
|
|
144
|
-
try { ex2('nha ops start', { timeout: 10_000, stdio: 'ignore' }); } catch {}
|
|
145
159
|
}
|
|
146
|
-
} catch {}
|
|
160
|
+
} catch { /* ignore */ }
|
|
147
161
|
|
|
162
|
+
// Send the response NOW, before we kick off the restart. The client
|
|
163
|
+
// needs this payload to know what version to wait for during polling.
|
|
148
164
|
sendJSON(res, 200, {
|
|
149
165
|
success: true,
|
|
150
|
-
message: newVersion ? `Updated to v${newVersion}
|
|
166
|
+
message: newVersion ? `Updated to v${newVersion}. Restarting server...` : 'Update completed. Restarting...',
|
|
151
167
|
newVersion,
|
|
152
|
-
restart:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
restart: true,
|
|
169
|
+
restarting: true,
|
|
170
|
+
needsManualRestart: false,
|
|
171
|
+
stdout: stdout.trim().slice(-2000),
|
|
172
|
+
stderr: stderr.trim().slice(-2000),
|
|
156
173
|
});
|
|
174
|
+
|
|
175
|
+
// ── Schedule the self-restart ──
|
|
176
|
+
const parentPid = process.pid;
|
|
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' */ }
|
|
210
|
+
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
try {
|
|
213
|
+
if (process.platform === 'win32') {
|
|
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();
|
|
225
|
+
spawn('powershell.exe', ['-NoProfile', '-Command', psScript], {
|
|
226
|
+
detached: true,
|
|
227
|
+
stdio: 'ignore',
|
|
228
|
+
windowsHide: true,
|
|
229
|
+
}).unref();
|
|
230
|
+
} else {
|
|
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);
|
|
238
|
+
const sh = `
|
|
239
|
+
i=0
|
|
240
|
+
while kill -0 ${parentPid} 2>/dev/null && [ $i -lt 60 ]; do
|
|
241
|
+
sleep 0.2
|
|
242
|
+
i=$((i+1))
|
|
243
|
+
done
|
|
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
|
|
248
|
+
`;
|
|
249
|
+
const childStdio = logFd ? ['ignore', logFd, logFd] : 'ignore';
|
|
250
|
+
spawn('sh', ['-c', sh], {
|
|
251
|
+
detached: true,
|
|
252
|
+
stdio: childStdio,
|
|
253
|
+
}).unref();
|
|
254
|
+
}
|
|
255
|
+
} catch { /* spawn failed — user will need to restart manually */ }
|
|
256
|
+
|
|
257
|
+
setTimeout(() => process.exit(0), 200);
|
|
258
|
+
}, 600);
|
|
157
259
|
} catch (e) {
|
|
158
260
|
const isPermission = e.message?.includes('EACCES') || e.message?.includes('permission denied');
|
|
159
261
|
sendJSON(res, 200, {
|
|
160
262
|
success: false,
|
|
161
263
|
error: isPermission ? 'EACCES: permission denied' : e.message,
|
|
162
264
|
message: isPermission
|
|
163
|
-
? 'Permission denied. Run: sudo npm install -g nothumanallowed@latest'
|
|
164
|
-
:
|
|
265
|
+
? 'Permission denied. Run: sudo npm install -g nothumanallowed@latest --prefer-online'
|
|
266
|
+
: `Update failed: ${e.message}. Try manually: npm cache clean --force && npm install -g nothumanallowed@latest --prefer-online`,
|
|
165
267
|
});
|
|
166
268
|
}
|
|
167
269
|
});
|