premanmcp 1.0.0 → 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/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));
@@ -493,14 +493,32 @@ async function showDesktopSignedIn(openDesktopSignedIn, args, creds, stopOpening
493
493
  // The same --dest the install honoured, or the launch would look for the app
494
494
  // somewhere it was never copied to.
495
495
  const destination = args.value("--dest", "/Applications");
496
- 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 });
497
501
  stopOpening?.();
502
+ const email = String(creds?.user_email || creds?.user?.email || "").trim();
498
503
  if (opened.state === "opened-signed-in") {
499
- 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
+ );
500
509
  } else if (opened.state === "not-installed") {
501
510
  process.stdout.write(
502
511
  `PreMan is not in ${destination} yet \u2014 open it once installed and sign in.\n`
503
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
+ );
504
522
  } else {
505
523
  // Either the session could not be handed over or this app is too old to take
506
524
  // it up. Say so rather than let the customer wonder why they are looking at
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {