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/credentials.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { approvePageHref, MAX_EXPIRES_IN_SECONDS, USER_CODE_RE } from "./ghLogin.js";
|
|
5
6
|
export const CREDENTIALS_FILE = "credentials.json";
|
|
6
7
|
const DIR_MODE = 0o700;
|
|
7
8
|
const FILE_MODE = 0o600;
|
|
@@ -175,6 +176,113 @@ export function deleteCredential(env, origin, home) {
|
|
|
175
176
|
writeStore(env, store, home);
|
|
176
177
|
return true;
|
|
177
178
|
}
|
|
179
|
+
// ------------------------------------------------------- the pending login (C3)
|
|
180
|
+
export const PENDING_LOGIN_FILE = "pending-login.json";
|
|
181
|
+
export function pendingLoginPath(env, home) {
|
|
182
|
+
return join(credentialsDir(env, home), PENDING_LOGIN_FILE);
|
|
183
|
+
}
|
|
184
|
+
const nonEmpty = (v) => typeof v === "string" && v.length > 0;
|
|
185
|
+
/**
|
|
186
|
+
* The pending login for `origin`, or null. TOLERANT like `readCredentials`: a
|
|
187
|
+
* missing, unreadable, garbled or wrong-shaped file, or one for another origin,
|
|
188
|
+
* reads as "none". An EXPIRED entry for this origin is deleted as it is read.
|
|
189
|
+
*
|
|
190
|
+
* Held to the WIRE's grammar (eecc9d6f G5): the user code is `USER_CODE_RE`'s
|
|
191
|
+
* shape and the open target must pass `approvePageHref` — exactly this origin's
|
|
192
|
+
* approve page, normalised — so a hand-edited file can make `login` print or
|
|
193
|
+
* open nothing the start route could not have sent. The interval must be finite.
|
|
194
|
+
*/
|
|
195
|
+
export function readPendingLogin(env, origin, now, home) {
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(readFileSync(pendingLoginPath(env, home), "utf8"));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
204
|
+
return null;
|
|
205
|
+
const v = parsed;
|
|
206
|
+
if (v.origin !== origin || !nonEmpty(v.deviceCode))
|
|
207
|
+
return null;
|
|
208
|
+
if (typeof v.expiresAtMs !== "number" || !Number.isFinite(v.expiresAtMs))
|
|
209
|
+
return null;
|
|
210
|
+
if (typeof v.savedAtMs !== "number" || !Number.isFinite(v.savedAtMs))
|
|
211
|
+
return null;
|
|
212
|
+
if (typeof v.interval !== "number" || !Number.isFinite(v.interval) || !(v.interval > 0))
|
|
213
|
+
return null;
|
|
214
|
+
const userCode = nonEmpty(v.userCode) ? v.userCode : null;
|
|
215
|
+
const rawTarget = nonEmpty(v.openTarget) ? v.openTarget : null;
|
|
216
|
+
if ((userCode === null) !== (rawTarget === null))
|
|
217
|
+
return null;
|
|
218
|
+
let openTarget = null;
|
|
219
|
+
if (userCode !== null) {
|
|
220
|
+
if (!USER_CODE_RE.test(userCode))
|
|
221
|
+
return null;
|
|
222
|
+
openTarget = approvePageHref(rawTarget, origin, userCode);
|
|
223
|
+
if (openTarget === null)
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
// H1/H6: no entry is live past min(its write, now) + a code's TTL. A savedAt
|
|
227
|
+
// LATER than now is not rejected — a backward clock step (NTP, resume from
|
|
228
|
+
// suspend) must not abandon a live, maybe already approved, code — it is read
|
|
229
|
+
// as now. So a future-dated forged file lives at most now + 900 s per read, and
|
|
230
|
+
// is ended by the poll answer anyway (`expired_token`/`invalid_*` clear it).
|
|
231
|
+
const effectiveSavedAt = Math.min(v.savedAtMs, now);
|
|
232
|
+
const expiresAtMs = Math.min(v.expiresAtMs, effectiveSavedAt + MAX_EXPIRES_IN_SECONDS * 1000);
|
|
233
|
+
if (expiresAtMs <= now) {
|
|
234
|
+
try {
|
|
235
|
+
clearPendingLogin(env, origin, home, v.deviceCode);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// An undeletable expired entry still reads as none; the next write replaces it.
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return { origin, deviceCode: v.deviceCode, userCode, openTarget, expiresAtMs, savedAtMs: v.savedAtMs, interval: v.interval };
|
|
243
|
+
}
|
|
244
|
+
/** Throws on a write failure; the caller (`login.ts`) treats that as "not persisted", never as a failed login. */
|
|
245
|
+
export function writePendingLogin(env, entry, home) {
|
|
246
|
+
const dir = credentialsDir(env, home);
|
|
247
|
+
mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
248
|
+
chmodSync(dir, DIR_MODE);
|
|
249
|
+
writeFileAtomic(pendingLoginPath(env, home), `${JSON.stringify(entry)}\n`, FILE_MODE);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Delete the pending login if it is `origin`'s — or if it cannot be read as
|
|
253
|
+
* anyone's (garbled), since nothing can resume it. Another origin's entry is
|
|
254
|
+
* left alone. `rmSync` on a symlink removes the link, never its target.
|
|
255
|
+
*
|
|
256
|
+
* eecc9d6f G2: given a `deviceCode`, ONLY an entry holding exactly that code is
|
|
257
|
+
* deleted (a garbled file is then left for the next write to replace), so a
|
|
258
|
+
* stale run that ends cannot delete the entry a NEWER run saved. `logout` calls
|
|
259
|
+
* it without one: logging out drops whatever is pending for the origin.
|
|
260
|
+
*/
|
|
261
|
+
// ponytail: read-then-unlink, not an atomic compare-and-delete — node builtins have
|
|
262
|
+
// none across processes. The window is the microseconds between the read and the
|
|
263
|
+
// `rmSync`, open only to another `flowsprite` run of the SAME user saving a newer
|
|
264
|
+
// entry in exactly that gap; the loser just starts a fresh login next time. A
|
|
265
|
+
// lock file (O_EXCL) around save/clear is the upgrade if that ever matters.
|
|
266
|
+
export function clearPendingLogin(env, origin, home, deviceCode) {
|
|
267
|
+
const path = pendingLoginPath(env, home);
|
|
268
|
+
let stored;
|
|
269
|
+
try {
|
|
270
|
+
stored = JSON.parse(readFileSync(path, "utf8"));
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (err.code === "ENOENT")
|
|
274
|
+
return;
|
|
275
|
+
stored = null;
|
|
276
|
+
}
|
|
277
|
+
const readable = typeof stored === "object" && stored !== null && typeof stored.origin === "string";
|
|
278
|
+
if (deviceCode !== undefined) {
|
|
279
|
+
if (readable && stored.origin === origin && stored.deviceCode === deviceCode)
|
|
280
|
+
rmSync(path, { force: true });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!readable || stored.origin === origin)
|
|
284
|
+
rmSync(path, { force: true });
|
|
285
|
+
}
|
|
178
286
|
/**
|
|
179
287
|
* Which token this invocation uses: an explicit `--token`, else
|
|
180
288
|
* `FLOWSPRITE_TOKEN`, else the credential stored for this origin. The stored one
|
package/dist/doctor.js
CHANGED
|
@@ -8,6 +8,14 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
*/
|
|
9
9
|
/** The major this package declares in `engines` — one authority, read by both halves. */
|
|
10
10
|
export const MIN_NODE_MAJOR = 20;
|
|
11
|
+
/**
|
|
12
|
+
* THE FAIL SET (card eecc9d6f N16): node too old, endpoint unreachable, no
|
|
13
|
+
* credential or a refused one. `sf`/`gh` absence is a NOTE, never a failure —
|
|
14
|
+
* neither is needed for the browser road.
|
|
15
|
+
*/
|
|
16
|
+
export function doctorOk(report) {
|
|
17
|
+
return report.nodeOk && report.endpointReachable && report.hasCredential && !report.credentialRefused;
|
|
18
|
+
}
|
|
11
19
|
export function nodeMajor(version) {
|
|
12
20
|
const m = /^v?(\d+)\./.exec(version);
|
|
13
21
|
return m ? Number(m[1]) : 0;
|
|
@@ -17,9 +25,15 @@ export function formatDoctor(report) {
|
|
|
17
25
|
return [
|
|
18
26
|
`${mark(report.nodeOk)} node ${report.nodeVersion} (needs >= ${MIN_NODE_MAJOR})`,
|
|
19
27
|
`${mark(report.endpointReachable)} endpoint ${report.endpoint} — ${report.endpointDetail}`,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
!report.hasCredential
|
|
29
|
+
? `FAIL credential for ${report.endpoint} not found (${report.credentialsPath}) — run \`flowsprite setup\` (logging in is its step 1), or \`flowsprite login\` alone`
|
|
30
|
+
: report.credentialRefused
|
|
31
|
+
? `FAIL credential for ${report.endpoint} refused by the endpoint (HTTP 401) — run \`flowsprite login\` for a new one`
|
|
32
|
+
: `ok credential for ${report.endpoint} found (${report.credentialSource === "stored" || report.credentialSource === null
|
|
33
|
+
? report.credentialsPath
|
|
34
|
+
: `from ${report.credentialSource}, not stored`})`,
|
|
35
|
+
`${report.sfOnPath ? "ok " : "note"} sf on PATH: ${report.sfOnPath ? "yes" : "no — needed for 'connect salesforce --from-sf-cli'. Install: npm install -g @salesforce/cli (macOS: brew install --cask salesforce-cli); then sf org login web --alias <name>."}`,
|
|
36
|
+
`${report.ghOnPath ? "ok " : "note"} gh on PATH: ${report.ghOnPath ? "yes" : "no — needed for a no-browser 'login'. Install: gh (e.g. brew install gh); then gh auth login -s user:email."}`,
|
|
23
37
|
];
|
|
24
38
|
}
|
|
25
39
|
/**
|
package/dist/ghLogin.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
/** The user code's shape on BOTH roads and in `pending-login.json` (eecc9d6f G1/G5). */
|
|
56
|
+
export const USER_CODE_RE = /^[A-Z]{4}-[A-Z]{4}$/;
|
|
57
|
+
/** The approve page's path on the endpoint; the only thing a 409 may ask the CLI to open. */
|
|
58
|
+
const APPROVE_PATH = "/cli/auth";
|
|
59
|
+
/** The device-code TTL the endpoint documents (`expires_in: 900`) — and the ceiling. */
|
|
60
|
+
export const MAX_EXPIRES_IN_SECONDS = 900;
|
|
61
|
+
/**
|
|
62
|
+
* `expires_in` as the loop may trust it: a number this CLI reads off a response,
|
|
63
|
+
* and the loop's termination is the only thing between a bad value and a
|
|
64
|
+
* terminal that polls for a year. Anything larger than the documented TTL is a
|
|
65
|
+
* mistake or a hostile endpoint, and anything non-positive would end the loop
|
|
66
|
+
* before a single poll — both fold to the documented value. ONE rule for both
|
|
67
|
+
* roads: `/api/cli/auth/start` (`login.ts`) and the bootstrap door (below).
|
|
68
|
+
*/
|
|
69
|
+
export function clampExpiresIn(claimed) {
|
|
70
|
+
return typeof claimed === "number" && Number.isFinite(claimed) && claimed > 0
|
|
71
|
+
? Math.min(MAX_EXPIRES_IN_SECONDS, Math.max(1, claimed))
|
|
72
|
+
: MAX_EXPIRES_IN_SECONDS;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The poll interval as the loop may trust it — ONE rule for both roads and the
|
|
76
|
+
* resumed `pending-login.json` (eecc9d6f G3): a finite positive number, else the
|
|
77
|
+
* documented 5; never below 1 s (a tiny value is a busy loop) and never above
|
|
78
|
+
* `maxSeconds`, the code's remaining lifetime (a huge value would sleep past it).
|
|
79
|
+
*/
|
|
80
|
+
export function intervalSeconds(claimed, maxSeconds = MAX_EXPIRES_IN_SECONDS) {
|
|
81
|
+
const base = typeof claimed === "number" && Number.isFinite(claimed) && claimed > 0 ? Math.max(1, claimed) : 5;
|
|
82
|
+
return Math.max(1, Math.min(base, maxSeconds));
|
|
83
|
+
}
|
|
84
|
+
const STOP_CODES = new Set([
|
|
85
|
+
"in_progress",
|
|
86
|
+
"rate_limited",
|
|
87
|
+
"github_token_invalid",
|
|
88
|
+
"no_verified_email",
|
|
89
|
+
"noreply_email",
|
|
90
|
+
"invalid_request",
|
|
91
|
+
"invalid_argument",
|
|
92
|
+
"suspended",
|
|
93
|
+
"revoked",
|
|
94
|
+
"ambiguous_grant",
|
|
95
|
+
"github_unavailable",
|
|
96
|
+
"clerk_unavailable",
|
|
97
|
+
"server_misconfigured",
|
|
98
|
+
"server_error",
|
|
99
|
+
]);
|
|
100
|
+
const STOP_SERVER_ERROR = { kind: "stop", code: "server_error" };
|
|
101
|
+
/**
|
|
102
|
+
* The approve page this endpoint's 409 may ask the CLI to open, as the ONE string
|
|
103
|
+
* the CLI will print and hand to `openUrl` — or null. The raw server string is
|
|
104
|
+
* parsed, held field by field (the ORIGIN is exactly `origin`, host-anchored,
|
|
105
|
+
* never a prefix test; no userinfo; the path is `/cli/auth`; no fragment; the
|
|
106
|
+
* query is empty or exactly one `code` equal to the payload's own `userCode`),
|
|
107
|
+
* and then what is RETURNED is the NORMALISED `href`, never the raw string: the
|
|
108
|
+
* WHATWG parser removes dot segments, so `<origin>/gho_<token>/../cli/auth`
|
|
109
|
+
* parses to a pathname of `/cli/auth` while the raw string still carries the
|
|
110
|
+
* bearer (fix round 2 F6). The href is asserted to be one of the two exact
|
|
111
|
+
* forms, so nothing else can ever reach a sink.
|
|
112
|
+
*/
|
|
113
|
+
export function approvePageHref(target, origin, userCode) {
|
|
114
|
+
if (typeof target !== "string")
|
|
115
|
+
return null;
|
|
116
|
+
let url;
|
|
117
|
+
try {
|
|
118
|
+
url = new URL(target);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (url.origin !== origin || url.username !== "" || url.password !== "")
|
|
124
|
+
return null;
|
|
125
|
+
if (url.pathname !== APPROVE_PATH || url.hash !== "")
|
|
126
|
+
return null;
|
|
127
|
+
const params = [...url.searchParams.entries()];
|
|
128
|
+
if (params.length !== 0 && !(params.length === 1 && params[0][0] === "code" && params[0][1] === userCode))
|
|
129
|
+
return null;
|
|
130
|
+
const plain = `${origin}${APPROVE_PATH}`;
|
|
131
|
+
const withCode = `${plain}?code=${userCode}`;
|
|
132
|
+
return url.href === plain || url.href === withCode ? url.href : null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* One of four kinds for the door's answer. `origin` is the endpoint the request
|
|
136
|
+
* went to, so a 409's open target can be held to it.
|
|
137
|
+
*/
|
|
138
|
+
export function classifyBootstrapAnswer(status, body, origin) {
|
|
139
|
+
if (status === 200) {
|
|
140
|
+
const { token, org_id: orgId, user_id: userId, mcp_endpoint: mcpEndpoint } = body;
|
|
141
|
+
if (typeof token !== "string" ||
|
|
142
|
+
!FLOWSPRITE_TOKEN_RE.test(token) ||
|
|
143
|
+
typeof orgId !== "string" ||
|
|
144
|
+
!ORG_ID_RE.test(orgId) ||
|
|
145
|
+
typeof userId !== "string" ||
|
|
146
|
+
!USER_ID_RE.test(userId) ||
|
|
147
|
+
mcpEndpoint !== `${origin}/api/mcp`) {
|
|
148
|
+
return STOP_SERVER_ERROR;
|
|
149
|
+
}
|
|
150
|
+
return { kind: "token", token, orgId, userId, mcpEndpoint };
|
|
151
|
+
}
|
|
152
|
+
const deviceCode = body.device_code;
|
|
153
|
+
const expiresIn = clampExpiresIn(body.expires_in);
|
|
154
|
+
const interval = intervalSeconds(body.interval, expiresIn);
|
|
155
|
+
if (status === 202) {
|
|
156
|
+
if (typeof deviceCode !== "string" || !DEVICE_CODE_RE.test(deviceCode))
|
|
157
|
+
return STOP_SERVER_ERROR;
|
|
158
|
+
return { kind: "poll", deviceCode, expiresIn, interval };
|
|
159
|
+
}
|
|
160
|
+
if (status === 409 && (body.error === "browser_required" || body.error === "github_scope_missing")) {
|
|
161
|
+
const userCode = body.user_code;
|
|
162
|
+
if (typeof deviceCode !== "string" ||
|
|
163
|
+
!DEVICE_CODE_RE.test(deviceCode) ||
|
|
164
|
+
typeof userCode !== "string" ||
|
|
165
|
+
!USER_CODE_RE.test(userCode)) {
|
|
166
|
+
return STOP_SERVER_ERROR;
|
|
167
|
+
}
|
|
168
|
+
const openTarget = approvePageHref(body.verification_uri_complete ?? body.verification_uri, origin, userCode);
|
|
169
|
+
if (openTarget === null)
|
|
170
|
+
return STOP_SERVER_ERROR;
|
|
171
|
+
return { kind: "browser", reason: body.error, deviceCode, userCode, openTarget, expiresIn, interval };
|
|
172
|
+
}
|
|
173
|
+
const code = body.error;
|
|
174
|
+
if (typeof code !== "string" || !STOP_CODES.has(code))
|
|
175
|
+
return STOP_SERVER_ERROR;
|
|
176
|
+
const retryAfter = body.retry_after;
|
|
177
|
+
return code === "in_progress" && typeof retryAfter === "number" && retryAfter > 0
|
|
178
|
+
? { kind: "stop", code, retryAfter }
|
|
179
|
+
: { kind: "stop", code: code };
|
|
180
|
+
}
|