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/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
+ }