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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "15.1.8",
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": {
@@ -84,11 +84,42 @@ export async function cmdUI(args) {
84
84
  }
85
85
  } catch { /* port is free */ }
86
86
 
87
- // Auto-start ops daemon (Telegram + cron) if not already running
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';
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
 
@@ -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
- // Attach WebSocket AFTER HTTP server is listening to avoid WS error events
380
- await new Promise((resolve, reject) => {
381
- server.once('error', reject);
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', reject);
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
- // Detect the package manager and global prefix to handle permissions correctly
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
- // Get the installed version from npm output or registry
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
- // Restart ops daemon if running (so Telegram/Discord reconnect with new code)
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}! Restart the server to apply.` : 'Update completed',
166
+ message: newVersion ? `Updated to v${newVersion}. Restarting server...` : 'Update completed. Restarting...',
151
167
  newVersion,
152
- restart: false, // Do NOT auto-restart — it never works reliably
153
- needsManualRestart: true,
154
- stdout: stdout.trim(),
155
- stderr: stderr.trim()
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
- : 'Update failed. Try running: npm install -g nothumanallowed@latest'
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
  });