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/README.md
CHANGED
|
@@ -19,11 +19,12 @@ Local development form:
|
|
|
19
19
|
node bin/cli.js connect
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
First-time users are
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
`~/.
|
|
22
|
+
First-time users are asked for an email and the code sent to it — no password: the
|
|
23
|
+
terminal's credential is the `pm_live_` key. Pass `--password` to also set one for
|
|
24
|
+
dashboard sign-in, or set it later from the dashboard. PreMan creates or connects the
|
|
25
|
+
account, generates an API key, saves it to `~/.preman/credentials.json`, then writes a
|
|
26
|
+
`preman` MCP server into the config your agent actually reads (`~/.cursor/mcp.json`,
|
|
27
|
+
Claude Code's MCP config, or `~/.codex/config.toml`):
|
|
27
28
|
|
|
28
29
|
```json
|
|
29
30
|
{
|
|
@@ -40,11 +41,12 @@ agent actually reads (`~/.cursor/mcp.json`, Claude Code's MCP config, or
|
|
|
40
41
|
}
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
Restart your agent afterwards, then ask it to `run preman_status` to finish linking.
|
|
44
|
-
|
|
45
44
|
`connect` reads the config back after writing it, and prints a copy-paste snippet if it
|
|
46
|
-
cannot confirm the entry landed. It then
|
|
47
|
-
|
|
45
|
+
cannot confirm the entry landed. It then finishes the link itself by running your agent
|
|
46
|
+
headlessly (`claude -p`, `cursor-agent -p`, `codex exec`) so it makes one PreMan call —
|
|
47
|
+
that check-in is the only real proof the agent loaded the server. If the agent's CLI is
|
|
48
|
+
not on PATH, `connect` asks you to restart it and waits instead. `--no-auto-checkin`
|
|
49
|
+
turns the spawn off.
|
|
48
50
|
|
|
49
51
|
Once the agent has checked in, `connect` walks you into your first test: it runs one
|
|
50
52
|
against an endpoint you already have, or prints the discovery brief to hand your agent,
|
|
@@ -62,10 +64,18 @@ setup blocks for all three agents and exits 2.
|
|
|
62
64
|
|
|
63
65
|
### Cloud dispatch (optional)
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
With a coding-agent credential saved — a Cursor API key from
|
|
68
|
+
`cursor.com/dashboard → Integrations → API Keys`, or a Claude Code routine token and id
|
|
69
|
+
from `claude.ai/code/routines → your routine → Add API trigger` — PreMan can start an
|
|
70
|
+
agent run for you when it finds a failing endpoint, instead of handing back a prompt to
|
|
71
|
+
paste.
|
|
72
|
+
|
|
73
|
+
`connect` asks for it once you are already set up, so skipping costs nothing. Come back
|
|
74
|
+
to it any time:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm exec -y premanmcp@latest -- dispatch
|
|
78
|
+
```
|
|
69
79
|
|
|
70
80
|
You can also create or connect your account first:
|
|
71
81
|
|
package/bin/account.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
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 os from "node:os";
|
|
12
|
+
|
|
13
|
+
import { detectCandidates } from "./detect.js";
|
|
14
|
+
import {
|
|
15
|
+
CREDENTIALS_FILE,
|
|
16
|
+
backendUrl,
|
|
17
|
+
callBackendJson,
|
|
18
|
+
cliInvocation,
|
|
19
|
+
frontendUrl,
|
|
20
|
+
makeArgs,
|
|
21
|
+
openUrl,
|
|
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
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
|
|
40
|
+
export async function loginBrowser(args) {
|
|
41
|
+
const start = await callBackendJson(args, "POST", "/auth/device/request", {
|
|
42
|
+
json: { device_name: `PreMan CLI (${os.hostname()})` },
|
|
43
|
+
});
|
|
44
|
+
if (!start.ok) {
|
|
45
|
+
throw new Error(`could not start browser login: ${start.status_code} ${start.detail || ""}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const verificationUrl = start.verification_url || `${frontendUrl(args)}/device/verify?code=${start.device_code}`;
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
`\nApprove this device in your browser:\n ${verificationUrl}\n\n` +
|
|
51
|
+
`Confirmation code: ${start.user_code}\n\n`
|
|
52
|
+
);
|
|
53
|
+
if (!openUrl(verificationUrl)) {
|
|
54
|
+
process.stdout.write("Could not open a browser automatically — open the link above.\n");
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write("Waiting for approval…\n");
|
|
57
|
+
|
|
58
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
await sleep(POLL_INTERVAL_MS);
|
|
61
|
+
const poll = await callBackendJson(args, "POST", "/auth/device/poll", {
|
|
62
|
+
json: { device_code: start.device_code },
|
|
63
|
+
});
|
|
64
|
+
const status = String(poll.status || "").toLowerCase();
|
|
65
|
+
if (poll.ok && poll.api_key) {
|
|
66
|
+
const creds = {
|
|
67
|
+
api_key: String(poll.api_key),
|
|
68
|
+
backend_url: backendUrl(args),
|
|
69
|
+
user_email: poll.user_email || "",
|
|
70
|
+
device_name: os.hostname(),
|
|
71
|
+
created_at: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
saveStoredCredentials(creds);
|
|
74
|
+
process.stdout.write(`\nSigned in${creds.user_email ? ` as ${creds.user_email}` : ""}.\n`);
|
|
75
|
+
process.stdout.write(`Saved to ${CREDENTIALS_FILE}\n`);
|
|
76
|
+
return creds;
|
|
77
|
+
}
|
|
78
|
+
if (status === "expired" || status === "denied") {
|
|
79
|
+
throw new Error(`browser login ${status}. Run \`${cliInvocation()} login --browser\` again.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error("browser login timed out after 5 minutes.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function logoutCommand() {
|
|
86
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
87
|
+
rmSync(CREDENTIALS_FILE, { force: true });
|
|
88
|
+
process.stdout.write(`Removed ${CREDENTIALS_FILE}\n`);
|
|
89
|
+
} else {
|
|
90
|
+
process.stdout.write("No stored credentials to remove.\n");
|
|
91
|
+
}
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
"Note: this does not revoke the API key. Revoke it in the dashboard if the machine is shared.\n"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function line(label, ok, detail) {
|
|
98
|
+
const mark = ok === null ? "–" : ok ? "✓" : "✗";
|
|
99
|
+
return ` ${mark} ${label}${detail ? ` ${detail}` : ""}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function doctorCommand(commandArgs = []) {
|
|
103
|
+
const args = makeArgs(commandArgs);
|
|
104
|
+
const results = [];
|
|
105
|
+
let failures = 0;
|
|
106
|
+
|
|
107
|
+
const stored = readStoredCredentials();
|
|
108
|
+
const token = resolveApiKey(args);
|
|
109
|
+
results.push(
|
|
110
|
+
line(
|
|
111
|
+
"credentials",
|
|
112
|
+
Boolean(token),
|
|
113
|
+
token
|
|
114
|
+
? `${stored?.user_email || "key present"} (${CREDENTIALS_FILE})`
|
|
115
|
+
: `none — run \`${cliInvocation()} login\``
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
if (!token) failures += 1;
|
|
119
|
+
|
|
120
|
+
let health = { ok: false, status_code: 0, detail: "" };
|
|
121
|
+
try {
|
|
122
|
+
health = await callBackendJson(args, "GET", "/health");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
health = { ok: false, status_code: 0, detail: String(error?.message || error) };
|
|
125
|
+
}
|
|
126
|
+
results.push(line("backend", health.ok, `${backendUrl(args)}${health.ok ? "" : ` — ${health.detail || health.status_code}`}`));
|
|
127
|
+
if (!health.ok) failures += 1;
|
|
128
|
+
|
|
129
|
+
let status = null;
|
|
130
|
+
if (token && health.ok) {
|
|
131
|
+
try {
|
|
132
|
+
status = await callBackendJson(args, "GET", "/cli/status", { token });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
status = { ok: false, detail: String(error?.message || error) };
|
|
135
|
+
}
|
|
136
|
+
results.push(
|
|
137
|
+
line("api key", Boolean(status.ok), status.ok ? `workspace ${status.workspace?.name || "?"}` : status.detail || `status ${status.status_code}`)
|
|
138
|
+
);
|
|
139
|
+
if (!status.ok) failures += 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const candidates = detectCandidates(process.cwd());
|
|
143
|
+
results.push(
|
|
144
|
+
line(
|
|
145
|
+
"local target",
|
|
146
|
+
candidates.length > 0,
|
|
147
|
+
candidates.length
|
|
148
|
+
? `${candidates.length} candidate${candidates.length === 1 ? "" : "s"} (${candidates[0].url} from ${candidates[0].source})`
|
|
149
|
+
: "no signals in this directory"
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (status?.ok) {
|
|
154
|
+
const integrations = status.integrations || {};
|
|
155
|
+
for (const [name, section] of Object.entries(integrations)) {
|
|
156
|
+
results.push(line(name, Boolean(section?.connected), section?.connected ? "" : "not connected"));
|
|
157
|
+
}
|
|
158
|
+
results.push(
|
|
159
|
+
line("project link", Boolean(status.workspace?.project_id), status.workspace?.project_id ? "" : "workspace is not linked to a project")
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
process.stdout.write(`PreMan doctor\n\n${results.join("\n")}\n\n`);
|
|
164
|
+
process.stdout.write(failures ? `${failures} problem${failures === 1 ? "" : "s"} found.\n` : "Everything looks healthy.\n");
|
|
165
|
+
if (failures) process.exitCode = 1;
|
|
166
|
+
return { failures };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "error", "cancelled"]);
|
|
170
|
+
|
|
171
|
+
export async function watchCommand(commandArgs = []) {
|
|
172
|
+
const args = makeArgs(commandArgs);
|
|
173
|
+
const token = resolveApiKey(args);
|
|
174
|
+
if (!token) throw new Error(`no API key. Run \`${cliInvocation()} login\` first.`);
|
|
175
|
+
|
|
176
|
+
const positional = commandArgs.filter((value) => !value.startsWith("-"));
|
|
177
|
+
const runId = args.value("--run", positional[0] || "");
|
|
178
|
+
const integrationId = args.value("--integration", positional[1] || "");
|
|
179
|
+
if (!runId || !integrationId) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"usage: watch <run-id> <integration-id> (both are shown by `preman status`)"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const deadline = Date.now() + 15 * 60 * 1000;
|
|
187
|
+
while (Date.now() < deadline) {
|
|
188
|
+
const result = await callBackendJson(
|
|
189
|
+
args,
|
|
190
|
+
"GET",
|
|
191
|
+
`/integrations/github/${integrationId}/simulations/${runId}`,
|
|
192
|
+
{ token }
|
|
193
|
+
);
|
|
194
|
+
if (!result.ok) throw new Error(`could not read run: ${result.status_code} ${result.detail || ""}`);
|
|
195
|
+
|
|
196
|
+
for (const step of result.steps || []) {
|
|
197
|
+
const key = `${step.key}:${step.status}`;
|
|
198
|
+
if (seen.has(key)) continue;
|
|
199
|
+
seen.add(key);
|
|
200
|
+
const mark = step.status === "succeeded" ? "✓" : step.status === "failed" ? "✗" : "·";
|
|
201
|
+
process.stdout.write(` ${mark} ${String(step.key || "").padEnd(14)} ${step.message || ""}\n`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (TERMINAL_STATUSES.has(String(result.status || "").toLowerCase())) {
|
|
205
|
+
const verdict = result.summary?.verdict || result.status;
|
|
206
|
+
process.stdout.write(`\nRun ${result.status}: ${verdict}\n`);
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
await sleep(POLL_INTERVAL_MS);
|
|
210
|
+
}
|
|
211
|
+
throw new Error("stopped watching after 15 minutes; the run is still in progress.");
|
|
212
|
+
}
|
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
|
@@ -17,7 +17,13 @@ import path from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
19
|
import { ENDPOINTS_HELP, TEST_HELP, endpointsCommand, testCommand } from "./api_tools.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
CONNECT_HELP,
|
|
22
|
+
DISPATCH_HELP,
|
|
23
|
+
connectCommand,
|
|
24
|
+
dispatchCommand,
|
|
25
|
+
writeCursorConfig,
|
|
26
|
+
} from "./connect.js";
|
|
21
27
|
import {
|
|
22
28
|
INTEGRATIONS_HELP,
|
|
23
29
|
awsCommand,
|
|
@@ -26,6 +32,11 @@ import {
|
|
|
26
32
|
slackCommand,
|
|
27
33
|
} from "./integrations.js";
|
|
28
34
|
import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
|
|
35
|
+
import { STATUS_HELP, statusCommand } from "./status.js";
|
|
36
|
+
import { HOOK_HELP, hookCommand } from "./hook.js";
|
|
37
|
+
import { VERIFY_HELP, verifyCommand } from "./verify.js";
|
|
38
|
+
import { DESKTOP_HELP, installDesktopCommand } from "./desktop.js";
|
|
39
|
+
import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
|
|
29
40
|
import {
|
|
30
41
|
CREDENTIALS_FILE,
|
|
31
42
|
cliInvocation,
|
|
@@ -66,10 +77,18 @@ function printHelp() {
|
|
|
66
77
|
// for a global install and 38 for npm exec, so a fixed layout is wrong for
|
|
67
78
|
// one of them.
|
|
68
79
|
const usage = [
|
|
80
|
+
["status", "Healthy / failing / recently fixed endpoints"],
|
|
81
|
+
["verify [options]", "Test endpoints against your local app"],
|
|
82
|
+
["hook install|uninstall|status", "Manage the git pre-push hook"],
|
|
83
|
+
["doctor", "Diagnose credentials, backend, target, integrations"],
|
|
84
|
+
["install-desktop", "Download and install the PreMan desktop app"],
|
|
69
85
|
["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
|
|
70
86
|
["connect [options]", "Pick a coding agent and connect it"],
|
|
87
|
+
["dispatch [options]", "Let PreMan start agent runs for you"],
|
|
71
88
|
["aws | github | slack", "Connect one integration on its own"],
|
|
72
|
-
["login", "Create/login to PreMan from the terminal"],
|
|
89
|
+
["login [--browser]", "Create/login to PreMan from the terminal"],
|
|
90
|
+
["logout", "Delete stored CLI credentials"],
|
|
91
|
+
["watch <run> <integration>", "Follow a push simulation live"],
|
|
73
92
|
["install [options]", "Install PreMan into Cursor MCP config"],
|
|
74
93
|
["", "Start the PreMan MCP server"],
|
|
75
94
|
["link|tools|run ...", "Drive a published hosted MCP"],
|
|
@@ -85,7 +104,7 @@ function printHelp() {
|
|
|
85
104
|
|
|
86
105
|
Usage:
|
|
87
106
|
${usageLines}
|
|
88
|
-
${INTEGRATIONS_HELP}${CONNECT_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
107
|
+
${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
89
108
|
Login options:
|
|
90
109
|
--email <email> Pre-fill the email prompt
|
|
91
110
|
--backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
|
|
@@ -109,6 +128,10 @@ ${HOSTED_HELP}`);
|
|
|
109
128
|
}
|
|
110
129
|
|
|
111
130
|
async function loginCommand() {
|
|
131
|
+
if (hasFlag("--browser")) {
|
|
132
|
+
await loginBrowser(cliArgs);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
112
135
|
const creds = await authenticateTerminal(cliArgs);
|
|
113
136
|
process.stdout.write(`PreMan account ready.
|
|
114
137
|
|
|
@@ -192,8 +215,24 @@ function startServer() {
|
|
|
192
215
|
async function main() {
|
|
193
216
|
if (command === "login") {
|
|
194
217
|
await loginCommand();
|
|
218
|
+
} else if (command === "status") {
|
|
219
|
+
await statusCommand(commandArgs);
|
|
220
|
+
} else if (command === "verify") {
|
|
221
|
+
await verifyCommand(commandArgs);
|
|
222
|
+
} else if (command === "hook") {
|
|
223
|
+
await hookCommand(commandArgs);
|
|
224
|
+
} else if (command === "logout") {
|
|
225
|
+
await logoutCommand();
|
|
226
|
+
} else if (command === "doctor") {
|
|
227
|
+
await doctorCommand(commandArgs);
|
|
228
|
+
} else if (command === "watch") {
|
|
229
|
+
await watchCommand(commandArgs);
|
|
230
|
+
} else if (command === "install-desktop") {
|
|
231
|
+
await installDesktopCommand(commandArgs);
|
|
195
232
|
} else if (command === "connect") {
|
|
196
233
|
await connectCommand(commandArgs);
|
|
234
|
+
} else if (command === "dispatch") {
|
|
235
|
+
await dispatchCommand(commandArgs);
|
|
197
236
|
} else if (command === "onboard" || command === "setup") {
|
|
198
237
|
// makeArgs/authenticateTerminal/connectCommand are injected rather than
|
|
199
238
|
// imported there, so integrations.js stays free of a cycle back into the CLI.
|