From 9494816c3a1f4c1a873ca7116b5a1214fa9827e0 Mon Sep 17 00:00:00 2001 From: crowlbot <280062030+crowlbot@users.noreply.github.com> Date: Wed, 13 May 2026 13:31:26 +0000 Subject: [PATCH] feat: emit JSON for env list, database list/query, and logs (NDJSON) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the agent-ergonomics series. Wires `--json` (from #91) into the list/inspect commands that an agent would normally pipe into `jq`: - `env list --json` — array of `{ id, key, value, isSecret, contexts }`. Secret values are emitted as `null` rather than the `***` placeholder so agents can tell "secret" from "literally three asterisks". Contexts are resolved to names. - `database list --json` — array of database instances with `name`, `engine`, `createdAt`, `assignments` (app slugs), `connection` (the safe-to-display fields), and a nested `databases` array. - `database query --json` — `{ rows: [...] }` on success; `{ error: { code, message, ... } }` (via the existing error envelope) with errorCode `POSTGRES_ERROR` or `QUERY_ERROR` on failure. - `logs --json` — NDJSON: one log record per line on stdout, fields flattened to lowerCamelCase (`timestamp`, `traceId`, `severity`, `severityNumber`, `body`, `scope`, `revision`, `attributes`). Pipes cleanly into `jq -c .` or `grep -F`. The "connected, streaming logs" preamble is suppressed. Human output is unchanged when `--json` is not set. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/database.ts | 37 ++++++++++++++++++++++++++++++++++++- deploy/env.ts | 22 +++++++++++++++++++++- deploy/mod.ts | 22 +++++++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/deploy/database.ts b/deploy/database.ts index f616191..eb823b2 100644 --- a/deploy/database.ts +++ b/deploy/database.ts @@ -1,6 +1,11 @@ import { Command, ValidationError } from "@cliffy/command"; import { createTrpcClient } from "../auth.ts"; -import { error, renderTemporalTimestamp, tablePrinter } from "../util.ts"; +import { + error, + renderTemporalTimestamp, + tablePrinter, + writeJsonResult, +} from "../util.ts"; import { green } from "@std/fmt/colors"; import type { GlobalContext } from "../main.ts"; import { parse as parseConnectionString } from "pg-connection-string"; @@ -244,6 +249,20 @@ const databasesQueryCommand = new Command() array: false, }); + if (options.json) { + if (res.kind === "ok") { + writeJsonResult({ rows: res.rows ?? [] }); + return; + } + if (res.kind === "postgres_error") { + error(options, res.error, { errorCode: "POSTGRES_ERROR" }); + } + if (res.error) { + error(options, res.message, { errorCode: "QUERY_ERROR" }); + } + return; + } + if (res.kind === "ok") { if ( Array.isArray(res.rows) && res.rows.length > 0 && @@ -318,6 +337,22 @@ const databasesListCommand = new Command() } & ConnectionInfo >; + if (options.json) { + writeJsonResult(list.map((database) => ({ + name: database.slug, + engine: database.engine, + createdAt: database.created_at, + assignments: database.assignments.map((a) => a.app_slug), + connection: database.safeConnectionConfig, + databases: database.databases.map((db) => ({ + name: db.name, + status: db.status, + createdAt: db.created_at, + })), + }))); + return; + } + tablePrinter( ["NAME", "ENGINE", "ASSIGNMENTS", "CONNECTION DETAILS"], list, diff --git a/deploy/env.ts b/deploy/env.ts index 1d95abf..08d1b81 100644 --- a/deploy/env.ts +++ b/deploy/env.ts @@ -1,6 +1,11 @@ import { Command } from "@cliffy/command"; import { parse as dotEnvParse } from "@std/dotenv"; -import { error, isNonInteractive, tablePrinter } from "../util.ts"; +import { + error, + isNonInteractive, + tablePrinter, + writeJsonResult, +} from "../util.ts"; import { green } from "@std/fmt/colors"; import { createTrpcClient } from "../auth.ts"; import type { GlobalContext } from "../main.ts"; @@ -42,6 +47,21 @@ const envListCommand = new Command() { org }, ) as Context[]; + if (options.json) { + writeJsonResult(envVars.map((envVar) => ({ + id: envVar.id, + key: envVar.key, + value: envVar.is_secret ? null : envVar.value, + isSecret: envVar.is_secret, + contexts: envVar.context_ids + ? envVar.context_ids.map((id) => + contexts.find((c) => c.id === id)?.name ?? id + ) + : null, + }))); + return; + } + if (envVars.length === 0) { console.log( "There are no environment variables set on this application.", diff --git a/deploy/mod.ts b/deploy/mod.ts index 21050d7..ad17a25 100644 --- a/deploy/mod.ts +++ b/deploy/mod.ts @@ -146,6 +146,7 @@ const logsCommand = new Command() const seenIds = new Set(); let onceConnected = false; + const encoder = new TextEncoder(); const sub = trpcClient.subscription( "apps.logs", { @@ -160,7 +161,7 @@ const logsCommand = new Command() onData: (data: unknown) => { const typedData = data as "streaming" | null | LogEntry[]; if (typedData === "streaming") { - if (!onceConnected && !options.quiet) { + if (!onceConnected && !options.quiet && !options.json) { console.log("connected, streaming logs..."); } onceConnected = true; @@ -174,6 +175,25 @@ const logsCommand = new Command() seenIds.add(id); } + if (options.json) { + // NDJSON: one record per line on stdout, severity preserved as + // a numeric field so agents can filter without re-parsing. + Deno.stdout.writeSync(encoder.encode( + JSON.stringify({ + timestamp: log.Timestamp, + traceId: log.TraceId || null, + spanId: log.SpanId || null, + severity: log.SeverityText, + severityNumber: log.SeverityNumber, + body: log.Body, + scope: log.ScopeName, + revision: log.Revision, + attributes: log.LogAttributes, + }) + "\n", + )); + continue; + } + const prefix = `[${renderTemporalTimestamp(log.Timestamp)}${ log.TraceId ? ` (${log.TraceId})` : "" }]`;