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,195 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ export const CREDENTIALS_FILE = "credentials.json";
6
+ const DIR_MODE = 0o700;
7
+ const FILE_MODE = 0o600;
8
+ /**
9
+ * Where the store lives, in precedence order: an explicit
10
+ * `FLOWSPRITE_CONFIG_DIR`, else `$XDG_CONFIG_HOME/flowsprite`, else
11
+ * `~/.config/flowsprite`. `env` is injected so a test never touches a real home
12
+ * directory.
13
+ */
14
+ export function credentialsDir(env, home = homedir()) {
15
+ const explicit = env.FLOWSPRITE_CONFIG_DIR;
16
+ if (typeof explicit === "string" && explicit.length > 0)
17
+ return explicit;
18
+ const xdg = env.XDG_CONFIG_HOME;
19
+ if (typeof xdg === "string" && xdg.length > 0)
20
+ return join(xdg, "flowsprite");
21
+ return join(home, ".config", "flowsprite");
22
+ }
23
+ export function credentialsPath(env, home) {
24
+ return join(credentialsDir(env, home), CREDENTIALS_FILE);
25
+ }
26
+ /**
27
+ * TOLERANT BY CONTRACT: a missing file, an unreadable one, invalid JSON, or a
28
+ * JSON value that is not an object all read as "no credentials". A CLI whose
29
+ * first act is to crash on a file the person cannot see is a CLI they cannot
30
+ * recover from — and the recovery (`flowsprite login`) overwrites the file
31
+ * anyway. Entries that are not well-shaped credentials are dropped rather than
32
+ * returned, so no caller has to re-check a token's type.
33
+ */
34
+ export function readCredentials(env, home) {
35
+ let raw;
36
+ try {
37
+ raw = readFileSync(credentialsPath(env, home), "utf8");
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
50
+ return {};
51
+ const store = {};
52
+ for (const [origin, value] of Object.entries(parsed)) {
53
+ // SHAPE FIRST, then field types: a non-object entry would otherwise reach a
54
+ // template as "[object Object]" or throw on a property read.
55
+ if (typeof value !== "object" || value === null || Array.isArray(value))
56
+ continue;
57
+ const v = value;
58
+ if (typeof v.token !== "string" || v.token.length === 0)
59
+ continue;
60
+ if (typeof v.orgId !== "string" || typeof v.userId !== "string")
61
+ continue;
62
+ if (typeof v.mcpEndpoint !== "string")
63
+ continue;
64
+ store[origin] = {
65
+ token: v.token,
66
+ orgId: v.orgId,
67
+ userId: v.userId,
68
+ mcpEndpoint: v.mcpEndpoint,
69
+ savedAt: typeof v.savedAt === "number" ? v.savedAt : 0,
70
+ };
71
+ }
72
+ return store;
73
+ }
74
+ /**
75
+ * ATOMIC, EXCLUSIVE, AND 0600 — in that order of importance.
76
+ *
77
+ * THE TEMP FILE IS CREATED WITH `flag: "wx"`, AND THAT IS THE SECURITY PROPERTY,
78
+ * not the mode. `wx` is `O_CREAT | O_EXCL | O_WRONLY`, which the kernel refuses
79
+ * (EEXIST) on anything already at that path INCLUDING A SYMLINK — it never
80
+ * follows one. Measured at fd0f434, before this flag existed: a `credentials.json.tmp`
81
+ * planted as a symlink to a 0644 file elsewhere took the write, so the token
82
+ * landed in the attacker's file, that file was chmod'd to 0600, and
83
+ * `credentials.json` then BECAME the symlink. `O_EXCL` is what closes that; a
84
+ * mode argument never could, because the mode applies to a file being created
85
+ * and the old write was not creating one.
86
+ *
87
+ * THE NAME IS UNIQUE PER WRITE (`pid` + a random uuid). A fixed `.tmp` is both
88
+ * the thing an attacker plants and the thing two concurrent `flowsprite` runs
89
+ * collide on; with `wx` a fixed name would turn that collision into a hard
90
+ * failure rather than a silent overwrite, which is worse than not colliding.
91
+ *
92
+ * The suffix is a PARAMETER with a random default, for one reason: the two
93
+ * defences above are independent, and with a random name a test can never reach
94
+ * the `wx` one — the planted path is simply never used, so dropping `flag: "wx"`
95
+ * stays green. Passing a known suffix lets a test pre-create the EXACT path the
96
+ * write will use and assert the refusal. Nothing in this package passes it.
97
+ *
98
+ * THE MODE IS RIGHT FROM THE FIRST BYTE, and now really is. `writeFileSync`'s
99
+ * `mode` applies at creation, and because `wx` guarantees this call is the
100
+ * creator, there is no pre-existing-file case left in which it does not apply —
101
+ * that was the hole the old fixed-name version had (a pre-existing 0644 `.tmp`
102
+ * kept 0644 until the final chmod, so the docstring's "never world-readable even
103
+ * for an instant" was false of exactly the file it was describing).
104
+ *
105
+ * `renameSync` is the atomic swap (same directory, so same filesystem). The
106
+ * `chmodSync` AFTER it is for a DIFFERENT case the create mode cannot reach: a
107
+ * `credentials.json` that already existed with a wider mode, which rename does
108
+ * not change. The two are not redundant — see the all-terms bite recorded for
109
+ * this function in EVIDENCE-FLO-500.md.
110
+ *
111
+ * The directory is created 0700 — a 0600 file in a 0755 directory still leaks
112
+ * its existence and its size to every local account. `mkdirSync`'s `mode` is
113
+ * masked by the process umask, so it is asserted with an explicit `chmodSync`
114
+ * rather than trusted from the create.
115
+ */
116
+ /**
117
+ * The temp path a write will use, given the target and a unique suffix.
118
+ * Exported so a test can pre-create it; see `writeStore`'s docstring.
119
+ */
120
+ export function tempPathFor(target, unique) {
121
+ return `${target}.${unique}.tmp`;
122
+ }
123
+ /** A suffix no other process and no attacker can predict. */
124
+ function freshSuffix() {
125
+ return `${process.pid}.${randomUUID()}`;
126
+ }
127
+ /**
128
+ * The write itself, as the ONE primitive — everything the docstring above argues
129
+ * about `wx`, the unique name, the create mode and the post-rename `chmod` lives
130
+ * here and nowhere else.
131
+ *
132
+ * It is exported because `mcpConfig.ts` writes a second kind of token-bearing
133
+ * file (a client's MCP config) and a second copy of this sequence is how one of
134
+ * them loses `wx` in a later edit. The caller supplies the mode, because a
135
+ * client config may legitimately be stricter than 0600 and the credential store
136
+ * never is.
137
+ */
138
+ export function writeFileAtomic(path, contents, mode, unique = freshSuffix()) {
139
+ const tmp = tempPathFor(path, unique);
140
+ writeFileSync(tmp, contents, { flag: "wx", mode });
141
+ try {
142
+ renameSync(tmp, path);
143
+ }
144
+ catch (err) {
145
+ // Leave nothing half-written behind: a surviving temp file holds a live
146
+ // token at a path nothing will ever clean up.
147
+ rmSync(tmp, { force: true });
148
+ throw err;
149
+ }
150
+ chmodSync(path, mode);
151
+ }
152
+ function writeStore(env, store, home, unique = freshSuffix()) {
153
+ const dir = credentialsDir(env, home);
154
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE });
155
+ chmodSync(dir, DIR_MODE);
156
+ writeFileAtomic(credentialsPath(env, home), `${JSON.stringify(store, null, 2)}\n`, FILE_MODE, unique);
157
+ }
158
+ export function writeCredential(env, origin, cred, home,
159
+ /** Test seam only — see `writeStore`. Every caller in this package omits it. */
160
+ unique) {
161
+ const store = readCredentials(env, home);
162
+ store[origin] = cred;
163
+ writeStore(env, store, home, unique ?? freshSuffix());
164
+ }
165
+ /**
166
+ * Remove ONE origin's entry. Returns whether there was one, so `logout` can say
167
+ * "you were not logged in there" instead of claiming a removal it did not make.
168
+ * Other origins' entries survive: this is the whole reason the file is keyed.
169
+ */
170
+ export function deleteCredential(env, origin, home) {
171
+ const store = readCredentials(env, home);
172
+ if (!Object.prototype.hasOwnProperty.call(store, origin))
173
+ return false;
174
+ delete store[origin];
175
+ writeStore(env, store, home);
176
+ return true;
177
+ }
178
+ /**
179
+ * Which token this invocation uses: an explicit `--token`, else
180
+ * `FLOWSPRITE_TOKEN`, else the credential stored for this origin. The stored one
181
+ * is LAST so that a one-off token on the command line never has to fight a stale
182
+ * login, and so CI can set an env var without writing to a home directory.
183
+ */
184
+ export function resolveToken(input) {
185
+ if (typeof input.flag === "string" && input.flag.length > 0) {
186
+ return { token: input.flag, source: "--token" };
187
+ }
188
+ if (typeof input.env === "string" && input.env.length > 0) {
189
+ return { token: input.env, source: "FLOWSPRITE_TOKEN" };
190
+ }
191
+ if (typeof input.stored === "string" && input.stored.length > 0) {
192
+ return { token: input.stored, source: "stored" };
193
+ }
194
+ return null;
195
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * `flowsprite doctor` — what is wrong before anything else is tried.
4
+ *
5
+ * THE PROBE IS INJECTED so the formatter is testable without a network, a PATH
6
+ * or a child process; `realProbe` below is the one place any of those are
7
+ * touched.
8
+ */
9
+ /** The major this package declares in `engines` — one authority, read by both halves. */
10
+ export const MIN_NODE_MAJOR = 20;
11
+ export function nodeMajor(version) {
12
+ const m = /^v?(\d+)\./.exec(version);
13
+ return m ? Number(m[1]) : 0;
14
+ }
15
+ export function formatDoctor(report) {
16
+ const mark = (ok) => (ok ? "ok " : "FAIL");
17
+ return [
18
+ `${mark(report.nodeOk)} node ${report.nodeVersion} (needs >= ${MIN_NODE_MAJOR})`,
19
+ `${mark(report.endpointReachable)} endpoint ${report.endpoint} — ${report.endpointDetail}`,
20
+ `${mark(report.hasCredential)} credential for ${report.endpoint} ${report.hasCredential ? "found" : "not found — run `flowsprite login`"} (${report.credentialsPath})`,
21
+ `${report.sfOnPath ? "ok " : "note"} sf on PATH: ${report.sfOnPath ? "yes" : "no (only needed for Salesforce CLI work)"}`,
22
+ `${report.ghOnPath ? "ok " : "note"} gh on PATH: ${report.ghOnPath ? "yes" : "no (only needed for GitHub CLI work)"}`,
23
+ ];
24
+ }
25
+ /**
26
+ * Is `name` an executable this shell could run?
27
+ *
28
+ * NEVER `command -v` THROUGH A SHELL. `spawnSync(name, ["--version"])` with the
29
+ * default `shell: false` passes the name as argv[0] to execvp — there is no
30
+ * string for a crafted value to break out of. ENOENT is the absence signal; a
31
+ * non-zero exit is NOT (a binary that exists and dislikes `--version` is still
32
+ * on PATH).
33
+ */
34
+ export function isOnPath(name) {
35
+ const res = spawnSync(name, ["--version"], { shell: false, stdio: "ignore" });
36
+ if (res.error && res.error.code === "ENOENT")
37
+ return false;
38
+ return res.error === undefined || res.error === null;
39
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Which Flowsprite deployment this invocation talks to.
3
+ *
4
+ * THE BARE-ORIGIN RULE IS RE-STATED HERE, NOT IMPORTED. The repo's authority for
5
+ * it is `requireAppOrigin` in `convex/mcpSetup.ts` — unexported code inside the
6
+ * Convex isolate, which a zero-dependency node package cannot reach. This is a
7
+ * deliberate textual mirror of that paragraph, for the same reason it gives: the
8
+ * value is CONCATENATED with a path (`/api/mcp`, `/api/cli/auth/start`), so an
9
+ * origin carrying a path, a query or a fragment produces a URL that looks right
10
+ * and cannot work, and one carrying userinfo hands credentials to whatever reads
11
+ * the terminal. A trailing slash is accepted because `new URL("https://x/")`
12
+ * has pathname `"/"` — that IS the canonical origin, not a path.
13
+ *
14
+ * ONE RULE THIS CLI ADDS ON TOP: `http:` is accepted ONLY for loopback. Every
15
+ * request built from this origin carries an `fp_user_` bearer, and plaintext
16
+ * http to anything off this machine puts that bearer on the wire for whoever is
17
+ * on the path. `http://localhost:3000` is the dev server and is the whole reason
18
+ * `http:` is allowed at all, so the exception is an ALLOW-LIST of the three
19
+ * spellings of this machine — a deny-list would default every new host into the
20
+ * plaintext case, which is the wrong direction for a credential.
21
+ */
22
+ export const DEFAULT_ENDPOINT = "https://app.flowsprite.com";
23
+ /** The three spellings of this machine, the only hosts `http:` is allowed for. */
24
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
25
+ /**
26
+ * THE REFUSAL NEVER ECHOES THE VALUE — not the userinfo, not the host, not a
27
+ * "cleaned" version of it. Written as a structural rule rather than a filter for
28
+ * credential-looking parts: a filter is an enumeration of what looks like a
29
+ * secret, and the one it forgets is the one that reaches the screen (and a
30
+ * terminal scrollback, and a pasted bug report). The source name plus the rule
31
+ * is everything the person needs to fix it, and they can already see their own
32
+ * input.
33
+ */
34
+ export function parseEndpoint(input) {
35
+ const chosen = typeof input.flag === "string" && input.flag.length > 0
36
+ ? { raw: input.flag, source: "--endpoint" }
37
+ : typeof input.env === "string" && input.env.length > 0
38
+ ? { raw: input.env, source: "FLOWSPRITE_ENDPOINT" }
39
+ : { raw: DEFAULT_ENDPOINT, source: "default" };
40
+ let url = null;
41
+ try {
42
+ url = new URL(chosen.raw);
43
+ }
44
+ catch {
45
+ url = null;
46
+ }
47
+ // `URL.hostname` is already lower-cased and, for IPv6, already bracketed —
48
+ // measured: `http://LOCALHOST:3000` -> "localhost", `http://[::1]:3000` -> "[::1]".
49
+ const loopback = url !== null && LOOPBACK_HOSTS.has(url.hostname);
50
+ // The NEGATION of the rule in the docstring, so TS narrows `url` past it.
51
+ if (url === null ||
52
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
53
+ url.username !== "" ||
54
+ url.password !== "" ||
55
+ url.pathname !== "/" ||
56
+ url.search !== "" ||
57
+ url.hash !== "") {
58
+ return {
59
+ ok: false,
60
+ source: chosen.source,
61
+ message: `The endpoint from ${chosen.source} is not a usable Flowsprite origin. ` +
62
+ "It must be https:// with a host and nothing else — no username or password, " +
63
+ "no path, no query string and no #fragment. A single trailing slash is fine. " +
64
+ "Plain http:// is accepted only for localhost, 127.0.0.1 and [::1], because every " +
65
+ "request carries an access token. " +
66
+ `Example: ${DEFAULT_ENDPOINT}`,
67
+ };
68
+ }
69
+ // `url.origin`, never the raw string: lower-cased host, default port dropped,
70
+ // and no trailing slash for the `${origin}/api/mcp` concatenations to double.
71
+ return { ok: true, origin: url.origin, source: chosen.source };
72
+ }
package/dist/login.js ADDED
@@ -0,0 +1,154 @@
1
+ import { isLoginFailureKind, LOGIN_REMEDIES, POLL_CONTINUE_KINDS } from "./messages.js";
2
+ /**
3
+ * RFC 8628 device-code login against `/api/cli/auth/{start,poll}`.
4
+ *
5
+ * WHAT THE PERSON SEES: the user code and `verification_uri_complete`. NOT the
6
+ * device code — that value IS the credential the poll exchanges, so printing it
7
+ * would put a bearer-equivalent in a terminal scrollback for no gain (the
8
+ * browser never needs it). NOT the token either, on the way out.
9
+ *
10
+ * SLEEP CONVENTION — THE LOOP SLEEPS BEFORE EACH POLL, including the first.
11
+ * The clause ("polls every interval seconds") does not settle before-vs-between,
12
+ * and both are conforming; this one is chosen because the first poll is
13
+ * otherwise guaranteed to answer `authorization_pending` (nobody can have
14
+ * approved in the microseconds since `start` returned), and because the server
15
+ * treats a too-fast poll as `slow_down`, which lengthens the client's own next
16
+ * window.
17
+ *
18
+ * ponytail: no `sf`-CLI door on this ladder — card B4 is Blocked, so there is no
19
+ * "reuse the Salesforce CLI's browser session" rung here. When it lands it is
20
+ * one more branch BEFORE `start` is called, not a second login function.
21
+ */
22
+ /** Seconds added to the poll interval each time the server says `slow_down`. */
23
+ const SLOW_DOWN_STEP_SECONDS = 5;
24
+ /** The device-code TTL the endpoint documents (`expires_in: 900`) — and the ceiling. */
25
+ const MAX_EXPIRES_IN_SECONDS = 900;
26
+ function failure(kind) {
27
+ return { ok: false, kind, message: LOGIN_REMEDIES[kind] };
28
+ }
29
+ /** One POST of a JSON body, answering with the status and the parsed body. */
30
+ async function postJson(deps, url, body) {
31
+ let res;
32
+ try {
33
+ res = await deps.fetch(url, {
34
+ method: "POST",
35
+ headers: {
36
+ "content-type": "application/json",
37
+ accept: "application/json",
38
+ "user-agent": `flowsprite-cli/${deps.version}`,
39
+ },
40
+ body: JSON.stringify(body),
41
+ });
42
+ }
43
+ catch {
44
+ // The error object is DISCARDED, not formatted: a transport error's message
45
+ // is written by the runtime around the request we just built, and the one
46
+ // caller of this function has a fixed sentence for the whole class.
47
+ return null;
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = await res.json();
52
+ }
53
+ catch {
54
+ parsed = null;
55
+ }
56
+ const obj = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
57
+ ? parsed
58
+ : {};
59
+ return { status: res.status, body: obj };
60
+ }
61
+ /** The `{error: <kind>}` a refusal carries, mapped onto a kind we have a remedy for. */
62
+ function refusalKind(body) {
63
+ return isLoginFailureKind(body.error) ? body.error : "server_error";
64
+ }
65
+ export async function deviceCodeLogin(deps) {
66
+ const start = await postJson(deps, `${deps.origin}/api/cli/auth/start`, {
67
+ client_name: "flowsprite-cli",
68
+ cli_version: deps.version,
69
+ });
70
+ if (start === null)
71
+ return failure("transport");
72
+ if (start.status !== 200) {
73
+ // `rate_limited` (429), `invalid_argument`/`invalid_request` (400),
74
+ // `server_misconfigured`/`server_error` (500) — the route's own vocabulary,
75
+ // read off the body rather than guessed from the status.
76
+ return failure(refusalKind(start.body));
77
+ }
78
+ const deviceCode = start.body.device_code;
79
+ const userCode = start.body.user_code;
80
+ const completeUri = start.body.verification_uri_complete;
81
+ const fallbackUri = start.body.verification_uri;
82
+ if (typeof deviceCode !== "string" || typeof userCode !== "string") {
83
+ return failure("server_error");
84
+ }
85
+ const openTarget = typeof completeUri === "string"
86
+ ? completeUri
87
+ : typeof fallbackUri === "string"
88
+ ? fallbackUri
89
+ : null;
90
+ if (openTarget === null)
91
+ return failure("server_error");
92
+ deps.writer.out(`Open ${openTarget} and confirm the code ${userCode}`);
93
+ deps.writer.out("Waiting for you to approve this login...");
94
+ if (deps.openUrl) {
95
+ try {
96
+ deps.openUrl(openTarget);
97
+ }
98
+ catch {
99
+ // Deliberately silent: see LoginDeps.openUrl. The URL is already printed.
100
+ }
101
+ }
102
+ const intervalFromServer = start.body.interval;
103
+ let intervalSeconds = typeof intervalFromServer === "number" && intervalFromServer > 0 ? intervalFromServer : 5;
104
+ // The device code's own lifetime bounds the loop, so a server that answers
105
+ // `authorization_pending` forever still ends — with the same sentence a real
106
+ // `expired_token` gets, because the window really has passed.
107
+ //
108
+ // CLAMPED TO THE REAL TTL. `expires_in` is a number this CLI reads off a
109
+ // response, and the loop's termination is the only thing standing between a
110
+ // bad value and a terminal that polls for a year. The server's own TTL is 15
111
+ // minutes (`docs/mcp.md`, `expires_in: 900`), so anything larger is either a
112
+ // mistake or a hostile endpoint, and anything non-positive would end the loop
113
+ // before a single poll. Both fold to the documented value.
114
+ const claimed = start.body.expires_in;
115
+ const expiresIn = typeof claimed === "number" && Number.isFinite(claimed) && claimed > 0
116
+ ? Math.min(claimed, MAX_EXPIRES_IN_SECONDS)
117
+ : MAX_EXPIRES_IN_SECONDS;
118
+ let waitedSeconds = 0;
119
+ for (;;) {
120
+ if (waitedSeconds >= expiresIn)
121
+ return failure("expired_token");
122
+ await deps.sleep(intervalSeconds * 1000);
123
+ waitedSeconds += intervalSeconds;
124
+ const poll = await postJson(deps, `${deps.origin}/api/cli/auth/poll`, {
125
+ device_code: deviceCode,
126
+ });
127
+ if (poll === null)
128
+ return failure("transport");
129
+ if (poll.status === 200) {
130
+ const token = poll.body.token;
131
+ const orgId = poll.body.org_id;
132
+ const userId = poll.body.user_id;
133
+ const mcpEndpoint = poll.body.mcp_endpoint;
134
+ if (typeof token !== "string" ||
135
+ typeof orgId !== "string" ||
136
+ typeof userId !== "string" ||
137
+ typeof mcpEndpoint !== "string") {
138
+ return failure("server_error");
139
+ }
140
+ return { ok: true, token, orgId, userId, mcpEndpoint };
141
+ }
142
+ const kind = poll.body.error;
143
+ if (typeof kind === "string" && POLL_CONTINUE_KINDS.has(kind)) {
144
+ if (kind === "slow_down") {
145
+ // The server has ALREADY raised its own required interval by this much;
146
+ // raising ours to match is what stops the next poll earning another one.
147
+ // It persists for the rest of the loop rather than applying to one poll.
148
+ intervalSeconds += SLOW_DOWN_STEP_SECONDS;
149
+ }
150
+ continue;
151
+ }
152
+ return failure(refusalKind(poll.body));
153
+ }
154
+ }
@@ -0,0 +1,109 @@
1
+ import { redact } from "./redact.js";
2
+ /**
3
+ * EVERY message this module produces goes through `redact` before it is
4
+ * returned. The bearer lives only in a header, so no message SHOULD carry it —
5
+ * but "should" is the part that fails: a vendor error string can quote the
6
+ * request it was building, and the caller has no way to tell which strings came
7
+ * from us. Redacting at the one place messages are constructed makes the
8
+ * guarantee a property of this file rather than of every future edit to it.
9
+ */
10
+ function fail(kind, message, code) {
11
+ const safe = redact(message);
12
+ return code === undefined ? { ok: false, kind, message: safe } : { ok: false, kind, message: safe, code };
13
+ }
14
+ export function createMcpClient(deps) {
15
+ const url = `${deps.origin}/api/mcp`;
16
+ let nextId = 0;
17
+ async function rpc(method, params) {
18
+ let res;
19
+ try {
20
+ res = await deps.fetch(url, {
21
+ method: "POST",
22
+ headers: {
23
+ authorization: `Bearer ${deps.token}`,
24
+ "content-type": "application/json",
25
+ accept: "application/json",
26
+ "user-agent": `flowsprite-cli/${deps.version}`,
27
+ },
28
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++nextId, method, params }),
29
+ });
30
+ }
31
+ catch (err) {
32
+ // Transport death, never reaching the app. Classified by the error's NAME,
33
+ // never by parsing its message text, which varies by runtime and libc.
34
+ const name = err instanceof Error ? err.name : "unknown";
35
+ return fail("transport", `Could not reach ${url} (${name}).`);
36
+ }
37
+ // 401 IS READ OFF THE STATUS, before the body. The body also carries
38
+ // `error.code -32001`, but a status is what the auth layer actually sets.
39
+ if (res.status === 401) {
40
+ return fail("unauthorized", `${url} refused the token (HTTP 401). Run \`flowsprite login\` to get a new one.`);
41
+ }
42
+ let body;
43
+ try {
44
+ body = await res.json();
45
+ }
46
+ catch {
47
+ return fail("transport", `${url} answered HTTP ${res.status} with a body that is not JSON.`);
48
+ }
49
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
50
+ return fail("transport", `${url} answered HTTP ${res.status} with a body that is not a JSON-RPC object.`);
51
+ }
52
+ const envelope = body;
53
+ const rpcError = envelope.error;
54
+ if (typeof rpcError === "object" && rpcError !== null && !Array.isArray(rpcError)) {
55
+ const e = rpcError;
56
+ const code = typeof e.code === "number" ? e.code : undefined;
57
+ const message = typeof e.message === "string" ? e.message : "unknown JSON-RPC error";
58
+ return fail("rpc_error", message, code);
59
+ }
60
+ const result = envelope.result;
61
+ if (typeof result !== "object" || result === null || Array.isArray(result)) {
62
+ return fail("transport", `${url} answered HTTP ${res.status} with neither a result nor an error.`);
63
+ }
64
+ const r = result;
65
+ const content = Array.isArray(r.content) ? r.content : [];
66
+ if (r.isError === true) {
67
+ // The tool's own refusal text is the whole value of this branch — an
68
+ // `isError` result with no text would otherwise be an empty message.
69
+ const text = content.find((c) => typeof c.text === "string")?.text;
70
+ return fail("tool_error", text ?? `${method} failed and said nothing about why.`);
71
+ }
72
+ return r.structuredContent === undefined
73
+ ? { ok: true, content }
74
+ : { ok: true, content, structured: r.structuredContent };
75
+ }
76
+ return {
77
+ url,
78
+ callTool: (name, args) => rpc("tools/call", { name, arguments: args }),
79
+ initialize: () => rpc("initialize", {
80
+ protocolVersion: "2025-06-18",
81
+ capabilities: {},
82
+ clientInfo: { name: "flowsprite-cli", version: deps.version },
83
+ }),
84
+ };
85
+ }
86
+ /**
87
+ * The JSON payload every `setup_*` / `sfdc_*` tool puts in `content[0].text`.
88
+ *
89
+ * It lives here rather than in the caller because the SHAPE is a property of
90
+ * this endpoint, not of setup: the tools answer with a JSON string, not with a
91
+ * structured field. A body that is not parseable JSON is a `null` rather than a
92
+ * throw — the caller already has to handle "the tool refused", and a second
93
+ * failure channel for the same call is how one of them stops being handled.
94
+ */
95
+ export function toolJson(result) {
96
+ if (!result.ok)
97
+ return null;
98
+ if (result.structured !== undefined)
99
+ return result.structured;
100
+ const text = result.content.find((c) => typeof c.text === "string")?.text;
101
+ if (typeof text !== "string")
102
+ return null;
103
+ try {
104
+ return JSON.parse(text);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }