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.
- package/README.md +93 -0
- package/dist/bin.js +128 -0
- package/dist/cli.js +974 -0
- package/dist/credentials.js +195 -0
- package/dist/doctor.js +39 -0
- package/dist/endpoint.js +72 -0
- package/dist/login.js +154 -0
- package/dist/mcpClient.js +109 -0
- package/dist/mcpConfig.js +266 -0
- package/dist/mcpInstall.js +225 -0
- package/dist/messages.js +47 -0
- package/dist/output.js +21 -0
- package/dist/redact.js +47 -0
- package/dist/setup.js +216 -0
- package/dist/status.js +92 -0
- package/dist/usage.js +54 -0
- package/dist/version.js +10 -0
- package/package.json +21 -0
|
@@ -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
|
+
}
|
package/dist/endpoint.js
ADDED
|
@@ -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
|
+
}
|