diff --git a/src/cli/commands/dashboard/index.ts b/src/cli/commands/dashboard/index.ts new file mode 100644 index 00000000..7d63e9b0 --- /dev/null +++ b/src/cli/commands/dashboard/index.ts @@ -0,0 +1,9 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getDashboardOpenCommand } from "./open.js"; + +export function getDashboardCommand(context: CLIContext): Command { + return new Command("dashboard") + .description("Manage app dashboard") + .addCommand(getDashboardOpenCommand(context)); +} diff --git a/src/cli/commands/project/dashboard.ts b/src/cli/commands/dashboard/open.ts similarity index 86% rename from src/cli/commands/project/dashboard.ts rename to src/cli/commands/dashboard/open.ts index 7174ac9e..bcf02565 100644 --- a/src/cli/commands/project/dashboard.ts +++ b/src/cli/commands/dashboard/open.ts @@ -14,8 +14,8 @@ async function openDashboard(): Promise { return { outroMessage: `Dashboard opened at ${dashboardUrl}` }; } -export function getDashboardCommand(context: CLIContext): Command { - return new Command("dashboard") +export function getDashboardOpenCommand(context: CLIContext): Command { + return new Command("open") .description("Open the app dashboard in your browser") .action(async () => { await runCommand(openDashboard, { requireAuth: true }, context); diff --git a/src/cli/commands/site/deploy.ts b/src/cli/commands/site/deploy.ts index b33efa6b..5c64337b 100644 --- a/src/cli/commands/site/deploy.ts +++ b/src/cli/commands/site/deploy.ts @@ -53,14 +53,10 @@ async function deployAction(options: DeployOptions): Promise { } export function getSiteDeployCommand(context: CLIContext): Command { - return new Command("site") - .description("Manage site deployments") - .addCommand( - new Command("deploy") - .description("Deploy built site files to Base44 hosting") - .option("-y, --yes", "Skip confirmation prompt") - .action(async (options: DeployOptions) => { - await runCommand(() => deployAction(options), { requireAuth: true }, context); - }) - ); + return new Command("deploy") + .description("Deploy built site files to Base44 hosting") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (options: DeployOptions) => { + await runCommand(() => deployAction(options), { requireAuth: true }, context); + }); } diff --git a/src/cli/commands/site/index.ts b/src/cli/commands/site/index.ts new file mode 100644 index 00000000..775e816c --- /dev/null +++ b/src/cli/commands/site/index.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getSiteDeployCommand } from "./deploy.js"; +import { getSiteOpenCommand } from "./open.js"; + +export function getSiteCommand(context: CLIContext): Command { + return new Command("site") + .description("Manage app site (frontend app)") + .addCommand(getSiteDeployCommand(context)) + .addCommand(getSiteOpenCommand(context)); +} diff --git a/src/cli/commands/site/open.ts b/src/cli/commands/site/open.ts new file mode 100644 index 00000000..94fbcd71 --- /dev/null +++ b/src/cli/commands/site/open.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import open from "open"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand } from "@/cli/utils/index.js"; +import { getSiteUrl } from "@/core/site/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; + +async function openAction(): Promise { + const siteUrl = await getSiteUrl(); + + if (!process.env.CI) { + await open(siteUrl); + } + + return { outroMessage: `Site opened at ${siteUrl}` }; +} + +export function getSiteOpenCommand(context: CLIContext): Command { + return new Command("open") + .description("Open the published site in your browser") + .action(async () => { + await runCommand(openAction, { requireAuth: true }, context); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 77474222..bc98e4e8 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -7,10 +7,10 @@ import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; -import { getDashboardCommand } from "@/cli/commands/project/dashboard.js"; +import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; import { getLinkCommand } from "@/cli/commands/project/link.js"; -import { getSiteDeployCommand } from "@/cli/commands/site/deploy.js"; +import { getSiteCommand } from "@/cli/commands/site/index.js"; import packageJson from "../../package.json"; export function createProgram(context: CLIContext): Command { @@ -48,7 +48,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getFunctionsDeployCommand(context)); // Register site commands - program.addCommand(getSiteDeployCommand(context)); + program.addCommand(getSiteCommand(context)); return program; } diff --git a/src/core/site/api.ts b/src/core/site/api.ts index 983c74a4..4eda7cb6 100644 --- a/src/core/site/api.ts +++ b/src/core/site/api.ts @@ -1,6 +1,7 @@ -import { getAppClient } from "@/core/clients/index.js"; +import { getAppClient, base44Client } from "@/core/clients/index.js"; +import { getAppConfig } from "@/core/project/index.js"; import { readFile } from "@/core/utils/fs.js"; -import { DeployResponseSchema } from "@/core/site/schema.js"; +import { DeployResponseSchema, PublishedUrlResponseSchema } from "@/core/site/schema.js"; import type { DeployResponse } from "@/core/site/schema.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; @@ -35,3 +36,22 @@ export async function uploadSite(archivePath: string): Promise { return result.data; } + +export async function getSiteUrl(projectId?: string): Promise { + const id = projectId ?? getAppConfig().id; + + let response; + try { + response = await base44Client.get(`api/apps/platform/${id}/published-url`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching site URL"); + } + + const result = PublishedUrlResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError("Invalid response from server", result.error); + } + + return result.data.url; +} diff --git a/src/core/site/schema.ts b/src/core/site/schema.ts index dcffc39e..633d6e05 100644 --- a/src/core/site/schema.ts +++ b/src/core/site/schema.ts @@ -10,3 +10,7 @@ export const DeployResponseSchema = z.object({ })); export type DeployResponse = z.infer; + +export const PublishedUrlResponseSchema = z.object({ + url: z.string(), +}); diff --git a/tests/cli/dashboard.spec.ts b/tests/cli/dashboard_open.spec.ts similarity index 74% rename from tests/cli/dashboard.spec.ts rename to tests/cli/dashboard_open.spec.ts index 13f83c8a..61d4d4b6 100644 --- a/tests/cli/dashboard.spec.ts +++ b/tests/cli/dashboard_open.spec.ts @@ -1,23 +1,23 @@ import { describe, it } from "vitest"; import { setupCLITests, fixture } from "./testkit/index.js"; -describe("dashboard command", () => { +describe("dashboard open command", () => { const t = setupCLITests(); it("opens dashboard URL when in a project", async () => { await t.givenLoggedInWithProject(fixture("basic")); - const result = await t.run("dashboard"); + const result = await t.run("dashboard", "open"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Dashboard opened"); - t.expectResult(result).toContain("test-app-id"); // App ID from fixture + t.expectResult(result).toContain("test-app-id"); }); it("fails when not in a project directory", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - const result = await t.run("dashboard"); + const result = await t.run("dashboard", "open"); t.expectResult(result).toFail(); t.expectResult(result).toContain("No Base44 project found"); diff --git a/tests/cli/site_open.spec.ts b/tests/cli/site_open.spec.ts new file mode 100644 index 00000000..ad44e568 --- /dev/null +++ b/tests/cli/site_open.spec.ts @@ -0,0 +1,35 @@ +import { describe, it } from "vitest"; +import { setupCLITests, fixture } from "./testkit/index.js"; + +describe("site open command", () => { + const t = setupCLITests(); + + it("opens site URL when in a project", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + + const result = await t.run("site", "open"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Site opened"); + t.expectResult(result).toContain("https://my-app.base44.app"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("site", "open"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSiteUrlError({ status: 404, body: { detail: "App not found" } }); + + const result = await t.run("site", "open"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index f2ac3ed6..f4a8cb54 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -42,6 +42,10 @@ export interface SiteDeployResponse { app_url: string; } +export interface SiteUrlResponse { + url: string; +} + export interface AgentsPushResponse { created: string[]; updated: string[]; @@ -145,6 +149,16 @@ export class Base44APIMock { return this; } + /** Mock GET /api/apps/platform/{appId}/published-url - Get site URL */ + mockSiteUrl(response: SiteUrlResponse): this { + this.handlers.push( + http.get(`${BASE_URL}/api/apps/platform/${this.appId}/published-url`, () => + HttpResponse.json(response) + ) + ); + return this; + } + /** Mock PUT /api/apps/{appId}/agent-configs - Push agents */ mockAgentsPush(response: AgentsPushResponse): this { this.handlers.push( @@ -201,6 +215,11 @@ export class Base44APIMock { return this.mockError("post", `/api/apps/${this.appId}/deploy-dist`, error); } + /** Mock site URL to return an error */ + mockSiteUrlError(error: ErrorResponse): this { + return this.mockError("get", `/api/apps/platform/${this.appId}/published-url`, error); + } + /** Mock agents push to return an error */ mockAgentsPushError(error: ErrorResponse): this { return this.mockError("put", `/api/apps/${this.appId}/agent-configs`, error);