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/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,5 @@
1
1
  import { toolJson } from "./mcpClient.js";
2
+ import { printable } from "./redact.js";
2
3
  /**
3
4
  * The setup plane, driven from a terminal.
4
5
  *
@@ -171,18 +172,103 @@ export function planBindArgs(input) {
171
172
  args: { sandboxId: connection, installationId, repoOwner: parts[0], repoName: parts[1] },
172
173
  };
173
174
  }
175
+ /** The `purpose` a minted link carries in `pendingLinks` — the set is `convex/setupLinks.ts purposeValidator`. */
176
+ const LINK_PURPOSE = {
177
+ salesforce: "salesforce_connect",
178
+ github: "github_install",
179
+ };
180
+ /**
181
+ * The provider's ids in a `setup_status` payload, as strings.
182
+ *
183
+ * github: `installations[].installationId` — a NUMBER in the tool's validator
184
+ * (`convex/mcpSetup.ts installationDetail`), stringified so one set type holds
185
+ * both. salesforce: `connections[].id` — a document id, already a string.
186
+ * Anything else-shaped is dropped, never coerced from `undefined` to "undefined".
187
+ */
188
+ export function idsOf(provider, status) {
189
+ const root = asObject(status);
190
+ if (root === null)
191
+ return [];
192
+ const out = [];
193
+ if (provider === "github") {
194
+ for (const i of asArray(root.installations)) {
195
+ if (typeof i.installationId === "number")
196
+ out.push(String(i.installationId));
197
+ }
198
+ }
199
+ else {
200
+ for (const c of asArray(root.connections)) {
201
+ if (typeof c.id === "string" && c.id.length > 0)
202
+ out.push(c.id);
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * What is already connected, for the sentence printed before a link is minted.
209
+ *
210
+ * THE RULE: the writer keeps newlines for whole documents and multi-line
211
+ * refusals; a field spliced INTO a sentence is scrubbed here, or a newline in
212
+ * it forges a line (driven: a displayName "Acme\nDone." printed an exact
213
+ * `Done.` line). `instructions` and `nextAction.text` are whole-field prints
214
+ * and stay as they are.
215
+ */
216
+ function connectedNames(provider, status) {
217
+ const root = asObject(status);
218
+ if (root === null)
219
+ return [];
220
+ const rows = provider === "github" ? asArray(root.installations) : asArray(root.connections);
221
+ const field = provider === "github" ? "accountLogin" : "displayName";
222
+ return rows.flatMap((r) => (typeof r[field] === "string" ? [printable(r[field])] : []));
223
+ }
174
224
  /**
175
225
  * Run one browser-leg step: mint the link, show it, then watch `setup_status`
176
- * until the step is no longer the next one.
226
+ * until a NEW provider id appears.
227
+ *
228
+ * DONE IS A NEW ID, NEVER THE LADDER MOVING. The previous version returned
229
+ * `done` when the planner's next step was no longer this one — which is also
230
+ * true from the FIRST poll whenever this step was never the next one (GitHub
231
+ * already connected, Salesforce not yet connected, a second org being added),
232
+ * so `connect github` printed "Done." after five seconds with nothing done and a
233
+ * live-link slot burnt. So the id set is read BEFORE the mint (`before` is the
234
+ * payload the caller already holds, else read here), and done ⇔ some id in a
235
+ * poll is outside that set. A link that is CONSUMED is not done either: it is
236
+ * consumed when the person clicks Go, before the provider round-trip, and
237
+ * re-authorizing an org that is already connected REFRESHES its row without a
238
+ * new id — so a consumed link is reported as progress, and the end-of-wait
239
+ * sentence says whether the link was used.
177
240
  *
178
241
  * 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.
242
+ * `expiresAt` passing means the person must be handed a fresh link (`expired`);
243
+ * the overall budget running out means the CLI stops waiting but the link may
244
+ * still be good (`timeout`); and `done` is the new id above.
184
245
  */
185
- export async function runLinkStep(deps, tool, args, stepKind) {
246
+ export async function runLinkStep(deps, tool, args, provider, before) {
247
+ if (before === undefined) {
248
+ const read = await deps.client.callTool("setup_status", {});
249
+ if (!read.ok)
250
+ return { kind: "failed", message: read.message };
251
+ before = toolJson(read);
252
+ }
253
+ const already = new Set(idsOf(provider, before));
254
+ const connected = asObject(asObject(asObject(before)?.workspace)?.[provider])?.connected === true;
255
+ // A snapshot that contradicts itself is not a baseline: "connected" with no
256
+ // row listed means a NEW row could not be told from the one it forgot, so
257
+ // this fails closed rather than minting a link it could never resolve.
258
+ if (connected && already.size === 0) {
259
+ return {
260
+ kind: "failed",
261
+ 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.`,
262
+ };
263
+ }
264
+ if (already.size > 0) {
265
+ const names = connectedNames(provider, before);
266
+ const who = names.length > 0 ? ` (${names.join(", ")})` : "";
267
+ deps.writer.out(provider === "github"
268
+ ? `GitHub is already connected to this workspace${who}. This link installs the Flowsprite app on another account or organization.`
269
+ : `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.`);
270
+ }
271
+ const mintedAt = deps.now();
186
272
  const minted = await deps.client.callTool(tool, args);
187
273
  if (!minted.ok)
188
274
  return { kind: "failed", message: minted.message };
@@ -192,25 +278,104 @@ export async function runLinkStep(deps, tool, args, stepKind) {
192
278
  }
193
279
  const url = payload.url;
194
280
  const expiresAt = typeof payload.expiresAt === "number" ? payload.expiresAt : null;
281
+ // Server clock minus ours, latency-inclusive (the mint's round trip is folded
282
+ // in, which only makes the cutoff below more conservative). The mint answers
283
+ // `expiresAt = serverNow + ttl` AND the ttl itself (`expiresInSeconds`, both
284
+ // tools in convex/mcpSetup.ts — pinned by setup.test.ts), so the server's
285
+ // "now" is read off the response rather than mirrored as a constant here. A
286
+ // mint without the ttl gives NO skew (null): the used inference below is then
287
+ // skipped entirely — the CLI clock alone cannot tell an expiry from a use —
288
+ // and expiry falls back to the CLI clock.
289
+ const ttlMs = typeof payload.expiresInSeconds === "number" ? payload.expiresInSeconds * 1000 : null;
290
+ const skew = expiresAt === null || ttlMs === null ? null : expiresAt - ttlMs - mintedAt;
291
+ /** `now` on the server's clock, as far as the mint let us measure it. */
292
+ const serverNow = (now) => now + (skew ?? 0);
195
293
  deps.writer.out(`Open this link in a browser where you are signed in to Flowsprite:`);
196
- deps.writer.out(` ${url}`);
294
+ deps.writer.out(` ${printable(url)}`);
197
295
  if (typeof payload.instructions === "string")
198
296
  deps.writer.out(payload.instructions);
199
297
  deps.writer.out("Waiting for you to finish in the browser...");
298
+ let used = false;
200
299
  const deadline = deps.now() + LINK_POLL_MAX_MS;
201
300
  for (;;) {
202
301
  await deps.sleep(LINK_POLL_INTERVAL_MS);
203
302
  const status = await deps.client.callTool("setup_status", {});
204
303
  if (!status.ok)
205
304
  return { kind: "failed", message: status.message };
206
- if (planNextStep(toolJson(status)).kind !== stepKind)
305
+ const now = deps.now();
306
+ const current = toolJson(status);
307
+ // ponytail: done is a WORKSPACE-WIDE new provider id, so a connect finished
308
+ // by another member during this wait is attributed to this link, and two
309
+ // same-purpose links minted in the same millisecond share the (purpose,
310
+ // expiresAt) fingerprint below. A per-link id in the mint response and in
311
+ // `pendingLinks` closes both; that is a server change (DEBT row), not this card.
312
+ if (idsOf(provider, current).some((id) => !already.has(id)))
207
313
  return { kind: "done" };
314
+ // The minted link is matched by (purpose, expiresAt). `pendingLinks` lists
315
+ // only LIVE links by the SERVER'S clock, so a link that has expired leaves it
316
+ // exactly like one that was opened. Absence is evidence of a use only on a
317
+ // poll taken more than one poll interval before `expiresAt` by the SERVER's
318
+ // clock (ours plus the measured skew) — later than that, an expiry reads as
319
+ // a use. Only when the mint gave a skew to measure that by, and only when the
320
+ // poll actually CARRIED a `pendingLinks` array: an omitted, null or non-array
321
+ // field says nothing about the row.
322
+ const pending = asObject(current)?.pendingLinks;
323
+ if (!used &&
324
+ expiresAt !== null &&
325
+ skew !== null &&
326
+ serverNow(now) < expiresAt - LINK_POLL_INTERVAL_MS &&
327
+ Array.isArray(pending)) {
328
+ const mine = asArray(pending).some((l) => l.purpose === LINK_PURPOSE[provider] && l.expiresAt === expiresAt);
329
+ if (!mine) {
330
+ used = true;
331
+ deps.writer.out("Link opened — finishing the connection in the browser...");
332
+ }
333
+ }
208
334
  // 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" };
335
+ // same tick as the id appearing is reported as the completion it was. Expiry
336
+ // is judged on the SERVER's clock (the one that drops the row), the same
337
+ // estimate the used cutoff uses; the deadline stays a CLI budget.
338
+ if (expiresAt !== null && serverNow(now) >= expiresAt)
339
+ return { kind: "expired", used };
213
340
  if (now >= deadline)
341
+ return { kind: "timeout", used };
342
+ }
343
+ }
344
+ // ---------------------------------------------------------- repository creation
345
+ /** How long `setup` waits for a repository it just asked Flowsprite to create (FLO-511 DM5). */
346
+ export const REPO_WAIT_MAX_MS = 180_000;
347
+ /**
348
+ * Wait for a repository `setup_bind_repo` has started creating.
349
+ *
350
+ * WHY THIS EXISTS: `hasBinding` is already true while `bootstrapState` is
351
+ * "creating", so without it the ladder's very next read names the first sync,
352
+ * and `sfdc_trigger_sync` refuses a repository that is still being created.
353
+ *
354
+ * The budget is counted in SLEPT milliseconds, not the clock: the sleeps are the
355
+ * whole of the wait this module adds, and counting them keeps the bound exact
356
+ * under an injected sleep that returns at once.
357
+ */
358
+ export async function waitForRepository(deps, connectionId) {
359
+ // READ FIRST, then sleep: a repository that finished during the bind's own
360
+ // round trip is not made to wait five seconds for nothing.
361
+ for (let slept = 0;; slept += LINK_POLL_INTERVAL_MS) {
362
+ const status = await deps.client.callTool("setup_status", {});
363
+ if (!status.ok)
364
+ return { kind: "error", message: status.message };
365
+ const conn = asArray(asObject(toolJson(status))?.connections).find((c) => c.id === connectionId);
366
+ const state = conn?.bootstrapState;
367
+ if (state === "ready")
368
+ return { kind: "ready" };
369
+ if (state === "failed") {
370
+ const error = typeof conn?.bootstrapError === "string" && conn.bootstrapError.length > 0
371
+ ? conn.bootstrapError
372
+ : "no reason was given.";
373
+ return { kind: "failed", error };
374
+ }
375
+ if (state !== "creating")
376
+ return { kind: "gone" };
377
+ if (slept >= REPO_WAIT_MAX_MS)
214
378
  return { kind: "timeout" };
379
+ await deps.sleep(LINK_POLL_INTERVAL_MS);
215
380
  }
216
381
  }
package/dist/sfCli.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The pure half of `flowsprite connect salesforce --from-sf-cli <alias>`
3
+ * (FLO-515): the alias rule, the `sf org display --verbose --json` envelope
4
+ * parser, the remedies, and the allow-list projection of the door's reply.
5
+ *
6
+ * NOTHING HERE PRINTS. The `sfdxAuthUrl` this module extracts is a reusable
7
+ * Salesforce credential that the CLI's own redactor cannot see (`redact.ts`
8
+ * covers `fp_user_`/`fp_live_` runs only), so the value's only sinks are the
9
+ * spawn stdout it came from and the `tools/call` arguments — the caller in
10
+ * `cli.ts` (`connectFromSfCli`) holds it in one scope and hands it to exactly
11
+ * one place. The acceptance oracle censuses both files for `fs` / `console`
12
+ * identifiers, which is why this module imports nothing.
13
+ */
14
+ /**
15
+ * An sf alias or username: letters, digits, `. _ @ + -`, not starting with `-`
16
+ * (flag-shaped for the downstream CLI), at most 80 characters. `@` and `+`
17
+ * because `--target-org` takes usernames, which are emails and can be
18
+ * plus-addressed. The value is spawned as ONE argv element (never a shell), so
19
+ * the rule exists for the printed remedies and for the third-party CLI's own
20
+ * parser, not for a shell.
21
+ */
22
+ export const SF_ALIAS_RE = /^(?!-)[A-Za-z0-9._@+-]{1,80}$/;
23
+ export const SF_ALIAS_RULE = "--from-sf-cli wants an sf alias or username: letters, digits, . _ @ + -, not starting with -, at most 80 characters.";
24
+ export const FROM_SF_CLI_EXCLUSIVE = "--from-sf-cli takes the org from the sf CLI; --sandbox and --my-domain belong to the browser link.";
25
+ /**
26
+ * Every `force://` run up to the next whitespace becomes a marker that carries
27
+ * NO scheme, so a scrubbed line can never itself read as an auth URL. Applied
28
+ * to every string this command prints that did not originate in this CLI's own
29
+ * constants — a tool's refusal text, an org's display name.
30
+ */
31
+ export function scrubForceUrls(text) {
32
+ return text.replace(/force:\/\/\S*/g, "[sf auth url removed]");
33
+ }
34
+ const SF_CLIENT_ID = "PlatformCLI";
35
+ const SF_URL_PREFIX = "force://PlatformCLI::";
36
+ const ERROR_NAME_RE = /^[A-Za-z0-9_]{1,64}$/;
37
+ /**
38
+ * Read the `sf org display --verbose --json` envelope: `{status, result:{…}}`,
39
+ * parsed from the first `{` because a CLI notice can precede the JSON on
40
+ * stdout (measured: the update notice goes to stderr on sf 2.127 / 2.150, but
41
+ * the setup skill body already parses from the first `{` for the other
42
+ * configurations).
43
+ *
44
+ * A non-zero envelope `status` reports the envelope's `name` — an error-class
45
+ * identifier such as `NoAuthInfoFound` — and NEVER its free-text `message`:
46
+ * that text is third-party and can quote the URL (spec-gate codex C2), so it
47
+ * is not even returned. An `sf org login jwt` org has no `sfdxAuthUrl`, and an
48
+ * auth file written before the CLI stored its client id yields
49
+ * `force://:<token>@host` (empty client id); both are `no_url`.
50
+ */
51
+ export function parseSfOrgDisplay(stdout) {
52
+ const start = stdout.indexOf("{");
53
+ if (start < 0)
54
+ return { ok: false, reason: "not_json" };
55
+ let doc;
56
+ try {
57
+ doc = JSON.parse(stdout.slice(start));
58
+ }
59
+ catch {
60
+ return { ok: false, reason: "not_json" };
61
+ }
62
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
63
+ return { ok: false, reason: "not_json" };
64
+ const envelope = doc;
65
+ if (envelope.status !== 0) {
66
+ const name = typeof envelope.name === "string" && ERROR_NAME_RE.test(envelope.name) ? envelope.name : "";
67
+ return { ok: false, reason: "status", message: name };
68
+ }
69
+ const result = typeof envelope.result === "object" && envelope.result !== null && !Array.isArray(envelope.result)
70
+ ? envelope.result
71
+ : {};
72
+ const url = result.sfdxAuthUrl;
73
+ if (typeof url !== "string" || !url.startsWith(SF_URL_PREFIX))
74
+ return { ok: false, reason: "no_url" };
75
+ const clientId = typeof result.clientId === "string" ? result.clientId : "";
76
+ if (clientId !== SF_CLIENT_ID)
77
+ return { ok: false, reason: "client", clientId };
78
+ return { ok: true, sfdxAuthUrl: url };
79
+ }
80
+ /** The `sf org login web` line for this alias — sandbox users need the host. */
81
+ export function sfLoginLine(alias) {
82
+ return (`sf org login web --alias ${alias} --instance-url <your sandbox My Domain, ` +
83
+ "for example https://acme--uat.sandbox.my.salesforce.com, or https://test.salesforce.com; " +
84
+ "leave --instance-url off for a production or Developer Edition org (sf's default login host)>");
85
+ }
86
+ /** The logout-then-login remedy for a login this door cannot take over. */
87
+ export function sfRelogin(alias) {
88
+ return `Run: sf org logout --target-org ${alias}, then ${sfLoginLine(alias)}`;
89
+ }
90
+ /**
91
+ * What to print when `sf` is not on PATH: the install line(s) for the platform,
92
+ * the PATH note for a fresh npm install the shell cannot see yet, then the
93
+ * login line. No auto-install (DM3): a global install is the person's decision.
94
+ */
95
+ export function sfRemedy(alias, platform) {
96
+ const lines = ["The Salesforce CLI (sf) is not on PATH, so there is no login to hand over. Install it:", " npm install -g @salesforce/cli"];
97
+ if (platform === "darwin")
98
+ lines.push(" brew install --cask salesforce-cli");
99
+ if (platform === "win32")
100
+ lines.push(" or the Windows installer: https://developer.salesforce.com/tools/salesforcecli");
101
+ if (platform !== "win32") {
102
+ lines.push('If you installed it with npm and this shell still cannot find it: export PATH="$(npm prefix -g)/bin:$PATH"');
103
+ }
104
+ lines.push("Then log in to the org and run this command again:", ` ${sfLoginLine(alias)}`);
105
+ return lines;
106
+ }
107
+ const DOOR_CODES = new Set(["sf_cli_refresh_failed", "sf_cli_identity_failed", "sf_cli_env_unresolved", "sf_cli_save_failed"]);
108
+ const ENV_TYPES = new Set(["production", "sandbox", "developer"]);
109
+ /**
110
+ * Rebuild the door's reply from an ALLOW-LIST — exactly {ok, username, orgName,
111
+ * envType, reconnected} or {ok, code} — so a reply is never serialized
112
+ * wholesale and an extra key (the URL echoed back, anything) is dropped rather
113
+ * than printed. The org-reported strings are scrubbed here, before any sink.
114
+ * `null` is "not the door's shape", which the caller reports as unexpected.
115
+ */
116
+ export function projectDoorResult(doc) {
117
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
118
+ return null;
119
+ const d = doc;
120
+ if (d.ok === true) {
121
+ if (typeof d.username !== "string" || typeof d.envType !== "string" || !ENV_TYPES.has(d.envType))
122
+ return null;
123
+ if (typeof d.reconnected !== "boolean")
124
+ return null;
125
+ if (d.orgName !== undefined && typeof d.orgName !== "string")
126
+ return null;
127
+ return {
128
+ ok: true,
129
+ username: scrubForceUrls(d.username),
130
+ ...(d.orgName === undefined ? {} : { orgName: scrubForceUrls(d.orgName) }),
131
+ envType: d.envType,
132
+ reconnected: d.reconnected,
133
+ };
134
+ }
135
+ if (d.ok === false && typeof d.code === "string" && DOOR_CODES.has(d.code))
136
+ return { ok: false, code: d.code };
137
+ return null;
138
+ }
139
+ /** One sentence per refusal code the door can answer with. */
140
+ export function doorRefusalSentence(code, alias) {
141
+ switch (code) {
142
+ case "sf_cli_refresh_failed":
143
+ return `Salesforce refused that login's token. Log in again: ${sfLoginLine(alias)}`;
144
+ case "sf_cli_identity_failed":
145
+ return "Salesforce accepted the token but its identity could not be read. Try again in a minute; if it persists, connect this org from the browser.";
146
+ case "sf_cli_env_unresolved":
147
+ return "Flowsprite could not read whether that org is a sandbox and does not guess production versus sandbox. Try again; if it persists, connect it from the browser.";
148
+ default:
149
+ return "Flowsprite could not save the connection. Run `flowsprite status` and try again.";
150
+ }
151
+ }
package/dist/status.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { printable } from "./redact.js";
1
2
  import { asArray, asObject, planNextStep } from "./setup.js";
2
3
  /**
3
4
  * Render a `setup_status` payload as plain text.
@@ -37,6 +38,15 @@ function expiryText(value) {
37
38
  }
38
39
  return new Date(value).toISOString();
39
40
  }
41
+ /** A connection's repository, with its creation state when it is not ready yet (FLO-511 DM5). */
42
+ function repoText(c) {
43
+ const repo = str(c.repo, "none");
44
+ if (c.bootstrapState === "creating")
45
+ return `${repo} (creating)`;
46
+ if (c.bootstrapState === "failed")
47
+ return `${repo} (failed: ${printable(str(c.bootstrapError, "no reason given"))})`;
48
+ return repo;
49
+ }
40
50
  export function renderStatus(payload) {
41
51
  const root = obj(payload);
42
52
  if (root === null) {
@@ -64,7 +74,7 @@ export function renderStatus(payload) {
64
74
  if (connections.length === 0)
65
75
  lines.push(" none");
66
76
  for (const c of connections) {
67
- lines.push(` ${str(c.id)} ${str(c.displayName)} [${str(c.envType)}] repo: ${str(c.repo, "none")} synced: ${yesNo(c.hasSyncJob)}`);
77
+ lines.push(` ${str(c.id)} ${str(c.displayName)} [${str(c.envType)}] repo: ${repoText(c)} synced: ${yesNo(c.hasSyncJob)}`);
68
78
  }
69
79
  const installations = arr(root.installations);
70
80
  lines.push("", `GitHub installations (${installations.length})`);
package/dist/usage.js CHANGED
@@ -5,13 +5,20 @@ Usage: flowsprite [command] [options]
5
5
 
6
6
  Commands:
7
7
  setup Run the whole setup ladder (the default).
8
- login Log this terminal in with a device code.
8
+ login [--workspace <name>] [--no-gh]
9
+ Log this terminal in. Uses the GitHub CLI's login
10
+ (gh) when it has one, else a device code in the
11
+ browser.
9
12
  logout Forget the stored credential for this endpoint.
10
13
  status Show the setup checklist, connections and installations.
11
14
  connect salesforce Get a one-time link to connect a Salesforce org.
12
15
  connect github Get a one-time link to install the GitHub app.
13
- bind Bind a GitHub repo to a Salesforce connection. With no
14
- --repo/--create it offers the repos setup_status lists.
16
+ connect salesforce --from-sf-cli <alias>
17
+ Connect an org the Salesforce CLI is already logged
18
+ in to (no browser).
19
+ bind Give a Salesforce connection a GitHub repo. With no
20
+ --repo/--create it offers to create a new private
21
+ one with a suggested name, or pick an existing one.
15
22
  sync Start a metadata sync and show the recent sync log.
16
23
  doctor Check node, the endpoint, the credential, and sf/gh.
17
24
  mcp install Point an AI client at this workspace's MCP endpoint.
@@ -23,13 +30,21 @@ Options:
23
30
  --token <token> Use this token instead of the stored credential.
24
31
  --save With --token: also store it for this endpoint.
25
32
  --no-open Do not try to open a browser during login.
26
- --sandbox connect salesforce: use the sandbox login host.
27
- --my-domain <host> connect salesforce: use this My Domain login host.
33
+ --workspace <name> login: the name for the workspace the GitHub CLI
34
+ login creates. Omitted, login asks at a terminal;
35
+ through a pipe it uses the browser instead.
36
+ --no-gh login: skip the GitHub CLI and use the browser.
37
+ --sandbox setup / connect salesforce: use the sandbox login host.
38
+ --my-domain <host> setup / connect salesforce: use this My Domain login host.
39
+ --from-sf-cli <alias> connect salesforce: hand over the sf CLI's login for
40
+ that alias instead of a browser link. Needs sf on
41
+ PATH and a web login (not JWT).
28
42
  --connection <id> bind / sync: the Salesforce connection id.
29
43
  --installation <number> bind: the GitHub installation id.
30
- --repo <owner/name> bind: a repository that already exists.
31
- --create <name> bind: create a new repository instead. Always PRIVATE —
32
- Flowsprite only mirrors into private repos.
44
+ --repo <owner/name> bind: use a repository that already exists.
45
+ --create <name> bind: create a new repository with this name, without
46
+ asking. Always PRIVATE — Flowsprite only mirrors
47
+ into private repos.
33
48
  --client <name> mcp: claude-code, codex, claude-desktop or cursor.
34
49
  Omitted, mcp install offers the ones it detects and
35
50
  mcp print shows every one.
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * manifest are pinned EQUAL by cli/src/packaging.test.ts, which reads both, so
8
8
  * they cannot disagree without a red test.
9
9
  */
10
- export const VERSION = "0.1.0";
10
+ export const VERSION = "0.1.3";
package/dist/wiring.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createStatusLine, createWriter } from "./output.js";
2
+ import { printable, redact } from "./redact.js";
3
+ /**
4
+ * THE WRITER, THE STATUS LINE AND THE REVEAL SINK ARE BUILT HERE; the readline
5
+ * prompt (`ask`, built in bin.ts) only passes through, redacted and scrubbed, and the bin's
6
+ * last-line-of-defence catch is its own exit (see the exit list in output.ts).
7
+ * Two conditions govern what exists:
8
+ *
9
+ * - the writer (`log`/`error`, both redacting) — unconditional;
10
+ * - the status line and the ONE unredacted sink, `reveal` — only when
11
+ * `stdout.isTTY === true`. Strictly `=== true`: a real pipe carries
12
+ * `isTTY === undefined`, so `!== false` would reveal into a pipe. The sink
13
+ * writes through the SAME `stdout` object whose `isTTY` authorised it, never
14
+ * through `log` (a separate function a caller could point anywhere). Its only
15
+ * reader is `mcpPrint` in `cli.ts`.
16
+ *
17
+ * The browser opener and the prompt need BOTH stdout and stdin to be terminals
18
+ * (`interactive`); the secret needs only stdout: stdin may be a here-doc while
19
+ * the person is still watching the terminal the secret lands in.
20
+ */
21
+ export function wireDeps(io) {
22
+ const stdoutIsTTY = io.stdout.isTTY === true;
23
+ const interactive = stdoutIsTTY && io.stdin.isTTY === true;
24
+ // FLO-509: the in-place login countdown exists only when stdout is a terminal
25
+ // (the login lines go to stdout). On a pipe a redrawn line is just noise in a log.
26
+ const statusLine = stdoutIsTTY ? createStatusLine((text) => io.stdout.write(text)) : null;
27
+ const writer = createWriter({
28
+ out: (line) => {
29
+ statusLine?.settle();
30
+ io.log(line);
31
+ },
32
+ err: (line) => {
33
+ statusLine?.settle();
34
+ io.error(line);
35
+ },
36
+ });
37
+ const deps = {
38
+ env: io.env,
39
+ writer,
40
+ fetch: io.fetch,
41
+ sleep: io.sleep,
42
+ now: io.now,
43
+ nodeVersion: io.nodeVersion,
44
+ isOnPath: io.isOnPath,
45
+ version: io.version,
46
+ spawn: io.spawn,
47
+ fileExists: io.fileExists,
48
+ home: io.home,
49
+ platform: io.platform,
50
+ ...(statusLine === null ? {} : { progress: statusLine.progress }),
51
+ // `mcp print --reveal` is the ONE command allowed to put a token on stdout,
52
+ // and this is the fact it is allowed on. The sink settles a dangling
53
+ // countdown first so the secret never lands glued to a status line.
54
+ ...(stdoutIsTTY
55
+ ? {
56
+ reveal: (line) => {
57
+ statusLine?.settle();
58
+ io.stdout.write(line + "\n");
59
+ },
60
+ }
61
+ : {}),
62
+ // The browser opener and the prompt are BOTH gated on there being a terminal.
63
+ // History (measured 2026-09-15 at bin.ts, before this wiring existed,
64
+ // FLO-500): a non-interactive `login` (stdout piped, as in a CI job or a
65
+ // probe script) still popped a browser tab on this machine, because the
66
+ // opener was wired unconditionally. A process nobody is watching must not
67
+ // take over the screen of whoever happens to be at the machine.
68
+ // The question is redacted AND scrubbed HERE because readline writes it to
69
+ // stdout raw, around the writer — the prompt text is CLI-built (a path, a
70
+ // display name), but the guarantee should not depend on every future caller
71
+ // remembering. `printable`, not the writer's per-line scrub: a question is
72
+ // ONE line by construction, so a newline in a spliced display name is a
73
+ // forged line, not structure.
74
+ ...(interactive ? { openUrl: io.openUrl, ask: (question) => io.ask(printable(redact(question))) } : {}),
75
+ };
76
+ return { deps, statusLine };
77
+ }