flowsprite 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * FLO-517 — the pure half of `flowsprite login`'s gh road: what `gh auth token`
3
+ * printed is read BY SHAPE, and what `POST /api/cli/bootstrap/github` answered is
4
+ * classified BY SHAPE, before either value reaches a sink. This module imports
5
+ * nothing, so the classification can be pinned without a harness.
6
+ *
7
+ * THE INVARIANTS. Every field of the door's answer that this CLI will STORE,
8
+ * PRINT or OPEN is held to the grammar of whoever mints it, and anything else is
9
+ * `stop server_error`: a 200's token is the minter's `fp_` grammar (a door that
10
+ * echoed the gh bearer back would otherwise be stored as a credential), its ids
11
+ * are Clerk's `org_`/`user_` grammars and its endpoint is exactly this origin's
12
+ * `/api/mcp`; a 409's open target is exactly this origin's approve page
13
+ * (`/cli/auth`, no userinfo, no hash, at most `?code=<the payload's user_code>`)
14
+ * so a bearer cannot fit into a URL the CLI prints or hands to `openUrl`; the
15
+ * server's `remediation` is never carried — every sentence the CLI prints is a
16
+ * fixed local string.
17
+ */
18
+ /**
19
+ * A GitHub token in running text: gh's own prefixes at a WORD BOUNDARY with a
20
+ * minimum suffix, so `highs_score` and a bare `ghs_` never match (spec gate C7 —
21
+ * an unanchored `ghs_[A-Za-z0-9_]+` matched inside `highs_score`). Real tokens
22
+ * are 40+ characters; the minimums are floors, not the grammar.
23
+ */
24
+ export const GH_TOKEN_RE = /\b(?:(?:gho|ghp|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{20,})/;
25
+ /** The bearer grammar the door itself enforces (`lib/cliBootstrapGithub.ts`, 401 outside it). */
26
+ const GH_BEARER_RE = /^[A-Za-z0-9_]{20,255}$/;
27
+ /**
28
+ * The token `gh auth token` printed, or null when there is no usable one: the
29
+ * command must have exited 0 and printed EXACTLY ONE line of bearer shape.
30
+ *
31
+ * A `ghs_` prefix is refused HERE, locally: `gh auth token` returns
32
+ * `GH_TOKEN`/`GITHUB_TOKEN` from the environment ahead of any stored login
33
+ * (measured gh 2.99.0), so in GitHub Actions it yields the job's installation
34
+ * token, which is not a user and cannot answer `GET /user` (UNVERIFIED: GitHub
35
+ * answers 403 for it, which the door reports as 502 `github_unavailable`, not
36
+ * a 401). Whatever the door would say, refusing it locally keeps CI on the
37
+ * browser road it had, at no request.
38
+ */
39
+ export function readGhToken(outcome) {
40
+ if (outcome.status !== 0)
41
+ return null;
42
+ const text = (outcome.stdout ?? "").trim();
43
+ if (text.length === 0 || /[\r\n]/.test(text))
44
+ return null;
45
+ if (!GH_BEARER_RE.test(text) || text.startsWith("ghs_"))
46
+ return null;
47
+ return text;
48
+ }
49
+ /** A Flowsprite token as the minter emits it (`generateRawToken`, `convex/lib/mcpCrypto.ts`): the prefix and EXACTLY 32 suffix characters. */
50
+ const FLOWSPRITE_TOKEN_RE = /^fp_(?:user|live)_[A-Za-z0-9_-]{32}$/;
51
+ /** Clerk's id grammars — the door copies `org_id`/`user_id` from Clerk. */
52
+ const ORG_ID_RE = /^org_[A-Za-z0-9]+$/;
53
+ const USER_ID_RE = /^user_[A-Za-z0-9]+$/;
54
+ const DEVICE_CODE_RE = /^[0-9a-f]{64}$/;
55
+ const USER_CODE_RE = /^[A-Z]{4}-[A-Z]{4}$/;
56
+ /** The approve page's path on the endpoint; the only thing a 409 may ask the CLI to open. */
57
+ const APPROVE_PATH = "/cli/auth";
58
+ /** The device-code TTL the endpoint documents (`expires_in: 900`) — and the ceiling. */
59
+ export const MAX_EXPIRES_IN_SECONDS = 900;
60
+ /**
61
+ * `expires_in` as the loop may trust it: a number this CLI reads off a response,
62
+ * and the loop's termination is the only thing between a bad value and a
63
+ * terminal that polls for a year. Anything larger than the documented TTL is a
64
+ * mistake or a hostile endpoint, and anything non-positive would end the loop
65
+ * before a single poll — both fold to the documented value. ONE rule for both
66
+ * roads: `/api/cli/auth/start` (`login.ts`) and the bootstrap door (below).
67
+ */
68
+ export function clampExpiresIn(claimed) {
69
+ return typeof claimed === "number" && Number.isFinite(claimed) && claimed > 0
70
+ ? Math.min(MAX_EXPIRES_IN_SECONDS, Math.max(1, claimed))
71
+ : MAX_EXPIRES_IN_SECONDS;
72
+ }
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;
76
+ }
77
+ const STOP_CODES = new Set([
78
+ "in_progress",
79
+ "rate_limited",
80
+ "github_token_invalid",
81
+ "no_verified_email",
82
+ "noreply_email",
83
+ "invalid_request",
84
+ "invalid_argument",
85
+ "suspended",
86
+ "revoked",
87
+ "ambiguous_grant",
88
+ "github_unavailable",
89
+ "clerk_unavailable",
90
+ "server_misconfigured",
91
+ "server_error",
92
+ ]);
93
+ const STOP_SERVER_ERROR = { kind: "stop", code: "server_error" };
94
+ /**
95
+ * The approve page this endpoint's 409 may ask the CLI to open, as the ONE string
96
+ * the CLI will print and hand to `openUrl` — or null. The raw server string is
97
+ * parsed, held field by field (the ORIGIN is exactly `origin`, host-anchored,
98
+ * never a prefix test; no userinfo; the path is `/cli/auth`; no fragment; the
99
+ * query is empty or exactly one `code` equal to the payload's own `userCode`),
100
+ * and then what is RETURNED is the NORMALISED `href`, never the raw string: the
101
+ * WHATWG parser removes dot segments, so `<origin>/gho_<token>/../cli/auth`
102
+ * parses to a pathname of `/cli/auth` while the raw string still carries the
103
+ * bearer (fix round 2 F6). The href is asserted to be one of the two exact
104
+ * forms, so nothing else can ever reach a sink.
105
+ */
106
+ function approvePageHref(target, origin, userCode) {
107
+ if (typeof target !== "string")
108
+ return null;
109
+ let url;
110
+ try {
111
+ url = new URL(target);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ if (url.origin !== origin || url.username !== "" || url.password !== "")
117
+ return null;
118
+ if (url.pathname !== APPROVE_PATH || url.hash !== "")
119
+ return null;
120
+ const params = [...url.searchParams.entries()];
121
+ if (params.length !== 0 && !(params.length === 1 && params[0][0] === "code" && params[0][1] === userCode))
122
+ return null;
123
+ const plain = `${origin}${APPROVE_PATH}`;
124
+ const withCode = `${plain}?code=${userCode}`;
125
+ return url.href === plain || url.href === withCode ? url.href : null;
126
+ }
127
+ /**
128
+ * One of four kinds for the door's answer. `origin` is the endpoint the request
129
+ * went to, so a 409's open target can be held to it.
130
+ */
131
+ export function classifyBootstrapAnswer(status, body, origin) {
132
+ if (status === 200) {
133
+ const { token, org_id: orgId, user_id: userId, mcp_endpoint: mcpEndpoint } = body;
134
+ if (typeof token !== "string" ||
135
+ !FLOWSPRITE_TOKEN_RE.test(token) ||
136
+ typeof orgId !== "string" ||
137
+ !ORG_ID_RE.test(orgId) ||
138
+ typeof userId !== "string" ||
139
+ !USER_ID_RE.test(userId) ||
140
+ mcpEndpoint !== `${origin}/api/mcp`) {
141
+ return STOP_SERVER_ERROR;
142
+ }
143
+ return { kind: "token", token, orgId, userId, mcpEndpoint };
144
+ }
145
+ const deviceCode = body.device_code;
146
+ const expiresIn = clampExpiresIn(body.expires_in);
147
+ const interval = intervalSeconds(body.interval);
148
+ if (status === 202) {
149
+ if (typeof deviceCode !== "string" || !DEVICE_CODE_RE.test(deviceCode))
150
+ return STOP_SERVER_ERROR;
151
+ return { kind: "poll", deviceCode, expiresIn, interval };
152
+ }
153
+ if (status === 409 && (body.error === "browser_required" || body.error === "github_scope_missing")) {
154
+ const userCode = body.user_code;
155
+ if (typeof deviceCode !== "string" ||
156
+ !DEVICE_CODE_RE.test(deviceCode) ||
157
+ typeof userCode !== "string" ||
158
+ !USER_CODE_RE.test(userCode)) {
159
+ return STOP_SERVER_ERROR;
160
+ }
161
+ const openTarget = approvePageHref(body.verification_uri_complete ?? body.verification_uri, origin, userCode);
162
+ if (openTarget === null)
163
+ return STOP_SERVER_ERROR;
164
+ return { kind: "browser", reason: body.error, deviceCode, userCode, openTarget, expiresIn, interval };
165
+ }
166
+ const code = body.error;
167
+ if (typeof code !== "string" || !STOP_CODES.has(code))
168
+ return STOP_SERVER_ERROR;
169
+ const retryAfter = body.retry_after;
170
+ return code === "in_progress" && typeof retryAfter === "number" && retryAfter > 0
171
+ ? { kind: "stop", code, retryAfter }
172
+ : { kind: "stop", code: code };
173
+ }
package/dist/login.js CHANGED
@@ -1,4 +1,5 @@
1
- import { isLoginFailureKind, LOGIN_REMEDIES, POLL_CONTINUE_KINDS } from "./messages.js";
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";
2
3
  /**
3
4
  * RFC 8628 device-code login against `/api/cli/auth/{start,poll}`.
4
5
  *
@@ -15,19 +16,27 @@ import { isLoginFailureKind, LOGIN_REMEDIES, POLL_CONTINUE_KINDS } from "./messa
15
16
  * treats a too-fast poll as `slow_down`, which lengthens the client's own next
16
17
  * window.
17
18
  *
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.
19
+ * THE GH ROAD (FLO-517, `ghCliLogin` below) is the one shortcut rung, and it
20
+ * sits BEFORE `start` is called: a terminal already signed in to `gh` sends that
21
+ * bearer to `/api/cli/bootstrap/github` and either gets the credential in one
22
+ * round trip, or is handed a device code whose browser/poll tail is THIS
23
+ * ladder's own `announceBrowser` + `pollLoop` — split out of `deviceCodeLogin`
24
+ * so the 409 and 202 roads reuse it instead of duplicating it. The Salesforce
25
+ * CLI's login is a different credential, consumed by `connect salesforce
26
+ * --from-sf-cli` (cli/src/cli.ts `connectFromSfCli`, FLO-515), not here.
21
27
  */
22
28
  /** Seconds added to the poll interval each time the server says `slow_down`. */
23
29
  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;
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")}`;
34
+ }
26
35
  function failure(kind) {
27
36
  return { ok: false, kind, message: LOGIN_REMEDIES[kind] };
28
37
  }
