premanmcp 0.7.1 → 0.9.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 +23 -13
- package/bin/account.js +212 -0
- package/bin/changed.js +175 -0
- package/bin/cli.js +42 -3
- package/bin/connect.js +279 -59
- package/bin/desktop.js +214 -0
- package/bin/detect.js +378 -0
- package/bin/hook.js +165 -0
- package/bin/integrations.js +6 -26
- package/bin/progress.js +110 -0
- package/bin/shared.js +122 -30
- 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
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
* hand, and no third-party credential ever touches the terminal.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
|
|
19
17
|
import {
|
|
20
18
|
assertOk,
|
|
21
19
|
callBackendJson,
|
|
22
20
|
frontendUrl,
|
|
21
|
+
openUrl,
|
|
23
22
|
promptText,
|
|
24
23
|
resolveApiKey,
|
|
25
24
|
} from "./shared.js";
|
|
@@ -42,9 +41,9 @@ function paint(code, text) {
|
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
export const MARK = {
|
|
45
|
-
ok: () => paint("32", "
|
|
46
|
-
skip: () => paint("90", "
|
|
47
|
-
fail: () => paint("31", "
|
|
44
|
+
ok: () => paint("32", "?"),
|
|
45
|
+
skip: () => paint("90", "?"),
|
|
46
|
+
fail: () => paint("31", "?"),
|
|
48
47
|
};
|
|
49
48
|
|
|
50
49
|
/** A finished step, marked so it can be found at a glance. */
|
|
@@ -52,25 +51,6 @@ export function connected(message) {
|
|
|
52
51
|
process.stdout.write(`${MARK.ok()} ${message}\n`);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Open a URL in the customer's default browser, best effort. */
|
|
56
|
-
export function openUrl(url) {
|
|
57
|
-
const command =
|
|
58
|
-
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
59
|
-
try {
|
|
60
|
-
const child = spawn(command, [url], {
|
|
61
|
-
stdio: "ignore",
|
|
62
|
-
detached: true,
|
|
63
|
-
shell: process.platform === "win32",
|
|
64
|
-
});
|
|
65
|
-
child.unref();
|
|
66
|
-
return true;
|
|
67
|
-
} catch {
|
|
68
|
-
// Headless boxes and locked-down shells are normal. The URL is printed
|
|
69
|
-
// either way, so this is a convenience, never the only path.
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
54
|
function present(url, what) {
|
|
75
55
|
process.stdout.write(`\n ${url}\n\n`);
|
|
76
56
|
if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
|
|
@@ -349,7 +329,7 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
349
329
|
let i = 0;
|
|
350
330
|
while (i < steps.length) {
|
|
351
331
|
const step = steps[i];
|
|
352
|
-
process.stdout.write(`\n
|
|
332
|
+
process.stdout.write(`\n?? ${step.name} ??\n`);
|
|
353
333
|
|
|
354
334
|
const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
|
|
355
335
|
if (choice === "back") {
|
|
@@ -377,7 +357,7 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
377
357
|
i += 1;
|
|
378
358
|
}
|
|
379
359
|
|
|
380
|
-
process.stdout.write("\n
|
|
360
|
+
process.stdout.write("\n?? done ??\n");
|
|
381
361
|
// One line per step, marked, so the outcome is scannable rather than prose.
|
|
382
362
|
for (const step of steps) {
|
|
383
363
|
const result = outcome.get(step.name);
|
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,7 +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
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
@@ -101,6 +101,36 @@ export function frontendUrl(args) {
|
|
|
101
101
|
return args.value("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND).replace(/\/+$/, "");
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Open a URL in the user's default browser, best effort.
|
|
106
|
+
*
|
|
107
|
+
* Returns false without spawning anything when nobody is there to look at it:
|
|
108
|
+
* `PREMAN_NO_BROWSER` for an explicit no, and a non-TTY stdout for CI, piped
|
|
109
|
+
* output, and test runs. Every caller prints the URL as well, so this is only
|
|
110
|
+
* ever a convenience — a suite that exercises these flows should not be able to
|
|
111
|
+
* throw tabs at whoever is running it.
|
|
112
|
+
*/
|
|
113
|
+
export function openUrl(url) {
|
|
114
|
+
const optOut = (process.env.PREMAN_NO_BROWSER || "").trim().toLowerCase();
|
|
115
|
+
if (optOut && !["0", "false", "no"].includes(optOut)) return false;
|
|
116
|
+
if (!process.stdout.isTTY) return false;
|
|
117
|
+
|
|
118
|
+
const opener =
|
|
119
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
120
|
+
try {
|
|
121
|
+
const child = spawn(opener, [url], {
|
|
122
|
+
stdio: "ignore",
|
|
123
|
+
detached: true,
|
|
124
|
+
shell: process.platform === "win32",
|
|
125
|
+
});
|
|
126
|
+
child.unref();
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
// Headless boxes and locked-down shells are normal.
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
export async function promptText(question) {
|
|
105
135
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
106
136
|
try {
|
|
@@ -171,7 +201,12 @@ export async function promptPasswordTwice() {
|
|
|
171
201
|
return password;
|
|
172
202
|
}
|
|
173
203
|
|
|
174
|
-
export async function callBackendJson(
|
|
204
|
+
export async function callBackendJson(
|
|
205
|
+
args,
|
|
206
|
+
method,
|
|
207
|
+
routePath,
|
|
208
|
+
{ json, token, query, headers: extraHeaders } = {}
|
|
209
|
+
) {
|
|
175
210
|
const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
|
|
176
211
|
if (query) {
|
|
177
212
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -183,6 +218,11 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
|
|
|
183
218
|
const hasBody = json !== undefined && json !== null;
|
|
184
219
|
if (hasBody) headers["Content-Type"] = "application/json";
|
|
185
220
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
221
|
+
if (extraHeaders) {
|
|
222
|
+
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
223
|
+
if (value != null && value !== "") headers[key] = String(value);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
186
226
|
|
|
187
227
|
const resp = await fetch(url, {
|
|
188
228
|
method,
|
|
@@ -225,7 +265,50 @@ async function verifyUnconfirmedAccount(args, email) {
|
|
|
225
265
|
return verified.access_token;
|
|
226
266
|
}
|
|
227
267
|
|
|
268
|
+
/**
|
|
269
|
+
* A password only if the caller asked for one.
|
|
270
|
+
*
|
|
271
|
+
* The terminal's durable credential is the pm_live_ key, so signup does not
|
|
272
|
+
* need a password and no longer asks for one. `--password` is for anyone who
|
|
273
|
+
* wants to be able to sign in to the dashboard immediately; `--password <value>`
|
|
274
|
+
* skips the prompt for scripted runs.
|
|
275
|
+
*/
|
|
276
|
+
async function optionalPassword(args) {
|
|
277
|
+
if (!args.has("--password")) return "";
|
|
278
|
+
const supplied = args.value("--password", "");
|
|
279
|
+
// Bare `--password` followed by another flag means "prompt me", not "my
|
|
280
|
+
// password is --backend".
|
|
281
|
+
if (supplied && !supplied.startsWith("--")) {
|
|
282
|
+
if (supplied.length < 6) throw new Error("Password must be at least 6 characters.");
|
|
283
|
+
return supplied;
|
|
284
|
+
}
|
|
285
|
+
return promptPasswordTwice();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Turn a verification code into a session token.
|
|
290
|
+
*
|
|
291
|
+
* Without a password this is one call: verify-otp signs the user in and the
|
|
292
|
+
* backend keeps the account passwordless, so the dashboard knows to offer a
|
|
293
|
+
* code rather than a password field.
|
|
294
|
+
*/
|
|
295
|
+
async function completeWithCode(args, email, otp, password) {
|
|
296
|
+
if (password) {
|
|
297
|
+
const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
|
|
298
|
+
json: { email, otp, new_password: password },
|
|
299
|
+
});
|
|
300
|
+
assertOk(setPassword, "set password");
|
|
301
|
+
return String(setPassword.access_token || "");
|
|
302
|
+
}
|
|
303
|
+
const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
|
|
304
|
+
json: { email, otp },
|
|
305
|
+
});
|
|
306
|
+
assertOk(verified, "verify OTP");
|
|
307
|
+
return String(verified.access_token || "");
|
|
308
|
+
}
|
|
309
|
+
|
|
228
310
|
async function createAccountFromTerminal(args, email) {
|
|
311
|
+
const password = await optionalPassword(args);
|
|
229
312
|
const signup = await callBackendJson(args, "POST", "/auth/start-signup", {
|
|
230
313
|
json: { email },
|
|
231
314
|
});
|
|
@@ -233,24 +316,21 @@ async function createAccountFromTerminal(args, email) {
|
|
|
233
316
|
if (signup.ok) {
|
|
234
317
|
process.stdout.write("Verification code sent. Check your email.\n");
|
|
235
318
|
const otp = await promptText("Verification code: ");
|
|
236
|
-
|
|
237
|
-
const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
|
|
238
|
-
json: { email, otp, new_password: password },
|
|
239
|
-
});
|
|
240
|
-
assertOk(setPassword, "set password");
|
|
241
|
-
return String(setPassword.access_token || "");
|
|
319
|
+
return completeWithCode(args, email, otp, password);
|
|
242
320
|
}
|
|
243
321
|
|
|
244
322
|
if (signup.status_code !== 404) {
|
|
245
323
|
assertOk(signup, "start signup");
|
|
246
324
|
}
|
|
247
325
|
|
|
326
|
+
// Backends predating start-signup have no passwordless route in, so this one
|
|
327
|
+
// path still has to ask.
|
|
248
328
|
process.stdout.write(
|
|
249
329
|
"This PreMan backend uses the password-first signup flow. Create your password now, then enter the email code.\n"
|
|
250
330
|
);
|
|
251
|
-
const
|
|
331
|
+
const legacyPassword = password || (await promptPasswordTwice());
|
|
252
332
|
const legacySignup = await callBackendJson(args, "POST", "/auth/signup", {
|
|
253
|
-
json: { email, password },
|
|
333
|
+
json: { email, password: legacyPassword },
|
|
254
334
|
});
|
|
255
335
|
assertOk(legacySignup, "signup");
|
|
256
336
|
process.stdout.write("Verification code sent. Check your email.\n");
|
|
@@ -293,26 +373,31 @@ export async function authenticateTerminal(args) {
|
|
|
293
373
|
process.stdout.write("No PreMan account found. Creating one now.\n");
|
|
294
374
|
accessToken = await createAccountFromTerminal(args, email);
|
|
295
375
|
} else if (account.needs_password) {
|
|
296
|
-
|
|
376
|
+
// No password on this account, so a code is the only way in.
|
|
377
|
+
const password = await optionalPassword(args);
|
|
378
|
+
process.stdout.write("Sending a sign-in code to your email.\n");
|
|
297
379
|
const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
|
|
298
380
|
assertOk(resend, "resend OTP");
|
|
299
381
|
const otp = await promptText("Verification code: ");
|
|
300
|
-
|
|
301
|
-
const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
|
|
302
|
-
json: { email, otp, new_password: password },
|
|
303
|
-
});
|
|
304
|
-
assertOk(setPassword, "set password");
|
|
305
|
-
accessToken = String(setPassword.access_token || "");
|
|
382
|
+
accessToken = await completeWithCode(args, email, otp, password);
|
|
306
383
|
} else {
|
|
307
|
-
const password = await promptSecret("Password: ");
|
|
308
|
-
|
|
309
|
-
json: { email
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
384
|
+
const password = await promptSecret("Password (Enter to get a code by email): ");
|
|
385
|
+
if (!password) {
|
|
386
|
+
const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
|
|
387
|
+
assertOk(resend, "resend OTP");
|
|
388
|
+
process.stdout.write("Sign-in code sent. Check your email.\n");
|
|
389
|
+
const otp = await promptText("Verification code: ");
|
|
390
|
+
accessToken = await completeWithCode(args, email, otp, "");
|
|
313
391
|
} else {
|
|
314
|
-
|
|
315
|
-
|
|
392
|
+
const login = await callBackendJson(args, "POST", "/auth/login", {
|
|
393
|
+
json: { email, password },
|
|
394
|
+
});
|
|
395
|
+
if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
|
|
396
|
+
accessToken = await verifyUnconfirmedAccount(args, email);
|
|
397
|
+
} else {
|
|
398
|
+
assertOk(login, "login");
|
|
399
|
+
accessToken = String(login.access_token || "");
|
|
400
|
+
}
|
|
316
401
|
}
|
|
317
402
|
}
|
|
318
403
|
|
|
@@ -360,6 +445,16 @@ export function resolveApiKey(args) {
|
|
|
360
445
|
* Matches the backend's install snippets (`build_install_snippets`) so the
|
|
361
446
|
* copy-paste path and this writer cannot drift.
|
|
362
447
|
*/
|
|
448
|
+
// npm exec rather than npx: same resolution, but it is the command every install
|
|
449
|
+
// of npm ships, and it is what the rest of our instructions use. The trailing "--"
|
|
450
|
+
// stops npm from claiming flags meant for the server.
|
|
451
|
+
//
|
|
452
|
+
// Exported so the post-write verifier checks for the launcher we actually emit.
|
|
453
|
+
// These drifted once already -- every writer emitted "npm" while the verifier
|
|
454
|
+
// demanded "npx", so every connect printed a mismatch warning.
|
|
455
|
+
export const LAUNCHER_COMMAND = "npm";
|
|
456
|
+
export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
|
|
457
|
+
|
|
363
458
|
export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
364
459
|
const env = {
|
|
365
460
|
PREMAN_BACKEND: backendUrl(args),
|
|
@@ -370,11 +465,8 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
|
370
465
|
if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
|
|
371
466
|
|
|
372
467
|
return {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// The trailing "--" stops npm from claiming flags meant for the server.
|
|
376
|
-
command: "npm",
|
|
377
|
-
args: ["exec", "-y", "premanmcp@latest", "--"],
|
|
468
|
+
command: LAUNCHER_COMMAND,
|
|
469
|
+
args: [...LAUNCHER_ARGS],
|
|
378
470
|
env,
|
|
379
471
|
};
|
|
380
472
|
}
|