premanmcp 0.13.0 → 0.15.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 +42 -6
- package/bin/account.js +5 -0
- package/bin/cli.js +3 -2
- package/bin/connect/guide.js +85 -23
- package/bin/connect.js +8 -0
- package/bin/desktop.js +47 -14
- package/bin/detect.js +59 -3
- package/bin/hook.js +12 -6
- package/bin/integrations.js +283 -46
- package/bin/link.js +207 -0
- package/bin/repo.js +193 -0
- package/bin/shared.js +76 -13
- package/bin/status.js +35 -0
- package/bin/verify.js +125 -8
- package/dist/server.js +192 -3
- package/package.json +2 -2
package/bin/repo.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which repository is this checkout, and does PreMan already know it?
|
|
3
|
+
*
|
|
4
|
+
* The CLI has always known it was inside *a* git repository — `preman hook
|
|
5
|
+
* install` asks git for the git-dir, and agent pairing sends `process.cwd()` —
|
|
6
|
+
* but never which GitHub repository that was. So a terminal-first user finished
|
|
7
|
+
* setup with no way to learn whether the code under their cwd was connected,
|
|
8
|
+
* and the answer only surfaced much later as a fix task that produced no pull
|
|
9
|
+
* request because its endpoint came from outside a connected repo.
|
|
10
|
+
*
|
|
11
|
+
* Reading the remote is a hint, never an action. Nothing here connects a
|
|
12
|
+
* repository, grants access, or writes to the account: it turns "go and connect
|
|
13
|
+
* a repo" into "connect acme/api?", and stops there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
import { callBackendJson } from "./shared.js";
|
|
19
|
+
|
|
20
|
+
// Local git calls answer in milliseconds. The timeout exists for the pathological
|
|
21
|
+
// case — a network filesystem, a repository mid-rebase — where blocking setup on
|
|
22
|
+
// git is worse than not knowing which repo this is.
|
|
23
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
24
|
+
|
|
25
|
+
/** Owner and repo names GitHub itself accepts. */
|
|
26
|
+
const NAME = /^[A-Za-z0-9._-]+$/;
|
|
27
|
+
|
|
28
|
+
function git(...gitArgs) {
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync("git", gitArgs, {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
timeout: GIT_TIMEOUT_MS,
|
|
33
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
34
|
+
});
|
|
35
|
+
return result.status === 0 ? String(result.stdout || "").trim() : "";
|
|
36
|
+
} catch {
|
|
37
|
+
// No git on PATH is not this command's problem to report.
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The remote this checkout pushes to, or "".
|
|
44
|
+
*
|
|
45
|
+
* `origin` first because it is what the overwhelming majority of clones call
|
|
46
|
+
* their upstream. A single differently-named remote is still unambiguous, so it
|
|
47
|
+
* counts; two of them are a choice we have no business guessing at, since
|
|
48
|
+
* picking the wrong one would name someone else's repository.
|
|
49
|
+
*/
|
|
50
|
+
export function checkoutRemote() {
|
|
51
|
+
const origin = git("remote", "get-url", "origin");
|
|
52
|
+
if (origin) return origin;
|
|
53
|
+
|
|
54
|
+
const named = git("remote").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
55
|
+
return named.length === 1 ? git("remote", "get-url", named[0]) : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reduce a git remote to `owner/repo`, or "" when it is not GitHub.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately not "send the remote and let the backend parse it". A remote can
|
|
62
|
+
* carry credentials — `https://x-access-token:ghp_…@github.com/acme/api` is what
|
|
63
|
+
* a CI clone or a credential helper leaves in the config — and while the backend
|
|
64
|
+
* rejects a URL with userinfo, by then the token has already crossed the network
|
|
65
|
+
* and can be sitting in a request log. Discarding everything but the two names
|
|
66
|
+
* locally means there is nothing to leak, and the backend still has final say on
|
|
67
|
+
* whether the pair is a repository it will accept.
|
|
68
|
+
*/
|
|
69
|
+
export function githubSlug(remote) {
|
|
70
|
+
let value = String(remote || "").trim();
|
|
71
|
+
if (!value || value.length > 2000) return "";
|
|
72
|
+
|
|
73
|
+
// scp-style: git@github.com:owner/repo.git — not a URL, so it is matched
|
|
74
|
+
// before anything tries to parse it as one.
|
|
75
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(value);
|
|
76
|
+
if (scp && !value.includes("://")) {
|
|
77
|
+
if (!isGitHubHost(scp[1])) return "";
|
|
78
|
+
return pair(scp[2]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (value.includes("://")) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(value);
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
// `hostname` excludes any userinfo, so a token in the remote never reaches
|
|
89
|
+
// the comparison and never reaches the return value.
|
|
90
|
+
if (!isGitHubHost(parsed.hostname)) return "";
|
|
91
|
+
return pair(parsed.pathname);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A bare `owner/repo`, or `github.com/owner/repo` pasted without a scheme.
|
|
95
|
+
for (const prefix of ["github.com/", "www.github.com/"]) {
|
|
96
|
+
if (value.toLowerCase().startsWith(prefix)) {
|
|
97
|
+
value = value.slice(prefix.length);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return pair(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isGitHubHost(host) {
|
|
105
|
+
return ["github.com", "www.github.com"].includes(String(host || "").toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pair(rawPath) {
|
|
109
|
+
const parts = String(rawPath || "")
|
|
110
|
+
.split("/")
|
|
111
|
+
.map((part) => part.trim())
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
if (parts.length !== 2) return "";
|
|
114
|
+
|
|
115
|
+
const [owner, repoPart] = parts;
|
|
116
|
+
const repo = repoPart.replace(/\.git$/i, "");
|
|
117
|
+
if (!NAME.test(owner) || !NAME.test(repo)) return "";
|
|
118
|
+
if ([owner, repo].some((name) => name === "." || name === "..")) return "";
|
|
119
|
+
return `${owner}/${repo}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Ask the backend which connected repository this checkout is, if any.
|
|
124
|
+
*
|
|
125
|
+
* Returns null when there is nothing to ask about — not a repo, no remote, not
|
|
126
|
+
* GitHub — so callers can treat "no answer" and "not connected" as the different
|
|
127
|
+
* situations they are. Never throws: this is a courtesy on top of setup, and
|
|
128
|
+
* setup must not fail because a lookup did.
|
|
129
|
+
*/
|
|
130
|
+
export async function resolveCheckout(args, token, { slug = checkoutRemote() } = {}) {
|
|
131
|
+
const owner = githubSlug(slug);
|
|
132
|
+
if (!owner) return null;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await callBackendJson(args, "POST", "/integrations/github/resolve", {
|
|
136
|
+
token,
|
|
137
|
+
json: { remote_url: owner },
|
|
138
|
+
});
|
|
139
|
+
if (!result.ok) return null;
|
|
140
|
+
return { slug: owner, ...result };
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Why there is no repository to offer, in one sentence, or "" if there is one.
|
|
148
|
+
*
|
|
149
|
+
* `resolveCheckout` answering null is four different situations, and the
|
|
150
|
+
* difference is the whole of what the customer does next: create a repository,
|
|
151
|
+
* add a remote, name which remote, or accept that PreMan opens pull requests on
|
|
152
|
+
* GitHub only. Setup used to fall silent on all four, which read as PreMan
|
|
153
|
+
* having nothing to say about the directory it was standing in — and left the
|
|
154
|
+
* one thing that decides whether a fix can ever open a PR entirely unmentioned.
|
|
155
|
+
*/
|
|
156
|
+
export function explainNoCheckout() {
|
|
157
|
+
if (!git("rev-parse", "--git-dir")) {
|
|
158
|
+
return "Not a git repository, so there is no repository to connect here.";
|
|
159
|
+
}
|
|
160
|
+
const remote = checkoutRemote();
|
|
161
|
+
if (!remote) {
|
|
162
|
+
return "This checkout has no single remote to read, so there is no repository to connect.";
|
|
163
|
+
}
|
|
164
|
+
if (!githubSlug(remote)) {
|
|
165
|
+
return `${remote} is not a GitHub remote — PreMan opens pull requests on GitHub only.`;
|
|
166
|
+
}
|
|
167
|
+
// A slug we could read but the backend would not resolve: its own outage, a
|
|
168
|
+
// revoked key, a host it does not accept. Worth separating from the three
|
|
169
|
+
// above because re-running is the fix, and for those three it is not.
|
|
170
|
+
return `Could not check ${githubSlug(remote)} with PreMan just now.`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What to tell the user about a resolved checkout, or "" when nothing is worth
|
|
175
|
+
* saying. Never more than the two lines that change what they do next.
|
|
176
|
+
*/
|
|
177
|
+
export function describeCheckout(resolution) {
|
|
178
|
+
if (!resolution || resolution.supported === false) return "";
|
|
179
|
+
const { slug, match, detail } = resolution;
|
|
180
|
+
|
|
181
|
+
if (!match) return `${slug} is not connected to PreMan yet.\n`;
|
|
182
|
+
|
|
183
|
+
// Connected and able to open PRs: one line, because there is no action left.
|
|
184
|
+
if (!resolution.blockers?.length) {
|
|
185
|
+
return `${slug} is connected — fixes here can open a pull request.\n`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Connected but a fix would stop short of a PR. Worth saying now: the gap is
|
|
189
|
+
// invisible until a fix task has already been worked, and it is the owner's
|
|
190
|
+
// toggle to flip.
|
|
191
|
+
const why = detail || "Auto-PR is not available for this repository yet.";
|
|
192
|
+
return `${slug} is connected, but fixes here cannot open a pull request:\n ${why}\n`;
|
|
193
|
+
}
|
package/bin/shared.js
CHANGED
|
@@ -222,12 +222,45 @@ export function apiKeyIsExplicit(args) {
|
|
|
222
222
|
return Boolean(args.value("--api-key", "") || process.env.PREMAN_API_KEY);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The saved login, but only when it is the login actually in play.
|
|
227
|
+
*
|
|
228
|
+
* A key given by flag or environment overrides the stored one and may belong to
|
|
229
|
+
* an entirely different deployment, so the URLs saved beside the stored key must
|
|
230
|
+
* not be applied to it. Keeping the two together is the whole point: reading the
|
|
231
|
+
* key out of this file while ignoring the backend recorded next to it is what
|
|
232
|
+
* sends a local key to production and collects a 401.
|
|
233
|
+
*/
|
|
234
|
+
function activeLogin(args) {
|
|
235
|
+
return apiKeyIsExplicit(args) ? null : readStoredCredentials();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Which PreMan deployment this invocation talks to.
|
|
240
|
+
*
|
|
241
|
+
* The stored fallback is what makes the answer survive a git hook. A hook runs
|
|
242
|
+
* in whatever environment `git push` had, which is rarely the shell that
|
|
243
|
+
* exported PREMAN_BACKEND -- and a dev.env file is not an environment, it
|
|
244
|
+
* belongs to the API server, not to this CLI. Login already writes the backend
|
|
245
|
+
* down beside the key it minted there; this reads it back.
|
|
246
|
+
*/
|
|
225
247
|
export function backendUrl(args) {
|
|
226
|
-
return args
|
|
248
|
+
return args
|
|
249
|
+
.value("--backend", process.env.PREMAN_BACKEND || activeLogin(args)?.backend_url || DEFAULT_BACKEND)
|
|
250
|
+
.replace(/\/+$/, "");
|
|
227
251
|
}
|
|
228
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Where the dashboard lives for this user.
|
|
255
|
+
*
|
|
256
|
+
* Same durability problem as the backend, and the same fix -- with the same
|
|
257
|
+
* pairing rule, because a run lives on whichever deployment its key belongs to
|
|
258
|
+
* and the link has to reach the dashboard sitting in front of *that* one.
|
|
259
|
+
*/
|
|
229
260
|
export function frontendUrl(args) {
|
|
230
|
-
return args
|
|
261
|
+
return args
|
|
262
|
+
.value("--frontend", process.env.PREMAN_FRONTEND || activeLogin(args)?.frontend_url || DEFAULT_FRONTEND)
|
|
263
|
+
.replace(/\/+$/, "");
|
|
231
264
|
}
|
|
232
265
|
|
|
233
266
|
/**
|
|
@@ -384,6 +417,14 @@ export function assertOk(result, action) {
|
|
|
384
417
|
throw new Error(`${action} failed: ${result.status_code} ${detail}`);
|
|
385
418
|
}
|
|
386
419
|
|
|
420
|
+
function authSessionFrom(result, email) {
|
|
421
|
+
const user = result?.user && typeof result.user === "object" ? result.user : null;
|
|
422
|
+
return {
|
|
423
|
+
access_token: String(result?.access_token || ""),
|
|
424
|
+
user: user?.id != null ? { id: user.id, email: user.email || email } : { email },
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
387
428
|
async function verifyUnconfirmedAccount(args, email) {
|
|
388
429
|
process.stdout.write("This email exists but is not verified. Sending a new OTP.\n");
|
|
389
430
|
const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
|
|
@@ -391,7 +432,7 @@ async function verifyUnconfirmedAccount(args, email) {
|
|
|
391
432
|
const otp = await promptText("Verification code: ");
|
|
392
433
|
const verified = await callBackendJson(args, "POST", "/auth/verify-otp", { json: { email, otp } });
|
|
393
434
|
assertOk(verified, "verify OTP");
|
|
394
|
-
return verified
|
|
435
|
+
return authSessionFrom(verified, email);
|
|
395
436
|
}
|
|
396
437
|
|
|
397
438
|
/**
|
|
@@ -427,13 +468,13 @@ async function completeWithCode(args, email, otp, password) {
|
|
|
427
468
|
json: { email, otp, new_password: password },
|
|
428
469
|
});
|
|
429
470
|
assertOk(setPassword, "set password");
|
|
430
|
-
return
|
|
471
|
+
return authSessionFrom(setPassword, email);
|
|
431
472
|
}
|
|
432
473
|
const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
|
|
433
474
|
json: { email, otp },
|
|
434
475
|
});
|
|
435
476
|
assertOk(verified, "verify OTP");
|
|
436
|
-
return
|
|
477
|
+
return authSessionFrom(verified, email);
|
|
437
478
|
}
|
|
438
479
|
|
|
439
480
|
async function createAccountFromTerminal(args, email) {
|
|
@@ -468,7 +509,7 @@ async function createAccountFromTerminal(args, email) {
|
|
|
468
509
|
json: { email, otp },
|
|
469
510
|
});
|
|
470
511
|
assertOk(verified, "verify OTP");
|
|
471
|
-
return
|
|
512
|
+
return authSessionFrom(verified, email);
|
|
472
513
|
}
|
|
473
514
|
|
|
474
515
|
/**
|
|
@@ -481,6 +522,7 @@ export async function authenticateTerminal(args) {
|
|
|
481
522
|
const creds = {
|
|
482
523
|
api_key: explicitKey,
|
|
483
524
|
backend_url: backendUrl(args),
|
|
525
|
+
frontend_url: frontendUrl(args),
|
|
484
526
|
user_email: args.value("--email", ""),
|
|
485
527
|
device_name: os.hostname(),
|
|
486
528
|
created_at: new Date().toISOString(),
|
|
@@ -496,11 +538,11 @@ export async function authenticateTerminal(args) {
|
|
|
496
538
|
const account = await callBackendJson(args, "GET", "/auth/needs-password", { query: { email } });
|
|
497
539
|
assertOk(account, "check account");
|
|
498
540
|
|
|
499
|
-
let
|
|
541
|
+
let session = { access_token: "", user: { email } };
|
|
500
542
|
|
|
501
543
|
if (!account.exists) {
|
|
502
544
|
process.stdout.write("No PreMan account found. Creating one now.\n");
|
|
503
|
-
|
|
545
|
+
session = await createAccountFromTerminal(args, email);
|
|
504
546
|
} else if (account.needs_password) {
|
|
505
547
|
// No password on this account, so a code is the only way in.
|
|
506
548
|
const password = await optionalPassword(args);
|
|
@@ -508,7 +550,7 @@ export async function authenticateTerminal(args) {
|
|
|
508
550
|
const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
|
|
509
551
|
assertOk(resend, "resend OTP");
|
|
510
552
|
const otp = await promptText("Verification code: ");
|
|
511
|
-
|
|
553
|
+
session = await completeWithCode(args, email, otp, password);
|
|
512
554
|
} else {
|
|
513
555
|
const password = await promptSecret("Password (Enter to get a code by email): ");
|
|
514
556
|
if (!password) {
|
|
@@ -516,22 +558,31 @@ export async function authenticateTerminal(args) {
|
|
|
516
558
|
assertOk(resend, "resend OTP");
|
|
517
559
|
process.stdout.write("Sign-in code sent. Check your email.\n");
|
|
518
560
|
const otp = await promptText("Verification code: ");
|
|
519
|
-
|
|
561
|
+
session = await completeWithCode(args, email, otp, "");
|
|
520
562
|
} else {
|
|
521
563
|
const login = await callBackendJson(args, "POST", "/auth/login", {
|
|
522
564
|
json: { email, password },
|
|
523
565
|
});
|
|
524
566
|
if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
|
|
525
|
-
|
|
567
|
+
session = await verifyUnconfirmedAccount(args, email);
|
|
526
568
|
} else {
|
|
527
569
|
assertOk(login, "login");
|
|
528
|
-
|
|
570
|
+
session = authSessionFrom(login, email);
|
|
529
571
|
}
|
|
530
572
|
}
|
|
531
573
|
}
|
|
532
574
|
|
|
575
|
+
const accessToken = String(session?.access_token || "");
|
|
533
576
|
if (!accessToken) throw new Error("PreMan did not return an access token.");
|
|
534
577
|
|
|
578
|
+
let sessionUser = session.user && session.user.id != null ? session.user : null;
|
|
579
|
+
if (!sessionUser) {
|
|
580
|
+
const me = await callBackendJson(args, "GET", "/auth/me", { token: accessToken });
|
|
581
|
+
if (me.ok && me.id != null) {
|
|
582
|
+
sessionUser = { id: me.id, email: me.email || email, email_verified: me.email_verified };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
535
586
|
const keyName = `PreMan MCP CLI (${os.hostname()})`;
|
|
536
587
|
const key = await callBackendJson(args, "POST", "/api-keys", {
|
|
537
588
|
token: accessToken,
|
|
@@ -544,12 +595,24 @@ export async function authenticateTerminal(args) {
|
|
|
544
595
|
|
|
545
596
|
const creds = {
|
|
546
597
|
api_key: String(key.key),
|
|
598
|
+
access_token: accessToken,
|
|
599
|
+
user: sessionUser,
|
|
547
600
|
backend_url: backendUrl(args),
|
|
601
|
+
frontend_url: frontendUrl(args),
|
|
548
602
|
user_email: email,
|
|
549
603
|
device_name: os.hostname(),
|
|
550
604
|
created_at: new Date().toISOString(),
|
|
551
605
|
};
|
|
552
|
-
saveStoredCredentials(
|
|
606
|
+
saveStoredCredentials({
|
|
607
|
+
api_key: creds.api_key,
|
|
608
|
+
backend_url: creds.backend_url,
|
|
609
|
+
frontend_url: creds.frontend_url,
|
|
610
|
+
user_email: creds.user_email,
|
|
611
|
+
device_name: creds.device_name,
|
|
612
|
+
created_at: creds.created_at,
|
|
613
|
+
});
|
|
614
|
+
const { writeDesktopSession } = await import("./desktop.js");
|
|
615
|
+
writeDesktopSession(creds);
|
|
553
616
|
return creds;
|
|
554
617
|
}
|
|
555
618
|
|
package/bin/status.js
CHANGED
|
@@ -118,6 +118,41 @@ export function renderStatus(payload, { colour = false } = {}) {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Between the endpoints and what was fixed, because it is the only section
|
|
122
|
+
// here that is asking for something. A repair PreMan wrote and nobody merged
|
|
123
|
+
// is invisible everywhere else in this command, which is how a fix ends up
|
|
124
|
+
// sitting on a branch for a fortnight while the endpoint it fixes keeps
|
|
125
|
+
// failing two lines further up.
|
|
126
|
+
const chase = payload.chase || {};
|
|
127
|
+
const waiting = chase.waiting_on_you || [];
|
|
128
|
+
if (waiting.length) {
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(`${paint.bold("Waiting on you")}`);
|
|
131
|
+
for (const entry of waiting) {
|
|
132
|
+
const label = truncate(entry.title || "fix", 40).padEnd(40);
|
|
133
|
+
const pr = entry.pr_url ? paint.blue(entry.pr_url) : paint.dim("no PR recorded");
|
|
134
|
+
lines.push(
|
|
135
|
+
` ${paint.yellow("→")} ${label} ${pr} ${paint.dim(`${entry.waiting_for || entry.stage} · ${relativeTime(entry.since)}`)}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Split from `recently_fixed` on purpose. That list means "somebody marked it
|
|
141
|
+
// resolved"; this one means PreMan re-sent the request that was failing and
|
|
142
|
+
// production answered correctly, which is a different and much stronger claim.
|
|
143
|
+
const verified = chase.verified_in_production || [];
|
|
144
|
+
if (verified.length) {
|
|
145
|
+
lines.push("");
|
|
146
|
+
lines.push(`${paint.bold("Verified in production")} ${paint.dim("(7d)")}`);
|
|
147
|
+
for (const entry of verified) {
|
|
148
|
+
const label = truncate(entry.title || "fix", 40).padEnd(40);
|
|
149
|
+
const detail = entry.status ? `now returns ${entry.status}` : "re-ran clean";
|
|
150
|
+
lines.push(
|
|
151
|
+
` ${paint.green("✓")} ${label} ${paint.dim(`${detail} · ${relativeTime(entry.since)}`)}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
const fixed = payload.recently_fixed || [];
|
|
122
157
|
if (fixed.length) {
|
|
123
158
|
lines.push("");
|
package/bin/verify.js
CHANGED
|
@@ -24,9 +24,10 @@ import os from "node:os";
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
|
|
26
26
|
import { changedFilesForPush, readChangedFiles, readPushRefsFromStdin, repoRoot } from "./changed.js";
|
|
27
|
-
import { resolveLocalTarget } from "./detect.js";
|
|
27
|
+
import { missingMountPrefix, resolveLocalTarget } from "./detect.js";
|
|
28
|
+
import { formatDashboardLink, formatPushLink } from "./link.js";
|
|
28
29
|
import { createReporter } from "./progress.js";
|
|
29
|
-
import { backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, resolveApiKey } from "./shared.js";
|
|
30
|
+
import { backendUrl, callBackendJson, cliInvocation, frontendUrl, makeArgs, nowMs, resolveApiKey } from "./shared.js";
|
|
30
31
|
|
|
31
32
|
export const VERIFY_HELP = `
|
|
32
33
|
Verify options:
|
|
@@ -278,7 +279,8 @@ async function fetchInventory(args, token) {
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
async function uploadRuns(args, { token, projectId, batchId, runs }) {
|
|
281
|
-
if (!
|
|
282
|
+
if (!runs.length) return { inserted: 0, error: null };
|
|
283
|
+
if (!projectId) return { inserted: 0, error: null, unstored: "no_project" };
|
|
282
284
|
let inserted = 0;
|
|
283
285
|
for (let offset = 0; offset < runs.length; offset += UPLOAD_BATCH_SIZE) {
|
|
284
286
|
const chunk = runs.slice(offset, offset + UPLOAD_BATCH_SIZE);
|
|
@@ -415,9 +417,29 @@ async function runPlan(
|
|
|
415
417
|
const retarget = (url) =>
|
|
416
418
|
origin && String(url).startsWith(origin) ? target.url + String(url).slice(origin.length) : url;
|
|
417
419
|
|
|
420
|
+
// A planned path can arrive without the prefix its router is mounted under.
|
|
421
|
+
// The plan is scanned from the pushed files alone, and the mount is normally
|
|
422
|
+
// declared in an untouched module as a variable no static scan can resolve,
|
|
423
|
+
// so the running app's spec is the only thing that knows. Correcting the URL
|
|
424
|
+
// is what makes the case reach the endpoint at all; correcting the reported
|
|
425
|
+
// path is what files the result under the route the developer wrote, since
|
|
426
|
+
// the uploaded run is keyed on `entry.path`. Uncorrected, an endpoint this
|
|
427
|
+
// push introduced is reported broken on a URL nobody wrote.
|
|
428
|
+
const mounted = (url, prefix) => {
|
|
429
|
+
const text = String(url);
|
|
430
|
+
return prefix && text.startsWith(target.url)
|
|
431
|
+
? target.url + prefix + text.slice(target.url.length)
|
|
432
|
+
: text;
|
|
433
|
+
};
|
|
434
|
+
const planned = plan.selected.map((entry) => {
|
|
435
|
+
const prefix = missingMountPrefix(entry.method, entry.path, target.specRoutes) || "";
|
|
436
|
+
return { entry: prefix ? { ...entry, path: prefix + entry.path } : entry, prefix };
|
|
437
|
+
});
|
|
438
|
+
const remounted = planned.filter((item) => item.prefix).length;
|
|
439
|
+
|
|
418
440
|
const work = [];
|
|
419
441
|
let withheld = 0;
|
|
420
|
-
for (const entry of
|
|
442
|
+
for (const { entry, prefix } of planned) {
|
|
421
443
|
for (const testCase of entry.cases) {
|
|
422
444
|
const method = String(testCase.method || "GET").toUpperCase();
|
|
423
445
|
const mutating = !READ_METHODS.has(method) || testCase.effect === "mutating";
|
|
@@ -425,7 +447,7 @@ async function runPlan(
|
|
|
425
447
|
withheld += 1;
|
|
426
448
|
continue;
|
|
427
449
|
}
|
|
428
|
-
work.push({ entry, testCase: { ...testCase, url: retarget(testCase.url) } });
|
|
450
|
+
work.push({ entry, testCase: { ...testCase, url: mounted(retarget(testCase.url), prefix) } });
|
|
429
451
|
}
|
|
430
452
|
}
|
|
431
453
|
|
|
@@ -465,6 +487,15 @@ async function runPlan(
|
|
|
465
487
|
`${work.length} case${work.length === 1 ? "" : "s"}${policy.writes ? "" : ", read-only"}` +
|
|
466
488
|
(plan.traffic?.available ? ", ranked by production traffic" : "")
|
|
467
489
|
);
|
|
490
|
+
// Rewriting a URL the developer did not write is worth one line of output.
|
|
491
|
+
// Without it, a mount recovered from the spec is indistinguishable from a
|
|
492
|
+
// scan that got the path right, and the next person to debug a wrong prefix
|
|
493
|
+
// has no idea this step exists.
|
|
494
|
+
if (remounted) {
|
|
495
|
+
reporter.line(
|
|
496
|
+
`[preman] recovered the mount prefix for ${remounted} endpoint${remounted === 1 ? "" : "s"} from ${target.via}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
468
499
|
}
|
|
469
500
|
|
|
470
501
|
const runs = await runSlottedPool(
|
|
@@ -515,6 +546,12 @@ async function runPlan(
|
|
|
515
546
|
if (withheld) reporter.line(`[preman] ${withheld} mutating case${withheld === 1 ? "" : "s"} withheld`);
|
|
516
547
|
if (uploaded) reporter.line(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}`);
|
|
517
548
|
if (upload.error) reporter.line(`[preman] ${upload.error}`);
|
|
549
|
+
if (upload.unstored === "no_project") {
|
|
550
|
+
reporter.line(
|
|
551
|
+
`[preman] results not stored: this workspace has no linked project. ` +
|
|
552
|
+
`Link one at ${frontendUrl(args)}/settings to get a dashboard link here.`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
518
555
|
if (shouldBlock) {
|
|
519
556
|
reporter.line(`[preman] blocking this push (${gate.from}). Set PREMAN_SKIP_HOOK=1 to override.`);
|
|
520
557
|
} else if (breaking.length || removed.length) {
|
|
@@ -532,6 +569,16 @@ async function runPlan(
|
|
|
532
569
|
: "";
|
|
533
570
|
reporter.line(`[preman] ${found} — advisory only${disarmed}`);
|
|
534
571
|
}
|
|
572
|
+
// Last, so it sits closest to git's own output. Only once something was
|
|
573
|
+
// stored: with no project the link would open an empty view.
|
|
574
|
+
if (uploaded) {
|
|
575
|
+
const link = formatPushLink({
|
|
576
|
+
batchId,
|
|
577
|
+
state: failed.length ? "failed" : "passed",
|
|
578
|
+
args,
|
|
579
|
+
});
|
|
580
|
+
if (link) reporter.line(link);
|
|
581
|
+
}
|
|
535
582
|
}
|
|
536
583
|
|
|
537
584
|
return {
|
|
@@ -563,6 +610,21 @@ async function runPlan(
|
|
|
563
610
|
};
|
|
564
611
|
}
|
|
565
612
|
|
|
613
|
+
/**
|
|
614
|
+
* What the always-printed link should call itself.
|
|
615
|
+
*
|
|
616
|
+
* A scan in flight is the one state worth naming. It is the difference between
|
|
617
|
+
* "there is nothing here" and "there is nothing here yet", and the only one
|
|
618
|
+
* where the right response is to wait rather than to go and configure something.
|
|
619
|
+
*/
|
|
620
|
+
function dashboardLabel(scan) {
|
|
621
|
+
if (!scan?.running) return "\u25c6 PreMan \u00b7 view endpoints";
|
|
622
|
+
const found = Number(scan.findings) || 0;
|
|
623
|
+
return found
|
|
624
|
+
? `\u25c6 PreMan \u00b7 scanning for endpoints \u2014 ${found} found so far`
|
|
625
|
+
: "\u25c6 PreMan \u00b7 scanning for endpoints";
|
|
626
|
+
}
|
|
627
|
+
|
|
566
628
|
async function runVerify(args) {
|
|
567
629
|
const advisory = args.has("--pre-push");
|
|
568
630
|
const asJson = args.has("--json");
|
|
@@ -571,9 +633,26 @@ async function runVerify(args) {
|
|
|
571
633
|
1000;
|
|
572
634
|
const deadline = Date.now() + budgetMs;
|
|
573
635
|
|
|
636
|
+
// Filled the moment `/cli/status` answers, and read by `finish` afterwards.
|
|
637
|
+
// Every early return happens either before that call or after it, so the only
|
|
638
|
+
// outcomes that see null are the ones where the backend never spoke -- for
|
|
639
|
+
// which "a scan is running" would be a claim we cannot make.
|
|
640
|
+
let scan = null;
|
|
641
|
+
|
|
574
642
|
const finish = (outcome) => {
|
|
575
|
-
if (asJson)
|
|
576
|
-
|
|
643
|
+
if (asJson) {
|
|
644
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, scan }, null, 2)}\n`);
|
|
645
|
+
} else {
|
|
646
|
+
if (outcome.message) process.stdout.write(`${outcome.message}\n`);
|
|
647
|
+
// Somewhere to look, whatever happened. The run-specific link is printed
|
|
648
|
+
// by the verify paths themselves, which have a batch to point at; this is
|
|
649
|
+
// the fallback for every outcome that does not, and it is the only output
|
|
650
|
+
// a skip would otherwise leave behind.
|
|
651
|
+
if (!outcome.uploaded) {
|
|
652
|
+
const link = formatDashboardLink({ args, label: dashboardLabel(scan) });
|
|
653
|
+
if (link) process.stdout.write(`${link}\n`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
577
656
|
// A deliberate block is the one non-zero code advisory mode does not flatten.
|
|
578
657
|
if ((!advisory || outcome.blocked) && outcome.exitCode) process.exitCode = outcome.exitCode;
|
|
579
658
|
return outcome;
|
|
@@ -601,6 +680,7 @@ async function runVerify(args) {
|
|
|
601
680
|
});
|
|
602
681
|
}
|
|
603
682
|
const projectId = status.workspace?.project_id || null;
|
|
683
|
+
scan = status.scan && typeof status.scan === "object" ? status.scan : null;
|
|
604
684
|
|
|
605
685
|
const root = repoRoot();
|
|
606
686
|
// The plan comes first so a brand-new endpoint — one this push introduces and
|
|
@@ -615,6 +695,21 @@ async function runVerify(args) {
|
|
|
615
695
|
|
|
616
696
|
const { endpoints, error } = await fetchInventory(args, token);
|
|
617
697
|
if ((error || !endpoints.length) && !planEndpoints.length) {
|
|
698
|
+
// An empty inventory during a scan is not the same answer as an empty one
|
|
699
|
+
// without a scan. Telling someone to run discovery while discovery is
|
|
700
|
+
// running sends them to do the thing already in progress.
|
|
701
|
+
if (!error && scan?.running) {
|
|
702
|
+
const found = Number(scan.findings) || 0;
|
|
703
|
+
return finish({
|
|
704
|
+
state: "skipped",
|
|
705
|
+
reason: "scan_in_progress",
|
|
706
|
+
message:
|
|
707
|
+
`[preman] skipped: endpoint discovery is still running` +
|
|
708
|
+
(found ? ` (${found} found so far)` : "") +
|
|
709
|
+
`. Push again once it finishes.`,
|
|
710
|
+
exitCode: 0,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
618
713
|
return finish({
|
|
619
714
|
state: "skipped",
|
|
620
715
|
reason: "no_inventory",
|
|
@@ -626,7 +721,15 @@ async function runVerify(args) {
|
|
|
626
721
|
}
|
|
627
722
|
|
|
628
723
|
const extraPorts = args.value("--port", "") ? [args.value("--port", "")] : [];
|
|
629
|
-
|
|
724
|
+
// Both sets, not the plan alone. The plan is here so a first-ever push can
|
|
725
|
+
// confirm a target with nothing saved yet, but consulting it *exclusively*
|
|
726
|
+
// hands a single route a veto over the whole check: a one-endpoint push whose
|
|
727
|
+
// mount prefix the scan could not resolve matches nothing in the live spec,
|
|
728
|
+
// scores 0/1, and the run is abandoned as "probably a different service" —
|
|
729
|
+
// exactly when the developer most wants the new endpoint exercised. Together,
|
|
730
|
+
// the saved inventory carries the identity question and the plan only adds to
|
|
731
|
+
// it. With an empty inventory this is still the plan on its own.
|
|
732
|
+
const confirmationEndpoints = [...endpoints, ...planEndpoints];
|
|
630
733
|
const resolved = await resolveLocalTarget(confirmationEndpoints, {
|
|
631
734
|
extraPorts,
|
|
632
735
|
timeoutMs: 2000,
|
|
@@ -723,6 +826,20 @@ async function runVerify(args) {
|
|
|
723
826
|
}
|
|
724
827
|
if (uploaded) process.stdout.write(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}\n`);
|
|
725
828
|
if (upload.error) process.stdout.write(`[preman] ${upload.error}\n`);
|
|
829
|
+
if (upload.unstored === "no_project") {
|
|
830
|
+
process.stdout.write(
|
|
831
|
+
`[preman] results not stored: this workspace has no linked project. ` +
|
|
832
|
+
`Link one at ${frontendUrl(args)}/settings to get a dashboard link here.\n`
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
if (uploaded) {
|
|
836
|
+
const link = formatPushLink({
|
|
837
|
+
batchId,
|
|
838
|
+
state: failed.length ? "failed" : "passed",
|
|
839
|
+
args,
|
|
840
|
+
});
|
|
841
|
+
if (link) process.stdout.write(`${link}\n`);
|
|
842
|
+
}
|
|
726
843
|
}
|
|
727
844
|
|
|
728
845
|
return finish({ ...outcome, message: null });
|