premanmcp 1.0.1 → 1.0.3

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
@@ -133,9 +133,16 @@ export function desktopAppRunning(destination = "/Applications") {
133
133
  * installed. A copy that ignores the request keeps running and the caller says
134
134
  * so rather than escalating.
135
135
  */
136
+ // Longer than the app allows its own shutdown, which is fifteen seconds of
137
+ // waiting for the runner's last calls to PreMan. Giving up first is not a
138
+ // neutral act: what follows is a launch, and a launch that lands while the old
139
+ // instance still holds the single-instance lock is delivered to that instance
140
+ // rather than starting a new one.
141
+ const QUIT_TIMEOUT_MS = 20_000;
142
+
136
143
  export async function quitDesktopApp({
137
144
  destination = "/Applications",
138
- timeoutMs = 8_000,
145
+ timeoutMs = QUIT_TIMEOUT_MS,
139
146
  sleep = defaultSleep,
140
147
  } = {}) {
141
148
  if (!desktopAppRunning(destination)) return "not-running";
@@ -230,6 +237,7 @@ export async function openDesktopSignedIn(
230
237
  // exercised without a real app on the machine running the tests.
231
238
  isRunning = desktopAppRunning,
232
239
  quit = quitDesktopApp,
240
+ launch = defaultLaunch,
233
241
  } = {}
234
242
  ) {
235
243
  if (!desktopAppInstalled(destination)) {
@@ -241,9 +249,18 @@ export async function openDesktopSignedIn(
241
249
  restarted = (await quit({ destination, sleep })) === "quit";
242
250
  }
243
251
  const handoff = writeDesktopSession(creds);
244
- // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
245
- // which may well pick a different copy than the one just installed.
246
- spawn("open", ["-a", installedAppPath(destination)], { stdio: "ignore", detached: true }).unref();
252
+
253
+ // Asked to go and still here. Launching now does not start anything: the old
254
+ // instance holds the single-instance lock until it actually exits, so the
255
+ // launch reaches it as a second-instance and it re-shows the window it was in
256
+ // the middle of dismantling -- which is how onboarding ended on a black
257
+ // rectangle. Nothing here can hurry it, and the session on disk is what the
258
+ // next launch adopts, so saying so beats making it worse.
259
+ if (wasRunning && restartIfRunning && !restarted) {
260
+ return { state: "opened-already-running", handoff: handoff.state };
261
+ }
262
+
263
+ launch(destination);
247
264
  if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
248
265
 
249
266
  if (wasRunning && !restarted) {
@@ -266,6 +283,14 @@ export async function openDesktopSignedIn(
266
283
 
267
284
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
268
285
 
286
+ // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
287
+ // which may well pick a different copy than the one just installed.
288
+ const defaultLaunch = (destination) =>
289
+ spawn("open", ["-a", installedAppPath(destination)], {
290
+ stdio: "ignore",
291
+ detached: true,
292
+ }).unref();
293
+
269
294
  /**
270
295
  * Open a Playground session URL in PreMan.app on macOS when it is installed.
271
296
  * Windows/Linux (and Mac without the app) get the website URL printed instead.
@@ -15,8 +15,10 @@
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,
21
+ askWhileWaiting,
20
22
  assertOk,
21
23
  backendUrl,
22
24
  callBackendJson,
@@ -115,37 +117,58 @@ export class Unrecoverable extends Error {}
115
117
  * Returns the truthy value from ``check``, or null on timeout. Ordinary
116
118
  * exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
117
119
  */
120
+ const NOT_ANSWERED = Symbol("not answered");
121
+
118
122
  async function waitFor(
119
123
  label,
120
124
  check,
121
- { hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS } = {}
125
+ { hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS, escape = null } = {}
122
126
  ) {
123
127
  const startedAt = Date.now();
124
128
  const deadline = startedAt + timeoutMs;
125
129
  let hinted = false;
130
+ let asked = null;
126
131
  process.stdout.write(`Waiting for ${label}`);
127
- while (Date.now() < deadline) {
128
- try {
129
- const done = await check();
130
- if (done) {
131
- process.stdout.write(" done.\n");
132
- return done;
132
+ try {
133
+ while (Date.now() < deadline) {
134
+ try {
135
+ const done = await check();
136
+ if (done) {
137
+ process.stdout.write(asked ? "\n" : " done.\n");
138
+ return done;
139
+ }
140
+ } catch (err) {
141
+ if (err instanceof Unrecoverable) {
142
+ process.stdout.write("\n");
143
+ throw err;
144
+ }
145
+ /* keep waiting; the customer is elsewhere */
133
146
  }
134
- } catch (err) {
135
- if (err instanceof Unrecoverable) {
136
- process.stdout.write("\n");
137
- throw err;
147
+ // A minute of dots is indistinguishable from a hang, and the reason this
148
+ // waits forever is usually something the customer can act on.
149
+ if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
150
+ hinted = true;
151
+ process.stdout.write(`\n${hint}\n`);
152
+ // Dots and a question cannot share a line. Once there is something to
153
+ // answer, the answering is the whole of what this is doing.
154
+ if (escape) asked = askWhileWaiting(escape.question);
155
+ else process.stdout.write("Still waiting");
156
+ }
157
+ if (!asked) process.stdout.write(".");
158
+ // Polling carries on underneath the question, so an install that lands
159
+ // while it is on the screen still ends this the way it should.
160
+ const answer = await Promise.race([
161
+ new Promise((resolve) => setTimeout(() => resolve(NOT_ANSWERED), POLL_INTERVAL_MS)),
162
+ asked ? asked.reply : new Promise(() => {}),
163
+ ]);
164
+ if (answer !== NOT_ANSWERED) {
165
+ escape.answer = typeof answer === "string" ? answer : "";
166
+ return null;
138
167
  }
139
- /* keep waiting; the customer is elsewhere */
140
- }
141
- // A minute of dots is indistinguishable from a hang, and the reason this
142
- // waits forever is usually something the customer can act on.
143
- if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
144
- hinted = true;
145
- process.stdout.write(`\n${hint}\nStill waiting`);
146
168
  }
147
- process.stdout.write(".");
148
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
169
+ } finally {
170
+ // Whichever side won, the question goes when the wait does.
171
+ asked?.cancel();
149
172
  }
150
173
  process.stdout.write("\n");
151
174
  return null;
@@ -254,7 +277,7 @@ export async function awsCommand(args) {
254
277
  // GitHub
255
278
  // ---------------------------------------------------------------------------
256
279
 
257
- export async function githubCommand(args) {
280
+ export async function githubCommand(args, { ensureDesktopApp = null } = {}) {
258
281
  const token = requireKey(args);
259
282
 
260
283
  // This route answers with a bare array; callBackendJson exposes it as `list`.
@@ -265,15 +288,24 @@ export async function githubCommand(args) {
265
288
 
266
289
  const seen = new Set((await listRepos()).map((r) => r.id));
267
290
 
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.
291
+ // The desktop return is the right ending for a run started in a terminal: the
292
+ // CLI is already polling for the installation, and the web return 302s the
293
+ // tab into the hosted dashboard an app they did not ask for, on whichever
294
+ // environment owns the App's setup URL.
295
+ //
296
+ // What it must never be is unconditional. That page's only action is a
297
+ // preman:// link, and a machine with no app answers one with a system dialog
298
+ // reading "There is no application set to open the URL preman://open". That is
299
+ // where a first run actually ended, every time: the app installs at the end of
300
+ // this walk and GitHub is asked about in the middle, so by the time this link
301
+ // was minted there had never been an app. A caller may put one there first;
302
+ // without one, the web return is the only ending that leads anywhere.
303
+ const hasDesktopApp = ensureDesktopApp
304
+ ? await ensureDesktopApp()
305
+ : desktopAppInstalled(args.value("--dest", "/Applications"));
274
306
  const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
275
307
  token,
276
- json: { return_to: "desktop" },
308
+ json: { return_to: hasDesktopApp ? "desktop" : "web" },
277
309
  });
278
310
  assertOk(started, "start GitHub install");
279
311
 
@@ -290,37 +322,64 @@ export async function githubCommand(args) {
290
322
  // a success carrying no repositories means the App is installed and sharing
291
323
  // nothing — two dead ends that are indistinguishable from the repository list.
292
324
  let refresh = null;
293
- const done = await waitFor(
294
- "the installation",
295
- async () => {
296
- // Installing the App and having repositories appear are two events: the
297
- // callback records the installation, and a refresh materialises the repos.
298
- // Polling the repo list alone waits for something that may never arrive on
299
- // its own.
300
- refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
301
- token,
302
- json: {},
303
- });
304
- // Compare against what existed before, so a user who already had repos
305
- // connected is not told they are done the moment polling starts.
306
- const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
307
- return fresh.length ? fresh : null;
308
- },
309
- {
310
- timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
311
- hintAfterMs: 20000,
312
- hint: "Still nothing from GitHub confirm a repository to come back here.",
313
- }
314
- );
325
+ const lookForRepos = async () => {
326
+ // Installing the App and having repositories appear are two events: the
327
+ // callback records the installation, and a refresh materialises the repos.
328
+ // Polling the repo list alone waits for something that may never arrive on
329
+ // its own.
330
+ refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
331
+ token,
332
+ json: {},
333
+ });
334
+ // Compare against what existed before, so a user who already had repos
335
+ // connected is not told they are done the moment polling starts.
336
+ const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
337
+ return fresh.length ? fresh : null;
338
+ };
339
+
340
+ // Waiting is the right default, because the install usually lands inside it.
341
+ // What it cannot be is the only thing on offer. Somebody who picked no
342
+ // repository, or installed onto an organisation where an owner has to approve
343
+ // it first, is watching dots for an event that is not coming, and the only
344
+ // way out of that was to guess that Ctrl-C here was safe.
345
+ const escape = {
346
+ question:
347
+ "Have you saved the repository selection on GitHub? [y/n, or Enter to keep waiting]: ",
348
+ answer: null,
349
+ };
350
+ let done = await waitFor("the installation", lookForRepos, {
351
+ timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
352
+ hintAfterMs: Number(process.env.PREMAN_GITHUB_HINT_MS) || 20000,
353
+ hint: "Still nothing from GitHub — confirm a repository to come back here.",
354
+ escape,
355
+ });
356
+
357
+ // A yes buys one more look, not one more minute. The callback lands while the
358
+ // question is still on the screen often enough to be worth asking again, and
359
+ // if it has not by now, more dots will not change that.
360
+ if (!done && affirmative(escape.answer)) {
361
+ done = await lookForRepos().catch(() => null);
362
+ }
315
363
 
316
364
  if (!done) {
317
- process.stdout.write(githubHandOff(args, refresh));
365
+ process.stdout.write(githubHandOff(args, refresh, escape.answer));
318
366
  return;
319
367
  }
320
368
  connected(`GitHub connected: ${done.length} repository(ies).`);
321
369
  for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
322
370
  }
323
371
 
372
+ /**
373
+ * Whether a free-text reply means yes.
374
+ *
375
+ * Silence is not consent anywhere else in this walk and it is not here either:
376
+ * only an explicit yes counts, and everything else is left for later.
377
+ */
378
+ function affirmative(answer) {
379
+ const said = (answer || "").trim().toLowerCase();
380
+ return said === "y" || said === "yes";
381
+ }
382
+
324
383
  /**
325
384
  * Stop waiting, and name the dead end instead of the timeout.
326
385
  *
@@ -328,7 +387,16 @@ export async function githubCommand(args) {
328
387
  * finishes in the browser whether this process is watching or not, and the two
329
388
  * ways it can complete and still leave PreMan with nothing are both actionable.
330
389
  */
331
- function githubHandOff(args, refresh) {
390
+ function githubHandOff(args, refresh, answer = null) {
391
+ // A no is a decision, not a dead end. Reporting it as one would be telling
392
+ // somebody that something went wrong immediately after they said they had
393
+ // simply not done it yet.
394
+ if (answer !== null && !affirmative(answer)) {
395
+ return (
396
+ `Left for later, and nothing here is broken.\n` +
397
+ ` Connect it whenever you like with '${cliInvocation()} github'.\n`
398
+ );
399
+ }
332
400
  const installed = Boolean(refresh?.ok) && Number(refresh.installations_refreshed || 0) > 0;
333
401
  if (installed) {
334
402
  return (
@@ -590,6 +658,12 @@ async function askStep(question, { assumeYes, canGoBack, defaultYes = true }) {
590
658
  * only step that asks nothing: by then every decision is made, and a download is
591
659
  * something to leave running, not something to make someone watch first.
592
660
  *
661
+ * With one exception, and it is not a preference. Connecting a repository ends
662
+ * with GitHub handing the browser back through a preman:// link, and a machine
663
+ * with no app answers that with a system dialog rather than a window. So saying
664
+ * yes to the repository pulls the download forward to just before it; saying no
665
+ * leaves the order exactly as described above.
666
+ *
593
667
  * The steps themselves live in connect/guide.js, which is also what
594
668
  * `preman connect --guide` runs. Two walks that asked the same questions in
595
669
  * different words with different defaults was the thing worth deleting.
@@ -631,6 +705,43 @@ export async function onboardCommand(
631
705
  // is said in one place rather than before PreMan has looked at the code.
632
706
  const checkout = await resolveCheckout(args, resolveApiKey(args));
633
707
 
708
+ // What the app install did, once, shared by the step that needs an app to
709
+ // exist and the step that owns installing one.
710
+ //
711
+ // GitHub's callback ends on a page whose only action is a preman:// link, and
712
+ // the app is the last step of this walk — so a first run always handed GitHub
713
+ // back to a machine with nothing to open it with, and macOS said so in a
714
+ // dialog. The download moves in front of that step when it is needed and stays
715
+ // where it was when it is not: someone who declines the repository never
716
+ // reaches this, and still gets the app at the end.
717
+ //
718
+ // Memoised because it is a hundred megabytes and both callers want the same
719
+ // copy of it. Answers whether there is now an app on this machine, which is
720
+ // the only part either caller can act on.
721
+ let desktopInstall = null;
722
+ const installedNow = (result) => result?.state === "installed" || result?.state === "current";
723
+ const ensureDesktopApp = async ({ onInstalled } = {}) => {
724
+ const already = desktopPrecheck(args);
725
+ if (already?.state === "installed") return true;
726
+ // --no-desktop, or a platform with no build. Nothing to install here, and so
727
+ // nothing that could ever answer a preman:// link either.
728
+ if (already) return false;
729
+ if (!desktopInstall) {
730
+ process.stdout.write(
731
+ "Getting the PreMan app first, so GitHub has somewhere to hand you back to.\n"
732
+ );
733
+ desktopInstall = await runDesktopInstall(args, {
734
+ install: () => installDesktop([...commandArgs], onInstalled ? { onInstalled } : undefined),
735
+ });
736
+ // Handed the session the moment it lands, before anything can launch it.
737
+ // The app reads one only when it starts, and the callback's deep link is a
738
+ // start — without this it opens on a login screen for an account signed in
739
+ // ninety seconds ago.
740
+ if (installedNow(desktopInstall)) writeDesktopSession(creds);
741
+ }
742
+ return installedNow(desktopInstall);
743
+ };
744
+
634
745
  const steps = [
635
746
  {
636
747
  // No question. Everything below is a decision about code PreMan has not
@@ -672,7 +783,7 @@ export async function onboardCommand(
672
783
  return `Connect ${checkout.slug} so PreMan can open pull requests for the ${found} endpoint${found === 1 ? "" : "s"} it just mapped?`;
673
784
  },
674
785
  run: async () => {
675
- await githubCommand(args);
786
+ await githubCommand(args, { ensureDesktopApp });
676
787
  // Finishing the App install is not the same as this repository being
677
788
  // connected: the install shares whichever repositories the customer
678
789
  // picked, which may not include this one. Re-asking is the difference
@@ -783,66 +894,55 @@ export async function onboardCommand(
783
894
  // and someone who already has the app does not watch it download again — both
784
895
  // of which happened when the check came after.
785
896
  const desktop = desktopPrecheck(args);
786
- if (desktop?.state === "installed") {
897
+ if (desktop?.state === "installed" || !desktop) {
787
898
  steps.push({
788
899
  name: "Desktop app",
789
- // Nothing to download, which is the point of this branch. It is not a
790
- // reason to stop at the sentence: the step exists to leave the customer
791
- // looking at PreMan, so the one run with nothing to install was also the
792
- // only run that opened nothing, and it closed by sending someone to a
793
- // website instead of the app already sitting on their machine.
900
+ // No question. See offerDesktop in connect/guide.js for why the ask went
901
+ // away; `--no-desktop` is the way past it.
902
+ //
903
+ // Installing and launching used to be one thing here, which is why the one
904
+ // run with nothing to install was also the only run that opened nothing: it
905
+ // stopped at the sentence and sent someone to a website instead of the app
906
+ // already sitting on their machine. They are two things now, and the
907
+ // install may have happened further up the walk, so this step is the launch
908
+ // and ensureDesktopApp decides whether anything is left to download.
794
909
  run: async () => {
795
- process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
796
- const stopOpening = showOpeningDesktop();
910
+ // Said only when this walk did not just download it. After an install of
911
+ // our own, announcing that it is already installed is the walk telling
912
+ // somebody about their own last thirty seconds.
913
+ if (!desktopInstall && desktopPrecheck(args)?.state === "installed") {
914
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
915
+ }
916
+ let stopOpening = null;
797
917
  try {
918
+ const ready = await ensureDesktopApp({
919
+ onInstalled: () => {
920
+ stopOpening ??= showOpeningDesktop();
921
+ },
922
+ });
923
+ if (!ready) {
924
+ if (desktopInstall?.state === "unsupported") {
925
+ // Not a failure: the download link has already been printed, and
926
+ // the account this step exists to create is finished either way.
927
+ process.stdout.write("Sign in there with the account you just used.\n");
928
+ }
929
+ // A failed install already said so through runDesktopInstall, and its
930
+ // state is what keeps this out of the summary as a skip.
931
+ return desktopInstall || { state: "skipped" };
932
+ }
933
+ stopOpening ??= showOpeningDesktop();
798
934
  return {
935
+ ...(desktopInstall || {}),
936
+ // Onboarding installs *and* hands the fresh session over, so the app
937
+ // opens already signed in rather than on a login screen for the
938
+ // account created ninety seconds ago.
799
939
  appOpened: await showDesktopSignedIn(openDesktopSignedIn, args, creds, stopOpening),
800
940
  };
801
941
  } finally {
802
- stopOpening();
942
+ stopOpening?.();
803
943
  }
804
944
  },
805
945
  });
806
- } else if (!desktop) {
807
- steps.push({
808
- name: "Desktop app",
809
- // No question. See offerDesktop in connect/guide.js for why the ask went
810
- // away; `--no-desktop` is the way past it.
811
- run: () =>
812
- runDesktopInstall(args, {
813
- // Onboarding installs *and* hands the fresh session over, so the app
814
- // opens already signed in rather than on a login screen for the
815
- // account created ninety seconds ago.
816
- install: async () => {
817
- let stopOpening = null;
818
- try {
819
- const installed = await installDesktop([...commandArgs], {
820
- onInstalled: () => {
821
- stopOpening ??= showOpeningDesktop();
822
- },
823
- });
824
- if (installed?.state === "unsupported") {
825
- // Not a failure: the download link has already been printed, and
826
- // the account this step exists to create is finished either way.
827
- process.stdout.write("Sign in there with the account you just used.\n");
828
- return installed;
829
- }
830
- stopOpening ??= showOpeningDesktop();
831
- return {
832
- ...installed,
833
- appOpened: await showDesktopSignedIn(
834
- openDesktopSignedIn,
835
- args,
836
- creds,
837
- stopOpening
838
- ),
839
- };
840
- } finally {
841
- stopOpening?.();
842
- }
843
- },
844
- }),
845
- });
846
946
  }
847
947
 
848
948
  // Outcome per step rather than three lists, so revisiting a step replaces its
package/bin/shared.js CHANGED
@@ -321,6 +321,48 @@ export async function promptText(question) {
321
321
  }
322
322
  }
323
323
 
324
+ /**
325
+ * Ask a question that whatever we are waiting for may answer first.
326
+ *
327
+ * Returns the reply and a `cancel` for when it arrives too late to matter. A
328
+ * plain promptText cannot be raced: abandoning its promise leaves the readline
329
+ * interface open, an open interface holds stdin, and stdin holds the process
330
+ * open with it -- so a wait that ended on its own would sit there afterwards on
331
+ * a question nobody needed answering.
332
+ *
333
+ * Where there is no terminal there is nobody to ask, so the reply is a promise
334
+ * that never settles. A race is then decided entirely by the other side, which
335
+ * is how an unattended run goes on behaving like one.
336
+ */
337
+ export function askWhileWaiting(question) {
338
+ const nobodyThere =
339
+ !process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed;
340
+ if (nobodyThere) return { reply: new Promise(() => {}), cancel: () => {} };
341
+
342
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
343
+ let cancelled = false;
344
+ let settle = null;
345
+ const reply = new Promise((resolve) => {
346
+ settle = (value) => {
347
+ if (settle.done) return;
348
+ settle.done = true;
349
+ resolve(value);
350
+ };
351
+ });
352
+ rl.question(question).then(
353
+ (value) => settle(value.trim()),
354
+ () => settle("")
355
+ );
356
+ rl.once("close", () => settle(cancelled ? null : ""));
357
+ return {
358
+ reply,
359
+ cancel: () => {
360
+ cancelled = true;
361
+ rl.close();
362
+ },
363
+ };
364
+ }
365
+
324
366
  export async function promptSecret(question) {
325
367
  if (!process.stdin.isTTY || !process.stdin.setRawMode) {
326
368
  return promptText(question);
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.1",
3
+ "version": "1.0.3",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,38 +0,0 @@
1
- /**
2
- * Auth flow endpoints (signup, verify OTP, login, resend OTP) with JSON schemas
3
- * for share_endpoints_with_ui / agent-sessions push.
4
- */
5
- export type AuthFlowEndpoint = {
6
- method: string;
7
- path_template: string;
8
- description: string;
9
- tags: string[];
10
- source_file: string;
11
- request_body_schema: Record<string, unknown>;
12
- response_schema: Record<string, unknown>;
13
- mcp_tool?: string;
14
- };
15
- /** Core auth endpoints aligned with routes/auth/routes.py and user_auth_* MCP tools. */
16
- export declare function buildAuthFlowEndpoints(): AuthFlowEndpoint[];
17
- export type ShareAuthFlowResult = {
18
- session_id: string;
19
- url: string;
20
- endpoint_count: number;
21
- user_id: number | null;
22
- auto_discoverable: boolean;
23
- upstream_base_url: string;
24
- endpoints: AuthFlowEndpoint[];
25
- ui: {
26
- url: string;
27
- note: string;
28
- };
29
- related_tools: string[];
30
- };
31
- export declare function shareAuthFlowToUi(opts: {
32
- backendUrl: string;
33
- frontendUrl: string;
34
- upstreamBaseUrl?: string;
35
- sessionId?: string;
36
- intent?: string;
37
- apiKey?: string;
38
- }): Promise<ShareAuthFlowResult>;
@@ -1,170 +0,0 @@
1
- /**
2
- * Auth flow endpoints (signup, verify OTP, login, resend OTP) with JSON schemas
3
- * for share_endpoints_with_ui / agent-sessions push.
4
- */
5
- const EMAIL_PROP = { type: "string", format: "email", description: "User email" };
6
- const PASSWORD_PROP = {
7
- type: "string",
8
- minLength: 6,
9
- description: "Password (min 6 characters on the server)",
10
- };
11
- const OTP_PROP = { type: "string", description: "6-digit code from email" };
12
- const TOKEN_RESPONSE = {
13
- type: "object",
14
- properties: {
15
- access_token: { type: "string", description: "JWT bearer token" },
16
- token_type: { type: "string", enum: ["bearer"] },
17
- user: {
18
- type: "object",
19
- properties: {
20
- id: { type: "string" },
21
- email: { type: "string", format: "email" },
22
- },
23
- required: ["id", "email"],
24
- },
25
- },
26
- required: ["access_token", "token_type", "user"],
27
- };
28
- const OTP_SENT_RESPONSE = {
29
- type: "object",
30
- properties: {
31
- message: { type: "string" },
32
- email_sent: { type: "boolean" },
33
- },
34
- required: ["message", "email_sent"],
35
- };
36
- /** Core auth endpoints aligned with routes/auth/routes.py and user_auth_* MCP tools. */
37
- export function buildAuthFlowEndpoints() {
38
- return [
39
- {
40
- method: "POST",
41
- path_template: "/auth/signup",
42
- description: "Register with email and password. Sends OTP to email; next: verify-otp.",
43
- tags: ["auth", "signup"],
44
- source_file: "routes/auth/routes.py",
45
- mcp_tool: "user_auth_signup",
46
- request_body_schema: {
47
- type: "object",
48
- properties: { email: EMAIL_PROP, password: PASSWORD_PROP },
49
- required: ["email", "password"],
50
- additionalProperties: false,
51
- },
52
- response_schema: {
53
- type: "object",
54
- properties: {
55
- message: { type: "string" },
56
- user_id: { type: "string" },
57
- email: { type: "string", format: "email" },
58
- email_sent: { type: "boolean" },
59
- },
60
- required: ["message", "user_id", "email", "email_sent"],
61
- },
62
- },
63
- {
64
- method: "POST",
65
- path_template: "/auth/verify-otp",
66
- description: "Verify email OTP after signup; returns JWT access_token.",
67
- tags: ["auth", "otp"],
68
- source_file: "routes/auth/routes.py",
69
- mcp_tool: "user_auth_verify_otp",
70
- request_body_schema: {
71
- type: "object",
72
- properties: { email: EMAIL_PROP, otp: OTP_PROP },
73
- required: ["email", "otp"],
74
- additionalProperties: false,
75
- },
76
- response_schema: TOKEN_RESPONSE,
77
- },
78
- {
79
- method: "POST",
80
- path_template: "/auth/login",
81
- description: "Login with email and password. Returns access_token if email is verified.",
82
- tags: ["auth", "login"],
83
- source_file: "routes/auth/routes.py",
84
- mcp_tool: "user_auth_login",
85
- request_body_schema: {
86
- type: "object",
87
- properties: { email: EMAIL_PROP, password: PASSWORD_PROP },
88
- required: ["email", "password"],
89
- additionalProperties: false,
90
- },
91
- response_schema: TOKEN_RESPONSE,
92
- },
93
- {
94
- method: "POST",
95
- path_template: "/auth/resend-otp",
96
- description: "Send (resend) verification OTP to email.",
97
- tags: ["auth", "otp"],
98
- source_file: "routes/auth/routes.py",
99
- mcp_tool: "user_auth_resend_otp",
100
- request_body_schema: {
101
- type: "object",
102
- properties: { email: EMAIL_PROP },
103
- required: ["email"],
104
- additionalProperties: false,
105
- },
106
- response_schema: OTP_SENT_RESPONSE,
107
- },
108
- ];
109
- }
110
- export async function shareAuthFlowToUi(opts) {
111
- const backend = opts.backendUrl.replace(/\/+$/, "");
112
- const frontend = opts.frontendUrl.replace(/\/+$/, "");
113
- const apiKey = opts.apiKey?.trim();
114
- if (!apiKey) {
115
- throw new Error("PreMan authentication required. Run preman_login first so auth-flow sessions can stream to your dashboard.");
116
- }
117
- const upstream = (opts.upstreamBaseUrl || backend).replace(/\/+$/, "") || backend;
118
- const sessionId = opts.sessionId?.trim() || crypto.randomUUID();
119
- const endpoints = buildAuthFlowEndpoints().map((ep) => ({
120
- ...ep,
121
- base_url: upstream,
122
- }));
123
- const resp = await fetch(`${backend}/agent-sessions/${encodeURIComponent(sessionId)}/endpoints`, {
124
- method: "POST",
125
- headers: {
126
- "Content-Type": "application/json",
127
- Accept: "application/json",
128
- Authorization: `Bearer ${apiKey}`,
129
- },
130
- body: JSON.stringify({
131
- endpoints,
132
- upstream_base_url: upstream,
133
- intent: opts.intent || "Auth flow: signup, verify OTP, login, resend OTP",
134
- client_label: "premanmcp",
135
- }),
136
- });
137
- const text = await resp.text();
138
- let body = {};
139
- try {
140
- body = text ? JSON.parse(text) : {};
141
- }
142
- catch {
143
- throw new Error(`Agent session push failed: ${resp.status} ${text.slice(0, 500)}`);
144
- }
145
- if (!resp.ok) {
146
- throw new Error(String(body.detail ?? body.error ?? `Agent session push failed: ${resp.status}`));
147
- }
148
- const sid = String(body.id ?? sessionId);
149
- const url = `${frontend}/try?session=${encodeURIComponent(sid)}`;
150
- return {
151
- session_id: sid,
152
- url,
153
- endpoint_count: Number(body.endpoint_count ?? endpoints.length),
154
- user_id: typeof body.user_id === "number" ? body.user_id : null,
155
- auto_discoverable: Boolean(body.auto_discoverable),
156
- upstream_base_url: upstream,
157
- endpoints: buildAuthFlowEndpoints(),
158
- ui: {
159
- url,
160
- note: "Open in Cursor Agent Browser or the Playground session list. Test signup → verify-otp → login, or resend-otp. " +
161
- "Schemas are prefilled from routes/auth Pydantic models.",
162
- },
163
- related_tools: [
164
- "user_auth_signup",
165
- "user_auth_verify_otp",
166
- "user_auth_login",
167
- "user_auth_resend_otp",
168
- ],
169
- };
170
- }
@@ -1,30 +0,0 @@
1
- export declare const MCP_PREVIEW_RESOURCE_URI = "ui://preman/mcp-preview";
2
- export declare const RESOURCE_URI_META_KEY = "ui/resourceUri";
3
- export declare function escapeHtmlAttr(s: string): string;
4
- export declare function escapeHtmlText(s: string): string;
5
- export interface PreviewTool {
6
- name?: string;
7
- description?: string;
8
- inputSchema?: unknown;
9
- _endpoint_ref?: {
10
- method?: string;
11
- path_template?: string;
12
- tags?: string[];
13
- source?: string;
14
- };
15
- }
16
- export interface PreviewPayload {
17
- intent?: string | string[];
18
- selection_method?: string | string[];
19
- rationale?: string | string[] | Record<string, string>;
20
- selected_count?: number;
21
- spec_preview?: {
22
- upstream_base_url?: string | string[];
23
- tools?: PreviewTool[];
24
- };
25
- }
26
- export declare function buildConversionPanelHtml(data: PreviewPayload): string;
27
- export declare function writeMcpPreviewFile(panelHtml: string): Promise<{
28
- absolutePath: string;
29
- fileUrl: string;
30
- }>;
@@ -1,166 +0,0 @@
1
- /**
2
- * Two-pane HTML for mcp_preview — shared by the MCP stdio server and
3
- * scripts/emit-mcp-preview.mjs (browser tab fallback when Cursor does not render mcp-app).
4
- */
5
- import fs from "node:fs/promises";
6
- import path from "node:path";
7
- import { pathToFileURL } from "node:url";
8
- export const MCP_PREVIEW_RESOURCE_URI = "ui://preman/mcp-preview";
9
- export const RESOURCE_URI_META_KEY = "ui/resourceUri";
10
- export function escapeHtmlAttr(s) {
11
- return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
12
- }
13
- export function escapeHtmlText(s) {
14
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15
- }
16
- export function buildConversionPanelHtml(data) {
17
- const toStr = (v) => {
18
- if (v == null)
19
- return "";
20
- if (Array.isArray(v))
21
- return v.filter((x) => x != null).map((x) => String(x)).join("; ");
22
- if (typeof v === "object") {
23
- try {
24
- return Object.entries(v)
25
- .map(([k, val]) => `${k}: ${val == null ? "" : String(val)}`)
26
- .join(" · ");
27
- }
28
- catch {
29
- try {
30
- return JSON.stringify(v);
31
- }
32
- catch {
33
- return String(v);
34
- }
35
- }
36
- }
37
- return String(v);
38
- };
39
- const intent = toStr(data.intent).trim() || "(unspecified)";
40
- const method = toStr(data.selection_method).trim() || "unknown";
41
- const rationale = toStr(data.rationale).trim();
42
- const upstream = toStr(data.spec_preview?.upstream_base_url).trim();
43
- const tools = Array.isArray(data.spec_preview?.tools) ? data.spec_preview.tools : [];
44
- const selectedCount = typeof data.selected_count === "number" ? data.selected_count : tools.length;
45
- const linkKey = (m, p) => `${(m || "").toUpperCase()} ${p || ""}`.trim();
46
- const endpointRows = tools
47
- .map((t) => {
48
- const ref = t._endpoint_ref ?? {};
49
- const m = (ref.method ?? "").toUpperCase();
50
- const p = ref.path_template ?? "";
51
- const key = linkKey(m, p);
52
- const tags = Array.isArray(ref.tags) && ref.tags.length > 0 ? ref.tags.join(", ") : "";
53
- return `
54
- <div class="row" data-link="${escapeHtmlAttr(key)}">
55
- <div class="row-head">
56
- <span class="method ${escapeHtmlAttr(m)}">${escapeHtmlText(m)}</span>
57
- <span class="path">${escapeHtmlText(p)}</span>
58
- </div>
59
- ${tags ? `<div class="row-meta">tags: ${escapeHtmlText(tags)}</div>` : ""}
60
- </div>`;
61
- })
62
- .join("");
63
- const toolRows = tools
64
- .map((t) => {
65
- const ref = t._endpoint_ref ?? {};
66
- const key = linkKey(ref.method, ref.path_template);
67
- const schemaJson = (() => {
68
- try {
69
- return JSON.stringify(t.inputSchema ?? {}, null, 2);
70
- }
71
- catch {
72
- return "{}";
73
- }
74
- })();
75
- return `
76
- <div class="row" data-link="${escapeHtmlAttr(key)}">
77
- <div class="tool-name">${escapeHtmlText(t.name ?? "(unnamed)")}</div>
78
- ${t.description ? `<div class="tool-desc">${escapeHtmlText(t.description)}</div>` : ""}
79
- <pre class="tool-schema">${escapeHtmlText(schemaJson)}</pre>
80
- </div>`;
81
- })
82
- .join("");
83
- const empty = tools.length === 0
84
- ? `<div class="empty">No matching endpoints. Try a different intent or run <code>verify_endpoints_live</code> first.</div>`
85
- : "";
86
- return `<!DOCTYPE html>
87
- <html lang="en">
88
- <head>
89
- <meta charset="UTF-8" />
90
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
91
- <title>PreMan · MCP Preview</title>
92
- <style>
93
- :root { color-scheme: light dark; --bg:#0d1117; --bg2:#161b22; --border:#30363d; --fg:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --green:#2ea043; --orange:#bf8700; --red:#cf222e; --purple:#8957e5; }
94
- * { margin:0; padding:0; box-sizing:border-box; }
95
- html, body { height:100%; background:var(--bg); color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,sans-serif; font-size:13px; }
96
- body { display:flex; flex-direction:column; overflow:hidden; }
97
- header { padding:10px 14px; border-bottom:1px solid var(--border); background:var(--bg2); flex-shrink:0; }
98
- header h1 { font-size:13px; font-weight:600; color:var(--accent); }
99
- header .meta { font-size:11px; color:var(--muted); margin-top:3px; }
100
- header .meta strong { color:var(--fg); font-weight:600; }
101
- header .rationale { font-size:11px; color:var(--muted); margin-top:5px; font-style:italic; max-width:900px; }
102
- .columns { flex:1; display:grid; grid-template-columns:1fr 1fr; gap:1px; background:var(--border); overflow:hidden; min-height:0; }
103
- .col { background:var(--bg); overflow-y:auto; }
104
- .col-header { padding:7px 12px; font-size:10px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:var(--muted); border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg2); z-index:1; }
105
- .row { padding:9px 12px; border-bottom:1px solid var(--border); transition:background 0.1s; }
106
- .row:last-child { border-bottom:none; }
107
- .row:hover, .row.linked { background:rgba(88,166,255,0.08); }
108
- .row-head { display:flex; align-items:center; gap:8px; }
109
- .row-meta { font-size:10px; color:var(--muted); margin-top:3px; padding-left:56px; }
110
- .method { display:inline-block; min-width:48px; text-align:center; padding:2px 6px; border-radius:3px; font-weight:700; font-size:10px; color:white; font-family:SFMono-Regular,Consolas,monospace; }
111
- .method.GET{background:var(--accent);} .method.POST{background:var(--green);} .method.PATCH{background:var(--orange);} .method.PUT{background:var(--purple);} .method.DELETE{background:var(--red);}
112
- .path { font-family:SFMono-Regular,Consolas,monospace; font-size:12px; word-break:break-all; }
113
- .tool-name { font-family:SFMono-Regular,Consolas,monospace; font-size:12px; font-weight:600; color:var(--accent); }
114
- .tool-desc { font-size:11px; color:var(--muted); margin-top:3px; line-height:1.4; }
115
- .tool-schema { font-family:SFMono-Regular,Consolas,monospace; font-size:10px; background:var(--bg2); padding:6px 8px; border-radius:3px; margin-top:6px; white-space:pre-wrap; word-break:break-all; max-height:120px; overflow-y:auto; line-height:1.4; color:var(--muted); }
116
- .empty { padding:24px; text-align:center; color:var(--muted); font-style:italic; }
117
- .empty code { font-family:SFMono-Regular,Consolas,monospace; color:var(--accent); background:var(--bg2); padding:1px 5px; border-radius:3px; font-style:normal; }
118
- footer { padding:8px 14px; border-top:1px solid var(--border); background:var(--bg2); display:flex; gap:10px; align-items:center; flex-shrink:0; font-size:11px; color:var(--muted); }
119
- footer .upstream { font-family:SFMono-Regular,Consolas,monospace; color:var(--accent); }
120
- footer .deploy-hint { margin-left:auto; }
121
- footer .deploy-hint code { font-family:SFMono-Regular,Consolas,monospace; color:var(--fg); background:var(--bg); padding:2px 6px; border-radius:3px; }
122
- </style>
123
- </head>
124
- <body>
125
- <header>
126
- <h1>API → MCP Conversion preview</h1>
127
- <div class="meta">
128
- Intent: <strong>${escapeHtmlText(intent)}</strong> · Selected <strong>${selectedCount}</strong> endpoint${selectedCount === 1 ? "" : "s"} · Method: ${escapeHtmlText(method)}
129
- </div>
130
- ${rationale ? `<div class="rationale">${escapeHtmlText(rationale)}</div>` : ""}
131
- </header>
132
- <main class="columns">
133
- <div class="col">
134
- <div class="col-header">Your API endpoints (${tools.length})</div>
135
- ${endpointRows}${tools.length === 0 ? empty : ""}
136
- </div>
137
- <div class="col">
138
- <div class="col-header">Generated MCP tools (${tools.length})</div>
139
- ${toolRows}${tools.length === 0 ? empty : ""}
140
- </div>
141
- </main>
142
- <footer>
143
- <span>Upstream: <span class="upstream">${escapeHtmlText(upstream || "(not set)")}</span></span>
144
- <span class="deploy-hint">Next: ask the agent to call <code>mcp_deploy</code></span>
145
- </footer>
146
- <script>
147
- document.querySelectorAll('.row[data-link]').forEach(row => {
148
- const key = row.getAttribute('data-link');
149
- if (!key) return;
150
- const matches = () => document.querySelectorAll('[data-link="' + CSS.escape(key) + '"]');
151
- row.addEventListener('mouseenter', () => matches().forEach(r => r.classList.add('linked')));
152
- row.addEventListener('mouseleave', () => matches().forEach(r => r.classList.remove('linked')));
153
- });
154
- </script>
155
- </body>
156
- </html>`;
157
- }
158
- export async function writeMcpPreviewFile(panelHtml) {
159
- const outPath = path.join(process.cwd(), "preman-mcp", "mcp-preview-last.html");
160
- await fs.mkdir(path.dirname(outPath), { recursive: true });
161
- await fs.writeFile(outPath, panelHtml, "utf8");
162
- return {
163
- absolutePath: outPath,
164
- fileUrl: pathToFileURL(outPath).href,
165
- };
166
- }
@@ -1,11 +0,0 @@
1
- /**
2
- * App user auth (JWT) — tools that call the FastAPI backend at PREMAN_BACKEND /auth/*.
3
- * Separate from preman_login (device flow + pm_live_ API key). No API key is required
4
- * for these tools; for JWT-protected routes, pass the access_token from login/verify.
5
- */
6
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
- /**
8
- * Register MCP tools for routes/auth (email, OTP, password, JWT).
9
- * Proxies to the same process as preman-local (PREMAN_BACKEND, e.g. http://127.0.0.1:8000).
10
- */
11
- export declare function registerUserAuthFlowTools(server: McpServer, backendUrl: string): void;
@@ -1,279 +0,0 @@
1
- import { z } from "zod";
2
- function toolError(message, code = "backend_error", hints) {
3
- const payload = { error: message, error_code: code };
4
- if (hints)
5
- payload._agent_hints = hints;
6
- return {
7
- content: [{ type: "text", text: JSON.stringify(payload) }],
8
- isError: true,
9
- };
10
- }
11
- function jsonOk(obj) {
12
- return { content: [{ type: "text", text: JSON.stringify(obj) }] };
13
- }
14
- function normalizeBase(backendUrl) {
15
- return backendUrl.replace(/\/+$/, "");
16
- }
17
- async function callAuthJson(base, method, path, opts) {
18
- const url = new URL(path.startsWith("/") ? path.slice(1) : path, `${base}/`);
19
- if (opts?.query) {
20
- for (const [k, v] of Object.entries(opts.query)) {
21
- if (v != null && v !== "")
22
- url.searchParams.set(k, v);
23
- }
24
- }
25
- const headers = { Accept: "application/json" };
26
- const hasBody = opts?.json != null && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE");
27
- if (hasBody) {
28
- headers["Content-Type"] = "application/json";
29
- }
30
- if (opts?.token) {
31
- headers.Authorization = `Bearer ${opts.token.trim()}`;
32
- }
33
- const init = { method, headers };
34
- if (hasBody && opts?.json) {
35
- init.body = JSON.stringify(opts.json);
36
- }
37
- const resp = await fetch(url, init);
38
- const text = await resp.text();
39
- let parsed;
40
- try {
41
- parsed = text ? JSON.parse(text) : {};
42
- }
43
- catch {
44
- parsed = { raw: text };
45
- }
46
- const body = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
47
- ? parsed
48
- : { value: parsed };
49
- return {
50
- status_code: resp.status,
51
- ok: resp.ok,
52
- ...body,
53
- };
54
- }
55
- /**
56
- * Register MCP tools for routes/auth (email, OTP, password, JWT).
57
- * Proxies to the same process as preman-local (PREMAN_BACKEND, e.g. http://127.0.0.1:8000).
58
- */
59
- export function registerUserAuthFlowTools(server, backendUrl) {
60
- const base = normalizeBase(backendUrl);
61
- server.tool("user_auth_start_signup", "Start signup with email only. Sends an OTP; next call user_auth_set_password with email, OTP, and new password. Uses POST /auth/start-signup on PREMAN_BACKEND (no API key). If the backend does not support this endpoint yet, use user_auth_signup instead.", {
62
- email: z.string().describe("User email"),
63
- }, async (args) => {
64
- try {
65
- const r = await callAuthJson(base, "POST", "/auth/start-signup", {
66
- json: { email: args.email },
67
- });
68
- if (!r.ok) {
69
- if (r.status_code === 404) {
70
- return toolError("This backend does not support email-only signup yet. Use user_auth_signup with email and password, then user_auth_verify_otp.", "backend_error", {
71
- next_actions: ["Call user_auth_signup with email and password.", "Then call user_auth_verify_otp with the email code."],
72
- related_tools: ["user_auth_signup", "user_auth_verify_otp"],
73
- });
74
- }
75
- return toolError(String(r.detail ?? r.message ?? "start signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
76
- next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
77
- related_tools: ["user_auth_set_password", "user_auth_login"],
78
- });
79
- }
80
- return jsonOk(r);
81
- }
82
- catch (e) {
83
- const m = e instanceof Error ? e.message : String(e);
84
- return toolError(m, "backend_error", {
85
- next_actions: ["Ensure PREMAN_BACKEND is running and JWT_SECRET is set on the server."],
86
- });
87
- }
88
- });
89
- server.tool("user_auth_signup", "Register with email and password. Sends an OTP; next call user_auth_verify_otp. Uses POST /auth/signup on PREMAN_BACKEND (no API key).", {
90
- email: z.string().describe("User email"),
91
- password: z.string().describe("Password (min 6 characters on the server)"),
92
- }, async (args) => {
93
- try {
94
- const r = await callAuthJson(base, "POST", "/auth/signup", {
95
- json: { email: args.email, password: args.password },
96
- });
97
- if (!r.ok) {
98
- return toolError(String(r.detail ?? r.message ?? "signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
99
- next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
100
- related_tools: ["user_auth_verify_otp", "user_auth_login"],
101
- });
102
- }
103
- return jsonOk(r);
104
- }
105
- catch (e) {
106
- const m = e instanceof Error ? e.message : String(e);
107
- return toolError(m, "backend_error", {
108
- next_actions: ["Ensure PREMAN_BACKEND is running and JWT_SECRET is set on the server."],
109
- });
110
- }
111
- });
112
- server.tool("user_auth_verify_otp", "Verify the email OTP and receive access_token (JWT). POST /auth/verify-otp.", { email: z.string(), otp: z.string().describe("6-digit code from email") }, async (args) => {
113
- try {
114
- const r = await callAuthJson(base, "POST", "/auth/verify-otp", {
115
- json: { email: args.email, otp: args.otp },
116
- });
117
- if (!r.ok) {
118
- return toolError(String(r.detail ?? "verify failed"), "auth_required", {
119
- next_actions: ["Request a new code with user_auth_resend_otp if expired."],
120
- related_tools: ["user_auth_resend_otp", "user_auth_signup"],
121
- });
122
- }
123
- return jsonOk(r);
124
- }
125
- catch (e) {
126
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
127
- }
128
- });
129
- server.tool("user_auth_login", "Login with email and password. Returns access_token if email is verified. POST /auth/login.", { email: z.string(), password: z.string() }, async (args) => {
130
- try {
131
- const r = await callAuthJson(base, "POST", "/auth/login", {
132
- json: { email: args.email, password: args.password },
133
- });
134
- if (!r.ok) {
135
- const sc = r.status_code;
136
- const code = sc === 403 || sc === 401 ? "auth_required" : "backend_error";
137
- return toolError(String(r.detail ?? "login failed"), code, {
138
- next_actions: [
139
- "If 403 email not verified: use user_auth_verify_otp or user_auth_resend_otp.",
140
- "If 403 migrated account: use user_auth_forgot_password or user_auth_set_password flow.",
141
- ],
142
- related_tools: ["user_auth_verify_otp", "user_auth_needs_password", "user_auth_set_password"],
143
- });
144
- }
145
- return jsonOk(r);
146
- }
147
- catch (e) {
148
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
149
- }
150
- });
151
- server.tool("user_auth_needs_password", "Check if an account must set a password (e.g. migrated user). GET /auth/needs-password?email=", { email: z.string().optional().describe("Email to check; omit to return false/false from server") }, async (args) => {
152
- try {
153
- const r = await callAuthJson(base, "GET", "/auth/needs-password", {
154
- query: { email: args.email },
155
- });
156
- if (!r.ok) {
157
- return toolError(String(r.detail ?? "request failed"), "backend_error");
158
- }
159
- return jsonOk(r);
160
- }
161
- catch (e) {
162
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
163
- }
164
- });
165
- server.tool("user_auth_resend_otp", "Resend verification OTP. POST /auth/resend-otp with { email }.", { email: z.string() }, async (args) => {
166
- try {
167
- const r = await callAuthJson(base, "POST", "/auth/resend-otp", {
168
- json: { email: args.email },
169
- });
170
- if (!r.ok) {
171
- return toolError(String(r.detail ?? "resend failed"), "invalid_input");
172
- }
173
- return jsonOk(r);
174
- }
175
- catch (e) {
176
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
177
- }
178
- });
179
- server.tool("user_auth_forgot_password", "Request password reset OTP. POST /auth/forgot-password with { email }.", { email: z.string() }, async (args) => {
180
- try {
181
- const r = await callAuthJson(base, "POST", "/auth/forgot-password", {
182
- json: { email: args.email },
183
- });
184
- if (!r.ok) {
185
- return toolError(String(r.detail ?? "forgot failed"), "backend_error");
186
- }
187
- return jsonOk(r);
188
- }
189
- catch (e) {
190
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
191
- }
192
- });
193
- server.tool("user_auth_set_password", "Set a new password using OTP (migrated / forgot flow). Returns access_token. POST /auth/set-password.", {
194
- email: z.string(),
195
- otp: z.string(),
196
- new_password: z.string().min(6),
197
- }, async (args) => {
198
- try {
199
- const r = await callAuthJson(base, "POST", "/auth/set-password", {
200
- json: {
201
- email: args.email,
202
- otp: args.otp,
203
- new_password: args.new_password,
204
- },
205
- });
206
- if (!r.ok) {
207
- return toolError(String(r.detail ?? "set password failed"), "auth_required", {
208
- next_actions: ["Request a new OTP with user_auth_forgot_password or user_auth_resend_otp."],
209
- related_tools: ["user_auth_forgot_password"],
210
- });
211
- }
212
- return jsonOk(r);
213
- }
214
- catch (e) {
215
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
216
- }
217
- });
218
- server.tool("user_auth_me", "Current user profile (JWT). GET /auth/me with Authorization: Bearer access_token from login/verify.", {
219
- access_token: z.string().describe("JWT from user_auth_login or user_auth_verify_otp"),
220
- }, async (args) => {
221
- try {
222
- const r = await callAuthJson(base, "GET", "/auth/me", {
223
- token: args.access_token,
224
- });
225
- if (!r.ok) {
226
- return toolError(String(r.detail ?? "unauthorized"), "auth_required", {
227
- next_actions: ["Call user_auth_login to obtain a fresh access_token."],
228
- related_tools: ["user_auth_login"],
229
- });
230
- }
231
- return jsonOk(r);
232
- }
233
- catch (e) {
234
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
235
- }
236
- });
237
- server.tool("user_auth_change_password", "Change password for the signed-in user. POST /auth/change-password with JWT.", {
238
- access_token: z.string(),
239
- current_password: z.string(),
240
- new_password: z.string().min(6),
241
- }, async (args) => {
242
- try {
243
- const r = await callAuthJson(base, "POST", "/auth/change-password", {
244
- token: args.access_token,
245
- json: {
246
- current_password: args.current_password,
247
- new_password: args.new_password,
248
- },
249
- });
250
- if (!r.ok) {
251
- return toolError(String(r.detail ?? "change password failed"), r.status_code === 401 ? "auth_required" : "invalid_input", {
252
- related_tools: ["user_auth_login", "user_auth_me"],
253
- });
254
- }
255
- return jsonOk(r);
256
- }
257
- catch (e) {
258
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
259
- }
260
- });
261
- server.tool("user_auth_delete_account", "Delete the current account. DELETE /auth/me with JWT. Irreversible.", {
262
- access_token: z.string().describe("JWT from user_auth_login or user_auth_verify_otp"),
263
- }, async (args) => {
264
- try {
265
- const r = await callAuthJson(base, "DELETE", "/auth/me", {
266
- token: args.access_token,
267
- });
268
- if (!r.ok) {
269
- return toolError(String(r.detail ?? "delete failed"), "auth_required", {
270
- related_tools: ["user_auth_login"],
271
- });
272
- }
273
- return jsonOk(r);
274
- }
275
- catch (e) {
276
- return toolError(e instanceof Error ? e.message : String(e), "backend_error");
277
- }
278
- });
279
- }