premanmcp 0.16.3 → 1.0.1

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/desktop.js CHANGED
@@ -1,10 +1,30 @@
1
1
  /**
2
2
  * `preman install-desktop` — download and install the PreMan desktop app.
3
3
  *
4
- * Version-less asset names are used on purpose: `/releases/latest/download/
5
- * PreMan-mac-arm64.dmg` keeps working after every release, where a versioned URL
6
- * would 404 the day the next build ships. The live tag is read from the GitHub
7
- * API only to report what was installed.
4
+ * ## One release, resolved once
5
+ *
6
+ * An install reads three things from the release: the version to report, the
7
+ * disk image, and the checksum to verify it against. Each used to be a separate
8
+ * request to `/releases/latest/…`, which is three independent answers to "what
9
+ * is the newest release" taken minutes apart, across a 100 MB download. Desktop
10
+ * ships several times a day, so those answers disagreed regularly, and every way
11
+ * they could disagree was bad:
12
+ *
13
+ * * The version was read *before* the download, so a release landing mid-fetch
14
+ * meant reporting `Installed PreMan 0.3.170` over a copy of 0.3.172 — the
15
+ * install genuinely was not the version it claimed.
16
+ * * The manifest was read *after*, so the same race made the checksum belong to
17
+ * a different build than the image. That is a hard `size mismatch`, and the
18
+ * 100 MB the user just waited for is discarded and fetched again.
19
+ * * `electron-updater` then found a newer build on first launch and downloaded
20
+ * a third copy, this time a 97 MB ZIP.
21
+ *
22
+ * So the tag is resolved once, up front, and every later request is pinned to
23
+ * it. A pinned install is reproducible, its reported version is the one on disk,
24
+ * and its checksum describes the bytes it actually fetched. Version-less aliases
25
+ * remain the fallback for when the tag cannot be resolved, which is the only
26
+ * reason they still exist here: they are what keeps a URL valid across releases,
27
+ * at the cost of not saying which release it is.
8
28
  *
9
29
  * Arch detection is trivial here in a way it is not on the website: `uname -m`
10
30
  * is authoritative, so there is no need for the WebGL guessing the download page
@@ -32,6 +52,7 @@ export const DESKTOP_HELP = `
32
52
  Install-desktop options:
33
53
  --arch <arm64|x64> Override architecture detection
34
54
  --dest <dir> Install directory. Defaults to /Applications
55
+ --force Reinstall even when the latest release is already installed
35
56
  --keep-dmg Leave the downloaded disk image in place
36
57
  --print-url Print the resolved download URL and exit
37
58
  `;
@@ -57,10 +78,78 @@ export function installedAppPath(destination = "/Applications") {
57
78
  return path.join(destination, APP_NAME);
58
79
  }
59
80
 
81
+ /**
82
+ * `CFBundleShortVersionString` of the installed app, or `""`.
83
+ *
84
+ * Lives here rather than in link.js, which is where it started, because the
85
+ * installer needs it too -- to answer "is this already the release I am about to
86
+ * download" -- and link.js already imports this module, so the dependency only
87
+ * points one way from here.
88
+ */
89
+ export function installedDesktopVersion(destination = "/Applications") {
90
+ try {
91
+ const plist = path.join(installedAppPath(destination), "Contents", "Info.plist");
92
+ const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
93
+ readFileSync(plist, "utf8")
94
+ );
95
+ return match ? match[1].trim() : "";
96
+ } catch {
97
+ // Not installed, or a bundle we cannot read.
98
+ return "";
99
+ }
100
+ }
101
+
60
102
  export function desktopAppInstalled(destination = "/Applications") {
61
103
  return process.platform === "darwin" && existsSync(installedAppPath(destination));
62
104
  }
63
105
 
