premanmcp 0.16.3 → 1.0.0

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
@@ -1,10 +1,30 @@
1
1
  /**
2
2
  * `preman install-desktop` — download and install the PreMan desktop app.
3
3
  *
4
- * Version-less asset names are used on purpose: `/releases/latest/download/
5
- * PreMan-mac-arm64.dmg` keeps working after every release, where a versioned URL
6
- * would 404 the day the next build ships. The live tag is read from the GitHub
7
- * API only to report what was installed.
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,6 +78,27 @@ 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
  }
@@ -174,10 +216,29 @@ export function printPlayground(url) {
174
216
  }
175
217
  }
176
218
 
177
- export function dmgUrl(arch) {
219
+ /**
220
+ * Where to fetch the disk image for `arch`.
221
+ *
222
+ * With a tag, the release's own versioned asset -- one exact build, which cannot
223
+ * become a different one between resolving it and finishing the download.
224
+ * Without one, the version-less alias, which always resolves to something but
225
+ * will not say what.
226
+ */
227
+ export function dmgUrl(arch, tag = null) {
228
+ if (tag) return `${RELEASES_BASE}/download/${tag}/PreMan-${versionFromTag(tag)}-${arch}.dmg`;
178
229
  return `${RELEASES_BASE}/latest/download/PreMan-mac-${arch}.dmg`;
179
230
  }
180
231
 
232
+ /** Where to fetch the update manifest, pinned the same way. */
233
+ export function manifestUrl(tag = null) {
234
+ if (tag) return `${RELEASES_BASE}/download/${tag}/latest-mac.yml`;
235
+ return `${RELEASES_BASE}/latest/download/latest-mac.yml`;
236
+ }
237
+
238
+ export function versionFromTag(tag) {
239
+ return String(tag || "").replace(/^v/i, "");
240
+ }
241
+
181
242
  export function detectArch() {
182
243
  const machine = os.machine ? os.machine() : os.arch();
183
244
  if (machine === "arm64" || machine === "aarch64") return "arm64";
@@ -201,16 +262,46 @@ async function fetchLatestRelease() {
201
262
  }
202
263
  }
203
264
 
