Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 107 additions & 4 deletions deploy/apps.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<GlobalContext>()
.description("List applications in an organization")
.option("--org <name:string>", "The name of the organization")
Expand All @@ -27,6 +42,7 @@ const appsListCommand = new Command<GlobalContext>()
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 };
Expand All @@ -47,7 +63,7 @@ const appsListCommand = new Command<GlobalContext>()
}

if (res.items.length === 0) {
console.log("No applications in this organization.");
console.error("No applications in this organization.");
return;
}

Expand All @@ -63,7 +79,93 @@ const appsListCommand = new Command<GlobalContext>()
);

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<GlobalContext>()
.description("Show an application, including its production URL and domains")
.option("--org <name:string>", "The name of the organization")
.option("--app <name:string>", "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(", ") || "—",
],
);
}
}));

Expand All @@ -73,4 +175,5 @@ export const appsCommand = new Command<GlobalContext>()
appsCommand.showHelp();
})
.command("list", appsListCommand)
.alias("ls");
.alias("ls")
.command("get", appsGetCommand);
100 changes: 65 additions & 35 deletions deploy/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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);
Expand Down Expand Up @@ -86,7 +88,7 @@ export async function publish(
}

if (context.debug) {
console.log("Manifest", manifest);
console.error("Manifest", manifest);
}

const trpcClient = createTrpcClient(context);
Expand Down Expand Up @@ -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(
{
Expand All @@ -203,7 +210,7 @@ export async function publish(
}

if (context.debug) {
console.log(
console.error(
`uploading ${JSON.stringify(internalPath)}`,
);
}
Expand All @@ -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(
Expand All @@ -239,7 +246,7 @@ export async function publish(
},
);

if (useProgress) await progress.stop();
if (progress) await progress.stop();

log();

Expand All @@ -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.",
Expand All @@ -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(
Expand Down Expand Up @@ -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.`,
Expand All @@ -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({
Expand All @@ -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}`),
Expand All @@ -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}`)
}`,
Expand Down
Loading