alvin-bot 4.8.4 → 4.8.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.8.6] — 2026-04-11
6
+
7
+ ### 🐛 LaunchAgent: `/restart` left the bot down forever
8
+
9
+ Caught on the Mac mini production bot: running `/restart` in Telegram killed the bot cleanly but the process never came back, leaving the bot dead until manual intervention.
10
+
11
+ **Root cause**: The 4.6.0 LaunchAgent plist template hardcoded a conditional `KeepAlive`:
12
+
13
+ ```xml
14
+ <key>KeepAlive</key>
15
+ <dict>
16
+ <key>SuccessfulExit</key>
17
+ <false/> <!-- don't restart on normal exit -->
18
+ <key>Crashed</key>
19
+ <true/> <!-- only restart on crash -->
20
+ </dict>
21
+ ```
22
+
23
+ That meant launchd would only auto-restart on **crashes**, not on normal exits. But `/restart` (and `/update`) work by calling `process.exit(0)` — a deliberate clean exit — and relying on the process manager to bring the bot back up. With pm2 this always worked because pm2's default is "restart on any exit". With launchd's conditional KeepAlive, `process.exit(0)` was the ONE exit code that guaranteed the bot stayed down.
24
+
25
+ **Fix**: Plist template now uses `<key>KeepAlive</key><true/>` — unconditional restart on any exit. Matches pm2's default behavior. `ThrottleInterval` dropped from 10s to 5s so recovery is quicker.
26
+
27
+ **Migration for existing installs**: re-run `alvin-bot launchd install` to get the new plist. The install script unloads the old plist, writes the new one, and reloads it — existing data and running state are preserved.
28
+
29
+ Also removed the stale "(PM2)" suffix from the `/restart` Telegram command description — it's just "Restart the bot" now, since the command works identically with both pm2 and launchd.
30
+
31
+ ## [4.8.5] — 2026-04-11
32
+
33
+ ### 🐛 `/update` now works for npm-global installs
34
+
35
+ Caught on the test MacBook: `/update` reported *"Already up to date — no new commits"* even though npm had a newer version published. Root cause was two separate bugs feeding into each other.
36
+
37
+ **Bug 1 — false git-repo detection**. `isGitRepo()` used `git rev-parse --is-inside-work-tree` which walks up the directory tree looking for any ancestor `.git` folder. On the test MacBook, `alvin-bot` was installed at `/opt/homebrew/lib/node_modules/alvin-bot/` which has no `.git` itself — but Homebrew stores its formula tree at `/opt/homebrew/` as a git repo. So `git rev-parse` walked up, found Homebrew's `.git`, and returned `true`. The updater then dutifully fetched Homebrew's upstream (which was up-to-date), found 0 new commits, and reported "Already up to date" — about the wrong repository.
38
+
39
+ **Fix**: `isOwnGitRepo()` now does a strict check for `PROJECT_ROOT/.git` directly, no directory walk. False positives from ancestor git repos are impossible.
40
+
41
+ **Bug 2 — no update path for npm-global installs**. Even with a correct `isGitRepo()` check, the updater would return *"Not in a git repo — update only supported for source installs."* for npm-global installs. That meant you could never update an npm-installed alvin-bot from within the bot itself.
42
+
43
+ **Fix**: New `runNpmUpdate()` path that kicks in when `PROJECT_ROOT` looks like a `node_modules/alvin-bot` install (covers Homebrew node, plain npm, nvm, volta). It:
44
+
45
+ 1. Reads the local version from `package.json`
46
+ 2. Queries `npm view alvin-bot version` for the latest published version
47
+ 3. Compares via a tiny semver compare
48
+ 4. If newer: runs `npm install -g alvin-bot@latest --no-audit --no-fund` (5 minute timeout)
49
+ 5. Signals the caller to restart so the new code takes effect
50
+ 6. Detects `EACCES` and suggests `sudo` explicitly instead of a cryptic error
51
+
52
+ Also improved the git path: falls back to `npm install` + `npm run build` when `pnpm-lock.yaml` doesn't exist (previously hard-coded pnpm).
53
+
54
+ After 4.8.5, `/update` on the test MacBook will correctly detect the npm install, see that v4.8.4 is the latest, fetch it, and restart. No more false-positive "up to date" when a newer release is out.
55
+
5
56
  ## [4.8.4] — 2026-04-11
