@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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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-
|
|
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.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|