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/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,
|
|
14
|
-
|
|
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,
|
|
97
|
-
already signed in, then
|
|
98
|
-
summary at the end.
|
|
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
|
|
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", "
|
|
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));
|
package/bin/connect/guide.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
360
|
-
|
|
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
|
-
|
|
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 (
|
|
436
|
+
if (already) {
|
|
365
437
|
process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
|
|
366
|
-
return
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import os from "node:os";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
|
|
29
|
-
import { CREDENTIALS_DIR,
|
|
29
|
+
import { CREDENTIALS_DIR, makeArgs } from "./shared.js";
|
|
30
30
|
|
|
31
31
|
export const DESKTOP_HELP = `
|
|
32
32
|
Install-desktop options:
|
|
@@ -68,18 +68,50 @@ export function desktopAppInstalled(destination = "/Applications") {
|
|
|
68
68
|
* something different to say in each case: an app that is not installed is not
|
|
69
69
|
* a failure, it is the fallback where the customer signs in themselves.
|
|
70
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
|
+
*/
|
|
71
88
|
export function writeDesktopSession(creds) {
|
|
72
89
|
const apiKey = String(creds?.api_key || "").trim();
|
|
73
|
-
|
|
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;
|
|
74
101
|
mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
75
|
-
writeFileSync(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
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);
|
|
80
106
|
return { state: "written", path: DESKTOP_SESSION_FILE };
|
|
81
107
|
}
|
|
82
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
|
+
|
|
83
115
|
/**
|
|
84
116
|
* Install-or-not aside, get the customer into the app signed in.
|
|
85
117
|
*
|
|
@@ -100,7 +132,9 @@ export async function openDesktopSignedIn(
|
|
|
100
132
|
return { state: "not-installed" };
|
|
101
133
|
}
|
|
102
134
|
const handoff = writeDesktopSession(creds);
|
|
103
|
-
|
|
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();
|
|
104
138
|
if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
|
|
105
139
|
|
|
106
140
|
const deadline = Date.now() + waitMs;
|
|
@@ -269,7 +303,7 @@ function appInsideVolume(volume) {
|
|
|
269
303
|
return bundle ? path.join(volume, bundle) : null;
|
|
270
304
|
}
|
|
271
305
|
|
|
272
|
-
export async function installDesktopCommand(commandArgs = []) {
|
|
306
|
+
export async function installDesktopCommand(commandArgs = [], { onInstalled } = {}) {
|
|
273
307
|
const args = makeArgs(commandArgs);
|
|
274
308
|
|
|
275
309
|
if (process.platform !== "darwin" && !args.has("--print-url")) {
|
|
@@ -334,11 +368,10 @@ export async function installDesktopCommand(commandArgs = []) {
|
|
|
334
368
|
run("cp", ["-R", source, target]);
|
|
335
369
|
chmodSync(target, 0o755);
|
|
336
370
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
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;
|
|
342
375
|
} finally {
|
|
343
376
|
if (mounted) {
|
|
344
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
|
-
|
|
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
|
|
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 (!
|
|
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(
|
|
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() {
|