265
+ /**
266
+ * The tag `/releases/latest` currently points at, read out of its redirect.
267
+ *
268
+ * A second way to ask, because the first one is rate limited: the GitHub API
269
+ * allows 60 unauthenticated calls an hour per address, which a shared office or
270
+ * a CI runner can exhaust without doing anything unusual. Downloads are not
271
+ * limited, and the alias redirects to the release it resolved -- so asking for
272
+ * the alias and declining to follow the redirect yields the same tag the
273
+ * download itself would have used, which is exactly the one worth pinning.
274
+ */
275
+ export function tagFromRedirect(location) {
276
+ return String(location || "").match(/\/releases\/download\/([^/]+)\//)?.[1] ?? null;
277
+ }
278
+
279
+ async function resolveReleaseTag(arch) {
280
+ const release = await fetchLatestRelease();
281
+ if (release?.tag_name) return String(release.tag_name);
282
+ try {
283
+ const resp = await fetch(dmgUrl(arch), {
284
+ method: "HEAD",
285
+ redirect: "manual",
286
+ headers: { "User-Agent": "premanmcp-cli" },
287
+ });
288
+ return tagFromRedirect(resp.headers.get("location"));
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
204
294
  /**
205
295
  * The sha512 and size electron-builder published for this architecture's disk
206
296
  * image, or null if the manifest could not be read.
207
297
  *
208
298
  * `latest-mac.yml` is the only integrity signal available without our own signing
209
299
  * infrastructure. It keys entries by the *versioned* filename
210
- * (`PreMan-0.3.79-arm64.dmg`), while we download the version-less alias, so the
211
- * version has to be read out of the manifest to find the right entry. The alias
212
- * is a copy of the same artifact, which the size check confirms before the digest
213
- * is trusted.
300
+ * (`PreMan-0.3.79-arm64.dmg`), so the version is read out of the manifest to
301
+ * find the right entry. On a pinned install that version is the tag's, and the
302
+ * asset named in the manifest is the one that was downloaded; on a fallback
303
+ * install it describes the alias's twin, which the size check confirms before
304
+ * the digest is trusted.
214
305
  */
215
306
  export function parseMacManifest(text, arch) {
216
307
  const version = text.match(/^version:\s*(\S+)/m)?.[1];
@@ -231,9 +322,9 @@ export function parseMacManifest(text, arch) {
231
322
  return null;
232
323
  }
233
324
 
234
- async function expectedDigest(arch) {
325
+ async function expectedDigest(arch, tag = null) {
235
326
  try {
236
- const resp = await fetch(`${RELEASES_BASE}/latest/download/latest-mac.yml`, {
327
+ const resp = await fetch(manifestUrl(tag), {
237
328
  headers: { "User-Agent": "premanmcp-cli" },
238
329
  });
239
330
  if (!resp.ok) return null;
@@ -367,18 +458,41 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
367
458
  if (!["arm64", "x64"].includes(arch)) {
368
459
  throw new Error(`unsupported --arch ${arch}; expected arm64 or x64`);
369
460
  }
370
- const url = dmgUrl(arch);
371
-
372
461
  if (args.has("--print-url")) {
373
- process.stdout.write(`${url}\n`);
374
- return { state: "printed", url, arch };
462
+ // The alias, and no network call: this is asked for by scripts and by people
463
+ // who want the URL that keeps working, not the one for today's build.
464
+ const alias = dmgUrl(arch);
465
+ process.stdout.write(`${alias}\n`);
466
+ return { state: "printed", url: alias, arch };
375
467
  }
376
468
 
377
- const release = await fetchLatestRelease();
378
- const version = String(release?.tag_name || "").replace(/^v/i, "") || "latest";
469
+ // Resolved once. Everything below is pinned to this tag, so the version
470
+ // reported, the image downloaded and the checksum verified against it all
471
+ // describe the same release even if another ships while this runs.
472
+ const tag = await resolveReleaseTag(arch);
473
+ const url = dmgUrl(arch, tag);
474
+ const version = tag ? versionFromTag(tag) : "latest";
379
475
  const destination = args.value("--dest", "/Applications");
380
476
 
381
- process.stdout.write(`Downloading PreMan ${version} (${arch})…\n`);
477
+ const installed = installedDesktopVersion(destination);
478
+ if (tag && installed && installed === version && !args.has("--force")) {
479
+ // Re-fetching 100 MB to arrive at the bytes already on disk is the most
480
+ // literal form of the "it downloads the update again" complaint, and it is
481
+ // what this command did every time it was run.
482
+ process.stdout.write(
483
+ `PreMan ${installed} is already installed and is the latest release.\n` +
484
+ `Nothing to do. Use --force to reinstall.\n`
485
+ );
486
+ return { state: "current", version: installed, arch, path: installedAppPath(destination) };
487
+ }
488
+
489
+ if (installed === version) {
490
+ process.stdout.write(`Reinstalling PreMan ${version} (${arch})…\n`);
491
+ } else if (installed) {
492
+ process.stdout.write(`Replacing PreMan ${installed} with ${version} (${arch})…\n`);
493
+ } else {
494
+ process.stdout.write(`Downloading PreMan ${version} (${arch})…\n`);
495
+ }
382
496
  const workDir = mkdtempSync(path.join(os.tmpdir(), "preman-desktop-"));
383
497
  const dmgPath = path.join(workDir, `PreMan-mac-${arch}.dmg`);
384
498
  let mounted = null;
@@ -386,8 +500,21 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
386
500
  try {
387
501
  const progress = progressLine();
388
502
  let bytes;
503
+ let pinned = tag;
389
504
  try {
390
- bytes = await download(url, dmgPath, progress.tick);
505
+ try {
506
+ bytes = await download(url, dmgPath, progress.tick);
507
+ } catch (err) {
508
+ // Pinning names an asset instead of an alias, so it is the one thing
509
+ // here that can be wrong about a release rather than merely unlucky --
510
+ // an older build that predates the versioned artifact, or a rename.
511
+ // Falling back to the alias costs the guarantees above and is still an
512
+ // install; failing outright would make this worse than what it replaced.
513
+ if (!pinned || !/download failed: 404/.test(String(err?.message))) throw err;
514
+ process.stdout.write(` ${tag} has no versioned image; using the latest alias\n`);
515
+ pinned = null;
516
+ bytes = await download(dmgUrl(arch), dmgPath, progress.tick);
517
+ }
391
518
  } finally {
392
519
  // Cleared even when the download throws, or the error prints onto the
393
520
  // half-drawn progress line.
@@ -395,7 +522,9 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
395
522
  }
396
523
  process.stdout.write(` ${(bytes / BYTES_PER_MB).toFixed(1)} MB\n`);
397
524
 
398
- const expected = await expectedDigest(arch);
525
+ // Read from the same place the image came from, pinned or not, so the two
526
+ // can never describe different releases.
527
+ const expected = await expectedDigest(arch, pinned);
399
528
  if (expected) {
400
529
  if (expected.size && expected.size !== bytes) {
401
530
  throw new Error(
@@ -425,8 +554,12 @@ export async function installDesktopCommand(commandArgs = [], { onInstalled } =
425
554
  run("cp", ["-R", source, target]);
426
555
  chmodSync(target, 0o755);
427
556
 
428
- const result = { state: "installed", version, arch, path: target };
429
- process.stdout.write(`\nInstalled ${target}\n\n`);
557
+ // The manifest's version describes bytes whose size and digest were just
558
+ // checked against it, which makes it a better answer than the tag resolved
559
+ // before any of this ran -- and the only honest one on the alias fallback.
560
+ const installedVersion = expected?.version || installedDesktopVersion(destination) || version;
561
+ const result = { state: "installed", version: installedVersion, arch, path: target };
562
+ process.stdout.write(`\nInstalled PreMan ${installedVersion} to ${target}\n\n`);
430
563
  onInstalled?.(result);
431
564
  return result;
432
565
  } finally {
@@ -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
 
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 { readFileSync } from "node:fs";
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/tests.js CHANGED
@@ -3,7 +3,7 @@
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 /mcp/call-tool`.
6
+ * scenarios for one endpoint via `POST /cli/tests/generate`.
7
7
  */
8
8
 
9
9
  import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
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, "POST", "/mcp/call-tool", {
246
+ const result = await safeCall(args, "GET", "/cli/endpoints", {
247
247
  token,
248
- json: {
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
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function createServer(): McpServer;
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 {};