@retasc/cli 1.35.0 → 1.36.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/CHANGELOG.md CHANGED
@@ -6,6 +6,67 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.36.0 (2026-08-25)
10
+
11
+ - **RTSC-520** — the CLI now says when it is out of date. There was no version check
12
+ anywhere in `cli/`, so a global install went stale silently and stayed that way for as
13
+ long as the machine lived — and a stale CLI does not merely lack features, it *lies*:
14
+ its copy describes SERVER behaviour, so 1.11.0 kept telling people a decline "covers the
15
+ whole org, including later migrations" long after RTSC-507 made that false, and 1.10.0
16
+ had no Google door, so it sent Google-only humans through GitHub and minted them a
17
+ second identity. After any login-gated command an outdated build now prints one line
18
+ naming both versions and asks whether to update:
19
+
20
+ ```
21
+ A newer retasc is available (1.36.0, you have 1.35.2).
22
+ Update now? [y/N]
23
+ ```
24
+
25
+ Notify then ask, never a silent `npm i -g`: interactive terminals only (no TTY prints
26
+ the notice and nothing else, so scripted runs are unchanged), at most once a day with a
27
+ decline remembered, and a failed install — EACCES on a root-owned prefix is the common
28
+ one — reports itself and names `npm i -g @retasc/cli@latest` without ever failing the
29
+ command you actually ran. A successful update says it applies from your next command and
30
+ that agents need restarting to pick up the new MCP server. The MCP proxy never prints it.
31
+
32
+ The published version is resolved server-side from the npm registry, cached hourly, and
33
+ handed down on `manage:me` — a call every login-gated command already makes, so checking
34
+ costs no round trip and no shared rate limit, and no release step can forget to update
35
+ it. An optional `RETASC_CLI_MIN_HONEST` marks the oldest version whose copy still matches
36
+ the server; below it the notice says so plainly, because "a newer version exists" and
37
+ "what this build just told you may be false" are different problems.
38
+
39
+ ## 1.35.2 (2026-08-25)
40
+
41
+ - **RTSC-743** — the last CLI replies that answered a person with a machine shape now
42
+ answer in sentences. A failed MCP request used to paste 200 characters of the raw
43
+ response body after the status, which on that endpoint is either a JSON-RPC envelope or,
44
+ when the failure lands at Cloudflare rather than in Convex, a page of HTML; it now says
45
+ what the status means and what to do about it, and reads the body for a message rather
46
+ than printing it. A reply that is not JSON at all (a proxy error page, a captive portal)
47
+ named a JSON parser's position; it now names what actually answered. `retasc login`
48
+ stringified GitHub's whole OAuth payload into its error, burying `error_description`,
49
+ the one field in it written for a person. `whoami` and `doctor` could surface a bare
50
+ parser complaint from `resolveBinding`, which is a poor answer to "what is broken?" from
51
+ the two commands you run when something already is. And `retasc import` printed the
52
+ summary with the wire payload's own field names as labels — `issuesCreated`,
53
+ `commentsInserted`, `attachmentsRehosted` — where it now prints Issues created,
54
+ Comments and Attachments copied. Along the way, `doctor` and `whoami` stop telling you
55
+ to re-run `retasc bind` when the thing that answered was a proxy rather than Retasc:
56
+ that failure carries no verdict on your key, and re-binding to fix it mints a new one
57
+ for nothing.
58
+
59
+ ## 1.35.1 (2026-08-25)
60
+
61
+ - **RTSC-478** — a network failure now names its own cause. Node reports every failed
62
+ `fetch` as the literal `fetch failed` and puts the diagnosis on `err.cause.code`, which
63
+ the CLI read past, so `retasc login` on a machine whose TLS trust store Node cannot read
64
+ died on `✗ fetch failed` and nothing else. It now prints
65
+ `✗ fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)` — a searchable string naming the
66
+ real problem. `cli/README.md` gains a troubleshooting entry keyed on that code, covering
67
+ both causes: a corporate TLS-inspecting proxy, and a Homebrew `openssl@3` keg that lost
68
+ its CA symlink (the confusing one, since `curl` and `npm` keep working).
69
+
9
70
  ## 1.35.0 (2026-08-25)
10
71
 
11
72
  - **RTSC-709** — `retasc issue show` and `retasc issue list` read the queue from a
package/README.md CHANGED
@@ -122,6 +122,34 @@ Two more are reachable but not as their own command, which is also deliberate:
122
122
  If you want any of the above from a terminal anyway, say so in an issue: the point of this
123
123
  list is that their absence is a choice we wrote down, not one nobody has noticed.
124
124
 
