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/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. */
|
package/dist/redact.js
CHANGED
|
@@ -1,41 +1,55 @@
|
|
|
1
|
+
import { GH_TOKEN_RE } from "./ghLogin.js";
|
|
1
2
|
/**
|
|
2
|
-
* Rewrite every
|
|
3
|
+
* Rewrite every credential this CLI can hold, wherever it appears in a string,
|
|
4
|
+
* to a stub that keeps the prefix and the last few characters.
|
|
3
5
|
*
|
|
4
6
|
* NO IMPORTABLE REDACTOR EXISTS IN THIS REPO (measured at f301c49: `fp_user_`
|
|
5
7
|
* appears in lib/ and scripts/ only as comments and a type guard), so this is
|
|
6
8
|
* the CLI's own, re-stated rather than shared.
|
|
7
9
|
*
|
|
8
|
-
* THE
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
10
|
+
* THE RULE FOR WHAT IS COVERED: every credential this CLI can hold, by the
|
|
11
|
+
* grammar of whoever mints it — the Flowsprite token grammar from the minter
|
|
12
|
+
* (`generateRawToken` in `convex/lib/mcpCrypto.ts`: `fp_live_` or `fp_user_`
|
|
13
|
+
* followed by characters from `[A-Za-z0-9-_]`; the suffix alphabet CONTAINS
|
|
14
|
+
* `_`, so the match is greedy over that class — stopping at the first
|
|
15
|
+
* underscore would leave most of the secret on the screen), and the GitHub
|
|
16
|
+
* token grammar from gh's own prefixes (`GH_TOKEN_RE`, `cli/src/ghLogin.ts`:
|
|
17
|
+
* `gho_`/`ghp_`/`ghu_`/`ghs_`/`ghr_` and `github_pat_`, at a word boundary with
|
|
18
|
+
* a minimum suffix, since FLO-517 reads `gh auth token` and sends it as a
|
|
19
|
+
* bearer). The members are enumerated in the two regexes and nowhere else.
|
|
20
|
+
* THE WORD BOUNDARY IS A TRADE: a GitHub token glued to a word character
|
|
21
|
+
* (`9gho_…`, `_gho_…`) is NOT redacted, so that `highs_score` is not (the AC2
|
|
22
|
+
* oracle's control). That is acceptable because this redactor is the SECOND
|
|
23
|
+
* net for that token: by construction it never reaches the writer at all —
|
|
24
|
+
* `cli/src/login.ts` sends it as a request header and prints only fixed
|
|
25
|
+
* sentences, which the same oracle pins over every arm.
|
|
26
|
+
* This is NOT a general secret scrubber: a value with no marker to match on is
|
|
27
|
+
* kept off the screen BY CONSTRUCTION instead — the device code (64 hex
|
|
28
|
+
* characters) is never passed to the writer by `cli/src/login.ts`, which the
|
|
29
|
+
* AC3 acceptance oracle pins (`printedTheDeviceCode: false`). A setup-link token
|
|
30
|
+
* is the same shape and the same story, except that one is DELIBERATELY shown:
|
|
31
|
+
* it is the thing the person has to open.
|
|
25
32
|
*
|
|
26
33
|
* Applied by the shared writer (`cli/src/output.ts`), so the redaction is a
|
|
27
34
|
* property of the ONE place lines leave this process, not of each caller
|
|
28
35
|
* remembering. A caller that bypassed the writer would bypass this.
|
|
29
36
|
*/
|
|
30
|
-
const TOKEN =
|
|
37
|
+
const TOKEN = new RegExp(`fp_(?:user|live)_[A-Za-z0-9_-]+|${GH_TOKEN_RE.source}`, "g");
|
|
31
38
|
/** How many trailing characters survive, so a person can tell two tokens apart. */
|
|
32
39
|
const KEEP_LAST = 4;
|
|
40
|
+
/** The marker the grammar pinned: `fp_user_`/`fp_live_`, `gho_`-style, or `github_pat_`. */
|
|
41
|
+
function prefixLength(match) {
|
|
42
|
+
if (match.startsWith("fp_"))
|
|
43
|
+
return "fp_user_".length;
|
|
44
|
+
if (match.startsWith("github_pat_"))
|
|
45
|
+
return "github_pat_".length;
|
|
46
|
+
return "gho_".length;
|
|
47
|
+
}
|
|
33
48
|
export function redact(text) {
|
|
34
49
|
return text.replace(TOKEN, (match) => {
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const suffix = match.slice("fp_user_".length);
|
|
50
|
+
// The prefix is a fixed width per family, so this is a slice, not a search.
|
|
51
|
+
const prefix = match.slice(0, prefixLength(match));
|
|
52
|
+
const suffix = match.slice(prefix.length);
|
|
39
53
|
// A suffix at or under the keep length would be echoed whole. It cannot be a
|
|
40
54
|
// real token (the minter always emits 32), and truncating to nothing would
|
|
41
55
|
// be indistinguishable from an empty value — so the stub says "..." and
|
|
@@ -45,3 +59,17 @@ export function redact(text) {
|
|
|
45
59
|
return `${prefix}...${suffix.slice(-KEEP_LAST)}`;
|
|
46
60
|
});
|
|
47
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Server-supplied text made safe to print: every C0 control (0x00-0x1F), DEL
|
|
64
|
+
* (0x7F) and C1 control (0x80-0x9F) becomes a space, so a stored error cannot
|
|
65
|
+
* move the cursor, recolour the terminal or forge a second line.
|
|
66
|
+
*
|
|
67
|
+
* It lives beside `redact` because the two are applied together at the one
|
|
68
|
+
* place a line leaves the process, the writer (`cli/src/output.ts`), per line.
|
|
69
|
+
* A newline is a C0 too, which is why the writer splits on it BEFORE calling
|
|
70
|
+
* this: the endpoint pretty-prints tool JSON across lines, and those lines are
|
|
71
|
+
* meant to stay lines.
|
|
72
|
+
*/
|
|
73
|
+
export function printable(text) {
|
|
74
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
75
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { toolJson } from "./mcpClient.js";
|
|
2
|
+
import { clock, minutesSpan } from "./messages.js";
|
|
3
|
+
import { printable } from "./redact.js";
|
|
2
4
|
/**
|
|
3
5
|
* The setup plane, driven from a terminal.
|
|
4
6
|
*
|
|
@@ -171,46 +173,309 @@ export function planBindArgs(input) {
|
|
|
171
173
|
args: { sandboxId: connection, installationId, repoOwner: parts[0], repoName: parts[1] },
|
|
172
174
|
};
|
|
173
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
|
+
}
|
|
271
|
+
/** The `purpose` a minted link carries in `pendingLinks` — the set is `convex/setupLinks.ts purposeValidator`. */
|
|
272
|
+
const LINK_PURPOSE = {
|
|
273
|
+
salesforce: "salesforce_connect",
|
|
274
|
+
github: "github_install",
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* The provider's ids in a `setup_status` payload, as strings.
|
|
278
|
+
*
|
|
279
|
+
* github: `installations[].installationId` — a NUMBER in the tool's validator
|
|
280
|
+
* (`convex/mcpSetup.ts installationDetail`), stringified so one set type holds
|
|
281
|
+
* both. salesforce: `connections[].id` — a document id, already a string.
|
|
282
|
+
* Anything else-shaped is dropped, never coerced from `undefined` to "undefined".
|
|
283
|
+
*/
|
|
284
|
+
export function idsOf(provider, status) {
|
|
285
|
+
const root = asObject(status);
|
|
286
|
+
if (root === null)
|
|
287
|
+
return [];
|
|
288
|
+
const out = [];
|
|
289
|
+
if (provider === "github") {
|
|
290
|
+
for (const i of asArray(root.installations)) {
|
|
291
|
+
if (typeof i.installationId === "number")
|
|
292
|
+
out.push(String(i.installationId));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
for (const c of asArray(root.connections)) {
|
|
297
|
+
if (typeof c.id === "string" && c.id.length > 0)
|
|
298
|
+
out.push(c.id);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* What is already connected, for the sentence printed before a link is minted.
|
|
305
|
+
*
|
|
306
|
+
* THE RULE: the writer keeps newlines for whole documents and multi-line
|
|
307
|
+
* refusals; a field spliced INTO a sentence is scrubbed here, or a newline in
|
|
308
|
+
* it forges a line (driven: a displayName "Acme\nDone." printed an exact
|
|
309
|
+
* `Done.` line). `nextAction.text` is a whole-field print and stays as it is.
|
|
310
|
+
*/
|
|
311
|
+
function connectedNames(provider, status) {
|
|
312
|
+
const root = asObject(status);
|
|
313
|
+
if (root === null)
|
|
314
|
+
return [];
|
|
315
|
+
const rows = provider === "github" ? asArray(root.installations) : asArray(root.connections);
|
|
316
|
+
const field = provider === "github" ? "accountLogin" : "displayName";
|
|
317
|
+
return rows.flatMap((r) => (typeof r[field] === "string" ? [printable(r[field])] : []));
|
|
318
|
+
}
|
|
174
319
|
/**
|
|
175
320
|
* Run one browser-leg step: mint the link, show it, then watch `setup_status`
|
|
176
|
-
* until
|
|
321
|
+
* until a NEW provider id appears.
|
|
322
|
+
*
|
|
323
|
+
* DONE IS A NEW ID, NEVER THE LADDER MOVING. The previous version returned
|
|
324
|
+
* `done` when the planner's next step was no longer this one — which is also
|
|
325
|
+
* true from the FIRST poll whenever this step was never the next one (GitHub
|
|
326
|
+
* already connected, Salesforce not yet connected, a second org being added),
|
|
327
|
+
* so `connect github` printed "Done." after five seconds with nothing done and a
|
|
328
|
+
* live-link slot burnt. So the id set is read BEFORE the mint (`before` is the
|
|
329
|
+
* payload the caller already holds, else read here), and done ⇔ some id in a
|
|
330
|
+
* poll is outside that set. A link that is CONSUMED is not done either: it is
|
|
331
|
+
* consumed when the person clicks Go, before the provider round-trip, and
|
|
332
|
+
* re-authorizing an org that is already connected REFRESHES its row without a
|
|
333
|
+
* new id — so a consumed link is reported as progress, and the end-of-wait
|
|
334
|
+
* sentence says whether the link was used.
|
|
177
335
|
*
|
|
178
336
|
* THE STOP CONDITIONS ARE THREE AND THEY ARE DIFFERENT. The link's own
|
|
179
|
-
* `expiresAt` passing means the person must be handed a fresh link (
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* read from the PLANNER moving on, not from a field this module interprets
|
|
183
|
-
* itself.
|
|
337
|
+
* `expiresAt` passing means the person must be handed a fresh link (`expired`);
|
|
338
|
+
* the overall budget running out means the CLI stops waiting but the link may
|
|
339
|
+
* still be good (`timeout`); and `done` is the new id above.
|
|
184
340
|
*/
|
|
185
|
-
export async function runLinkStep(deps, tool, args,
|
|
341
|
+
export async function runLinkStep(deps, tool, args, provider, before) {
|
|
342
|
+
if (before === undefined) {
|
|
343
|
+
const read = await deps.client.callTool("setup_status", {});
|
|
344
|
+
if (!read.ok)
|
|
345
|
+
return { kind: "failed", message: read.message };
|
|
346
|
+
before = toolJson(read);
|
|
347
|
+
}
|
|
348
|
+
const already = new Set(idsOf(provider, before));
|
|
349
|
+
const connected = asObject(asObject(asObject(before)?.workspace)?.[provider])?.connected === true;
|
|
350
|
+
// A snapshot that contradicts itself is not a baseline: "connected" with no
|
|
351
|
+
// row listed means a NEW row could not be told from the one it forgot, so
|
|
352
|
+
// this fails closed rather than minting a link it could never resolve.
|
|
353
|
+
if (connected && already.size === 0) {
|
|
354
|
+
return {
|
|
355
|
+
kind: "failed",
|
|
356
|
+
message: `Flowsprite says ${provider === "github" ? "GitHub" : "a Salesforce org"} is connected to this workspace but lists nothing for it. Run \`flowsprite status\` and try again.`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (already.size > 0) {
|
|
360
|
+
const names = connectedNames(provider, before);
|
|
361
|
+
const who = names.length > 0 ? ` (${names.join(", ")})` : "";
|
|
362
|
+
deps.writer.out(provider === "github"
|
|
363
|
+
? `GitHub is already connected to this workspace${who}. This link installs the Flowsprite app on another account or organization.`
|
|
364
|
+
: `A Salesforce org is already connected to this workspace${who}. This link connects another org; signing in to one that is already connected only refreshes it.`);
|
|
365
|
+
}
|
|
366
|
+
const mintedAt = deps.now();
|
|
186
367
|
const minted = await deps.client.callTool(tool, args);
|
|
187
368
|
if (!minted.ok)
|
|
188
369
|
return { kind: "failed", message: minted.message };
|
|
189
370
|
const payload = asObject(toolJson(minted));
|
|
190
371
|
if (payload === null || typeof payload.url !== "string") {
|
|
191
|
-
return { kind: "failed", message:
|
|
372
|
+
return { kind: "failed", message: "Flowsprite did not return a link for this step. Run `flowsprite status` and try again." };
|
|
192
373
|
}
|
|
193
374
|
const url = payload.url;
|
|
194
375
|
const expiresAt = typeof payload.expiresAt === "number" ? payload.expiresAt : null;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
376
|
+
// Server clock minus ours, latency-inclusive (the mint's round trip is folded
|
|
377
|
+
// in, which only makes the cutoff below more conservative). The mint answers
|
|
378
|
+
// `expiresAt = serverNow + ttl` AND the ttl itself (`expiresInSeconds`, both
|
|
379
|
+
// tools in convex/mcpSetup.ts — pinned by setup.test.ts), so the server's
|
|
380
|
+
// "now" is read off the response rather than mirrored as a constant here. A
|
|
381
|
+
// mint without the ttl gives NO skew (null): the used inference below is then
|
|
382
|
+
// skipped entirely — the CLI clock alone cannot tell an expiry from a use —
|
|
383
|
+
// and expiry falls back to the CLI clock.
|
|
384
|
+
const ttlMs = typeof payload.expiresInSeconds === "number" ? payload.expiresInSeconds * 1000 : null;
|
|
385
|
+
const skew = expiresAt === null || ttlMs === null ? null : expiresAt - ttlMs - mintedAt;
|
|
386
|
+
/** `now` on the server's clock, as far as the mint let us measure it. */
|
|
387
|
+
const serverNow = (now) => now + (skew ?? 0);
|
|
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);
|
|
199
393
|
deps.writer.out("Waiting for you to finish in the browser...");
|
|
394
|
+
let used = false;
|
|
200
395
|
const deadline = deps.now() + LINK_POLL_MAX_MS;
|
|
201
396
|
for (;;) {
|
|
202
397
|
await deps.sleep(LINK_POLL_INTERVAL_MS);
|
|
203
398
|
const status = await deps.client.callTool("setup_status", {});
|
|
204
399
|
if (!status.ok)
|
|
205
400
|
return { kind: "failed", message: status.message };
|
|
206
|
-
|
|
401
|
+
const now = deps.now();
|
|
402
|
+
const current = toolJson(status);
|
|
403
|
+
// ponytail: done is a WORKSPACE-WIDE new provider id, so a connect finished
|
|
404
|
+
// by another member during this wait is attributed to this link, and two
|
|
405
|
+
// same-purpose links minted in the same millisecond share the (purpose,
|
|
406
|
+
// expiresAt) fingerprint below. A per-link id in the mint response and in
|
|
407
|
+
// `pendingLinks` closes both; that is a server change (DEBT row), not this card.
|
|
408
|
+
if (idsOf(provider, current).some((id) => !already.has(id)))
|
|
207
409
|
return { kind: "done" };
|
|
410
|
+
// The minted link is matched by (purpose, expiresAt). `pendingLinks` lists
|
|
411
|
+
// only LIVE links by the SERVER'S clock, so a link that has expired leaves it
|
|
412
|
+
// exactly like one that was opened. Absence is evidence of a use only on a
|
|
413
|
+
// poll taken more than one poll interval before `expiresAt` by the SERVER's
|
|
414
|
+
// clock (ours plus the measured skew) — later than that, an expiry reads as
|
|
415
|
+
// a use. Only when the mint gave a skew to measure that by, and only when the
|
|
416
|
+
// poll actually CARRIED a `pendingLinks` array: an omitted, null or non-array
|
|
417
|
+
// field says nothing about the row.
|
|
418
|
+
const pending = asObject(current)?.pendingLinks;
|
|
419
|
+
if (!used &&
|
|
420
|
+
expiresAt !== null &&
|
|
421
|
+
skew !== null &&
|
|
422
|
+
serverNow(now) < expiresAt - LINK_POLL_INTERVAL_MS &&
|
|
423
|
+
Array.isArray(pending)) {
|
|
424
|
+
const mine = asArray(pending).some((l) => l.purpose === LINK_PURPOSE[provider] && l.expiresAt === expiresAt);
|
|
425
|
+
if (!mine) {
|
|
426
|
+
used = true;
|
|
427
|
+
deps.writer.out("Link opened — finishing the connection in the browser...");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
208
430
|
// The link check comes AFTER the status read, so a link that expired in the
|
|
209
|
-
// same tick as the
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
431
|
+
// same tick as the id appearing is reported as the completion it was. Expiry
|
|
432
|
+
// is judged on the SERVER's clock (the one that drops the row), the same
|
|
433
|
+
// estimate the used cutoff uses; the deadline stays a CLI budget.
|
|
434
|
+
if (expiresAt !== null && serverNow(now) >= expiresAt)
|
|
435
|
+
return { kind: "expired", used };
|
|
213
436
|
if (now >= deadline)
|
|
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)}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// ---------------------------------------------------------- repository creation
|
|
445
|
+
/** How long `setup` waits for a repository it just asked Flowsprite to create (FLO-511 DM5). */
|
|
446
|
+
export const REPO_WAIT_MAX_MS = 180_000;
|
|
447
|
+
/**
|
|
448
|
+
* Wait for a repository `setup_bind_repo` has started creating.
|
|
449
|
+
*
|
|
450
|
+
* WHY THIS EXISTS: `hasBinding` is already true while `bootstrapState` is
|
|
451
|
+
* "creating", so without it the ladder's very next read names the first sync,
|
|
452
|
+
* and `sfdc_trigger_sync` refuses a repository that is still being created.
|
|
453
|
+
*
|
|
454
|
+
* The budget is counted in SLEPT milliseconds, not the clock: the sleeps are the
|
|
455
|
+
* whole of the wait this module adds, and counting them keeps the bound exact
|
|
456
|
+
* under an injected sleep that returns at once.
|
|
457
|
+
*/
|
|
458
|
+
export async function waitForRepository(deps, connectionId) {
|
|
459
|
+
// READ FIRST, then sleep: a repository that finished during the bind's own
|
|
460
|
+
// round trip is not made to wait five seconds for nothing.
|
|
461
|
+
for (let slept = 0;; slept += LINK_POLL_INTERVAL_MS) {
|
|
462
|
+
const status = await deps.client.callTool("setup_status", {});
|
|
463
|
+
if (!status.ok)
|
|
464
|
+
return { kind: "error", message: status.message };
|
|
465
|
+
const conn = asArray(asObject(toolJson(status))?.connections).find((c) => c.id === connectionId);
|
|
466
|
+
const state = conn?.bootstrapState;
|
|
467
|
+
if (state === "ready")
|
|
468
|
+
return { kind: "ready" };
|
|
469
|
+
if (state === "failed") {
|
|
470
|
+
const error = typeof conn?.bootstrapError === "string" && conn.bootstrapError.length > 0
|
|
471
|
+
? conn.bootstrapError
|
|
472
|
+
: "no reason was given.";
|
|
473
|
+
return { kind: "failed", error };
|
|
474
|
+
}
|
|
475
|
+
if (state !== "creating")
|
|
476
|
+
return { kind: "gone" };
|
|
477
|
+
if (slept >= REPO_WAIT_MAX_MS)
|
|
214
478
|
return { kind: "timeout" };
|
|
479
|
+
await deps.sleep(LINK_POLL_INTERVAL_MS);
|
|
215
480
|
}
|
|
216
481
|
}
|