@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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit team` — who is on the team, and the two things an admin does to it
|
|
3
|
+
* (BLI-3461, decision D3: invite and role change ship in the PUBLIC build).
|
|
4
|
+
*
|
|
5
|
+
* Same shape as the rest of the CLI-parity surface: this file owns terminal
|
|
6
|
+
* input and output, the dashboard owns every decision. The roster union, the
|
|
7
|
+
* super_admin gate, the self-role-change refusal and the audit line all live
|
|
8
|
+
* server-side, so a stale CLI can be wrong about wording and never about
|
|
9
|
+
* permission.
|
|
10
|
+
*
|
|
11
|
+
* `team invite` and `team role` change what a person can do, so both are
|
|
12
|
+
* deliberate: the role change asks before it acts unless `--yes` is passed, and
|
|
13
|
+
* a headless run without `--yes` is refused by name rather than assumed.
|
|
14
|
+
*/
|
|
15
|
+
import { writeLine } from "./cli-io.js";
|
|
16
|
+
import { renderTeamMembers } from "./settings-render.js";
|
|
17
|
+
import { confirmDestructive } from "./settings.js";
|
|
18
|
+
import { asRecord, callTower, openTower, writeCommandFailure, } from "./tower-command.js";
|
|
19
|
+
export async function runTeam(command, io) {
|
|
20
|
+
const tower = await openTower("team", command, io);
|
|
21
|
+
if (command.action === "members")
|
|
22
|
+
return showMembers(command, tower, io);
|
|
23
|
+
if (command.action === "invite")
|
|
24
|
+
return invite(command, tower, io);
|
|
25
|
+
return changeRole(command, tower, io);
|
|
26
|
+
}
|
|
27
|
+
async function showMembers(command, tower, io) {
|
|
28
|
+
const result = await callTower(tower, { path: "/api/team/members", label: "team members" });
|
|
29
|
+
if (!result.ok)
|
|
30
|
+
return writeCommandFailure(io, command.json, result);
|
|
31
|
+
const body = asRecord(result.body);
|
|
32
|
+
if (command.json) {
|
|
33
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
for (const line of renderTeamMembers(body))
|
|
37
|
+
writeLine(io.stdout, line);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The invite needs a team, and the obvious one is the caller's own.
|
|
42
|
+
*
|
|
43
|
+
* So `--team-id` is optional and the fallback is a real read of
|
|
44
|
+
* `/api/team/members` rather than a guess. A person with no team gets told
|
|
45
|
+
* that, which is the truth and is fixable, instead of a uuid validation error
|
|
46
|
+
* from a body we assembled badly.
|
|
47
|
+
*/
|
|
48
|
+
async function invite(command, tower, io) {
|
|
49
|
+
let teamId = command.teamId;
|
|
50
|
+
if (!teamId) {
|
|
51
|
+
const directory = await callTower(tower, {
|
|
52
|
+
path: "/api/team/members",
|
|
53
|
+
label: "team invite lookup",
|
|
54
|
+
});
|
|
55
|
+
if (!directory.ok)
|
|
56
|
+
return writeCommandFailure(io, command.json, directory);
|
|
57
|
+
const currentTeam = asRecord(asRecord(directory.body).currentTeam);
|
|
58
|
+
if (typeof currentTeam.id !== "string") {
|
|
59
|
+
return writeCommandFailure(io, command.json, {
|
|
60
|
+
reason: "no_current_team",
|
|
61
|
+
detail: "You do not belong to a team, so there is nowhere to invite them. Pass --team-id <uuid>.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
teamId = currentTeam.id;
|
|
65
|
+
}
|
|
66
|
+
const result = await callTower(tower, {
|
|
67
|
+
path: "/api/team/invite",
|
|
68
|
+
method: "POST",
|
|
69
|
+
label: "team invite",
|
|
70
|
+
body: { email: command.email, role: command.role, team_id: teamId },
|
|
71
|
+
});
|
|
72
|
+
writeLine(io.stderr, `[team cli] invite ${JSON.stringify({
|
|
73
|
+
role: command.role,
|
|
74
|
+
team_named: command.teamId != null,
|
|
75
|
+
outcome: result.ok ? "sent" : result.reason,
|
|
76
|
+
})}`);
|
|
77
|
+
if (!result.ok)
|
|
78
|
+
return writeCommandFailure(io, command.json, result);
|
|
79
|
+
const body = asRecord(result.body);
|
|
80
|
+
if (command.json) {
|
|
81
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
writeLine(io.stdout, `Invited ${String(command.email)} as ${String(body.role ?? command.role)}. ` +
|
|
85
|
+
"Tower emailed them a sign-in link.");
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
async function changeRole(command, tower, io) {
|
|
89
|
+
const confirmed = await confirmDestructive(command, io, `Change ${command.targetUserId} to ${command.role}? [Y/n] `, "team role");
|
|
90
|
+
if (!confirmed.ok)
|
|
91
|
+
return writeCommandFailure(io, command.json, confirmed);
|
|
92
|
+
const result = await callTower(tower, {
|
|
93
|
+
path: `/api/team/members/${encodeURIComponent(command.targetUserId ?? "")}/role`,
|
|
94
|
+
method: "PATCH",
|
|
95
|
+
label: "team role",
|
|
96
|
+
body: { role: command.role },
|
|
97
|
+
});
|
|
98
|
+
writeLine(io.stderr, `[team cli] role change ${JSON.stringify({
|
|
99
|
+
to: command.role,
|
|
100
|
+
outcome: result.ok ? "changed" : result.reason,
|
|
101
|
+
})}`);
|
|
102
|
+
if (!result.ok)
|
|
103
|
+
return writeCommandFailure(io, command.json, result);
|
|
104
|
+
const body = asRecord(result.body);
|
|
105
|
+
if (command.json) {
|
|
106
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
writeLine(io.stdout, `${String(body.user_id ?? command.targetUserId)} is now ${String(body.role)}.`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every "ask Tower a question as this machine's owner" command needs
|
|
3
|
+
* (BLI-3461).
|
|
4
|
+
*
|
|
5
|
+
* `tower-client.ts` (BLI-3457) already owns the transport: the paired session,
|
|
6
|
+
* the bearer header, and a failure that names itself rather than throwing. This
|
|
7
|
+
* module is the thin layer above it that the *command* shape needs — open the
|
|
8
|
+
* session once, call a route, and turn a refusal into the one thing a person or
|
|
9
|
+
* a `--json` consumer reads.
|
|
10
|
+
*
|
|
11
|
+
* Two rules it exists to keep:
|
|
12
|
+
*
|
|
13
|
+
* - **A refusal keeps its HTTP status.** `cockpit settings` shows a section a
|
|
14
|
+
* person may not see as "admin only" rather than as an error, and it can only
|
|
15
|
+
* tell those apart if the 403 survives the trip. `towerJsonRequest` folds a
|
|
16
|
+
* non-2xx into a failure; this keeps `httpStatus` on it.
|
|
17
|
+
* - **stdout belongs to `--json`.** Every operational line goes to stderr, which
|
|
18
|
+
* launchd captures; a `--json` run puts exactly one object on stdout.
|
|
19
|
+
*/
|
|
20
|
+
import { writeLine } from "./cli-io.js";
|
|
21
|
+
import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
|
|
22
|
+
/** Client-side ceiling. These routes are small reads and writes, not turns. */
|
|
23
|
+
const REQUEST_DEADLINE_MS = 30_000;
|
|
24
|
+
export async function openTower(commandName, command, io) {
|
|
25
|
+
const session = await loadPairedSession(commandName, command.homeDir);
|
|
26
|
+
return {
|
|
27
|
+
dashboardUrl: command.dashboardUrl ?? session.dashboard_url,
|
|
28
|
+
deviceToken: session.device_token,
|
|
29
|
+
fetch: io.fetch,
|
|
30
|
+
log: (line) => writeLine(io.stderr, line),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function callTower(context, options) {
|
|
34
|
+
const result = await towerJsonRequest({
|
|
35
|
+
dashboardUrl: context.dashboardUrl,
|
|
36
|
+
path: options.path,
|
|
37
|
+
deviceToken: context.deviceToken,
|
|
38
|
+
fetch: context.fetch,
|
|
39
|
+
method: options.method ?? "GET",
|
|
40
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
41
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
42
|
+
label: options.label,
|
|
43
|
+
log: context.log,
|
|
44
|
+
});
|
|
45
|
+
if (result.ok)
|
|
46
|
+
return result;
|
|
47
|
+
const failure = result;
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: failure.reason,
|
|
51
|
+
detail: towerFailureDetail(failure.reason, failure.detail),
|
|
52
|
+
...(failure.httpStatus === undefined ? {} : { httpStatus: failure.httpStatus }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The one way a command reports that it could not do the thing.
|
|
57
|
+
*
|
|
58
|
+
* `--json` gets a single object on stdout; a person gets one sentence on
|
|
59
|
+
* stderr. Both carry the reason label, because "it failed" is not a result.
|
|
60
|
+
*/
|
|
61
|
+
export function writeCommandFailure(io, json, failure) {
|
|
62
|
+
if (json) {
|
|
63
|
+
writeLine(io.stdout, JSON.stringify({
|
|
64
|
+
ok: false,
|
|
65
|
+
error: failure.reason,
|
|
66
|
+
detail: failure.detail,
|
|
67
|
+
...(failure.httpStatus === undefined ? {} : { httpStatus: failure.httpStatus }),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
writeLine(io.stderr, failure.detail);
|
|
72
|
+
}
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
/** True when the caller was told, politely, that this is not theirs to see. */
|
|
76
|
+
export function isForbidden(result) {
|
|
77
|
+
return !result.ok && result.httpStatus === 403;
|
|
78
|
+
}
|
|
79
|
+
/** A record body, or an empty object — never a crash on an unexpected shape. */
|
|
80
|
+
export function asRecord(body) {
|
|
81
|
+
return body && typeof body === "object" && !Array.isArray(body)
|
|
82
|
+
? body
|
|
83
|
+
: {};
|
|
84
|
+
}
|
|
85
|
+
/** A list body, or an empty list. Same reasoning as `asRecord`. */
|
|
86
|
+
export function asList(value) {
|
|
87
|
+
return Array.isArray(value) ? value : [];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `provider:model` → the pair the settings API stores.
|
|
91
|
+
*
|
|
92
|
+
* There is deliberately NO allowlist here, matching `cockpit jarvis --model`:
|
|
93
|
+
* the dashboard holds the list and refuses an unknown key with its own
|
|
94
|
+
* sentence, so a terminal copy could only ever go stale and start refusing
|
|
95
|
+
* models that work.
|
|
96
|
+
*/
|
|
97
|
+
export function parseModelKey(key) {
|
|
98
|
+
const separator = key.indexOf(":");
|
|
99
|
+
if (separator <= 0 || separator === key.length - 1) {
|
|
100
|
+
throw new Error(`\`${key}\` is not a model key. Use provider:model, e.g. openai:gpt-5.6-terra.`);
|
|
101
|
+
}
|
|
102
|
+
return { provider: key.slice(0, separator), model: key.slice(separator + 1) };
|
|
103
|
+
}
|
|
104
|
+
/** Reads piped stdin whole. Used only for env content, which is never echoed. */
|
|
105
|
+
export async function readAllStdin(stream) {
|
|
106
|
+
stream.setEncoding("utf8");
|
|
107
|
+
let text = "";
|
|
108
|
+
for await (const chunk of stream) {
|
|
109
|
+
text += chunk;
|
|
110
|
+
}
|
|
111
|
+
return text;
|
|
112
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a workbook document and the library shelf read in a terminal.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions, no io and no network: `workbook.ts` fetches, this lays out.
|
|
5
|
+
* Split out by BLI-3460 so the wrapping can be asserted at a fixed width by a
|
|
6
|
+
* test that never opens a socket.
|
|
7
|
+
*
|
|
8
|
+
* The documents are hardcoded prose (BLI-3247: "written, not compiled") walked
|
|
9
|
+
* into Markdown by the dashboard's `lib/workbook/to-markdown.ts`. Nothing here
|
|
10
|
+
* summarises, reorders or paraphrases — it wraps, and it strips the emphasis
|
|
11
|
+
* markers a reader did not ask for. `--markdown` bypasses this file entirely
|
|
12
|
+
* and prints the server's bytes, which is what lets this layout be opinionated.
|
|
13
|
+
*/
|
|
14
|
+
const DIM = "\x1b[2m";
|
|
15
|
+
const RESET = "\x1b[0m";
|
|
16
|
+
/** One compartment per project, its documents underneath, addressed as typed. */
|
|
17
|
+
export function renderShelf(projects, width) {
|
|
18
|
+
const docs = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
|
|
19
|
+
const out = [
|
|
20
|
+
`WORKBOOK · ${projects.length} project${projects.length === 1 ? "" : "s"} · ${docs} document${docs === 1 ? "" : "s"}`,
|
|
21
|
+
];
|
|
22
|
+
for (const project of projects) {
|
|
23
|
+
out.push("", (project.title ?? project.slug ?? "").toUpperCase());
|
|
24
|
+
if (project.line)
|
|
25
|
+
out.push(...wrap(project.line, width - 2).map((line) => ` ${line}`));
|
|
26
|
+
for (const doc of project.docs ?? []) {
|
|
27
|
+
out.push(` ⏺ cockpit workbook ${project.slug ?? ""} ${doc.slug ?? ""}`);
|
|
28
|
+
out.push(` ${doc.title ?? ""} · ${doc.kind ?? ""}`);
|
|
29
|
+
if (doc.line) {
|
|
30
|
+
out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}`)));
|
|
31
|
+
}
|
|
32
|
+
out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}`));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** The document's own header, then its blocks laid out for `width`. */
|
|
38
|
+
export function renderDocText(payload, markdown, width) {
|
|
39
|
+
const doc = payload.doc ?? {};
|
|
40
|
+
const out = [];
|
|
41
|
+
if (doc.title)
|
|
42
|
+
out.push(...wrap(doc.title.toUpperCase(), width));
|
|
43
|
+
const meta = [doc.kind, payload.project?.title, doc.author, doc.date].filter(Boolean);
|
|
44
|
+
if (meta.length > 0)
|
|
45
|
+
out.push(...wrap(meta.join(" · "), width).map((line) => dim(line)));
|
|
46
|
+
if (out.length > 0)
|
|
47
|
+
out.push("");
|
|
48
|
+
out.push(...renderMarkdownText(markdown, width));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Markdown to plain text. The block vocabulary is exactly what
|
|
53
|
+
* `to-markdown.ts` emits — headings, bullet lists, quotes, pipe tables,
|
|
54
|
+
* paragraphs — so there is no "unknown block" case to guess at; anything else
|
|
55
|
+
* falls through as a wrapped paragraph rather than being dropped.
|
|
56
|
+
*/
|
|
57
|
+
export function renderMarkdownText(markdown, width) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const block of markdown.split("\n\n")) {
|
|
60
|
+
const text = block.trim();
|
|
61
|
+
if (!text)
|
|
62
|
+
continue;
|
|
63
|
+
if (out.length > 0)
|
|
64
|
+
out.push("");
|
|
65
|
+
if (text.startsWith("## ")) {
|
|
66
|
+
out.push(plain(text.slice(3)).toUpperCase());
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (text.startsWith("### ")) {
|
|
70
|
+
out.push(plain(text.slice(4)));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (text.startsWith("|")) {
|
|
74
|
+
out.push(...renderTable(text, width));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (text.startsWith(">")) {
|
|
78
|
+
const quote = text
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((line) => plain(line.replace(/^>\s?/, "")))
|
|
81
|
+
.join(" ")
|
|
82
|
+
.trim();
|
|
83
|
+
out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}`)));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (text.startsWith("- ")) {
|
|
87
|
+
for (const item of text.split("\n")) {
|
|
88
|
+
const body = plain(item.replace(/^-\s+/, ""));
|
|
89
|
+
const wrapped = wrap(body, width - 4);
|
|
90
|
+
wrapped.forEach((line, index) => {
|
|
91
|
+
out.push(index === 0 ? ` · ${line}` : ` ${line}`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
out.push(...wrap(plain(text), width));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A GFM pipe table as padded columns. When the padded table would not fit the
|
|
102
|
+
* width, each row is printed as `header: cell` lines instead — narrower, and
|
|
103
|
+
* still every cell. Dropping columns to fit is never an option: a table with a
|
|
104
|
+
* column missing looks complete and is not.
|
|
105
|
+
*/
|
|
106
|
+
function renderTable(block, width) {
|
|
107
|
+
const rows = block
|
|
108
|
+
.split("\n")
|
|
109
|
+
.map((line) => line.trim())
|
|
110
|
+
.filter((line) => line.startsWith("|"))
|
|
111
|
+
.map((line) => line
|
|
112
|
+
.replace(/^\|/, "")
|
|
113
|
+
.replace(/\|$/, "")
|
|
114
|
+
.split("|")
|
|
115
|
+
.map((cell) => plain(cell.trim())))
|
|
116
|
+
.filter((cells) => !cells.every((cell) => /^-{3,}$/.test(cell)));
|
|
117
|
+
if (rows.length === 0)
|
|
118
|
+
return [];
|
|
119
|
+
const header = rows[0] ?? [];
|
|
120
|
+
const body = rows.slice(1);
|
|
121
|
+
const columns = Math.max(...rows.map((cells) => cells.length));
|
|
122
|
+
const widths = [];
|
|
123
|
+
for (let index = 0; index < columns; index += 1) {
|
|
124
|
+
widths.push(Math.max(...rows.map((cells) => (cells[index] ?? "").length)));
|
|
125
|
+
}
|
|
126
|
+
const tableWidth = widths.reduce((sum, value) => sum + value, 0) + 2 * (columns - 1);
|
|
127
|
+
if (tableWidth <= width) {
|
|
128
|
+
return rows.map((cells) => cells
|
|
129
|
+
.map((cell, index) => cell.padEnd(index === columns - 1 ? 0 : (widths[index] ?? 0)))
|
|
130
|
+
.join(" ")
|
|
131
|
+
.trimEnd());
|
|
132
|
+
}
|
|
133
|
+
const out = [];
|
|
134
|
+
body.forEach((cells, rowIndex) => {
|
|
135
|
+
if (rowIndex > 0)
|
|
136
|
+
out.push("");
|
|
137
|
+
cells.forEach((cell, index) => {
|
|
138
|
+
const label = header[index];
|
|
139
|
+
out.push(label ? ` ${label}: ${cell}` : ` ${cell}`);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
return out.length > 0 ? out : rows.map((cells) => cells.join(" · "));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The Markdown of one section: from its `## Heading` to the next one. Returns
|
|
146
|
+
* null when the anchor is not in the index or its heading is not in the text —
|
|
147
|
+
* a wrong slice is worse than a refusal, because it reads as the whole answer.
|
|
148
|
+
*/
|
|
149
|
+
export function sectionSlice(markdown, sections, sectionId) {
|
|
150
|
+
const wanted = sections.find((section) => section.id === sectionId);
|
|
151
|
+
if (!wanted?.title)
|
|
152
|
+
return null;
|
|
153
|
+
const blocks = markdown.split("\n\n");
|
|
154
|
+
const start = blocks.findIndex((block) => block.trim() === `## ${wanted.title}`);
|
|
155
|
+
if (start === -1)
|
|
156
|
+
return null;
|
|
157
|
+
const rest = blocks.slice(start + 1);
|
|
158
|
+
const nextHeading = rest.findIndex((block) => block.trim().startsWith("## "));
|
|
159
|
+
const end = nextHeading === -1 ? blocks.length : start + 1 + nextHeading;
|
|
160
|
+
return blocks.slice(start, end).join("\n\n").trim();
|
|
161
|
+
}
|
|
162
|
+
// ----------------------------------------------------------------- small parts
|
|
163
|
+
/** Greedy word wrap. A word longer than the column keeps its own line, uncut. */
|
|
164
|
+
export function wrap(text, width) {
|
|
165
|
+
const limit = Math.max(20, width);
|
|
166
|
+
const words = text.split(/\s+/u).filter(Boolean);
|
|
167
|
+
if (words.length === 0)
|
|
168
|
+
return [];
|
|
169
|
+
const lines = [];
|
|
170
|
+
let line = "";
|
|
171
|
+
for (const word of words) {
|
|
172
|
+
if (!line) {
|
|
173
|
+
line = word;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (line.length + 1 + word.length <= limit) {
|
|
177
|
+
line = `${line} ${word}`;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
lines.push(line);
|
|
181
|
+
line = word;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
lines.push(line);
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
/** Emphasis markers are Markdown's, not a reader's; plain text drops them. */
|
|
188
|
+
function plain(text) {
|
|
189
|
+
return text
|
|
190
|
+
.replace(/\*\*(.+?)\*\*/gu, "$1")
|
|
191
|
+
.replace(/\*(.+?)\*/gu, "$1")
|
|
192
|
+
.replace(/\\\|/gu, "|");
|
|
193
|
+
}
|
|
194
|
+
function dim(text) {
|
|
195
|
+
return `${DIM}${text}${RESET}`;
|
|
196
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit workbook` — the project document library, read in the terminal
|
|
3
|
+
* (BLI-3460).
|
|
4
|
+
*
|
|
5
|
+
* The documents are hardcoded prose (BLI-3247: "written, not compiled"), and
|
|
6
|
+
* the dashboard's `lib/workbook/to-markdown.ts` walks that TSX into Markdown
|
|
7
|
+
* without a browser. This command asks for that Markdown and lays it out for a
|
|
8
|
+
* width. It renders NOTHING of its own: no summarising, no reordering, no
|
|
9
|
+
* paraphrase. `--markdown` prints the server's bytes unchanged, which is the
|
|
10
|
+
* escape hatch for anything this layout does not suit — and the reason the
|
|
11
|
+
* layout is allowed to be opinionated.
|
|
12
|
+
*
|
|
13
|
+
* No pager. A pager owns the terminal, breaks piping, and behaves differently
|
|
14
|
+
* on the two supported host families; `cockpit workbook tower workbook | less`
|
|
15
|
+
* is the person's decision to make, not this command's.
|
|
16
|
+
*/
|
|
17
|
+
import { writeLine, writeRaw } from "./cli-io.js";
|
|
18
|
+
import { renderDocText, renderShelf, sectionSlice, } from "./workbook-render.js";
|
|
19
|
+
import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
|
|
20
|
+
const READ_DEADLINE_MS = 30_000;
|
|
21
|
+
/** The band a document reads well in. Narrower loses the table, wider loses the eye. */
|
|
22
|
+
const WIDTH_FLOOR = 60;
|
|
23
|
+
const WIDTH_CEILING = 100;
|
|
24
|
+
const WIDTH_FALLBACK = 80;
|
|
25
|
+
export async function runWorkbook(command, io) {
|
|
26
|
+
const session = await loadPairedSession("workbook", command.homeDir);
|
|
27
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
28
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
29
|
+
const width = resolveWidth(command.width, io);
|
|
30
|
+
const path = command.project && command.doc
|
|
31
|
+
? `/api/cockpit/workbook?project=${encodeURIComponent(command.project)}&doc=${encodeURIComponent(command.doc)}`
|
|
32
|
+
: "/api/cockpit/workbook";
|
|
33
|
+
const read = await towerJsonRequest({
|
|
34
|
+
dashboardUrl,
|
|
35
|
+
path,
|
|
36
|
+
method: "GET",
|
|
37
|
+
deviceToken: session.device_token,
|
|
38
|
+
fetch: io.fetch,
|
|
39
|
+
label: command.doc ? "workbook-doc" : "workbook-index",
|
|
40
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
41
|
+
log,
|
|
42
|
+
});
|
|
43
|
+
if (!read.ok) {
|
|
44
|
+
writeFailure(command, io, read);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
if (!command.doc) {
|
|
48
|
+
return writeIndex(command, io, read.body, width);
|
|
49
|
+
}
|
|
50
|
+
return writeDoc(command, io, read.body, width);
|
|
51
|
+
}
|
|
52
|
+
// -------------------------------------------------------------------- index
|
|
53
|
+
function writeIndex(command, io, payload, width) {
|
|
54
|
+
const projects = payload.projects ?? [];
|
|
55
|
+
if (command.project) {
|
|
56
|
+
const found = projects.find((project) => project.slug === command.project);
|
|
57
|
+
if (!found) {
|
|
58
|
+
const known = projects.map((project) => project.slug ?? "").filter(Boolean);
|
|
59
|
+
const sentence = `No project "${command.project}" in the workbook library (${known.join(", ") || "none"}).`;
|
|
60
|
+
writeLine(io.stderr, `[workbook cli] read missed ${JSON.stringify({
|
|
61
|
+
reason: "workbook_project_not_found",
|
|
62
|
+
projects: projects.length,
|
|
63
|
+
})}`);
|
|
64
|
+
if (command.json) {
|
|
65
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "workbook_project_not_found", detail: sentence }));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
writeLine(io.stderr, sentence);
|
|
69
|
+
}
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
if (command.json) {
|
|
73
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, project: found }));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
for (const line of renderShelf([found], width))
|
|
77
|
+
writeLine(io.stdout, line);
|
|
78
|
+
}
|
|
79
|
+
logIndexRead(io, [found]);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if (command.json) {
|
|
83
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, projects }));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
for (const line of renderShelf(projects, width))
|
|
87
|
+
writeLine(io.stdout, line);
|
|
88
|
+
}
|
|
89
|
+
logIndexRead(io, projects);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
function logIndexRead(io, projects) {
|
|
93
|
+
writeLine(io.stderr, `[workbook cli] index read ${JSON.stringify({
|
|
94
|
+
projects: projects.length,
|
|
95
|
+
docs: projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0),
|
|
96
|
+
})}`);
|
|
97
|
+
}
|
|
98
|
+
// --------------------------------------------------------------------- doc
|
|
99
|
+
function writeDoc(command, io, payload, width) {
|
|
100
|
+
const markdown = payload.markdown ?? "";
|
|
101
|
+
const sections = payload.sections ?? [];
|
|
102
|
+
let selected = markdown;
|
|
103
|
+
if (command.section) {
|
|
104
|
+
const slice = sectionSlice(markdown, sections, command.section);
|
|
105
|
+
if (slice === null) {
|
|
106
|
+
const known = sections.map((section) => section.id ?? "").filter(Boolean);
|
|
107
|
+
const sentence = `No section "${command.section}" in this document (${known.join(", ") || "none"}).`;
|
|
108
|
+
writeLine(io.stderr, `[workbook cli] section missed ${JSON.stringify({
|
|
109
|
+
reason: "workbook_section_not_found",
|
|
110
|
+
sections: sections.length,
|
|
111
|
+
})}`);
|
|
112
|
+
if (command.json) {
|
|
113
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "workbook_section_not_found", detail: sentence }));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
writeLine(io.stderr, sentence);
|
|
117
|
+
}
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
selected = slice;
|
|
121
|
+
}
|
|
122
|
+
if (command.json) {
|
|
123
|
+
writeLine(io.stdout, JSON.stringify({
|
|
124
|
+
ok: true,
|
|
125
|
+
project: payload.project ?? null,
|
|
126
|
+
doc: payload.doc ?? null,
|
|
127
|
+
markdown: selected,
|
|
128
|
+
sections,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
else if (command.markdown) {
|
|
132
|
+
// Verbatim: the bytes the renderer produced, plus the newline a terminal
|
|
133
|
+
// needs. Nothing is re-wrapped, re-cased, or re-ordered.
|
|
134
|
+
writeRaw(io.stdout, selected);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
for (const line of renderDocText(payload, selected, width))
|
|
138
|
+
writeLine(io.stdout, line);
|
|
139
|
+
}
|
|
140
|
+
writeLine(io.stderr, `[workbook cli] doc read ${JSON.stringify({
|
|
141
|
+
project: payload.project?.slug ?? null,
|
|
142
|
+
doc: payload.doc?.slug ?? null,
|
|
143
|
+
markdown_chars: selected.length,
|
|
144
|
+
sections: sections.length,
|
|
145
|
+
section_selected: command.section != null,
|
|
146
|
+
mode: command.json ? "json" : command.markdown ? "markdown" : "text",
|
|
147
|
+
width,
|
|
148
|
+
})}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
// ----------------------------------------------------------------- small parts
|
|
152
|
+
/**
|
|
153
|
+
* The wrap column: an explicit `--width` wins, otherwise the terminal's own
|
|
154
|
+
* width held between 60 and 100. A pipe with no width reported reads as 80,
|
|
155
|
+
* the width a terminal has when nobody has said otherwise.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveWidth(explicit, io) {
|
|
158
|
+
if (explicit !== undefined)
|
|
159
|
+
return explicit;
|
|
160
|
+
const columns = io.stdout.columns;
|
|
161
|
+
const reported = typeof columns === "number" && columns > 0 ? columns : WIDTH_FALLBACK;
|
|
162
|
+
return Math.min(WIDTH_CEILING, Math.max(WIDTH_FLOOR, reported));
|
|
163
|
+
}
|
|
164
|
+
function writeFailure(command, io, failure) {
|
|
165
|
+
const detail = towerFailureDetail(failure.reason, failure.detail);
|
|
166
|
+
writeLine(io.stderr, `[workbook cli] read failed ${JSON.stringify({
|
|
167
|
+
reason: failure.reason,
|
|
168
|
+
http_status: failure.httpStatus ?? null,
|
|
169
|
+
})}`);
|
|
170
|
+
if (command.json) {
|
|
171
|
+
writeLine(io.stdout, JSON.stringify({
|
|
172
|
+
ok: false,
|
|
173
|
+
error: failure.reason,
|
|
174
|
+
detail,
|
|
175
|
+
httpStatus: failure.httpStatus ?? null,
|
|
176
|
+
}));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
writeLine(io.stderr, `The workbook could not be read: ${detail}`);
|
|
180
|
+
}
|