premanmcp 0.7.0 → 0.8.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/account.js +223 -0
- package/bin/changed.js +175 -0
- package/bin/cli.js +61 -16
- package/bin/connect.js +33 -9
- package/bin/desktop.js +214 -0
- package/bin/detect.js +378 -0
- package/bin/hook.js +165 -0
- package/bin/integrations.js +59 -21
- package/bin/progress.js +110 -0
- package/bin/shared.js +52 -6
- package/bin/status.js +210 -0
- package/bin/verify.js +701 -0
- package/package.json +4 -2
package/bin/hook.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `preman hook install|uninstall|status` — the git pre-push entry point.
|
|
3
|
+
*
|
|
4
|
+
* The single most important property here: **PreMan must never block a push.**
|
|
5
|
+
* No backend, no credentials, no detectable local target, a crash, or a timeout
|
|
6
|
+
* all exit 0 with a notice. A tool that stops `git push` when its own
|
|
7
|
+
* infrastructure is down gets uninstalled the same day, so the hook is advisory
|
|
8
|
+
* by construction rather than by configuration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { cliInvocation, makeArgs } from "./shared.js";
|
|
16
|
+
import { BLOCK_EXIT_CODE } from "./verify.js";
|
|
17
|
+
|
|
18
|
+
export const HOOK_HELP = `
|
|
19
|
+
Hook options:
|
|
20
|
+
install Write the pre-push hook into this repository
|
|
21
|
+
uninstall Remove PreMan's pre-push hook
|
|
22
|
+
status Report whether the hook is installed
|
|
23
|
+
--force Overwrite a foreign pre-push hook (a backup is kept)
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const MARKER = "# >>> preman pre-push >>>";
|
|
27
|
+
const END_MARKER = "# <<< preman pre-push <<<";
|
|
28
|
+
const HOOK_TIMEOUT_SECONDS = 120;
|
|
29
|
+
|
|
30
|
+
function gitDir() {
|
|
31
|
+
const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
|
|
32
|
+
if (result.status !== 0) {
|
|
33
|
+
throw new Error("not inside a git repository");
|
|
34
|
+
}
|
|
35
|
+
return path.resolve(result.stdout.trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hookPath() {
|
|
39
|
+
return path.join(gitDir(), "hooks", "pre-push");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hookBody() {
|
|
43
|
+
// `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
|
|
44
|
+
// non-zero and still exit 0 itself.
|
|
45
|
+
//
|
|
46
|
+
// Git's ref payload arrives on this script's stdin and is what tells PreMan
|
|
47
|
+
// which commits are actually being pushed, so it is piped straight through.
|
|
48
|
+
return `#!/bin/sh
|
|
49
|
+
${MARKER}
|
|
50
|
+
# Managed by PreMan. Runs the local endpoint suite before the push leaves.
|
|
51
|
+
# Advisory unless this repository opted into blocking: only exit code
|
|
52
|
+
# ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
|
|
53
|
+
if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
|
|
54
|
+
PREMAN_HOOK=1 ${cliInvocation()} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
|
|
55
|
+
preman_status=$?
|
|
56
|
+
if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
|
|
57
|
+
exit ${BLOCK_EXIT_CODE}
|
|
58
|
+
fi
|
|
59
|
+
if [ "$preman_status" -ne 0 ]; then
|
|
60
|
+
printf '[preman] checks skipped (%s)\\n' "advisory" >&2
|
|
61
|
+
fi
|
|
62
|
+
fi
|
|
63
|
+
${END_MARKER}
|
|
64
|
+
exit 0
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isOurHook(text) {
|
|
69
|
+
return text.includes(MARKER);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function installHook(args) {
|
|
73
|
+
const target = hookPath();
|
|
74
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
75
|
+
|
|
76
|
+
if (existsSync(target)) {
|
|
77
|
+
const existing = readFileSync(target, "utf8");
|
|
78
|
+
if (isOurHook(existing)) {
|
|
79
|
+
writeFileSync(target, hookBody(), { mode: 0o755 });
|
|
80
|
+
chmodSync(target, 0o755);
|
|
81
|
+
return { path: target, action: "updated" };
|
|
82
|
+
}
|
|
83
|
+
if (!args.has("--force")) {
|
|
84
|
+
return {
|
|
85
|
+
path: target,
|
|
86
|
+
action: "conflict",
|
|
87
|
+
detail: "a pre-push hook already exists; re-run with --force to replace it",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const backup = `${target}.preman-backup`;
|
|
91
|
+
writeFileSync(backup, existing, { mode: 0o755 });
|
|
92
|
+
writeFileSync(target, hookBody(), { mode: 0o755 });
|
|
93
|
+
chmodSync(target, 0o755);
|
|
94
|
+
return { path: target, action: "replaced", detail: `previous hook saved to ${backup}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
writeFileSync(target, hookBody(), { mode: 0o755 });
|
|
98
|
+
chmodSync(target, 0o755);
|
|
99
|
+
return { path: target, action: "installed" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function uninstallHook() {
|
|
103
|
+
const target = hookPath();
|
|
104
|
+
if (!existsSync(target)) return { path: target, action: "absent" };
|
|
105
|
+
const existing = readFileSync(target, "utf8");
|
|
106
|
+
if (!isOurHook(existing)) {
|
|
107
|
+
return { path: target, action: "foreign", detail: "left in place; PreMan did not write it" };
|
|
108
|
+
}
|
|
109
|
+
unlinkSync(target);
|
|
110
|
+
const backup = `${target}.preman-backup`;
|
|
111
|
+
if (existsSync(backup)) {
|
|
112
|
+
writeFileSync(target, readFileSync(backup, "utf8"), { mode: 0o755 });
|
|
113
|
+
unlinkSync(backup);
|
|
114
|
+
return { path: target, action: "restored", detail: "previous hook restored from backup" };
|
|
115
|
+
}
|
|
116
|
+
return { path: target, action: "removed" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hookStatus() {
|
|
120
|
+
const target = hookPath();
|
|
121
|
+
if (!existsSync(target)) return { path: target, state: "absent" };
|
|
122
|
+
const existing = readFileSync(target, "utf8");
|
|
123
|
+
return { path: target, state: isOurHook(existing) ? "installed" : "foreign" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function hookCommand(commandArgs = []) {
|
|
127
|
+
const sub = commandArgs.find((value) => !value.startsWith("-")) || "status";
|
|
128
|
+
const args = makeArgs(commandArgs);
|
|
129
|
+
|
|
130
|
+
if (sub === "install") {
|
|
131
|
+
const result = installHook(args);
|
|
132
|
+
if (result.action === "conflict") {
|
|
133
|
+
process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(
|
|
137
|
+
`Pre-push hook ${result.action}: ${result.path}\n` +
|
|
138
|
+
(result.detail ? ` ${result.detail}\n` : "") +
|
|
139
|
+
`\nPreMan will now check affected endpoints before each push.\n` +
|
|
140
|
+
`It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
|
|
141
|
+
);
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (sub === "uninstall") {
|
|
146
|
+
const result = uninstallHook();
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
`Pre-push hook ${result.action}: ${result.path}\n` + (result.detail ? ` ${result.detail}\n` : "")
|
|
149
|
+
);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (sub === "status") {
|
|
154
|
+
const result = hookStatus();
|
|
155
|
+
const label = {
|
|
156
|
+
installed: "installed (PreMan)",
|
|
157
|
+
foreign: "present, but not written by PreMan",
|
|
158
|
+
absent: "not installed",
|
|
159
|
+
}[result.state];
|
|
160
|
+
process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error(`unknown hook subcommand: ${sub}${HOOK_HELP}`);
|
|
165
|
+
}
|
package/bin/integrations.js
CHANGED
|
@@ -224,8 +224,13 @@ export async function awsCommand(args) {
|
|
|
224
224
|
export async function githubCommand(args) {
|
|
225
225
|
const token = requireKey(args);
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
const
|
|
227
|
+
// This route answers with a bare array; callBackendJson exposes it as `list`.
|
|
228
|
+
const listRepos = async () => {
|
|
229
|
+
const resp = await callBackendJson(args, "GET", "/integrations/github", { token });
|
|
230
|
+
return resp.list || resp.integrations || resp.repos || [];
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const seen = new Set((await listRepos()).map((r) => r.id));
|
|
229
234
|
|
|
230
235
|
const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
|
|
231
236
|
token,
|
|
@@ -239,11 +244,17 @@ export async function githubCommand(args) {
|
|
|
239
244
|
process.stdout.write("Pick the repositories PreMan may read.\n");
|
|
240
245
|
|
|
241
246
|
const done = await waitFor("the installation", async () => {
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
// Installing the App and having repositories appear are two events: the
|
|
248
|
+
// callback records the installation, and a refresh materialises the repos.
|
|
249
|
+
// Polling the repo list alone waits for something that may never arrive on
|
|
250
|
+
// its own.
|
|
251
|
+
await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
252
|
+
token,
|
|
253
|
+
json: {},
|
|
254
|
+
});
|
|
244
255
|
// Compare against what existed before, so a user who already had repos
|
|
245
256
|
// connected is not told they are done the moment polling starts.
|
|
246
|
-
const fresh =
|
|
257
|
+
const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
|
|
247
258
|
return fresh.length ? fresh : null;
|
|
248
259
|
});
|
|
249
260
|
|
|
@@ -289,10 +300,14 @@ export async function slackCommand(args) {
|
|
|
289
300
|
// The guided run
|
|
290
301
|
// ---------------------------------------------------------------------------
|
|
291
302
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
303
|
+
/** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
|
|
304
|
+
async function askStep(question, { assumeYes, canGoBack }) {
|
|
305
|
+
if (assumeYes) return "yes";
|
|
306
|
+
const hint = canGoBack ? "[Y/n/b]" : "[Y/n]";
|
|
307
|
+
const answer = (await promptText(`${question} ${hint}: `)).trim().toLowerCase();
|
|
308
|
+
if (canGoBack && (answer === "b" || answer === "back")) return "back";
|
|
309
|
+
if (answer === "" || answer === "y" || answer === "yes") return "yes";
|
|
310
|
+
return "no";
|
|
296
311
|
}
|
|
297
312
|
|
|
298
313
|
/**
|
|
@@ -323,32 +338,54 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
323
338
|
{ name: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
|
|
324
339
|
];
|
|
325
340
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
341
|
+
// Outcome per step rather than three lists, so revisiting a step replaces its
|
|
342
|
+
// result instead of recording it twice.
|
|
343
|
+
const outcome = new Map();
|
|
344
|
+
|
|
345
|
+
// Indexed rather than for..of so "back" can move the cursor. Re-running a
|
|
346
|
+
// step is safe: every one of these is idempotent on the backend -- an AWS
|
|
347
|
+
// grant returns the existing link, and an App install that already happened
|
|
348
|
+
// is detected rather than duplicated.
|
|
349
|
+
let i = 0;
|
|
350
|
+
while (i < steps.length) {
|
|
351
|
+
const step = steps[i];
|
|
331
352
|
process.stdout.write(`\n── ${step.name} ──\n`);
|
|
332
|
-
|
|
333
|
-
|
|
353
|
+
|
|
354
|
+
const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
|
|
355
|
+
if (choice === "back") {
|
|
356
|
+
// Drop the result we are about to redo, or the summary would report the
|
|
357
|
+
// stale outcome of a step the customer chose to revisit.
|
|
358
|
+
outcome.delete(steps[i - 1].name);
|
|
359
|
+
i -= 1;
|
|
334
360
|
continue;
|
|
335
361
|
}
|
|
362
|
+
if (choice === "no") {
|
|
363
|
+
outcome.set(step.name, { state: "skipped" });
|
|
364
|
+
i += 1;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
336
368
|
try {
|
|
337
369
|
await step.run();
|
|
338
|
-
|
|
370
|
+
outcome.set(step.name, { state: "done" });
|
|
339
371
|
} catch (err) {
|
|
340
372
|
// Report and carry on: a failed Slack install must not cost the customer
|
|
341
373
|
// the AWS connection they just finished.
|
|
342
|
-
|
|
374
|
+
outcome.set(step.name, { state: "failed", detail: err.message });
|
|
343
375
|
process.stdout.write(`Could not finish ${step.name}: ${err.message}\n`);
|
|
344
376
|
}
|
|
377
|
+
i += 1;
|
|
345
378
|
}
|
|
346
379
|
|
|
347
380
|
process.stdout.write("\n── done ──\n");
|
|
348
381
|
// One line per step, marked, so the outcome is scannable rather than prose.
|
|
349
|
-
for (const
|
|
350
|
-
|
|
351
|
-
|
|
382
|
+
for (const step of steps) {
|
|
383
|
+
const result = outcome.get(step.name);
|
|
384
|
+
if (!result) continue;
|
|
385
|
+
if (result.state === "done") process.stdout.write(`${MARK.ok()} ${step.name}\n`);
|
|
386
|
+
else if (result.state === "skipped") process.stdout.write(`${MARK.skip()} ${step.name} (skipped)\n`);
|
|
387
|
+
else process.stdout.write(`${MARK.fail()} ${step.name}: ${result.detail}\n`);
|
|
388
|
+
}
|
|
352
389
|
process.stdout.write(`\nOpen ${frontendUrl(args)} to see your logs and endpoints.\n`);
|
|
353
390
|
}
|
|
354
391
|
|
|
@@ -360,6 +397,7 @@ Setup options:
|
|
|
360
397
|
preman slack Add PreMan to a Slack workspace
|
|
361
398
|
|
|
362
399
|
--yes Accept every step without prompting (onboard)
|
|
400
|
+
b at any onboard prompt Go back to the previous step
|
|
363
401
|
--account <id> AWS account id, skips the prompt
|
|
364
402
|
--region <region> AWS region for log groups. Defaults to us-east-1
|
|
365
403
|
--project-id <id> PreMan project to attach the log connector to
|
package/bin/progress.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live terminal rendering for concurrent work.
|
|
3
|
+
*
|
|
4
|
+
* A pre-push run is the moment the developer is watching, so silence for the
|
|
5
|
+
* length of a 120-second budget reads as a hang. This renders one line per
|
|
6
|
+
* worker, updated in place, with finished results scrolling above them.
|
|
7
|
+
*
|
|
8
|
+
* Everything degrades to plain sequential lines when stdout is not a TTY. A
|
|
9
|
+
* hook's output is routinely piped, captured by a GUI git client, or read in
|
|
10
|
+
* CI, and cursor-movement escapes in a log file are worse than no animation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
14
|
+
const FRAME_MS = 80;
|
|
15
|
+
|
|
16
|
+
const CLEAR_LINE = "\u001b[2K";
|
|
17
|
+
const HIDE_CURSOR = "\u001b[?25l";
|
|
18
|
+
const SHOW_CURSOR = "\u001b[?25h";
|
|
19
|
+
|
|
20
|
+
const GREEN = "\u001b[32m";
|
|
21
|
+
const RED = "\u001b[31m";
|
|
22
|
+
const YELLOW = "\u001b[33m";
|
|
23
|
+
const DIM = "\u001b[2m";
|
|
24
|
+
const RESET = "\u001b[0m";
|
|
25
|
+
|
|
26
|
+
function truncate(text, width) {
|
|
27
|
+
const value = String(text ?? "");
|
|
28
|
+
return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}\u2026`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createReporter({ stream = process.stdout, live } = {}) {
|
|
32
|
+
const isLive = live ?? Boolean(stream.isTTY);
|
|
33
|
+
const slots = new Map();
|
|
34
|
+
let rendered = 0;
|
|
35
|
+
let frame = 0;
|
|
36
|
+
let timer = null;
|
|
37
|
+
const colour = (code, text) => (isLive ? `${code}${text}${RESET}` : text);
|
|
38
|
+
|
|
39
|
+
function width() {
|
|
40
|
+
return Math.max(40, Math.min(stream.columns || 100, 140));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clearLive() {
|
|
44
|
+
if (!isLive || rendered === 0) return;
|
|
45
|
+
stream.write(`\u001b[${rendered}A`);
|
|
46
|
+
for (let i = 0; i < rendered; i += 1) stream.write(`${CLEAR_LINE}\n`);
|
|
47
|
+
stream.write(`\u001b[${rendered}A`);
|
|
48
|
+
rendered = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function drawLive() {
|
|
52
|
+
if (!isLive || slots.size === 0) return;
|
|
53
|
+
const spin = FRAMES[frame % FRAMES.length];
|
|
54
|
+
for (const label of slots.values()) {
|
|
55
|
+
stream.write(`${CLEAR_LINE} ${colour(YELLOW, spin)} ${truncate(label, width() - 6)}\n`);
|
|
56
|
+
}
|
|
57
|
+
rendered = slots.size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function repaint() {
|
|
61
|
+
clearLive();
|
|
62
|
+
drawLive();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
isLive,
|
|
67
|
+
|
|
68
|
+
/** Print a line that stays in the scrollback, above the live region. */
|
|
69
|
+
line(text) {
|
|
70
|
+
clearLive();
|
|
71
|
+
stream.write(`${text}\n`);
|
|
72
|
+
drawLive();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Claim a worker line. `slot` is any stable key for that worker. */
|
|
76
|
+
busy(slot, label) {
|
|
77
|
+
slots.set(slot, label);
|
|
78
|
+
if (!isLive) return;
|
|
79
|
+
if (timer === null) {
|
|
80
|
+
stream.write(HIDE_CURSOR);
|
|
81
|
+
timer = setInterval(() => {
|
|
82
|
+
frame += 1;
|
|
83
|
+
repaint();
|
|
84
|
+
}, FRAME_MS);
|
|
85
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
86
|
+
}
|
|
87
|
+
repaint();
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/** Release a worker line and record its outcome in the scrollback. */
|
|
91
|
+
done(slot, { ok, skipped, label, detail }) {
|
|
92
|
+
slots.delete(slot);
|
|
93
|
+
const mark = skipped ? colour(DIM, "\u00b7") : ok ? colour(GREEN, "\u2713") : colour(RED, "\u2717");
|
|
94
|
+
const tail = detail ? ` ${colour(DIM, detail)}` : "";
|
|
95
|
+
clearLive();
|
|
96
|
+
stream.write(` ${mark} ${truncate(label, width() - 12)}${tail}\n`);
|
|
97
|
+
drawLive();
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
stop() {
|
|
101
|
+
if (timer !== null) {
|
|
102
|
+
clearInterval(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
}
|
|
105
|
+
clearLive();
|
|
106
|
+
slots.clear();
|
|
107
|
+
if (isLive) stream.write(SHOW_CURSOR);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/bin/shared.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* over a module-level argv, so a command can pass its own slice.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
10
11
|
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -18,6 +19,28 @@ export const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
|
|
|
18
19
|
export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
19
20
|
|
|
20
21
|
/** Wrap a raw argv slice in the positional lookup the CLI has always used. */
|
|
22
|
+
/**
|
|
23
|
+
* How to tell this user to invoke us.
|
|
24
|
+
*
|
|
25
|
+
* The help used to say "preman connect" unconditionally, which is only true
|
|
26
|
+
* after a global install. Anyone reading it straight out of
|
|
27
|
+
* `npm exec premanmcp -- --help` -- the way the docs tell them to run it --
|
|
28
|
+
* copied a command that answers "command not found".
|
|
29
|
+
*/
|
|
30
|
+
let _invocation = null;
|
|
31
|
+
|
|
32
|
+
export function cliInvocation() {
|
|
33
|
+
if (_invocation) return _invocation;
|
|
34
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
35
|
+
try {
|
|
36
|
+
const found = spawnSync(probe, ["preman"], { stdio: "pipe", encoding: "utf8" });
|
|
37
|
+
_invocation = found.status === 0 && found.stdout.trim() ? "preman" : null;
|
|
38
|
+
} catch {
|
|
39
|
+
_invocation = null;
|
|
40
|
+
}
|
|
41
|
+
return (_invocation ||= "npm exec -y premanmcp@latest --");
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
export function makeArgs(commandArgs = []) {
|
|
22
45
|
return {
|
|
23
46
|
raw: commandArgs,
|
|
@@ -148,7 +171,12 @@ export async function promptPasswordTwice() {
|
|
|
148
171
|
return password;
|
|
149
172
|
}
|
|
150
173
|
|
|
151
|
-
export async function callBackendJson(
|
|
174
|
+
export async function callBackendJson(
|
|
175
|
+
args,
|
|
176
|
+
method,
|
|
177
|
+
routePath,
|
|
178
|
+
{ json, token, query, headers: extraHeaders } = {}
|
|
179
|
+
) {
|
|
152
180
|
const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
|
|
153
181
|
if (query) {
|
|
154
182
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -160,6 +188,11 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
|
|
|
160
188
|
const hasBody = json !== undefined && json !== null;
|
|
161
189
|
if (hasBody) headers["Content-Type"] = "application/json";
|
|
162
190
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
191
|
+
if (extraHeaders) {
|
|
192
|
+
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
193
|
+
if (value != null && value !== "") headers[key] = String(value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
163
196
|
|
|
164
197
|
const resp = await fetch(url, {
|
|
165
198
|
method,
|
|
@@ -173,6 +206,12 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
|
|
|
173
206
|
} catch {
|
|
174
207
|
body = { raw: text };
|
|
175
208
|
}
|
|
209
|
+
// Some routes answer with a bare array. Spreading one into this object turns
|
|
210
|
+
// it into {0:…,1:…} and quietly loses the list, so it gets its own key.
|
|
211
|
+
if (Array.isArray(body)) {
|
|
212
|
+
return { status_code: resp.status, ok: resp.ok, list: body };
|
|
213
|
+
}
|
|
214
|
+
|
|
176
215
|
return {
|
|
177
216
|
status_code: resp.status,
|
|
178
217
|
ok: resp.ok,
|
|
@@ -331,6 +370,16 @@ export function resolveApiKey(args) {
|
|
|
331
370
|
* Matches the backend's install snippets (`build_install_snippets`) so the
|
|
332
371
|
* copy-paste path and this writer cannot drift.
|
|
333
372
|
*/
|
|
373
|
+
// npm exec rather than npx: same resolution, but it is the command every install
|
|
374
|
+
// of npm ships, and it is what the rest of our instructions use. The trailing "--"
|
|
375
|
+
// stops npm from claiming flags meant for the server.
|
|
376
|
+
//
|
|
377
|
+
// Exported so the post-write verifier checks for the launcher we actually emit.
|
|
378
|
+
// These drifted once already -- every writer emitted "npm" while the verifier
|
|
379
|
+
// demanded "npx", so every connect printed a mismatch warning.
|
|
380
|
+
export const LAUNCHER_COMMAND = "npm";
|
|
381
|
+
export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
|
|
382
|
+
|
|
334
383
|
export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
335
384
|
const env = {
|
|
336
385
|
PREMAN_BACKEND: backendUrl(args),
|
|
@@ -341,11 +390,8 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
|
341
390
|
if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
|
|
342
391
|
|
|
343
392
|
return {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// The trailing "--" stops npm from claiming flags meant for the server.
|
|
347
|
-
command: "npm",
|
|
348
|
-
args: ["exec", "-y", "premanmcp@latest", "--"],
|
|
393
|
+
command: LAUNCHER_COMMAND,
|
|
394
|
+
args: [...LAUNCHER_ARGS],
|
|
349
395
|
env,
|
|
350
396
|
};
|
|
351
397
|
}
|