106
+ /**
107
+ * Is a copy of the app already up?
108
+ *
109
+ * This decides whether a handoff is possible at all. `open` on a running app
110
+ * raises the window it already has instead of starting a process, and the
111
+ * session file is only read at launch -- so a running app cannot take up a
112
+ * session no matter how long the CLI waits for it to. Without this check the
113
+ * CLI waited out the full timeout and then reported the failure as though the
114
+ * app were merely old, which left people looking at whichever account the app
115
+ * was already showing with nothing to explain it.
116
+ *
117
+ * Matched on the bundle's own executable directory. The helper processes live
118
+ * under `Contents/Frameworks/...` and so do not match, which keeps this to the
119
+ * one process whose launch reads the file.
120
+ */
121
+ export function desktopAppRunning(destination = "/Applications") {
122
+ if (process.platform !== "darwin") return false;
123
+ const binary = path.join(installedAppPath(destination), "Contents", "MacOS");
124
+ const found = spawnSync("pgrep", ["-f", binary], { encoding: "utf8" });
125
+ return found.status === 0 && String(found.stdout || "").trim() !== "";
126
+ }
127
+
128
+ /**
129
+ * Ask the app to quit, and wait for it to actually be gone.
130
+ *
131
+ * Only ever a graceful `quit`: this runs while someone is watching a setup walk,
132
+ * and a tool that kills an app to save three seconds is not one people leave
133
+ * installed. A copy that ignores the request keeps running and the caller says
134
+ * so rather than escalating.
135
+ */
136
+ export async function quitDesktopApp({
137
+ destination = "/Applications",
138
+ timeoutMs = 8_000,
139
+ sleep = defaultSleep,
140
+ } = {}) {
141
+ if (!desktopAppRunning(destination)) return "not-running";
142
+ spawnSync("osascript", ["-e", `quit app "${APP_NAME.replace(/\.app$/i, "")}"`], {
143
+ stdio: "ignore",
144
+ });
145
+ const deadline = Date.now() + timeoutMs;
146
+ while (Date.now() < deadline) {
147
+ await sleep(250);
148
+ if (!desktopAppRunning(destination)) return "quit";
149
+ }
150
+ return "still-running";
151
+ }
152
+
64
153
  /**
65
154
  * Hand the account the CLI just signed in to over to the desktop app.
66
155
  *
@@ -123,28 +212,56 @@ export function clearDesktopSession() {
123
212
  * all would otherwise be reported as signed in while the customer looks at a
124
213
  * login screen. The file is left behind on timeout -- it expires on its own, and
125
214
  * a slow first launch can still find it.
215
+ *
216
+ * An app that is already running is the one case where waiting cannot help,
217
+ * because only a launch reads the file. `restartIfRunning` is how a caller whose
218
+ * whole purpose is to leave someone signed in asks for the restart that makes
219
+ * the handoff possible; callers that are only opening a window leave it off and
220
+ * get told the session was not taken up.
126
221
  */
127
222
  export async function openDesktopSignedIn(
128
223
  creds,
129
- { destination = "/Applications", waitMs = 12_000, sleep = defaultSleep } = {}
224
+ {
225
+ destination = "/Applications",
226
+ waitMs = 12_000,
227
+ sleep = defaultSleep,
228
+ restartIfRunning = false,
229
+ // Injected the same way `sleep` is, so the running/quitting branches can be
230
+ // exercised without a real app on the machine running the tests.
231
+ isRunning = desktopAppRunning,
232
+ quit = quitDesktopApp,
233
+ } = {}
130
234
  ) {
131
235
  if (!desktopAppInstalled(destination)) {
132
236
  return { state: "not-installed" };
133
237
  }
238
+ const wasRunning = isRunning(destination);
239
+ let restarted = false;
240
+ if (wasRunning && restartIfRunning) {
241
+ restarted = (await quit({ destination, sleep })) === "quit";
242
+ }
134
243
  const handoff = writeDesktopSession(creds);
135
244
  // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
136
245
  // which may well pick a different copy than the one just installed.
137
246
  spawn("open", ["-a", installedAppPath(destination)], { stdio: "ignore", detached: true }).unref();
138
247
  if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
139
248
 
249
+ if (wasRunning && !restarted) {
250
+ // Nothing is going to read the file, so the timeout would only be a slower
251
+ // way to reach this same answer. The session is deliberately left on disk:
252
+ // it is what the next launch adopts, which is exactly what the customer is
253
+ // about to be told to do.
254
+ return { state: "opened-already-running", handoff: handoff.state };
255
+ }
256
+
140
257
  const deadline = Date.now() + waitMs;
141
258
  while (Date.now() < deadline) {
142
259
  await sleep(500);
143
260
  if (!existsSync(DESKTOP_SESSION_FILE)) {
144
- return { state: "opened-signed-in", handoff: handoff.state };
261
+ return { state: "opened-signed-in", handoff: handoff.state, restarted };
145
262
  }
146
263
  }
147
- return { state: "opened-not-adopted", handoff: handoff.state };
264
+ return { state: "opened-not-adopted", handoff: handoff.state, restarted };
148
265
  }
