diff --git a/AGENTS.md b/AGENTS.md index 8763617bb..a053abbfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,12 @@ cli/ │ │ │ │ ├── resource.ts │ │ │ │ └── index.ts │ │ │ └── index.ts +│ │ ├── site/ # Site deployment (NOT a Resource) +│ │ │ ├── schema.ts # DeployResponse Zod schema +│ │ │ ├── config.ts # getSiteFilePaths() - glob files for validation +│ │ │ ├── api.ts # uploadSite() - reads archive, sends to API +│ │ │ ├── deploy.ts # deploySite() - validates, creates tar.gz, uploads +│ │ │ └── index.ts │ │ ├── utils/ │ │ │ ├── fs.ts # File system utilities │ │ │ └── index.ts @@ -78,6 +84,8 @@ cli/ │ │ │ └── create.ts │ │ └── entities/ │ │ └── push.ts +│ │ └── site/ +│ │ └── deploy.ts │ ├── utils/ │ │ ├── runCommand.ts # Command wrapper with branding │ │ ├── runTask.ts # Spinner wrapper @@ -231,6 +239,48 @@ export const entityResource: Resource = { 8. Register in `project/config.ts` (add to `readProjectConfig`) 9. Add typed field to `ProjectData` interface +## Site Module + +The site module (`src/core/site/`) handles deploying built frontend files to Base44 hosting. Unlike Resources, the site module: + +- Reads built artifacts (JS, CSS, HTML) from the output directory +- Gets configuration from `site.outputDirectory` in project config +- Creates a tar.gz archive and uploads it to the API + +### Architecture + +``` +site/ +├── schema.ts # DeployResponse Zod schema +├── config.ts # getSiteFilePaths() - glob files for validation +├── api.ts # uploadSite() - reads archive, sends to API +├── deploy.ts # deploySite() - validates, creates archive, uploads +└── index.ts # Barrel exports +``` + +### Key Functions + +```typescript +import { deploySite } from "@core/site/index.js"; + +// Deploy site from output directory (returns deployment details) +const { app_url, files_count } = await deploySite("./dist"); +``` + +### Deploy Flow + +1. Validate output directory exists and has files +2. Create temporary tar.gz archive using `tar` package +3. Upload archive to `POST /api/apps/{app_id}/deploy-dist` +4. Parse response with Zod schema +5. Clean up temporary archive file + +### CLI Command + +```bash +base44 site deploy +``` + ## Path Aliases Single alias defined in `tsconfig.json`: diff --git a/README.md b/README.md index 48fb0b9ba..9b5ad0ccd 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ base44 create # 3. Push entities to Base44 base44 entities push + +# 4. Build and deploy your site +npm run build +base44 site deploy ``` ## Commands @@ -49,6 +53,12 @@ base44 entities push |---------|-------------| | `base44 entities push` | Push local entity schemas to Base44 | +### Site Deployment + +| Command | Description | +|---------|-------------| +| `base44 site deploy` | Deploy built site files to Base44 hosting | + ## Configuration ### Project Configuration @@ -61,7 +71,10 @@ Base44 projects are configured via a `config.jsonc` (or `config.json`) file in t "id": "your-app-id", // Set after project creation "name": "My Project", "entitiesDir": "./entities", // Default: ./entities - "functionsDir": "./functions" // Default: ./functions + "functionsDir": "./functions", // Default: ./functions + "site": { + "outputDirectory": "../dist" // Path to built site files + } } ``` @@ -91,6 +104,7 @@ my-project/ │ │ ├── user.jsonc │ │ └── product.jsonc ├── src/ # Your frontend code +├── dist/ # Built site files (for deployment) └── package.json ``` diff --git a/package-lock.json b/package-lock.json index c5c9f3b04..4670ed4bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/ejs": "^3.1.5", "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.10.5", + "@types/tar": "^6.1.13", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "chalk": "^5.6.2", @@ -31,6 +32,7 @@ "ky": "^1.14.2", "lodash.kebabcase": "^4.1.1", "p-wait-for": "^6.0.0", + "tar": "^7.4.3", "tsdown": "^0.12.4", "tsx": "^4.19.2", "typescript": "^5.7.2", @@ -862,6 +864,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1689,6 +1704,27 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, + "node_modules/@types/tar/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.52.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", @@ -2509,6 +2545,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -4711,6 +4757,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5790,6 +5859,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6494,6 +6580,16 @@ "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 78ebedc94..1d7c2b1af 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/ejs": "^3.1.5", "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.10.5", + "@types/tar": "^6.1.13", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "chalk": "^5.6.2", @@ -52,6 +53,7 @@ "ky": "^1.14.2", "lodash.kebabcase": "^4.1.1", "p-wait-for": "^6.0.0", + "tar": "^7.4.3", "tsdown": "^0.12.4", "tsx": "^4.19.2", "typescript": "^5.7.2", diff --git a/src/cli/commands/site/deploy.ts b/src/cli/commands/site/deploy.ts new file mode 100644 index 000000000..5fd474a28 --- /dev/null +++ b/src/cli/commands/site/deploy.ts @@ -0,0 +1,50 @@ +import { resolve } from "node:path"; +import { Command } from "commander"; +import { log, confirm, isCancel } from "@clack/prompts"; +import { readProjectConfig } from "@core/project/index.js"; +import { deploySite } from "@core/site/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; + +async function deployAction(): Promise { + const { project } = await readProjectConfig(); + + if (!project.site?.outputDirectory) { + throw new Error( + "No site configuration found. Please add 'site.outputDirectory' to your config.jsonc" + ); + } + + const outputDir = resolve(project.root, project.site.outputDirectory); + + const shouldDeploy = await confirm({ + message: `Deploy site from ${project.site.outputDirectory}?`, + }); + + if (isCancel(shouldDeploy) || !shouldDeploy) { + log.warn("Deployment cancelled"); + return; + } + + const result = await runTask( + "Creating archive and deploying site...", + async () => { + return await deploySite(outputDir); + }, + { + successMessage: "Site deployed successfully", + errorMessage: "Deployment failed", + } + ); + + log.success(`Site deployed to: ${result.app_url}`); +} + +export const siteDeployCommand = new Command("site") + .description("Manage site deployments") + .addCommand( + new Command("deploy") + .description("Deploy built site files to Base44 hosting") + .action(async () => { + await runCommand(deployAction, { requireAuth: true }); + }) + ); diff --git a/src/cli/index.ts b/src/cli/index.ts index 265dcd5a7..8b3576d2b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { whoamiCommand } from "./commands/auth/whoami.js"; import { logoutCommand } from "./commands/auth/logout.js"; import { entitiesPushCommand } from "./commands/entities/push.js"; import { createCommand } from "./commands/project/create.js"; +import { siteDeployCommand } from "./commands/site/deploy.js"; import packageJson from "../../package.json"; const program = new Command(); @@ -28,5 +29,8 @@ program.addCommand(createCommand); // Register entities commands program.addCommand(entitiesPushCommand); +// Register site commands +program.addCommand(siteDeployCommand); + // Parse command line arguments program.parse(); diff --git a/src/cli/utils/runTask.ts b/src/cli/utils/runTask.ts index ff339bef8..e602d6efb 100644 --- a/src/cli/utils/runTask.ts +++ b/src/cli/utils/runTask.ts @@ -5,11 +5,13 @@ import { spinner } from "@clack/prompts"; * The spinner is automatically started, and stopped on both success and error. * * @param startMessage - Message to show when spinner starts - * @param operation - The async operation to execute + * @param operation - The async operation to execute. Receives an updateMessage function + * to update the spinner text during long-running operations. * @param options - Optional configuration for success/error messages * @returns The result of the operation * * @example + * // Simple usage * const data = await runTask( * "Fetching data...", * async () => { @@ -21,10 +23,24 @@ import { spinner } from "@clack/prompts"; * errorMessage: "Failed to fetch data", * } * ); + * + * @example + * // With progress updates + * const result = await runTask( + * "Processing files...", + * async (updateMessage) => { + * for (const file of files) { + * updateMessage(`Processing ${file.name}...`); + * await process(file); + * } + * return files.length; + * }, + * { successMessage: "All files processed" } + * ); */ export async function runTask( startMessage: string, - operation: () => Promise, + operation: (updateMessage: (message: string) => void) => Promise, options?: { successMessage?: string; errorMessage?: string; @@ -33,8 +49,10 @@ export async function runTask( const s = spinner(); s.start(startMessage); + const updateMessage = (message: string) => s.message(message); + try { - const result = await operation(); + const result = await operation(updateMessage); s.stop(options?.successMessage || startMessage); return result; } catch (error) { diff --git a/src/core/index.ts b/src/core/index.ts index c1608eb16..8f600d4c1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,6 +2,7 @@ export * from "./auth/index.js"; export * from "./resources/index.js"; export * from "./project/index.js"; export * from "./clients/index.js"; +export * from "./site/index.js"; export * from "./utils/index.js"; export * from "./errors.js"; export * from "./consts.js"; diff --git a/src/core/site/api.ts b/src/core/site/api.ts new file mode 100644 index 000000000..0083a88d4 --- /dev/null +++ b/src/core/site/api.ts @@ -0,0 +1,27 @@ +import { getAppClient } from "@core/clients/index.js"; +import { readFile } from "../utils/fs.js"; +import { DeployResponseSchema } from "./schema.js"; +import type { DeployResponse } from "./schema.js"; + +/** + * Uploads a tar.gz archive file to the Base44 hosting API. + * + * @param archivePath - Path to the tar.gz archive file + * @returns Deploy response with the site URL and deployment details + * @throws Error if file read or upload fails + */ +export async function uploadSite(archivePath: string): Promise { + const archiveBuffer = await readFile(archivePath); + const blob = new Blob([archiveBuffer], { type: "application/gzip" }); + const formData = new FormData(); + formData.append("file", blob, "dist.tar.gz"); + + const appClient = getAppClient(); + const response = await appClient.post("deploy-dist", { + body: formData, + }); + + const result = DeployResponseSchema.parse(await response.json()); + + return result; +} diff --git a/src/core/site/config.ts b/src/core/site/config.ts new file mode 100644 index 000000000..90f87d258 --- /dev/null +++ b/src/core/site/config.ts @@ -0,0 +1,16 @@ +import { globby } from "globby"; + +/** + * Gets all file paths in the output directory. + * Used to check if the directory contains any files before deployment. + * + * @param outputDir - The directory containing built site files + * @returns Array of relative file paths + */ +export async function getSiteFilePaths(outputDir: string): Promise { + return await globby("**/*", { + cwd: outputDir, + onlyFiles: true, + absolute: false, + }); +} diff --git a/src/core/site/deploy.ts b/src/core/site/deploy.ts new file mode 100644 index 000000000..c1beebbac --- /dev/null +++ b/src/core/site/deploy.ts @@ -0,0 +1,53 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { create as tarCreate } from "tar"; +import { getBase44ClientId } from "@core/config.js"; +import type { DeployResponse } from "./schema.js"; +import { getSiteFilePaths } from "./config.js"; +import { uploadSite } from "./api.js"; +import { pathExists, deleteFile } from "../utils/fs.js"; + +export async function deploySite( + siteOutputDir: string +): Promise { + if (!(await pathExists(siteOutputDir))) { + throw new Error( + `Output directory does not exist: ${siteOutputDir}. Make sure to build your project first.` + ); + } + + const filePaths = await getSiteFilePaths(siteOutputDir); + if (filePaths.length === 0) { + throw new Error( + `No files found in output directory: ${siteOutputDir}. Make sure to build your project first.` + ); + } + + // Create a temporary file for the archive + const archivePath = join( + tmpdir(), + `base44-site-${getBase44ClientId()}-${randomUUID().toString()}.tar.gz` + ); + + try { + await createArchive(siteOutputDir, archivePath); + return await uploadSite(archivePath); + } finally { + await deleteFile(archivePath); + } +} + +async function createArchive( + pathToArchive: string, + targetArchivePath: string +): Promise { + await tarCreate( + { + gzip: true, + file: targetArchivePath, + cwd: pathToArchive, + }, + ["."] + ); +} diff --git a/src/core/site/index.ts b/src/core/site/index.ts new file mode 100644 index 000000000..593462fcd --- /dev/null +++ b/src/core/site/index.ts @@ -0,0 +1,4 @@ +export * from "./schema.js"; +export * from "./config.js"; +export * from "./api.js"; +export * from "./deploy.js"; \ No newline at end of file diff --git a/src/core/site/schema.ts b/src/core/site/schema.ts new file mode 100644 index 000000000..479f9c02a --- /dev/null +++ b/src/core/site/schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +/** + * Response from the deploy API endpoint. + */ +export const DeployResponseSchema = z.object({ + /** Whether the deployment was successful */ + success: z.boolean(), + /** The app ID */ + app_id: z.string(), + /** Number of files deployed */ + files_count: z.number(), + /** Total size of deployed files in bytes */ + total_size_bytes: z.number(), + /** Timestamp when deployment completed */ + deployed_at: z.string(), + /** The URL where the site is deployed */ + app_url: z.string().url(), +}); + +export type DeployResponse = z.infer; diff --git a/src/core/utils/fs.ts b/src/core/utils/fs.ts index 471724de8..ee34fc8b9 100644 --- a/src/core/utils/fs.ts +++ b/src/core/utils/fs.ts @@ -37,6 +37,22 @@ export async function copyFile(src: string, dest: string): Promise { await fsCopyFile(src, dest); } +export async function readFile(filePath: string): Promise { + if (!(await pathExists(filePath))) { + throw new Error(`File not found: ${filePath}`); + } + + try { + return await fsReadFile(filePath); + } catch (error) { + throw new Error( + `Failed to read file ${filePath}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } +} + export async function readJsonFile(filePath: string): Promise { if (!(await pathExists(filePath))) { throw new Error(`File not found: ${filePath}`);