flowsprite 0.1.3 → 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,15 +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
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 { SAME_ACCOUNT_NOTE } from "./messages.js";
10
11
  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
+ 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";
13
15
  import { renderStatus } from "./status.js";
14
16
  import { USAGE } from "./usage.js";
15
17
  /**
@@ -26,7 +28,8 @@ import { USAGE } from "./usage.js";
26
28
  export const EXIT_OK = 0;
27
29
  export const EXIT_FAILED = 1;
28
30
  export const EXIT_USAGE = 2;
29
- const OPTIONS = {
31
+ /** The parser's option table — exported so usage.test.ts can prove USAGE names every flag. */
32
+ export const OPTIONS = {
30
33
  endpoint: { type: "string" },
31
34
  json: { type: "boolean" },
32
35
  token: { type: "string" },
@@ -176,6 +179,7 @@ async function cmdLogin(deps, origin, values) {
176
179
  version: deps.version,
177
180
  ...(values["no-open"] === true || deps.openUrl === undefined ? {} : { openUrl: deps.openUrl }),
178
181
  ...(deps.progress === undefined ? {} : { progress: deps.progress }),
182
+ pending: pendingLoginPort(deps, origin),
179
183
  };
180
184
  // FLO-517: the gh road first, unless opted out. Null means it fell to the
181
185
  // browser login at its own start, having said why in one line.
@@ -201,7 +205,9 @@ async function cmdLogin(deps, origin, values) {
201
205
  savedAt: deps.now(),
202
206
  };
203
207
  // The token is NOT in this sentence, and the writer would redact it anyway.
204
- 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}`;
205
211
  try {
206
212
  writeCredential(deps.env, origin, cred, deps.home);
207
213
  }
@@ -224,37 +230,90 @@ async function cmdLogin(deps, origin, values) {
224
230
  return EXIT_FAILED;
225
231
  }
226
232
  deps.writer.out(who);
233
+ deps.writer.out(SAME_ACCOUNT_NOTE);
227
234
  return EXIT_OK;
228
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
+ }
229
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
+ }
230
279
  const removed = deleteCredential(deps.env, origin, deps.home);
231
280
  deps.writer.out(removed ? `Logged out of ${origin}.` : `There was no stored credential for ${origin}.`);
232
281
  return EXIT_OK;
233
282
  }
234
283
  async function cmdDoctor(deps, origin, values) {
235
284
  const stored = readCredentials(deps.env, deps.home)[origin];
236
- let reachable = false;
237
- let detail = "no credential, so reachability was not tested";
238
- const built = clientForQuiet(deps, origin, values, stored?.token);
239
- if (built !== null) {
240
- const probe = await built.initialize();
241
- reachable = probe.ok;
242
- // `initialize` is the reachability probe (DM2). An `unauthorized` answer
243
- // still proves the endpoint is THERE — it is reported as its own line rather
244
- // than folded into "unreachable", which would send someone to check DNS.
245
- detail = probe.ok ? "reachable" : `${probe.kind}: ${probe.message}`;
246
- }
247
- 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 = {
248
304
  nodeVersion: deps.nodeVersion,
249
305
  nodeOk: nodeMajor(deps.nodeVersion) >= MIN_NODE_MAJOR,
250
306
  endpoint: origin,
251
307
  endpointReachable: reachable,
252
308
  endpointDetail: detail,
253
- hasCredential: stored !== undefined,
309
+ hasCredential: token !== null,
310
+ credentialSource: token?.source ?? null,
311
+ credentialRefused: token !== null && unauthorized,
254
312
  credentialsPath: credentialsPath(deps.env, deps.home),
255
313
  sfOnPath: deps.isOnPath("sf"),
256
314
  ghOnPath: deps.isOnPath("gh"),
257
315
  };
316
+ const report = { ...checks, ok: doctorOk(checks) };
258
317
  if (values.json === true) {
259
318
  deps.writer.out(JSON.stringify(report, null, 2));
260
319
  }
@@ -262,18 +321,8 @@ async function cmdDoctor(deps, origin, values) {
262
321
  for (const line of formatDoctor(report))
263
322
  deps.writer.out(line);
264
323
  }
265
- return report.nodeOk ? EXIT_OK : EXIT_FAILED;
266
- }
267
- /** Like `clientFor` but silent and optional — doctor reports absence, it does not refuse. */
268
- function clientForQuiet(deps, origin, values, storedToken) {
269
- const token = resolveToken({
270
- flag: typeof values.token === "string" ? values.token : undefined,
271
- env: deps.env.FLOWSPRITE_TOKEN,
272
- stored: storedToken,
273
- });
274
- if (token === null)
275
- return null;
276
- 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;
277
326
  }
278
327
  async function fetchStatus(deps, client) {
279
328
  const result = await client.callTool("setup_status", {});
@@ -302,7 +351,14 @@ async function cmdStatus(deps, origin, values) {
302
351
  return EXIT_OK;
303
352
  }
304
353
  function setupDeps(deps, client) {
305
- 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
+ };
306
362
  }
307
363
  async function cmdConnect(deps, origin, values, which) {
308
364
  if (which !== "salesforce" && which !== "github") {
@@ -322,10 +378,48 @@ async function cmdConnect(deps, origin, values, which) {
322
378
  // (card A3, blocked), which would use a token the person already holds
323
379
  // instead of an app install. The Salesforce one is `connectFromSfCli` above.
324
380
  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);
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);
329
423
  }
330
424
  /**
331
425
  * `flowsprite connect salesforce --from-sf-cli <alias>` (FLO-515): read the
@@ -341,11 +435,34 @@ async function cmdConnect(deps, origin, values, which) {
341
435
  * function's source for `fs` / `console` identifiers: the writer and the
342
436
  * tools/call body are its only sinks.
343
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
+ *
344
457
  * Refusals BEFORE the network, in this order: the flag exclusivity (DM5), the
345
458
  * 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).
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).
349
466
  */
350
467
  async function connectFromSfCli(deps, origin, values, alias) {
351
468
  if (values.sandbox === true || typeof values["my-domain"] === "string") {
@@ -373,8 +490,8 @@ async function connectFromSfCli(deps, origin, values, alias) {
373
490
  // construction because SF_ALIAS_RE (checked above) admits no quote, space or
374
491
  // shell metacharacter, so the alias cannot end the command or start another.
375
492
  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]);
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"]);
378
495
  const spawnError = run.error !== undefined && run.error !== null;
379
496
  // On win32 "sf is not installed" arrives two ways, both the install remedy
380
497
  // the PATH probe gives elsewhere: cmd.exe itself could not start (a spawn
@@ -389,30 +506,57 @@ async function connectFromSfCli(deps, origin, values, alias) {
389
506
  return EXIT_USAGE;
390
507
  }
391
508
  if (spawnError) {
392
- deps.writer.err(`sf org display could not be run for '${alias}'. Is that org logged in? ${sfLoginLine(alias)}`);
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);
393
530
  return EXIT_FAILED;
394
531
  }
395
- const parsed = parseSfOrgDisplay(run.stdout ?? "");
396
532
  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.
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.
400
543
  const named = !parsed.ok && parsed.reason === "status" && parsed.message !== "";
401
544
  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)}`);