29
38
  /** One POST of a JSON body, answering with the status and the parsed body. */
30
- async function postJson(deps, url, body) {
39
+ async function postJson(deps, url, body, headers = {}) {
31
40
  let res;
32
41
  try {
33
42
  res = await deps.fetch(url, {
@@ -36,6 +45,7 @@ async function postJson(deps, url, body) {
36
45
  "content-type": "application/json",
37
46
  accept: "application/json",
38
47
  "user-agent": `flowsprite-cli/${deps.version}`,
48
+ ...headers,
39
49
  },
40
50
  body: JSON.stringify(body),
41
51
  });
@@ -62,7 +72,8 @@ async function postJson(deps, url, body) {
62
72
  function refusalKind(body) {
63
73
  return isLoginFailureKind(body.error) ? body.error : "server_error";
64
74
  }
65
- export async function deviceCodeLogin(deps) {
75
+ /** The POST to `/api/cli/auth/start`, parsed into the payload shape — or the failure. */
76
+ async function startDeviceCode(deps) {
66
77
  const start = await postJson(deps, `${deps.origin}/api/cli/auth/start`, {
67
78
  client_name: "flowsprite-cli",
68
79
  cli_version: deps.version,
@@ -89,33 +100,34 @@ export async function deviceCodeLogin(deps) {
89
100
  : null;
90
101
  if (openTarget === null)
91
102
  return failure("server_error");
92
- deps.writer.out(`Open ${openTarget} and confirm the code ${userCode}`);
103
+ // The device code's own lifetime bounds the loop, so a server that answers
104
+ // `authorization_pending` forever still ends — with the same sentence a real
105
+ // `expired_token` gets, because the window really has passed. CLAMPED TO THE
106
+ // REAL TTL (`clampExpiresIn`): a number this CLI reads off a response is what
107
+ // stands between a bad value and a terminal that polls for a year.
108
+ const expiresIn = clampExpiresIn(start.body.expires_in);
109
+ const interval = intervalSeconds(start.body.interval);
110
+ return { deviceCode, userCode, openTarget, expiresIn, interval };
111
+ }
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}`);
93
115
  deps.writer.out("Waiting for you to approve this login...");
116
+ deps.writer.out(timeLeftLine(payload.expiresIn));
94
117
  if (deps.openUrl) {
95
118
  try {
96
- deps.openUrl(openTarget);
119
+ deps.openUrl(payload.openTarget);
97
120
  }
98
121
  catch {
99
122
  // Deliberately silent: see LoginDeps.openUrl. The URL is already printed.
100
123
  }
101
124
  }
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;
125
+ }
126
+ /** Poll `/api/cli/auth/poll` until the code is approved and minted, refused, or expired. */
127
+ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
128
+ let intervalSeconds = interval;
118
129
  let waitedSeconds = 0;
130
+ let approverNotePrinted = false;
119
131
  for (;;) {
120
132
  if (waitedSeconds >= expiresIn)
121
133
  return failure("expired_token");
@@ -141,6 +153,20 @@ export async function deviceCodeLogin(deps) {
141
153
  }
142
154
  const kind = poll.body.error;
143
155
  if (typeof kind === "string" && POLL_CONTINUE_KINDS.has(kind)) {
156
+ // FLO-509: an informational bit on the pending answer. Strictly `true`:
157
+ // anything else a server sends here is ignored, never coerced. Printed once
158
+ // per login, and it never ends the wait — the refusal can be fixed inside
159
+ // the window, and a different person may still approve the same code.
160
+ if (kind === "authorization_pending" &&
161
+ poll.body.approver_blocked === true &&
162
+ !approverNotePrinted) {
163
+ approverNotePrinted = true;
164
+ deps.writer.out(APPROVER_BLOCKED_NOTE);
165
+ }
166
+ // ponytail: time left is expires_in minus the SUM OF SLEEPS, so fetch latency
167
+ // and a suspended laptop are not counted and it can overstate; thread `now`
168
+ // into LoginDeps for a wall-clock countdown if that ever reads wrong.
169
+ deps.progress?.(`Still waiting — ${clock(expiresIn - waitedSeconds)} left`);
144
170
  if (kind === "slow_down") {
145
171
  // The server has ALREADY raised its own required interval by this much;
146
172
  // raising ours to match is what stops the next poll earning another one.
@@ -152,3 +178,108 @@ export async function deviceCodeLogin(deps) {
152
178
  return failure(refusalKind(poll.body));
153
179
  }
154
180
  }
181
+ export async function deviceCodeLogin(deps) {
182
+ const started = await startDeviceCode(deps);
183
+ if ("ok" in started)
184
+ return started;
185
+ announceBrowser(deps, started);
186
+ return pollLoop(deps, started);
187
+ }
188
+ /**
189
+ * `gh auth token`, read by shape. Null on every way it can fail — a non-zero
190
+ * exit, a throw, a REJECTING promise, or stdout that is not one token line.
191
+ * The error object is DISCARDED, never formatted (spec gate C4): a rejection's
192
+ * message can carry gh's stderr, and gh's stderr can carry the token.
193
+ */
194
+ async function ghToken(deps) {
195
+ let outcome;
196
+ try {
197
+ outcome = await deps.spawn("gh", ["auth", "token"]);
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ if (typeof outcome !== "object" || outcome === null)
203
+ return null;
204
+ const { status, stdout } = outcome;
205
+ return readGhToken({
206
+ status: typeof status === "number" ? status : null,
207
+ ...(typeof stdout === "string" ? { stdout } : {}),
208
+ });
209
+ }
210
+ /**
211
+ * The workspace name the door is asked to create: `--workspace`, else one
212
+ * prompt when there is a terminal (an empty answer takes the default), else
213
+ * NULL — a pipe with no `--workspace` never creates a workspace silently (the
214
+ * caller says so in one sentence and takes the browser road, which a script
215
+ * had before this road existed). Asked AFTER the gh token was read — a
216
+ * logged-out gh never prompts — and BEFORE the door is asked.
217
+ */
218
+ async function workspaceName(deps) {
219
+ if (deps.workspaceName !== null)
220
+ return deps.workspaceName;
221
+ if (deps.ask === undefined)
222
+ return null;
223
+ deps.writer.out(GH_ROAD.context);
224
+ const answer = (await deps.ask(GH_ROAD.question)).trim();
225
+ return answer.length > 0 ? answer : GH_ROAD.defaultWorkspace;
226
+ }
227
+ function stop(answer) {
228
+ const message = answer.code === "in_progress" && answer.retryAfter !== undefined
229
+ ? inProgressRemedy(answer.retryAfter)
230
+ : GH_DOOR_REMEDIES[answer.code];
231
+ return { ok: false, kind: answer.code, message };
232
+ }
233
+ /**
234
+ * FLO-517 — `flowsprite login` through the gh CLI's token. Returns null when the
235
+ * road falls to the browser login at its OWN start (gh absent, not logged in, or
236
+ * a pipe with no `--workspace`: one sentence, then the caller runs
237
+ * `deviceCodeLogin`); otherwise the result,
238
+ * which on a 409 or 202 is the SAME browser/poll tail with the door's payload —
239
+ * never a second `/api/cli/auth/start`.
240
+ *
241
+ * THE TOKEN'S SINKS are exactly two: the spawn's stdout (read once) and the
242
+ * request's `authorization` header. It is in no message (every sentence is a
243
+ * fixed local string), never logged, never stored; the writer's redactor
244
+ * (`redact.ts`) is the second net, not the first.
245
+ */
246
+ export async function ghCliLogin(deps) {
247
+ if (!deps.isOnPath("gh")) {
248
+ deps.writer.out(GH_ROAD.notFound);
249
+ return null;
250
+ }
251
+ const token = await ghToken(deps);
252
+ if (token === null) {
253
+ deps.writer.out(GH_ROAD.notLoggedIn);
254
+ return null;
255
+ }
256
+ const companyName = await workspaceName(deps);
257
+ if (companyName === null) {
258
+ deps.writer.out(GH_ROAD.noTerminal);
259
+ return null;
260
+ }
261
+ deps.writer.out(GH_ROAD.using);
262
+ const posted = await postJson(deps, `${deps.origin}/api/cli/bootstrap/github`, { company_name: companyName, cli_version: deps.version }, { authorization: `Bearer ${token}` });
263
+ if (posted === null)
264
+ return failure("transport");
265
+ const answer = classifyBootstrapAnswer(posted.status, posted.body, deps.origin);
266
+ switch (answer.kind) {
267
+ case "token":
268
+ return { ok: true, token: answer.token, orgId: answer.orgId, userId: answer.userId, mcpEndpoint: answer.mcpEndpoint };
269
+ case "poll":
270
+ deps.writer.out(GH_ROAD.seedPending);
271
+ return pollLoop(deps, answer);
272
+ case "browser":
273
+ if (answer.reason === "github_scope_missing") {
274
+ deps.writer.out(GH_ROAD.scopeMissing);
275
+ deps.writer.out(GH_ROAD.scopeRemediation);
276
+ }
277
+ else {
278
+ deps.writer.out(GH_ROAD.browserRequired);
279
+ }
280
+ announceBrowser(deps, answer);
281
+ return pollLoop(deps, answer);
282
+ default:
283
+ return stop(answer);
284
+ }
285
+ }
package/dist/mcpClient.js CHANGED
@@ -6,6 +6,9 @@ import { redact } from "./redact.js";
6
6
  * request it was building, and the caller has no way to tell which strings came
7
7
  * from us. Redacting at the one place messages are constructed makes the
8
8
  * guarantee a property of this file rather than of every future edit to it.
9
+ * (Control characters are NOT scrubbed here: that is the writer's job,
10
+ * `cli/src/output.ts`, because tool text also reaches the terminal through
11
+ * parsed `toolJson` fields that never pass this constructor.)
9
12
  */
10
13
  function fail(kind, message, code) {
11
14
  const safe = redact(message);
package/dist/messages.js CHANGED
@@ -13,6 +13,28 @@ 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
+ 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.`;
26
+ }
27
+ /**
28
+ * FLO-509 — printed once per login when a pending poll carries
29
+ * `approver_blocked: true`.
30
+ *
31
+ * PAST TENSE, because the server's marker is SET-ONLY: it says someone WAS
32
+ * refused, never that the code is still unapprovable (an admin may have fixed it,
33
+ * or a different person may approve it). It names no reason, person or
34
+ * workspace: the terminal is not told those, and the browser already showed that
35
+ * person the specific remedy.
36
+ */
37
+ 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
38
  /** The wire values that mean "keep polling", kept apart from the terminal ones above. */
17
39
  export const POLL_CONTINUE_KINDS = new Set(["authorization_pending", "slow_down"]);
18
40
  /**
@@ -45,3 +67,66 @@ const WIRE_KINDS = new Set([
45
67
  export function isLoginFailureKind(value) {
46
68
  return typeof value === "string" && WIRE_KINDS.has(value);
47
69
  }
70
+ // ------------------------------------------------------------ the gh road (FLO-517)
71
+ /**
72
+ * The lines `flowsprite login` prints on its gh road, before the device-code
73
+ * road takes over or the door answers. EVERY sentence here is a fixed local
74
+ * string: the door's `remediation` field is a server string, and a message is a
75
+ * sink the GitHub token must never reach (spec gate C2) — so the scope remedy is
76
+ * this CLI's own line, not the server's.
77
+ */
78
+ /** The workspace name an empty prompt answer takes; the prompt shows it in brackets. */
79
+ const DEFAULT_WORKSPACE = "My workspace";
80
+ export const GH_ROAD = {
81
+ /** `gh` is not on PATH: one line, then the browser login it always was. */
82
+ notFound: "The GitHub CLI (gh) is not on PATH — using the browser to log in.",
83
+ /** `gh auth token` exited non-zero, threw, or printed anything but one token line. */
84
+ notLoggedIn: "gh is not logged in — using the browser to log in.",
85
+ /** Printed once before the workspace-name prompt, only when there is a terminal to prompt at. */
86
+ context: "Setting up Flowsprite from your GitHub CLI login.",
87
+ defaultWorkspace: DEFAULT_WORKSPACE,
88
+ question: `Name for your new Flowsprite workspace [${DEFAULT_WORKSPACE}]: `,
89
+ /** A pipe with no `--workspace`: a workspace is never created on a default nobody typed. */
90
+ 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.",
91
+ /** The road taken, named right before the door is asked. */
92
+ using: "Using your GitHub CLI login to set up Flowsprite...",
93
+ /** A 202: the poll loop finishes it — no code, no browser. */
94
+ seedPending: "Your workspace is being set up — this takes a few seconds.",
95
+ /**
96
+ * A 409 `browser_required`. It claims NOTHING about an existing account: the
97
+ * server's code is deliberately generic and also covers an email collision, a
98
+ * Clerk 422 and a consumed op.
99
+ */
100
+ browserRequired: "Flowsprite needs the browser to finish this login.",
101
+ scopeMissing: "Your gh login lacks the user:email scope, so the browser will finish this login. To skip the browser next time, run:",
102
+ scopeRemediation: "gh auth refresh -h github.com -s user:email",
103
+ };
104
+ /** The `in_progress` sentence, with the seconds the door's `retry_after` named. */
105
+ export function inProgressRemedy(seconds) {
106
+ return `Another login for this GitHub account is in progress. Wait ${seconds} seconds and run \`flowsprite login\` again.`;
107
+ }
108
+ /**
109
+ * One sentence per way the door can STOP the login (exit 1, no browser). The
110
+ * codes shared with the device-code road keep that road's remedy — except
111
+ * `invalid_argument`, which on this door means the typed workspace name (the
112
+ * only value the door validates), so "a bug in this CLI" would be wrong.
113
+ * `in_progress` names the wait; see `inProgressRemedy`.
114
+ */
115
+ export const GH_DOOR_REMEDIES = {
116
+ in_progress: inProgressRemedy(60),
117
+ rate_limited: LOGIN_REMEDIES.rate_limited,
118
+ github_token_invalid: "GitHub rejected the token gh holds. Run `gh auth login`, then `flowsprite login` again.",
119
+ 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.",
120
+ 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.",
121
+ invalid_request: LOGIN_REMEDIES.invalid_request,
122
+ invalid_argument: "Flowsprite rejected that workspace name. Run `flowsprite login --workspace <name>` with a different name.",
123
+ suspended: LOGIN_REMEDIES.suspended,
124
+ revoked: LOGIN_REMEDIES.revoked,
125
+ ambiguous_grant: LOGIN_REMEDIES.ambiguous_grant,
126
+ // "could not be completed", never "did not answer": these codes also cover a
127
+ // malformed answer from GitHub or Clerk (spec gate C6).
128
+ github_unavailable: "The GitHub check could not be completed. Try again in a minute, or run `flowsprite login --no-gh` to use the browser.",
129
+ clerk_unavailable: "Flowsprite's account service could not complete the request. Try again in a minute.",
130
+ server_misconfigured: LOGIN_REMEDIES.server_misconfigured,
131
+ server_error: LOGIN_REMEDIES.server_error,
132
+ };
package/dist/output.js CHANGED
@@ -1,12 +1,64 @@
1
- import { redact } from "./redact.js";
1
+ import { printable, redact } from "./redact.js";
2
2
  /**
3
3
  * `sinks` is injected so a test can record every line without a process.
4
4
  * The bin wires `console.log` / `console.error`.
5
5
  */
6
6
  export function createWriter(sinks) {
7
7
  return {
8
- out: (line) => sinks.out(redact(line)),
9
- err: (line) => sinks.err(redact(line)),
8
+ out: (line) => sinks.out(scrubLines(redact(line))),
9
+ err: (line) => sinks.err(scrubLines(redact(line))),
10
+ };
11
+ }
12
+ /** `printable` per line, keeping the newlines that separate them. */
13
+ function scrubLines(text) {
14
+ return text.split("\n").map(printable).join("\n");
15
+ }
16
+ /**
17
+ * FLO-509 — a status line redrawn IN PLACE on a terminal: carriage return, the
18
+ * redacted text, erase-to-end-of-line, and NO newline. `settle()` ends a line
19
+ * left dangling; `wireDeps` settles it before every writer and reveal line, and
20
+ * the bin settles it in its catch and SIGINT paths, so a later result or error
21
+ * never lands glued to the end of a countdown.
22
+ *
23
+ * Redacted and scrubbed like `out`/`err`, BEFORE this function adds its own
24
+ * carriage return and erase sequence: this is exit 2 of the five listed above,
25
+ * so it gets the same guarantee rather than relying on callers.
26
+ */
27
+ export function createStatusLine(write) {
28
+ let dangling = false;
29
+ return {
30
+ progress: (line) => {
31
+ write(`\r${scrubLines(redact(line))}\x1b[K`);
32
+ dangling = true;
33
+ },
34
+ settle: () => {
35
+ if (!dangling)
36
+ return;
37
+ dangling = false;
38
+ write("\n");
39
+ },
40
+ };
41
+ }
42
+ /**
43
+ * FLO-509 — the Ctrl-C handler for a terminal with a status line: end the
44
+ * dangling countdown line, then RE-RAISE the signal. Without the settle, node dies
45
+ * mid-line and the next prompt lands glued to "Still waiting — 14:55 left" in
46
+ * shells that do not add a newline.
47
+ *
48
+ * RE-RAISE, NOT EXIT: the listener is process-wide, so every command run at a
49
+ * terminal (`setup`, `mcp`, `doctor`) reaches it. The bin installs it with
50
+ * `process.once`, so by the time `reraise` runs the listener is gone and the
51
+ * re-sent SIGINT meets node's default action — on POSIX the process dies BY THE
52
+ * SIGNAL, as it did before this handler existed (measured in a pty on node
53
+ * v26.8.1, macOS, FLO-509), rather than with an ordinary exit status.
54
+ * UNVERIFIED on Windows: node emulates `process.kill(pid, "SIGINT")` as a forced
55
+ * termination and console writes there can be asynchronous, so the settling
56
+ * newline may not land before the process ends (no worse than before FLO-509).
57
+ */
58
+ export function settleThenReraise(status, reraise) {
59
+ return () => {
60
+ status.settle();
61
+ reraise();
10
62
  };
11
63
  }
12
64
  /** A writer that records, for tests and for the doctor's dry runs. */