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
66 changes: 66 additions & 0 deletions packages/cli/src/cli/commands/domains/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import type { Domain } from "@/core/domains/index.js";
import { addDomain, waitForDomainActive } from "@/core/domains/index.js";
import { domainStatusText, logDomainSetup, toJsonStdout } from "./shared.js";

interface AddOptions {
wait?: boolean;
}

function waitMessage(domain: Domain | undefined): string {
if (!domain) return "Waiting for domain to appear...";
return `Waiting for domain to become active (${domainStatusText(domain)})...`;
}

async function addDomainAction(
{ log, runTask, jsonMode }: CLIContext,
hostname: string,
options: AddOptions,
): Promise<RunCommandResult> {
let domain = await runTask(
`Connecting ${hostname}...`,
async () => await addDomain(hostname),
{ errorMessage: "Failed to connect domain" },
);

if (options.wait && !domain.active) {
domain = await runTask(
waitMessage(domain),
async (updateMessage) =>
await waitForDomainActive(hostname, {
onTick: (d) => updateMessage(waitMessage(d)),
}),
{
successMessage: `${hostname} is active`,
errorMessage: "Domain did not become active",
},
);
}

if (jsonMode) {
return {
outroMessage: `Domain ${hostname} is ${domainStatusText(domain)}`,
stdout: toJsonStdout(domain),
};
}

logDomainSetup(domain, log);
return {
outroMessage: domain.active
? `${hostname} is active`
: `${hostname} connected — add the CNAME record above to finish`,
};
}

export function getDomainsAddCommand(): Command {
return new Base44Command("add")
.description("Connect a custom domain to this app")
.argument("<hostname>", "Domain to connect, e.g. app.example.com")
.option(
"--wait",
"Poll until the domain and its TLS certificate are active",
)
.action(addDomainAction);
}
12 changes: 12 additions & 0 deletions packages/cli/src/cli/commands/domains/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Command } from "commander";
import { getDomainsAddCommand } from "./add.js";
import { getDomainsListCommand } from "./list.js";
import { getDomainsRemoveCommand } from "./remove.js";

export function getDomainsCommand(): Command {
return new Command("domains")
.description("Manage custom domains for full-stack apps")
.addCommand(getDomainsAddCommand())
.addCommand(getDomainsListCommand())
.addCommand(getDomainsRemoveCommand());
}
42 changes: 42 additions & 0 deletions packages/cli/src/cli/commands/domains/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { listDomains } from "@/core/domains/index.js";
import { formatDomainLine, toJsonStdout } from "./shared.js";

async function listDomainsAction({
log,
runTask,
jsonMode,
}: CLIContext): Promise<RunCommandResult> {
const domains = await runTask(
"Fetching domains...",
async () => await listDomains(),
{ errorMessage: "Failed to fetch domains" },
);

if (jsonMode) {
return {
outroMessage: `${domains.length} domains`,
stdout: toJsonStdout({ domains }),
};
}

if (domains.length === 0) {
return { outroMessage: "No custom domains found" };
}

for (const domain of domains) {
log.message(formatDomainLine(domain));
}

return {
outroMessage: `${domains.length} domain${domains.length !== 1 ? "s" : ""}`,
};
}

export function getDomainsListCommand(): Command {
return new Base44Command("list")
.description("List custom domains connected to this app")
.action(listDomainsAction);
}
49 changes: 49 additions & 0 deletions packages/cli/src/cli/commands/domains/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { confirm, isCancel } from "@clack/prompts";
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { removeDomain } from "@/core/domains/index.js";
import { InvalidInputError } from "@/core/errors.js";
import { toJsonStdout } from "./shared.js";

interface RemoveOptions {
yes?: boolean;
}

async function removeDomainAction(
{ runTask, jsonMode, isNonInteractive }: CLIContext,
hostname: string,
options: RemoveOptions,
): Promise<RunCommandResult> {
if (isNonInteractive && !options.yes) {
throw new InvalidInputError("--yes is required in non-interactive mode");
}

if (!options.yes) {
const shouldRemove = await confirm({
message: `Disconnect ${hostname} from this app?`,
});
if (isCancel(shouldRemove) || !shouldRemove) {
return { outroMessage: "Removal cancelled" };
}
}

const result = await runTask(
`Removing ${hostname}...`,
async () => await removeDomain(hostname),
{ errorMessage: "Failed to remove domain" },
);

return {
outroMessage: `Disconnected ${hostname}`,
stdout: jsonMode ? toJsonStdout(result) : undefined,
};
}

export function getDomainsRemoveCommand(): Command {
return new Base44Command("remove")
.description("Disconnect a custom domain from this app")
.argument("<hostname>", "Domain to disconnect")
.option("-y, --yes", "Skip confirmation prompt")
.action(removeDomainAction);
}
45 changes: 45 additions & 0 deletions packages/cli/src/cli/commands/domains/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Logger } from "@base44-cli/logger";
import { theme } from "@/cli/utils/index.js";
import type { Domain } from "@/core/domains/index.js";

export function toJsonStdout(result: unknown): string {
return `${JSON.stringify(result, null, 2)}\n`;
}

/** "pending (SSL: pending_validation)" — one-line status summary. */
export function domainStatusText(domain: Domain): string {
const status = domain.status ?? "unknown";
const ssl = domain.sslStatus ?? "unknown";
return domain.active ? "active" : `${status} (SSL: ${ssl})`;
}