545
+ deps.writer.err(`sf org auth show-sfdx-auth-url ${what} (exit ${run.status ?? "by signal"}). Is that org logged in? ${sfLoginLine(alias)}`);
403
546
  return EXIT_FAILED;
404
547
  }
405
548
  if (!parsed.ok) {
406
549
  switch (parsed.reason) {
407
550
  case "not_json":
408
- deps.writer.err(`sf org display did not return its JSON. ${sfLoginLine(alias)}`);
551
+ deps.writer.err(`sf org auth show-sfdx-auth-url did not return its JSON. ${sfLoginLine(alias)}`);
409
552
  break;
410
553
  case "status":
411
- deps.writer.err(`sf org display reported ${parsed.message || "an error"}. ${sfLoginLine(alias)}`);
554
+ deps.writer.err(`sf org auth show-sfdx-auth-url reported ${parsed.message || "an error"}. ${sfLoginLine(alias)}`);
412
555
  break;
413
556
  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.
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.
416
560
  deps.writer.err("That sf login cannot be handed over: only the Salesforce CLI's default connected app (PlatformCLI) " +
417
561
  `with a web login works. ${sfRelogin(alias)}`);
418
562
  }
@@ -439,7 +583,7 @@ async function connectFromSfCli(deps, origin, values, alias) {
439
583
  }
440
584
  const doc = projectDoorResult(toolJson(res));
441
585
  if (doc === null) {
442
- deps.writer.err("Unexpected reply from setup_connect_salesforce_cli.");
586
+ deps.writer.err("Flowsprite sent an unexpected reply while connecting that Salesforce CLI login.");
443
587
  return EXIT_FAILED;
444
588
  }
445
589
  if (values.json === true)
@@ -835,21 +979,68 @@ async function runSync(deps, values, client, rung) {
835
979
  * it is a backstop, not a budget.
836
980
  */
837
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
+ }
838
999
  async function cmdSetup(deps, origin, values) {
839
1000
  // Doctor-lite: the two things that make every later step fail confusingly.
840
1001
  if (nodeMajor(deps.nodeVersion) < MIN_NODE_MAJOR) {
841
1002
  deps.writer.err(`flowsprite needs node ${MIN_NODE_MAJOR} or newer; this is ${deps.nodeVersion}.`);
842
1003
  return EXIT_FAILED;
843
1004
  }
844
- if (readCredentials(deps.env, deps.home)[origin] === undefined && resolveToken({
845
- flag: typeof values.token === "string" ? values.token : undefined,
846
- env: deps.env.FLOWSPRITE_TOKEN,
847
- }) === 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) {
848
1034
  deps.writer.out(`Not logged in to ${origin} yet.`);
1035
+ browserNotice();
1036
+ steps.enter(1);
849
1037
  const rc = await cmdLogin(deps, origin, values);
850
1038
  if (rc !== EXIT_OK)
851
1039
  return rc;
852
1040
  }
1041
+ else {
1042
+ steps.skip(1, login);
1043
+ }
853
1044
  const built = clientFor(deps, origin, values);
854
1045
  if (!built.ok)
855
1046
  return EXIT_FAILED;
@@ -871,14 +1062,15 @@ async function cmdSetup(deps, origin, values) {
871
1062
  * even when a volatile field makes two payloads differ.
872
1063
  */
873
1064
  let previousStatus = null;
874
- 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;
875
1067
  for (let rungs = 0;; rungs++) {
876
1068
  const status = await fetchStatus(deps, built.client);
877
1069
  if (!status.ok)
878
1070
  return EXIT_FAILED;
879
1071
  const serialized = JSON.stringify(status.payload) ?? "";
880
- if (previousTool !== null && serialized === previousStatus) {
881
- 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. ` +
882
1074
  "Stopping rather than repeating it: the setup steps are list-wide, so a step that does not " +
883
1075
  "move the checklist would repeat forever. Run `flowsprite status` to see what is left.");
884
1076
  return EXIT_FAILED;
@@ -889,6 +1081,11 @@ async function cmdSetup(deps, origin, values) {
889
1081
  }
890
1082
  previousStatus = serialized;
891
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();
892
1089
  switch (step.kind) {
893
1090
  case "done":
894
1091
  deps.writer.out("Setup is complete.");
@@ -910,10 +1107,11 @@ async function cmdSetup(deps, origin, values) {
910
1107
  // The payload just read IS the pre-mint snapshot: the rung is done when a
911
1108
  // NEW connection / installation appears in it, and the identity check at
912
1109
  // 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);
1110
+ browserNotice();
1111
+ const rc = await settleLinkOutcome(deps, built.client, await runLinkStep(sdeps, tool, args, provider, status.payload), provider, status.payload);
914
1112
  if (rc !== EXIT_OK)
915
1113
  return rc;
916
- previousTool = tool;
1114
+ previousStep = provider === "salesforce" ? "Connecting Salesforce" : "Connecting GitHub";
917
1115
  break;
918
1116
  }
919
1117
  default: {
@@ -948,7 +1146,7 @@ async function cmdSetup(deps, origin, values) {
948
1146
  : await runSync(deps, values, built.client, rung);
949
1147
  if (rc !== EXIT_OK)
950
1148
  return rc;
951
- previousTool = step.kind === "bind_repo" ? "setup_bind_repo" : "sfdc_trigger_sync";
1149
+ previousStep = step.kind === "bind_repo" ? "Binding a repository" : "Starting the first sync";
952
1150
  break;
953
1151
  }
954
1152
  }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { approvePageHref, MAX_EXPIRES_IN_SECONDS, USER_CODE_RE } from "./ghLogin.js";
5
6
  export const CREDENTIALS_FILE = "credentials.json";
6
7
  const DIR_MODE = 0o700;
7
8
  const FILE_MODE = 0o600;
@@ -175,6 +176,113 @@ export function deleteCredential(env, origin, home) {
175
176
  writeStore(env, store, home);
176
177
  return true;
177
178
  }
179
+ // ------------------------------------------------------- the pending login (C3)
180
+ export const PENDING_LOGIN_FILE = "pending-login.json";
181
+ export function pendingLoginPath(env, home) {
182
+ return join(credentialsDir(env, home), PENDING_LOGIN_FILE);
183
+ }
184
+ const nonEmpty = (v) => typeof v === "string" && v.length > 0;
185
+ /**
186
+ * The pending login for `origin`, or null. TOLERANT like `readCredentials`: a
187
+ * missing, unreadable, garbled or wrong-shaped file, or one for another origin,
188
+ * reads as "none". An EXPIRED entry for this origin is deleted as it is read.
189
+ *
190
+ * Held to the WIRE's grammar (eecc9d6f G5): the user code is `USER_CODE_RE`'s
191
+ * shape and the open target must pass `approvePageHref` — exactly this origin's
192
+ * approve page, normalised — so a hand-edited file can make `login` print or
193
+ * open nothing the start route could not have sent. The interval must be finite.
194
+ */
195
+ export function readPendingLogin(env, origin, now, home) {
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(readFileSync(pendingLoginPath(env, home), "utf8"));
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
204
+ return null;
205
+ const v = parsed;
206
+ if (v.origin !== origin || !nonEmpty(v.deviceCode))
207
+ return null;
208
+ if (typeof v.expiresAtMs !== "number" || !Number.isFinite(v.expiresAtMs))
209
+ return null;
210
+ if (typeof v.savedAtMs !== "number" || !Number.isFinite(v.savedAtMs))
211
+ return null;
212
+ if (typeof v.interval !== "number" || !Number.isFinite(v.interval) || !(v.interval > 0))
213
+ return null;
214
+ const userCode = nonEmpty(v.userCode) ? v.userCode : null;
215
+ const rawTarget = nonEmpty(v.openTarget) ? v.openTarget : null;
216
+ if ((userCode === null) !== (rawTarget === null))
217
+ return null;
218
+ let openTarget = null;
219
+ if (userCode !== null) {
220
+ if (!USER_CODE_RE.test(userCode))
221
+ return null;
222
+ openTarget = approvePageHref(rawTarget, origin, userCode);
223
+ if (openTarget === null)
224
+ return null;
225
+ }
226
+ // H1/H6: no entry is live past min(its write, now) + a code's TTL. A savedAt
227
+ // LATER than now is not rejected — a backward clock step (NTP, resume from
228
+ // suspend) must not abandon a live, maybe already approved, code — it is read
229
+ // as now. So a future-dated forged file lives at most now + 900 s per read, and
230
+ // is ended by the poll answer anyway (`expired_token`/`invalid_*` clear it).
231
+ const effectiveSavedAt = Math.min(v.savedAtMs, now);
232
+ const expiresAtMs = Math.min(v.expiresAtMs, effectiveSavedAt + MAX_EXPIRES_IN_SECONDS * 1000);
233
+ if (expiresAtMs <= now) {
234
+ try {
235
+ clearPendingLogin(env, origin, home, v.deviceCode);
236
+ }
237
+ catch {
238
+ // An undeletable expired entry still reads as none; the next write replaces it.
239
+ }
240
+ return null;
241
+ }
242
+ return { origin, deviceCode: v.deviceCode, userCode, openTarget, expiresAtMs, savedAtMs: v.savedAtMs, interval: v.interval };
243
+ }
244
+ /** Throws on a write failure; the caller (`login.ts`) treats that as "not persisted", never as a failed login. */
245
+ export function writePendingLogin(env, entry, home) {
246
+ const dir = credentialsDir(env, home);
247
+ mkdirSync(dir, { recursive: true, mode: DIR_MODE });
248
+ chmodSync(dir, DIR_MODE);
249
+ writeFileAtomic(pendingLoginPath(env, home), `${JSON.stringify(entry)}\n`, FILE_MODE);
250
+ }
251
+ /**
252
+ * Delete the pending login if it is `origin`'s — or if it cannot be read as
253
+ * anyone's (garbled), since nothing can resume it. Another origin's entry is
254
+ * left alone. `rmSync` on a symlink removes the link, never its target.
255
+ *
256
+ * eecc9d6f G2: given a `deviceCode`, ONLY an entry holding exactly that code is
257
+ * deleted (a garbled file is then left for the next write to replace), so a
258
+ * stale run that ends cannot delete the entry a NEWER run saved. `logout` calls
259
+ * it without one: logging out drops whatever is pending for the origin.
260
+ */
261
+ // ponytail: read-then-unlink, not an atomic compare-and-delete — node builtins have
262
+ // none across processes. The window is the microseconds between the read and the
263
+ // `rmSync`, open only to another `flowsprite` run of the SAME user saving a newer
264
+ // entry in exactly that gap; the loser just starts a fresh login next time. A
265
+ // lock file (O_EXCL) around save/clear is the upgrade if that ever matters.
266
+ export function clearPendingLogin(env, origin, home, deviceCode) {
267
+ const path = pendingLoginPath(env, home);
268
+ let stored;
269
+ try {
270
+ stored = JSON.parse(readFileSync(path, "utf8"));
271
+ }
272
+ catch (err) {
273
+ if (err.code === "ENOENT")
274
+ return;
275
+ stored = null;
276
+ }
277
+ const readable = typeof stored === "object" && stored !== null && typeof stored.origin === "string";
278
+ if (deviceCode !== undefined) {
279
+ if (readable && stored.origin === origin && stored.deviceCode === deviceCode)
280
+ rmSync(path, { force: true });
281
+ return;
282
+ }
283
+ if (!readable || stored.origin === origin)
284
+ rmSync(path, { force: true });
285
+ }
178
286
  /**
179
287
  * Which token this invocation uses: an explicit `--token`, else
180
288
  * `FLOWSPRITE_TOKEN`, else the credential stored for this origin. The stored one