@adaptic/maestro 1.10.0 → 1.10.2
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/.env.example +9 -0
- package/bin/maestro.mjs +92 -0
- package/framework-features.json +10 -0
- package/package.json +1 -1
- package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
- package/scripts/healthcheck.sh +15 -9
- package/scripts/local-triggers/generate-plists.sh +15 -0
- package/scripts/local-triggers/generate-plists.test.mjs +19 -7
- package/scripts/poller/slack-socket-mode.mjs +739 -0
- package/scripts/poller/slack-socket-mode.test.mjs +688 -0
- package/scripts/setup/init-slack-socket-mode.mjs +260 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* init-slack-socket-mode.mjs — Interactive init for the Slack Socket Mode
|
|
4
|
+
* feature.
|
|
5
|
+
*
|
|
6
|
+
* Called by `maestro init slack-socket-mode --apply`. Walks the operator
|
|
7
|
+
* through enabling Socket Mode on the Slack app, generating an app-level
|
|
8
|
+
* token with the `connections:write` scope, and dropping it into .env.
|
|
9
|
+
* Then ensures the launchd plist exists + loads it.
|
|
10
|
+
*
|
|
11
|
+
* Safe to run repeatedly — every step is idempotent and the script never
|
|
12
|
+
* overwrites an existing token. If the operator already configured the
|
|
13
|
+
* feature, the script confirms each piece is in place and exits 0.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { join, dirname, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createInterface } from "node:readline/promises";
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const AGENT_DIR = process.env.AGENT_ROOT || process.env.AGENT_DIR || resolve(__dirname, "..", "..");
|
|
23
|
+
|
|
24
|
+
const ok = (m) => process.stdout.write(`[init-slack-socket-mode] ✓ ${m}\n`);
|
|
25
|
+
const warn = (m) => process.stdout.write(`[init-slack-socket-mode] ⚠ ${m}\n`);
|
|
26
|
+
const info = (m) => process.stdout.write(`[init-slack-socket-mode] ${m}\n`);
|
|
27
|
+
const fail = (m) => process.stderr.write(`[init-slack-socket-mode] ✗ ${m}\n`);
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Step 1 — Print the Slack admin walkthrough
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function printWalkthrough() {
|
|
34
|
+
process.stdout.write(`
|
|
35
|
+
================================================================================
|
|
36
|
+
Slack Socket Mode Setup — 3 steps in the Slack admin UI
|
|
37
|
+
================================================================================
|
|
38
|
+
|
|
39
|
+
This feature gives the agent a persistent WebSocket to Slack so DMs and
|
|
40
|
+
@mentions arrive in real time instead of being polled every 60 seconds.
|
|
41
|
+
You need a Slack app with Socket Mode enabled and an app-level token.
|
|
42
|
+
|
|
43
|
+
Step 1 — Create or open the agent's Slack app
|
|
44
|
+
Open https://api.slack.com/apps
|
|
45
|
+
If you already have an app for this agent's bot, click into it.
|
|
46
|
+
Otherwise: "Create New App" → "From scratch" → pick a workspace.
|
|
47
|
+
|
|
48
|
+
Step 2 — Enable Socket Mode + Event Subscriptions
|
|
49
|
+
In the left sidebar:
|
|
50
|
+
a. "Socket Mode" → toggle ON
|
|
51
|
+
When prompted, generate an App-Level Token. Give it a name
|
|
52
|
+
(e.g. "maestro-socket") and add the connections:write scope.
|
|
53
|
+
Copy the token (xapp-1-…) somewhere safe — you'll paste it
|
|
54
|
+
below in step 3.
|
|
55
|
+
b. "Event Subscriptions" → toggle ON
|
|
56
|
+
Subscribe to BOT events:
|
|
57
|
+
- message.channels
|
|
58
|
+
- message.groups
|
|
59
|
+
- message.im
|
|
60
|
+
- message.mpim
|
|
61
|
+
- app_mention
|
|
62
|
+
Reinstall the app to your workspace when prompted.
|
|
63
|
+
|
|
64
|
+
Step 3 — Add the app-level token to .env
|
|
65
|
+
Paste it on the prompt below, or skip and add it manually as:
|
|
66
|
+
SLACK_APP_LEVEL_TOKEN=xapp-1-…
|
|
67
|
+
in ${join(AGENT_DIR, ".env")}.
|
|
68
|
+
|
|
69
|
+
================================================================================
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Step 2 — Check .env for SLACK_APP_LEVEL_TOKEN and offer to add it
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse `.env` into a plain object. Best-effort — no fancy quoting. Used
|
|
79
|
+
* only to detect whether the token line is present.
|
|
80
|
+
*/
|
|
81
|
+
function readEnv(envPath) {
|
|
82
|
+
if (!existsSync(envPath)) return {};
|
|
83
|
+
const map = {};
|
|
84
|
+
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
|
85
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
86
|
+
if (m) map[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Append a single `KEY=VALUE` line to .env. Does NOT rewrite existing keys
|
|
93
|
+
* — the operator should hand-edit if they need to change a value.
|
|
94
|
+
*/
|
|
95
|
+
function appendEnvLine(envPath, key, value) {
|
|
96
|
+
if (!existsSync(envPath)) {
|
|
97
|
+
writeFileSync(envPath, `${key}=${value}\n`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const body = readFileSync(envPath, "utf-8");
|
|
101
|
+
const suffix = body.endsWith("\n") ? "" : "\n";
|
|
102
|
+
appendFileSync(envPath, `${suffix}${key}=${value}\n`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function promptForToken() {
|
|
106
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
107
|
+
try {
|
|
108
|
+
const answer = await rl.question("Paste your SLACK_APP_LEVEL_TOKEN (xapp-1-…), or press ENTER to skip: ");
|
|
109
|
+
return answer.trim();
|
|
110
|
+
} finally {
|
|
111
|
+
rl.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function ensureTokenInEnv() {
|
|
116
|
+
const envPath = join(AGENT_DIR, ".env");
|
|
117
|
+
const env = readEnv(envPath);
|
|
118
|
+
const existing = env.SLACK_APP_LEVEL_TOKEN || "";
|
|
119
|
+
if (existing.startsWith("xapp-")) {
|
|
120
|
+
ok("SLACK_APP_LEVEL_TOKEN already set in .env");
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Skip the prompt when running in a non-interactive environment (CI,
|
|
125
|
+
// upgrade in --apply mode, etc). The operator can re-run interactively
|
|
126
|
+
// later or hand-edit .env.
|
|
127
|
+
if (!process.stdin.isTTY) {
|
|
128
|
+
warn("SLACK_APP_LEVEL_TOKEN missing and stdin is non-interactive — skipping prompt.");
|
|
129
|
+
info(`Add the token manually to ${envPath}:`);
|
|
130
|
+
info(" SLACK_APP_LEVEL_TOKEN=xapp-1-…");
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const token = await promptForToken();
|
|
135
|
+
if (!token) {
|
|
136
|
+
warn("No token provided — skipping. Add it manually before launchd-loading the plist.");
|
|
137
|
+
info(` echo 'SLACK_APP_LEVEL_TOKEN=xapp-1-…' >> ${envPath}`);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (!token.startsWith("xapp-")) {
|
|
141
|
+
fail(`Token must start with 'xapp-' — got '${token.slice(0, 8)}…'. Skipping.`);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
appendEnvLine(envPath, "SLACK_APP_LEVEL_TOKEN", token);
|
|
145
|
+
ok(`SLACK_APP_LEVEL_TOKEN appended to ${envPath}`);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Step 3 — Ensure the launchd plist exists, then load it
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Derive the agent's launchd label prefix from config/agent.json or fall
|
|
155
|
+
* back to the directory name. Mirrors the resolution in
|
|
156
|
+
* scripts/local-triggers/generate-plists.sh.
|
|
157
|
+
*/
|
|
158
|
+
function resolveAgentFirstName() {
|
|
159
|
+
try {
|
|
160
|
+
const a = JSON.parse(readFileSync(join(AGENT_DIR, "config/agent.json"), "utf-8"));
|
|
161
|
+
if (a.firstName && typeof a.firstName === "string") {
|
|
162
|
+
return a.firstName.toLowerCase();
|
|
163
|
+
}
|
|
164
|
+
} catch { /* try fallback */ }
|
|
165
|
+
return AGENT_DIR.split("/").pop().replace(/-ai$/, "").toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function regenPlistsIfNeeded(firstName) {
|
|
169
|
+
const plistDir = join(AGENT_DIR, "scripts/local-triggers/plists");
|
|
170
|
+
const expected = join(plistDir, `ai.adaptic.${firstName}-slack-socket.plist`);
|
|
171
|
+
if (existsSync(expected)) {
|
|
172
|
+
ok(`plist already generated: ${expected}`);
|
|
173
|
+
return expected;
|
|
174
|
+
}
|
|
175
|
+
// Run the generator. It's idempotent and lives in the agent repo.
|
|
176
|
+
const generator = join(AGENT_DIR, "scripts/local-triggers/generate-plists.sh");
|
|
177
|
+
if (!existsSync(generator)) {
|
|
178
|
+
warn(`generator missing at ${generator} — cannot create plist`);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
info(`generating plists via ${generator}`);
|
|
182
|
+
const r = spawnSync("/bin/bash", [generator], { cwd: AGENT_DIR, encoding: "utf-8" });
|
|
183
|
+
if (r.status !== 0) {
|
|
184
|
+
fail(`generator failed (exit ${r.status}): ${(r.stderr || "").trim()}`);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (existsSync(expected)) {
|
|
188
|
+
ok(`plist generated: ${expected}`);
|
|
189
|
+
return expected;
|
|
190
|
+
}
|
|
191
|
+
warn(`generator ran but ${expected} not found. Re-check generate-plists.sh.`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Copy the generated plist into ~/Library/LaunchAgents/ and launchctl-load
|
|
197
|
+
* it. Idempotent: if already loaded, unload-then-load to pick up changes.
|
|
198
|
+
*/
|
|
199
|
+
function loadPlist(plistPath, label) {
|
|
200
|
+
const home = process.env.HOME || "";
|
|
201
|
+
if (!home) {
|
|
202
|
+
warn("HOME not set — cannot install plist");
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const installedDir = join(home, "Library/LaunchAgents");
|
|
206
|
+
mkdirSync(installedDir, { recursive: true });
|
|
207
|
+
const installed = join(installedDir, `${label}.plist`);
|
|
208
|
+
|
|
209
|
+
// Copy (preserve atomicity by writing-then-renaming).
|
|
210
|
+
try {
|
|
211
|
+
const body = readFileSync(plistPath, "utf-8");
|
|
212
|
+
writeFileSync(installed + ".tmp", body);
|
|
213
|
+
// Use rename to make the install atomic.
|
|
214
|
+
spawnSync("/bin/mv", [installed + ".tmp", installed], { encoding: "utf-8" });
|
|
215
|
+
ok(`copied to ${installed}`);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
fail(`could not install plist: ${err.message}`);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Unload any existing instance, then load the fresh one.
|
|
222
|
+
spawnSync("launchctl", ["unload", installed], { encoding: "utf-8" });
|
|
223
|
+
const loadRes = spawnSync("launchctl", ["load", installed], { encoding: "utf-8" });
|
|
224
|
+
if (loadRes.status !== 0) {
|
|
225
|
+
warn(`launchctl load returned ${loadRes.status}: ${(loadRes.stderr || "").trim()}`);
|
|
226
|
+
info("You can retry manually: launchctl load " + installed);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
ok(`launchctl loaded ${label}`);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Main
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async function main() {
|
|
238
|
+
printWalkthrough();
|
|
239
|
+
const tokenOk = await ensureTokenInEnv();
|
|
240
|
+
|
|
241
|
+
const firstName = resolveAgentFirstName();
|
|
242
|
+
const label = `ai.adaptic.${firstName}-slack-socket`;
|
|
243
|
+
const plistPath = regenPlistsIfNeeded(firstName);
|
|
244
|
+
|
|
245
|
+
if (plistPath && tokenOk) {
|
|
246
|
+
loadPlist(plistPath, label);
|
|
247
|
+
} else if (plistPath && !tokenOk) {
|
|
248
|
+
info("Skipping launchctl load — add SLACK_APP_LEVEL_TOKEN to .env first, then run:");
|
|
249
|
+
info(` launchctl load ~/Library/LaunchAgents/${label}.plist`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
ok("init-slack-socket-mode complete");
|
|
253
|
+
info("Verify with: maestro doctor (checks token + plist + recent connect log)");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
main().catch((err) => {
|
|
257
|
+
fail(`unexpected error: ${err.message}`);
|
|
258
|
+
process.stderr.write(`${err.stack}\n`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|