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 +7 -17
- package/bin/cli.js +18 -2
- package/bin/desktop.js +78 -3
- package/bin/integrations.js +120 -60
- package/bin/shared.js +25 -2
- package/bin/status.js +13 -3
- package/bin/tests.js +8 -12
- package/bin/verify.js +6 -2
- 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
|
@@ -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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
// tab into the hosted dashboard
|
|
271
|
-
// environment owns the App's setup URL.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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);
|
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
|
-
|
|
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
|
|