@bli-cockpit/cli 0.2.213 → 0.2.217

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.
@@ -347,6 +347,13 @@ export function parseTeamArgs(args) {
347
347
  */
348
348
  function parseTeamDeviceArgs(rest, flags, common) {
349
349
  const [deviceVerb, deviceRef, ...extra] = rest;
350
+ if (deviceVerb === "scope") {
351
+ const [deviceScope, ...surplus] = extra;
352
+ if (!deviceRef || (deviceScope !== "notetaker_bot" && deviceScope !== "none") || surplus.length > 0) {
353
+ throw new Error("team device scope takes <id|name> notetaker_bot|none.");
354
+ }
355
+ return { kind: "team", action: "device-scope", deviceRef, deviceScope, ...common };
356
+ }
350
357
  if (extra.length > 0)
351
358
  throw new Error("team device does not accept that many arguments.");
352
359
  if (!deviceVerb || deviceVerb === "list") {
@@ -378,7 +385,7 @@ function parseTeamDeviceArgs(rest, flags, common) {
378
385
  ...common,
379
386
  };
380
387
  }
381
- throw new Error("team device takes one verb: list, doctor <id|name>, or revoke <id|name>.");
388
+ throw new Error("team device takes one verb: list, doctor <id|name>, scope <id|name> notetaker_bot|none, or revoke <id|name>.");
382
389
  }
383
390
  /** `cockpit model [show|set <key>]` — the alias surface for the personal chat model. */
384
391
  export function parseModelArgs(args) {
@@ -391,7 +391,7 @@ export const TOWER_COMMAND_HELP = [
391
391
  "recording <recording-id> [--json] — one recording, text and all, shown or alternate.",
392
392
  "accounts --show-webhook <account-id> — that account's webhook URL, where to paste it at the vendor, and whether a signing secret is stored (BLI-4394). Until one is, every request to that URL is refused, which is the right way to be off.",
393
393
  "webhook <account-id> --secret-stdin — store the VENDOR's webhook signing secret. THE SECRET IS READ FROM STDIN, never a flag: printf \"%s\" \"<signing secret>\" | cockpit notes webhook <account-id> --secret-stdin",
394
- "connect --provider wisprflow [--name <n>] — Wispr Flow has no API key: this prints an address and a code, you approve in your browser (Google, Apple, Microsoft or company single sign-on), and Tower reads your Notetaker meetings from then on. Needs Notetaker and MCP access on your Wispr account (BLI-12926).",
394
+ "connect --provider wisprflow — Wispr Flow has no API key: this asks Tower to start the sign-in (the same door the Settings button, the MCP tool notes_connect_wisprflow and JARVIS use), prints and opens Wispr's approval page (Google, Apple, Microsoft or company single sign-on), and waits up to 10 minutes for Tower to report it connected, then Tower reads your Notetaker meetings from then on. Needs Notetaker and MCP access on your Wispr account. Exits 1 with a reason if nothing connects (BLI-12926, BLI-17973).",
395
395
  "connect --provider fellow|circleback [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
396
396
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
397
397
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
@@ -204,6 +204,9 @@ export function localSubcommandHelp(command) {
204
204
  " Every paired collector device: name, id, trust state, owner, and why",
205
205
  " (revoked reason, or when it was approved, or that it's still waiting).",
206
206
  " Admin only.",
207
+ " cockpit team device scope <id|name> notetaker_bot|none [--json]",
208
+ " Super admin only. Gives the meeting bot's device the scope Tower's claim door requires,",
209
+ " or takes it away. Without it the bot is refused every meeting (bot_scope_required).",
207
210
  " cockpit team device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]",
208
211
  " Admin only. Names the device, asks first unless --yes, then revokes it —",
209
212
  " this cannot be undone. --reason must be one of: stale_test_pairing,",
@@ -19,12 +19,14 @@
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
20
  import { ask, emit, fail, READ_DEADLINE_MS, TAG } from "./notes-door.js";
21
21
  import { printCoverage, readCoverage } from "./notes-recordings.js";
22
- import { signInToWisprflow } from "./notes-wisprflow-login.js";
22
+ import { connectWisprflowInBrowser } from "./notes-wisprflow-login.js";
23
+ // BLI-17974: the sync loop lives in `notes-sync-loop.ts`; re-exported so `notes.ts` is unchanged.
24
+ export { syncNotetaker } from "./notes-sync-loop.js";
23
25
  /** A pass can take a while; a read never should. */
24
26
  const WRITE_DEADLINE_MS = 120_000;
25
27
  /** An API key is long; anything past this is not one, and is refused by size. */
26
28
  const KEY_MAX_CHARS = 500;
27
- /** BLI-12926: a Wispr Flow sign-in bundle holds an OAuth refresh token, which can run longer. */
29
+ /** BLI-12926: a pre-built Wispr Flow sign-in bundle (`--key-stdin`) holds an OAuth refresh token, which can run longer. */
28
30
  const SIGN_IN_MAX_CHARS = 4000;
29
31
  /**
30
32
  * Granola, read through the one door that answers for it
@@ -102,7 +104,7 @@ export async function listNotesAccounts(command, door) {
102
104
  writeLine(door.io.stdout, "");
103
105
  writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
104
106
  writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
105
- writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (signs in through your browser; no key)");
107
+ writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
106
108
  if (coverage)
107
109
  printCoverage(door, coverage);
108
110
  return 0;
@@ -126,37 +128,51 @@ export async function listNotesAccounts(command, door) {
126
128
  void command;
127
129
  return 0;
128
130
  }
129
- export async function connectNotetaker(command, door) {
130
- let apiKey;
131
+ /**
132
+ * BLI-17973. Wispr Flow has no API key: this calls the same start door the
133
+ * Settings button, the MCP tool and JARVIS call, opens the address it returns
134
+ * and polls the same status door. Nothing is read from stdin and no credential
135
+ * passes through here; the server holds the sign-in's state and seals the result.
136
+ */
137
+ async function connectWisprflowViaBrowser(command, door, options) {
138
+ if (command.name) {
139
+ writeLine(door.io.stderr, `${TAG} --name ignored for wisprflow ${JSON.stringify({ reason: "web_sign_in_names_the_account" })}`);
140
+ }
141
+ const connected = await connectWisprflowInBrowser(door, options);
142
+ if (!connected.ok)
143
+ return fail(door, connected.reason, connected.detail);
144
+ if (door.json)
145
+ return emit(door, { ok: true, account: connected.account, already_connected: connected.already });
146
+ writeLine(door.io.stdout, connected.already
147
+ ? `Wispr Flow is already connected (${connected.account.id}).`
148
+ : `Connected wisprflow (${connected.account.id}).`);
149
+ writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
150
+ return 0;
151
+ }
152
+ export async function connectNotetaker(command, door, wisprflow = {}) {
131
153
  if (command.provider === "wisprflow" && !command.keyStdin) {
132
- // BLI-12926. Wispr Flow has no API key: the person signs in through their
133
- // browser and what gets sealed is the sign-in. Nothing is read from stdin.
134
- const signedIn = await signInToWisprflow({ io: door.io });
135
- if (!signedIn.ok)
136
- return fail(door, signedIn.reason, signedIn.detail);
137
- apiKey = signedIn.bundle;
154
+ return connectWisprflowViaBrowser(command, door, wisprflow);
138
155
  }
139
- else {
140
- // The key comes from stdin and nowhere else. An interactive terminal with
141
- // nothing piped in is told how, rather than left waiting on a prompt that
142
- // would echo the secret into the scrollback.
143
- if (isInteractiveStdin(door.io)) {
144
- return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
145
- }
146
- try {
147
- apiKey = (await readPipedText(door.io.stdin, {
148
- // A Wispr Flow sign-in bundle carries an OAuth refresh token, which
149
- // can be far longer than any API key.
150
- maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
151
- overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
152
- })).trim();
153
- }
154
- catch (error) {
155
- return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
156
- }
157
- if (apiKey === "") {
158
- return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
159
- }
156
+ // The key comes from stdin and nowhere else. An interactive terminal with
157
+ // nothing piped in is told how, rather than left waiting on a prompt that
158
+ // would echo the secret into the scrollback.
159
+ if (isInteractiveStdin(door.io)) {
160
+ return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
161
+ }
162
+ let apiKey;
163
+ try {
164
+ apiKey = (await readPipedText(door.io.stdin, {
165
+ // A pre-built Wispr Flow sign-in bundle carries an OAuth refresh token,
166
+ // which can be far longer than any API key.
167
+ maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
168
+ overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
169
+ })).trim();
170
+ }
171
+ catch (error) {
172
+ return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
173
+ }
174
+ if (apiKey === "") {
175
+ return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
160
176
  }
161
177
  const answer = await ask(door, {
162
178
  path: "/api/notes/accounts",
@@ -281,34 +297,6 @@ export async function detachNotetaker(command, door) {
281
297
  writeLine(door.io.stdout, "The meetings it already filed stay where they are.");
282
298
  return 0;
283
299
  }
284
- export async function syncNotetaker(command, door) {
285
- const accountId = command.accountId ?? "";
286
- if (accountId === "") {
287
- // One account per call on purpose: the sweep over ALL of them is the cron,
288
- // and a terminal asking for "everything" would hide which one failed.
289
- return fail(door, "account_required", "Name the notetaker to sync: `cockpit notes sync <account-id>`. `cockpit notes accounts` lists the ids.");
290
- }
291
- const answer = await ask(door, {
292
- path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
293
- method: "POST",
294
- label: "notes sync",
295
- timeoutMs: WRITE_DEADLINE_MS,
296
- });
297
- if (!answer.ok)
298
- return fail(door, answer.reason, answer.detail);
299
- const run = answer.body.run;
300
- if (door.json)
301
- return emit(door, { ok: true, run });
302
- if (!run) {
303
- writeLine(door.io.stdout, "Tower answered without a run.");
304
- return 0;
305
- }
306
- // The ledger row's own words, relayed rather than rephrased.
307
- writeLine(door.io.stdout, `${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped.`);
308
- if (run.detail)
309
- writeLine(door.io.stdout, run.detail);
310
- return run.ok ? 0 : 1;
311
- }
312
300
  /**
313
301
  * `sync-status [<account id>]` → `GET /api/notes/sync-status`. Ticket 10730:
314
302
  * the read the integrations audit went to `notes_sync_runs` directly for.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `cockpit notes sync <account-id>` — read one notetaker now, until it is caught
3
+ * up (BLI-17974; split out of `notes-accounts.ts`).
4
+ *
5
+ * ONE door call is a bounded pass: it answers well inside Vercel's 60 s limit
6
+ * with `more: true` and a cursor it saved after every meeting. The terminal asks
7
+ * again until Tower says the account is caught up, printing each pass, and exits
8
+ * 1 with the reason when a pass fails. The loop calls the same door the web
9
+ * Sync button, the MCP `notes_sync` tool and JARVIS call; nothing here reads a
10
+ * notetaker itself.
11
+ *
12
+ * stdout is the answer, stderr is the commentary (in `--json` the progress lines
13
+ * move to stderr so stdout stays one object).
14
+ */
15
+ import { writeLine } from "./cli-io.js";
16
+ import { ask, emit, fail, TAG } from "./notes-door.js";
17
+ /** One door call is a bounded pass (under a minute); the terminal allows twice that. */
18
+ const WRITE_DEADLINE_MS = 120_000;
19
+ /** BLI-17974: bounds on the sync loop, so a stuck account ends in a reason, not a hang. */
20
+ const MAX_SYNC_PASSES = 200;
21
+ const MAX_STALLED_PASSES = 3;
22
+ const MAX_TRANSIENT_FAILURES = 3;
23
+ const MAX_LIVE_WAITS = 40;
24
+ /** How long to wait for a pass another caller has live before asking again. */
25
+ const LIVE_PASS_WAIT_MS = 5_000;
26
+ export async function syncNotetaker(command, door) {
27
+ const accountId = command.accountId ?? "";
28
+ if (accountId === "") {
29
+ // One account per call on purpose: the sweep over ALL of them is the cron,
30
+ // and a terminal asking for "everything" would hide which one failed.
31
+ return fail(door, "account_required", "Name the notetaker to sync: `cockpit notes sync <account-id>`. `cockpit notes accounts` lists the ids.");
32
+ }
33
+ // BLI-17974: ONE door call is a bounded pass (it answers well inside Vercel's
34
+ // 60 s limit with `more: true` and a saved cursor). The terminal asks again
35
+ // until Tower says the account is caught up, printing each pass, and exits 1
36
+ // with the reason when a pass fails. The loop is the same door the web button
37
+ // and the MCP tool call; nothing here reads a notetaker itself.
38
+ const env = door.io.env ?? {};
39
+ const pollMs = Number.parseInt(env["COCKPIT_NOTES_SYNC_POLL_MS"] ?? "", 10);
40
+ const liveWaitMs = Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS;
41
+ const totals = { stored: 0, unchanged: 0, skipped: 0 };
42
+ let passes = 0;
43
+ let stalled = 0;
44
+ let liveWaits = 0;
45
+ let transientFailures = 0;
46
+ let last;
47
+ while (passes < MAX_SYNC_PASSES) {
48
+ const answer = await ask(door, {
49
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
50
+ method: "POST",
51
+ label: "notes sync",
52
+ timeoutMs: WRITE_DEADLINE_MS,
53
+ });
54
+ if (!answer.ok) {
55
+ // A timeout or a 5xx mid-history is the call's, not the account's: the
56
+ // cursor was saved after every meeting, so asking again resumes. Three in
57
+ // a row is a real outage and is reported with its reason.
58
+ const transient = /^(timeout|network|unreachable|http_5\d\d|BLI-E5\d\d|read_failed)/i.test(answer.reason);
59
+ if (transient && transientFailures < MAX_TRANSIENT_FAILURES) {
60
+ transientFailures += 1;
61
+ writeLine(door.io.stderr, `${TAG} sync call failed, retrying ${JSON.stringify({ reason: answer.reason, attempt: transientFailures })}`);
62
+ await sleepMs(liveWaitMs);
63
+ continue;
64
+ }
65
+ return failRun(door, answer.reason, answer.detail, last, passes, totals);
66
+ }
67
+ transientFailures = 0;
68
+ const run = answer.body.run;
69
+ if (!run) {
70
+ writeLine(door.io.stdout, "Tower answered without a run.");
71
+ return failRun(door, "no_run_in_answer", "Tower answered the sync without a run.", last, passes, totals);
72
+ }
73
+ last = run;
74
+ if (run.reason === "running") {
75
+ // Another pass (the cron, a second terminal) is live on this account. It
76
+ // writes the same cursor; wait for it rather than racing it.
77
+ liveWaits += 1;
78
+ writeLine(door.io.stderr, `${TAG} another pass is running ${JSON.stringify({ run_id: run.runId ?? null, waits: liveWaits })}`);
79
+ if (liveWaits > MAX_LIVE_WAITS) {
80
+ return failRun(door, "pass_still_running", "Another pass kept running on this notetaker. Check `cockpit notes sync-status`, then try again.", last, passes, totals);
81
+ }
82
+ await sleepMs(liveWaitMs);
83
+ continue;
84
+ }
85
+ passes += 1;
86
+ totals.stored += run.notesAdded;
87
+ totals.unchanged += run.notesUpdated;
88
+ totals.skipped += run.notesSkipped;
89
+ const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
90
+ const line = `pass ${passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
91
+ // Progress is commentary in --json (stdout is the one answer), and the
92
+ // answer in a terminal.
93
+ writeLine(door.json ? door.io.stderr : door.io.stdout, line);
94
+ if (run.detail && !run.ok)
95
+ writeLine(door.json ? door.io.stderr : door.io.stdout, run.detail);
96
+ if (!run.ok) {
97
+ return failRun(door, run.reason, run.detail || `The pass ended ${run.reason}.`, run, passes, totals);
98
+ }
99
+ if (!run.more) {
100
+ if (door.json)
101
+ return emit(door, { ok: true, passes, totals, run });
102
+ writeLine(door.io.stdout, `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
103
+ return 0;
104
+ }
105
+ // A pass that handled nothing and still says "more" would loop forever.
106
+ if (run.notesAdded + run.notesUpdated + run.notesSkipped === 0)
107
+ stalled += 1;
108
+ else
109
+ stalled = 0;
110
+ if (stalled >= MAX_STALLED_PASSES) {
111
+ return failRun(door, "no_progress", `Three passes in a row handled no meeting (last: ${run.reason}). The cursor is saved; try again later or check \`cockpit notes sync-status\`.`, run, passes, totals);
112
+ }
113
+ }
114
+ return failRun(door, "too_many_passes", `Stopped after ${MAX_SYNC_PASSES} passes with meetings still waiting. Run \`cockpit notes sync ${accountId}\` again to continue from the saved cursor.`, last, passes, totals);
115
+ }
116
+ /** A sync that did not finish: the reason on stderr, the structured answer on stdout in --json. */
117
+ function failRun(door, reason, detail, run, passes, totals) {
118
+ writeLine(door.io.stderr, `${TAG} sync stopped ${JSON.stringify({ reason, passes })}`);
119
+ if (door.json) {
120
+ writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail, passes, totals, run }));
121
+ }
122
+ else {
123
+ writeLine(door.io.stderr, detail);
124
+ }
125
+ return 1;
126
+ }
127
+ function sleepMs(ms) {
128
+ return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
129
+ }
@@ -1,219 +1,188 @@
1
1
  /**
2
- * `cockpit notes connect --provider wisprflow` — signing a person into Wispr
3
- * Flow's MCP server from a terminal (BLI-12926).
2
+ * `cockpit notes connect --provider wisprflow` — connecting Wispr Flow from a
3
+ * terminal (BLI-12926, rebuilt by BLI-17973 as one backend door).
4
4
  *
5
- * Wispr Flow has no API key for meetings. Its Notetaker is read through a
6
- * remote MCP server behind OAuth (docs.wisprflow.ai, "Connect Claude, ChatGPT
7
- * or another MCP client to Wispr Flow": "no API key or token is needed"). Its
8
- * authorization server publishes, at
9
- * `https://mcp-auth.wisprflow.com/.well-known/oauth-authorization-server`
10
- * (read 2026-10-08), dynamic client registration, PKCE `S256`, a refresh-token
11
- * grant and the RFC 8628 DEVICE grant. The device grant is the one a terminal
12
- * can use: it prints an address and a short code, the person approves in their
13
- * own browser (Google, Apple, Microsoft or company single sign-on; an
14
- * email-and-password Wispr account cannot complete MCP authorization), and this
15
- * file polls until Wispr hands back a refresh token.
5
+ * Wispr Flow has no API key for meetings: its Notetaker is read through a
6
+ * remote MCP server behind OAuth, and the sign-in is a person approving on
7
+ * Wispr's page. The first terminal version registered a device-grant client
8
+ * straight from the collector; Wispr's registration endpoint now refuses it
9
+ * (HTTP 400 `invalid_client_metadata`) and the command still exited 0. The
10
+ * second version only opened Tower's Settings page, which made the CLI a
11
+ * wrapper around the web UI.
16
12
  *
17
- * What leaves this machine: a client registration, then the device and token
18
- * calls, all to Wispr's own authorization server. The refresh token goes into a
19
- * bundle (`{ v, client_id, refresh_token }`) that `connect` posts to Tower's
20
- * accounts door, which seals it. It is never printed, never written to a file
21
- * and never put in a flag; `--json` shows the account, not the bundle.
13
+ * Now the terminal is a plain client of the same backend door the Settings
14
+ * button, the MCP tools and JARVIS call, with its own caller identity (this
15
+ * machine's device token):
22
16
  *
23
- * **No live call has been made from this code.** Every shape is the RFC's, and
24
- * the first real sign-in is the proof. In particular it is NOT known whether
25
- * Wispr's server accepts a dynamically registered client on the device grant
26
- * (the document lists both, not that they combine); if it does not, the refusal
27
- * below says what Wispr answered and `--key-stdin` accepts a bundle made by any
28
- * other means.
17
+ * 1. `POST /api/notes/accounts/wisprflow/start` answers `{ authorize_url,
18
+ * attempt_id, expires_at }`. The OAuth state and PKCE verifier stay on
19
+ * Tower's server; the terminal never sees a secret. Wispr Flow already
20
+ * connected is refused by the door (`account_already_connected`).
21
+ * 2. The address is printed first (a machine with no browser loses nothing),
22
+ * then opened with `browser-open.ts`, the one opener the collector has.
23
+ * 3. `GET /api/notes/accounts/wisprflow/status?attempt_id=` is polled every few
24
+ * seconds, up to ten minutes, until the attempt is `connected`, `failed` or
25
+ * `expired`. Every refusal carries a reason label and the caller exits 1.
29
26
  *
30
27
  * stdout carries nothing from here; every line is stderr commentary.
31
28
  */
32
29
  import { writeLine } from "./cli-io.js";
33
- export const WISPRFLOW_AUTH_ISSUER = "https://mcp-auth.wisprflow.com";
34
- export const WISPRFLOW_MCP_RESOURCE = "https://api.wisprflow.ai/connect/mcp";
35
- const SCOPE = "openid offline_access";
36
- const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
37
- const REQUEST_DEADLINE_MS = 30_000;
38
- /** RFC 8628: a client polls no faster than the server's interval, default 5 s. */
39
- const DEFAULT_INTERVAL_SECONDS = 5;
40
- /** Never wait on a person longer than this, whatever the server says. */
41
- const MAX_WAIT_SECONDS = 15 * 60;
30
+ import { openInBrowser } from "./browser-open.js";
31
+ import { ask, READ_DEADLINE_MS } from "./notes-door.js";
32
+ export const WISPRFLOW_START_PATH = "/api/notes/accounts/wisprflow/start";
33
+ export const WISPRFLOW_STATUS_PATH = "/api/notes/accounts/wisprflow/status";
34
+ /** How often the status door is asked whether the sign-in has landed. */
35
+ const POLL_INTERVAL_MS = 5_000;
36
+ /** Never wait on a person longer than this. */
37
+ const MAX_WAIT_MS = 10 * 60 * 1000;
38
+ /** A flaky read is retried; this many in a row is a real outage and ends the wait. */
39
+ const MAX_CONSECUTIVE_READ_FAILURES = 4;
42
40
  const TAG = "[notes wisprflow signin]";
43
- export async function signInToWisprflow(options) {
44
- const { io } = options;
41
+ /** What a person reads for a failed attempt, keyed on the status door's reason label. */
42
+ const FAILURE_SENTENCES = {
43
+ access_denied: "Wispr Flow says the sign-in was declined, so nothing was connected.",
44
+ state_expired: "The sign-in was not approved within ten minutes, so it expired and nothing was connected.",
45
+ state_already_used: "That sign-in link was already used, so nothing was connected.",
46
+ wisprflow_token_refused: "Wispr Flow refused to finish the sign-in, so nothing was connected.",
47
+ no_refresh_token: "Wispr Flow signed you in but did not allow Tower to keep reading, so it would stop working within the hour. Nothing was connected.",
48
+ account_already_connected: "Wispr Flow is already connected to your Tower account. Detach it first, then sign in again.",
49
+ credential_key_unavailable: "Tower could not safely store the sign-in just now, so nothing was connected.",
50
+ wisprflow_unreachable: "Tower could not reach Wispr Flow just now, so nothing was connected.",
51
+ wisprflow_registration_refused: "Wispr Flow would not start a sign-in for Tower, so nothing was connected.",
52
+ };
53
+ function failureDetail(reason) {
54
+ return FAILURE_SENTENCES[reason] ?? `The Wispr Flow sign-in ended without connecting (${reason}).`;
55
+ }
56
+ export async function connectWisprflowInBrowser(door, options = {}) {
45
57
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
58
  const now = options.now ?? Date.now;
47
- const metadata = await readMetadata(io);
48
- if (!metadata.ok)
49
- return metadata;
50
- // 1. Register a client. A public client: no secret, only an id.
51
- const registered = await postJson(io, metadata.value.registration_endpoint, {
52
- client_name: "Tower (BLI) notes sync",
53
- grant_types: [DEVICE_GRANT, "refresh_token"],
54
- response_types: [],
55
- token_endpoint_auth_method: "none",
56
- scope: SCOPE,
59
+ const open = options.open ?? ((url) => openInBrowser(url));
60
+ const stderr = door.io.stderr;
61
+ // 1. Ask the one start door for a sign-in.
62
+ const started = await ask(door, {
63
+ path: WISPRFLOW_START_PATH,
64
+ method: "POST",
65
+ label: "notes wisprflow start",
66
+ timeoutMs: READ_DEADLINE_MS,
67
+ body: {},
57
68
  });
58
- if (!registered.ok)
59
- return explain(io, "registration", registered);
60
- const clientId = stringField(registered.body, "client_id");
61
- if (!clientId) {
62
- return { ok: false, reason: "wisprflow_registration_unusable", detail: "Wispr Flow accepted the registration but sent no client id." };
69
+ if (!started.ok) {
70
+ log(door, "start refused", { reason: started.reason });
71
+ if (started.reason === "account_already_connected")
72
+ return alreadyConnected(door);
73
+ return { ok: false, reason: started.reason, detail: started.detail };
63
74
  }
64
- // 2. Ask for a code.
65
- const device = await postForm(io, metadata.value.device_authorization_endpoint, {
66
- client_id: clientId,
67
- scope: SCOPE,
68
- resource: WISPRFLOW_MCP_RESOURCE,
69
- });
70
- if (!device.ok)
71
- return explain(io, "device code", device);
72
- const deviceCode = stringField(device.body, "device_code");
73
- const userCode = stringField(device.body, "user_code");
74
- const address = stringField(device.body, "verification_uri_complete") ?? stringField(device.body, "verification_uri");
75
- if (!deviceCode || !userCode || !address) {
76
- return { ok: false, reason: "wisprflow_device_unusable", detail: "Wispr Flow's device answer was missing its code or its address." };
75
+ const body = started.body;
76
+ const authorizeUrl = typeof body.authorize_url === "string" ? body.authorize_url : "";
77
+ const attemptId = typeof body.attempt_id === "string" ? body.attempt_id : "";
78
+ if (authorizeUrl === "" || attemptId === "") {
79
+ log(door, "start answer unusable", { reason: "start_answer_unusable" });
80
+ return {
81
+ ok: false,
82
+ reason: "start_answer_unusable",
83
+ detail: "Tower started a sign-in but did not say where to send you or how to watch it, so nothing was connected.",
84
+ };
77
85
  }
78
- const waitSeconds = Math.min(numberField(device.body, "expires_in") ?? MAX_WAIT_SECONDS, MAX_WAIT_SECONDS);
79
- let intervalMs = (numberField(device.body, "interval") ?? DEFAULT_INTERVAL_SECONDS) * 1000;
80
- writeLine(io.stderr, "Sign in to Wispr Flow to let Tower read your meeting notes:");
81
- writeLine(io.stderr, ` 1. Open ${address}`);
82
- writeLine(io.stderr, ` 2. If it asks, enter the code ${userCode}`);
83
- writeLine(io.stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
84
- writeLine(io.stderr, `Waiting up to ${Math.round(waitSeconds / 60)} minutes for you to approve...`);
85
- log(io, "waiting", { interval_s: intervalMs / 1000, wait_s: waitSeconds });
86
- // 3. Poll.
87
- const deadline = now() + waitSeconds * 1000;
86
+ log(door, "started", { attempt_id: attemptId, expires_at: typeof body.expires_at === "string" ? body.expires_at : null });
87
+ // 2. Print the address first, then open it.
88
+ writeLine(stderr, "Connect Wispr Flow to Tower: approve on Wispr's page in your browser.");
89
+ writeLine(stderr, ` ${authorizeUrl}`);
90
+ writeLine(stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
91
+ const opened = open(authorizeUrl);
92
+ log(door, "browser open attempted", opened.ok ? { reason: opened.reason, program: opened.program } : { reason: opened.reason });
93
+ if (!opened.ok)
94
+ writeLine(stderr, `(${opened.detail} The address above still works.)`);
95
+ writeLine(stderr, `Waiting up to ${MAX_WAIT_MS / 60_000} minutes for the sign-in to finish...`);
96
+ // 3. Poll the one status door.
97
+ const deadline = now() + MAX_WAIT_MS;
98
+ let polls = 0;
99
+ let failures = 0;
88
100
  while (now() < deadline) {
89
- await sleep(intervalMs);
90
- const polled = await postForm(io, metadata.value.token_endpoint, {
91
- grant_type: DEVICE_GRANT,
92
- device_code: deviceCode,
93
- client_id: clientId,
94
- resource: WISPRFLOW_MCP_RESOURCE,
101
+ polls += 1;
102
+ const polled = await ask(door, {
103
+ path: `${WISPRFLOW_STATUS_PATH}?attempt_id=${encodeURIComponent(attemptId)}`,
104
+ method: "GET",
105
+ label: "notes wisprflow status",
106
+ timeoutMs: READ_DEADLINE_MS,
95
107
  });
96
- if (polled.ok) {
97
- const refreshToken = stringField(polled.body, "refresh_token");
98
- if (!refreshToken) {
99
- return {
100
- ok: false,
101
- reason: "wisprflow_no_offline_access",
102
- detail: "Wispr Flow signed you in but did not hand back a refresh token, so Tower could not read your meetings"
103
- + " every half hour without you. Nothing was connected.",
104
- };
108
+ if (!polled.ok) {
109
+ failures += 1;
110
+ log(door, "poll failed", { attempt_id: attemptId, polls, reason: polled.reason, consecutive_failures: failures });
111
+ // The attempt itself is gone or not ours: waiting longer cannot change that.
112
+ if (polled.reason === "attempt_not_found" || failures >= MAX_CONSECUTIVE_READ_FAILURES) {
113
+ return { ok: false, reason: polled.reason, detail: polled.detail };
105
114
  }
106
- log(io, "signed in", { client_registered: true, refresh_token_received: true });
107
- return {
108
- ok: true,
109
- bundle: JSON.stringify({ v: 1, client_id: clientId, refresh_token: refreshToken }),
110
- };
111
- }
112
- const code = stringField(polled.body, "error");
113
- if (code === "authorization_pending")
114
- continue;
115
- if (code === "slow_down") {
116
- intervalMs += 5000;
117
- continue;
118
115
  }
119
- if (code === "expired_token") {
120
- return { ok: false, reason: "wisprflow_code_expired", detail: "The code expired before it was approved. Run the command again." };
121
- }
122
- if (code === "access_denied") {
123
- return { ok: false, reason: "wisprflow_denied", detail: "Wispr Flow says the request was declined. Nothing was connected." };
116
+ else {
117
+ failures = 0;
118
+ const status = polled.body;
119
+ const state = typeof status.status === "string" ? status.status : "unknown";
120
+ const reason = typeof status.reason === "string" ? status.reason : null;
121
+ log(door, "poll", { attempt_id: attemptId, polls, status: state, reason });
122
+ if (state === "connected") {
123
+ const accountId = typeof status.account_id === "string" ? status.account_id : "";
124
+ log(door, "connected", { attempt_id: attemptId, account_id: accountId || null, polls });
125
+ return { ok: true, account: await readAccount(door, accountId), already: false };
126
+ }
127
+ if (state === "failed" || state === "expired") {
128
+ const why = reason ?? (state === "expired" ? "state_expired" : "unknown");
129
+ log(door, "ended without connecting", { attempt_id: attemptId, status: state, reason: why });
130
+ return { ok: false, reason: why, detail: failureDetail(why) };
131
+ }
124
132
  }
125
- return explain(io, "sign-in", polled);
133
+ await sleep(POLL_INTERVAL_MS);
126
134
  }
127
- return { ok: false, reason: "wisprflow_timed_out", detail: "Nobody approved the code in time. Run the command again." };
128
- }
129
- /** Metadata only: a step, a status and an OAuth error label. Never a code or a token. */
130
- function log(io, message, fields) {
131
- writeLine(io.stderr, `${TAG} ${message} ${JSON.stringify(fields)}`);
135
+ log(door, "timed out", { attempt_id: attemptId, polls });
136
+ return {
137
+ ok: false,
138
+ reason: "wisprflow_timed_out",
139
+ detail: "The sign-in did not finish within 10 minutes, so nothing was connected."
140
+ + " Run this again for a fresh link, or check `cockpit notes accounts`.",
141
+ };
132
142
  }
133
- async function readMetadata(io) {
134
- const got = await send(io, `${WISPRFLOW_AUTH_ISSUER}/.well-known/oauth-authorization-server`, { method: "GET" });
135
- if (!got.ok)
136
- return explain(io, "discovery", got);
137
- const registration = stringField(got.body, "registration_endpoint");
138
- const device = stringField(got.body, "device_authorization_endpoint");
139
- const token = stringField(got.body, "token_endpoint");
140
- if (!registration || !device || !token) {
143
+ /** The start door said Wispr Flow is already connected: read which account and report it honestly. */
144
+ async function alreadyConnected(door) {
145
+ const accounts = await readAccounts(door);
146
+ const existing = accounts?.find((account) => account.provider === "wisprflow");
147
+ if (!existing) {
141
148
  return {
142
149
  ok: false,
143
- reason: "wisprflow_discovery_changed",
144
- detail: "Wispr Flow's sign-in no longer advertises client registration and the device flow. Tower cannot sign you in from a terminal until it does.",
150
+ reason: "account_already_connected",
151
+ detail: failureDetail("account_already_connected"),
145
152
  };
146
153
  }
147
- // Every endpoint must stay on Wispr's own host: a discovery document that
148
- // points the token call somewhere else is not one to send a code to.
149
- for (const endpoint of [registration, device, token]) {
150
- if (!endpoint.startsWith(`${WISPRFLOW_AUTH_ISSUER}/`)) {
151
- return { ok: false, reason: "wisprflow_discovery_changed", detail: "Wispr Flow's sign-in points at a host Tower does not recognise, so nothing was sent." };
152
- }
153
- }
154
- return { ok: true, value: { registration_endpoint: registration, device_authorization_endpoint: device, token_endpoint: token } };
155
- }
156
- async function postJson(io, url, body) {
157
- return send(io, url, {
158
- method: "POST",
159
- headers: { "Content-Type": "application/json", Accept: "application/json" },
160
- body: JSON.stringify(body),
161
- });
162
- }
163
- async function postForm(io, url, body) {
164
- return send(io, url, {
165
- method: "POST",
166
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
167
- body: new URLSearchParams(body).toString(),
168
- });
169
- }
170
- async function send(io, url, init) {
171
- const call = io.fetch;
172
- const controller = new AbortController();
173
- const deadline = setTimeout(() => controller.abort(), REQUEST_DEADLINE_MS);
174
- try {
175
- const response = await call(url, { ...init, signal: controller.signal });
176
- let body = null;
177
- try {
178
- body = await response.json();
179
- }
180
- catch {
181
- body = null;
182
- }
183
- return response.ok ? { ok: true, body } : { ok: false, status: response.status, body, network: false };
184
- }
185
- catch {
186
- return { ok: false, status: 0, body: null, network: true };
187
- }
188
- finally {
189
- clearTimeout(deadline);
190
- }
191
- }
192
- /** A refusal in the vendor's own words: its OAuth error label, never a body dump. */
193
- function explain(io, step, failure) {
194
- if (failure.network) {
195
- return { ok: false, reason: "wisprflow_unreachable", detail: `Could not reach Wispr Flow's sign-in during ${step}. Check the connection and try again.` };
196
- }
197
- const label = stringField(failure.body, "error");
198
- const words = stringField(failure.body, "error_description");
199
- log(io, "refused", { step, http_status: failure.status, oauth_error: label ?? "none" });
154
+ log(door, "already connected", { account_id: existing.id, status: existing.status });
155
+ if (existing.status === "active")
156
+ return { ok: true, account: existing, already: true };
200
157
  return {
201
158
  ok: false,
202
- reason: `wisprflow_${step.replace(/\s+/g, "_")}_refused`,
203
- detail: `Wispr Flow refused the ${step} step (HTTP ${failure.status}${label ? `, ${label}` : ""}).`
204
- + `${words ? ` It said: ${words.slice(0, 200)}` : ""}`
205
- + " Nothing was connected.",
159
+ reason: "wisprflow_needs_detach",
160
+ detail: `Wispr Flow is connected (${existing.id}) but its status is ${existing.status}`
161
+ + `${existing.status_reason ? ` (${existing.status_reason})` : ""}. `
162
+ + `Detach it first with \`cockpit notes detach ${existing.id}\`, then run this again.`,
206
163
  };
207
164
  }
208
- function stringField(body, name) {
209
- if (typeof body !== "object" || body === null)
210
- return null;
211
- const value = body[name];
212
- return typeof value === "string" && value.trim() !== "" ? value : null;
165
+ /** The account row for the id the status door named. A failed read still reports the id. */
166
+ async function readAccount(door, accountId) {
167
+ const accounts = await readAccounts(door);
168
+ const found = accounts?.find((account) => account.id === accountId) ?? accounts?.find((account) => account.provider === "wisprflow");
169
+ return found ?? { id: accountId, provider: "wisprflow", label: null, workspace: null, status: "active", status_reason: null };
213
170
  }
214
- function numberField(body, name) {
215
- if (typeof body !== "object" || body === null)
171
+ async function readAccounts(door) {
172
+ const answer = await ask(door, {
173
+ path: "/api/notes/accounts",
174
+ method: "GET",
175
+ label: "notes accounts",
176
+ timeoutMs: READ_DEADLINE_MS,
177
+ });
178
+ if (!answer.ok) {
179
+ log(door, "accounts read failed", { reason: answer.reason });
216
180
  return null;
217
- const value = body[name];
218
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
181
+ }
182
+ const accounts = answer.body.accounts;
183
+ return Array.isArray(accounts) ? accounts : [];
184
+ }
185
+ /** Metadata only: a step and counts or a reason label. Never a token, never a URL, never a body. */
186
+ function log(door, message, fields) {
187
+ writeLine(door.io.stderr, `${TAG} ${message} ${JSON.stringify(fields)}`);
219
188
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.213");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.217");
25
25
  return 0;
26
26
  }
27
27
 
@@ -37,6 +37,8 @@ export async function runTeam(command, io) {
37
37
  return deviceList(command, tower, io);
38
38
  if (command.action === "device-doctor")
39
39
  return deviceDoctor(command, tower, io);
40
+ if (command.action === "device-scope")
41
+ return deviceScope(command, tower, io);
40
42
  return deviceRevoke(command, tower, io);
41
43
  }
42
44
  async function showMembers(command, tower, io) {
@@ -212,6 +214,36 @@ async function deviceDoctor(command, tower, io) {
212
214
  writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...asRecord(result.body) }) : `Doctor requested for ${device.device_name ?? device.device_id}. Waiting for its next check-in.`);
213
215
  return 0;
214
216
  }
217
+ /**
218
+ * `cockpit team device scope <id|name> notetaker_bot|none` (BLI-17978): the ONE door that gives the meeting bot's
219
+ * device the capability Tower's claim door requires, or takes it away. Super admin on the server; the word `none`
220
+ * is the CLI's spelling of "no scope". It replaces the manual SQL step the bot's README used to carry.
221
+ */
222
+ async function deviceScope(command, tower, io) {
223
+ const listed = await callTower(tower, { path: "/api/team/devices", label: "team device scope lookup" });
224
+ if (!listed.ok)
225
+ return writeCommandFailure(io, command.json, listed);
226
+ const device = findDevice(devicesOf(asRecord(listed.body)), command.deviceRef ?? "");
227
+ if (!device)
228
+ return writeCommandFailure(io, command.json, { reason: "device_not_found", detail: "No unique device matches. Run cockpit team device list to see ids and names." });
229
+ const result = await callTower(tower, {
230
+ path: `/api/ambient/devices/${encodeURIComponent(device.device_id)}/scope`,
231
+ method: "POST", label: "team device scope", body: { scope: command.deviceScope },
232
+ });
233
+ writeLine(io.stderr, `[team cli] device scope ${JSON.stringify({ device_id: device.device_id, scope: command.deviceScope, outcome: result.ok ? "answered" : result.reason })}`);
234
+ if (!result.ok)
235
+ return writeCommandFailure(io, command.json, result);
236
+ const body = asRecord(result.body);
237
+ if (command.json) {
238
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
239
+ return 0;
240
+ }
241
+ const name = String(device.device_name ?? device.device_id);
242
+ writeLine(io.stdout, body.changed === false
243
+ ? `${name} already has ${command.deviceScope === "none" ? "no scope" : "the notetaker_bot scope"}. Nothing changed.`
244
+ : command.deviceScope === "none" ? `Took the service scope away from ${name}.` : `${name} now holds the notetaker_bot scope. The bot's next claim will go through.`);
245
+ return 0;
246
+ }
215
247
  /** Exact directory labels locate a roster id; attribution remains the server resolver's job. */
216
248
  async function githubLogin(command, tower, io) {
217
249
  let personId = command.personRef ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.213",
3
+ "version": "0.2.217",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.66",
31
- "@bli-cockpit/mcp": "0.1.122",
31
+ "@bli-cockpit/mcp": "0.1.125",
32
32
  "@bli-cockpit/telemetry-core": "0.1.75"
33
33
  },
34
34
  "repository": {