@flowapt/flowiq-cli 0.1.10 → 0.1.11

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 CHANGED
@@ -16,12 +16,24 @@ flowiq --version # should print 0.1.x
16
16
 
17
17
  ## Authenticate
18
18
 
19
- Get a personal staff API key from a FlowIQ super-admin (an `fiq_staff_…`
20
- string). Then:
19
+ Default flow — browser login (like `npm login`):
21
20
 
22
21
  ```bash
23
22
  flowiq auth login
24
- # paste your key when prompted
23
+ ```
24
+
25
+ The CLI shows a short code (e.g. `FQ4X-9KTB`), opens
26
+ `https://app.flowiq.live/cli-auth` when you press ENTER, and waits. A
27
+ logged-in FlowIQ **super admin** confirms the code on the page matches the
28
+ one in your terminal and clicks Approve — the CLI then receives a
29
+ freshly-minted personal key automatically. **Never approve a code you
30
+ didn't just see in your own terminal.**
31
+
32
+ Fallbacks (CI / no browser):
33
+
34
+ ```bash
35
+ flowiq auth login --token fiq_staff_… # supply a key directly
36
+ flowiq auth login --paste # prompt for a key on stdin
25
37
  ```
26
38
 
27
39
  Your key is saved to `~/.config/flowiq/auth.json` with `chmod 600`. To
@@ -31,8 +43,16 @@ verify what you're authenticated as:
31
43
  flowiq auth whoami
32
44
  ```
33
45
 
46
+ To rotate this device's token (mints a fresh key, revokes the old one
47
+ server-side — run whenever you want a new secret):
48
+
49
+ ```bash
50
+ flowiq auth refresh
51
+ ```
52
+
34
53
  To log out (deletes the local key file, does NOT revoke the key
35
- server-side — ask your admin to revoke):
54
+ server-side — use `flowiq auth refresh` first if you want the old key
55
+ dead, or ask another super admin to revoke):
36
56
 
37
57
  ```bash
38
58
  flowiq auth logout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,23 @@
1
- // `flowiq auth login` / `flowiq auth logout` / `flowiq auth whoami`.
2
- // Stores a `fiq_staff_…` bearer token at ~/.config/flowiq/auth.json.
1
+ // `flowiq auth login` / `logout` / `whoami` / `refresh`.
2
+ //
3
+ // Default login = browser device flow (like `npm login` / `gh auth login`):
4
+ // 1. POST /cli/login-start (unauthenticated) → device_code + user_code
5
+ // 2. Show the user code, press ENTER → open https://app.flowiq.live/cli-auth
6
+ // 3. A logged-in SUPER ADMIN confirms the codes match and approves
7
+ // 4. Poll /cli/login-poll until the key is minted server-side (mint-at-claim)
8
+ //
9
+ // Fallbacks: `--token <key>` (direct) or `--paste` (interactive paste) keep
10
+ // the original flow for CI or when no browser is handy.
11
+ //
12
+ // `flowiq auth refresh` rotates the saved key in place via /cli/token-rotate —
13
+ // run it on each device whenever you want a fresh secret; the old key is
14
+ // revoked server-side. If the saved key is already dead it falls back to the
15
+ // full browser login.
3
16
 
4
17
  import readline from "node:readline";
5
- import { loadConfig, saveConfig, clearConfig, configFilePath } from "../config.js";
18
+ import os from "node:os";
19
+ import { spawn } from "node:child_process";
20
+ import { loadConfig, saveConfig, clearConfig } from "../config.js";
6
21
  import { http } from "../http.js";
7
22
 
8
23
  function prompt(question, { silent = false } = {}) {
@@ -41,25 +56,42 @@ function prompt(question, { silent = false } = {}) {
41
56
  });
42
57
  }
43
58
 
