premanmcp 0.12.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 CHANGED
@@ -10,8 +10,9 @@ PreMan is agent-first API infrastructure. It lets backend teams expose endpoints
10
10
  npm exec -y premanmcp@latest -- onboard
11
11
  ```
12
12
 
13
- Create the account, verify the email, and get the PreMan app installed and opened
14
- already signed in. A bare `preman` in a terminal does the same thing.
13
+ Create the account, verify the email, get the PreMan app installed and opened already
14
+ signed in, and hook this repo so `git push` checks the endpoints you touched. A bare
15
+ `preman` in a terminal does the same thing.
15
16
 
16
17
  ## Connect a coding agent
17
18
 
@@ -93,9 +94,11 @@ preman status # which of those are done
93
94
  ```
94
95
 
95
96
  `preman onboard` (or `setup`, or a bare `preman` in a terminal) is what someone starting
96
- out runs: create the account and verify the email, then install the PreMan app and open it
97
- already signed in, then GitHub, AWS, Slack — one question per step, `b` to go back, and a
98
- summary at the end. Connecting a coding agent is no longer part of starting out; run
97
+ out runs: create the account and verify the email, install the PreMan app and open it
98
+ already signed in, then write the pre-push hook into this repo — one question per step, `b`
99
+ to go back, and a summary at the end. GitHub, AWS and Slack are their own commands now.
100
+ Onboarding outside a git repository skips the hook rather than failing; run `preman hook
101
+ install` in the repo you meant. Connecting a coding agent is no longer part of starting out; run
99
102
  `preman connect` when you actually want one wired into an IDE. `connect --guide` still runs
100
103
  the full pass inside connect itself: discovery, a first test, the runner, the desktop app
101
104
  and the integration prompts.
@@ -257,7 +260,7 @@ npm exec -y premanmcp@latest -- install # Cursor-only installer (legacy)
257
260
  ### Push testing
258
261
 
259
262
  ```bash
260
- preman hook install # Write the pre-push hook (connect does this too)
263
+ preman hook install # Write the pre-push hook (onboard and connect do this too)
261
264
  preman hook status # Installed? And does the command in it still answer?
262
265
  preman hook repair # Rewrite it if it stopped working (usually automatic)
263
266
  preman hook uninstall # Remove it, restoring any hook it replaced
@@ -280,6 +283,39 @@ hook we did not write is never touched, and no hook is installed where you never
280
283
  one. The answer is remembered for an hour per directory, and `PREMAN_NO_HOOK_REPAIR=1` turns
281
284
  it off.
282
285
 
286
+ #### The link a push leaves behind
287
+
288
+ Once runs are stored, the hook prints a line you can click straight through to that push:
289
+
290
+ ```
291
+ ◆ PreMan · check #a3f21c passed — view results
292
+ https://app.preman.live/observability/endpoints?batch=a3f21c9e-…
293
+ ```
294
+
295
+ Where the click lands is decided before anything is printed, because a terminal hands the
296
+ URL it was given to the OS and there is nothing left of the hook by then:
297
+
298
+ | What is true when the hook runs | What the link opens |
299
+ | --- | --- |
300
+ | A frontend other than `app.preman.live` | That frontend, in the browser |
301
+ | PreMan.app installed and new enough to route | The desktop app, via `preman://` |
302
+ | Anything else | `app.preman.live`, in the browser |
303
+
304
+ The frontend is read from `--frontend`, then `PREMAN_FRONTEND`, then the one saved at login
305
+ in `~/.preman/credentials.json`. That last fallback is what makes a local setup work from a
306
+ hook at all: `git push` does not carry the shell that exported the variable, and a `dev.env`
307
+ file is not an environment.
308
+
309
+ The bare URL is always printed underneath, because macOS Terminal.app renders no hyperlink.
310
+ Piped output — a GUI git client, CI — gets the plain line with no escapes at all, and
311
+ `NO_COLOR` drops the colour but keeps the link.
312
+
313
+ Desktop builds from `MIN_ROUTE_VERSION` in `bin/link.js` onward are expected to read
314
+ `preman://open?source=push&route=<url-encoded path>` and navigate to that path. Older builds
315
+ raise the window and ignore the query, so the CLI reads `CFBundleShortVersionString` out of
316
+ the installed bundle and falls back to https rather than sending them somewhere they cannot
317
+ follow.
318
+
283
319
  ### Runner
