premanmcp 1.0.3 → 1.0.5

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/hook.js CHANGED
@@ -139,6 +139,57 @@ function isGeneratedInvocation(invocation) {
139
139
  return invocation === "preman" || /^npm exec -y premanmcp@\S+ --$/.test(invocation);
140
140
  }
141
141
 
142
+ /**
143
+ * The release a generated invocation pins, or "" when it does not pin one.
144
+ *
145
+ * The bare `preman` form resolves at push time, and `@latest` is whatever we
146
+ * ship next, so neither can be out of date. Only an explicit version can be.
147
+ */
148
+ export function pinnedRelease(invocation) {
149
+ const match = /^npm exec -y premanmcp@(\S+) --$/.exec(String(invocation || "").trim());
150
+ const version = match ? match[1] : "";
151
+ return version === "latest" ? "" : version;
152
+ }
153
+
154
+ /**
155
+ * Is `left` an earlier release than `right`?
156
+ *
157
+ * Anything this cannot read as three numbers compares as not-older, so a
158
+ * prerelease tag or a mirror's own numbering is left alone rather than being
159
+ * rewritten on a guess.
160
+ */
161
+ export function isOlderRelease(left, right) {
162
+ const parse = (value) => String(value).split(".").map((part) => Number.parseInt(part, 10));
163
+ const a = parse(left);
164
+ const b = parse(right);
165
+ if (a.length !== 3 || b.length !== 3) return false;
166
+ if ([...a, ...b].some((part) => !Number.isFinite(part))) return false;
167
+ for (let index = 0; index < 3; index += 1) {
168
+ if (a[index] !== b[index]) return a[index] < b[index];
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Does this hook run a PreMan older than the one asking?
175
+ *
176
+ * A pinned hook keeps working long after it stops being current -- it answers
177
+ * `help`, it runs `verify`, it just does all of it as the version that wrote
178
+ * it. Found in the wild: a hook still on 0.13.0 while 1.0.4 was installed,
179
+ * printing an error message whose formatting had been fixed several releases
180
+ * earlier, with nothing anywhere to say why.
181
+ *
182
+ * Only ever true when we would be moving forward. An older CLI run for some
183
+ * other reason must not drag a newer hook back with it.
184
+ */
185
+ export function hookIsBehind(invocation) {
186
+ if (declaredInvocation()) return false;
187
+ const pinned = pinnedRelease(invocation);
188
+ const running = packageVersion();
189
+ if (!pinned || !running) return false;
190
+ return isOlderRelease(pinned, running);
191
+ }
192
+
142
193
  function hookBody(invocation) {
143
194
  // `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
144
195
  // non-zero and still exit 0 itself.
@@ -319,9 +370,11 @@ function rememberHookCheck(cwd, now, fields) {
319
370
  * PreMan is running here -- any command, the MCP server starting -- is taken as
320
371
  * the moment to check.
321
372
  *
322
- * Deliberately narrow. It only ever rewrites a hook we wrote that no longer
323
- * answers: an absent hook is not installed behind the user, a foreign hook is
324
- * not touched, and a hook that works is left on whatever version it pins.
373
+ * Deliberately narrow. It only ever rewrites a hook we wrote: an absent hook is
374
+ * not installed behind the user, and a foreign hook is not touched. A working
375
+ * hook is rewritten only when it pins a release older than the one running --
376
+ * see `hookIsBehind` -- because "it answers" and "it is the CLI we ship" stopped
377
+ * being the same question once a pin could outlive several releases.
325
378
  */
326
379
  export function repairDeadHook({ cwd = process.cwd(), now = Date.now(), force = false } = {}) {
327
380
  const remember = (fields) => rememberHookCheck(cwd, now, fields);
@@ -337,11 +390,14 @@ export function repairDeadHook({ cwd = process.cwd(), now = Date.now(), force =
337
390
  return remember({ action: "not-a-repository" });
338
391
  }
339
392
  if (status.state !== "installed") return remember({ action: status.state });
340
- if (status.works) return remember({ action: "healthy", invocation: status.invocation });
393
+ const behind = hookIsBehind(status.invocation);
394
+ if (status.works && !behind) {
395
+ return remember({ action: "healthy", invocation: status.invocation });
396
+ }
341
397
 
342
398
  const result = installHook(makeArgs([]));
343
399
  return remember({
344
- action: result.action === "updated" ? "repaired" : result.action,
400
+ action: result.action === "updated" ? (behind ? "upgraded" : "repaired") : result.action,
345
401
  invocation: result.invocation || "",
346
402
  replaced: status.invocation,
347
403
  });
package/bin/shared.js CHANGED
@@ -482,16 +482,52 @@ export async function callBackendJson(
482
482
  * instead" reached a customer as `410 [object Object]` -- a remedy the response
483
483
  * carried and the screen never showed.
484
484
  */
485
+ /**
486
+ * A readable reason for a backend failure, always as a string.
487
+ *
488
+ * FastAPI answers a rejected request with an object and a validation error with
489
+ * an array of them, and every caller interpolates what comes back into a line
490
+ * somebody reads. The nested cases used to be handed back as they arrived --
491
+ * `detail.message` being itself an object was returned as an object -- so the
492
+ * line printed "[object Object]", which is the exact failure this function
493
+ * exists to prevent, reintroduced one level further down.
494
+ */
485
495
  export function describeFailure(result, fallback = "backend error") {
486
- const detail = result?.detail ?? result?.message ?? result?.raw;
487
- if (typeof detail === "string" && detail) return detail;
488
- if (Array.isArray(detail) && detail.length) {
489
- return detail.map((item) => item?.msg || JSON.stringify(item)).join("; ");
496
+ return readDetail(result?.detail ?? result?.message ?? result?.raw) || fallback;
497
+ }
498
+
499
+ /** The first thing in `detail` a person could read, however deeply it is wrapped. */
500
+ function readDetail(detail, depth = 0) {
501
+ if (typeof detail === "string") return detail.trim();
502
+ if (typeof detail === "number" || typeof detail === "boolean") return String(detail);
503
+ if (Array.isArray(detail)) {
504
+ return detail
505
+ .map((item) => readDetail(item, depth + 1))
506
+ .filter(Boolean)
507
+ .join("; ");
490
508
  }
491
509
  if (detail && typeof detail === "object") {
492
- return detail.message || detail.error || detail.code || JSON.stringify(detail);
510
+ // Bounded, because these arrive from somewhere else and a structure that
511
+ // refers to itself must not take the CLI down on its way to a skip message.
512
+ if (depth < 3) {
513
+ for (const key of ["msg", "message", "detail", "error", "description", "code"]) {
514
+ const nested = readDetail(detail[key], depth + 1);
515
+ if (nested) return nested;
516
+ }
517
+ }
518
+ try {
519
+ const json = JSON.stringify(detail);
520
+ // An empty object describes nothing. Printing "{}" as the reason a push
521
+ // was skipped is the same unhelpfulness as "[object Object]" in nicer
522
+ // punctuation; the caller's fallback at least names the kind of failure.
523
+ return json === "{}" || json === "[]" ? "" : json;
524
+ } catch {
525
+ // Circular, or otherwise not describable. The caller's fallback is better
526
+ // than throwing from the thing that was explaining a failure.
527
+ return "";
528
+ }
493
529
  }
494
- return fallback;
530
+ return "";
495
531
  }
496
532
 
497
533
  export function assertOk(result, action) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "test": "npm run build && npm run test:proxy",
16
16
  "test:proxy": "node --test scripts/smoke-proxy.mjs",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
- "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs",
18
+ "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-onboard-opening.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs scripts/smoke-shared-errors.mjs",
19
19
  "test:dmg": "node --test --test-timeout=300000 scripts/smoke-install-desktop-volume.mjs"
20
20
  },
21
21
  "devDependencies": {