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/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 Flowsprite bearer token in a string to a stub.
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 TOKEN GRAMMAR IS TAKEN FROM THE MINTER, not guessed: `generateRawToken`
9
- * in `convex/lib/mcpCrypto.ts` emits `fp_live_` or `fp_user_` followed by 32
10
- * characters drawn from `[A-Za-z0-9-_]`. The suffix alphabet CONTAINS `_`, so
11
- * the match is greedy over that class — stopping at the first underscore would
12
- * leave most of the secret on the screen.
13
- *
14
- * THE SET THIS COVERS IS EXACTLY `fp_user_` AND `fp_live_`, and nothing else —
15
- * stated so the next reader does not assume it is a general secret scrubber.
16
- * Those are the only two prefixes `generateRawToken` can emit and the only
17
- * credentials this CLI ever holds. The OTHER value worth never printing, the
18
- * device code, is not covered here and does not need to be: it is 64 hex
19
- * characters with no marker to match on, and it is kept off the screen BY
20
- * CONSTRUCTION instead — `cli/src/login.ts` prints `user_code` and
21
- * `verification_uri_complete` and never passes `device_code` to the writer, which
22
- * the AC3 acceptance oracle pins (`printedTheDeviceCode: false`). A setup-link
23
- * token is the same shape and the same story, except that one is DELIBERATELY
24
- * shown: it is the thing the person has to open.
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 = /fp_(?:user|live)_[A-Za-z0-9_-]+/g;
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
- // `fp_user_` / `fp_live_` — a fixed 8 characters, so this is a slice, not a
36
- // search: the prefix is the part the grammar above already pinned.
37
- const prefix = match.slice(0, "fp_user_".length);
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 the step is no longer the next one.
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 (this returns
180
- * `expired` and says so); the overall budget running out means the CLI stops
181
- * waiting but the link may still be good (`timeout`); and the step completing is
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, stepKind) {
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: `${tool} did not return a link.` };
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
- deps.writer.out(`Open this link in a browser where you are signed in to Flowsprite:`);
196
- deps.writer.out(` ${url}`);
197
- if (typeof payload.instructions === "string")
198
- deps.writer.out(payload.instructions);
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
- if (planNextStep(toolJson(status)).kind !== stepKind)
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 step completing is reported as the completion it was.
210
- const now = deps.now();
211
- if (expiresAt !== null && now >= expiresAt)
212
- return { kind: "expired" };
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
  }