agent-afk 5.6.0 → 5.7.0

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.
@@ -10,8 +10,8 @@
10
10
  * Always exits 0 — never fails an install.
11
11
  */
12
12
 
13
- import { execSync } from 'node:child_process';
14
- import { readFileSync } from 'node:fs';
13
+ import { execSync, execFileSync } from 'node:child_process';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
15
  import { homedir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
 
@@ -35,6 +35,11 @@ export function detectPathGap(prefix, pathEnv) {
35
35
  * given file. All errors are silently discarded — this is best-effort cleanup
36
36
  * that must never fail an install.
37
37
  *
38
+ * NOTE: the install flow no longer calls this. SIGTERM'ing a manually-started
39
+ * bot left it dead with nothing to relaunch it (postinstall cannot reliably
40
+ * spawn a detached long-lived process). The main block now NOTIFIES instead
41
+ * (see isManualBotRunning). Retained as a tested, reusable utility.
42
+ *
38
43
  * @param {string} pidFilePath - Full path to the PID file to read.
39
44
  * @param {function} [killFn] - Injectable kill function; defaults to process.kill.
40
45
  * Pass a stub in tests to avoid needing process.kill.
@@ -50,6 +55,88 @@ export function killStaleDaemon(pidFilePath, killFn = process.kill) {
50
55
  }
51
56
  }
52
57
 
58
+ /**
59
+ * Read-only probe: is a *manually-started* telegram bot still alive? Only the
60
+ * `afk telegram start` path records a child PID in bot.pid; a launchd-supervised
61
+ * bot does NOT (launchd owns its PID — see src/service/launchd/paths.ts), so a
62
+ * non-null result here always means a manual bot — exactly the instance the
63
+ * launchd kickstart below cannot reach.
64
+ *
65
+ * Unlike the manager's isRunning(), this does NOT unlink a stale PID file:
66
+ * postinstall must not mutate runtime state. Returns the live PID, or null when
67
+ * the file is missing/malformed or the process is gone.
68
+ *
69
+ * @param {string} pidFilePath
70
+ * @param {function} [probeFn] - Injectable existence probe; defaults to process.kill.
71
+ * @returns {number|null}
72
+ */
73
+ export function isManualBotRunning(pidFilePath, probeFn = process.kill) {
74
+ try {
75
+ const raw = readFileSync(pidFilePath, 'utf8').trim();
76
+ const pid = parseInt(raw, 10);
77
+ if (!Number.isFinite(pid) || pid <= 0) return null;
78
+ probeFn(pid, 0); // signal 0 = existence check; delivers no signal
79
+ return pid;
80
+ } catch {
81
+ // File missing, non-numeric PID, ESRCH, EPERM — treat as not-running.
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Restart installed AFK launchd services so they pick up the just-installed
88
+ * code. A long-running Node process keeps the OLD module graph in memory after
89
+ * `npm install -g` overwrites the files on disk; only a restart swaps it.
90
+ * `launchctl kickstart -k` kills and relaunches the job against the (now
91
+ * updated) on-disk entrypoint — ProgramArguments are unchanged, only file
92
+ * contents, so no plist reload is needed.
93
+ *
94
+ * Fail-open and best-effort: a service whose plist is absent is skipped (never
95
+ * installed as a service); a launchctl error (job not loaded, launchctl wedged)
96
+ * is swallowed so the install never fails. macOS only — the caller gates on
97
+ * platform; elsewhere ~/Library/LaunchAgents won't exist so nothing restarts.
98
+ *
99
+ * Label / path / domain conventions mirror src/service/launchd/paths.ts
100
+ * (labelFor → `com.afk.<name>`, plist under ~/Library/LaunchAgents, guiDomain →
101
+ * `gui/<uid>`). Kept in sync by hand — this plain .mjs cannot import the
102
+ * compiled TS helpers.
103
+ *
104
+ * @param {object} [opts]
105
+ * @param {string} [opts.home] - Home dir; defaults to os.homedir().
106
+ * @param {number} [opts.uid] - Numeric uid; defaults to process.getuid().
107
+ * @param {string[]} [opts.labels] - launchd labels to consider.
108
+ * @param {function} [opts.existsFn] - Injectable plist existence check.
109
+ * @param {function} [opts.execFn] - Injectable launchctl runner; receives the argv array.
110
+ * @returns {string[]} labels that were successfully restarted.
111
+ */
112
+ export function restartLaunchdServices(opts = {}) {
113
+ const home = opts.home ?? homedir();
114
+ const uid =
115
+ opts.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 501);
116
+ const labels = opts.labels ?? ['com.afk.telegram', 'com.afk.daemon'];
117
+ const existsFn = opts.existsFn ?? existsSync;
118
+ const execFn =
119
+ opts.execFn ??
120
+ ((argv) =>
121
+ execFileSync('launchctl', argv, {
122
+ stdio: ['ignore', 'ignore', 'ignore'],
123
+ timeout: 8000,
124
+ }));
125
+
126
+ const restarted = [];
127
+ for (const label of labels) {
128
+ const plist = join(home, 'Library', 'LaunchAgents', `${label}.plist`);
129
+ if (!existsFn(plist)) continue; // not installed as a service
130
+ try {
131
+ execFn(['kickstart', '-k', `gui/${uid}/${label}`]);
132
+ restarted.push(label);
133
+ } catch {
134
+ // Job not loaded, or launchctl errored — skip; install must not fail.
135
+ }
136
+ }
137
+ return restarted;
138
+ }
139
+
53
140
  // ─── Main block ─────────────────────────────────────────────────────────────
54
141
  // Guard: only run when executed directly (not when imported by tests).
55
142
  const isMain =
@@ -106,15 +193,42 @@ if (isMain) {
106
193
  // execSync failed (npm not found, timeout, etc.) — silently ignore.
107
194
  }
108
195
 
109
- // Kill any stale daemon left over from the previous version so the
110
- // updated binary takes over on next launch rather than the old process
111
- // continuing to field requests.
196
+ // Bring already-running AFK services onto the just-installed code. A long
197
+ // running process keeps the old module graph in memory after npm overwrites
198
+ // the files on disk — only a restart swaps it.
199
+ //
200
+ // 1. launchd-supervised services (telegram + daemon): restart in place so
201
+ // they re-exec the new entrypoint. macOS only; fail-open.
202
+ if (process.platform === 'darwin') {
203
+ try {
204
+ const restarted = restartLaunchdServices();
205
+ if (restarted.length > 0) {
206
+ const names = restarted.map((l) => l.replace(/^com\.afk\./, '')).join(', ');
207
+ process.stdout.write(
208
+ `\n↻ Restarted AFK service(s) onto the new version: ${names}\n`,
209
+ );
210
+ }
211
+ } catch {
212
+ // restartLaunchdServices is already fail-open; belt-and-suspenders.
213
+ }
214
+ }
215
+
216
+ // 2. A manually-started telegram bot (`afk telegram start`) is not supervised
217
+ // by launchd, so we cannot relaunch it safely from an npm lifecycle script.
218
+ // Earlier versions SIGTERM'd it here, which left it dead with nothing to
219
+ // bring it back. Notify instead so the user can restart it deliberately.
112
220
  try {
113
221
  const afkHome = process.env['AFK_HOME'] ?? join(homedir(), '.afk');
114
- const pidFilePath = join(afkHome, 'state', 'telegram', 'bot.pid');
115
- killStaleDaemon(pidFilePath);
222
+ const botPidPath = join(afkHome, 'state', 'telegram', 'bot.pid');
223
+ const manualPid = isManualBotRunning(botPidPath);
224
+ if (manualPid !== null) {
225
+ process.stdout.write(
226
+ `\n⚠ A manually-started telegram bot (PID ${manualPid}) is still running the previous version.\n` +
227
+ ` Restart it to apply the update: afk telegram restart\n`,
228
+ );
229
+ }
116
230
  } catch {
117
- // Belt-and-suspenders: killStaleDaemon already swallows all errors internally.
231
+ // Best-effort notice never block the install.
118
232
  }
119
233
 
120
234
  process.exit(0);