premanmcp 1.0.0 → 1.0.2

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/api_tools.js CHANGED
@@ -9,7 +9,13 @@
9
9
 
10
10
  import { readFileSync } from "node:fs";
11
11
 
12
- import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
12
+ import {
13
+ callBackendJson,
14
+ cliInvocation,
15
+ describeFailure,
16
+ makeArgs,
17
+ resolveApiKey,
18
+ } from "./shared.js";
13
19
  import { printPlayground } from "./desktop.js";
14
20
 
15
21
  export const ENDPOINTS_HELP = `
@@ -61,22 +67,6 @@ const TOOL_ROUTES = {
61
67
  share_endpoints_with_ui: { method: "POST", path: "/cli/playground-session" },
62
68
  };
63
69
 
64
- /**
65
- * A failure a person can act on.
66
- *
67
- * FastAPI's `detail` is often an object, and interpolating one straight into a
68
- * template gives `[object Object]` — which is what a retired route reported for
69
- * a day, hiding both its status and its remedy.
70
- */
71
- function describeFailure(result) {
72
- const detail = result.detail ?? result.message ?? result.raw;
73
- if (typeof detail === "string" && detail) return detail;
74
- if (detail && typeof detail === "object") {
75
- return detail.message || detail.code || JSON.stringify(detail);
76
- }
77
- return "backend error";
78
- }
79
-
80
70
  export async function callTool(args, tool, toolArguments) {
81
71
  const token = resolveApiKey(args);
82
72
  if (!token) {
package/bin/cli.js CHANGED
@@ -38,7 +38,12 @@ import { STATUS_HELP, statusCommand } from "./status.js";
38
38
  import { HOOK_HELP, hookCommand, installHook, scheduleHookRepair } from "./hook.js";
39
39
  import { RUNNER_HELP, runnerCommand } from "./runner.js";
40
40
  import { VERIFY_HELP, verifyCommand } from "./verify.js";
41
- import { DESKTOP_HELP, installDesktopCommand, openDesktopSignedIn } from "./desktop.js";
41
+ import {
42
+ DESKTOP_HELP,
43
+ desktopAppRunning,
44
+ installDesktopCommand,
45
+ openDesktopSignedIn,
46
+ } from "./desktop.js";
42
47
  import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
43
48
  import {
44
49
  CREDENTIALS_FILE,
@@ -150,7 +155,18 @@ Email: ${creds.user_email || "unknown"}
150
155
  Backend: ${creds.backend_url}
151
156
  API key: ${creds.api_key}
152
157
  Saved to: ${CREDENTIALS_FILE}
153
-
158
+ `);
159
+ // `authenticateTerminal` has already left the session for the app to pick up,
160
+ // but only a launch reads it. Signing in here while the app is open otherwise
161
+ // changes nothing on screen, and the window carries on showing the previous
162
+ // account with no sign that a different one is now signed in.
163
+ if (desktopAppRunning()) {
164
+ process.stdout.write(
165
+ `\nPreMan is open and still signed in as before.\n` +
166
+ ` Quit and reopen it to switch${creds.user_email ? ` to ${creds.user_email}` : ""}.\n`
167
+ );
168
+ }
169
+ process.stdout.write(`
154
170
  You can now run:
155
171
  ${cli} connect
156
172
  `);
package/bin/desktop.js CHANGED
@@ -103,6 +103,53 @@ export function desktopAppInstalled(destination = "/Applications") {
103
103
  return process.platform === "darwin" && existsSync(installedAppPath(destination));
104
104
  }
105
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
+
106
153
  /**
107
154
  * Hand the account the CLI just signed in to over to the desktop app.
108
155
  *
@@ -165,28 +212,56 @@ export function clearDesktopSession() {
165
212
  * all would otherwise be reported as signed in while the customer looks at a
166
213
  * login screen. The file is left behind on timeout -- it expires on its own, and
167
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.
168
221
  */
169
222
  export async function openDesktopSignedIn(
170
223
  creds,
171
- { 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
+ } = {}
172
234
  ) {
173
235
  if (!desktopAppInstalled(destination)) {
174
236
  return { state: "not-installed" };
175
237
  }
238
+ const wasRunning = isRunning(destination);
239
+ let restarted = false;
240
+ if (wasRunning && restartIfRunning) {
241
+ restarted = (await quit({ destination, sleep })) === "quit";
242
+ }
176
243
  const handoff = writeDesktopSession(creds);
177
244
  // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
178
245
  // which may well pick a different copy than the one just installed.
179
246
  spawn("open", ["-a", installedAppPath(destination)], { stdio: "ignore", detached: true }).unref();
180
247
  if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
181
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
+
182
257
  const deadline = Date.now() + waitMs;
183
258
  while (Date.now() < deadline) {
184
259
  await sleep(500);
185
260
  if (!existsSync(DESKTOP_SESSION_FILE)) {
186
- return { state: "opened-signed-in", handoff: handoff.state };
261
+ return { state: "opened-signed-in", handoff: handoff.state, restarted };
187
262
  }
188
263
  }
189
- return { state: "opened-not-adopted", handoff: handoff.state };
264
+ return { state: "opened-not-adopted", handoff: handoff.state, restarted };
190
265
  }
