flowsprite 0.1.0 → 0.1.4

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
@@ -1,14 +1,17 @@
1
1
  import { homedir } from "node:os";
2
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";
3
+ import { clearPendingLogin, credentialsPath, deleteCredential, readCredentials, readPendingLogin, resolveToken, writeCredential, writePendingLogin, } from "./credentials.js";
4
+ import { doctorOk, 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 { SAME_ACCOUNT_NOTE } from "./messages.js";
11
+ import { printable, redact } from "./redact.js";
12
+ import { asArray, asObject, browserWaitNotice, createStepTracker, planBindArgs, planNextStep, rerunHint, rungConnectionIds, runLinkStep, stepOfRung, waitForRepository, } from "./setup.js";
13
+ import { MAX_EXPIRES_IN_SECONDS } from "./ghLogin.js";
14
+ import { doorRefusalSentence, FROM_SF_CLI_EXCLUSIVE, parseSfAuthUrl, projectDoorResult, scrubForceUrls, SF_ALIAS_RE, SF_ALIAS_RULE, SF_TOO_OLD_REMEDY, sfLoginLine, sfRelogin, sfRemedy, } from "./sfCli.js";
12
15
  import { renderStatus } from "./status.js";
13
16
  import { USAGE } from "./usage.js";
14
17
  /**
@@ -25,14 +28,18 @@ import { USAGE } from "./usage.js";
25
28
  export const EXIT_OK = 0;
26
29
  export const EXIT_FAILED = 1;
27
30
  export const EXIT_USAGE = 2;
28
- const OPTIONS = {
31
+ /** The parser's option table — exported so usage.test.ts can prove USAGE names every flag. */
32
+ export const OPTIONS = {
29
33
  endpoint: { type: "string" },
30
34
  json: { type: "boolean" },
31
35
  token: { type: "string" },
32
36
  save: { type: "boolean" },
33
37
  "no-open": { type: "boolean" },
38
+ workspace: { type: "string" },
39
+ "no-gh": { type: "boolean" },
34
40
  sandbox: { type: "boolean" },
35
41
  "my-domain": { type: "string" },
42
+ "from-sf-cli": { type: "string" },
36
43
  connection: { type: "string" },
37
44
  installation: { type: "string" },
38
45
  repo: { type: "string" },
@@ -73,6 +80,12 @@ export async function main(argv, deps) {
73
80
  if (!COMMANDS.has(command)) {
74
81
  return usageError(deps.writer, `Unknown command '${command}'.`);
75
82
  }
83
+ // `--from-sf-cli` is accepted by exactly one command. Refused elsewhere
84
+ // rather than ignored: FLO-514 found that a flag `setup` accepts and
85
+ // silently drops is how a person's intent goes missing.
86
+ if (typeof values["from-sf-cli"] === "string" && !(command === "connect" && positionals[1] === "salesforce")) {
87
+ return usageError(deps.writer, "--from-sf-cli belongs to `connect salesforce`: flowsprite connect salesforce --from-sf-cli <alias>.");
88
+ }
76
89
  const endpoint = parseEndpoint({
77
90
  flag: typeof values.endpoint === "string" ? values.endpoint : undefined,
78
91
  env: deps.env.FLOWSPRITE_ENDPOINT,
@@ -131,6 +144,13 @@ function clientFor(deps, origin, values) {
131
144
  }
132
145
  // ------------------------------------------------------------------- commands
133
146
  async function cmdLogin(deps, origin, values) {
147
+ // `--workspace` is the one value the gh road sends that a person typed.
148
+ // Refused empty HERE, before any spawn or request, rather than sent for the
149
+ // door to refuse: the door's `invalid_argument` costs a round trip to say so.
150
+ const workspaceFlag = typeof values.workspace === "string" ? values.workspace.trim() : null;
151
+ if (workspaceFlag !== null && workspaceFlag.length === 0) {
152
+ return usageError(deps.writer, "--workspace needs a name: flowsprite login --workspace <name>.");
153
+ }
134
154
  // A token handed in on the command line or the environment SKIPS the device
135
155
  // loop entirely — there is nothing to approve. It is not stored unless --save
136
156
  // is given, so a one-off token in a CI job leaves nothing behind.
@@ -149,14 +169,30 @@ async function cmdLogin(deps, origin, values) {
149
169
  }
150
170
  return EXIT_OK;
151
171
  }
152
- const result = await deviceCodeLogin({
172
+ // ONE deps object for both roads, so the 409 road's browser tail inherits
173
+ // `--no-open` (and an absent opener) exactly as the device-code road does.
174
+ const loginDeps = {
153
175
  origin,
154
176
  fetch: deps.fetch,
155
177
  sleep: deps.sleep,
156
178
  writer: deps.writer,
157
179
  version: deps.version,
158
180
  ...(values["no-open"] === true || deps.openUrl === undefined ? {} : { openUrl: deps.openUrl }),
159
- });
181
+ ...(deps.progress === undefined ? {} : { progress: deps.progress }),
182
+ pending: pendingLoginPort(deps, origin),
183
+ };
184
+ // FLO-517: the gh road first, unless opted out. Null means it fell to the
185
+ // browser login at its own start, having said why in one line.
186
+ const viaGh = values["no-gh"] === true
187
+ ? null
188
+ : await ghCliLogin({
189
+ ...loginDeps,
190
+ isOnPath: deps.isOnPath,
191
+ spawn: deps.spawn,
192
+ ...(deps.ask === undefined ? {} : { ask: deps.ask }),
193
+ workspaceName: workspaceFlag,
194
+ });
195
+ const result = viaGh ?? (await deviceCodeLogin(loginDeps));
160
196
  if (!result.ok) {
161
197
  deps.writer.err(result.message);
162
198
  return EXIT_FAILED;
@@ -169,7 +205,9 @@ async function cmdLogin(deps, origin, values) {
169
205
  savedAt: deps.now(),
170
206
  };
171
207
  // 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}`;
208
+ // C4: the email and workspace name are DISPLAY ONLY — scrubbed in login.ts,
209
+ // and never in `cred` above, so they are never stored.
210
+ const who = `Logged in to ${origin} as ${result.userEmail ?? result.userId} in workspace ${result.orgName ?? result.orgId}`;
173
211
  try {
174
212
  writeCredential(deps.env, origin, cred, deps.home);
175
213
  }
@@ -192,37 +230,90 @@ async function cmdLogin(deps, origin, values) {
192
230
  return EXIT_FAILED;
193
231
  }
194
232
  deps.writer.out(who);
233
+ deps.writer.out(SAME_ACCOUNT_NOTE);
195
234
  return EXIT_OK;
196
235
  }
236
+ /**
237
+ * C3 — the pending device login for THIS origin, kept in `pending-login.json`
238
+ * beside the credential store (0600, see `credentials.ts`). The clock lives here
239
+ * so `login.ts` stays clock-free: it is handed seconds LEFT and hands back the
240
+ * payload's own TTL.
241
+ */
242
+ function pendingLoginPort(deps, origin) {
243
+ return {
244
+ load: () => {
245
+ const saved = readPendingLogin(deps.env, origin, deps.now(), deps.home);
246
+ if (saved === null)
247
+ return null;
248
+ return {
249
+ deviceCode: saved.deviceCode,
250
+ ...(saved.userCode === null || saved.openTarget === null
251
+ ? {}
252
+ : { userCode: saved.userCode, openTarget: saved.openTarget }),
253
+ expiresIn: Math.ceil((saved.expiresAtMs - deps.now()) / 1000),
254
+ interval: saved.interval,
255
+ };
256
+ },
257
+ save: (p) => writePendingLogin(deps.env, {
258
+ origin,
259
+ deviceCode: p.deviceCode,
260
+ userCode: p.userCode ?? null,
261
+ openTarget: p.openTarget ?? null,
262
+ expiresAtMs: deps.now() + p.expiresIn * 1000,
263
+ savedAtMs: deps.now(),
264
+ interval: p.interval,
265
+ }, deps.home),
266
+ clear: (deviceCode) => clearPendingLogin(deps.env, origin, deps.home, deviceCode),
267
+ };
268
+ }
197
269
  function cmdLogout(deps, origin) {
270
+ // C3: a half-finished login goes too, so the next `login` starts fresh. Best
271
+ // effort: an undeletable file must not turn a logout into a crash. No device
272
+ // code is named (G2): logout drops whatever is pending for this origin.
273
+ try {
274
+ clearPendingLogin(deps.env, origin, deps.home);
275
+ }
276
+ catch {
277
+ // The credential removal below is what logout promises; this is housekeeping.
278
+ }
198
279
  const removed = deleteCredential(deps.env, origin, deps.home);
199
280
  deps.writer.out(removed ? `Logged out of ${origin}.` : `There was no stored credential for ${origin}.`);
200
281
  return EXIT_OK;
201
282
  }
202
283
  async function cmdDoctor(deps, origin, values) {
203
284
  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 = {
285
+ const token = resolveToken({
286
+ flag: typeof values.token === "string" ? values.token : undefined,
287
+ env: deps.env.FLOWSPRITE_TOKEN,
288
+ stored: stored?.token,
289
+ });
290
+ // N7: the endpoint is probed WITH OR WITHOUT a credential — `initialize` with
291
+ // an empty bearer. HEALTHY is exactly two answers (G8): a successful
292
+ // `initialize`, or a 401 (`unauthorized` — the auth layer answered, the
293
+ // credential is the problem or is absent). Anything else — no answer, a body
294
+ // that is not JSON-RPC, a JSON-RPC error, a tool error — FAILS the endpoint.
295
+ const probe = await createMcpClient({ origin, token: token?.token ?? "", fetch: deps.fetch, version: deps.version }).initialize();
296
+ const unauthorized = !probe.ok && probe.kind === "unauthorized";
297
+ const reachable = probe.ok || unauthorized;
298
+ const detail = probe.ok || (token !== null && unauthorized)
299
+ ? "reachable"
300
+ : unauthorized
301
+ ? "reachable (it answered HTTP 401 to a request with no credential, as it should)"
302
+ : `${probe.kind}: ${probe.message}`;
303
+ const checks = {
216
304
  nodeVersion: deps.nodeVersion,
217
305
  nodeOk: nodeMajor(deps.nodeVersion) >= MIN_NODE_MAJOR,
218
306
  endpoint: origin,
219
307
  endpointReachable: reachable,
220
308
  endpointDetail: detail,
221
- hasCredential: stored !== undefined,
309
+ hasCredential: token !== null,
310
+ credentialSource: token?.source ?? null,
311
+ credentialRefused: token !== null && unauthorized,
222
312
  credentialsPath: credentialsPath(deps.env, deps.home),
223
313
  sfOnPath: deps.isOnPath("sf"),
224
314
  ghOnPath: deps.isOnPath("gh"),
225
315
  };
316
+ const report = { ...checks, ok: doctorOk(checks) };
226
317
  if (values.json === true) {
227
318
  deps.writer.out(JSON.stringify(report, null, 2));
228
319
  }
@@ -230,18 +321,8 @@ async function cmdDoctor(deps, origin, values) {
230
321
  for (const line of formatDoctor(report))
231
322
  deps.writer.out(line);
232
323
  }
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 });
324
+ // N16: exit 1 iff any check FAILs (see `doctorOk`); sf/gh are notes only.
325
+ return report.ok ? EXIT_OK : EXIT_FAILED;
245
326
  }
246
327
  async function fetchStatus(deps, client) {
247
328
  const result = await client.callTool("setup_status", {});
@@ -270,42 +351,287 @@ async function cmdStatus(deps, origin, values) {
270
351
  return EXIT_OK;
271
352
  }
272
353
  function setupDeps(deps, client) {
273
- return { client, writer: deps.writer, sleep: deps.sleep, now: deps.now };
354
+ return {
355
+ client,
356
+ writer: deps.writer,
357
+ sleep: deps.sleep,
358
+ now: deps.now,
359
+ // C8: the setup-link countdown shares the login's status line — a terminal only.
360
+ ...(deps.progress === undefined ? {} : { progress: deps.progress }),
361
+ };
274
362
  }
275
363
  async function cmdConnect(deps, origin, values, which) {
276
364
  if (which !== "salesforce" && which !== "github") {
277
365
  return usageError(deps.writer, "connect takes 'salesforce' or 'github'.");
278
366
  }
367
+ // The `sf`-CLI door (FLO-515): a branch chosen BEFORE the link is minted,
368
+ // not a change to the link step. The alias was already routed here by `main`.
369
+ if (which === "salesforce" && typeof values["from-sf-cli"] === "string") {
370
+ return await connectFromSfCli(deps, origin, values, values["from-sf-cli"]);
371
+ }
279
372
  const built = clientFor(deps, origin, values);
280
373
  if (!built.ok)
281
374
  return EXIT_FAILED;
282
375
  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"] } : {}),
376
+ // ponytail: the GitHub alternative door is absent here and plugs in at THIS
377
+ // call, as a branch chosen before the link is minted — the gh-token door
378
+ // (card A3, blocked), which would use a token the person already holds
379
+ // instead of an app install. The Salesforce one is `connectFromSfCli` above.
380
+ const args = which === "salesforce" ? salesforceLinkArgs(values) : {};
381
+ // The pre-mint snapshot is read HERE (it was read inside the link step before
382
+ // N1) so the used-but-no-new-id outcome can compare "connected" before and
383
+ // after. `done` still means a NEW connection or installation.
384
+ const before = await fetchStatus(deps, built.client);
385
+ if (!before.ok)
386
+ return EXIT_FAILED;
387
+ const outcome = await runLinkStep(setupDeps(deps, built.client), tool, args, which, before.payload);
388
+ return await settleLinkOutcome(deps, built.client, outcome, which, before.payload);
389
+ }
390
+ /** `workspace.<provider>.connected === true` in a `setup_status` payload — strictly true. */
391
+ function providerConnected(status, provider) {
392
+ return asObject(asObject(asObject(status)?.workspace)?.[provider])?.connected === true;
393
+ }
394
+ /**
395
+ * Card eecc9d6f N1 — a link that was USED but produced no NEW id. The success
396
+ * sentence is NEUTRAL (G7): "is connected" is what the read shows; whether a row
397
+ * was refreshed or newly made is not something this read can tell. Re-authorizing
398
+ * a connection Flowsprite already had a row for refreshes that row, so no new id
399
+ * appears even when the person did exactly the right thing. The rule: the step
400
+ * SUCCEEDED iff the provider was NOT connected in the pre-mint snapshot and IS
401
+ * connected now — the link changed the answer. (Connected BEFORE as well is the
402
+ * second-org case, which stays the old "used, but nothing new" failure: nothing
403
+ * shows this link did anything.) Applied to both providers: the GitHub link step
404
+ * shares this exact outcome path.
405
+ */
406
+ async function settleLinkOutcome(deps, client, outcome, provider, before) {
407
+ if ((outcome.kind === "expired" || outcome.kind === "timeout") && outcome.used && !providerConnected(before, provider)) {
408
+ const read = await client.callTool("setup_status", {});
409
+ if (read.ok && providerConnected(toolJson(read), provider)) {
410
+ deps.writer.out(provider === "salesforce"
411
+ ? "Salesforce is connected."
412
+ : "GitHub is connected.");
413
+ return EXIT_OK;
414
+ }
415
+ if (read.ok) {
416
+ deps.writer.err(provider === "salesforce"
417
+ ? "The link was opened, but Salesforce is still not connected to this workspace — the Salesforce sign-in did not complete a connection. Run the command again for a fresh link."
418
+ : "The link was opened, but GitHub is still not connected to this workspace — the app install did not complete. Run the command again for a fresh link.");
419
+ return EXIT_FAILED;
420
+ }
421
+ }
422
+ return reportLinkOutcome(deps.writer, outcome, provider);
423
+ }
424
+ /**
425
+ * `flowsprite connect salesforce --from-sf-cli <alias>` (FLO-515): read the
426
+ * Salesforce CLI's own login for that alias and hand it to the server door
427
+ * `setup_connect_salesforce_cli`, so the credential crosses inside THIS process
428
+ * — spawn stdout → parser → tools/call arguments — and never enters a chat, a
429
+ * transcript or this CLI's output. Why that matters: an agent harness redacted
430
+ * `result.sfdxAuthUrl` before its model could relay it (measured 2026-09-17,
431
+ * Claude Code), and the writer's redactor covers `fp_` tokens only, so the URL
432
+ * is scoped to the spawn-to-call span below and appears in NO string. Every
433
+ * printed text that did not originate in this CLI's constants goes through
434
+ * `scrubForceUrls` as defence in depth. The acceptance oracle censuses this
435
+ * function's source for `fs` / `console` identifiers: the writer and the
436
+ * tools/call body are its only sinks.
437
+ *
438
+ * WHICH COMMAND, and why it changed (card 263cefa5, 2026-09-20): ONE spawn of
439
+ * `sf org auth show-sfdx-auth-url --target-org <alias> --json`, whose stdout
440
+ * under `--json` is `{status, result:{sfdxAuthUrl}, warnings}` — no
441
+ * accessToken, no password, no clientId. The command's own "this exposes an
442
+ * SFDX Auth URL" warning lands BOTH in that `warnings` array and on stderr
443
+ * (sf-plugins-core `toSuccessJson`/`warn`, measured against 12.2.6), and this
444
+ * CLI prints neither. What IS read off that envelope: `status` (to tell a
445
+ * success from an error), the identifier-shaped `name` on a non-zero one (the
446
+ * error CLASS, printed; never its free text), and `result.sfdxAuthUrl` — the
447
+ * only one of the three that is a credential, and it is read into one scope
448
+ * and never printed. FLO-515 read the login from `sf org display --verbose --json`,
449
+ * and sf 2.147.0 (published 2026-07-31) bundles a @salesforce/plugin-org that
450
+ * redacts `sfdxAuthUrl` there behind a placeholder unless a
451
+ * documented-temporary env var is set — so on sf 2.150.6 a good web login was
452
+ * answered with the logout-then-login remedy (measured in UAT, 2026-09-20).
453
+ * The stable command ships in the same plugin version and needs no env var,
454
+ * which is why this door passes NO third argument to `deps.spawn` and overlays
455
+ * no environment on the child.
456
+ *
457
+ * Refusals BEFORE the network, in this order: the flag exclusivity (DM5), the
458
+ * alias rule (DM4), `sf` on PATH (DM3: install lines, never an auto-install),
459
+ * the stored credential, the spawn's own failure, an `sf` too old to have the
460
+ * command (263cefa5 DM2), sf's own non-zero exit (which is where a login with
461
+ * no refresh token lands: the command throws rather than answering with a url,
462
+ * so a JWT or access-token login is refused HERE and not by the url check
463
+ * below), then the envelope's shape (DM2: an empty-client or other-app url is
464
+ * refused here with the alias-bearing remedy rather than round-tripped to a
465
+ * server that would refuse it anyway).
466
+ */
467
+ async function connectFromSfCli(deps, origin, values, alias) {
468
+ if (values.sandbox === true || typeof values["my-domain"] === "string") {
469
+ return usageError(deps.writer, FROM_SF_CLI_EXCLUSIVE);
470
+ }
471
+ if (!SF_ALIAS_RE.test(alias))
472
+ return usageError(deps.writer, SF_ALIAS_RULE);
473
+ // On Windows the npm install of sf is a `sf.cmd` shim, which node refuses to
474
+ // spawn without a shell (>= 20.12), so `isOnPath("sf")` reads FALSE for a
475
+ // person who has it (reasoned from Node's documented .cmd refusal, FLO-515
476
+ // fix round 1 — no Windows run exists in this repo's harness). There the
477
+ // PATH probe is skipped and the spawn itself is the probe.
478
+ const win32 = deps.platform === "win32";
479
+ if (!win32 && !deps.isOnPath("sf")) {
480
+ for (const line of sfRemedy(alias, deps.platform))
481
+ deps.writer.err(line);
482
+ return EXIT_USAGE;
483
+ }
484
+ const built = clientFor(deps, origin, values);
485
+ if (!built.ok)
486
+ return EXIT_FAILED;
487
+ // sf's stdout and stderr are NEVER printed: either can quote the URL.
488
+ // UNVERIFIED: the win32 arm — no Windows machine in this repo's harness. It
489
+ // runs sf through cmd.exe as ONE command string; that string is safe by
490
+ // construction because SF_ALIAS_RE (checked above) admits no quote, space or
491
+ // shell metacharacter, so the alias cannot end the command or start another.
492
+ const run = win32
493
+ ? await deps.spawn("cmd.exe", ["/d", "/s", "/c", "sf org auth show-sfdx-auth-url --target-org " + alias + " --json"])
494
+ : await deps.spawn("sf", ["org", "auth", "show-sfdx-auth-url", "--target-org", alias, "--json"]);
495
+ const spawnError = run.error !== undefined && run.error !== null;
496
+ // On win32 "sf is not installed" arrives two ways, both the install remedy
497
+ // the PATH probe gives elsewhere: cmd.exe itself could not start (a spawn
498
+ // `error`), or cmd.exe started and reported the command not recognized —
499
+ // its documented command-not-found ERRORLEVEL is 9009, so that STATUS is
500
+ // read as "no sf", never as "that org is not logged in". Reasoned from
501
+ // Windows' documented behaviour; UNVERIFIED on a real box like the rest of
502
+ // this branch.
503
+ if (win32 && (spawnError || run.status === 9009)) {
504
+ for (const line of sfRemedy(alias, "win32"))
505
+ deps.writer.err(line);
506
+ return EXIT_USAGE;
507
+ }
508
+ if (spawnError) {
509
+ deps.writer.err(`sf org auth show-sfdx-auth-url could not be run for '${alias}'. Is that org logged in? ${sfLoginLine(alias)}`);
510
+ return EXIT_FAILED;
511
+ }
512
+ const parsed = parseSfAuthUrl(run.stdout ?? "");
513
+ // THE RULE: exit 127 with NO JSON envelope on stdout is READ AS "this `sf`
514
+ // lacks the subcommand", i.e. it predates 2.147.0. That is an operational
515
+ // classification, not a proven cause: a missing subcommand is the only shape
516
+ // measured to produce it, on sf 2.127.2 (2026-09-20), in both its forms — a
517
+ // clean shell gives rc 127 with EMPTY stdout and "is not a sf command" on
518
+ // stderr, and the SAME run under `DEBUG=sf:*` gives rc 127 with ~2.5 KB
519
+ // (2509-2510 bytes across runs; a timestamp digit) of debug noise on stdout
520
+ // carrying zero `{` characters and absolute filesystem paths, a second
521
+ // reason never to print it. So the test is "did anything parse", not "was
522
+ // stdout empty" — fix round 1 F1: the empty-stdout form sent a debug shell
523
+ // down the logged-out remedy, this card's own defect class. Exit 127 that DOES carry an envelope is not this
524
+ // arm; it falls to the generic non-zero arm below and is named from its
525
+ // `name`. The remedy here is to update sf, never to log in again, so this
526
+ // sentence carries neither the login line nor sf's own stderr. It must sit
527
+ // above the generic non-zero arm, which would otherwise swallow it.
528
+ if (run.status === 127 && !parsed.ok && parsed.reason === "not_json") {
529
+ deps.writer.err(SF_TOO_OLD_REMEDY);
530
+ return EXIT_FAILED;
531
+ }
532
+ if (run.status !== 0) {
533
+ // sf's JSON error envelope names an error CLASS. `NamedOrgNotFoundError`
534
+ // for a logged-out alias at exit 2 is MEASURED (sf 2.127.2, via the shared
535
+ // `Flags.requiredOrg()` resolution). For a JWT or access-token login the
536
+ // command throws instead of answering with a url — so that login lands
537
+ // HERE rather than in the `no_url` arm below — and the card's NC-3 reads
538
+ // plugin-org 6.0.11 as throwing `error.noRefreshToken` there, i.e. a name
539
+ // such as `NoRefreshTokenError`; UNVERIFIED on this box, which has
540
+ // plugin-org 5.9.71 and no `org/auth` command at all. Nothing depends on
541
+ // the spelling: whatever identifier-shaped `name` arrives is printed, and
542
+ // its free text, `actions` and stderr never are.
543
+ const named = !parsed.ok && parsed.reason === "status" && parsed.message !== "";
544
+ const what = named ? `reported ${parsed.message}` : `failed for '${alias}'`;
545
+ deps.writer.err(`sf org auth show-sfdx-auth-url ${what} (exit ${run.status ?? "by signal"}). Is that org logged in? ${sfLoginLine(alias)}`);
546
+ return EXIT_FAILED;
547
+ }
548
+ if (!parsed.ok) {
549
+ switch (parsed.reason) {
550
+ case "not_json":
551
+ deps.writer.err(`sf org auth show-sfdx-auth-url did not return its JSON. ${sfLoginLine(alias)}`);
552
+ break;
553
+ case "status":
554
+ deps.writer.err(`sf org auth show-sfdx-auth-url reported ${parsed.message || "an error"}. ${sfLoginLine(alias)}`);
555
+ break;
556
+ default:
557
+ // `no_url`: no url in the envelope, a non-string one, another connected
558
+ // app's (`force://Other::…`) or an older auth file's empty client id
559
+ // (`force://:…`) — the server door would refuse every one of them.
560
+ deps.writer.err("That sf login cannot be handed over: only the Salesforce CLI's default connected app (PlatformCLI) " +
561
+ `with a web login works. ${sfRelogin(alias)}`);
294
562
  }
295
- : {};
296
- const outcome = await runLinkStep(setupDeps(deps, built.client), tool, args, stepKind);
297
- return reportLinkOutcome(deps.writer, outcome);
563
+ return EXIT_FAILED;
564
+ }
565
+ // The ONE place the URL leaves this process: exactly this key, nothing else.
566
+ const res = await built.client.callTool("setup_connect_salesforce_cli", { sfdxAuthUrl: parsed.sfdxAuthUrl });
567
+ if (!res.ok) {
568
+ switch (res.kind) {
569
+ case "tool_error":
570
+ // The door's own refusal sentence (or whatever the server said), scrubbed.
571
+ deps.writer.err(scrubForceUrls(res.message));
572
+ break;
573
+ case "rpc_error":
574
+ // A JSON-RPC error's message is the server's text: only its code is printed.
575
+ deps.writer.err(`Flowsprite's endpoint rejected the call${res.code === undefined ? "" : ` (JSON-RPC error ${res.code})`}. Run \`flowsprite doctor\`.`);
576
+ break;
577
+ default:
578
+ // `unauthorized` / `transport`: the client builds these sentences from
579
+ // its own URL, the HTTP status and an error NAME — no server text.
580
+ deps.writer.err(scrubForceUrls(res.message));
581
+ }
582
+ return EXIT_FAILED;
583
+ }
584
+ const doc = projectDoorResult(toolJson(res));
585
+ if (doc === null) {
586
+ deps.writer.err("Flowsprite sent an unexpected reply while connecting that Salesforce CLI login.");
587
+ return EXIT_FAILED;
588
+ }
589
+ if (values.json === true)
590
+ deps.writer.out(JSON.stringify(doc, null, 2));
591
+ if (!doc.ok) {
592
+ deps.writer.err(doorRefusalSentence(doc.code, alias));
593
+ return EXIT_FAILED;
594
+ }
595
+ if (values.json !== true) {
596
+ deps.writer.out(`Connected ${doc.orgName ?? doc.username} (${doc.envType}).`);
597
+ if (doc.reconnected)
598
+ deps.writer.out("That org was already connected to this workspace; its login was refreshed.");
599
+ deps.writer.out("Done.");
600
+ }
601
+ return EXIT_OK;
602
+ }
603
+ /**
604
+ * The `setup_connect_salesforce` arguments the flags ask for — ONE home, used by
605
+ * `connect salesforce` and by `setup`'s Salesforce rung, so a flag `setup`
606
+ * accepts cannot be one it silently ignores (FLO-514: `--my-domain` was).
607
+ */
608
+ function salesforceLinkArgs(values) {
609
+ return {
610
+ ...(values.sandbox === true ? { env: "sandbox" } : {}),
611
+ ...(typeof values["my-domain"] === "string" ? { myDomain: values["my-domain"] } : {}),
612
+ };
298
613
  }
299
- function reportLinkOutcome(writer, outcome) {
614
+ function reportLinkOutcome(writer, outcome, provider) {
615
+ const noun = provider === "github" ? "GitHub installation" : "Salesforce org";
300
616
  switch (outcome.kind) {
301
617
  case "done":
302
618
  writer.out("Done.");
303
619
  return EXIT_OK;
304
620
  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
621
  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`.");
622
+ if (outcome.used) {
623
+ // The link was opened but no new id arrived: re-authorizing an org (or
624
+ // re-installing on an account) that is already connected refreshes the
625
+ // existing row and mints nothing new, so "expired" would misdescribe it.
626
+ writer.err(`The link was used, but no new ${noun} appeared in this workspace. ` +
627
+ "If you signed in to one that was already connected, it was refreshed — run `flowsprite status`.");
628
+ }
629
+ else if (outcome.kind === "expired") {
630
+ writer.err("That setup link expired before it was used. Run the command again for a fresh one.");
631
+ }
632
+ else {
633
+ writer.err("Gave up waiting after 10 minutes. The link may still work — finish it in the browser, then run `flowsprite status`.");
634
+ }
309
635
  return EXIT_FAILED;
310
636
  default:
311
637
  writer.err(outcome.message);
@@ -352,13 +678,13 @@ async function cmdBind(deps, origin, values) {
352
678
  const built = clientFor(deps, origin, values);
353
679
  if (!built.ok)
354
680
  return EXIT_FAILED;
355
- return await runBind(deps, values, built.client, null);
681
+ return await runBind(deps, origin, values, built.client, null);
356
682
  }
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);
683
+ async function runBind(deps, origin, values, client, rung) {
684
+ const filled = await fillBindInput(deps, origin, client, values, rung);
685
+ if (!filled.ok)
686
+ return filled.rc;
687
+ const plan = planBindArgs(filled.input);
362
688
  if (!plan.ok) {
363
689
  // A ONE-LINE REMEDY, not the whole usage text. By the time we are here the
364
690
  // command line parsed fine and the workspace was read; dumping 39 lines of
@@ -379,8 +705,45 @@ async function runBind(deps, values, client, rung) {
379
705
  for (const line of textOf(result.content))
380
706
  deps.writer.out(line);
381
707
  }
708
+ // Only `setup` waits: its NEXT rung is the first sync, which refuses a
709
+ // repository that is still being created. The wait sits inside the bind step,
710
+ // before the ladder's next read, so FIX-F1's identity guard compares the
711
+ // payload AFTER the repository settled.
712
+ // ponytail: a bare `flowsprite bind` does not wait — it has no next step to
713
+ // protect; `flowsprite status` shows "(creating)". Wait there too if people
714
+ // chain `bind && sync` in scripts.
715
+ if (rung !== null && asObject(toolJson(result))?.bootstrapState === "creating") {
716
+ return await reportRepositoryWait(deps, values, client, String(filled.input.connection));
717
+ }
382
718
  return EXIT_OK;
383
719
  }
720
+ async function reportRepositoryWait(deps, values, client, connectionId) {
721
+ // Progress is prose, so under --json it goes to stderr and stdout stays parseable.
722
+ const say = (line) => (values.json === true ? deps.writer.err(line) : deps.writer.out(line));
723
+ say("Waiting for GitHub to create the repository...");
724
+ const outcome = await waitForRepository(setupDeps(deps, client), connectionId);
725
+ switch (outcome.kind) {
726
+ case "ready":
727
+ say("Repository ready.");
728
+ return EXIT_OK;
729
+ case "gone":
730
+ return EXIT_OK;
731
+ case "failed":
732
+ // Scrubbed HERE as well as at the writer: the writer keeps newlines (a
733
+ // multi-line refusal or a --json document is meant to stay lines), but a
734
+ // stored error INTERPOLATED into one sentence must not forge a second line
735
+ // (FLO-511 F3 pins it).
736
+ deps.writer.err(`Creating the repository failed: ${printable(outcome.error)}`);
737
+ deps.writer.err("Run `flowsprite setup` again to choose another name, or pick an existing repository.");
738
+ return EXIT_FAILED;
739
+ case "timeout":
740
+ deps.writer.err("The repository is still being created. Run `flowsprite setup` again in a minute.");
741
+ return EXIT_FAILED;
742
+ default:
743
+ deps.writer.err(outcome.message);
744
+ return EXIT_FAILED;
745
+ }
746
+ }
384
747
  /**
385
748
  * Fill in `--connection`, `--installation` and the repository when the workspace
386
749
  * leaves no choice, and ask when it does.
@@ -391,14 +754,16 @@ async function runBind(deps, values, client, rung) {
391
754
  * refuses — never guessed, because binding SUPERSEDES whatever that connection
392
755
  * was bound to before and switches off its data export.
393
756
  *
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.
757
+ * THE REPOSITORY DEFAULTS TO A NEW PRIVATE ONE (FLO-511, Noah 2026-09-17). A
758
+ * fresh GitHub installation has no repository list Flowsprite can show, so
759
+ * picking cannot be the default. Creating writes to the person's GitHub account,
760
+ * and the tool treats create + private as a consent decision, so it happens only
761
+ * after a yes at a prompt (or an explicit `--create`); without a prompt the
762
+ * command refuses and names the flag. Picking an existing repository is the "no"
763
+ * branch, and `--repo` is its non-interactive form.
400
764
  */
401
- async function fillBindInput(deps, client, values, rung) {
765
+ async function fillBindInput(deps, origin, client, values, rung) {
766
+ const refuse = { ok: false, rc: EXIT_USAGE };
402
767
  const input = {
403
768
  connection: typeof values.connection === "string" ? values.connection : undefined,
404
769
  installation: typeof values.installation === "string" ? values.installation : undefined,
@@ -410,7 +775,7 @@ async function fillBindInput(deps, client, values, rung) {
410
775
  return status.ok ? status.payload : undefined;
411
776
  })();
412
777
  if (payload === undefined)
413
- return null;
778
+ return refuse;
414
779
  const root = asObject(payload);
415
780
  const connections = asArray(root?.connections);
416
781
  const installations = asArray(root?.installations);
@@ -428,7 +793,7 @@ async function fillBindInput(deps, client, values, rung) {
428
793
  if (typeof input.connection === "string") {
429
794
  if (rung !== null && !rung.connectionIds.includes(input.connection)) {
430
795
  deps.writer.err(outOfRungRefusal(input.connection, rung));
431
- return null;
796
+ return refuse;
432
797
  }
433
798
  }
434
799
  else {
@@ -437,7 +802,7 @@ async function fillBindInput(deps, client, values, rung) {
437
802
  `${String(c.displayName ?? "?")} [${String(c.envType ?? "?")}]`,
438
803
  ]));
439
804
  if (picked === null)
440
- return null;
805
+ return refuse;
441
806
  input.connection = picked;
442
807
  }
443
808
  // ---- which GitHub installation ------------------------------------------
@@ -447,30 +812,70 @@ async function fillBindInput(deps, client, values, rung) {
447
812
  `@${String(i.accountLogin ?? "?")} [${String(i.accountType ?? "?")}]`,
448
813
  ]));
449
814
  if (picked === null)
450
- return null;
815
+ return refuse;
451
816
  input.installation = picked;
452
817
  }
453
- // ---- which repository ----------------------------------------------------
818
+ // ---- create a new repository, or pick an existing one -------------------
454
819
  if (typeof input.repo !== "string" && typeof input.create !== "string") {
455
- const chosen = installations.find((i) => String(i.installationId ?? "") === String(input.installation));
820
+ const installationId = String(input.installation);
821
+ const chosen = installations.find((i) => String(i.installationId ?? "") === installationId);
822
+ const target = connections.find((c) => String(c.id ?? "") === String(input.connection));
823
+ const suggested = typeof target?.suggestedRepoName === "string" && target.suggestedRepoName.length > 0
824
+ ? target.suggestedRepoName
825
+ : null;
826
+ // Decided from setup_status BEFORE any call: a refusal's `kind` never
827
+ // reaches this client over HTTP, only its sentence.
828
+ if (chosen?.needsUserAuth === true) {
829
+ deps.writer.err(`GitHub needs a one-time sign-in before Flowsprite can create a repository on @${String(chosen.accountLogin ?? "?")}. ` +
830
+ `Open ${origin}/api/auth/github/user/start?installation_id=${installationId} in the browser where this workspace is open, ` +
831
+ "then run `flowsprite setup` again.");
832
+ deps.writer.err("To use a repository that already exists instead, pass --repo owner/name.");
833
+ return { ok: false, rc: EXIT_FAILED };
834
+ }
835
+ if (deps.ask === undefined) {
836
+ deps.writer.err("Flowsprite creates a new private repository here by default, and that needs a yes. " +
837
+ `Run \`flowsprite setup\` at a terminal, or pass --create ${suggested ?? "<name>"} ` +
838
+ "(or --repo owner/name to use an existing repository).");
839
+ return refuse;
840
+ }
841
+ const answer = (await deps.ask(`Create a new private GitHub repository for ${String(target?.displayName ?? input.connection)}? [Y/n] `)).trim().toLowerCase();
842
+ if (answer !== "" && answer !== "y" && answer !== "yes" && answer !== "n" && answer !== "no") {
843
+ // A typo is neither answer: reading it as "no" would bind an existing
844
+ // repository (silently, when exactly one is listed).
845
+ deps.writer.err("Answer y or n.");
846
+ return refuse;
847
+ }
848
+ if (answer === "" || answer === "y" || answer === "yes") {
849
+ const typed = (await deps.ask(`Repository name [${suggested ?? ""}]: `)).trim();
850
+ const name = typed.length > 0 ? typed : suggested;
851
+ if (name === null) {
852
+ deps.writer.err("The new repository needs a name. Run `flowsprite setup` again and type one, or pass --create <name>.");
853
+ return refuse;
854
+ }
855
+ // Not validated here: the server's own rule refuses a bad name, and its
856
+ // sentence is what the person sees.
857
+ input.create = name;
858
+ return { ok: true, input };
859
+ }
456
860
  const repos = asArray(chosen?.repos);
457
861
  if (repos.length === 0) {
458
862
  // 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;
863
+ // repositories, not that the installation has none. The web's repository
864
+ // dropdown on Salesforce Orgs is the only thing that lists them.
865
+ deps.writer.err(`Flowsprite has not listed any repositories for installation ${installationId} yet. ` +
866
+ "Open this connection's repository list on the Salesforce Orgs page in the app and press Refresh, " +
867
+ "then run `flowsprite setup` again — or answer yes to create a new private one.");
868
+ return refuse;
464
869
  }
465
870
  const picked = await pickOne(deps, REPOSITORY, repos.map((r) => [
466
871
  String(r.fullName ?? ""),
467
872
  `${r.private === true ? "private" : "PUBLIC — will be refused"}, default branch ${String(r.defaultBranch ?? "?")}`,
468
873
  ]));
469
874
  if (picked === null)
470
- return null;
875
+ return refuse;
471
876
  input.repo = picked;
472
877
  }
473
- return input;
878
+ return { ok: true, input };
474
879
  }
475
880
  /**
476
881
  * One of a list, or nothing.
@@ -574,21 +979,68 @@ async function runSync(deps, values, client, rung) {
574
979
  * it is a backstop, not a budget.
575
980
  */
576
981
  const MAX_SETUP_RUNGS = 24;
982
+ /** Which login `setup` would use, in words, or null when there is none (N3 step 1). */
983
+ function loginInWords(deps, origin, values) {
984
+ const stored = readCredentials(deps.env, deps.home)[origin];
985
+ const token = resolveToken({
986
+ flag: typeof values.token === "string" ? values.token : undefined,
987
+ env: deps.env.FLOWSPRITE_TOKEN,
988
+ stored: stored?.token,
989
+ });
990
+ if (token === null)
991
+ return null;
992
+ if (token.source !== "stored")
993
+ return `using the token from ${token.source}`;
994
+ // The stored ids are what the file holds (C4: names are never stored).
995
+ return stored !== undefined && stored.userId.length > 0
996
+ ? `already logged in as ${stored.userId} in workspace ${stored.orgId}`
997
+ : "already logged in with a saved token";
998
+ }
577
999
  async function cmdSetup(deps, origin, values) {
578
1000
  // Doctor-lite: the two things that make every later step fail confusingly.
579
1001
  if (nodeMajor(deps.nodeVersion) < MIN_NODE_MAJOR) {
580
1002
  deps.writer.err(`flowsprite needs node ${MIN_NODE_MAJOR} or newer; this is ${deps.nodeVersion}.`);
581
1003
  return EXIT_FAILED;
582
1004
  }
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) {
1005
+ const steps = createStepTracker(deps.writer);
1006
+ const rc = await runSetup(deps, origin, values, steps);
1007
+ // N14: EVERY non-success exit past the node check ends with how to continue.
1008
+ // No new state: a rerun re-derives its step from setup_status.
1009
+ if (rc !== EXIT_OK)
1010
+ deps.writer.err(rerunHint(rerunLogin(deps, origin, values), steps.current()));
1011
+ return rc;
1012
+ }
1013
+ /** G6: what a rerun would log in with — a saved credential is claimed only when one is stored. */
1014
+ function rerunLogin(deps, origin, values) {
1015
+ // H2: an explicit token wins for THIS run, so a plain rerun would use a
1016
+ // different login (the stored one, or none) — say "supplied" whatever is stored.
1017
+ const flag = typeof values.token === "string" && values.token.length > 0;
1018
+ const env = typeof deps.env.FLOWSPRITE_TOKEN === "string" && deps.env.FLOWSPRITE_TOKEN.length > 0;
1019
+ if (flag || env)
1020
+ return "supplied";
1021
+ return readCredentials(deps.env, deps.home)[origin] !== undefined ? "stored" : "none";
1022
+ }
1023
+ async function runSetup(deps, origin, values, steps) {
1024
+ let noticePrinted = false;
1025
+ const login = loginInWords(deps, origin, values);
1026
+ /** N4: once, before the first thing that waits on the browser — naming only what this run waits on (G9a). */
1027
+ const browserNotice = () => {
1028
+ if (noticePrinted)
1029
+ return;
1030
+ noticePrinted = true;
1031
+ deps.writer.out(browserWaitNotice(login === null ? MAX_EXPIRES_IN_SECONDS : null));
1032
+ };
1033
+ if (login === null) {
587
1034
  deps.writer.out(`Not logged in to ${origin} yet.`);
1035
+ browserNotice();
1036
+ steps.enter(1);
588
1037
  const rc = await cmdLogin(deps, origin, values);
589
1038
  if (rc !== EXIT_OK)
590
1039
  return rc;
591
1040
  }
1041
+ else {
1042
+ steps.skip(1, login);
1043
+ }
592
1044
  const built = clientFor(deps, origin, values);
593
1045
  if (!built.ok)
594
1046
  return EXIT_FAILED;
@@ -610,14 +1062,15 @@ async function cmdSetup(deps, origin, values) {
610
1062
  * even when a volatile field makes two payloads differ.
611
1063
  */
612
1064
  let previousStatus = null;
613
- let previousTool = null;
1065
+ /** C7: the step that just ran, in words — the tool name is MCP vocabulary, not a terminal user's. */
1066
+ let previousStep = null;
614
1067
  for (let rungs = 0;; rungs++) {
615
1068
  const status = await fetchStatus(deps, built.client);
616
1069
  if (!status.ok)
617
1070
  return EXIT_FAILED;
618
1071
  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. ` +
1072
+ if (previousStep !== null && serialized === previousStatus) {
1073
+ deps.writer.err(`${previousStep} reported success, but this workspace's setup status did not change. ` +
621
1074
  "Stopping rather than repeating it: the setup steps are list-wide, so a step that does not " +
622
1075
  "move the checklist would repeat forever. Run `flowsprite status` to see what is left.");
623
1076
  return EXIT_FAILED;
@@ -628,6 +1081,11 @@ async function cmdSetup(deps, origin, values) {
628
1081
  }
629
1082
  previousStatus = serialized;
630
1083
  const step = planNextStep(status.payload);
1084
+ const stepNumber = stepOfRung(step.kind);
1085
+ if (stepNumber !== null)
1086
+ steps.enter(stepNumber);
1087
+ else if (step.kind === "first_sync")
1088
+ steps.finishSteps();
631
1089
  switch (step.kind) {
632
1090
  case "done":
633
1091
  deps.writer.out("Setup is complete.");
@@ -640,12 +1098,20 @@ async function cmdSetup(deps, origin, values) {
640
1098
  case "connect_salesforce":
641
1099
  case "connect_github": {
642
1100
  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));
1101
+ if (step.kind === "connect_salesforce" && deps.isOnPath("sf")) {
1102
+ 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.");
1103
+ }
1104
+ const provider = step.kind === "connect_salesforce" ? "salesforce" : "github";
1105
+ const tool = provider === "salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
1106
+ const args = provider === "salesforce" ? salesforceLinkArgs(values) : {};
1107
+ // The payload just read IS the pre-mint snapshot: the rung is done when a
1108
+ // NEW connection / installation appears in it, and the identity check at
1109
+ // the top of the loop then sees the ladder move on that same new row.
1110
+ browserNotice();
1111
+ const rc = await settleLinkOutcome(deps, built.client, await runLinkStep(sdeps, tool, args, provider, status.payload), provider, status.payload);
646
1112
  if (rc !== EXIT_OK)
647
1113
  return rc;
648
- previousTool = tool;
1114
+ previousStep = provider === "salesforce" ? "Connecting Salesforce" : "Connecting GitHub";
649
1115
  break;
650
1116
  }
651
1117
  default: {
@@ -660,13 +1126,27 @@ async function cmdSetup(deps, origin, values) {
660
1126
  return EXIT_FAILED;
661
1127
  }
662
1128
  deps.writer.out(step.text);
1129
+ if (step.kind === "first_sync") {
1130
+ // A repository still being CREATED already counts as bound, so a re-run
1131
+ // of `setup` (or a wait that ended "gone") can land here while
1132
+ // `sfdc_trigger_sync` would refuse it. Wait for each one first, then
1133
+ // re-read the ladder rather than syncing off this stale payload.
1134
+ const creating = asArray(asObject(status.payload)?.connections).filter((c) => connectionIds.includes(String(c.id)) && c.bootstrapState === "creating");
1135
+ for (const c of creating) {
1136
+ const rc = await reportRepositoryWait(deps, values, built.client, String(c.id));
1137
+ if (rc !== EXIT_OK)
1138
+ return rc;
1139
+ }
1140
+ if (creating.length > 0)
1141
+ continue;
1142
+ }
663
1143
  const rung = { kind: step.kind, connectionIds, status: status.payload };
664
1144
  const rc = step.kind === "bind_repo"
665
- ? await runBind(deps, values, built.client, rung)
1145
+ ? await runBind(deps, origin, values, built.client, rung)
666
1146
  : await runSync(deps, values, built.client, rung);
667
1147
  if (rc !== EXIT_OK)
668
1148
  return rc;
669
- previousTool = step.kind === "bind_repo" ? "setup_bind_repo" : "sfdc_trigger_sync";
1149
+ previousStep = step.kind === "bind_repo" ? "Binding a repository" : "Starting the first sync";
670
1150
  break;
671
1151
  }
672
1152
  }
@@ -715,12 +1195,44 @@ function mcpPrint(deps, origin, values, flag) {
715
1195
  const stored = readCredentials(deps.env, deps.home)[origin];
716
1196
  const mcpEndpoint = stored?.mcpEndpoint ?? `${origin}/api/mcp`;
717
1197
  const tokenEnv = typeof values["token-env"] === "string" ? values["token-env"] : undefined;
718
- let placeholder = TOKEN_PLACEHOLDER;
1198
+ const clients = flag !== undefined ? [flag] : [...CLIENTS];
1199
+ const ctxLite = pathContext(deps);
1200
+ /** Every stdout line of this invocation, with `placeholder` where the bearer goes. */
1201
+ const render = (placeholder) => {
1202
+ const rows = clients.map((client) => {
1203
+ const cfg = configPathFor(client, ctxLite);
1204
+ const command = commandFor(client);
1205
+ return {
1206
+ client,
1207
+ // What `mcp install` WOULD do for this client, on this machine, right now —
1208
+ // derived from the same planner, so `print --json` cannot describe a route
1209
+ // `install` does not take.
1210
+ kind: planInstall(client, {
1211
+ ...ctxLite,
1212
+ origin,
1213
+ mcpEndpoint,
1214
+ token: TOKEN_PLACEHOLDER,
1215
+ tokenEnv,
1216
+ onPath: deps.isOnPath,
1217
+ fileExists: deps.fileExists,
1218
+ }).kind,
1219
+ path: cfg.path,
1220
+ ...(command === null ? {} : { command }),
1221
+ snippet: renderSnippet(client, { url: mcpEndpoint, placeholder, tokenEnv }),
1222
+ };
1223
+ });
1224
+ // ONE client asked for, one object; no client asked for, the array. Nothing
1225
+ // else on stdout either way — that is what makes this parseable.
1226
+ if (values.json === true)
1227
+ return [JSON.stringify(flag !== undefined ? rows[0] : rows, null, 2)];
1228
+ return rows.flatMap((row, n) => [...(n > 0 ? [""] : []), `# ${row.client}`, row.snippet]);
1229
+ };
719
1230
  if (values.reveal === true) {
720
- if (deps.isTTY !== true) {
1231
+ if (deps.reveal === undefined) {
721
1232
  // A SECRET GOES TO A TERMINAL OR NOWHERE. Through a pipe the destination is
722
1233
  // a file, a log or another process's buffer, and none of those is something
723
- // the person watching can decide to clear.
1234
+ // the person watching can decide to clear. The sink exists iff stdout is a
1235
+ // terminal (wiring.ts), so its absence IS the pipe.
724
1236
  deps.writer.err("--reveal prints the real token, so it only runs at a terminal and stdout here is not one. " +
725
1237
  "Re-run it at a terminal, or drop --reveal for a snippet with a placeholder.");
726
1238
  return EXIT_USAGE;
@@ -734,44 +1246,16 @@ function mcpPrint(deps, origin, values, flag) {
734
1246
  deps.writer.err(`Not logged in to ${origin}, so there is no token to reveal. Run \`flowsprite login\`.`);
735
1247
  return EXIT_FAILED;
736
1248
  }
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));
1249
+ // THE ONE CALL SITE OF THE UNREDACTED SINK (FLO-513 AC3). Every stdout line
1250
+ // of a --reveal run — the `# client` headers, the snippet, or the --json
1251
+ // document — goes through it, because the writer would redact the token
1252
+ // back out. The real token enters `render` only on this path.
1253
+ for (const line of render(token.token))
1254
+ deps.reveal(line);
767
1255
  return EXIT_OK;
768
1256
  }
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
- });
1257
+ for (const line of render(TOKEN_PLACEHOLDER))
1258
+ deps.writer.out(line);
775
1259
  return EXIT_OK;
776
1260
  }
777
1261
  async function mcpInstall(deps, origin, values, flag) {