284
320
 
285
321
  `preman onboard` and `connect --guide` set this up for you; these are for managing it
package/bin/account.js CHANGED
@@ -11,6 +11,7 @@ import { existsSync, rmSync } from "node:fs";
11
11
  import os from "node:os";
12
12
 
13
13
  import { detectCandidates } from "./detect.js";
14
+ import { DESKTOP_SESSION_FILE } from "./desktop.js";
14
15
  import { hookStatus } from "./hook.js";
15
16
  import {
16
17
  CREDENTIALS_FILE,
@@ -90,6 +91,10 @@ export async function logoutCommand() {
90
91
  } else {
91
92
  process.stdout.write("No stored credentials to remove.\n");
92
93
  }
94
+ if (existsSync(DESKTOP_SESSION_FILE)) {
95
+ rmSync(DESKTOP_SESSION_FILE, { force: true });
96
+ process.stdout.write(`Removed ${DESKTOP_SESSION_FILE}\n`);
97
+ }
93
98
  process.stdout.write(
94
99
  "Note: this does not revoke the API key. Revoke it in the dashboard if the machine is shared.\n"
95
100
  );
package/bin/cli.js CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  } from "./integrations.js";
36
36
  import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
37
37
  import { STATUS_HELP, statusCommand } from "./status.js";
38
- import { HOOK_HELP, hookCommand, scheduleHookRepair } from "./hook.js";
38
+ import { HOOK_HELP, hookCommand, installHook, scheduleHookRepair } from "./hook.js";
39
39
  import { RUNNER_HELP, runnerCommand } from "./runner.js";
40
40
  import { VERIFY_HELP, verifyCommand } from "./verify.js";
41
41
  import { DESKTOP_HELP, installDesktopCommand, openDesktopSignedIn } from "./desktop.js";
@@ -91,7 +91,7 @@ function printHelp() {
91
91
  ["runner start|status|stop", "Run PreMan's queued agent work on this machine"],
92
92
  ["doctor", "Diagnose credentials, backend, target, integrations"],
93
93
  ["install-desktop", "Download and install the PreMan desktop app"],
94
- ["onboard", "Create an account, install the app signed in, then integrations"],
94
+ ["onboard", "Sign in, map this repo's endpoints, connect it, test on push"],
95
95
  ["connect [options]", "Pick a coding agent and connect it (optional)"],
96
96
  ["dispatch [options]", "Let PreMan start agent runs for you"],
97
97
  ["aws | github | slack", "Connect one integration on its own"],
@@ -263,6 +263,7 @@ async function main() {
263
263
  authenticateTerminal,
264
264
  installDesktop: installDesktopCommand,
265
265
  openDesktopSignedIn,
266
+ installPushHook: installHook,
266
267
  });
