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 +7 -17
- package/bin/cli.js +18 -2
- package/bin/desktop.js +78 -3
- package/bin/integrations.js +20 -2
- package/bin/shared.js +25 -2
- package/bin/status.js +13 -3
- package/bin/tests.js +8 -12
- package/package.json +1 -1
package/bin/api_tools.js
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
|
|
12
|
-
import {
|
|
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 {
|
|
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
|
-
{
|
|
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));
|
package/bin/integrations.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
436
|
-
|
|
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 {
|
|
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
|
-
|
|
229
|
-
|
|
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 {
|
|
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
|
-
|
|
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);
|