From 2ea68cc6862930da5997395af7c8b3698f9ca10b Mon Sep 17 00:00:00 2001 From: dor-chaouat Date: Sun, 18 Jan 2026 18:15:43 +0200 Subject: [PATCH 1/6] fix(command): wrap with lines --- src/cli/utils/banner.ts | 2 -- src/cli/utils/runCommand.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/utils/banner.ts b/src/cli/utils/banner.ts index 371f5024..2f7f4a45 100644 --- a/src/cli/utils/banner.ts +++ b/src/cli/utils/banner.ts @@ -3,14 +3,12 @@ import { printAnimatedLines } from "./animate.js"; const orange = chalk.hex("#E86B3C"); const BANNER_LINES = [ - "", "██████╗ █████╗ ███████╗███████╗ ██╗ ██╗██╗ ██╗", "██╔══██╗██╔══██╗██╔════╝██╔════╝ ██║ ██║██║ ██║", "██████╔╝███████║███████╗█████╗ ███████║███████║", "██╔══██╗██╔══██║╚════██║██╔══╝ ╚════██║╚════██║", "██████╔╝██║ ██║███████║███████╗ ██║ ██║", "╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝", - "", ]; /** diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index e773bcd7..16c1d684 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -68,6 +68,8 @@ export async function runCommand( commandFn: () => Promise, options?: RunCommandOptions ): Promise { + console.log(); + if (options?.fullBanner) { await printBanner(); intro(""); From b8a59677ba0b08ea9a1b178641d7ff1d75fe8824 Mon Sep 17 00:00:00 2001 From: dor-chaouat Date: Sun, 18 Jan 2026 18:24:46 +0200 Subject: [PATCH 2/6] lint --- src/cli/utils/runCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 16c1d684..b8c7a334 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -69,7 +69,7 @@ export async function runCommand( options?: RunCommandOptions ): Promise { console.log(); - + if (options?.fullBanner) { await printBanner(); intro(""); From d4797119dbeccaa9b21c9a16cce02a9c110a900c Mon Sep 17 00:00:00 2001 From: dor-chaouat Date: Mon, 19 Jan 2026 23:35:02 +0200 Subject: [PATCH 3/6] feat(link): new command --- package-lock.json | 163 +++++++++++++++++++++++++++++++ package.json | 2 + src/cli/commands/project/link.ts | 58 +++++++++++ src/cli/index.ts | 2 + src/core/project/api.ts | 35 ++++++- src/core/project/create.ts | 53 +++++++++- src/core/resources/entity/api.ts | 14 +++ 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/cli/commands/project/link.ts diff --git a/package-lock.json b/package-lock.json index ba05dec8..ab8c69c4 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", @@ -2654,6 +2673,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", @@ -2863,6 +2889,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", @@ -3805,6 +3841,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", @@ -4035,6 +4086,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", @@ -4196,6 +4254,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", @@ -4773,6 +4838,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", @@ -4979,6 +5057,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", @@ -5373,6 +5458,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", @@ -5421,6 +5513,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", @@ -5715,6 +5830,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", @@ -5996,6 +6118,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", @@ -6525,6 +6657,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", @@ -6566,6 +6722,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 33ba8224..e3ec52dc 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,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", @@ -60,6 +61,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" }, diff --git a/src/cli/commands/project/link.ts b/src/cli/commands/project/link.ts new file mode 100644 index 00000000..cc41bed5 --- /dev/null +++ b/src/cli/commands/project/link.ts @@ -0,0 +1,58 @@ +import { resolve } from "node:path"; +import { Command } from "commander"; +import { select, isCancel } from "@clack/prompts"; +import type { Option } from "@clack/prompts"; +import chalk from "chalk"; +import { createProjectFileForExistingProject, listProjects, readProjectConfig } from "@core/project/index.js"; +import type { Project } from "@core/project/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; + +const orange = chalk.hex("#E86B3C"); +const cyan = chalk.hex("#00D4FF"); + +async function link(): Promise { + const projects = await listProjects(); + const projectOptions: Array> = projects.map((p) => ({ + value: p, + label: p.name, + hint: p.user_description, + })); + + const selectedProject = await select({ + message: "Select a project", + options: projectOptions, + }); + + if (isCancel(selectedProject)) { + return { outroMessage: "Project selection cancelled" }; + }; + + const projectId = (selectedProject as Project).id; + const resolvedPath = resolve('./'); + + if (selectedProject) { + await runTask( + "Pulling your project...", + async () => { + return await createProjectFileForExistingProject({ + projectId, + projectName: selectedProject.name, + path: resolvedPath, + }); + }, + { + successMessage: orange("Project pulled successfully"), + errorMessage: "Failed to link project", + } + ); + } + + return { outroMessage: "Your project is set and ready to use" }; +} + +export const linkCommand = new Command("link") + .description("Link an existing Base44 project") + .action(async () => { + await runCommand(link, { requireAuth: true }); + }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 8b3576d2..3e5805bb 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 { linkCommand } from "./commands/project/link.js"; import { siteDeployCommand } from "./commands/site/deploy.js"; import packageJson from "../../package.json"; @@ -25,6 +26,7 @@ program.addCommand(logoutCommand); // Register project commands program.addCommand(createCommand); +program.addCommand(linkCommand); // Register entities commands program.addCommand(entitiesPushCommand); diff --git a/src/core/project/api.ts b/src/core/project/api.ts index 1e324f42..f4d8896f 100644 --- a/src/core/project/api.ts +++ b/src/core/project/api.ts @@ -1,3 +1,8 @@ +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { join } from "node:path"; +import unzipper from "unzipper"; +import kebabCase from "lodash.kebabcase"; import { base44Client } from "@core/clients/index.js"; import { CreateProjectResponseSchema } from "./schema.js"; @@ -16,4 +21,32 @@ export async function createProject(projectName: string, description?: string) { return { projectId: result.id, }; -} +}; + +export async function getProject(projectId: string) { + const response = await base44Client.get(`api/apps/${projectId}`); + return await response.json() as Project; +}; + +export type Project = { + id: string; + name: string; + user_description: string; +}; + +export async function listProjects() { + const response = await base44Client.get(`api/apps?sort=-updated_date&limit=20&fields=id,name,user_description,status,updated_date`); + const projects = await response.json() as Project[]; + + return projects; +}; + +export async function downloadProject(projectId: string, projectName: string) { + const response = await base44Client.get(`api/apps/${projectId}/coding/export-to-zip`); + const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream); + + await pipeline( + nodeStream, + unzipper.Extract({ path: join(process.cwd(), kebabCase(projectName)) }) + ); +}; diff --git a/src/core/project/create.ts b/src/core/project/create.ts index ef2bee31..208c92da 100644 --- a/src/core/project/create.ts +++ b/src/core/project/create.ts @@ -1,8 +1,11 @@ import { globby } from "globby"; import { getProjectConfigPatterns } from "../consts.js"; -import { createProject } from "./api.js"; +import { createProject, downloadProject } from "./api.js"; import { renderTemplate } from "./template.js"; import type { Template } from "./schema.js"; +import kebabCase from "lodash.kebabcase"; +import { pushEntities, pullEntities } from "@core/resources/index.js"; +import { readProjectConfig } from "./config.js"; export interface CreateProjectOptions { name: string; @@ -48,3 +51,51 @@ export async function createProjectFiles( projectDir: basePath, }; } + +export async function createProjectFileForExistingProject( + options: { projectId: string, projectName: string, path: string; } +): Promise { + const { projectId, projectName, path: basePath } = options; + + // Check if project already exists + const existingConfigs = await globby(getProjectConfigPatterns(), { + cwd: basePath, + 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, projectName); + process.env.BASE44_CLIENT_ID = projectId; + + // Render the template to the destination path + const newProject = await createProject(`${projectName} Copy`); + + await renderTemplate({ + "id": "backend-only", + "name": "Init a basic project", + "description": "Minimal Base44 backend setup for creating entities", + "path": "backend-only" + }, kebabCase(projectName), { + name: `${projectName} Copy`, + description: `Copy of ${projectName}`, + projectId: newProject.projectId, + }); + + await pullEntities(kebabCase(projectName)); + + process.env.BASE44_CLIENT_ID = newProject.projectId; + const { entities } = await readProjectConfig(kebabCase(projectName)); + + await pushEntities(entities); + + return { + projectId, + projectDir: basePath, + }; +} diff --git a/src/core/resources/entity/api.ts b/src/core/resources/entity/api.ts index 1c39fd30..f7171bba 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,3 +1,5 @@ +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"; @@ -32,3 +34,15 @@ export async function pushEntities( return result; } + +export async function pullEntities(destPath: string): Promise { + const appClient = getAppClient(); + const response = await appClient.get("entities-schemas"); + const result = await response.json() as { schemas: { entity_name: string, schema: any }[] }; + + result.schemas.forEach((entity) => { + writeJsonFile(join(destPath, 'base44', 'entities', `${entity.entity_name}.json`), entity.schema); + }); + + return result; +}; From beb9f401cc68b4c0a69c982d63609a2ddae45acd Mon Sep 17 00:00:00 2001 From: dor-chaouat Date: Mon, 26 Jan 2026 22:11:51 +0200 Subject: [PATCH 4/6] wip --- package-lock.json | 8 +++ src/cli/commands/project/eject.ts | 98 +++++++++++++++++++++++++++ src/cli/program.ts | 2 + src/core/config.ts | 2 +- src/core/project/create.ts | 34 ++++------ src/core/resources/entity/api.ts | 16 ++--- src/core/resources/entity/deploy.ts | 14 +++- src/core/resources/entity/schema.ts | 14 ++++ src/core/resources/function/api.ts | 12 +++- src/core/resources/function/deploy.ts | 24 ++++++- src/core/resources/function/schema.ts | 8 +++ 11 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 src/cli/commands/project/eject.ts diff --git a/package-lock.json b/package-lock.json index 6e9262a2..fceb2103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1781,6 +1781,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2083,6 +2084,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2409,6 +2411,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3205,6 +3208,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5819,6 +5823,7 @@ "integrity": "sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.107.0", "@rolldown/pluginutils": "1.0.0-beta.59" @@ -6601,6 +6606,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6712,6 +6718,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6892,6 +6899,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/cli/commands/project/eject.ts b/src/cli/commands/project/eject.ts new file mode 100644 index 00000000..02c32336 --- /dev/null +++ b/src/cli/commands/project/eject.ts @@ -0,0 +1,98 @@ +import { join, resolve } from "node:path"; +import { Command } from "commander"; +import { select, isCancel, cancel } from "@clack/prompts"; +import type { Option } from "@clack/prompts"; +import chalk from "chalk"; +import { createProjectFilesForExistingProject, listProjects, setAppConfig } from "@core/project/index.js"; +import type { Project } from "@core/project/index.js"; +import { runCommand, runTask } from "../../utils/index.js"; +import type { RunCommandResult } from "../../utils/runCommand.js"; +import { getFunctions, pullEntities, pullFunctions, pushEntities, pushFunctions } from "@core/resources/index.js"; + +const orange = chalk.hex("#E86B3C"); +const cyan = chalk.hex("#00D4FF"); + +async function eject(): 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 = await select({ + message: "Choose a project to download", + options: projectOptions, + }); + + if (isCancel(selectedProject)) { + cancel("Operation cancelled."); + process.exit(0); + }; + + const projectId = selectedProject.id; + const resolvedPath = resolve('./'); + + const { projectId: newProjectId, projectDir } = await runTask( + "Downloading your project's code...", + async () => { + return await createProjectFilesForExistingProject({ + projectId, + projectName: selectedProject.name, + path: resolvedPath, + }); + }, + { + successMessage: orange("Project pulled successfully"), + errorMessage: "Failed to link project", + } + ); + + // Push in behalf of the existing project + setAppConfig({ id: projectId, projectRoot: resolvedPath }); + + const entities = await pullEntities(projectDir); + const functions = await pullFunctions(projectDir); + + // Push in behalf of the new project + setAppConfig({ id: newProjectId, projectRoot: resolvedPath }); + + if (entities.length > 0) { + try { + await runTask( + "Syncing your project's entities...", + async () => { + // Pull the entities from the original project + + await pushEntities(entities); + }, + { + successMessage: orange("Project entities synced successfully"), + errorMessage: "Failed to sync project entities", + } + ); + } catch (error) { console.error(error); } + } + + try { + await runTask( + "Syncing your project's backend functions...", + async () => { + await pushFunctions(functions); + }, + { + successMessage: orange("Project functions synced successfully"), + errorMessage: "Failed to sync project functions", + } + ); + } catch (error) { console.error(error); } + + return { outroMessage: "Your project is set and ready to use" }; +} + +export const ejectCommand = new Command("eject") + .description("Download the code for an existing Base44 project") + .action(async () => { + await runCommand(eject, { 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..d818088d 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-3019.velino.org"; } diff --git a/src/core/project/create.ts b/src/core/project/create.ts index 4ecf2318..673677c7 100644 --- a/src/core/project/create.ts +++ b/src/core/project/create.ts @@ -1,11 +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 } from "@core/resources/index.js"; +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; @@ -52,13 +53,13 @@ export async function createProjectFiles( }; } -export async function createProjectFileForExistingProject( +export async function createProjectFilesForExistingProject( options: { projectId: string, projectName: string, path: string; } ): Promise { const { projectId, projectName, path: basePath } = options; // Check if project already exists - const existingConfigs = await globby(getProjectConfigPatterns(), { + const existingConfigs = await globby(PROJECT_CONFIG_PATTERNS, { cwd: basePath, absolute: true, }); @@ -71,31 +72,22 @@ export async function createProjectFileForExistingProject( // Create the project via API to get the app ID await downloadProject(projectId, projectName); - process.env.BASE44_CLIENT_ID = projectId; - + // Render the template to the destination path const newProject = await createProject(`${projectName} Copy`); - await renderTemplate({ - "id": "backend-only", - "name": "Init a basic project", - "description": "Minimal Base44 backend setup for creating entities", - "path": "backend-only" - }, kebabCase(projectName), { + // Get the default template + const templates = await listTemplates(); + const template = templates.find((t) => t.id === 'backend-only'); + + await renderTemplate(template!, kebabCase(projectName), { name: `${projectName} Copy`, description: `Copy of ${projectName}`, projectId: newProject.projectId, }); - - await pullEntities(kebabCase(projectName)); - - process.env.BASE44_CLIENT_ID = newProject.projectId; - const { entities } = await readProjectConfig(kebabCase(projectName)); - - await pushEntities(entities); return { projectId, - projectDir: basePath, + projectDir: kebabCase(projectName), }; } diff --git a/src/core/resources/entity/api.ts b/src/core/resources/entity/api.ts index 14b0d78c..d4c57957 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,8 +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[] @@ -35,14 +35,12 @@ export async function syncEntities( return result; } -export async function pullEntities(destPath: string): Promise { +export async function getEntities(): Promise { const appClient = getAppClient(); - const response = await appClient.get("entities-schemas"); - const result = await response.json() as { schemas: { entity_name: string, schema: any }[] }; - - result.schemas.forEach((entity) => { - writeJsonFile(join(destPath, 'base44', 'entities', `${entity.entity_name}.json`), entity.schema); - }); + 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; From 96f3d6963631c64fabf957ca66b31205ef1f5d80 Mon Sep 17 00:00:00 2001 From: dor-chaouat Date: Tue, 27 Jan 2026 12:20:48 +0200 Subject: [PATCH 5/6] wip --- package-lock.json | 8 -------- src/core/config.ts | 2 +- src/core/project/api.ts | 27 ++++++++++++++++++++++----- src/core/utils/fs.ts | 6 ++++++ 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index fceb2103..6e9262a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1781,7 +1781,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2084,7 +2083,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2411,7 +2409,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3208,7 +3205,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5823,7 +5819,6 @@ "integrity": "sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.107.0", "@rolldown/pluginutils": "1.0.0-beta.59" @@ -6606,7 +6601,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6718,7 +6712,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6899,7 +6892,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/core/config.ts b/src/core/config.ts index d818088d..77cfc220 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://pr-3019.velino.org"; + return process.env.BASE44_API_URL || "https://app.base44.com"; } diff --git a/src/core/project/api.ts b/src/core/project/api.ts index c0d19ce5..caaf7995 100644 --- a/src/core/project/api.ts +++ b/src/core/project/api.ts @@ -1,9 +1,12 @@ +import { createWriteStream } from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; import unzipper from "unzipper"; import kebabCase from "lodash.kebabcase"; import { base44Client } from "@core/clients/index.js"; +import { deleteFile, makeDirectory } from "@core/utils/fs.js"; import { CreateProjectResponseSchema, ProjectsResponseSchema } from "./schema.js"; import type { ProjectsResponse } from "./schema.js"; @@ -41,8 +44,22 @@ export async function downloadProject(projectId: string, projectName: string) { const response = await base44Client.get(`api/apps/${projectId}/coding/export-to-zip`); const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream); - await pipeline( - nodeStream, - unzipper.Extract({ path: join(process.cwd(), kebabCase(projectName)) }) - ); + // Save zip to temp file first (unzipper.Extract has issues with some files) + const zipPath = join(tmpdir(), `base44-${projectId}-${Date.now()}.zip`); + await pipeline(nodeStream, createWriteStream(zipPath)); + + // Extract manually for reliable extraction of all files + const outputDir = join(process.cwd(), kebabCase(projectName)); + const directory = await unzipper.Open.file(zipPath); + + for (const file of directory.files) { + if (file.type === 'Directory') continue; + + const filePath = join(outputDir, file.path); + await makeDirectory(dirname(filePath)); + await pipeline(file.stream(), createWriteStream(filePath)); + } + + // Clean up temp zip file + await deleteFile(zipPath); }; diff --git a/src/core/utils/fs.ts b/src/core/utils/fs.ts index b833423c..89f2f206 100644 --- a/src/core/utils/fs.ts +++ b/src/core/utils/fs.ts @@ -107,3 +107,9 @@ export async function deleteFile(filePath: string): Promise { } await unlink(filePath); } + +export async function makeDirectory(dirPath: string): Promise { + if (!(await pathExists(dirPath))) { + await mkdir(dirPath, { recursive: true }); + } +} From fb27fa50dc1afa90ef46d2a447c77f0fd5bea795 Mon Sep 17 00:00:00 2001 From: dor-chaouat Date: Mon, 2 Feb 2026 14:45:42 +0200 Subject: [PATCH 6/6] wip --- src/cli/commands/project/create.ts | 5 +- src/cli/commands/project/deploy.ts | 2 +- src/cli/commands/project/eject.ts | 120 +++++++++++------- src/core/config.ts | 2 +- src/core/project/api.ts | 96 ++++++++++---- src/core/project/create.ts | 21 +-- src/core/project/template.ts | 1 + .../backend-only/base44/config.jsonc.ejs | 20 ++- 8 files changed, 169 insertions(+), 98 deletions(-) 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 index 02c32336..264f2fd4 100644 --- a/src/cli/commands/project/eject.ts +++ b/src/cli/commands/project/eject.ts @@ -1,18 +1,21 @@ -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { Command } from "commander"; -import { select, isCancel, cancel } from "@clack/prompts"; +import { select, isCancel, cancel, text, confirm } from "@clack/prompts"; import type { Option } from "@clack/prompts"; -import chalk from "chalk"; -import { createProjectFilesForExistingProject, listProjects, setAppConfig } from "@core/project/index.js"; +import { createProject, createProjectFilesForExistingProject, listProjects, setAppConfig, writeAppConfig } from "@core/project/index.js"; import type { Project } from "@core/project/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; +import { runCommand, runTask, theme } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; -import { getFunctions, pullEntities, pullFunctions, pushEntities, pushFunctions } from "@core/resources/index.js"; +import { isDirEmpty, writeFile, writeJsonFile } from "@core/utils/fs.js"; +import kebabCase from "lodash.kebabcase"; +import { execa } from "execa"; +import { deployAction } from "./deploy.js"; -const orange = chalk.hex("#E86B3C"); -const cyan = chalk.hex("#00D4FF"); +interface EjectOptions { + path?: string; +} -async function eject(): Promise { +async function eject(options: EjectOptions): Promise { const projects = await listProjects(); const ejectableProjects = projects.filter((p) => p.isManagedSourceCode !== false); const projectOptions: Array> = ejectableProjects.map((p) => ({ @@ -21,7 +24,7 @@ async function eject(): Promise { hint: p.userDescription, })); - const selectedProject = await select({ + const selectedProject = false ? { id: '697e53fdb9fbf6eb3c4b8b8d', name: 'Home', isManagedSourceCode: true, userDescription: 'desc' } : await select({ message: "Choose a project to download", options: projectOptions, }); @@ -32,67 +35,88 @@ async function eject(): Promise { }; const projectId = selectedProject.id; - const resolvedPath = resolve('./'); + const suggestedPath = await isDirEmpty() ? `./` : `./${kebabCase(selectedProject.name)}`; + + const selectedPath = options.path ?? await text({ + message: "Where should we create your project?", + placeholder: suggestedPath, + initialValue: suggestedPath, + }); - const { projectId: newProjectId, projectDir } = await runTask( + if (isCancel(selectedPath)) { + cancel("Operation cancelled."); + process.exit(0); + } + + const resolvedPath = resolve(selectedPath); + + await runTask( "Downloading your project's code...", - async () => { - return await createProjectFilesForExistingProject({ + async (updateMessage) => { + await createProjectFilesForExistingProject({ projectId, projectName: selectedProject.name, - path: resolvedPath, + 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: orange("Project pulled successfully"), + successMessage: theme.colors.base44Orange("Project pulled successfully"), errorMessage: "Failed to link project", } ); - // Push in behalf of the existing project - setAppConfig({ id: projectId, projectRoot: resolvedPath }); - - const entities = await pullEntities(projectDir); - const functions = await pullFunctions(projectDir); - - // Push in behalf of the new project - setAppConfig({ id: newProjectId, projectRoot: resolvedPath }); + const shouldDeploy = await confirm({ + message: 'Would you like to deploy your project now?' + }); - if (entities.length > 0) { + if (!isCancel(shouldDeploy) && shouldDeploy) { try { await runTask( - "Syncing your project's entities...", - async () => { - // Pull the entities from the original project + "Installing dependencies...", + async (updateMessage) => { + await execa({ cwd: resolvedPath, shell: true })`npm install`; - await pushEntities(entities); + updateMessage("Building project..."); + await execa({ cwd: resolvedPath, shell: true })`npm run build`; }, { - successMessage: orange("Project entities synced successfully"), - errorMessage: "Failed to sync project entities", + successMessage: theme.colors.base44Orange("Project built successfully"), + errorMessage: "Failed to build project", } ); + + await deployAction({ yes: true }); } catch (error) { console.error(error); } - } + }; - try { - await runTask( - "Syncing your project's backend functions...", - async () => { - await pushFunctions(functions); - }, - { - successMessage: orange("Project functions synced successfully"), - errorMessage: "Failed to sync project functions", - } - ); - } catch (error) { console.error(error); } - - return { outroMessage: "Your project is set and ready to use" }; + 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") - .action(async () => { - await runCommand(eject, { requireAuth: true, requireAppConfig: false }); + .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/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 caaf7995..38cd8a79 100644 --- a/src/core/project/api.ts +++ b/src/core/project/api.ts @@ -1,12 +1,9 @@ -import { createWriteStream } from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { join, dirname } from "node:path"; -import { tmpdir } from "node:os"; -import unzipper from "unzipper"; -import kebabCase from "lodash.kebabcase"; +import { dirname, join } from "node:path"; +import { Parser } from "tar"; import { base44Client } from "@core/clients/index.js"; -import { deleteFile, makeDirectory } from "@core/utils/fs.js"; +import { makeDirectory, writeFile } from "@core/utils/fs.js"; import { CreateProjectResponseSchema, ProjectsResponseSchema } from "./schema.js"; import type { ProjectsResponse } from "./schema.js"; @@ -40,26 +37,79 @@ export async function listProjects(): Promise { return projects; } -export async function downloadProject(projectId: string, projectName: string) { - const response = await base44Client.get(`api/apps/${projectId}/coding/export-to-zip`); +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); - // Save zip to temp file first (unzipper.Extract has issues with some files) - const zipPath = join(tmpdir(), `base44-${projectId}-${Date.now()}.zip`); - await pipeline(nodeStream, createWriteStream(zipPath)); + 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; + } - // Extract manually for reliable extraction of all files - const outputDir = join(process.cwd(), kebabCase(projectName)); - const directory = await unzipper.Open.file(zipPath); + if (entry.type === "Directory") { + await makeDirectory(fullPath); + entry.resume(); + return; + } - for (const file of directory.files) { - if (file.type === 'Directory') continue; + // Read file content + const chunks: Buffer[] = []; + for await (const chunk of entry) { + chunks.push(chunk); + } + const content = Buffer.concat(chunks).toString("utf-8"); - const filePath = join(outputDir, file.path); - await makeDirectory(dirname(filePath)); - await pipeline(file.stream(), createWriteStream(filePath)); - } + // Handle function config files + if (functionConfigPattern.test(entryPath)) { + const config: FunctionConfig = JSON.parse(content); + const funcDir = dirname(fullPath); - // Clean up temp zip file - await deleteFile(zipPath); -}; + // 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 673677c7..8dcfc9c6 100644 --- a/src/core/project/create.ts +++ b/src/core/project/create.ts @@ -54,13 +54,13 @@ export async function createProjectFiles( } export async function createProjectFilesForExistingProject( - options: { projectId: string, projectName: string, path: string; } + options: { projectId: string, projectName: string, projectPath: string; } ): Promise { - const { projectId, projectName, path: basePath } = options; + const { projectId, projectName, projectPath } = options; // Check if project already exists const existingConfigs = await globby(PROJECT_CONFIG_PATTERNS, { - cwd: basePath, + cwd: projectPath, absolute: true, }); @@ -71,20 +71,7 @@ export async function createProjectFilesForExistingProject( } // Create the project via API to get the app ID - await downloadProject(projectId, projectName); - - // Render the template to the destination path - const newProject = await createProject(`${projectName} Copy`); - - // Get the default template - const templates = await listTemplates(); - const template = templates.find((t) => t.id === 'backend-only'); - - await renderTemplate(template!, kebabCase(projectName), { - name: `${projectName} Copy`, - description: `Copy of ${projectName}`, - projectId: newProject.projectId, - }); + await downloadProject(projectId, projectPath); return { projectId, 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/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 %>"<% } + %> }