44
- export async function login(opts = {}) {
45
- const cfg = await loadConfig();
46
- if (opts.apiUrl) cfg.api_url = opts.apiUrl;
47
-
48
- let token = opts.token;
49
- if (!token) {
50
- process.stdout.write(`API URL: ${cfg.api_url}\n`);
51
- token = await prompt("Paste your staff API key (fiq_staff_…): ", { silent: true });
52
- token = token.trim();
59
+ // Unauthenticated POST — login-start / login-poll run BEFORE a token exists,
60
+ // so they can't go through http.js (which requires the saved token).
61
+ async function unauthedPost(apiUrl, endpoint, body) {
62
+ const url = `${apiUrl.replace(/\/$/, "")}/cli/${endpoint}`;
63
+ const resp = await fetch(url, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
66
+ body: JSON.stringify(body ?? {}),
67
+ });
68
+ const text = await resp.text();
69
+ let parsed;
70
+ try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; }
71
+ if (!resp.ok) {
72
+ throw new Error(parsed.message || parsed.error || `${endpoint} → HTTP ${resp.status}`);
53
73
  }
74
+ return parsed;
75
+ }
54
76
 
55
- if (!token.startsWith("fiq_staff_")) {
56
- console.error(`Error: token must start with "fiq_staff_" (got ${token.slice(0, 12)}…).`);
57
- process.exit(1);
77
+ function openBrowser(url) {
78
+ const platform = process.platform;
79
+ let cmd, args;
80
+ if (platform === "darwin") { cmd = "open"; args = [url]; }
81
+ else if (platform === "win32") { cmd = "cmd"; args = ["/c", "start", "", url]; }
82
+ else { cmd = "xdg-open"; args = [url]; }
83
+ try {
84
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
85
+ return true;
86
+ } catch {
87
+ return false;
58
88
  }
89
+ }
59
90
 
60
- // Save first so http.js can use it, then verify with /cli/whoami.
61
- const file = await saveConfig({ api_url: cfg.api_url, token });
91
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
62
92
 
93
+ async function saveAndVerify(apiUrl, token) {
94
+ const file = await saveConfig({ api_url: apiUrl, token });
63
95
  try {
64
96
  const me = await http.get("whoami");
65
97
  console.log(`Logged in as ${me.user_email || me.user_id} (super_admin=${me.is_super_admin})`);
@@ -73,6 +105,109 @@ export async function login(opts = {}) {
73
105
  }
74
106
  }
75
107
 
108
+ async function browserLogin(cfg) {
109
+ const deviceName = `${os.hostname()} (${os.platform()})`;
110
+ let start;
111
+ try {
112
+ start = await unauthedPost(cfg.api_url, "login-start", { device_name: deviceName });
113
+ } catch (e) {
114
+ console.error(`Could not start browser login: ${e.message}`);
115
+ console.error("Fallback: flowiq auth login --paste");
116
+ process.exit(1);
117
+ }
118
+
119
+ console.log("");
120
+ console.log("First, confirm this code matches the one shown in your browser:");
121
+ console.log("");
122
+ console.log(` ${start.user_code}`);
123
+ console.log("");
124
+ console.log(`Approval page: ${start.verification_url}`);
125
+ await prompt("Press ENTER to open in the browser... ");
126
+ if (!openBrowser(start.verification_url)) {
127
+ console.log("(Could not open a browser — visit the URL above manually.)");
128
+ }
129
+
130
+ const intervalMs = Math.max(2, start.interval || 5) * 1000;
131
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
132
+ process.stdout.write("Waiting for a super admin to approve");
133
+
134
+ while (Date.now() < deadline) {
135
+ await sleep(intervalMs);
136
+ let poll;
137
+ try {
138
+ poll = await unauthedPost(cfg.api_url, "login-poll", { device_code: start.device_code });
139
+ } catch (e) {
140
+ console.error(`\nPolling failed: ${e.message}`);
141
+ process.exit(1);
142
+ }
143
+
144
+ if (poll.status === "pending") { process.stdout.write("."); continue; }
145
+ if (poll.status === "slow_down") { await sleep(intervalMs); continue; }
146
+ process.stdout.write("\n");
147
+
148
+ if (poll.status === "approved") {
149
+ console.log(`Approved${poll.user_email ? ` by ${poll.user_email}` : ""}.`);
150
+ await saveAndVerify(cfg.api_url, poll.token);
151
+ return;
152
+ }
153
+ if (poll.status === "denied") { console.error("Login was DENIED in the browser."); process.exit(1); }
154
+ if (poll.status === "expired") { console.error("Login request expired. Re-run `flowiq auth login`."); process.exit(1); }
155
+ if (poll.status === "claimed") { console.error("This login was already claimed. Re-run `flowiq auth login`."); process.exit(1); }
156
+ console.error(`Unexpected status: ${poll.status}`); process.exit(1);
157
+ }
158
+
159
+ console.error("\nTimed out waiting for approval. Re-run `flowiq auth login`.");
160
+ process.exit(1);
161
+ }
162
+
163
+ export async function login(opts = {}) {
164
+ const cfg = await loadConfig();
165
+ if (opts.apiUrl) cfg.api_url = opts.apiUrl;
166
+
167
+ // Fallback paths: --token (direct) or --paste (interactive paste).
168
+ let token = opts.token;
169
+ if (!token && opts.paste) {
170
+ process.stdout.write(`API URL: ${cfg.api_url}\n`);
171
+ token = (await prompt("Paste your staff API key (fiq_staff_…): ", { silent: true })).trim();
172
+ }
173
+
174
+ if (token) {
175
+ if (!token.startsWith("fiq_staff_")) {
176
+ console.error(`Error: token must start with "fiq_staff_" (got ${token.slice(0, 12)}…).`);
177
+ process.exit(1);
178
+ }
179
+ await saveAndVerify(cfg.api_url, token);
180
+ return;
181
+ }
182
+
183
+ // Default: browser device flow.
184
+ await browserLogin(cfg);
185
+ }
186
+
187
+ export async function refresh() {
188
+ const cfg = await loadConfig();
189
+ if (!cfg.token) {
190
+ console.log("No saved token — starting browser login instead.");
191
+ await browserLogin(cfg);
192
+ return;
193
+ }
194
+
195
+ try {
196
+ const rotated = await http.post("token-rotate", {});
197
+ await saveConfig({ api_url: cfg.api_url, token: rotated.token });
198
+ console.log(`Token rotated (${rotated.rotated_from} → ${rotated.key_prefix}).`);
199
+ console.log(rotated.old_key_revoked ? "Old key revoked." : "WARNING: old key could not be revoked — revoke it manually.");
200
+ } catch (e) {
201
+ if (e.status === 401) {
202
+ console.log("Saved token is no longer valid — starting browser login…");
203
+ await browserLogin(cfg);
204
+ return;
205
+ }
206
+ console.error(`Refresh failed: ${e.message}`);
207
+ process.exit(1);
208
+ }
209
+ }
210
+
76
211
  export async function logout() {
77
212
  const removed = await clearConfig();
78
213
  console.log(removed ? "Logged out." : "Already logged out.");
package/src/index.js CHANGED
@@ -40,10 +40,14 @@ export function run(argv) {
40
40
  // auth
41
41
  const auth = program.command("auth").description("Manage CLI authentication");
42
42
  auth.command("login")
43
- .description("Save a staff API key (fiq_staff_…)")
44
- .option("--token <token>", "supply the token via flag instead of stdin")
43
+ .description("Log in via the browser (super-admin approval); --token/--paste for the old key-paste flow")
44
+ .option("--token <token>", "supply a staff API key directly instead of the browser flow")
45
+ .option("--paste", "prompt for a staff API key on stdin instead of the browser flow")
45
46
  .option("--api-url <url>", "override the API host (default https://api.flowiq.live)")
46
47
  .action((opts) => authCmd.login(opts));
48
+ auth.command("refresh")
49
+ .description("Rotate this device's token in place (old key is revoked server-side)")
50
+ .action(() => authCmd.refresh());
47
51
  auth.command("logout").description("Remove the saved token").action(() => authCmd.logout());
48
52
  auth.command("whoami").description("Show the user behind the saved token").action(() => authCmd.whoami());
49
53