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/README.md +8 -5
- package/dist/bin.js +45 -45
- package/dist/cli.js +615 -131
- package/dist/credentials.js +108 -0
- package/dist/doctor.js +17 -3
- package/dist/ghLogin.js +180 -0
- package/dist/login.js +290 -37
- package/dist/mcpClient.js +39 -1
- package/dist/messages.js +147 -0
- package/dist/output.js +55 -3
- package/dist/redact.js +51 -23
- package/dist/setup.js +282 -17
- package/dist/sfCli.js +179 -0
- package/dist/status.js +11 -1
- package/dist/usage.js +65 -25
- package/dist/version.js +1 -1
- package/dist/wiring.js +96 -0
- package/package.json +2 -2
package/dist/login.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
25
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|