From dcf727073f6e342181d5cd04a47ab966b7844621 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Tue, 13 Jan 2026 18:17:23 +0200 Subject: [PATCH 1/6] basic implementation of site deploy command --- AGENTS.md | 31 ++++++++++++++++++ src/cli/commands/site/deploy.ts | 56 +++++++++++++++++++++++++++++++++ src/cli/index.ts | 4 +++ src/core/index.ts | 1 + src/core/site/api.ts | 27 ++++++++++++++++ src/core/site/config.ts | 43 +++++++++++++++++++++++++ src/core/site/deploy.ts | 27 ++++++++++++++++ src/core/site/index.ts | 4 +++ src/core/site/schema.ts | 24 ++++++++++++++ 9 files changed, 217 insertions(+) create mode 100644 src/cli/commands/site/deploy.ts create mode 100644 src/core/site/api.ts create mode 100644 src/core/site/config.ts create mode 100644 src/core/site/deploy.ts create mode 100644 src/core/site/index.ts create mode 100644 src/core/site/schema.ts diff --git a/AGENTS.md b/AGENTS.md index 8763617bb..5bda0ae88 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 # SiteFile, DeployResponse schemas +│ │ │ ├── config.ts # readSiteFiles() - glob and encode files +│ │ │ ├── api.ts # uploadSite() - API call to hosting +│ │ │ ├── deploy.ts # deploySite() - orchestrates read + upload +│ │ │ └── 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,29 @@ 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) not config files +- Gets configuration from `site.outputDirectory` in project config +- Uploads binary files as base64-encoded payloads + +### Key Functions + +```typescript +import { deploySite } from "@core/site/index.js"; + +// Deploy site from output directory (returns site URL) +const { url } = await deploySite("./dist"); +``` + +### CLI Command + +```bash +base44 site deploy +``` + ## Path Aliases Single alias defined in `tsconfig.json`: diff --git a/src/cli/commands/site/deploy.ts b/src/cli/commands/site/deploy.ts new file mode 100644 index 000000000..e24c834fa --- /dev/null +++ b/src/cli/commands/site/deploy.ts @@ -0,0 +1,56 @@ +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 { + // 1. Load project config + const { project } = await readProjectConfig(); + + // 2. Validate site configuration exists + if (!project.site?.outputDirectory) { + log.error( + "No site configuration found. Please add a 'site.outputDirectory' to your config.jsonc" + ); + process.exit(1); + } + + const outputDir = resolve(project.root, project.site.outputDirectory); + + // 3. Confirm with user + const shouldDeploy = await confirm({ + message: `Deploy site from ${project.site.outputDirectory}?`, + }); + + if (isCancel(shouldDeploy) || !shouldDeploy) { + log.warn("Deployment cancelled"); + process.exit(0); + } + + // 4. Deploy to Base44 + const result = await runTask( + "Deploying site...", + async () => { + return await deploySite(outputDir); + }, + { + successMessage: "Site deployed successfully", + errorMessage: "Failed to deploy site", + } + ); + + // 5. Display the deployed URL + log.success(`Site deployed to: ${result.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); + }) + ); 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/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..b1fba0ceb --- /dev/null +++ b/src/core/site/api.ts @@ -0,0 +1,27 @@ +import type { SiteFile, DeployResponse } from "./schema.js"; + +/** + * Uploads site files to the Base44 hosting API. + * + * @param files - Array of files with base64-encoded content to upload + * @returns Deploy response with the site URL + * + * @example + * const files = await readSiteFiles("./dist"); + * const { url } = await uploadSite(files); + */ +export async function uploadSite(files: SiteFile[]): Promise { + // TODO: Implement actual FormData upload to Base44 API + // The endpoint will accept multipart/form-data with all files + // and return the deployed site URL + + // Placeholder implementation - simulate API call + await Promise.resolve(); + + // Log file count for debugging (remove when implementing real API) + console.log(`[Placeholder] Would upload ${files.length} files`); + + return { + url: "https://example.base44.app", + }; +} diff --git a/src/core/site/config.ts b/src/core/site/config.ts new file mode 100644 index 000000000..f62146932 --- /dev/null +++ b/src/core/site/config.ts @@ -0,0 +1,43 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { globby } from "globby"; +import type { SiteFile } from "./schema.js"; + +/** + * Reads all files from a site output directory and encodes them as base64. + * + * @param outputDir - The directory containing built site files (e.g., "dist") + * @returns Array of SiteFile objects with relative paths and base64 content + * + * @example + * const files = await readSiteFiles("./dist"); + * // files = [{ path: "index.html", content: "PGh0bWw+..." }, ...] + */ +export async function readSiteFiles(outputDir: string): Promise { + // Glob all files (not directories) in the output directory + const filePaths = await globby("**/*", { + cwd: outputDir, + onlyFiles: true, + absolute: false, + }); + + if (filePaths.length === 0) { + return []; + } + + // Read each file and encode as base64 + const files = await Promise.all( + filePaths.map(async (relativePath): Promise => { + const absolutePath = join(outputDir, relativePath); + const buffer = await readFile(absolutePath); + const content = buffer.toString("base64"); + + return { + path: relativePath, + content, + }; + }) + ); + + return files; +} diff --git a/src/core/site/deploy.ts b/src/core/site/deploy.ts new file mode 100644 index 000000000..140fc0a06 --- /dev/null +++ b/src/core/site/deploy.ts @@ -0,0 +1,27 @@ +import type { DeployResponse } from "./schema.js"; +import { readSiteFiles } from "./config.js"; +import { uploadSite } from "./api.js"; + +/** + * Deploys a site from the given output directory to Base44 hosting. + * Reads all files, validates, and uploads to the API. + * + * @param outputDir - The directory containing built site files (e.g., "./dist") + * @returns Deploy response with the site URL + * @throws Error if no files found in the output directory + * + * @example + * const { url } = await deploySite("./dist"); + * console.log(`Deployed to: ${url}`); + */ +export async function deploySite(outputDir: string): Promise { + const files = await readSiteFiles(outputDir); + + if (files.length === 0) { + throw new Error( + `No files found in output directory: ${outputDir}. Make sure to build your project first.` + ); + } + + return await uploadSite(files); +} 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..39f5e17b2 --- /dev/null +++ b/src/core/site/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * Represents a single file to be deployed. + * Contains the relative path and base64-encoded content. + */ +export const SiteFileSchema = z.object({ + /** Relative path from output directory (e.g., "index.html", "assets/main.js") */ + path: z.string(), + /** Base64-encoded file content */ + content: z.string(), +}); + +export type SiteFile = z.infer; + +/** + * Response from the deploy API endpoint. + */ +export const DeployResponseSchema = z.object({ + /** The URL where the site is deployed */ + url: z.string().url(), +}); + +export type DeployResponse = z.infer; From eb27f9249043ae68ca2a21f2d6d0d03aff892a5f Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Tue, 13 Jan 2026 18:37:09 +0200 Subject: [PATCH 2/6] added basic generator to show loaded files --- src/cli/commands/site/deploy.ts | 24 ++++++----- src/cli/utils/runTask.ts | 24 +++++++++-- src/core/site/api.ts | 8 +--- src/core/site/config.ts | 70 ++++++++++++++++++--------------- src/core/site/deploy.ts | 40 +++++++++++++++---- 5 files changed, 105 insertions(+), 61 deletions(-) diff --git a/src/cli/commands/site/deploy.ts b/src/cli/commands/site/deploy.ts index e24c834fa..0e91ce10b 100644 --- a/src/cli/commands/site/deploy.ts +++ b/src/cli/commands/site/deploy.ts @@ -6,42 +6,40 @@ import { deploySite } from "@core/site/index.js"; import { runCommand, runTask } from "../../utils/index.js"; async function deployAction(): Promise { - // 1. Load project config const { project } = await readProjectConfig(); - // 2. Validate site configuration exists if (!project.site?.outputDirectory) { - log.error( - "No site configuration found. Please add a 'site.outputDirectory' to your config.jsonc" + throw new Error( + "No site configuration found. Please add 'site.outputDirectory' to your config.jsonc" ); - process.exit(1); } const outputDir = resolve(project.root, project.site.outputDirectory); - // 3. Confirm with user const shouldDeploy = await confirm({ message: `Deploy site from ${project.site.outputDirectory}?`, }); if (isCancel(shouldDeploy) || !shouldDeploy) { log.warn("Deployment cancelled"); - process.exit(0); + return; } - // 4. Deploy to Base44 const result = await runTask( - "Deploying site...", - async () => { - return await deploySite(outputDir); + "Reading site files...", + async (updateMessage) => { + return await deploySite(outputDir, (progress) => { + updateMessage( + `Reading files (${progress.current}/${progress.total}): ${progress.path}` + ); + }); }, { successMessage: "Site deployed successfully", - errorMessage: "Failed to deploy site", + errorMessage: "Deployment failed", } ); - // 5. Display the deployed URL log.success(`Site deployed to: ${result.url}`); } 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/site/api.ts b/src/core/site/api.ts index b1fba0ceb..7a4aa3296 100644 --- a/src/core/site/api.ts +++ b/src/core/site/api.ts @@ -5,10 +5,6 @@ import type { SiteFile, DeployResponse } from "./schema.js"; * * @param files - Array of files with base64-encoded content to upload * @returns Deploy response with the site URL - * - * @example - * const files = await readSiteFiles("./dist"); - * const { url } = await uploadSite(files); */ export async function uploadSite(files: SiteFile[]): Promise { // TODO: Implement actual FormData upload to Base44 API @@ -16,10 +12,10 @@ export async function uploadSite(files: SiteFile[]): Promise { // and return the deployed site URL // Placeholder implementation - simulate API call - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 2000)); // Log file count for debugging (remove when implementing real API) - console.log(`[Placeholder] Would upload ${files.length} files`); + // console.log(`[Placeholder] Would upload ${files.length} files`); return { url: "https://example.base44.app", diff --git a/src/core/site/config.ts b/src/core/site/config.ts index f62146932..068596dc1 100644 --- a/src/core/site/config.ts +++ b/src/core/site/config.ts @@ -3,41 +3,49 @@ import { join } from "node:path"; import { globby } from "globby"; import type { SiteFile } from "./schema.js"; -/** - * Reads all files from a site output directory and encodes them as base64. - * - * @param outputDir - The directory containing built site files (e.g., "dist") - * @returns Array of SiteFile objects with relative paths and base64 content - * - * @example - * const files = await readSiteFiles("./dist"); - * // files = [{ path: "index.html", content: "PGh0bWw+..." }, ...] - */ -export async function readSiteFiles(outputDir: string): Promise { - // Glob all files (not directories) in the output directory - const filePaths = await globby("**/*", { +async function readSiteFile( + outputDir: string, + relativePath: string +): Promise { + const absolutePath = join(outputDir, relativePath); + const buffer = await readFile(absolutePath); + const content = buffer.toString("base64"); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + return { + path: relativePath, + content, + }; +} + +export async function getSiteFilePaths(outputDir: string): Promise { + return await globby("**/*", { cwd: outputDir, onlyFiles: true, absolute: false, }); +} - if (filePaths.length === 0) { - return []; +/** + * Reads site files one by one, yielding each file as it's read. + * Useful for showing progress during file reading. + * + * @param outputDir - The directory containing built site files + * @param filePaths - Array of relative file paths to read + * @yields Each file as it's read + * + * @example + * const paths = await getSiteFilePaths("./dist"); + * for await (const file of readSiteFilesStream("./dist", paths)) { + * console.log(`Read: ${file.path}`); + * } + */ +export async function* readSiteFilesStream( + outputDir: string, + filePaths: string[] +): AsyncGenerator { + for (const relativePath of filePaths) { + yield await readSiteFile(outputDir, relativePath); } - - // Read each file and encode as base64 - const files = await Promise.all( - filePaths.map(async (relativePath): Promise => { - const absolutePath = join(outputDir, relativePath); - const buffer = await readFile(absolutePath); - const content = buffer.toString("base64"); - - return { - path: relativePath, - content, - }; - }) - ); - - return files; } diff --git a/src/core/site/deploy.ts b/src/core/site/deploy.ts index 140fc0a06..39c95add2 100644 --- a/src/core/site/deploy.ts +++ b/src/core/site/deploy.ts @@ -1,27 +1,51 @@ -import type { DeployResponse } from "./schema.js"; -import { readSiteFiles } from "./config.js"; +import type { SiteFile, DeployResponse } from "./schema.js"; +import { getSiteFilePaths, readSiteFilesStream } from "./config.js"; import { uploadSite } from "./api.js"; +export interface DeploySiteProgress { + total: number; + current: number; + path: string; +} + /** * Deploys a site from the given output directory to Base44 hosting. - * Reads all files, validates, and uploads to the API. + * Reads files one by one with progress callback, validates, and uploads to the API. * * @param outputDir - The directory containing built site files (e.g., "./dist") + * @param onProgress - Optional callback called for each file read * @returns Deploy response with the site URL * @throws Error if no files found in the output directory * * @example - * const { url } = await deploySite("./dist"); - * console.log(`Deployed to: ${url}`); + * const { url } = await deploySite("./dist", (progress) => { + * console.log(`Reading ${progress.current}/${progress.total}: ${progress.path}`); + * }); */ -export async function deploySite(outputDir: string): Promise { - const files = await readSiteFiles(outputDir); +export async function deploySite( + outputDir: string, + onProgress?: (progress: DeploySiteProgress) => void +): Promise { + const filePaths = await getSiteFilePaths(outputDir); - if (files.length === 0) { + if (filePaths.length === 0) { throw new Error( `No files found in output directory: ${outputDir}. Make sure to build your project first.` ); } + const files: SiteFile[] = []; + let current = 0; + + for await (const file of readSiteFilesStream(outputDir, filePaths)) { + current++; + onProgress?.({ + total: filePaths.length, + current, + path: file.path, + }); + files.push(file); + } + return await uploadSite(files); } From 3fa66d4d988c98ee512950870afe330ad20cb6bf Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 15 Jan 2026 11:11:15 +0200 Subject: [PATCH 3/6] upload the site dist as a tar.gs file --- package-lock.json | 96 +++++++++++++++++++++++++++++++++ package.json | 2 + src/cli/commands/site/deploy.ts | 12 ++--- src/core/site/api.ts | 33 ++++++------ src/core/site/config.ts | 49 +++-------------- src/core/site/deploy.ts | 76 +++++++++++++------------- src/core/site/schema.ts | 25 ++++----- src/core/utils/fs.ts | 16 ++++++ 8 files changed, 193 insertions(+), 116 deletions(-) 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 index 0e91ce10b..1cbf051e4 100644 --- a/src/cli/commands/site/deploy.ts +++ b/src/cli/commands/site/deploy.ts @@ -26,13 +26,9 @@ async function deployAction(): Promise { } const result = await runTask( - "Reading site files...", - async (updateMessage) => { - return await deploySite(outputDir, (progress) => { - updateMessage( - `Reading files (${progress.current}/${progress.total}): ${progress.path}` - ); - }); + "Creating archive and deploying site...", + async () => { + return await deploySite(outputDir); }, { successMessage: "Site deployed successfully", @@ -40,7 +36,7 @@ async function deployAction(): Promise { } ); - log.success(`Site deployed to: ${result.url}`); + log.success(`Site deployed to: ${result.app_url}`); } export const siteDeployCommand = new Command("site") diff --git a/src/core/site/api.ts b/src/core/site/api.ts index 7a4aa3296..a8e3cfed5 100644 --- a/src/core/site/api.ts +++ b/src/core/site/api.ts @@ -1,23 +1,26 @@ -import type { SiteFile, DeployResponse } from "./schema.js"; +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 site files to the Base44 hosting API. + * Uploads a tar.gz archive file to the Base44 hosting API. * - * @param files - Array of files with base64-encoded content to upload - * @returns Deploy response with the site URL + * @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(files: SiteFile[]): Promise { - // TODO: Implement actual FormData upload to Base44 API - // The endpoint will accept multipart/form-data with all files - // and return the deployed site URL +export async function uploadSite(archivePath: string): Promise { + const archiveBuffer = await readFile(archivePath); + const formData = new FormData(); + formData.append("file", archiveBuffer, "dist.tar.gz"); - // Placeholder implementation - simulate API call - await new Promise((resolve) => setTimeout(resolve, 2000)); + const appClient = getAppClient(); + const response = await appClient.post("deploy-dist", { + body: formData, + }); - // Log file count for debugging (remove when implementing real API) - // console.log(`[Placeholder] Would upload ${files.length} files`); + const result = DeployResponseSchema.parse(await response.json()); - return { - url: "https://example.base44.app", - }; + return result; } diff --git a/src/core/site/config.ts b/src/core/site/config.ts index 068596dc1..90f87d258 100644 --- a/src/core/site/config.ts +++ b/src/core/site/config.ts @@ -1,24 +1,12 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { globby } from "globby"; -import type { SiteFile } from "./schema.js"; - -async function readSiteFile( - outputDir: string, - relativePath: string -): Promise { - const absolutePath = join(outputDir, relativePath); - const buffer = await readFile(absolutePath); - const content = buffer.toString("base64"); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - return { - path: relativePath, - content, - }; -} +/** + * 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, @@ -26,26 +14,3 @@ export async function getSiteFilePaths(outputDir: string): Promise { absolute: false, }); } - -/** - * Reads site files one by one, yielding each file as it's read. - * Useful for showing progress during file reading. - * - * @param outputDir - The directory containing built site files - * @param filePaths - Array of relative file paths to read - * @yields Each file as it's read - * - * @example - * const paths = await getSiteFilePaths("./dist"); - * for await (const file of readSiteFilesStream("./dist", paths)) { - * console.log(`Read: ${file.path}`); - * } - */ -export async function* readSiteFilesStream( - outputDir: string, - filePaths: string[] -): AsyncGenerator { - for (const relativePath of filePaths) { - yield await readSiteFile(outputDir, relativePath); - } -} diff --git a/src/core/site/deploy.ts b/src/core/site/deploy.ts index 39c95add2..c1beebbac 100644 --- a/src/core/site/deploy.ts +++ b/src/core/site/deploy.ts @@ -1,51 +1,53 @@ -import type { SiteFile, DeployResponse } from "./schema.js"; -import { getSiteFilePaths, readSiteFilesStream } from "./config.js"; +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 interface DeploySiteProgress { - total: number; - current: number; - path: string; -} - -/** - * Deploys a site from the given output directory to Base44 hosting. - * Reads files one by one with progress callback, validates, and uploads to the API. - * - * @param outputDir - The directory containing built site files (e.g., "./dist") - * @param onProgress - Optional callback called for each file read - * @returns Deploy response with the site URL - * @throws Error if no files found in the output directory - * - * @example - * const { url } = await deploySite("./dist", (progress) => { - * console.log(`Reading ${progress.current}/${progress.total}: ${progress.path}`); - * }); - */ export async function deploySite( - outputDir: string, - onProgress?: (progress: DeploySiteProgress) => void + siteOutputDir: string ): Promise { - const filePaths = await getSiteFilePaths(outputDir); + 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: ${outputDir}. Make sure to build your project first.` + `No files found in output directory: ${siteOutputDir}. Make sure to build your project first.` ); } - const files: SiteFile[] = []; - let current = 0; + // Create a temporary file for the archive + const archivePath = join( + tmpdir(), + `base44-site-${getBase44ClientId()}-${randomUUID().toString()}.tar.gz` + ); - for await (const file of readSiteFilesStream(outputDir, filePaths)) { - current++; - onProgress?.({ - total: filePaths.length, - current, - path: file.path, - }); - files.push(file); + try { + await createArchive(siteOutputDir, archivePath); + return await uploadSite(archivePath); + } finally { + await deleteFile(archivePath); } +} - return await uploadSite(files); +async function createArchive( + pathToArchive: string, + targetArchivePath: string +): Promise { + await tarCreate( + { + gzip: true, + file: targetArchivePath, + cwd: pathToArchive, + }, + ["."] + ); } diff --git a/src/core/site/schema.ts b/src/core/site/schema.ts index 39f5e17b2..479f9c02a 100644 --- a/src/core/site/schema.ts +++ b/src/core/site/schema.ts @@ -1,24 +1,21 @@ import { z } from "zod"; -/** - * Represents a single file to be deployed. - * Contains the relative path and base64-encoded content. - */ -export const SiteFileSchema = z.object({ - /** Relative path from output directory (e.g., "index.html", "assets/main.js") */ - path: z.string(), - /** Base64-encoded file content */ - content: z.string(), -}); - -export type SiteFile = z.infer; - /** * 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 */ - url: z.string().url(), + 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}`); From eebcf94bf8578b7e117986d98d66342e16431eca Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 15 Jan 2026 16:34:24 +0200 Subject: [PATCH 4/6] use blob --- src/core/site/api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/site/api.ts b/src/core/site/api.ts index a8e3cfed5..0083a88d4 100644 --- a/src/core/site/api.ts +++ b/src/core/site/api.ts @@ -12,8 +12,9 @@ import type { DeployResponse } from "./schema.js"; */ 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", archiveBuffer, "dist.tar.gz"); + formData.append("file", blob, "dist.tar.gz"); const appClient = getAppClient(); const response = await appClient.post("deploy-dist", { From 964d177f63d24ba6d34944f86fd1bfaf7c794dd3 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 15 Jan 2026 17:54:07 +0200 Subject: [PATCH 5/6] Require auth --- src/cli/commands/site/deploy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/site/deploy.ts b/src/cli/commands/site/deploy.ts index 1cbf051e4..5fd474a28 100644 --- a/src/cli/commands/site/deploy.ts +++ b/src/cli/commands/site/deploy.ts @@ -45,6 +45,6 @@ export const siteDeployCommand = new Command("site") new Command("deploy") .description("Deploy built site files to Base44 hosting") .action(async () => { - await runCommand(deployAction); + await runCommand(deployAction, { requireAuth: true }); }) ); From 35e2ad63963331ae1890a00fcf6be5a8210a2a1b Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 15 Jan 2026 17:58:12 +0200 Subject: [PATCH 6/6] update docs --- AGENTS.md | 35 +++++++++++++++++++++++++++-------- README.md | 16 +++++++++++++++- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5bda0ae88..a053abbfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,10 +62,10 @@ cli/ │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── site/ # Site deployment (NOT a Resource) -│ │ │ ├── schema.ts # SiteFile, DeployResponse schemas -│ │ │ ├── config.ts # readSiteFiles() - glob and encode files -│ │ │ ├── api.ts # uploadSite() - API call to hosting -│ │ │ ├── deploy.ts # deploySite() - orchestrates read + upload +│ │ │ ├── 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 @@ -243,19 +243,38 @@ export const entityResource: Resource = { 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) not config files +- Reads built artifacts (JS, CSS, HTML) from the output directory - Gets configuration from `site.outputDirectory` in project config -- Uploads binary files as base64-encoded payloads +- 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 site URL) -const { url } = await deploySite("./dist"); +// 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 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 ```