diff --git a/packages/cli/README.md b/packages/cli/README.md index afe37e3a5..fff90b575 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -70,6 +70,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`sandbox checkpoint`](https://docs.base44.com/developers/references/cli/commands/sandbox-checkpoint) | Save a restore point in the builder's version history; run it after each unit of work, since sandbox writes are committed but not checkpointed and a Restore or Revert in the builder discards everything after the last checkpoint | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | | [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser | +| `drop [dir]` | Host a folder of static files (index.html at its root) on Base44 as a new app | | [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources | ## Global flags diff --git a/packages/cli/src/cli/commands/drop.ts b/packages/cli/src/cli/commands/drop.ts new file mode 100644 index 000000000..632e2ad40 --- /dev/null +++ b/packages/cli/src/cli/commands/drop.ts @@ -0,0 +1,35 @@ +import { resolve } from "node:path"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { dropSite } from "@/core/site/index.js"; + +async function dropAction( + { runTask, jsonMode }: CLIContext, + dir: string, +): Promise { + const { appId, appUrl } = await runTask( + "Creating app and deploying site...", + async () => await dropSite(resolve(dir)), + { + successMessage: theme.colors.base44Orange("Site deployed successfully"), + errorMessage: "Drop failed", + }, + ); + + return { + outroMessage: `Visit your site at: ${theme.colors.links(appUrl)}`, + stdout: jsonMode + ? `${JSON.stringify({ appId, appUrl }, null, 2)}\n` + : undefined, + }; +} + +export function getDropCommand(): Command { + return new Base44Command("drop", { requireAppContext: false }) + .description( + "Host a folder of static files (index.html at its root) on Base44 as a new app", + ) + .argument("[dir]", "Folder to deploy", ".") + .action(dropAction); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 06d896cd7..dc18fa501 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -9,6 +9,7 @@ import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getBranchesCommand } from "@/cli/commands/branches/index.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; +import { getDropCommand } from "@/cli/commands/drop.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"; @@ -82,6 +83,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getDashboardCommand()); program.addCommand(getBuildCommand()); program.addCommand(getDeployCommand()); + program.addCommand(getDropCommand()); program.addCommand(getVisibilityCommand()); program.addCommand(getLinkCommand()); program.addCommand(getEjectCommand()); diff --git a/packages/cli/src/core/site/drop.ts b/packages/cli/src/core/site/drop.ts new file mode 100644 index 000000000..8d4672bd0 --- /dev/null +++ b/packages/cli/src/core/site/drop.ts @@ -0,0 +1,23 @@ +import { basename, join } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; +import { createProject } from "@/core/project/api.js"; +import { setAppContext } from "@/core/project/app-config.js"; +import { deploySite } from "@/core/site/deploy.js"; +import { pathExists } from "@/core/utils/fs.js"; + +async function requireIndexHtml(siteDir: string) { + if (!(await pathExists(join(siteDir, "index.html")))) { + throw new InvalidInputError( + `No index.html found in "${siteDir}" — a static site needs one at the folder root.`, + ); + } +} + +export async function dropSite(siteDir: string) { + await requireIndexHtml(siteDir); + const name = basename(siteDir); + const { projectId } = await createProject(name, `Static site '${name}'`); + setAppContext({ id: projectId }); + const { appUrl } = await deploySite(siteDir); + return { appId: projectId, appUrl }; +} diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index d95877b04..be30c274d 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -2,6 +2,7 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; export * from "./deployment.js"; +export * from "./drop.js"; export * from "./full-stack.js"; export * from "./git-hash.js"; export * from "./manifest.js"; diff --git a/packages/cli/tests/cli/drop.spec.ts b/packages/cli/tests/cli/drop.spec.ts new file mode 100644 index 000000000..55d5a2d2e --- /dev/null +++ b/packages/cli/tests/cli/drop.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const APP_URL = "https://my-site.base44.app"; + +describe("drop command", () => { + const t = setupCLITests(); + + it("creates an app named after the folder and deploys it", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + await t.givenUnlinkedProject(fixture("static-drop")); + t.api.mockCreateApp({ id: t.api.appId, name: "my-site" }); + t.api.mockSiteDeploy({ app_url: APP_URL }); + + const result = await t.run("drop", "my-site"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Site deployed successfully"); + t.expectResult(result).toContain(APP_URL); + expect(t.api.createAppRequests).toEqual([ + expect.objectContaining({ + name: "my-site", + is_managed_source_code: false, + }), + ]); + }); + + it("prints the app id and URL as JSON under --json", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + await t.givenUnlinkedProject(fixture("static-drop")); + t.api.mockCreateApp({ id: t.api.appId, name: "my-site" }); + t.api.mockSiteDeploy({ app_url: APP_URL }); + + const result = await t.run("drop", "my-site", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + appId: t.api.appId, + appUrl: APP_URL, + }); + }); + + it("fails before creating an app when the folder has no index.html", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + await t.givenUnlinkedProject(fixture("static-drop")); + t.api.mockCreateApp({ id: t.api.appId, name: "no-index" }); + + const result = await t.run("drop", "no-index"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No index.html found"); + expect(t.api.createAppRequests).toEqual([]); + }); + + it("reports the server error when the deploy fails", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + await t.givenUnlinkedProject(fixture("static-drop")); + t.api.mockCreateApp({ id: t.api.appId, name: "my-site" }); + t.api.mockSiteDeployError({ status: 413, body: { error: "Too large" } }); + + const result = await t.run("drop", "my-site"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Too large"); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 946a6f585..cf265a146 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -986,8 +986,19 @@ export class TestAPIServer { // ─── GENERAL ENDPOINTS ─────────────────────────────────── + /** Captured JSON bodies of POST /api/apps requests. */ + readonly createAppRequests: unknown[] = []; + mockCreateApp(response: CreateAppResponse): this { - return this.addRoute("POST", "/api/apps", response); + this.pendingRoutes.push({ + method: "POST", + path: "/api/apps", + handler: (req, res) => { + this.createAppRequests.push(req.body); + res.status(200).json(response); + }, + }); + return this; } mockListProjects(response: ListProjectsResponse[]): this { diff --git a/packages/cli/tests/fixtures/static-drop/my-site/assets/style.css b/packages/cli/tests/fixtures/static-drop/my-site/assets/style.css new file mode 100644 index 000000000..6c8438155 --- /dev/null +++ b/packages/cli/tests/fixtures/static-drop/my-site/assets/style.css @@ -0,0 +1 @@ +h1 { color: tomato; } diff --git a/packages/cli/tests/fixtures/static-drop/my-site/index.html b/packages/cli/tests/fixtures/static-drop/my-site/index.html new file mode 100644 index 000000000..1c9d70a22 --- /dev/null +++ b/packages/cli/tests/fixtures/static-drop/my-site/index.html @@ -0,0 +1,2 @@ + +

Dropped

diff --git a/packages/cli/tests/fixtures/static-drop/no-index/about.html b/packages/cli/tests/fixtures/static-drop/no-index/about.html new file mode 100644 index 000000000..88c6fa2e6 --- /dev/null +++ b/packages/cli/tests/fixtures/static-drop/no-index/about.html @@ -0,0 +1 @@ +

not the entry