premanmcp 0.10.4 → 0.10.6
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 +60 -14
- package/bin/account.js +34 -0
- package/bin/api_tools.js +1 -1
- package/bin/cli.js +23 -5
- package/bin/connect.js +130 -15
- package/bin/hook.js +276 -22
- package/bin/integrations.js +86 -13
- package/bin/runner.js +51 -8
- package/bin/shared.js +66 -20
- package/dist/server.js +25 -0
- package/package.json +2 -2
package/bin/hook.js
CHANGED
|
@@ -8,24 +8,37 @@
|
|
|
8
8
|
* by construction rather than by configuration.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
12
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
14
16
|
|
|
15
|
-
import { cliInvocation, makeArgs } from "./shared.js";
|
|
17
|
+
import { cliInvocation, makeArgs, packageVersion } from "./shared.js";
|
|
16
18
|
import { BLOCK_EXIT_CODE } from "./verify.js";
|
|
17
19
|
|
|
18
20
|
export const HOOK_HELP = `
|
|
19
21
|
Hook options:
|
|
20
22
|
install Write the pre-push hook into this repository
|
|
21
23
|
uninstall Remove PreMan's pre-push hook
|
|
22
|
-
status Report whether the hook is installed
|
|
24
|
+
status Report whether the hook is installed, and still works
|
|
25
|
+
repair Rewrite a hook of ours whose command no longer answers
|
|
23
26
|
--force Overwrite a foreign pre-push hook (a backup is kept)
|
|
27
|
+
PREMAN_HOOK_INVOCATION=<cmd> Use this command line instead of probing for one
|
|
28
|
+
PREMAN_NO_HOOK_REPAIR=1 Never repair a hook in the background
|
|
24
29
|
`;
|
|
25
30
|
|
|
26
31
|
const MARKER = "# >>> preman pre-push >>>";
|
|
27
32
|
const END_MARKER = "# <<< preman pre-push <<<";
|
|
28
33
|
const HOOK_TIMEOUT_SECONDS = 120;
|
|
34
|
+
const PROBE_TIMEOUT_MS = 45000;
|
|
35
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const HEALTH_FILE = path.join(os.homedir(), ".preman", "hook-health.json");
|
|
37
|
+
// How long a healthy answer is believed for. The repair costs a probe, and a
|
|
38
|
+
// hook that worked an hour ago almost always still works, so this is the knob
|
|
39
|
+
// that keeps a background check off the critical path of every command.
|
|
40
|
+
const RECHECK_MS = 60 * 60 * 1000;
|
|
41
|
+
const HEALTH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
29
42
|
|
|
30
43
|
function gitDir() {
|
|
31
44
|
const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
|
|
@@ -39,7 +52,90 @@ function hookPath() {
|
|
|
39
52
|
return path.join(gitDir(), "hooks", "pre-push");
|
|
40
53
|
}
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The command lines a hook could carry, best first.
|
|
57
|
+
*
|
|
58
|
+
* A hook is written once and read at every push, so `@latest` quietly hands each
|
|
59
|
+
* push to whatever we ship next; the version that wrote the hook is the one that
|
|
60
|
+
* keeps running it, and `preman hook install` is how that moves. `@latest` is
|
|
61
|
+
* left only for the case where we cannot read our own manifest to know it.
|
|
62
|
+
*
|
|
63
|
+
* The bare `preman` form is offered first when it is ours — see `pathPremanOwner`
|
|
64
|
+
* — because it starts in milliseconds where npm exec does not.
|
|
65
|
+
*/
|
|
66
|
+
export function hookInvocations() {
|
|
67
|
+
const version = packageVersion();
|
|
68
|
+
const pinned = `npm exec -y premanmcp@${version || "latest"} --`;
|
|
69
|
+
const preferred = cliInvocation();
|
|
70
|
+
return preferred.startsWith("npm exec") ? [pinned] : [preferred, pinned];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const probed = new Map();
|
|
74
|
+
|
|
75
|
+
/** An invocation the user pinned by hand, or "". */
|
|
76
|
+
function declaredInvocation() {
|
|
77
|
+
return String(process.env.PREMAN_HOOK_INVOCATION || "").trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Does this command line reach a PreMan CLI that knows `verify`, from a process
|
|
82
|
+
* that is not this one?
|
|
83
|
+
*
|
|
84
|
+
* This is the check whose absence produced `Unknown command: verify` on every
|
|
85
|
+
* push: the hook is generated shell run minutes or months later, so the only
|
|
86
|
+
* evidence that matters is a separate process answering. `help` is the cheapest
|
|
87
|
+
* subcommand that proves both halves — that something ran, and that it was us
|
|
88
|
+
* rather than another package's identically named `preman`.
|
|
89
|
+
*
|
|
90
|
+
* Memoized per command line, because a connect installs and then re-reads the
|
|
91
|
+
* hook, and npm exec is slow enough that paying twice shows.
|
|
92
|
+
*/
|
|
93
|
+
export function invocationAnswers(invocation) {
|
|
94
|
+
// Declaring an invocation means "use this and stop asking", so the same answer
|
|
95
|
+
// has to hold when we later read it back out of a hook -- otherwise the escape
|
|
96
|
+
// hatch writes a hook that every status then calls broken.
|
|
97
|
+
if (invocation && invocation === declaredInvocation()) return true;
|
|
98
|
+
if (probed.has(invocation)) return probed.get(invocation);
|
|
99
|
+
let answered = false;
|
|
100
|
+
try {
|
|
101
|
+
const probe = spawnSync(`${invocation} help`, {
|
|
102
|
+
shell: true,
|
|
103
|
+
encoding: "utf8",
|
|
104
|
+
timeout: Number(process.env.PREMAN_HOOK_PROBE_MS) || PROBE_TIMEOUT_MS,
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
});
|
|
107
|
+
answered = probe.status === 0 && /\bverify\b/.test(String(probe.stdout || ""));
|
|
108
|
+
} catch {
|
|
109
|
+
answered = false;
|
|
110
|
+
}
|
|
111
|
+
probed.set(invocation, answered);
|
|
112
|
+
return answered;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Test seam: the probe above is memoized for the life of the process. */
|
|
116
|
+
export function resetInvocationProbe() {
|
|
117
|
+
probed.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The invocation to write into a hook, or "" when nothing here can run PreMan.
|
|
122
|
+
*
|
|
123
|
+
* `PREMAN_HOOK_INVOCATION` is taken on trust: it exists for the setups we cannot
|
|
124
|
+
* probe our way to — a wrapper script, a monorepo runner, a pinned mirror.
|
|
125
|
+
*/
|
|
126
|
+
export function provenInvocation() {
|
|
127
|
+
return (
|
|
128
|
+
declaredInvocation() || hookInvocations().find((candidate) => invocationAnswers(candidate)) || ""
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The command line an already-written hook calls, or "". */
|
|
133
|
+
function embeddedInvocation(text) {
|
|
134
|
+
const match = /^\s*PREMAN_HOOK=1 (.+) verify --pre-push\b/m.exec(text);
|
|
135
|
+
return match ? match[1].trim() : "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hookBody(invocation) {
|
|
43
139
|
// `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
|
|
44
140
|
// non-zero and still exit 0 itself.
|
|
45
141
|
//
|
|
@@ -51,7 +147,7 @@ ${MARKER}
|
|
|
51
147
|
# Advisory unless this repository opted into blocking: only exit code
|
|
52
148
|
# ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
|
|
53
149
|
if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
|
|
54
|
-
PREMAN_HOOK=1 ${
|
|
150
|
+
PREMAN_HOOK=1 ${invocation} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
|
|
55
151
|
preman_status=$?
|
|
56
152
|
if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
|
|
57
153
|
exit ${BLOCK_EXIT_CODE}
|
|
@@ -71,16 +167,31 @@ function isOurHook(text) {
|
|
|
71
167
|
return text.includes(MARKER);
|
|
72
168
|
}
|
|
73
169
|
|
|
74
|
-
export function installHook(args) {
|
|
170
|
+
export function installHook(args, { invocation = provenInvocation() } = {}) {
|
|
75
171
|
const target = hookPath();
|
|
172
|
+
|
|
173
|
+
// A hook holding a command that does not run is worse than no hook: it is
|
|
174
|
+
// silent, it says "installed" in every status we print, and the only sign of
|
|
175
|
+
// it is one skipped line scrolling past a push nobody reads.
|
|
176
|
+
if (!invocation) {
|
|
177
|
+
return {
|
|
178
|
+
path: target,
|
|
179
|
+
action: "unproven",
|
|
180
|
+
detail:
|
|
181
|
+
"no way to run the PreMan CLI from a hook was found here" +
|
|
182
|
+
" -- install it globally (npm i -g premanmcp) or set PREMAN_HOOK_INVOCATION",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const body = hookBody(invocation);
|
|
76
187
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
77
188
|
|
|
78
189
|
if (existsSync(target)) {
|
|
79
190
|
const existing = readFileSync(target, "utf8");
|
|
80
191
|
if (isOurHook(existing)) {
|
|
81
|
-
writeFileSync(target,
|
|
192
|
+
writeFileSync(target, body, { mode: 0o755 });
|
|
82
193
|
chmodSync(target, 0o755);
|
|
83
|
-
return { path: target, action: "updated" };
|
|
194
|
+
return { path: target, action: "updated", invocation };
|
|
84
195
|
}
|
|
85
196
|
if (!args.has("--force")) {
|
|
86
197
|
return {
|
|
@@ -91,14 +202,19 @@ export function installHook(args) {
|
|
|
91
202
|
}
|
|
92
203
|
const backup = `${target}.preman-backup`;
|
|
93
204
|
writeFileSync(backup, existing, { mode: 0o755 });
|
|
94
|
-
writeFileSync(target,
|
|
205
|
+
writeFileSync(target, body, { mode: 0o755 });
|
|
95
206
|
chmodSync(target, 0o755);
|
|
96
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
path: target,
|
|
209
|
+
action: "replaced",
|
|
210
|
+
invocation,
|
|
211
|
+
detail: `previous hook saved to ${backup}`,
|
|
212
|
+
};
|
|
97
213
|
}
|
|
98
214
|
|
|
99
|
-
writeFileSync(target,
|
|
215
|
+
writeFileSync(target, body, { mode: 0o755 });
|
|
100
216
|
chmodSync(target, 0o755);
|
|
101
|
-
return { path: target, action: "installed" };
|
|
217
|
+
return { path: target, action: "installed", invocation };
|
|
102
218
|
}
|
|
103
219
|
|
|
104
220
|
export function uninstallHook() {
|
|
@@ -122,17 +238,124 @@ export function uninstallHook() {
|
|
|
122
238
|
* `current` is what tells a caller an installed hook still needs rewriting.
|
|
123
239
|
*
|
|
124
240
|
* A hook is generated shell holding one invocation, and that invocation can go
|
|
125
|
-
* stale — the machine gained a `preman` that is not ours,
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* tell the difference.
|
|
241
|
+
* stale — the machine gained a `preman` that is not ours, lost the one that was,
|
|
242
|
+
* or upgraded past the version the hook pins. "Installed" then means a file
|
|
243
|
+
* exists that calls the wrong thing on every push, so anything that skips work
|
|
244
|
+
* when the hook is present has to be able to tell the difference.
|
|
245
|
+
*
|
|
246
|
+
* `works` answers the harder question — does the command in the file still run
|
|
247
|
+
* PreMan — so callers ask for it: `preman hook status` and `preman doctor` do,
|
|
248
|
+
* connect does not. Both answers cost a probe for an installed hook, since
|
|
249
|
+
* "up to date" means "equal to what we would write now", and what we would write
|
|
250
|
+
* is whatever answers here. An absent or foreign hook costs nothing.
|
|
129
251
|
*/
|
|
130
|
-
export function hookStatus() {
|
|
252
|
+
export function hookStatus({ probe = false } = {}) {
|
|
131
253
|
const target = hookPath();
|
|
132
|
-
|
|
254
|
+
const missing = { path: target, current: false, invocation: "", works: null };
|
|
255
|
+
if (!existsSync(target)) return { ...missing, state: "absent" };
|
|
133
256
|
const existing = readFileSync(target, "utf8");
|
|
134
|
-
if (!isOurHook(existing)) return {
|
|
135
|
-
|
|
257
|
+
if (!isOurHook(existing)) return { ...missing, state: "foreign" };
|
|
258
|
+
|
|
259
|
+
const invocation = embeddedInvocation(existing);
|
|
260
|
+
// Falling back to what the file already carries keeps a machine that can no
|
|
261
|
+
// longer prove any invocation -- offline, say -- from reporting a hook as out
|
|
262
|
+
// of date and sending its owner to a reinstall that would refuse to write.
|
|
263
|
+
const wanted = provenInvocation() || invocation;
|
|
264
|
+
return {
|
|
265
|
+
path: target,
|
|
266
|
+
state: "installed",
|
|
267
|
+
invocation,
|
|
268
|
+
current: Boolean(wanted) && existing === hookBody(wanted),
|
|
269
|
+
works: probe ? Boolean(invocation) && invocationAnswers(invocation) : null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readHookChecks() {
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(readFileSync(HEALTH_FILE, "utf8"));
|
|
276
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
277
|
+
} catch {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Remember what a check found, so the next command does not pay for it again. */
|
|
283
|
+
function rememberHookCheck(cwd, now, fields) {
|
|
284
|
+
const checks = readHookChecks();
|
|
285
|
+
for (const [key, value] of Object.entries(checks)) {
|
|
286
|
+
if (now - Number(value?.at || 0) > HEALTH_TTL_MS) delete checks[key];
|
|
287
|
+
}
|
|
288
|
+
checks[cwd] = { at: now, ...fields };
|
|
289
|
+
try {
|
|
290
|
+
mkdirSync(path.dirname(HEALTH_FILE), { recursive: true });
|
|
291
|
+
writeFileSync(HEALTH_FILE, JSON.stringify(checks, null, 2) + "\n", { mode: 0o600 });
|
|
292
|
+
} catch {
|
|
293
|
+
// A cache we cannot write costs a probe next time and nothing else.
|
|
294
|
+
}
|
|
295
|
+
return { cwd, ...fields };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Put a hook of ours back into working order, without being asked.
|
|
300
|
+
*
|
|
301
|
+
* The failure this exists for is invisible: a hook written by a version that
|
|
302
|
+
* embedded the wrong command keeps printing one skipped line per push, and
|
|
303
|
+
* nothing re-runs `connect` after an upgrade to notice. So anything that proves
|
|
304
|
+
* PreMan is running here -- any command, the MCP server starting -- is taken as
|
|
305
|
+
* the moment to check.
|
|
306
|
+
*
|
|
307
|
+
* Deliberately narrow. It only ever rewrites a hook we wrote that no longer
|
|
308
|
+
* answers: an absent hook is not installed behind the user, a foreign hook is
|
|
309
|
+
* not touched, and a hook that works is left on whatever version it pins.
|
|
310
|
+
*/
|
|
311
|
+
export function repairDeadHook({ cwd = process.cwd(), now = Date.now(), force = false } = {}) {
|
|
312
|
+
const remember = (fields) => rememberHookCheck(cwd, now, fields);
|
|
313
|
+
if (!force) {
|
|
314
|
+
const last = readHookChecks()[cwd];
|
|
315
|
+
if (last && now - Number(last.at || 0) < RECHECK_MS) return { cwd, action: "recent" };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let status;
|
|
319
|
+
try {
|
|
320
|
+
status = hookStatus({ probe: true });
|
|
321
|
+
} catch {
|
|
322
|
+
return remember({ action: "not-a-repository" });
|
|
323
|
+
}
|
|
324
|
+
if (status.state !== "installed") return remember({ action: status.state });
|
|
325
|
+
if (status.works) return remember({ action: "healthy", invocation: status.invocation });
|
|
326
|
+
|
|
327
|
+
const result = installHook(makeArgs([]));
|
|
328
|
+
return remember({
|
|
329
|
+
action: result.action === "updated" ? "repaired" : result.action,
|
|
330
|
+
invocation: result.invocation || "",
|
|
331
|
+
replaced: status.invocation,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Run the repair in a detached child, because every step of it is synchronous
|
|
337
|
+
* and one of them shells out to npm. Nothing that merely wants a healthy hook
|
|
338
|
+
* should wait on a probe, least of all the MCP server, whose stdout is a
|
|
339
|
+
* protocol -- hence stdio ignored rather than inherited.
|
|
340
|
+
*/
|
|
341
|
+
export function scheduleHookRepair({ cwd = process.cwd(), now = Date.now() } = {}) {
|
|
342
|
+
if (process.env.PREMAN_HOOK) return { action: "skipped", reason: "running inside the hook" };
|
|
343
|
+
if (process.env.PREMAN_NO_HOOK_REPAIR) return { action: "skipped", reason: "disabled" };
|
|
344
|
+
const last = readHookChecks()[cwd];
|
|
345
|
+
if (last && now - Number(last.at || 0) < RECHECK_MS) {
|
|
346
|
+
return { action: "skipped", reason: "checked recently" };
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const child = spawn(process.execPath, [path.join(__dirname, "cli.js"), "hook", "repair"], {
|
|
350
|
+
cwd,
|
|
351
|
+
detached: true,
|
|
352
|
+
stdio: "ignore",
|
|
353
|
+
});
|
|
354
|
+
child.unref();
|
|
355
|
+
return { action: "spawned", pid: child.pid };
|
|
356
|
+
} catch {
|
|
357
|
+
return { action: "skipped", reason: "could not spawn" };
|
|
358
|
+
}
|
|
136
359
|
}
|
|
137
360
|
|
|
138
361
|
export async function hookCommand(commandArgs = []) {
|
|
@@ -141,19 +364,36 @@ export async function hookCommand(commandArgs = []) {
|
|
|
141
364
|
|
|
142
365
|
if (sub === "install") {
|
|
143
366
|
const result = installHook(args);
|
|
144
|
-
if (result.action === "conflict") {
|
|
367
|
+
if (result.action === "conflict" || result.action === "unproven") {
|
|
145
368
|
process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
|
|
146
369
|
return result;
|
|
147
370
|
}
|
|
148
371
|
process.stdout.write(
|
|
149
372
|
`Pre-push hook ${result.action}: ${result.path}\n` +
|
|
150
373
|
(result.detail ? ` ${result.detail}\n` : "") +
|
|
374
|
+
` Runs: ${result.invocation} verify --pre-push\n` +
|
|
151
375
|
`\nPreMan will now check affected endpoints before each push.\n` +
|
|
152
376
|
`It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
|
|
153
377
|
);
|
|
154
378
|
return result;
|
|
155
379
|
}
|
|
156
380
|
|
|
381
|
+
if (sub === "repair") {
|
|
382
|
+
// Asked for by hand, "repair" means now: the hourly gate exists to keep this
|
|
383
|
+
// off the critical path of unrelated commands, not to refuse a person.
|
|
384
|
+
const result = repairDeadHook({ force: !args.has("--if-stale") });
|
|
385
|
+
const said = {
|
|
386
|
+
repaired: () => `Pre-push hook repaired: it now runs ${result.invocation}\n`,
|
|
387
|
+
healthy: () => "Pre-push hook already works here.\n",
|
|
388
|
+
recent: () => "Pre-push hook checked recently; nothing to do.\n",
|
|
389
|
+
absent: () => `No PreMan hook here. Install one: ${cliInvocation()} hook install\n`,
|
|
390
|
+
foreign: () => "The pre-push hook here is not ours; left alone.\n",
|
|
391
|
+
unproven: () => "Cannot repair: no way to run the PreMan CLI was found here.\n",
|
|
392
|
+
}[result.action];
|
|
393
|
+
process.stdout.write(said ? said() : `Pre-push hook: ${result.action}\n`);
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
|
|
157
397
|
if (sub === "uninstall") {
|
|
158
398
|
const result = uninstallHook();
|
|
159
399
|
process.stdout.write(
|
|
@@ -163,13 +403,27 @@ export async function hookCommand(commandArgs = []) {
|
|
|
163
403
|
}
|
|
164
404
|
|
|
165
405
|
if (sub === "status") {
|
|
166
|
-
const result = hookStatus();
|
|
406
|
+
const result = hookStatus({ probe: true });
|
|
167
407
|
const label = {
|
|
168
408
|
installed: "installed (PreMan)",
|
|
169
409
|
foreign: "present, but not written by PreMan",
|
|
170
410
|
absent: "not installed",
|
|
171
411
|
}[result.state];
|
|
172
412
|
process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
|
|
413
|
+
if (result.state === "installed") {
|
|
414
|
+
process.stdout.write(` Runs: ${result.invocation || "(unreadable)"}\n`);
|
|
415
|
+
// "Installed" was never the question -- a hook that cannot reach the CLI
|
|
416
|
+
// prints one skipped line per push and is otherwise indistinguishable.
|
|
417
|
+
process.stdout.write(
|
|
418
|
+
result.works
|
|
419
|
+
? " That command answers here.\n"
|
|
420
|
+
: ` That command does not answer here -- pushes are being skipped.\n` +
|
|
421
|
+
` Repair it: ${cliInvocation()} hook install --force\n`
|
|
422
|
+
);
|
|
423
|
+
if (!result.current) {
|
|
424
|
+
process.stdout.write(` Out of date: ${cliInvocation()} hook install --force\n`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
173
427
|
return result;
|
|
174
428
|
}
|
|
175
429
|
|
package/bin/integrations.js
CHANGED
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
|
|
27
27
|
const POLL_INTERVAL_MS = 3000;
|
|
28
28
|
const POLL_TIMEOUT_MS = 300000;
|
|
29
|
+
// GitHub is the one install where waiting longer buys nothing. A CloudFormation
|
|
30
|
+
// stack genuinely takes minutes, but the App either redirects back seconds after
|
|
31
|
+
// the customer confirms or it never does — and five more minutes of dots turns a
|
|
32
|
+
// hand-off into an outage. PREMAN_GITHUB_POLL_MS shortens it, so a test of the
|
|
33
|
+
// hand-off does not have to spend three quarters of a minute reaching it.
|
|
34
|
+
const GITHUB_POLL_TIMEOUT_MS = 45000;
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* Colour only when someone is actually watching.
|
|
@@ -75,9 +81,13 @@ export class Unrecoverable extends Error {}
|
|
|
75
81
|
* Returns the truthy value from ``check``, or null on timeout. Ordinary
|
|
76
82
|
* exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
|
|
77
83
|
*/
|
|
78
|
-
async function waitFor(
|
|
84
|
+
async function waitFor(
|
|
85
|
+
label,
|
|
86
|
+
check,
|
|
87
|
+
{ hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS } = {}
|
|
88
|
+
) {
|
|
79
89
|
const startedAt = Date.now();
|
|
80
|
-
const deadline = startedAt +
|
|
90
|
+
const deadline = startedAt + timeoutMs;
|
|
81
91
|
let hinted = false;
|
|
82
92
|
process.stdout.write(`Waiting for ${label}`);
|
|
83
93
|
while (Date.now() < deadline) {
|
|
@@ -232,6 +242,10 @@ export async function githubCommand(args) {
|
|
|
232
242
|
present(url, "install the PreMan GitHub App");
|
|
233
243
|
process.stdout.write("Pick the repositories PreMan may read.\n");
|
|
234
244
|
|
|
245
|
+
// The refresh answer is the diagnosis. A 409 means GitHub never called back;
|
|
246
|
+
// a success carrying no repositories means the App is installed and sharing
|
|
247
|
+
// nothing — two dead ends that are indistinguishable from the repository list.
|
|
248
|
+
let refresh = null;
|
|
235
249
|
const done = await waitFor(
|
|
236
250
|
"the installation",
|
|
237
251
|
async () => {
|
|
@@ -239,7 +253,7 @@ export async function githubCommand(args) {
|
|
|
239
253
|
// callback records the installation, and a refresh materialises the repos.
|
|
240
254
|
// Polling the repo list alone waits for something that may never arrive on
|
|
241
255
|
// its own.
|
|
242
|
-
await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
256
|
+
refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
243
257
|
token,
|
|
244
258
|
json: {},
|
|
245
259
|
});
|
|
@@ -249,24 +263,46 @@ export async function githubCommand(args) {
|
|
|
249
263
|
return fresh.length ? fresh : null;
|
|
250
264
|
},
|
|
251
265
|
{
|
|
266
|
+
timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
|
|
267
|
+
hintAfterMs: 20000,
|
|
252
268
|
hint:
|
|
253
|
-
"
|
|
254
|
-
"
|
|
269
|
+
"Still nothing from GitHub. Picking at least one repository and confirming\n" +
|
|
270
|
+
"is what sends you back here.",
|
|
255
271
|
}
|
|
256
272
|
);
|
|
257
273
|
|
|
258
274
|
if (!done) {
|
|
259
|
-
process.stdout.write(
|
|
260
|
-
`Timed out: GitHub never told PreMan about an installation.\n` +
|
|
261
|
-
` - Check that the App is installed: https://github.com/settings/installations\n` +
|
|
262
|
-
` - Then re-run '${cliInvocation()} github'.\n`
|
|
263
|
-
);
|
|
275
|
+
process.stdout.write(githubHandOff(args, refresh));
|
|
264
276
|
return;
|
|
265
277
|
}
|
|
266
278
|
connected(`GitHub connected: ${done.length} repository(ies).`);
|
|
267
279
|
for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
|
|
268
280
|
}
|
|
269
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Stop waiting, and name the dead end instead of the timeout.
|
|
284
|
+
*
|
|
285
|
+
* Holding the terminal for five minutes taught nobody anything: the install
|
|
286
|
+
* finishes in the browser whether this process is watching or not, and the two
|
|
287
|
+
* ways it can complete and still leave PreMan with nothing are both actionable.
|
|
288
|
+
*/
|
|
289
|
+
function githubHandOff(args, refresh) {
|
|
290
|
+
const installed = Boolean(refresh?.ok) && Number(refresh.installations_refreshed || 0) > 0;
|
|
291
|
+
if (installed) {
|
|
292
|
+
return (
|
|
293
|
+
`The App is installed, but no repositories are shared with it.\n` +
|
|
294
|
+
` - Add some: https://github.com/settings/installations\n` +
|
|
295
|
+
` - Then re-run '${cliInvocation()} github'.\n`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return (
|
|
299
|
+
`Nothing from GitHub yet — no need to wait here.\n` +
|
|
300
|
+
` - Finish the install in the browser; it records itself when you confirm.\n` +
|
|
301
|
+
` - Check it: ${frontendUrl(args)} or https://github.com/settings/installations\n` +
|
|
302
|
+
` - Then re-run '${cliInvocation()} github'.\n`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
270
306
|
// ---------------------------------------------------------------------------
|
|
271
307
|
// Slack
|
|
272
308
|
// ---------------------------------------------------------------------------
|
|
@@ -301,6 +337,17 @@ export async function slackCommand(args) {
|
|
|
301
337
|
// The guided run
|
|
302
338
|
// ---------------------------------------------------------------------------
|
|
303
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Why a step that needs a connected agent cannot run on its own.
|
|
342
|
+
*
|
|
343
|
+
* Both of these drive the agent `connect` just linked, so skipping that step
|
|
344
|
+
* leaves them without one -- which is a thing to say plainly, with the command
|
|
345
|
+
* that does it later, rather than a stack trace about a missing id.
|
|
346
|
+
*/
|
|
347
|
+
function needsAgent(command) {
|
|
348
|
+
return `no coding agent connected yet -- run '${cliInvocation()} connect', then '${cliInvocation()} ${command}'`;
|
|
349
|
+
}
|
|
350
|
+
|
|
304
351
|
/** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
|
|
305
352
|
async function askStep(question, { assumeYes, canGoBack }) {
|
|
306
353
|
if (assumeYes) return "yes";
|
|
@@ -319,7 +366,10 @@ async function askStep(question, { assumeYes, canGoBack }) {
|
|
|
319
366
|
* throws is reported and the run continues rather than unwinding the ones that
|
|
320
367
|
* already worked.
|
|
321
368
|
*/
|
|
322
|
-
export async function onboardCommand(
|
|
369
|
+
export async function onboardCommand(
|
|
370
|
+
commandArgs,
|
|
371
|
+
{ makeArgs, authenticateTerminal, connectCommand, discoverEndpoints, runnerCommand }
|
|
372
|
+
) {
|
|
323
373
|
const args = makeArgs(commandArgs);
|
|
324
374
|
const assumeYes = args.has("--yes");
|
|
325
375
|
|
|
@@ -328,11 +378,33 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
328
378
|
const creds = await authenticateTerminal(args);
|
|
329
379
|
connected(`Signed in as ${creds.user_email || "your account"}.`);
|
|
330
380
|
|
|
381
|
+
// Which agent the endpoints and runner steps drive. `connect` decided it, by
|
|
382
|
+
// detection or by asking, and this is the answer rather than a second prompt.
|
|
383
|
+
let linked = null;
|
|
384
|
+
|
|
331
385
|
const steps = [
|
|
332
386
|
{
|
|
333
387
|
name: "coding agent",
|
|
334
388
|
question: "Connect your coding agent?",
|
|
335
|
-
run: () =>
|
|
389
|
+
run: async () => {
|
|
390
|
+
linked = (await connectCommand([...commandArgs, "--skip-login"])) || null;
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
name: "endpoints",
|
|
395
|
+
question: "Map this repository's endpoints?",
|
|
396
|
+
run: () => {
|
|
397
|
+
if (!linked?.agent) throw new Error(needsAgent("endpoints discover"));
|
|
398
|
+
return discoverEndpoints(args, linked.agent, linked.serverName);
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "runner",
|
|
403
|
+
question: "Let PreMan run your agent here when it finds something to fix?",
|
|
404
|
+
run: () => {
|
|
405
|
+
if (!linked?.agent) throw new Error(needsAgent("runner start --background"));
|
|
406
|
+
return runnerCommand(["start", "--background", "--agent", linked.agent.id]);
|
|
407
|
+
},
|
|
336
408
|
},
|
|
337
409
|
{ name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
|
|
338
410
|
{ name: "AWS logs", question: "Connect AWS?", run: () => awsCommand(args) },
|
|
@@ -392,7 +464,8 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
392
464
|
|
|
393
465
|
export const INTEGRATIONS_HELP = `
|
|
394
466
|
Setup options:
|
|
395
|
-
preman onboard Sign in, then
|
|
467
|
+
preman onboard Sign in, then agent, endpoints, runner, GitHub,
|
|
468
|
+
AWS and Slack, one prompt per step
|
|
396
469
|
preman aws Connect an AWS account and stream a log group
|
|
397
470
|
preman github Install the PreMan GitHub App
|
|
398
471
|
preman slack Add PreMan to a Slack workspace
|