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/account.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account-adjacent commands: browser login, logout, doctor, and watch.
|
|
3
|
+
*
|
|
4
|
+
* `loginBrowser` drives the device-code flow at /auth/device/*, which has existed
|
|
5
|
+
* in the backend with no client. It is the right path for anyone who does not
|
|
6
|
+
* want to type a password into a terminal, and the only one that works when the
|
|
7
|
+
* account is behind SSO.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, rmSync } from "node:fs";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
|
|
14
|
+
import { detectCandidates } from "./detect.js";
|
|
15
|
+
import {
|
|
16
|
+
CREDENTIALS_FILE,
|
|
17
|
+
backendUrl,
|
|
18
|
+
callBackendJson,
|
|
19
|
+
cliInvocation,
|
|
20
|
+
frontendUrl,
|
|
21
|
+
makeArgs,
|
|
22
|
+
readStoredCredentials,
|
|
23
|
+
resolveApiKey,
|
|
24
|
+
saveStoredCredentials,
|
|
25
|
+
} from "./shared.js";
|
|
26
|
+
|
|
27
|
+
export const ACCOUNT_HELP = `
|
|
28
|
+
Account options:
|
|
29
|
+
login --browser Approve sign-in in the browser instead of typing a password
|
|
30
|
+
logout Delete stored CLI credentials
|
|
31
|
+
doctor Check backend, credentials, target, and integrations
|
|
32
|
+
watch <run-id> Follow a push simulation as it runs
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const POLL_INTERVAL_MS = 3000;
|
|
36
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
function openBrowser(url) {
|
|
39
|
+
const opener =
|
|
40
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
41
|
+
try {
|
|
42
|
+
spawn(opener, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
|
|
51
|
+
export async function loginBrowser(args) {
|
|
52
|
+
const start = await callBackendJson(args, "POST", "/auth/device/request", {
|
|
53
|
+
json: { device_name: `PreMan CLI (${os.hostname()})` },
|
|
54
|
+
});
|
|
55
|
+
if (!start.ok) {
|
|
56
|
+
throw new Error(`could not start browser login: ${start.status_code} ${start.detail || ""}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const verificationUrl = start.verification_url || `${frontendUrl(args)}/device/verify?code=${start.device_code}`;
|
|
60
|
+
process.stdout.write(
|
|
61
|
+
`\nApprove this device in your browser:\n ${verificationUrl}\n\n` +
|
|
62
|
+
`Confirmation code: ${start.user_code}\n\n`
|
|
63
|
+
);
|
|
64
|
+
if (!openBrowser(verificationUrl)) {
|
|
65
|
+
process.stdout.write("Could not open a browser automatically — open the link above.\n");
|
|
66
|
+
}
|
|
67
|
+
process.stdout.write("Waiting for approval…\n");
|
|
68
|
+
|
|
69
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
await sleep(POLL_INTERVAL_MS);
|
|
72
|
+
const poll = await callBackendJson(args, "POST", "/auth/device/poll", {
|
|
73
|
+
json: { device_code: start.device_code },
|
|
74
|
+
});
|
|
75
|
+
const status = String(poll.status || "").toLowerCase();
|
|
76
|
+
if (poll.ok && poll.api_key) {
|
|
77
|
+
const creds = {
|
|
78
|
+
api_key: String(poll.api_key),
|
|
79
|
+
backend_url: backendUrl(args),
|
|
80
|
+
user_email: poll.user_email || "",
|
|
81
|
+
device_name: os.hostname(),
|
|
82
|
+
created_at: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
saveStoredCredentials(creds);
|
|
85
|
+
process.stdout.write(`\nSigned in${creds.user_email ? ` as ${creds.user_email}` : ""}.\n`);
|
|
86
|
+
process.stdout.write(`Saved to ${CREDENTIALS_FILE}\n`);
|
|
87
|
+
return creds;
|
|
88
|
+
}
|
|
89
|
+
if (status === "expired" || status === "denied") {
|
|
90
|
+
throw new Error(`browser login ${status}. Run \`${cliInvocation()} login --browser\` again.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error("browser login timed out after 5 minutes.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function logoutCommand() {
|
|
97
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
98
|
+
rmSync(CREDENTIALS_FILE, { force: true });
|
|
99
|
+
process.stdout.write(`Removed ${CREDENTIALS_FILE}\n`);
|
|
100
|
+
} else {
|
|
101
|
+
process.stdout.write("No stored credentials to remove.\n");
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(
|
|
104
|
+
"Note: this does not revoke the API key. Revoke it in the dashboard if the machine is shared.\n"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function line(label, ok, detail) {
|
|
109
|
+
const mark = ok === null ? "–" : ok ? "✓" : "✗";
|
|
110
|
+
return ` ${mark} ${label}${detail ? ` ${detail}` : ""}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function doctorCommand(commandArgs = []) {
|
|
114
|
+
const args = makeArgs(commandArgs);
|
|
115
|
+
const results = [];
|
|
116
|
+
let failures = 0;
|
|
117
|
+
|
|
118
|
+
const stored = readStoredCredentials();
|
|
119
|
+
const token = resolveApiKey(args);
|
|
120
|
+
results.push(
|
|
121
|
+
line(
|
|
122
|
+
"credentials",
|
|
123
|
+
Boolean(token),
|
|
124
|
+
token
|
|
125
|
+
? `${stored?.user_email || "key present"} (${CREDENTIALS_FILE})`
|
|
126
|
+
: `none — run \`${cliInvocation()} login\``
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
if (!token) failures += 1;
|
|
130
|
+
|
|
131
|
+
let health = { ok: false, status_code: 0, detail: "" };
|
|
132
|
+
try {
|
|
133
|
+
health = await callBackendJson(args, "GET", "/health");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
health = { ok: false, status_code: 0, detail: String(error?.message || error) };
|
|
136
|
+
}
|
|
137
|
+
results.push(line("backend", health.ok, `${backendUrl(args)}${health.ok ? "" : ` — ${health.detail || health.status_code}`}`));
|
|
138
|
+
if (!health.ok) failures += 1;
|
|
139
|
+
|
|
140
|
+
let status = null;
|
|
141
|
+
if (token && health.ok) {
|
|
142
|
+
try {
|
|
143
|
+
status = await callBackendJson(args, "GET", "/cli/status", { token });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
status = { ok: false, detail: String(error?.message || error) };
|
|
146
|
+
}
|
|
147
|
+
results.push(
|
|
148
|
+
line("api key", Boolean(status.ok), status.ok ? `workspace ${status.workspace?.name || "?"}` : status.detail || `status ${status.status_code}`)
|
|
149
|
+
);
|
|
150
|
+
if (!status.ok) failures += 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const candidates = detectCandidates(process.cwd());
|
|
154
|
+
results.push(
|
|
155
|
+
line(
|
|
156
|
+
"local target",
|
|
157
|
+
candidates.length > 0,
|
|
158
|
+
candidates.length
|
|
159
|
+
? `${candidates.length} candidate${candidates.length === 1 ? "" : "s"} (${candidates[0].url} from ${candidates[0].source})`
|
|
160
|
+
: "no signals in this directory"
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (status?.ok) {
|
|
165
|
+
const integrations = status.integrations || {};
|
|
166
|
+
for (const [name, section] of Object.entries(integrations)) {
|
|
167
|
+
results.push(line(name, Boolean(section?.connected), section?.connected ? "" : "not connected"));
|
|
168
|
+
}
|
|
169
|
+
results.push(
|
|
170
|
+
line("project link", Boolean(status.workspace?.project_id), status.workspace?.project_id ? "" : "workspace is not linked to a project")
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
process.stdout.write(`PreMan doctor\n\n${results.join("\n")}\n\n`);
|
|
175
|
+
process.stdout.write(failures ? `${failures} problem${failures === 1 ? "" : "s"} found.\n` : "Everything looks healthy.\n");
|
|
176
|
+
if (failures) process.exitCode = 1;
|
|
177
|
+
return { failures };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "error", "cancelled"]);
|
|
181
|
+
|
|
182
|
+
export async function watchCommand(commandArgs = []) {
|
|
183
|
+
const args = makeArgs(commandArgs);
|
|
184
|
+
const token = resolveApiKey(args);
|
|
185
|
+
if (!token) throw new Error(`no API key. Run \`${cliInvocation()} login\` first.`);
|
|
186
|
+
|
|
187
|
+
const positional = commandArgs.filter((value) => !value.startsWith("-"));
|
|
188
|
+
const runId = args.value("--run", positional[0] || "");
|
|
189
|
+
const integrationId = args.value("--integration", positional[1] || "");
|
|
190
|
+
if (!runId || !integrationId) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"usage: watch <run-id> <integration-id> (both are shown by `preman status`)"
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const deadline = Date.now() + 15 * 60 * 1000;
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
const result = await callBackendJson(
|
|
200
|
+
args,
|
|
201
|
+
"GET",
|
|
202
|
+
`/integrations/github/${integrationId}/simulations/${runId}`,
|
|
203
|
+
{ token }
|
|
204
|
+
);
|
|
205
|
+
if (!result.ok) throw new Error(`could not read run: ${result.status_code} ${result.detail || ""}`);
|
|
206
|
+
|
|
207
|
+
for (const step of result.steps || []) {
|
|
208
|
+
const key = `${step.key}:${step.status}`;
|
|
209
|
+
if (seen.has(key)) continue;
|
|
210
|
+
seen.add(key);
|
|
211
|
+
const mark = step.status === "succeeded" ? "✓" : step.status === "failed" ? "✗" : "·";
|
|
212
|
+
process.stdout.write(` ${mark} ${String(step.key || "").padEnd(14)} ${step.message || ""}\n`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (TERMINAL_STATUSES.has(String(result.status || "").toLowerCase())) {
|
|
216
|
+
const verdict = result.summary?.verdict || result.status;
|
|
217
|
+
process.stdout.write(`\nRun ${result.status}: ${verdict}\n`);
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
await sleep(POLL_INTERVAL_MS);
|
|
221
|
+
}
|
|
222
|
+
throw new Error("stopped watching after 15 minutes; the run is still in progress.");
|
|
223
|
+
}
|
package/bin/changed.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files a push actually touches.
|
|
3
|
+
*
|
|
4
|
+
* Git hands a pre-push hook one line per ref on stdin —
|
|
5
|
+
* `<local ref> <local sha> <remote ref> <remote sha>` — which is the only
|
|
6
|
+
* authoritative statement of what is about to leave the machine. Everything
|
|
7
|
+
* else (diffing against HEAD, against a guessed upstream) is a guess that goes
|
|
8
|
+
* wrong on exactly the pushes that matter: a first push of a new branch, an
|
|
9
|
+
* amend, a force push.
|
|
10
|
+
*
|
|
11
|
+
* Reading it is best-effort by design. If stdin is absent, empty, or malformed,
|
|
12
|
+
* we fall back rather than fail, because a hook that cannot decide what changed
|
|
13
|
+
* must still let the push through.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
// Git's "this ref does not exist yet" sentinel, in both SHA-1 and SHA-256 form.
|
|
21
|
+
const NULL_SHAS = new Set(["0".repeat(40), "0".repeat(64)]);
|
|
22
|
+
|
|
23
|
+
// A push of a brand-new branch has no remote counterpart to diff against, so
|
|
24
|
+
// walking its full history could mean scanning the entire repository. Cap it.
|
|
25
|
+
const NEW_BRANCH_DEPTH = 50;
|
|
26
|
+
|
|
27
|
+
const CODE_EXTENSIONS = new Set([
|
|
28
|
+
".py", ".js", ".ts", ".mjs", ".cjs", ".tsx", ".jsx", ".go", ".rb", ".java", ".php", ".cs", ".rs",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const SPEC_NAMES = new Set([
|
|
32
|
+
"openapi.json", "openapi.yaml", "openapi.yml",
|
|
33
|
+
"swagger.json", "swagger.yaml", "swagger.yml",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function git(cwdArgs, { cwd } = {}) {
|
|
37
|
+
const result = spawnSync("git", cwdArgs, { encoding: "utf8", cwd, maxBuffer: 32 * 1024 * 1024 });
|
|
38
|
+
if (result.status !== 0) return null;
|
|
39
|
+
return String(result.stdout || "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function repoRoot() {
|
|
43
|
+
const out = git(["rev-parse", "--show-toplevel"]);
|
|
44
|
+
return out ? out.trim() : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse git's pre-push stdin payload into the ref pairs being pushed. */
|
|
48
|
+
export function parsePushRefs(raw) {
|
|
49
|
+
return String(raw || "")
|
|
50
|
+
.split("\n")
|
|
51
|
+
.map((line) => line.trim())
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((line) => {
|
|
54
|
+
const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
|
|
55
|
+
return { localRef, localSha, remoteRef, remoteSha };
|
|
56
|
+
})
|
|
57
|
+
.filter((ref) => ref.localSha && !NULL_SHAS.has(ref.localSha));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read git's stdin payload without hanging when the hook was run by hand. */
|
|
61
|
+
export function readPushRefsFromStdin() {
|
|
62
|
+
// A TTY means a human typed the command, so there is no ref payload coming
|
|
63
|
+
// and a blocking read would hang the terminal.
|
|
64
|
+
if (process.stdin.isTTY) return [];
|
|
65
|
+
try {
|
|
66
|
+
return parsePushRefs(readFileSync(0, "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function diffNames(range, cwd) {
|
|
73
|
+
const out = git(["diff", "--name-only", "--diff-filter=ACMR", range], { cwd });
|
|
74
|
+
if (out === null) return null;
|
|
75
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Files touched by the last `depth` commits reachable from `sha`.
|
|
80
|
+
*
|
|
81
|
+
* A new branch has no remote counterpart, and the obvious `sha~N..sha` range
|
|
82
|
+
* fails outright when the branch is younger than N commits -- which is most
|
|
83
|
+
* new branches. Walking the log bounds the work the same way without needing
|
|
84
|
+
* the ancestor to exist.
|
|
85
|
+
*/
|
|
86
|
+
function logNames(sha, depth, cwd) {
|
|
87
|
+
const out = git(
|
|
88
|
+
["log", "--format=", "--name-only", "--diff-filter=ACMR", `-n${depth}`, sha],
|
|
89
|
+
{ cwd }
|
|
90
|
+
);
|
|
91
|
+
if (out === null) return null;
|
|
92
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The files this push introduces, relative to the repo root.
|
|
97
|
+
*
|
|
98
|
+
* Uncommitted work is deliberately excluded: a push ships commits, so testing
|
|
99
|
+
* the dirty working tree would report on code the teammate pulling this branch
|
|
100
|
+
* will never receive.
|
|
101
|
+
*/
|
|
102
|
+
export function changedFilesForPush(refs, { cwd } = {}) {
|
|
103
|
+
const root = cwd || repoRoot();
|
|
104
|
+
if (!root) return { files: [], reason: "not_a_git_repository" };
|
|
105
|
+
|
|
106
|
+
const names = new Set();
|
|
107
|
+
let reason = null;
|
|
108
|
+
|
|
109
|
+
for (const ref of refs) {
|
|
110
|
+
const isNewBranch = !ref.remoteSha || NULL_SHAS.has(ref.remoteSha);
|
|
111
|
+
// Never fall back to a bare `git diff <sha>`: that compares the working
|
|
112
|
+
// tree, so a clean checkout reports nothing at all and a dirty one reports
|
|
113
|
+
// edits this push does not carry.
|
|
114
|
+
let found;
|
|
115
|
+
if (isNewBranch) {
|
|
116
|
+
found = logNames(ref.localSha, NEW_BRANCH_DEPTH, root);
|
|
117
|
+
reason = "new_branch_bounded_history";
|
|
118
|
+
} else {
|
|
119
|
+
found = diffNames(`${ref.remoteSha}..${ref.localSha}`, root);
|
|
120
|
+
}
|
|
121
|
+
for (const name of found ?? []) names.add(name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!refs.length) {
|
|
125
|
+
// Run by hand rather than by git: compare against the tracked upstream.
|
|
126
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { cwd: root });
|
|
127
|
+
const fallback = upstream ? diffNames(`${upstream.trim()}..HEAD`, root) : null;
|
|
128
|
+
for (const name of fallback ?? []) names.add(name);
|
|
129
|
+
reason = upstream ? "upstream_diff" : "no_refs_no_upstream";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { files: [...names], reason, root };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Only files a scanner could find an endpoint in. */
|
|
136
|
+
export function isScannable(relPath) {
|
|
137
|
+
const base = path.basename(relPath).toLowerCase();
|
|
138
|
+
if (SPEC_NAMES.has(base)) return true;
|
|
139
|
+
return CODE_EXTENSIONS.has(path.extname(relPath).toLowerCase());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the pushed files' contents for the backend to scan.
|
|
144
|
+
*
|
|
145
|
+
* Reads from the working tree rather than the commit. They agree on the
|
|
146
|
+
* overwhelmingly common push-what-you-committed path, and when they disagree
|
|
147
|
+
* the working tree is what the developer's running dev server is serving —
|
|
148
|
+
* which is what the plan is about to be tested against.
|
|
149
|
+
*/
|
|
150
|
+
export function readChangedFiles(files, { root, maxFiles = 300, maxBytes = 512 * 1024 } = {}) {
|
|
151
|
+
const out = [];
|
|
152
|
+
let skipped = 0;
|
|
153
|
+
|
|
154
|
+
for (const rel of files) {
|
|
155
|
+
if (out.length >= maxFiles) {
|
|
156
|
+
skipped += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!isScannable(rel)) continue;
|
|
160
|
+
try {
|
|
161
|
+
const content = readFileSync(path.join(root, rel), "utf8");
|
|
162
|
+
if (Buffer.byteLength(content, "utf8") > maxBytes) {
|
|
163
|
+
skipped += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
out.push({ path: rel, content });
|
|
167
|
+
} catch {
|
|
168
|
+
// Deleted in the working tree, or unreadable. Either way there is nothing
|
|
169
|
+
// to scan, and the diff still reports the endpoint as removed.
|
|
170
|
+
skipped += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { files: out, skipped };
|
|
175
|
+
}
|
package/bin/cli.js
CHANGED
|
@@ -26,8 +26,14 @@ import {
|
|
|
26
26
|
slackCommand,
|
|
27
27
|
} from "./integrations.js";
|
|
28
28
|
import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
|
|
29
|
+
import { STATUS_HELP, statusCommand } from "./status.js";
|
|
30
|
+
import { HOOK_HELP, hookCommand } from "./hook.js";
|
|
31
|
+
import { VERIFY_HELP, verifyCommand } from "./verify.js";
|
|
32
|
+
import { DESKTOP_HELP, installDesktopCommand } from "./desktop.js";
|
|
33
|
+
import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
|
|
29
34
|
import {
|
|
30
35
|
CREDENTIALS_FILE,
|
|
36
|
+
cliInvocation,
|
|
31
37
|
DEFAULT_BACKEND,
|
|
32
38
|
DEFAULT_FRONTEND,
|
|
33
39
|
authenticateTerminal,
|
|
@@ -58,24 +64,45 @@ function hasFlag(name) {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
function printHelp() {
|
|
67
|
+
// Say "preman x" only to people who can actually run it; everyone else gets
|
|
68
|
+
// the form that works from a bare npm install.
|
|
69
|
+
const cli = cliInvocation();
|
|
70
|
+
// Padded here rather than hand-aligned: the invocation prefix is 6 characters
|
|
71
|
+
// for a global install and 38 for npm exec, so a fixed layout is wrong for
|
|
72
|
+
// one of them.
|
|
73
|
+
const usage = [
|
|
74
|
+
["status", "Healthy / failing / recently fixed endpoints"],
|
|
75
|
+
["verify [options]", "Test endpoints against your local app"],
|
|
76
|
+
["hook install|uninstall|status", "Manage the git pre-push hook"],
|
|
77
|
+
["doctor", "Diagnose credentials, backend, target, integrations"],
|
|
78
|
+
["install-desktop", "Download and install the PreMan desktop app"],
|
|
79
|
+
["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
|
|
80
|
+
["connect [options]", "Pick a coding agent and connect it"],
|
|
81
|
+
["aws | github | slack", "Connect one integration on its own"],
|
|
82
|
+
["login [--browser]", "Create/login to PreMan from the terminal"],
|
|
83
|
+
["logout", "Delete stored CLI credentials"],
|
|
84
|
+
["watch <run> <integration>", "Follow a push simulation live"],
|
|
85
|
+
["install [options]", "Install PreMan into Cursor MCP config"],
|
|
86
|
+
["", "Start the PreMan MCP server"],
|
|
87
|
+
["link|tools|run ...", "Drive a published hosted MCP"],
|
|
88
|
+
["endpoints list|discover|setup ...", "Discover, list, and set up API endpoints"],
|
|
89
|
+
["test <id> [--scenario ...] [--stress]", "Generate + run tests for an endpoint"],
|
|
90
|
+
];
|
|
91
|
+
const width = Math.max(...usage.map(([command]) => `${cli} ${command}`.trimEnd().length));
|
|
92
|
+
const usageLines = usage
|
|
93
|
+
.map(([command, blurb]) => ` ${`${cli} ${command}`.trimEnd().padEnd(width)} ${blurb}`)
|
|
94
|
+
.join("\n");
|
|
95
|
+
|
|
61
96
|
process.stdout.write(`PreMan MCP
|
|
62
97
|
|
|
63
98
|
Usage:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
preman aws | github | slack Connect one integration on its own
|
|
67
|
-
npm exec -y premanmcp@latest -- login Create/login to PreMan from the terminal
|
|
68
|
-
npm exec -y premanmcp@latest -- install [options] Install PreMan into Cursor MCP config
|
|
69
|
-
npm exec -y premanmcp@latest -- Start the PreMan MCP server
|
|
70
|
-
preman link|tools|run ... Drive a published hosted MCP
|
|
71
|
-
preman endpoints list|discover|setup ... Discover, list, and set up API endpoints
|
|
72
|
-
preman test <id> [--scenario ...] [--stress] Generate + run tests for an endpoint
|
|
73
|
-
${INTEGRATIONS_HELP}${CONNECT_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
99
|
+
${usageLines}
|
|
100
|
+
${INTEGRATIONS_HELP}${CONNECT_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
74
101
|
Login options:
|
|
75
102
|
--email <email> Pre-fill the email prompt
|
|
76
103
|
--backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
|
|
77
104
|
|
|
78
|
-
Install options (Cursor only — prefer '
|
|
105
|
+
Install options (Cursor only — prefer '${cli} connect'):
|
|
79
106
|
--api-key <key> PreMan API key. If omitted, stored CLI credentials are used
|
|
80
107
|
--backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
|
|
81
108
|
--frontend <url> PreMan frontend URL. Defaults to ${DEFAULT_FRONTEND}
|
|
@@ -87,13 +114,17 @@ Install options (Cursor only — prefer 'preman connect'):
|
|
|
87
114
|
|
|
88
115
|
Examples:
|
|
89
116
|
npm exec -y premanmcp@latest -- connect
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
${cli} connect --agent claude-code
|
|
118
|
+
${cli} login
|
|
92
119
|
npm exec -y premanmcp@latest -- install --project --backend http://127.0.0.1:8000
|
|
93
120
|
${HOSTED_HELP}`);
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
async function loginCommand() {
|
|
124
|
+
if (hasFlag("--browser")) {
|
|
125
|
+
await loginBrowser(cliArgs);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
97
128
|
const creds = await authenticateTerminal(cliArgs);
|
|
98
129
|
process.stdout.write(`PreMan account ready.
|
|
99
130
|
|
|
@@ -103,7 +134,7 @@ API key: ${creds.api_key}
|
|
|
103
134
|
Saved to: ${CREDENTIALS_FILE}
|
|
104
135
|
|
|
105
136
|
You can now run:
|
|
106
|
-
|
|
137
|
+
${cli} connect
|
|
107
138
|
`);
|
|
108
139
|
}
|
|
109
140
|
|
|
@@ -138,12 +169,12 @@ Server name: ${serverName}
|
|
|
138
169
|
Backend: ${serverConfig.env.PREMAN_BACKEND}
|
|
139
170
|
|
|
140
171
|
Next steps:
|
|
141
|
-
1. ${hasInlineKey ? "Your PreMan API key was written to the MCP config." : hasStoredKey ? `Your PreMan API key is saved in ${CREDENTIALS_FILE}; the MCP server will load it automatically.` : "Run
|
|
172
|
+
1. ${hasInlineKey ? "Your PreMan API key was written to the MCP config." : hasStoredKey ? `Your PreMan API key is saved in ${CREDENTIALS_FILE}; the MCP server will load it automatically.` : "Run ${cli} login to create/connect your account and generate an API key."}
|
|
142
173
|
2. Restart Cursor or toggle the PreMan MCP server off/on.
|
|
143
174
|
3. In your API repo, ask your coding agent:
|
|
144
175
|
"Use PreMan to convert the endpoints I choose into a hosted MCP server, then give me the Cursor/Claude install snippet."
|
|
145
176
|
|
|
146
|
-
Tip: '
|
|
177
|
+
Tip: '${cli} connect' also supports Claude Code and Codex, and links the agent to your account.
|
|
147
178
|
`);
|
|
148
179
|
}
|
|
149
180
|
|
|
@@ -177,6 +208,20 @@ function startServer() {
|
|
|
177
208
|
async function main() {
|
|
178
209
|
if (command === "login") {
|
|
179
210
|
await loginCommand();
|
|
211
|
+
} else if (command === "status") {
|
|
212
|
+
await statusCommand(commandArgs);
|
|
213
|
+
} else if (command === "verify") {
|
|
214
|
+
await verifyCommand(commandArgs);
|
|
215
|
+
} else if (command === "hook") {
|
|
216
|
+
await hookCommand(commandArgs);
|
|
217
|
+
} else if (command === "logout") {
|
|
218
|
+
await logoutCommand();
|
|
219
|
+
} else if (command === "doctor") {
|
|
220
|
+
await doctorCommand(commandArgs);
|
|
221
|
+
} else if (command === "watch") {
|
|
222
|
+
await watchCommand(commandArgs);
|
|
223
|
+
} else if (command === "install-desktop") {
|
|
224
|
+
await installDesktopCommand(commandArgs);
|
|
180
225
|
} else if (command === "connect") {
|
|
181
226
|
await connectCommand(commandArgs);
|
|
182
227
|
} else if (command === "onboard" || command === "setup") {
|
package/bin/connect.js
CHANGED
|
@@ -22,8 +22,11 @@ import {
|
|
|
22
22
|
backendUrl,
|
|
23
23
|
buildServerConfig,
|
|
24
24
|
callBackendJson,
|
|
25
|
+
cliInvocation,
|
|
25
26
|
frontendUrl,
|
|
26
27
|
hasKeyAvailable,
|
|
28
|
+
LAUNCHER_ARGS,
|
|
29
|
+
LAUNCHER_COMMAND,
|
|
27
30
|
makeArgs,
|
|
28
31
|
promptSecret,
|
|
29
32
|
promptText,
|
|
@@ -271,8 +274,13 @@ export function renderAllAgentSnippets(args, serverName, { projectInstall = fals
|
|
|
271
274
|
PREMAN_BACKEND: backendUrl(args),
|
|
272
275
|
PREMAN_FRONTEND: frontendUrl(args),
|
|
273
276
|
};
|
|
274
|
-
const serverConfig = {
|
|
275
|
-
|
|
277
|
+
const serverConfig = {
|
|
278
|
+
command: LAUNCHER_COMMAND,
|
|
279
|
+
args: [...LAUNCHER_ARGS],
|
|
280
|
+
env,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
|
|
276
284
|
return AGENTS.map((agent) => {
|
|
277
285
|
// Adjust hint based on projectInstall, matching verifyWrittenConfig logic
|
|
278
286
|
let hint = agent.snippetHint;
|
|
@@ -318,8 +326,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
|
|
|
318
326
|
if (!block) {
|
|
319
327
|
return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
|
|
320
328
|
}
|
|
321
|
-
if (!block.some((line) => line.trim() ===
|
|
322
|
-
return {
|
|
329
|
+
if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
|
|
330
|
+
return {
|
|
331
|
+
status: "mismatch",
|
|
332
|
+
detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
|
|
333
|
+
};
|
|
323
334
|
}
|
|
324
335
|
return { status: "verified" };
|
|
325
336
|
}
|
|
@@ -335,8 +346,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
|
|
|
335
346
|
|
|
336
347
|
const entry = readJsonFile(written.path).mcpServers?.[serverName];
|
|
337
348
|
if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
|
|
338
|
-
if (entry.command !==
|
|
339
|
-
return {
|
|
349
|
+
if (entry.command !== LAUNCHER_COMMAND) {
|
|
350
|
+
return {
|
|
351
|
+
status: "mismatch",
|
|
352
|
+
detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
|
|
353
|
+
};
|
|
340
354
|
}
|
|
341
355
|
return { status: "verified" };
|
|
342
356
|
} catch (error) {
|
|
@@ -401,11 +415,21 @@ async function captureDispatchCredential(args, agent, apiKey) {
|
|
|
401
415
|
|
|
402
416
|
if (!secret) {
|
|
403
417
|
if (!process.stdin.isTTY) return;
|
|
418
|
+
// Framed as the expected step rather than an optional aside. Without it
|
|
419
|
+
// PreMan can only suggest fixes; with it, it can run them. Presenting it as
|
|
420
|
+
// "optional, press Enter to skip" meant almost everyone skipped the thing
|
|
421
|
+
// that makes the product act rather than advise.
|
|
404
422
|
process.stdout.write(
|
|
405
|
-
`\
|
|
423
|
+
`\nLet PreMan start ${agent.label} runs for you — it can then apply fixes and\n` +
|
|
424
|
+
`run checks on a schedule instead of only telling you what to do.\n`
|
|
406
425
|
);
|
|
407
|
-
secret = await promptSecret(
|
|
408
|
-
if (!secret)
|
|
426
|
+
secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to set up later): `);
|
|
427
|
+
if (!secret) {
|
|
428
|
+
process.stdout.write(
|
|
429
|
+
`Skipped. Run '${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
|
|
430
|
+
);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
409
433
|
if (agent.dispatch.needsRoutine && !routineId) {
|
|
410
434
|
routineId = await promptText("Routine id or URL: ");
|
|
411
435
|
}
|