diff --git a/package-lock.json b/package-lock.json index d6a1248d..275d8ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.10.5", "@types/tar": "^6.1.13", + "@types/unzipper": "^0.10.11", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "chalk": "^5.6.2", @@ -39,6 +40,7 @@ "tsx": "^4.19.2", "typescript": "^5.7.2", "typescript-eslint": "^8.52.0", + "unzipper": "^0.12.3", "vitest": "^4.0.16", "zod": "^4.3.5" }, @@ -1734,6 +1736,16 @@ "node": ">=8" } }, + "node_modules/@types/unzipper": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz", + "integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "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", @@ -2347,6 +2359,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2670,6 +2689,13 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2909,6 +2935,16 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3851,6 +3887,21 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4081,6 +4132,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4242,6 +4300,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4883,6 +4948,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5089,6 +5167,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5517,6 +5602,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5565,6 +5657,29 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5872,6 +5987,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6153,6 +6275,16 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -6682,6 +6814,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6723,6 +6879,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 39f52cf9..bf052adc 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.10.5", "@types/tar": "^6.1.13", + "@types/unzipper": "^0.10.11", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", "chalk": "^5.6.2", @@ -58,6 +59,7 @@ "tsx": "^4.19.2", "typescript": "^5.7.2", "typescript-eslint": "^8.52.0", + "unzipper": "^0.12.3", "vitest": "^4.0.16", "zod": "^4.3.5", "open": "^11.0.0" diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts index feaa5ff0..dbe333a3 100644 --- a/src/cli/commands/project/create.ts +++ b/src/cli/commands/project/create.ts @@ -72,8 +72,7 @@ async function createInteractive(options: CreateOptions): Promise { return options.name ? Promise.resolve(options.name) : text({ message: "What is the name of your project?", - placeholder: basename(process.cwd()), - initialValue: basename(process.cwd()), + placeholder: "my-app", validate: (value) => { if (!value || value.trim().length === 0) { return "Every project deserves a name"; @@ -230,7 +229,7 @@ async function executeCreate({ await runTask( "Installing AI agent skills...", async () => { - await execa("npx", ["-y", "add-skill", "base44/skills", "-y"], { + await execa("npx", ["-y", "skills", "add", "base44/skills", "-a", "claude-code", "-y"], { cwd: resolvedPath, shell: true }); diff --git a/src/cli/commands/project/deploy.ts b/src/cli/commands/project/deploy.ts index dfc0c507..a72e9579 100644 --- a/src/cli/commands/project/deploy.ts +++ b/src/cli/commands/project/deploy.ts @@ -12,7 +12,7 @@ interface DeployOptions { yes?: boolean; } -async function deployAction(options: DeployOptions): Promise { +export async function deployAction(options: DeployOptions): Promise { const projectData = await readProjectConfig(); if (!hasResourcesToDeploy(projectData)) { diff --git a/src/cli/commands/project/eject.ts b/src/cli/commands/project/eject.ts new file mode 100644 index 00000000..264f2fd4 --- /dev/null +++ b/src/cli/commands/project/eject.ts @@ -0,0 +1,122 @@ +import { resolve } from "node:path"; +import { Command } from "commander"; +import { select, isCancel, cancel, text, confirm } from "@clack/prompts"; +import type { Option } from "@clack/prompts"; +import { createProject, createProjectFilesForExistingProject, listProjects, setAppConfig, writeAppConfig } from "@core/project/index.js"; +import type { Project } from "@core/project/index.js"; +import { runCommand, runTask, theme } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { isDirEmpty, writeFile, writeJsonFile } from "@core/utils/fs.js"; +import kebabCase from "lodash.kebabcase"; +import { execa } from "execa"; +import { deployAction } from "./deploy.js"; + +interface EjectOptions { + path?: string; +} + +async function eject(options: EjectOptions): Promise { + const projects = await listProjects(); + const ejectableProjects = projects.filter((p) => p.isManagedSourceCode !== false); + const projectOptions: Array> = ejectableProjects.map((p) => ({ + value: p, + label: p.name, + hint: p.userDescription, + })); + + const selectedProject = false ? { id: '697e53fdb9fbf6eb3c4b8b8d', name: 'Home', isManagedSourceCode: true, userDescription: 'desc' } : await select({ + message: "Choose a project to download", + options: projectOptions, + }); + + if (isCancel(selectedProject)) { + cancel("Operation cancelled."); + process.exit(0); + }; + + const projectId = selectedProject.id; + const suggestedPath = await isDirEmpty() ? `./` : `./${kebabCase(selectedProject.name)}`; + + const selectedPath = options.path ?? await text({ + message: "Where should we create your project?", + placeholder: suggestedPath, + initialValue: suggestedPath, + }); + + if (isCancel(selectedPath)) { + cancel("Operation cancelled."); + process.exit(0); + } + + const resolvedPath = resolve(selectedPath); + + await runTask( + "Downloading your project's code...", + async (updateMessage) => { + await createProjectFilesForExistingProject({ + projectId, + projectName: selectedProject.name, + projectPath: resolvedPath, + }); + + updateMessage('Creating a new project...'); + + const newProjectName = `${selectedProject.name} Copy`; + const { projectId: newProjectId } = await createProject(newProjectName, selectedProject.userDescription) + + updateMessage('Linking the project...'); + + await writeAppConfig(resolvedPath, newProjectId); + await writeFile(`${resolvedPath}/.env.local`, `VITE_BASE44_APP_ID=${newProjectId}`); + await writeJsonFile(`${resolvedPath}/base44/config.json`, { + name: newProjectName, + description: selectedProject.userDescription, + site: { + installCommand: "npm install", + buildCommand: "npm run build", + serveCommand: "npm run dev", + outputDirectory: "./dist" + } + }); + + setAppConfig({ id: newProjectId, projectRoot: resolvedPath }); + }, + { + successMessage: theme.colors.base44Orange("Project pulled successfully"), + errorMessage: "Failed to link project", + } + ); + + const shouldDeploy = await confirm({ + message: 'Would you like to deploy your project now?' + }); + + if (!isCancel(shouldDeploy) && shouldDeploy) { + try { + await runTask( + "Installing dependencies...", + async (updateMessage) => { + await execa({ cwd: resolvedPath, shell: true })`npm install`; + + updateMessage("Building project..."); + await execa({ cwd: resolvedPath, shell: true })`npm run build`; + }, + { + successMessage: theme.colors.base44Orange("Project built successfully"), + errorMessage: "Failed to build project", + } + ); + + await deployAction({ yes: true }); + } catch (error) { console.error(error); } + }; + + return { outroMessage: "Your new project is set and ready to use" }; +} + +export const ejectCommand = new Command("eject") + .description("Download the code for an existing Base44 project") + .option("-p, --path ", "Path where to write the project") + .action(async (options: EjectOptions) => { + await runCommand(() => eject(options), { requireAuth: true, requireAppConfig: false }); + }); diff --git a/src/cli/program.ts b/src/cli/program.ts index ef42b78a..9f130c50 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,6 +10,7 @@ import { deployCommand } from "./commands/project/deploy.js"; import { linkCommand } from "./commands/project/link.js"; import { siteDeployCommand } from "./commands/site/deploy.js"; import packageJson from "../../package.json"; +import { ejectCommand } from "./commands/project/eject.js"; const program = new Command(); @@ -34,6 +35,7 @@ program.addCommand(createCommand); program.addCommand(dashboardCommand); program.addCommand(deployCommand); program.addCommand(linkCommand); +program.addCommand(ejectCommand); // Register entities commands program.addCommand(entitiesPushCommand); diff --git a/src/core/config.ts b/src/core/config.ts index 77cfc220..a029f9c6 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -28,5 +28,5 @@ export function getAppConfigPath(projectRoot: string): string { } export function getBase44ApiUrl(): string { - return process.env.BASE44_API_URL || "https://app.base44.com"; + return process.env.BASE44_API_URL || "https://pr-3088.velino.org"; } diff --git a/src/core/project/api.ts b/src/core/project/api.ts index 9b044f23..38cd8a79 100644 --- a/src/core/project/api.ts +++ b/src/core/project/api.ts @@ -1,4 +1,9 @@ +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { dirname, join } from "node:path"; +import { Parser } from "tar"; import { base44Client } from "@core/clients/index.js"; +import { makeDirectory, writeFile } from "@core/utils/fs.js"; import { CreateProjectResponseSchema, ProjectsResponseSchema } from "./schema.js"; import type { ProjectsResponse } from "./schema.js"; @@ -31,3 +36,80 @@ export async function listProjects(): Promise { return projects; } + +interface FunctionConfig { + name: string; + entry: string; + files: Array<{ path: string; content: string }>; +} + +export async function downloadProject(projectId: string, projectPath: string) { + const response = await base44Client.get(`api/apps/${projectId}/eject`, { timeout: false }); + const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream); + + await makeDirectory(projectPath); + + // Patterns for special handling + const functionConfigPattern = /^base44\/functions\/[^/]+\/config\.jsonc$/; + const agentOrEntityPattern = /^base44\/(agents|entities)\/[^/]+\.jsonc$/; + + const parser = new Parser({ + onReadEntry: async (entry) => { + const entryPath = entry.path; + const fullPath = join(projectPath, entryPath); + + // Skip root-level functions/ directory (we generate from base44/functions/) + if (entryPath.startsWith("functions/")) { + entry.resume(); + return; + } + + if (entry.type === "Directory") { + await makeDirectory(fullPath); + entry.resume(); + return; + } + + // Read file content + const chunks: Buffer[] = []; + for await (const chunk of entry) { + chunks.push(chunk); + } + const content = Buffer.concat(chunks).toString("utf-8"); + + // Handle function config files + if (functionConfigPattern.test(entryPath)) { + const config: FunctionConfig = JSON.parse(content); + const funcDir = dirname(fullPath); + + // Write function.jsonc with only name and entry + const funcConfig = { name: config.name, entry: config.entry }; + await writeFile( + join(funcDir, "function.jsonc"), + JSON.stringify(funcConfig, null, 2) + ); + + // Write each file from the files array + for (const file of config.files) { + await writeFile(join(funcDir, file.path), file.content); + } + } + // Handle agent and entity files - remove .jsonc suffix from name + else if (agentOrEntityPattern.test(entryPath)) { + const config = JSON.parse(content); + if (config.name && config.name.endsWith(".json")) { + config.name = config.name.replace(/\.json$/, ""); + } + await makeDirectory(dirname(fullPath)); + await writeFile(fullPath, JSON.stringify(config, null, 2)); + } + // Regular file - write as-is + else { + await makeDirectory(dirname(fullPath)); + await writeFile(fullPath, content); + } + }, + }); + + await pipeline(nodeStream, parser); +} diff --git a/src/core/project/create.ts b/src/core/project/create.ts index 47a08ecd..8dcfc9c6 100644 --- a/src/core/project/create.ts +++ b/src/core/project/create.ts @@ -1,8 +1,12 @@ import { globby } from "globby"; import { PROJECT_CONFIG_PATTERNS } from "../consts.js"; -import { createProject } from "./api.js"; -import { renderTemplate } from "./template.js"; +import { createProject, downloadProject } from "./api.js"; +import { listTemplates, renderTemplate } from "./template.js"; import type { Template } from "./schema.js"; +import kebabCase from "lodash.kebabcase"; +import { pushEntities, pullEntities, getEntities } from "@core/resources/index.js"; +import { readProjectConfig } from "./config.js"; +import { setAppConfig } from "./app-config.js"; export interface CreateProjectOptions { name: string; @@ -48,3 +52,29 @@ export async function createProjectFiles( projectDir: basePath, }; } + +export async function createProjectFilesForExistingProject( + options: { projectId: string, projectName: string, projectPath: string; } +): Promise { + const { projectId, projectName, projectPath } = options; + + // Check if project already exists + const existingConfigs = await globby(PROJECT_CONFIG_PATTERNS, { + cwd: projectPath, + absolute: true, + }); + + if (existingConfigs.length > 0) { + throw new Error( + `A Base44 project already exists at ${existingConfigs[0]}. Please choose a different location.` + ); + } + + // Create the project via API to get the app ID + await downloadProject(projectId, projectPath); + + return { + projectId, + projectDir: kebabCase(projectName), + }; +} diff --git a/src/core/project/template.ts b/src/core/project/template.ts index 767c1941..6c76f93e 100644 --- a/src/core/project/template.ts +++ b/src/core/project/template.ts @@ -11,6 +11,7 @@ export interface TemplateData { name: string; description?: string; projectId: string; + hosting?: boolean; } interface TemplateFrontmatter { diff --git a/src/core/resources/entity/api.ts b/src/core/resources/entity/api.ts index 0d6c0ec4..d4c57957 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,6 +1,8 @@ +import { join } from "node:path"; +import { writeJsonFile } from "@core/utils/fs.js"; import { getAppClient } from "@core/clients/index.js"; -import { SyncEntitiesResponseSchema } from "./schema.js"; -import type { SyncEntitiesResponse, Entity } from "./schema.js"; +import { GetEntitiesResponseSchema, SyncEntitiesResponseSchema } from "./schema.js"; +import type { SyncEntitiesResponse, Entity, GetEntitiesResponse } from "./schema.js"; export async function syncEntities( entities: Entity[] @@ -32,3 +34,13 @@ export async function syncEntities( return result; } + +export async function getEntities(): Promise { + const appClient = getAppClient(); + const response = await appClient.get("entity-schemas"); + const data = await response.json(); + + const result = GetEntitiesResponseSchema.parse(data); + + return result; +}; diff --git a/src/core/resources/entity/deploy.ts b/src/core/resources/entity/deploy.ts index a74565b3..88cc831f 100644 --- a/src/core/resources/entity/deploy.ts +++ b/src/core/resources/entity/deploy.ts @@ -1,5 +1,7 @@ -import { syncEntities } from "./api.js"; +import { writeJsonFile } from "@core/utils/fs.js"; +import { getEntities, syncEntities } from "./api.js"; import type { Entity, SyncEntitiesResponse } from "./schema.js"; +import { join } from "node:path"; export async function pushEntities( entities: Entity[] @@ -10,3 +12,13 @@ export async function pushEntities( return syncEntities(entities); } + +export async function pullEntities(projectPath: string): Promise { + const entities = await getEntities(); + + entities.schemas.forEach((entity) => { + writeJsonFile(join(projectPath, 'base44', 'entities', `${entity.entityName}.json`), entity.entitySchema); + }); + + return entities.schemas.map((schema) => ({ name: schema.entityName, ...schema.entitySchema })); +} diff --git a/src/core/resources/entity/schema.ts b/src/core/resources/entity/schema.ts index 9fdea594..456e441c 100644 --- a/src/core/resources/entity/schema.ts +++ b/src/core/resources/entity/schema.ts @@ -13,3 +13,17 @@ export const SyncEntitiesResponseSchema = z.object({ }); export type SyncEntitiesResponse = z.infer; + +export const GetEntitiesResponseSchema = z.object({ + schemas: z.array(z.object({ + entity_name: z.string(), + entity_schema: z.any(), + })), +}).transform((data) => ({ + schemas: data.schemas.map((schema) => ({ + entityName: schema.entity_name, + entitySchema: schema.entity_schema, + })), +})); + +export type GetEntitiesResponse = z.infer; diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 293c8636..c5e5fb9d 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -1,6 +1,6 @@ import { getAppClient } from "@core/clients/index.js"; -import { DeployFunctionsResponseSchema } from "./schema.js"; -import type { FunctionWithCode, DeployFunctionsResponse } from "./schema.js"; +import { DeployFunctionsResponseSchema, GetFunctionsResponseSchema } from "./schema.js"; +import type { FunctionWithCode, DeployFunctionsResponse, GetFunctionsResponse } from "./schema.js"; function toDeployPayloadItem(fn: FunctionWithCode) { return { @@ -26,3 +26,11 @@ export async function deployFunctions( const result = DeployFunctionsResponseSchema.parse(await response.json()); return result; } + +export async function getFunctions(): Promise { + const appClient = getAppClient(); + const response = await appClient.get("backend-functions"); + const result = GetFunctionsResponseSchema.parse(await response.json()); + + return result; +} diff --git a/src/core/resources/function/deploy.ts b/src/core/resources/function/deploy.ts index 018eadf3..08b27b97 100644 --- a/src/core/resources/function/deploy.ts +++ b/src/core/resources/function/deploy.ts @@ -1,5 +1,6 @@ -import { readTextFile } from "../../utils/fs.js"; -import { deployFunctions } from "./api.js"; +import { join } from "node:path"; +import { readTextFile, writeFile, writeJsonFile } from "../../utils/fs.js"; +import { deployFunctions, getFunctions } from "./api.js"; import type { Function, FunctionWithCode, DeployFunctionsResponse } from "./schema.js"; async function loadFunctionCode(fn: Function): Promise { @@ -17,3 +18,22 @@ export async function pushFunctions( const functionsWithCode = await Promise.all(functions.map(loadFunctionCode)); return deployFunctions(functionsWithCode); } + +export async function pullFunctions(projectPath: string): Promise { + const { functions } = await getFunctions(); + + functions.forEach((func) => { + const functionDir = join(projectPath, 'base44', 'functions', func.name); + + writeJsonFile(join(functionDir, 'function.json'), { name: func.name, entry: 'index.js' }); + writeFile(join(functionDir, 'index.js'), func.code); + }); + + return functions.map((func) => ({ + name: func.name, + entry: 'index.js', + triggers: [], + code: func.code, + codePath: join(projectPath, 'base44', 'functions', func.name, 'index.js') + })) +} diff --git a/src/core/resources/function/schema.ts b/src/core/resources/function/schema.ts index 5ad43a6d..63f58496 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -28,3 +28,11 @@ export type DeployFunctionsResponse = z.infer< typeof DeployFunctionsResponseSchema >; +export const GetFunctionsResponseSchema = z.object({ + functions: z.array(z.object({ + name: z.string(), + code: z.string(), + })), +}); + +export type GetFunctionsResponse = z.infer; diff --git a/src/core/utils/fs.ts b/src/core/utils/fs.ts index b296f983..7b9dac01 100644 --- a/src/core/utils/fs.ts +++ b/src/core/utils/fs.ts @@ -47,8 +47,7 @@ export async function readFile(filePath: string): Promise { return await fsReadFile(filePath); } catch (error) { throw new Error( - `Failed to read file ${filePath}: ${ - error instanceof Error ? error.message : "Unknown error" + `Failed to read file ${filePath}: ${error instanceof Error ? error.message : "Unknown error" }` ); } @@ -63,8 +62,7 @@ export async function readTextFile(filePath: string): Promise { return await fsReadFile(filePath, "utf-8"); } catch (error) { throw new Error( - `Failed to read file ${filePath}: ${ - error instanceof Error ? error.message : "Unknown error" + `Failed to read file ${filePath}: ${error instanceof Error ? error.message : "Unknown error" }` ); } @@ -83,8 +81,7 @@ export async function readJsonFile(filePath: string): Promise { throw new Error(`File contains invalid JSON: ${filePath} (${error.message})`); } throw new Error( - `Failed to read file ${filePath}: ${ - error instanceof Error ? error.message : "Unknown error" + `Failed to read file ${filePath}: ${error instanceof Error ? error.message : "Unknown error" }` ); } @@ -113,3 +110,9 @@ export async function isDirEmpty(dir = process.cwd()) { const files = await readdir(dir); return files.length === 0; } + +export async function makeDirectory(dirPath: string): Promise { + if (!(await pathExists(dirPath))) { + await mkdir(dirPath, { recursive: true }); + } +} diff --git a/templates/backend-only/base44/config.jsonc.ejs b/templates/backend-only/base44/config.jsonc.ejs index 6e73acfe..97e310ab 100644 --- a/templates/backend-only/base44/config.jsonc.ejs +++ b/templates/backend-only/base44/config.jsonc.ejs @@ -3,15 +3,25 @@ // Commented-out properties show available options you can enable. { - "name": "<%= name %>"<% if (description) { %>, - "description": "<%= description %>"<% } %> - + "name": "<%= name %>" // Site/hosting configuration // Docs: https://docs.base44.com/configuration/hosting + <% if (hosting) { %> + "site": { + "installCommand": "npm install", + "buildCommand": "npm run build", + "serveCommand": "npm run dev", + "outputDirectory": "./dist" + } + <% } else { %> // "site": { + // "installCommand": "npm install", // "buildCommand": "npm run build", // "serveCommand": "npm run dev", - // "outputDirectory": "./dist", - // "installCommand": "npm ci" + // "outputDirectory": "./dist" // } + <% } %> + <% if (description) { %>, + "description": "<%= description %>"<% } + %> }