flowsprite 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/bin.js +45 -45
- package/dist/cli.js +378 -92
- package/dist/ghLogin.js +173 -0
- package/dist/login.js +157 -26
- package/dist/mcpClient.js +3 -0
- package/dist/messages.js +85 -0
- package/dist/output.js +55 -3
- package/dist/redact.js +51 -23
- package/dist/setup.js +178 -13
- package/dist/sfCli.js +151 -0
- package/dist/status.js +11 -1
- package/dist/usage.js +23 -8
- package/dist/version.js +1 -1
- package/dist/wiring.js +77 -0
- package/package.json +2 -2
package/dist/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,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
|
|
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 (
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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: ${
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
--
|
|
27
|
-
|
|
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
|
|
32
|
-
Flowsprite only mirrors
|
|
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
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
|
+
}
|