premanmcp 0.10.6 → 0.10.7
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 +11 -5
- package/bin/account.js +19 -4
- package/bin/cli.js +8 -2
- package/bin/connect/agents.js +198 -0
- package/bin/connect/checkin.js +503 -0
- package/bin/connect/configs.js +276 -0
- package/bin/connect/dispatch.js +131 -0
- package/bin/connect/errors.js +16 -0
- package/bin/connect/guide.js +523 -0
- package/bin/connect/pairing.js +94 -0
- package/bin/connect.js +101 -1554
- package/bin/desktop.js +1 -1
- package/bin/detect.js +42 -3
- package/bin/hook.js +10 -1
- package/bin/hosted.js +4 -4
- package/bin/progress.js +2 -5
- package/bin/runner.js +3 -30
- package/bin/shared.js +26 -1
- package/bin/status.js +1 -6
- package/bin/tests.js +341 -0
- package/bin/verify.js +82 -54
- package/dist/server.js +53 -0
- package/package.json +2 -2
package/bin/desktop.js
CHANGED
|
@@ -136,7 +136,7 @@ function mountedVolume(hdiutilOutput) {
|
|
|
136
136
|
export async function installDesktopCommand(commandArgs = []) {
|
|
137
137
|
const args = makeArgs(commandArgs);
|
|
138
138
|
|
|
139
|
-
if (process.platform !== "darwin") {
|
|
139
|
+
if (process.platform !== "darwin" && !args.has("--print-url")) {
|
|
140
140
|
process.stdout.write(
|
|
141
141
|
`The PreMan desktop app is macOS-only right now.\n` +
|
|
142
142
|
`Downloads for other platforms: ${RELEASES_BASE}/latest\n`
|
package/bin/detect.js
CHANGED
|
@@ -246,6 +246,36 @@ function normalisePathTemplate(value) {
|
|
|
246
246
|
.toLowerCase() || "/";
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
function normaliseBaseUrl(value) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = new URL(String(value || "").trim());
|
|
252
|
+
if (!isLoopback(parsed.href)) {
|
|
253
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/+$/, "");
|
|
254
|
+
}
|
|
255
|
+
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
256
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
257
|
+
return `${parsed.protocol}//loopback:${port}${pathname}`;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Prefer endpoints explicitly registered to this candidate. If none carry that
|
|
265
|
+
* base URL, preserve the existing repo-local workflow where a production
|
|
266
|
+
* inventory is retargeted to a local dev server and confirmed by route overlap.
|
|
267
|
+
*/
|
|
268
|
+
function inventoryForCandidate(candidate, inventory) {
|
|
269
|
+
const all = inventory || [];
|
|
270
|
+
const candidateBase = normaliseBaseUrl(candidate.url);
|
|
271
|
+
const matching = candidateBase
|
|
272
|
+
? all.filter((ep) => normaliseBaseUrl(ep?.base_url) === candidateBase)
|
|
273
|
+
: [];
|
|
274
|
+
const selected = matching.length ? matching : all;
|
|
275
|
+
const scope = matching.length ? "matching_base_url" : "all_routes";
|
|
276
|
+
return { inventory: selected, scope };
|
|
277
|
+
}
|
|
278
|
+
|
|
249
279
|
/** Route keys the live app advertises through its generated spec, if it has one. */
|
|
250
280
|
async function liveSpecRoutes(baseUrl, timeoutMs) {
|
|
251
281
|
for (const specPath of SPEC_PATHS) {
|
|
@@ -305,7 +335,8 @@ export async function confirmCandidate(candidate, inventory, { timeoutMs = 2000
|
|
|
305
335
|
let matched = 0;
|
|
306
336
|
for (const key of expected) if (live.keys.has(key)) matched += 1;
|
|
307
337
|
const overlap = matched / expected.size;
|
|
308
|
-
|
|
338
|
+
const requiredRoutes = Math.min(CONFIRM_MIN_ROUTES, expected.size);
|
|
339
|
+
if (matched >= requiredRoutes && overlap >= CONFIRM_MIN_OVERLAP) {
|
|
309
340
|
return {
|
|
310
341
|
...candidate,
|
|
311
342
|
confirmed: true,
|
|
@@ -366,9 +397,17 @@ export async function resolveLocalTarget(
|
|
|
366
397
|
}
|
|
367
398
|
const attempts = [];
|
|
368
399
|
for (const candidate of candidates) {
|
|
369
|
-
const
|
|
400
|
+
const scoped = inventoryForCandidate(candidate, inventory);
|
|
401
|
+
const result = await confirmCandidate(candidate, scoped.inventory, { timeoutMs });
|
|
370
402
|
attempts.push(result);
|
|
371
|
-
if (result.confirmed)
|
|
403
|
+
if (result.confirmed) {
|
|
404
|
+
return {
|
|
405
|
+
target: result,
|
|
406
|
+
candidates: attempts,
|
|
407
|
+
inventory: scoped.inventory,
|
|
408
|
+
inventoryScope: scoped.scope,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
372
411
|
}
|
|
373
412
|
return {
|
|
374
413
|
target: null,
|
package/bin/hook.js
CHANGED
|
@@ -135,6 +135,10 @@ function embeddedInvocation(text) {
|
|
|
135
135
|
return match ? match[1].trim() : "";
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function isGeneratedInvocation(invocation) {
|
|
139
|
+
return invocation === "preman" || /^npm exec -y premanmcp@\S+ --$/.test(invocation);
|
|
140
|
+
}
|
|
141
|
+
|
|
138
142
|
function hookBody(invocation) {
|
|
139
143
|
// `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
|
|
140
144
|
// non-zero and still exit 0 itself.
|
|
@@ -257,10 +261,15 @@ export function hookStatus({ probe = false } = {}) {
|
|
|
257
261
|
if (!isOurHook(existing)) return { ...missing, state: "foreign" };
|
|
258
262
|
|
|
259
263
|
const invocation = embeddedInvocation(existing);
|
|
264
|
+
const declared = declaredInvocation();
|
|
260
265
|
// Falling back to what the file already carries keeps a machine that can no
|
|
261
266
|
// longer prove any invocation -- offline, say -- from reporting a hook as out
|
|
262
267
|
// of date and sending its owner to a reinstall that would refuse to write.
|
|
263
|
-
|
|
268
|
+
// A custom wrapper or direct Node command is itself the user's pin. Ambient
|
|
269
|
+
// PATH changes must not make it stale after PREMAN_HOOK_INVOCATION disappears.
|
|
270
|
+
const wanted =
|
|
271
|
+
declared ||
|
|
272
|
+
(isGeneratedInvocation(invocation) ? provenInvocation() || invocation : invocation);
|
|
264
273
|
return {
|
|
265
274
|
path: target,
|
|
266
275
|
state: "installed",
|
package/bin/hosted.js
CHANGED
|
@@ -37,7 +37,7 @@ export class CliError extends Error {
|
|
|
37
37
|
|
|
38
38
|
// --- arg parsing (mirrors bin/cli.js; no --flag=value form) -----------------
|
|
39
39
|
|
|
40
|
-
function
|
|
40
|
+
function makeHostedArgs(commandArgs) {
|
|
41
41
|
return {
|
|
42
42
|
positional: commandArgs.filter((a, i) => {
|
|
43
43
|
if (a.startsWith("-")) return false;
|
|
@@ -297,7 +297,7 @@ async function promptToken() {
|
|
|
297
297
|
// --- commands --------------------------------------------------------------
|
|
298
298
|
|
|
299
299
|
export async function linkCommand(commandArgs) {
|
|
300
|
-
const args =
|
|
300
|
+
const args = makeHostedArgs(commandArgs);
|
|
301
301
|
const target = args.positional[0];
|
|
302
302
|
const envName = args.value("--env", "");
|
|
303
303
|
if (!target) throw new CliError("Usage: preman link <mcp-url-or-id> --env <name> [--token pm_hmcp_…]");
|
|
@@ -343,7 +343,7 @@ export async function linkCommand(commandArgs) {
|
|
|
343
343
|
}
|
|
344
344
|
|
|
345
345
|
export async function toolsCommand(commandArgs) {
|
|
346
|
-
const args =
|
|
346
|
+
const args = makeHostedArgs(commandArgs);
|
|
347
347
|
const config = readCliConfig();
|
|
348
348
|
const profile = resolveProfile(config, args.value("--env", ""));
|
|
349
349
|
const timeoutMs = Number(args.value("--timeout", "")) * 1000 || DEFAULT_TIMEOUT_MS;
|
|
@@ -368,7 +368,7 @@ export async function toolsCommand(commandArgs) {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
export async function runCommand(commandArgs) {
|
|
371
|
-
const args =
|
|
371
|
+
const args = makeHostedArgs(commandArgs);
|
|
372
372
|
const selection = args.positional[0];
|
|
373
373
|
if (!selection) {
|
|
374
374
|
throw new CliError(
|
package/bin/progress.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* CI, and cursor-movement escapes in a log file are worse than no animation.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { truncate } from "./shared.js";
|
|
14
|
+
|
|
13
15
|
const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
14
16
|
const FRAME_MS = 80;
|
|
15
17
|
|
|
@@ -23,11 +25,6 @@ const YELLOW = "\u001b[33m";
|
|
|
23
25
|
const DIM = "\u001b[2m";
|
|
24
26
|
const RESET = "\u001b[0m";
|
|
25
27
|
|
|
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
28
|
export function createReporter({ stream = process.stdout, live } = {}) {
|
|
32
29
|
const isLive = live ?? Boolean(stream.isTTY);
|
|
33
30
|
const slots = new Map();
|
package/bin/runner.js
CHANGED
|
@@ -38,14 +38,7 @@ import os from "node:os";
|
|
|
38
38
|
import path from "node:path";
|
|
39
39
|
import { fileURLToPath } from "node:url";
|
|
40
40
|
|
|
41
|
-
import {
|
|
42
|
-
CREDENTIALS_DIR,
|
|
43
|
-
backendUrl,
|
|
44
|
-
callBackendJson,
|
|
45
|
-
cliInvocation,
|
|
46
|
-
makeArgs,
|
|
47
|
-
resolveApiKey,
|
|
48
|
-
} from "./shared.js";
|
|
41
|
+
import { CREDENTIALS_DIR, backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, onPath, packageVersion, resolveApiKey } from "./shared.js";
|
|
49
42
|
|
|
50
43
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
51
44
|
|
|
@@ -137,15 +130,6 @@ export function deviceId(projectPath) {
|
|
|
137
130
|
return `cli-${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`;
|
|
138
131
|
}
|
|
139
132
|
|
|
140
|
-
export function packageVersion() {
|
|
141
|
-
try {
|
|
142
|
-
const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
143
|
-
return String(pkg.version || "0.0.0");
|
|
144
|
-
} catch {
|
|
145
|
-
return "0.0.0";
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
133
|
function readPid() {
|
|
150
134
|
try {
|
|
151
135
|
const pid = Number(readFileSync(RUNNER_PID_FILE, "utf8").trim());
|
|
@@ -208,7 +192,7 @@ export async function registerRunner(args, { agent, projectPath, label = "", api
|
|
|
208
192
|
project_path: project,
|
|
209
193
|
project_name: path.basename(project),
|
|
210
194
|
platform: `${process.platform}-${process.arch}`,
|
|
211
|
-
app_version: packageVersion(),
|
|
195
|
+
app_version: packageVersion() || undefined,
|
|
212
196
|
capabilities: RUNNER_CAPABILITIES,
|
|
213
197
|
},
|
|
214
198
|
}
|
|
@@ -250,7 +234,7 @@ export function agentCommand(agent, prompt, { fullAccess = false } = {}) {
|
|
|
250
234
|
return { bin: "claude", args, stream: "claude-json" };
|
|
251
235
|
}
|
|
252
236
|
if (agent === "cursor") {
|
|
253
|
-
return { bin: "cursor-agent", args: ["-p", prompt], stream: "text" };
|
|
237
|
+
return { bin: "cursor-agent", args: ["--approve-mcps", "-p", prompt], stream: "text" };
|
|
254
238
|
}
|
|
255
239
|
if (agent === "codex") {
|
|
256
240
|
const args = ["exec"];
|
|
@@ -261,13 +245,6 @@ export function agentCommand(agent, prompt, { fullAccess = false } = {}) {
|
|
|
261
245
|
return null;
|
|
262
246
|
}
|
|
263
247
|
|
|
264
|
-
export function onPath(binary) {
|
|
265
|
-
const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
|
|
266
|
-
stdio: "ignore",
|
|
267
|
-
});
|
|
268
|
-
return probe.status === 0;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
248
|
function sanitizeId(value, cap) {
|
|
272
249
|
return String(value || "")
|
|
273
250
|
.replace(/[^A-Za-z0-9_.:-]/g, "_")
|
|
@@ -406,10 +383,6 @@ function childEnv() {
|
|
|
406
383
|
|
|
407
384
|
// ── Job execution ───────────────────────────────────────────────────────
|
|
408
385
|
|
|
409
|
-
function nowMs() {
|
|
410
|
-
return Number(process.hrtime.bigint() / 1000000n);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
386
|
/** POST a runner callback. Never throws — a lost callback is retried or fenced. */
|
|
414
387
|
async function callback(args, state, route, body, log) {
|
|
415
388
|
try {
|
package/bin/shared.js
CHANGED
|
@@ -119,6 +119,30 @@ export function cliInvocation() {
|
|
|
119
119
|
pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Is this binary on PATH?
|
|
124
|
+
*
|
|
125
|
+
* Lived in three files at once, which is two more places for "installed" to mean
|
|
126
|
+
* something slightly different than it does everywhere else.
|
|
127
|
+
*/
|
|
128
|
+
export function onPath(binary) {
|
|
129
|
+
const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
|
|
130
|
+
stdio: "ignore",
|
|
131
|
+
});
|
|
132
|
+
return probe.status === 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Monotonic milliseconds, for durations that must not follow the wall clock. */
|
|
136
|
+
export function nowMs() {
|
|
137
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Clip to `width`, ellipsis included in the count. */
|
|
141
|
+
export function truncate(value, width) {
|
|
142
|
+
const text = String(value ?? "");
|
|
143
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
144
|
+
}
|
|
145
|
+
|
|
122
146
|
/** The version of the package this process is running from, or "". */
|
|
123
147
|
export function packageVersion() {
|
|
124
148
|
try {
|
|
@@ -560,7 +584,7 @@ export function resolveApiKey(args) {
|
|
|
560
584
|
export const LAUNCHER_COMMAND = "npm";
|
|
561
585
|
export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
|
|
562
586
|
|
|
563
|
-
export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
587
|
+
export function buildServerConfig(args, { pairCode = "", codingAgent = "" } = {}) {
|
|
564
588
|
const env = {
|
|
565
589
|
PREMAN_BACKEND: backendUrl(args),
|
|
566
590
|
PREMAN_FRONTEND: frontendUrl(args),
|
|
@@ -568,6 +592,7 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
|
|
|
568
592
|
const apiKey = resolveApiKey(args);
|
|
569
593
|
if (apiKey) env.PREMAN_API_KEY = apiKey;
|
|
570
594
|
if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
|
|
595
|
+
if (codingAgent) env.PREMAN_CODING_AGENT = codingAgent;
|
|
571
596
|
|
|
572
597
|
return {
|
|
573
598
|
command: LAUNCHER_COMMAND,
|
package/bin/status.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* section as possibly empty.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
|
|
9
|
+
import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey, truncate } from "./shared.js";
|
|
10
10
|
|
|
11
11
|
export const STATUS_HELP = `
|
|
12
12
|
Status options:
|
|
@@ -47,11 +47,6 @@ function relativeTime(iso) {
|
|
|
47
47
|
return `${Math.round(seconds / 86400)}d ago`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function truncate(value, width) {
|
|
51
|
-
const text = String(value ?? "");
|
|
52
|
-
return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
50
|
const MAX_TARGET_WIDTH = 46;
|
|
56
51
|
|
|
57
52
|
/** Column widths are measured across every row we will print, so the failing and
|
package/bin/tests.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `preman tests generate|review|setup|enrich` — Collections workbench test generation.
|
|
3
|
+
*
|
|
4
|
+
* Thin HTTP wrappers over the same `/workbench/generate-tests` routes the
|
|
5
|
+
* dashboard uses. Distinct from `preman test <id>`, which generates + runs
|
|
6
|
+
* scenarios for one endpoint via `POST /mcp/call-tool`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
|
|
10
|
+
|
|
11
|
+
export const TESTS_HELP = `
|
|
12
|
+
Collections tests:
|
|
13
|
+
preman tests generate [--workspace id] [--integration-id id] [--json-out]
|
|
14
|
+
Scan the connected GitHub repo and write heuristic suites on saved requests.
|
|
15
|
+
No LLM. Writes and ambiguous routes are flagged for review.
|
|
16
|
+
preman tests review [--workspace id] [--json-out]
|
|
17
|
+
List suites still flagged (review_status=required) with the reason.
|
|
18
|
+
preman tests review --approve <request_id> [--workspace id] [--json-out]
|
|
19
|
+
Approve one flagged suite so it can be scheduled. Does not raise
|
|
20
|
+
unattended_policy. DELETE stays skipped unless allow_destructive.
|
|
21
|
+
preman tests review --all-required [--workspace id] [--json-out]
|
|
22
|
+
Approve every currently flagged suite. Same gates as --approve.
|
|
23
|
+
preman tests setup [--list] [--workspace id] [--json-out]
|
|
24
|
+
Harvest path-param fixture IDs from list GETs / schema examples.
|
|
25
|
+
Stores them on the workspace and prints a .env snippet.
|
|
26
|
+
Does not invent UUIDs, create secrets, or approve suites.
|
|
27
|
+
--list shows missing fixtures without calling the API.
|
|
28
|
+
preman tests enrich [--list] [--workspace id] [--json-out]
|
|
29
|
+
LLM-enrich remaining review-cleared heuristic suites (cap 40).
|
|
30
|
+
--list shows the remaining queue without calling the model.
|
|
31
|
+
Skips destructive/billing unless standing policy already allows that risk.
|
|
32
|
+
Does not enable writes and does not publish a Guard baseline.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
class CliError extends Error {
|
|
36
|
+
constructor(message, exitCode = 1) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.exitCode = exitCode;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireKey(args) {
|
|
43
|
+
const token = resolveApiKey(args);
|
|
44
|
+
if (!token) {
|
|
45
|
+
throw new CliError(
|
|
46
|
+
`No PreMan API key. Run \`${cliInvocation()} login\` or pass --api-key pm_live_...`,
|
|
47
|
+
2,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function workspaceHeaders(args) {
|
|
54
|
+
return { "x-workspace-id": args.value("--workspace", "") };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function wantsJson(args) {
|
|
58
|
+
return args.has("--json-out") || args.has("--json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printJson(value) {
|
|
62
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function errorDetail(result) {
|
|
66
|
+
const detail = result.detail;
|
|
67
|
+
if (typeof detail === "string") return detail;
|
|
68
|
+
if (Array.isArray(detail)) {
|
|
69
|
+
return detail.map((item) => item.msg || JSON.stringify(item)).join("; ");
|
|
70
|
+
}
|
|
71
|
+
if (detail && typeof detail === "object") {
|
|
72
|
+
return detail.message || detail.error || JSON.stringify(detail);
|
|
73
|
+
}
|
|
74
|
+
return result.message || result.raw || "backend error";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function workbench(args, method, routePath, { json } = {}) {
|
|
78
|
+
const token = requireKey(args);
|
|
79
|
+
const result = await callBackendJson(args, method, routePath, {
|
|
80
|
+
token,
|
|
81
|
+
json,
|
|
82
|
+
headers: workspaceHeaders(args),
|
|
83
|
+
});
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
throw new CliError(
|
|
86
|
+
`${method} ${routePath} failed: ${result.status_code} ${errorDetail(result)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const { status_code, ...payload } = result;
|
|
90
|
+
return payload;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function flagLine(item) {
|
|
94
|
+
const method = item.method || "";
|
|
95
|
+
const url = item.url || item.name || "";
|
|
96
|
+
const reason = item.reason_label || item.reason || "Needs review";
|
|
97
|
+
const id = item.request_id || "";
|
|
98
|
+
return ` ${id} ${method} ${url} ${reason}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function printGenerateSummary(payload) {
|
|
102
|
+
const generated = payload.suites_generated || 0;
|
|
103
|
+
const repo = payload.repo_url || "the connected repository";
|
|
104
|
+
process.stdout.write(`Generated ${generated} suite(s) from ${repo}\n`);
|
|
105
|
+
process.stdout.write(
|
|
106
|
+
` scanned ${payload.scanned || 0} matched ${payload.matched || 0} unmatched ${payload.unmatched || 0}\n`,
|
|
107
|
+
);
|
|
108
|
+
process.stdout.write(
|
|
109
|
+
` auto-approved ${payload.auto_approved || 0} flagged ${payload.flagged_for_review || 0}` +
|
|
110
|
+
` waiting for base URL ${payload.waiting_for_base_url || 0}\n`,
|
|
111
|
+
);
|
|
112
|
+
if (payload.bodies_filled_from_schema) {
|
|
113
|
+
process.stdout.write(` bodies filled from schema ${payload.bodies_filled_from_schema}\n`);
|
|
114
|
+
}
|
|
115
|
+
const flagged = payload.flagged || [];
|
|
116
|
+
if (flagged.length) {
|
|
117
|
+
process.stdout.write(`\n${flagged.length} flagged for review:\n`);
|
|
118
|
+
for (const item of flagged) process.stdout.write(`${flagLine(item)}\n`);
|
|
119
|
+
process.stdout.write(
|
|
120
|
+
`\nNext: \`${cliInvocation()} tests review --approve <request_id>\` enables a read-only schedule only.\n`,
|
|
121
|
+
);
|
|
122
|
+
} else if ((payload.flagged_for_review || 0) === 0 && generated) {
|
|
123
|
+
process.stdout.write(
|
|
124
|
+
`\nNo suites flagged. Optional: \`${cliInvocation()} tests enrich\` to add LLM cases to remaining heuristic suites.\n`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const samples = payload.unmatched_samples || [];
|
|
128
|
+
if (samples.length) {
|
|
129
|
+
process.stdout.write(`\nUnmatched repo routes (not imported):\n`);
|
|
130
|
+
for (const sample of samples.slice(0, 10)) process.stdout.write(` ${sample}\n`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function printReviewList(payload) {
|
|
135
|
+
const items = payload.items || [];
|
|
136
|
+
const total = payload.total ?? items.length;
|
|
137
|
+
process.stdout.write(`${total} suite(s) flagged for review\n`);
|
|
138
|
+
for (const item of items) process.stdout.write(`${flagLine(item)}\n`);
|
|
139
|
+
if (total) {
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`\nApprove one: \`${cliInvocation()} tests review --approve <request_id>\`\n` +
|
|
142
|
+
`Approve enables a read-only schedule only. DELETE stays skipped unless allow_destructive.\n`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function printApproveResult(payload) {
|
|
148
|
+
const id = payload.request_id || "";
|
|
149
|
+
const status = payload.review_status || "approved";
|
|
150
|
+
const enabled = payload.enabled ? "enabled" : "disabled";
|
|
151
|
+
process.stdout.write(`Approved ${id} ${status} ${enabled}\n`);
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
"Schedule is enabled. unattended_policy is unchanged; DELETE still skips unless allow_destructive.\n",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function printApproveAll(results) {
|
|
158
|
+
process.stdout.write(`Approved ${results.length} flagged suite(s)\n`);
|
|
159
|
+
for (const payload of results) {
|
|
160
|
+
const enabled = payload.enabled ? "enabled" : "disabled";
|
|
161
|
+
process.stdout.write(` ${payload.request_id || ""} ${payload.review_status || "approved"} ${enabled}\n`);
|
|
162
|
+
}
|
|
163
|
+
if (results.length) {
|
|
164
|
+
process.stdout.write(
|
|
165
|
+
"Schedule is enabled. unattended_policy is unchanged; DELETE still skips unless allow_destructive.\n",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function printEnrichList(payload) {
|
|
171
|
+
const items = payload.items || [];
|
|
172
|
+
const total = payload.total ?? items.length;
|
|
173
|
+
process.stdout.write(`${total} suite(s) remaining for agent enrichment\n`);
|
|
174
|
+
for (const item of items) process.stdout.write(`${flagLine(item)}\n`);
|
|
175
|
+
if (total) {
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
`\nRun \`${cliInvocation()} tests enrich\` to append LLM cases (cap 40). Does not enable writes.\n`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function printSetupList(payload) {
|
|
183
|
+
const items = (payload.items || []).filter((item) => !item.value_set);
|
|
184
|
+
const total = payload.total ?? items.length;
|
|
185
|
+
process.stdout.write(`${total} path-param fixture(s) still missing\n`);
|
|
186
|
+
for (const item of payload.items || []) {
|
|
187
|
+
const state = item.value_set ? "set" : "missing";
|
|
188
|
+
process.stdout.write(
|
|
189
|
+
` ${item.name || item.param || ""} ${item.method || ""} ${item.url || ""} ${state}\n`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (payload.dotenv) {
|
|
193
|
+
process.stdout.write(`\nCurrent .env:\n${payload.dotenv}`);
|
|
194
|
+
}
|
|
195
|
+
if (total) {
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
`\nRun \`${cliInvocation()} tests setup\` to harvest ids from list GETs.\n`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function printSetupSummary(payload) {
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
`Stored ${payload.stored || 0} fixture(s). Missing ${payload.missing || 0}.\n`,
|
|
205
|
+
);
|
|
206
|
+
process.stdout.write(
|
|
207
|
+
` harvested ${payload.harvested || 0}` +
|
|
208
|
+
` list GETs ${payload.list_gets_called || 0}` +
|
|
209
|
+
` from lists ${payload.from_list_get || 0}` +
|
|
210
|
+
` from schema ${payload.from_schema || 0}\n`,
|
|
211
|
+
);
|
|
212
|
+
if (payload.dotenv) {
|
|
213
|
+
process.stdout.write(`\n.env\n${payload.dotenv}`);
|
|
214
|
+
}
|
|
215
|
+
const missing = payload.missing_items || [];
|
|
216
|
+
if (missing.length) {
|
|
217
|
+
process.stdout.write(`\nStill missing:\n`);
|
|
218
|
+
for (const item of missing) {
|
|
219
|
+
process.stdout.write(` ${item.name || item.param || ""} ${item.method || ""} ${item.url || ""}\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function printEnrichSummary(payload) {
|
|
225
|
+
process.stdout.write(
|
|
226
|
+
`Enriched ${payload.enriched || 0} suite(s). Remaining ${payload.remaining || 0}.\n`,
|
|
227
|
+
);
|
|
228
|
+
process.stdout.write(
|
|
229
|
+
` skipped sensitive ${payload.skipped_sensitive || 0}` +
|
|
230
|
+
` pending review ${payload.skipped_pending_review || 0}` +
|
|
231
|
+
` already enriched ${payload.skipped_already_enriched || 0}` +
|
|
232
|
+
` no suite ${payload.skipped_no_suite || 0}` +
|
|
233
|
+
` failed ${payload.failed || 0}\n`,
|
|
234
|
+
);
|
|
235
|
+
const remaining = payload.remaining_items || [];
|
|
236
|
+
if (remaining.length) {
|
|
237
|
+
process.stdout.write(`\nStill remaining:\n`);
|
|
238
|
+
for (const item of remaining) process.stdout.write(`${flagLine(item)}\n`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function requestIdFlag(args, flag) {
|
|
243
|
+
const value = args.value(flag, "");
|
|
244
|
+
if (!value || value.startsWith("-")) {
|
|
245
|
+
throw new CliError(`pass a request id after ${flag}${TESTS_HELP}`, 2);
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function generateCommand(args) {
|
|
251
|
+
const integrationId = args.value("--integration-id", "");
|
|
252
|
+
const json = {};
|
|
253
|
+
if (integrationId && !integrationId.startsWith("-")) json.integration_id = integrationId;
|
|
254
|
+
const payload = await workbench(args, "POST", "/workbench/generate-tests", { json });
|
|
255
|
+
if (wantsJson(args)) return printJson(payload);
|
|
256
|
+
printGenerateSummary(payload);
|
|
257
|
+
return payload;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function reviewCommand(args) {
|
|
261
|
+
if (args.has("--approve") && args.has("--all-required")) {
|
|
262
|
+
throw new CliError("use either --approve <request_id> or --all-required, not both", 2);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (args.has("--approve")) {
|
|
266
|
+
const requestId = encodeURIComponent(requestIdFlag(args, "--approve"));
|
|
267
|
+
const payload = await workbench(
|
|
268
|
+
args,
|
|
269
|
+
"POST",
|
|
270
|
+
`/workbench/generate-tests/review/${requestId}/approve`,
|
|
271
|
+
);
|
|
272
|
+
if (wantsJson(args)) return printJson(payload);
|
|
273
|
+
printApproveResult(payload);
|
|
274
|
+
return payload;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const listed = await workbench(args, "GET", "/workbench/generate-tests/review");
|
|
278
|
+
if (args.has("--all-required")) {
|
|
279
|
+
const items = listed.items || [];
|
|
280
|
+
const results = [];
|
|
281
|
+
for (const item of items) {
|
|
282
|
+
const requestId = encodeURIComponent(item.request_id || "");
|
|
283
|
+
if (!requestId) continue;
|
|
284
|
+
results.push(
|
|
285
|
+
await workbench(args, "POST", `/workbench/generate-tests/review/${requestId}/approve`),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (wantsJson(args)) return printJson({ approved: results, listed });
|
|
289
|
+
if (!results.length) {
|
|
290
|
+
process.stdout.write("0 suite(s) flagged for review\n");
|
|
291
|
+
return { approved: results, listed };
|
|
292
|
+
}
|
|
293
|
+
printApproveAll(results);
|
|
294
|
+
return { approved: results, listed };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (wantsJson(args)) return printJson(listed);
|
|
298
|
+
printReviewList(listed);
|
|
299
|
+
return listed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function setupCommand(args) {
|
|
303
|
+
if (args.has("--list")) {
|
|
304
|
+
const listed = await workbench(args, "GET", "/workbench/generate-tests/fixtures");
|
|
305
|
+
if (wantsJson(args)) return printJson(listed);
|
|
306
|
+
printSetupList(listed);
|
|
307
|
+
return listed;
|
|
308
|
+
}
|
|
309
|
+
const payload = await workbench(args, "POST", "/workbench/generate-tests/fixtures");
|
|
310
|
+
if (wantsJson(args)) return printJson(payload);
|
|
311
|
+
printSetupSummary(payload);
|
|
312
|
+
return payload;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function enrichCommand(args) {
|
|
316
|
+
if (args.has("--list")) {
|
|
317
|
+
const listed = await workbench(args, "GET", "/workbench/generate-tests/enrich");
|
|
318
|
+
if (wantsJson(args)) return printJson(listed);
|
|
319
|
+
printEnrichList(listed);
|
|
320
|
+
return listed;
|
|
321
|
+
}
|
|
322
|
+
const payload = await workbench(args, "POST", "/workbench/generate-tests/enrich");
|
|
323
|
+
if (wantsJson(args)) return printJson(payload);
|
|
324
|
+
printEnrichSummary(payload);
|
|
325
|
+
return payload;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function testsCommand(commandArgs = []) {
|
|
329
|
+
const sub = commandArgs[0] && !commandArgs[0].startsWith("-") ? commandArgs[0] : "";
|
|
330
|
+
const rest = sub ? commandArgs.slice(1) : commandArgs;
|
|
331
|
+
const args = makeArgs(rest);
|
|
332
|
+
|
|
333
|
+
if (!sub || sub === "help") {
|
|
334
|
+
throw new CliError(`pass generate, review, setup, or enrich${TESTS_HELP}`, 2);
|
|
335
|
+
}
|
|
336
|
+
if (sub === "generate") return generateCommand(args);
|
|
337
|
+
if (sub === "review") return reviewCommand(args);
|
|
338
|
+
if (sub === "setup") return setupCommand(args);
|
|
339
|
+
if (sub === "enrich") return enrichCommand(args);
|
|
340
|
+
throw new CliError(`unknown tests subcommand: ${sub}${TESTS_HELP}`, 2);
|
|
341
|
+
}
|