125
+ ## Troubleshooting
126
+
127
+ ### `fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)` or `(SELF_SIGNED_CERT_IN_CHAIN)`
128
+
129
+ Node cannot verify the TLS certificate, so every request the CLI makes dies before it is
130
+ sent. It usually shows up on `retasc login`, because that is the first command that talks
131
+ to the network. Two causes, and the code tells you which:
132
+
133
+ - **A corporate TLS-inspecting proxy** (Zscaler and similar) re-signs traffic with a root
134
+ Node does not trust — normally `SELF_SIGNED_CERT_IN_CHAIN`. Point Node at your
135
+ organisation's CA bundle:
136
+
137
+ ```bash
138
+ export NODE_EXTRA_CA_CERTS=/path/to/your-company-ca.pem
139
+ ```
140
+
141
+ - **A Homebrew Node whose OpenSSL keg lost its CA symlink** — normally
142
+ `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, and the confusing one, because `curl` and `npm`
143
+ keep working: they do not read the file Node reads. Relink it:
144
+
145
+ ```bash
146
+ brew postinstall openssl@3
147
+ ```
148
+
149
+ Never set `NODE_TLS_REJECT_UNAUTHORIZED=0`. It turns off certificate verification for
150
+ every connection the process makes, including the one carrying your session token.
151
+ `NODE_EXTRA_CA_CERTS` fixes the same failures with verification left on.
152
+
125
153
  ## Links
126
154
 
127
155
  - Website — [retasc.com](https://retasc.com)
package/dist/api.js CHANGED
@@ -3,7 +3,9 @@ import { makeFunctionReference } from "convex/server";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { refreshSession, deviceLogin } from "./auth.js";
5
5
  import { selfCommand } from "./lib/launcher.js";
6
+ import { clean as sanitize } from "./lib/text.js";
6
7
  import { VERSION } from "./version.js";
8
+ import { recordRelease } from "./lib/updateNotice.js";
7
9
  // Typed-ish references to the public management functions in convex/manage.ts.
8
10
  // The CLI is a standalone package, so we reference functions by name rather than
9
11
  // importing the parent's generated api.
@@ -100,6 +102,19 @@ export function formatError(e) {
100
102
  .split("\n")[0]
101
103
  .replace(/^.*?Uncaught Error:\s*/, "")
102
104
  .trim();
105
+ // RTSC-478: a failed `fetch` throws the literal `TypeError: fetch failed` and puts the
106
+ // actual diagnosis on `cause.code` — `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` when Node cannot
107
+ // read the machine's CA roots, `SELF_SIGNED_CERT_IN_CHAIN` behind a corporate
108
+ // TLS-inspecting proxy, `ECONNREFUSED`, `ENOTFOUND`. Reading `message` alone throws away
109
+ // the diagnosis Node already handed us, and leaves `retasc login` — the FIRST command a
110
+ // new user runs — dead-ended on a string that names nothing and searches for nothing:
111
+ // ✗ fetch failed → ✗ fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)
112
+ // Fallback branch only: a structured backend error (above) already carries its own `code`.
113
+ // Sanitized like every other string we print, because `cause` is not always ours.
114
+ const raw = e?.cause?.code;
115
+ const code = typeof raw === "string" ? sanitize(raw).trim() : "";
116
+ if (code && !message.includes(code))
117
+ return { message: `${message} (${code})` };
103
118
  return { message };
104
119
  }
105
120
  /**
@@ -173,7 +188,15 @@ async function withAuth(call) {
173
188
  }
174
189
  }
175
190
  export const api = {
176
- me: () => withAuth(() => client().query(fns.me, {})),
191
+ // RTSC-520 every login-gated command already makes this call, so the published-version
192
+ // signal rides down on it and costs nothing. Recording it HERE, rather than at each of the
193
+ // nine call sites, is also what keeps the notice out of the machine channel: the MCP proxy
194
+ // never imports this module, so it can never have a signal to print.
195
+ me: async () => {
196
+ const res = await withAuth(() => client().query(fns.me, {}));
197
+ recordRelease(res?.cli);
198
+ return res;
199
+ },
177
200
  listProjects: (args) => withAuth(() => client().query(fns.listProjects, args)),
178
201
  createOrg: (args) => withAuth(() => client().mutation(fns.createOrg, args)),
179
202
  createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
package/dist/auth.js CHANGED
@@ -4,6 +4,9 @@ import { makeFunctionReference } from "convex/server";
4
4
  import { loadConfig, patchConfig } from "./config.js";
5
5
  import { ask, isInteractive } from "./lib/prompt.js";
6
6
  import { startBrowserLogin } from "./lib/browserLogin.js";
7
+ import { clean } from "./lib/text.js";
8
+ import { selfCommand } from "./lib/launcher.js";
9
+ import { VERSION } from "./version.js";
7
10
  // The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
8
11
  // secret). Baked in so `retasc login` works out-of-the-box; override via env.
9
12
  const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
@@ -140,7 +143,13 @@ async function githubDeviceToken() {
140
143
  scope: "read:user user:email",
141
144
  });
142
145
  if (!start.device_code) {
143
- throw new Error(`GitHub device-flow start failed: ${JSON.stringify(start)}`);
146
+ // Say what GitHub said, not what GitHub SENT. The whole response used to be
147
+ // JSON.stringify'd into this message, which put a raw OAuth payload in front of
148
+ // someone whose only question is whether to try again — and buried
149
+ // `error_description`, the one field in it written for a person to read.
150
+ const said = start.error_description ?? start.error;
151
+ throw new Error(`GitHub didn't hand back a sign-in code${said ? `: ${clean(said)}` : "."} ` +
152
+ `Check your connection and run \`${selfCommand(VERSION)} login\` again.`);
144
153
  }