149
266
 
150
267
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -174,10 +291,29 @@ export function printPlayground(url) {
174
291
  }
175
292
  }
176
293
 
177
- export function dmgUrl(arch) {
294
+ /**
295
+ * Where to fetch the disk image for `arch`.
296
+ *
297
+ * With a tag, the release's own versioned asset -- one exact build, which cannot
298
+ * become a different one between resolving it and finishing the download.
299
+ * Without one, the version-less alias, which always resolves to something but
300
+ * will not say what.
301
+ */
302
+ export function dmgUrl(arch, tag = null) {
303
+ if (tag) return `${RELEASES_BASE}/download/${tag}/PreMan-${versionFromTag(tag)}-${arch}.dmg`;
178
304
  return `${RELEASES_BASE}/latest/download/PreMan-mac-${arch}.dmg`;
179
305
  }
180
306
 
307
+ /** Where to fetch the update manifest, pinned the same way. */
308
+ export function manifestUrl(tag = null) {
309
+ if (tag) return `${RELEASES_BASE}/download/${tag}/latest-mac.yml`;
310
+ return `${RELEASES_BASE}/latest/download/latest-mac.yml`;
311
+ }
312
+
313
+ export function versionFromTag(tag) {
314
+ return String(tag || "").replace(/^v/i, "");
315
+ }
316
+
181
317
  export function detectArch() {
182
318
  const machine = os.machine ? os.machine() : os.arch();
183
319
  if (machine === "arm64" || machine === "aarch64") return "arm64";
@@ -201,16 +337,46 @@ async function fetchLatestRelease() {
201
337
  }
202
338
  }
203
339
 
340
+ /**
341
+ * The tag `/releases/latest` currently points at, read out of its redirect.
342
+ *
343
+ * A second way to ask, because the first one is rate limited: the GitHub API
344
+ * allows 60 unauthenticated calls an hour per address, which a shared office or
345
+ * a CI runner can exhaust without doing anything unusual. Downloads are not
346
+ * limited, and the alias redirects to the release it resolved -- so asking for
347
+ * the alias and declining to follow the redirect yields the same tag the
348
+ * download itself would have used, which is exactly the one worth pinning.
349
+ */
350
+ export function tagFromRedirect(location) {
351
+ return String(location || "").match(/\/releases\/download\/([^/]+)\//)?.[1] ?? null;
352
+ }
353
+
354
+ async function resolveReleaseTag(arch) {
355
+ const release = await fetchLatestRelease();
356
+ if (release?.tag_name) return String(release.tag_name);
357
+ try {
358
+ const resp = await fetch(dmgUrl(arch), {
359
+ method: "HEAD",
360
+ redirect: "manual",
361
+ headers: { "User-Agent": "premanmcp-cli" },
362
+ });
363
+ return tagFromRedirect(resp.headers.get("location"));
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+
204
369
  /**
205
370
  * The sha512 and size electron-builder published for this architecture's disk
206
371
  * image, or null if the manifest could not be read.
207
372
  *
208
373
  * `latest-mac.yml` is the only integrity signal available without our own signing
209
374
  * infrastructure. It keys entries by the *versioned* filename
210
- * (`PreMan-0.3.79-arm64.dmg`), while we download the version-less alias, so the
211
- * version has to be read out of the manifest to find the right entry. The alias
212
- * is a copy of the same artifact, which the size check confirms before the digest
213
- * is trusted.
375
+ * (`PreMan-0.3.79-arm64.dmg`), so the version is read out of the manifest to
376
+ * find the right entry. On a pinned install that version is the tag's, and the
377
+ * asset named in the manifest is the one that was downloaded; on a fallback
378
+ * install it describes the alias's twin, which the size check confirms before
379
+ * the digest is trusted.
214
380
  */
215
381
  export function parseMacManifest(text, arch) {
216
382
  const version = text.match(/^version:\s*(\S+)/m)?.[1];
@@ -231,9 +397,9 @@ export function parseMacManifest(text, arch) {
231
397
  return null;
232
398
  }
233
399
 
234
- async function expectedDigest(arch) {
400
+ async function expectedDigest(arch, tag = null) {
235
401
  try {
236
- const resp = await fetch(`${RELEASES_BASE}/latest/download/latest-mac.yml`, {
402
+ const resp = await fetch(manifestUrl(tag), {
237
403
  headers: { "User-Agent": "premanmcp-cli" },
238
404
  });
239
405
  if (!resp.ok) return null;
@@ -367,18 +533,41 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
367
533
  if (!["arm64", "x64"].includes(arch)) {
368
534
  throw new Error(`unsupported --arch ${arch}; expected arm64 or x64`);
369
535
  }
370
- const url = dmgUrl(arch);
371
-
372
536
  if (args.has("--print-url")) {
373
- process.stdout.write(`${url}\n`);
374
- return { state: "printed", url, arch };
537
+ // The alias, and no network call: this is asked for by scripts and by people
538
+ // who want the URL that keeps working, not the one for today's build.
539
+ const alias = dmgUrl(arch);
540
+ process.stdout.write(`${alias}\n`);
541
+ return { state: "printed", url: alias, arch };
375
542
  }
376
543
 
377
- const release = await fetchLatestRelease();
378
- const version = String(release?.tag_name || "").replace(/^v/i, "") || "latest";
544
+ // Resolved once. Everything below is pinned to this tag, so the version
545
+ // reported, the image downloaded and the checksum verified against it all
546
+ // describe the same release even if another ships while this runs.
547
+ const tag = await resolveReleaseTag(arch);
548
+ const url = dmgUrl(arch, tag);
549
+ const version = tag ? versionFromTag(tag) : "latest";
379
550
  const destination = args.value("--dest", "/Applications");
380
551
 
381
- process.stdout.write(`Downloading PreMan ${version} (${arch})…\n`);
552
+ const installed = installedDesktopVersion(destination);
553
+ if (tag && installed && installed === version && !args.has("--force")) {
554
+ // Re-fetching 100 MB to arrive at the bytes already on disk is the most
555
+ // literal form of the "it downloads the update again" complaint, and it is
556
+ // what this command did every time it was run.
557
+ process.stdout.write(
558
+ `PreMan ${installed} is already installed and is the latest release.\n` +
559
+ `Nothing to do. Use --force to reinstall.\n`
560
+ );
561
+ return { state: "current", version: installed, arch, path: installedAppPath(destination) };
562
+ }
563
+
564
+ if (installed === version) {
565
+ process.stdout.write(`Reinstalling PreMan ${version} (${arch})…\n`);
566
+ } else if (installed) {
567
+ process.stdout.write(`Replacing PreMan ${installed} with ${version} (${arch})…\n`);
568
+ } else {
569
+ process.stdout.write(`Downloading PreMan ${version} (${arch})…\n`);
570
+ }
382
571
  const workDir = mkdtempSync(path.join(os.tmpdir(), "preman-desktop-"));
383
572
  const dmgPath = path.join(workDir, `PreMan-mac-${arch}.dmg`);
384
573
  let mounted = null;
@@ -386,8 +575,21 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
386
575
  try {
387
576
  const progress = progressLine();
388
577
  let bytes;
578
+ let pinned = tag;
389
579
  try {
390
- bytes = await download(url, dmgPath, progress.tick);
580
+ try {
581
+ bytes = await download(url, dmgPath, progress.tick);
582
+ } catch (err) {
583
+ // Pinning names an asset instead of an alias, so it is the one thing
584
+ // here that can be wrong about a release rather than merely unlucky --
585
+ // an older build that predates the versioned artifact, or a rename.
586
+ // Falling back to the alias costs the guarantees above and is still an
587
+ // install; failing outright would make this worse than what it replaced.
588
+ if (!pinned || !/download failed: 404/.test(String(err?.message))) throw err;
589
+ process.stdout.write(` ${tag} has no versioned image; using the latest alias\n`);
590
+ pinned = null;
591
+ bytes = await download(dmgUrl(arch), dmgPath, progress.tick);
592
+ }
391
593
  } finally {
392
594
  // Cleared even when the download throws, or the error prints onto the
393
595
  // half-drawn progress line.
@@ -395,7 +597,9 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
395
597
  }
396
598
  process.stdout.write(` ${(bytes / BYTES_PER_MB).toFixed(1)} MB\n`);
397
599
 
398
- const expected = await expectedDigest(arch);
600
+ // Read from the same place the image came from, pinned or not, so the two
601
+ // can never describe different releases.
602
+ const expected = await expectedDigest(arch, pinned);
399
603
  if (expected) {
400
604
  if (expected.size && expected.size !== bytes) {
401
605
  throw new Error(
@@ -425,8 +629,12 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
425
629
  run("cp", ["-R", source, target]);
426
630
  chmodSync(target, 0o755);
427
631
 
428
- const result = { state: "installed", version, arch, path: target };
429
- process.stdout.write(`\nInstalled ${target}\n\n`);
632
+ // The manifest's version describes bytes whose size and digest were just
633
+ // checked against it, which makes it a better answer than the tag resolved
634
+ // before any of this ran -- and the only honest one on the alias fallback.
635
+ const installedVersion = expected?.version || installedDesktopVersion(destination) || version;
636
+ const result = { state: "installed", version: installedVersion, arch, path: target };
637
+ process.stdout.write(`\nInstalled PreMan ${installedVersion} to ${target}\n\n`);
430
638
  onInstalled?.(result);
431
639
  return result;
432
640
  } finally {
@@ -265,9 +265,15 @@ export async function githubCommand(args) {
265
265
 
266
266
  const seen = new Set((await listRepos()).map((r) => r.id));
267
267
 
268
+ // `return_to: "desktop"` because the person who ran this is in a terminal and
269
+ // the CLI is already polling for the installation. The web return 302s the
270
+ // tab into the hosted dashboard � an app they did not ask for, on whichever
271
+ // environment owns the App's setup URL. The desktop return finishes on a page
272
+ // that offers PreMan and says the tab may be closed, which is all the browser
273
+ // still has to do here.
268
274
  const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
269
275
  token,
270
- json: {},
276
+ json: { return_to: "desktop" },
271
277
  });
272
278
  assertOk(started, "start GitHub install");
273
279
 
@@ -487,14 +493,32 @@ async function showDesktopSignedIn(openDesktopSignedIn, args, creds, stopOpening
487
493
  // The same --dest the install honoured, or the launch would look for the app
488
494
  // somewhere it was never copied to.
489
495
  const destination = args.value("--dest", "/Applications");
490
- const opened = await openDesktopSignedIn(creds, { destination });
496
+ // This step exists to end with someone looking at PreMan as the account the
497
+ // walk just signed in to. A copy that is already running only reads a session
498
+ // when it starts, so restarting it is the difference between doing that and
499
+ // handing back a window still showing whoever was signed in before.
500
+ const opened = await openDesktopSignedIn(creds, { destination, restartIfRunning: true });
491
501
  stopOpening?.();
502
+ const email = String(creds?.user_email || creds?.user?.email || "").trim();
492
503
  if (opened.state === "opened-signed-in") {
493
- process.stdout.write("Opened PreMan, signed in as this account.\n");
504
+ process.stdout.write(
505
+ opened.restarted
506
+ ? "PreMan was already open \u2014 restarted it, signed in as this account.\n"
507
+ : "Opened PreMan, signed in as this account.\n"
508
+ );
494
509
  } else if (opened.state === "not-installed") {
495
510
  process.stdout.write(
496
511
  `PreMan is not in ${destination} yet \u2014 open it once installed and sign in.\n`
497
512
  );
513
+ } else if (opened.state === "opened-already-running") {
514
+ // The window on screen belongs to an earlier session, and nothing about it
515
+ // says so. Name the account being switched to, and the one action that
516
+ // completes the switch -- the session is on disk and the next launch takes
517
+ // it up, so quitting really is all that is left to do.
518
+ process.stdout.write(
519
+ `PreMan was already open and kept its previous sign-in.\n` +
520
+ ` Quit and reopen it to switch${email ? ` to ${email}` : ""}.\n`
521
+ );
498
522
  } else {
499
523
  // Either the session could not be handed over or this app is too old to take
500
524
  // it up. Say so rather than let the customer wonder why they are looking at
package/bin/link.js CHANGED
@@ -14,12 +14,11 @@
14
14
  * a `git push` is exactly the behaviour that gets a tool uninstalled.
15
15
  */
16
16
 
17
- import { readFileSync } from "node:fs";
18
- import path from "node:path";
19
-
20
- import { desktopAppInstalled, installedAppPath } from "./desktop.js";
17
+ import { desktopAppInstalled, installedDesktopVersion } from "./desktop.js";
21
18
  import { DEFAULT_FRONTEND, frontendUrl } from "./shared.js";
22
19
 
20
+ export { installedDesktopVersion };
21
+
23
22
  const DIM = "\u001b[2m";
24
23
  const RESET = "\u001b[0m";
25
24
 
@@ -82,20 +81,6 @@ export function compareVersions(a, b) {
82
81
  return 0;
83
82
  }
84
83
 
85
- /** CFBundleShortVersionString of the installed app, or "". */
86
- export function installedDesktopVersion(destination = "/Applications") {
87
- try {
88
- const plist = path.join(installedAppPath(destination), "Contents", "Info.plist");
89
- const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
90
- readFileSync(plist, "utf8")
91
- );
92
- return match ? match[1].trim() : "";
93
- } catch {
94
- // Not installed, or a bundle we cannot read. Either way: not routable.
95
- return "";
96
- }
97
- }
98
-
99
84
  export function desktopSupportsRouting(destination = "/Applications") {
100
85
  const version = installedDesktopVersion(destination);
101
86
  return Boolean(version) && compareVersions(version, MIN_ROUTE_VERSION) >= 0;
package/bin/shared.js CHANGED
@@ -430,10 +430,33 @@ export async function callBackendJson(
430
430
  };
431
431
  }
432
432
 
433
+ /**
434
+ * Turn a backend error body into something a person can act on.
435
+ *
436
+ * FastAPI's ``detail`` is a string on a raised HTTPException, an array of
437
+ * ``{loc, msg}`` objects on a validation error, and occasionally an object with
438
+ * its own shape. Interpolating it directly renders the two useful cases as
439
+ * ``[object Object]``, which is how "this route is gone, call /cli/endpoints
440
+ * instead" reached a customer as `410 [object Object]` -- a remedy the response
441
+ * carried and the screen never showed.
442
+ */
443
+ export function describeFailure(result, fallback = "backend error") {
444
+ const detail = result?.detail ?? result?.message ?? result?.raw;
445
+ if (typeof detail === "string" && detail) return detail;
446
+ if (Array.isArray(detail) && detail.length) {
447
+ return detail.map((item) => item?.msg || JSON.stringify(item)).join("; ");
448
+ }
449
+ if (detail && typeof detail === "object") {
450
+ return detail.message || detail.error || detail.code || JSON.stringify(detail);
451
+ }
452
+ return fallback;
453
+ }
454
+
433
455
  export function assertOk(result, action) {
434
456
  if (result.ok) return;
435
- const detail = result.detail || result.message || result.raw || `${action} failed`;
436
- throw new Error(`${action} failed: ${result.status_code} ${detail}`);
457
+ throw new Error(
458
+ `${action} failed: ${result.status_code} ${describeFailure(result, `${action} failed`)}`
459
+ );
437
460
  }
438
461
 
439
462
  function authSessionFrom(result, email) {
package/bin/status.js CHANGED
@@ -6,7 +6,15 @@
6
6
  * section as possibly empty.
7
7
  */
8
8
 
9
- import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey, truncate } from "./shared.js";
9
+ import {
10
+ backendUrl,
11
+ callBackendJson,
12
+ cliInvocation,
13
+ describeFailure,
14
+ makeArgs,
15
+ resolveApiKey,
16
+ truncate,
17
+ } from "./shared.js";
10
18
 
11
19
  export const STATUS_HELP = `
12
20
  Status options:
@@ -225,8 +233,10 @@ export async function statusCommand(commandArgs = []) {
225
233
  });
226
234
 
227
235
  if (!result.ok) {
228
- const detail = result.detail || result.raw || "request failed";
229
- throw new Error(`could not read status from ${backendUrl(args)}: ${result.status_code} ${detail}`);
236
+ throw new Error(
237
+ `could not read status from ${backendUrl(args)}: ${result.status_code} ` +
238
+ describeFailure(result, "request failed")
239
+ );
230
240
  }
231
241
 
232
242
  const { status_code, ok, ...payload } = result;
package/bin/tests.js CHANGED
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * Thin HTTP wrappers over the same `/workbench/generate-tests` routes the
5
5
  * dashboard uses. Distinct from `preman test <id>`, which generates + runs
6
- * scenarios for one endpoint via `POST /mcp/call-tool`.
6
+ * scenarios for one endpoint via `POST /cli/tests/generate`.
7
7
  */
8
8
 
9
- import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
9
+ import {
10
+ callBackendJson,
11
+ cliInvocation,
12
+ describeFailure,
13
+ makeArgs,
14
+ resolveApiKey,
15
+ } from "./shared.js";
10
16
 
11
17
  export const TESTS_HELP = `
12
18
  Collections tests:
@@ -62,17 +68,7 @@ function printJson(value) {
62
68
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
63
69
  }
64
70
 
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
- }
71
+ const errorDetail = (result) => describeFailure(result);
76
72
 
77
73
  async function workbench(args, method, routePath, { json } = {}) {
78
74
  const token = requireKey(args);
package/bin/verify.js CHANGED
@@ -243,12 +243,9 @@ async function safeCall(args, method, routePath, options) {
243
243
  async function fetchInventory(args, token) {
244
244
  const endpoints = [];
245
245
  for (let offset = 0; offset < INVENTORY_MAX_ENDPOINTS; offset += INVENTORY_PAGE_SIZE) {
246
- const result = await safeCall(args, "POST", "/mcp/call-tool", {
246
+ const result = await safeCall(args, "GET", "/cli/endpoints", {
247
247
  token,
248
- json: {
249
- tool: "get_endpoints",
250
- arguments: { format: "json", limit: INVENTORY_PAGE_SIZE, offset },
251
- },
248
+ query: { limit: INVENTORY_PAGE_SIZE, offset },
252
249
  });
253
250
  if (!result.ok) {
254
251
  return {
package/dist/server.d.ts CHANGED
@@ -1,2 +1,7 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function createServer(): McpServer;
1
+ /** Transparent stdio -> hosted Streamable HTTP MCP bridge.
2
+ *
3
+ * This process deliberately defines no tools or schemas. Initialization,
4
+ * discovery, notifications, and calls are forwarded byte-for-byte at the JSON-
5
+ * RPC layer so the Python service remains the sole public contract authority.
6
+ */
7
+ export {};