flowsprite 0.1.0 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/bin.js +45 -45
- package/dist/cli.js +615 -131
- package/dist/credentials.js +108 -0
- package/dist/doctor.js +17 -3
- package/dist/ghLogin.js +180 -0
- package/dist/login.js +290 -37
- package/dist/mcpClient.js +39 -1
- package/dist/messages.js +147 -0
- package/dist/output.js +55 -3
- package/dist/redact.js +51 -23
- package/dist/setup.js +282 -17
- package/dist/sfCli.js +179 -0
- package/dist/status.js +11 -1
- package/dist/usage.js +65 -25
- package/dist/version.js +1 -1
- package/dist/wiring.js +96 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { credentialsPath, deleteCredential, readCredentials, resolveToken, writeCredential, } from "./credentials.js";
|
|
4
|
-
import { formatDoctor, MIN_NODE_MAJOR, nodeMajor } from "./doctor.js";
|
|
3
|
+
import { clearPendingLogin, credentialsPath, deleteCredential, readCredentials, readPendingLogin, resolveToken, writeCredential, writePendingLogin, } from "./credentials.js";
|
|
4
|
+
import { doctorOk, formatDoctor, MIN_NODE_MAJOR, nodeMajor } from "./doctor.js";
|
|
5
5
|
import { parseEndpoint } from "./endpoint.js";
|
|
6
|
-
import { deviceCodeLogin } from "./login.js";
|
|
6
|
+
import { deviceCodeLogin, ghCliLogin } from "./login.js";
|
|
7
7
|
import { createMcpClient, toolJson } from "./mcpClient.js";
|
|
8
8
|
import { jsonConfigHasServer, McpConfigError, SERVER_NAME, tomlConfigHasServer, upsertJsonConfig, } from "./mcpConfig.js";
|
|
9
9
|
import { CLIENTS, commandFor, configPathFor, isClientId, planInstall, renderSnippet, TOKEN_PLACEHOLDER, } from "./mcpInstall.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { SAME_ACCOUNT_NOTE } from "./messages.js";
|
|
11
|
+
import { printable, redact } from "./redact.js";
|
|
12
|
+
import { asArray, asObject, browserWaitNotice, createStepTracker, planBindArgs, planNextStep, rerunHint, rungConnectionIds, runLinkStep, stepOfRung, waitForRepository, } from "./setup.js";
|
|
13
|
+
import { MAX_EXPIRES_IN_SECONDS } from "./ghLogin.js";
|
|
14
|
+
import { doorRefusalSentence, FROM_SF_CLI_EXCLUSIVE, parseSfAuthUrl, projectDoorResult, scrubForceUrls, SF_ALIAS_RE, SF_ALIAS_RULE, SF_TOO_OLD_REMEDY, sfLoginLine, sfRelogin, sfRemedy, } from "./sfCli.js";
|
|
12
15
|
import { renderStatus } from "./status.js";
|
|
13
16
|
import { USAGE } from "./usage.js";
|
|
14
17
|
/**
|
|
@@ -25,14 +28,18 @@ import { USAGE } from "./usage.js";
|
|
|
25
28
|
export const EXIT_OK = 0;
|
|
26
29
|
export const EXIT_FAILED = 1;
|
|
27
30
|
export const EXIT_USAGE = 2;
|
|
28
|
-
|
|
31
|
+
/** The parser's option table — exported so usage.test.ts can prove USAGE names every flag. */
|
|
32
|
+
export const OPTIONS = {
|
|
29
33
|
endpoint: { type: "string" },
|
|
30
34
|
json: { type: "boolean" },
|
|
31
35
|
token: { type: "string" },
|
|
32
36
|
save: { type: "boolean" },
|
|
33
37
|
"no-open": { type: "boolean" },
|
|
38
|
+
workspace: { type: "string" },
|
|
39
|
+
"no-gh": { type: "boolean" },
|
|
34
40
|
sandbox: { type: "boolean" },
|
|
35
41
|
"my-domain": { type: "string" },
|
|
42
|
+
"from-sf-cli": { type: "string" },
|
|
36
43
|
connection: { type: "string" },
|
|
37
44
|
installation: { type: "string" },
|
|
38
45
|
repo: { type: "string" },
|
|
@@ -73,6 +80,12 @@ export async function main(argv, deps) {
|
|
|
73
80
|
if (!COMMANDS.has(command)) {
|
|
74
81
|
return usageError(deps.writer, `Unknown command '${command}'.`);
|
|
75
82
|
}
|
|
83
|
+
// `--from-sf-cli` is accepted by exactly one command. Refused elsewhere
|
|
84
|
+
// rather than ignored: FLO-514 found that a flag `setup` accepts and
|
|
85
|
+
// silently drops is how a person's intent goes missing.
|
|
86
|
+
if (typeof values["from-sf-cli"] === "string" && !(command === "connect" && positionals[1] === "salesforce")) {
|
|
87
|
+
return usageError(deps.writer, "--from-sf-cli belongs to `connect salesforce`: flowsprite connect salesforce --from-sf-cli <alias>.");
|
|
88
|
+
}
|
|
76
89
|
const endpoint = parseEndpoint({
|
|
77
90
|
flag: typeof values.endpoint === "string" ? values.endpoint : undefined,
|
|
78
91
|
env: deps.env.FLOWSPRITE_ENDPOINT,
|
|
@@ -131,6 +144,13 @@ function clientFor(deps, origin, values) {
|
|
|
131
144
|
}
|
|
132
145
|
// ------------------------------------------------------------------- commands
|
|
133
146
|
async function cmdLogin(deps, origin, values) {
|
|
147
|
+
// `--workspace` is the one value the gh road sends that a person typed.
|
|
148
|
+
// Refused empty HERE, before any spawn or request, rather than sent for the
|
|
149
|
+
// door to refuse: the door's `invalid_argument` costs a round trip to say so.
|
|
150
|
+
const workspaceFlag = typeof values.workspace === "string" ? values.workspace.trim() : null;
|
|
151
|
+
if (workspaceFlag !== null && workspaceFlag.length === 0) {
|
|
152
|
+
return usageError(deps.writer, "--workspace needs a name: flowsprite login --workspace <name>.");
|
|
153
|
+
}
|
|
134
154
|
// A token handed in on the command line or the environment SKIPS the device
|
|
135
155
|
// loop entirely — there is nothing to approve. It is not stored unless --save
|
|
136
156
|
// is given, so a one-off token in a CI job leaves nothing behind.
|
|
@@ -149,14 +169,30 @@ async function cmdLogin(deps, origin, values) {
|
|
|
149
169
|
}
|
|
150
170
|
return EXIT_OK;
|
|
151
171
|
}
|
|
152
|
-
|
|
172
|
+
// ONE deps object for both roads, so the 409 road's browser tail inherits
|
|
173
|
+
// `--no-open` (and an absent opener) exactly as the device-code road does.
|
|
174
|
+
const loginDeps = {
|
|
153
175
|
origin,
|
|
154
176
|
fetch: deps.fetch,
|
|
155
177
|
sleep: deps.sleep,
|
|
156
178
|
writer: deps.writer,
|
|
157
179
|
version: deps.version,
|
|
158
180
|
...(values["no-open"] === true || deps.openUrl === undefined ? {} : { openUrl: deps.openUrl }),
|
|
159
|
-
|
|
181
|
+
...(deps.progress === undefined ? {} : { progress: deps.progress }),
|
|
182
|
+
pending: pendingLoginPort(deps, origin),
|
|
183
|
+
};
|
|
184
|
+
// FLO-517: the gh road first, unless opted out. Null means it fell to the
|
|
185
|
+
// browser login at its own start, having said why in one line.
|
|
186
|
+
const viaGh = values["no-gh"] === true
|
|
187
|
+
? null
|
|
188
|
+
: await ghCliLogin({
|
|
189
|
+
...loginDeps,
|
|
190
|
+
isOnPath: deps.isOnPath,
|
|
191
|
+
spawn: deps.spawn,
|
|
192
|
+
...(deps.ask === undefined ? {} : { ask: deps.ask }),
|
|
193
|
+
workspaceName: workspaceFlag,
|
|
194
|
+
});
|
|
195
|
+
const result = viaGh ?? (await deviceCodeLogin(loginDeps));
|
|
160
196
|
if (!result.ok) {
|
|
161
197
|
deps.writer.err(result.message);
|
|
162
198
|
return EXIT_FAILED;
|
|
@@ -169,7 +205,9 @@ async function cmdLogin(deps, origin, values) {
|
|
|
169
205
|
savedAt: deps.now(),
|
|
170
206
|
};
|
|
171
207
|
// The token is NOT in this sentence, and the writer would redact it anyway.
|
|
172
|
-
|
|
208
|
+
// C4: the email and workspace name are DISPLAY ONLY — scrubbed in login.ts,
|
|
209
|
+
// and never in `cred` above, so they are never stored.
|
|
210
|
+
const who = `Logged in to ${origin} as ${result.userEmail ?? result.userId} in workspace ${result.orgName ?? result.orgId}`;
|
|
173
211
|
try {
|
|
174
212
|
writeCredential(deps.env, origin, cred, deps.home);
|
|
175
213
|
}
|
|
@@ -192,37 +230,90 @@ async function cmdLogin(deps, origin, values) {
|
|
|
192
230
|
return EXIT_FAILED;
|
|
193
231
|
}
|
|
194
232
|
deps.writer.out(who);
|
|
233
|
+
deps.writer.out(SAME_ACCOUNT_NOTE);
|
|
195
234
|
return EXIT_OK;
|
|
196
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* C3 — the pending device login for THIS origin, kept in `pending-login.json`
|
|
238
|
+
* beside the credential store (0600, see `credentials.ts`). The clock lives here
|
|
239
|
+
* so `login.ts` stays clock-free: it is handed seconds LEFT and hands back the
|
|
240
|
+
* payload's own TTL.
|
|
241
|
+
*/
|
|
242
|
+
function pendingLoginPort(deps, origin) {
|
|
243
|
+
return {
|
|
244
|
+
load: () => {
|
|
245
|
+
const saved = readPendingLogin(deps.env, origin, deps.now(), deps.home);
|
|
246
|
+
if (saved === null)
|
|
247
|
+
return null;
|
|
248
|
+
return {
|
|
249
|
+
deviceCode: saved.deviceCode,
|
|
250
|
+
...(saved.userCode === null || saved.openTarget === null
|
|
251
|
+
? {}
|
|
252
|
+
: { userCode: saved.userCode, openTarget: saved.openTarget }),
|
|
253
|
+
expiresIn: Math.ceil((saved.expiresAtMs - deps.now()) / 1000),
|
|
254
|
+
interval: saved.interval,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
save: (p) => writePendingLogin(deps.env, {
|
|
258
|
+
origin,
|
|
259
|
+
deviceCode: p.deviceCode,
|
|
260
|
+
userCode: p.userCode ?? null,
|
|
261
|
+
openTarget: p.openTarget ?? null,
|
|
262
|
+
expiresAtMs: deps.now() + p.expiresIn * 1000,
|
|
263
|
+
savedAtMs: deps.now(),
|
|
264
|
+
interval: p.interval,
|
|
265
|
+
}, deps.home),
|
|
266
|
+
clear: (deviceCode) => clearPendingLogin(deps.env, origin, deps.home, deviceCode),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
197
269
|
function cmdLogout(deps, origin) {
|
|
270
|
+
// C3: a half-finished login goes too, so the next `login` starts fresh. Best
|
|
271
|
+
// effort: an undeletable file must not turn a logout into a crash. No device
|
|
272
|
+
// code is named (G2): logout drops whatever is pending for this origin.
|
|
273
|
+
try {
|
|
274
|
+
clearPendingLogin(deps.env, origin, deps.home);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// The credential removal below is what logout promises; this is housekeeping.
|
|
278
|
+
}
|
|
198
279
|
const removed = deleteCredential(deps.env, origin, deps.home);
|
|
199
280
|
deps.writer.out(removed ? `Logged out of ${origin}.` : `There was no stored credential for ${origin}.`);
|
|
200
281
|
return EXIT_OK;
|
|
201
282
|
}
|
|
202
283
|
async function cmdDoctor(deps, origin, values) {
|
|
203
284
|
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
const
|
|
285
|
+
const token = resolveToken({
|
|
286
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
287
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
288
|
+
stored: stored?.token,
|
|
289
|
+
});
|
|
290
|
+
// N7: the endpoint is probed WITH OR WITHOUT a credential — `initialize` with
|
|
291
|
+
// an empty bearer. HEALTHY is exactly two answers (G8): a successful
|
|
292
|
+
// `initialize`, or a 401 (`unauthorized` — the auth layer answered, the
|
|
293
|
+
// credential is the problem or is absent). Anything else — no answer, a body
|
|
294
|
+
// that is not JSON-RPC, a JSON-RPC error, a tool error — FAILS the endpoint.
|
|
295
|
+
const probe = await createMcpClient({ origin, token: token?.token ?? "", fetch: deps.fetch, version: deps.version }).initialize();
|
|
296
|
+
const unauthorized = !probe.ok && probe.kind === "unauthorized";
|
|
297
|
+
const reachable = probe.ok || unauthorized;
|
|
298
|
+
const detail = probe.ok || (token !== null && unauthorized)
|
|
299
|
+
? "reachable"
|
|
300
|
+
: unauthorized
|
|
301
|
+
? "reachable (it answered HTTP 401 to a request with no credential, as it should)"
|
|
302
|
+
: `${probe.kind}: ${probe.message}`;
|
|
303
|
+
const checks = {
|
|
216
304
|
nodeVersion: deps.nodeVersion,
|
|
217
305
|
nodeOk: nodeMajor(deps.nodeVersion) >= MIN_NODE_MAJOR,
|
|
218
306
|
endpoint: origin,
|
|
219
307
|
endpointReachable: reachable,
|
|
220
308
|
endpointDetail: detail,
|
|
221
|
-
hasCredential:
|
|
309
|
+
hasCredential: token !== null,
|
|
310
|
+
credentialSource: token?.source ?? null,
|
|
311
|
+
credentialRefused: token !== null && unauthorized,
|
|
222
312
|
credentialsPath: credentialsPath(deps.env, deps.home),
|
|
223
313
|
sfOnPath: deps.isOnPath("sf"),
|
|
224
314
|
ghOnPath: deps.isOnPath("gh"),
|
|
225
315
|
};
|
|
316
|
+
const report = { ...checks, ok: doctorOk(checks) };
|
|
226
317
|
if (values.json === true) {
|
|
227
318
|
deps.writer.out(JSON.stringify(report, null, 2));
|
|
228
319
|
}
|
|
@@ -230,18 +321,8 @@ async function cmdDoctor(deps, origin, values) {
|
|
|
230
321
|
for (const line of formatDoctor(report))
|
|
231
322
|
deps.writer.out(line);
|
|
232
323
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
/** Like `clientFor` but silent and optional — doctor reports absence, it does not refuse. */
|
|
236
|
-
function clientForQuiet(deps, origin, values, storedToken) {
|
|
237
|
-
const token = resolveToken({
|
|
238
|
-
flag: typeof values.token === "string" ? values.token : undefined,
|
|
239
|
-
env: deps.env.FLOWSPRITE_TOKEN,
|
|
240
|
-
stored: storedToken,
|
|
241
|
-
});
|
|
242
|
-
if (token === null)
|
|
243
|
-
return null;
|
|
244
|
-
return createMcpClient({ origin, token: token.token, fetch: deps.fetch, version: deps.version });
|
|
324
|
+
// N16: exit 1 iff any check FAILs (see `doctorOk`); sf/gh are notes only.
|
|
325
|
+
return report.ok ? EXIT_OK : EXIT_FAILED;
|
|
245
326
|
}
|
|
246
327
|
async function fetchStatus(deps, client) {
|
|
247
328
|
const result = await client.callTool("setup_status", {});
|
|
@@ -270,42 +351,287 @@ async function cmdStatus(deps, origin, values) {
|
|
|
270
351
|
return EXIT_OK;
|
|
271
352
|
}
|
|
272
353
|
function setupDeps(deps, client) {
|
|
273
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
client,
|
|
356
|
+
writer: deps.writer,
|
|
357
|
+
sleep: deps.sleep,
|
|
358
|
+
now: deps.now,
|
|
359
|
+
// C8: the setup-link countdown shares the login's status line — a terminal only.
|
|
360
|
+
...(deps.progress === undefined ? {} : { progress: deps.progress }),
|
|
361
|
+
};
|
|
274
362
|
}
|
|
275
363
|
async function cmdConnect(deps, origin, values, which) {
|
|
276
364
|
if (which !== "salesforce" && which !== "github") {
|
|
277
365
|
return usageError(deps.writer, "connect takes 'salesforce' or 'github'.");
|
|
278
366
|
}
|
|
367
|
+
// The `sf`-CLI door (FLO-515): a branch chosen BEFORE the link is minted,
|
|
368
|
+
// not a change to the link step. The alias was already routed here by `main`.
|
|
369
|
+
if (which === "salesforce" && typeof values["from-sf-cli"] === "string") {
|
|
370
|
+
return await connectFromSfCli(deps, origin, values, values["from-sf-cli"]);
|
|
371
|
+
}
|
|
279
372
|
const built = clientFor(deps, origin, values);
|
|
280
373
|
if (!built.ok)
|
|
281
374
|
return EXIT_FAILED;
|
|
282
375
|
const tool = which === "salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
376
|
+
// ponytail: the GitHub alternative door is absent here and plugs in at THIS
|
|
377
|
+
// call, as a branch chosen before the link is minted — the gh-token door
|
|
378
|
+
// (card A3, blocked), which would use a token the person already holds
|
|
379
|
+
// instead of an app install. The Salesforce one is `connectFromSfCli` above.
|
|
380
|
+
const args = which === "salesforce" ? salesforceLinkArgs(values) : {};
|
|
381
|
+
// The pre-mint snapshot is read HERE (it was read inside the link step before
|
|
382
|
+
// N1) so the used-but-no-new-id outcome can compare "connected" before and
|
|
383
|
+
// after. `done` still means a NEW connection or installation.
|
|
384
|
+
const before = await fetchStatus(deps, built.client);
|
|
385
|
+
if (!before.ok)
|
|
386
|
+
return EXIT_FAILED;
|
|
387
|
+
const outcome = await runLinkStep(setupDeps(deps, built.client), tool, args, which, before.payload);
|
|
388
|
+
return await settleLinkOutcome(deps, built.client, outcome, which, before.payload);
|
|
389
|
+
}
|
|
390
|
+
/** `workspace.<provider>.connected === true` in a `setup_status` payload — strictly true. */
|
|
391
|
+
function providerConnected(status, provider) {
|
|
392
|
+
return asObject(asObject(asObject(status)?.workspace)?.[provider])?.connected === true;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Card eecc9d6f N1 — a link that was USED but produced no NEW id. The success
|
|
396
|
+
* sentence is NEUTRAL (G7): "is connected" is what the read shows; whether a row
|
|
397
|
+
* was refreshed or newly made is not something this read can tell. Re-authorizing
|
|
398
|
+
* a connection Flowsprite already had a row for refreshes that row, so no new id
|
|
399
|
+
* appears even when the person did exactly the right thing. The rule: the step
|
|
400
|
+
* SUCCEEDED iff the provider was NOT connected in the pre-mint snapshot and IS
|
|
401
|
+
* connected now — the link changed the answer. (Connected BEFORE as well is the
|
|
402
|
+
* second-org case, which stays the old "used, but nothing new" failure: nothing
|
|
403
|
+
* shows this link did anything.) Applied to both providers: the GitHub link step
|
|
404
|
+
* shares this exact outcome path.
|
|
405
|
+
*/
|
|
406
|
+
async function settleLinkOutcome(deps, client, outcome, provider, before) {
|
|
407
|
+
if ((outcome.kind === "expired" || outcome.kind === "timeout") && outcome.used && !providerConnected(before, provider)) {
|
|
408
|
+
const read = await client.callTool("setup_status", {});
|
|
409
|
+
if (read.ok && providerConnected(toolJson(read), provider)) {
|
|
410
|
+
deps.writer.out(provider === "salesforce"
|
|
411
|
+
? "Salesforce is connected."
|
|
412
|
+
: "GitHub is connected.");
|
|
413
|
+
return EXIT_OK;
|
|
414
|
+
}
|
|
415
|
+
if (read.ok) {
|
|
416
|
+
deps.writer.err(provider === "salesforce"
|
|
417
|
+
? "The link was opened, but Salesforce is still not connected to this workspace — the Salesforce sign-in did not complete a connection. Run the command again for a fresh link."
|
|
418
|
+
: "The link was opened, but GitHub is still not connected to this workspace — the app install did not complete. Run the command again for a fresh link.");
|
|
419
|
+
return EXIT_FAILED;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return reportLinkOutcome(deps.writer, outcome, provider);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* `flowsprite connect salesforce --from-sf-cli <alias>` (FLO-515): read the
|
|
426
|
+
* Salesforce CLI's own login for that alias and hand it to the server door
|
|
427
|
+
* `setup_connect_salesforce_cli`, so the credential crosses inside THIS process
|
|
428
|
+
* — spawn stdout → parser → tools/call arguments — and never enters a chat, a
|
|
429
|
+
* transcript or this CLI's output. Why that matters: an agent harness redacted
|
|
430
|
+
* `result.sfdxAuthUrl` before its model could relay it (measured 2026-09-17,
|
|
431
|
+
* Claude Code), and the writer's redactor covers `fp_` tokens only, so the URL
|
|
432
|
+
* is scoped to the spawn-to-call span below and appears in NO string. Every
|
|
433
|
+
* printed text that did not originate in this CLI's constants goes through
|
|
434
|
+
* `scrubForceUrls` as defence in depth. The acceptance oracle censuses this
|
|
435
|
+
* function's source for `fs` / `console` identifiers: the writer and the
|
|
436
|
+
* tools/call body are its only sinks.
|
|
437
|
+
*
|
|
438
|
+
* WHICH COMMAND, and why it changed (card 263cefa5, 2026-09-20): ONE spawn of
|
|
439
|
+
* `sf org auth show-sfdx-auth-url --target-org <alias> --json`, whose stdout
|
|
440
|
+
* under `--json` is `{status, result:{sfdxAuthUrl}, warnings}` — no
|
|
441
|
+
* accessToken, no password, no clientId. The command's own "this exposes an
|
|
442
|
+
* SFDX Auth URL" warning lands BOTH in that `warnings` array and on stderr
|
|
443
|
+
* (sf-plugins-core `toSuccessJson`/`warn`, measured against 12.2.6), and this
|
|
444
|
+
* CLI prints neither. What IS read off that envelope: `status` (to tell a
|
|
445
|
+
* success from an error), the identifier-shaped `name` on a non-zero one (the
|
|
446
|
+
* error CLASS, printed; never its free text), and `result.sfdxAuthUrl` — the
|
|
447
|
+
* only one of the three that is a credential, and it is read into one scope
|
|
448
|
+
* and never printed. FLO-515 read the login from `sf org display --verbose --json`,
|
|
449
|
+
* and sf 2.147.0 (published 2026-07-31) bundles a @salesforce/plugin-org that
|
|
450
|
+
* redacts `sfdxAuthUrl` there behind a placeholder unless a
|
|
451
|
+
* documented-temporary env var is set — so on sf 2.150.6 a good web login was
|
|
452
|
+
* answered with the logout-then-login remedy (measured in UAT, 2026-09-20).
|
|
453
|
+
* The stable command ships in the same plugin version and needs no env var,
|
|
454
|
+
* which is why this door passes NO third argument to `deps.spawn` and overlays
|
|
455
|
+
* no environment on the child.
|
|
456
|
+
*
|
|
457
|
+
* Refusals BEFORE the network, in this order: the flag exclusivity (DM5), the
|
|
458
|
+
* alias rule (DM4), `sf` on PATH (DM3: install lines, never an auto-install),
|
|
459
|
+
* the stored credential, the spawn's own failure, an `sf` too old to have the
|
|
460
|
+
* command (263cefa5 DM2), sf's own non-zero exit (which is where a login with
|
|
461
|
+
* no refresh token lands: the command throws rather than answering with a url,
|
|
462
|
+
* so a JWT or access-token login is refused HERE and not by the url check
|
|
463
|
+
* below), then the envelope's shape (DM2: an empty-client or other-app url is
|
|
464
|
+
* refused here with the alias-bearing remedy rather than round-tripped to a
|
|
465
|
+
* server that would refuse it anyway).
|
|
466
|
+
*/
|
|
467
|
+
async function connectFromSfCli(deps, origin, values, alias) {
|
|
468
|
+
if (values.sandbox === true || typeof values["my-domain"] === "string") {
|
|
469
|
+
return usageError(deps.writer, FROM_SF_CLI_EXCLUSIVE);
|
|
470
|
+
}
|
|
471
|
+
if (!SF_ALIAS_RE.test(alias))
|
|
472
|
+
return usageError(deps.writer, SF_ALIAS_RULE);
|
|
473
|
+
// On Windows the npm install of sf is a `sf.cmd` shim, which node refuses to
|
|
474
|
+
// spawn without a shell (>= 20.12), so `isOnPath("sf")` reads FALSE for a
|
|
475
|
+
// person who has it (reasoned from Node's documented .cmd refusal, FLO-515
|
|
476
|
+
// fix round 1 — no Windows run exists in this repo's harness). There the
|
|
477
|
+
// PATH probe is skipped and the spawn itself is the probe.
|
|
478
|
+
const win32 = deps.platform === "win32";
|
|
479
|
+
if (!win32 && !deps.isOnPath("sf")) {
|
|
480
|
+
for (const line of sfRemedy(alias, deps.platform))
|
|
481
|
+
deps.writer.err(line);
|
|
482
|
+
return EXIT_USAGE;
|
|
483
|
+
}
|
|
484
|
+
const built = clientFor(deps, origin, values);
|
|
485
|
+
if (!built.ok)
|
|
486
|
+
return EXIT_FAILED;
|
|
487
|
+
// sf's stdout and stderr are NEVER printed: either can quote the URL.
|
|
488
|
+
// UNVERIFIED: the win32 arm — no Windows machine in this repo's harness. It
|
|
489
|
+
// runs sf through cmd.exe as ONE command string; that string is safe by
|
|
490
|
+
// construction because SF_ALIAS_RE (checked above) admits no quote, space or
|
|
491
|
+
// shell metacharacter, so the alias cannot end the command or start another.
|
|
492
|
+
const run = win32
|
|
493
|
+
? await deps.spawn("cmd.exe", ["/d", "/s", "/c", "sf org auth show-sfdx-auth-url --target-org " + alias + " --json"])
|
|
494
|
+
: await deps.spawn("sf", ["org", "auth", "show-sfdx-auth-url", "--target-org", alias, "--json"]);
|
|
495
|
+
const spawnError = run.error !== undefined && run.error !== null;
|
|
496
|
+
// On win32 "sf is not installed" arrives two ways, both the install remedy
|
|
497
|
+
// the PATH probe gives elsewhere: cmd.exe itself could not start (a spawn
|
|
498
|
+
// `error`), or cmd.exe started and reported the command not recognized —
|
|
499
|
+
// its documented command-not-found ERRORLEVEL is 9009, so that STATUS is
|
|
500
|
+
// read as "no sf", never as "that org is not logged in". Reasoned from
|
|
501
|
+
// Windows' documented behaviour; UNVERIFIED on a real box like the rest of
|
|
502
|
+
// this branch.
|
|
503
|
+
if (win32 && (spawnError || run.status === 9009)) {
|
|
504
|
+
for (const line of sfRemedy(alias, "win32"))
|
|
505
|
+
deps.writer.err(line);
|
|
506
|
+
return EXIT_USAGE;
|
|
507
|
+
}
|
|
508
|
+
if (spawnError) {
|
|
509
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url could not be run for '${alias}'. Is that org logged in? ${sfLoginLine(alias)}`);
|
|
510
|
+
return EXIT_FAILED;
|
|
511
|
+
}
|
|
512
|
+
const parsed = parseSfAuthUrl(run.stdout ?? "");
|
|
513
|
+
// THE RULE: exit 127 with NO JSON envelope on stdout is READ AS "this `sf`
|
|
514
|
+
// lacks the subcommand", i.e. it predates 2.147.0. That is an operational
|
|
515
|
+
// classification, not a proven cause: a missing subcommand is the only shape
|
|
516
|
+
// measured to produce it, on sf 2.127.2 (2026-09-20), in both its forms — a
|
|
517
|
+
// clean shell gives rc 127 with EMPTY stdout and "is not a sf command" on
|
|
518
|
+
// stderr, and the SAME run under `DEBUG=sf:*` gives rc 127 with ~2.5 KB
|
|
519
|
+
// (2509-2510 bytes across runs; a timestamp digit) of debug noise on stdout
|
|
520
|
+
// carrying zero `{` characters and absolute filesystem paths, a second
|
|
521
|
+
// reason never to print it. So the test is "did anything parse", not "was
|
|
522
|
+
// stdout empty" — fix round 1 F1: the empty-stdout form sent a debug shell
|
|
523
|
+
// down the logged-out remedy, this card's own defect class. Exit 127 that DOES carry an envelope is not this
|
|
524
|
+
// arm; it falls to the generic non-zero arm below and is named from its
|
|
525
|
+
// `name`. The remedy here is to update sf, never to log in again, so this
|
|
526
|
+
// sentence carries neither the login line nor sf's own stderr. It must sit
|
|
527
|
+
// above the generic non-zero arm, which would otherwise swallow it.
|
|
528
|
+
if (run.status === 127 && !parsed.ok && parsed.reason === "not_json") {
|
|
529
|
+
deps.writer.err(SF_TOO_OLD_REMEDY);
|
|
530
|
+
return EXIT_FAILED;
|
|
531
|
+
}
|
|
532
|
+
if (run.status !== 0) {
|
|
533
|
+
// sf's JSON error envelope names an error CLASS. `NamedOrgNotFoundError`
|
|
534
|
+
// for a logged-out alias at exit 2 is MEASURED (sf 2.127.2, via the shared
|
|
535
|
+
// `Flags.requiredOrg()` resolution). For a JWT or access-token login the
|
|
536
|
+
// command throws instead of answering with a url — so that login lands
|
|
537
|
+
// HERE rather than in the `no_url` arm below — and the card's NC-3 reads
|
|
538
|
+
// plugin-org 6.0.11 as throwing `error.noRefreshToken` there, i.e. a name
|
|
539
|
+
// such as `NoRefreshTokenError`; UNVERIFIED on this box, which has
|
|
540
|
+
// plugin-org 5.9.71 and no `org/auth` command at all. Nothing depends on
|
|
541
|
+
// the spelling: whatever identifier-shaped `name` arrives is printed, and
|
|
542
|
+
// its free text, `actions` and stderr never are.
|
|
543
|
+
const named = !parsed.ok && parsed.reason === "status" && parsed.message !== "";
|
|
544
|
+
const what = named ? `reported ${parsed.message}` : `failed for '${alias}'`;
|
|
545
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url ${what} (exit ${run.status ?? "by signal"}). Is that org logged in? ${sfLoginLine(alias)}`);
|
|
546
|
+
return EXIT_FAILED;
|
|
547
|
+
}
|
|
548
|
+
if (!parsed.ok) {
|
|
549
|
+
switch (parsed.reason) {
|
|
550
|
+
case "not_json":
|
|
551
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url did not return its JSON. ${sfLoginLine(alias)}`);
|
|
552
|
+
break;
|
|
553
|
+
case "status":
|
|
554
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url reported ${parsed.message || "an error"}. ${sfLoginLine(alias)}`);
|
|
555
|
+
break;
|
|
556
|
+
default:
|
|
557
|
+
// `no_url`: no url in the envelope, a non-string one, another connected
|
|
558
|
+
// app's (`force://Other::…`) or an older auth file's empty client id
|
|
559
|
+
// (`force://:…`) — the server door would refuse every one of them.
|
|
560
|
+
deps.writer.err("That sf login cannot be handed over: only the Salesforce CLI's default connected app (PlatformCLI) " +
|
|
561
|
+
`with a web login works. ${sfRelogin(alias)}`);
|
|
294
562
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
563
|
+
return EXIT_FAILED;
|
|
564
|
+
}
|
|
565
|
+
// The ONE place the URL leaves this process: exactly this key, nothing else.
|
|
566
|
+
const res = await built.client.callTool("setup_connect_salesforce_cli", { sfdxAuthUrl: parsed.sfdxAuthUrl });
|
|
567
|
+
if (!res.ok) {
|
|
568
|
+
switch (res.kind) {
|
|
569
|
+
case "tool_error":
|
|
570
|
+
// The door's own refusal sentence (or whatever the server said), scrubbed.
|
|
571
|
+
deps.writer.err(scrubForceUrls(res.message));
|
|
572
|
+
break;
|
|
573
|
+
case "rpc_error":
|
|
574
|
+
// A JSON-RPC error's message is the server's text: only its code is printed.
|
|
575
|
+
deps.writer.err(`Flowsprite's endpoint rejected the call${res.code === undefined ? "" : ` (JSON-RPC error ${res.code})`}. Run \`flowsprite doctor\`.`);
|
|
576
|
+
break;
|
|
577
|
+
default:
|
|
578
|
+
// `unauthorized` / `transport`: the client builds these sentences from
|
|
579
|
+
// its own URL, the HTTP status and an error NAME — no server text.
|
|
580
|
+
deps.writer.err(scrubForceUrls(res.message));
|
|
581
|
+
}
|
|
582
|
+
return EXIT_FAILED;
|
|
583
|
+
}
|
|
584
|
+
const doc = projectDoorResult(toolJson(res));
|
|
585
|
+
if (doc === null) {
|
|
586
|
+
deps.writer.err("Flowsprite sent an unexpected reply while connecting that Salesforce CLI login.");
|
|
587
|
+
return EXIT_FAILED;
|
|
588
|
+
}
|
|
589
|
+
if (values.json === true)
|
|
590
|
+
deps.writer.out(JSON.stringify(doc, null, 2));
|
|
591
|
+
if (!doc.ok) {
|
|
592
|
+
deps.writer.err(doorRefusalSentence(doc.code, alias));
|
|
593
|
+
return EXIT_FAILED;
|
|
594
|
+
}
|
|
595
|
+
if (values.json !== true) {
|
|
596
|
+
deps.writer.out(`Connected ${doc.orgName ?? doc.username} (${doc.envType}).`);
|
|
597
|
+
if (doc.reconnected)
|
|
598
|
+
deps.writer.out("That org was already connected to this workspace; its login was refreshed.");
|
|
599
|
+
deps.writer.out("Done.");
|
|
600
|
+
}
|
|
601
|
+
return EXIT_OK;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The `setup_connect_salesforce` arguments the flags ask for — ONE home, used by
|
|
605
|
+
* `connect salesforce` and by `setup`'s Salesforce rung, so a flag `setup`
|
|
606
|
+
* accepts cannot be one it silently ignores (FLO-514: `--my-domain` was).
|
|
607
|
+
*/
|
|
608
|
+
function salesforceLinkArgs(values) {
|
|
609
|
+
return {
|
|
610
|
+
...(values.sandbox === true ? { env: "sandbox" } : {}),
|
|
611
|
+
...(typeof values["my-domain"] === "string" ? { myDomain: values["my-domain"] } : {}),
|
|
612
|
+
};
|
|
298
613
|
}
|
|
299
|
-
function reportLinkOutcome(writer, outcome) {
|
|
614
|
+
function reportLinkOutcome(writer, outcome, provider) {
|
|
615
|
+
const noun = provider === "github" ? "GitHub installation" : "Salesforce org";
|
|
300
616
|
switch (outcome.kind) {
|
|
301
617
|
case "done":
|
|
302
618
|
writer.out("Done.");
|
|
303
619
|
return EXIT_OK;
|
|
304
620
|
case "expired":
|
|
305
|
-
writer.err("That setup link expired before it was used. Run the command again for a fresh one.");
|
|
306
|
-
return EXIT_FAILED;
|
|
307
621
|
case "timeout":
|
|
308
|
-
|
|
622
|
+
if (outcome.used) {
|
|
623
|
+
// The link was opened but no new id arrived: re-authorizing an org (or
|
|
624
|
+
// re-installing on an account) that is already connected refreshes the
|
|
625
|
+
// existing row and mints nothing new, so "expired" would misdescribe it.
|
|
626
|
+
writer.err(`The link was used, but no new ${noun} appeared in this workspace. ` +
|
|
627
|
+
"If you signed in to one that was already connected, it was refreshed — run `flowsprite status`.");
|
|
628
|
+
}
|
|
629
|
+
else if (outcome.kind === "expired") {
|
|
630
|
+
writer.err("That setup link expired before it was used. Run the command again for a fresh one.");
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
writer.err("Gave up waiting after 10 minutes. The link may still work — finish it in the browser, then run `flowsprite status`.");
|
|
634
|
+
}
|
|
309
635
|
return EXIT_FAILED;
|
|
310
636
|
default:
|
|
311
637
|
writer.err(outcome.message);
|
|
@@ -352,13 +678,13 @@ async function cmdBind(deps, origin, values) {
|
|
|
352
678
|
const built = clientFor(deps, origin, values);
|
|
353
679
|
if (!built.ok)
|
|
354
680
|
return EXIT_FAILED;
|
|
355
|
-
return await runBind(deps, values, built.client, null);
|
|
681
|
+
return await runBind(deps, origin, values, built.client, null);
|
|
356
682
|
}
|
|
357
|
-
async function runBind(deps, values, client, rung) {
|
|
358
|
-
const filled = await fillBindInput(deps, client, values, rung);
|
|
359
|
-
if (filled
|
|
360
|
-
return
|
|
361
|
-
const plan = planBindArgs(filled);
|
|
683
|
+
async function runBind(deps, origin, values, client, rung) {
|
|
684
|
+
const filled = await fillBindInput(deps, origin, client, values, rung);
|
|
685
|
+
if (!filled.ok)
|
|
686
|
+
return filled.rc;
|
|
687
|
+
const plan = planBindArgs(filled.input);
|
|
362
688
|
if (!plan.ok) {
|
|
363
689
|
// A ONE-LINE REMEDY, not the whole usage text. By the time we are here the
|
|
364
690
|
// command line parsed fine and the workspace was read; dumping 39 lines of
|
|
@@ -379,8 +705,45 @@ async function runBind(deps, values, client, rung) {
|
|
|
379
705
|
for (const line of textOf(result.content))
|
|
380
706
|
deps.writer.out(line);
|
|
381
707
|
}
|
|
708
|
+
// Only `setup` waits: its NEXT rung is the first sync, which refuses a
|
|
709
|
+
// repository that is still being created. The wait sits inside the bind step,
|
|
710
|
+
// before the ladder's next read, so FIX-F1's identity guard compares the
|
|
711
|
+
// payload AFTER the repository settled.
|
|
712
|
+
// ponytail: a bare `flowsprite bind` does not wait — it has no next step to
|
|
713
|
+
// protect; `flowsprite status` shows "(creating)". Wait there too if people
|
|
714
|
+
// chain `bind && sync` in scripts.
|
|
715
|
+
if (rung !== null && asObject(toolJson(result))?.bootstrapState === "creating") {
|
|
716
|
+
return await reportRepositoryWait(deps, values, client, String(filled.input.connection));
|
|
717
|
+
}
|
|
382
718
|
return EXIT_OK;
|
|
383
719
|
}
|
|
720
|
+
async function reportRepositoryWait(deps, values, client, connectionId) {
|
|
721
|
+
// Progress is prose, so under --json it goes to stderr and stdout stays parseable.
|
|
722
|
+
const say = (line) => (values.json === true ? deps.writer.err(line) : deps.writer.out(line));
|
|
723
|
+
say("Waiting for GitHub to create the repository...");
|
|
724
|
+
const outcome = await waitForRepository(setupDeps(deps, client), connectionId);
|
|
725
|
+
switch (outcome.kind) {
|
|
726
|
+
case "ready":
|
|
727
|
+
say("Repository ready.");
|
|
728
|
+
return EXIT_OK;
|
|
729
|
+
case "gone":
|
|
730
|
+
return EXIT_OK;
|
|
731
|
+
case "failed":
|
|
732
|
+
// Scrubbed HERE as well as at the writer: the writer keeps newlines (a
|
|
733
|
+
// multi-line refusal or a --json document is meant to stay lines), but a
|
|
734
|
+
// stored error INTERPOLATED into one sentence must not forge a second line
|
|
735
|
+
// (FLO-511 F3 pins it).
|
|
736
|
+
deps.writer.err(`Creating the repository failed: ${printable(outcome.error)}`);
|
|
737
|
+
deps.writer.err("Run `flowsprite setup` again to choose another name, or pick an existing repository.");
|
|
738
|
+
return EXIT_FAILED;
|
|
739
|
+
case "timeout":
|
|
740
|
+
deps.writer.err("The repository is still being created. Run `flowsprite setup` again in a minute.");
|
|
741
|
+
return EXIT_FAILED;
|
|
742
|
+
default:
|
|
743
|
+
deps.writer.err(outcome.message);
|
|
744
|
+
return EXIT_FAILED;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
384
747
|
/**
|
|
385
748
|
* Fill in `--connection`, `--installation` and the repository when the workspace
|
|
386
749
|
* leaves no choice, and ask when it does.
|
|
@@ -391,14 +754,16 @@ async function runBind(deps, values, client, rung) {
|
|
|
391
754
|
* refuses — never guessed, because binding SUPERSEDES whatever that connection
|
|
392
755
|
* was bound to before and switches off its data export.
|
|
393
756
|
*
|
|
394
|
-
* THE REPOSITORY
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
757
|
+
* THE REPOSITORY DEFAULTS TO A NEW PRIVATE ONE (FLO-511, Noah 2026-09-17). A
|
|
758
|
+
* fresh GitHub installation has no repository list Flowsprite can show, so
|
|
759
|
+
* picking cannot be the default. Creating writes to the person's GitHub account,
|
|
760
|
+
* and the tool treats create + private as a consent decision, so it happens only
|
|
761
|
+
* after a yes at a prompt (or an explicit `--create`); without a prompt the
|
|
762
|
+
* command refuses and names the flag. Picking an existing repository is the "no"
|
|
763
|
+
* branch, and `--repo` is its non-interactive form.
|
|
400
764
|
*/
|
|
401
|
-
async function fillBindInput(deps, client, values, rung) {
|
|
765
|
+
async function fillBindInput(deps, origin, client, values, rung) {
|
|
766
|
+
const refuse = { ok: false, rc: EXIT_USAGE };
|
|
402
767
|
const input = {
|
|
403
768
|
connection: typeof values.connection === "string" ? values.connection : undefined,
|
|
404
769
|
installation: typeof values.installation === "string" ? values.installation : undefined,
|
|
@@ -410,7 +775,7 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
410
775
|
return status.ok ? status.payload : undefined;
|
|
411
776
|
})();
|
|
412
777
|
if (payload === undefined)
|
|
413
|
-
return
|
|
778
|
+
return refuse;
|
|
414
779
|
const root = asObject(payload);
|
|
415
780
|
const connections = asArray(root?.connections);
|
|
416
781
|
const installations = asArray(root?.installations);
|
|
@@ -428,7 +793,7 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
428
793
|
if (typeof input.connection === "string") {
|
|
429
794
|
if (rung !== null && !rung.connectionIds.includes(input.connection)) {
|
|
430
795
|
deps.writer.err(outOfRungRefusal(input.connection, rung));
|
|
431
|
-
return
|
|
796
|
+
return refuse;
|
|
432
797
|
}
|
|
433
798
|
}
|
|
434
799
|
else {
|
|
@@ -437,7 +802,7 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
437
802
|
`${String(c.displayName ?? "?")} [${String(c.envType ?? "?")}]`,
|
|
438
803
|
]));
|
|
439
804
|
if (picked === null)
|
|
440
|
-
return
|
|
805
|
+
return refuse;
|
|
441
806
|
input.connection = picked;
|
|
442
807
|
}
|
|
443
808
|
// ---- which GitHub installation ------------------------------------------
|
|
@@ -447,30 +812,70 @@ async function fillBindInput(deps, client, values, rung) {
|
|
|
447
812
|
`@${String(i.accountLogin ?? "?")} [${String(i.accountType ?? "?")}]`,
|
|
448
813
|
]));
|
|
449
814
|
if (picked === null)
|
|
450
|
-
return
|
|
815
|
+
return refuse;
|
|
451
816
|
input.installation = picked;
|
|
452
817
|
}
|
|
453
|
-
// ----
|
|
818
|
+
// ---- create a new repository, or pick an existing one -------------------
|
|
454
819
|
if (typeof input.repo !== "string" && typeof input.create !== "string") {
|
|
455
|
-
const
|
|
820
|
+
const installationId = String(input.installation);
|
|
821
|
+
const chosen = installations.find((i) => String(i.installationId ?? "") === installationId);
|
|
822
|
+
const target = connections.find((c) => String(c.id ?? "") === String(input.connection));
|
|
823
|
+
const suggested = typeof target?.suggestedRepoName === "string" && target.suggestedRepoName.length > 0
|
|
824
|
+
? target.suggestedRepoName
|
|
825
|
+
: null;
|
|
826
|
+
// Decided from setup_status BEFORE any call: a refusal's `kind` never
|
|
827
|
+
// reaches this client over HTTP, only its sentence.
|
|
828
|
+
if (chosen?.needsUserAuth === true) {
|
|
829
|
+
deps.writer.err(`GitHub needs a one-time sign-in before Flowsprite can create a repository on @${String(chosen.accountLogin ?? "?")}. ` +
|
|
830
|
+
`Open ${origin}/api/auth/github/user/start?installation_id=${installationId} in the browser where this workspace is open, ` +
|
|
831
|
+
"then run `flowsprite setup` again.");
|
|
832
|
+
deps.writer.err("To use a repository that already exists instead, pass --repo owner/name.");
|
|
833
|
+
return { ok: false, rc: EXIT_FAILED };
|
|
834
|
+
}
|
|
835
|
+
if (deps.ask === undefined) {
|
|
836
|
+
deps.writer.err("Flowsprite creates a new private repository here by default, and that needs a yes. " +
|
|
837
|
+
`Run \`flowsprite setup\` at a terminal, or pass --create ${suggested ?? "<name>"} ` +
|
|
838
|
+
"(or --repo owner/name to use an existing repository).");
|
|
839
|
+
return refuse;
|
|
840
|
+
}
|
|
841
|
+
const answer = (await deps.ask(`Create a new private GitHub repository for ${String(target?.displayName ?? input.connection)}? [Y/n] `)).trim().toLowerCase();
|
|
842
|
+
if (answer !== "" && answer !== "y" && answer !== "yes" && answer !== "n" && answer !== "no") {
|
|
843
|
+
// A typo is neither answer: reading it as "no" would bind an existing
|
|
844
|
+
// repository (silently, when exactly one is listed).
|
|
845
|
+
deps.writer.err("Answer y or n.");
|
|
846
|
+
return refuse;
|
|
847
|
+
}
|
|
848
|
+
if (answer === "" || answer === "y" || answer === "yes") {
|
|
849
|
+
const typed = (await deps.ask(`Repository name [${suggested ?? ""}]: `)).trim();
|
|
850
|
+
const name = typed.length > 0 ? typed : suggested;
|
|
851
|
+
if (name === null) {
|
|
852
|
+
deps.writer.err("The new repository needs a name. Run `flowsprite setup` again and type one, or pass --create <name>.");
|
|
853
|
+
return refuse;
|
|
854
|
+
}
|
|
855
|
+
// Not validated here: the server's own rule refuses a bad name, and its
|
|
856
|
+
// sentence is what the person sees.
|
|
857
|
+
input.create = name;
|
|
858
|
+
return { ok: true, input };
|
|
859
|
+
}
|
|
456
860
|
const repos = asArray(chosen?.repos);
|
|
457
861
|
if (repos.length === 0) {
|
|
458
862
|
// An EMPTY list means Flowsprite has not listed that installation's
|
|
459
|
-
// repositories, not that the installation has none
|
|
460
|
-
//
|
|
461
|
-
deps.writer.err(`Flowsprite has not listed any repositories for installation ${
|
|
462
|
-
"
|
|
463
|
-
|
|
863
|
+
// repositories, not that the installation has none. The web's repository
|
|
864
|
+
// dropdown on Salesforce Orgs is the only thing that lists them.
|
|
865
|
+
deps.writer.err(`Flowsprite has not listed any repositories for installation ${installationId} yet. ` +
|
|
866
|
+
"Open this connection's repository list on the Salesforce Orgs page in the app and press Refresh, " +
|
|
867
|
+
"then run `flowsprite setup` again — or answer yes to create a new private one.");
|
|
868
|
+
return refuse;
|
|
464
869
|
}
|
|
465
870
|
const picked = await pickOne(deps, REPOSITORY, repos.map((r) => [
|
|
466
871
|
String(r.fullName ?? ""),
|
|
467
872
|
`${r.private === true ? "private" : "PUBLIC — will be refused"}, default branch ${String(r.defaultBranch ?? "?")}`,
|
|
468
873
|
]));
|
|
469
874
|
if (picked === null)
|
|
470
|
-
return
|
|
875
|
+
return refuse;
|
|
471
876
|
input.repo = picked;
|
|
472
877
|
}
|
|
473
|
-
return input;
|
|
878
|
+
return { ok: true, input };
|
|
474
879
|
}
|
|
475
880
|
/**
|
|
476
881
|
* One of a list, or nothing.
|
|
@@ -574,21 +979,68 @@ async function runSync(deps, values, client, rung) {
|
|
|
574
979
|
* it is a backstop, not a budget.
|
|
575
980
|
*/
|
|
576
981
|
const MAX_SETUP_RUNGS = 24;
|
|
982
|
+
/** Which login `setup` would use, in words, or null when there is none (N3 step 1). */
|
|
983
|
+
function loginInWords(deps, origin, values) {
|
|
984
|
+
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
985
|
+
const token = resolveToken({
|
|
986
|
+
flag: typeof values.token === "string" ? values.token : undefined,
|
|
987
|
+
env: deps.env.FLOWSPRITE_TOKEN,
|
|
988
|
+
stored: stored?.token,
|
|
989
|
+
});
|
|
990
|
+
if (token === null)
|
|
991
|
+
return null;
|
|
992
|
+
if (token.source !== "stored")
|
|
993
|
+
return `using the token from ${token.source}`;
|
|
994
|
+
// The stored ids are what the file holds (C4: names are never stored).
|
|
995
|
+
return stored !== undefined && stored.userId.length > 0
|
|
996
|
+
? `already logged in as ${stored.userId} in workspace ${stored.orgId}`
|
|
997
|
+
: "already logged in with a saved token";
|
|
998
|
+
}
|
|
577
999
|
async function cmdSetup(deps, origin, values) {
|
|
578
1000
|
// Doctor-lite: the two things that make every later step fail confusingly.
|
|
579
1001
|
if (nodeMajor(deps.nodeVersion) < MIN_NODE_MAJOR) {
|
|
580
1002
|
deps.writer.err(`flowsprite needs node ${MIN_NODE_MAJOR} or newer; this is ${deps.nodeVersion}.`);
|
|
581
1003
|
return EXIT_FAILED;
|
|
582
1004
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1005
|
+
const steps = createStepTracker(deps.writer);
|
|
1006
|
+
const rc = await runSetup(deps, origin, values, steps);
|
|
1007
|
+
// N14: EVERY non-success exit past the node check ends with how to continue.
|
|
1008
|
+
// No new state: a rerun re-derives its step from setup_status.
|
|
1009
|
+
if (rc !== EXIT_OK)
|
|
1010
|
+
deps.writer.err(rerunHint(rerunLogin(deps, origin, values), steps.current()));
|
|
1011
|
+
return rc;
|
|
1012
|
+
}
|
|
1013
|
+
/** G6: what a rerun would log in with — a saved credential is claimed only when one is stored. */
|
|
1014
|
+
function rerunLogin(deps, origin, values) {
|
|
1015
|
+
// H2: an explicit token wins for THIS run, so a plain rerun would use a
|
|
1016
|
+
// different login (the stored one, or none) — say "supplied" whatever is stored.
|
|
1017
|
+
const flag = typeof values.token === "string" && values.token.length > 0;
|
|
1018
|
+
const env = typeof deps.env.FLOWSPRITE_TOKEN === "string" && deps.env.FLOWSPRITE_TOKEN.length > 0;
|
|
1019
|
+
if (flag || env)
|
|
1020
|
+
return "supplied";
|
|
1021
|
+
return readCredentials(deps.env, deps.home)[origin] !== undefined ? "stored" : "none";
|
|
1022
|
+
}
|
|
1023
|
+
async function runSetup(deps, origin, values, steps) {
|
|
1024
|
+
let noticePrinted = false;
|
|
1025
|
+
const login = loginInWords(deps, origin, values);
|
|
1026
|
+
/** N4: once, before the first thing that waits on the browser — naming only what this run waits on (G9a). */
|
|
1027
|
+
const browserNotice = () => {
|
|
1028
|
+
if (noticePrinted)
|
|
1029
|
+
return;
|
|
1030
|
+
noticePrinted = true;
|
|
1031
|
+
deps.writer.out(browserWaitNotice(login === null ? MAX_EXPIRES_IN_SECONDS : null));
|
|
1032
|
+
};
|
|
1033
|
+
if (login === null) {
|
|
587
1034
|
deps.writer.out(`Not logged in to ${origin} yet.`);
|
|
1035
|
+
browserNotice();
|
|
1036
|
+
steps.enter(1);
|
|
588
1037
|
const rc = await cmdLogin(deps, origin, values);
|
|
589
1038
|
if (rc !== EXIT_OK)
|
|
590
1039
|
return rc;
|
|
591
1040
|
}
|
|
1041
|
+
else {
|
|
1042
|
+
steps.skip(1, login);
|
|
1043
|
+
}
|
|
592
1044
|
const built = clientFor(deps, origin, values);
|
|
593
1045
|
if (!built.ok)
|
|
594
1046
|
return EXIT_FAILED;
|
|
@@ -610,14 +1062,15 @@ async function cmdSetup(deps, origin, values) {
|
|
|
610
1062
|
* even when a volatile field makes two payloads differ.
|
|
611
1063
|
*/
|
|
612
1064
|
let previousStatus = null;
|
|
613
|
-
|
|
1065
|
+
/** C7: the step that just ran, in words — the tool name is MCP vocabulary, not a terminal user's. */
|
|
1066
|
+
let previousStep = null;
|
|
614
1067
|
for (let rungs = 0;; rungs++) {
|
|
615
1068
|
const status = await fetchStatus(deps, built.client);
|
|
616
1069
|
if (!status.ok)
|
|
617
1070
|
return EXIT_FAILED;
|
|
618
1071
|
const serialized = JSON.stringify(status.payload) ?? "";
|
|
619
|
-
if (
|
|
620
|
-
deps.writer.err(
|
|
1072
|
+
if (previousStep !== null && serialized === previousStatus) {
|
|
1073
|
+
deps.writer.err(`${previousStep} reported success, but this workspace's setup status did not change. ` +
|
|
621
1074
|
"Stopping rather than repeating it: the setup steps are list-wide, so a step that does not " +
|
|
622
1075
|
"move the checklist would repeat forever. Run `flowsprite status` to see what is left.");
|
|
623
1076
|
return EXIT_FAILED;
|
|
@@ -628,6 +1081,11 @@ async function cmdSetup(deps, origin, values) {
|
|
|
628
1081
|
}
|
|
629
1082
|
previousStatus = serialized;
|
|
630
1083
|
const step = planNextStep(status.payload);
|
|
1084
|
+
const stepNumber = stepOfRung(step.kind);
|
|
1085
|
+
if (stepNumber !== null)
|
|
1086
|
+
steps.enter(stepNumber);
|
|
1087
|
+
else if (step.kind === "first_sync")
|
|
1088
|
+
steps.finishSteps();
|
|
631
1089
|
switch (step.kind) {
|
|
632
1090
|
case "done":
|
|
633
1091
|
deps.writer.out("Setup is complete.");
|
|
@@ -640,12 +1098,20 @@ async function cmdSetup(deps, origin, values) {
|
|
|
640
1098
|
case "connect_salesforce":
|
|
641
1099
|
case "connect_github": {
|
|
642
1100
|
deps.writer.out(step.text);
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1101
|
+
if (step.kind === "connect_salesforce" && deps.isOnPath("sf")) {
|
|
1102
|
+
deps.writer.out("Already logged in to that org with the Salesforce CLI? `flowsprite connect salesforce --from-sf-cli <alias>` connects it without a browser.");
|
|
1103
|
+
}
|
|
1104
|
+
const provider = step.kind === "connect_salesforce" ? "salesforce" : "github";
|
|
1105
|
+
const tool = provider === "salesforce" ? "setup_connect_salesforce" : "setup_connect_github";
|
|
1106
|
+
const args = provider === "salesforce" ? salesforceLinkArgs(values) : {};
|
|
1107
|
+
// The payload just read IS the pre-mint snapshot: the rung is done when a
|
|
1108
|
+
// NEW connection / installation appears in it, and the identity check at
|
|
1109
|
+
// the top of the loop then sees the ladder move on that same new row.
|
|
1110
|
+
browserNotice();
|
|
1111
|
+
const rc = await settleLinkOutcome(deps, built.client, await runLinkStep(sdeps, tool, args, provider, status.payload), provider, status.payload);
|
|
646
1112
|
if (rc !== EXIT_OK)
|
|
647
1113
|
return rc;
|
|
648
|
-
|
|
1114
|
+
previousStep = provider === "salesforce" ? "Connecting Salesforce" : "Connecting GitHub";
|
|
649
1115
|
break;
|
|
650
1116
|
}
|
|
651
1117
|
default: {
|
|
@@ -660,13 +1126,27 @@ async function cmdSetup(deps, origin, values) {
|
|
|
660
1126
|
return EXIT_FAILED;
|
|
661
1127
|
}
|
|
662
1128
|
deps.writer.out(step.text);
|
|
1129
|
+
if (step.kind === "first_sync") {
|
|
1130
|
+
// A repository still being CREATED already counts as bound, so a re-run
|
|
1131
|
+
// of `setup` (or a wait that ended "gone") can land here while
|
|
1132
|
+
// `sfdc_trigger_sync` would refuse it. Wait for each one first, then
|
|
1133
|
+
// re-read the ladder rather than syncing off this stale payload.
|
|
1134
|
+
const creating = asArray(asObject(status.payload)?.connections).filter((c) => connectionIds.includes(String(c.id)) && c.bootstrapState === "creating");
|
|
1135
|
+
for (const c of creating) {
|
|
1136
|
+
const rc = await reportRepositoryWait(deps, values, built.client, String(c.id));
|
|
1137
|
+
if (rc !== EXIT_OK)
|
|
1138
|
+
return rc;
|
|
1139
|
+
}
|
|
1140
|
+
if (creating.length > 0)
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
663
1143
|
const rung = { kind: step.kind, connectionIds, status: status.payload };
|
|
664
1144
|
const rc = step.kind === "bind_repo"
|
|
665
|
-
? await runBind(deps, values, built.client, rung)
|
|
1145
|
+
? await runBind(deps, origin, values, built.client, rung)
|
|
666
1146
|
: await runSync(deps, values, built.client, rung);
|
|
667
1147
|
if (rc !== EXIT_OK)
|
|
668
1148
|
return rc;
|
|
669
|
-
|
|
1149
|
+
previousStep = step.kind === "bind_repo" ? "Binding a repository" : "Starting the first sync";
|
|
670
1150
|
break;
|
|
671
1151
|
}
|
|
672
1152
|
}
|
|
@@ -715,12 +1195,44 @@ function mcpPrint(deps, origin, values, flag) {
|
|
|
715
1195
|
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
716
1196
|
const mcpEndpoint = stored?.mcpEndpoint ?? `${origin}/api/mcp`;
|
|
717
1197
|
const tokenEnv = typeof values["token-env"] === "string" ? values["token-env"] : undefined;
|
|
718
|
-
|
|
1198
|
+
const clients = flag !== undefined ? [flag] : [...CLIENTS];
|
|
1199
|
+
const ctxLite = pathContext(deps);
|
|
1200
|
+
/** Every stdout line of this invocation, with `placeholder` where the bearer goes. */
|
|
1201
|
+
const render = (placeholder) => {
|
|
1202
|
+
const rows = clients.map((client) => {
|
|
1203
|
+
const cfg = configPathFor(client, ctxLite);
|
|
1204
|
+
const command = commandFor(client);
|
|
1205
|
+
return {
|
|
1206
|
+
client,
|
|
1207
|
+
// What `mcp install` WOULD do for this client, on this machine, right now —
|
|
1208
|
+
// derived from the same planner, so `print --json` cannot describe a route
|
|
1209
|
+
// `install` does not take.
|
|
1210
|
+
kind: planInstall(client, {
|
|
1211
|
+
...ctxLite,
|
|
1212
|
+
origin,
|
|
1213
|
+
mcpEndpoint,
|
|
1214
|
+
token: TOKEN_PLACEHOLDER,
|
|
1215
|
+
tokenEnv,
|
|
1216
|
+
onPath: deps.isOnPath,
|
|
1217
|
+
fileExists: deps.fileExists,
|
|
1218
|
+
}).kind,
|
|
1219
|
+
path: cfg.path,
|
|
1220
|
+
...(command === null ? {} : { command }),
|
|
1221
|
+
snippet: renderSnippet(client, { url: mcpEndpoint, placeholder, tokenEnv }),
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
// ONE client asked for, one object; no client asked for, the array. Nothing
|
|
1225
|
+
// else on stdout either way — that is what makes this parseable.
|
|
1226
|
+
if (values.json === true)
|
|
1227
|
+
return [JSON.stringify(flag !== undefined ? rows[0] : rows, null, 2)];
|
|
1228
|
+
return rows.flatMap((row, n) => [...(n > 0 ? [""] : []), `# ${row.client}`, row.snippet]);
|
|
1229
|
+
};
|
|
719
1230
|
if (values.reveal === true) {
|
|
720
|
-
if (deps.
|
|
1231
|
+
if (deps.reveal === undefined) {
|
|
721
1232
|
// A SECRET GOES TO A TERMINAL OR NOWHERE. Through a pipe the destination is
|
|
722
1233
|
// a file, a log or another process's buffer, and none of those is something
|
|
723
|
-
// the person watching can decide to clear.
|
|
1234
|
+
// the person watching can decide to clear. The sink exists iff stdout is a
|
|
1235
|
+
// terminal (wiring.ts), so its absence IS the pipe.
|
|
724
1236
|
deps.writer.err("--reveal prints the real token, so it only runs at a terminal and stdout here is not one. " +
|
|
725
1237
|
"Re-run it at a terminal, or drop --reveal for a snippet with a placeholder.");
|
|
726
1238
|
return EXIT_USAGE;
|
|
@@ -734,44 +1246,16 @@ function mcpPrint(deps, origin, values, flag) {
|
|
|
734
1246
|
deps.writer.err(`Not logged in to ${origin}, so there is no token to reveal. Run \`flowsprite login\`.`);
|
|
735
1247
|
return EXIT_FAILED;
|
|
736
1248
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const command = commandFor(client);
|
|
744
|
-
return {
|
|
745
|
-
client,
|
|
746
|
-
// What `mcp install` WOULD do for this client, on this machine, right now —
|
|
747
|
-
// derived from the same planner, so `print --json` cannot describe a route
|
|
748
|
-
// `install` does not take.
|
|
749
|
-
kind: planInstall(client, {
|
|
750
|
-
...ctxLite,
|
|
751
|
-
origin,
|
|
752
|
-
mcpEndpoint,
|
|
753
|
-
token: TOKEN_PLACEHOLDER,
|
|
754
|
-
tokenEnv,
|
|
755
|
-
onPath: deps.isOnPath,
|
|
756
|
-
fileExists: deps.fileExists,
|
|
757
|
-
}).kind,
|
|
758
|
-
path: cfg.path,
|
|
759
|
-
...(command === null ? {} : { command }),
|
|
760
|
-
snippet: renderSnippet(client, { url: mcpEndpoint, placeholder, tokenEnv }),
|
|
761
|
-
};
|
|
762
|
-
});
|
|
763
|
-
if (values.json === true) {
|
|
764
|
-
// ONE client asked for, one object; no client asked for, the array. Nothing
|
|
765
|
-
// else on stdout either way — that is what makes this parseable.
|
|
766
|
-
deps.writer.out(JSON.stringify(flag !== undefined ? rows[0] : rows, null, 2));
|
|
1249
|
+
// THE ONE CALL SITE OF THE UNREDACTED SINK (FLO-513 AC3). Every stdout line
|
|
1250
|
+
// of a --reveal run — the `# client` headers, the snippet, or the --json
|
|
1251
|
+
// document — goes through it, because the writer would redact the token
|
|
1252
|
+
// back out. The real token enters `render` only on this path.
|
|
1253
|
+
for (const line of render(token.token))
|
|
1254
|
+
deps.reveal(line);
|
|
767
1255
|
return EXIT_OK;
|
|
768
1256
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
deps.writer.out("");
|
|
772
|
-
deps.writer.out(`# ${row.client}`);
|
|
773
|
-
deps.writer.out(row.snippet);
|
|
774
|
-
});
|
|
1257
|
+
for (const line of render(TOKEN_PLACEHOLDER))
|
|
1258
|
+
deps.writer.out(line);
|
|
775
1259
|
return EXIT_OK;
|
|
776
1260
|
}
|
|
777
1261
|
async function mcpInstall(deps, origin, values, flag) {
|