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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { credentialsPath, deleteCredential, readCredentials, resolveToken, writeCredential, } from "./credentials.js";
|
|
4
|
+
import { formatDoctor, MIN_NODE_MAJOR, nodeMajor } from "./doctor.js";
|
|
5
|
+
import { parseEndpoint } from "./endpoint.js";
|
|
6
|
+
import { deviceCodeLogin } from "./login.js";
|
|
7
|
+
import { createMcpClient, toolJson } from "./mcpClient.js";
|
|
8
|
+
import { jsonConfigHasServer, McpConfigError, SERVER_NAME, tomlConfigHasServer, upsertJsonConfig, } from "./mcpConfig.js";
|
|
9
|
+
import { CLIENTS, commandFor, configPathFor, isClientId, planInstall, renderSnippet, TOKEN_PLACEHOLDER, } from "./mcpInstall.js";
|
|
10
|
+
import { redact } from "./redact.js";
|
|
11
|
+
import { asArray, asObject, planBindArgs, planNextStep, rungConnectionIds, runLinkStep, } from "./setup.js";
|
|
12
|
+
import { renderStatus } from "./status.js";
|
|
13
|
+
import { USAGE } from "./usage.js";
|
|
14
|
+
/**
|
|
15
|
+
* The argv router. EXIT CODES ARE THE CONTRACT: 0 success, 1 the command ran and
|
|
16
|
+
* the answer was bad, 2 the command line itself was wrong (unknown command,
|
|
17
|
+
* unknown flag, missing required flag). A script can tell "you asked wrong" from
|
|
18
|
+
* "Flowsprite said no" without parsing prose.
|
|
19
|
+
*
|
|
20
|
+
* EVERY EFFECT IS INJECTED (DM3): fetch, sleep, the clock, the environment, the
|
|
21
|
+
* writer, the PATH probe and the optional prompt. `bin.ts` is the only file that
|
|
22
|
+
* names a real one, so every command below is drivable in a test without a
|
|
23
|
+
* network, a home directory or a TTY.
|
|
24
|
+
*/
|
|
25
|
+
export const EXIT_OK = 0;
|
|
26
|
+
export const EXIT_FAILED = 1;
|
|
27
|
+
export const EXIT_USAGE = 2;
|
|
28
|
+
const OPTIONS = {
|
|
29
|
+
endpoint: { type: "string" },
|
|
30
|
+
json: { type: "boolean" },
|
|
31
|
+
token: { type: "string" },
|
|
32
|
+
save: { type: "boolean" },
|
|
33
|
+
"no-open": { type: "boolean" },
|
|
34
|
+
sandbox: { type: "boolean" },
|
|
35
|
+
"my-domain": { type: "string" },
|
|
36
|
+
connection: { type: "string" },
|
|
37
|
+
installation: { type: "string" },
|
|
38
|
+
repo: { type: "string" },
|
|
39
|
+
create: { type: "string" },
|
|
40
|
+
client: { type: "string" },
|
|
41
|
+
print: { type: "boolean" },
|
|
42
|
+
force: { type: "boolean" },
|
|
43
|
+
reveal: { type: "boolean" },
|
|
44
|
+
"token-env": { type: "string" },
|
|
45
|
+
help: { type: "boolean", short: "h" },
|
|
46
|
+
version: { type: "boolean", short: "v" },
|
|
47
|
+
};
|
|
48
|
+
const COMMANDS = new Set(["setup", "login", "logout", "status", "connect", "bind", "sync", "doctor", "mcp"]);
|
|
49
|
+
export async function main(argv, deps) {
|
|
50
|
+
let values;
|
|
51
|
+
let positionals;
|
|
52
|
+
try {
|
|
53
|
+
// `strict: true` is what makes an unknown flag an error at all, and it
|
|
54
|
+
// covers SINGLE-dash too: measured on node 22, `-x` throws
|
|
55
|
+
// ERR_PARSE_ARGS_UNKNOWN_OPTION exactly as `--nope` does, and a clustered
|
|
56
|
+
// `-xyz` reports the first unknown short. There is no second check to write.
|
|
57
|
+
const parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
58
|
+
values = parsed.values;
|
|
59
|
+
positionals = parsed.positionals;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return usageError(deps.writer, err instanceof Error ? err.message : "could not parse the command line");
|
|
63
|
+
}
|
|
64
|
+
if (values.help === true) {
|
|
65
|
+
deps.writer.out(USAGE);
|
|
66
|
+
return EXIT_OK;
|
|
67
|
+
}
|
|
68
|
+
if (values.version === true) {
|
|
69
|
+
deps.writer.out(deps.version);
|
|
70
|
+
return EXIT_OK;
|
|
71
|
+
}
|
|
72
|
+
const command = positionals[0] ?? "setup";
|
|
73
|
+
if (!COMMANDS.has(command)) {
|
|
74
|
+
return usageError(deps.writer, `Unknown command '${command}'.`);
|
|
75
|
+
}
|
|
76
|
+
const endpoint = parseEndpoint({
|
|
77
|
+
flag: typeof values.endpoint === "string" ? values.endpoint : undefined,
|
|
78
|
+
env: deps.env.FLOWSPRITE_ENDPOINT,
|
|
79
|
+
});
|
|
80
|
+
if (!endpoint.ok)
|
|
81
|
+
return usageError(deps.writer, endpoint.message);
|
|
82
|
+
const origin = endpoint.origin;
|
|
83
|
+
switch (command) {
|
|
84
|
+
case "login":
|
|
85
|
+
return await cmdLogin(deps, origin, values);
|
|
86
|
+
case "logout":
|
|
87
|
+
return cmdLogout(deps, origin);
|
|
88
|
+
case "doctor":
|
|
89
|
+
return await cmdDoctor(deps, origin, values);
|
|
90
|
+
case "status":
|
|
91
|
+
return await cmdStatus(deps, origin, values);
|
|
92
|
+
case "connect":
|
|
93
|
+
return await cmdConnect(deps, origin, values, positionals[1]);
|
|
94
|
+
case "mcp":
|
|
95
|
+
return await cmdMcp(deps, origin, values, positionals[1]);
|
|
96
|
+
case "bind":
|
|
97
|
+
return await cmdBind(deps, origin, values);
|
|
98
|
+
case "sync":
|
|
99
|
+
return await cmdSync(deps, origin, values);
|
|
100
|
+
default:
|
|
101
|
+
return await cmdSetup(deps, origin, values);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function usageError(writer, message) {
|
|
105
|
+
writer.err(message);
|
|
106
|
+
writer.err("");
|
|
107
|
+
writer.err(USAGE);
|
|
108
|
+
return EXIT_USAGE;
|
|
109
|
+
}
|
|
110
|
+
// ------------------------------------------------------------------ the client
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the token for this origin and build a client, or explain what is
|
|
113
|
+
* missing. `--token` beats `FLOWSPRITE_TOKEN` beats the stored credential —
|
|
114
|
+
* precedence lives in `resolveToken`, this only supplies the three candidates.
|
|
115
|
+
*/
|
|
116
|
+
function clientFor(deps, origin, values) {
|
|
117
|
+
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
118
|
+
const token = resolveToken({
|
|
119
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
120
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
121
|
+
stored: stored?.token,
|
|
122
|
+
});
|
|
123
|
+
if (token === null) {
|
|
124
|
+
deps.writer.err(`Not logged in to ${origin}. Run \`flowsprite login\`, or pass --token.`);
|
|
125
|
+
return { ok: false };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
client: createMcpClient({ origin, token: token.token, fetch: deps.fetch, version: deps.version }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ------------------------------------------------------------------- commands
|
|
133
|
+
async function cmdLogin(deps, origin, values) {
|
|
134
|
+
// A token handed in on the command line or the environment SKIPS the device
|
|
135
|
+
// loop entirely — there is nothing to approve. It is not stored unless --save
|
|
136
|
+
// is given, so a one-off token in a CI job leaves nothing behind.
|
|
137
|
+
const direct = typeof values.token === "string" && values.token.length > 0
|
|
138
|
+
? values.token
|
|
139
|
+
: typeof deps.env.FLOWSPRITE_TOKEN === "string" && deps.env.FLOWSPRITE_TOKEN.length > 0
|
|
140
|
+
? deps.env.FLOWSPRITE_TOKEN
|
|
141
|
+
: null;
|
|
142
|
+
if (direct !== null) {
|
|
143
|
+
if (values.save === true) {
|
|
144
|
+
writeCredential(deps.env, origin, { token: direct, orgId: "", userId: "", mcpEndpoint: `${origin}/api/mcp`, savedAt: deps.now() }, deps.home);
|
|
145
|
+
deps.writer.out(`Saved a token for ${origin}.`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
deps.writer.out(`Using the token you supplied for ${origin}. Pass --save to store it.`);
|
|
149
|
+
}
|
|
150
|
+
return EXIT_OK;
|
|
151
|
+
}
|
|
152
|
+
const result = await deviceCodeLogin({
|
|
153
|
+
origin,
|
|
154
|
+
fetch: deps.fetch,
|
|
155
|
+
sleep: deps.sleep,
|
|
156
|
+
writer: deps.writer,
|
|
157
|
+
version: deps.version,
|
|
158
|
+
...(values["no-open"] === true || deps.openUrl === undefined ? {} : { openUrl: deps.openUrl }),
|
|
159
|
+
});
|
|
160
|
+
if (!result.ok) {
|
|
161
|
+
deps.writer.err(result.message);
|
|
162
|
+
return EXIT_FAILED;
|
|
163
|
+
}
|
|
164
|
+
const cred = {
|
|
165
|
+
token: result.token,
|
|
166
|
+
orgId: result.orgId,
|
|
167
|
+
userId: result.userId,
|
|
168
|
+
mcpEndpoint: result.mcpEndpoint,
|
|
169
|
+
savedAt: deps.now(),
|
|
170
|
+
};
|
|
171
|
+
// The token is NOT in this sentence, and the writer would redact it anyway.
|
|
172
|
+
const who = `Logged in to ${origin} as ${result.userId} in workspace ${result.orgId}`;
|
|
173
|
+
try {
|
|
174
|
+
writeCredential(deps.env, origin, cred, deps.home);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
// THE LOGIN ALREADY SUCCEEDED AND THE DEVICE ROW IS ALREADY CONSUMED, so a
|
|
178
|
+
// failed write is not "the login failed": the token was issued, it exists,
|
|
179
|
+
// and this process is the only thing that will ever hold it. Saying so —
|
|
180
|
+
// and saying how to use it for this session — is the difference between one
|
|
181
|
+
// lost token and a person logging in again and again. A second login mints a
|
|
182
|
+
// SECOND token on the same grant, which is how a workspace ends up with
|
|
183
|
+
// several live grants and every later login answers `ambiguous_grant`.
|
|
184
|
+
//
|
|
185
|
+
// The token itself is never printed (the writer redacts, and it is not in
|
|
186
|
+
// any of these strings). `err.message` is not printed either: it is a node
|
|
187
|
+
// string about a path, and the path is already named here.
|
|
188
|
+
deps.writer.out(who);
|
|
189
|
+
deps.writer.err(`Your token was issued, but it could not be saved to ${credentialsPath(deps.env, deps.home)} ` +
|
|
190
|
+
`(${err.code ?? "write failed"}). ` +
|
|
191
|
+
"Fix that path, or set FLOWSPRITE_TOKEN / pass --token to use this session's token without storing it.");
|
|
192
|
+
return EXIT_FAILED;
|
|
193
|
+
}
|
|
194
|
+
deps.writer.out(who);
|
|
195
|
+
return EXIT_OK;
|
|
196
|
+
}
|
|
197
|
+
function cmdLogout(deps, origin) {
|
|
198
|
+
const removed = deleteCredential(deps.env, origin, deps.home);
|
|
199
|
+
deps.writer.out(removed ? `Logged out of ${origin}.` : `There was no stored credential for ${origin}.`);
|
|
200
|
+
return EXIT_OK;
|
|
201
|
+
}
|
|
202
|
+
async function cmdDoctor(deps, origin, values) {
|
|
203
|
+
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
204
|
+
let reachable = false;
|
|
205
|
+
let detail = "no credential, so reachability was not tested";
|
|
206
|
+
const built = clientForQuiet(deps, origin, values, stored?.token);
|
|
207
|
+
if (built !== null) {
|
|
208
|
+
const probe = await built.initialize();
|
|
209
|
+
reachable = probe.ok;
|
|
210
|
+
// `initialize` is the reachability probe (DM2). An `unauthorized` answer
|
|
211
|
+
// still proves the endpoint is THERE — it is reported as its own line rather
|
|
212
|
+
// than folded into "unreachable", which would send someone to check DNS.
|
|
213
|
+
detail = probe.ok ? "reachable" : `${probe.kind}: ${probe.message}`;
|
|
214
|
+
}
|
|
215
|
+
const report = {
|
|
216
|
+
nodeVersion: deps.nodeVersion,
|
|
217
|
+
nodeOk: nodeMajor(deps.nodeVersion) >= MIN_NODE_MAJOR,
|
|
218
|
+
endpoint: origin,
|
|
219
|
+
endpointReachable: reachable,
|
|
220
|
+
endpointDetail: detail,
|
|
221
|
+
hasCredential: stored !== undefined,
|
|
222
|
+
credentialsPath: credentialsPath(deps.env, deps.home),
|
|
223
|
+
sfOnPath: deps.isOnPath("sf"),
|
|
224
|
+
ghOnPath: deps.isOnPath("gh"),
|
|
225
|
+
};
|
|
226
|
+
if (values.json === true) {
|
|
227
|
+
deps.writer.out(JSON.stringify(report, null, 2));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
for (const line of formatDoctor(report))
|
|
231
|
+
deps.writer.out(line);
|
|
232
|
+
}
|
|
233
|
+
return report.nodeOk ? EXIT_OK : EXIT_FAILED;
|
|
234
|
+
}
|
|
235
|
+
/** Like `clientFor` but silent and optional — doctor reports absence, it does not refuse. */
|
|
236
|
+
function clientForQuiet(deps, origin, values, storedToken) {
|
|
237
|
+
const token = resolveToken({
|
|
238
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
239
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
240
|
+
stored: storedToken,
|
|
241
|
+
});
|
|
242
|
+
if (token === null)
|
|
243
|
+
return null;
|
|
244
|
+
return createMcpClient({ origin, token: token.token, fetch: deps.fetch, version: deps.version });
|
|
245
|
+
}
|
|
246
|
+
async function fetchStatus(deps, client) {
|
|
247
|
+
const result = await client.callTool("setup_status", {});
|
|
248
|
+
if (!result.ok) {
|
|
249
|
+
deps.writer.err(result.message);
|
|
250
|
+
return { ok: false };
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, payload: toolJson(result) };
|
|
253
|
+
}
|
|
254
|
+
async function cmdStatus(deps, origin, values) {
|
|
255
|
+
const built = clientFor(deps, origin, values);
|
|
256
|
+
if (!built.ok)
|
|
257
|
+
return EXIT_FAILED;
|
|
258
|
+
const status = await fetchStatus(deps, built.client);
|
|
259
|
+
if (!status.ok)
|
|
260
|
+
return EXIT_FAILED;
|
|
261
|
+
if (values.json === true) {
|
|
262
|
+
// The payload, byte for byte as the tool sent it — a machine consumer must
|
|
263
|
+
// never be reading this CLI's idea of the shape.
|
|
264
|
+
deps.writer.out(JSON.stringify(status.payload, null, 2));
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
for (const line of renderStatus(status.payload))
|
|
268
|
+
deps.writer.out(line);
|
|
269
|
+
}
|
|
270
|
+
return EXIT_OK;
|
|
271
|
+
}
|
|
272
|
+
function setupDeps(deps, client) {
|
|
273
|
+
return { client, writer: deps.writer, sleep: deps.sleep, now: deps.now };
|
|
274
|
+
}
|
|
275
|
+
async function cmdConnect(deps, origin, values, which) {
|
|
276
|
+
if (which !== "salesforce" && which !== "github") {
|
|
277
|
+
return usageError(deps.writer, "connect takes 'salesforce' or 'github'.");
|
|
278
|
+
}
|
|
279
|
+
const built = clientFor(deps, origin, values);
|
|
280
|
+
if (!built.ok)
|
|
281
|
+
return EXIT_FAILED;
|
|
282
|
+
const tool = which === "salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
|
|
283
|
+
const stepKind = which === "salesforce" ? "connect_salesforce" : "connect_github";
|
|
284
|
+
// ponytail: both alternative doors are absent here, and both plug in at THIS
|
|
285
|
+
// call, as a branch chosen before the link is minted — not as a change to the
|
|
286
|
+
// link step itself. Salesforce: the `sf`-CLI handoff (card B4, Blocked), which
|
|
287
|
+
// would reuse a `sf org login` session instead of a browser link. GitHub: the
|
|
288
|
+
// gh-token door (card A3, blocked), which would use a token the person already
|
|
289
|
+
// holds instead of an app install.
|
|
290
|
+
const args = which === "salesforce"
|
|
291
|
+
? {
|
|
292
|
+
...(values.sandbox === true ? { env: "sandbox" } : {}),
|
|
293
|
+
...(typeof values["my-domain"] === "string" ? { myDomain: values["my-domain"] } : {}),
|
|
294
|
+
}
|
|
295
|
+
: {};
|
|
296
|
+
const outcome = await runLinkStep(setupDeps(deps, built.client), tool, args, stepKind);
|
|
297
|
+
return reportLinkOutcome(deps.writer, outcome);
|
|
298
|
+
}
|
|
299
|
+
function reportLinkOutcome(writer, outcome) {
|
|
300
|
+
switch (outcome.kind) {
|
|
301
|
+
case "done":
|
|
302
|
+
writer.out("Done.");
|
|
303
|
+
return EXIT_OK;
|
|
304
|
+
case "expired":
|
|
305
|
+
writer.err("That setup link expired before it was used. Run the command again for a fresh one.");
|
|
306
|
+
return EXIT_FAILED;
|
|
307
|
+
case "timeout":
|
|
308
|
+
writer.err("Gave up waiting after 10 minutes. The link may still work — finish it in the browser, then run `flowsprite status`.");
|
|
309
|
+
return EXIT_FAILED;
|
|
310
|
+
default:
|
|
311
|
+
writer.err(outcome.message);
|
|
312
|
+
return EXIT_FAILED;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* What the CURRENT rung of the server's ladder is about, when a rung is being
|
|
317
|
+
* driven by `setup` rather than by a bare `bind` / `sync`.
|
|
318
|
+
*
|
|
319
|
+
* It exists because the rungs are LIST-WIDE (see `rungConnectionIds`): "bind a
|
|
320
|
+
* repo" means "some connection has none", not "this connection has none". An
|
|
321
|
+
* explicit `--connection` naming a connection the rung is NOT about would
|
|
322
|
+
* re-do work on the wrong row and leave the rung exactly where it was.
|
|
323
|
+
*/
|
|
324
|
+
const CONNECTION = { singular: "Salesforce connection", plural: "Salesforce connections", flag: "--connection" };
|
|
325
|
+
const INSTALLATION = { singular: "GitHub installation", plural: "GitHub installations", flag: "--installation" };
|
|
326
|
+
const REPOSITORY = { singular: "repository", plural: "repositories", flag: "--repo" };
|
|
327
|
+
/**
|
|
328
|
+
* Why an explicit `--connection` is refused under a rung.
|
|
329
|
+
*
|
|
330
|
+
* ⚠️ THE REASON IS BRANCHED, BECAUSE "NOT IN THE RUNG'S SET" HAS TWO CAUSES AND
|
|
331
|
+
* ONLY ONE OF THEM IS "you already did this one". An id that is not a connection
|
|
332
|
+
* in this workspace at all — a typo, a stale id copied from another workspace —
|
|
333
|
+
* is also not in the set, and telling that person their connection "is already
|
|
334
|
+
* bound" is a false statement about a row that does not exist, pointing them at
|
|
335
|
+
* the wrong fix. Membership in `connections` is what distinguishes the two, so it
|
|
336
|
+
* is read rather than assumed.
|
|
337
|
+
*
|
|
338
|
+
* ONE HOME for both rungs: `bind` and `sync` had the same sentence with the same
|
|
339
|
+
* defect, differing only in two words. Two copies is how one of them gets fixed.
|
|
340
|
+
*/
|
|
341
|
+
function outOfRungRefusal(flagValue, rung) {
|
|
342
|
+
const known = asArray(asObject(rung.status)?.connections).some((c) => String(c.id ?? "") === flagValue);
|
|
343
|
+
const binding = rung.kind === "bind_repo";
|
|
344
|
+
const reason = known
|
|
345
|
+
? `--connection ${flagValue} is already ${binding ? "bound" : "synced"}`
|
|
346
|
+
: `--connection ${flagValue} is not a connection in this workspace`;
|
|
347
|
+
return (`${reason}, and the workspace still has ${rung.connectionIds.length} connection(s) that ` +
|
|
348
|
+
`${binding ? "are not bound" : "have not synced"}: ${rung.connectionIds.join(", ")}. ` +
|
|
349
|
+
`Re-run ${binding ? "bind" : "sync"} for one of those, or drop --connection and let setup ask.`);
|
|
350
|
+
}
|
|
351
|
+
async function cmdBind(deps, origin, values) {
|
|
352
|
+
const built = clientFor(deps, origin, values);
|
|
353
|
+
if (!built.ok)
|
|
354
|
+
return EXIT_FAILED;
|
|
355
|
+
return await runBind(deps, values, built.client, null);
|
|
356
|
+
}
|
|
357
|
+
async function runBind(deps, values, client, rung) {
|
|
358
|
+
const filled = await fillBindInput(deps, client, values, rung);
|
|
359
|
+
if (filled === null)
|
|
360
|
+
return EXIT_USAGE;
|
|
361
|
+
const plan = planBindArgs(filled);
|
|
362
|
+
if (!plan.ok) {
|
|
363
|
+
// A ONE-LINE REMEDY, not the whole usage text. By the time we are here the
|
|
364
|
+
// command line parsed fine and the workspace was read; dumping 39 lines of
|
|
365
|
+
// usage buries the sentence that says what to do.
|
|
366
|
+
deps.writer.err(plan.message);
|
|
367
|
+
return EXIT_USAGE;
|
|
368
|
+
}
|
|
369
|
+
const result = await client.callTool("setup_bind_repo", plan.args);
|
|
370
|
+
if (!result.ok) {
|
|
371
|
+
deps.writer.err(result.message);
|
|
372
|
+
return EXIT_FAILED;
|
|
373
|
+
}
|
|
374
|
+
if (values.json === true) {
|
|
375
|
+
deps.writer.out(JSON.stringify(toolJson(result), null, 2));
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
deps.writer.out("Bound.");
|
|
379
|
+
for (const line of textOf(result.content))
|
|
380
|
+
deps.writer.out(line);
|
|
381
|
+
}
|
|
382
|
+
return EXIT_OK;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Fill in `--connection`, `--installation` and the repository when the workspace
|
|
386
|
+
* leaves no choice, and ask when it does.
|
|
387
|
+
*
|
|
388
|
+
* ONE candidate is not a choice, so it is taken silently. SEVERAL is a decision
|
|
389
|
+
* that belongs to the person: with a prompt available it is asked, and without
|
|
390
|
+
* one (a pipe, a CI job) the candidates are LISTED ON STDERR and the command
|
|
391
|
+
* refuses — never guessed, because binding SUPERSEDES whatever that connection
|
|
392
|
+
* was bound to before and switches off its data export.
|
|
393
|
+
*
|
|
394
|
+
* THE REPOSITORY IS PICKED THE SAME WAY, and that is what makes a bare
|
|
395
|
+
* `flowsprite setup` able to pass this rung at all: `setup_status` already
|
|
396
|
+
* carries each installation's repositories (FLO-496 AC6), so the ladder does not
|
|
397
|
+
* have to ask the person for a flag it never told them they needed. `--create`
|
|
398
|
+
* stays the non-interactive alternative for a workspace whose repos Flowsprite
|
|
399
|
+
* has not listed.
|
|
400
|
+
*/
|
|
401
|
+
async function fillBindInput(deps, client, values, rung) {
|
|
402
|
+
const input = {
|
|
403
|
+
connection: typeof values.connection === "string" ? values.connection : undefined,
|
|
404
|
+
installation: typeof values.installation === "string" ? values.installation : undefined,
|
|
405
|
+
repo: typeof values.repo === "string" ? values.repo : undefined,
|
|
406
|
+
create: typeof values.create === "string" ? values.create : undefined,
|
|
407
|
+
};
|
|
408
|
+
const payload = rung !== null ? rung.status : await (async () => {
|
|
409
|
+
const status = await fetchStatus(deps, client);
|
|
410
|
+
return status.ok ? status.payload : undefined;
|
|
411
|
+
})();
|
|
412
|
+
if (payload === undefined)
|
|
413
|
+
return null;
|
|
414
|
+
const root = asObject(payload);
|
|
415
|
+
const connections = asArray(root?.connections);
|
|
416
|
+
const installations = asArray(root?.installations);
|
|
417
|
+
// ---- which connection ----------------------------------------------------
|
|
418
|
+
// Under a rung, the CANDIDATES are the connections that rung is unsatisfied
|
|
419
|
+
// about — not every connection — and an explicit --connection outside that set
|
|
420
|
+
// is refused rather than silently re-bound (it would leave the rung unmoved,
|
|
421
|
+
// which is the unbounded-loop defect one level down).
|
|
422
|
+
const eligible = rung !== null
|
|
423
|
+
? connections.filter((c) => rung.connectionIds.includes(String(c.id)))
|
|
424
|
+
: (() => {
|
|
425
|
+
const unbound = connections.filter((c) => c.hasBinding !== true);
|
|
426
|
+
return unbound.length > 0 ? unbound : connections;
|
|
427
|
+
})();
|
|
428
|
+
if (typeof input.connection === "string") {
|
|
429
|
+
if (rung !== null && !rung.connectionIds.includes(input.connection)) {
|
|
430
|
+
deps.writer.err(outOfRungRefusal(input.connection, rung));
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const picked = await pickOne(deps, CONNECTION, eligible.map((c) => [
|
|
436
|
+
String(c.id ?? ""),
|
|
437
|
+
`${String(c.displayName ?? "?")} [${String(c.envType ?? "?")}]`,
|
|
438
|
+
]));
|
|
439
|
+
if (picked === null)
|
|
440
|
+
return null;
|
|
441
|
+
input.connection = picked;
|
|
442
|
+
}
|
|
443
|
+
// ---- which GitHub installation ------------------------------------------
|
|
444
|
+
if (typeof input.installation !== "string") {
|
|
445
|
+
const picked = await pickOne(deps, INSTALLATION, installations.map((i) => [
|
|
446
|
+
String(i.installationId ?? ""),
|
|
447
|
+
`@${String(i.accountLogin ?? "?")} [${String(i.accountType ?? "?")}]`,
|
|
448
|
+
]));
|
|
449
|
+
if (picked === null)
|
|
450
|
+
return null;
|
|
451
|
+
input.installation = picked;
|
|
452
|
+
}
|
|
453
|
+
// ---- which repository ----------------------------------------------------
|
|
454
|
+
if (typeof input.repo !== "string" && typeof input.create !== "string") {
|
|
455
|
+
const chosen = installations.find((i) => String(i.installationId ?? "") === String(input.installation));
|
|
456
|
+
const repos = asArray(chosen?.repos);
|
|
457
|
+
if (repos.length === 0) {
|
|
458
|
+
// An EMPTY list means Flowsprite has not listed that installation's
|
|
459
|
+
// repositories, not that the installation has none — the distinction the
|
|
460
|
+
// setup_bind_repo description draws, and the remedy differs.
|
|
461
|
+
deps.writer.err(`Flowsprite has not listed any repositories for installation ${String(input.installation)}. ` +
|
|
462
|
+
"Refresh Sync Accounts in the app, then try again — or pass --create <name> to make a new private one.");
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
const picked = await pickOne(deps, REPOSITORY, repos.map((r) => [
|
|
466
|
+
String(r.fullName ?? ""),
|
|
467
|
+
`${r.private === true ? "private" : "PUBLIC — will be refused"}, default branch ${String(r.defaultBranch ?? "?")}`,
|
|
468
|
+
]));
|
|
469
|
+
if (picked === null)
|
|
470
|
+
return null;
|
|
471
|
+
input.repo = picked;
|
|
472
|
+
}
|
|
473
|
+
return input;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* One of a list, or nothing.
|
|
477
|
+
*
|
|
478
|
+
* The listing goes to STDERR, not stdout: it is diagnostic, and `--json` callers
|
|
479
|
+
* must be able to parse stdout whatever route the command took.
|
|
480
|
+
*/
|
|
481
|
+
async function pickOne(deps, noun, rows) {
|
|
482
|
+
const options = rows.filter(([id]) => id.length > 0);
|
|
483
|
+
if (options.length === 0) {
|
|
484
|
+
deps.writer.err(`This workspace has no ${noun.singular} to use here. Run \`flowsprite status\` to see what it has.`);
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
if (options.length === 1)
|
|
488
|
+
return options[0][0];
|
|
489
|
+
deps.writer.err(`Several ${noun.plural}:`);
|
|
490
|
+
options.forEach(([id, desc], n) => deps.writer.err(` ${n + 1}) ${id} ${desc}`));
|
|
491
|
+
if (deps.ask === undefined) {
|
|
492
|
+
deps.writer.err(`Re-run with the one you want, for example ${noun.flag} ${options[0][0]}.`);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const answer = (await deps.ask(`Which ${noun.singular}? [1-${options.length}] `)).trim();
|
|
496
|
+
const n = Number(answer);
|
|
497
|
+
if (!Number.isInteger(n) || n < 1 || n > options.length) {
|
|
498
|
+
deps.writer.err(`'${answer}' is not one of 1-${options.length}.`);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
return options[n - 1][0];
|
|
502
|
+
}
|
|
503
|
+
async function cmdSync(deps, origin, values) {
|
|
504
|
+
const built = clientFor(deps, origin, values);
|
|
505
|
+
if (!built.ok)
|
|
506
|
+
return EXIT_FAILED;
|
|
507
|
+
return await runSync(deps, values, built.client, null);
|
|
508
|
+
}
|
|
509
|
+
async function runSync(deps, values, client, rung) {
|
|
510
|
+
let connection = typeof values.connection === "string" ? values.connection : "";
|
|
511
|
+
if (rung !== null && connection.length > 0 && !rung.connectionIds.includes(connection)) {
|
|
512
|
+
deps.writer.err(outOfRungRefusal(connection, rung));
|
|
513
|
+
return EXIT_USAGE;
|
|
514
|
+
}
|
|
515
|
+
if (connection.length === 0) {
|
|
516
|
+
let pool;
|
|
517
|
+
if (rung !== null) {
|
|
518
|
+
const ids = rung.connectionIds;
|
|
519
|
+
pool = asArray(asObject(rung.status)?.connections).filter((c) => ids.includes(String(c.id)));
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
const status = await fetchStatus(deps, client);
|
|
523
|
+
if (!status.ok)
|
|
524
|
+
return EXIT_FAILED;
|
|
525
|
+
const all = asArray(asObject(status.payload)?.connections);
|
|
526
|
+
// The SAME narrowing `fillBindInput` does for the bind rung: a connection
|
|
527
|
+
// that has already synced is not a candidate for "start the first sync",
|
|
528
|
+
// and offering it is how `setup` re-triggers a sync that changes nothing.
|
|
529
|
+
const unsynced = all.filter((c) => c.hasSyncJob !== true);
|
|
530
|
+
pool = unsynced.length > 0 ? unsynced : all;
|
|
531
|
+
}
|
|
532
|
+
const picked = await pickOne(deps, CONNECTION, pool.map((c) => [
|
|
533
|
+
String(c.id ?? ""),
|
|
534
|
+
`${String(c.displayName ?? "?")} [${String(c.envType ?? "?")}]`,
|
|
535
|
+
]));
|
|
536
|
+
if (picked === null)
|
|
537
|
+
return EXIT_USAGE;
|
|
538
|
+
connection = picked;
|
|
539
|
+
}
|
|
540
|
+
const started = await client.callTool("sfdc_trigger_sync", { sandboxId: connection });
|
|
541
|
+
if (!started.ok) {
|
|
542
|
+
deps.writer.err(started.message);
|
|
543
|
+
return EXIT_FAILED;
|
|
544
|
+
}
|
|
545
|
+
// Under --json the ONLY thing on stdout is the JSON below: the trigger's prose
|
|
546
|
+
// would make the stream unparseable for the caller who asked for machine output.
|
|
547
|
+
if (values.json !== true) {
|
|
548
|
+
for (const line of textOf(started.content))
|
|
549
|
+
deps.writer.out(line);
|
|
550
|
+
}
|
|
551
|
+
const logs = await client.callTool("sfdc_sync_logs", { sandboxId: connection });
|
|
552
|
+
if (!logs.ok) {
|
|
553
|
+
deps.writer.err(logs.message);
|
|
554
|
+
return EXIT_FAILED;
|
|
555
|
+
}
|
|
556
|
+
if (values.json === true) {
|
|
557
|
+
deps.writer.out(JSON.stringify(toolJson(logs), null, 2));
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
for (const line of textOf(logs.content))
|
|
561
|
+
deps.writer.out(line);
|
|
562
|
+
}
|
|
563
|
+
return EXIT_OK;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* A HARD CEILING on how many rungs one `setup` may take.
|
|
567
|
+
*
|
|
568
|
+
* The identity check below is the precise diagnosis — "that tool said OK and the
|
|
569
|
+
* ladder did not move" — but it is not a BOUND on its own: one volatile field in
|
|
570
|
+
* the payload (a `pendingLinks` expiry ticking down) makes two reads differ
|
|
571
|
+
* without the ladder having moved, and the loop runs again. This is the term that
|
|
572
|
+
* makes termination structural. The ladder has four rungs and a workspace can
|
|
573
|
+
* legitimately need several passes of the list-wide ones, so this is generous;
|
|
574
|
+
* it is a backstop, not a budget.
|
|
575
|
+
*/
|
|
576
|
+
const MAX_SETUP_RUNGS = 24;
|
|
577
|
+
async function cmdSetup(deps, origin, values) {
|
|
578
|
+
// Doctor-lite: the two things that make every later step fail confusingly.
|
|
579
|
+
if (nodeMajor(deps.nodeVersion) < MIN_NODE_MAJOR) {
|
|
580
|
+
deps.writer.err(`flowsprite needs node ${MIN_NODE_MAJOR} or newer; this is ${deps.nodeVersion}.`);
|
|
581
|
+
return EXIT_FAILED;
|
|
582
|
+
}
|
|
583
|
+
if (readCredentials(deps.env, deps.home)[origin] === undefined && resolveToken({
|
|
584
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
585
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
586
|
+
}) === null) {
|
|
587
|
+
deps.writer.out(`Not logged in to ${origin} yet.`);
|
|
588
|
+
const rc = await cmdLogin(deps, origin, values);
|
|
589
|
+
if (rc !== EXIT_OK)
|
|
590
|
+
return rc;
|
|
591
|
+
}
|
|
592
|
+
const built = clientFor(deps, origin, values);
|
|
593
|
+
if (!built.ok)
|
|
594
|
+
return EXIT_FAILED;
|
|
595
|
+
const sdeps = setupDeps(deps, built.client);
|
|
596
|
+
/**
|
|
597
|
+
* THE LOOP MUST PROVE THE LADDER MOVED, and this is why.
|
|
598
|
+
*
|
|
599
|
+
* The ladder is the SERVER'S and its rungs are LIST-WIDE
|
|
600
|
+
* (`connections.some(...)` in `nextSetupAction`), while every action this CLI
|
|
601
|
+
* takes is per-connection. So a rung CAN succeed and leave the next answer
|
|
602
|
+
* identical — and at fd0f434 that produced 200 consecutive `setup_bind_repo`
|
|
603
|
+
* calls against the same connection, each superseding the binding the last one
|
|
604
|
+
* made and clearing that connection's data-export consent. (The comment that
|
|
605
|
+
* used to sit here, "every rung that succeeds changes the answer", was simply
|
|
606
|
+
* false; it is deleted rather than softened.)
|
|
607
|
+
*
|
|
608
|
+
* Two terms, and they answer different questions: the identity check says WHICH
|
|
609
|
+
* tool stopped making progress, and `MAX_SETUP_RUNGS` guarantees termination
|
|
610
|
+
* even when a volatile field makes two payloads differ.
|
|
611
|
+
*/
|
|
612
|
+
let previousStatus = null;
|
|
613
|
+
let previousTool = null;
|
|
614
|
+
for (let rungs = 0;; rungs++) {
|
|
615
|
+
const status = await fetchStatus(deps, built.client);
|
|
616
|
+
if (!status.ok)
|
|
617
|
+
return EXIT_FAILED;
|
|
618
|
+
const serialized = JSON.stringify(status.payload) ?? "";
|
|
619
|
+
if (previousTool !== null && serialized === previousStatus) {
|
|
620
|
+
deps.writer.err(`\`${previousTool}\` reported success, but this workspace's setup status did not change. ` +
|
|
621
|
+
"Stopping rather than repeating it: the setup steps are list-wide, so a step that does not " +
|
|
622
|
+
"move the checklist would repeat forever. Run `flowsprite status` to see what is left.");
|
|
623
|
+
return EXIT_FAILED;
|
|
624
|
+
}
|
|
625
|
+
if (rungs >= MAX_SETUP_RUNGS) {
|
|
626
|
+
deps.writer.err(`Stopped after ${MAX_SETUP_RUNGS} setup steps without finishing. Run \`flowsprite status\` to see what is left.`);
|
|
627
|
+
return EXIT_FAILED;
|
|
628
|
+
}
|
|
629
|
+
previousStatus = serialized;
|
|
630
|
+
const step = planNextStep(status.payload);
|
|
631
|
+
switch (step.kind) {
|
|
632
|
+
case "done":
|
|
633
|
+
deps.writer.out("Setup is complete.");
|
|
634
|
+
for (const line of renderStatus(status.payload))
|
|
635
|
+
deps.writer.out(line);
|
|
636
|
+
return EXIT_OK;
|
|
637
|
+
case "web_only":
|
|
638
|
+
deps.writer.out(step.text);
|
|
639
|
+
return EXIT_OK;
|
|
640
|
+
case "connect_salesforce":
|
|
641
|
+
case "connect_github": {
|
|
642
|
+
deps.writer.out(step.text);
|
|
643
|
+
const tool = step.kind === "connect_salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
|
|
644
|
+
const args = step.kind === "connect_salesforce" && values.sandbox === true ? { env: "sandbox" } : {};
|
|
645
|
+
const rc = reportLinkOutcome(deps.writer, await runLinkStep(sdeps, tool, args, step.kind));
|
|
646
|
+
if (rc !== EXIT_OK)
|
|
647
|
+
return rc;
|
|
648
|
+
previousTool = tool;
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
default: {
|
|
652
|
+
// The two per-connection rungs. `rungConnectionIds` derives WHICH
|
|
653
|
+
// connections the list-wide rung is unsatisfied about; an empty answer
|
|
654
|
+
// means the payload does not explain its own rung, which is a stop, not
|
|
655
|
+
// a licence to act on an arbitrary connection.
|
|
656
|
+
const connectionIds = rungConnectionIds(status.payload, step.kind);
|
|
657
|
+
if (connectionIds.length === 0) {
|
|
658
|
+
deps.writer.err(`Flowsprite says the next step is ${step.kind === "bind_repo" ? "binding a repository" : "the first sync"}, ` +
|
|
659
|
+
"but its status does not say which connection needs it. Run `flowsprite status`.");
|
|
660
|
+
return EXIT_FAILED;
|
|
661
|
+
}
|
|
662
|
+
deps.writer.out(step.text);
|
|
663
|
+
const rung = { kind: step.kind, connectionIds, status: status.payload };
|
|
664
|
+
const rc = step.kind === "bind_repo"
|
|
665
|
+
? await runBind(deps, values, built.client, rung)
|
|
666
|
+
: await runSync(deps, values, built.client, rung);
|
|
667
|
+
if (rc !== EXIT_OK)
|
|
668
|
+
return rc;
|
|
669
|
+
previousTool = step.kind === "bind_repo" ? "setup_bind_repo" : "sfdc_trigger_sync";
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** The human-readable text an MCP tool result carries, one line per content entry. */
|
|
676
|
+
function textOf(content) {
|
|
677
|
+
return content.flatMap((c) => (typeof c.text === "string" ? [c.text] : []));
|
|
678
|
+
}
|
|
679
|
+
// ------------------------------------------------------------- mcp install/print
|
|
680
|
+
const MCP_CLIENT = { singular: "AI client", plural: "AI clients", flag: "--client" };
|
|
681
|
+
/**
|
|
682
|
+
* `mcp install` and `mcp print`: point an AI client at this workspace's MCP
|
|
683
|
+
* endpoint, or show the person what to paste.
|
|
684
|
+
*
|
|
685
|
+
* THE TOKEN RULE, in one place because it is the reason this command exists
|
|
686
|
+
* (AC3 / DM3): the stored bearer reaches a config file or a client's own
|
|
687
|
+
* registration command, and reaches STDOUT only through `mcp print --reveal` at a
|
|
688
|
+
* terminal. Every other route — the success lines, the refusals, a failing
|
|
689
|
+
* command's stderr, an install-time fallback snippet — carries the placeholder or
|
|
690
|
+
* nothing at all, and no line ever carries an argv.
|
|
691
|
+
*/
|
|
692
|
+
async function cmdMcp(deps, origin, values, sub) {
|
|
693
|
+
if (sub !== "install" && sub !== "print") {
|
|
694
|
+
return usageError(deps.writer, "mcp takes 'install' or 'print'.");
|
|
695
|
+
}
|
|
696
|
+
const flag = typeof values.client === "string" ? values.client : undefined;
|
|
697
|
+
if (flag !== undefined && !isClientId(flag)) {
|
|
698
|
+
// An unknown --client is a COMMAND-LINE error (exit 2), not a refusal: the
|
|
699
|
+
// list of clients is closed and the usage text names it.
|
|
700
|
+
return usageError(deps.writer, `Unknown --client '${flag}'. Known clients: ${CLIENTS.join(", ")}.`);
|
|
701
|
+
}
|
|
702
|
+
return sub === "print"
|
|
703
|
+
? mcpPrint(deps, origin, values, flag)
|
|
704
|
+
: await mcpInstall(deps, origin, values, flag);
|
|
705
|
+
}
|
|
706
|
+
/** The facts every client path is derived from — `home`, `platform`, `env`, and nothing else. */
|
|
707
|
+
function pathContext(deps) {
|
|
708
|
+
return {
|
|
709
|
+
home: deps.home ?? homedir(),
|
|
710
|
+
platform: deps.platform ?? process.platform,
|
|
711
|
+
env: deps.env,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function mcpPrint(deps, origin, values, flag) {
|
|
715
|
+
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
716
|
+
const mcpEndpoint = stored?.mcpEndpoint ?? `${origin}/api/mcp`;
|
|
717
|
+
const tokenEnv = typeof values["token-env"] === "string" ? values["token-env"] : undefined;
|
|
718
|
+
let placeholder = TOKEN_PLACEHOLDER;
|
|
719
|
+
if (values.reveal === true) {
|
|
720
|
+
if (deps.isTTY !== true) {
|
|
721
|
+
// A SECRET GOES TO A TERMINAL OR NOWHERE. Through a pipe the destination is
|
|
722
|
+
// a file, a log or another process's buffer, and none of those is something
|
|
723
|
+
// the person watching can decide to clear.
|
|
724
|
+
deps.writer.err("--reveal prints the real token, so it only runs at a terminal and stdout here is not one. " +
|
|
725
|
+
"Re-run it at a terminal, or drop --reveal for a snippet with a placeholder.");
|
|
726
|
+
return EXIT_USAGE;
|
|
727
|
+
}
|
|
728
|
+
const token = resolveToken({
|
|
729
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
730
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
731
|
+
stored: stored?.token,
|
|
732
|
+
});
|
|
733
|
+
if (token === null) {
|
|
734
|
+
deps.writer.err(`Not logged in to ${origin}, so there is no token to reveal. Run \`flowsprite login\`.`);
|
|
735
|
+
return EXIT_FAILED;
|
|
736
|
+
}
|
|
737
|
+
placeholder = token.token;
|
|
738
|
+
}
|
|
739
|
+
const clients = flag !== undefined ? [flag] : [...CLIENTS];
|
|
740
|
+
const ctxLite = pathContext(deps);
|
|
741
|
+
const rows = clients.map((client) => {
|
|
742
|
+
const cfg = configPathFor(client, ctxLite);
|
|
743
|
+
const command = commandFor(client);
|
|
744
|
+
return {
|
|
745
|
+
client,
|
|
746
|
+
// What `mcp install` WOULD do for this client, on this machine, right now —
|
|
747
|
+
// derived from the same planner, so `print --json` cannot describe a route
|
|
748
|
+
// `install` does not take.
|
|
749
|
+
kind: planInstall(client, {
|
|
750
|
+
...ctxLite,
|
|
751
|
+
origin,
|
|
752
|
+
mcpEndpoint,
|
|
753
|
+
token: TOKEN_PLACEHOLDER,
|
|
754
|
+
tokenEnv,
|
|
755
|
+
onPath: deps.isOnPath,
|
|
756
|
+
fileExists: deps.fileExists,
|
|
757
|
+
}).kind,
|
|
758
|
+
path: cfg.path,
|
|
759
|
+
...(command === null ? {} : { command }),
|
|
760
|
+
snippet: renderSnippet(client, { url: mcpEndpoint, placeholder, tokenEnv }),
|
|
761
|
+
};
|
|
762
|
+
});
|
|
763
|
+
if (values.json === true) {
|
|
764
|
+
// ONE client asked for, one object; no client asked for, the array. Nothing
|
|
765
|
+
// else on stdout either way — that is what makes this parseable.
|
|
766
|
+
deps.writer.out(JSON.stringify(flag !== undefined ? rows[0] : rows, null, 2));
|
|
767
|
+
return EXIT_OK;
|
|
768
|
+
}
|
|
769
|
+
rows.forEach((row, n) => {
|
|
770
|
+
if (n > 0)
|
|
771
|
+
deps.writer.out("");
|
|
772
|
+
deps.writer.out(`# ${row.client}`);
|
|
773
|
+
deps.writer.out(row.snippet);
|
|
774
|
+
});
|
|
775
|
+
return EXIT_OK;
|
|
776
|
+
}
|
|
777
|
+
async function mcpInstall(deps, origin, values, flag) {
|
|
778
|
+
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
779
|
+
const resolved = resolveToken({
|
|
780
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
781
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
782
|
+
stored: stored?.token,
|
|
783
|
+
});
|
|
784
|
+
if (resolved === null) {
|
|
785
|
+
deps.writer.err(`Not logged in to ${origin}, so there is no token to install. Run \`flowsprite login\` first, ` +
|
|
786
|
+
"or run `flowsprite mcp print` for a snippet to fill in by hand.");
|
|
787
|
+
return EXIT_FAILED;
|
|
788
|
+
}
|
|
789
|
+
const ctxLite = pathContext(deps);
|
|
790
|
+
const client = flag ?? (await chooseClient(deps, ctxLite));
|
|
791
|
+
if (client === null)
|
|
792
|
+
return EXIT_USAGE;
|
|
793
|
+
// ---- --token-env, resolved before the plan -------------------------------
|
|
794
|
+
const tokenEnv = typeof values["token-env"] === "string" ? values["token-env"] : undefined;
|
|
795
|
+
let token = resolved.token;
|
|
796
|
+
if (tokenEnv !== undefined) {
|
|
797
|
+
if (client === "claude-desktop" || client === "cursor") {
|
|
798
|
+
deps.writer.err(`--token-env is not something ${client} can use: its config holds the literal header value, ` +
|
|
799
|
+
"so a variable name there is a config that never authenticates. Drop --token-env for this client.");
|
|
800
|
+
return EXIT_USAGE;
|
|
801
|
+
}
|
|
802
|
+
if (client === "claude-code") {
|
|
803
|
+
// claude-code has no indirection either — it stores the header. So the flag
|
|
804
|
+
// means "use what that variable holds RIGHT NOW", and an unset variable is a
|
|
805
|
+
// command line that cannot be carried out, not an empty header to write.
|
|
806
|
+
const value = deps.env[tokenEnv];
|
|
807
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
808
|
+
deps.writer.err(`--token-env ${tokenEnv} was given, but ${tokenEnv} is not set in this environment. ` +
|
|
809
|
+
"claude-code stores the header value itself, so there is nothing to read.");
|
|
810
|
+
return EXIT_USAGE;
|
|
811
|
+
}
|
|
812
|
+
token = value;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const mcpEndpoint = stored?.mcpEndpoint ?? `${origin}/api/mcp`;
|
|
816
|
+
const plan = planInstall(client, {
|
|
817
|
+
...ctxLite,
|
|
818
|
+
origin,
|
|
819
|
+
mcpEndpoint,
|
|
820
|
+
token,
|
|
821
|
+
tokenEnv,
|
|
822
|
+
onPath: deps.isOnPath,
|
|
823
|
+
fileExists: deps.fileExists,
|
|
824
|
+
});
|
|
825
|
+
const fallback = () => {
|
|
826
|
+
deps.writer.out(renderSnippet(client, { url: mcpEndpoint, placeholder: TOKEN_PLACEHOLDER, tokenEnv }));
|
|
827
|
+
};
|
|
828
|
+
// ---- --print: say what would happen, change nothing ----------------------
|
|
829
|
+
if (values.print === true) {
|
|
830
|
+
deps.writer.out(`# ${client} — ${plan.kind === "write" ? `would write ${plan.path}` : plan.kind === "exec" ? `would run \`${plan.argv[0]}\`` : "has no automated route"}`);
|
|
831
|
+
fallback();
|
|
832
|
+
return EXIT_OK;
|
|
833
|
+
}
|
|
834
|
+
// ---- already registered? -------------------------------------------------
|
|
835
|
+
const cfg = configPathFor(client, ctxLite);
|
|
836
|
+
const already = cfg.format === "toml" ? tomlConfigHasServer(cfg.path) : jsonConfigHasServer(cfg.path);
|
|
837
|
+
if (already && values.force !== true) {
|
|
838
|
+
const line = `${SERVER_NAME} is already registered in ${cfg.path}.`;
|
|
839
|
+
if (deps.ask === undefined) {
|
|
840
|
+
deps.writer.err(`${line} Re-run with --force to replace it.`);
|
|
841
|
+
return EXIT_USAGE;
|
|
842
|
+
}
|
|
843
|
+
const answer = (await deps.ask(`${line} Replace it? [y/N] `)).trim().toLowerCase();
|
|
844
|
+
if (answer !== "y" && answer !== "yes") {
|
|
845
|
+
deps.writer.out("Left the existing entry alone.");
|
|
846
|
+
return EXIT_OK;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
switch (plan.kind) {
|
|
850
|
+
case "print":
|
|
851
|
+
deps.writer.out(`Nothing to run for ${client}: ${plan.reason}.`);
|
|
852
|
+
deps.writer.out("Paste this instead, with your own token:");
|
|
853
|
+
deps.writer.out(plan.snippet);
|
|
854
|
+
return EXIT_OK;
|
|
855
|
+
case "write": {
|
|
856
|
+
let result;
|
|
857
|
+
try {
|
|
858
|
+
result = upsertJsonConfig({ path: plan.path, entry: plan.entry });
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
if (err instanceof McpConfigError) {
|
|
862
|
+
deps.writer.err(err.message);
|
|
863
|
+
deps.writer.err("Nothing was written. Fix that file, or add the entry by hand:");
|
|
864
|
+
fallback();
|
|
865
|
+
return EXIT_FAILED;
|
|
866
|
+
}
|
|
867
|
+
throw err;
|
|
868
|
+
}
|
|
869
|
+
deps.writer.out(`Registered ${SERVER_NAME} with ${plan.describe}.`);
|
|
870
|
+
// THE PATH, NEVER THE CONTENTS: the entry that was just written holds the
|
|
871
|
+
// bearer, and this line is the one a person copies into a chat.
|
|
872
|
+
deps.writer.out(`Wrote ${plan.path}${result.backup === undefined ? "" : ` (backup: ${result.backup})`}`);
|
|
873
|
+
return EXIT_OK;
|
|
874
|
+
}
|
|
875
|
+
default: {
|
|
876
|
+
const [command, ...args] = plan.argv;
|
|
877
|
+
if (command === undefined)
|
|
878
|
+
throw new Error("an exec plan with no command");
|
|
879
|
+
// `already` here means "there was an entry AND replacing it was authorized",
|
|
880
|
+
// because the only way past the block above with `already` true is --force or
|
|
881
|
+
// a yes at the prompt. Keying this on `--force` alone was a real defect: an
|
|
882
|
+
// interactive yes then reached the add below, which REFUSES a duplicate
|
|
883
|
+
// (exit 1, see the measurement), so the person who said yes was told the
|
|
884
|
+
// command could not register it.
|
|
885
|
+
if (already && client === "claude-code") {
|
|
886
|
+
// MEASURED 2026-09-15 (unpiped — the first reading of this was `| head`'s
|
|
887
|
+
// exit code, not the command's): a second `claude mcp add` with a name
|
|
888
|
+
// already in the config prints "MCP server <name> already exists in user
|
|
889
|
+
// config", leaves the entry untouched, and EXITS 1. So without this remove
|
|
890
|
+
// a `--force` install would simply FAIL — the person asked to replace the
|
|
891
|
+
// entry and would be told the command could not register it. The remove is
|
|
892
|
+
// what makes --force do what it says.
|
|
893
|
+
//
|
|
894
|
+
// Its own rc is ignored deliberately: removing a name that is not there is
|
|
895
|
+
// REFUSED with rc 1 and its own message ("No MCP server named … in user
|
|
896
|
+
// scope", measured unpiped 2026-09-15), which is not a problem worth
|
|
897
|
+
// reporting here — the add below is the real check either way.
|
|
898
|
+
await deps.spawn(command, ["mcp", "remove", SERVER_NAME, "--scope", "user"]);
|
|
899
|
+
}
|
|
900
|
+
const outcome = await deps.spawn(command, args);
|
|
901
|
+
if (outcome.error !== undefined || outcome.status !== 0) {
|
|
902
|
+
// THE COMMAND NAME AND ITS OWN STDERR, never the argv — the argv is where
|
|
903
|
+
// the bearer is on this path. `redact` runs here as well as in the writer
|
|
904
|
+
// because a vendor error string is the likeliest place a token comes back.
|
|
905
|
+
// THREE SOURCES, IN THE ORDER THAT SAYS SOMETHING. A command that RAN and
|
|
906
|
+
// refused explains itself on stderr. A command that could not START at all
|
|
907
|
+
// has no stderr — `spawnSync` reports that as `error` and leaves `status`
|
|
908
|
+
// null, so reading only stderr printed the useless "(exit null)" for the
|
|
909
|
+
// commonest case of all, a binary that is not really there. Both are
|
|
910
|
+
// redacted here as well as in the writer: a vendor error string is the
|
|
911
|
+
// likeliest place a token comes back.
|
|
912
|
+
const fromStderr = redact(String(outcome.stderr ?? "").trim().split("\n")[0] ?? "");
|
|
913
|
+
const fromError = outcome.error === undefined
|
|
914
|
+
? ""
|
|
915
|
+
: redact((outcome.error instanceof Error ? outcome.error.message : String(outcome.error))
|
|
916
|
+
.split("\n")[0] ?? "");
|
|
917
|
+
const detail = fromStderr.length > 0 ? fromStderr : fromError;
|
|
918
|
+
deps.writer.err(`\`${command}\` could not register ${SERVER_NAME}` +
|
|
919
|
+
(detail.length > 0 ? `: ${detail}` : ` (exit ${String(outcome.status)})`));
|
|
920
|
+
deps.writer.err("Add it by hand instead:");
|
|
921
|
+
fallback();
|
|
922
|
+
return EXIT_FAILED;
|
|
923
|
+
}
|
|
924
|
+
deps.writer.out(`Registered ${SERVER_NAME} with ${plan.describe}.`);
|
|
925
|
+
if (client === "codex") {
|
|
926
|
+
const envName = tokenEnv ?? "FLOWSPRITE_MCP_TOKEN";
|
|
927
|
+
deps.writer.out(`Set ${envName} in the shell that starts codex — the config holds the variable NAME, not the token. ` +
|
|
928
|
+
`\`flowsprite mcp print --client codex --reveal\` prints the export line at a terminal.`);
|
|
929
|
+
}
|
|
930
|
+
return EXIT_OK;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Which client to install into when `--client` was not given.
|
|
936
|
+
*
|
|
937
|
+
* DETECTED means "this machine has something to install into": the client's own
|
|
938
|
+
* command is on PATH, or its config file is already there. Everything else is
|
|
939
|
+
* listed as print-only rather than offered — offering a client that is not
|
|
940
|
+
* installed produces a config file nothing reads, which is indistinguishable from
|
|
941
|
+
* a successful install until the person wonders why their assistant cannot see
|
|
942
|
+
* the workspace.
|
|
943
|
+
*/
|
|
944
|
+
async function chooseClient(deps, ctxLite) {
|
|
945
|
+
const detected = [];
|
|
946
|
+
const rest = [];
|
|
947
|
+
for (const client of CLIENTS) {
|
|
948
|
+
const command = commandFor(client);
|
|
949
|
+
const present = (command !== null && deps.isOnPath(command)) || deps.fileExists(configPathFor(client, ctxLite).path);
|
|
950
|
+
(present ? detected : rest).push(client);
|
|
951
|
+
}
|
|
952
|
+
if (rest.length > 0) {
|
|
953
|
+
deps.writer.err(`Not detected here (use \`flowsprite mcp print --client <name>\` for a snippet): ${rest.join(", ")}.`);
|
|
954
|
+
}
|
|
955
|
+
if (detected.length === 0) {
|
|
956
|
+
deps.writer.err(`No supported AI client was detected. Name one anyway with --client (${CLIENTS.join(", ")}), ` +
|
|
957
|
+
"or run `flowsprite mcp print` and paste the snippet.");
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
// `pickOne` takes one silently, prompts at a terminal and refuses through a
|
|
961
|
+
// pipe with the flag to re-run with — the same three behaviours `bind` gets.
|
|
962
|
+
const picked = await pickOne(deps, MCP_CLIENT, detected.map((client) => {
|
|
963
|
+
const command = commandFor(client);
|
|
964
|
+
return [
|
|
965
|
+
client,
|
|
966
|
+
command !== null && deps.isOnPath(command)
|
|
967
|
+
? `\`${command}\` is on PATH`
|
|
968
|
+
: `${configPathFor(client, ctxLite).path} exists`,
|
|
969
|
+
];
|
|
970
|
+
}));
|
|
971
|
+
// Every id in `detected` came from CLIENTS, so whatever `pickOne` returns is one
|
|
972
|
+
// of them; `isClientId` proves that to the type system rather than asserting it.
|
|
973
|
+
return picked !== null && isClientId(picked) ? picked : null;
|
|
974
|
+
}
|