@bli-cockpit/cli 0.2.212 → 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.
- package/dist/commands/agent-door.js +5 -3
- package/dist/commands/local-args-tower-admin.js +8 -1
- package/dist/commands/local-args-tower-manager.js +371 -0
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-command-shapes-manager.js +9 -0
- package/dist/commands/local-help-commands-tower-ops.js +41 -0
- package/dist/commands/local-help-commands-tower.js +1 -1
- package/dist/commands/local-help-commands.js +3 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +8 -12
- package/dist/commands/manager-render.js +168 -0
- package/dist/commands/manager-requests.js +100 -0
- package/dist/commands/manager.js +211 -0
- package/dist/commands/notes-accounts.js +48 -60
- package/dist/commands/notes-sync-loop.js +129 -0
- package/dist/commands/notes-wisprflow-login.js +156 -187
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/team.js +32 -0
- package/package.json +2 -2
|
@@ -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` —
|
|
3
|
-
*
|
|
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
|
|
6
|
-
* remote MCP server behind OAuth
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 (!
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return { ok: false, reason:
|
|
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
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
writeLine(
|
|
81
|
-
writeLine(
|
|
82
|
-
writeLine(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
const polled = await
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
+
await sleep(POLL_INTERVAL_MS);
|
|
126
134
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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: "
|
|
144
|
-
detail: "
|
|
150
|
+
reason: "account_already_connected",
|
|
151
|
+
detail: failureDetail("account_already_connected"),
|
|
145
152
|
};
|
|
146
153
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
203
|
-
detail: `Wispr Flow
|
|
204
|
-
+ `${
|
|
205
|
-
+
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
return
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.217");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
package/dist/commands/team.js
CHANGED
|
@@ -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.
|
|
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.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.125",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.75"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|