flowsprite 0.1.0 → 0.1.3
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 +8 -5
- package/dist/bin.js +45 -45
- package/dist/cli.js +378 -92
- package/dist/ghLogin.js +173 -0
- package/dist/login.js +157 -26
- package/dist/mcpClient.js +3 -0
- package/dist/messages.js +85 -0
- package/dist/output.js +55 -3
- package/dist/redact.js +51 -23
- package/dist/setup.js +178 -13
- package/dist/sfCli.js +151 -0
- package/dist/status.js +11 -1
- package/dist/usage.js +23 -8
- package/dist/version.js +1 -1
- package/dist/wiring.js +77 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -3,12 +3,13 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import { credentialsPath, deleteCredential, readCredentials, resolveToken, writeCredential, } from "./credentials.js";
|
|
4
4
|
import { formatDoctor, MIN_NODE_MAJOR, nodeMajor } from "./doctor.js";
|
|
5
5
|
import { parseEndpoint } from "./endpoint.js";
|
|
6
|
-
import { deviceCodeLogin } from "./login.js";
|
|
6
|
+
import { deviceCodeLogin, ghCliLogin } from "./login.js";
|
|
7
7
|
import { createMcpClient, toolJson } from "./mcpClient.js";
|
|
8
8
|
import { jsonConfigHasServer, McpConfigError, SERVER_NAME, tomlConfigHasServer, upsertJsonConfig, } from "./mcpConfig.js";
|
|
9
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";
|
|
10
|
+
import { printable, redact } from "./redact.js";
|
|
11
|
+
import { asArray, asObject, planBindArgs, planNextStep, rungConnectionIds, runLinkStep, waitForRepository, } from "./setup.js";
|
|
12
|
+
import { doorRefusalSentence, FROM_SF_CLI_EXCLUSIVE, parseSfOrgDisplay, projectDoorResult, scrubForceUrls, SF_ALIAS_RE, SF_ALIAS_RULE, sfLoginLine, sfRelogin, sfRemedy, } from "./sfCli.js";
|
|
12
13
|
import { renderStatus } from "./status.js";
|
|
13
14
|
import { USAGE } from "./usage.js";
|
|
14
15
|
/**
|
|
@@ -31,8 +32,11 @@ const OPTIONS = {
|
|
|
31
32
|
token: { type: "string" },
|
|
32
33
|
save: { type: "boolean" },
|
|
33
34
|
"no-open": { type: "boolean" },
|
|
35
|
+
workspace: { type: "string" },
|
|
36
|
+
"no-gh": { type: "boolean" },
|
|
34
37
|
sandbox: { type: "boolean" },
|
|
35
38
|
"my-domain": { type: "string" },
|
|
39
|
+
"from-sf-cli": { type: "string" },
|
|
36
40
|
connection: { type: "string" },
|
|
37
41
|
installation: { type: "string" },
|
|
38
42
|
repo: { type: "string" },
|
|
@@ -73,6 +77,12 @@ export async function main(argv, deps) {
|
|
|
73
77
|
if (!COMMANDS.has(command)) {
|
|
74
78
|
return usageError(deps.writer, `Unknown command '${command}'.`);
|
|
75
79
|
}
|
|
80
|
+
// `--from-sf-cli` is accepted by exactly one command. Refused elsewhere
|
|
81
|
+
// rather than ignored: FLO-514 found that a flag `setup` accepts and
|
|
82
|
+
// silently drops is how a person's intent goes missing.
|
|
83
|
+
if (typeof values["from-sf-cli"] === "string" && !(command === "connect" && positionals[1] === "salesforce")) {
|
|
84
|
+
return usageError(deps.writer, "--from-sf-cli belongs to `connect salesforce`: flowsprite connect salesforce --from-sf-cli <alias>.");
|
|
85
|
+
}
|
|
76
86
|
const endpoint = parseEndpoint({
|
|
77
87
|
flag: typeof values.endpoint === "string" ? values.endpoint : undefined,
|
|
78
88
|
env: deps.env.FLOWSPRITE_ENDPOINT,
|
|
@@ -131,6 +141,13 @@ function clientFor(deps, origin, values) {
|
|
|
131
141
|
}
|
|
132
142
|
// ------------------------------------------------------------------- commands
|
|
133
143
|
async function cmdLogin(deps, origin, values) {
|
|
144
|
+
// `--workspace` is the one value the gh road sends that a person typed.
|
|
145
|
+
// Refused empty HERE, before any spawn or request, rather than sent for the
|
|
146
|
+
// door to refuse: the door's `invalid_argument` costs a round trip to say so.
|
|
147
|
+
const workspaceFlag = typeof values.workspace === "string" ? values.workspace.trim() : null;
|
|
148
|
+
if (workspaceFlag !== null && workspaceFlag.length === 0) {
|
|
149
|
+
return usageError(deps.writer, "--workspace needs a name: flowsprite login --workspace <name>.");
|
|
150
|
+
}
|
|
134
151
|
// A token handed in on the command line or the environment SKIPS the device
|
|
135
152
|
// loop entirely — there is nothing to approve. It is not stored unless --save
|
|
136
153
|
// is given, so a one-off token in a CI job leaves nothing behind.
|
|
@@ -149,14 +166,29 @@ async function cmdLogin(deps, origin, values) {
|
|
|
149
166
|
}
|
|
150
167
|
return EXIT_OK;
|
|
151
168
|
}
|
|
152
|
-
|
|
169
|
+
// ONE deps object for both roads, so the 409 road's browser tail inherits
|
|
170
|
+
// `--no-open` (and an absent opener) exactly as the device-code road does.
|
|
171
|
+
const loginDeps = {
|
|
153
172
|
origin,
|
|
154
173
|
fetch: deps.fetch,
|
|
155
174
|
sleep: deps.sleep,
|
|
156
175
|
writer: deps.writer,
|
|
157
176
|
version: deps.version,
|
|
158
177
|
...(values["no-open"] === true || deps.openUrl === undefined ? {} : { openUrl: deps.openUrl }),
|
|
159
|
-
|
|
178
|
+
...(deps.progress === undefined ? {} : { progress: deps.progress }),
|
|
179
|
+
};
|
|
180
|
+
// FLO-517: the gh road first, unless opted out. Null means it fell to the
|
|
181
|
+
// browser login at its own start, having said why in one line.
|
|
182
|
+
const viaGh = values["no-gh"] === true
|
|
183
|
+
? null
|
|
184
|
+
: await ghCliLogin({
|
|
185
|
+
...loginDeps,
|
|
186
|
+
isOnPath: deps.isOnPath,
|
|
187
|
+
spawn: deps.spawn,
|
|
188
|
+
...(deps.ask === undefined ? {} : { ask: deps.ask }),
|
|
189
|
+
workspaceName: workspaceFlag,
|
|
190
|
+
});
|
|
191
|
+
const result = viaGh ?? (await deviceCodeLogin(loginDeps));
|
|
160
192
|
if (!result.ok) {
|
|
161
193
|
deps.writer.err(result.message);
|
|
162
194
|
return EXIT_FAILED;
|
|
@@ -276,36 +308,186 @@ async function cmdConnect(deps, origin, values, which) {
|
|
|
276
308
|
if (which !== "salesforce" && which !== "github") {
|
|
277
309
|
return usageError(deps.writer, "connect takes 'salesforce' or 'github'.");
|
|
278
310
|
}
|
|
311
|
+
// The `sf`-CLI door (FLO-515): a branch chosen BEFORE the link is minted,
|
|
312
|
+
// not a change to the link step. The alias was already routed here by `main`.
|
|
313
|
+
if (which === "salesforce" && typeof values["from-sf-cli"] === "string") {
|
|
314
|
+
return await connectFromSfCli(deps, origin, values, values["from-sf-cli"]);
|
|
315
|
+
}
|
|
279
316
|
const built = clientFor(deps, origin, values);
|
|
280
317
|
if (!built.ok)
|
|
281
318
|
return EXIT_FAILED;
|
|
282
319
|
const tool = which === "salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
320
|
+
// ponytail: the GitHub alternative door is absent here and plugs in at THIS
|
|
321
|
+
// call, as a branch chosen before the link is minted — the gh-token door
|
|
322
|
+
// (card A3, blocked), which would use a token the person already holds
|
|
323
|
+
// instead of an app install. The Salesforce one is `connectFromSfCli` above.
|
|
324
|
+
const args = which === "salesforce" ? salesforceLinkArgs(values) : {};
|
|
325
|
+
// No pre-mint snapshot is passed: the link step reads one itself, so `done`
|
|
326
|
+
// means a NEW connection or installation, never "the ladder is elsewhere".
|
|
327
|
+
const outcome = await runLinkStep(setupDeps(deps, built.client), tool, args, which);
|
|
328
|
+
return reportLinkOutcome(deps.writer, outcome, which);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* `flowsprite connect salesforce --from-sf-cli <alias>` (FLO-515): read the
|
|
332
|
+
* Salesforce CLI's own login for that alias and hand it to the server door
|
|
333
|
+
* `setup_connect_salesforce_cli`, so the credential crosses inside THIS process
|
|
334
|
+
* — spawn stdout → parser → tools/call arguments — and never enters a chat, a
|
|
335
|
+
* transcript or this CLI's output. Why that matters: an agent harness redacted
|
|
336
|
+
* `result.sfdxAuthUrl` before its model could relay it (measured 2026-09-17,
|
|
337
|
+
* Claude Code), and the writer's redactor covers `fp_` tokens only, so the URL
|
|
338
|
+
* is scoped to the spawn-to-call span below and appears in NO string. Every
|
|
339
|
+
* printed text that did not originate in this CLI's constants goes through
|
|
340
|
+
* `scrubForceUrls` as defence in depth. The acceptance oracle censuses this
|
|
341
|
+
* function's source for `fs` / `console` identifiers: the writer and the
|
|
342
|
+
* tools/call body are its only sinks.
|
|
343
|
+
*
|
|
344
|
+
* Refusals BEFORE the network, in this order: the flag exclusivity (DM5), the
|
|
345
|
+
* alias rule (DM4), `sf` on PATH (DM3: install lines, never an auto-install),
|
|
346
|
+
* the stored credential, then the spawn's own failure and the envelope's shape
|
|
347
|
+
* (DM2: a JWT or empty-client login is refused here with the alias-bearing
|
|
348
|
+
* remedy rather than round-tripped to a server that would refuse it anyway).
|
|
349
|
+
*/
|
|
350
|
+
async function connectFromSfCli(deps, origin, values, alias) {
|
|
351
|
+
if (values.sandbox === true || typeof values["my-domain"] === "string") {
|
|
352
|
+
return usageError(deps.writer, FROM_SF_CLI_EXCLUSIVE);
|
|
353
|
+
}
|
|
354
|
+
if (!SF_ALIAS_RE.test(alias))
|
|
355
|
+
return usageError(deps.writer, SF_ALIAS_RULE);
|
|
356
|
+
// On Windows the npm install of sf is a `sf.cmd` shim, which node refuses to
|
|
357
|
+
// spawn without a shell (>= 20.12), so `isOnPath("sf")` reads FALSE for a
|
|
358
|
+
// person who has it (reasoned from Node's documented .cmd refusal, FLO-515
|
|
359
|
+
// fix round 1 — no Windows run exists in this repo's harness). There the
|
|
360
|
+
// PATH probe is skipped and the spawn itself is the probe.
|
|
361
|
+
const win32 = deps.platform === "win32";
|
|
362
|
+
if (!win32 && !deps.isOnPath("sf")) {
|
|
363
|
+
for (const line of sfRemedy(alias, deps.platform))
|
|
364
|
+
deps.writer.err(line);
|
|
365
|
+
return EXIT_USAGE;
|
|
366
|
+
}
|
|
367
|
+
const built = clientFor(deps, origin, values);
|
|
368
|
+
if (!built.ok)
|
|
369
|
+
return EXIT_FAILED;
|
|
370
|
+
// sf's stdout and stderr are NEVER printed: either can quote the URL.
|
|
371
|
+
// UNVERIFIED: the win32 arm — no Windows machine in this repo's harness. It
|
|
372
|
+
// runs sf through cmd.exe as ONE command string; that string is safe by
|
|
373
|
+
// construction because SF_ALIAS_RE (checked above) admits no quote, space or
|
|
374
|
+
// shell metacharacter, so the alias cannot end the command or start another.
|
|
375
|
+
const run = win32
|
|
376
|
+
? await deps.spawn("cmd.exe", ["/d", "/s", "/c", "sf org display --verbose --json --target-org " + alias])
|
|
377
|
+
: await deps.spawn("sf", ["org", "display", "--verbose", "--json", "--target-org", alias]);
|
|
378
|
+
const spawnError = run.error !== undefined && run.error !== null;
|
|
379
|
+
// On win32 "sf is not installed" arrives two ways, both the install remedy
|
|
380
|
+
// the PATH probe gives elsewhere: cmd.exe itself could not start (a spawn
|
|
381
|
+
// `error`), or cmd.exe started and reported the command not recognized —
|
|
382
|
+
// its documented command-not-found ERRORLEVEL is 9009, so that STATUS is
|
|
383
|
+
// read as "no sf", never as "that org is not logged in". Reasoned from
|
|
384
|
+
// Windows' documented behaviour; UNVERIFIED on a real box like the rest of
|
|
385
|
+
// this branch.
|
|
386
|
+
if (win32 && (spawnError || run.status === 9009)) {
|
|
387
|
+
for (const line of sfRemedy(alias, "win32"))
|
|
388
|
+
deps.writer.err(line);
|
|
389
|
+
return EXIT_USAGE;
|
|
390
|
+
}
|
|
391
|
+
if (spawnError) {
|
|
392
|
+
deps.writer.err(`sf org display could not be run for '${alias}'. Is that org logged in? ${sfLoginLine(alias)}`);
|
|
393
|
+
return EXIT_FAILED;
|
|
394
|
+
}
|
|
395
|
+
const parsed = parseSfOrgDisplay(run.stdout ?? "");
|
|
396
|
+
if (run.status !== 0) {
|
|
397
|
+
// sf's JSON error envelope names an error CLASS (`NamedOrgNotFoundError`
|
|
398
|
+
// for a logged-out alias, exit 2); that identifier is printed, its free
|
|
399
|
+
// text and stderr never are.
|
|
400
|
+
const named = !parsed.ok && parsed.reason === "status" && parsed.message !== "";
|
|
401
|
+
const what = named ? `reported ${parsed.message}` : `failed for '${alias}'`;
|
|
402
|
+
deps.writer.err(`sf org display ${what} (exit ${run.status ?? "by signal"}). Is that org logged in? ${sfLoginLine(alias)}`);
|
|
403
|
+
return EXIT_FAILED;
|
|
404
|
+
}
|
|
405
|
+
if (!parsed.ok) {
|
|
406
|
+
switch (parsed.reason) {
|
|
407
|
+
case "not_json":
|
|
408
|
+
deps.writer.err(`sf org display did not return its JSON. ${sfLoginLine(alias)}`);
|
|
409
|
+
break;
|
|
410
|
+
case "status":
|
|
411
|
+
deps.writer.err(`sf org display reported ${parsed.message || "an error"}. ${sfLoginLine(alias)}`);
|
|
412
|
+
break;
|
|
413
|
+
default:
|
|
414
|
+
// `no_url` (a JWT login, or an older auth file with an empty client id)
|
|
415
|
+
// and `client` (another connected app): the server would refuse both.
|
|
416
|
+
deps.writer.err("That sf login cannot be handed over: only the Salesforce CLI's default connected app (PlatformCLI) " +
|
|
417
|
+
`with a web login works. ${sfRelogin(alias)}`);
|
|
294
418
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
419
|
+
return EXIT_FAILED;
|
|
420
|
+
}
|
|
421
|
+
// The ONE place the URL leaves this process: exactly this key, nothing else.
|
|
422
|
+
const res = await built.client.callTool("setup_connect_salesforce_cli", { sfdxAuthUrl: parsed.sfdxAuthUrl });
|
|
423
|
+
if (!res.ok) {
|
|
424
|
+
switch (res.kind) {
|
|
425
|
+
case "tool_error":
|
|
426
|
+
// The door's own refusal sentence (or whatever the server said), scrubbed.
|
|
427
|
+
deps.writer.err(scrubForceUrls(res.message));
|
|
428
|
+
break;
|
|
429
|
+
case "rpc_error":
|
|
430
|
+
// A JSON-RPC error's message is the server's text: only its code is printed.
|
|
431
|
+
deps.writer.err(`Flowsprite's endpoint rejected the call${res.code === undefined ? "" : ` (JSON-RPC error ${res.code})`}. Run \`flowsprite doctor\`.`);
|
|
432
|
+
break;
|
|
433
|
+
default:
|
|
434
|
+
// `unauthorized` / `transport`: the client builds these sentences from
|
|
435
|
+
// its own URL, the HTTP status and an error NAME — no server text.
|
|
436
|
+
deps.writer.err(scrubForceUrls(res.message));
|
|
437
|
+
}
|
|
438
|
+
return EXIT_FAILED;
|
|
439
|
+
}
|
|
440
|
+
const doc = projectDoorResult(toolJson(res));
|
|
441
|
+
if (doc === null) {
|
|
442
|
+
deps.writer.err("Unexpected reply from setup_connect_salesforce_cli.");
|
|
443
|
+
return EXIT_FAILED;
|
|
444
|
+
}
|
|
445
|
+
if (values.json === true)
|
|
446
|
+
deps.writer.out(JSON.stringify(doc, null, 2));
|
|
447
|
+
if (!doc.ok) {
|
|
448
|
+
deps.writer.err(doorRefusalSentence(doc.code, alias));
|
|
449
|
+
return EXIT_FAILED;
|
|
450
|
+
}
|
|
451
|
+
if (values.json !== true) {
|
|
452
|
+
deps.writer.out(`Connected ${doc.orgName ?? doc.username} (${doc.envType}).`);
|
|
453
|
+
if (doc.reconnected)
|
|
454
|
+
deps.writer.out("That org was already connected to this workspace; its login was refreshed.");
|
|
455
|
+
deps.writer.out("Done.");
|
|
456
|
+
}
|
|
457
|
+
return EXIT_OK;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The `setup_connect_salesforce` arguments the flags ask for — ONE home, used by
|
|
461
|
+
* `connect salesforce` and by `setup`'s Salesforce rung, so a flag `setup`
|
|
462
|
+
* accepts cannot be one it silently ignores (FLO-514: `--my-domain` was).
|
|
463
|
+
*/
|
|
464
|
+
function salesforceLinkArgs(values) {
|
|
465
|
+
return {
|
|
466
|
+
...(values.sandbox === true ? { env: "sandbox" } : {}),
|
|
467
|
+
...(typeof values["my-domain"] === "string" ? { myDomain: values["my-domain"] } : {}),
|
|
468
|
+
};
|
|
298
469
|
}
|
|
299
|
-
function reportLinkOutcome(writer, outcome) {
|
|
470
|
+
function reportLinkOutcome(writer, outcome, provider) {
|
|
471
|
+
const noun = provider === "github" ? "GitHub installation" : "Salesforce org";
|
|
300
472
|
switch (outcome.kind) {
|
|
301
473
|
case "done":
|
|
302
474
|
writer.out("Done.");
|
|
303
475
|
return EXIT_OK;
|
|
304
476
|
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
477
|
case "timeout":
|
|
308
|
-
|
|
478
|
+
if (outcome.used) {
|
|
479
|
+
// The link was opened but no new id arrived: re-authorizing an org (or
|
|
480
|
+
// re-installing on an account) that is already connected refreshes the
|
|
481
|
+
// existing row and mints nothing new, so "expired" would misdescribe it.
|
|
482
|
+
writer.err(`The link was used, but no new ${noun} appeared in this workspace. ` +
|
|
483
|
+
"If you signed in to one that was already connected, it was refreshed — run `flowsprite status`.");
|
|
484
|
+
}
|
|
485
|
+
else if (outcome.kind === "expired") {
|
|
486
|
+
writer.err("That setup link expired before it was used. Run the command again for a fresh one.");
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
writer.err("Gave up waiting after 10 minutes. The link may still work — finish it in the browser, then run `flowsprite status`.");
|
|
490
|
+
}
|
|
309
491
|
return EXIT_FAILED;
|
|
310
492
|
default:
|
|
311
493
|
writer.err(outcome.message);
|
|
@@ -352,13 +534,13 @@ async function cmdBind(deps, origin, values) {
|
|
|
352
534
|
const built = clientFor(deps, origin, values);
|
|
353
535
|
if (!built.ok)
|
|
354
536
|
return EXIT_FAILED;
|
|
355
|
-
return await runBind(deps, values, built.client, null);
|
|
537
|
+
return await runBind(deps, origin, values, built.client, null);
|
|
356
538
|
}
|
|
357
|
-
async function runBind(deps, values, client, rung) {
|
|
358
|
-
const filled = await fillBindInput(deps, client, values, rung);
|
|
359
|
-
if (filled
|
|
360
|
-
return
|
|
361
|
-
const plan = planBindArgs(filled);
|
|
539
|
+
async function runBind(deps, origin, values, client, rung) {
|
|
540
|
+
const filled = await fillBindInput(deps, origin, client, values, rung);
|
|
541
|
+
if (!filled.ok)
|
|
542
|
+
return filled.rc;
|
|
543
|
+
const plan = planBindArgs(filled.input);
|
|
362
544
|
if (!plan.ok) {
|
|
363
545
|
// A ONE-LINE REMEDY, not the whole usage text. By the time we are here the
|
|
364
546
|
// command line parsed fine and the workspace was read; dumping 39 lines of
|
|
@@ -379,8 +561,45 @@ async function runBind(deps, values, client, rung) {
|
|
|
379
561
|
for (const line of textOf(result.content))
|
|
380
562
|
deps.writer.out(line);
|
|
381
563
|
}
|
|
564
|
+
// Only `setup` waits: its NEXT rung is the first sync, which refuses a
|
|
565
|
+
// repository that is still being created. The wait sits inside the bind step,
|
|
566
|
+
// before the ladder's next read, so FIX-F1's identity guard compares the
|
|
567
|
+
// payload AFTER the repository settled.
|
|
568
|
+
// ponytail: a bare `flowsprite bind` does not wait — it has no next step to
|
|
569
|
+
// protect; `flowsprite status` shows "(creating)". Wait there too if people
|
|
570
|
+
// chain `bind && sync` in scripts.
|
|
571
|
+
if (rung !== null && asObject(toolJson(result))?.bootstrapState === "creating") {
|
|
572
|
+
return await reportRepositoryWait(deps, values, client, String(filled.input.connection));
|
|
573
|
+
}
|
|
382
574
|
return EXIT_OK;
|
|
383
575
|
}
|
|
576
|
+
async function reportRepositoryWait(deps, values, client, connectionId) {
|
|
577
|
+
// Progress is prose, so under --json it goes to stderr and stdout stays parseable.
|
|
578
|
+
const say = (line) => (values.json === true ? deps.writer.err(line) : deps.writer.out(line));
|
|
579
|
+
say("Waiting for GitHub to create the repository...");
|
|
580
|
+
const outcome = await waitForRepository(setupDeps(deps, client), connectionId);
|
|
581
|
+
switch (outcome.kind) {
|
|
582
|
+
case "ready":
|
|
583
|
+
say("Repository ready.");
|
|
584
|
+
return EXIT_OK;
|
|
585
|
+
case "gone":
|
|
586
|
+
return EXIT_OK;
|
|
587
|
+
case "failed":
|
|
588
|
+
// Scrubbed HERE as well as at the writer: the writer keeps newlines (a
|
|
589
|
+
// multi-line refusal or a --json document is meant to stay lines), but a
|
|
590
|
+
// stored error INTERPOLATED into one sentence must not forge a second line
|
|
591
|
+
// (FLO-511 F3 pins it).
|
|
592
|
+
deps.writer.err(`Creating the repository failed: ${printable(outcome.error)}`);
|
|
593
|
+
deps.writer.err("Run `flowsprite setup` again to choose another name, or pick an existing repository.");
|
|
594
|
+
return EXIT_FAILED;
|
|
595
|
+
case "timeout":
|
|
596
|
+
deps.writer.err("The repository is still being created. Run `flowsprite setup` again in a minute.");
|
|
597
|
+
return EXIT_FAILED;
|
|
598
|
+
default:
|
|
599
|
+
deps.writer.err(outcome.message);
|
|
600
|
+
return EXIT_FAILED;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
384
603
|
/**
|
|
385
604
|
* Fill in `--connection`, `--installation` and the repository when the workspace
|
|
386
605
|
* leaves no choice, and ask when it does.
|
|
@@ -391,14 +610,16 @@ async function runBind(deps, values, client, rung) {
|
|
|
391
610
|
* refuses — never guessed, because binding SUPERSEDES whatever that connection
|
|
392
611
|
* was bound to before and switches off its data export.
|
|
393
612
|
*
|
|
394
|
-
* THE REPOSITORY
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
613
|
+
* THE REPOSITORY DEFAULTS TO A NEW PRIVATE ONE (FLO-511, Noah 2026-09-17). A
|
|
614
|
+
* fresh GitHub installation has no repository list Flowsprite can show, so
|
|
615
|
+
* picking cannot be the default. Creating writes to the person's GitHub account,
|
|
616
|
+
* and the tool treats create + private as a consent decision, so it happens only
|
|
617
|
+
* after a yes at a prompt (or an explicit `--create`); without a prompt the
|
|
618
|
+
* command refuses and names the flag. Picking an existing repository is the "no"
|
|
619
|
+
* branch, and `--repo` is its non-interactive form.
|
|
400
620
|
*/
|
|
401
|
-
async function fillBindInput(deps, client, values, rung) {
|
|
621
|
+
async function fillBindInput(deps, origin, client, values, rung) {
|
|
622
|
+
const refuse = { ok: false, rc: EXIT_USAGE };
|
|
402
623
|
const input = {
|
|
403
624
|
connection: typeof values.connection === "string" ? values.connection : undefined,
|
|
404
625
|
installation: typeof values.installation === "string" ? values.installation : undefined,
|
|
@@ -410,7 +631,7 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
410
631
|
return status.ok ? status.payload : undefined;
|
|
411
632
|
})();
|
|
412
633
|
if (payload === undefined)
|
|
413
|
-
return
|
|
634
|
+
return refuse;
|
|
414
635
|
const root = asObject(payload);
|
|
415
636
|
const connections = asArray(root?.connections);
|
|
416
637
|
const installations = asArray(root?.installations);
|
|
@@ -428,7 +649,7 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
428
649
|
if (typeof input.connection === "string") {
|
|
429
650
|
if (rung !== null && !rung.connectionIds.includes(input.connection)) {
|
|
430
651
|
deps.writer.err(outOfRungRefusal(input.connection, rung));
|
|
431
|
-
return
|
|
652
|
+
return refuse;
|
|
432
653
|
}
|
|
433
654
|
}
|
|
434
655
|
else {
|
|
@@ -437,7 +658,7 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
437
658
|
`${String(c.displayName ?? "?")} [${String(c.envType ?? "?")}]`,
|
|
438
659
|
]));
|
|
439
660
|
if (picked === null)
|
|
440
|
-
return
|
|
661
|
+
return refuse;
|
|
441
662
|
input.connection = picked;
|
|
442
663
|
}
|
|
443
664
|
// ---- which GitHub installation ------------------------------------------
|
|
@@ -447,30 +668,70 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
447
668
|
`@${String(i.accountLogin ?? "?")} [${String(i.accountType ?? "?")}]`,
|
|
448
669
|
]));
|
|
449
670
|
if (picked === null)
|
|
450
|
-
return
|
|
671
|
+
return refuse;
|
|
451
672
|
input.installation = picked;
|
|
452
673
|
}
|
|
453
|
-
// ----
|
|
674
|
+
// ---- create a new repository, or pick an existing one -------------------
|
|
454
675
|
if (typeof input.repo !== "string" && typeof input.create !== "string") {
|
|
455
|
-
const
|
|
676
|
+
const installationId = String(input.installation);
|
|
677
|
+
const chosen = installations.find((i) => String(i.installationId ?? "") === installationId);
|
|
678
|
+
const target = connections.find((c) => String(c.id ?? "") === String(input.connection));
|
|
679
|
+
const suggested = typeof target?.suggestedRepoName === "string" && target.suggestedRepoName.length > 0
|
|
680
|
+
? target.suggestedRepoName
|
|
681
|
+
: null;
|
|
682
|
+
// Decided from setup_status BEFORE any call: a refusal's `kind` never
|
|
683
|
+
// reaches this client over HTTP, only its sentence.
|
|
684
|
+
if (chosen?.needsUserAuth === true) {
|
|
685
|
+
deps.writer.err(`GitHub needs a one-time sign-in before Flowsprite can create a repository on @${String(chosen.accountLogin ?? "?")}. ` +
|
|
686
|
+
`Open ${origin}/api/auth/github/user/start?installation_id=${installationId} in the browser where this workspace is open, ` +
|
|
687
|
+
"then run `flowsprite setup` again.");
|
|
688
|
+
deps.writer.err("To use a repository that already exists instead, pass --repo owner/name.");
|
|
689
|
+
return { ok: false, rc: EXIT_FAILED };
|
|
690
|
+
}
|
|
691
|
+
if (deps.ask === undefined) {
|
|
692
|
+
deps.writer.err("Flowsprite creates a new private repository here by default, and that needs a yes. " +
|
|
693
|
+
`Run \`flowsprite setup\` at a terminal, or pass --create ${suggested ?? "<name>"} ` +
|
|
694
|
+
"(or --repo owner/name to use an existing repository).");
|
|
695
|
+
return refuse;
|
|
696
|
+
}
|
|
697
|
+
const answer = (await deps.ask(`Create a new private GitHub repository for ${String(target?.displayName ?? input.connection)}? [Y/n] `)).trim().toLowerCase();
|
|
698
|
+
if (answer !== "" && answer !== "y" && answer !== "yes" && answer !== "n" && answer !== "no") {
|
|
699
|
+
// A typo is neither answer: reading it as "no" would bind an existing
|
|
700
|
+
// repository (silently, when exactly one is listed).
|
|
701
|
+
deps.writer.err("Answer y or n.");
|
|
702
|
+
return refuse;
|
|
703
|
+
}
|
|
704
|
+
if (answer === "" || answer === "y" || answer === "yes") {
|
|
705
|
+
const typed = (await deps.ask(`Repository name [${suggested ?? ""}]: `)).trim();
|
|
706
|
+
const name = typed.length > 0 ? typed : suggested;
|
|
707
|
+
if (name === null) {
|
|
708
|
+
deps.writer.err("The new repository needs a name. Run `flowsprite setup` again and type one, or pass --create <name>.");
|
|
709
|
+
return refuse;
|
|
710
|
+
}
|
|
711
|
+
// Not validated here: the server's own rule refuses a bad name, and its
|
|
712
|
+
// sentence is what the person sees.
|
|
713
|
+
input.create = name;
|
|
714
|
+
return { ok: true, input };
|
|
715
|
+
}
|
|
456
716
|
const repos = asArray(chosen?.repos);
|
|
457
717
|
if (repos.length === 0) {
|
|
458
718
|
// An EMPTY list means Flowsprite has not listed that installation's
|
|
459
|
-
// repositories, not that the installation has none
|
|
460
|
-
//
|
|
461
|
-
deps.writer.err(`Flowsprite has not listed any repositories for installation ${
|
|
462
|
-
"
|
|
463
|
-
|
|
719
|
+
// repositories, not that the installation has none. The web's repository
|
|
720
|
+
// dropdown on Salesforce Orgs is the only thing that lists them.
|
|
721
|
+
deps.writer.err(`Flowsprite has not listed any repositories for installation ${installationId} yet. ` +
|
|
722
|
+
"Open this connection's repository list on the Salesforce Orgs page in the app and press Refresh, " +
|
|
723
|
+
"then run `flowsprite setup` again — or answer yes to create a new private one.");
|
|
724
|
+
return refuse;
|
|
464
725
|
}
|
|
465
726
|
const picked = await pickOne(deps, REPOSITORY, repos.map((r) => [
|
|
466
727
|
String(r.fullName ?? ""),
|
|
467
728
|
`${r.private === true ? "private" : "PUBLIC — will be refused"}, default branch ${String(r.defaultBranch ?? "?")}`,
|
|
468
729
|
]));
|
|
469
730
|
if (picked === null)
|
|
470
|
-
return
|
|
731
|
+
return refuse;
|
|
471
732
|
input.repo = picked;
|
|
472
733
|
}
|
|
473
|
-
return input;
|
|
734
|
+
return { ok: true, input };
|
|
474
735
|
}
|
|
475
736
|
/**
|
|
476
737
|
* One of a list, or nothing.
|
|
@@ -640,9 +901,16 @@ async function cmdSetup(deps, origin, values) {
|
|
|
640
901
|
case "connect_salesforce":
|
|
641
902
|
case "connect_github": {
|
|
642
903
|
deps.writer.out(step.text);
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
904
|
+
if (step.kind === "connect_salesforce" && deps.isOnPath("sf")) {
|
|
905
|
+
deps.writer.out("Already logged in to that org with the Salesforce CLI? `flowsprite connect salesforce --from-sf-cli <alias>` connects it without a browser.");
|
|
906
|
+
}
|
|
907
|
+
const provider = step.kind === "connect_salesforce" ? "salesforce" : "github";
|
|
908
|
+
const tool = provider === "salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
|
|
909
|
+
const args = provider === "salesforce" ? salesforceLinkArgs(values) : {};
|
|
910
|
+
// The payload just read IS the pre-mint snapshot: the rung is done when a
|
|
911
|
+
// NEW connection / installation appears in it, and the identity check at
|
|
912
|
+
// the top of the loop then sees the ladder move on that same new row.
|
|
913
|
+
const rc = reportLinkOutcome(deps.writer, await runLinkStep(sdeps, tool, args, provider, status.payload), provider);
|
|
646
914
|
if (rc !== EXIT_OK)
|
|
647
915
|
return rc;
|
|
648
916
|
previousTool = tool;
|
|
@@ -660,9 +928,23 @@ async function cmdSetup(deps, origin, values) {
|
|
|
660
928
|
return EXIT_FAILED;
|
|
661
929
|
}
|
|
662
930
|
deps.writer.out(step.text);
|
|
931
|
+
if (step.kind === "first_sync") {
|
|
932
|
+
// A repository still being CREATED already counts as bound, so a re-run
|
|
933
|
+
// of `setup` (or a wait that ended "gone") can land here while
|
|
934
|
+
// `sfdc_trigger_sync` would refuse it. Wait for each one first, then
|
|
935
|
+
// re-read the ladder rather than syncing off this stale payload.
|
|
936
|
+
const creating = asArray(asObject(status.payload)?.connections).filter((c) => connectionIds.includes(String(c.id)) && c.bootstrapState === "creating");
|
|
937
|
+
for (const c of creating) {
|
|
938
|
+
const rc = await reportRepositoryWait(deps, values, built.client, String(c.id));
|
|
939
|
+
if (rc !== EXIT_OK)
|
|
940
|
+
return rc;
|
|
941
|
+
}
|
|
942
|
+
if (creating.length > 0)
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
663
945
|
const rung = { kind: step.kind, connectionIds, status: status.payload };
|
|
664
946
|
const rc = step.kind === "bind_repo"
|
|
665
|
-
? await runBind(deps, values, built.client, rung)
|
|
947
|
+
? await runBind(deps, origin, values, built.client, rung)
|
|
666
948
|
: await runSync(deps, values, built.client, rung);
|
|
667
949
|
if (rc !== EXIT_OK)
|
|
668
950
|
return rc;
|
|
@@ -715,12 +997,44 @@ function mcpPrint(deps, origin, values, flag) {
|
|
|
715
997
|
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
716
998
|
const mcpEndpoint = stored?.mcpEndpoint ?? `${origin}/api/mcp`;
|
|
717
999
|
const tokenEnv = typeof values["token-env"] === "string" ? values["token-env"] : undefined;
|
|
718
|
-
|
|
1000
|
+
const clients = flag !== undefined ? [flag] : [...CLIENTS];
|
|
1001
|
+
const ctxLite = pathContext(deps);
|
|
1002
|
+
/** Every stdout line of this invocation, with `placeholder` where the bearer goes. */
|
|
1003
|
+
const render = (placeholder) => {
|
|
1004
|
+
const rows = clients.map((client) => {
|
|
1005
|
+
const cfg = configPathFor(client, ctxLite);
|
|
1006
|
+
const command = commandFor(client);
|
|
1007
|
+
return {
|
|
1008
|
+
client,
|
|
1009
|
+
// What `mcp install` WOULD do for this client, on this machine, right now —
|
|
1010
|
+
// derived from the same planner, so `print --json` cannot describe a route
|
|
1011
|
+
// `install` does not take.
|
|
1012
|
+
kind: planInstall(client, {
|
|
1013
|
+
...ctxLite,
|
|
1014
|
+
origin,
|
|
1015
|
+
mcpEndpoint,
|
|
1016
|
+
token: TOKEN_PLACEHOLDER,
|
|
1017
|
+
tokenEnv,
|
|
1018
|
+
onPath: deps.isOnPath,
|
|
1019
|
+
fileExists: deps.fileExists,
|
|
1020
|
+
}).kind,
|
|
1021
|
+
path: cfg.path,
|
|
1022
|
+
...(command === null ? {} : { command }),
|
|
1023
|
+
snippet: renderSnippet(client, { url: mcpEndpoint, placeholder, tokenEnv }),
|
|
1024
|
+
};
|
|
1025
|
+
});
|
|
1026
|
+
// ONE client asked for, one object; no client asked for, the array. Nothing
|
|
1027
|
+
// else on stdout either way — that is what makes this parseable.
|
|
1028
|
+
if (values.json === true)
|
|
1029
|
+
return [JSON.stringify(flag !== undefined ? rows[0] : rows, null, 2)];
|
|
1030
|
+
return rows.flatMap((row, n) => [...(n > 0 ? [""] : []), `# ${row.client}`, row.snippet]);
|
|
1031
|
+
};
|
|
719
1032
|
if (values.reveal === true) {
|
|
720
|
-
if (deps.
|
|
1033
|
+
if (deps.reveal === undefined) {
|
|
721
1034
|
// A SECRET GOES TO A TERMINAL OR NOWHERE. Through a pipe the destination is
|
|
722
1035
|
// a file, a log or another process's buffer, and none of those is something
|
|
723
|
-
// the person watching can decide to clear.
|
|
1036
|
+
// the person watching can decide to clear. The sink exists iff stdout is a
|
|
1037
|
+
// terminal (wiring.ts), so its absence IS the pipe.
|
|
724
1038
|
deps.writer.err("--reveal prints the real token, so it only runs at a terminal and stdout here is not one. " +
|
|
725
1039
|
"Re-run it at a terminal, or drop --reveal for a snippet with a placeholder.");
|
|
726
1040
|
return EXIT_USAGE;
|
|
@@ -734,44 +1048,16 @@ function mcpPrint(deps, origin, values, flag) {
|
|
|
734
1048
|
deps.writer.err(`Not logged in to ${origin}, so there is no token to reveal. Run \`flowsprite login\`.`);
|
|
735
1049
|
return EXIT_FAILED;
|
|
736
1050
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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));
|
|
1051
|
+
// THE ONE CALL SITE OF THE UNREDACTED SINK (FLO-513 AC3). Every stdout line
|
|
1052
|
+
// of a --reveal run — the `# client` headers, the snippet, or the --json
|
|
1053
|
+
// document — goes through it, because the writer would redact the token
|
|
1054
|
+
// back out. The real token enters `render` only on this path.
|
|
1055
|
+
for (const line of render(token.token))
|
|
1056
|
+
deps.reveal(line);
|
|
767
1057
|
return EXIT_OK;
|
|
768
1058
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
deps.writer.out("");
|
|
772
|
-
deps.writer.out(`# ${row.client}`);
|
|
773
|
-
deps.writer.out(row.snippet);
|
|
774
|
-
});
|
|
1059
|
+
for (const line of render(TOKEN_PLACEHOLDER))
|
|
1060
|
+
deps.writer.out(line);
|
|
775
1061
|
return EXIT_OK;
|
|
776
1062
|
}
|
|
777
1063
|
async function mcpInstall(deps, origin, values, flag) {
|