diff --git a/packages/cli/src/cli/commands/domains/add.ts b/packages/cli/src/cli/commands/domains/add.ts new file mode 100644 index 000000000..218deba49 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/add.ts @@ -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 { + 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("", "Domain to connect, e.g. app.example.com") + .option( + "--wait", + "Poll until the domain and its TLS certificate are active", + ) + .action(addDomainAction); +} diff --git a/packages/cli/src/cli/commands/domains/index.ts b/packages/cli/src/cli/commands/domains/index.ts new file mode 100644 index 000000000..a12f94608 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/index.ts @@ -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()); +} diff --git a/packages/cli/src/cli/commands/domains/list.ts b/packages/cli/src/cli/commands/domains/list.ts new file mode 100644 index 000000000..c59707a93 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/list.ts @@ -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 { + 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); +} diff --git a/packages/cli/src/cli/commands/domains/remove.ts b/packages/cli/src/cli/commands/domains/remove.ts new file mode 100644 index 000000000..ca2f63416 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/remove.ts @@ -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 { + 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("", "Domain to disconnect") + .option("-y, --yes", "Skip confirmation prompt") + .action(removeDomainAction); +} diff --git a/packages/cli/src/cli/commands/domains/shared.ts b/packages/cli/src/cli/commands/domains/shared.ts new file mode 100644 index 000000000..75546e771 --- /dev/null +++ b/packages/cli/src/cli/commands/domains/shared.ts @@ -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.", + ), + ); +} diff --git a/packages/cli/src/cli/commands/project/logs.ts b/packages/cli/src/cli/commands/project/logs.ts index 602ca8fc6..0ee71ba51 100644 --- a/packages/cli/src/cli/commands/project/logs.ts +++ b/packages/cli/src/cli/commands/project/logs.ts @@ -270,6 +270,20 @@ async function logsAction( options: LogsOptions, ): Promise { 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; @@ -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, diff --git a/packages/cli/src/cli/commands/slug/index.ts b/packages/cli/src/cli/commands/slug/index.ts new file mode 100644 index 000000000..00187a18d --- /dev/null +++ b/packages/cli/src/cli/commands/slug/index.ts @@ -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()); +} diff --git a/packages/cli/src/cli/commands/slug/reset.ts b/packages/cli/src/cli/commands/slug/reset.ts new file mode 100644 index 000000000..c69e21b7c --- /dev/null +++ b/packages/cli/src/cli/commands/slug/reset.ts @@ -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 { + 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); +} diff --git a/packages/cli/src/cli/commands/slug/set.ts b/packages/cli/src/cli/commands/slug/set.ts new file mode 100644 index 000000000..eb3c5a5cd --- /dev/null +++ b/packages/cli/src/cli/commands/slug/set.ts @@ -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 { + 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( + "", + "New slug, e.g. my-app (3-50 chars: lowercase letters, numbers, hyphens)", + ) + .action(setSlugAction); +} diff --git a/packages/cli/src/cli/commands/slug/shared.ts b/packages/cli/src/cli/commands/slug/shared.ts new file mode 100644 index 000000000..11ed51a43 --- /dev/null +++ b/packages/cli/src/cli/commands/slug/shared.ts @@ -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)}`); +} diff --git a/packages/cli/src/cli/commands/slug/show.ts b/packages/cli/src/cli/commands/slug/show.ts new file mode 100644 index 000000000..debc4ed48 --- /dev/null +++ b/packages/cli/src/cli/commands/slug/show.ts @@ -0,0 +1,40 @@ +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { theme } from "@/cli/utils/index.js"; +import { getSiteUrl } from "@/core/project/index.js"; +import { getSlug } from "@/core/slug/index.js"; +import { logAppUrl, toJsonStdout } from "./shared.js"; + +export async function showSlugAction({ + log, + runTask, + jsonMode, +}: CLIContext): Promise { + const { slug, url } = await runTask( + "Fetching slug...", + async () => { + const { slug } = await getSlug(); + return { slug, url: slug ? await getSiteUrl() : null }; + }, + { errorMessage: "Failed to fetch slug" }, + ); + + if (jsonMode) { + return { + outroMessage: slug ? `Slug: ${slug}` : "This app has no slug yet", + stdout: toJsonStdout({ slug, url }), + }; + } + + if (!slug) { + return { + outroMessage: + "This app has no slug yet — set one with 'base44 slug set '", + }; + } + + log.message(`${theme.styles.header("Slug")}: ${theme.styles.bold(slug)}`); + if (url) { + logAppUrl(url, log); + } + return { outroMessage: `Slug: ${slug}` }; +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 04e1c33e6..9ce7d8c75 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -7,6 +7,7 @@ import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; +import { getDomainsCommand } from "@/cli/commands/domains/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsCommand } from "@/cli/commands/functions/index.js"; import { getBuildCommand } from "@/cli/commands/project/build.js"; @@ -19,6 +20,7 @@ import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; +import { getSlugCommand } from "@/cli/commands/slug/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import { getWorkspaceCommand } from "@/cli/commands/workspace/index.js"; import { Base44Command } from "@/cli/utils/index.js"; @@ -94,6 +96,12 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + // Register custom domain commands + program.addCommand(getDomainsCommand()); + + // Register slug commands + program.addCommand(getSlugCommand()); + // Register secrets commands program.addCommand(getSecretsCommand()); diff --git a/packages/cli/src/core/domains/api.ts b/packages/cli/src/core/domains/api.ts new file mode 100644 index 000000000..4ecc01386 --- /dev/null +++ b/packages/cli/src/core/domains/api.ts @@ -0,0 +1,128 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { + ApiError, + SchemaValidationError, + TimeoutError, +} from "@/core/errors.js"; +import type { + AddDomainRequest, + Domain, + RemoveDomainResponse, +} from "./schema.js"; +import { + AddDomainResponseSchema, + DomainsListResponseSchema, + RemoveDomainResponseSchema, +} from "./schema.js"; + +/** Connect a custom domain to the app (creates the CF custom hostname). */ +export async function addDomain(hostname: string): Promise { + const appClient = getAppClient(); + + const request: AddDomainRequest = { hostname }; + let response: KyResponse; + try { + response = await appClient.post("domains", { + json: request, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "connecting domain"); + } + + const result = AddDomainResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** List the custom domains connected to the app, with live status. */ +export async function listDomains(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("domains"); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing domains"); + } + + const result = DomainsListResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** Disconnect a custom domain (deletes the CF custom hostname + route). */ +export async function removeDomain( + hostname: string, +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.delete( + `domains/${encodeURIComponent(hostname)}`, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "removing domain"); + } + + const result = RemoveDomainResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +interface WaitForDomainOptions { + /** Poll interval in ms (default 2000). */ + intervalMs?: number; + /** Give up after this many ms (default 10 minutes). */ + timeoutMs?: number; + /** Called with the latest domain state (or undefined) on each poll. */ + onTick?: (domain: Domain | undefined) => void; +} + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Poll `listDomains` until `hostname` is fully active (hostname + SSL), then + * resolve with it. Throws `TimeoutError` if it never activates within the + * budget — typically because the CNAME record hasn't been added yet. + */ +export async function waitForDomainActive( + hostname: string, + options: WaitForDomainOptions = {}, +): Promise { + const intervalMs = options.intervalMs ?? 2_000; + const timeoutMs = options.timeoutMs ?? 10 * 60_000; + const deadline = Date.now() + timeoutMs; + + for (;;) { + const domain = (await listDomains()).find((d) => d.hostname === hostname); + options.onTick?.(domain); + if (domain?.active) { + return domain; + } + if (Date.now() >= deadline) { + throw new TimeoutError( + `Timed out waiting for ${hostname} to become active`, + ); + } + await delay(intervalMs); + } +} diff --git a/packages/cli/src/core/domains/index.ts b/packages/cli/src/core/domains/index.ts new file mode 100644 index 000000000..4ac144047 --- /dev/null +++ b/packages/cli/src/core/domains/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/domains/schema.ts b/packages/cli/src/core/domains/schema.ts new file mode 100644 index 000000000..b8aff3441 --- /dev/null +++ b/packages/cli/src/core/domains/schema.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +// ─── SHARED ────────────────────────────────────────────────── + +/** + * DNS / ownership details Cloudflare returns for a pending custom hostname. + * Values are provider-shaped and opaque to the CLI, so they pass through as + * `unknown` (rendered/serialized verbatim). + */ +const DomainVerificationSchema = z + .object({ + ownership_verification: z.unknown().nullable().optional(), + ownership_verification_http: z.unknown().nullable().optional(), + ssl_validation_records: z.array(z.unknown()).nullable().optional(), + ssl_validation_errors: z.array(z.unknown()).nullable().optional(), + }) + .transform((data) => ({ + ownershipVerification: data.ownership_verification ?? null, + ownershipVerificationHttp: data.ownership_verification_http ?? null, + sslValidationRecords: data.ssl_validation_records ?? null, + sslValidationErrors: data.ssl_validation_errors ?? null, + })); + +const DomainSchema = z + .object({ + hostname: z.string(), + cname_target: z.string(), + status: z.string().nullable(), + ssl_status: z.string().nullable(), + active: z.boolean(), + pending_deployment: z.boolean().optional(), + verification: DomainVerificationSchema, + }) + .transform((data) => ({ + hostname: data.hostname, + cnameTarget: data.cname_target, + status: data.status, + sslStatus: data.ssl_status, + active: data.active, + pendingDeployment: data.pending_deployment ?? false, + verification: data.verification, + })); + +export type Domain = z.infer; + +// ─── REQUESTS ──────────────────────────────────────────────── + +/** Request payload for POST domains (sent as snake_case JSON). */ +export interface AddDomainRequest { + hostname: string; +} + +// ─── RESPONSES ─────────────────────────────────────────────── + +/** POST domains returns a single domain view. */ +export const AddDomainResponseSchema = DomainSchema; + +export const DomainsListResponseSchema = z + .object({ domains: z.array(DomainSchema) }) + .transform((data) => data.domains); + +export const RemoveDomainResponseSchema = z + .object({ hostname: z.string(), deleted: z.boolean() }) + .transform((data) => ({ hostname: data.hostname, deleted: data.deleted })); + +export type RemoveDomainResponse = z.infer; diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index b5bf8049a..fd110805d 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -268,6 +268,14 @@ export class InvalidInputError extends UserError { readonly code = "INVALID_INPUT"; } +/** + * Thrown when a polled operation (e.g. waiting for a custom domain to become + * active) does not reach the desired state within its time budget. + */ +export class TimeoutError extends UserError { + readonly code = "TIMEOUT"; +} + /** * Thrown when a required external dependency is not installed (e.g., Deno, Git). */ diff --git a/packages/cli/src/core/slug/api.ts b/packages/cli/src/core/slug/api.ts new file mode 100644 index 000000000..0311ae3f4 --- /dev/null +++ b/packages/cli/src/core/slug/api.ts @@ -0,0 +1,61 @@ +import type { KyResponse } from "ky"; +import { base44Client, getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getAppContext } from "@/core/project/index.js"; +import type { AppSlug, UpdateSlugRequest } from "./schema.js"; +import { AppSlugResponseSchema, SlugSuggestionsSchema } from "./schema.js"; + +/** Fetch the app's current slug (from the app document). */ +export async function getSlug(): Promise { + const { id } = getAppContext(); + + let response: KyResponse; + try { + response = await base44Client.get(`api/apps/${id}`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching app slug"); + } + + const result = AppSlugResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +/** + * Change the app's slug; pass null to reset to the auto-generated slug. + * When the requested slug is already in use, the API's alternative + * suggestions are surfaced as hints on the thrown ApiError. + */ +export async function updateSlug(slug: string | null): Promise { + const appClient = getAppClient(); + + const request: UpdateSlugRequest = { slug }; + let response: KyResponse; + try { + response = await appClient.patch("metadata/slug", { json: request }); + } catch (error) { + const apiError = await ApiError.fromHttpError(error, "updating slug"); + const suggestions = SlugSuggestionsSchema.safeParse(apiError.responseBody) + .data?.suggestions; + if (suggestions && suggestions.length > 0) { + apiError.hints.unshift({ + message: `Available alternatives: ${suggestions.join(", ")}`, + }); + } + throw apiError; + } + + const result = AppSlugResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/slug/index.ts b/packages/cli/src/core/slug/index.ts new file mode 100644 index 000000000..4ac144047 --- /dev/null +++ b/packages/cli/src/core/slug/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/slug/schema.ts b/packages/cli/src/core/slug/schema.ts new file mode 100644 index 000000000..0faa02632 --- /dev/null +++ b/packages/cli/src/core/slug/schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +/** + * App document returned by GET api/apps/{id} and PATCH metadata/slug. + * Only the slug is consumed; every other field is dropped. + */ +export const AppSlugResponseSchema = z + .object({ slug: z.string().nullable().optional() }) + .transform((data) => ({ slug: data.slug ?? null })); + +export type AppSlug = z.infer; + +// ─── REQUESTS ──────────────────────────────────────────────── + +/** Request payload for PATCH metadata/slug. Null resets to auto-generated. */ +export interface UpdateSlugRequest { + slug: string | null; +} + +// ─── ERRORS ────────────────────────────────────────────────── + +/** 400 "slug already in use" bodies carry alternative slug suggestions. */ +export const SlugSuggestionsSchema = z.object({ + suggestions: z.array(z.string()), +}); diff --git a/packages/cli/tests/cli/domains.spec.ts b/packages/cli/tests/cli/domains.spec.ts new file mode 100644 index 000000000..934387627 --- /dev/null +++ b/packages/cli/tests/cli/domains.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const DOMAIN = { + hostname: "app.example.com", + cname_target: "b44apps.dev", + status: "pending" as const, + ssl_status: "pending_validation" as const, + active: false, + pending_deployment: false, + verification: { + ownership_verification: { + type: "txt", + name: "_cf-custom-hostname.app.example.com", + value: "abc123", + }, + ownership_verification_http: { + http_url: + "http://app.example.com/.well-known/cf-custom-hostname-challenge/x", + http_body: "body", + }, + ssl_validation_records: [ + { txt_name: "_acme-challenge.app.example.com", txt_value: "zzz" }, + ], + ssl_validation_errors: null, + }, +}; + +const ACTIVE_DOMAIN = { + ...DOMAIN, + status: "active" as const, + ssl_status: "active" as const, + active: true, +}; + +describe("domains add command", () => { + const t = setupCLITests(); + + it("prints the CNAME record and status", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAdd(DOMAIN); + + const result = await t.run("domains", "add", "app.example.com"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("CNAME"); + t.expectResult(result).toContain("app.example.com"); + t.expectResult(result).toContain("b44apps.dev"); + }); + + it("outputs JSON with --json (snake→camel transform)", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAdd(DOMAIN); + + const result = await t.run("domains", "add", "app.example.com", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.hostname).toBe("app.example.com"); + expect(parsed.cnameTarget).toBe("b44apps.dev"); + expect(parsed.sslStatus).toBe("pending_validation"); + expect(parsed.pendingDeployment).toBe(false); + expect(parsed.verification.ownershipVerification).toEqual({ + type: "txt", + name: "_cf-custom-hostname.app.example.com", + value: "abc123", + }); + }); + + it("waits until the domain is active with --wait", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAdd(DOMAIN); + // First poll of listDomains returns the active domain (no delay incurred). + t.api.mockDomainList({ domains: [ACTIVE_DOMAIN] }); + + const result = await t.run("domains", "add", "app.example.com", "--wait"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("app.example.com is active"); + }); + + it("fails when the API returns an error", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainAddError({ + status: 400, + body: { message: "invalid hostname" }, + }); + + const result = await t.run("domains", "add", "bad_host"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("invalid hostname"); + }); +}); + +describe("domains list command", () => { + const t = setupCLITests(); + + it("lists custom domains", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainList({ + domains: [DOMAIN, { ...ACTIVE_DOMAIN, hostname: "www.example.com" }], + }); + + const result = await t.run("domains", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("app.example.com"); + t.expectResult(result).toContain("www.example.com"); + t.expectResult(result).toContain("2 domains"); + }); + + it("lists domains as JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainList({ domains: [DOMAIN] }); + + const result = await t.run("domains", "list", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.domains).toHaveLength(1); + expect(parsed.domains[0].hostname).toBe("app.example.com"); + expect(parsed.domains[0].cnameTarget).toBe("b44apps.dev"); + expect(parsed.domains[0].active).toBe(false); + }); + + it("shows an empty-state message when there are no domains", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainList({ domains: [] }); + + const result = await t.run("domains", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No custom domains found"); + }); +}); + +describe("domains remove command", () => { + const t = setupCLITests(); + + it("requires -y in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + + const result = await t.run("domains", "remove", "app.example.com"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); + }); + + it("removes a domain with -y", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainRemove("app.example.com", { + hostname: "app.example.com", + deleted: true, + }); + + const result = await t.run("domains", "remove", "app.example.com", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Disconnected app.example.com"); + }); + + it("surfaces the API error", async () => { + await t.givenLoggedInWithProject(fixture("fullstack-project")); + t.api.mockDomainRemoveError("app.example.com", { + status: 500, + body: { message: "Server error" }, + }); + + const result = await t.run("domains", "remove", "app.example.com", "-y"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/slug.spec.ts b/packages/cli/tests/cli/slug.spec.ts new file mode 100644 index 000000000..148c39392 --- /dev/null +++ b/packages/cli/tests/cli/slug.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const APP = { id: "test-app-id", name: "My App", slug: "my-app" }; + +describe("slug command", () => { + const t = setupCLITests(); + + it("shows the current slug and app URL", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + + const result = await t.run("slug"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("my-app"); + t.expectResult(result).toContain("https://my-app.base44.app"); + }); + + it("outputs JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + + const result = await t.run("slug", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.slug).toBe("my-app"); + expect(parsed.url).toBe("https://my-app.base44.app"); + }); + + it("reports when the app has no slug", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet({ ...APP, slug: null }); + + const result = await t.run("slug"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("no slug"); + }); +}); + +describe("slug set command", () => { + const t = setupCLITests(); + + it("sets a custom slug and prints old slug, new slug, and URL", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdate({ ...APP, slug: "new-slug" }); + t.api.mockSiteUrl({ url: "https://new-slug.base44.app" }); + + const result = await t.run("slug", "set", "new-slug"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("my-app"); + t.expectResult(result).toContain("new-slug"); + t.expectResult(result).toContain("https://new-slug.base44.app"); + expect(t.api.slugUpdateRequests).toEqual([{ slug: "new-slug" }]); + }); + + it("outputs JSON with --json", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdate({ ...APP, slug: "new-slug" }); + t.api.mockSiteUrl({ url: "https://new-slug.base44.app" }); + + const result = await t.run("slug", "set", "new-slug", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.previousSlug).toBe("my-app"); + expect(parsed.slug).toBe("new-slug"); + expect(parsed.url).toBe("https://new-slug.base44.app"); + }); + + it("fails when the slug format is invalid", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdateError({ + status: 400, + body: { + detail: + "Custom URL must be 3-50 characters and contain only letters, numbers, and hyphens", + }, + }); + + const result = await t.run("slug", "set", "x!"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("3-50 characters"); + }); + + it("surfaces suggestions when the slug is already in use", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppGet(APP); + t.api.mockSlugUpdateError({ + status: 400, + body: { + detail: "URL slug 'taken' is already in use", + suggestions: ["taken-app", "taken-hq"], + }, + }); + + const result = await t.run("slug", "set", "taken"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("already in use"); + t.expectResult(result).toContain("taken-app"); + }); +}); + +describe("slug reset command", () => { + const t = setupCLITests(); + + it("resets to the auto-generated slug", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSlugUpdate({ ...APP, slug: "my-app-12345678" }); + t.api.mockSiteUrl({ url: "https://my-app-12345678.base44.app" }); + + const result = await t.run("slug", "reset"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("my-app-12345678"); + expect(t.api.slugUpdateRequests).toEqual([{ slug: null }]); + }); + + it("surfaces API errors", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSlugUpdateError({ + status: 500, + body: { detail: "Server error" }, + }); + + const result = await t.run("slug", "reset"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Server error"); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 8d10f02a1..b010e30c4 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -225,6 +225,37 @@ interface DeploymentFinalizeResponse { deployment_id: string; } +interface DomainPayload { + hostname: string; + cname_target: string; + status: string | null; + ssl_status: string | null; + active: boolean; + pending_deployment?: boolean; + verification: { + ownership_verification?: unknown; + ownership_verification_http?: unknown; + ssl_validation_records?: unknown[] | null; + ssl_validation_errors?: unknown[] | null; + }; +} + +interface DomainsListResponse { + domains: DomainPayload[]; +} + +interface RemoveDomainResponse { + hostname: string; + deleted: boolean; +} + +/** The app document (only the fields the CLI's slug commands consume). */ +interface AppPayload { + id: string; + name?: string; + slug?: string | null; +} + /** A parsed part of a multipart/form-data request body. */ interface MultipartField { name: string; @@ -323,7 +354,7 @@ interface ErrorResponse { // ─── ROUTE HANDLER TYPES ───────────────────────────────────── -type Method = "GET" | "POST" | "PUT" | "DELETE"; +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RouteEntry { method: Method; @@ -401,6 +432,7 @@ export class TestAPIServer { | "get" | "post" | "put" + | "patch" | "delete"; this.app[method](entry.path, entry.handler); } @@ -762,6 +794,81 @@ export class TestAPIServer { return this; } + // ─── CUSTOM DOMAIN ENDPOINTS ───────────────────────────── + + /** Captured JSON bodies of POST domains requests. */ + readonly domainAddRequests: unknown[] = []; + + /** Mock POST /api/apps/{appId}/domains (connect a domain). */ + mockDomainAdd(response: DomainPayload): this { + this.pendingRoutes.push({ + method: "POST", + path: `/api/apps/${this.appId}/domains`, + handler: (req, res) => { + this.domainAddRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; + } + + mockDomainAddError(error: ErrorResponse): this { + return this.addErrorRoute("POST", `/api/apps/${this.appId}/domains`, error); + } + + /** Mock GET /api/apps/{appId}/domains (list). */ + mockDomainList(response: DomainsListResponse): this { + return this.addRoute("GET", `/api/apps/${this.appId}/domains`, response); + } + + /** Mock DELETE /api/apps/{appId}/domains/{hostname}. */ + mockDomainRemove(hostname: string, response: RemoveDomainResponse): this { + return this.addRoute( + "DELETE", + `/api/apps/${this.appId}/domains/${encodeURIComponent(hostname)}`, + response, + ); + } + + mockDomainRemoveError(hostname: string, error: ErrorResponse): this { + return this.addErrorRoute( + "DELETE", + `/api/apps/${this.appId}/domains/${encodeURIComponent(hostname)}`, + error, + ); + } + + // ─── SLUG ENDPOINTS ────────────────────────────────────── + + /** Mock GET /api/apps/{appId} (the app document; used for slug reads). */ + mockAppGet(response: AppPayload): this { + return this.addRoute("GET", `/api/apps/${this.appId}`, response); + } + + /** Captured JSON bodies of PATCH metadata/slug requests. */ + readonly slugUpdateRequests: unknown[] = []; + + /** Mock PATCH /api/apps/{appId}/metadata/slug (returns the updated app). */ + mockSlugUpdate(response: AppPayload): this { + this.pendingRoutes.push({ + method: "PATCH", + path: `/api/apps/${this.appId}/metadata/slug`, + handler: (req, res) => { + this.slugUpdateRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; + } + + mockSlugUpdateError(error: ErrorResponse): this { + return this.addErrorRoute( + "PATCH", + `/api/apps/${this.appId}/metadata/slug`, + error, + ); + } + // ─── SECRETS ENDPOINTS ─────────────────────────────────── mockSecretsList(response: SecretsListResponse): this {