flowsprite 0.1.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.
@@ -0,0 +1,266 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { writeFileAtomic } from "./credentials.js";
4
+ /**
5
+ * MUTATE ONE ENTRY IN SOMEONE ELSE'S FILE, OR REFUSE.
6
+ *
7
+ * The files this writes are not Flowsprite's. `~/.claude.json` on this machine is
8
+ * 348 KB of live state — projects, history, onboarding flags — and a client's MCP
9
+ * config is the only copy the person has. So the contract is preservation first:
10
+ * parse, set exactly `mcpServers.flowsprite`, write the whole object back, and
11
+ * REFUSE anything that cannot be parsed back out (an unparsable file, a non-object
12
+ * root, a symlink) rather than replacing it with a file this CLI understands.
13
+ * A refusal leaves a person with a config they can fix; an overwrite does not.
14
+ *
15
+ * The write goes through `writeFileAtomic` from `credentials.ts` — the same
16
+ * unique-name + `O_EXCL` + rename + chmod sequence that file argues for at length.
17
+ * It is imported rather than restated so the two writers cannot drift.
18
+ */
19
+ /** The server name this CLI owns in every client's config. */
20
+ export const SERVER_NAME = "flowsprite";
21
+ const FILE_MODE = 0o600;
22
+ /** The suffix a backup's name carries, between the config name and the epoch. */
23
+ export const BACKUP_MARKER = ".flowsprite-backup-";
24
+ export class McpConfigError extends Error {
25
+ code;
26
+ path;
27
+ constructor(code, path, message) {
28
+ super(message);
29
+ this.name = "McpConfigError";
30
+ this.code = code;
31
+ this.path = path;
32
+ }
33
+ }
34
+ function isPlainObject(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ /**
38
+ * Read a config for mutation, or throw the typed refusal.
39
+ *
40
+ * Returns `null` for a file that is simply not there — the one tolerated absence,
41
+ * because creating a client's first config is the normal case.
42
+ */
43
+ function readForMutation(path) {
44
+ let stat;
45
+ try {
46
+ // `lstatSync`, never `statSync`: the question is what is AT this path, and a
47
+ // `statSync` on a symlink answers about its target.
48
+ stat = lstatSync(path);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ if (stat.isSymbolicLink()) {
54
+ // THE LEAF ONLY. A symlinked DIRECTORY on the way here is FOLLOWED, by design:
55
+ // a dotfiles setup that symlinks `~/.cursor` or the whole Application Support
56
+ // folder is an ordinary arrangement, and refusing it would make this CLI
57
+ // unusable for the people most likely to have four AI clients configured. What
58
+ // is refused is a symlinked CONFIG FILE, where writing through the link would
59
+ // put a bearer token into a file the person did not name.
60
+ throw new McpConfigError("symlink", path, `${path} is a symbolic link; refusing to write through it.`);
61
+ }
62
+ let raw;
63
+ try {
64
+ raw = readFileSync(path, "utf8");
65
+ }
66
+ catch (err) {
67
+ // A config that `lstat` can see but this process cannot READ. Without this the
68
+ // EACCES escapes as a raw node error to `bin.ts`'s last-resort handler, which
69
+ // prints one redacted line with no remedy in it. The person needs to be told
70
+ // WHICH file and that the problem is permission, not content.
71
+ throw new McpConfigError("unreadable", path, `${path} could not be read (${err.code ?? "read failed"}); refusing to replace a file this CLI cannot preserve.`);
72
+ }
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ }
77
+ catch {
78
+ throw new McpConfigError("unparsable", path, `${path} is not valid JSON; refusing to overwrite it.`);
79
+ }
80
+ if (!isPlainObject(parsed)) {
81
+ throw new McpConfigError("not_object", path, `${path} is not a JSON object; refusing to overwrite it.`);
82
+ }
83
+ // A `mcpServers` that is not an object is the same problem one level down: the
84
+ // only way to add an entry would be to discard whatever is there.
85
+ if (parsed.mcpServers !== undefined && !isPlainObject(parsed.mcpServers)) {
86
+ throw new McpConfigError("not_object", path, `${path} has an mcpServers value that is not an object; refusing to overwrite it.`);
87
+ }
88
+ return { raw, root: parsed };
89
+ }
90
+ /**
91
+ * Every `<name>.flowsprite-backup-*` already sitting beside `path` that is a
92
+ * REGULAR FILE.
93
+ *
94
+ * `.isFile()` is the load-bearing half. A directory or a symlink wearing a backup
95
+ * name is not a backup — nothing can be restored from it — and counting it would
96
+ * make this function answer "already backed up" for a path where no backup exists,
97
+ * which is how a config gets mutated with nothing saved. It also keeps this
98
+ * agreeing with the exclusive create below, which refuses the same things: the two
99
+ * must have one answer, or the fast path waves through exactly what the slow path
100
+ * would have caught.
101
+ */
102
+ function existingBackups(path) {
103
+ const base = basename(path);
104
+ const dir = dirname(path);
105
+ try {
106
+ return readdirSync(dir).filter((n) => {
107
+ if (!n.startsWith(`${base}${BACKUP_MARKER}`))
108
+ return false;
109
+ try {
110
+ return lstatSync(join(dir, n)).isFile();
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ });
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
121
+ /**
122
+ * The mode the config comes out with: 0600, unless what is there is already
123
+ * STRICTER, in which case it is kept.
124
+ *
125
+ * The direction matters. A config that holds a bearer token must not stay 0644
126
+ * because it happened to be 0644 before; but a person who has locked their config
127
+ * down to 0400 has made a decision this CLI has no business widening. "Stricter"
128
+ * is "no bits outside owner read/write".
129
+ *
130
+ * (An earlier version also excluded a mode of 0, reasoning that preserving it
131
+ * would leave a file nothing can read back. That branch was UNREACHABLE: a 0-mode
132
+ * config fails `readForMutation`'s read first and is refused as `unreadable`
133
+ * before this function is ever called. It is deleted rather than kept as a
134
+ * comforting no-op.)
135
+ */
136
+ function modeFor(path) {
137
+ let current;
138
+ try {
139
+ current = statSync(path).mode & 0o777;
140
+ }
141
+ catch {
142
+ return FILE_MODE;
143
+ }
144
+ return (current & ~FILE_MODE) === 0 ? current : FILE_MODE;
145
+ }
146
+ /**
147
+ * Set `mcpServers.flowsprite` in a JSON client config, preserving everything else.
148
+ *
149
+ * ONE BACKUP PER PATH, FOREVER (brief DM2 / ASSUME-2). The first write beside a
150
+ * file that has no `.flowsprite-backup-*` takes one; every later write leaves it
151
+ * alone. The point of the backup is the state BEFORE this CLI ever touched the
152
+ * file, and a rolling backup overwrites exactly that with a copy of our own work.
153
+ * There is no restore command in v1: the file is named in the output and a person
154
+ * (or an agent) moves it back.
155
+ *
156
+ * THE BACKUP IS WRITTEN WITH A DIRECT `wx`, NOT THROUGH `writeFileAtomic`, and
157
+ * that is the whole difference between a backup and a lie. `writeFileAtomic` puts
158
+ * `O_EXCL` on its TEMP file and then RENAMES over the destination — rename
159
+ * replaces whatever is there, so two writes aiming at one backup name (two
160
+ * installs inside the same second, for two different clients' configs that share
161
+ * a directory, or two `flowsprite` processes) would leave a "backup" that is a
162
+ * copy of the ALREADY-MUTATED file. A direct `O_EXCL` create cannot: the second
163
+ * one gets EEXIST, which is read here as "a backup already exists", the same
164
+ * answer the readdir check gives. The check is the fast path; this is the one
165
+ * that is actually a guarantee.
166
+ */
167
+ export function upsertJsonConfig(input) {
168
+ const { path, entry } = input;
169
+ const existing = readForMutation(path);
170
+ const root = existing?.root ?? {};
171
+ const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
172
+ const replaced = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
173
+ let backup;
174
+ if (existing !== null && existingBackups(path).length === 0) {
175
+ const stamp = Math.floor((input.now ?? Date.now()) / 1000);
176
+ const candidate = join(dirname(path), `${basename(path)}${BACKUP_MARKER}${stamp}`);
177
+ try {
178
+ // 0600 whatever the original was: the copy may hold a bearer token, and a
179
+ // backup nobody can read is a smaller problem than one everybody can.
180
+ writeFileSync(candidate, existing.raw, { flag: "wx", mode: FILE_MODE });
181
+ backup = candidate;
182
+ }
183
+ catch (err) {
184
+ // EEXIST IS NOT BY ITSELF "ALREADY BACKED UP". `O_EXCL` fails with EEXIST for
185
+ // anything at that path — a regular file, a directory, and a DANGLING SYMLINK
186
+ // too. Read as a bare success, a dangling symlink wearing the backup name
187
+ // would mean: nothing saved, no error, and the config mutated anyway. So the
188
+ // success case is narrowed to what can actually be restored from, and
189
+ // everything else is a refusal taken BEFORE the write, leaving the config
190
+ // exactly as it was.
191
+ //
192
+ // Anything that is not EEXIST (a read-only directory, ENOSPC) stops the write
193
+ // for the same reason: losing the only copy of someone's config is the
194
+ // failure this whole file exists to avoid.
195
+ if (err.code !== "EEXIST")
196
+ throw err;
197
+ let isFile = false;
198
+ try {
199
+ isFile = lstatSync(candidate).isFile();
200
+ }
201
+ catch {
202
+ isFile = false;
203
+ }
204
+ if (!isFile) {
205
+ throw new McpConfigError("backup_blocked", path, `${candidate} is in the way of this config's backup and is not a regular file; ` +
206
+ "refusing to change the config without one.");
207
+ }
208
+ // A real backup is already there: the pre-Flowsprite state is saved, so this
209
+ // is a success and `backup` stays undefined because THIS call did not take one.
210
+ }
211
+ }
212
+ servers[SERVER_NAME] = entry;
213
+ root.mcpServers = servers;
214
+ mkdirSync(dirname(path), { recursive: true });
215
+ writeFileAtomic(path, `${JSON.stringify(root, null, 2)}\n`, modeFor(path), input.unique);
216
+ return { wrote: true, replaced, ...(backup === undefined ? {} : { backup }) };
217
+ }
218
+ /**
219
+ * Is `name` already registered in this JSON config?
220
+ *
221
+ * FALSE FOR EVERYTHING IT CANNOT READ, deliberately: this answers "must I ask
222
+ * before replacing?", and a file that is missing, unreadable or malformed has no
223
+ * entry to protect. It is not a substitute for the refusals above — the write
224
+ * still goes through `upsertJsonConfig`, which throws on exactly those files.
225
+ */
226
+ export function jsonConfigHasServer(path, name = SERVER_NAME) {
227
+ try {
228
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
229
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers))
230
+ return false;
231
+ return Object.prototype.hasOwnProperty.call(parsed.mcpServers, name);
232
+ }
233
+ catch {
234
+ return false;
235
+ }
236
+ }
237
+ /**
238
+ * Is `name` already a `[mcp_servers.<name>]` section in a codex `config.toml`?
239
+ *
240
+ * A TEXT SCAN, NOT A TOML PARSE — this repo has no TOML library and card FLO-501
241
+ * adds no dependency (NC-1). It matches the section-header form `codex mcp add`
242
+ * itself writes, MEASURED 2026-09-15 on a throwaway `CODEX_HOME`:
243
+ *
244
+ * [mcp_servers.probe]
245
+ * url = "https://example.invalid/mcp"
246
+ * bearer_token_env_var = "X"
247
+ *
248
+ * CEILING, stated because the next reader will assume more: an entry written by
249
+ * hand as a quoted key (`["mcp_servers"."flowsprite"]`) or as an inline table
250
+ * under a single `mcp_servers = { … }` is NOT seen by this, so the answer is
251
+ * "no conflict" and `codex mcp add` then overwrites that entry — which is what it
252
+ * does anyway (measured: a second `codex mcp add` with the same name replaces the
253
+ * section and exits 0). ponytail: a real TOML reader is the upgrade path, and it
254
+ * costs a dependency this package does not have.
255
+ */
256
+ export function tomlConfigHasServer(path, name = SERVER_NAME) {
257
+ let text;
258
+ try {
259
+ text = readFileSync(path, "utf8");
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
265
+ return new RegExp(`^[ \\t]*\\[mcp_servers\\.${escaped}\\][ \\t]*$`, "m").test(text);
266
+ }
@@ -0,0 +1,225 @@
1
+ import { join } from "node:path";
2
+ import { SERVER_NAME } from "./mcpConfig.js";
3
+ /** Every client this CLI can install, in the order they are offered. */
4
+ export const CLIENTS = ["claude-code", "codex", "claude-desktop", "cursor"];
5
+ export function isClientId(value) {
6
+ return CLIENTS.includes(value);
7
+ }
8
+ /** The env var codex is told to read the bearer from, unless `--token-env` names another. */
9
+ export const TOKEN_ENV_DEFAULT = "FLOWSPRITE_MCP_TOKEN";
10
+ /** What `mcp print` puts where the bearer goes, unless `--reveal` is given on a TTY. */
11
+ export const TOKEN_PLACEHOLDER = "<YOUR_TOKEN>";
12
+ /**
13
+ * The `mcp-remote` version Claude Desktop is pointed at.
14
+ *
15
+ * MEASURED, not guessed: `npm view mcp-remote version` -> 0.14.2 on 2026-09-15.
16
+ * It is PINNED because the entry this CLI writes is `npx -y mcp-remote@<version>`
17
+ * and an unpinned `npx -y mcp-remote` re-resolves to whatever is newest every time
18
+ * Claude Desktop starts the server — a supply-chain surface on a process that
19
+ * holds a live Flowsprite bearer. Bump it deliberately, in a card, with the
20
+ * measurement re-run.
21
+ */
22
+ export const MCP_REMOTE_VERSION = "0.14.2";
23
+ function nonEmpty(value) {
24
+ return typeof value === "string" && value.length > 0 ? value : undefined;
25
+ }
26
+ /**
27
+ * THE ONE DERIVATION OF EVERY CLIENT'S CONFIG PATH.
28
+ *
29
+ * Both the fallback writer and the "is flowsprite already registered?" check read
30
+ * it from here, so they cannot aim at different files — which is the shape of the
31
+ * bug where a conflict check passes over a file the write then clobbers.
32
+ *
33
+ * Paths, and where each came from:
34
+ * - claude-code: `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`. MEASURED
35
+ * 2026-09-15: `claude mcp add --transport http … --scope user` wrote exactly that
36
+ * file, with a top-level `mcpServers` entry `{type:"http", url, headers}`.
37
+ * - codex: `$CODEX_HOME/config.toml`, else `~/.codex/config.toml`. MEASURED: a
38
+ * throwaway `CODEX_HOME` got a `config.toml` holding `[mcp_servers.<name>]`.
39
+ * - claude-desktop: the platform's application-support path. The darwin one is
40
+ * MEASURED on this machine (its live flowsprite entry is the `npx mcp-remote`
41
+ * shape this file writes); win32 and linux are from Claude Desktop's own
42
+ * documented locations and are UNVERIFIED here — no install of either to read.
43
+ * - cursor: `~/.cursor/mcp.json` (ASSUME-1; no Cursor install on this machine,
44
+ * which is why that client is only WRITTEN when the file is already there).
45
+ */
46
+ export function configPathFor(client, ctx) {
47
+ const { home, platform, env } = ctx;
48
+ switch (client) {
49
+ case "claude-code":
50
+ return { path: join(nonEmpty(env.CLAUDE_CONFIG_DIR) ?? home, ".claude.json"), format: "json" };
51
+ case "codex":
52
+ return { path: join(nonEmpty(env.CODEX_HOME) ?? join(home, ".codex"), "config.toml"), format: "toml" };
53
+ case "claude-desktop": {
54
+ if (platform === "darwin") {
55
+ return {
56
+ path: join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
57
+ format: "json",
58
+ };
59
+ }
60
+ if (platform === "win32") {
61
+ const appData = nonEmpty(env.APPDATA) ?? join(home, "AppData", "Roaming");
62
+ return { path: join(appData, "Claude", "claude_desktop_config.json"), format: "json" };
63
+ }
64
+ return { path: join(home, ".config", "Claude", "claude_desktop_config.json"), format: "json" };
65
+ }
66
+ default:
67
+ return { path: join(home, ".cursor", "mcp.json"), format: "json" };
68
+ }
69
+ }
70
+ /** The command each client registers through, or null where it has none. */
71
+ export function commandFor(client) {
72
+ if (client === "claude-code")
73
+ return "claude";
74
+ if (client === "codex")
75
+ return "codex";
76
+ return null;
77
+ }
78
+ function bearer(token) {
79
+ return `Bearer ${token}`;
80
+ }
81
+ /**
82
+ * The plan for one client. Pure — every fact comes from `ctx`.
83
+ *
84
+ * The `--token-env` rules are NOT here: `cli.ts` resolves that flag before
85
+ * building the context, because for claude-code it means "read the CURRENT value
86
+ * of that variable and use it as the bearer" (a resolution that can fail) while
87
+ * for codex it means "pass the NAME through", and for the two write clients it is
88
+ * refused outright — their configs hold literal values, so a variable name in one
89
+ * is a config that silently does not authenticate.
90
+ */
91
+ export function planInstall(client, ctx) {
92
+ const url = ctx.mcpEndpoint;
93
+ const { path } = configPathFor(client, ctx);
94
+ switch (client) {
95
+ case "claude-code": {
96
+ if (ctx.onPath("claude")) {
97
+ return {
98
+ kind: "exec",
99
+ argv: [
100
+ "claude",
101
+ "mcp",
102
+ "add",
103
+ "--transport",
104
+ "http",
105
+ SERVER_NAME,
106
+ url,
107
+ "--header",
108
+ `Authorization: ${bearer(ctx.token)}`,
109
+ "--scope",
110
+ "user",
111
+ ],
112
+ describe: "Claude Code, through its own `claude` command",
113
+ };
114
+ }
115
+ return {
116
+ kind: "write",
117
+ path,
118
+ entry: { type: "http", url, headers: { Authorization: bearer(ctx.token) } },
119
+ describe: "Claude Code (the `claude` command is not on PATH, so its config was written directly)",
120
+ };
121
+ }
122
+ case "codex": {
123
+ const envName = nonEmpty(ctx.tokenEnv) ?? TOKEN_ENV_DEFAULT;
124
+ if (ctx.onPath("codex")) {
125
+ return {
126
+ kind: "exec",
127
+ argv: ["codex", "mcp", "add", SERVER_NAME, "--url", url, "--bearer-token-env-var", envName],
128
+ describe: "Codex, through its own `codex` command",
129
+ };
130
+ }
131
+ // NO TOML WRITER (NC-1): this package has zero dependencies and a
132
+ // hand-rolled TOML mutation of someone's `config.toml` is exactly the
133
+ // "overwrite what you cannot preserve" this card refuses elsewhere.
134
+ return {
135
+ kind: "print",
136
+ snippet: renderSnippet("codex", { url, placeholder: TOKEN_PLACEHOLDER, tokenEnv: envName }),
137
+ reason: `the \`codex\` command is not on PATH, and this CLI does not edit ${path} itself`,
138
+ };
139
+ }
140
+ case "claude-desktop": {
141
+ // STDIO ONLY: Claude Desktop takes a command and arguments, not a URL with
142
+ // headers (measured on this machine — every server in its live config is
143
+ // `command`/`args`), so the bridge is `mcp-remote`.
144
+ return {
145
+ kind: "write",
146
+ path,
147
+ entry: {
148
+ command: "npx",
149
+ args: ["-y", `mcp-remote@${MCP_REMOTE_VERSION}`, url, "--header", `Authorization: ${bearer(ctx.token)}`],
150
+ },
151
+ describe: "Claude Desktop (restart it to pick the server up)",
152
+ };
153
+ }
154
+ default: {
155
+ if (ctx.fileExists(path)) {
156
+ return {
157
+ kind: "write",
158
+ path,
159
+ entry: { url, headers: { Authorization: bearer(ctx.token) } },
160
+ describe: "Cursor",
161
+ };
162
+ }
163
+ // ASSUME-1 is unverified here, so a file that does not exist is not
164
+ // created: a wrong guess at the shape would be a config Cursor ignores,
165
+ // and a person who has never configured Cursor has nothing to compare it
166
+ // against. An existing file proves the location at least.
167
+ return {
168
+ kind: "print",
169
+ snippet: renderSnippet("cursor", { url, placeholder: TOKEN_PLACEHOLDER }),
170
+ reason: `${path} does not exist yet, so there is no Cursor config to add an entry to`,
171
+ };
172
+ }
173
+ }
174
+ }
175
+ /**
176
+ * The hand-edit snippet for a client: what a person pastes when the automated
177
+ * route is not available, and what `mcp print` shows.
178
+ *
179
+ * It is the SAME shape the writer produces, rendered from the same values, so the
180
+ * printed instructions and the automated write cannot describe different configs.
181
+ */
182
+ export function renderSnippet(client, input) {
183
+ const { url, placeholder } = input;
184
+ const auth = `Bearer ${placeholder}`;
185
+ if (client === "codex") {
186
+ const envName = nonEmpty(input.tokenEnv) ?? TOKEN_ENV_DEFAULT;
187
+ return [
188
+ `# ~/.codex/config.toml (or $CODEX_HOME/config.toml)`,
189
+ `[mcp_servers.${SERVER_NAME}]`,
190
+ `url = "${url}"`,
191
+ `bearer_token_env_var = "${envName}"`,
192
+ ``,
193
+ `# and in the shell that starts codex:`,
194
+ `export ${envName}=${placeholder}`,
195
+ ].join("\n");
196
+ }
197
+ if (client === "claude-desktop") {
198
+ return [
199
+ `// claude_desktop_config.json`,
200
+ JSON.stringify({
201
+ mcpServers: {
202
+ [SERVER_NAME]: {
203
+ command: "npx",
204
+ args: ["-y", `mcp-remote@${MCP_REMOTE_VERSION}`, url, "--header", `Authorization: ${auth}`],
205
+ },
206
+ },
207
+ }, null, 2),
208
+ ].join("\n");
209
+ }
210
+ if (client === "cursor") {
211
+ return [
212
+ `// ~/.cursor/mcp.json`,
213
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: { url, headers: { Authorization: auth } } } }, null, 2),
214
+ ].join("\n");
215
+ }
216
+ // claude-code: the one-liner first, because it is what most people will use,
217
+ // and the file shape under it for anyone who would rather edit than run.
218
+ return [
219
+ `claude mcp add --transport http ${SERVER_NAME} ${url} \\`,
220
+ ` --header "Authorization: ${auth}" --scope user`,
221
+ ``,
222
+ `// or, in ~/.claude.json ($CLAUDE_CONFIG_DIR/.claude.json when that is set)`,
223
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "http", url, headers: { Authorization: auth } } } }, null, 2),
224
+ ].join("\n");
225
+ }
@@ -0,0 +1,47 @@
1
+ export const LOGIN_REMEDIES = {
2
+ expired_token: "This login code has expired. Codes last 15 minutes — run `flowsprite login` again to start a new one.",
3
+ access_denied: "The login was denied in the browser. If that was not you, run `flowsprite login` again and approve only a code you started yourself.",
4
+ no_grant: "Your Flowsprite account has no MCP access in this workspace yet. Ask a workspace owner or admin to grant you MCP access in Settings, then run `flowsprite login` again.",
5
+ not_member: "You are not an active member of that workspace any more. Ask an owner or admin to re-invite you, then run `flowsprite login` again.",
6
+ suspended: "That workspace is suspended, so no new access tokens can be issued. An owner needs to resolve the suspension in billing before a terminal can log in.",
7
+ revoked: "Your MCP access in that workspace has been revoked. Ask a workspace owner or admin to grant it again, then run `flowsprite login` again.",
8
+ ambiguous_grant: "You hold more than one live MCP grant in that workspace, so Flowsprite cannot tell which one this terminal should use. Ask an owner or admin to revoke the extra grants, then run `flowsprite login` again.",
9
+ rate_limited: "Too many logins have been started from this network in the last few minutes. Wait ten minutes and run `flowsprite login` again.",
10
+ invalid_request: "Flowsprite rejected the login request as malformed. This is a bug in this CLI version — upgrade it, and report it if a newer version does the same.",
11
+ invalid_argument: "Flowsprite rejected one of the values this CLI sent when starting the login. This is a bug in this CLI version — upgrade it, and report it if a newer version does the same.",
12
+ server_misconfigured: "That Flowsprite deployment is not configured for command-line login. Whoever operates it needs to set its MCP service secret.",
13
+ server_error: "Flowsprite hit an unexpected error while logging this terminal in. Try again in a minute; if it keeps happening, that is an operator problem, not something you can fix here.",
14
+ transport: "Could not reach that Flowsprite deployment. Check the endpoint and your network, then run `flowsprite login` again.",
15
+ };
16
+ /** The wire values that mean "keep polling", kept apart from the terminal ones above. */
17
+ export const POLL_CONTINUE_KINDS = new Set(["authorization_pending", "slow_down"]);
18
+ /**
19
+ * The kinds that can arrive FROM THE WIRE — a strict subset of the remedy table.
20
+ *
21
+ * ⚠️ `transport` IS NOT IN IT, and that is the point of this set existing. It is a
22
+ * real `LoginFailureKind` with a real remedy ("could not reach that deployment"),
23
+ * but it is a conclusion this CLI draws when `fetch` THROWS — never something a
24
+ * server says. Keying the wire lookup on the remedy table instead meant a
25
+ * response body of `{"error":"transport"}` was read as "unreachable", which is a
26
+ * lie about a deployment that plainly answered, and it points the person at their
27
+ * network instead of at the workspace. Anything not in this set folds to
28
+ * `server_error` and is never echoed: an unrecognised error string is still a
29
+ * server string.
30
+ */
31
+ const WIRE_KINDS = new Set([
32
+ "expired_token",
33
+ "access_denied",
34
+ "no_grant",
35
+ "not_member",
36
+ "suspended",
37
+ "revoked",
38
+ "ambiguous_grant",
39
+ "rate_limited",
40
+ "invalid_request",
41
+ "invalid_argument",
42
+ "server_misconfigured",
43
+ "server_error",
44
+ ]);
45
+ export function isLoginFailureKind(value) {
46
+ return typeof value === "string" && WIRE_KINDS.has(value);
47
+ }
package/dist/output.js ADDED
@@ -0,0 +1,21 @@
1
+ import { redact } from "./redact.js";
2
+ /**
3
+ * `sinks` is injected so a test can record every line without a process.
4
+ * The bin wires `console.log` / `console.error`.
5
+ */
6
+ export function createWriter(sinks) {
7
+ return {
8
+ out: (line) => sinks.out(redact(line)),
9
+ err: (line) => sinks.err(redact(line)),
10
+ };
11
+ }
12
+ /** A writer that records, for tests and for the doctor's dry runs. */
13
+ export function createRecordingWriter() {
14
+ const lines = [];
15
+ const errors = [];
16
+ const w = createWriter({
17
+ out: (line) => lines.push(line),
18
+ err: (line) => errors.push(line),
19
+ });
20
+ return { ...w, lines, errors };
21
+ }
package/dist/redact.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Rewrite every Flowsprite bearer token in a string to a stub.
3
+ *
4
+ * NO IMPORTABLE REDACTOR EXISTS IN THIS REPO (measured at f301c49: `fp_user_`
5
+ * appears in lib/ and scripts/ only as comments and a type guard), so this is
6
+ * the CLI's own, re-stated rather than shared.
7
+ *
8
+ * THE TOKEN GRAMMAR IS TAKEN FROM THE MINTER, not guessed: `generateRawToken`
9
+ * in `convex/lib/mcpCrypto.ts` emits `fp_live_` or `fp_user_` followed by 32
10
+ * characters drawn from `[A-Za-z0-9-_]`. The suffix alphabet CONTAINS `_`, so
11
+ * the match is greedy over that class — stopping at the first underscore would
12
+ * leave most of the secret on the screen.
13
+ *
14
+ * THE SET THIS COVERS IS EXACTLY `fp_user_` AND `fp_live_`, and nothing else —
15
+ * stated so the next reader does not assume it is a general secret scrubber.
16
+ * Those are the only two prefixes `generateRawToken` can emit and the only
17
+ * credentials this CLI ever holds. The OTHER value worth never printing, the
18
+ * device code, is not covered here and does not need to be: it is 64 hex
19
+ * characters with no marker to match on, and it is kept off the screen BY
20
+ * CONSTRUCTION instead — `cli/src/login.ts` prints `user_code` and
21
+ * `verification_uri_complete` and never passes `device_code` to the writer, which
22
+ * the AC3 acceptance oracle pins (`printedTheDeviceCode: false`). A setup-link
23
+ * token is the same shape and the same story, except that one is DELIBERATELY
24
+ * shown: it is the thing the person has to open.
25
+ *
26
+ * Applied by the shared writer (`cli/src/output.ts`), so the redaction is a
27
+ * property of the ONE place lines leave this process, not of each caller
28
+ * remembering. A caller that bypassed the writer would bypass this.
29
+ */
30
+ const TOKEN = /fp_(?:user|live)_[A-Za-z0-9_-]+/g;
31
+ /** How many trailing characters survive, so a person can tell two tokens apart. */
32
+ const KEEP_LAST = 4;
33
+ export function redact(text) {
34
+ return text.replace(TOKEN, (match) => {
35
+ // `fp_user_` / `fp_live_` — a fixed 8 characters, so this is a slice, not a
36
+ // search: the prefix is the part the grammar above already pinned.
37
+ const prefix = match.slice(0, "fp_user_".length);
38
+ const suffix = match.slice("fp_user_".length);
39
+ // A suffix at or under the keep length would be echoed whole. It cannot be a
40
+ // real token (the minter always emits 32), and truncating to nothing would
41
+ // be indistinguishable from an empty value — so the stub says "..." and
42
+ // keeps nothing.
43
+ if (suffix.length <= KEEP_LAST)
44
+ return `${prefix}...`;
45
+ return `${prefix}...${suffix.slice(-KEEP_LAST)}`;
46
+ });
47
+ }