@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,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one shared "call the dashboard as me" client (BLI-3457).
|
|
3
|
+
*
|
|
4
|
+
* Every command that speaks to Tower on the paired person's behalf — `jarvis`
|
|
5
|
+
* today, the rest of the CLI-parity surface next — needs the same three
|
|
6
|
+
* things: the paired device session, a request that carries its device token,
|
|
7
|
+
* and a failure that names itself instead of throwing a transport stack trace
|
|
8
|
+
* at a person. Before this module each command grew its own copy; the copies
|
|
9
|
+
* drifted (one normalised the dashboard URL, one did not) and only one of them
|
|
10
|
+
* ever named a reason.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Nothing here throws for a transport error. `towerRequest` returns
|
|
14
|
+
* `{ ok: false, reason }` for every network-shaped failure, and the reason
|
|
15
|
+
* label is the thing that travels to the operator.
|
|
16
|
+
* - `loadPairedSession` still throws, because "this machine is not paired" is
|
|
17
|
+
* not a transport failure — it is a precondition the caller cannot proceed
|
|
18
|
+
* past, and the command's top level turns it into one plain sentence.
|
|
19
|
+
* - The device token is attached to the request and never returned, logged, or
|
|
20
|
+
* put in a reason. Log lines here carry metadata only.
|
|
21
|
+
*
|
|
22
|
+
* The HTTP vocabulary (`normalizeDashboardUrl`, `readResponseJson`,
|
|
23
|
+
* `responseErrorMessage`) is reused from `upload-http.ts` rather than copied —
|
|
24
|
+
* the upload path and the conversation path answer to the same rules.
|
|
25
|
+
*/
|
|
26
|
+
import { errorMessage } from "./commands/cli-io.js";
|
|
27
|
+
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "./local-state.js";
|
|
28
|
+
import { normalizeDashboardUrl, readResponseJson, responseErrorMessage, } from "./upload-http.js";
|
|
29
|
+
const defaultLog = (line) => {
|
|
30
|
+
console.error(line);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Loads the paired device session, or explains in one sentence why it cannot.
|
|
34
|
+
*
|
|
35
|
+
* `commandName` names the caller so the sentence reads as the command a person
|
|
36
|
+
* actually typed ("`cockpit jarvis` needs a valid paired Tower session"),
|
|
37
|
+
* rather than the JARVIS-specific wording this used to be hard-coded to.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadPairedSession(commandName, homeDir) {
|
|
40
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
41
|
+
try {
|
|
42
|
+
const session = await readLocalCollectorSessionFile(paths);
|
|
43
|
+
if (session.session_state !== "valid") {
|
|
44
|
+
throw new Error(`session_${session.session_state}`);
|
|
45
|
+
}
|
|
46
|
+
return session;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`\`cockpit ${commandName}\` needs a valid paired Tower session. ` +
|
|
50
|
+
`Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Makes one authenticated request to the dashboard. Never throws.
|
|
55
|
+
*
|
|
56
|
+
* A 4xx/5xx still comes back as `{ ok: true, response }` — the status is the
|
|
57
|
+
* caller's to interpret, because several routes put a readable refusal in a
|
|
58
|
+
* non-2xx body. Only a request that produced no response at all is a failure
|
|
59
|
+
* here, and it always carries a reason label.
|
|
60
|
+
*/
|
|
61
|
+
export async function towerRequest(options) {
|
|
62
|
+
const log = options.log ?? defaultLog;
|
|
63
|
+
const startedAt = Date.now();
|
|
64
|
+
const url = `${normalizeDashboardUrl(options.dashboardUrl)}${options.path}`;
|
|
65
|
+
const isForm = typeof FormData !== "undefined" && options.body instanceof FormData;
|
|
66
|
+
const headers = {
|
|
67
|
+
...(options.headers ?? {}),
|
|
68
|
+
authorization: `Bearer ${options.deviceToken}`,
|
|
69
|
+
};
|
|
70
|
+
// fetch derives the multipart boundary from a FormData body; declaring the
|
|
71
|
+
// content-type by hand would strip it and the server would parse nothing.
|
|
72
|
+
if (options.body !== undefined && !isForm)
|
|
73
|
+
headers["content-type"] = "application/json";
|
|
74
|
+
const init = {
|
|
75
|
+
method: options.method ?? "POST",
|
|
76
|
+
headers,
|
|
77
|
+
...(options.body === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { body: (isForm ? options.body : JSON.stringify(options.body)) }),
|
|
80
|
+
...(timeoutSignal(options.timeoutMs) ?? {}),
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const response = await options.fetch(url, init);
|
|
84
|
+
log(`[tower client] responded ${JSON.stringify({
|
|
85
|
+
request: options.label,
|
|
86
|
+
http_status: response.status,
|
|
87
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
88
|
+
streamed: (response.headers.get("content-type") ?? "").includes("application/x-ndjson"),
|
|
89
|
+
elapsed_ms: Date.now() - startedAt,
|
|
90
|
+
})}`);
|
|
91
|
+
return { ok: true, response };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const reason = isAbortError(error)
|
|
95
|
+
? "turn_timed_out"
|
|
96
|
+
: "gateway_unreachable";
|
|
97
|
+
log(`[tower client] request failed ${JSON.stringify({
|
|
98
|
+
request: options.label,
|
|
99
|
+
reason,
|
|
100
|
+
elapsed_ms: Date.now() - startedAt,
|
|
101
|
+
})}`);
|
|
102
|
+
return { ok: false, reason, detail: towerFailureDetail(reason, errorMessage(error)) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `towerRequest` plus reading the body as JSON and turning a non-2xx into a
|
|
107
|
+
* named failure carrying the server's own words when it supplied any.
|
|
108
|
+
*/
|
|
109
|
+
export async function towerJsonRequest(options) {
|
|
110
|
+
const result = await towerRequest(options);
|
|
111
|
+
if (!result.ok)
|
|
112
|
+
return result;
|
|
113
|
+
const body = await readResponseJson(result.response);
|
|
114
|
+
if (!result.response.ok) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: "http_error",
|
|
118
|
+
httpStatus: result.response.status,
|
|
119
|
+
detail: responseErrorMessage(body, `http_${result.response.status}`),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, httpStatus: result.response.status, body };
|
|
123
|
+
}
|
|
124
|
+
/** One plain sentence per reason, so no caller has to invent its own wording. */
|
|
125
|
+
export function towerFailureDetail(reason, detail) {
|
|
126
|
+
switch (reason) {
|
|
127
|
+
case "gateway_unreachable":
|
|
128
|
+
return `Tower could not be reached (${detail}).`;
|
|
129
|
+
case "turn_timed_out":
|
|
130
|
+
return "Tower did not finish in time, so the answer is incomplete. Ask again.";
|
|
131
|
+
case "http_error":
|
|
132
|
+
return detail;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function isAbortError(error) {
|
|
136
|
+
if (!error || typeof error !== "object")
|
|
137
|
+
return false;
|
|
138
|
+
const name = error.name;
|
|
139
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A deadline when the runtime has one. `AbortSignal.timeout` exists on Node 18+
|
|
143
|
+
* on both supported host families; the guard is here so a stripped runtime
|
|
144
|
+
* loses the ceiling rather than the whole request.
|
|
145
|
+
*/
|
|
146
|
+
function timeoutSignal(timeoutMs) {
|
|
147
|
+
if (!timeoutMs || typeof AbortSignal?.timeout !== "function")
|
|
148
|
+
return null;
|
|
149
|
+
return { signal: AbortSignal.timeout(timeoutMs) };
|
|
150
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a Tower turn as it happens (BLI-3457).
|
|
3
|
+
*
|
|
4
|
+
* The dashboard's conversation routes speak one JSON object per line
|
|
5
|
+
* (`application/x-ndjson`): `activity` events as tools start and settle, then
|
|
6
|
+
* exactly one `final` event carrying what the plain JSON response used to be.
|
|
7
|
+
* That is the same wire format the browser already reads
|
|
8
|
+
* (`apps/dashboard/src/lib/webchat/tool-trace.ts`); this is the terminal's
|
|
9
|
+
* reader for it.
|
|
10
|
+
*
|
|
11
|
+
* Three things it refuses to do:
|
|
12
|
+
* - Throw. A stream that dies mid-turn comes back as a named reason, never an
|
|
13
|
+
* exception in the middle of a person's answer.
|
|
14
|
+
* - Present a truncated turn as a complete one. No `final` event means the
|
|
15
|
+
* turn failed, and it says which way it failed
|
|
16
|
+
* (`turn_timed_out` when the dashboard's own 120s ceiling is the likely
|
|
17
|
+
* cause, `stream_ended_without_final` otherwise).
|
|
18
|
+
* - Require the stream. A dashboard that has not shipped streaming yet answers
|
|
19
|
+
* `application/json`; that single object is read as the final event and the
|
|
20
|
+
* fallback is named `stream_not_available` rather than passed off as a
|
|
21
|
+
* stream that happened to be quiet.
|
|
22
|
+
*
|
|
23
|
+
* A malformed line is skipped, named `ndjson_line_unparseable`, and the lines
|
|
24
|
+
* after it still count — one bad byte cannot swallow an answer.
|
|
25
|
+
*/
|
|
26
|
+
import { errorMessage } from "./commands/cli-io.js";
|
|
27
|
+
import { isAbortError } from "./tower-client.js";
|
|
28
|
+
/**
|
|
29
|
+
* The dashboard caps a turn at 120s (`maxDuration = 120`). A stream that stops
|
|
30
|
+
* without a final event anywhere near that mark was almost certainly cut off by
|
|
31
|
+
* the platform, and saying `turn_timed_out` is the honest label; earlier than
|
|
32
|
+
* that, the stream broke for some other reason and gets its own.
|
|
33
|
+
*/
|
|
34
|
+
const CEILING_HINT_MS = 110_000;
|
|
35
|
+
/**
|
|
36
|
+
* Yields every event on the response, then stops at the first `final`.
|
|
37
|
+
*
|
|
38
|
+
* Handles both wire shapes: real NDJSON, and a plain `application/json` body
|
|
39
|
+
* from a dashboard that does not stream yet (yielded as the final event, after
|
|
40
|
+
* a `stream_not_available` note).
|
|
41
|
+
*/
|
|
42
|
+
export async function* readTowerStream(response) {
|
|
43
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
44
|
+
if (!contentType.includes("application/x-ndjson")) {
|
|
45
|
+
yield* readPlainJsonBody(response);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let buffer = "";
|
|
49
|
+
try {
|
|
50
|
+
for await (const chunk of iterateBody(response)) {
|
|
51
|
+
buffer += chunk;
|
|
52
|
+
const lines = buffer.split("\n");
|
|
53
|
+
buffer = lines.pop() ?? "";
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const item = parseLine(line);
|
|
56
|
+
if (!item)
|
|
57
|
+
continue;
|
|
58
|
+
yield item;
|
|
59
|
+
if (item.kind === "event" && item.event.type === "final")
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
yield {
|
|
66
|
+
kind: "note",
|
|
67
|
+
reason: isAbortError(error) ? "turn_timed_out" : "stream_read_failed",
|
|
68
|
+
detail: errorMessage(error),
|
|
69
|
+
};
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Whatever was still in flight when the body ended.
|
|
73
|
+
const tail = parseLine(buffer);
|
|
74
|
+
if (tail)
|
|
75
|
+
yield tail;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The whole turn in one call: renders activity as it arrives, buffers the
|
|
79
|
+
* final, and names any way the turn failed to produce one.
|
|
80
|
+
*
|
|
81
|
+
* `onActivity` is where a caller draws its live trace. `onNote` sees every
|
|
82
|
+
* named oddity (a skipped line, a non-streaming dashboard) so nothing is
|
|
83
|
+
* silently absorbed — the caller decides whether that is a log line or a
|
|
84
|
+
* sentence.
|
|
85
|
+
*/
|
|
86
|
+
export async function readTowerTurn(response, options = {}) {
|
|
87
|
+
const startedAt = options.startedAt ?? Date.now();
|
|
88
|
+
const activity = [];
|
|
89
|
+
const notes = [];
|
|
90
|
+
let streamed = true;
|
|
91
|
+
let final = null;
|
|
92
|
+
for await (const item of readTowerStream(response)) {
|
|
93
|
+
if (item.kind === "note") {
|
|
94
|
+
if (item.reason === "stream_not_available")
|
|
95
|
+
streamed = false;
|
|
96
|
+
notes.push({ reason: item.reason, detail: item.detail });
|
|
97
|
+
options.onNote?.(item.reason, item.detail);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (item.event.type === "final") {
|
|
101
|
+
final = item.event;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
activity.push(item.event);
|
|
105
|
+
options.onActivity?.(item.event);
|
|
106
|
+
}
|
|
107
|
+
const elapsedMs = Date.now() - startedAt;
|
|
108
|
+
if (!final) {
|
|
109
|
+
// The last named note is the truest reason we have; absent one, a stream
|
|
110
|
+
// that simply stopped near the dashboard's ceiling is a timeout.
|
|
111
|
+
const terminal = notes.at(-1);
|
|
112
|
+
const reason = terminal && terminal.reason !== "stream_not_available"
|
|
113
|
+
? terminal.reason
|
|
114
|
+
: elapsedMs >= CEILING_HINT_MS
|
|
115
|
+
? "turn_timed_out"
|
|
116
|
+
: "stream_ended_without_final";
|
|
117
|
+
options.log?.(`[tower stream] turn incomplete ${JSON.stringify({
|
|
118
|
+
reason,
|
|
119
|
+
activity_events: activity.length,
|
|
120
|
+
notes: notes.length,
|
|
121
|
+
elapsed_ms: elapsedMs,
|
|
122
|
+
})}`);
|
|
123
|
+
return { ok: false, reason, detail: streamFailureDetail(reason, terminal?.detail) };
|
|
124
|
+
}
|
|
125
|
+
options.log?.(`[tower stream] turn complete ${JSON.stringify({
|
|
126
|
+
streamed,
|
|
127
|
+
activity_events: activity.length,
|
|
128
|
+
notes: notes.length,
|
|
129
|
+
elapsed_ms: elapsedMs,
|
|
130
|
+
})}`);
|
|
131
|
+
return { ok: true, final, activity, streamed };
|
|
132
|
+
}
|
|
133
|
+
/** One plain sentence per way a turn can end without an answer. */
|
|
134
|
+
export function streamFailureDetail(reason, detail) {
|
|
135
|
+
switch (reason) {
|
|
136
|
+
case "turn_timed_out":
|
|
137
|
+
return "Tower ran out of time before finishing that turn, so the answer is incomplete. Ask again.";
|
|
138
|
+
case "stream_ended_without_final":
|
|
139
|
+
return "Tower stopped sending mid-answer, so the answer is incomplete. Ask again.";
|
|
140
|
+
case "stream_read_failed":
|
|
141
|
+
return `The connection to Tower broke mid-answer${detail ? ` (${detail})` : ""}. Ask again.`;
|
|
142
|
+
case "response_body_not_json":
|
|
143
|
+
return "Tower answered with something that was not JSON.";
|
|
144
|
+
case "response_body_empty":
|
|
145
|
+
return "Tower answered with an empty body.";
|
|
146
|
+
default:
|
|
147
|
+
return detail ?? reason;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A dashboard that does not stream yet. The whole body is one JSON object; it
|
|
152
|
+
* becomes the final event so every caller has exactly one code path.
|
|
153
|
+
*/
|
|
154
|
+
async function* readPlainJsonBody(response) {
|
|
155
|
+
yield {
|
|
156
|
+
kind: "note",
|
|
157
|
+
reason: "stream_not_available",
|
|
158
|
+
detail: response.headers.get("content-type") ?? "none",
|
|
159
|
+
};
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = await response.text();
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
yield {
|
|
166
|
+
kind: "note",
|
|
167
|
+
reason: isAbortError(error) ? "turn_timed_out" : "stream_read_failed",
|
|
168
|
+
detail: errorMessage(error),
|
|
169
|
+
};
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!text.trim()) {
|
|
173
|
+
yield { kind: "note", reason: "response_body_empty" };
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
yield { kind: "note", reason: "response_body_not_json" };
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
185
|
+
yield { kind: "note", reason: "response_body_not_json" };
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
yield {
|
|
189
|
+
kind: "event",
|
|
190
|
+
event: { ...parsed, type: "final" },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** One NDJSON line: an event, a named skip, or nothing at all for blank lines. */
|
|
194
|
+
function parseLine(line) {
|
|
195
|
+
const trimmed = line.trim();
|
|
196
|
+
if (!trimmed)
|
|
197
|
+
return null;
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(trimmed);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// The line text is never logged: it is conversation content.
|
|
204
|
+
return { kind: "note", reason: "ndjson_line_unparseable" };
|
|
205
|
+
}
|
|
206
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
207
|
+
return { kind: "note", reason: "unexpected_event_type" };
|
|
208
|
+
}
|
|
209
|
+
const type = parsed.type;
|
|
210
|
+
if (type !== "activity" && type !== "final") {
|
|
211
|
+
return { kind: "note", reason: "unexpected_event_type" };
|
|
212
|
+
}
|
|
213
|
+
return { kind: "event", event: parsed };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Decoded text chunks from a response body.
|
|
217
|
+
*
|
|
218
|
+
* Node's web `ReadableStream` is async-iterable on both supported host
|
|
219
|
+
* families (macOS and Windows, Node 20+), and that is the fast path. The
|
|
220
|
+
* `getReader()` fallback is there because a test double or a polyfilled fetch
|
|
221
|
+
* may hand back a stream without the iterator, and losing the answer over an
|
|
222
|
+
* iteration protocol would be absurd.
|
|
223
|
+
*/
|
|
224
|
+
async function* iterateBody(response) {
|
|
225
|
+
const body = response.body;
|
|
226
|
+
if (!body) {
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
if (text)
|
|
229
|
+
yield text;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const decoder = new TextDecoder();
|
|
233
|
+
const asyncIterable = body;
|
|
234
|
+
if (typeof asyncIterable[Symbol.asyncIterator] === "function") {
|
|
235
|
+
for await (const chunk of asyncIterable) {
|
|
236
|
+
yield decoder.decode(chunk, { stream: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const reader = body.getReader();
|
|
241
|
+
while (true) {
|
|
242
|
+
const { done, value } = await reader.read();
|
|
243
|
+
if (done)
|
|
244
|
+
break;
|
|
245
|
+
if (value)
|
|
246
|
+
yield decoder.decode(value, { stream: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const rest = decoder.decode();
|
|
250
|
+
if (rest)
|
|
251
|
+
yield rest;
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.39",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"pretypecheck": "npm run build",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "npm run build",
|
|
27
|
-
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
|
+
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/telemetry-core": "0.1.25"
|