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.
- package/README.md +72 -22
- package/bin/api_tools.js +215 -0
- package/bin/cli.js +63 -324
- package/bin/connect.js +715 -0
- package/bin/integrations.js +367 -0
- package/bin/shared.js +351 -0
- package/dist/server.js +121 -1
- package/package.json +3 -2
|
@@ -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}[0m` : 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
|
+
`;
|