Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 183 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
"@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",
"@vercel/detect-agent": "^1.1.0",
"chalk": "^5.6.2",
"commander": "^12.1.0",
Expand All @@ -64,6 +67,7 @@
"tar": "^7.5.4",
"tmp-promise": "^3.0.3",
"typescript": "^5.7.2",
"typescript-eslint": "^8.52.0",
"vitest": "^4.0.16",
"zod": "^4.3.5"
},
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ async function executeCreate({
await runTask(
"Installing AI agent skills...",
async () => {
await execa("npx", ["-y", "skills", "add", "base44/skills", "-y"], {
await execa("npx", ["-y", "skills", "add", "base44/skills", "-a", "claude-code", "-y"], {
cwd: resolvedPath,
shell: true,
});
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface DeployOptions {
yes?: boolean;
}

async function deployAction(options: DeployOptions): Promise<RunCommandResult> {
export async function deployAction(options: DeployOptions): Promise<RunCommandResult> {
const projectData = await readProjectConfig();

if (!hasResourcesToDeploy(projectData)) {
Expand Down
113 changes: 113 additions & 0 deletions src/cli/commands/project/eject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 { runCommand, runTask, theme } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";
import kebabCase from "lodash.kebabcase";
import { execa } from "execa";
import { deployAction } from "./deploy.js";
import { createProject, createProjectFilesForExistingProject, isDirEmpty, listProjects, Project, writeAppConfig, writeJsonFile, writeFile, setAppConfig } from "@/core/index.js";
import { CLIContext } from "@/cli/types.js";

interface EjectOptions {
path?: string;
}

async function eject(options: EjectOptions): Promise<RunCommandResult> {
const projects = await listProjects();
const ejectableProjects = projects.filter((p) => p.isManagedSourceCode !== false);
const projectOptions: Array<Option<Project>> = 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}`);

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 function getEjectCommand(context: CLIContext): Command {
return new Command("eject")
.description("Download the code for an existing Base44 project")
.option("-p, --path <path>", "Path where to write the project")
.action(async (options: EjectOptions) => {
await runCommand(() => eject(options), { requireAuth: true, requireAppConfig: false }, context);
});
}
2 changes: 2 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getLinkCommand } from "@/cli/commands/project/link.js";
import { getSiteCommand } from "@/cli/commands/site/index.js";
import { getTypesCommand } from "@/cli/commands/types/index.js";
import packageJson from "../../package.json";
import { getEjectCommand } from "./commands/project/eject.js";
import type { CLIContext } from "./types.js";

export function createProgram(context: CLIContext): Command {
Expand All @@ -38,6 +39,7 @@ export function createProgram(context: CLIContext): Command {
program.addCommand(getDashboardCommand(context));
program.addCommand(getDeployCommand(context));
program.addCommand(getLinkCommand(context));
program.addCommand(getEjectCommand(context));

// Register entities commands
program.addCommand(getEntitiesPushCommand(context));
Expand Down
18 changes: 18 additions & 0 deletions src/core/project/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { extract } from "tar";
import type { KyResponse } from "ky";
import { base44Client } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
Expand All @@ -6,6 +9,7 @@ import {
CreateProjectResponseSchema,
ProjectsResponseSchema,
} from "@/core/project/schema.js";
import { makeDirectory } from "../utils";

export async function createProject(projectName: string, description?: string) {
let response: KyResponse;
Expand Down Expand Up @@ -60,3 +64,17 @@ export async function listProjects(): Promise<ProjectsResponse> {

return result.data;
}

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);
await pipeline(nodeStream, extract({ cwd: projectPath }));
}
35 changes: 31 additions & 4 deletions src/core/project/create.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { globby } from "globby";
import { PROJECT_CONFIG_PATTERNS } from "@/core/consts.js";
import { PROJECT_CONFIG_PATTERNS } from "../consts.js";
import { createProject, downloadProject } from "./api.js";
import { renderTemplate } from "./template.js";
import type { Template } from "./schema.js";
import kebabCase from "lodash.kebabcase";
import { ConfigExistsError } from "@/core/errors.js";
import { createProject } from "@/core/project/api.js";
import type { Template } from "@/core/project/schema.js";
import { renderTemplate } from "@/core/project/template.js";

export interface CreateProjectOptions {
name: string;
Expand Down Expand Up @@ -49,3 +50,29 @@ export async function createProjectFiles(
projectDir: basePath,
};
}

export async function createProjectFilesForExistingProject(
options: { projectId: string, projectName: string, projectPath: string; }
): Promise<CreateProjectResult> {
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),
};
}
1 change: 1 addition & 0 deletions src/core/project/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface TemplateData {
name: string;
description?: string;
projectId: string;
hosting?: boolean;
}

interface TemplateFrontmatter {
Expand Down
19 changes: 13 additions & 6 deletions src/core/resources/entity/api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import type { SyncEntitiesResponse, Entity, GetEntitiesResponse } from "./schema.js";
import type { KyResponse } from "ky";
import { HTTPError } from "ky";
import { getAppClient } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import type {
Entity,
SyncEntitiesResponse,
} from "@/core/resources/entity/schema.js";
import { SyncEntitiesResponseSchema } from "@/core/resources/entity/schema.js";
import { GetEntitiesResponseSchema, SyncEntitiesResponseSchema } from "@/core/resources/entity/schema.js";
import { getAppClient } from "@/core/clients/base44-client.js";

export async function syncEntities(
entities: Entity[]
Expand Down Expand Up @@ -46,3 +43,13 @@ export async function syncEntities(

return result.data;
}

export async function getEntities(): Promise<GetEntitiesResponse> {
const appClient = getAppClient();
const response = await appClient.get("entity-schemas");
const data = await response.json();

const result = GetEntitiesResponseSchema.parse(data);

return result;
};
14 changes: 13 additions & 1 deletion src/core/resources/entity/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { syncEntities } from "@/core/resources/entity/api.js";
import { join } from "node:path";
import { getEntities, syncEntities } from "@/core/resources/entity/api.js";
import type {
Entity,
SyncEntitiesResponse,
} from "@/core/resources/entity/schema.js";
import { writeJsonFile } from "@/core/utils";

export async function pushEntities(
entities: Entity[]
Expand All @@ -13,3 +15,13 @@ export async function pushEntities(

return syncEntities(entities);
}

export async function pullEntities(projectPath: string): Promise<Entity[]> {
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 }));
}
14 changes: 14 additions & 0 deletions src/core/resources/entity/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,17 @@ export const SyncEntitiesResponseSchema = z.object({
});

export type SyncEntitiesResponse = z.infer<typeof SyncEntitiesResponseSchema>;

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<typeof GetEntitiesResponseSchema>;
17 changes: 11 additions & 6 deletions src/core/resources/function/api.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import type { FunctionWithCode, DeployFunctionsResponse, GetFunctionsResponse } from "./schema.js";
import type { KyResponse } from "ky";
import { getAppClient } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import type {
DeployFunctionsResponse,
FunctionWithCode,
} from "@/core/resources/function/schema.js";
import { DeployFunctionsResponseSchema } from "@/core/resources/function/schema.js";
import { DeployFunctionsResponseSchema, GetFunctionsResponseSchema } from "@/core/resources/function/schema.js";
import { getAppClient } from "@/core/clients/base44-client.js";

function toDeployPayloadItem(fn: FunctionWithCode) {
return {
Expand Down Expand Up @@ -45,3 +42,11 @@ export async function deployFunctions(

return result.data;
}

export async function getFunctions(): Promise<GetFunctionsResponse> {
const appClient = getAppClient();
const response = await appClient.get("backend-functions");
const result = GetFunctionsResponseSchema.parse(await response.json());

return result;
}
24 changes: 22 additions & 2 deletions src/core/resources/function/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { join } from "node:path";
import { basename } from "node:path";
import { deployFunctions } from "@/core/resources/function/api.js";
import { deployFunctions, getFunctions } from "@/core/resources/function/api.js";
import type {
BackendFunction,
DeployFunctionsResponse,
FunctionFile,
FunctionWithCode,
} from "@/core/resources/function/schema.js";
import { readTextFile } from "@/core/utils/fs.js";
import { readTextFile, writeFile, writeJsonFile } from "@/core/utils/fs.js";

async function loadFunctionCode(
fn: BackendFunction
Expand All @@ -30,3 +31,22 @@ export async function pushFunctions(
const functionsWithCode = await Promise.all(functions.map(loadFunctionCode));
return deployFunctions(functionsWithCode);
}

export async function pullFunctions(projectPath: string): Promise<FunctionWithCode[]> {
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')
}))
}
Loading