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/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.value("--backend", process.env.PREMAN_BACKEND || DEFAULT_BACKEND).replace(/\/+$/, "");
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.value("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND).replace(/\/+$/, "");
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.access_token;
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 String(setPassword.access_token || "");
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 String(verified.access_token || "");
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 String(verified.access_token || "");
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 accessToken = "";
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
- accessToken = await createAccountFromTerminal(args, email);
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
- accessToken = await completeWithCode(args, email, otp, password);
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
- accessToken = await completeWithCode(args, email, otp, "");
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
- accessToken = await verifyUnconfirmedAccount(args, email);
567
+ session = await verifyUnconfirmedAccount(args, email);
526
568
  } else {
527
569
  assertOk(login, "login");
528
- accessToken = String(login.access_token || "");
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(creds);
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 (!projectId || !runs.length) return { inserted: 0, error: null };
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 plan.selected) {
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) process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
576
- else if (outcome.message) process.stdout.write(`${outcome.message}\n`);
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
- const confirmationEndpoints = planEndpoints.length ? planEndpoints : endpoints;
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 });