diff --git a/config.ts b/config.ts index b885639..ad83529 100644 --- a/config.ts +++ b/config.ts @@ -199,7 +199,11 @@ export function actionHandler< } this.configSaved = true; - if (this.doNotCreate && !config) { + // `config` is always a truthy wrapper object; `config.config` is the + // existing config file (if any). When a command opted out of file + // creation, skip writing only when there is no file to update — i.e. + // never create a new deno.jsonc as a side effect. + if (this.doNotCreate && !config.config) { return Promise.resolve(); } diff --git a/deploy/mod.ts b/deploy/mod.ts index a5f9b7a..abe395d 100644 --- a/deploy/mod.ts +++ b/deploy/mod.ts @@ -2,6 +2,7 @@ import { Command, ValidationError } from "@cliffy/command"; import { green, red, setColorEnabled, yellow } from "@std/fmt/colors"; import { error, + ExitCode, renderTemporalTimestamp, tablePrinter, writeJsonResult, @@ -124,6 +125,10 @@ const logsCommand = new Command() .option("--end ", "The ending timestamp of the logs", { depends: ["start"], }) + .option( + "--once", + "Capture currently-available (backfilled) logs, then exit instead of tailing live. Bounded, non-interactive capture for CI/agents; defaults to the last hour, widen with --start.", + ) .example( "Stream live logs", "logs --app my-app", @@ -132,7 +137,15 @@ const logsCommand = new Command() "View logs from a specific time", "logs --app my-app --start '2025-01-01T00:00:00Z'", ) + .example( + "Capture current logs then exit (CI/agents)", + "logs --app my-app --once --json --non-interactive", + ) .action(actionHandler(async (config, options) => { + // `logs` is read-only: like the other inspection commands it must not + // create a deno.jsonc as a side effect (which would also print a non-JSON + // "Created configuration file" line on stdout, breaking --json output). + config.noCreate(); const org = await getOrg(options, config, options.org); const { app } = await getApp(options, config, false, org, options.app); @@ -154,14 +167,25 @@ const logsCommand = new Command() const seenIds = new Set(); let onceConnected = false; + // In --once mode we drain the backfill window and exit at the live boundary + // instead of tailing forever. Default that window to the last hour when no + // explicit --start is given, so the bounded capture actually has logs to + // drain — a bare `new Date()` would request an empty window. An explicit + // --start always wins. + const ONCE_LOOKBACK_MS = 60 * 60 * 1000; + const startDate = options.start + ? new Date(options.start) + : options.once + ? new Date(Date.now() - ONCE_LOOKBACK_MS) + : new Date(); + const encoder = new TextEncoder(); const sub = trpcClient.subscription( "apps.logs", { org, app, - start: (options.start ? new Date(options.start) : new Date()) - .toISOString(), + start: startDate.toISOString(), end: options.end ? new Date(options.end).toISOString() : undefined, filter: {}, }, @@ -169,6 +193,14 @@ const logsCommand = new Command() onData: (data: unknown) => { const typedData = data as "streaming" | null | LogEntry[]; if (typedData === "streaming") { + // Backfill complete: the server is about to switch to live tailing. + // In --once mode this boundary is our cue to stop — all currently + // available logs have already been flushed synchronously above, so + // close the subscription and exit cleanly. + if (options.once) { + sub.unsubscribe(); + Deno.exit(ExitCode.OK); + } if (!onceConnected && !options.quiet && !options.json) { console.log("connected, streaming logs..."); } @@ -227,6 +259,11 @@ const logsCommand = new Command() }, onStopped: () => { sub.unsubscribe(); + // A server-completed stream (e.g. a bounded --start/--end window) + // in --once mode is a clean, deterministic end-of-capture. + if (options.once) { + Deno.exit(ExitCode.OK); + } }, }, );