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.
- package/README.md +8 -5
- package/dist/bin.js +45 -45
- package/dist/cli.js +378 -92
- package/dist/ghLogin.js +173 -0
- package/dist/login.js +157 -26
- package/dist/mcpClient.js +3 -0
- package/dist/messages.js +85 -0
- package/dist/output.js +55 -3
- package/dist/redact.js +51 -23
- package/dist/setup.js +178 -13
- package/dist/sfCli.js +151 -0
- package/dist/status.js +11 -1
- package/dist/usage.js +23 -8
- package/dist/version.js +1 -1
- package/dist/wiring.js +77 -0
- package/package.json +2 -2
package/dist/ghLogin.js
ADDED
|
@@ -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 {
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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. */
|