@retasc/cli 1.2.4 → 1.3.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/dist/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ConvexHttpClient } from "convex/browser";
2
2
  import { makeFunctionReference } from "convex/server";
3
3
  import { loadConfig } from "./config.js";
4
+ import { refreshSession, deviceLogin } from "./auth.js";
4
5
  // Typed-ish references to the public management functions in convex/manage.ts.
5
6
  // The CLI is a standalone package, so we reference functions by name rather than
6
7
  // importing the parent's generated api.
@@ -26,18 +27,65 @@ function client() {
26
27
  c.setAuth(cfg.token);
27
28
  return c;
28
29
  }
30
+ // Does this error mean "the access token is missing/expired", i.e. a refresh
31
+ // might fix it? The access-token JWT lives ~1h, so any long-lived login trips
32
+ // this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
33
+ // (requireUser), and the Convex platform rejects a stale JWT with "Could not
34
+ // verify OIDC token"/"Unauthenticated". Match either, case-insensitively.
35
+ export function isAuthError(e) {
36
+ const msg = String(e?.message ?? e);
37
+ return /unauthenticated|could not verify oidc|oidc token/i.test(msg);
38
+ }
39
+ /**
40
+ * Run an authed management call, transparently recovering from an expired
41
+ * access token. The two-token design (short access JWT + long refresh token) is
42
+ * the point: the CLI silently rides the refresh token across the hourly access
43
+ * boundary instead of forcing `retasc login` every hour (RTSC-165).
44
+ *
45
+ * On an auth error we try ONE recovery, then retry the call ONCE:
46
+ * 1. If there is no stored token at all, the user was never signed in — this
47
+ * isn't an expiry, so rethrow the clean "sign in first" error rather than
48
+ * launching a surprise device flow under a misleading "session expired".
49
+ * 2. `refreshSession()` — swap the refresh token for a fresh access token.
50
+ * 3. If that fails (refresh token expired/invalid), fall back to a full
51
+ * `deviceLogin()` — but only when stdout is a TTY, since the device flow
52
+ * prints a code to stdout for a human to authorize; in a captured/piped
53
+ * context we surface a clear "run `retasc login`" error instead of hanging
54
+ * or spilling the prompt into someone's `$(retasc …)` capture.
55
+ * A second auth failure after recovery is real (rethrown) — we never loop.
56
+ */
57
+ async function withAuth(call) {
58
+ try {
59
+ return await call();
60
+ }
61
+ catch (e) {
62
+ if (!isAuthError(e))
63
+ throw e;
64
+ // No session to recover — never signed in. Surface the original error.
65
+ if (!loadConfig().token)
66
+ throw e;
67
+ if (await refreshSession())
68
+ return await call();
69
+ if (!process.stdout.isTTY) {
70
+ throw new Error("Session expired and could not refresh. Run `retasc login`.");
71
+ }
72
+ console.error("Session expired — re-authenticating with GitHub…");
73
+ await deviceLogin();
74
+ return await call();
75
+ }
76
+ }
29
77
  export const api = {
30
- me: () => client().query(fns.me, {}),
31
- listProjects: (args) => client().query(fns.listProjects, args),
32
- createOrg: (args) => client().mutation(fns.createOrg, args),
33
- createProject: (args) => client().mutation(fns.createProject, args),
34
- renameProjectPrefix: (args) => client().mutation(fns.renameProjectPrefix, args),
35
- listKeys: (args) => client().query(fns.listKeys, args),
36
- mintKey: (args) => client().action(fns.mintKey, args),
37
- rotateKey: (args) => client().action(fns.rotateKey, args),
38
- revokeKey: (args) => client().mutation(fns.revokeKey, args),
39
- createInvite: (args) => client().action(fns.createInvite, args),
40
- acceptInvite: (args) => client().mutation(fns.acceptInvite, args),
41
- listInvites: (args) => client().query(fns.listInvites, args),
42
- revokeInvite: (args) => client().mutation(fns.revokeInvite, args),
78
+ me: () => withAuth(() => client().query(fns.me, {})),
79
+ listProjects: (args) => withAuth(() => client().query(fns.listProjects, args)),
80
+ createOrg: (args) => withAuth(() => client().mutation(fns.createOrg, args)),
81
+ createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
82
+ renameProjectPrefix: (args) => withAuth(() => client().mutation(fns.renameProjectPrefix, args)),
83
+ listKeys: (args) => withAuth(() => client().query(fns.listKeys, args)),
84
+ mintKey: (args) => withAuth(() => client().action(fns.mintKey, args)),
85
+ rotateKey: (args) => withAuth(() => client().action(fns.rotateKey, args)),
86
+ revokeKey: (args) => withAuth(() => client().mutation(fns.revokeKey, args)),
87
+ createInvite: (args) => withAuth(() => client().action(fns.createInvite, args)),
88
+ acceptInvite: (args) => withAuth(() => client().mutation(fns.acceptInvite, args)),
89
+ listInvites: (args) => withAuth(() => client().query(fns.listInvites, args)),
90
+ revokeInvite: (args) => withAuth(() => client().mutation(fns.revokeInvite, args)),
43
91
  };
package/dist/auth.js CHANGED
@@ -76,3 +76,39 @@ export async function deviceLogin() {
76
76
  }
77
77
  patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
78
78
  }
79
+ /**
80
+ * Silently mint a fresh access token from the stored refresh token and persist
81
+ * the rotated pair to ~/.retasc/config.json.
82
+ *
83
+ * Convex Auth access tokens (the JWT in `token`) live only ~1h; the refresh
84
+ * token is long-lived (session default ~30d) and exists precisely to swap for a
85
+ * new access token without re-running the device flow. Calling `auth:signIn`
86
+ * with `{ refreshToken }` (no provider/params) is Convex Auth's refresh grant —
87
+ * it returns `{ tokens: { token, refreshToken } }` with BOTH rotated (refresh
88
+ * tokens are single-use; the old one is invalidated), so we must write the new
89
+ * pair back immediately and never reuse the old refresh token.
90
+ *
91
+ * Returns true on success (config now holds a fresh session), false if there is
92
+ * no refresh token or the grant is rejected (expired/invalid/reused) — the
93
+ * caller then falls back to a full `deviceLogin()`. Never throws and never
94
+ * prints token values.
95
+ */
96
+ export async function refreshSession() {
97
+ const cfg = loadConfig();
98
+ if (!cfg.refreshToken)
99
+ return false;
100
+ try {
101
+ const convex = new ConvexHttpClient(cfg.deploymentUrl);
102
+ const res = await convex.action(signIn, { refreshToken: cfg.refreshToken });
103
+ const tokens = res?.tokens;
104
+ if (!tokens?.token)
105
+ return false;
106
+ patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
107
+ return true;
108
+ }
109
+ catch {
110
+ // Expired/invalid/reused refresh token, or a transient backend error. Treat
111
+ // as "cannot refresh" — the caller re-authenticates via the device flow.
112
+ return false;
113
+ }
114
+ }
package/dist/index.js CHANGED
@@ -434,7 +434,11 @@ program
434
434
  .option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
435
435
  .option("--force", "Also delete orphans (issue done but branch unmerged)")
436
436
  .option("--json", "Emit the reconciled branch table as JSON")
437
- .action((opts) => tidyAction(opts).catch(fail));
437
+ .option("--only <RTSC-NN>", "Scope the sweep to a single issue's branch (used by the MCP auto-reap)")
438
+ .action((opts) =>
439
+ // Normalize --only to the canonical uppercase id so it matches the branch ids
440
+ // scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
441
+ tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
438
442
  program
439
443
  .command("done")
440
444
  .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
@@ -38,15 +38,59 @@ export function applyObservation(leases, o) {
38
38
  }
39
39
  // Releases / terminal status drop the lease — the issue is named in the REQUEST.
40
40
  const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
41
- if (id) {
42
- if (o.toolName === "release_issue")
43
- leases.delete(id);
44
- if (o.toolName === "save_issue") {
45
- const status = o.args?.status;
46
- if (status === "done" || status === "canceled")
47
- leases.delete(id);
48
- }
49
- }
41
+ if (id && o.toolName === "release_issue")
42
+ leases.delete(id);
43
+ const closeId = terminalCloseId(o);
44
+ if (closeId)
45
+ leases.delete(closeId);
46
+ }
47
+ /**
48
+ * The issue id to reap if this observation is a terminal close (done|canceled) via
49
+ * `save_issue`, else undefined. This is the SAME rule applyObservation uses to drop
50
+ * the lease — factored out so "which calls end an issue" lives in one place and the
51
+ * proxy's auto-reap (RTSC-181) can't drift from the lease bookkeeping. The caller
52
+ * decides whether it actually held the lease and whether the call succeeded
53
+ * (`isToolError`) before acting — this only classifies the request shape.
54
+ */
55
+ export function terminalCloseId(o) {
56
+ if (o.toolName !== "save_issue")
57
+ return undefined;
58
+ const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
59
+ if (!id)
60
+ return undefined;
61
+ const status = o.args?.status;
62
+ return status === "done" || status === "canceled" ? id : undefined;
63
+ }
64
+ /**
65
+ * Did a tools/call result come back as an MCP error envelope (`{ isError: true }`)?
66
+ * Used to gate side effects — the proxy must never reap a branch off a `save_issue`
67
+ * that actually FAILED server-side. Distinct from isClaimLost (which matches a
68
+ * specific error string); this is the generic "did the tool error at all" check.
69
+ */
70
+ export function isToolError(result) {
71
+ return (!!result && typeof result === "object" && result.isError === true);
72
+ }
73
+ /**
74
+ * The full RTSC-181 reap gate as ONE pure decision: given an observed tool call and
75
+ * the leases this session holds, return the issue id to reap — or undefined. Reap ONLY
76
+ * when ALL hold:
77
+ * 1. it's a terminal close (save_issue done|canceled),
78
+ * 2. of an issue THIS session actually claimed (in `leases` — never someone else's),
79
+ * 3. and the call genuinely succeeded — a truthy payload that is not an error
80
+ * envelope. A transport-level failure yields an undefined result (no envelope to
81
+ * catch); requiring a truthy payload means we never reap off a failed close.
82
+ * This is the security-critical check — keeping it pure makes every branch testable
83
+ * (the proxy just spawns on a non-undefined return).
84
+ */
85
+ export function shouldReapOnClose(o, leases) {
86
+ const id = terminalCloseId(o);
87
+ if (!id)
88
+ return undefined;
89
+ if (!leases.has(id))
90
+ return undefined;
91
+ if (!o.result || isToolError(o.result))
92
+ return undefined;
93
+ return id;
50
94
  }
51
95
  /** A JSON-RPC `heartbeat` tool call for one lease (the proxy's timer sends these). */
52
96
  export function heartbeatRequest(rpcId, issueId, claimToken) {
package/dist/proxy.js CHANGED
@@ -7,7 +7,10 @@
7
7
  // no watchdog. Self-enforcing: no proxy → no Retasc tools → can't orphan a lease.
8
8
  import { createInterface } from "node:readline";
9
9
  import { hostname } from "node:os";
10
- import { applyObservation, heartbeatRequest, isClaimLost, } from "./lib/watchdog.js";
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { applyObservation, heartbeatRequest, isClaimLost, shouldReapOnClose, } from "./lib/watchdog.js";
11
14
  import { resolveConn } from "./lib/keystore.js";
12
15
  import { toolResult as parseTool } from "./lib/toolresult.js";
13
16
  import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
@@ -30,6 +33,87 @@ let sessionKeyFallback = false;
30
33
  function log(msg) {
31
34
  process.stderr.write(`[retasc-watchdog] ${msg}\n`);
32
35
  }
36
+ // RTSC-181: closing an issue over MCP (save_issue status:done) marks it done on the
37
+ // control plane but the server never touches git — so the branch/worktree orphans.
38
+ // The proxy is the local hands: when it sees a session's own claim close terminally,
39
+ // it reaps that issue's branch out-of-band via the CLI's existing `tidy --prune`,
40
+ // which reuses every safety guard (skips dirty/unmerged/current-branch). Resolved
41
+ // once at startup; null if we're not in a git repo (nothing to reap).
42
+ const MAIN_CHECKOUT = resolveMainCheckout();
43
+ // The compiled CLI entrypoint (dist/index.js) — resolved relative to this module so
44
+ // it's correct however the proxy was launched (not process.argv, which a wrapper
45
+ // could rewrite). Reaps re-invoke it as `retasc tidy`.
46
+ const CLI_ENTRY = fileURLToPath(new URL("./index.js", import.meta.url));
47
+ // Issues whose reap child is in flight — so a duplicate/retried close doesn't spawn
48
+ // two concurrent teardowns racing on the same branch.
49
+ const reaping = new Set();
50
+ // Reaps are SERIALIZED through this chain: concurrent `tidy` children mutating the
51
+ // same main checkout contend on `.git` locks (index.lock, ref locks) — a wave-close
52
+ // (next_batch → N × save_issue done) would otherwise fire N parallel worktree/branch
53
+ // deletes and hit "cannot lock ref" errors + partial teardown. One at a time avoids it.
54
+ let reapChain = Promise.resolve();
55
+ const REAP_TIMEOUT_MS = 60_000; // a stuck git (e.g. a credential prompt) must not wedge the queue
56
+ /** The main checkout root (worktree/branch ops always target it), or null if not in
57
+ * a git repo. `--git-common-dir` resolves to the MAIN `.git` even from a worktree. */
58
+ function resolveMainCheckout() {
59
+ const r = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
60
+ if (r.status !== 0)
61
+ return null;
62
+ const commonDir = (r.stdout ?? "").trim();
63
+ return commonDir ? dirname(resolve(commonDir)) : null;
64
+ }
65
+ /**
66
+ * Reap a terminally-closed issue's branch/worktree, out-of-band (RTSC-181). Enqueues
67
+ * a `retasc tidy --prune --only <id>` run (serialized — see `reapChain`) as a SEPARATE
68
+ * process rooted at the main checkout so (a) blocking git never stalls the proxy's
69
+ * stdio loop, and (b) cwd is the main checkout, not the agent's worktree — so `tidy`'s
70
+ * "you're inside its worktree" guard doesn't skip the very tree we mean to remove.
71
+ * `tidy` still enforces every safety check: an unmerged (or dirty) branch is left as an
72
+ * orphan, never force-deleted — so only a clean, merged worktree is ever removed.
73
+ */
74
+ function reapClosedIssue(issueId) {
75
+ if (!MAIN_CHECKOUT)
76
+ return; // not in a git repo — nothing local to reap
77
+ if (reaping.has(issueId))
78
+ return; // a teardown for this issue is already queued/running
79
+ reaping.add(issueId);
80
+ // Chain after any in-flight reap so git ops on the shared checkout never overlap.
81
+ reapChain = reapChain.then(() => runReap(issueId));
82
+ }
83
+ /** Run one `tidy --prune --only` child to completion (resolve on exit/error/timeout).
84
+ * Never rejects — a failed reap is best-effort cleanup, logged, never fatal. */
85
+ function runReap(issueId) {
86
+ return new Promise((resolve) => {
87
+ log(`${issueId} closed → reaping its branch/worktree…`);
88
+ const child = spawn(process.execPath, [CLI_ENTRY, "tidy", "--prune", "--only", issueId], {
89
+ cwd: MAIN_CHECKOUT,
90
+ env: process.env,
91
+ // stdout is ignored (tidy reports on stderr); stderr inherits so its reap log
92
+ // lands in the same MCP stderr stream as the rest of the watchdog's output.
93
+ stdio: ["ignore", "ignore", "inherit"],
94
+ });
95
+ let settled = false;
96
+ const finish = () => {
97
+ if (settled)
98
+ return;
99
+ settled = true;
100
+ clearTimeout(timer);
101
+ reaping.delete(issueId);
102
+ resolve();
103
+ };
104
+ const timer = setTimeout(() => {
105
+ log(`reap of ${issueId} timed out — killing it`);
106
+ child.kill("SIGKILL"); // 'exit' still fires → finish() runs
107
+ }, REAP_TIMEOUT_MS);
108
+ timer.unref?.();
109
+ child.on("error", (e) => {
110
+ log(`reap of ${issueId} couldn't start: ${String(e?.message ?? e)}`);
111
+ finish();
112
+ });
113
+ child.on("exit", finish);
114
+ child.unref?.(); // a pending reap must not keep the proxy alive
115
+ });
116
+ }
33
117
  async function postRemote(body) {
34
118
  const res = await fetch(MCP_URL, {
35
119
  method: "POST",
@@ -120,18 +204,25 @@ async function handleLine(line) {
120
204
  // Watch tools/call traffic for claims/releases (request args + result), and
121
205
  // flag the workspace-key fallback on whoami so the AGENT sees the degraded
122
206
  // state (RTSC-143) — the startup stderr warning only reaches the MCP logs.
207
+ let reapId;
123
208
  if (msg.method === "tools/call" && resp) {
124
- applyObservation(leases, {
125
- toolName: msg.params?.name,
126
- args: msg.params?.arguments,
127
- result: toolResult(resp, msg.params?.name),
128
- });
209
+ const result = toolResult(resp, msg.params?.name);
210
+ const obs = { toolName: msg.params?.name, args: msg.params?.arguments, result };
211
+ // Decide the reap BEFORE applyObservation drops the lease (the gate checks lease
212
+ // ownership). Pure decision — see shouldReapOnClose. We act on it AFTER relaying
213
+ // the response, below (RTSC-181).
214
+ reapId = shouldReapOnClose(obs, leases);
215
+ applyObservation(leases, obs);
129
216
  appendFallbackNotice(msg.params?.name, resp, sessionKeyFallback);
130
217
  }
131
218
  // Relay the response (requests have an id; notifications don't).
132
219
  if (resp != null && msg.id !== undefined) {
133
220
  process.stdout.write(JSON.stringify(resp) + "\n");
134
221
  }
222
+ // Reap only after the tool result is on the wire — spawns its own process, so git
223
+ // never delays the response the agent is blocked on (RTSC-181).
224
+ if (reapId)
225
+ reapClosedIssue(reapId);
135
226
  }
136
227
  async function heartbeatAll() {
137
228
  for (const [issueId, token] of [...leases]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "Retasc CLI — sign in with GitHub, 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": {
@@ -46,7 +46,7 @@
46
46
  "convex": "^1.41.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^22.0.0",
49
+ "@types/node": "^26.1.0",
50
50
  "typescript": "^5.6.0"
51
51
  }
52
52
  }