From fac86485149918121459d40de7755593c23e762d Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:43:17 +0100 Subject: [PATCH 1/4] fix(apps): pass org to apps.listByPage query `apps list --org ` resolved the org but never forwarded it to the `apps.listByPage` procedure, so the backend rejected the request with a MALFORMED_REQUEST error ("org: expected string, received undefined"). Forward the resolved org slug like `deployments list` already does. --- deploy/apps.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/apps.ts b/deploy/apps.ts index e939f2a..06a76c2 100644 --- a/deploy/apps.ts +++ b/deploy/apps.ts @@ -27,6 +27,7 @@ const appsListCommand = new Command() const trpcClient = createTrpcClient(options); const res = await trpcClient.query("apps.listByPage", { + org, cursor: options.cursor, limit: options.limit ?? 20, }) as { items: AppItem[]; nextCursor: string | null }; From d217da3f6075be358c4321751760671e0dedf013 Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:43:27 +0100 Subject: [PATCH 2/4] feat(apps): add `apps get` exposing productionUrl and domains Closes the gap from #108: agents/CI with only a token, org slug and app slug had no machine-readable way to discover an app's live production URL. `apps get --org --app --json` returns { slug, productionUrl, domains, productionRevisionId, timelines, ... }. Domains live on revision timelines rather than the app record, so we read the latest revision's timelines and pick the Production-context partition; apps with no deployment yet report a null productionUrl gracefully. A human-readable table is printed in non-json mode. --- deploy/apps.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/deploy/apps.ts b/deploy/apps.ts index 06a76c2..82949ad 100644 --- a/deploy/apps.ts +++ b/deploy/apps.ts @@ -1,6 +1,6 @@ import { Command } from "@cliffy/command"; import { createTrpcClient } from "../auth.ts"; -import { actionHandler, getOrg } from "../config.ts"; +import { actionHandler, getApp, getOrg } from "../config.ts"; import type { GlobalContext } from "../main.ts"; import { renderTemporalTimestamp, @@ -16,6 +16,21 @@ interface AppItem { layers: Array<{ slug: string }>; } +interface AppDetail { + id: string; + slug: string; + created_at: Date; + updated_at: Date; + build_config: { frameworkPreset?: string | null } | null; +} + +interface TimelineEntry { + partition_config_name: string; + context_name: string; + active_revision_id: string | null; + domains: string[]; +} + const appsListCommand = new Command() .description("List applications in an organization") .option("--org ", "The name of the organization") @@ -68,10 +83,95 @@ const appsListCommand = new Command() } })); +/** Pick the timeline that serves the app's production context. */ +function findProductionTimeline( + timelines: TimelineEntry[], +): TimelineEntry | undefined { + return timelines.find((t) => t.context_name === "Production") ?? + timelines.find((t) => t.partition_config_name === "Production"); +} + +const appsGetCommand = new Command() + .description("Show an application, including its production URL and domains") + .option("--org ", "The name of the organization") + .option("--app ", "The name of the application") + .action(actionHandler(async (config, options) => { + config.noCreate(); + const org = await getOrg(options, config, options.org); + const { app } = await getApp(options, config, false, org, options.app); + const trpcClient = createTrpcClient(options); + + const detail = await trpcClient.query("apps.get", { + org, + app, + }) as AppDetail; + + // Domains live on revision timelines, not on the app record. Querying the + // timelines of any revision returns the app-wide partition state, so the + // latest revision is enough to read the current production domains. + const revisions = await trpcClient.query("revisions.listByPage", { + org, + app, + limit: 1, + }) as { items: Array<{ id: string }> }; + + let timelines: TimelineEntry[] = []; + const latestRevision = revisions.items[0]?.id; + if (latestRevision) { + timelines = await trpcClient.query("revisions.listTimelines", { + org, + app, + revision: latestRevision, + }) as TimelineEntry[]; + } + + const production = findProductionTimeline(timelines); + const domains = (production?.domains ?? []).map((d) => `https://${d}`); + const productionUrl = domains[0] ?? null; + + if (options.json) { + writeJsonResult({ + id: detail.id, + slug: detail.slug, + org, + productionUrl, + domains, + productionRevisionId: production?.active_revision_id ?? null, + frameworkPreset: detail.build_config?.frameworkPreset ?? null, + createdAt: detail.created_at, + updatedAt: detail.updated_at, + timelines: timelines.map((t) => ({ + partition: t.partition_config_name, + context: t.context_name, + activeRevisionId: t.active_revision_id, + domains: t.domains.map((d) => `https://${d}`), + })), + }); + return; + } + + console.log(`App: ${detail.slug}`); + console.log(`Production URL: ${productionUrl ?? "—"}`); + + if (timelines.length > 0) { + console.log(); + tablePrinter( + ["PARTITION", "CONTEXT", "DOMAINS"], + timelines, + (t) => [ + t.partition_config_name, + t.context_name, + t.domains.map((d) => `https://${d}`).join(", ") || "—", + ], + ); + } + })); + export const appsCommand = new Command() .description("Manage applications") .action(() => { appsCommand.showHelp(); }) .command("list", appsListCommand) - .alias("ls"); + .alias("ls") + .command("get", appsGetCommand); From 3bda3884b0012c7af7198eddcc6d278adc3a3cc4 Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:43:37 +0100 Subject: [PATCH 3/4] feat(deploy): expose productionUrl and emit JSON under --no-wait For #108, the deploy `--json` payload now includes an explicit `productionUrl` (derived from the Production-context timeline) alongside the existing builds-page `url` and `timelines`. `--no-wait --json` previously wrote nothing to stdout; it now emits a JSON object with org/app/revisionId/url and status "pending" so agents can track the in-flight build. Also fixes a latent hang: the upload ProgressBar was constructed unconditionally but only stopped when drawn, so in --json/--quiet mode its render timer kept the event loop alive and the process never exited. The bar is now only instantiated when it will actually be rendered. --- deploy/publish.ts | 76 +++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/deploy/publish.ts b/deploy/publish.ts index 92cf974..233b8cd 100644 --- a/deploy/publish.ts +++ b/deploy/publish.ts @@ -162,35 +162,40 @@ export async function publish( } const useProgress = shouldUseSpinner(context); - const progress = new ProgressBar({ - max: missingHashes.length, - emptyChar: " ", - fillChar: green("█"), - formatter(formatter) { - const minutes = (formatter.time / 1000 / 60 | 0).toString().padStart( - 2, - "0", - ); - const seconds = (formatter.time / 1000 % 60 | 0).toString().padStart( - 2, - "0", - ); - - const length = formatter.max.toString().length; - return `[${yellow(minutes)}:${ - yellow(seconds) - }] ${formatter.progressBar} ${ - yellow(formatter.value.toString().padStart(length, " ")) - }/${yellow(formatter.max.toString())} files uploaded.`; - }, - }); + // Only instantiate the bar when it will actually be drawn; otherwise its + // internal render timer keeps the event loop alive and the process hangs + // after we're done (e.g. under --json / --no-wait). + const progress = useProgress + ? new ProgressBar({ + max: missingHashes.length, + emptyChar: " ", + fillChar: green("█"), + formatter(formatter) { + const minutes = (formatter.time / 1000 / 60 | 0).toString().padStart( + 2, + "0", + ); + const seconds = (formatter.time / 1000 % 60 | 0).toString().padStart( + 2, + "0", + ); + + const length = formatter.max.toString().length; + return `[${yellow(minutes)}:${ + yellow(seconds) + }] ${formatter.progressBar} ${ + yellow(formatter.value.toString().padStart(length, " ")) + }/${yellow(formatter.max.toString())} files uploaded.`; + }, + }) + : undefined; let tarball = body .pipeThrough( new TransformStream({ transform({ internalPath, data, hash }, controller) { if (missingHashes.includes(hash)) { - if (useProgress) progress.value += 1; + if (progress) progress.value += 1; controller.enqueue( { @@ -239,7 +244,7 @@ export async function publish( }, ); - if (useProgress) await progress.stop(); + if (progress) await progress.stop(); log(); @@ -257,6 +262,17 @@ export async function publish( if (wait) { await waitForRevision(context, org, app, revisionId, revision); + } else if (context.json) { + // Without --wait the build hasn't finished, so the production URL isn't + // known yet; still emit the revision id so agents can poll/track it. + writeJsonResult({ + org, + app, + revisionId, + url: `${context.endpoint}/${org}/${app}/builds/${revisionId}`, + status: "pending", + productionUrl: null, + }); } else { log( "To see the deployment, go to the revision page and wait for the build to complete.", @@ -347,7 +363,16 @@ export async function waitForRevision( org, app, revision: revisionId, - }) as Array<{ partition_config_name: string; domains: string[] }>; + }) as Array< + { partition_config_name: string; context_name: string; domains: string[] } + >; + + const productionTimeline = + timelines.find((t) => t.context_name === "Production") ?? + timelines.find((t) => t.partition_config_name === "Production"); + const productionUrl = productionTimeline?.domains[0] + ? `https://${productionTimeline.domains[0]}` + : null; if (context.json) { writeJsonResult({ @@ -356,6 +381,7 @@ export async function waitForRevision( revisionId, url: `${context.endpoint}/${org}/${app}/builds/${revisionId}`, status: revision?.status ?? "ready", + productionUrl, timelines: timelines.map((t) => ({ partition: t.partition_config_name, domains: t.domains.map((d) => `https://${d}`), From e3c50142dfa4d5f7e022425219abceef3bafe8ff Mon Sep 17 00:00:00 2001 From: tcerqueira Date: Mon, 29 Jun 2026 13:53:14 +0100 Subject: [PATCH 4/4] refactor(deploy): route success/status output to stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize stdout discipline across deploy/apps.ts and deploy/publish.ts: stdout now carries only data payloads (the --json result object, and in human mode the list/query tables and apps-get data), while all post-action confirmations, progress/status chrome, and debug diagnostics go to stderr — in both --json and human modes. publish.ts: the `log()` helper (used for "You can view the revision here", "Loaded previously uploaded files", "Successfully uploaded your application!", "No files were changed…", "Waiting for deployment to complete…", "To see the deployment…") now writes to stderr; the "Successfully deployed" confirmation, per-timeline URL lines, the cancelled/failed notice, and all debug console.log calls were moved to console.error. apps.ts: the "No applications in this organization." notice and the "More results available…" pagination hint moved to stderr; the apps list table and the apps get data display stay on stdout. --- deploy/apps.ts | 6 ++++-- deploy/publish.ts | 24 ++++++++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/deploy/apps.ts b/deploy/apps.ts index 82949ad..080315e 100644 --- a/deploy/apps.ts +++ b/deploy/apps.ts @@ -63,7 +63,7 @@ const appsListCommand = new Command() } if (res.items.length === 0) { - console.log("No applications in this organization."); + console.error("No applications in this organization."); return; } @@ -79,7 +79,9 @@ const appsListCommand = new Command() ); if (res.nextCursor) { - console.log(`\nMore results available; pass --cursor ${res.nextCursor}`); + console.error( + `\nMore results available; pass --cursor ${res.nextCursor}`, + ); } })); diff --git a/deploy/publish.ts b/deploy/publish.ts index 233b8cd..28bb537 100644 --- a/deploy/publish.ts +++ b/deploy/publish.ts @@ -33,8 +33,10 @@ export async function publish( const quiet = context.quiet || context.json; const log: typeof console.log = quiet ? () => {} + // Status/progress chrome goes to stderr; stdout is reserved for the + // (--json) result payload. // deno-lint-ignore no-explicit-any - : console.log.bind(console) as any; + : console.error.bind(console) as any; function startSpinner(message: string): Spinner { const spinner = new Spinner({ message, color: "yellow" }); @@ -55,7 +57,7 @@ export async function publish( ); if (context.debug) { - console.log(`reading ${JSON.stringify(relativePath)}`); + console.error(`reading ${JSON.stringify(relativePath)}`); } const data = await Deno.readFile(path); @@ -86,7 +88,7 @@ export async function publish( } if (context.debug) { - console.log("Manifest", manifest); + console.error("Manifest", manifest); } const trpcClient = createTrpcClient(context); @@ -158,7 +160,7 @@ export async function publish( } if (context.debug) { - console.log("Missing hashes", missingHashes); + console.error("Missing hashes", missingHashes); } const useProgress = shouldUseSpinner(context); @@ -208,7 +210,7 @@ export async function publish( } if (context.debug) { - console.log( + console.error( `uploading ${JSON.stringify(internalPath)}`, ); } @@ -225,7 +227,7 @@ export async function publish( suffix: "debug.tar.gz", }); await Deno.writeFile(path, tb2); - console.log(`Created debug tarball at '${path}'`); + console.error(`Created debug tarball at '${path}'`); } const resp = await authedFetch( @@ -290,8 +292,10 @@ export async function waitForRevision( const quiet = context.quiet || context.json; const log: typeof console.log = quiet ? () => {} + // Status/progress chrome goes to stderr; stdout is reserved for the + // (--json) result payload. // deno-lint-ignore no-explicit-any - : console.log.bind(console) as any; + : console.error.bind(console) as any; const trpcClient = createTrpcClient(context); log( @@ -351,7 +355,7 @@ export async function waitForRevision( `View ${context.endpoint}/${org}/${app}/builds/${revisionId} for details.`, }); } - console.log( + console.error( `\n${red("✗")} The revision ${ revision.status === "cancelled" ? "was " : "" }${revision.status}.\n Please view the revision in the dashboard for more information.`, @@ -390,10 +394,10 @@ export async function waitForRevision( return; } - console.log(`\n${green("✔")} Successfully deployed your application!`); + console.error(`\n${green("✔")} Successfully deployed your application!`); for (const timeline of timelines) { - console.log( + console.error( `${timeline.partition_config_name} url:${ timeline.domains.map((domain) => `\n https://${domain}`) }`,