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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * The steps after the link: endpoints, a first test, runner, desktop,
3
+ * integrations, push testing.
4
+ *
5
+ * Each one is optional, each one is survivable, and none of them may fail the
6
+ * connect that already worked -- a customer who cannot finish Slack today should
7
+ * still leave with a runner and a hook. That rule is why every step here catches
8
+ * its own errors and reports them as a line rather than raising.
9
+ */
10
+
11
+ import path from "node:path";
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import { callTool as callPremanTool, printTestSummary } from "../api_tools.js";
15
+ import { installDesktopCommand } from "../desktop.js";
16
+ import { hookStatus, installHook } from "../hook.js";
17
+ import { MARK, awsCommand, githubCommand, slackCommand } from "../integrations.js";
18
+ import { confirmRunnerOnline, pairingIsLive, readRunnerState, registerRunner, runnerIsAlive, startBackground } from "../runner.js";
19
+ import { callBackendJson, cliInvocation, frontendUrl, onPath, promptText } from "../shared.js";
20
+ import { agentBlocker } from "./agents.js";
21
+ import { lastLine } from "./checkin.js";
22
+
23
+ // ── Guided first run ────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Printed directly beneath the brief, for the case where no agent could be run.
27
+ *
28
+ * Deliberately does not start with `endpoints discover`: that command's job is
29
+ * to drive the agent, and pointing back at it from under its own fallback
30
+ * output sent people in a circle. Built per call rather than as a constant
31
+ * because `cliInvocation()` is not `preman` for the npm-exec users who are the
32
+ * majority — hardcoding it told them to run a command they do not have.
33
+ */
34
+ function manualSteps() {
35
+ const cli = cliInvocation();
36
+ return (
37
+ ` ${cli} endpoints setup --file endpoints.json # register runnable requests\n` +
38
+ ` ${cli} test <request-id> # generate + run your first scenarios\n`
39
+ );
40
+ }
41
+
42
+ export function nextStepsBlock(agent) {
43
+ return (
44
+ "\nNext steps:\n" +
45
+ ` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
46
+ ` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
47
+ ` 3. ${cliInvocation()} endpoints discover # map this repo's endpoints\n` +
48
+ ` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
49
+ );
50
+ }
51
+
52
+ /**
53
+ * What is left once the link is done, as commands rather than as questions.
54
+ *
55
+ * Connect used to run all of these itself, which bought a prompt per step and a
56
+ * wait as long as the slowest broken one — and a link that had already succeeded
57
+ * ended on a failed runner and a five-minute GitHub poll. Each is one command, so
58
+ * this lists them and gets out of the way.
59
+ *
60
+ * Endpoint discovery is the exception and runs inline just above this; see the
61
+ * call site. It is not listed here because by now it has already happened.
62
+ */
63
+ export function whatIsLeftBlock(args) {
64
+ const cli = cliInvocation();
65
+ return (
66
+ "\nWhat is left:\n" +
67
+ ` ${cli} runner start --background # let PreMan apply fixes on this machine\n` +
68
+ ` ${cli} github # connect GitHub, or do it in the dashboard\n` +
69
+ `\n${cli} status reports your endpoints, last push and integrations.\n` +
70
+ `Watch runs at ${frontendUrl(args)}\n`
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Ask, or take the step's own default when nobody can answer.
76
+ *
77
+ * `--yes` returns the default rather than a blanket yes, which matters for
78
+ * exactly one step: the desktop app defaults to no because it downloads a
79
+ * hundred-odd megabytes and writes to /Applications. A blanket yes made
80
+ * `preman connect --yes` do that unattended, which is not what anyone means by
81
+ * "do not ask me questions" -- they get `preman install-desktop` for that.
82
+ */
83
+ export async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
84
+ if (assumeYes) return defaultYes;
85
+ if (!process.stdin.isTTY) return false;
86
+ const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
87
+ if (answer === "") return defaultYes;
88
+ return answer.startsWith("y");
89
+ }
90
+
91
+ export function step(title) {
92
+ process.stdout.write(`\n${title}\n`);
93
+ }
94
+
95
+ /** What the account can see right now: registry rows, and the runnable subset. */
96
+ async function endpointCounts(args) {
97
+ const inventory = await callPremanTool(args, "get_endpoints", {
98
+ include_workbench: true,
99
+ limit: 200,
100
+ });
101
+ const runnable = inventory.workbench_requests || [];
102
+ return {
103
+ registered: (inventory.endpoints || []).length,
104
+ runnable: runnable.length,
105
+ first: runnable[0] || null,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The one-shot command that has an agent execute the discovery brief.
111
+ *
112
+ * Same shape as `headlessCheckIn`, with the read tools the walk needs added to
113
+ * Claude Code's allow-list: print mode refuses anything un-allowed instead of
114
+ * asking, so an agent given only the MCP server can call PreMan and read nothing.
115
+ */
116
+ export function headlessDiscovery(agent, serverName, instructions = []) {
117
+ const prompt = [
118
+ `Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
119
+ "Work autonomously and do not ask for confirmation.",
120
+ "",
121
+ ...instructions,
122
+ ].join("\n");
123
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: ["--approve-mcps", "-p", prompt] };
124
+ if (agent.id === "claude_code") {
125
+ return {
126
+ bin: "claude",
127
+ args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
128
+ };
129
+ }
130
+ if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Get this project's endpoints into PreMan without handing anyone homework.
136
+ *
137
+ * Discovery is the agent's job — only it can read the repo — but "here is a brief,
138
+ * go paste it somewhere" is the step everybody dropped out on, and the commands
139
+ * printed after it referenced a file the user never had. So run the agent on the
140
+ * brief and report the two numbers that matter.
141
+ *
142
+ * Never throws: a failed discovery must not fail the connect.
143
+ */
144
+ export async function discoverEndpoints(
145
+ args,
146
+ agent,
147
+ serverName,
148
+ { blockedHere = "", force = false, basePath = ".", frameworkHint = undefined } = {}
149
+ ) {
150
+ try {
151
+ const before = await endpointCounts(args);
152
+ // Onboarding skips a repo that is already mapped, because re-walking it buys
153
+ // nothing and costs the slowest step in the run. An explicit
154
+ // `endpoints discover` means the opposite: the usual reason to type it is
155
+ // that routes changed since the last walk.
156
+ if (before.registered && !force) {
157
+ process.stdout.write(
158
+ `${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
159
+ );
160
+ return before;
161
+ }
162
+
163
+ const brief = await callPremanTool(args, "discover_endpoints_from_codebase", {
164
+ base_path: basePath,
165
+ framework_hint: frameworkHint,
166
+ });
167
+ const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
168
+ // Discovery has to read *this* repo, so it cannot be moved out of a directory
169
+ // that redirects it the way the check-in was — it can only be skipped, before
170
+ // it spends ten minutes proving what the self-test already found out.
171
+ const blocker = blockedHere || (spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
172
+ if (!spec || !onPath(spec.bin) || blocker) {
173
+ // Say why before printing homework: "here is a brief" reads as PreMan not
174
+ // working, when the actual answer is one login away.
175
+ if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
176
+ for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
177
+ process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${manualSteps()}`);
178
+ return before;
179
+ }
180
+
181
+ process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
182
+ const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
183
+ const outcome = await runAgentOnce(spec, timeoutMs);
184
+ const after = await endpointCounts(args);
185
+
186
+ if (!after.registered) {
187
+ // Both halves: the exit code says the run failed, the agent's own last line
188
+ // says why, and either one alone has sent someone down the wrong path.
189
+ const why =
190
+ [outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
191
+ "it registered nothing";
192
+ process.stdout.write(
193
+ `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
194
+ ` Run it yourself: ${cliInvocation()} endpoints discover\n`
195
+ );
196
+ return after;
197
+ }
198
+ process.stdout.write(
199
+ `${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
200
+ );
201
+ return after;
202
+ } catch (error) {
203
+ process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
204
+ return { registered: 0, runnable: 0, first: null };
205
+ }
206
+ }
207
+
208
+ /** Run one bounded, non-interactive agent invocation and keep its tail. */
209
+ async function runAgentOnce(spec, timeoutMs) {
210
+ return new Promise((resolve) => {
211
+ let child;
212
+ try {
213
+ child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
214
+ } catch (error) {
215
+ resolve({ ok: false, reason: error.message, output: "" });
216
+ return;
217
+ }
218
+ let output = "";
219
+ const absorb = (chunk) => {
220
+ output = `${output}${chunk}`.slice(-8000);
221
+ };
222
+ child.stdout?.setEncoding("utf8");
223
+ child.stderr?.setEncoding("utf8");
224
+ child.stdout?.on("data", absorb);
225
+ child.stderr?.on("data", absorb);
226
+
227
+ const timer = setTimeout(() => {
228
+ child.kill("SIGTERM");
229
+ resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
230
+ }, timeoutMs);
231
+ child.on("error", (error) => {
232
+ clearTimeout(timer);
233
+ resolve({ ok: false, reason: error.message, output });
234
+ });
235
+ child.on("close", (code) => {
236
+ clearTimeout(timer);
237
+ resolve({
238
+ ok: code === 0,
239
+ reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
240
+ output,
241
+ });
242
+ });
243
+ });
244
+ }
245
+
246
+ /** Generate and run scenarios against the first runnable request, if they want it. */
247
+ async function runFirstTest(args, runnable, { assumeYes }) {
248
+ const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
249
+ if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
250
+ process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
251
+ return;
252
+ }
253
+ try {
254
+ process.stdout.write("Generating scenarios…\n");
255
+ const result = await callPremanTool(args, "generate_endpoint_tests", {
256
+ target: runnable.id,
257
+ run: true,
258
+ allow_writes: false,
259
+ max_cases: 10,
260
+ });
261
+ printTestSummary(result);
262
+ } catch (error) {
263
+ process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Whether a stored pairing can still be used, rather than merely matching.
269
+ *
270
+ * Matching agent and path only prove the state was written for this project. The
271
+ * token behind it can be revoked, replaced by another device, or left over from an
272
+ * API key that no longer exists — and reusing one of those starts a daemon that
273
+ * 401s on its first stream and exits, which reads as "the runner cannot start
274
+ * here" when the pairing is the only thing that needed replacing.
275
+ *
276
+ * A backend we cannot reach answers "usable": re-pairing is for a token the
277
+ * backend rejected, not for a network that was briefly down.
278
+ */
279
+ export async function runnerPairingIsUsable(args, existing, agentId, { cwd = process.cwd() } = {}) {
280
+ if (!existing || existing.agent !== agentId) return false;
281
+ if (path.resolve(String(existing.project_path || "")) !== path.resolve(cwd)) return false;
282
+ return pairingIsLive(args, existing);
283
+ }
284
+
285
+ /**
286
+ * Pair this machine as a runner and leave it running.
287
+ *
288
+ * Without this, everything PreMan finds later is advice: it can describe a fix
289
+ * and cannot apply one. With it, the queue has somewhere to land.
290
+ */
291
+ async function setUpRunner(args, agent, { assumeYes }) {
292
+ if (args.has("--no-runner")) return { state: "skipped" };
293
+ const existing = readRunnerState();
294
+ if (existing && runnerIsAlive()) {
295
+ process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
296
+ return { state: "running" };
297
+ }
298
+ if (
299
+ !(await confirm(
300
+ `Let PreMan run ${agent.label} here when it finds something to fix?`,
301
+ { assumeYes }
302
+ ))
303
+ ) {
304
+ process.stdout.write(
305
+ `${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
306
+ );
307
+ return { state: "skipped" };
308
+ }
309
+
310
+ try {
311
+ if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
312
+ await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
313
+ }
314
+ const started = startBackground([]);
315
+ // Claiming a runner that died two seconds later is worse than saying nothing:
316
+ // the whole point of this step is that PreMan can act, and someone told it
317
+ // can will wait for fixes that no device is listening for.
318
+ const up = await confirmRunnerOnline(started);
319
+ if (up.state === "exited") {
320
+ process.stdout.write(
321
+ `${MARK.fail()} The runner exited right after starting.\n` +
322
+ (up.detail ? ` ${up.detail}\n` : "") +
323
+ ` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
324
+ );
325
+ return { state: "failed", detail: up.detail };
326
+ }
327
+ process.stdout.write(
328
+ `${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
329
+ ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
330
+ );
331
+ // Paired and online still cannot run anything if the agent it dispatches to
332
+ // will not start, and every job would fail with the same opaque exit code.
333
+ const blocker = agentBlocker(agent.id);
334
+ if (blocker) {
335
+ process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
336
+ }
337
+ return { state: "running", pid: started.pid };
338
+ } catch (error) {
339
+ process.stdout.write(
340
+ `${MARK.fail()} Could not start the runner: ${error.message}\n` +
341
+ ` Retry later: ${cliInvocation()} runner start --background\n`
342
+ );
343
+ return { state: "failed", detail: error.message };
344
+ }
345
+ }
346
+
347
+ /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
348
+ async function offerDesktop(args, { assumeYes }) {
349
+ if (args.has("--no-desktop")) return { state: "skipped" };
350
+ if (process.platform !== "darwin") return { state: "unsupported" };
351
+ step("Desktop app");
352
+ if (existsSync("/Applications/PreMan.app")) {
353
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
354
+ return { state: "installed" };
355
+ }
356
+ // Default no, unlike every other step here: this one downloads a hundred-odd
357
+ // megabytes and writes to /Applications, which nobody should get by pressing
358
+ // Enter to move past a prompt.
359
+ if (
360
+ !(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
361
+ assumeYes,
362
+ defaultYes: false,
363
+ }))
364
+ ) {
365
+ process.stdout.write(
366
+ `${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
367
+ );
368
+ return { state: "skipped" };
369
+ }
370
+ try {
371
+ return await installDesktopCommand([]);
372
+ } catch (error) {
373
+ process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
374
+ return { state: "failed", detail: error.message };
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Which of GitHub, AWS and Slack this account already has.
380
+ *
381
+ * One probe per provider because each owns its own list route; a provider whose
382
+ * route errors is reported as unknown rather than missing, so a backend hiccup
383
+ * does not send someone through an install they already did.
384
+ */
385
+ export async function integrationStatus(args, apiKey) {
386
+ const probe = async (method, route, pick) => {
387
+ try {
388
+ const result = await callBackendJson(args, method, route, { token: apiKey });
389
+ if (!result.ok) return null;
390
+ return (pick(result) || []).length;
391
+ } catch {
392
+ return null;
393
+ }
394
+ };
395
+ const [github, aws, slack] = await Promise.all([
396
+ probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
397
+ probe("GET", "/aws-links", (r) => r.links || r.list),
398
+ probe("GET", "/slack/connections", (r) => r.connections || r.list),
399
+ ]);
400
+ return { github, aws, slack };
401
+ }
402
+
403
+ /**
404
+ * Show what is connected, and offer to finish what is not.
405
+ *
406
+ * Each install lives in the customer's browser session, so every one of these
407
+ * opens a URL and polls our own API until the other side reports the connection
408
+ * exists — the terminal picks the result up on its own, whether they finished in
409
+ * the browser or in the desktop app.
410
+ */
411
+ async function connectIntegrations(args, apiKey, { assumeYes }) {
412
+ if (args.has("--no-integrations")) return;
413
+ const status = await integrationStatus(args, apiKey);
414
+ const providers = [
415
+ { key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
416
+ { key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
417
+ { key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
418
+ ];
419
+
420
+ for (const provider of providers) {
421
+ const count = status[provider.key];
422
+ if (count === null) {
423
+ process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
424
+ } else if (count > 0) {
425
+ process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
426
+ } else {
427
+ process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
428
+ }
429
+ }
430
+
431
+ for (const provider of providers) {
432
+ if (status[provider.key] !== 0) continue;
433
+ if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
434
+ try {
435
+ await provider.run();
436
+ } catch (error) {
437
+ // One provider's failure must not cost the customer the ones that worked.
438
+ process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
439
+ }
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Install the pre-push hook, so a push is what triggers the tests.
445
+ *
446
+ * Not a question: testing what you just changed before it ships is the product,
447
+ * the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
448
+ * still there. Asking only produced accounts that never tested anything.
449
+ */
450
+ export async function setUpPushTesting(args) {
451
+ if (args.has("--no-hook")) return { state: "skipped" };
452
+ const current = (() => {
453
+ try {
454
+ return hookStatus({ probe: true });
455
+ } catch {
456
+ return null; // not a git repository
457
+ }
458
+ })();
459
+ if (!current) {
460
+ process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
461
+ return { state: "unavailable" };
462
+ }
463
+ // A hook whose invocation went stale, or no longer answers, is reinstalled
464
+ // rather than reported as on: it is the case where PreMan looks connected and
465
+ // silently checks nothing.
466
+ if (current.state === "installed" && current.current && current.works) {
467
+ process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
468
+ return { state: "installed" };
469
+ }
470
+
471
+ const result = installHook(args);
472
+ if (result.action === "conflict") {
473
+ process.stdout.write(
474
+ `${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
475
+ );
476
+ return result;
477
+ }
478
+ if (result.action === "unproven") {
479
+ process.stdout.write(
480
+ `${MARK.skip()} No push testing: ${result.detail}\n` +
481
+ ` Then: ${cliInvocation()} hook install\n`
482
+ );
483
+ return result;
484
+ }
485
+ process.stdout.write(
486
+ `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
487
+ " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
488
+ );
489
+ return result;
490
+ }
491
+
492
+ /**
493
+ * Everything after the link, in one pass, for whoever asks for it with `--guide`.
494
+ *
495
+ * Order follows what a new account needs to see: what PreMan found, then where to
496
+ * watch it, then who to tell, then when to run it. It is opt-in because running
497
+ * all of it unasked is what turned a finished connect into a screen of prompts,
498
+ * a dead runner and a five-minute wait on an integration nobody had asked for.
499
+ */
500
+ export async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
501
+ const assumeYes = args.has("--yes");
502
+
503
+ step("Endpoints");
504
+ const counts = await discoverEndpoints(args, agent, serverName, { blockedHere });
505
+
506
+ if (counts.first) {
507
+ step("First test");
508
+ await runFirstTest(args, counts.first, { assumeYes });
509
+ }
510
+
511
+ step("Runner");
512
+ await setUpRunner(args, agent, { assumeYes });
513
+
514
+ await offerDesktop(args, { assumeYes });
515
+
516
+ step("Integrations");
517
+ await connectIntegrations(args, apiKey, { assumeYes });
518
+
519
+ step("Testing on push");
520
+ await setUpPushTesting(args);
521
+
522
+ process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
523
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pair codes and the credential behind them.
3
+ *
4
+ * Split out because one failure here is not like the others: a 401 means the key
5
+ * is dead, and writing a dead key into an agent's config produces a setup that
6
+ * looks connected and fails on every call. Everything in here exists to avoid
7
+ * that specific outcome.
8
+ */
9
+
10
+ import { apiKeyIsExplicit, authenticateTerminal, callBackendJson, clearStoredCredentials, cliInvocation, resolveApiKey } from "../shared.js";
11
+ import { ConnectError, EXIT_USAGE } from "./errors.js";
12
+
13
+ // ── Pairing ─────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Mint the pair code, distinguishing "could not reach it" from "this key is dead".
17
+ *
18
+ * 401 is the backend saying the key is invalid or revoked, which is the one
19
+ * failure the caller must not shrug off: every other outcome still leaves a
20
+ * usable config, but writing a revoked key into an agent's config produces a
21
+ * setup that looks connected and fails on every call.
22
+ */
23
+ async function startPairing(args, agent, apiKey) {
24
+ const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
25
+ token: apiKey,
26
+ json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
27
+ });
28
+ if (result.status_code === 401) return { pairCode: "", pairingId: "", stale: true };
29
+ if (!result.ok) {
30
+ process.stdout.write(
31
+ `Note: could not start pairing (${result.status_code}); the config still works, ` +
32
+ "your agent will link on its first PreMan call.\n"
33
+ );
34
+ return { pairCode: "", pairingId: "", stale: false };
35
+ }
36
+ return {
37
+ pairCode: String(result.pair_code || ""),
38
+ pairingId: String(result.pairing_id || ""),
39
+ stale: false,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * The --no-pair stand-in for the auth check pairing performs implicitly.
45
+ *
46
+ * Declining a pair code should not also decline noticing the key is dead,
47
+ * otherwise this flag writes exactly the broken config pairing would catch.
48
+ */
49
+ async function verifyKey(args, apiKey) {
50
+ const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
51
+ return { pairCode: "", pairingId: "", stale: result.status_code === 401 };
52
+ }
53
+
54
+ /** Whichever of the two ways to learn the backend still accepts this key. */
55
+ export async function checkKeyAndPair(args, agent, apiKey) {
56
+ return args.has("--no-pair")
57
+ ? verifyKey(args, apiKey)
58
+ : startPairing(args, agent, apiKey);
59
+ }
60
+
61
+ /**
62
+ * Replace credentials the backend has rejected, and return the new key.
63
+ *
64
+ * Only stored credentials can be replaced here: an explicit --api-key or
65
+ * PREMAN_API_KEY would just be re-read, so those say so and stop rather than
66
+ * looping. Refusing to continue is deliberate — the alternative is writing a
67
+ * key we know is dead into the agent's config.
68
+ */
69
+ export async function refreshStaleCredentials(args) {
70
+ if (apiKeyIsExplicit(args)) {
71
+ const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
72
+ throw new ConnectError(
73
+ `The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
74
+ `dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
75
+ EXIT_USAGE
76
+ );
77
+ }
78
+ if (args.has("--skip-login") || !process.stdin.isTTY) {
79
+ throw new ConnectError(
80
+ "Saved PreMan credentials are invalid or revoked. Run " +
81
+ `'${cliInvocation()} logout', then connect again to sign in.`,
82
+ EXIT_USAGE
83
+ );
84
+ }
85
+
86
+ process.stdout.write(
87
+ "\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
88
+ "account behind it was removed. Signing you in again.\n"
89
+ );
90
+ clearStoredCredentials();
91
+ await authenticateTerminal(args);
92
+ process.stdout.write("\n");
93
+ return resolveApiKey(args);
94
+ }