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/login.js CHANGED
@@ -1,4 +1,6 @@
1
- import { isLoginFailureKind, LOGIN_REMEDIES, POLL_CONTINUE_KINDS } 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";
2
4
  /**
3
5
  * RFC 8628 device-code login against `/api/cli/auth/{start,poll}`.
4
6
  *
@@ -15,19 +17,59 @@ import { isLoginFailureKind, LOGIN_REMEDIES, POLL_CONTINUE_KINDS } from "./messa
15
17
  * treats a too-fast poll as `slow_down`, which lengthens the client's own next
16
18
  * window.
17
19
  *
18
- * ponytail: no `sf`-CLI door on this ladder — card B4 is Blocked, so there is no
19
- * "reuse the Salesforce CLI's browser session" rung here. When it lands it is
20
- * one more branch BEFORE `start` is called, not a second login function.
20
+ * THE GH ROAD (FLO-517, `ghCliLogin` below) is the one shortcut rung, and it
21
+ * sits BEFORE `start` is called: a terminal already signed in to `gh` sends that
22
+ * bearer to `/api/cli/bootstrap/github` and either gets the credential in one
23
+ * round trip, or is handed a device code whose browser/poll tail is THIS
24
+ * ladder's own `announceBrowser` + `pollLoop` — split out of `deviceCodeLogin`
25
+ * so the 409 and 202 roads reuse it instead of duplicating it. The Salesforce
26
+ * CLI's login is a different credential, consumed by `connect salesforce
27
+ * --from-sf-cli` (cli/src/cli.ts `connectFromSfCli`, FLO-515), not here.
21
28
  */
22
29
  /** Seconds added to the poll interval each time the server says `slow_down`. */
23
30
  const SLOW_DOWN_STEP_SECONDS = 5;
24
- /** The device-code TTL the endpoint documents (`expires_in: 900`) — and the ceiling. */
25
- const MAX_EXPIRES_IN_SECONDS = 900;
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
+ }
67
+ }
26
68
  function failure(kind) {
27
69
  return { ok: false, kind, message: LOGIN_REMEDIES[kind] };
28
70
  }
29
71
  /** One POST of a JSON body, answering with the status and the parsed body. */