145
154
  announceCode("GitHub", start.verification_uri, start.user_code, start.verification_uri_complete);
146
155
  // Poll GitHub for the access token.
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { basename, dirname, resolve, sep } from "node:path";
4
4
  import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, resolveIssueRef, } from "../lib/claim.js";
5
5
  import { loadConfig } from "../config.js";
6
+ import { clean } from "../lib/text.js";
6
7
  function git(args, cwd) {
7
8
  return spawnSync("git", args, { encoding: "utf8", cwd });
8
9
  }
@@ -124,7 +125,12 @@ export async function claimAction(opts) {
124
125
  }
125
126
  // The id flows into a git ref and a filesystem path — reject a malformed one.
126
127
  if (!isValidIssueId(claim.issueId)) {
127
- note(`✗ Server returned an unexpected issue id: ${JSON.stringify(claim.issueId)}`);
128
+ // Named in words, not JSON.stringify'd. The value is either a string, in which case
129
+ // quoting it is enough, or it is a shape — and dumping that shape tells the reader
130
+ // nothing they can act on while looking exactly like a crash.
131
+ const shown = typeof claim.issueId === "string" ? `"${clean(claim.issueId)}"` : "nothing usable";
132
+ note(`✗ Retasc sent ${shown} where an issue id (like RTSC-42) should be.`);
133
+ note(` Nothing was branched or checked out. Try again, or run \`retasc doctor\`.`);
128
134
  process.exit(1);
129
135
  }
130
136
  // claim_issue returns just the id — fetch the title so the slug is meaningful.
@@ -134,7 +134,7 @@ export async function doctorAction() {
134
134
  // The binding EXISTS; we just can't verify it. Don't send the user off
135
135
  // to re-bind over a dead wifi link or a server having a bad minute.
136
136
  networkDown = true;
137
- warn(`binding present (${local.source} scope) but the server didn't answer: ${msg}.`);
137
+ warn(`binding present (${local.source} scope) but no usable answer came back: ${msg}.`);
138
138
  }
139
139
  else {
140
140
  bad(`key not accepted by the server: ${msg}. Re-run \`retasc bind\`.`);
@@ -50,6 +50,42 @@ export function reimportWarning(history, source, label) {
50
50
  ` Re-importing re-syncs those issues, so any edits you made in Retasc to them\n` +
51
51
  ` (status, labels, and so on) will be replaced by ${clean(label)}'s version.`);
52
52
  }
