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/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
- const result = await deviceCodeLogin({
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
- 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"] } : {}),
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
- const outcome = await runLinkStep(setupDeps(deps, built.client), tool, args, stepKind);
297
- return reportLinkOutcome(deps.writer, outcome);
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
- writer.err("Gave up waiting after 10 minutes. The link may still work — finish it in the browser, then run `flowsprite status`.");
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 === null)
360
- return EXIT_USAGE;
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 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.
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 null;
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 null;
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 null;
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 null;
671
+ return refuse;
451
672
  input.installation = picked;
452
673
  }
453
- // ---- which repository ----------------------------------------------------
674
+ // ---- create a new repository, or pick an existing one -------------------
454
675
  if (typeof input.repo !== "string" && typeof input.create !== "string") {
455
- const chosen = installations.find((i) => String(i.installationId ?? "") === String(input.installation));
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 — 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;
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 null;
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
- 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));
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
- let placeholder = TOKEN_PLACEHOLDER;
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.isTTY !== true) {
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
- 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));
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
- 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
- });
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) {