30
- async function postJson(deps, url, body) {
72
+ async function postJson(deps, url, body, headers = {}) {
31
73
  let res;
32
74
  try {
33
75
  res = await deps.fetch(url, {
@@ -36,6 +78,7 @@ async function postJson(deps, url, body) {
36
78
  "content-type": "application/json",
37
79
  accept: "application/json",
38
80
  "user-agent": `flowsprite-cli/${deps.version}`,
81
+ ...headers,
39
82
  },
40
83
  body: JSON.stringify(body),
41
84
  });
@@ -62,7 +105,8 @@ async function postJson(deps, url, body) {
62
105
  function refusalKind(body) {
63
106
  return isLoginFailureKind(body.error) ? body.error : "server_error";
64
107
  }
65
- export async function deviceCodeLogin(deps) {
108
+ /** The POST to `/api/cli/auth/start`, parsed into the payload shape — or the failure. */
109
+ async function startDeviceCode(deps) {
66
110
  const start = await postJson(deps, `${deps.origin}/api/cli/auth/start`, {
67
111
  client_name: "flowsprite-cli",
68
112
  cli_version: deps.version,
@@ -79,48 +123,60 @@ export async function deviceCodeLogin(deps) {
79
123
  const userCode = start.body.user_code;
80
124
  const completeUri = start.body.verification_uri_complete;
81
125
  const fallbackUri = start.body.verification_uri;
82
- if (typeof deviceCode !== "string" || typeof userCode !== "string") {
126
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || !USER_CODE_RE.test(userCode)) {
83
127
  return failure("server_error");
84
128
  }
85
- const openTarget = typeof completeUri === "string"
86
- ? completeUri
87
- : typeof fallbackUri === "string"
88
- ? fallbackUri
89
- : 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);
90
135
  if (openTarget === null)
91
- return failure("server_error");
92
- deps.writer.out(`Open ${openTarget} and confirm the code ${userCode}`);
136
+ return { ok: false, kind: "server_error", message: OPEN_TARGET_REFUSED };
137
+ // The device code's own lifetime bounds the loop, so a server that answers
138
+ // `authorization_pending` forever still ends — with the same sentence a real
139
+ // `expired_token` gets, because the window really has passed. CLAMPED TO THE
140
+ // REAL TTL (`clampExpiresIn`): a number this CLI reads off a response is what
141
+ // stands between a bad value and a terminal that polls for a year.
142
+ const expiresIn = clampExpiresIn(start.body.expires_in);
143
+ const interval = intervalSeconds(start.body.interval, expiresIn);
144
+ return { deviceCode, userCode, openTarget, expiresIn, interval };
145
+ }
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)}`);
93
154
  deps.writer.out("Waiting for you to approve this login...");
94
- if (deps.openUrl) {
155
+ deps.writer.out(timeLeftLine(payload.expiresIn));
156
+ deps.writer.out(waitHintLine(payload.expiresIn));
157
+ if (open && deps.openUrl) {
95
158
  try {
96
- deps.openUrl(openTarget);
159
+ deps.openUrl(payload.openTarget);
97
160
  }
98
161
  catch {
99
162
  // Deliberately silent: see LoginDeps.openUrl. The URL is already printed.
100
163
  }
101
164
  }
102
- const intervalFromServer = start.body.interval;
103
- let intervalSeconds = typeof intervalFromServer === "number" && intervalFromServer > 0 ? intervalFromServer : 5;
104
- // The device code's own lifetime bounds the loop, so a server that answers
105
- // `authorization_pending` forever still ends — with the same sentence a real
106
- // `expired_token` gets, because the window really has passed.
107
- //
108
- // CLAMPED TO THE REAL TTL. `expires_in` is a number this CLI reads off a
109
- // response, and the loop's termination is the only thing standing between a
110
- // bad value and a terminal that polls for a year. The server's own TTL is 15
111
- // minutes (`docs/mcp.md`, `expires_in: 900`), so anything larger is either a
112
- // mistake or a hostile endpoint, and anything non-positive would end the loop
113
- // before a single poll. Both fold to the documented value.
114
- const claimed = start.body.expires_in;
115
- const expiresIn = typeof claimed === "number" && Number.isFinite(claimed) && claimed > 0
116
- ? Math.min(claimed, MAX_EXPIRES_IN_SECONDS)
117
- : MAX_EXPIRES_IN_SECONDS;
165
+ }
166
+ /** Poll `/api/cli/auth/poll` until the code is approved and minted, refused, or expired. */
167
+ async function pollLoop(deps, { deviceCode, expiresIn, interval, openTarget }) {
168
+ let intervalSeconds = interval;
118
169
  let waitedSeconds = 0;
170
+ let approverNotePrinted = false;
171
+ let reprintAt = REPRINT_EVERY_SECONDS;
119
172
  for (;;) {
120
173
  if (waitedSeconds >= expiresIn)
121
174
  return failure("expired_token");
122
- await deps.sleep(intervalSeconds * 1000);
123
- 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;
124
180
  const poll = await postJson(deps, `${deps.origin}/api/cli/auth/poll`, {
125
181
  device_code: deviceCode,
126
182
  });
@@ -137,10 +193,42 @@ export async function deviceCodeLogin(deps) {
137
193
  typeof mcpEndpoint !== "string") {
138
194
  return failure("server_error");
139
195
  }
140
- 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
+ };
141
207
  }
142
208
  const kind = poll.body.error;
143
209
  if (typeof kind === "string" && POLL_CONTINUE_KINDS.has(kind)) {
210
+ // FLO-509: an informational bit on the pending answer. Strictly `true`:
211
+ // anything else a server sends here is ignored, never coerced. Printed once
212
+ // per login, and it never ends the wait — the refusal can be fixed inside
213
+ // the window, and a different person may still approve the same code.
214
+ if (kind === "authorization_pending" &&
215
+ poll.body.approver_blocked === true &&
216
+ !approverNotePrinted) {
217
+ approverNotePrinted = true;
218
+ deps.writer.out(APPROVER_BLOCKED_NOTE);
219
+ }
220
+ // ponytail: time left is expires_in minus the SUM OF SLEEPS, so fetch latency
221
+ // and a suspended laptop are not counted and it can overstate; thread `now`
222
+ // into LoginDeps for a wall-clock countdown if that ever reads wrong.
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
+ }
144
232
  if (kind === "slow_down") {
145
233
  // The server has ALREADY raised its own required interval by this much;
146
234
  // raising ours to match is what stops the next poll earning another one.
@@ -152,3 +240,168 @@ export async function deviceCodeLogin(deps) {
152
240
  return failure(refusalKind(poll.body));
153
241
  }
154
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
+ }
292
+ export async function deviceCodeLogin(deps) {
293
+ const resumed = await resumePending(deps);
294
+ if (resumed !== null)
295
+ return resumed;
296
+ const started = await startDeviceCode(deps);
297
+ if ("ok" in started)
298
+ return started;
299
+ savePending(deps, started);
300
+ announceBrowser(deps, started);
301
+ return pollAndSettle(deps, started);
302
+ }
303
+ /**
304
+ * `gh auth token`, read by shape. Null on every way it can fail — a non-zero
305
+ * exit, a throw, a REJECTING promise, or stdout that is not one token line.
306
+ * The error object is DISCARDED, never formatted (spec gate C4): a rejection's
307
+ * message can carry gh's stderr, and gh's stderr can carry the token.
308
+ */
309
+ async function ghToken(deps) {
310
+ let outcome;
311
+ try {
312
+ outcome = await deps.spawn("gh", ["auth", "token"]);
313
+ }
314
+ catch {
315
+ return null;
316
+ }
317
+ if (typeof outcome !== "object" || outcome === null)
318
+ return null;
319
+ const { status, stdout } = outcome;
320
+ return readGhToken({
321
+ status: typeof status === "number" ? status : null,
322
+ ...(typeof stdout === "string" ? { stdout } : {}),
323
+ });
324
+ }
325
+ /**
326
+ * The workspace name the door is asked to create: `--workspace`, else one
327
+ * prompt when there is a terminal (an empty answer takes the default), else
328
+ * NULL — a pipe with no `--workspace` never creates a workspace silently (the
329
+ * caller says so in one sentence and takes the browser road, which a script
330
+ * had before this road existed). Asked AFTER the gh token was read — a
331
+ * logged-out gh never prompts — and BEFORE the door is asked.
332
+ */
333
+ async function workspaceName(deps) {
334
+ if (deps.workspaceName !== null)
335
+ return deps.workspaceName;
336
+ if (deps.ask === undefined)
337
+ return null;
338
+ deps.writer.out(GH_ROAD.context);
339
+ const answer = (await deps.ask(GH_ROAD.question)).trim();
340
+ return answer.length > 0 ? answer : GH_ROAD.defaultWorkspace;
341
+ }
342
+ function stop(answer) {
343
+ const message = answer.code === "in_progress" && answer.retryAfter !== undefined
344
+ ? inProgressRemedy(answer.retryAfter)
345
+ : GH_DOOR_REMEDIES[answer.code];
346
+ return { ok: false, kind: answer.code, message };
347
+ }
348
+ /**
349
+ * FLO-517 — `flowsprite login` through the gh CLI's token. Returns null when the
350
+ * road falls to the browser login at its OWN start (gh absent, not logged in, or
351
+ * a pipe with no `--workspace`: one sentence, then the caller runs
352
+ * `deviceCodeLogin`); otherwise the result,
353
+ * which on a 409 or 202 is the SAME browser/poll tail with the door's payload —
354
+ * never a second `/api/cli/auth/start`.
355
+ *
356
+ * THE TOKEN'S SINKS are exactly two: the spawn's stdout (read once) and the
357
+ * request's `authorization` header. It is in no message (every sentence is a
358
+ * fixed local string), never logged, never stored; the writer's redactor
359
+ * (`redact.ts`) is the second net, not the first.
360
+ */
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;
367
+ if (!deps.isOnPath("gh")) {
368
+ deps.writer.out(GH_ROAD.notFound);
369
+ return null;
370
+ }
371
+ const token = await ghToken(deps);
372
+ if (token === null) {
373
+ deps.writer.out(GH_ROAD.notLoggedIn);
374
+ return null;
375
+ }
376
+ const companyName = await workspaceName(deps);
377
+ if (companyName === null) {
378
+ deps.writer.out(GH_ROAD.noTerminal);
379
+ return null;
380
+ }
381
+ deps.writer.out(GH_ROAD.using);
382
+ const posted = await postJson(deps, `${deps.origin}/api/cli/bootstrap/github`, { company_name: companyName, cli_version: deps.version }, { authorization: `Bearer ${token}` });
383
+ if (posted === null)
384
+ return failure("transport");
385
+ const answer = classifyBootstrapAnswer(posted.status, posted.body, deps.origin);
386
+ switch (answer.kind) {
387
+ case "token":
388
+ return { ok: true, token: answer.token, orgId: answer.orgId, userId: answer.userId, mcpEndpoint: answer.mcpEndpoint };
389
+ case "poll":
390
+ savePending(deps, answer);
391
+ deps.writer.out(GH_ROAD.seedPending);
392
+ return pollAndSettle(deps, answer);
393
+ case "browser":
394
+ if (answer.reason === "github_scope_missing") {
395
+ deps.writer.out(GH_ROAD.scopeMissing);
396
+ deps.writer.out(GH_ROAD.scopeRemediation);
397
+ }
398
+ else {
399
+ deps.writer.out(GH_ROAD.browserRequired);
400
+ }
401
+ savePending(deps, answer);
402
+ announceBrowser(deps, answer);
403
+ return pollAndSettle(deps, answer);
404
+ default:
405
+ return stop(answer);
406
+ }
407
+ }
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 —
@@ -6,11 +26,27 @@ import { redact } from "./redact.js";
6
26
  * request it was building, and the caller has no way to tell which strings came
7
27
  * from us. Redacting at the one place messages are constructed makes the
8
28
  * guarantee a property of this file rather than of every future edit to it.
29
+ * (Control characters are NOT scrubbed here: that is the writer's job,
30
+ * `cli/src/output.ts`, because tool text also reaches the terminal through
31
+ * parsed `toolJson` fields that never pass this constructor.)
9
32
  */
10
33
  function fail(kind, message, code) {
11
34
  const safe = redact(message);
12
35
  return code === undefined ? { ok: false, kind, message: safe } : { ok: false, kind, message: safe, code };
13
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
+ }
14
50
  export function createMcpClient(deps) {
15
51
  const url = `${deps.origin}/api/mcp`;
16
52
  let nextId = 0;
@@ -67,7 +103,9 @@ export function createMcpClient(deps) {
67
103
  // The tool's own refusal text is the whole value of this branch — an
68
104
  // `isError` result with no text would otherwise be an empty message.
69
105
  const text = content.find((c) => typeof c.text === "string")?.text;
70
- 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 };
71
109
  }
72
110
  return r.structuredContent === undefined
73
111
  ? { ok: true, content }
package/dist/messages.js CHANGED
@@ -13,8 +13,92 @@ export const LOGIN_REMEDIES = {
13
13
  server_error: "Flowsprite hit an unexpected error while logging this terminal in. Try again in a minute; if it keeps happening, that is an operator problem, not something you can fix here.",
14
14
  transport: "Could not reach that Flowsprite deployment. Check the endpoint and your network, then run `flowsprite login` again.",
15
15
  };
16
+ /**
17
+ * FLO-509 — printed once, right after "Waiting for you to approve this login...".
18
+ * `seconds` is the CLAMPED `expires_in`. Whole minutes are rounded DOWN, so the
19
+ * line never promises more time than the loop will actually wait (61 s is "1
20
+ * minute", not 2), and anything under a minute says so rather than "0 minutes".
21
+ */
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) {
27
+ const minutes = Math.floor(seconds / 60);
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}`}`;
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.";
69
+ /**
70
+ * FLO-509 — printed once per login when a pending poll carries
71
+ * `approver_blocked: true`.
72
+ *
73
+ * PAST TENSE, because the server's marker is SET-ONLY: it says someone WAS
74
+ * refused, never that the code is still unapprovable (an admin may have fixed it,
75
+ * or a different person may approve it). It names no reason, person or
76
+ * workspace: the terminal is not told those, and the browser already showed that
77
+ * person the specific remedy.
78
+ */
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.";
16
80
  /** The wire values that mean "keep polling", kept apart from the terminal ones above. */
17
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
+ ]);
18
102
  /**
19
103
  * The kinds that can arrive FROM THE WIRE — a strict subset of the remedy table.
20
104
  *
@@ -45,3 +129,66 @@ const WIRE_KINDS = new Set([
45
129
  export function isLoginFailureKind(value) {
46
130
  return typeof value === "string" && WIRE_KINDS.has(value);
47
131
  }
132
+ // ------------------------------------------------------------ the gh road (FLO-517)
133
+ /**
134
+ * The lines `flowsprite login` prints on its gh road, before the device-code
135
+ * road takes over or the door answers. EVERY sentence here is a fixed local
136
+ * string: the door's `remediation` field is a server string, and a message is a
137
+ * sink the GitHub token must never reach (spec gate C2) — so the scope remedy is
138
+ * this CLI's own line, not the server's.
139
+ */
140
+ /** The workspace name an empty prompt answer takes; the prompt shows it in brackets. */
141
+ const DEFAULT_WORKSPACE = "My workspace";
142
+ export const GH_ROAD = {
143
+ /** `gh` is not on PATH: one line, then the browser login it always was. */
144
+ notFound: "The GitHub CLI (gh) is not on PATH — using the browser to log in.",
145
+ /** `gh auth token` exited non-zero, threw, or printed anything but one token line. */
146
+ notLoggedIn: "gh is not logged in — using the browser to log in.",
147
+ /** Printed once before the workspace-name prompt, only when there is a terminal to prompt at. */
148
+ context: "Setting up Flowsprite from your GitHub CLI login.",
149
+ defaultWorkspace: DEFAULT_WORKSPACE,
150
+ question: `Name for your new Flowsprite workspace [${DEFAULT_WORKSPACE}]: `,
151
+ /** A pipe with no `--workspace`: a workspace is never created on a default nobody typed. */
152
+ noTerminal: "No terminal to ask for a workspace name — pass `--workspace <name>` to set up from your GitHub CLI login. Using the browser to log in.",
153
+ /** The road taken, named right before the door is asked. */
154
+ using: "Using your GitHub CLI login to set up Flowsprite...",
155
+ /** A 202: the poll loop finishes it — no code, no browser. */
156
+ seedPending: "Your workspace is being set up — this takes a few seconds.",
157
+ /**
158
+ * A 409 `browser_required`. It claims NOTHING about an existing account: the
159
+ * server's code is deliberately generic and also covers an email collision, a
160
+ * Clerk 422 and a consumed op.
161
+ */
162
+ browserRequired: "Flowsprite needs the browser to finish this login.",
163
+ scopeMissing: "Your gh login lacks the user:email scope, so the browser will finish this login. To skip the browser next time, run:",
164
+ scopeRemediation: "gh auth refresh -h github.com -s user:email",
165
+ };
166
+ /** The `in_progress` sentence, with the seconds the door's `retry_after` named. */
167
+ export function inProgressRemedy(seconds) {
168
+ return `Another login for this GitHub account is in progress. Wait ${seconds} seconds and run \`flowsprite login\` again.`;
169
+ }
170
+ /**
171
+ * One sentence per way the door can STOP the login (exit 1, no browser). The
172
+ * codes shared with the device-code road keep that road's remedy — except
173
+ * `invalid_argument`, which on this door means the typed workspace name (the
174
+ * only value the door validates), so "a bug in this CLI" would be wrong.
175
+ * `in_progress` names the wait; see `inProgressRemedy`.
176
+ */
177
+ export const GH_DOOR_REMEDIES = {
178
+ in_progress: inProgressRemedy(60),
179
+ rate_limited: LOGIN_REMEDIES.rate_limited,
180
+ github_token_invalid: "GitHub rejected the token gh holds. Run `gh auth login`, then `flowsprite login` again.",
181
+ no_verified_email: "GitHub has no verified primary email for this account. Verify an email address on GitHub and make it your primary address (https://github.com/settings/emails), then run `flowsprite login` again.",
182
+ noreply_email: "Your GitHub primary email is a noreply address. Set a real verified email as your primary on GitHub, then run `flowsprite login` again.",
183
+ invalid_request: LOGIN_REMEDIES.invalid_request,
184
+ invalid_argument: "Flowsprite rejected that workspace name. Run `flowsprite login --workspace <name>` with a different name.",
185
+ suspended: LOGIN_REMEDIES.suspended,
186
+ revoked: LOGIN_REMEDIES.revoked,
187
+ ambiguous_grant: LOGIN_REMEDIES.ambiguous_grant,
188
+ // "could not be completed", never "did not answer": these codes also cover a
189
+ // malformed answer from GitHub or Clerk (spec gate C6).
190
+ github_unavailable: "The GitHub check could not be completed. Try again in a minute, or run `flowsprite login --no-gh` to use the browser.",
191
+ clerk_unavailable: "Flowsprite's account service could not complete the request. Try again in a minute.",
192
+ server_misconfigured: LOGIN_REMEDIES.server_misconfigured,
193
+ server_error: LOGIN_REMEDIES.server_error,
194
+ };