@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.
@@ -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
+ });