53
+ /**
54
+ * What each field of the server's `ImportSummary` is CALLED, for a person.
55
+ *
56
+ * The summary is a wire payload, and it used to be printed with its own keys as labels —
57
+ * `issuesCreated`, `commentsInserted`, `attachmentsRehosted`, one per line. That is a JSON
58
+ * object with the braces taken off: it reads as the inside of the program rather than as
59
+ * the answer to "what just came across". The names live here, in one list, next to the
60
+ * shape they describe (`ImportSummary` in convex/import.ts).
61
+ */
62
+ const SUMMARY_LABELS = {
63
+ totalIssues: "Issues seen",
64
+ issuesCreated: "Issues created",
65
+ issuesUpdated: "Issues updated",
66
+ relationsAdded: "Links between issues",
67
+ commentsInserted: "Comments",
68
+ attachmentsRehosted: "Attachments copied",
69
+ attachmentsFailed: "Attachments that failed",
70
+ };
71
+ /**
72
+ * A label for a field the list above doesn't name.
73
+ *
74
+ * A field this CLI is too old to know about must still read as English rather than as a
75
+ * key, because the pairing is permanent: the backend deploys separately, so there is
76
+ * always a version of this CLI that predates the newest summary field. `fooBarBaz`
77
+ * becomes "Foo bar baz" — not the real name someone would have chosen, but a phrase.
78
+ */
79
+ export function summaryLabel(key) {
80
+ // `hasOwnProperty`, not a bare lookup. The keys come off the wire, and a bare
81
+ // `SUMMARY_LABELS[key]` walks Object.prototype: a field named `constructor` or
82
+ // `toString` returns a FUNCTION, which `padEnd` then throws on. That would crash
83
+ // `retasc import` on the line after a multi-minute run finished successfully.
84
+ if (Object.prototype.hasOwnProperty.call(SUMMARY_LABELS, key))
85
+ return SUMMARY_LABELS[key];
86
+ const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
87
+ return words.charAt(0).toUpperCase() + words.slice(1);
88
+ }
53
89
  /**
54
90
  * The summary after a run, with the one field that needs words rather than a number.
55
91
  *
@@ -66,8 +102,11 @@ export function summaryLines(summary, label) {
66
102
  unmapped.push(...v.map(String));
67
103
  continue;
68
104
  }
69
- if (typeof v === "number" || typeof v === "string")
70
- out.push(` ${k.padEnd(20)}${clean(v)}`);
105
+ // Only numbers and strings are printed, which is also what keeps a structured field
106
+ // (`sample`, `relationCyclesSkipped`) from arriving as `[object Object]`.
107
+ if (typeof v === "number" || typeof v === "string") {
108
+ out.push(` ${summaryLabel(k).padEnd(24)}${clean(v)}`);
109
+ }
71
110
  }
72
111
  if (unmapped.length) {
73
112
  out.push("", `! These ${clean(label)} statuses weren't mapped, so their issues landed in todo:`, ` ${unmapped.map((u) => clean(u)).join(", ")}`, " They appeared after you set the mapping. Re-import to place them.");
package/dist/config.js CHANGED
@@ -68,6 +68,12 @@ export function loadConfig() {
68
68
  // "never asked", so a mangled file leads to a question rather than to an
69
69
  // install nobody agreed to.
70
70
  globalInstall: typeof stored.globalInstall === "boolean" ? stored.globalInstall : undefined,
71
+ // Same rule again (RTSC-520): a hand-edited or mangled value reads as "never shown".
72
+ // Finite, because `NaN`/`Infinity` survive JSON round-trips through some editors and
73
+ // would make the once-a-day gate either permanently open or permanently shut.
74
+ updateNoticeAt: typeof stored.updateNoticeAt === "number" && Number.isFinite(stored.updateNoticeAt)
75
+ ? stored.updateNoticeAt
76
+ : undefined,
71
77
  };
72
78
  }
73
79
  /** Move a corrupt config aside to a unique sibling so a human can recover any
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { issueShowAction, issueListAction, checkpointAction, checkClaimAction, }
21
21
  import { runProxy } from "./proxy.js";
22
22
  import { deviceLogin } from "./auth.js";
23
23
  import { api, formatError } from "./api.js";
24
+ import { maybeOfferUpdate } from "./lib/updateNotice.js";
24
25
  const program = new Command();
25
26
  program
26
27
  .name("retasc")
@@ -111,7 +112,7 @@ program
111
112
  const msg = String(e?.message ?? e).replace(/[\x00-\x1f\x7f]/g, " ");
112
113
  // Unreachable ≠ rejected: don't imply a bad key over a dead network.
113
114
  if (isNetworkError(e))
114
- console.log(`This folder → binding present, but the server didn't answer: ${msg}\n`);
115
+ console.log(`This folder → binding present, but no usable answer came back: ${msg}\n`);
115
116
  else
116
117
  console.log(`This folder → bound, but the key did not resolve: ${msg}\n`);
117
118
  }
@@ -677,4 +678,13 @@ program
677
678
  console.log(`mcp url: ${cfg.mcpUrl}`);
678
679
  console.log(`signed in: ${isLoggedIn(cfg) ? `yes${cfg.user?.name ? ` (${cfg.user.name})` : ""}` : "no"}`);
679
680
  });
681
+ // RTSC-520 — after the command, never during it. A postAction hook is the only place the
682
+ // notice can go without interleaving with a command's own output or with a prompt it is in
683
+ // the middle of asking, and it fires for whatever ran, so nothing has to remember to call it.
684
+ // `maybeOfferUpdate` stays silent unless the command actually talked to the backend and the
685
+ // build is genuinely behind; it never throws, because a notice must not fail a command that
686
+ // already succeeded.
687
+ program.hook("postAction", async () => {
688
+ await maybeOfferUpdate();
689
+ });
680
690
  program.parseAsync(process.argv);
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { getBinding } from "./keystore.js";
5
+ import { parseToolResult } from "./toolresult.js";
5
6
  /** Claude Code's config file. RETASC_CLAUDE_CONFIG overrides it (tests). */