/** A padded single-line row for the `domains list` table. */
export function formatDomainLine(domain: Domain): string {
const status = (
domain.active ? "active" : (domain.status ?? "unknown")
).padEnd(12);
const ssl = `ssl:${domain.sslStatus ?? "unknown"}`.padEnd(22);
return `${domain.hostname.padEnd(32)} ${status} ${ssl} → ${theme.colors.links(domain.cnameTarget)}`;
}

/**
* Print the exact DNS record the user must add plus the current status. TLS is
* issued automatically by Cloudflare once the CNAME resolves.
*/
export function logDomainSetup(domain: Domain, log: Logger): void {
log.message(`${theme.styles.header("Add this DNS record")}:`);
log.message(
` CNAME ${domain.hostname} ${theme.styles.dim("→")} ${theme.colors.links(domain.cnameTarget)}`,
);
log.message(`${theme.styles.header("Status")}: ${domainStatusText(domain)}`);
if (domain.pendingDeployment) {
log.warn(
"This app has no production deployment yet — the domain will start serving once the app is published.",
);
}
log.message(
theme.styles.dim(
"TLS certificate is issued automatically once the CNAME resolves.",
),
);
}
24 changes: 14 additions & 10 deletions packages/cli/src/cli/commands/project/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,20 @@ async function logsAction(
options: LogsOptions,
): Promise<RunCommandResult> {
validateLimit(options.limit);

if (options.follow) {
if (options.until) {
throw new InvalidInputError(
"--until cannot be combined with --follow (a stream has no end).",
);
}
if (options.order) {
throw new InvalidInputError(
"--order cannot be combined with --follow (a live tail always streams oldest to newest).",
);
}
}

const specifiedFunctions = parseFunctionNames(options.function);
const localProjectRoot = ctx.app?.projectRoot;

Expand All @@ -289,16 +303,6 @@ async function logsAction(
}

if (options.follow) {
if (options.until) {
throw new InvalidInputError(
"--until cannot be combined with --follow (a stream has no end).",
);
}
if (options.order) {
throw new InvalidInputError(
"--order cannot be combined with --follow (a live tail always streams oldest to newest).",
);
}
options.order = "asc"; // tail reads oldest -> newest
return followLogs(
functionNames,
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/cli/commands/slug/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Command } from "commander";
import { Base44Command } from "@/cli/utils/index.js";
import { getSlugResetCommand } from "./reset.js";
import { getSlugSetCommand } from "./set.js";
import { showSlugAction } from "./show.js";

export function getSlugCommand(): Command {
return new Base44Command("slug")
.description("Show or change the app's URL slug (its public subdomain)")
.allowExcessArguments(false)
.action(showSlugAction)
.addCommand(getSlugSetCommand())
.addCommand(getSlugResetCommand());
}
40 changes: 40 additions & 0 deletions packages/cli/src/cli/commands/slug/reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { getSiteUrl } from "@/core/project/index.js";
import { updateSlug } from "@/core/slug/index.js";
import { logAppUrl, toJsonStdout } from "./shared.js";

async function resetSlugAction({
log,
runTask,
jsonMode,
}: CLIContext): Promise<RunCommandResult> {
const result = await runTask(
"Resetting slug...",
async () => {
const updated = await updateSlug(null);
return { slug: updated.slug, url: await getSiteUrl() };
},
{ errorMessage: "Failed to reset slug" },
);

if (jsonMode) {
return {
outroMessage: `Slug reset to ${result.slug}`,
stdout: toJsonStdout(result),
};
}

log.message(
`${theme.styles.header("Slug")}: ${theme.styles.bold(result.slug ?? "")}`,
);
logAppUrl(result.url, log);
return { outroMessage: `Slug reset to ${result.slug}` };
}

export function getSlugResetCommand(): Command {
return new Base44Command("reset")
.description("Reset the slug to an auto-generated one")
.action(resetSlugAction);
}
44 changes: 44 additions & 0 deletions packages/cli/src/cli/commands/slug/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { getSiteUrl } from "@/core/project/index.js";
import { getSlug, updateSlug } from "@/core/slug/index.js";
import { logAppUrl, toJsonStdout } from "./shared.js";

async function setSlugAction(
{ log, runTask, jsonMode }: CLIContext,
slug: string,
): Promise<RunCommandResult> {
const result = await runTask(
`Setting slug to ${slug}...`,
async () => {
const { slug: previousSlug } = await getSlug();
const updated = await updateSlug(slug);
return { previousSlug, slug: updated.slug, url: await getSiteUrl() };
},
{ errorMessage: "Failed to update slug" },
);

if (jsonMode) {
return {
outroMessage: `Slug set to ${result.slug}`,
stdout: toJsonStdout(result),
};
}

log.message(
`${theme.styles.header("Slug")}: ${result.previousSlug ?? "(none)"} ${theme.styles.dim("→")} ${theme.styles.bold(result.slug ?? "")}`,
);
logAppUrl(result.url, log);
return { outroMessage: `Slug set to ${result.slug}` };
}

export function getSlugSetCommand(): Command {
return new Base44Command("set")
.description("Set a custom slug for this app")
.argument(
"<slug>",
"New slug, e.g. my-app (3-50 chars: lowercase letters, numbers, hyphens)",
)
.action(setSlugAction);
}
11 changes: 11 additions & 0 deletions packages/cli/src/cli/commands/slug/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Logger } from "@base44-cli/logger";
import { theme } from "@/cli/utils/index.js";

export function toJsonStdout(result: unknown): string {
return `${JSON.stringify(result, null, 2)}\n`;
}

/** "URL: https://my-app.base44.app" — the slug-derived production URL line. */
export function logAppUrl(url: string, log: Logger): void {
log.message(`${theme.styles.header("URL")}: ${theme.colors.links(url)}`);
}
Loading
Loading