flowsprite 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +260 -62
- package/dist/credentials.js +108 -0
- package/dist/doctor.js +17 -3
- package/dist/ghLogin.js +13 -6
- package/dist/login.js +148 -26
- package/dist/mcpClient.js +36 -1
- package/dist/messages.js +64 -2
- package/dist/setup.js +107 -7
- package/dist/sfCli.js +45 -17
- package/dist/usage.js +57 -32
- package/dist/version.js +1 -1
- package/dist/wiring.js +29 -10
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,15 +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
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 { SAME_ACCOUNT_NOTE } from "./messages.js";
|
|
10
11
|
import { printable, redact } from "./redact.js";
|
|
11
|
-
import { asArray, asObject, planBindArgs, planNextStep, rungConnectionIds, runLinkStep, waitForRepository, } from "./setup.js";
|
|
12
|
-
import {
|
|
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";
|
|
13
15
|
import { renderStatus } from "./status.js";
|
|
14
16
|
import { USAGE } from "./usage.js";
|
|
15
17
|
/**
|
|
@@ -26,7 +28,8 @@ import { USAGE } from "./usage.js";
|
|
|
26
28
|
export const EXIT_OK = 0;
|
|
27
29
|
export const EXIT_FAILED = 1;
|
|
28
30
|
export const EXIT_USAGE = 2;
|
|
29
|
-
|
|
31
|
+
/** The parser's option table — exported so usage.test.ts can prove USAGE names every flag. */
|
|
32
|
+
export const OPTIONS = {
|
|
30
33
|
endpoint: { type: "string" },
|
|
31
34
|
json: { type: "boolean" },
|
|
32
35
|
token: { type: "string" },
|
|
@@ -176,6 +179,7 @@ async function cmdLogin(deps, origin, values) {
|
|
|
176
179
|
version: deps.version,
|
|
177
180
|
...(values["no-open"] === true || deps.openUrl === undefined ? {} : { openUrl: deps.openUrl }),
|
|
178
181
|
...(deps.progress === undefined ? {} : { progress: deps.progress }),
|
|
182
|
+
pending: pendingLoginPort(deps, origin),
|
|
179
183
|
};
|
|
180
184
|
// FLO-517: the gh road first, unless opted out. Null means it fell to the
|
|
181
185
|
// browser login at its own start, having said why in one line.
|
|
@@ -201,7 +205,9 @@ async function cmdLogin(deps, origin, values) {
|
|
|
201
205
|
savedAt: deps.now(),
|
|
202
206
|
};
|
|
203
207
|
// The token is NOT in this sentence, and the writer would redact it anyway.
|
|
204
|
-
|
|
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}`;
|
|
205
211
|
try {
|
|
206
212
|
writeCredential(deps.env, origin, cred, deps.home);
|
|
207
213
|
}
|
|
@@ -224,37 +230,90 @@ async function cmdLogin(deps, origin, values) {
|
|
|
224
230
|
return EXIT_FAILED;
|
|
225
231
|
}
|
|
226
232
|
deps.writer.out(who);
|
|
233
|
+
deps.writer.out(SAME_ACCOUNT_NOTE);
|
|
227
234
|
return EXIT_OK;
|
|
228
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
|
+
}
|
|
229
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
|
+
}
|
|
230
279
|
const removed = deleteCredential(deps.env, origin, deps.home);
|
|
231
280
|
deps.writer.out(removed ? `Logged out of ${origin}.` : `There was no stored credential for ${origin}.`);
|
|
232
281
|
return EXIT_OK;
|
|
233
282
|
}
|
|
234
283
|
async function cmdDoctor(deps, origin, values) {
|
|
235
284
|
const stored = readCredentials(deps.env, deps.home)[origin];
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
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 = {
|
|
248
304
|
nodeVersion: deps.nodeVersion,
|
|
249
305
|
nodeOk: nodeMajor(deps.nodeVersion) >= MIN_NODE_MAJOR,
|
|
250
306
|
endpoint: origin,
|
|
251
307
|
endpointReachable: reachable,
|
|
252
308
|
endpointDetail: detail,
|
|
253
|
-
hasCredential:
|
|
309
|
+
hasCredential: token !== null,
|
|
310
|
+
credentialSource: token?.source ?? null,
|
|
311
|
+
credentialRefused: token !== null && unauthorized,
|
|
254
312
|
credentialsPath: credentialsPath(deps.env, deps.home),
|
|
255
313
|
sfOnPath: deps.isOnPath("sf"),
|
|
256
314
|
ghOnPath: deps.isOnPath("gh"),
|
|
257
315
|
};
|
|
316
|
+
const report = { ...checks, ok: doctorOk(checks) };
|
|
258
317
|
if (values.json === true) {
|
|
259
318
|
deps.writer.out(JSON.stringify(report, null, 2));
|
|
260
319
|
}
|
|
@@ -262,18 +321,8 @@ async function cmdDoctor(deps, origin, values) {
|
|
|
262
321
|
for (const line of formatDoctor(report))
|
|
263
322
|
deps.writer.out(line);
|
|
264
323
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
/** Like `clientFor` but silent and optional — doctor reports absence, it does not refuse. */
|
|
268
|
-
function clientForQuiet(deps, origin, values, storedToken) {
|
|
269
|
-
const token = resolveToken({
|
|
270
|
-
flag: typeof values.token === "string" ? values.token : undefined,
|
|
271
|
-
env: deps.env.FLOWSPRITE_TOKEN,
|
|
272
|
-
stored: storedToken,
|
|
273
|
-
});
|
|
274
|
-
if (token === null)
|
|
275
|
-
return null;
|
|
276
|
-
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;
|
|
277
326
|
}
|
|
278
327
|
async function fetchStatus(deps, client) {
|
|
279
328
|
const result = await client.callTool("setup_status", {});
|
|
@@ -302,7 +351,14 @@ async function cmdStatus(deps, origin, values) {
|
|
|
302
351
|
return EXIT_OK;
|
|
303
352
|
}
|
|
304
353
|
function setupDeps(deps, client) {
|
|
305
|
-
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
|
+
};
|
|
306
362
|
}
|
|
307
363
|
async function cmdConnect(deps, origin, values, which) {
|
|
308
364
|
if (which !== "salesforce" && which !== "github") {
|
|
@@ -322,10 +378,48 @@ async function cmdConnect(deps, origin, values, which) {
|
|
|
322
378
|
// (card A3, blocked), which would use a token the person already holds
|
|
323
379
|
// instead of an app install. The Salesforce one is `connectFromSfCli` above.
|
|
324
380
|
const args = which === "salesforce" ? salesforceLinkArgs(values) : {};
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
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);
|
|
329
423
|
}
|
|
330
424
|
/**
|
|
331
425
|
* `flowsprite connect salesforce --from-sf-cli <alias>` (FLO-515): read the
|
|
@@ -341,11 +435,34 @@ async function cmdConnect(deps, origin, values, which) {
|
|
|
341
435
|
* function's source for `fs` / `console` identifiers: the writer and the
|
|
342
436
|
* tools/call body are its only sinks.
|
|
343
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
|
+
*
|
|
344
457
|
* Refusals BEFORE the network, in this order: the flag exclusivity (DM5), the
|
|
345
458
|
* alias rule (DM4), `sf` on PATH (DM3: install lines, never an auto-install),
|
|
346
|
-
* the stored credential,
|
|
347
|
-
* (DM2
|
|
348
|
-
*
|
|
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).
|
|
349
466
|
*/
|
|
350
467
|
async function connectFromSfCli(deps, origin, values, alias) {
|
|
351
468
|
if (values.sandbox === true || typeof values["my-domain"] === "string") {
|
|
@@ -373,8 +490,8 @@ async function connectFromSfCli(deps, origin, values, alias) {
|
|
|
373
490
|
// construction because SF_ALIAS_RE (checked above) admits no quote, space or
|
|
374
491
|
// shell metacharacter, so the alias cannot end the command or start another.
|
|
375
492
|
const run = win32
|
|
376
|
-
? await deps.spawn("cmd.exe", ["/d", "/s", "/c", "sf org
|
|
377
|
-
: await deps.spawn("sf", ["org", "
|
|
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"]);
|
|
378
495
|
const spawnError = run.error !== undefined && run.error !== null;
|
|
379
496
|
// On win32 "sf is not installed" arrives two ways, both the install remedy
|
|
380
497
|
// the PATH probe gives elsewhere: cmd.exe itself could not start (a spawn
|
|
@@ -389,30 +506,57 @@ async function connectFromSfCli(deps, origin, values, alias) {
|
|
|
389
506
|
return EXIT_USAGE;
|
|
390
507
|
}
|
|
391
508
|
if (spawnError) {
|
|
392
|
-
deps.writer.err(`sf org
|
|
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);
|
|
393
530
|
return EXIT_FAILED;
|
|
394
531
|
}
|
|
395
|
-
const parsed = parseSfOrgDisplay(run.stdout ?? "");
|
|
396
532
|
if (run.status !== 0) {
|
|
397
|
-
// sf's JSON error envelope names an error CLASS
|
|
398
|
-
// for a logged-out alias
|
|
399
|
-
//
|
|
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.
|
|
400
543
|
const named = !parsed.ok && parsed.reason === "status" && parsed.message !== "";
|
|
401
544
|
const what = named ? `reported ${parsed.message}` : `failed for '${alias}'`;
|
|
402
|
-
deps.writer.err(`sf org
|
|
545
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url ${what} (exit ${run.status ?? "by signal"}). Is that org logged in? ${sfLoginLine(alias)}`);
|
|
403
546
|
return EXIT_FAILED;
|
|
404
547
|
}
|
|
405
548
|
if (!parsed.ok) {
|
|
406
549
|
switch (parsed.reason) {
|
|
407
550
|
case "not_json":
|
|
408
|
-
deps.writer.err(`sf org
|
|
551
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url did not return its JSON. ${sfLoginLine(alias)}`);
|
|
409
552
|
break;
|
|
410
553
|
case "status":
|
|
411
|
-
deps.writer.err(`sf org
|
|
554
|
+
deps.writer.err(`sf org auth show-sfdx-auth-url reported ${parsed.message || "an error"}. ${sfLoginLine(alias)}`);
|
|
412
555
|
break;
|
|
413
556
|
default:
|
|
414
|
-
// `no_url
|
|
415
|
-
//
|
|
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.
|
|
416
560
|
deps.writer.err("That sf login cannot be handed over: only the Salesforce CLI's default connected app (PlatformCLI) " +
|
|
417
561
|
`with a web login works. ${sfRelogin(alias)}`);
|
|
418
562
|
}
|
|
@@ -439,7 +583,7 @@ async function connectFromSfCli(deps, origin, values, alias) {
|
|
|
439
583
|
}
|
|
440
584
|
const doc = projectDoorResult(toolJson(res));
|
|
441
585
|
if (doc === null) {
|
|
442
|
-
deps.writer.err("
|
|
586
|
+
deps.writer.err("Flowsprite sent an unexpected reply while connecting that Salesforce CLI login.");
|
|
443
587
|
return EXIT_FAILED;
|
|
444
588
|
}
|
|
445
589
|
if (values.json === true)
|
|
@@ -835,21 +979,68 @@ async function runSync(deps, values, client, rung) {
|
|
|
835
979
|
* it is a backstop, not a budget.
|
|
836
980
|
*/
|
|
837
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
|
+
}
|
|
838
999
|
async function cmdSetup(deps, origin, values) {
|
|
839
1000
|
// Doctor-lite: the two things that make every later step fail confusingly.
|
|
840
1001
|
if (nodeMajor(deps.nodeVersion) < MIN_NODE_MAJOR) {
|
|
841
1002
|
deps.writer.err(`flowsprite needs node ${MIN_NODE_MAJOR} or newer; this is ${deps.nodeVersion}.`);
|
|
842
1003
|
return EXIT_FAILED;
|
|
843
1004
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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) {
|
|
848
1034
|
deps.writer.out(`Not logged in to ${origin} yet.`);
|
|
1035
|
+
browserNotice();
|
|
1036
|
+
steps.enter(1);
|
|
849
1037
|
const rc = await cmdLogin(deps, origin, values);
|
|
850
1038
|
if (rc !== EXIT_OK)
|
|
851
1039
|
return rc;
|
|
852
1040
|
}
|
|
1041
|
+
else {
|
|
1042
|
+
steps.skip(1, login);
|
|
1043
|
+
}
|
|
853
1044
|
const built = clientFor(deps, origin, values);
|
|
854
1045
|
if (!built.ok)
|
|
855
1046
|
return EXIT_FAILED;
|
|
@@ -871,14 +1062,15 @@ async function cmdSetup(deps, origin, values) {
|
|
|
871
1062
|
* even when a volatile field makes two payloads differ.
|
|
872
1063
|
*/
|
|
873
1064
|
let previousStatus = null;
|
|
874
|
-
|
|
1065
|
+
/** C7: the step that just ran, in words — the tool name is MCP vocabulary, not a terminal user's. */
|
|
1066
|
+
let previousStep = null;
|
|
875
1067
|
for (let rungs = 0;; rungs++) {
|
|
876
1068
|
const status = await fetchStatus(deps, built.client);
|
|
877
1069
|
if (!status.ok)
|
|
878
1070
|
return EXIT_FAILED;
|
|
879
1071
|
const serialized = JSON.stringify(status.payload) ?? "";
|
|
880
|
-
if (
|
|
881
|
-
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. ` +
|
|
882
1074
|
"Stopping rather than repeating it: the setup steps are list-wide, so a step that does not " +
|
|
883
1075
|
"move the checklist would repeat forever. Run `flowsprite status` to see what is left.");
|
|
884
1076
|
return EXIT_FAILED;
|
|
@@ -889,6 +1081,11 @@ async function cmdSetup(deps, origin, values) {
|
|
|
889
1081
|
}
|
|
890
1082
|
previousStatus = serialized;
|
|
891
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();
|
|
892
1089
|
switch (step.kind) {
|
|
893
1090
|
case "done":
|
|
894
1091
|
deps.writer.out("Setup is complete.");
|
|
@@ -910,10 +1107,11 @@ async function cmdSetup(deps, origin, values) {
|
|
|
910
1107
|
// The payload just read IS the pre-mint snapshot: the rung is done when a
|
|
911
1108
|
// NEW connection / installation appears in it, and the identity check at
|
|
912
1109
|
// the top of the loop then sees the ladder move on that same new row.
|
|
913
|
-
|
|
1110
|
+
browserNotice();
|
|
1111
|
+
const rc = await settleLinkOutcome(deps, built.client, await runLinkStep(sdeps, tool, args, provider, status.payload), provider, status.payload);
|
|
914
1112
|
if (rc !== EXIT_OK)
|
|
915
1113
|
return rc;
|
|
916
|
-
|
|
1114
|
+
previousStep = provider === "salesforce" ? "Connecting Salesforce" : "Connecting GitHub";
|
|
917
1115
|
break;
|
|
918
1116
|
}
|
|
919
1117
|
default: {
|
|
@@ -948,7 +1146,7 @@ async function cmdSetup(deps, origin, values) {
|
|
|
948
1146
|
: await runSync(deps, values, built.client, rung);
|
|
949
1147
|
if (rc !== EXIT_OK)
|
|
950
1148
|
return rc;
|
|
951
|
-
|
|
1149
|
+
previousStep = step.kind === "bind_repo" ? "Binding a repository" : "Starting the first sync";
|
|
952
1150
|
break;
|
|
953
1151
|
}
|
|
954
1152
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { approvePageHref, MAX_EXPIRES_IN_SECONDS, USER_CODE_RE } from "./ghLogin.js";
|
|
5
6
|
export const CREDENTIALS_FILE = "credentials.json";
|
|
6
7
|
const DIR_MODE = 0o700;
|
|
7
8
|
const FILE_MODE = 0o600;
|
|
@@ -175,6 +176,113 @@ export function deleteCredential(env, origin, home) {
|
|
|
175
176
|
writeStore(env, store, home);
|
|
176
177
|
return true;
|
|
177
178
|
}
|
|
179
|
+
// ------------------------------------------------------- the pending login (C3)
|
|
180
|
+
export const PENDING_LOGIN_FILE = "pending-login.json";
|
|
181
|
+
export function pendingLoginPath(env, home) {
|
|
182
|
+
return join(credentialsDir(env, home), PENDING_LOGIN_FILE);
|
|
183
|
+
}
|
|
184
|
+
const nonEmpty = (v) => typeof v === "string" && v.length > 0;
|
|
185
|
+
/**
|
|
186
|
+
* The pending login for `origin`, or null. TOLERANT like `readCredentials`: a
|
|
187
|
+
* missing, unreadable, garbled or wrong-shaped file, or one for another origin,
|
|
188
|
+
* reads as "none". An EXPIRED entry for this origin is deleted as it is read.
|
|
189
|
+
*
|
|
190
|
+
* Held to the WIRE's grammar (eecc9d6f G5): the user code is `USER_CODE_RE`'s
|
|
191
|
+
* shape and the open target must pass `approvePageHref` — exactly this origin's
|
|
192
|
+
* approve page, normalised — so a hand-edited file can make `login` print or
|
|
193
|
+
* open nothing the start route could not have sent. The interval must be finite.
|
|
194
|
+
*/
|
|
195
|
+
export function readPendingLogin(env, origin, now, home) {
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(readFileSync(pendingLoginPath(env, home), "utf8"));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
204
|
+
return null;
|
|
205
|
+
const v = parsed;
|
|
206
|
+
if (v.origin !== origin || !nonEmpty(v.deviceCode))
|
|
207
|
+
return null;
|
|
208
|
+
if (typeof v.expiresAtMs !== "number" || !Number.isFinite(v.expiresAtMs))
|
|
209
|
+
return null;
|
|
210
|
+
if (typeof v.savedAtMs !== "number" || !Number.isFinite(v.savedAtMs))
|
|
211
|
+
return null;
|
|
212
|
+
if (typeof v.interval !== "number" || !Number.isFinite(v.interval) || !(v.interval > 0))
|
|
213
|
+
return null;
|
|
214
|
+
const userCode = nonEmpty(v.userCode) ? v.userCode : null;
|
|
215
|
+
const rawTarget = nonEmpty(v.openTarget) ? v.openTarget : null;
|
|
216
|
+
if ((userCode === null) !== (rawTarget === null))
|
|
217
|
+
return null;
|
|
218
|
+
let openTarget = null;
|
|
219
|
+
if (userCode !== null) {
|
|
220
|
+
if (!USER_CODE_RE.test(userCode))
|
|
221
|
+
return null;
|
|
222
|
+
openTarget = approvePageHref(rawTarget, origin, userCode);
|
|
223
|
+
if (openTarget === null)
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
// H1/H6: no entry is live past min(its write, now) + a code's TTL. A savedAt
|
|
227
|
+
// LATER than now is not rejected — a backward clock step (NTP, resume from
|
|
228
|
+
// suspend) must not abandon a live, maybe already approved, code — it is read
|
|
229
|
+
// as now. So a future-dated forged file lives at most now + 900 s per read, and
|
|
230
|
+
// is ended by the poll answer anyway (`expired_token`/`invalid_*` clear it).
|
|
231
|
+
const effectiveSavedAt = Math.min(v.savedAtMs, now);
|
|
232
|
+
const expiresAtMs = Math.min(v.expiresAtMs, effectiveSavedAt + MAX_EXPIRES_IN_SECONDS * 1000);
|
|
233
|
+
if (expiresAtMs <= now) {
|
|
234
|
+
try {
|
|
235
|
+
clearPendingLogin(env, origin, home, v.deviceCode);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// An undeletable expired entry still reads as none; the next write replaces it.
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return { origin, deviceCode: v.deviceCode, userCode, openTarget, expiresAtMs, savedAtMs: v.savedAtMs, interval: v.interval };
|
|
243
|
+
}
|
|
244
|
+
/** Throws on a write failure; the caller (`login.ts`) treats that as "not persisted", never as a failed login. */
|
|
245
|
+
export function writePendingLogin(env, entry, home) {
|
|
246
|
+
const dir = credentialsDir(env, home);
|
|
247
|
+
mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
248
|
+
chmodSync(dir, DIR_MODE);
|
|
249
|
+
writeFileAtomic(pendingLoginPath(env, home), `${JSON.stringify(entry)}\n`, FILE_MODE);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Delete the pending login if it is `origin`'s — or if it cannot be read as
|
|
253
|
+
* anyone's (garbled), since nothing can resume it. Another origin's entry is
|
|
254
|
+
* left alone. `rmSync` on a symlink removes the link, never its target.
|
|
255
|
+
*
|
|
256
|
+
* eecc9d6f G2: given a `deviceCode`, ONLY an entry holding exactly that code is
|
|
257
|
+
* deleted (a garbled file is then left for the next write to replace), so a
|
|
258
|
+
* stale run that ends cannot delete the entry a NEWER run saved. `logout` calls
|
|
259
|
+
* it without one: logging out drops whatever is pending for the origin.
|
|
260
|
+
*/
|
|
261
|
+
// ponytail: read-then-unlink, not an atomic compare-and-delete — node builtins have
|
|
262
|
+
// none across processes. The window is the microseconds between the read and the
|
|
263
|
+
// `rmSync`, open only to another `flowsprite` run of the SAME user saving a newer
|
|
264
|
+
// entry in exactly that gap; the loser just starts a fresh login next time. A
|
|
265
|
+
// lock file (O_EXCL) around save/clear is the upgrade if that ever matters.
|
|
266
|
+
export function clearPendingLogin(env, origin, home, deviceCode) {
|
|
267
|
+
const path = pendingLoginPath(env, home);
|
|
268
|
+
let stored;
|
|
269
|
+
try {
|
|
270
|
+
stored = JSON.parse(readFileSync(path, "utf8"));
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (err.code === "ENOENT")
|
|
274
|
+
return;
|
|
275
|
+
stored = null;
|
|
276
|
+
}
|
|
277
|
+
const readable = typeof stored === "object" && stored !== null && typeof stored.origin === "string";
|
|
278
|
+
if (deviceCode !== undefined) {
|
|
279
|
+
if (readable && stored.origin === origin && stored.deviceCode === deviceCode)
|
|
280
|
+
rmSync(path, { force: true });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!readable || stored.origin === origin)
|
|
284
|
+
rmSync(path, { force: true });
|
|
285
|
+
}
|
|
178
286
|
/**
|
|
179
287
|
* Which token this invocation uses: an explicit `--token`, else
|
|
180
288
|
* `FLOWSPRITE_TOKEN`, else the credential stored for this origin. The stored one
|