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/doctor.js CHANGED
@@ -8,6 +8,14 @@ import { spawnSync } from "node:child_process";
8
8
  */
9
9
  /** The major this package declares in `engines` — one authority, read by both halves. */
10
10
  export const MIN_NODE_MAJOR = 20;
11
+ /**
12
+ * THE FAIL SET (card eecc9d6f N16): node too old, endpoint unreachable, no
13
+ * credential or a refused one. `sf`/`gh` absence is a NOTE, never a failure —
14
+ * neither is needed for the browser road.
15
+ */
16
+ export function doctorOk(report) {
17
+ return report.nodeOk && report.endpointReachable && report.hasCredential && !report.credentialRefused;
18
+ }
11
19
  export function nodeMajor(version) {
12
20
  const m = /^v?(\d+)\./.exec(version);
13
21
  return m ? Number(m[1]) : 0;
@@ -17,9 +25,15 @@ export function formatDoctor(report) {
17
25
  return [
18
26
  `${mark(report.nodeOk)} node ${report.nodeVersion} (needs >= ${MIN_NODE_MAJOR})`,
19
27
  `${mark(report.endpointReachable)} endpoint ${report.endpoint} — ${report.endpointDetail}`,
20
- `${mark(report.hasCredential)} credential for ${report.endpoint} ${report.hasCredential ? "found" : "not found — run `flowsprite login`"} (${report.credentialsPath})`,
21
- `${report.sfOnPath ? "ok " : "note"} sf on PATH: ${report.sfOnPath ? "yes" : "no (only needed for Salesforce CLI work)"}`,
22
- `${report.ghOnPath ? "ok " : "note"} gh on PATH: ${report.ghOnPath ? "yes" : "no (only needed for GitHub CLI work)"}`,
28
+ !report.hasCredential
29
+ ? `FAIL credential for ${report.endpoint} not found (${report.credentialsPath}) — run \`flowsprite setup\` (logging in is its step 1), or \`flowsprite login\` alone`
30
+ : report.credentialRefused
31
+ ? `FAIL credential for ${report.endpoint} refused by the endpoint (HTTP 401) — run \`flowsprite login\` for a new one`
32
+ : `ok credential for ${report.endpoint} found (${report.credentialSource === "stored" || report.credentialSource === null
33
+ ? report.credentialsPath
34
+ : `from ${report.credentialSource}, not stored`})`,
35
+ `${report.sfOnPath ? "ok " : "note"} sf on PATH: ${report.sfOnPath ? "yes" : "no — needed for 'connect salesforce --from-sf-cli'. Install: npm install -g @salesforce/cli (macOS: brew install --cask salesforce-cli); then sf org login web --alias <name>."}`,
36
+ `${report.ghOnPath ? "ok " : "note"} gh on PATH: ${report.ghOnPath ? "yes" : "no — needed for a no-browser 'login'. Install: gh (e.g. brew install gh); then gh auth login -s user:email."}`,
23
37
  ];
24
38
  }
25
39
  /**
package/dist/ghLogin.js CHANGED
@@ -52,7 +52,8 @@ const FLOWSPRITE_TOKEN_RE = /^fp_(?:user|live)_[A-Za-z0-9_-]{32}$/;
52
52
  const ORG_ID_RE = /^org_[A-Za-z0-9]+$/;
53
53
  const USER_ID_RE = /^user_[A-Za-z0-9]+$/;
54
54
  const DEVICE_CODE_RE = /^[0-9a-f]{64}$/;
55
- const USER_CODE_RE = /^[A-Z]{4}-[A-Z]{4}$/;
55
+ /** The user code's shape on BOTH roads and in `pending-login.json` (eecc9d6f G1/G5). */
56
+ export const USER_CODE_RE = /^[A-Z]{4}-[A-Z]{4}$/;
56
57
  /** The approve page's path on the endpoint; the only thing a 409 may ask the CLI to open. */
57
58
  const APPROVE_PATH = "/cli/auth";
58
59
  /** The device-code TTL the endpoint documents (`expires_in: 900`) — and the ceiling. */
@@ -70,9 +71,15 @@ export function clampExpiresIn(claimed) {
70
71
  ? Math.min(MAX_EXPIRES_IN_SECONDS, Math.max(1, claimed))
71
72
  : MAX_EXPIRES_IN_SECONDS;
72
73
  }
73
- /** The poll interval as the loop may trust it: a positive number, else the documented 5. ONE rule for both roads. */
74
- export function intervalSeconds(claimed) {
75
- return typeof claimed === "number" && claimed > 0 ? claimed : 5;
74
+ /**
75
+ * The poll interval as the loop may trust it — ONE rule for both roads and the
76
+ * resumed `pending-login.json` (eecc9d6f G3): a finite positive number, else the
77
+ * documented 5; never below 1 s (a tiny value is a busy loop) and never above
78
+ * `maxSeconds`, the code's remaining lifetime (a huge value would sleep past it).
79
+ */
80
+ export function intervalSeconds(claimed, maxSeconds = MAX_EXPIRES_IN_SECONDS) {
81
+ const base = typeof claimed === "number" && Number.isFinite(claimed) && claimed > 0 ? Math.max(1, claimed) : 5;
82
+ return Math.max(1, Math.min(base, maxSeconds));
76
83
  }
77
84
  const STOP_CODES = new Set([
78
85
  "in_progress",
@@ -103,7 +110,7 @@ const STOP_SERVER_ERROR = { kind: "stop", code: "server_error" };
103
110
  * bearer (fix round 2 F6). The href is asserted to be one of the two exact
104
111
  * forms, so nothing else can ever reach a sink.
105
112
  */
106
- function approvePageHref(target, origin, userCode) {
113
+ export function approvePageHref(target, origin, userCode) {
107
114
  if (typeof target !== "string")
108
115
  return null;
109
116
  let url;
@@ -144,7 +151,7 @@ export function classifyBootstrapAnswer(status, body, origin) {
144
151
  }
145
152
  const deviceCode = body.device_code;
146
153
  const expiresIn = clampExpiresIn(body.expires_in);
147
- const interval = intervalSeconds(body.interval);
154
+ const interval = intervalSeconds(body.interval, expiresIn);
148
155
  if (status === 202) {
149
156
  if (typeof deviceCode !== "string" || !DEVICE_CODE_RE.test(deviceCode))
150
157
  return STOP_SERVER_ERROR;
package/dist/login.js CHANGED
@@ -1,5 +1,6 @@
1
- import { clampExpiresIn, classifyBootstrapAnswer, intervalSeconds, readGhToken, } from "./ghLogin.js";
2
- import { APPROVER_BLOCKED_NOTE, GH_DOOR_REMEDIES, GH_ROAD, inProgressRemedy, isLoginFailureKind, LOGIN_REMEDIES, POLL_CONTINUE_KINDS, timeLeftLine, } from "./messages.js";
1
+ import { approvePageHref, clampExpiresIn, classifyBootstrapAnswer, intervalSeconds, readGhToken, USER_CODE_RE, } from "./ghLogin.js";
2
+ import { APPROVER_BLOCKED_NOTE, clock, CODE_ENDING_KINDS, GH_DOOR_REMEDIES, GH_ROAD, inProgressRemedy, isLoginFailureKind, LOGIN_REMEDIES, OPEN_TARGET_REFUSED, POLL_CONTINUE_KINDS, RESUMING_LINE, SEED_RESUME_HINT, stillWaitingLine, timeLeftLine, waitHintLine, } from "./messages.js";
3
+ import { printable } from "./redact.js";
3
4
  /**
4
5
  * RFC 8628 device-code login against `/api/cli/auth/{start,poll}`.
5
6
  *
@@ -27,10 +28,42 @@ import { APPROVER_BLOCKED_NOTE, GH_DOOR_REMEDIES, GH_ROAD, inProgressRemedy, isL
27
28
  */
28
29
  /** Seconds added to the poll interval each time the server says `slow_down`. */
29
30
  const SLOW_DOWN_STEP_SECONDS = 5;
30
- /** `m:ss`, for the countdown. */
31
- function clock(seconds) {
32
- const whole = Math.max(0, Math.floor(seconds));
33
- return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
31
+ /** How often, in SLEPT seconds, a run with no status line re-prints the link (C2). */
32
+ const REPRINT_EVERY_SECONDS = 60;
33
+ /** The longest server-supplied display string the success line takes (an email's RFC 5321 cap). */
34
+ const DISPLAY_MAX = 254;
35
+ /**
36
+ * C4 — a display string from the poll answer, or undefined (the id is shown
37
+ * instead). It is SERVER text spliced into a CLI sentence, so it is scrubbed of
38
+ * every control character here (a newline in it would forge a line) — the
39
+ * writer's per-line scrub keeps newlines, so it is not enough on its own.
40
+ *
41
+ * eecc9d6f G4/H5 — THE RULE, not a list: every code point Unicode marks
42
+ * `Default_Ignorable_Code_Point` (renders as nothing: ZWSP/ZWJ, bidi marks, all
43
+ * variation selectors incl. Mongolian FVS, tag characters, the soft hyphen, the
44
+ * combining grapheme joiner…), every format character (`\p{Cf}`), and the line
45
+ * and paragraph separators U+2028/2029 are REMOVED, before the writer redacts.
46
+ * Removed, not spaced: a token split by an invisible character would otherwise
47
+ * reach the writer as two halves its redactor (`redact.ts`, a run of the token
48
+ * alphabet) matches only the first of; joined, the writer redacts it whole.
49
+ * Visible combining marks (`\p{Mn}` outside that property — accents in real
50
+ * names) are kept.
51
+ */
52
+ const INVISIBLE = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2028\u2029]/gu;
53
+ function displayText(value) {
54
+ if (typeof value !== "string")
55
+ return undefined;
56
+ const text = printable(value.replace(INVISIBLE, "")).trim();
57
+ return text.length > 0 && text.length <= DISPLAY_MAX ? text : undefined;
58
+ }
59
+ /** A pending-port call that must never fail the login (C3). */
60
+ function quietly(call) {
61
+ try {
62
+ call();
63
+ }
64
+ catch {
65
+ // Persistence is a convenience: an unwritable config dir costs the resume, not the login.
66
+ }
34
67
  }
35
68
  function failure(kind) {
36
69
  return { ok: false, kind, message: LOGIN_REMEDIES[kind] };
@@ -90,31 +123,38 @@ async function startDeviceCode(deps) {
90
123
  const userCode = start.body.user_code;
91
124
  const completeUri = start.body.verification_uri_complete;
92
125
  const fallbackUri = start.body.verification_uri;
93
- if (typeof deviceCode !== "string" || typeof userCode !== "string") {
126
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || !USER_CODE_RE.test(userCode)) {
94
127
  return failure("server_error");
95
128
  }
96
- const openTarget = typeof completeUri === "string"
97
- ? completeUri
98
- : typeof fallbackUri === "string"
99
- ? fallbackUri
100
- : null;
129
+ // eecc9d6f G1: the ONE string this CLI prints and hands to `openUrl` is held to
130
+ // the gh road's rule (`approvePageHref`): exactly this origin's `/cli/auth`,
131
+ // optionally `?code=<this user code>`, normalised. A foreign host, `file:`,
132
+ // `javascript:`, userinfo, or an option-shaped `-a…` (macOS `open` reads a
133
+ // `-a`/`-b` option out of a single argv element) never reaches a sink.
134
+ const openTarget = approvePageHref(completeUri ?? fallbackUri, deps.origin, userCode);
101
135
  if (openTarget === null)
102
- return failure("server_error");
136
+ return { ok: false, kind: "server_error", message: OPEN_TARGET_REFUSED };
103
137
  // The device code's own lifetime bounds the loop, so a server that answers
104
138
  // `authorization_pending` forever still ends — with the same sentence a real
105
139
  // `expired_token` gets, because the window really has passed. CLAMPED TO THE
106
140
  // REAL TTL (`clampExpiresIn`): a number this CLI reads off a response is what
107
141
  // stands between a bad value and a terminal that polls for a year.
108
142
  const expiresIn = clampExpiresIn(start.body.expires_in);
109
- const interval = intervalSeconds(start.body.interval);
143
+ const interval = intervalSeconds(start.body.interval, expiresIn);
110
144
  return { deviceCode, userCode, openTarget, expiresIn, interval };
111
145
  }
112
- /** The three lines a person acts on, plus the best-effort browser open. */
113
- function announceBrowser(deps, payload) {
114
- deps.writer.out(`Open ${payload.openTarget} and confirm the code ${payload.userCode}`);
146
+ /**
147
+ * The lines a person acts on, plus the best-effort browser open. A RESUMED login
148
+ * is announced again but not re-opened (`open: false`): the browser was opened
149
+ * by the run that started it, and the approval may already be done.
150
+ */
151
+ function announceBrowser(deps, payload, open = true) {
152
+ // printable(): defence in depth — both values were already held to a grammar.
153
+ deps.writer.out(`Open ${printable(payload.openTarget)} and confirm the code ${printable(payload.userCode)}`);
115
154
  deps.writer.out("Waiting for you to approve this login...");
116
155
  deps.writer.out(timeLeftLine(payload.expiresIn));
117
- if (deps.openUrl) {
156
+ deps.writer.out(waitHintLine(payload.expiresIn));
157
+ if (open && deps.openUrl) {
118
158
  try {
119
159
  deps.openUrl(payload.openTarget);
120
160
  }
@@ -124,15 +164,19 @@ function announceBrowser(deps, payload) {
124
164
  }
125
165
  }
126
166
  /** Poll `/api/cli/auth/poll` until the code is approved and minted, refused, or expired. */
127
- async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
167
+ async function pollLoop(deps, { deviceCode, expiresIn, interval, openTarget }) {
128
168
  let intervalSeconds = interval;
129
169
  let waitedSeconds = 0;
130
170
  let approverNotePrinted = false;
171
+ let reprintAt = REPRINT_EVERY_SECONDS;
131
172
  for (;;) {
132
173
  if (waitedSeconds >= expiresIn)
133
174
  return failure("expired_token");
134
- await deps.sleep(intervalSeconds * 1000);
135
- waitedSeconds += intervalSeconds;
175
+ // G3: never sleep past the code's end — the last nap is cut to the time left,
176
+ // so the slept total is at most `expiresIn` whatever `slow_down` added.
177
+ const nap = Math.min(intervalSeconds, expiresIn - waitedSeconds);
178
+ await deps.sleep(nap * 1000);
179
+ waitedSeconds += nap;
136
180
  const poll = await postJson(deps, `${deps.origin}/api/cli/auth/poll`, {
137
181
  device_code: deviceCode,
138
182
  });
@@ -149,7 +193,17 @@ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
149
193
  typeof mcpEndpoint !== "string") {
150
194
  return failure("server_error");
151
195
  }
152
- return { ok: true, token, orgId, userId, mcpEndpoint };
196
+ const userEmail = displayText(poll.body.user_email);
197
+ const orgName = displayText(poll.body.org_name);
198
+ return {
199
+ ok: true,
200
+ token,
201
+ orgId,
202
+ userId,
203
+ mcpEndpoint,
204
+ ...(userEmail === undefined ? {} : { userEmail }),
205
+ ...(orgName === undefined ? {} : { orgName }),
206
+ };
153
207
  }
154
208
  const kind = poll.body.error;
155
209
  if (typeof kind === "string" && POLL_CONTINUE_KINDS.has(kind)) {
@@ -166,7 +220,15 @@ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
166
220
  // ponytail: time left is expires_in minus the SUM OF SLEEPS, so fetch latency
167
221
  // and a suspended laptop are not counted and it can overstate; thread `now`
168
222
  // into LoginDeps for a wall-clock countdown if that ever reads wrong.
169
- deps.progress?.(`Still waiting — ${clock(expiresIn - waitedSeconds)} left`);
223
+ if (deps.progress) {
224
+ deps.progress(`Still waiting — ${clock(expiresIn - waitedSeconds)} left`);
225
+ }
226
+ else if (waitedSeconds >= reprintAt) {
227
+ // C2: no status line (a pipe, an agent shell) — one line per minute of
228
+ // waiting, carrying the link, so it never scrolls out of reach.
229
+ deps.writer.out(stillWaitingLine(expiresIn - waitedSeconds, openTarget));
230
+ reprintAt = (Math.floor(waitedSeconds / REPRINT_EVERY_SECONDS) + 1) * REPRINT_EVERY_SECONDS;
231
+ }
170
232
  if (kind === "slow_down") {
171
233
  // The server has ALREADY raised its own required interval by this much;
172
234
  // raising ours to match is what stops the next poll earning another one.
@@ -178,12 +240,65 @@ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
178
240
  return failure(refusalKind(poll.body));
179
241
  }
180
242
  }
243
+ /**
244
+ * C3 — poll, then settle the saved pending login. Success and every answer in
245
+ * `CODE_ENDING_KINDS` clear it (G10); a transient answer keeps it for the next
246
+ * `flowsprite login`. The clear names THIS run's device code (G2), so a stale
247
+ * run never deletes the entry a newer run saved.
248
+ */
249
+ async function pollAndSettle(deps, pending) {
250
+ const result = await pollLoop(deps, pending);
251
+ if (result.ok || CODE_ENDING_KINDS.has(result.kind))
252
+ quietly(() => deps.pending?.clear(pending.deviceCode));
253
+ return result;
254
+ }
255
+ function savePending(deps, pending) {
256
+ quietly(() => deps.pending?.save(pending));
257
+ }
258
+ /**
259
+ * C3 — resume an unexpired login an earlier run announced, or null when none is
260
+ * saved (or the port fails). A resumed login calls NEITHER `/api/cli/auth/start`
261
+ * NOR the gh door. The saved numbers are re-clamped by the SAME rules the wire
262
+ * values get (G3): the wait is at most the seconds left (itself at most 900),
263
+ * and the interval is 1 s to that — so the slept total of a resumed login never
264
+ * exceeds the time the file says is left, capped at the code's 900 s TTL.
265
+ */
266
+ async function resumePending(deps) {
267
+ let saved;
268
+ try {
269
+ saved = deps.pending?.load() ?? null;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ if (saved === null)
275
+ return null;
276
+ const pending = {
277
+ ...saved,
278
+ expiresIn: clampExpiresIn(saved.expiresIn),
279
+ interval: intervalSeconds(saved.interval, clampExpiresIn(saved.expiresIn)),
280
+ };
281
+ deps.writer.out(RESUMING_LINE);
282
+ if (pending.userCode !== undefined && pending.openTarget !== undefined) {
283
+ announceBrowser(deps, { userCode: pending.userCode, openTarget: pending.openTarget, expiresIn: pending.expiresIn }, false);
284
+ }
285
+ else {
286
+ // G9b: the gh road's 202 — the workspace is being set up; nothing to approve.
287
+ deps.writer.out(GH_ROAD.seedPending);
288
+ deps.writer.out(SEED_RESUME_HINT);
289
+ }
290
+ return pollAndSettle(deps, pending);
291
+ }
181
292
  export async function deviceCodeLogin(deps) {
293
+ const resumed = await resumePending(deps);
294
+ if (resumed !== null)
295
+ return resumed;
182
296
  const started = await startDeviceCode(deps);
183
297
  if ("ok" in started)
184
298
  return started;
299
+ savePending(deps, started);
185
300
  announceBrowser(deps, started);
186
- return pollLoop(deps, started);
301
+ return pollAndSettle(deps, started);
187
302
  }
188
303
  /**
189
304
  * `gh auth token`, read by shape. Null on every way it can fail — a non-zero
@@ -244,6 +359,11 @@ function stop(answer) {
244
359
  * (`redact.ts`) is the second net, not the first.
245
360
  */
246
361
  export async function ghCliLogin(deps) {
362
+ // C3: a login an earlier run left waiting is finished first — before gh is
363
+ // spawned or the door is asked, either of which would start a second one.
364
+ const resumed = await resumePending(deps);
365
+ if (resumed !== null)
366
+ return resumed;
247
367
  if (!deps.isOnPath("gh")) {
248
368
  deps.writer.out(GH_ROAD.notFound);
249
369
  return null;
@@ -267,8 +387,9 @@ export async function ghCliLogin(deps) {
267
387
  case "token":
268
388
  return { ok: true, token: answer.token, orgId: answer.orgId, userId: answer.userId, mcpEndpoint: answer.mcpEndpoint };
269
389
  case "poll":
390
+ savePending(deps, answer);
270
391
  deps.writer.out(GH_ROAD.seedPending);
271
- return pollLoop(deps, answer);
392
+ return pollAndSettle(deps, answer);
272
393
  case "browser":
273
394
  if (answer.reason === "github_scope_missing") {
274
395
  deps.writer.out(GH_ROAD.scopeMissing);
@@ -277,8 +398,9 @@ export async function ghCliLogin(deps) {
277
398
  else {
278
399
  deps.writer.out(GH_ROAD.browserRequired);
279
400
  }
401
+ savePending(deps, answer);
280
402
  announceBrowser(deps, answer);
281
- return pollLoop(deps, answer);
403
+ return pollAndSettle(deps, answer);
282
404
  default:
283
405
  return stop(answer);
284
406
  }
package/dist/mcpClient.js CHANGED
@@ -1,4 +1,24 @@
1
1
  import { redact } from "./redact.js";
2
+ /**
3
+ * The closed set of `structuredContent.error.code` values the endpoint can send on a refused
4
+ * `tools/call` (server side: `lib/mcpErrorEnvelope.ts`, `MCP_ERROR_CODES`). Duplicated here because
5
+ * this package builds standalone from `src/` and cannot import `lib/`; a root-tsc-checked test
6
+ * pins the two lists equal.
7
+ */
8
+ export const TOOL_ERROR_CODES = [
9
+ "not_found",
10
+ "unauthorized",
11
+ "forbidden",
12
+ "invalid_argument",
13
+ "quota_exceeded",
14
+ "conflict",
15
+ "expired",
16
+ "not_ready",
17
+ "storage_failed",
18
+ "vendor_failed",
19
+ "cancelled",
20
+ "internal",
21
+ ];
2
22
  /**
3
23
  * EVERY message this module produces goes through `redact` before it is
4
24
  * returned. The bearer lives only in a header, so no message SHOULD carry it —
@@ -14,6 +34,19 @@ function fail(kind, message, code) {
14
34
  const safe = redact(message);
15
35
  return code === undefined ? { ok: false, kind, message: safe } : { ok: false, kind, message: safe, code };
16
36
  }
37
+ /** The envelope's code + retryable, or undefined unless BOTH are well-formed (never a partial read). */
38
+ function readToolError(structured) {
39
+ if (typeof structured !== "object" || structured === null || Array.isArray(structured))
40
+ return undefined;
41
+ const error = structured.error;
42
+ if (typeof error !== "object" || error === null || Array.isArray(error))
43
+ return undefined;
44
+ const { code, retryable } = error;
45
+ if (typeof retryable !== "boolean")
46
+ return undefined;
47
+ const known = TOOL_ERROR_CODES.find((c) => c === code);
48
+ return known === undefined ? undefined : { code: known, retryable };
49
+ }
17
50
  export function createMcpClient(deps) {
18
51
  const url = `${deps.origin}/api/mcp`;
19
52
  let nextId = 0;
@@ -70,7 +103,9 @@ export function createMcpClient(deps) {
70
103
  // The tool's own refusal text is the whole value of this branch — an
71
104
  // `isError` result with no text would otherwise be an empty message.
72
105
  const text = content.find((c) => typeof c.text === "string")?.text;
73
- return fail("tool_error", text ?? `${method} failed and said nothing about why.`);
106
+ const failure = fail("tool_error", text ?? `${method} failed and said nothing about why.`);
107
+ const toolError = readToolError(r.structuredContent);
108
+ return toolError === undefined ? failure : { ...failure, toolError };
74
109
  }
75
110
  return r.structuredContent === undefined
76
111
  ? { ok: true, content }
package/dist/messages.js CHANGED
@@ -20,10 +20,52 @@ export const LOGIN_REMEDIES = {
20
20
  * minute", not 2), and anything under a minute says so rather than "0 minutes".
21
21
  */
22
22
  export function timeLeftLine(seconds) {
23
+ return `This code is good for ${minutesSpan(seconds)}. Press Ctrl-C to stop waiting.`;
24
+ }
25
+ /** Whole minutes, rounded DOWN (see `timeLeftLine`), or "less than a minute". */
26
+ export function minutesSpan(seconds) {
23
27
  const minutes = Math.floor(seconds / 60);
24
- const span = minutes < 1 ? "less than a minute" : `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
25
- return `This code is good for ${span}. Press Ctrl-C to stop waiting.`;
28
+ return minutes < 1 ? "less than a minute" : `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
29
+ }
30
+ /** `m:ss`, for a countdown. Negative and fractional seconds fold to whole, non-negative ones. */
31
+ export function clock(seconds) {
32
+ const whole = Math.max(0, Math.floor(seconds));
33
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
34
+ }
35
+ /**
36
+ * Card eecc9d6f C2 — printed once after the link is announced. An agent shell
37
+ * (measured: Claude Code backgrounds a command at 120 s) can stop this process
38
+ * long before the code expires; the device code is kept on disk
39
+ * (`pending-login.json`) so the next `flowsprite login` resumes it instead of
40
+ * starting over.
41
+ */
42
+ export function waitHintLine(seconds) {
43
+ return `This waits up to ${minutesSpan(seconds)} for you to approve in the browser. If your terminal or tool stops it sooner, run \`flowsprite login\` again — it picks up the same login.`;
44
+ }
45
+ /**
46
+ * C2 — re-printed every 60 s of waiting when there is no terminal status line
47
+ * (a pipe, an agent shell), so the link never scrolls out of reach. `openTarget`
48
+ * is absent on the gh road's 202, which has no page to open.
49
+ */
50
+ export function stillWaitingLine(secondsLeft, openTarget) {
51
+ return `Still waiting — ${clock(secondsLeft)} left.${openTarget === undefined ? "" : ` Open ${openTarget}`}`;
26
52
  }
53
+ /**
54
+ * eecc9d6f G1 — `/api/cli/auth/start` answered with a page that is not THIS
55
+ * endpoint's approve page (another host, another scheme, a malformed or
56
+ * option-shaped string). Nothing is printed, opened or saved; the commonest
57
+ * honest cause is the same host spelled two ways (127.0.0.1 vs localhost).
58
+ */
59
+ export const OPEN_TARGET_REFUSED = "Flowsprite answered with a sign-in link for a different host than the endpoint this terminal is using, so it was not opened or printed. If you passed --endpoint, use the exact host the server uses (127.0.0.1 and localhost count as different hosts), then run `flowsprite login` again.";
60
+ /**
61
+ * eecc9d6f G9b — printed when a login is resumed on the gh road's 202 (the
62
+ * workspace was being set up): nothing waits on a browser there.
63
+ */
64
+ export const SEED_RESUME_HINT = "If your terminal or tool stops this sooner, run `flowsprite login` again — it picks up the same login.";
65
+ /** C3 — printed when `flowsprite login` picks up a login an earlier run started. */
66
+ export const RESUMING_LINE = "Picking up the login this terminal started earlier.";
67
+ /** C5 — printed after the success line: the setup links are bound to the account AND the workspace. */
68
+ export const SAME_ACCOUNT_NOTE = "Setup links this terminal prints open only in a browser signed in to Flowsprite as this same account, in this same workspace.";
27
69
  /**
28
70
  * FLO-509 — printed once per login when a pending poll carries
29
71
  * `approver_blocked: true`.
@@ -37,6 +79,26 @@ export function timeLeftLine(seconds) {
37
79
  export const APPROVER_BLOCKED_NOTE = "Someone who opened this code in the browser could not approve it; that page says why and what to do. This terminal keeps waiting and logs in as soon as the code is approved.";
38
80
  /** The wire values that mean "keep polling", kept apart from the terminal ones above. */
39
81
  export const POLL_CONTINUE_KINDS = new Set(["authorization_pending", "slow_down"]);
82
+ /**
83
+ * eecc9d6f G10/H1 — the poll answers that END a device code, and so clear its
84
+ * saved pending login. The rule: the server said THIS code can no longer be
85
+ * approved or exchanged — including `invalid_request`/`invalid_argument`, the
86
+ * wire's two `invalid_*` kinds (`WIRE_KINDS` below), which say the code itself
87
+ * is unusable. Only the four transient answers keep it: `transport`,
88
+ * `rate_limited`, `server_error`, `server_misconfigured` — and an entry is never
89
+ * live past its write + 900 s (`readPendingLogin`).
90
+ */
91
+ export const CODE_ENDING_KINDS = new Set([
92
+ "expired_token",
93
+ "access_denied",
94
+ "no_grant",
95
+ "not_member",
96
+ "suspended",
97
+ "revoked",
98
+ "ambiguous_grant",
99
+ "invalid_request",
100
+ "invalid_argument",
101
+ ]);
40
102
  /**
41
103
  * The kinds that can arrive FROM THE WIRE — a strict subset of the remedy table.
42
104
  *
package/dist/setup.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { toolJson } from "./mcpClient.js";
2
+ import { clock, minutesSpan } from "./messages.js";
2
3
  import { printable } from "./redact.js";
3
4
  /**
4
5
  * The setup plane, driven from a terminal.
@@ -172,6 +173,101 @@ export function planBindArgs(input) {
172
173
  args: { sandboxId: connection, installationId, repoOwner: parts[0], repoName: parts[1] },
173
174
  };
174
175
  }
176
+ /**
177
+ * C6 — the ONE instruction block a link step prints, CLI-owned. The server's
178
+ * `instructions` field is deliberately NOT printed: it repeated this sentence
179
+ * and named an MCP tool (`setup_status`) a terminal user cannot call.
180
+ */
181
+ export function linkInstructions(url, expiresInSeconds) {
182
+ const seconds = expiresInSeconds ?? LINK_POLL_MAX_MS / 1000;
183
+ return [
184
+ "Open this link in a browser signed in to Flowsprite as the same account, in this same workspace:",
185
+ ` ${printable(url)}`,
186
+ LINK_SECRET_LINE,
187
+ `The link works once and expires in ${minutesSpan(seconds)}.`,
188
+ ];
189
+ }
190
+ /** Card eecc9d6f N12 — directly under every setup link. */
191
+ export const LINK_SECRET_LINE = "This link is a one-time secret for your account — don't paste it in chat or share it.";
192
+ // ------------------------------------------------------------- setup's steps (N3)
193
+ /**
194
+ * Card eecc9d6f N3 — the three steps `setup` names, ONE table. A ladder rung
195
+ * maps onto a step by `stepOfRung`; `skipped` is what a step that needed no
196
+ * work this run says (step 1's is built at the call, it names the login).
197
+ */
198
+ export const SETUP_STEPS = [
199
+ { label: "approve this login", skipped: "" },
200
+ { label: "connect Salesforce", skipped: "Salesforce is already connected" },
201
+ { label: "connect GitHub + create the private repo", skipped: "GitHub is already connected and every org has its repository" },
202
+ ];
203
+ /**
204
+ * The step a rung belongs to. `first_sync` comes AFTER step 3 (null: it opens
205
+ * no step, but reaching it means steps 2 and 3 needed nothing); `done` and
206
+ * `web_only` open none.
207
+ */
208
+ export function stepOfRung(kind) {
209
+ if (kind === "connect_salesforce")
210
+ return 2;
211
+ if (kind === "connect_github" || kind === "bind_repo")
212
+ return 3;
213
+ return null;
214
+ }
215
+ /**
216
+ * Prints "Step n/3: <label>" the first time a step is entered and "Step n/3:
217
+ * skipped — <why>" for every earlier step the run never entered. `current()` is
218
+ * the step a rerun would be at (N14).
219
+ */
220
+ export function createStepTracker(writer) {
221
+ let at = 0;
222
+ const total = SETUP_STEPS.length;
223
+ const skipTo = (step) => {
224
+ for (let n = at + 1; n < step; n++)
225
+ writer.out(`Step ${n}/${total}: skipped — ${SETUP_STEPS[n - 1].skipped}`);
226
+ };
227
+ return {
228
+ current: () => Math.min(Math.max(at, 1), total),
229
+ enter(step) {
230
+ if (step <= at)
231
+ return;
232
+ skipTo(step);
233
+ writer.out(`Step ${step}/${total}: ${SETUP_STEPS[step - 1].label}`);
234
+ at = step;
235
+ },
236
+ skip(step, why) {
237
+ if (step <= at)
238
+ return;
239
+ skipTo(step);
240
+ writer.out(`Step ${step}/${total}: skipped — ${why}`);
241
+ at = step;
242
+ },
243
+ /** Past the last step (the first sync): every step not entered is reported skipped. */
244
+ finishSteps() {
245
+ skipTo(total + 1);
246
+ at = Math.max(at, total);
247
+ },
248
+ };
249
+ }
250
+ /**
251
+ * Card eecc9d6f N4 — printed once, before the first wait on the browser. The
252
+ * numbers are the code's own bounds: the device code's TTL ceiling and the link
253
+ * step's wait budget. `loginSeconds` is null when this run will NOT log in (step
254
+ * 1 skipped, G9a): the notice then names only the links.
255
+ */
256
+ export function browserWaitNotice(loginSeconds) {
257
+ const links = `up to ${minutesSpan(LINK_POLL_MAX_MS / 1000)} for each link`;
258
+ return (`Setup waits on your browser: ${loginSeconds === null ? links : `up to ${minutesSpan(loginSeconds)} to approve the login, and ${links}`}. ` +
259
+ "Keep this command running; if it stops, rerun `flowsprite setup` and it continues from the checklist's current step.");
260
+ }
261
+ /** Card eecc9d6f N14 — the last line of every `setup` run that does not succeed. */
262
+ export function rerunHint(login, step) {
263
+ const where = `it picks up at step ${step} (or later, if you finish that step in the browser first)`;
264
+ if (login === "stored")
265
+ return `Rerun \`flowsprite setup\` to continue — your login is saved, and ${where}.`;
266
+ if (login === "supplied") {
267
+ return `Rerun \`flowsprite setup\` with the same --token or FLOWSPRITE_TOKEN to continue (nothing was saved; \`flowsprite login --token <t> --save\` stores it) — ${where}.`;
268
+ }
269
+ return "Rerun `flowsprite setup` to continue — it starts at step 1, logging this terminal in.";
270
+ }
175
271
  /** The `purpose` a minted link carries in `pendingLinks` — the set is `convex/setupLinks.ts purposeValidator`. */
176
272
  const LINK_PURPOSE = {
177
273
  salesforce: "salesforce_connect",
@@ -210,8 +306,7 @@ export function idsOf(provider, status) {
210
306
  * THE RULE: the writer keeps newlines for whole documents and multi-line
211
307
  * refusals; a field spliced INTO a sentence is scrubbed here, or a newline in
212
308
  * it forges a line (driven: a displayName "Acme\nDone." printed an exact
213
- * `Done.` line). `instructions` and `nextAction.text` are whole-field prints
214
- * and stay as they are.
309
+ * `Done.` line). `nextAction.text` is a whole-field print and stays as it is.
215
310
  */
216
311
  function connectedNames(provider, status) {
217
312
  const root = asObject(status);
@@ -274,7 +369,7 @@ export async function runLinkStep(deps, tool, args, provider, before) {
274
369
  return { kind: "failed", message: minted.message };
275
370
  const payload = asObject(toolJson(minted));
276
371
  if (payload === null || typeof payload.url !== "string") {
277
- return { kind: "failed", message: `${tool} did not return a link.` };
372
+ return { kind: "failed", message: "Flowsprite did not return a link for this step. Run `flowsprite status` and try again." };
278
373
  }
279
374
  const url = payload.url;
280
375
  const expiresAt = typeof payload.expiresAt === "number" ? payload.expiresAt : null;
@@ -290,10 +385,11 @@ export async function runLinkStep(deps, tool, args, provider, before) {
290
385
  const skew = expiresAt === null || ttlMs === null ? null : expiresAt - ttlMs - mintedAt;
291
386
  /** `now` on the server's clock, as far as the mint let us measure it. */
292
387
  const serverNow = (now) => now + (skew ?? 0);
293
- deps.writer.out(`Open this link in a browser where you are signed in to Flowsprite:`);
294
- deps.writer.out(` ${printable(url)}`);
295
- if (typeof payload.instructions === "string")
296
- deps.writer.out(payload.instructions);
388
+ const expiresInSeconds = typeof payload.expiresInSeconds === "number" && payload.expiresInSeconds > 0
389
+ ? payload.expiresInSeconds
390
+ : null;
391
+ for (const line of linkInstructions(url, expiresInSeconds))
392
+ deps.writer.out(line);
297
393
  deps.writer.out("Waiting for you to finish in the browser...");
298
394
  let used = false;
299
395
  const deadline = deps.now() + LINK_POLL_MAX_MS;
@@ -339,6 +435,10 @@ export async function runLinkStep(deps, tool, args, provider, before) {
339
435
  return { kind: "expired", used };
340
436
  if (now >= deadline)
341
437
  return { kind: "timeout", used };
438
+ // C8: the link's own expiry by the server's clock when the mint named one,
439
+ // else the CLI's wait budget — whichever this step will actually stop at.
440
+ const leftMs = expiresAt === null ? deadline - now : Math.min(expiresAt - serverNow(now), deadline - now);
441
+ deps.progress?.(`Link expires in ${clock(leftMs / 1000)}`);
342
442
  }
343
443
  }
344
444
  // ---------------------------------------------------------- repository creation