191
266
 
192
267
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { describeCheckout, explainNoCheckout, resolveCheckout } from "./repo.js";
18
+ import { desktopAppInstalled, writeDesktopSession } from "./desktop.js";
18
19
  import {
19
20
  DEFAULT_BACKEND,
20
21
  assertOk,
@@ -254,7 +255,7 @@ export async function awsCommand(args) {
254
255
  // GitHub
255
256
  // ---------------------------------------------------------------------------
256
257
 
257
- export async function githubCommand(args) {
258
+ export async function githubCommand(args, { ensureDesktopApp = null } = {}) {
258
259
  const token = requireKey(args);
259
260
 
260
261
  // This route answers with a bare array; callBackendJson exposes it as `list`.
@@ -265,15 +266,24 @@ export async function githubCommand(args) {
265
266
 
266
267
  const seen = new Set((await listRepos()).map((r) => r.id));
267
268
 
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.
269
+ // The desktop return is the right ending for a run started in a terminal: the
270
+ // CLI is already polling for the installation, and the web return 302s the
271
+ // tab into the hosted dashboard an app they did not ask for, on whichever
272
+ // environment owns the App's setup URL.
273
+ //
274
+ // What it must never be is unconditional. That page's only action is a
275
+ // preman:// link, and a machine with no app answers one with a system dialog
276
+ // reading "There is no application set to open the URL preman://open". That is
277
+ // where a first run actually ended, every time: the app installs at the end of
278
+ // this walk and GitHub is asked about in the middle, so by the time this link
279
+ // was minted there had never been an app. A caller may put one there first;
280
+ // without one, the web return is the only ending that leads anywhere.
281
+ const hasDesktopApp = ensureDesktopApp
282
+ ? await ensureDesktopApp()
283
+ : desktopAppInstalled(args.value("--dest", "/Applications"));
274
284
  const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
275
285
  token,
276
- json: { return_to: "desktop" },
286
+ json: { return_to: hasDesktopApp ? "desktop" : "web" },
277
287
  });
278
288
  assertOk(started, "start GitHub install");
279
289
 
@@ -493,14 +503,32 @@ async function showDesktopSignedIn(openDesktopSignedIn, args, creds, stopOpening
493
503
  // The same --dest the install honoured, or the launch would look for the app
494
504
  // somewhere it was never copied to.
495
505
  const destination = args.value("--dest", "/Applications");
496
- const opened = await openDesktopSignedIn(creds, { destination });
506
+ // This step exists to end with someone looking at PreMan as the account the
507
+ // walk just signed in to. A copy that is already running only reads a session
508
+ // when it starts, so restarting it is the difference between doing that and
509
+ // handing back a window still showing whoever was signed in before.
510
+ const opened = await openDesktopSignedIn(creds, { destination, restartIfRunning: true });
497
511
  stopOpening?.();
512
+ const email = String(creds?.user_email || creds?.user?.email || "").trim();
498
513
  if (opened.state === "opened-signed-in") {
499
- process.stdout.write("Opened PreMan, signed in as this account.\n");
514
+ process.stdout.write(
515
+ opened.restarted
516
+ ? "PreMan was already open \u2014 restarted it, signed in as this account.\n"
517
+ : "Opened PreMan, signed in as this account.\n"
518
+ );
500
519
  } else if (opened.state === "not-installed") {
501
520
  process.stdout.write(
502
521
  `PreMan is not in ${destination} yet \u2014 open it once installed and sign in.\n`
503
522
  );
523
+ } else if (opened.state === "opened-already-running") {
524
+ // The window on screen belongs to an earlier session, and nothing about it
525
+ // says so. Name the account being switched to, and the one action that
526
+ // completes the switch -- the session is on disk and the next launch takes
527
+ // it up, so quitting really is all that is left to do.
528
+ process.stdout.write(
529
+ `PreMan was already open and kept its previous sign-in.\n` +
530
+ ` Quit and reopen it to switch${email ? ` to ${email}` : ""}.\n`
531
+ );
504
532
  } else {
505
533
  // Either the session could not be handed over or this app is too old to take
506
534
  // it up. Say so rather than let the customer wonder why they are looking at
@@ -572,6 +600,12 @@ async function askStep(question, { assumeYes, canGoBack, defaultYes = true }) {
572
600
  * only step that asks nothing: by then every decision is made, and a download is
573
601
  * something to leave running, not something to make someone watch first.
574
602
  *
603
+ * With one exception, and it is not a preference. Connecting a repository ends
604
+ * with GitHub handing the browser back through a preman:// link, and a machine
605
+ * with no app answers that with a system dialog rather than a window. So saying
606
+ * yes to the repository pulls the download forward to just before it; saying no
607
+ * leaves the order exactly as described above.
608
+ *
575
609
  * The steps themselves live in connect/guide.js, which is also what
576
610
  * `preman connect --guide` runs. Two walks that asked the same questions in
577
611
  * different words with different defaults was the thing worth deleting.
@@ -613,6 +647,43 @@ export async function onboardCommand(
613
647
  // is said in one place rather than before PreMan has looked at the code.
614
648
  const checkout = await resolveCheckout(args, resolveApiKey(args));
615
649
 
650
+ // What the app install did, once, shared by the step that needs an app to
651
+ // exist and the step that owns installing one.
652
+ //
653
+ // GitHub's callback ends on a page whose only action is a preman:// link, and
654
+ // the app is the last step of this walk — so a first run always handed GitHub
655
+ // back to a machine with nothing to open it with, and macOS said so in a
656
+ // dialog. The download moves in front of that step when it is needed and stays
657
+ // where it was when it is not: someone who declines the repository never
658
+ // reaches this, and still gets the app at the end.
659
+ //
660
+ // Memoised because it is a hundred megabytes and both callers want the same
661
+ // copy of it. Answers whether there is now an app on this machine, which is
662
+ // the only part either caller can act on.
663
+ let desktopInstall = null;
664
+ const installedNow = (result) => result?.state === "installed" || result?.state === "current";
665
+ const ensureDesktopApp = async ({ onInstalled } = {}) => {
666
+ const already = desktopPrecheck(args);
667
+ if (already?.state === "installed") return true;
668
+ // --no-desktop, or a platform with no build. Nothing to install here, and so
669
+ // nothing that could ever answer a preman:// link either.
670
+ if (already) return false;
671
+ if (!desktopInstall) {
672
+ process.stdout.write(
673
+ "Getting the PreMan app first, so GitHub has somewhere to hand you back to.\n"
674
+ );
675
+ desktopInstall = await runDesktopInstall(args, {
676
+ install: () => installDesktop([...commandArgs], onInstalled ? { onInstalled } : undefined),
677
+ });
678
+ // Handed the session the moment it lands, before anything can launch it.
679
+ // The app reads one only when it starts, and the callback's deep link is a
680
+ // start — without this it opens on a login screen for an account signed in
681
+ // ninety seconds ago.
682
+ if (installedNow(desktopInstall)) writeDesktopSession(creds);
683
+ }
684
+ return installedNow(desktopInstall);
685
+ };
686
+
616
687
  const steps = [
617
688
  {
618
689
  // No question. Everything below is a decision about code PreMan has not
@@ -654,7 +725,7 @@ export async function onboardCommand(
654
725
  return `Connect ${checkout.slug} so PreMan can open pull requests for the ${found} endpoint${found === 1 ? "" : "s"} it just mapped?`;
655
726
  },
656
727
  run: async () => {
657
- await githubCommand(args);
728
+ await githubCommand(args, { ensureDesktopApp });
658
729
  // Finishing the App install is not the same as this repository being
659
730
  // connected: the install shares whichever repositories the customer
660
731
  // picked, which may not include this one. Re-asking is the difference
@@ -765,66 +836,55 @@ export async function onboardCommand(
765
836
  // and someone who already has the app does not watch it download again — both
766
837
  // of which happened when the check came after.
767
838
  const desktop = desktopPrecheck(args);
768
- if (desktop?.state === "installed") {
839
+ if (desktop?.state === "installed" || !desktop) {
769
840
  steps.push({
770
841
  name: "Desktop app",
771
- // Nothing to download, which is the point of this branch. It is not a
772
- // reason to stop at the sentence: the step exists to leave the customer
773
- // looking at PreMan, so the one run with nothing to install was also the
774
- // only run that opened nothing, and it closed by sending someone to a
775
- // website instead of the app already sitting on their machine.
842
+ // No question. See offerDesktop in connect/guide.js for why the ask went
843
+ // away; `--no-desktop` is the way past it.
844
+ //
845
+ // Installing and launching used to be one thing here, which is why the one
846
+ // run with nothing to install was also the only run that opened nothing: it
847
+ // stopped at the sentence and sent someone to a website instead of the app
848
+ // already sitting on their machine. They are two things now, and the
849
+ // install may have happened further up the walk, so this step is the launch
850
+ // and ensureDesktopApp decides whether anything is left to download.
776
851
  run: async () => {
777
- process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
778
- const stopOpening = showOpeningDesktop();
852
+ // Said only when this walk did not just download it. After an install of
853
+ // our own, announcing that it is already installed is the walk telling
854
+ // somebody about their own last thirty seconds.
855
+ if (!desktopInstall && desktopPrecheck(args)?.state === "installed") {
856
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
857
+ }
858
+ let stopOpening = null;
779
859
  try {
860
+ const ready = await ensureDesktopApp({
861
+ onInstalled: () => {
862
+ stopOpening ??= showOpeningDesktop();
863
+ },
864
+ });
865
+ if (!ready) {
866
+ if (desktopInstall?.state === "unsupported") {
867
+ // Not a failure: the download link has already been printed, and
868
+ // the account this step exists to create is finished either way.
869
+ process.stdout.write("Sign in there with the account you just used.\n");
870
+ }
871
+ // A failed install already said so through runDesktopInstall, and its
872
+ // state is what keeps this out of the summary as a skip.
873
+ return desktopInstall || { state: "skipped" };
874
+ }
875
+ stopOpening ??= showOpeningDesktop();
780
876
  return {
877
+ ...(desktopInstall || {}),
878
+ // Onboarding installs *and* hands the fresh session over, so the app
879
+ // opens already signed in rather than on a login screen for the
880
+ // account created ninety seconds ago.
781
881
  appOpened: await showDesktopSignedIn(openDesktopSignedIn, args, creds, stopOpening),
782
882
  };
783
883
  } finally {
784
- stopOpening();
884
+ stopOpening?.();
785
885
  }
786
886
  },
787
887
  });
788
- } else if (!desktop) {
789
- steps.push({
790
- name: "Desktop app",
791
- // No question. See offerDesktop in connect/guide.js for why the ask went
792
- // away; `--no-desktop` is the way past it.
793
- run: () =>
794
- runDesktopInstall(args, {
795
- // Onboarding installs *and* hands the fresh session over, so the app
796
- // opens already signed in rather than on a login screen for the
797
- // account created ninety seconds ago.
798
- install: async () => {
799
- let stopOpening = null;
800
- try {
801
- const installed = await installDesktop([...commandArgs], {
802
- onInstalled: () => {
803
- stopOpening ??= showOpeningDesktop();
804
- },
805
- });
806
- if (installed?.state === "unsupported") {
807
- // Not a failure: the download link has already been printed, and
808
- // the account this step exists to create is finished either way.
809
- process.stdout.write("Sign in there with the account you just used.\n");
810
- return installed;
811
- }
812
- stopOpening ??= showOpeningDesktop();
813
- return {
814
- ...installed,
815
- appOpened: await showDesktopSignedIn(
816
- openDesktopSignedIn,
817
- args,
818
- creds,
819
- stopOpening
820
- ),
821
- };
822
- } finally {
823
- stopOpening?.();
824
- }
825
- },
826
- }),
827
- });
828
888
  }
829
889
 
830
890
  // Outcome per step rather than three lists, so revisiting a step replaces its
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
@@ -6,7 +6,13 @@
6
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
@@ -27,7 +27,7 @@ import { changedFilesForPush, readChangedFiles, readPushRefsFromStdin, repoRoot
27
27
  import { missingMountPrefix, resolveLocalTarget } from "./detect.js";
28
28
  import { formatDashboardLink, formatPushLink } from "./link.js";
29
29
  import { createReporter } from "./progress.js";
30
- import { backendUrl, callBackendJson, cliInvocation, frontendUrl, makeArgs, nowMs, resolveApiKey } from "./shared.js";
30
+ import { backendUrl, callBackendJson, cliInvocation, describeFailure, frontendUrl, makeArgs, nowMs, resolveApiKey } from "./shared.js";
31
31
 
32
32
  export const VERIFY_HELP = `
33
33
  Verify options:
@@ -250,7 +250,11 @@ async function fetchInventory(args, token) {
250
250
  if (!result.ok) {
251
251
  return {
252
252
  endpoints,
253
- error: result.detail || `status ${result.status_code}`,
253
+ // FastAPI answers a rejected request with an object, and a validation
254
+ // error with an array of them. Interpolated straight into the skip
255
+ // line, both read as "[object Object]" -- which is what every push
256
+ // against a backend that said no has printed.
257
+ error: describeFailure(result, `status ${result.status_code}`),
254
258
  };
255
259
  }
256
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {