diff --git a/deploy/apps.ts b/deploy/apps.ts index e939f2a..74e814c 100644 --- a/deploy/apps.ts +++ b/deploy/apps.ts @@ -1,9 +1,10 @@ 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, + selectProductionUrl, tablePrinter, writeJsonResult, } from "../util.ts"; @@ -16,6 +17,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") @@ -27,6 +43,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 }; @@ -47,7 +64,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; } @@ -63,7 +80,83 @@ 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}`, + ); + } + })); + +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); + + // 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. Both + // queries only depend on org/app, so run them concurrently. + const [detail, revisions] = await Promise.all([ + trpcClient.query("apps.get", { org, app }) as Promise, + trpcClient.query("revisions.listByPage", { + org, + app, + limit: 1, + }) as Promise<{ 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 { timeline: production, domains, productionUrl } = + selectProductionUrl(timelines); + + 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(", ") || "—", + ], + ); } })); @@ -73,4 +166,5 @@ export const appsCommand = new Command() appsCommand.showHelp(); }) .command("list", appsListCommand) - .alias("ls"); + .alias("ls") + .command("get", appsGetCommand); diff --git a/deploy/publish.ts b/deploy/publish.ts index 92cf974..6215238 100644 --- a/deploy/publish.ts +++ b/deploy/publish.ts @@ -4,7 +4,12 @@ import { Spinner } from "@std/cli/unstable-spinner"; import { join, relative, resolve, SEPARATOR } from "@std/path"; import { green, red, yellow } from "@std/fmt/colors"; import { authedFetch, createTrpcClient } from "../auth.ts"; -import { error, shouldUseSpinner, writeJsonResult } from "../util.ts"; +import { + error, + selectProductionUrl, + shouldUseSpinner, + writeJsonResult, +} from "../util.ts"; import type { GlobalContext } from "../main.ts"; import type { ConfigContext } from "../config.ts"; @@ -33,8 +38,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 +62,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 +93,7 @@ export async function publish( } if (context.debug) { - console.log("Manifest", manifest); + console.error("Manifest", manifest); } const trpcClient = createTrpcClient(context); @@ -158,39 +165,44 @@ export async function publish( } if (context.debug) { - console.log("Missing hashes", missingHashes); + console.error("Missing hashes", missingHashes); } 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( { @@ -203,7 +215,7 @@ export async function publish( } if (context.debug) { - console.log( + console.error( `uploading ${JSON.stringify(internalPath)}`, ); } @@ -220,7 +232,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( @@ -239,7 +251,7 @@ export async function publish( }, ); - if (useProgress) await progress.stop(); + if (progress) await progress.stop(); log(); @@ -257,6 +269,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.", @@ -274,8 +297,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( @@ -335,7 +360,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.`, @@ -347,7 +372,11 @@ 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 { productionUrl } = selectProductionUrl(timelines); if (context.json) { writeJsonResult({ @@ -356,6 +385,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}`), @@ -364,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}`) }`, diff --git a/util.ts b/util.ts index 4cd65d3..92bfcb5 100644 --- a/util.ts +++ b/util.ts @@ -136,6 +136,31 @@ function exitCodeToName(code: ExitCode): string { } } +/** Minimal shape of a revision timeline needed to locate production. */ +export interface ProductionTimelineLike { + partition_config_name: string; + context_name: string; + domains: string[]; +} + +/** + * Select the timeline that serves the app's production context and derive its + * public URL/domains. Centralized so the deploy flow and `apps get` can't drift + * on how "production" is identified or how domains are https-prefixed. + */ +export function selectProductionUrl( + timelines: T[], +): { + timeline: T | undefined; + domains: string[]; + productionUrl: string | null; +} { + const timeline = timelines.find((t) => t.context_name === "Production") ?? + timelines.find((t) => t.partition_config_name === "Production"); + const domains = (timeline?.domains ?? []).map((d) => `https://${d}`); + return { timeline, domains, productionUrl: domains[0] ?? null }; +} + export function renderTemporalTimestamp(timestamp: string, hideDate = false) { function pad(n: number, width: number): string { return n.toString().padStart(width, "0");