@agenticmail/enterprise 0.5.613 → 0.5.615

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,18 @@
2
2
 
3
3
  All notable changes to AgenticMail Enterprise are documented here.
4
4
 
5
+ ## [0.5.614] - 2026-05-25
6
+
7
+ ### Fixed — Telegram/WhatsApp "typing…" now stays on for the whole turn
8
+
9
+ The agent sent the chat-action ONCE on inbound, so "typing…" showed for ~5s
10
+ (Telegram's window) then vanished while the agent was still working — unlike
11
+ the open-source bridge, which refreshes it. Added a self-refreshing typing
12
+ indicator in the chat handler: pings every 4s and stops when the session turn
13
+ completes (and on error / a 15-min safety cap). Keyed per-chat so follow-up
14
+ messages don't stack multiple loops. Applies to both Telegram (`sendChatAction`)
15
+ and WhatsApp (`composing` presence).
16
+
5
17
  ## [0.5.613] - 2026-05-25
6
18
 
7
19
  ### Fixed — Agents now always use a PERMANENT workspace, never /tmp
@@ -421,6 +421,33 @@ if (_LOG_THRESHOLD > 2) {
421
421
  console.warn = function() {
422
422
  };
423
423
  }
424
+ var _typingTimers = /* @__PURE__ */ new Map();
425
+ var _TYPING_REFRESH_MS = 4e3;
426
+ var _TYPING_MAX_MS = 15 * 6e4;
427
+ function startTypingIndicator(key, tick) {
428
+ if (!key || _typingTimers.has(key)) return;
429
+ try {
430
+ tick();
431
+ } catch {
432
+ }
433
+ const interval = setInterval(() => {
434
+ try {
435
+ tick();
436
+ } catch {
437
+ }
438
+ }, _TYPING_REFRESH_MS);
439
+ const safety = setTimeout(() => stopTypingIndicator(key), _TYPING_MAX_MS);
440
+ interval.unref?.();
441
+ safety.unref?.();
442
+ _typingTimers.set(key, { interval, safety });
443
+ }
444
+ function stopTypingIndicator(key) {
445
+ const t = _typingTimers.get(key);
446
+ if (!t) return;
447
+ clearInterval(t.interval);
448
+ clearTimeout(t.safety);
449
+ _typingTimers.delete(key);
450
+ }
424
451
  function _stripMd(text) {
425
452
  if (!text) return text;
426
453
  return text.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/__(.+?)__/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/```[\s\S]*?```/g, (m) => m.replace(/```\w*\n?/g, "").trim()).replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
@@ -1708,30 +1735,36 @@ Please complete this task now.`,
1708
1735
  }
1709
1736
  });