6
7
  export function claudeConfigPath() {
7
8
  return process.env.RETASC_CLAUDE_CONFIG || join(homedir(), ".claude.json");
@@ -123,6 +124,18 @@ export function claudeLocalRetascEntry(dir) {
123
124
  export function bindingRefused(e) {
124
125
  return /\bUNAUTHORIZED\b/i.test(String(e?.message ?? e));
125
126
  }
127
+ /**
128
+ * A failure that says nothing about the key: see `isNetworkError`.
129
+ *
130
+ * Every caller of `resolveBinding` splits its failures two ways, and the split decides
131
+ * what the human is told to do. These three are neither a refusal nor a dead network,
132
+ * so they are tagged explicitly instead of being classified by accident.
133
+ */
134
+ function noVerdict(message) {
135
+ const e = new Error(message);
136
+ e.retascNoVerdict = true;
137
+ return e;
138
+ }
126
139
  /** Read the Retasc binding a folder's agent actually uses, from either legal
127
140
  * location — claude-local first, matching Claude Code's runtime precedence. */
128
141
  export function readLocalBinding(dir) {
@@ -217,6 +230,16 @@ export function sameIdentity(a, b) {
217
230
  * advice differs: "check your network / try again" vs "re-run retasc bind".
218
231
  * A 4xx (other than 429) IS a verdict: the server saw the key and said no. */
219
232
  export function isNetworkError(e) {
233
+ // Tagged rather than matched on its prose. `noVerdict` marks the failures where
234
+ // something answered but not Retasc, or Retasc answered in a shape we could not
235
+ // read: the server passed no judgement on the key either way. Without this they
236
+ // fall through to the 4xx branch and `doctor` says "key not accepted, re-run
237
+ // `retasc bind`" at somebody whose real problem is a proxy, which is both wrong
238
+ // and the most expensive wrong answer available (a re-bind mints a new key).
239
+ // A FLAG, not a regex, because this file's own comment is right that matching
240
+ // prose is fragile, and these strings are copy that will be reworded.
241
+ if (e?.retascNoVerdict === true)
242
+ return true;
220
243
  const name = String(e?.name ?? "");
221
244
  if (name === "TimeoutError" || name === "AbortError")
222
245
  return true;
@@ -248,7 +271,17 @@ export async function resolveBinding(url, key) {
248
271
  });
249
272
  if (!res.ok)
250
273
  throw new Error(`MCP server returned ${res.status}`);
251
- const body = await res.json();
274
+ // `res.json()` used to be called straight, so a 200 carrying a proxy's error page
275
+ // (or a captive portal's sign-in form) failed with a JSON parser's complaint about
276
+ // a `<`. Read the text and say what actually happened instead.
277
+ const raw = await res.text();
278
+ let body;
279
+ try {
280
+ body = raw ? JSON.parse(raw) : null;
281
+ }
282
+ catch {
283
+ throw noVerdict("a proxy or a VPN sign-in page answered instead of Retasc");
284
+ }
252
285
  if (body?.error)
253
286
  throw new Error(body.error.message ?? "MCP error");
254
287
  const result = body?.result;
@@ -256,6 +289,16 @@ export async function resolveBinding(url, key) {
256
289
  throw new Error(result?.content?.[0]?.text ?? "key not accepted");
257
290
  const text = result?.content?.[0]?.text;
258
291
  if (!text)
259
- throw new Error("empty whoami response");
260
- return JSON.parse(text);
292
+ throw noVerdict("the server said nothing at all about this key");
293
+ // Tolerant, and never a bare JSON.parse. This message is printed by `whoami` and
294
+ // `doctor` — the two commands someone runs when things are already wrong — so a
295
+ // "SyntaxError: Unexpected token … in JSON at position 42" here would answer a
296
+ // person's "what is broken?" with a parser's internal state. The shared parser also
297
+ // recovers a payload that arrived with prose after it (RTSC-142/143), which is the
298
+ // regression that made this line worth hardening in the first place.
299
+ const parsed = parseToolResult(body);
300
+ if (parsed.kind === "raw") {
301
+ throw noVerdict("the server's answer wasn't in a shape this CLI could read");
302
+ }
303
+ return parsed.value;
261
304
  }
package/dist/lib/claim.js CHANGED
@@ -6,6 +6,7 @@ import { readFileSync, existsSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { claudeLocalRetascEntry } from "./binding.js";
8
8
  import { resolveConn } from "./keystore.js";
9
+ import { clean } from "./text.js";
9
10
  // Tool-payload extraction lives in the shared tolerant parser (RTSC-143);
10
11
  // re-exported so existing importers of this module keep working.
11
12
  import { toolResult } from "./toolresult.js";
@@ -125,6 +126,63 @@ export function parseClaimResult(result) {
125
126
  const title = typeof r.issue === "object" ? r.issue?.title : undefined;
126
127
  return { issueId, title, claimToken: r.claimToken };
127
128
  }
129
+ /**
130
+ * A sentence a PERSON can act on for a non-2xx from the MCP endpoint.
131
+ *
132
+ * This path used to paste 200 characters of the raw body after the status, and the raw
133
+ * body is the one shape a reader cannot use: from Convex it is a JSON-RPC envelope, and
134
+ * when the failure happens at Cloudflare instead of in Convex it is a page of HTML. Both
135
+ * land in the terminal of somebody who typed `retasc claim`, and neither says what to do.
136
+ *
137
+ * So the body is never printed. It is READ — for a message, if it carries one — and what
138
+ * gets shown is what the status means, in words, plus the fix where there is one. The
139
+ * status is kept, in parentheses, because it is the thing worth quoting in a bug report.
140
+ *
141
+ * Pure, and exported, so the wording is pinned by a test rather than by a live 401.
142
+ */
143
+ export function mcpFailureMessage(status, body = "", url) {
144
+ const said = bodyMessage(body);
145
+ const why = status === 401 || status === 403
146
+ ? "Retasc refused this folder's key. Re-run `retasc bind` here to mint a fresh one."
147
+ : status === 404
148
+ ? `Nothing is serving Retasc at ${url ? clean(url) : "that address"}. Check this folder's MCP url, or re-run \`retasc bind\`.`
149
+ : status === 429
150
+ ? "Retasc is rate-limiting this key. Wait a moment, then try again."
151
+ : status >= 500
152
+ ? "Retasc's server had a problem. Nothing was claimed or changed — try again in a minute."
153
+ : "Retasc refused the request.";
154
+ return `${why} (HTTP ${status}${said ? `: ${said}` : ""})`;
155
+ }
156
+ /**
157
+ * The one sentence inside an error body, or nothing.
158
+ *
159
+ * Only ever returns text the server MEANT as a message — a JSON-RPC `error.message`, a
160
+ * tool result's text, a bare `message` field. Anything else (HTML, a payload with no
161
+ * message in it, a body that is not JSON at all) returns undefined, so the caller says
162
+ * its own sentence rather than quoting a structure at the reader. Control characters are
163
+ * stripped and it is capped at one line: this reaches a terminal that acts on escapes.
164
+ */
165
+ function bodyMessage(body) {
166
+ if (!/^\s*[{[]/.test(body))
167
+ return undefined;
168
+ let parsed;
169
+ try {
170
+ parsed = JSON.parse(body);
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ const said = [parsed?.error?.message, parsed?.result?.content?.[0]?.text, parsed?.message].find((c) => typeof c === "string" && c.trim());
176
+ if (!said)
177
+ return undefined;
178
+ const line = clean(said).split("\n")[0].trim();
179
+ // A "message" that is itself a payload is the very thing this function exists to keep
180
+ // off the screen — a server can put its JSON result in `content[0].text`, and quoting
181
+ // that back would reintroduce the blob one layer down.
182
+ if (!line || /^[{[]/.test(line))
183
+ return undefined;
184
+ return line.slice(0, 200);
185
+ }
128
186
  /**
129
187
  * Call one MCP tool over JSON-RPC with the agent key. Throws on transport errors
130
188
  * and on tool errors (isError) — surfacing the server's message (CLAIM_LOST,
@@ -147,12 +205,23 @@ export async function mcpCall(conn, name, args = {}, fetchImpl = fetch) {
147
205
  });
148
206
  const text = await res.text();
149
207
  // A non-2xx (expired/invalid key → 401, server error → 5xx) must not look like
150
- // an empty queue. Surface the status + a body snippet so a bad key is obvious.
151
- if (!res.ok) {
152
- const snippet = text ? `: ${text.slice(0, 200).replace(/\s+/g, " ").trim()}` : "";
153
- throw new Error(`MCP request failed (HTTP ${res.status})${snippet}`);
208
+ // an empty queue say what happened, in words. See mcpFailureMessage.
209
+ if (!res.ok)
210
+ throw new Error(mcpFailureMessage(res.status, text, conn.url));
211
+ // A 200 whose body is not JSON is a captive portal, a proxy error page, or a
212
+ // deployment answering on the wrong route. Letting JSON.parse throw puts
213
+ // "Unexpected token < in JSON at position 0" in front of a person, which names
214
+ // neither the problem nor the fix.
215
+ let resp = null;
216
+ if (text) {
217
+ try {
218
+ resp = JSON.parse(text);
219
+ }
220
+ catch {
221
+ throw new Error(`${clean(conn.url)} answered, but not with Retasc's reply — something in between (a proxy, ` +
222
+ `a captive portal, a VPN sign-in page) is intercepting it.`);
223
+ }
154
224
  }
155
- const resp = text ? JSON.parse(text) : null;
156
225
  if (resp?.error) {
157
226
  throw new Error(resp.error?.message || "MCP transport error");
158
227
  }
@@ -168,11 +168,17 @@ export function versionStamp(version, argv1 = process.argv[1] ?? "") {
168
168
  const how = /[\\/]_npx[\\/]/.test(argv1) ? `npx ${PKG}@${version}` : `${PKG} ${version}`;
169
169
  return ` (${how})`;
170
170
  }
171
- /** Install (or upgrade to) an exact version globally. Returns null on success. */
172
- function installGlobal(version) {
173
- // A cold global install takes seconds with no output of its own. Silence here reads as
174
- // a hang in the middle of a bind, so say what is happening before it starts.
175
- console.log(" Installing the retasc CLI so your agent can start it…");
171
+ /**
172
+ * Install (or upgrade to) an exact version globally. Returns null on success, and the
173
+ * first useful line of npm's failure otherwise.
174
+ *
175
+ * Exported since RTSC-520, which upgrades an existing install rather than creating one —
176
+ * same npm invocation, different reason. The "what is happening" line moved OUT to the
177
+ * callers with it: a cold install takes seconds with no output of its own and silence
178
+ * reads as a hang, but the sentence that makes sense mid-`bind` ("so your agent can start
179
+ * it") is the wrong sentence for someone who just answered y to an update prompt.
180
+ */
181
+ export function installGlobal(version) {
176
182
  let r;
177
183
  try {
178
184
  r = spawnSync("npm", ["install", "-g", `${PKG}@${version}`], {
@@ -228,7 +234,9 @@ export function resolveLauncher(opts) {
228
234
  });
229
235
  if (opts.install === false)
230
236
  return npxFallback("install not attempted");
231
- // 2. Try to make `retasc` real.
237
+ // 2. Try to make `retasc` real. Say so first — a cold install takes seconds in silence
238
+ // and this one lands in the middle of a bind, where silence reads as a hang.
239
+ console.log(" Installing the retasc CLI so your agent can start it…");
232
240
  const failure = installGlobal(opts.version);
233
241
  // 3. Prove it, by running it. An exit code of 0 is not evidence the command resolves:
234
242
  // npm can install happily into a prefix whose bin directory PATH never searches.
@@ -0,0 +1,172 @@
1
+ // "You are running an old build" — the one thing the CLI never said (RTSC-520).
2
+ //
3
+ // A stale install is not merely missing features. CLI copy describes SERVER behaviour,
4
+ // so an old build makes false statements about the account in front of it: 1.11.0 told
5
+ // people a decline "covers the whole org, including later migrations" long after
6
+ // RTSC-507 made that untrue, and 1.10.0 had no Google door at all, so it sent a
7
+ // Google-only human through GitHub and minted them a second identity. Both are worse
8
+ // than a missing feature, because nothing on screen hints the sentence is out of date.
9
+ //
10
+ // The stance, and it is deliberate: NOTIFY, THEN ASK. Never a silent `npm i -g`. That
11
+ // mutates someone's machine unasked, fails on a root-owned prefix, and can swap the
12
+ // binary under a running agent. `resolveLauncher` already refuses to surprise-install
13
+ // for someone who manages their own install; this must not undercut it.
14
+ //
15
+ // Four rules fall out of that, and every one of them is a test below:
16
+ // - Interactive only. No TTY ⇒ the notice, never the prompt and never the install.
17
+ // - Never in the MCP proxy. An upgrade nag inside an agent's tool results is noise in
18
+ // a machine channel. Structurally impossible here: the signal is only ever recorded
19
+ // by `api.me()`, and the proxy does not import api.js.
20
+ // - At most once a day, stamped in ~/.retasc/config.json. Declining must not re-ask on
21
+ // the next command.
22
+ // - A failed install is NOT a failed command. EACCES is the common one. Report it, name
23
+ // the manual command, and carry on with whatever the human actually ran.
24
+ //
25
+ // Everything goes to STDERR. `retasc issue list --json | jq` must keep working byte for
26
+ // byte, and a notice on stdout would break exactly the scripted callers the issue
27
+ // promises not to disturb.
28
+ import { VERSION } from "../version.js";
29
+ import { loadConfig, patchConfig } from "../config.js";
30
+ import { confirm, isInteractive } from "./prompt.js";
31
+ import { installGlobal } from "./launcher.js";
32
+ const PKG = "@retasc/cli";
33
+ export const MANUAL_INSTALL = `npm i -g ${PKG}@latest`;
34
+ /** Once a day. The notice is worth reading; a notice on every command is wallpaper. */
35
+ export const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
36
+ /**
37
+ * The signal from the most recent `me()` this process made.
38
+ *
39
+ * A module slot rather than a parameter because the notice fires from a postAction hook
40
+ * that has no idea which commands talked to the backend. This is also what scopes the
41
+ * feature correctly for free: only a login-gated command calls `me()`, so only a
42
+ * login-gated command can ever have something to say.
43
+ */
44
+ let recorded;
45
+ /**
46
+ * A published version and nothing else: three numbers, optionally a prerelease tag.
47
+ *
48
+ * Validated HERE as well as on the server, and not out of politeness. These strings do two
49
+ * dangerous things: they are printed to a terminal, and `latest` is handed to `spawnSync`
50
+ * as the version half of `@retasc/cli@<v>` — which on Windows runs through a shell. Trusting
51
+ * the server for the CLI's own subprocess and terminal safety is the wrong shape of trust;
52
+ * `formatError` already refuses it for backend error strings, for the same reason.
53
+ */
54
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
55
+ const clean = (v) => typeof v === "string" && VERSION_RE.test(v.trim()) ? v.trim() : null;
56
+ export function recordRelease(signal) {
57
+ if (!signal || typeof signal !== "object")
58
+ return;
59
+ const { latest, minHonest } = signal;
60
+ recorded = { latest: clean(latest), minHonest: clean(minHonest) };
61
+ }
62
+ export function releaseSignal() {
63
+ return recorded;
64
+ }
65
+ /** Test seam only — the slot is process-global by design. */
66
+ export function resetRelease() {
67
+ recorded = undefined;
68
+ }
69
+ /**
70
+ * Compare two versions: negative if a < b, 0 if equal, positive if a > b.
71
+ *
72
+ * Deliberately small. npm's `latest` dist-tag is a release, and this only ever compares
73
+ * against it, so full semver precedence would be dead code — but a PRERELEASE has to
74
+ * sort below its own release (`1.36.0-rc.1` < `1.36.0`) or someone testing an rc would
75
+ * be told they are ahead of the world and never hear about the real 1.36.0.
76
+ * Unparseable input answers 0, which reads as "no opinion" and keeps us silent.
77
+ */
78
+ export function compareVersions(a, b) {
79
+ const parse = (v) => {
80
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
81
+ return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] } : null;
82
+ };
83
+ const x = parse(a);
84
+ const y = parse(b);
85
+ if (!x || !y)
86
+ return 0;
87
+ for (let i = 0; i < 3; i++)
88
+ if (x.nums[i] !== y.nums[i])
89
+ return x.nums[i] - y.nums[i];
90
+ if (x.pre === y.pre)
91
+ return 0;
92
+ if (x.pre === undefined)
93
+ return 1; // a release outranks any prerelease of itself
94
+ if (y.pre === undefined)
95
+ return -1;
96
+ return x.pre < y.pre ? -1 : 1;
97
+ }
98
+ /**
99
+ * The whole decision, with every edge injected so the interesting paths — no TTY, a
100
+ * root-owned prefix, a deployment too old to send the field — can be tested without
101
+ * owning a terminal, an npm prefix, or a backend.
102
+ */
103
+ export async function offerUpdate(deps) {
104
+ const signal = deps.signal();
105
+ const latest = signal?.latest;
106
+ // No signal at all is the normal case for an old deployment or a cold cache, and it
107
+ // means exactly one thing: we do not know, so we do not speak.
108
+ if (!latest)
109
+ return "silent";
110
+ if (compareVersions(deps.version, latest) >= 0)
111
+ return "silent";
112
+ const now = deps.now();
113
+ const last = deps.lastNoticeAt();
114
+ if (last !== undefined && now - last < NOTICE_INTERVAL_MS)
115
+ return "silent";
116
+ // Stamp on PRINT, not on answer: someone who declines and someone who ignores the line
117
+ // have both been told today, and re-asking either tomorrow-minus-one-command is the
118
+ // nagging this gate exists to prevent. Stamped before the prompt so a Ctrl-C mid-question
119
+ // doesn't reset the clock either.
120
+ deps.stampNotice(now);
121
+ // Two different problems, said differently. "Newer exists" is an invitation; "your copy
122
+ // no longer matches the server" is a warning that what they just read may be false.
123
+ const dishonest = signal?.minHonest ? compareVersions(deps.version, signal.minHonest) < 0 : false;
124
+ if (dishonest) {
125
+ deps.out(`This retasc is ${deps.version}; ${latest} is published.`);
126
+ deps.out(" Its wording no longer matches how the server behaves, so what it tells you about your account may be out of date.");
127
+ }
128
+ else {
129
+ deps.out(`A newer retasc is available (${latest}, you have ${deps.version}).`);
130
+ }
131
+ // No TTY: the notice is the whole of it. A scripted run keeps behaving as it does today.
132
+ if (!deps.interactive()) {
133
+ deps.out(` To update: ${MANUAL_INSTALL}`);
134
+ return "noticed";
135
+ }
136
+ if (!(await deps.ask("Update now?"))) {
137
+ deps.out(` Left alone. To update later: ${MANUAL_INSTALL}`);
138
+ return "declined";
139
+ }
140
+ deps.out(` Updating to ${latest}…`);
141
+ const failure = deps.install(latest);
142
+ if (failure) {
143
+ // NOT a failed command. Whatever they actually ran already succeeded above this line.
144
+ deps.out(` Couldn't update (${failure}).`);
145
+ deps.out(` To do it by hand: ${MANUAL_INSTALL}`);
146
+ return "install-failed";
147
+ }
148
+ // Say where it took effect, or the next thing they see will look like it didn't work:
149
+ // this process is still the OLD build, and every already-running agent is still pointed
150
+ // at the old MCP proxy. Same beat RTSC-519 added to `bind`.
151
+ deps.out(` ✓ Updated to ${latest}. It applies from your next command — restart your agents to pick up the new MCP server.`);
152
+ return "installed";
153
+ }
154
+ /** The real-deps entry point. Never throws: a notice must not be able to fail a command. */
155
+ export async function maybeOfferUpdate() {
156
+ try {
157
+ return await offerUpdate({
158
+ version: VERSION,
159
+ signal: releaseSignal,
160
+ interactive: isInteractive,
161
+ lastNoticeAt: () => loadConfig().updateNoticeAt,
162
+ stampNotice: (at) => patchConfig({ updateNoticeAt: at }),
163
+ ask: (q) => confirm(q),
164
+ install: installGlobal,
165
+ out: (line) => console.error(line),
166
+ now: () => Date.now(),
167
+ });
168
+ }
169
+ catch {
170
+ return "silent";
171
+ }
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {