@retasc/cli 1.29.0 → 1.31.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/index.js CHANGED
@@ -5,7 +5,7 @@ import { selfCommand, versionStamp } from "./lib/launcher.js";
5
5
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
6
6
  import { installMcp, normalizeScope } from "./commands/mcp.js";
7
7
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
8
- import { claimAction } from "./commands/claim.js";
8
+ import { claimAction, releaseAction } from "./commands/claim.js";
9
9
  import { bindAction, setupFromToken } from "./commands/bind.js";
10
10
  import { joinAction } from "./commands/join.js";
11
11
  import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
@@ -195,6 +195,13 @@ program
195
195
  // RTSC-495 — the agent's door. Everything this command normally asks was already
196
196
  // answered in the Dash, so the token stands in for all of it and nothing is prompted.
197
197
  .option("--setup <token>", "Complete setup from a Dash setup code — no sign-in, no prompts")
198
+ // RTSC-713 — the OTHER agent door, and the one that needs no Dash trip first.
199
+ //
200
+ // Three behaviours, deliberately on one flag because none is useful alone: NDJSON
201
+ // outcomes on stdout, the browser door without a TTY, and continuable states reported
202
+ // at exit 0 instead of thrown. Structured output an agent cannot act on, or a door
203
+ // that leads to a thrown Error, is each worse than neither.
204
+ .option("--json", "Report machine-readable outcomes (for an agent driving setup)")
198
205
  .action(async (opts) => {
199
206
  // BEFORE requireLogin: the whole point is a machine that has never signed in. The
200
207
  // token is the authorization, and asking for a session here would refuse every
@@ -593,6 +600,13 @@ program
593
600
  // Normalize --only to the canonical uppercase id so it matches the branch ids
594
601
  // scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
595
602
  tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
603
+ program
604
+ .command("release [issue]")
605
+ .description("Hand a claimed issue back to the queue. Leaves your worktree and branch alone.")
606
+ .option("--id <RTSC-NN>", "The issue to release (or pass it positionally)")
607
+ .requiredOption("--claim-token <token>", "The claim token printed when you claimed it (the server fences on it)")
608
+ .option("--note <text>", "Handoff note for whoever picks it up next")
609
+ .action((issueArg, opts) => releaseAction({ issueArg, ...opts }).catch(fail));
596
610
  program
597
611
  .command("done")
598
612
  .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
@@ -119,6 +119,55 @@ export function readShadowedBinding(dir) {
119
119
  return undefined;
120
120
  return parseServerEntry(folderEntry(dir), "folder");
121
121
  }
122
+ /**
123
+ * Is this a PLACEHOLDER entry — a bare URL with no credential of any kind (RTSC-691)?
124
+ *
125
+ * RTSC-678's flow starts by pointing the client at `https://mcp.retasc.com/mcp` with
126
+ * nothing else in it, so the server can answer the handshake with setup instructions.
127
+ * That entry is not a binding: it cannot authenticate, and every tool call through it
128
+ * fails. It exists to be replaced by the real one `bind` writes.
129
+ *
130
+ * It has to be recognised so it can be REMOVED, because scope precedence would otherwise
131
+ * make it win. `claude mcp add -t http` writes Claude-local scope by default; a `bind
132
+ * --scope project` writes `./.mcp.json`; and claude-local beats the folder marker at
133
+ * runtime (see the precedence note at the top of this file). Left in place, the finished
134
+ * setup would sit in a file nothing reads while the credential-less entry took every
135
+ * call — a setup that looks done and works nowhere.
136
+ *
137
+ * Deliberately narrow: a `url` and NO `command`, no env credential, no Authorization
138
+ * header. Anything carrying a credential, or spawning something, is somebody's real
139
+ * configuration and is never silently removed.
140
+ */
141
+ export function isPlaceholderEntry(raw, knownUrls = PLACEHOLDER_URLS) {
142
+ if (!raw || typeof raw !== "object")
143
+ return false;
144
+ if (typeof raw.url !== "string" || raw.url.length === 0)
145
+ return false;
146
+ if (typeof raw.command === "string")
147
+ return false;
148
+ if (raw.env && Object.keys(raw.env).length > 0)
149
+ return false;
150
+ // ANY header, not just Authorization. A gateway that injects credentials under some
151
+ // other name is still somebody's real configuration.
152
+ if (raw.headers && Object.keys(raw.headers).length > 0)
153
+ return false;
154
+ // And it must be the bootstrap URL we ourselves tell people to paste. This is the
155
+ // clause that matters: Claude Code stores MCP OAuth tokens OUTSIDE the server entry,
156
+ // keyed by server name, so a fully authorized OAuth binding is byte-identical to a
157
+ // placeholder by shape alone. RTSC-678 — this issue's parent — is about adding exactly
158
+ // that OAuth door, so shape-only matching would have grown a way to silently delete a
159
+ // working binding at the moment we shipped one.
160
+ return knownUrls.includes(normalizeUrl(raw.url));
161
+ }
162
+ /** The bare URLs we hand out for bootstrapping, normalized. */
163
+ const PLACEHOLDER_URLS = ["https://mcp.retasc.com/mcp"];
164
+ function normalizeUrl(u) {
165
+ return u.trim().replace(/\/+$/, "").toLowerCase();
166
+ }
167
+ /** Is there a placeholder in Claude Code's LOCAL scope for this folder? */
168
+ export function hasClaudeLocalPlaceholder(dir) {
169
+ return isPlaceholderEntry(claudeLocalRetascEntry(dir));
170
+ }
122
171
  /** A user-scope (global) Retasc server: top-level in Claude Code's config,
123
172
  * NOT under projects[dir]. Illegal under DESIGN §13 override #1 — it applies
124
173
  * to every folder that has no binding of its own, so issues can land in the
@@ -0,0 +1,80 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ /** How long to wait for the human to click Approve. Generous: they may have to sign in
4
+ * to the Dash first, and possibly through GitHub or Google after that. */
5
+ const WAIT_MS = 5 * 60 * 1000;
6
+ /**
7
+ * Open a loopback listener and return the URL to send the browser to, plus a promise
8
+ * that resolves when the browser comes back.
9
+ *
10
+ * Split in two on purpose. The caller needs the URL BEFORE the wait starts, so it can
11
+ * print it — a printed URL is what makes this work when the browser fails to open, which
12
+ * is the common case on a headless Linux box where `xdg-open` is absent.
13
+ */
14
+ export async function startBrowserLogin(dashUrl) {
15
+ const state = randomBytes(24).toString("hex");
16
+ let resolve;
17
+ let reject;
18
+ const settled = new Promise((res, rej) => {
19
+ resolve = res;
20
+ reject = rej;
21
+ });
22
+ // The timeout below rejects this promise whether or not anyone is awaiting it. A caller
23
+ // that takes `url`/`cancel` and never calls `wait()` would otherwise get an unhandled
24
+ // rejection, which is fatal on Node 15+. Attaching an inert handler at construction
25
+ // makes the promise safe to ignore; `wait()` still sees the real rejection.
26
+ settled.catch(() => { });
27
+ const server = createServer((req, res) => {
28
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
29
+ const code = url.searchParams.get("code");
30
+ const gotState = url.searchParams.get("state");
31
+ // The browser is a person's, so answer it in a way a person can read. Whatever
32
+ // happens here, the tab is finished with.
33
+ const reply = (status, body) => {
34
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
35
+ res.end(`<!doctype html><meta charset="utf-8"><title>Retasc</title>` +
36
+ `<body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto">${body}</body>`);
37
+ };
38
+ if (!code || gotState !== state) {
39
+ // A mismatched nonce means this redirect did not come from the flow we started.
40
+ // The code is not forwarded, so it expires unspent two minutes later.
41
+ reply(400, "<h1>Sign-in could not be verified</h1><p>Close this tab and run <code>retasc login</code> again.</p>");
42
+ return;
43
+ }
44
+ reply(200, "<h1>Signed in</h1><p>You can close this tab and go back to your terminal.</p>");
45
+ resolve({ code });
46
+ });
47
+ await new Promise((res, rej) => {
48
+ server.once("error", rej);
49
+ // Port 0: the OS picks a free one. Hard-coding a port would collide with a second
50
+ // `retasc login` and with whatever else the developer is running.
51
+ server.listen(0, "127.0.0.1", res);
52
+ });
53
+ const port = server.address().port;
54
+ const timer = setTimeout(() => {
55
+ reject(new Error(`TIMEOUT: no response from the browser within ${WAIT_MS / 60_000} minutes`));
56
+ }, WAIT_MS);
57
+ // Do not hold the process open on this timer alone.
58
+ timer.unref?.();
59
+ const close = () => {
60
+ clearTimeout(timer);
61
+ // `close()` stops accepting but leaves the browser's idle keep-alive socket open, and
62
+ // that handle holds the event loop until Node's 5s keepAliveTimeout fires — so the
63
+ // CLI would appear to hang for five seconds after a successful sign-in.
64
+ server.closeAllConnections?.();
65
+ server.close();
66
+ };
67
+ return {
68
+ url: `${dashUrl.replace(/\/+$/, "")}/cli-auth?port=${port}&state=${encodeURIComponent(state)}`,
69
+ port,
70
+ wait: async () => {
71
+ try {
72
+ return await settled;
73
+ }
74
+ finally {
75
+ close();
76
+ }
77
+ },
78
+ cancel: close,
79
+ };
80
+ }
@@ -0,0 +1,222 @@
1
+ // Local file DOWNLOAD for the MCP proxy (RTSC-681) — the read counterpart to attachFile.ts.
2
+ //
3
+ // Why this exists: attachments were one-way. Uploading has been a single tool call since
4
+ // RTSC-660 (the agent names a path, the proxy uploads with the key it holds), but reading one
5
+ // back required `Authorization: Bearer <agent key>` on /attachments/download — a credential the
6
+ // model structurally does not have and is explicitly told not to hunt for. So a human could
7
+ // attach a certificate for an agent to verify and the agent could see its name and nothing
8
+ // else. The proxy already brokers bytes in one direction with a credential the model never
9
+ // touches; this is the same trade in reverse.
10
+ //
11
+ // The cost of the reverse trade is different, and worse, which is why this module is stricter
12
+ // than its sibling rather than a mirror image of it. attachFile.ts confines what may be READ,
13
+ // because a proxy-side read bypasses the harness's file-access prompt. A proxy-side WRITE
14
+ // bypasses the harness's write prompt, and a write is the more dangerous primitive: bytes we
15
+ // place in `.claude/settings.json`, `.githooks/`, or a workflow file become someone's next
16
+ // command execution. The filename is not ours either — it comes off an attachment row any org
17
+ // member (or an importer pulling from Jira, ClickUp or Asana) can influence.
18
+ //
19
+ // So this side takes no destination from anybody. Downloads land in ONE directory the proxy
20
+ // owns, under a sanitized name, never overwriting, never through a symlink. An agent that
21
+ // wants the file somewhere else copies it with its own file tools — which the harness DOES
22
+ // prompt on. That asymmetry is the whole argument: bypassing the read prompt was unavoidable
23
+ // to make upload work at all, bypassing the write prompt is not.
24
+ import { lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
25
+ import { basename, join, sep } from "node:path";
26
+ /** The one tool name for "read an attached file", whichever transport can carry the bytes.
27
+ * The server publishes an INLINE variant under this SAME name (bytes in the tool result,
28
+ * capped, an image as an image); when a proxy is present it overrides that entry in
29
+ * `tools/list` with the to-disk form below. One name, the best shape the environment can
30
+ * actually support — exactly as save_attachment_file works. */
31
+ export const FETCH_TOOL_NAME = "get_attachment_file";
32
+ /** Matches the server's blob ceiling (MAX_ENCRYPTED_BLOB_BYTES), so nothing storable is
33
+ * un-readable. Checked against what the server SAYS the size is, and again against what it
34
+ * actually sent — a length header is a claim, not a guarantee. */
35
+ export const MAX_FETCH_BYTES = 50 * 1024 * 1024;
36
+ /** Where downloads land, relative to the attach root. Inside the workspace on purpose: a
37
+ * harness that sandboxes file reads to the project would not be able to open anything we
38
+ * wrote to the OS temp dir, and a file the agent cannot read is the bug we are fixing. */
39
+ export const DOWNLOAD_DIR_SEGMENTS = [".retasc", "attachments"];
40
+ /** The directory the proxy owns for downloaded attachments. */
41
+ export function downloadDir(root) {
42
+ return join(root, ...DOWNLOAD_DIR_SEGMENTS);
43
+ }
44
+ /**
45
+ * Reduce a server-supplied name to something safe to create INSIDE a directory we own.
46
+ *
47
+ * Every rule here is about a name we did not choose: the row's filename came from whoever
48
+ * uploaded it, or from an importer copying a third-party tracker's field verbatim.
49
+ * - `basename` first, then a second sweep for separators — `../../.git/hooks/pre-commit` and
50
+ * an absolute path both collapse to a leaf, and a Windows `..\\` does too.
51
+ * - control characters and NUL, which truncate paths in some syscalls and lie in terminals.
52
+ * - a leading dot, so nothing we write can become a dotfile (`.gitignore`, `.npmrc`, `.env`)
53
+ * even inside our own directory.
54
+ * - Windows device names (CON, NUL, COM1…), which are not openable as files there.
55
+ * - length, so a long title can't push us past a filesystem limit and get truncated into a
56
+ * name that collides with something else.
57
+ * Empty after all that (a name made entirely of stripped characters) falls back to the id,
58
+ * which is unique by construction.
59
+ */
60
+ export function sanitizeDownloadName(name, attachmentId) {
61
+ const leaf = basename(String(name ?? "").replace(/[\\/]+/g, "/"));
62
+ let cleaned = leaf
63
+ .replace(/[\\/]/g, "_")
64
+ .replace(/[\u0000-\u001f\u007f]/g, "") // control characters and NUL, which lie in a terminal and truncate paths
65
+ .replace(/^\.+/, "") // no dotfiles, not even inside a directory we own
66
+ .trim()
67
+ .slice(0, 120);
68
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(cleaned))
69
+ cleaned = `file-${cleaned}`;
70
+ return cleaned || `attachment-${attachmentId}`;
71
+ }
72
+ /**
73
+ * Where `attachmentId`'s bytes go. One directory per attachment (ids are unique and immutable,
74
+ * so two files never contend for a name and a cached file is never the wrong file), under the
75
+ * proxy-owned root. The id is validated rather than sanitized: it comes from the server, it is
76
+ * an opaque token, and anything that isn't one is a bug or an attack, not a name to clean up.
77
+ * The containment check at the end is belt-and-braces — after the id check and the name
78
+ * sanitizer there is no known way to escape, which is exactly when a cheap assertion earns its
79
+ * place.
80
+ */
81
+ export function resolveDownloadTarget(root, attachmentId, filename) {
82
+ const id = String(attachmentId ?? "").trim();
83
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) {
84
+ return { ok: false, error: `refusing to write: "${id}" is not a valid attachment id` };
85
+ }
86
+ const dir = join(downloadDir(root), id);
87
+ const name = sanitizeDownloadName(filename, id);
88
+ const path = join(dir, name);
89
+ if (path !== join(dir, basename(path)) || !path.startsWith(dir + sep)) {
90
+ return { ok: false, error: `refusing to write ${path}: it resolves outside ${dir}` };
91
+ }
92
+ return { ok: true, dir, path, filename: name };
93
+ }
94
+ /**
95
+ * The size of an already-downloaded file, or null if there isn't one. `lstat`, never `stat`:
96
+ * a symlink at this path would make `stat` report the target's size and the agent read
97
+ * whatever it points at, so a symlink counts as "not our file" and is reported as such.
98
+ *
99
+ * A file being present IS a complete download — writes go through a temp file and a rename
100
+ * below, so a crash mid-write can never leave a short file at the final path.
101
+ */
102
+ export function existingDownload(path) {
103
+ let st;
104
+ try {
105
+ st = lstatSync(path);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ if (st.isSymbolicLink())
111
+ return { symlink: true };
112
+ if (!st.isFile())
113
+ return null;
114
+ return { size: st.size };
115
+ }
116
+ /**
117
+ * Write the downloaded bytes, atomically and without ever following a symlink.
118
+ *
119
+ * `wx` on the temp file so we only ever create, never clobber; `rename` to publish, which
120
+ * REPLACES a symlink sitting at the destination rather than writing through it (the one
121
+ * escape a plain write would allow). A leftover temp file from a killed proxy is removed
122
+ * first — it is inside a directory only this code writes to, and its name is ours.
123
+ */
124
+ export function writeDownloadedFile(target, bytes, root) {
125
+ mkdirSync(target.dir, { recursive: true, mode: 0o700 });
126
+ ensureCacheIgnored(root);
127
+ const tmp = `${target.path}.part-${process.pid}`;
128
+ try {
129
+ unlinkSync(tmp);
130
+ }
131
+ catch {
132
+ /* nothing to clean up */
133
+ }
134
+ writeFileSync(tmp, bytes, { flag: "wx", mode: 0o600 });
135
+ renameSync(tmp, target.path);
136
+ }
137
+ /**
138
+ * Make the download directory ignore itself, once.
139
+ *
140
+ * A customer's certificate, production log or crash dump lands in their working tree, and the
141
+ * agent working there is about to commit something. A `.gitignore` holding `*` inside the
142
+ * directory covers everything in it (itself included) without touching the repo's own
143
+ * `.gitignore`, which is a file we have no business editing.
144
+ */
145
+ export function ensureCacheIgnored(root) {
146
+ const dir = downloadDir(root);
147
+ try {
148
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
149
+ writeFileSync(join(dir, ".gitignore"), "*\n", { flag: "wx", mode: 0o600 });
150
+ }
151
+ catch {
152
+ /* already there (the common case), or unwritable — never fail a download over this */
153
+ }
154
+ }
155
+ /** The tool the proxy advertises in place of the server's inline variant. It names the
156
+ * directory because the model has no other way to learn where the file will appear, and
157
+ * says the bytes never enter the context so nobody "helpfully" asks for base64 instead. */
158
+ export function localFetchToolDef(root) {
159
+ return {
160
+ name: FETCH_TOOL_NAME,
161
+ description: "Read a FILE attached to an issue, in ONE call. Give the attachment id (from " +
162
+ "list_attachments); your local retasc proxy downloads it with the credential it already " +
163
+ "holds and writes it to disk, then hands you the path — read it with your normal file " +
164
+ "tools. You do NOT need an API key for this and must not go looking for one, and you " +
165
+ "must not curl the download URL yourself: the whole point is that the key stays in the " +
166
+ `proxy. Files land under ${downloadDir(root)} (one folder per attachment, never ` +
167
+ "overwritten, git-ignored); copy it elsewhere yourself if you want it kept. Works at any " +
168
+ "size up to the 50MB attachment limit, and the bytes never pass through your context. " +
169
+ "For a link attachment (one saved with save_attachment) there is nothing to download — " +
170
+ "fetch its URL yourself.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ attachment: { type: "string", description: "Attachment id, e.g. from list_attachments." },
175
+ },
176
+ required: ["attachment"],
177
+ },
178
+ };
179
+ }
180
+ /**
181
+ * Put the local tool into a `tools/list` result: replace the server's entry of the same name
182
+ * (so the agent sees the shape this environment can actually serve, not the inline fallback)
183
+ * or append it when the server has none. Appending matters — the proxy needs only
184
+ * `get_attachment` to do its job, so it can offer to-disk downloads against a server deployed
185
+ * before the inline variant existed.
186
+ */
187
+ export function mergeFetchTool(tools, root) {
188
+ if (!Array.isArray(tools))
189
+ return tools;
190
+ const local = localFetchToolDef(root);
191
+ const idx = tools.findIndex((t) => t && typeof t === "object" && t.name === FETCH_TOOL_NAME);
192
+ if (idx === -1)
193
+ return [...tools, local];
194
+ const merged = [...tools];
195
+ merged[idx] = local;
196
+ return merged;
197
+ }
198
+ /** Does this JSON-RPC message want a LOCAL download? Any call to the tool does: unlike the
199
+ * upload pair there is no second argument shape to tell them apart, and when a proxy is
200
+ * present writing to disk is always the better answer — it costs the agent no context and
201
+ * has no size ceiling. */
202
+ export function isLocalFetchCall(msg) {
203
+ const m = msg;
204
+ return !!m && m.method === "tools/call" && m.params?.name === FETCH_TOOL_NAME;
205
+ }
206
+ /** Turn a download HTTP failure into something the agent can act on — same contract as the
207
+ * upload side: the status carries the meaning, and a bare "HTTP 402" tells an agent nothing
208
+ * it can relay to the human who alone can fix it. */
209
+ export function downloadFailureMessage(status, body) {
210
+ const detail = body.trim() ? ` — ${body.trim()}` : "";
211
+ switch (status) {
212
+ case 401:
213
+ return `download rejected: this proxy's key no longer authenticates${detail}`;
214
+ case 402:
215
+ return (`download refused for billing${detail}. PASS THIS TO YOUR HUMAN — you cannot fix it ` +
216
+ `yourself, only an owner can, and until they do every call will keep failing.`);
217
+ case 404:
218
+ return `download rejected: no such attachment in this project, or it has no stored file${detail}`;
219
+ default:
220
+ return `download failed with HTTP ${status}${detail}`;
221
+ }
222
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What `bind` reports when an AGENT is driving it (RTSC-713).
3
+ *
4
+ * # Why this exists
5
+ *
6
+ * An agent decides whether to keep going from three signals, and all three are
7
+ * structural rather than semantic: the exit code, which stream the output came on, and
8
+ * whether the text reads like a fault. Before this module, "signed in, session written,
9
+ * nothing named yet" fired all three:
10
+ *
11
+ * ✗ UNAUTHENTICATED: Not signed in. Run `npx -y @retasc/cli@1.30.0 login` first
12
+ * (no TTY here for the device flow).
13
+ *
14
+ * An agent that stops there is reading the signals correctly. The signals were wrong.
15
+ * Sign-in had succeeded, the session was on disk, and the only thing left was a question
16
+ * it could have asked its human in one sentence.
17
+ *
18
+ * So the rule this module enforces: **the exit code and the framing encode "can I
19
+ * continue?", not "did something unexpected happen?"** A state the flow can be resumed
20
+ * from exits 0 on stdout and never says error, however incomplete it is. Only a genuine
21
+ * dead end — nothing the agent or its human can do from here — exits non-zero.
22
+ *
23
+ * # Why not just word the instruction better
24
+ *
25
+ * Telling the agent "if you see UNAUTHENTICATED it is fine, keep going" asks a model to
26
+ * override the strongest stop signal it has on the strength of a sentence it read
27
+ * earlier. Sometimes it will and sometimes it will not, and which one is not something
28
+ * this codebase controls. Exiting 0 is deterministic. This is the same reasoning that
29
+ * put the claim nudge and the setup instructions in the server response rather than in
30
+ * CLAUDE.md: a mechanism beats advice.
31
+ *
32
+ * # Why the states live HERE and not in the server's instruction string
33
+ *
34
+ * The obvious alternative was a lookup table in `convex/lib/setupInstructions.ts`: "if
35
+ * the CLI says X do this, if it says Y do that." That is a second copy of this list,
36
+ * living in a different language, in a different deploy cycle, maintained by whoever
37
+ * touches it next. They diverge, and the divergence is invisible until an agent acts on
38
+ * a state that no longer exists.
39
+ *
40
+ * This repo has already paid for that once. `setupInstructions.ts` opens by explaining
41
+ * it was lifted out of `http.ts` "because it now has TWO readers and they must never
42
+ * diverge", and RTSC-694 was an entire issue spent collapsing one setup command into one
43
+ * place. So: the CLI owns its states and each one carries its own next step, and the
44
+ * server instruction says only "run this, act on what it tells you." Adding a state
45
+ * costs one change, in this file, and no server string can go stale.
46
+ *
47
+ * There is a test that greps for these code strings under `convex/`. If one ever appears
48
+ * there, the split has been broken.
49
+ */
50
+ /**
51
+ * Every terminal outcome of an agent-driven `bind`, in two classes.
52
+ *
53
+ * CONTINUABLE — the flow is alive and wants something. Exit 0, stdout, no fault
54
+ * vocabulary. The agent reads it, asks its human the one question that applies, and runs
55
+ * the next pass.
56
+ *
57
+ * TERMINAL — nothing the agent or its human can do inside this flow. Exit non-zero and
58
+ * say so plainly, because stopping is genuinely the right move.
59
+ *
60
+ * Note where NEEDS_SIGN_IN sits. Reaching a command with no session, having not yet
61
+ * tried to get one, is CONTINUABLE: the answer is to sign in, not to give up. Only a
62
+ * sign-in that was attempted and failed is terminal. Collapsing those two was the
63
+ * original bug.
64
+ */
65
+ export const CONTINUABLE_STATES = [
66
+ /** No session, and no attempt has been made yet. The browser door is available. */
67
+ "NEEDS_SIGN_IN",
68
+ /** Signed in. Zero orgs, or more than one, so which org this folder is for is unknown. */
69
+ "NEEDS_ORG",
70
+ /** Org settled. Zero projects, or more than one, so which project is unknown. */
71
+ "NEEDS_PROJECT",
72
+ /**
73
+ * Signed in, and holding an invitation. The choice is join-or-create and it is the
74
+ * human's to make.
75
+ *
76
+ * NOT called PENDING_INVITE, deliberately: `convex/setupStatus.ts` already has a state
77
+ * by that name and it means something else — a membership offer the server is
78
+ * surfacing, rather than the reason `bind` stopped. Two different things sharing one
79
+ * identifier across the boundary is precisely the confusion this file's split exists to
80
+ * prevent, so this one is named for the decision it is asking for.
81
+ */
82
+ "NEEDS_JOIN_OR_CREATE",
83
+ /** Everything named and written. The folder is bound; the client must restart. */
84
+ "BOUND",
85
+ ];
86
+ export const TERMINAL_STATES = [
87
+ /** Sign-in was attempted and did not complete: declined, timed out, or rejected. */
88
+ "SIGN_IN_FAILED",
89
+ /** No door exists: no browser reachable and no TTY. SSH and CI land here. */
90
+ "NO_SIGN_IN_DOOR",
91
+ /** This folder is already bound and the run was refused rather than silently re-pointed. */
92
+ "ALREADY_BOUND",
93
+ /** The credential was refused: revoked key, suspended member, deleted workspace. */
94
+ "REFUSED",
95
+ ];
96
+ export function isContinuable(state) {
97
+ return CONTINUABLE_STATES.includes(state);
98
+ }
99
+ /** stdout, one line, no trailing prose. Never stderr: see the module header. */
100
+ export function emit(e) {
101
+ process.stdout.write(JSON.stringify(e) + "\n");
102
+ }