6
57
 
7
58
  ### 🐛 WhatsApp self-chat detection for the new `@lid` identity format
package/bin/cli.js CHANGED
@@ -1461,15 +1461,10 @@ function renderLaunchdPlist({ label, nodePath, entryPoint, cwd, home, logDir })
1461
1461
  <true/>
1462
1462
 
1463
1463
  <key>KeepAlive</key>
1464
- <dict>
1465
- <key>SuccessfulExit</key>
1466
- <false/>
1467
- <key>Crashed</key>
1468
- <true/>
1469
- </dict>
1464
+ <true/>
1470
1465
 
1471
1466
  <key>ThrottleInterval</key>
1472
- <integer>10</integer>
1467
+ <integer>5</integer>
1473
1468
 
1474
1469
  <key>StandardOutPath</key>
1475
1470
  <string>${logDir}/alvin-bot.out.log</string>
@@ -156,7 +156,7 @@ export function registerCommands(bot) {
156
156
  { command: "webui", description: "Open Web UI in browser" },
157
157
  { command: "setup", description: "Configure API keys & platforms" },
158
158
  { command: "cancel", description: "Cancel running request" },
159
- { command: "restart", description: "Restart the bot (PM2)" },
159
+ { command: "restart", description: "Restart the bot" },
160
160
  { command: "update", description: "Pull latest, build, restart" },
161
161
  { command: "autoupdate", description: "Auto-update on|off|status" },
162
162
  ]).catch(err => console.error("Failed to set bot commands:", err));
@@ -25,83 +25,179 @@ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot
25
25
  const FLAG_FILE = resolve(DATA_DIR, "auto-update.flag");
26
26
  const AUTO_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
27
27
  let autoTimer = null;