1710
1737
  app.post("/api/runtime/chat", async (c) => {
1738
+ let typingKey = "";
1711
1739
  try {
1712
1740
  const ctx = await c.req.json();
1713
1741
  if (typeof ctx.messageText !== "string") ctx.messageText = "";
1714
1742
  const isMessagingSource = ["whatsapp", "telegram"].includes(ctx.source);
1715
1743
  console.log(`[chat] Message from ${ctx.senderName} (${ctx.senderEmail}) in ${ctx.source || ctx.spaceName}: "${ctx.messageText.slice(0, 80)}"`);
1744
+ typingKey = ctx.source === "telegram" ? `tg:${ctx.spaceId || ctx.senderEmail}` : ctx.source === "whatsapp" ? `wa:${ctx.senderEmail}` : "";
1716
1745
  if (ctx.source === "telegram") {
1717
- const tgToken = agent.config?.channels?.telegram?.botToken;
1746
+ const tgToken = agent.config?.channels?.telegram?.botToken || agent.config?.messagingChannels?.telegram?.botToken;
1718
1747
  const chatId = ctx.spaceId || ctx.senderEmail;
1719
1748
  if (tgToken && chatId) {
1720
- fetch(`https://api.telegram.org/bot${tgToken}/sendChatAction`, {
1721
- method: "POST",
1722
- headers: { "Content-Type": "application/json" },
1723
- body: JSON.stringify({ chat_id: chatId, action: "typing" })
1724
- }).catch(() => {
1749
+ startTypingIndicator(typingKey, () => {
1750
+ fetch(`https://api.telegram.org/bot${tgToken}/sendChatAction`, {
1751
+ method: "POST",
1752
+ headers: { "Content-Type": "application/json" },
1753
+ body: JSON.stringify({ chat_id: chatId, action: "typing" })
1754
+ }).catch(() => {
1755
+ });
1725
1756
  });
1726
1757
  }
1727
1758
  } else if (ctx.source === "whatsapp") {
1728
- import("./whatsapp-TBOB7TDL.js").then(({ getConnection }) => {
1729
- const conn = getConnection(AGENT_ID);
1730
- if (!conn?.connected) return;
1731
- const jid = ctx.senderEmail.includes("@") ? ctx.senderEmail : ctx.senderEmail.replace(/[^0-9]/g, "") + "@s.whatsapp.net";
1732
- conn.sock.presenceSubscribe(jid).then(() => conn.sock.sendPresenceUpdate("composing", jid)).catch(() => {
1759
+ const jid = ctx.senderEmail.includes("@") ? ctx.senderEmail : ctx.senderEmail.replace(/[^0-9]/g, "") + "@s.whatsapp.net";
1760
+ startTypingIndicator(typingKey, () => {
1761
+ import("./whatsapp-TBOB7TDL.js").then(({ getConnection }) => {
1762
+ const conn = getConnection(AGENT_ID);
1763
+ if (!conn?.connected) return;
1764
+ conn.sock.presenceSubscribe(jid).then(() => conn.sock.sendPresenceUpdate("composing", jid)).catch(() => {
1765
+ });
1766
+ }).catch(() => {
1733
1767
  });
1734
- }).catch(() => {
1735
1768
  });
1736
1769
  }
1737
1770
  const agentDomain = agent.email?.split("@")[1] || "agenticmail.io";
@@ -2032,6 +2065,7 @@ ${ambientContext}` : ""
2032
2065
  }
2033
2066
  });
2034
2067
  runtime.onSessionComplete(session.id, async (result) => {
2068
+ stopTypingIndicator(typingKey);
2035
2069
  sessionRouter?.unregister(agentId, session.id);
2036
2070
  if (taskId) {
2037
2071
  const usage = result?.usage || {};
@@ -2178,6 +2212,10 @@ ${ambientContext}` : ""
2178
2212
  }
2179
2213
  return c.json({ ok: true, sessionId: session.id });
2180
2214
  } catch (err) {
2215
+ try {
2216
+ stopTypingIndicator(typingKey);
2217
+ } catch {
2218
+ }
2181
2219
  console.error(`[chat] Error: ${err.message}`);
2182
2220
  return c.json({ error: err.message }, 500);
2183
2221
  }
package/dist/cli.js CHANGED
@@ -68,7 +68,22 @@ Skill Development:
68
68
  import("./cli-serve-4NLB4RK2.js").then((m) => m.runServe(args.slice(1))).catch(fatal);
69
69
  break;
70
70
  case "agent":
71
- import("./cli-agent-AMA2R4PG.js").then((m) => m.runAgent(args.slice(1))).catch(fatal);
71
+ import("./cli-agent-DOLO7OCU.js").then((m) => m.runAgent(args.slice(1))).catch(fatal);
72
+ break;
73
+ case "startup":
74
+ case "autostart":
75
+ Promise.all([
76
+ import("child_process"),
77
+ import("path"),
78
+ import("url")
79
+ ]).then(([cp, p, u]) => {
80
+ const here = p.dirname(u.fileURLToPath(import.meta.url));
81
+ const helper = p.join(here, "..", "scripts", "ensure-pm2-startup.cjs");
82
+ const child = cp.spawn(process.execPath, [helper, ...args.slice(1)], {
83
+ stdio: "inherit"
84
+ });
85
+ child.on("exit", (code) => process.exit(code ?? 0));
86
+ }).catch(fatal);
72
87
  break;
73
88
  case "setup":
74
89
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/enterprise",
3
- "version": "0.5.613",
3
+ "version": "0.5.615",
4
4
  "description": "AgenticMail Enterprise — cloud-hosted AI agent identity, email, auth & compliance for organizations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,9 @@
20
20
  "build": "tsup src/index.ts src/cli.ts src/registry/cli.ts src/watchdog.ts --format esm --external better-sqlite3 --external mongodb --external mysql2 --external @libsql/client --external @aws-sdk/client-dynamodb --external @aws-sdk/lib-dynamodb --external @aws-sdk/client-s3 --external @aws-sdk/s3-request-presigner --external @google-cloud/storage --external @azure/storage-blob --external @mozilla/readability --external imapflow --external nodemailer --external linkedom --external postgres --external playwright-core --external ws --external express && mkdir -p dist/dashboard/components dist/dashboard/pages dist/dashboard/vendor dist/dashboard/assets dist/registry && cp src/dashboard/index.html dist/dashboard/ && cp src/dashboard/app.js dist/dashboard/ && cp src/dashboard/components/*.js dist/dashboard/components/ && cp src/dashboard/pages/*.js dist/dashboard/pages/ && rm -rf dist/dashboard/pages/agent-detail && cp -r src/dashboard/pages/agent-detail dist/dashboard/pages/agent-detail && cp src/dashboard/vendor/*.js dist/dashboard/vendor/ && cp -r src/dashboard/assets/* dist/dashboard/assets/ && mkdir -p dist/dashboard/data && cp src/dashboard/data/*.js dist/dashboard/data/ && mkdir -p dist/dashboard/docs && cp src/dashboard/docs/*.html dist/dashboard/docs/ && cp src/dashboard/docs/*.css dist/dashboard/docs/ && mkdir -p dist/assets && cp src/engine/assets/* dist/assets/ && cp src/engine/soul-templates.json dist/",
21
21
  "dev": "npm run build && node --watch start-live.mjs",
22
22
  "rebuild": "npm run build && pm2 restart enterprise",
23
- "preuninstall": "node scripts/preuninstall.js"
23
+ "preuninstall": "node scripts/preuninstall.js",
24
+ "postinstall": "node scripts/ensure-pm2-startup.cjs --quiet || true",
25
+ "startup": "node scripts/ensure-pm2-startup.cjs"
24
26
  },
25
27
  "keywords": [
26
28
  "ai",
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ensure-pm2-startup.cjs — make sure PM2 resurrects on reboot.
4
+ *
5
+ * Why this exists
6
+ * ────────────────────────────────────────────────────────────────
7
+ * `pm2 startup` ships a launchd plist on macOS that has
8
+ * historically had two bugs:
9
+ *
10
+ * 1. LaunchOnlyOnce=true — a deprecated key launchd interprets
11
+ * as "only ever run this ONCE in the lifetime of the plist."
12
+ * After the first execution, it never fires on subsequent
13
+ * boots. Reboots stop restoring processes.
14
+ *
15
+ * 2. The agent isn't always re-bootstrapped after a plist
16
+ * change. The user has the file but launchctl never loaded
17
+ * it (launchctl list shows nothing).
18
+ *
19
+ * This script writes a correct plist + bootstraps it cleanly +
20
+ * runs `pm2 save` so the dump that resurrect reads is current.
21
+ *
22
+ * Idempotent — safe to run on every install / upgrade / manual
23
+ * invocation. Best-effort — failures log a WARN and exit 0 so
24
+ * npm install never aborts on a launchd hiccup.
25
+ *
26
+ * Platforms
27
+ * ────────────────────────────────────────────────────────────────
28
+ * macOS — full support (plist + launchctl)
29
+ * linux — currently no-op with a hint to run `pm2 startup`
30
+ * manually. systemd unit generation is a TODO.
31
+ * win32 — no-op.
32
+ *
33
+ * CLI
34
+ * ────────────────────────────────────────────────────────────────
35
+ * node scripts/ensure-pm2-startup.cjs # apply + save
36
+ * node scripts/ensure-pm2-startup.cjs --check # verify only
37
+ * node scripts/ensure-pm2-startup.cjs --quiet # no output on OK
38
+ */
39
+ 'use strict';
40
+
41
+ const fs = require('fs');
42
+ const os = require('os');
43
+ const path = require('path');
44
+ const { execFileSync, execSync, spawnSync } = require('child_process');
45
+
46
+ const QUIET = process.argv.includes('--quiet');
47
+ const CHECK_ONLY = process.argv.includes('--check');
48
+
49
+ function log(msg) { if (!QUIET) console.log('[pm2-startup] ' + msg); }
50
+ function warn(msg) { console.warn('[pm2-startup] ' + msg); }
51
+
52
+ function which(bin) {
53
+ try {
54
+ return execSync('command -v ' + bin, { encoding: 'utf8' }).trim();
55
+ } catch { return null; }
56
+ }
57
+
58
+ function findPm2() {
59
+ // Prefer the global homebrew install path because the launchd
60
+ // plist needs an absolute path (no $PATH lookup at boot).
61
+ const candidates = [
62
+ '/opt/homebrew/lib/node_modules/pm2/bin/pm2',
63
+ '/usr/local/lib/node_modules/pm2/bin/pm2',
64
+ which('pm2'),
65
+ ].filter(Boolean);
66
+ for (const c of candidates) {
67
+ if (fs.existsSync(c)) return c;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** Build the plist string. The PATH and PM2_HOME we capture from
73
+ * the current process so the agent's resurrect run sees the same
74
+ * toolchain the user does in their interactive shell. */
75
+ function buildPlist(user, pm2Bin) {
76
+ const pm2Home = process.env.PM2_HOME || path.join(os.homedir(), '.pm2');
77
+ const pathEnv = process.env.PATH || '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin';
78
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
79
+ return `<?xml version="1.0" encoding="UTF-8"?>
80
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
81
+ <plist version="1.0">
82
+ <dict>
83
+ <key>Label</key>
84
+ <string>com.PM2</string>
85
+ <key>UserName</key>
86
+ <string>${xml(user)}</string>
87
+ <!-- Fire once at boot/login. KeepAlive=false because pm2
88
+ resurrect is a one-shot — PM2 itself manages the children
89
+ after that. NO LaunchOnlyOnce (deprecated, breaks reboots). -->
90
+ <key>RunAtLoad</key>
91
+ <true/>
92
+ <key>KeepAlive</key>
93
+ <false/>
94
+ <key>ProgramArguments</key>
95
+ <array>
96
+ <string>/bin/sh</string>
97
+ <string>-c</string>
98
+ <string>${xml(pm2Bin)} resurrect</string>
99
+ </array>
100
+ <key>EnvironmentVariables</key>
101
+ <dict>
102
+ <key>PATH</key>
103
+ <string>${xml(pathEnv)}</string>
104
+ <key>PM2_HOME</key>
105
+ <string>${xml(pm2Home)}</string>
106
+ </dict>
107
+ <key>StandardErrorPath</key>
108
+ <string>/tmp/com.PM2.err</string>
109
+ <key>StandardOutPath</key>
110
+ <string>/tmp/com.PM2.out</string>
111
+ </dict>
112
+ </plist>
113
+ `;
114
+ }
115
+
116
+ /** Return true if the plist on disk matches what we'd write today.
117
+ * Used by --check to bail without rewriting when nothing changed. */
118
+ function plistIsCurrent(plistPath, expected) {
119
+ try {
120
+ const actual = fs.readFileSync(plistPath, 'utf8');
121
+ if (actual === expected) return true;
122
+ // Tolerate trivial whitespace differences.
123
+ return actual.replace(/\s+/g, '') === expected.replace(/\s+/g, '');
124
+ } catch { return false; }
125
+ }
126
+
127
+ /** True when launchctl reports the com.PM2 agent loaded under
128
+ * this user's GUI session. */
129
+ function pm2AgentLoaded() {
130
+ try {
131
+ const out = execSync('launchctl list', { encoding: 'utf8' });
132
+ return /\bcom\.PM2\b/.test(out);
133
+ } catch { return false; }
134
+ }
135
+
136
+ function ensureMac() {
137
+ const user = process.env.USER || os.userInfo().username;
138
+ if (!user) {
139
+ warn('Could not determine $USER — skipping launchd setup.');
140
+ return;
141
+ }
142
+ const pm2Bin = findPm2();
143
+ if (!pm2Bin) {
144
+ warn('pm2 not found. Install with: npm i -g pm2');
145
+ return;
146
+ }
147
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `pm2.${user}.plist`);
148
+ const desired = buildPlist(user, pm2Bin);
149
+
150
+ const upToDate = plistIsCurrent(plistPath, desired);
151
+ const loaded = pm2AgentLoaded();
152
+
153
+ if (CHECK_ONLY) {
154
+ if (upToDate && loaded) {
155
+ log('OK — plist current, launchd agent loaded.');
156
+ process.exit(0);
157
+ }
158
+ warn(`Needs fixup — plist current=${upToDate}, agent loaded=${loaded}.`);
159
+ process.exit(2);
160
+ }
161
+
162
+ if (!upToDate) {
163
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
164
+ fs.writeFileSync(plistPath, desired, 'utf8');
165
+ log(`Wrote ${plistPath}`);
166
+ } else {
167
+ log(`Plist already current at ${plistPath}`);
168
+ }
169
+
170
+ // Re-bootstrap regardless — covers the case where the file is
171
+ // right but launchd never loaded it. bootout is allowed to fail
172
+ // (agent may not currently be loaded) — that's fine.
173
+ const uid = os.userInfo().uid;
174
+ const tryBootout = spawnSync('launchctl',
175
+ ['bootout', `gui/${uid}`, plistPath],
176
+ { stdio: 'ignore' });
177
+ const tryBootstrap = spawnSync('launchctl',
178
+ ['bootstrap', `gui/${uid}`, plistPath],
179
+ { stdio: 'inherit' });
180
+ if (tryBootstrap.status !== 0) {
181
+ warn('launchctl bootstrap returned non-zero — agent may already '
182
+ + 'be loaded under a different session. Verify with `launchctl list | grep PM2`.');
183
+ }
184
+
185
+ // Save current process list so resurrect has a fresh dump.
186
+ try {
187
+ execFileSync(pm2Bin, ['save'], { stdio: 'inherit' });
188
+ } catch (ex) {
189
+ warn(`pm2 save failed: ${ex.message}`);
190
+ }
191
+
192
+ log('Done. Verify: `launchctl list | grep PM2` should show com.PM2');
193
+ }
194
+
195
+ function ensureLinux() {
196
+ if (CHECK_ONLY) return process.exit(0);
197
+ warn('Linux: this script only knows macOS. Run `pm2 startup` and '
198
+ + 'follow its instructions, then `pm2 save`.');
199
+ }
200
+
201
+ function main() {
202
+ switch (process.platform) {
203
+ case 'darwin': return ensureMac();
204
+ case 'linux': return ensureLinux();
205
+ default:
206
+ if (CHECK_ONLY) return process.exit(0);
207
+ warn(`Platform ${process.platform} not supported by this helper.`);
208
+ }
209
+ }
210
+
211
+ try { main(); }
212
+ catch (ex) {
213
+ warn(`Unexpected error: ${ex.message}`);
214
+ // Postinstall context: never fail the install.
215
+ process.exit(0);
216
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bash
2
+ # Background migration runner.
3
+ #
4
+ # Polls Supabase every 120s. The moment a `SELECT 1` succeeds against the
5
+ # pooler, runs pg_dump → restore into the local agenticmail_enterprise DB,
6
+ # rewrites enterprise/.env so DATABASE_URL points at localhost, and
7
+ # restarts the enterprise PM2 process so it picks up the new DB.
8
+ #
9
+ # Idempotent: on success it writes a sentinel file and exits 0. If
10
+ # something fails partway (dump corrupted, restore errored), it logs the
11
+ # detail and exits non-zero — the user can re-run by deleting the
12
+ # sentinel and starting the script again.
13
+
14
+ set -uo pipefail
15
+
16
+ ENV_FILE="/Users/ope/Desktop/projects/agenticmail/enterprise/.env"
17
+ ENV_BACKUP="${ENV_FILE}.pre-localpg"
18
+ LOG_FILE="/Users/ope/Desktop/projects/agenticmail/enterprise/logs/migrate-from-supabase.log"
19
+ DUMP_FILE="/tmp/agenticmail-enterprise-supabase.sql"
20
+ SENTINEL="/Users/ope/Desktop/projects/agenticmail/enterprise/logs/migrate-from-supabase.done"
21
+
22
+ LOCAL_DB="agenticmail_enterprise"
23
+ LOCAL_URL="postgresql://ope@localhost:5432/${LOCAL_DB}"
24
+
25
+ # Try both pooler hostnames the Supabase project may be reachable on.
26
+ # Session pooler (port 5432) is preferred for pg_dump — pgbouncer in
27
+ # transaction mode (6543) chokes on prepared statements.
28
+ SUPABASE_TXN="postgresql://postgres.ziurzgoffaexxgjmfjph:MK6PHWIpjDO0cPwU@aws-1-us-east-2.pooler.supabase.com:6543/postgres"
29
+ SUPABASE_SESSION="postgresql://postgres.ziurzgoffaexxgjmfjph:MK6PHWIpjDO0cPwU@aws-1-us-east-2.pooler.supabase.com:5432/postgres"
30
+ SUPABASE_DIRECT="postgresql://postgres:MK6PHWIpjDO0cPwU@db.ziurzgoffaexxgjmfjph.supabase.co:5432/postgres"
31
+
32
+ RETRY_INTERVAL_SECS=120
33
+ MAX_RETRIES=720 # 720 * 2 min = ~24 h budget
34
+
35
+ mkdir -p "$(dirname "$LOG_FILE")"
36
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" >&2; }
37
+
38
+ if [[ -f "$SENTINEL" ]]; then
39
+ log "Sentinel exists at $SENTINEL — migration already completed. Exiting."
40
+ exit 0
41
+ fi
42
+
43
+ log "Starting migration runner. Will retry every ${RETRY_INTERVAL_SECS}s for up to ${MAX_RETRIES} attempts."
44
+
45
+ # Pick the first URL that returns a successful SELECT 1. Echo the URL,
46
+ # or empty string if all fail.
47
+ pick_reachable_url() {
48
+ for url in "$SUPABASE_SESSION" "$SUPABASE_DIRECT" "$SUPABASE_TXN"; do
49
+ if PGCONNECT_TIMEOUT=8 psql "$url" -tAc "SELECT 1;" >/dev/null 2>&1; then
50
+ echo "$url"
51
+ return 0
52
+ fi
53
+ done
54
+ return 1
55
+ }
56
+
57
+ attempt=0
58
+ while (( attempt < MAX_RETRIES )); do
59
+ attempt=$(( attempt + 1 ))
60
+
61
+ if reachable_url=$(pick_reachable_url); then
62
+ log "Attempt $attempt: Supabase reachable via $(echo "$reachable_url" | sed 's/:[^@]*@/:****@/')"
63
+ break
64
+ fi
65
+
66
+ log "Attempt $attempt: Supabase still unreachable — sleeping ${RETRY_INTERVAL_SECS}s"
67
+ sleep "$RETRY_INTERVAL_SECS"
68
+ done
69
+
70
+ if [[ -z "${reachable_url:-}" ]]; then
71
+ log "ERROR: exhausted ${MAX_RETRIES} attempts without ever reaching Supabase. Giving up."
72
+ exit 2
73
+ fi
74
+
75
+ # pg_dump preference: session pooler (5432) or direct, since pgbouncer
76
+ # transaction-mode (6543) breaks pg_dump. If we landed on the txn pooler,
77
+ # switch to session pooler for the actual dump.
78
+ dump_url="$reachable_url"
79
+ if [[ "$dump_url" == *":6543/postgres" ]]; then
80
+ log "Reachable URL is txn pooler — switching to session pooler for the dump"
81
+ dump_url="$SUPABASE_SESSION"
82
+ fi
83
+
84
+ log "Running pg_dump → $DUMP_FILE"
85
+ if ! pg_dump \
86
+ --no-owner --no-acl \
87
+ --no-publications --no-subscriptions \
88
+ --schema=public \
89
+ --quote-all-identifiers \
90
+ --verbose \
91
+ --file="$DUMP_FILE" \
92
+ "$dump_url" 2>>"$LOG_FILE"; then
93
+ log "ERROR: pg_dump failed. Inspect $LOG_FILE for the tail of pg_dump stderr."
94
+ exit 3
95
+ fi
96
+
97
+ dump_bytes=$(stat -f%z "$DUMP_FILE" 2>/dev/null || stat -c%s "$DUMP_FILE")
98
+ log "pg_dump complete — $(printf '%d\n' "$dump_bytes") bytes"
99
+
100
+ # Wipe + restore. The local DB was created empty just before this
101
+ # script ran, so a clean restore is safe.
102
+ log "Resetting public schema on local DB and restoring"
103
+ psql "$LOCAL_URL" -c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;" >>"$LOG_FILE" 2>&1 || {
104
+ log "ERROR: could not reset public schema on local DB"
105
+ exit 4
106
+ }
107
+ if ! psql --quiet --single-transaction \
108
+ --set ON_ERROR_STOP=on \
109
+ "$LOCAL_URL" -f "$DUMP_FILE" >>"$LOG_FILE" 2>&1; then
110
+ log "ERROR: psql restore failed. Inspect $LOG_FILE for details."
111
+ exit 5
112
+ fi
113
+
114
+ # Quick sanity check: count tables + rows in each (top 5 by row count).
115
+ log "Restore complete. Verifying:"
116
+ psql "$LOCAL_URL" -c "
117
+ SELECT schemaname, relname AS table, n_live_tup AS rows
118
+ FROM pg_stat_user_tables
119
+ ORDER BY n_live_tup DESC NULLS LAST
120
+ LIMIT 20;
121
+ " >>"$LOG_FILE" 2>&1 || true
122
+
123
+ # Rewrite .env atomically. Keep a one-shot backup so the original
124
+ # Supabase URL is recoverable if anything explodes.
125
+ log "Backing up .env → $ENV_BACKUP and rewriting DATABASE_URL → local"
126
+ cp -p "$ENV_FILE" "$ENV_BACKUP"
127
+ # Use a sentinel marker line in the replacement so a re-run can be
128
+ # detected; perl in-place edit because BSD sed is annoying on macOS.
129
+ perl -i -pe 'BEGIN{$u=$ENV{LOCAL_URL}} s|^DATABASE_URL=.*$|DATABASE_URL=$u|' \
130
+ LOCAL_URL="$LOCAL_URL" "$ENV_FILE"
131
+ if ! grep -q "^DATABASE_URL=${LOCAL_URL}$" "$ENV_FILE"; then
132
+ log "ERROR: .env rewrite did not take effect. Restoring backup."
133
+ cp -p "$ENV_BACKUP" "$ENV_FILE"
134
+ exit 6
135
+ fi
136
+
137
+ log "Restarting enterprise PM2 process"
138
+ if ! pm2 restart enterprise >>"$LOG_FILE" 2>&1; then
139
+ log "ERROR: pm2 restart failed. Inspect $LOG_FILE."
140
+ exit 7
141
+ fi
142
+
143
+ # Give it a few seconds to start and verify health.
144
+ sleep 8
145
+ if curl -sS -o /dev/null -w "%{http_code}" http://127.0.0.1:3100/ | grep -qE "^(2|3)"; then
146
+ log "Enterprise responding on http://127.0.0.1:3100 — migration successful."
147
+ else
148
+ log "WARN: enterprise restarted but http://127.0.0.1:3100 not yet returning 2xx/3xx. Tail logs/enterprise-error.log."
149
+ fi
150
+
151
+ date > "$SENTINEL"
152
+ log "Wrote sentinel $SENTINEL. Done."
153
+ exit 0