@bli-cockpit/cli 0.2.173 → 0.2.174

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.
@@ -3,28 +3,44 @@
3
3
  *
4
4
  * cockpit logs <source> [--since 15m|1h|2d|<iso>] [--raw <template-id>] [--limit <n>]
5
5
  * [--all-levels] [--table <file>] [--json]
6
+ * cockpit logs <project> [--since 24h] [--json] (BLI-12855: stored digests)
7
+ * cockpit logs dashboard|railway --stored [--since 1h] (Tower's stored digests)
8
+ * cockpit logs --list (the projects Tower digests)
6
9
  *
7
10
  * <source> is `collector`, `dashboard`, `railway`, `railway:<service>` or `-`
8
- * (lines on stdin). Read-only: the digest is computed on this machine from the
9
- * source's own CLI or files; nothing is sent to Tower and no dashboard is
10
- * called, so there is no `--dashboard-url`.
11
+ * (lines on stdin): read-only, computed on this machine from the source's own CLI
12
+ * or files, nothing sent to Tower. Or <source> is a registry project id (`kolm`,
13
+ * `tower`...): then the digest is the one Tower's own cron stored, read through
14
+ * the dashboard's `/api/logs` door with this machine's device token, so it works
15
+ * with no `vercel` or `railway` login at all.
11
16
  */
12
- import { optionalNonEmpty, optionalPositiveInteger, parseNamedArgs } from "./local-arg-values.js";
17
+ import { LOG_PROJECT_PATTERN } from "@bli-cockpit/telemetry-core/log-digest";
18
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
13
19
  /** The source spellings, for the usage line and the refusal. `railway:<service>` takes any service name. */
14
- export const LOGS_SOURCES = ["collector", "dashboard", "railway", "railway:<service>", "-"];
15
- const SOURCE_PATTERN = /^(collector|dashboard|railway|railway:[A-Za-z0-9][A-Za-z0-9._-]{0,63}|-)$/;
20
+ export const LOGS_SOURCES = ["collector", "dashboard", "railway", "railway:<service>", "-", "<project>"];
21
+ /** The spellings that read a local log; every other name is a registry project (stored digests). */
22
+ const LOCAL_SOURCE_PATTERN = /^(collector|dashboard|railway|railway:[A-Za-z0-9][A-Za-z0-9._-]{0,63}|-)$/;
16
23
  const SINCE_PATTERN = /^(\d{1,4}[smhd]|\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?)$/;
17
24
  export function parseLogsArgs(args) {
18
25
  const values = parseNamedArgs(args, {
19
- allowedFlags: ["--home", "--since", "--raw", "--limit", "--table", "--all-levels", "--json"],
20
- valueFlags: ["--home", "--since", "--raw", "--limit", "--table"],
26
+ allowedFlags: ["--home", "--since", "--raw", "--limit", "--table", "--all-levels", "--stored", "--list", "--dashboard-url", "--json"],
27
+ valueFlags: ["--home", "--since", "--raw", "--limit", "--table", "--dashboard-url"],
21
28
  });
22
- const source = values.positionals[0];
23
- if (!source || !SOURCE_PATTERN.test(source)) {
24
- throw new Error(`logs needs a source: ${LOGS_SOURCES.join(", ")}. Example: \`cockpit logs dashboard --since 1h\`.`);
29
+ const list = values.booleans.has("--list");
30
+ const source = values.positionals[0] ?? (list ? "tower" : undefined);
31
+ if (!source || (!LOCAL_SOURCE_PATTERN.test(source) && !LOG_PROJECT_PATTERN.test(source))) {
32
+ throw new Error(`logs needs a source: ${LOGS_SOURCES.join(", ")}. Example: \`cockpit logs dashboard --since 1h\` or \`cockpit logs kolm --since 24h\`.`);
25
33
  }
26
34
  if (values.positionals.length > 1)
27
35
  throw new Error("logs takes one source; put the window in --since.");
36
+ const isProject = !LOCAL_SOURCE_PATTERN.test(source);
37
+ const stored = isProject || values.booleans.has("--stored");
38
+ if (values.booleans.has("--stored") && !["dashboard", "railway"].includes(source) && !isProject) {
39
+ throw new Error("--stored reads Tower's stored digests: use it with dashboard or railway, or name a project (`cockpit logs kolm`).");
40
+ }
41
+ if (stored && (values.flags.get("--raw") || values.flags.get("--table") || values.booleans.has("--all-levels"))) {
42
+ throw new Error("a stored digest keeps no raw line, and its classes were set when it was stored: --raw, --table and --all-levels read a local log.");
43
+ }
28
44
  const since = optionalNonEmpty(values.flags.get("--since"));
29
45
  if (since && !SINCE_PATTERN.test(since)) {
30
46
  throw new Error(`--since wants 15m, 1h, 2d or an ISO time like 2026-10-07T03:00Z; got ${JSON.stringify(since.slice(0, 40))}.`);
@@ -47,6 +63,9 @@ export function parseLogsArgs(args) {
47
63
  ...(rawLimit === undefined ? {} : { rawLimit }),
48
64
  ...(values.flags.get("--table") ? { tableFile: values.flags.get("--table") } : {}),
49
65
  allLevels: values.booleans.has("--all-levels"),
66
+ stored,
67
+ list,
68
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
50
69
  homeDir: optionalNonEmpty(values.flags.get("--home")),
51
70
  json: values.booleans.has("--json"),
52
71
  };
@@ -13,9 +13,9 @@ export const TOWER_OPS_COMMAND_HELP = [
13
13
  [
14
14
  "logs",
15
15
  [
16
- "Usage: cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
16
+ "Usage: cockpit logs <collector|dashboard|railway|railway:<service>|-|project> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--stored] [--list] [--json]",
17
17
  "",
18
- "A token-cheap digest of Tower's own logs, read on THIS machine (nothing is uploaded). Variable parts of",
18
+ "A token-cheap digest of Tower's own logs, read on THIS machine (nothing is uploaded) unless a project is named. Variable parts of",
19
19
  "each line are masked into a template, equal templates are counted, routine runs become counts, and every",
20
20
  "template is classified: our_bug and unknown first, then capacity, config_drift, rate_pushback, hiccup,",
21
21
  "and one COUNTED line for dead_source and benign (counted, never alerted). About 2 KB.",
@@ -27,10 +27,18 @@ export const TOWER_OPS_COMMAND_HELP = [
27
27
  " railway:<service> one service (ladder, runner, linear-sync, ...). Project and environment are always passed",
28
28
  " explicitly: this repo's `railway link` points at the runner service.",
29
29
  " - lines on stdin: plain text, `journalctl -o json`, `railway logs --json` or `vercel logs --json`.",
30
+ " <project> a BLI project (tower, kolm, portpal, pearl-portal, triage-crm, ...; `--list` names them): the digest Tower's",
31
+ " own 15-minute cron STORED from that project's Vercel, Railway and Supabase logs, merged over --since",
32
+ " (default 24h, at most 30d), real bugs first. Needs only this machine's Tower login: no vercel or railway",
33
+ " login. It keeps no raw line, so --raw, --table and --all-levels do not apply.",
34
+ "--stored dashboard or railway read Tower's stored digest of project `tower` (this also happens by itself, and says so,",
35
+ " when the local vercel or railway CLI is missing or logged out).",
36
+ "--list every project, every source, and which are wired or not_wired (with the reason).",
30
37
  "--raw <template-id> the original lines behind one template id from a digest (redacted, newest last; --limit n, default 20).",
31
38
  "--table <file> a class table JSON (same shape as the packaged one) to try a new pattern before committing it.",
32
39
  "A source that cannot be read is a named refusal, never a stack trace: vercel_cli_missing, vercel_not_logged_in,",
33
- "railway_cli_missing, railway_not_logged_in, railway_service_unknown, collector_logs_missing, stdin_is_terminal, ...",
40
+ "railway_cli_missing, railway_not_logged_in, railway_service_unknown, collector_logs_missing, stdin_is_terminal, and for a",
41
+ "project unknown_project, since_exceeds_retention, stored_read_failed, not_paired, ...",
34
42
  "--json writes one object to stdout: { ok, kind, source, window, lines, templates[], routine, notes, text, measure };",
35
43
  "a refusal is { ok: false, error, detail } and exits 1. Run list and teaching: docs/runbooks/tower-logs.md.",
36
44
  "The bli-tower MCP server has the twin: logs_digest.",
@@ -0,0 +1,56 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ const TAG = "[logs cli]";
4
+ const READ_DEADLINE_MS = 55_000;
5
+ /** The project a stored read is for: the named project, or `tower` for `dashboard` and `railway`. */
6
+ export function storedProject(command) {
7
+ if (!command.stored)
8
+ return null;
9
+ return command.source === "dashboard" || command.source === "railway" ? "tower" : command.source;
10
+ }
11
+ export async function runStoredLogs(command, io, options) {
12
+ let door;
13
+ try {
14
+ door = await openAgentDoor("logs", command, io);
15
+ }
16
+ catch (error) {
17
+ const detail = error instanceof Error ? error.message : "this machine is not paired with Tower";
18
+ if (options.onNotPaired)
19
+ return options.onNotPaired(detail);
20
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "not_paired" })}`);
21
+ if (command.json)
22
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail }));
23
+ else
24
+ writeLine(io.stderr, detail);
25
+ return 1;
26
+ }
27
+ if (command.list) {
28
+ const answer = await askAgentDoor(door, { path: "/api/logs?list=1", method: "GET", label: "logs list", timeoutMs: READ_DEADLINE_MS });
29
+ if (!answer.ok)
30
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
31
+ const body = answer.body;
32
+ if (command.json)
33
+ return emitAgentDoor(door, body);
34
+ for (const project of body.projects) {
35
+ writeLine(io.stdout, `${project.id} ${project.name}`);
36
+ for (const source of project.sources) {
37
+ writeLine(io.stdout, ` ${source.status === "wired" ? "wired " : "not_wired"} ${source.source}${source.reason ? `: ${source.reason}` : ""}`);
38
+ }
39
+ }
40
+ return 0;
41
+ }
42
+ const query = new URLSearchParams({ project: options.project });
43
+ if (command.since)
44
+ query.set("since", command.since);
45
+ const answer = await askAgentDoor(door, { path: `/api/logs?${query.toString()}`, method: "GET", label: "logs", timeoutMs: READ_DEADLINE_MS });
46
+ if (!answer.ok)
47
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
48
+ const body = answer.body;
49
+ writeLine(io.stderr, `${TAG} stored digest read ${JSON.stringify({ project: body.project, windows: body.windows, windows_ok: body.windows_ok, refusals: body.refusals.length, fallback: Boolean(options.fallbackReason) })}`);
50
+ if (command.json)
51
+ return emitAgentDoor(door, options.fallbackReason ? { ...body, fallback_from: options.fallbackReason } : body);
52
+ if (options.fallbackReason)
53
+ writeLine(io.stdout, `(no local ${command.source} access: ${options.fallbackReason}; this is the digest Tower stored)`);
54
+ writeLine(io.stdout, body.text);
55
+ return 0;
56
+ }
@@ -4,7 +4,8 @@
4
4
  * Read-only and local: the source's own CLI or files are read on THIS machine,
5
5
  * masked into templates, counted and classified by `@bli-cockpit/telemetry-core/log-digest`,
6
6
  * and printed in about 2 KB with real bugs (`our_bug`, `unknown`) first. Nothing
7
- * is uploaded and no Tower door is called. The MCP server's `logs_digest` tool
7
+ * is uploaded and no Tower door is called (the stored half, `logs-stored.ts`,
8
+ * is the one exception: a project name reads what Tower's cron stored). The MCP server's `logs_digest` tool
8
9
  * calls the same `digestSource`, so the two print the same thing.
9
10
  *
10
11
  * A source that cannot be read is a NAMED refusal (`vercel_cli_missing`,
@@ -12,9 +13,10 @@
12
13
  * detail }` on stdout under `--json`, exit 1. Never a stack trace.
13
14
  */
14
15
  import { readFile } from "node:fs/promises";
15
- import { ClassTable, digestSource } from "@bli-cockpit/telemetry-core/log-digest";
16
+ import { ClassTable, LOCAL_ACCESS_REFUSALS, digestSource } from "@bli-cockpit/telemetry-core/log-digest";
16
17
  import { getCollectorRuntimePaths } from "../local-state-paths.js";
17
18
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
19
+ import { runStoredLogs, storedProject } from "./logs-stored.js";
18
20
  const TAG = "[logs cli]";
19
21
  const MAX_STDIN_CHARS = 64 * 1024 * 1024;
20
22
  function refuse(io, json, reason, detail) {
@@ -26,6 +28,10 @@ function refuse(io, json, reason, detail) {
26
28
  return 1;
27
29
  }
28
30
  export async function runLogs(command, io, deps = {}) {
31
+ // BLI-12855: a project name (or --stored, or --list) reads the digests Tower's cron stored, not a local log.
32
+ const stored = storedProject(command);
33
+ if (command.list || stored)
34
+ return runStoredLogs(command, io, { project: stored ?? "tower" });
29
35
  const paths = getCollectorRuntimePaths(command.homeDir);
30
36
  let table;
31
37
  if (command.tableFile) {
@@ -61,6 +67,16 @@ export async function runLogs(command, io, deps = {}) {
61
67
  nowMs: deps.nowMs,
62
68
  log: (message, fields) => writeLine(io.stderr, `${message} ${JSON.stringify(fields)}`),
63
69
  });
70
+ if (!outcome.ok && LOCAL_ACCESS_REFUSALS.has(outcome.reason) && (command.source === "dashboard" || command.source === "railway") && !command.raw) {
71
+ // No local access to the platform: say so, then answer from what the cron stored (a refusal of its own if this machine is not paired).
72
+ writeLine(io.stderr, `${TAG} local source unreadable, reading the stored digest ${JSON.stringify({ reason: outcome.reason })}`);
73
+ return runStoredLogs(command, io, {
74
+ project: "tower",
75
+ fallbackReason: outcome.reason,
76
+ // The person asked for a local read: if the stored digest is out of reach too, the local refusal is the answer.
77
+ onNotPaired: () => refuse(io, command.json, outcome.reason, `${outcome.detail} This machine is not paired with Tower (cockpit login), so the stored digest could not be read either.`),
78
+ });
79
+ }
64
80
  if (!outcome.ok)
65
81
  return refuse(io, command.json, outcome.reason, outcome.detail);
66
82
  writeLine(io.stdout, command.json ? JSON.stringify(outcome) : outcome.text);
@@ -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.173");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.174");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.173",
3
+ "version": "0.2.174",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.47",
31
- "@bli-cockpit/mcp": "0.1.89",
32
- "@bli-cockpit/telemetry-core": "0.1.59"
30
+ "@bli-cockpit/memory-mcp": "0.1.48",
31
+ "@bli-cockpit/mcp": "0.1.90",
32
+ "@bli-cockpit/telemetry-core": "0.1.60"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",