flowsprite 0.1.0

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 ADDED
@@ -0,0 +1,93 @@
1
+ # flowsprite — the Flowsprite CLI
2
+
3
+ Log a terminal in to Flowsprite and drive workspace setup from the command line.
4
+ Zero runtime dependencies: node builtins only.
5
+
6
+ ## Install
7
+
8
+ Node 20 or newer. Nothing to install — `npx` fetches it:
9
+
10
+ npx flowsprite --help
11
+ npx flowsprite # runs `setup`, the default command
12
+
13
+ Or install it once: `npm i -g flowsprite`, then `flowsprite …`. The package is
14
+ `flowsprite` on npm (unscoped, UNLICENSED — proprietary; the source lives in the
15
+ Flowsprite repo). To run an unreleased change from a checkout of this repo:
16
+
17
+ pnpm exec tsc -p cli/tsconfig.json
18
+ node cli/dist/bin.js --help
19
+
20
+ ## Commands
21
+
22
+ | command | what it does |
23
+ | --- | --- |
24
+ | `setup` (default) | Runs the whole ladder: log in if needed, then connect Salesforce, connect GitHub, bind a repo and start the first sync, in the order the server asks for them. It needs **no flags** — where the workspace leaves a choice (which connection, which installation, which repository) it asks at a terminal and, through a pipe, lists the options and stops rather than guessing. |
25
+ | `login` | Device-code login. Prints a code and a URL; approve it in a browser. `--token <t>` skips the loop and uses that token instead (add `--save` to store it). |
26
+ | `logout` | Removes this endpoint's entry from the credentials file. Other endpoints keep theirs. |
27
+ | `status` | The setup checklist, the Salesforce connections, the GitHub installations and the next step. `--json` prints the tool's payload unmodified. |
28
+ | `connect salesforce` | Mints a one-time browser link (`--sandbox`, `--my-domain <host>`), prints it, and watches until the connection lands. |
29
+ | `connect github` | The same for installing the GitHub app. |
30
+ | `bind` | Binds a repo to a Salesforce connection. With no flags it offers the connections, installations and repositories `setup_status` already lists; `--connection <id>`, `--installation <n>`, `--repo owner/name` and `--create <name>` pin any of those for a non-interactive run. `--create` always makes a **private** repo — Flowsprite refuses to mirror into a public one. |
31
+ | `sync` | `sfdc_trigger_sync` then `sfdc_sync_logs` for `--connection <id>`. |
32
+ | `doctor` | node version, endpoint reachability, whether a credential exists for this endpoint (never its value), and whether `sf` and `gh` are on PATH. |
33
+ | `mcp install` | Points an AI client at this workspace's MCP endpoint. `--client claude-code\|codex\|claude-desktop\|cursor`; with no `--client` it offers the clients it detects and names the rest as print-only. Runs the client's own registration command where there is one (`claude mcp add`, `codex mcp add`), and otherwise writes the client's config file — backed up once, one entry changed, `0600`. `--print` shows what it would do and changes nothing; `--force` replaces an existing `flowsprite` entry (at a terminal it asks instead); `--token-env NAME` tells codex which variable holds the bearer. |
34
+ | `mcp print` | The config snippet for a client, with `<YOUR_TOKEN>` where the bearer goes — no credential needed. `--client <name>` for one, nothing for all four, `--json` for `{client, kind, path, command, snippet}`. `--reveal` substitutes the real token and runs **only at a terminal**. |
35
+
36
+ `--help` and `--version` exit 0. An unknown command or an unknown flag — single- or
37
+ double-dash — prints the usage and exits **2**. A command that ran and was refused
38
+ exits **1**.
39
+
40
+ ## Environment
41
+
42
+ | variable | meaning |
43
+ | --- | --- |
44
+ | `FLOWSPRITE_ENDPOINT` | The Flowsprite origin. Same as `--endpoint`. Default `https://app.flowsprite.com`. Must be a bare origin: scheme + host, no userinfo, path, query or fragment. Plain `http://` is accepted **only** for `localhost`, `127.0.0.1` and `[::1]` — every request carries an access token, so anything else must be `https://`. |
45
+ | `FLOWSPRITE_TOKEN` | A token to use instead of the stored credential. Same as `--token`. |
46
+ | `FLOWSPRITE_CONFIG_DIR` | Where `credentials.json` lives. Otherwise `$XDG_CONFIG_HOME/flowsprite`, otherwise `~/.config/flowsprite`. |
47
+
48
+ Precedence is flag > environment > stored credential, for both the endpoint and the token.
49
+
50
+ ## Where the credential lives
51
+
52
+ One `fp_user_` token per endpoint, in `credentials.json` inside the config
53
+ directory above: the file is written `0600` (temp file + rename, never a partial
54
+ read) inside a `0700` directory. A missing or malformed file reads as "no
55
+ credentials" rather than a crash.
56
+
57
+ `setup` stops rather than repeating itself: Flowsprite's setup steps are
58
+ list-wide ("some connection has no repo"), so if a step reports success and the
59
+ checklist does not move, the CLI says which step and stops instead of running it
60
+ again.
61
+
62
+ **One command prints a token, and only at a terminal: `mcp print --reveal`.**
63
+ Everything else — every success line, every refusal, a failing client command's
64
+ own stderr — carries `<YOUR_TOKEN>` or nothing, and no line ever carries a command
65
+ line. Every line leaves this process through one writer that rewrites any
66
+ `fp_user_`/`fp_live_` value to its prefix plus the last four characters, so a
67
+ token cannot reach a scrollback even from an error string that quoted one.
68
+
69
+ ## What `mcp install` touches
70
+
71
+ | client | route | file |
72
+ | --- | --- | --- |
73
+ | `claude-code` | `claude mcp add --transport http … --scope user` when `claude` is on PATH, else the file | `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json` |
74
+ | `codex` | `codex mcp add --url … --bearer-token-env-var NAME` when `codex` is on PATH, else a printed TOML snippet (this CLI does not edit TOML) | `$CODEX_HOME/config.toml`, else `~/.codex/config.toml` |
75
+ | `claude-desktop` | the file, always — it takes a stdio command, so the entry runs `npx -y mcp-remote@<pinned>` | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%/Claude/…`, `~/.config/Claude/…` |
76
+ | `cursor` | the file, but only when it already exists; otherwise a printed snippet | `~/.cursor/mcp.json` |
77
+
78
+ A file write **refuses rather than replaces** anything it cannot preserve: a
79
+ symlinked config, a file that is not valid JSON, or a root that is not a JSON
80
+ object. It backs the file up once — `<config>.flowsprite-backup-<epoch>`, kept
81
+ forever, never overwritten — and changes only `mcpServers.flowsprite`.
82
+
83
+ **Where the token goes.** Into the config file (`0600`), or into the client's own
84
+ command. `codex` is told an environment-variable **name**, never the value. The
85
+ `claude` command is the one exception: the bearer is one of its arguments, so it
86
+ is briefly visible to a local `ps` on your own machine — that is the cost of
87
+ letting the client own its file format instead of editing 348 KB of someone
88
+ else's live state.
89
+
90
+ ## Testing it by hand
91
+
92
+ `docs/E2E-TESTING-CLI-FLOW.md` is the one-sitting pass of the whole terminal door — every
93
+ command above, in journey order, with the exact lines to expect and the results table.
package/dist/bin.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { main } from "./cli.js";
7
+ import { isOnPath } from "./doctor.js";
8
+ import { createWriter } from "./output.js";
9
+ import { redact } from "./redact.js";
10
+ import { VERSION } from "./version.js";
11
+ /**
12
+ * THE ONLY FILE THAT NAMES A REAL EFFECT. Everything under it takes its fetch,
13
+ * clock, sleep, environment, writer, PATH probe and prompt as arguments, so the
14
+ * whole CLI is drivable in a test without a network, a home directory or a TTY.
15
+ */
16
+ /**
17
+ * Best effort, and deliberately fire-and-forget.
18
+ *
19
+ * ARGV ARRAY, `shell: false` (the default), and a FIXED command name — the URL
20
+ * is an argument, never part of a command string, so a crafted URL has no shell
21
+ * to reach. `detached` + `unref` so an opener that outlives the login does not
22
+ * hold this process open, and `ignore` for stdio so a GUI launcher's chatter
23
+ * never lands in the middle of the login output.
24
+ */
25
+ function openUrl(url) {
26
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
27
+ try {
28
+ const child = spawn(command, [url], { shell: false, stdio: "ignore", detached: true });
29
+ // An opener that is not installed raises `error` asynchronously; without a
30
+ // listener node turns that into an unhandled exception and kills the login.
31
+ child.on("error", () => { });
32
+ child.unref();
33
+ }
34
+ catch {
35
+ // Same reason: a browser that will not open is a convenience lost, never a
36
+ // login lost. The URL is already on screen.
37
+ }
38
+ }
39
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
40
+ /**
41
+ * Run a client's own registration command.
42
+ *
43
+ * ARGV ARRAY AND `shell: false` — the same rule `openUrl` follows above, and it
44
+ * matters more here: one of these arguments is `Authorization: Bearer <token>`.
45
+ * With a shell there would be quoting to get wrong; without one there is nothing
46
+ * to quote. `stdio` is piped rather than inherited so the child's stderr comes
47
+ * back as a value this CLI can redact before printing, instead of going straight
48
+ * to the terminal around the writer.
49
+ */
50
+ function runCommand(command, args) {
51
+ const result = spawnSync(command, args, {
52
+ shell: false,
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+ return {
57
+ status: result.status,
58
+ signal: result.signal,
59
+ stdout: result.stdout ?? "",
60
+ stderr: result.stderr ?? "",
61
+ error: result.error,
62
+ };
63
+ }
64
+ const writer = createWriter({
65
+ out: (line) => console.log(line),
66
+ err: (line) => console.error(line),
67
+ });
68
+ /**
69
+ * THE LAST LINE OF DEFENCE: no unexpected throw reaches a person as a stack
70
+ * trace.
71
+ *
72
+ * A stack trace is a bad answer twice over. It is unreadable advice — the
73
+ * measured case was a credential write to an unwritable directory, which
74
+ * surfaced as `Error: ENOENT: no such file or directory, mkdir '/proc/…'` with no
75
+ * exit code set at all — and it is an uncontrolled OUTPUT PATH: node prints it
76
+ * itself, around the writer, so every redaction this CLI does is bypassed by the
77
+ * one message nobody wrote on purpose. So the message is redacted explicitly
78
+ * here, the frames are dropped, and the exit code is 1.
79
+ *
80
+ * Commands do NOT rely on this: every expected failure is a typed result with its
81
+ * own sentence and exit code. This catches what nobody predicted.
82
+ */
83
+ let rc;
84
+ try {
85
+ rc = await main(process.argv.slice(2), {
86
+ env: process.env,
87
+ writer,
88
+ fetch: globalThis.fetch,
89
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
90
+ now: () => Date.now(),
91
+ nodeVersion: process.version,
92
+ isOnPath,
93
+ version: VERSION,
94
+ spawn: runCommand,
95
+ fileExists: existsSync,
96
+ // `mcp print --reveal` is the ONE command allowed to put a token on stdout,
97
+ // and this is the fact it is allowed on. stdout only: stdin may be a here-doc
98
+ // while the person is still watching the terminal the secret would land in.
99
+ isTTY: process.stdout.isTTY === true,
100
+ home: homedir(),
101
+ platform: process.platform,
102
+ // The browser opener and the prompt are BOTH gated on there being a terminal.
103
+ // Measured 2026-09-15: a non-interactive `login` (stdout piped, as in a CI
104
+ // job or a probe script) still popped a browser tab on this machine, because
105
+ // the opener was wired unconditionally. A process nobody is watching must
106
+ // not take over the screen of whoever happens to be at the machine.
107
+ ...(interactive
108
+ ? {
109
+ openUrl,
110
+ ask: async (question) => {
111
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
112
+ try {
113
+ return await rl.question(question);
114
+ }
115
+ finally {
116
+ rl.close();
117
+ }
118
+ },
119
+ }
120
+ : {}),
121
+ });
122
+ }
123
+ catch (err) {
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ console.error(`flowsprite: ${redact(message.split("\n")[0] ?? "unexpected error")}`);
126
+ rc = 1;
127
+ }
128
+ process.exitCode = rc;