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/sfCli.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of `flowsprite connect salesforce --from-sf-cli <alias>`
|
|
3
|
+
* (FLO-515): the alias rule, the `sf org auth show-sfdx-auth-url --json`
|
|
4
|
+
* envelope parser, the remedies, and the allow-list projection of the door's
|
|
5
|
+
* reply.
|
|
6
|
+
*
|
|
7
|
+
* The command changed on 2026-09-20 (card 263cefa5). FLO-515 read the login
|
|
8
|
+
* from `sf org display --verbose --json`; sf 2.147.0 (published 2026-07-31,
|
|
9
|
+
* `npm view @salesforce/cli time`) bundles @salesforce/plugin-org 6.0.x, which
|
|
10
|
+
* writes a `[REDACTED] Use 'sf org auth show-sfdx-auth-url' to view`
|
|
11
|
+
* placeholder into that command's `sfdxAuthUrl` unless a documented-temporary
|
|
12
|
+
* env var is set, so on a current sf the old parser answered `no_url` for a
|
|
13
|
+
* perfectly good web login (measured on sf 2.150.6, 2026-09-20). The stable
|
|
14
|
+
* command this module now parses ships in the same plugin version and needs no
|
|
15
|
+
* env var. Its `--json` stdout is `{status, result:{sfdxAuthUrl}, warnings}` —
|
|
16
|
+
* no accessToken, no password, no clientId; the command's own warning text
|
|
17
|
+
* lands both in that `warnings` array and on stderr (sf-plugins-core
|
|
18
|
+
* `toSuccessJson`/`warn`, measured against 12.2.6), and this CLI prints
|
|
19
|
+
* neither.
|
|
20
|
+
*
|
|
21
|
+
* NOTHING HERE PRINTS. The `sfdxAuthUrl` this module extracts is a reusable
|
|
22
|
+
* Salesforce credential that the CLI's own redactor cannot see (`redact.ts`
|
|
23
|
+
* covers `fp_user_`/`fp_live_` runs only), so the value's only sinks are the
|
|
24
|
+
* spawn stdout it came from and the `tools/call` arguments — the caller in
|
|
25
|
+
* `cli.ts` (`connectFromSfCli`) holds it in one scope and hands it to exactly
|
|
26
|
+
* one place. The acceptance oracle censuses both files for `fs` / `console`
|
|
27
|
+
* identifiers, which is why this module imports nothing.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* An sf alias or username: letters, digits, `. _ @ + -`, not starting with `-`
|
|
31
|
+
* (flag-shaped for the downstream CLI), at most 80 characters. `@` and `+`
|
|
32
|
+
* because `--target-org` takes usernames, which are emails and can be
|
|
33
|
+
* plus-addressed. The value is spawned as ONE argv element (never a shell), so
|
|
34
|
+
* the rule exists for the printed remedies and for the third-party CLI's own
|
|
35
|
+
* parser, not for a shell.
|
|
36
|
+
*/
|
|
37
|
+
export const SF_ALIAS_RE = /^(?!-)[A-Za-z0-9._@+-]{1,80}$/;
|
|
38
|
+
export const SF_ALIAS_RULE = "--from-sf-cli wants an sf alias or username: letters, digits, . _ @ + -, not starting with -, at most 80 characters.";
|
|
39
|
+
export const FROM_SF_CLI_EXCLUSIVE = "--from-sf-cli takes the org from the sf CLI; --sandbox and --my-domain belong to the browser link.";
|
|
40
|
+
/**
|
|
41
|
+
* Every `force://` run up to the next whitespace becomes a marker that carries
|
|
42
|
+
* NO scheme, so a scrubbed line can never itself read as an auth URL. Applied
|
|
43
|
+
* to every string this command prints that did not originate in this CLI's own
|
|
44
|
+
* constants — a tool's refusal text, an org's display name.
|
|
45
|
+
*/
|
|
46
|
+
export function scrubForceUrls(text) {
|
|
47
|
+
return text.replace(/force:\/\/\S*/g, "[sf auth url removed]");
|
|
48
|
+
}
|
|
49
|
+
const SF_URL_PREFIX = "force://PlatformCLI::";
|
|
50
|
+
const ERROR_NAME_RE = /^[A-Za-z0-9_]{1,64}$/;
|
|
51
|
+
/**
|
|
52
|
+
* The sentence for an `sf` that predates the command (card 263cefa5 DM4). It
|
|
53
|
+
* names no alias: a missing subcommand is a property of the INSTALL, not of
|
|
54
|
+
* the org, so the login line and the logout-then-login remedy are both wrong
|
|
55
|
+
* here — logging in again does not add a subcommand. It also stays true if sf
|
|
56
|
+
* ever removes the command, which is why it ends at the browser road.
|
|
57
|
+
*/
|
|
58
|
+
export const SF_TOO_OLD_REMEDY = "Your Salesforce CLI does not have `sf org auth show-sfdx-auth-url` (added in sf 2.147.0, July 2026), " +
|
|
59
|
+
"which this command reads the login from. Update it — sf update, or npm install -g @salesforce/cli — " +
|
|
60
|
+
"then run this again; if it persists on a current sf, connect this org from the browser: flowsprite connect salesforce.";
|
|
61
|
+
/**
|
|
62
|
+
* Read the `sf org auth show-sfdx-auth-url --target-org <alias> --json`
|
|
63
|
+
* envelope: `{status, result:{sfdxAuthUrl}, warnings}`, parsed from the first
|
|
64
|
+
* `{` because a CLI notice can precede the JSON (the setup skill body already
|
|
65
|
+
* parses from the first `{` for the other configurations). Only
|
|
66
|
+
* `result.sfdxAuthUrl` is read: `warnings` carries the command's own "this
|
|
67
|
+
* exposes an SFDX Auth URL" text, which also goes to stderr, and neither is
|
|
68
|
+
* returned or printed.
|
|
69
|
+
*
|
|
70
|
+
* A non-zero envelope `status` reports the envelope's `name` — an error-class
|
|
71
|
+
* identifier such as `NamedOrgNotFoundError` (a logged-out or mistyped alias)
|
|
72
|
+
* or `NoRefreshTokenError` (a JWT / access-token login) — and NEVER its
|
|
73
|
+
* free-text `message`: that text is third-party and can quote the URL
|
|
74
|
+
* (spec-gate codex C2), so it is not even returned.
|
|
75
|
+
*
|
|
76
|
+
* There is no `clientId` in this envelope and no `client` reason: the url's own
|
|
77
|
+
* `force://PlatformCLI::` prefix IS the client check here, and the server door
|
|
78
|
+
* re-checks it. An auth file written before the CLI stored its client id
|
|
79
|
+
* yields `force://:<token>@host` (empty client id), which fails that prefix and
|
|
80
|
+
* is `no_url` like an absent or non-string url.
|
|
81
|
+
*/
|
|
82
|
+
export function parseSfAuthUrl(stdout) {
|
|
83
|
+
const start = stdout.indexOf("{");
|
|
84
|
+
if (start < 0)
|
|
85
|
+
return { ok: false, reason: "not_json" };
|
|
86
|
+
let doc;
|
|
87
|
+
try {
|
|
88
|
+
doc = JSON.parse(stdout.slice(start));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { ok: false, reason: "not_json" };
|
|
92
|
+
}
|
|
93
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc))
|
|
94
|
+
return { ok: false, reason: "not_json" };
|
|
95
|
+
const envelope = doc;
|
|
96
|
+
if (envelope.status !== 0) {
|
|
97
|
+
const name = typeof envelope.name === "string" && ERROR_NAME_RE.test(envelope.name) ? envelope.name : "";
|
|
98
|
+
return { ok: false, reason: "status", message: name };
|
|
99
|
+
}
|
|
100
|
+
const result = typeof envelope.result === "object" && envelope.result !== null && !Array.isArray(envelope.result)
|
|
101
|
+
? envelope.result
|
|
102
|
+
: {};
|
|
103
|
+
const url = result.sfdxAuthUrl;
|
|
104
|
+
if (typeof url !== "string" || !url.startsWith(SF_URL_PREFIX))
|
|
105
|
+
return { ok: false, reason: "no_url" };
|
|
106
|
+
return { ok: true, sfdxAuthUrl: url };
|
|
107
|
+
}
|
|
108
|
+
/** The `sf org login web` line for this alias — sandbox users need the host. */
|
|
109
|
+
export function sfLoginLine(alias) {
|
|
110
|
+
return (`sf org login web --alias ${alias} --instance-url <your sandbox My Domain, ` +
|
|
111
|
+
"for example https://acme--uat.sandbox.my.salesforce.com, or https://test.salesforce.com; " +
|
|
112
|
+
"leave --instance-url off for a production or Developer Edition org (sf's default login host)>");
|
|
113
|
+
}
|
|
114
|
+
/** The logout-then-login remedy for a login this door cannot take over. */
|
|
115
|
+
export function sfRelogin(alias) {
|
|
116
|
+
return `Run: sf org logout --target-org ${alias}, then ${sfLoginLine(alias)}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* What to print when `sf` is not on PATH: the install line(s) for the platform,
|
|
120
|
+
* the PATH note for a fresh npm install the shell cannot see yet, then the
|
|
121
|
+
* login line. No auto-install (DM3): a global install is the person's decision.
|
|
122
|
+
*/
|
|
123
|
+
export function sfRemedy(alias, platform) {
|
|
124
|
+
const lines = ["The Salesforce CLI (sf) is not on PATH, so there is no login to hand over. Install it:", " npm install -g @salesforce/cli"];
|
|
125
|
+
if (platform === "darwin")
|
|
126
|
+
lines.push(" brew install --cask salesforce-cli");
|
|
127
|
+
if (platform === "win32")
|
|
128
|
+
lines.push(" or the Windows installer: https://developer.salesforce.com/tools/salesforcecli");
|
|
129
|
+
if (platform !== "win32") {
|
|
130
|
+
lines.push('If you installed it with npm and this shell still cannot find it: export PATH="$(npm prefix -g)/bin:$PATH"');
|
|
131
|
+
}
|
|
132
|
+
lines.push("Then log in to the org and run this command again:", ` ${sfLoginLine(alias)}`);
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
135
|
+
const DOOR_CODES = new Set(["sf_cli_refresh_failed", "sf_cli_identity_failed", "sf_cli_env_unresolved", "sf_cli_save_failed"]);
|
|
136
|
+
const ENV_TYPES = new Set(["production", "sandbox", "developer"]);
|
|
137
|
+
/**
|
|
138
|
+
* Rebuild the door's reply from an ALLOW-LIST — exactly {ok, username, orgName,
|
|
139
|
+
* envType, reconnected} or {ok, code} — so a reply is never serialized
|
|
140
|
+
* wholesale and an extra key (the URL echoed back, anything) is dropped rather
|
|
141
|
+
* than printed. The org-reported strings are scrubbed here, before any sink.
|
|
142
|
+
* `null` is "not the door's shape", which the caller reports as unexpected.
|
|
143
|
+
*/
|
|
144
|
+
export function projectDoorResult(doc) {
|
|
145
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc))
|
|
146
|
+
return null;
|
|
147
|
+
const d = doc;
|
|
148
|
+
if (d.ok === true) {
|
|
149
|
+
if (typeof d.username !== "string" || typeof d.envType !== "string" || !ENV_TYPES.has(d.envType))
|
|
150
|
+
return null;
|
|
151
|
+
if (typeof d.reconnected !== "boolean")
|
|
152
|
+
return null;
|
|
153
|
+
if (d.orgName !== undefined && typeof d.orgName !== "string")
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
username: scrubForceUrls(d.username),
|
|
158
|
+
...(d.orgName === undefined ? {} : { orgName: scrubForceUrls(d.orgName) }),
|
|
159
|
+
envType: d.envType,
|
|
160
|
+
reconnected: d.reconnected,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (d.ok === false && typeof d.code === "string" && DOOR_CODES.has(d.code))
|
|
164
|
+
return { ok: false, code: d.code };
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
/** One sentence per refusal code the door can answer with. */
|
|
168
|
+
export function doorRefusalSentence(code, alias) {
|
|
169
|
+
switch (code) {
|
|
170
|
+
case "sf_cli_refresh_failed":
|
|
171
|
+
return `Salesforce refused that login's token. Log in again: ${sfLoginLine(alias)}`;
|
|
172
|
+
case "sf_cli_identity_failed":
|
|
173
|
+
return "Salesforce accepted the token but its identity could not be read. Try again in a minute; if it persists, connect this org from the browser.";
|
|
174
|
+
case "sf_cli_env_unresolved":
|
|
175
|
+
return "Flowsprite could not read whether that org is a sandbox and does not guess production versus sandbox. Try again; if it persists, connect it from the browser.";
|
|
176
|
+
default:
|
|
177
|
+
return "Flowsprite could not save the connection. Run `flowsprite status` and try again.";
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { printable } from "./redact.js";
|
|
1
2
|
import { asArray, asObject, planNextStep } from "./setup.js";
|
|
2
3
|
/**
|
|
3
4
|
* Render a `setup_status` payload as plain text.
|
|
@@ -37,6 +38,15 @@ function expiryText(value) {
|
|
|
37
38
|
}
|
|
38
39
|
return new Date(value).toISOString();
|
|
39
40
|
}
|
|
41
|
+
/** A connection's repository, with its creation state when it is not ready yet (FLO-511 DM5). */
|
|
42
|
+
function repoText(c) {
|
|
43
|
+
const repo = str(c.repo, "none");
|
|
44
|
+
if (c.bootstrapState === "creating")
|
|
45
|
+
return `${repo} (creating)`;
|
|
46
|
+
if (c.bootstrapState === "failed")
|
|
47
|
+
return `${repo} (failed: ${printable(str(c.bootstrapError, "no reason given"))})`;
|
|
48
|
+
return repo;
|
|
49
|
+
}
|
|
40
50
|
export function renderStatus(payload) {
|
|
41
51
|
const root = obj(payload);
|
|
42
52
|
if (root === null) {
|
|
@@ -64,7 +74,7 @@ export function renderStatus(payload) {
|
|
|
64
74
|
if (connections.length === 0)
|
|
65
75
|
lines.push(" none");
|
|
66
76
|
for (const c of connections) {
|
|
67
|
-
lines.push(` ${str(c.id)} ${str(c.displayName)} [${str(c.envType)}] repo: ${
|
|
77
|
+
lines.push(` ${str(c.id)} ${str(c.displayName)} [${str(c.envType)}] repo: ${repoText(c)} synced: ${yesNo(c.hasSyncJob)}`);
|
|
68
78
|
}
|
|
69
79
|
const installations = arr(root.installations);
|
|
70
80
|
lines.push("", `GitHub installations (${installations.length})`);
|
package/dist/usage.js
CHANGED
|
@@ -1,44 +1,84 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The usage text, as data, so `--help` and every exit-2 refusal print the same thing.
|
|
3
|
+
*
|
|
4
|
+
* Card eecc9d6f N5/N6/N13: what Flowsprite is first, then examples, then each
|
|
5
|
+
* command WITH ITS OWN OPTIONS under it. Every flag `cli.ts` parses appears here
|
|
6
|
+
* (pinned in usage.test.ts against the parser's own option table). `--sandbox`
|
|
7
|
+
* names the generic hosts the server picks (`lib/salesforceOAuthExchange.ts`
|
|
8
|
+
* GENERIC_OAUTH_HOST: sandbox -> test.salesforce.com, prod -> login.salesforce.com).
|
|
9
|
+
*/
|
|
2
10
|
export const USAGE = `flowsprite — set up and drive a Flowsprite workspace from a terminal.
|
|
3
11
|
|
|
12
|
+
Flowsprite mirrors a Salesforce org's metadata into a PRIVATE GitHub repository.
|
|
13
|
+
\`flowsprite setup\` takes three steps: log this terminal in, connect Salesforce,
|
|
14
|
+
then connect GitHub and create the private repository.
|
|
15
|
+
|
|
4
16
|
Usage: flowsprite [command] [options]
|
|
5
17
|
|
|
6
|
-
|
|
7
|
-
setup
|
|
8
|
-
|
|
18
|
+
Examples:
|
|
19
|
+
flowsprite setup Log in and set the workspace up, step by step.
|
|
20
|
+
flowsprite setup --sandbox The same, connecting a Salesforce sandbox org.
|
|
21
|
+
flowsprite mcp install --client claude-code
|
|
22
|
+
Point Claude Code at this workspace.
|
|
23
|
+
|
|
24
|
+
Commands, each with its own options:
|
|
25
|
+
setup Run the whole setup (the default command). Steps
|
|
26
|
+
that are already done are skipped.
|
|
27
|
+
--sandbox Connect a Salesforce sandbox org (sign-in at
|
|
28
|
+
test.salesforce.com). Without it the sign-in is at
|
|
29
|
+
login.salesforce.com, the production login host.
|
|
30
|
+
--my-domain <host> Sign in to Salesforce at this My Domain host instead.
|
|
31
|
+
--no-open Do not try to open a browser for the login step.
|
|
32
|
+
login [--workspace <name>] [--no-gh]
|
|
33
|
+
Log this terminal in. Uses the GitHub CLI's login
|
|
34
|
+
(gh) when it has one, else a device code in the
|
|
35
|
+
browser.
|
|
36
|
+
--workspace <name> The name for the workspace the GitHub CLI login
|
|
37
|
+
creates. Omitted, login asks at a terminal; through
|
|
38
|
+
a pipe it uses the browser instead.
|
|
39
|
+
--no-gh Skip the GitHub CLI and use the browser.
|
|
40
|
+
--no-open Do not try to open a browser.
|
|
41
|
+
--token <token> Use this token instead of logging in.
|
|
42
|
+
--save With --token: also store it for this endpoint.
|
|
9
43
|
logout Forget the stored credential for this endpoint.
|
|
10
44
|
status Show the setup checklist, connections and installations.
|
|
11
45
|
connect salesforce Get a one-time link to connect a Salesforce org.
|
|
46
|
+
Takes --sandbox and --my-domain, as setup does.
|
|
47
|
+
connect salesforce --from-sf-cli <alias>
|
|
48
|
+
Connect an org the Salesforce CLI is already logged
|
|
49
|
+
in to (no browser). Needs sf on PATH and a web
|
|
50
|
+
login (not JWT).
|
|
12
51
|
connect github Get a one-time link to install the GitHub app.
|
|
13
|
-
bind
|
|
14
|
-
--repo/--create it offers
|
|
52
|
+
bind Give a Salesforce connection a GitHub repo. With no
|
|
53
|
+
--repo/--create it offers to create a new private
|
|
54
|
+
one with a suggested name, or pick an existing one.
|
|
55
|
+
--connection <id> The Salesforce connection id (also for sync).
|
|
56
|
+
--installation <number> The GitHub installation id.
|
|
57
|
+
--repo <owner/name> Use a repository that already exists.
|
|
58
|
+
--create <name> Create a new repository with this name, without
|
|
59
|
+
asking. Always PRIVATE — Flowsprite only mirrors
|
|
60
|
+
into private repos.
|
|
15
61
|
sync Start a metadata sync and show the recent sync log.
|
|
62
|
+
Takes --connection, as bind does.
|
|
16
63
|
doctor Check node, the endpoint, the credential, and sf/gh.
|
|
64
|
+
Exits 1 when a check FAILs.
|
|
17
65
|
mcp install Point an AI client at this workspace's MCP endpoint.
|
|
66
|
+
--client <name> claude-code, codex, claude-desktop or cursor.
|
|
67
|
+
Omitted, mcp install offers the ones it detects and
|
|
68
|
+
mcp print shows every one.
|
|
69
|
+
--print Show what it would do, change nothing.
|
|
70
|
+
--force Replace an existing flowsprite entry.
|
|
71
|
+
--token-env <NAME> codex reads the bearer from this variable;
|
|
72
|
+
claude-code uses its CURRENT value.
|
|
18
73
|
mcp print Show the config snippet for a client, token hidden.
|
|
74
|
+
Takes --client, as mcp install does.
|
|
75
|
+
--reveal Print the real token instead of <YOUR_TOKEN>. Only
|
|
76
|
+
at a terminal, never through a pipe.
|
|
19
77
|
|
|
20
|
-
Options:
|
|
78
|
+
Options for every command:
|
|
21
79
|
--endpoint <origin> Flowsprite origin (default https://app.flowsprite.com).
|
|
22
80
|
--json Machine-readable output where a command has one.
|
|
23
81
|
--token <token> Use this token instead of the stored credential.
|
|
24
|
-
--save With --token: also store it for this endpoint.
|
|
25
|
-
--no-open Do not try to open a browser during login.
|
|
26
|
-
--sandbox connect salesforce: use the sandbox login host.
|
|
27
|
-
--my-domain <host> connect salesforce: use this My Domain login host.
|
|
28
|
-
--connection <id> bind / sync: the Salesforce connection id.
|
|
29
|
-
--installation <number> bind: the GitHub installation id.
|
|
30
|
-
--repo <owner/name> bind: a repository that already exists.
|
|
31
|
-
--create <name> bind: create a new repository instead. Always PRIVATE —
|
|
32
|
-
Flowsprite only mirrors into private repos.
|
|
33
|
-
--client <name> mcp: claude-code, codex, claude-desktop or cursor.
|
|
34
|
-
Omitted, mcp install offers the ones it detects and
|
|
35
|
-
mcp print shows every one.
|
|
36
|
-
--print mcp install: show what it would do, change nothing.
|
|
37
|
-
--force mcp install: replace an existing flowsprite entry.
|
|
38
|
-
--reveal mcp print: print the real token instead of
|
|
39
|
-
<YOUR_TOKEN>. Only at a terminal, never through a pipe.
|
|
40
|
-
--token-env <NAME> mcp install: codex reads the bearer from this
|
|
41
|
-
variable; claude-code uses its CURRENT value.
|
|
42
82
|
-h, --help Show this help and exit 0.
|
|
43
83
|
-v, --version Show the version and exit 0.
|
|
44
84
|
|
package/dist/version.js
CHANGED
package/dist/wiring.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createStatusLine, createWriter } from "./output.js";
|
|
2
|
+
import { printable, redact } from "./redact.js";
|
|
3
|
+
/**
|
|
4
|
+
* THE WRITER, THE STATUS LINE AND THE REVEAL SINK ARE BUILT HERE; the readline
|
|
5
|
+
* prompt (`ask`, built in bin.ts) only passes through, redacted and scrubbed, and the bin's
|
|
6
|
+
* last-line-of-defence catch is its own exit (see the exit list in output.ts).
|
|
7
|
+
* Two conditions govern what exists:
|
|
8
|
+
*
|
|
9
|
+
* - the writer (`log`/`error`, both redacting) — unconditional;
|
|
10
|
+
* - the status line and the ONE unredacted sink, `reveal` — only when
|
|
11
|
+
* `stdout.isTTY === true`. Strictly `=== true`: a real pipe carries
|
|
12
|
+
* `isTTY === undefined`, so `!== false` would reveal into a pipe. The sink
|
|
13
|
+
* writes through the SAME `stdout` object whose `isTTY` authorised it, never
|
|
14
|
+
* through `log` (a separate function a caller could point anywhere). Its only
|
|
15
|
+
* reader is `mcpPrint` in `cli.ts`.
|
|
16
|
+
*
|
|
17
|
+
* The prompt needs BOTH stdout and stdin to be terminals (`interactive`); the
|
|
18
|
+
* secret needs only stdout: stdin may be a here-doc while the person is still
|
|
19
|
+
* watching the terminal the secret lands in. The browser opener needs NEITHER —
|
|
20
|
+
* it is decided by `shouldOpenBrowser` (card eecc9d6f C1).
|
|
21
|
+
*/
|
|
22
|
+
export function wireDeps(io) {
|
|
23
|
+
const stdoutIsTTY = io.stdout.isTTY === true;
|
|
24
|
+
const interactive = stdoutIsTTY && io.stdin.isTTY === true;
|
|
25
|
+
// FLO-509: the in-place login countdown exists only when stdout is a terminal
|
|
26
|
+
// (the login lines go to stdout). On a pipe a redrawn line is just noise in a log.
|
|
27
|
+
const statusLine = stdoutIsTTY ? createStatusLine((text) => io.stdout.write(text)) : null;
|
|
28
|
+
const writer = createWriter({
|
|
29
|
+
out: (line) => {
|
|
30
|
+
statusLine?.settle();
|
|
31
|
+
io.log(line);
|
|
32
|
+
},
|
|
33
|
+
err: (line) => {
|
|
34
|
+
statusLine?.settle();
|
|
35
|
+
io.error(line);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const deps = {
|
|
39
|
+
env: io.env,
|
|
40
|
+
writer,
|
|
41
|
+
fetch: io.fetch,
|
|
42
|
+
sleep: io.sleep,
|
|
43
|
+
now: io.now,
|
|
44
|
+
nodeVersion: io.nodeVersion,
|
|
45
|
+
isOnPath: io.isOnPath,
|
|
46
|
+
version: io.version,
|
|
47
|
+
spawn: io.spawn,
|
|
48
|
+
fileExists: io.fileExists,
|
|
49
|
+
home: io.home,
|
|
50
|
+
platform: io.platform,
|
|
51
|
+
...(statusLine === null ? {} : { progress: statusLine.progress }),
|
|
52
|
+
// `mcp print --reveal` is the ONE command allowed to put a token on stdout,
|
|
53
|
+
// and this is the fact it is allowed on. The sink settles a dangling
|
|
54
|
+
// countdown first so the secret never lands glued to a status line.
|
|
55
|
+
...(stdoutIsTTY
|
|
56
|
+
? {
|
|
57
|
+
reveal: (line) => {
|
|
58
|
+
statusLine?.settle();
|
|
59
|
+
io.stdout.write(line + "\n");
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
: {}),
|
|
63
|
+
// The browser opener is NOT gated on a terminal any more (card eecc9d6f C1).
|
|
64
|
+
// History: FLO-500 gated it on stdin+stdout TTYs after a piped probe popped
|
|
65
|
+
// a tab; that also starved every agent shell (Claude Code runs commands with
|
|
66
|
+
// no TTY on a person's own desktop), which is exactly where `login` is now
|
|
67
|
+
// run most. The rule is `shouldOpenBrowser`: `--no-open` (cli.ts), a CI
|
|
68
|
+
// environment, or a Linux box with no display keeps the browser shut.
|
|
69
|
+
...(shouldOpenBrowser(io.env, io.platform) ? { openUrl: io.openUrl } : {}),
|
|
70
|
+
// The prompt stays gated on there being a terminal on BOTH ends.
|
|
71
|
+
// The question is redacted AND scrubbed HERE because readline writes it to
|
|
72
|
+
// stdout raw, around the writer — the prompt text is CLI-built (a path, a
|
|
73
|
+
// display name), but the guarantee should not depend on every future caller
|
|
74
|
+
// remembering. `printable`, not the writer's per-line scrub: a question is
|
|
75
|
+
// ONE line by construction, so a newline in a spliced display name is a
|
|
76
|
+
// forged line, not structure.
|
|
77
|
+
...(interactive ? { ask: (question) => io.ask(printable(redact(question))) } : {}),
|
|
78
|
+
};
|
|
79
|
+
return { deps, statusLine };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* C1 — may `login` open a browser on this machine? Yes, unless a CI job (`CI`
|
|
83
|
+
* set to anything non-empty — the convention GitHub Actions, GitLab and most
|
|
84
|
+
* runners follow) or Linux with neither `DISPLAY` nor `WAYLAND_DISPLAY` (an SSH
|
|
85
|
+
* session or a container: `xdg-open` there has no screen, or picks a text
|
|
86
|
+
* browser that takes over the terminal). `--no-open` is the person's override,
|
|
87
|
+
* applied in `cli.ts`. PURE, so the whole cross product is pinned in wiring.test.ts.
|
|
88
|
+
*/
|
|
89
|
+
export function shouldOpenBrowser(env, platform) {
|
|
90
|
+
const set = (name) => typeof env[name] === "string" && env[name].length > 0;
|
|
91
|
+
if (set("CI"))
|
|
92
|
+
return false;
|
|
93
|
+
if (platform === "linux" && !set("DISPLAY") && !set("WAYLAND_DISPLAY"))
|
|
94
|
+
return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowsprite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Flowsprite CLI: log a terminal in to Flowsprite and drive workspace setup over the MCP endpoint. Zero runtime dependencies, node builtins only.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"homepage": "https://app.flowsprite.com/setup",
|
|
8
8
|
"bin": {
|
|
9
|
-
"flowsprite": "
|
|
9
|
+
"flowsprite": "dist/bin.js"
|
|
10
10
|
},
|
|
11
11
|
"engines": {
|
|
12
12
|
"node": ">=20"
|