flowsprite 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +260 -62
- package/dist/credentials.js +108 -0
- package/dist/doctor.js +17 -3
- package/dist/ghLogin.js +13 -6
- package/dist/login.js +148 -26
- package/dist/mcpClient.js +36 -1
- package/dist/messages.js +64 -2
- package/dist/setup.js +107 -7
- package/dist/sfCli.js +45 -17
- package/dist/usage.js +57 -32
- package/dist/version.js +1 -1
- package/dist/wiring.js +29 -10
- package/package.json +1 -1
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
CHANGED
|
@@ -52,7 +52,8 @@ const FLOWSPRITE_TOKEN_RE = /^fp_(?:user|live)_[A-Za-z0-9_-]{32}$/;
|
|
|
52
52
|
const ORG_ID_RE = /^org_[A-Za-z0-9]+$/;
|
|
53
53
|
const USER_ID_RE = /^user_[A-Za-z0-9]+$/;
|
|
54
54
|
const DEVICE_CODE_RE = /^[0-9a-f]{64}$/;
|
|
55
|
-
|
|
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}$/;
|
|
56
57
|
/** The approve page's path on the endpoint; the only thing a 409 may ask the CLI to open. */
|
|
57
58
|
const APPROVE_PATH = "/cli/auth";
|
|
58
59
|
/** The device-code TTL the endpoint documents (`expires_in: 900`) — and the ceiling. */
|
|
@@ -70,9 +71,15 @@ export function clampExpiresIn(claimed) {
|
|
|
70
71
|
? Math.min(MAX_EXPIRES_IN_SECONDS, Math.max(1, claimed))
|
|
71
72
|
: MAX_EXPIRES_IN_SECONDS;
|
|
72
73
|
}
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
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));
|
|
76
83
|
}
|
|
77
84
|
const STOP_CODES = new Set([
|
|
78
85
|
"in_progress",
|
|
@@ -103,7 +110,7 @@ const STOP_SERVER_ERROR = { kind: "stop", code: "server_error" };
|
|
|
103
110
|
* bearer (fix round 2 F6). The href is asserted to be one of the two exact
|
|
104
111
|
* forms, so nothing else can ever reach a sink.
|
|
105
112
|
*/
|
|
106
|
-
function approvePageHref(target, origin, userCode) {
|
|
113
|
+
export function approvePageHref(target, origin, userCode) {
|
|
107
114
|
if (typeof target !== "string")
|
|
108
115
|
return null;
|
|
109
116
|
let url;
|
|
@@ -144,7 +151,7 @@ export function classifyBootstrapAnswer(status, body, origin) {
|
|
|
144
151
|
}
|
|
145
152
|
const deviceCode = body.device_code;
|
|
146
153
|
const expiresIn = clampExpiresIn(body.expires_in);
|
|
147
|
-
const interval = intervalSeconds(body.interval);
|
|
154
|
+
const interval = intervalSeconds(body.interval, expiresIn);
|
|
148
155
|
if (status === 202) {
|
|
149
156
|
if (typeof deviceCode !== "string" || !DEVICE_CODE_RE.test(deviceCode))
|
|
150
157
|
return STOP_SERVER_ERROR;
|
package/dist/login.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
|
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";
|
|
3
4
|
/**
|
|
4
5
|
* RFC 8628 device-code login against `/api/cli/auth/{start,poll}`.
|
|
5
6
|
*
|
|
@@ -27,10 +28,42 @@ import { APPROVER_BLOCKED_NOTE, GH_DOOR_REMEDIES, GH_ROAD, inProgressRemedy, isL
|
|
|
27
28
|
*/
|
|
28
29
|
/** Seconds added to the poll interval each time the server says `slow_down`. */
|
|
29
30
|
const SLOW_DOWN_STEP_SECONDS = 5;
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
}
|
|
34
67
|
}
|
|
35
68
|
function failure(kind) {
|
|
36
69
|
return { ok: false, kind, message: LOGIN_REMEDIES[kind] };
|
|
@@ -90,31 +123,38 @@ async function startDeviceCode(deps) {
|
|
|
90
123
|
const userCode = start.body.user_code;
|
|
91
124
|
const completeUri = start.body.verification_uri_complete;
|
|
92
125
|
const fallbackUri = start.body.verification_uri;
|
|
93
|
-
if (typeof deviceCode !== "string" || typeof userCode !== "string") {
|
|
126
|
+
if (typeof deviceCode !== "string" || typeof userCode !== "string" || !USER_CODE_RE.test(userCode)) {
|
|
94
127
|
return failure("server_error");
|
|
95
128
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
|
101
135
|
if (openTarget === null)
|
|
102
|
-
return
|
|
136
|
+
return { ok: false, kind: "server_error", message: OPEN_TARGET_REFUSED };
|
|
103
137
|
// The device code's own lifetime bounds the loop, so a server that answers
|
|
104
138
|
// `authorization_pending` forever still ends — with the same sentence a real
|
|
105
139
|
// `expired_token` gets, because the window really has passed. CLAMPED TO THE
|
|
106
140
|
// REAL TTL (`clampExpiresIn`): a number this CLI reads off a response is what
|
|
107
141
|
// stands between a bad value and a terminal that polls for a year.
|
|
108
142
|
const expiresIn = clampExpiresIn(start.body.expires_in);
|
|
109
|
-
const interval = intervalSeconds(start.body.interval);
|
|
143
|
+
const interval = intervalSeconds(start.body.interval, expiresIn);
|
|
110
144
|
return { deviceCode, userCode, openTarget, expiresIn, interval };
|
|
111
145
|
}
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
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)}`);
|
|
115
154
|
deps.writer.out("Waiting for you to approve this login...");
|
|
116
155
|
deps.writer.out(timeLeftLine(payload.expiresIn));
|
|
117
|
-
|
|
156
|
+
deps.writer.out(waitHintLine(payload.expiresIn));
|
|
157
|
+
if (open && deps.openUrl) {
|
|
118
158
|
try {
|
|
119
159
|
deps.openUrl(payload.openTarget);
|
|
120
160
|
}
|
|
@@ -124,15 +164,19 @@ function announceBrowser(deps, payload) {
|
|
|
124
164
|
}
|
|
125
165
|
}
|
|
126
166
|
/** Poll `/api/cli/auth/poll` until the code is approved and minted, refused, or expired. */
|
|
127
|
-
async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
|
|
167
|
+
async function pollLoop(deps, { deviceCode, expiresIn, interval, openTarget }) {
|
|
128
168
|
let intervalSeconds = interval;
|
|
129
169
|
let waitedSeconds = 0;
|
|
130
170
|
let approverNotePrinted = false;
|
|
171
|
+
let reprintAt = REPRINT_EVERY_SECONDS;
|
|
131
172
|
for (;;) {
|
|
132
173
|
if (waitedSeconds >= expiresIn)
|
|
133
174
|
return failure("expired_token");
|
|
134
|
-
|
|
135
|
-
|
|
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;
|
|
136
180
|
const poll = await postJson(deps, `${deps.origin}/api/cli/auth/poll`, {
|
|
137
181
|
device_code: deviceCode,
|
|
138
182
|
});
|
|
@@ -149,7 +193,17 @@ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
|
|
|
149
193
|
typeof mcpEndpoint !== "string") {
|
|
150
194
|
return failure("server_error");
|
|
151
195
|
}
|
|
152
|
-
|
|
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
|
+
};
|
|
153
207
|
}
|
|
154
208
|
const kind = poll.body.error;
|
|
155
209
|
if (typeof kind === "string" && POLL_CONTINUE_KINDS.has(kind)) {
|
|
@@ -166,7 +220,15 @@ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
|
|
|
166
220
|
// ponytail: time left is expires_in minus the SUM OF SLEEPS, so fetch latency
|
|
167
221
|
// and a suspended laptop are not counted and it can overstate; thread `now`
|
|
168
222
|
// into LoginDeps for a wall-clock countdown if that ever reads wrong.
|
|
169
|
-
deps.progress
|
|
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
|
+
}
|
|
170
232
|
if (kind === "slow_down") {
|
|
171
233
|
// The server has ALREADY raised its own required interval by this much;
|
|
172
234
|
// raising ours to match is what stops the next poll earning another one.
|
|
@@ -178,12 +240,65 @@ async function pollLoop(deps, { deviceCode, expiresIn, interval }) {
|
|
|
178
240
|
return failure(refusalKind(poll.body));
|
|
179
241
|
}
|
|
180
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
|
+
}
|
|
181
292
|
export async function deviceCodeLogin(deps) {
|
|
293
|
+
const resumed = await resumePending(deps);
|
|
294
|
+
if (resumed !== null)
|
|
295
|
+
return resumed;
|
|
182
296
|
const started = await startDeviceCode(deps);
|
|
183
297
|
if ("ok" in started)
|
|
184
298
|
return started;
|
|
299
|
+
savePending(deps, started);
|
|
185
300
|
announceBrowser(deps, started);
|
|
186
|
-
return
|
|
301
|
+
return pollAndSettle(deps, started);
|
|
187
302
|
}
|
|
188
303
|
/**
|
|
189
304
|
* `gh auth token`, read by shape. Null on every way it can fail — a non-zero
|
|
@@ -244,6 +359,11 @@ function stop(answer) {
|
|
|
244
359
|
* (`redact.ts`) is the second net, not the first.
|
|
245
360
|
*/
|
|
246
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;
|
|
247
367
|
if (!deps.isOnPath("gh")) {
|
|
248
368
|
deps.writer.out(GH_ROAD.notFound);
|
|
249
369
|
return null;
|
|
@@ -267,8 +387,9 @@ export async function ghCliLogin(deps) {
|
|
|
267
387
|
case "token":
|
|
268
388
|
return { ok: true, token: answer.token, orgId: answer.orgId, userId: answer.userId, mcpEndpoint: answer.mcpEndpoint };
|
|
269
389
|
case "poll":
|
|
390
|
+
savePending(deps, answer);
|
|
270
391
|
deps.writer.out(GH_ROAD.seedPending);
|
|
271
|
-
return
|
|
392
|
+
return pollAndSettle(deps, answer);
|
|
272
393
|
case "browser":
|
|
273
394
|
if (answer.reason === "github_scope_missing") {
|
|
274
395
|
deps.writer.out(GH_ROAD.scopeMissing);
|
|
@@ -277,8 +398,9 @@ export async function ghCliLogin(deps) {
|
|
|
277
398
|
else {
|
|
278
399
|
deps.writer.out(GH_ROAD.browserRequired);
|
|
279
400
|
}
|
|
401
|
+
savePending(deps, answer);
|
|
280
402
|
announceBrowser(deps, answer);
|
|
281
|
-
return
|
|
403
|
+
return pollAndSettle(deps, answer);
|
|
282
404
|
default:
|
|
283
405
|
return stop(answer);
|
|
284
406
|
}
|
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 —
|
|
@@ -14,6 +34,19 @@ function fail(kind, message, code) {
|
|
|
14
34
|
const safe = redact(message);
|
|
15
35
|
return code === undefined ? { ok: false, kind, message: safe } : { ok: false, kind, message: safe, code };
|
|
16
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
|
+
}
|
|
17
50
|
export function createMcpClient(deps) {
|
|
18
51
|
const url = `${deps.origin}/api/mcp`;
|
|
19
52
|
let nextId = 0;
|
|
@@ -70,7 +103,9 @@ export function createMcpClient(deps) {
|
|
|
70
103
|
// The tool's own refusal text is the whole value of this branch — an
|
|
71
104
|
// `isError` result with no text would otherwise be an empty message.
|
|
72
105
|
const text = content.find((c) => typeof c.text === "string")?.text;
|
|
73
|
-
|
|
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 };
|
|
74
109
|
}
|
|
75
110
|
return r.structuredContent === undefined
|
|
76
111
|
? { ok: true, content }
|
package/dist/messages.js
CHANGED
|
@@ -20,10 +20,52 @@ export const LOGIN_REMEDIES = {
|
|
|
20
20
|
* minute", not 2), and anything under a minute says so rather than "0 minutes".
|
|
21
21
|
*/
|
|
22
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) {
|
|
23
27
|
const minutes = Math.floor(seconds / 60);
|
|
24
|
-
|
|
25
|
-
|
|
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}`}`;
|
|
26
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.";
|
|
27
69
|
/**
|
|
28
70
|
* FLO-509 — printed once per login when a pending poll carries
|
|
29
71
|
* `approver_blocked: true`.
|
|
@@ -37,6 +79,26 @@ export function timeLeftLine(seconds) {
|
|
|
37
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.";
|
|
38
80
|
/** The wire values that mean "keep polling", kept apart from the terminal ones above. */
|
|
39
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
|
+
]);
|
|
40
102
|
/**
|
|
41
103
|
* The kinds that can arrive FROM THE WIRE — a strict subset of the remedy table.
|
|
42
104
|
*
|
package/dist/setup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toolJson } from "./mcpClient.js";
|
|
2
|
+
import { clock, minutesSpan } from "./messages.js";
|
|
2
3
|
import { printable } from "./redact.js";
|
|
3
4
|
/**
|
|
4
5
|
* The setup plane, driven from a terminal.
|
|
@@ -172,6 +173,101 @@ export function planBindArgs(input) {
|
|
|
172
173
|
args: { sandboxId: connection, installationId, repoOwner: parts[0], repoName: parts[1] },
|
|
173
174
|
};
|
|
174
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* C6 — the ONE instruction block a link step prints, CLI-owned. The server's
|
|
178
|
+
* `instructions` field is deliberately NOT printed: it repeated this sentence
|
|
179
|
+
* and named an MCP tool (`setup_status`) a terminal user cannot call.
|
|
180
|
+
*/
|
|
181
|
+
export function linkInstructions(url, expiresInSeconds) {
|
|
182
|
+
const seconds = expiresInSeconds ?? LINK_POLL_MAX_MS / 1000;
|
|
183
|
+
return [
|
|
184
|
+
"Open this link in a browser signed in to Flowsprite as the same account, in this same workspace:",
|
|
185
|
+
` ${printable(url)}`,
|
|
186
|
+
LINK_SECRET_LINE,
|
|
187
|
+
`The link works once and expires in ${minutesSpan(seconds)}.`,
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
/** Card eecc9d6f N12 — directly under every setup link. */
|
|
191
|
+
export const LINK_SECRET_LINE = "This link is a one-time secret for your account — don't paste it in chat or share it.";
|
|
192
|
+
// ------------------------------------------------------------- setup's steps (N3)
|
|
193
|
+
/**
|
|
194
|
+
* Card eecc9d6f N3 — the three steps `setup` names, ONE table. A ladder rung
|
|
195
|
+
* maps onto a step by `stepOfRung`; `skipped` is what a step that needed no
|
|
196
|
+
* work this run says (step 1's is built at the call, it names the login).
|
|
197
|
+
*/
|
|
198
|
+
export const SETUP_STEPS = [
|
|
199
|
+
{ label: "approve this login", skipped: "" },
|
|
200
|
+
{ label: "connect Salesforce", skipped: "Salesforce is already connected" },
|
|
201
|
+
{ label: "connect GitHub + create the private repo", skipped: "GitHub is already connected and every org has its repository" },
|
|
202
|
+
];
|
|
203
|
+
/**
|
|
204
|
+
* The step a rung belongs to. `first_sync` comes AFTER step 3 (null: it opens
|
|
205
|
+
* no step, but reaching it means steps 2 and 3 needed nothing); `done` and
|
|
206
|
+
* `web_only` open none.
|
|
207
|
+
*/
|
|
208
|
+
export function stepOfRung(kind) {
|
|
209
|
+
if (kind === "connect_salesforce")
|
|
210
|
+
return 2;
|
|
211
|
+
if (kind === "connect_github" || kind === "bind_repo")
|
|
212
|
+
return 3;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Prints "Step n/3: <label>" the first time a step is entered and "Step n/3:
|
|
217
|
+
* skipped — <why>" for every earlier step the run never entered. `current()` is
|
|
218
|
+
* the step a rerun would be at (N14).
|
|
219
|
+
*/
|
|
220
|
+
export function createStepTracker(writer) {
|
|
221
|
+
let at = 0;
|
|
222
|
+
const total = SETUP_STEPS.length;
|
|
223
|
+
const skipTo = (step) => {
|
|
224
|
+
for (let n = at + 1; n < step; n++)
|
|
225
|
+
writer.out(`Step ${n}/${total}: skipped — ${SETUP_STEPS[n - 1].skipped}`);
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
current: () => Math.min(Math.max(at, 1), total),
|
|
229
|
+
enter(step) {
|
|
230
|
+
if (step <= at)
|
|
231
|
+
return;
|
|
232
|
+
skipTo(step);
|
|
233
|
+
writer.out(`Step ${step}/${total}: ${SETUP_STEPS[step - 1].label}`);
|
|
234
|
+
at = step;
|
|
235
|
+
},
|
|
236
|
+
skip(step, why) {
|
|
237
|
+
if (step <= at)
|
|
238
|
+
return;
|
|
239
|
+
skipTo(step);
|
|
240
|
+
writer.out(`Step ${step}/${total}: skipped — ${why}`);
|
|
241
|
+
at = step;
|
|
242
|
+
},
|
|
243
|
+
/** Past the last step (the first sync): every step not entered is reported skipped. */
|
|
244
|
+
finishSteps() {
|
|
245
|
+
skipTo(total + 1);
|
|
246
|
+
at = Math.max(at, total);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Card eecc9d6f N4 — printed once, before the first wait on the browser. The
|
|
252
|
+
* numbers are the code's own bounds: the device code's TTL ceiling and the link
|
|
253
|
+
* step's wait budget. `loginSeconds` is null when this run will NOT log in (step
|
|
254
|
+
* 1 skipped, G9a): the notice then names only the links.
|
|
255
|
+
*/
|
|
256
|
+
export function browserWaitNotice(loginSeconds) {
|
|
257
|
+
const links = `up to ${minutesSpan(LINK_POLL_MAX_MS / 1000)} for each link`;
|
|
258
|
+
return (`Setup waits on your browser: ${loginSeconds === null ? links : `up to ${minutesSpan(loginSeconds)} to approve the login, and ${links}`}. ` +
|
|
259
|
+
"Keep this command running; if it stops, rerun `flowsprite setup` and it continues from the checklist's current step.");
|
|
260
|
+
}
|
|
261
|
+
/** Card eecc9d6f N14 — the last line of every `setup` run that does not succeed. */
|
|
262
|
+
export function rerunHint(login, step) {
|
|
263
|
+
const where = `it picks up at step ${step} (or later, if you finish that step in the browser first)`;
|
|
264
|
+
if (login === "stored")
|
|
265
|
+
return `Rerun \`flowsprite setup\` to continue — your login is saved, and ${where}.`;
|
|
266
|
+
if (login === "supplied") {
|
|
267
|
+
return `Rerun \`flowsprite setup\` with the same --token or FLOWSPRITE_TOKEN to continue (nothing was saved; \`flowsprite login --token <t> --save\` stores it) — ${where}.`;
|
|
268
|
+
}
|
|
269
|
+
return "Rerun `flowsprite setup` to continue — it starts at step 1, logging this terminal in.";
|
|
270
|
+
}
|
|
175
271
|
/** The `purpose` a minted link carries in `pendingLinks` — the set is `convex/setupLinks.ts purposeValidator`. */
|
|
176
272
|
const LINK_PURPOSE = {
|
|
177
273
|
salesforce: "salesforce_connect",
|
|
@@ -210,8 +306,7 @@ export function idsOf(provider, status) {
|
|
|
210
306
|
* THE RULE: the writer keeps newlines for whole documents and multi-line
|
|
211
307
|
* refusals; a field spliced INTO a sentence is scrubbed here, or a newline in
|
|
212
308
|
* it forges a line (driven: a displayName "Acme\nDone." printed an exact
|
|
213
|
-
* `Done.` line). `
|
|
214
|
-
* and stay as they are.
|
|
309
|
+
* `Done.` line). `nextAction.text` is a whole-field print and stays as it is.
|
|
215
310
|
*/
|
|
216
311
|
function connectedNames(provider, status) {
|
|
217
312
|
const root = asObject(status);
|
|
@@ -274,7 +369,7 @@ export async function runLinkStep(deps, tool, args, provider, before) {
|
|
|
274
369
|
return { kind: "failed", message: minted.message };
|
|
275
370
|
const payload = asObject(toolJson(minted));
|
|
276
371
|
if (payload === null || typeof payload.url !== "string") {
|
|
277
|
-
return { kind: "failed", message:
|
|
372
|
+
return { kind: "failed", message: "Flowsprite did not return a link for this step. Run `flowsprite status` and try again." };
|
|
278
373
|
}
|
|
279
374
|
const url = payload.url;
|
|
280
375
|
const expiresAt = typeof payload.expiresAt === "number" ? payload.expiresAt : null;
|
|
@@ -290,10 +385,11 @@ export async function runLinkStep(deps, tool, args, provider, before) {
|
|
|
290
385
|
const skew = expiresAt === null || ttlMs === null ? null : expiresAt - ttlMs - mintedAt;
|
|
291
386
|
/** `now` on the server's clock, as far as the mint let us measure it. */
|
|
292
387
|
const serverNow = (now) => now + (skew ?? 0);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
388
|
+
const expiresInSeconds = typeof payload.expiresInSeconds === "number" && payload.expiresInSeconds > 0
|
|
389
|
+
? payload.expiresInSeconds
|
|
390
|
+
: null;
|
|
391
|
+
for (const line of linkInstructions(url, expiresInSeconds))
|
|
392
|
+
deps.writer.out(line);
|
|
297
393
|
deps.writer.out("Waiting for you to finish in the browser...");
|
|
298
394
|
let used = false;
|
|
299
395
|
const deadline = deps.now() + LINK_POLL_MAX_MS;
|
|
@@ -339,6 +435,10 @@ export async function runLinkStep(deps, tool, args, provider, before) {
|
|
|
339
435
|
return { kind: "expired", used };
|
|
340
436
|
if (now >= deadline)
|
|
341
437
|
return { kind: "timeout", used };
|
|
438
|
+
// C8: the link's own expiry by the server's clock when the mint named one,
|
|
439
|
+
// else the CLI's wait budget — whichever this step will actually stop at.
|
|
440
|
+
const leftMs = expiresAt === null ? deadline - now : Math.min(expiresAt - serverNow(now), deadline - now);
|
|
441
|
+
deps.progress?.(`Link expires in ${clock(leftMs / 1000)}`);
|
|
342
442
|
}
|
|
343
443
|
}
|
|
344
444
|
// ---------------------------------------------------------- repository creation
|