@bli-cockpit/cli 0.2.37 → 0.2.39
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/brief.js +133 -0
- package/dist/commands/correct.js +160 -0
- package/dist/commands/jarvis.js +215 -64
- package/dist/commands/local-args.js +570 -3
- package/dist/commands/local-help.js +172 -5
- package/dist/commands/local.js +91 -3
- package/dist/commands/notes-file.js +102 -0
- package/dist/commands/notes.js +490 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +172 -0
- package/dist/commands/scout.js +158 -0
- package/dist/commands/settings-render.js +137 -0
- package/dist/commands/settings.js +377 -0
- package/dist/commands/team.js +111 -0
- package/dist/commands/tower-command.js +112 -0
- package/dist/commands/workbook-render.js +196 -0
- package/dist/commands/workbook.js +180 -0
- package/dist/tower-client.js +150 -0
- package/dist/tower-stream.js +252 -0
- package/package.json +2 -2
|
@@ -22,6 +22,14 @@ export const rootCommandNames = new Set([
|
|
|
22
22
|
"sync",
|
|
23
23
|
"analyze",
|
|
24
24
|
"jarvis",
|
|
25
|
+
"model",
|
|
26
|
+
"scout",
|
|
27
|
+
"settings",
|
|
28
|
+
"team",
|
|
29
|
+
"workbook",
|
|
30
|
+
"brief",
|
|
31
|
+
"correct",
|
|
32
|
+
"notes",
|
|
25
33
|
"backfill",
|
|
26
34
|
"status",
|
|
27
35
|
"sessions",
|
|
@@ -42,11 +50,19 @@ export function localCommandHelp(command) {
|
|
|
42
50
|
" cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
|
|
43
51
|
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
44
52
|
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
45
|
-
" cockpit logout",
|
|
53
|
+
" cockpit logout [--revoke] [--yes] [--dashboard-url <url>] [--json]",
|
|
46
54
|
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
47
55
|
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
48
56
|
" cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
49
|
-
" cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--dashboard-url <url>] [--json]",
|
|
57
|
+
" cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
|
|
58
|
+
" cockpit model [show|set <provider:model>] [--json]",
|
|
59
|
+
" cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
|
|
60
|
+
" cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
|
|
61
|
+
" cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes]] [--json]",
|
|
62
|
+
" cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
|
|
63
|
+
" cockpit brief [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--dashboard-url <url>] [--json]",
|
|
64
|
+
" cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
|
|
65
|
+
" cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--yes] [--dashboard-url <url>] [--json]",
|
|
50
66
|
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
51
67
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
52
68
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
@@ -140,7 +156,70 @@ function localSubcommandHelp(command) {
|
|
|
140
156
|
"Alias for `cockpit login`.",
|
|
141
157
|
],
|
|
142
158
|
],
|
|
143
|
-
[
|
|
159
|
+
[
|
|
160
|
+
"logout",
|
|
161
|
+
[
|
|
162
|
+
"Usage: cockpit logout [--revoke] [--yes] [--dashboard-url <url>] [--json]",
|
|
163
|
+
"",
|
|
164
|
+
"Removes the local device session.",
|
|
165
|
+
"--revoke also asks Tower to end this device's token, which is what you want",
|
|
166
|
+
"when the machine is being handed back: without it this laptop stops collecting",
|
|
167
|
+
"but the token it held is still one the server would honour.",
|
|
168
|
+
"A device can only revoke ITSELF; revoking someone else's machine stays in the browser.",
|
|
169
|
+
"The local session is removed either way, and the two outcomes are reported separately.",
|
|
170
|
+
],
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
"model",
|
|
174
|
+
[
|
|
175
|
+
"Usage: cockpit model [show|set <provider:model>] [--json]",
|
|
176
|
+
"",
|
|
177
|
+
"Shows or changes YOUR chat model — the short way to say",
|
|
178
|
+
"`cockpit settings personal --chat-model`. Affects your account, not the org.",
|
|
179
|
+
"An unrecognised key is refused by the dashboard, not by this command.",
|
|
180
|
+
],
|
|
181
|
+
],
|
|
182
|
+
[
|
|
183
|
+
"settings",
|
|
184
|
+
[
|
|
185
|
+
"Usage: cockpit settings [section] [verb] [flags] [--json]",
|
|
186
|
+
"",
|
|
187
|
+
"With no section it shows everything you can see; a section you cannot see reads",
|
|
188
|
+
"`admin only` rather than failing, because that is the system working.",
|
|
189
|
+
"",
|
|
190
|
+
" cockpit settings personal [--chat-model <key>] [--brief-model <key>]",
|
|
191
|
+
" Your teams and your two model choices. No flags = show.",
|
|
192
|
+
" cockpit settings switches [set <key> <value>]",
|
|
193
|
+
" Access and feature switches. Everyone may look; only an admin may set.",
|
|
194
|
+
" cockpit settings models [set --chat <key>] [--memory <id>]",
|
|
195
|
+
" The ORG-WIDE model map. Admin only, in both directions.",
|
|
196
|
+
" cockpit settings env list",
|
|
197
|
+
" Stored env files: project, name, when, id. Never a value — the server",
|
|
198
|
+
" cannot return one and this command has no flag that prints one.",
|
|
199
|
+
" cockpit settings env set --project <project> --file <name> --content-stdin",
|
|
200
|
+
" Pipe the file in; there is no --content flag, because a secret on a",
|
|
201
|
+
" command line lands in shell history and in every process listing.",
|
|
202
|
+
" macOS: cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin",
|
|
203
|
+
" PowerShell: Get-Content <path> -Raw | cockpit settings env set --project <project> --file <name> --content-stdin",
|
|
204
|
+
" Run `cockpit settings env list` to see which file names this project accepts.",
|
|
205
|
+
" cockpit settings env delete --id <uuid> [--yes]",
|
|
206
|
+
" Asks first unless --yes; a headless run without --yes is refused, not assumed.",
|
|
207
|
+
],
|
|
208
|
+
],
|
|
209
|
+
[
|
|
210
|
+
"team",
|
|
211
|
+
[
|
|
212
|
+
"Usage: cockpit team [members | invite <email> --role <role> | role <userId> --role <role>] [--json]",
|
|
213
|
+
"",
|
|
214
|
+
" cockpit team [members]",
|
|
215
|
+
" Everyone on your team — roster and accounts together, with each person's id.",
|
|
216
|
+
" Open to every role; it says so when the view is read-only for you.",
|
|
217
|
+
" cockpit team invite <email> --role <role> [--team-id <uuid>]",
|
|
218
|
+
" Admin only. Defaults to your own team; Tower emails them a sign-in link.",
|
|
219
|
+
" cockpit team role <userId> --role <role> [--yes]",
|
|
220
|
+
" Admin only. Asks first unless --yes. You cannot change your own role.",
|
|
221
|
+
],
|
|
222
|
+
],
|
|
144
223
|
[
|
|
145
224
|
"start",
|
|
146
225
|
[
|
|
@@ -190,7 +269,7 @@ function localSubcommandHelp(command) {
|
|
|
190
269
|
[
|
|
191
270
|
"jarvis",
|
|
192
271
|
[
|
|
193
|
-
"Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--dashboard-url <url>] [--json]",
|
|
272
|
+
"Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
|
|
194
273
|
"",
|
|
195
274
|
"Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
|
|
196
275
|
"--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
|
|
@@ -199,11 +278,99 @@ function localSubcommandHelp(command) {
|
|
|
199
278
|
"Run with no question for an interactive terminal conversation.",
|
|
200
279
|
"Agents can pass --prompt, positional text, or pipe one question on stdin.",
|
|
201
280
|
"Each answer prints a per-tool trace line and, only on a fallback or model mismatch, one model receipt line.",
|
|
202
|
-
"
|
|
281
|
+
"Trace lines arrive live while JARVIS works; pass --no-stream to wait for the whole answer in one piece instead.",
|
|
282
|
+
"--json writes one machine-readable response (including trace and model) to stdout; operational metadata stays on stderr. A --json run never streams fragments, whichever mode it is in.",
|
|
283
|
+
"A turn that outlives the dashboard's own ceiling is reported as turn_timed_out — an incomplete answer is never printed as a finished one.",
|
|
203
284
|
"The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
|
|
285
|
+
"--threads lists the terminal conversations on this account; --thread <name> --history replays one (the newest --limit <n> messages). Neither takes a question, and neither can reach anybody else's conversations.",
|
|
286
|
+
"Run `cockpit login` first if this machine is not paired.",
|
|
287
|
+
],
|
|
288
|
+
],
|
|
289
|
+
[
|
|
290
|
+
"scout",
|
|
291
|
+
[
|
|
292
|
+
"Usage: cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
|
|
293
|
+
"",
|
|
294
|
+
"Prints the same Scout board the Tower page shows, in the same words: the standing",
|
|
295
|
+
"watch line, the cards waiting on a decision, and the raw signal watch underneath.",
|
|
296
|
+
"This is the board itself. To ask a QUESTION about it — what it means, whether it is",
|
|
297
|
+
"worth doing here — use `cockpit jarvis`, whose readScout tool reads the same rows.",
|
|
298
|
+
"--days <n> widens or narrows the window (1 to 90; the board defaults to 14).",
|
|
299
|
+
"Reading follows the Scout page audience setting, so whoever can open /scout can run this.",
|
|
300
|
+
"start, dismiss, and undo move one card and are super_admin actions on every surface;",
|
|
301
|
+
"a refusal is printed as Tower worded it and the board stays readable underneath.",
|
|
302
|
+
"The id can be the first 6 or more characters printed beside each card; an ambiguous",
|
|
303
|
+
"prefix is refused with the candidates rather than acting on the first match.",
|
|
304
|
+
"--json writes one object to stdout; operational lines stay on stderr.",
|
|
305
|
+
"Run `cockpit login` first if this machine is not paired.",
|
|
306
|
+
],
|
|
307
|
+
],
|
|
308
|
+
[
|
|
309
|
+
"workbook",
|
|
310
|
+
[
|
|
311
|
+
"Usage: cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
|
|
312
|
+
"",
|
|
313
|
+
"Reads the Tower workbook library: written project documents — workbooks, runbooks —",
|
|
314
|
+
"each with an author and a date. With no arguments it lists every compartment and the",
|
|
315
|
+
"command that opens each document.",
|
|
316
|
+
"`cockpit workbook tower` lists one project's shelf; `cockpit workbook tower workbook`",
|
|
317
|
+
"prints that document as wrapped plain text.",
|
|
318
|
+
"--section <id> prints one section, named by the anchors listed with the document.",
|
|
319
|
+
"--markdown prints the server's Markdown unchanged, for piping into anything else.",
|
|
320
|
+
"--width <n> sets the wrap column; otherwise the terminal's width is used, held between 60 and 100.",
|
|
321
|
+
"There is no pager: pipe it into one if you want paging.",
|
|
322
|
+
"Sign-in is the only gate, exactly as on the website page.",
|
|
204
323
|
"Run `cockpit login` first if this machine is not paired.",
|
|
205
324
|
],
|
|
206
325
|
],
|
|
326
|
+
[
|
|
327
|
+
"brief",
|
|
328
|
+
[
|
|
329
|
+
"Usage: cockpit brief [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--dashboard-url <url>] [--json]",
|
|
330
|
+
"",
|
|
331
|
+
"Prints the TODAY page — the same page the Tower website shows, rendered for a terminal.",
|
|
332
|
+
"--for opens somebody else's page; the website's own rule decides whether you may, and it refuses in plain words when you may not.",
|
|
333
|
+
"--tldr prints the ten-second version; the whole page is the default.",
|
|
334
|
+
"--version <pageId> steps back to one exact earlier version; --versions lists them with their ids.",
|
|
335
|
+
"--claims prints the [claimId] beside every line, which is what `cockpit correct --claim` takes.",
|
|
336
|
+
"Reading it here counts as opening it, exactly as opening it in a browser does.",
|
|
337
|
+
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
338
|
+
],
|
|
339
|
+
],
|
|
340
|
+
[
|
|
341
|
+
"notes",
|
|
342
|
+
[
|
|
343
|
+
"Usage: cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [flags]",
|
|
344
|
+
"",
|
|
345
|
+
"The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
|
|
346
|
+
"list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
|
|
347
|
+
"show <id> — one note with its text, its shelf, and why you are allowed to see it.",
|
|
348
|
+
"shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
|
|
349
|
+
"shelves — the shelves in use, with how many notes are on each.",
|
|
350
|
+
"upload <paths...> [--exclude \"<sentence>\"] — put one or more local files in. Explicit paths only; your shell does any globbing, and a file that does not exist is named rather than skipped.",
|
|
351
|
+
"paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in with no file. Defaults to stdin: `pbpaste | cockpit notes paste` on macOS, `Get-Clipboard | cockpit notes paste` on Windows.",
|
|
352
|
+
"--exclude carries your own sentence about what to leave out, exactly as the browser's box does.",
|
|
353
|
+
"share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
|
|
354
|
+
"unshare <id> — take it back. Never asks: it only ever narrows who can read.",
|
|
355
|
+
"move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
|
|
356
|
+
"A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
|
|
357
|
+
"--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
|
|
358
|
+
"Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
|
|
359
|
+
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
360
|
+
],
|
|
361
|
+
],
|
|
362
|
+
[
|
|
363
|
+
"correct",
|
|
364
|
+
[
|
|
365
|
+
"Usage: cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
|
|
366
|
+
"",
|
|
367
|
+
"Tells Tower that one line on the page is wrong. Same door the website's correction form uses.",
|
|
368
|
+
"Get the claim id from `cockpit brief --claims`; an id the page does not carry is refused before anything is written.",
|
|
369
|
+
"--text may be omitted when the correction is piped in on stdin.",
|
|
370
|
+
"The claim is checked against the live record FIRST. If the record disagrees, JARVIS says so and the correction is still filed with that outcome on it.",
|
|
371
|
+
"--supersedes <correctionId> takes an earlier correction back; nothing is ever edited or deleted.",
|
|
372
|
+
],
|
|
373
|
+
],
|
|
207
374
|
[
|
|
208
375
|
"backfill",
|
|
209
376
|
[
|
package/dist/commands/local.js
CHANGED
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
* status.ts `cockpit status`
|
|
18
18
|
* sessions.ts `cockpit sessions`
|
|
19
19
|
* jarvis.ts terminal adapter for the shared JARVIS gateway
|
|
20
|
+
* scout.ts `cockpit scout` — the board, and the three card verbs
|
|
21
|
+
* (+ scout-render.ts, the pure layout half)
|
|
22
|
+
* workbook.ts `cockpit workbook` — the project document library
|
|
23
|
+
* (+ workbook-render.ts, the pure layout half)
|
|
24
|
+
* brief.ts `cockpit brief` — the TODAY page in the terminal
|
|
25
|
+
* correct.ts `cockpit correct` — one line on it is wrong
|
|
26
|
+
* notes.ts `cockpit notes` — the meeting-notes surface, typed
|
|
27
|
+
* notes-file.ts the local screen a note file passes before it is sent
|
|
28
|
+
* tower-command.ts open a Tower session, call a route, name the refusal
|
|
29
|
+
* settings.ts `cockpit settings` and `cockpit model`
|
|
30
|
+
* settings-render.ts how a settings answer reads in a terminal
|
|
31
|
+
* team.ts `cockpit team` — members, invite, role
|
|
20
32
|
*
|
|
21
33
|
* What stays here is orchestration: onboard, login/logout/start, the sync tick,
|
|
22
34
|
* analyze, serve, autostart and agent-rules.
|
|
@@ -34,6 +46,14 @@ import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } fr
|
|
|
34
46
|
import { runStatus } from "./status.js";
|
|
35
47
|
import { runSessions } from "./sessions.js";
|
|
36
48
|
import { runJarvis } from "./jarvis.js";
|
|
49
|
+
import { runScout } from "./scout.js";
|
|
50
|
+
import { runModel, runSettings } from "./settings.js";
|
|
51
|
+
import { runTeam } from "./team.js";
|
|
52
|
+
import { asRecord, callTower, openTower } from "./tower-command.js";
|
|
53
|
+
import { runWorkbook } from "./workbook.js";
|
|
54
|
+
import { runBrief } from "./brief.js";
|
|
55
|
+
import { runCorrect } from "./correct.js";
|
|
56
|
+
import { runNotes } from "./notes.js";
|
|
37
57
|
import { createCollectorServer } from "../server.js";
|
|
38
58
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
39
59
|
import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
|
|
@@ -102,6 +122,22 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
102
122
|
return await runAnalyze(command, io);
|
|
103
123
|
case "jarvis":
|
|
104
124
|
return await runJarvis(command, io);
|
|
125
|
+
case "model":
|
|
126
|
+
return await runModel(command, io);
|
|
127
|
+
case "scout":
|
|
128
|
+
return await runScout(command, io);
|
|
129
|
+
case "settings":
|
|
130
|
+
return await runSettings(command, io);
|
|
131
|
+
case "team":
|
|
132
|
+
return await runTeam(command, io);
|
|
133
|
+
case "workbook":
|
|
134
|
+
return await runWorkbook(command, io);
|
|
135
|
+
case "brief":
|
|
136
|
+
return await runBrief(command, io);
|
|
137
|
+
case "correct":
|
|
138
|
+
return await runCorrect(command, io);
|
|
139
|
+
case "notes":
|
|
140
|
+
return await runNotes(command, io);
|
|
105
141
|
case "backfill":
|
|
106
142
|
return await runBackfillCommand(command, io);
|
|
107
143
|
case "status":
|
|
@@ -913,16 +949,68 @@ function nextStepForOnboardBlocker(blocker, options = {}) {
|
|
|
913
949
|
return "Run `cockpit status --json` and report the blocker label plus last failure reason.";
|
|
914
950
|
}
|
|
915
951
|
}
|
|
952
|
+
/**
|
|
953
|
+
* `cockpit logout` removes the local session file. `--revoke` also tells Tower
|
|
954
|
+
* to end the device's token (BLI-3461).
|
|
955
|
+
*
|
|
956
|
+
* Those are two different facts, and the difference matters when a laptop is
|
|
957
|
+
* being handed back: without the revoke the machine stops collecting but the
|
|
958
|
+
* token it held is still one the server would honour. The revoke is attempted
|
|
959
|
+
* FIRST, because it needs the token the local logout is about to delete, and a
|
|
960
|
+
* revoke that fails does not stop the local logout — the person asked to be
|
|
961
|
+
* signed out of this machine, and they get that either way. The outcome of each
|
|
962
|
+
* half is reported separately rather than folded into one "ok".
|
|
963
|
+
*/
|
|
916
964
|
async function runLogout(command, io) {
|
|
965
|
+
const revoke = command.revoke ? await revokeThisDevice(command, io) : null;
|
|
917
966
|
const result = await logoutLocalCollector({ homeDir: command.homeDir });
|
|
967
|
+
writeLine(io.stderr, `[logout] done ${JSON.stringify({
|
|
968
|
+
local_session: result.removed ? "removed" : "absent",
|
|
969
|
+
revoke: revoke ? revoke.outcome : "not_requested",
|
|
970
|
+
})}`);
|
|
918
971
|
if (command.json) {
|
|
919
|
-
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
920
|
-
return 0;
|
|
972
|
+
writeLine(io.stdout, JSON.stringify({ ...result, revoke: revoke ?? { outcome: "not_requested" } }, null, 2));
|
|
973
|
+
return revoke && revoke.outcome !== "revoked" ? 1 : 0;
|
|
921
974
|
}
|
|
922
975
|
writeLine(io.stdout, result.removed
|
|
923
976
|
? "Tower collector session removed."
|
|
924
977
|
: "No Tower collector session found.");
|
|
925
|
-
|
|
978
|
+
if (revoke) {
|
|
979
|
+
writeLine(io.stdout, revoke.outcome === "revoked"
|
|
980
|
+
? "This device's Tower token is revoked."
|
|
981
|
+
: `This device's Tower token was NOT revoked: ${revoke.detail}`);
|
|
982
|
+
}
|
|
983
|
+
return revoke && revoke.outcome !== "revoked" ? 1 : 0;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Ends this device's own session server-side.
|
|
987
|
+
*
|
|
988
|
+
* The route gates the device arm on `self_revoke_only`, so the device id is
|
|
989
|
+
* read from the paired session rather than accepted as an argument — there is
|
|
990
|
+
* no way to spell "revoke somebody else's laptop" here, and that is deliberate.
|
|
991
|
+
*/
|
|
992
|
+
async function revokeThisDevice(command, io) {
|
|
993
|
+
let tower;
|
|
994
|
+
let deviceId;
|
|
995
|
+
try {
|
|
996
|
+
const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
|
|
997
|
+
deviceId = session.device_id;
|
|
998
|
+
tower = await openTower("logout", command, io);
|
|
999
|
+
}
|
|
1000
|
+
catch (error) {
|
|
1001
|
+
return { outcome: "no_paired_session", detail: errorMessage(error) };
|
|
1002
|
+
}
|
|
1003
|
+
const result = await callTower(tower, {
|
|
1004
|
+
path: `/api/ambient/devices/${encodeURIComponent(deviceId)}/revoke`,
|
|
1005
|
+
method: "POST",
|
|
1006
|
+
label: "logout revoke",
|
|
1007
|
+
body: { reason: "Signed out from the device" },
|
|
1008
|
+
});
|
|
1009
|
+
if (!result.ok) {
|
|
1010
|
+
return { outcome: result.reason, detail: result.detail };
|
|
1011
|
+
}
|
|
1012
|
+
const body = asRecord(result.body);
|
|
1013
|
+
return { outcome: "revoked", detail: String(asRecord(body.device).revoked_at ?? "") };
|
|
926
1014
|
}
|
|
927
1015
|
async function runStart(command, io) {
|
|
928
1016
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a local file for `cockpit notes upload <paths...>` and `cockpit notes
|
|
3
|
+
* paste --file <path>` (BLI-3459), before anything is uploaded.
|
|
4
|
+
*
|
|
5
|
+
* The dashboard's `/api/notes/upload` route is the ONE authority on whether a
|
|
6
|
+
* dropped note is acceptable — `checkUploadedNoteFile` in
|
|
7
|
+
* `@bli-cockpit/harvest-analysis/meeting-notes/uploaded-file` holds the secret
|
|
8
|
+
* guard, the byte ceiling and the format classification, and this package
|
|
9
|
+
* cannot import from it (separate workspace, no dependency edge). So this
|
|
10
|
+
* module only catches what is cheap and purely local to check BEFORE spending a
|
|
11
|
+
* network round trip: does the path exist, is it a regular file this account can
|
|
12
|
+
* read, is there anything in it, and is it under the ceiling. Same discipline as
|
|
13
|
+
* `jarvis-attachment.ts`, and the same rule if the two ever drift: the server's
|
|
14
|
+
* check wins.
|
|
15
|
+
*
|
|
16
|
+
* **There is deliberately no extension refusal here.** BLI-3398 made every file
|
|
17
|
+
* name resolve to one of nine formats with `unknown` ACCEPTED, kept and labeled
|
|
18
|
+
* rather than turned away. A local extension allowlist would refuse files the
|
|
19
|
+
* server would happily store, which is exactly the withholding this repo's
|
|
20
|
+
* session-first rule forbids. The extension is reported so the terminal can name
|
|
21
|
+
* what it is sending; it never decides anything.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here throws. Every failure mode comes back as a named refusal, so the
|
|
24
|
+
* command prints a plain sentence rather than a stack trace, and the refusal
|
|
25
|
+
* label is what travels into the log line.
|
|
26
|
+
*/
|
|
27
|
+
import { readFile, stat } from "node:fs/promises";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
|
|
30
|
+
import { errorMessage } from "./cli-io.js";
|
|
31
|
+
/**
|
|
32
|
+
* Mirrors `UPLOADED_NOTE_MAX_BYTES` in
|
|
33
|
+
* `packages/cockpit-harvest-analysis/src/meeting-notes/uploaded-file.ts`. A
|
|
34
|
+
* courtesy check to avoid uploading something the server is certain to refuse,
|
|
35
|
+
* not a second source of truth.
|
|
36
|
+
*/
|
|
37
|
+
export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
38
|
+
/**
|
|
39
|
+
* Above this, the terminal says it is working before it starts. The route's own
|
|
40
|
+
* ceiling is `maxDuration = 300` because reading a note is one model call over
|
|
41
|
+
* the whole file, and a person watching a still cursor for two minutes assumes
|
|
42
|
+
* it hung.
|
|
43
|
+
*/
|
|
44
|
+
export const NOTE_SLOW_UPLOAD_BYTES = 256 * 1024;
|
|
45
|
+
/** The sentence the terminal prints for a refusal caught locally, before any request goes out. */
|
|
46
|
+
export function noteFileRefusalSentence(refusal, filePath) {
|
|
47
|
+
switch (refusal) {
|
|
48
|
+
case "file_not_found":
|
|
49
|
+
return `I could not find that file: ${filePath}`;
|
|
50
|
+
case "file_unreadable":
|
|
51
|
+
return `I could not read that file: ${filePath}`;
|
|
52
|
+
case "file_empty":
|
|
53
|
+
return `There is nothing in that file: ${filePath}`;
|
|
54
|
+
case "file_too_big":
|
|
55
|
+
return "That file is too big to put in as a note — keep it under 20 MB.";
|
|
56
|
+
case "looks_like_a_key_file":
|
|
57
|
+
// The same rule the server's gate applies, said the same way: the name is
|
|
58
|
+
// all it takes to decide, and looking inside to be sure would already be
|
|
59
|
+
// the thing the rule forbids.
|
|
60
|
+
return `That name looks like a key or credential file, so I did not open it: ${filePath}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reads and locally screens one note file. Never throws.
|
|
65
|
+
*
|
|
66
|
+
* The secret-shaped-name check runs FIRST and on the name alone — the bytes are
|
|
67
|
+
* never read for a file it turns away.
|
|
68
|
+
*/
|
|
69
|
+
export async function readNoteFile(filePath) {
|
|
70
|
+
const fileName = path.basename(filePath);
|
|
71
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
72
|
+
if (isSecretLikePathSegment(fileName)) {
|
|
73
|
+
return { ok: false, refusal: "looks_like_a_key_file", detail: "secret_like_name" };
|
|
74
|
+
}
|
|
75
|
+
let size;
|
|
76
|
+
try {
|
|
77
|
+
const info = await stat(filePath);
|
|
78
|
+
if (!info.isFile()) {
|
|
79
|
+
return { ok: false, refusal: "file_unreadable", detail: "not_a_regular_file" };
|
|
80
|
+
}
|
|
81
|
+
size = info.size;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const code = error.code;
|
|
85
|
+
if (code === "ENOENT")
|
|
86
|
+
return { ok: false, refusal: "file_not_found", detail: "enoent" };
|
|
87
|
+
return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
|
|
88
|
+
}
|
|
89
|
+
if (size === 0)
|
|
90
|
+
return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
|
|
91
|
+
if (size > NOTE_FILE_MAX_BYTES) {
|
|
92
|
+
return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
|
|
93
|
+
}
|
|
94
|
+
let bytes;
|
|
95
|
+
try {
|
|
96
|
+
bytes = await readFile(filePath);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, bytes, fileName, extension: extension || "none" };
|
|
102
|
+
}
|