diff --git a/deploy/apps.ts b/deploy/apps.ts index e939f2a..080315e 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") @@ -27,6 +42,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 +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; } @@ -63,7 +79,93 @@ 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}`, + ); + } + })); + +/** 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(", ") || "—", + ], + ); } })); @@ -73,4 +175,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..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,39 +160,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 +210,7 @@ export async function publish( } if (context.debug) { - console.log( + console.error( `uploading ${JSON.stringify(internalPath)}`, ); } @@ -220,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( @@ -239,7 +246,7 @@ export async function publish( }, ); - if (useProgress) await progress.stop(); + if (progress) await progress.stop(); log(); @@ -257,6 +264,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 +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( @@ -335,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.`, @@ -347,7 +367,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 +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}`) }`,