28
- async function isGitRepo() {
28
+ /**
29
+ * Is PROJECT_ROOT itself a git repository? We deliberately do NOT use
30
+ * `git rev-parse --is-inside-work-tree` because that walks UP the
31
+ * directory tree and would return true for any ancestor that happens
32
+ * to be a git repo — e.g. Homebrew stores its formula tree in a git
33
+ * repo at /opt/homebrew/, so a npm-global install of alvin-bot under
34
+ * /opt/homebrew/lib/node_modules/alvin-bot would be reported as a git
35
+ * repo even though it's just plain files shipped via npm.
36
+ *
37
+ * The strict check: does PROJECT_ROOT/.git exist?
38
+ */
39
+ function isOwnGitRepo() {
40
+ return fs.existsSync(resolve(PROJECT_ROOT, ".git"));
41
+ }
42
+ /**
43
+ * Heuristic for "this is an npm-global install": PROJECT_ROOT sits
44
+ * inside a node_modules/alvin-bot directory. Covers:
45
+ * - /opt/homebrew/lib/node_modules/alvin-bot (Homebrew node)
46
+ * - /usr/local/lib/node_modules/alvin-bot (plain npm)
47
+ * - ~/.nvm/versions/node/...alvin-bot (nvm)
48
+ * - ~/.volta/tools/image/packages/...alvin-bot (volta)
49
+ */
50
+ function isNpmGlobalInstall() {
51
+ return /node_modules[/\\]alvin-bot$/.test(PROJECT_ROOT) || PROJECT_ROOT.includes("node_modules/alvin-bot/");
52
+ }
53
+ function readLocalVersion() {
29
54
  try {
30
- const { stdout } = await execAsync("git rev-parse --is-inside-work-tree", {
31
- cwd: PROJECT_ROOT,
32
- timeout: 5_000,
55
+ const pkgPath = resolve(PROJECT_ROOT, "package.json");
56
+ const raw = fs.readFileSync(pkgPath, "utf-8");
57
+ const parsed = JSON.parse(raw);
58
+ return parsed.version ?? null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ async function fetchRemoteVersion() {
65
+ try {
66
+ const { stdout } = await execAsync("npm view alvin-bot version", {
67
+ timeout: 15_000,
33
68
  });
34
- return stdout.trim() === "true";
69
+ return stdout.trim() || null;
35
70
  }
36
71
  catch {
37
- return false;
72
+ return null;
73
+ }
74
+ }
75
+ /** Semver-compare A vs B. Returns negative if A < B, 0 if equal, positive if A > B. */
76
+ function compareSemver(a, b) {
77
+ const norm = (v) => v.replace(/^v/, "").split(/[.-]/).map((p) => parseInt(p, 10) || 0);
78
+ const av = norm(a);
79
+ const bv = norm(b);
80
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
81
+ const diff = (av[i] ?? 0) - (bv[i] ?? 0);
82
+ if (diff !== 0)
83
+ return diff;
38
84
  }
85
+ return 0;
39
86
  }
40
87
  /** Pull latest changes, install deps, rebuild. Returns a structured result
41
- * instead of throwing so the /update command can report cleanly to Telegram. */
88
+ * instead of throwing so the /update command can report cleanly to Telegram.
89
+ * Dispatches to the git path for source installs and the npm path for
90
+ * npm-global installs. */
42
91
  export async function runUpdate() {
43
92
  try {
44
- const isRepo = await isGitRepo();
45
- if (!isRepo) {
46
- return {
47
- ok: false,
48
- message: "Not in a git repo — update only supported for source installs.",
49
- requiresRestart: false,
50
- };
51
- }
52
- // Fetch latest without merging
53
- await execAsync("git fetch --quiet", {
54
- cwd: PROJECT_ROOT,
55
- timeout: 30_000,
56
- });
57
- // Count commits we're behind the upstream
58
- let behindCount = 0;
59
- try {
60
- const { stdout } = await execAsync("git rev-list --count HEAD..@{upstream}", {
61
- cwd: PROJECT_ROOT,
62
- timeout: 10_000,
63
- });
64
- behindCount = parseInt(stdout.trim() || "0", 10);
65
- }
66
- catch {
67
- // No upstream configured — treat as up-to-date
68
- behindCount = 0;
93
+ if (isOwnGitRepo()) {
94
+ return await runGitUpdate();
69
95
  }
70
- if (behindCount === 0) {
71
- return {
72
- ok: true,
73
- message: "Already up to date — no new commits.",
74
- requiresRestart: false,
75
- };
96
+ if (isNpmGlobalInstall()) {
97
+ return await runNpmUpdate();
76
98
  }
77
- // Fast-forward pull (refuses to merge if history diverged)
78
- await execAsync("git pull --ff-only", {
79
- cwd: PROJECT_ROOT,
80
- timeout: 60_000,
81
- });
82
- // Install (frozen lockfile to match upstream exactly)
83
- await execAsync("pnpm install --frozen-lockfile", {
84
- cwd: PROJECT_ROOT,
85
- timeout: 180_000,
86
- });
87
- // Build
88
- await execAsync("pnpm run build", {
99
+ return {
100
+ ok: false,
101
+ message: "Update not supported for this install type. Clone the git repo or use npm install -g alvin-bot.",
102
+ requiresRestart: false,
103
+ };
104
+ }
105
+ catch (err) {
106
+ const raw = err instanceof Error ? err.message : String(err);
107
+ const message = raw.length > 300 ? raw.slice(0, 300) + "…" : raw;
108
+ return { ok: false, message, requiresRestart: false };
109
+ }
110
+ }
111
+ async function runGitUpdate() {
112
+ // Fetch latest without merging
113
+ await execAsync("git fetch --quiet", {
114
+ cwd: PROJECT_ROOT,
115
+ timeout: 30_000,
116
+ });
117
+ // Count commits we're behind the upstream
118
+ let behindCount = 0;
119
+ try {
120
+ const { stdout } = await execAsync("git rev-list --count HEAD..@{upstream}", {
89
121
  cwd: PROJECT_ROOT,
90
- timeout: 180_000,
122
+ timeout: 10_000,
91
123
  });
124
+ behindCount = parseInt(stdout.trim() || "0", 10);
125
+ }
126
+ catch {
127
+ behindCount = 0;
128
+ }
129
+ if (behindCount === 0) {
130
+ return {
131
+ ok: true,
132
+ message: "Already up to date — no new commits.",
133
+ requiresRestart: false,
134
+ };
135
+ }
136
+ // Fast-forward pull
137
+ await execAsync("git pull --ff-only", {
138
+ cwd: PROJECT_ROOT,
139
+ timeout: 60_000,
140
+ });
141
+ // Prefer pnpm if the lockfile exists, otherwise fall back to npm
142
+ const hasPnpmLock = fs.existsSync(resolve(PROJECT_ROOT, "pnpm-lock.yaml"));
143
+ const installCmd = hasPnpmLock ? "pnpm install --frozen-lockfile" : "npm install --no-audit --no-fund";
144
+ const buildCmd = hasPnpmLock ? "pnpm run build" : "npm run build";
145
+ await execAsync(installCmd, { cwd: PROJECT_ROOT, timeout: 180_000 });
146
+ await execAsync(buildCmd, { cwd: PROJECT_ROOT, timeout: 180_000 });
147
+ return {
148
+ ok: true,
149
+ message: `Installed ${behindCount} commit(s), build successful.`,
150
+ requiresRestart: true,
151
+ };
152
+ }
153
+ async function runNpmUpdate() {
154
+ const current = readLocalVersion();
155
+ const latest = await fetchRemoteVersion();
156
+ if (!latest) {
157
+ return {
158
+ ok: false,
159
+ message: "Could not reach npm registry — check your internet connection.",
160
+ requiresRestart: false,
161
+ };
162
+ }
163
+ if (current && compareSemver(current, latest) >= 0) {
92
164
  return {
93
165
  ok: true,
94
- message: `Installed ${behindCount} commit(s), build successful.`,
95
- requiresRestart: true,
166
+ message: `Already up to date — v${current} is the latest published version.`,
167
+ requiresRestart: false,
96
168
  };
97
169
  }
170
+ // Newer version exists — install it globally. npm install -g writes to
171
+ // the globally-scoped node_modules directory (/opt/homebrew/lib/… on
172
+ // Homebrew, /usr/local/lib/… on plain npm). The running process still
173
+ // has the old code loaded in memory, so after install we signal the
174
+ // caller to restart.
175
+ try {
176
+ await execAsync("npm install -g alvin-bot@latest --no-audit --no-fund", {
177
+ timeout: 300_000, // 5 minutes for large installs
178
+ });
179
+ }
98
180
  catch (err) {
99
181
  const raw = err instanceof Error ? err.message : String(err);
100
- // Keep error messages short Telegram has a 4096 char limit and the
101
- // user doesn't need a 50-line stack trace.
102
- const message = raw.length > 300 ? raw.slice(0, 300) + "…" : raw;
103
- return { ok: false, message, requiresRestart: false };
182
+ // Permission errors are the most common npm -g failure mode
183
+ if (/EACCES|permission denied/i.test(raw)) {
184
+ return {
185
+ ok: false,
186
+ message: `npm install -g failed with permissions. Try: sudo npm install -g alvin-bot@latest`,
187
+ requiresRestart: false,
188
+ };
189
+ }
190
+ return {
191
+ ok: false,
192
+ message: `npm install failed: ${raw.slice(0, 200)}`,
193
+ requiresRestart: false,
194
+ };
104
195
  }
196
+ return {
197
+ ok: true,
198
+ message: `Installed v${latest} (was v${current ?? "?"}). Restarting...`,
199
+ requiresRestart: true,
200
+ };
105
201
  }
106
202
  export function getAutoUpdate() {
107
203
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.8.4",
3
+ "version": "4.8.6",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",