premanmcp 0.4.0 → 0.7.0

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,367 @@
1
+ /**
2
+ * Connect AWS, GitHub and Slack from the terminal.
3
+ *
4
+ * Each of these already had a backend that hands back a URL and a way to ask
5
+ * whether the customer finished. What was missing was anywhere to drive them
6
+ * except the dashboard, which meant a terminal-first user had to stop, find the
7
+ * web app, and hunt for three different settings screens before PreMan could
8
+ * show them anything.
9
+ *
10
+ * The shape is the same for all three because the constraint is the same: the
11
+ * customer's session with AWS, GitHub or Slack lives in their browser, not in
12
+ * this process. So we ask the backend for a URL, open it, and poll our own API
13
+ * until the other side reports the connection exists. Nothing is copied back by
14
+ * hand, and no third-party credential ever touches the terminal.
15
+ */
16
+
17
+ import { spawn } from "node:child_process";
18
+
19
+ import {
20
+ assertOk,
21
+ callBackendJson,
22
+ frontendUrl,
23
+ promptText,
24
+ resolveApiKey,
25
+ } from "./shared.js";
26
+
27
+ const POLL_INTERVAL_MS = 3000;
28
+ const POLL_TIMEOUT_MS = 300000;
29
+
30
+ /**
31
+ * Colour only when someone is actually watching.
32
+ *
33
+ * Escape codes in piped output end up in log files and CI transcripts as
34
+ * literal noise, and NO_COLOR is the standard way to ask us not to. The mark
35
+ * itself still prints in both cases -- it carries the meaning, the colour only
36
+ * makes it quicker to find.
37
+ */
38
+ const USE_COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
39
+
40
+ function paint(code, text) {
41
+ return USE_COLOR ? `[${code}m${text}` : text;
42
+ }
43
+
44
+ export const MARK = {
45
+ ok: () => paint("32", "✓"),
46
+ skip: () => paint("90", "○"),
47
+ fail: () => paint("31", "✗"),
48
+ };
49
+
50
+ /** A finished step, marked so it can be found at a glance. */
51
+ export function connected(message) {
52
+ process.stdout.write(`${MARK.ok()} ${message}\n`);
53
+ }
54
+
55
+ /** Open a URL in the customer's default browser, best effort. */
56
+ export function openUrl(url) {
57
+ const command =
58
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
59
+ try {
60
+ const child = spawn(command, [url], {
61
+ stdio: "ignore",
62
+ detached: true,
63
+ shell: process.platform === "win32",
64
+ });
65
+ child.unref();
66
+ return true;
67
+ } catch {
68
+ // Headless boxes and locked-down shells are normal. The URL is printed
69
+ // either way, so this is a convenience, never the only path.
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function present(url, what) {
75
+ process.stdout.write(`\n ${url}\n\n`);
76
+ if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
77
+ else process.stdout.write(`Open the link above to ${what}.\n`);
78
+ }
79
+
80
+ /**
81
+ * Raised by a poll that will never succeed, however long we wait.
82
+ *
83
+ * Distinct from an ordinary failed poll because the two need opposite
84
+ * handling: a network blip while the customer is in another tab should be
85
+ * ignored, but a stack deployed into the wrong AWS account will report the
86
+ * same answer forever, and waiting five minutes to say so is five minutes of
87
+ * the customer believing it might still work.
88
+ */
89
+ export class Unrecoverable extends Error {}
90
+
91
+ /**
92
+ * Poll ``check`` until it reports done.
93
+ *
94
+ * Returns the truthy value from ``check``, or null on timeout. Ordinary
95
+ * exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
96
+ */
97
+ async function waitFor(label, check) {
98
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
99
+ process.stdout.write(`Waiting for ${label}`);
100
+ while (Date.now() < deadline) {
101
+ try {
102
+ const done = await check();
103
+ if (done) {
104
+ process.stdout.write(" done.\n");
105
+ return done;
106
+ }
107
+ } catch (err) {
108
+ if (err instanceof Unrecoverable) {
109
+ process.stdout.write("\n");
110
+ throw err;
111
+ }
112
+ /* keep waiting; the customer is elsewhere */
113
+ }
114
+ process.stdout.write(".");
115
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
116
+ }
117
+ process.stdout.write("\n");
118
+ return null;
119
+ }
120
+
121
+ function requireKey(args) {
122
+ const apiKey = resolveApiKey(args);
123
+ if (!apiKey) {
124
+ throw new Error("Not signed in. Run 'preman login' first.");
125
+ }
126
+ return apiKey;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // AWS
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export async function awsCommand(args) {
134
+ const token = requireKey(args);
135
+
136
+ const accountId = (
137
+ args.value("--account", "") || (await promptText("AWS account ID (12 digits): "))
138
+ ).trim();
139
+ const region = args.value("--region", "us-east-1");
140
+
141
+ const created = await callBackendJson(args, "POST", "/aws-links", {
142
+ token,
143
+ json: { aws_account_id: accountId, console_region: region },
144
+ });
145
+ assertOk(created, "create AWS connection");
146
+
147
+ const setup = created.setup || {};
148
+ process.stdout.write("\nThis grants PreMan read-only access to:\n");
149
+ for (const grant of setup.grants || []) process.stdout.write(` - ${grant}\n`);
150
+
151
+ if (setup.quick_create_url) {
152
+ present(setup.quick_create_url, "create the CloudFormation stack");
153
+ process.stdout.write("Tick the IAM acknowledgement, then click Create stack.\n");
154
+ } else {
155
+ // No hosted template in this deployment: the CLI path still works.
156
+ process.stdout.write(`\nRun this to create the role:\n\n ${setup.cli_command}\n\n`);
157
+ }
158
+
159
+ const linkId = created.link?.id;
160
+ let verified;
161
+ try {
162
+ verified = await waitFor("the stack", async () => {
163
+ const result = await callBackendJson(args, "POST", `/aws-links/${linkId}/verify`, {
164
+ token,
165
+ query: { region },
166
+ });
167
+ // Waiting cannot fix a stack built in the wrong account: it will report
168
+ // this same answer until the customer redeploys somewhere else.
169
+ if (result.reason === "wrong_account") throw new Unrecoverable(result.remediation);
170
+ return result.verified ? result : null;
171
+ });
172
+ } catch (err) {
173
+ if (!(err instanceof Unrecoverable)) throw err;
174
+ process.stdout.write(`${err.message}\n`);
175
+ return;
176
+ }
177
+
178
+ if (!verified) {
179
+ process.stdout.write("Timed out. Re-run 'preman aws' once the stack finishes.\n");
180
+ return;
181
+ }
182
+ connected(`AWS connected: ${verified.role_arn}`);
183
+
184
+ const groups = await callBackendJson(args, "GET", `/aws-links/${linkId}/log-groups`, {
185
+ token,
186
+ query: { region },
187
+ });
188
+ const names = (groups.log_groups || []).map((g) => g.name);
189
+ if (!names.length) {
190
+ process.stdout.write(`No log groups found in ${region}.\n`);
191
+ return;
192
+ }
193
+
194
+ process.stdout.write("\nLog groups PreMan can read:\n");
195
+ names.slice(0, 10).forEach((name, i) => process.stdout.write(` ${i + 1}. ${name}\n`));
196
+
197
+ const projectId = args.value("--project-id", "");
198
+ if (!projectId) {
199
+ process.stdout.write("\nPass --project-id to start streaming one of these.\n");
200
+ return;
201
+ }
202
+
203
+ const pick = args.value("--log-group", "") || (await promptText("Stream which one? [1]: "));
204
+ const chosen = names.includes(pick) ? pick : names[Math.max(0, Number(pick || 1) - 1)];
205
+
206
+ const connector = await callBackendJson(args, "POST", `/projects/${projectId}/log-connectors`, {
207
+ token,
208
+ json: {
209
+ name: chosen,
210
+ connector_type: "cloudwatch",
211
+ stream: "backend",
212
+ interval_seconds: 15,
213
+ config: { account_link_id: linkId, region, log_group: chosen },
214
+ },
215
+ });
216
+ assertOk(connector, "create log connector");
217
+ connected(`Streaming ${chosen}. Logs appear in PreMan within about a minute.`);
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // GitHub
222
+ // ---------------------------------------------------------------------------
223
+
224
+ export async function githubCommand(args) {
225
+ const token = requireKey(args);
226
+
227
+ const before = await callBackendJson(args, "GET", "/integrations/github", { token });
228
+ const seen = new Set((before.integrations || before.repos || []).map((r) => r.id));
229
+
230
+ const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
231
+ token,
232
+ json: {},
233
+ });
234
+ assertOk(started, "start GitHub install");
235
+
236
+ const url = started.install_url || started.url;
237
+ if (!url) throw new Error("Backend returned no GitHub install URL.");
238
+ present(url, "install the PreMan GitHub App");
239
+ process.stdout.write("Pick the repositories PreMan may read.\n");
240
+
241
+ const done = await waitFor("the installation", async () => {
242
+ const now = await callBackendJson(args, "GET", "/integrations/github", { token });
243
+ const rows = now.integrations || now.repos || [];
244
+ // Compare against what existed before, so a user who already had repos
245
+ // connected is not told they are done the moment polling starts.
246
+ const fresh = rows.filter((r) => !seen.has(r.id));
247
+ return fresh.length ? fresh : null;
248
+ });
249
+
250
+ if (!done) {
251
+ process.stdout.write("Timed out. Re-run 'preman github' if the install finished.\n");
252
+ return;
253
+ }
254
+ connected(`GitHub connected: ${done.length} repository(ies).`);
255
+ for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Slack
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export async function slackCommand(args) {
263
+ const token = requireKey(args);
264
+
265
+ const before = await callBackendJson(args, "GET", "/slack/connections", { token });
266
+ const seen = new Set((before.connections || []).map((c) => c.id));
267
+
268
+ const started = await callBackendJson(args, "POST", "/slack/install", { token, json: {} });
269
+ assertOk(started, "start Slack install");
270
+
271
+ const url = started.install_url || started.url;
272
+ if (!url) throw new Error("Backend returned no Slack install URL.");
273
+ present(url, "add PreMan to your Slack workspace");
274
+
275
+ const done = await waitFor("the Slack install", async () => {
276
+ const now = await callBackendJson(args, "GET", "/slack/connections", { token });
277
+ const fresh = (now.connections || []).filter((c) => !seen.has(c.id));
278
+ return fresh.length ? fresh : null;
279
+ });
280
+
281
+ if (!done) {
282
+ process.stdout.write("Timed out. Re-run 'preman slack' if the install finished.\n");
283
+ return;
284
+ }
285
+ connected(`Slack connected: ${done[0].team_name || done[0].id}`);
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // The guided run
290
+ // ---------------------------------------------------------------------------
291
+
292
+ async function askYes(question, { assumeYes }) {
293
+ if (assumeYes) return true;
294
+ const answer = (await promptText(`${question} [Y/n]: `)).trim().toLowerCase();
295
+ return answer === "" || answer === "y" || answer === "yes";
296
+ }
297
+
298
+ /**
299
+ * Everything a new account needs, in one pass.
300
+ *
301
+ * Each step is optional and each failure is survivable: a customer who cannot
302
+ * finish Slack today should still leave with AWS streaming, so a step that
303
+ * throws is reported and the run continues rather than unwinding the ones that
304
+ * already worked.
305
+ */
306
+ export async function onboardCommand(commandArgs, { makeArgs, authenticateTerminal, connectCommand }) {
307
+ const args = makeArgs(commandArgs);
308
+ const assumeYes = args.has("--yes");
309
+
310
+ process.stdout.write("PreMan setup\n\n");
311
+
312
+ const creds = await authenticateTerminal(args);
313
+ connected(`Signed in as ${creds.user_email || "your account"}.`);
314
+
315
+ const steps = [
316
+ {
317
+ name: "coding agent",
318
+ question: "Connect your coding agent?",
319
+ run: () => connectCommand([...commandArgs, "--skip-login"]),
320
+ },
321
+ { name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
322
+ { name: "AWS logs", question: "Connect AWS?", run: () => awsCommand(args) },
323
+ { name: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
324
+ ];
325
+
326
+ const done = [];
327
+ const skipped = [];
328
+ const failed = [];
329
+
330
+ for (const step of steps) {
331
+ process.stdout.write(`\n── ${step.name} ──\n`);
332
+ if (!(await askYes(step.question, { assumeYes }))) {
333
+ skipped.push(step.name);
334
+ continue;
335
+ }
336
+ try {
337
+ await step.run();
338
+ done.push(step.name);
339
+ } catch (err) {
340
+ // Report and carry on: a failed Slack install must not cost the customer
341
+ // the AWS connection they just finished.
342
+ failed.push(`${step.name}: ${err.message}`);
343
+ process.stdout.write(`Could not finish ${step.name}: ${err.message}\n`);
344
+ }
345
+ }
346
+
347
+ process.stdout.write("\n── done ──\n");
348
+ // One line per step, marked, so the outcome is scannable rather than prose.
349
+ for (const name of done) process.stdout.write(`${MARK.ok()} ${name}\n`);
350
+ for (const name of skipped) process.stdout.write(`${MARK.skip()} ${name} (skipped)\n`);
351
+ for (const failure of failed) process.stdout.write(`${MARK.fail()} ${failure}\n`);
352
+ process.stdout.write(`\nOpen ${frontendUrl(args)} to see your logs and endpoints.\n`);
353
+ }
354
+
355
+ export const INTEGRATIONS_HELP = `
356
+ Setup options:
357
+ preman onboard Sign in, then connect agent, GitHub, AWS and Slack
358
+ preman aws Connect an AWS account and stream a log group
359
+ preman github Install the PreMan GitHub App
360
+ preman slack Add PreMan to a Slack workspace
361
+
362
+ --yes Accept every step without prompting (onboard)
363
+ --account <id> AWS account id, skips the prompt
364
+ --region <region> AWS region for log groups. Defaults to us-east-1
365
+ --project-id <id> PreMan project to attach the log connector to
366
+ --log-group <name> Log group to stream, skips the picker
367
+ `;