267
268
  } else if (command === "aws") {
268
269
  await awsCommand(makeArgs(commandArgs));
@@ -114,6 +114,10 @@ async function endpointCounts(args) {
114
114
  * asking, so an agent given only the MCP server can call PreMan and read nothing.
115
115
  */
116
116
  export function headlessDiscovery(agent, serverName, instructions = []) {
117
+ // Onboarding reaches here with no agent at all: nothing has been linked yet,
118
+ // and the machine may have none installed. That is not a failure, it is the
119
+ // brief-printing path, so answer "nothing to run" rather than throwing.
120
+ if (!agent) return null;
117
121
  const prompt = [
118
122
  `Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
119
123
  "Work autonomously and do not ask for confirmation.",
@@ -168,13 +172,20 @@ export async function discoverEndpoints(
168
172
  // Discovery has to read *this* repo, so it cannot be moved out of a directory
169
173
  // that redirects it the way the check-in was — it can only be skipped, before
170
174
  // it spends ten minutes proving what the self-test already found out.
171
- const blocker = blockedHere || (spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
175
+ // PREMAN_NO_AGENT is for runs that must not spawn one -- CI, a sandbox, a
176
+ // suite testing the walk rather than the walk's agent. It takes the same
177
+ // path a missing agent does: print the brief and move on.
178
+ const blocker =
179
+ blockedHere ||
180
+ (process.env.PREMAN_NO_AGENT ? "PREMAN_NO_AGENT is set" : "") ||
181
+ (agent && spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
172
182
  if (!spec || !onPath(spec.bin) || blocker) {
173
183
  // Say why before printing homework: "here is a brief" reads as PreMan not
174
184
  // working, when the actual answer is one login away.
175
185
  if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
176
186
  for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
177
- process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${manualSteps()}`);
187
+ const who = agent ? agent.label : "your coding agent";
188
+ process.stdout.write(`\nHand this brief to ${who}, then run:\n${manualSteps()}`);
178
189
  return before;
179
190
  }
180
191
 
@@ -356,35 +367,86 @@ async function setUpRunner(args, agent, { assumeYes }) {
356
367
  }
357
368
  }
358
369
 
359
- /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
360
- async function offerDesktop(args, { assumeYes }) {
370
+ /**
371
+ * The question, word for word, wherever the desktop app is offered.
372
+ *
373
+ * Shared because both walks ask it and they must not drift: "install the
374
+ * desktop app?" got yes from people who thought they were agreeing to a menu
375
+ * bar icon, then spent two minutes watching an unexplained download. The size
376
+ * and the destination are the two facts that change the answer, and the last
377
+ * clause is there because the honest answer to "do I need this?" is no.
378
+ */
379
+ export function desktopQuestion(args) {
380
+ const dest = args.value("--dest", "/Applications");
381
+ return `Download the PreMan desktop app? ~150MB, installs to ${dest} — optional, everything works without it`;
382
+ }
383
+
384
+ /** Where this run would put the app, honouring the same --dest the install does. */
385
+ function desktopAppPath(args) {
386
+ return path.join(args.value("--dest", "/Applications"), "PreMan.app");
387
+ }
388
+
389
+ /**
390
+ * Why the desktop app should not be asked about here, or null to go ahead.
391
+ *
392
+ * Split out from the offer so a caller that runs its own prompt loop can decide
393
+ * whether the question is worth asking *before* asking it. Both of these were
394
+ * bugs when the check lived after the prompt: a Linux user got a macOS-only
395
+ * question, and someone who already had the app got asked to download it again.
396
+ */
397
+ export function desktopPrecheck(args) {
361
398
  if (args.has("--no-desktop")) return { state: "skipped" };
362
- if (process.platform !== "darwin") return { state: "unsupported" };
399
+ // An explicit --desktop is honoured whatever the platform: someone who asked
400
+ // for the app by name is owed the macOS-only notice and the download link
401
+ // rather than silence. Only the unasked offer is withheld where there is no
402
+ // build, because a step that announces itself to say "not for you" reads as
403
+ // something being broken.
404
+ if (process.platform !== "darwin" && !args.has("--desktop")) return { state: "unsupported" };
405
+ if (existsSync(desktopAppPath(args))) return { state: "installed" };
406
+ return null;
407
+ }
408
+
409
+ /**
410
+ * Install the desktop app, reporting rather than raising.
411
+ *
412
+ * `install` is injectable because onboarding does more than this one does — it
413
+ * hands the fresh session to the app so it opens signed in — and the part worth
414
+ * sharing is the guards, the default and the wording, not the install itself.
415
+ */
416
+ export async function runDesktopInstall(args, { install = null } = {}) {
417
+ try {
418
+ return (await (install ? install() : installDesktopCommand([]))) || { state: "done" };
419
+ } catch (error) {
420
+ process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
421
+ return { state: "failed", detail: error.message };
422
+ }
423
+ }
424
+
425
+ export function desktopSkipped() {
426
+ process.stdout.write(`${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`);
427
+ }
428
+
429
+ /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
430
+ async function offerDesktop(args, { assumeYes } = {}) {
431
+ const already = desktopPrecheck(args);
432
+ // Silent on Linux and Windows: there is no build to offer, and a step that
433
+ // announces itself only to say "not for you" reads as something being wrong.
434
+ if (already && already.state !== "installed") return already;
363
435
  step("Desktop app");
364
- if (existsSync("/Applications/PreMan.app")) {
436
+ if (already) {
365
437
  process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
366
- return { state: "installed" };
438
+ return already;
367
439
  }
368
440
  // Default no, unlike every other step here: this one downloads a hundred-odd
369
441
  // megabytes and writes to /Applications, which nobody should get by pressing
370
- // Enter to move past a prompt.
371
- if (
372
- !(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
373
- assumeYes,
374
- defaultYes: false,
375
- }))
376
- ) {
377
- process.stdout.write(
378
- `${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
379
- );
442
+ // Enter to move past a prompt. `--desktop` is how an unattended run opts in,
443
+ // since `--yes` deliberately no longer does.
444
+ const wanted = args.has("--desktop");
445
+ if (!wanted && !(await confirm(desktopQuestion(args), { assumeYes, defaultYes: false }))) {
446
+ desktopSkipped();
380
447
  return { state: "skipped" };
381
448
  }
382
- try {
383
- return await installDesktopCommand([]);
384
- } catch (error) {
385
- process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
386
- return { state: "failed", detail: error.message };
387
- }
449
+ return runDesktopInstall(args);
388
450
  }
389
451
 
390
452
  /**
package/bin/connect.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  resolveApiKey,
35
35
  } from "./shared.js";
36
36
  import { MARK } from "./integrations.js";
37
+ import { describeCheckout, resolveCheckout } from "./repo.js";
37
38
  import { ConnectError, EXIT_USAGE } from "./connect/errors.js";
38
39
  import {
39
40
  agentBlocker,
@@ -286,6 +287,13 @@ export async function connectCommand(commandArgs) {
286
287
 
287
288
  process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
288
289
 
290
+ // A statement, not a step: the agent is now pointed at this working tree, and
291
+ // whether PreMan knows this tree as a connected repository decides what the
292
+ // discovery below is worth. One request, no prompt, and silence when there is
293
+ // no GitHub remote to resolve — `preman github` is still where connecting
294
+ // happens.
295
+ process.stdout.write(describeCheckout(await resolveCheckout(args, apiKey)));
296
+
289
297
  // Connecting is one job. Running discovery, pairing a runner, offering the
290
298
  // desktop app and installing three integrations is five more, each with its
291
299
  // own prompt and its own way to fail -- and a connect that ends in a failed
package/bin/desktop.js CHANGED
@@ -13,11 +13,20 @@
13
13
 
14
14
  import { spawn, spawnSync } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
16
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import {
17
+ chmodSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ rmSync,
24
+ writeFileSync,
25
+ } from "node:fs";
17
26
  import os from "node:os";
18
27
  import path from "node:path";
19
28
 
20
- import { CREDENTIALS_DIR, cliInvocation, makeArgs } from "./shared.js";
29
+ import { CREDENTIALS_DIR, makeArgs } from "./shared.js";
21
30
 
22
31
  export const DESKTOP_HELP = `
23
32
  Install-desktop options:
@@ -59,33 +68,87 @@ export function desktopAppInstalled(destination = "/Applications") {
59
68
  * something different to say in each case: an app that is not installed is not
60
69
  * a failure, it is the fallback where the customer signs in themselves.
61
70
  */
71
+ function desktopSessionUser(creds) {
72
+ const raw = creds?.user;
73
+ if (!raw || typeof raw !== "object") return null;
74
+ const id = raw.id;
75
+ const email = typeof raw.email === "string" ? raw.email : "";
76
+ if (id == null || !email) return null;
77
+ const user = { id, email };
78
+ if (typeof raw.email_verified === "boolean") user.email_verified = raw.email_verified;
79
+ return user;
80
+ }
81
+
82
+ /**
83
+ * The desktop app's durable login cache is Chromium localStorage:
84
+ * `flow_auth_token` (the JWT from /auth/verify-otp or /auth/login) and
85
+ * `flow_auth_user` (`{"id", "email"}`). This file is the handoff that lets the
86
+ * app write those keys as if the customer had signed in on its own screen.
87
+ */
62
88
  export function writeDesktopSession(creds) {
63
89
  const apiKey = String(creds?.api_key || "").trim();
64
- if (!apiKey.startsWith("pm_live_")) return { state: "no-key" };
90
+ const accessToken = String(creds?.access_token || "").trim();
91
+ const hasApiKey = apiKey.startsWith("pm_live_");
92
+ if (!hasApiKey && !accessToken) return { state: "no-key" };
93
+ const payload = {
94
+ user_email: creds?.user_email ?? creds?.user?.email ?? null,
95
+ created_at: new Date().toISOString(),
96
+ };
97
+ if (hasApiKey) payload.api_key = apiKey;
98
+ if (accessToken) payload.access_token = accessToken;
99
+ const user = desktopSessionUser(creds);
100
+ if (user) payload.user = user;
65
101
  mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
66
- writeFileSync(
67
- DESKTOP_SESSION_FILE,
68
- `${JSON.stringify({ api_key: apiKey, user_email: creds?.user_email ?? null, created_at: new Date().toISOString() }, null, 2)}\n`,
69
- { mode: 0o600 }
70
- );
102
+ writeFileSync(DESKTOP_SESSION_FILE, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
103
+ // writeFileSync's mode only applies when it creates the file, and this one
104
+ // holds a session token tighten an existing file explicitly.
105
+ chmodSync(DESKTOP_SESSION_FILE, 0o600);
71
106
  return { state: "written", path: DESKTOP_SESSION_FILE };
72
107
  }
73
108
 
109
+ export function clearDesktopSession() {
110
+ if (!existsSync(DESKTOP_SESSION_FILE)) return false;
111
+ rmSync(DESKTOP_SESSION_FILE, { force: true });
112
+ return true;
113
+ }
114
+
74
115
  /**
75
116
  * Install-or-not aside, get the customer into the app signed in.
76
117
  *
77
118
  * The session is written before the app is launched so a cold start finds it on
78
119
  * first read, rather than racing a window that is already loading.
120
+ *
121
+ * Whether it was taken up is then waited for rather than assumed: the app
122
+ * deletes the file as it reads it, and an app too old to know about the file at
123
+ * all would otherwise be reported as signed in while the customer looks at a
124
+ * login screen. The file is left behind on timeout -- it expires on its own, and
125
+ * a slow first launch can still find it.
79
126
  */
80
- export function openDesktopSignedIn(creds, { destination = "/Applications" } = {}) {
127
+ export async function openDesktopSignedIn(
128
+ creds,
129
+ { destination = "/Applications", waitMs = 12_000, sleep = defaultSleep } = {}
130
+ ) {
81
131
  if (!desktopAppInstalled(destination)) {
82
132
  return { state: "not-installed" };
83
133
  }
84
134
  const handoff = writeDesktopSession(creds);
85
- spawn("open", ["-a", "PreMan"], { stdio: "ignore", detached: true }).unref();
86
- return { state: handoff.state === "written" ? "opened-signed-in" : "opened", handoff: handoff.state };
135
+ // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
136
+ // which may well pick a different copy than the one just installed.
137
+ spawn("open", ["-a", installedAppPath(destination)], { stdio: "ignore", detached: true }).unref();
138
+ if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
139
+
140
+ const deadline = Date.now() + waitMs;
141
+ while (Date.now() < deadline) {
142
+ await sleep(500);
143
+ if (!existsSync(DESKTOP_SESSION_FILE)) {
144
+ return { state: "opened-signed-in", handoff: handoff.state };
145
+ }
146
+ }
147
+ return { state: "opened-not-adopted", handoff: handoff.state };
87
148
  }
88
149
 
150
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
151
+
89
152
  /**
90
153
  * Open a Playground session URL in PreMan.app on macOS when it is installed.
91
154
  * Windows/Linux (and Mac without the app) get the website URL printed instead.
@@ -212,7 +275,35 @@ function mountedVolume(hdiutilOutput) {
212
275
  return match[1].trim();
213
276
  }
214
277
 
215
- export async function installDesktopCommand(commandArgs = []) {
278
+ /**
279
+ * Attach the disk image somewhere we named, rather than wherever it landed.
280
+ *
281
+ * Asking for the mountpoint is what makes this unambiguous. Reading it back out
282
+ * of `hdiutil info` is not a substitute: that lists every image attached on the
283
+ * machine, so an unrelated volume -- an iOS restore image, someone else's dmg --
284
+ * could be searched for PreMan.app instead of the download.
285
+ */
286
+ function attachDiskImage(dmgPath, mountPoint) {
287
+ mkdirSync(mountPoint, { recursive: true });
288
+ try {
289
+ run("hdiutil", ["attach", dmgPath, "-nobrowse", "-quiet", "-readonly", "-mountpoint", mountPoint]);
290
+ return mountPoint;
291
+ } catch {
292
+ // Images that refuse an explicit mountpoint still report where they went, so
293
+ // fall back to this attach's own output -- never to the global image list.
294
+ return mountedVolume(run("hdiutil", ["attach", dmgPath, "-nobrowse", "-readonly"]));
295
+ }
296
+ }
297
+
298
+ /** The bundle to copy out, found by name and then by extension. */
299
+ function appInsideVolume(volume) {
300
+ const named = path.join(volume, APP_NAME);
301
+ if (existsSync(named)) return named;
302
+ const bundle = readdirSync(volume).find((entry) => entry.endsWith(".app"));
303
+ return bundle ? path.join(volume, bundle) : null;
304
+ }
305
+
306
+ export async function installDesktopCommand(commandArgs = [], { onInstalled } = {}) {
216
307
  const args = makeArgs(commandArgs);
217
308
 
218
309
  if (process.platform !== "darwin" && !args.has("--print-url")) {
@@ -266,23 +357,21 @@ export async function installDesktopCommand(commandArgs = []) {
266
357
  process.stdout.write(" checksum unavailable for this release; skipping verification\n");
267
358
  }
268
359
 
269
- const attach = run("hdiutil", ["attach", dmgPath, "-nobrowse", "-quiet", "-readonly"]);
270
- mounted = mountedVolume(attach || run("hdiutil", ["info"]));
360
+ mounted = attachDiskImage(dmgPath, path.join(workDir, "mnt"));
271
361
 
272
- const source = path.join(mounted, APP_NAME);
273
- if (!existsSync(source)) {
274
- throw new Error(`${APP_NAME} not found inside the disk image at ${mounted}`);
362
+ const source = appInsideVolume(mounted);
363
+ if (!source) {
364
+ throw new Error(`no application bundle found inside the disk image at ${mounted}`);
275
365
  }
276
366
  const target = path.join(destination, APP_NAME);
277
367
  if (existsSync(target)) rmSync(target, { recursive: true, force: true });
278
368
  run("cp", ["-R", source, target]);
279
369
  chmodSync(target, 0o755);
280
370
 
281
- process.stdout.write(
282
- `\nInstalled ${target}\n\nNext: open PreMan and sign in with the account you use here.\n` +
283
- `\`${cliInvocation()} onboard\` installs it and opens it already signed in.\n`
284
- );
285
- return { state: "installed", version, arch, path: target };
371
+ const result = { state: "installed", version, arch, path: target };
372
+ process.stdout.write(`\nInstalled ${target}\n\n`);
373
+ onInstalled?.(result);
374
+ return result;
286
375
  } finally {
287
376
  if (mounted) {
288
377
  spawnSync("hdiutil", ["detach", mounted, "-quiet"], { encoding: "utf8" });
package/bin/detect.js CHANGED
@@ -276,24 +276,76 @@ function inventoryForCandidate(candidate, inventory) {
276
276
  return { inventory: selected, scope };
277
277
  }
278
278
 
279
- /** Route keys the live app advertises through its generated spec, if it has one. */
279
+ /** Route keys the live app advertises through its generated spec, if it has one.
280
+ *
281
+ * `keys` answers "is this the same service"; `routes` maps each key back to the
282
+ * path as written, which is what a caller needs to build a URL that resolves.
283
+ * Normalising is lossy in both case and parameter name, so the original cannot
284
+ * be recovered from the key afterwards.
285
+ */
280
286
  async function liveSpecRoutes(baseUrl, timeoutMs) {
281
287
  for (const specPath of SPEC_PATHS) {
282
288
  const spec = await fetchJson(`${baseUrl}${specPath}`, timeoutMs);
283
289
  const paths = spec && typeof spec === "object" ? spec.paths : null;
284
290
  if (!paths || typeof paths !== "object") continue;
285
291
  const keys = new Set();
292
+ const routes = new Map();
286
293
  for (const [rawPath, methods] of Object.entries(paths)) {
287
294
  if (!methods || typeof methods !== "object") continue;
288
295
  for (const method of Object.keys(methods)) {
289
- keys.add(`${method.toUpperCase()} ${normalisePathTemplate(rawPath)}`);
296
+ const key = `${method.toUpperCase()} ${normalisePathTemplate(rawPath)}`;
297
+ keys.add(key);
298
+ if (!routes.has(key)) routes.set(key, String(rawPath));
290
299
  }
291
300
  }
292
- if (keys.size) return { keys, via: specPath };
301
+ if (keys.size) return { keys, routes, via: specPath };
293
302
  }
294
303
  return null;
295
304
  }
296
305
 
306
+ /**
307
+ * The mount prefix a scanned path is missing, according to the running app.
308
+ *
309
+ * A pre-push plan is scanned from the files the push touched, so the module that
310
+ * mounts the router is usually not among them -- and shipping it would not be
311
+ * enough anyway, because the prefix is commonly a variable
312
+ * (`include_router(r, prefix=settings.api_prefix)`) whose literal lives in a
313
+ * config class. No static scan resolves that without executing the app. The app
314
+ * itself has already published the answer in its spec, so the scanned path is
315
+ * matched against it by suffix and the difference is returned.
316
+ *
317
+ * Deliberately conservative, because a wrong prefix here sends real requests to
318
+ * a URL nobody wrote: the method must match, the suffix must land on a segment
319
+ * boundary so `/orders` cannot claim `/preorders`, and exactly one spec route
320
+ * may match. Two matches mean the same tail is mounted twice and choosing
321
+ * either would be a guess.
322
+ *
323
+ * Returns "" when the path is already correct, or null when it cannot be
324
+ * resolved -- which the caller must treat as "leave it alone", not as "".
325
+ */
326
+ export function missingMountPrefix(method, path, specRoutes) {
327
+ if (!specRoutes || typeof specRoutes.get !== "function") return null;
328
+ const verb = String(method || "GET").toUpperCase();
329
+ const normalised = normalisePathTemplate(path);
330
+ if (!normalised || normalised === "/") return null;
331
+ // The leading slash is what makes the comparison below segment-aware: it is
332
+ // why `/orders` does not match `/api/v1/preorders`, whose tail is `eorders`.
333
+ const suffix = normalised.startsWith("/") ? normalised : `/${normalised}`;
334
+ if (specRoutes.has(`${verb} ${suffix}`)) return "";
335
+
336
+ let found = null;
337
+ for (const key of specRoutes.keys()) {
338
+ const gap = key.indexOf(" ");
339
+ if (key.slice(0, gap) !== verb) continue;
340
+ const candidate = key.slice(gap + 1);
341
+ if (!candidate.endsWith(suffix)) continue;
342
+ // Ambiguous: refuse rather than pick.
343
+ if (found !== null) return null;
344
+ found = candidate.slice(0, candidate.length - suffix.length).replace(/\/+$/, "");
345
+ }
346
+ return found;
347
+ }
348
+
297
349
  export const CONFIRM_MIN_OVERLAP = 0.5;
298
350
  export const CONFIRM_MIN_ROUTES = 2;
299
351
 
@@ -345,6 +397,10 @@ export async function confirmCandidate(candidate, inventory, { timeoutMs = 2000
345
397
  expected: expected.size,
346
398
  overlap: Number(overlap.toFixed(3)),
347
399
  via: live.via,
400
+ // Carried so the caller can reconcile a scanned path against what the
401
+ // app actually serves. Only ever set on a spec-confirmed target: the
402
+ // probe path below proves reachability, not the route table.
403
+ specRoutes: live.routes,
348
404
  };
349
405
  }
350
406
  return {
package/bin/hook.js CHANGED
@@ -171,13 +171,19 @@ function isOurHook(text) {
171
171
  return text.includes(MARKER);
172
172
  }
173
173
 
174
- export function installHook(args, { invocation = provenInvocation() } = {}) {
174
+ export function installHook(args, { invocation } = {}) {
175
+ // Deliberately before the invocation is resolved: proving one costs a probe
176
+ // that can shell out to npm and reach the registry, and somewhere that is not
177
+ // a repository has nowhere to put a hook however that probe turns out. As a
178
+ // default parameter this ran first, so `preman onboard` in a plain directory
179
+ // spent up to the probe timeout before reporting there was nothing to do.
175
180
  const target = hookPath();
181
+ const resolved = invocation ?? provenInvocation();
176
182
 
177
183
  // A hook holding a command that does not run is worse than no hook: it is
178
184
  // silent, it says "installed" in every status we print, and the only sign of
179
185
  // it is one skipped line scrolling past a push nobody reads.
180
- if (!invocation) {
186
+ if (!resolved) {
181
187
  return {
182
188
  path: target,
183
189
  action: "unproven",
@@ -187,7 +193,7 @@ export function installHook(args, { invocation = provenInvocation() } = {}) {
187
193
  };
188
194
  }
189
195
 
190
- const body = hookBody(invocation);
196
+ const body = hookBody(resolved);
191
197
  mkdirSync(path.dirname(target), { recursive: true });
192
198
 
193
199
  if (existsSync(target)) {
@@ -195,7 +201,7 @@ export function installHook(args, { invocation = provenInvocation() } = {}) {
195
201
  if (isOurHook(existing)) {
196
202
  writeFileSync(target, body, { mode: 0o755 });
197
203
  chmodSync(target, 0o755);
198
- return { path: target, action: "updated", invocation };
204
+ return { path: target, action: "updated", invocation: resolved };
199
205
  }
200
206
  if (!args.has("--force")) {
201
207
  return {
@@ -211,14 +217,14 @@ export function installHook(args, { invocation = provenInvocation() } = {}) {
211
217
  return {
212
218
  path: target,
213
219
  action: "replaced",
214
- invocation,
220
+ invocation: resolved,
215
221
  detail: `previous hook saved to ${backup}`,
216
222
  };
217
223
  }
218
224
 
219
225
  writeFileSync(target, body, { mode: 0o755 });
220
226
  chmodSync(target, 0o755);
221
- return { path: target, action: "installed", invocation };
227
+ return { path: target, action: "installed", invocation: resolved };
222
228
  }
223
229
 
224
230
  export function uninstallHook() {