premanmcp 0.16.3 → 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/README.md +40 -371
- package/bin/api_tools.js +37 -8
- package/bin/cli.js +19 -3
- package/bin/desktop.js +233 -25
- package/bin/integrations.js +27 -3
- package/bin/link.js +3 -18
- package/bin/shared.js +25 -2
- package/bin/status.js +13 -3
- package/bin/tests.js +9 -13
- package/bin/verify.js +2 -5
- package/dist/server.d.ts +7 -2
- package/dist/server.js +109 -1843
- package/package.json +10 -15
package/bin/desktop.js
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `preman install-desktop` — download and install the PreMan desktop app.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ## One release, resolved once
|
|
5
|
+
*
|
|
6
|
+
* An install reads three things from the release: the version to report, the
|
|
7
|
+
* disk image, and the checksum to verify it against. Each used to be a separate
|
|
8
|
+
* request to `/releases/latest/…`, which is three independent answers to "what
|
|
9
|
+
* is the newest release" taken minutes apart, across a 100 MB download. Desktop
|
|
10
|
+
* ships several times a day, so those answers disagreed regularly, and every way
|
|
11
|
+
* they could disagree was bad:
|
|
12
|
+
*
|
|
13
|
+
* * The version was read *before* the download, so a release landing mid-fetch
|
|
14
|
+
* meant reporting `Installed PreMan 0.3.170` over a copy of 0.3.172 — the
|
|
15
|
+
* install genuinely was not the version it claimed.
|
|
16
|
+
* * The manifest was read *after*, so the same race made the checksum belong to
|
|
17
|
+
* a different build than the image. That is a hard `size mismatch`, and the
|
|
18
|
+
* 100 MB the user just waited for is discarded and fetched again.
|
|
19
|
+
* * `electron-updater` then found a newer build on first launch and downloaded
|
|
20
|
+
* a third copy, this time a 97 MB ZIP.
|
|
21
|
+
*
|
|
22
|
+
* So the tag is resolved once, up front, and every later request is pinned to
|
|
23
|
+
* it. A pinned install is reproducible, its reported version is the one on disk,
|
|
24
|
+
* and its checksum describes the bytes it actually fetched. Version-less aliases
|
|
25
|
+
* remain the fallback for when the tag cannot be resolved, which is the only
|
|
26
|
+
* reason they still exist here: they are what keeps a URL valid across releases,
|
|
27
|
+
* at the cost of not saying which release it is.
|
|
8
28
|
*
|
|
9
29
|
* Arch detection is trivial here in a way it is not on the website: `uname -m`
|
|
10
30
|
* is authoritative, so there is no need for the WebGL guessing the download page
|
|
@@ -32,6 +52,7 @@ export const DESKTOP_HELP = `
|
|
|
32
52
|
Install-desktop options:
|
|
33
53
|
--arch <arm64|x64> Override architecture detection
|
|
34
54
|
--dest <dir> Install directory. Defaults to /Applications
|
|
55
|
+
--force Reinstall even when the latest release is already installed
|
|
35
56
|
--keep-dmg Leave the downloaded disk image in place
|
|
36
57
|
--print-url Print the resolved download URL and exit
|
|
37
58
|
`;
|
|
@@ -57,10 +78,78 @@ export function installedAppPath(destination = "/Applications") {
|
|
|
57
78
|
return path.join(destination, APP_NAME);
|
|
58
79
|
}
|
|
59
80
|
|
|
81
|
+
/**
|
|
82
|
+
* `CFBundleShortVersionString` of the installed app, or `""`.
|
|
83
|
+
*
|
|
84
|
+
* Lives here rather than in link.js, which is where it started, because the
|
|
85
|
+
* installer needs it too -- to answer "is this already the release I am about to
|
|
86
|
+
* download" -- and link.js already imports this module, so the dependency only
|
|
87
|
+
* points one way from here.
|
|
88
|
+
*/
|
|
89
|
+
export function installedDesktopVersion(destination = "/Applications") {
|
|
90
|
+
try {
|
|
91
|
+
const plist = path.join(installedAppPath(destination), "Contents", "Info.plist");
|
|
92
|
+
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
|
|
93
|
+
readFileSync(plist, "utf8")
|
|
94
|
+
);
|
|
95
|
+
return match ? match[1].trim() : "";
|
|
96
|
+
} catch {
|
|
97
|
+
// Not installed, or a bundle we cannot read.
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
export function desktopAppInstalled(destination = "/Applications") {
|
|
61
103
|
return process.platform === "darwin" && existsSync(installedAppPath(destination));
|
|
62
104
|
}
|
|
63
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
|
+
|
|
64
153
|
/**
|
|
65
154
|
* Hand the account the CLI just signed in to over to the desktop app.
|
|
66
155
|
*
|
|
@@ -123,28 +212,56 @@ export function clearDesktopSession() {
|
|
|
123
212
|
* all would otherwise be reported as signed in while the customer looks at a
|
|
124
213
|
* login screen. The file is left behind on timeout -- it expires on its own, and
|
|
125
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.
|
|
126
221
|
*/
|
|
127
222
|
export async function openDesktopSignedIn(
|
|
128
223
|
creds,
|
|
129
|
-
{
|
|
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
|
+
} = {}
|
|
130
234
|
) {
|
|
131
235
|
if (!desktopAppInstalled(destination)) {
|
|
132
236
|
return { state: "not-installed" };
|
|
133
237
|
}
|
|
238
|
+
const wasRunning = isRunning(destination);
|
|
239
|
+
let restarted = false;
|
|
240
|
+
if (wasRunning && restartIfRunning) {
|
|
241
|
+
restarted = (await quit({ destination, sleep })) === "quit";
|
|
242
|
+
}
|
|
134
243
|
const handoff = writeDesktopSession(creds);
|
|
135
244
|
// The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
|
|
136
245
|
// which may well pick a different copy than the one just installed.
|
|
137
246
|
spawn("open", ["-a", installedAppPath(destination)], { stdio: "ignore", detached: true }).unref();
|
|
138
247
|
if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
|
|
139
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
|
+
|
|
140
257
|
const deadline = Date.now() + waitMs;
|
|
141
258
|
while (Date.now() < deadline) {
|
|
142
259
|
await sleep(500);
|
|
143
260
|
if (!existsSync(DESKTOP_SESSION_FILE)) {
|
|
144
|
-
return { state: "opened-signed-in", handoff: handoff.state };
|
|
261
|
+
return { state: "opened-signed-in", handoff: handoff.state, restarted };
|
|
145
262
|
}
|
|
146
263
|
}
|
|
147
|
-
return { state: "opened-not-adopted", handoff: handoff.state };
|
|
264
|
+
return { state: "opened-not-adopted", handoff: handoff.state, restarted };
|
|
148
265
|
}
|
|
149
266
|
|
|
150
267
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -174,10 +291,29 @@ export function printPlayground(url) {
|
|
|
174
291
|
}
|
|
175
292
|
}
|
|
176
293
|
|
|
177
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Where to fetch the disk image for `arch`.
|
|
296
|
+
*
|
|
297
|
+
* With a tag, the release's own versioned asset -- one exact build, which cannot
|
|
298
|
+
* become a different one between resolving it and finishing the download.
|
|
299
|
+
* Without one, the version-less alias, which always resolves to something but
|
|
300
|
+
* will not say what.
|
|
301
|
+
*/
|
|
302
|
+
export function dmgUrl(arch, tag = null) {
|
|
303
|
+
if (tag) return `${RELEASES_BASE}/download/${tag}/PreMan-${versionFromTag(tag)}-${arch}.dmg`;
|
|
178
304
|
return `${RELEASES_BASE}/latest/download/PreMan-mac-${arch}.dmg`;
|
|
179
305
|
}
|
|
180
306
|
|
|
307
|
+
/** Where to fetch the update manifest, pinned the same way. */
|
|
308
|
+
export function manifestUrl(tag = null) {
|
|
309
|
+
if (tag) return `${RELEASES_BASE}/download/${tag}/latest-mac.yml`;
|
|
310
|
+
return `${RELEASES_BASE}/latest/download/latest-mac.yml`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function versionFromTag(tag) {
|
|
314
|
+
return String(tag || "").replace(/^v/i, "");
|
|
315
|
+
}
|
|
316
|
+
|
|
181
317
|
export function detectArch() {
|
|
182
318
|
const machine = os.machine ? os.machine() : os.arch();
|
|
183
319
|
if (machine === "arm64" || machine === "aarch64") return "arm64";
|
|
@@ -201,16 +337,46 @@ async function fetchLatestRelease() {
|
|
|
201
337
|
}
|
|
202
338
|
}
|
|
203
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The tag `/releases/latest` currently points at, read out of its redirect.
|
|
342
|
+
*
|
|
343
|
+
* A second way to ask, because the first one is rate limited: the GitHub API
|
|
344
|
+
* allows 60 unauthenticated calls an hour per address, which a shared office or
|
|
345
|
+
* a CI runner can exhaust without doing anything unusual. Downloads are not
|
|
346
|
+
* limited, and the alias redirects to the release it resolved -- so asking for
|
|
347
|
+
* the alias and declining to follow the redirect yields the same tag the
|
|
348
|
+
* download itself would have used, which is exactly the one worth pinning.
|
|
349
|
+
*/
|
|
350
|
+
export function tagFromRedirect(location) {
|
|
351
|
+
return String(location || "").match(/\/releases\/download\/([^/]+)\//)?.[1] ?? null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function resolveReleaseTag(arch) {
|
|
355
|
+
const release = await fetchLatestRelease();
|
|
356
|
+
if (release?.tag_name) return String(release.tag_name);
|
|
357
|
+
try {
|
|
358
|
+
const resp = await fetch(dmgUrl(arch), {
|
|
359
|
+
method: "HEAD",
|
|
360
|
+
redirect: "manual",
|
|
361
|
+
headers: { "User-Agent": "premanmcp-cli" },
|
|
362
|
+
});
|
|
363
|
+
return tagFromRedirect(resp.headers.get("location"));
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
204
369
|
/**
|
|
205
370
|
* The sha512 and size electron-builder published for this architecture's disk
|
|
206
371
|
* image, or null if the manifest could not be read.
|
|
207
372
|
*
|
|
208
373
|
* `latest-mac.yml` is the only integrity signal available without our own signing
|
|
209
374
|
* infrastructure. It keys entries by the *versioned* filename
|
|
210
|
-
* (`PreMan-0.3.79-arm64.dmg`),
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
375
|
+
* (`PreMan-0.3.79-arm64.dmg`), so the version is read out of the manifest to
|
|
376
|
+
* find the right entry. On a pinned install that version is the tag's, and the
|
|
377
|
+
* asset named in the manifest is the one that was downloaded; on a fallback
|
|
378
|
+
* install it describes the alias's twin, which the size check confirms before
|
|
379
|
+
* the digest is trusted.
|
|
214
380
|
*/
|
|
215
381
|
export function parseMacManifest(text, arch) {
|
|
216
382
|
const version = text.match(/^version:\s*(\S+)/m)?.[1];
|
|
@@ -231,9 +397,9 @@ export function parseMacManifest(text, arch) {
|
|
|
231
397
|
return null;
|
|
232
398
|
}
|
|
233
399
|
|
|
234
|
-
async function expectedDigest(arch) {
|
|
400
|
+
async function expectedDigest(arch, tag = null) {
|
|
235
401
|
try {
|
|
236
|
-
const resp = await fetch(
|
|
402
|
+
const resp = await fetch(manifestUrl(tag), {
|
|
237
403
|
headers: { "User-Agent": "premanmcp-cli" },
|
|
238
404
|
});
|
|
239
405
|
if (!resp.ok) return null;
|
|
@@ -367,18 +533,41 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
|
|
|
367
533
|
if (!["arm64", "x64"].includes(arch)) {
|
|
368
534
|
throw new Error(`unsupported --arch ${arch}; expected arm64 or x64`);
|
|
369
535
|
}
|
|
370
|
-
const url = dmgUrl(arch);
|
|
371
|
-
|
|
372
536
|
if (args.has("--print-url")) {
|
|
373
|
-
|
|
374
|
-
|
|
537
|
+
// The alias, and no network call: this is asked for by scripts and by people
|
|
538
|
+
// who want the URL that keeps working, not the one for today's build.
|
|
539
|
+
const alias = dmgUrl(arch);
|
|
540
|
+
process.stdout.write(`${alias}\n`);
|
|
541
|
+
return { state: "printed", url: alias, arch };
|
|
375
542
|
}
|
|
376
543
|
|
|
377
|
-
|
|
378
|
-
|
|
544
|
+
// Resolved once. Everything below is pinned to this tag, so the version
|
|
545
|
+
// reported, the image downloaded and the checksum verified against it all
|
|
546
|
+
// describe the same release even if another ships while this runs.
|
|
547
|
+
const tag = await resolveReleaseTag(arch);
|
|
548
|
+
const url = dmgUrl(arch, tag);
|
|
549
|
+
const version = tag ? versionFromTag(tag) : "latest";
|
|
379
550
|
const destination = args.value("--dest", "/Applications");
|
|
380
551
|
|
|
381
|
-
|
|
552
|
+
const installed = installedDesktopVersion(destination);
|
|
553
|
+
if (tag && installed && installed === version && !args.has("--force")) {
|
|
554
|
+
// Re-fetching 100 MB to arrive at the bytes already on disk is the most
|
|
555
|
+
// literal form of the "it downloads the update again" complaint, and it is
|
|
556
|
+
// what this command did every time it was run.
|
|
557
|
+
process.stdout.write(
|
|
558
|
+
`PreMan ${installed} is already installed and is the latest release.\n` +
|
|
559
|
+
`Nothing to do. Use --force to reinstall.\n`
|
|
560
|
+
);
|
|
561
|
+
return { state: "current", version: installed, arch, path: installedAppPath(destination) };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (installed === version) {
|
|
565
|
+
process.stdout.write(`Reinstalling PreMan ${version} (${arch})…\n`);
|
|
566
|
+
} else if (installed) {
|
|
567
|
+
process.stdout.write(`Replacing PreMan ${installed} with ${version} (${arch})…\n`);
|
|
568
|
+
} else {
|
|
569
|
+
process.stdout.write(`Downloading PreMan ${version} (${arch})…\n`);
|
|
570
|
+
}
|
|
382
571
|
const workDir = mkdtempSync(path.join(os.tmpdir(), "preman-desktop-"));
|
|
383
572
|
const dmgPath = path.join(workDir, `PreMan-mac-${arch}.dmg`);
|
|
384
573
|
let mounted = null;
|
|
@@ -386,8 +575,21 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
|
|
|
386
575
|
try {
|
|
387
576
|
const progress = progressLine();
|
|
388
577
|
let bytes;
|
|
578
|
+
let pinned = tag;
|
|
389
579
|
try {
|
|
390
|
-
|
|
580
|
+
try {
|
|
581
|
+
bytes = await download(url, dmgPath, progress.tick);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
// Pinning names an asset instead of an alias, so it is the one thing
|
|
584
|
+
// here that can be wrong about a release rather than merely unlucky --
|
|
585
|
+
// an older build that predates the versioned artifact, or a rename.
|
|
586
|
+
// Falling back to the alias costs the guarantees above and is still an
|
|
587
|
+
// install; failing outright would make this worse than what it replaced.
|
|
588
|
+
if (!pinned || !/download failed: 404/.test(String(err?.message))) throw err;
|
|
589
|
+
process.stdout.write(` ${tag} has no versioned image; using the latest alias\n`);
|
|
590
|
+
pinned = null;
|
|
591
|
+
bytes = await download(dmgUrl(arch), dmgPath, progress.tick);
|
|
592
|
+
}
|
|
391
593
|
} finally {
|
|
392
594
|
// Cleared even when the download throws, or the error prints onto the
|
|
393
595
|
// half-drawn progress line.
|
|
@@ -395,7 +597,9 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
|
|
|
395
597
|
}
|
|
396
598
|
process.stdout.write(` ${(bytes / BYTES_PER_MB).toFixed(1)} MB\n`);
|
|
397
599
|
|
|
398
|
-
|
|
600
|
+
// Read from the same place the image came from, pinned or not, so the two
|
|
601
|
+
// can never describe different releases.
|
|
602
|
+
const expected = await expectedDigest(arch, pinned);
|
|
399
603
|
if (expected) {
|
|
400
604
|
if (expected.size && expected.size !== bytes) {
|
|
401
605
|
throw new Error(
|
|
@@ -425,8 +629,12 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
|
|
|
425
629
|
run("cp", ["-R", source, target]);
|
|
426
630
|
chmodSync(target, 0o755);
|
|
427
631
|
|
|
428
|
-
|
|
429
|
-
|
|
632
|
+
// The manifest's version describes bytes whose size and digest were just
|
|
633
|
+
// checked against it, which makes it a better answer than the tag resolved
|
|
634
|
+
// before any of this ran -- and the only honest one on the alias fallback.
|
|
635
|
+
const installedVersion = expected?.version || installedDesktopVersion(destination) || version;
|
|
636
|
+
const result = { state: "installed", version: installedVersion, arch, path: target };
|
|
637
|
+
process.stdout.write(`\nInstalled PreMan ${installedVersion} to ${target}\n\n`);
|
|
430
638
|
onInstalled?.(result);
|
|
431
639
|
return result;
|
|
432
640
|
} finally {
|
package/bin/integrations.js
CHANGED
|
@@ -265,9 +265,15 @@ export async function githubCommand(args) {
|
|
|
265
265
|
|
|
266
266
|
const seen = new Set((await listRepos()).map((r) => r.id));
|
|
267
267
|
|
|
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.
|
|
268
274
|
const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
|
|
269
275
|
token,
|
|
270
|
-
json: {},
|
|
276
|
+
json: { return_to: "desktop" },
|
|
271
277
|
});
|
|
272
278
|
assertOk(started, "start GitHub install");
|
|
273
279
|
|
|
@@ -487,14 +493,32 @@ async function showDesktopSignedIn(openDesktopSignedIn, args, creds, stopOpening
|
|
|
487
493
|
// The same --dest the install honoured, or the launch would look for the app
|
|
488
494
|
// somewhere it was never copied to.
|
|
489
495
|
const destination = args.value("--dest", "/Applications");
|
|
490
|
-
|
|
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 });
|
|
491
501
|
stopOpening?.();
|
|
502
|
+
const email = String(creds?.user_email || creds?.user?.email || "").trim();
|
|
492
503
|
if (opened.state === "opened-signed-in") {
|
|
493
|
-
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
|
+
);
|
|
494
509
|
} else if (opened.state === "not-installed") {
|
|
495
510
|
process.stdout.write(
|
|
496
511
|
`PreMan is not in ${destination} yet \u2014 open it once installed and sign in.\n`
|
|
497
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
|
+
);
|
|
498
522
|
} else {
|
|
499
523
|
// Either the session could not be handed over or this app is too old to take
|
|
500
524
|
// it up. Say so rather than let the customer wonder why they are looking at
|
package/bin/link.js
CHANGED
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
* a `git push` is exactly the behaviour that gets a tool uninstalled.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
|
|
20
|
-
import { desktopAppInstalled, installedAppPath } from "./desktop.js";
|
|
17
|
+
import { desktopAppInstalled, installedDesktopVersion } from "./desktop.js";
|
|
21
18
|
import { DEFAULT_FRONTEND, frontendUrl } from "./shared.js";
|
|
22
19
|
|
|
20
|
+
export { installedDesktopVersion };
|
|
21
|
+
|
|
23
22
|
const DIM = "\u001b[2m";
|
|
24
23
|
const RESET = "\u001b[0m";
|
|
25
24
|
|
|
@@ -82,20 +81,6 @@ export function compareVersions(a, b) {
|
|
|
82
81
|
return 0;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
/** CFBundleShortVersionString of the installed app, or "". */
|
|
86
|
-
export function installedDesktopVersion(destination = "/Applications") {
|
|
87
|
-
try {
|
|
88
|
-
const plist = path.join(installedAppPath(destination), "Contents", "Info.plist");
|
|
89
|
-
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
|
|
90
|
-
readFileSync(plist, "utf8")
|
|
91
|
-
);
|
|
92
|
-
return match ? match[1].trim() : "";
|
|
93
|
-
} catch {
|
|
94
|
-
// Not installed, or a bundle we cannot read. Either way: not routable.
|
|
95
|
-
return "";
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
84
|
export function desktopSupportsRouting(destination = "/Applications") {
|
|
100
85
|
const version = installedDesktopVersion(destination);
|
|
101
86
|
return Boolean(version) && compareVersions(version, MIN_ROUTE_VERSION) >= 0;
|
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
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Thin HTTP wrappers over the same `/workbench/generate-tests` routes the
|
|
5
5
|
* dashboard uses. Distinct from `preman test <id>`, which generates + runs
|
|
6
|
-
* scenarios for one endpoint via `POST /
|
|
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
|
@@ -243,12 +243,9 @@ async function safeCall(args, method, routePath, options) {
|
|
|
243
243
|
async function fetchInventory(args, token) {
|
|
244
244
|
const endpoints = [];
|
|
245
245
|
for (let offset = 0; offset < INVENTORY_MAX_ENDPOINTS; offset += INVENTORY_PAGE_SIZE) {
|
|
246
|
-
const result = await safeCall(args, "
|
|
246
|
+
const result = await safeCall(args, "GET", "/cli/endpoints", {
|
|
247
247
|
token,
|
|
248
|
-
|
|
249
|
-
tool: "get_endpoints",
|
|
250
|
-
arguments: { format: "json", limit: INVENTORY_PAGE_SIZE, offset },
|
|
251
|
-
},
|
|
248
|
+
query: { limit: INVENTORY_PAGE_SIZE, offset },
|
|
252
249
|
});
|
|
253
250
|
if (!result.ok) {
|
|
254
251
|
return {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** Transparent stdio -> hosted Streamable HTTP MCP bridge.
|
|
2
|
+
*
|
|
3
|
+
* This process deliberately defines no tools or schemas. Initialization,
|
|
4
|
+
* discovery, notifications, and calls are forwarded byte-for-byte at the JSON-
|
|
5
|
+
* RPC layer so the Python service remains the sole public contract authority.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|