From 36e3f2aa93cf442e6fa556724fe361b8ad5618b6 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Tue, 13 Jan 2026 13:20:12 +0200 Subject: [PATCH 1/2] many improvements --- AGENTS.md | 189 +++++++++++++++--- README.md | 143 ++++++++++--- src/cli/commands/project/create.ts | 19 +- src/cli/utils/runCommand.ts | 30 ++- src/cli/utils/runTask.ts | 15 +- src/core/auth/api.ts | 12 +- src/core/auth/authClient.ts | 16 -- src/core/auth/config.ts | 24 +-- src/core/auth/schema.ts | 1 - .../base44-client.ts} | 32 ++- src/core/clients/index.ts | 2 + src/core/clients/oauth-client.ts | 15 ++ src/core/config.ts | 34 +--- src/core/consts.ts | 14 ++ src/core/index.ts | 2 + src/core/project/api.ts | 2 +- src/core/project/baseResource.ts | 4 - src/core/project/config.ts | 58 +++--- src/core/project/create.ts | 2 +- src/core/project/index.ts | 1 - src/core/project/schema.ts | 9 +- src/core/project/template.ts | 12 +- src/core/project/types.ts | 19 ++ src/core/resources/entity/api.ts | 2 +- src/core/resources/entity/resource.ts | 2 +- src/core/resources/entity/schema.ts | 28 --- src/core/resources/function/config.ts | 2 +- src/core/resources/function/resource.ts | 2 +- src/core/resources/index.ts | 1 + src/core/resources/types.ts | 22 ++ src/core/utils/index.ts | 1 - 31 files changed, 489 insertions(+), 226 deletions(-) delete mode 100644 src/core/auth/authClient.ts rename src/core/{utils/httpClient.ts => clients/base44-client.ts} (70%) create mode 100644 src/core/clients/index.ts create mode 100644 src/core/clients/oauth-client.ts create mode 100644 src/core/consts.ts delete mode 100644 src/core/project/baseResource.ts create mode 100644 src/core/project/types.ts create mode 100644 src/core/resources/types.ts diff --git a/AGENTS.md b/AGENTS.md index e4deb3f54..05066777d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,16 +24,29 @@ The Base44 CLI is a TypeScript-based command-line tool built with: cli/ ├── src/ │ ├── core/ -│ │ ├── auth/ # Auth (user authentication, not a project resource) -│ │ │ ├── api.ts -│ │ │ ├── schema.ts -│ │ │ ├── config.ts +│ │ ├── api/ # HTTP clients +│ │ │ ├── oauth-client.ts # Unauthenticated client for login flow +│ │ │ ├── base44-client.ts # Authenticated client with token refresh │ │ │ └── index.ts -│ │ ├── resources/ # Project resources (entity, function, etc.) +│ │ ├── auth/ # User authentication +│ │ │ ├── api.ts # OAuth API calls +│ │ │ ├── schema.ts # Auth Zod schemas +│ │ │ ├── config.ts # Token storage/refresh +│ │ │ └── index.ts +│ │ ├── project/ # Project configuration +│ │ │ ├── config.ts # Project loading logic +│ │ │ ├── schema.ts # Project/template schemas +│ │ │ ├── api.ts # Project creation API +│ │ │ ├── create.ts # Project scaffolding +│ │ │ ├── template.ts # Template rendering +│ │ │ └── index.ts +│ │ ├── resources/ # Project resources (entity, function, etc.) +│ │ │ ├── types.ts # Resource interface │ │ │ ├── entity/ │ │ │ │ ├── schema.ts │ │ │ │ ├── config.ts │ │ │ │ ├── resource.ts +│ │ │ │ ├── api.ts │ │ │ │ └── index.ts │ │ │ ├── function/ │ │ │ │ ├── schema.ts @@ -41,52 +54,173 @@ cli/ │ │ │ │ ├── resource.ts │ │ │ │ └── index.ts │ │ │ └── index.ts -│ │ ├── config/ # Project/app configuration -│ │ │ ├── resource.ts # Resource interface -│ │ │ ├── project.ts # Project loading logic -│ │ │ ├── app.ts -│ │ │ └── index.ts │ │ ├── utils/ -│ │ ├── consts.ts -│ │ ├── errors.ts -│ │ └── index.ts +│ │ │ ├── fs.ts # File system utilities +│ │ │ └── index.ts +│ │ ├── consts.ts # Pure constants (NO imports from other core modules) +│ │ ├── config.ts # Path helpers and env loading +│ │ ├── errors.ts # Error classes +│ │ └── index.ts # Barrel export for all core modules │ └── cli/ │ ├── commands/ │ │ ├── auth/ -│ │ └── project/ +│ │ │ ├── login.ts +│ │ │ ├── logout.ts +│ │ │ └── whoami.ts +│ │ ├── project/ +│ │ │ ├── create.ts +│ │ │ └── show-project.ts +│ │ └── entities/ +│ │ └── push.ts │ ├── utils/ -│ └── index.ts +│ │ ├── runCommand.ts # Command wrapper with branding +│ │ ├── runTask.ts # Spinner wrapper +│ │ ├── banner.ts # ASCII art banner +│ │ ├── prompts.ts # Prompt utilities +│ │ └── index.ts +│ └── index.ts # CLI entry point +├── templates/ # Project templates +├── tests/ ├── dist/ ├── package.json └── tsconfig.json ``` +## Adding a New Command + +Commands live in `src/cli/commands/`. Follow these steps: + +### 1. Create the command file + +```typescript +// src/cli/commands//.ts +import { Command } from "commander"; +import { log } from "@clack/prompts"; +import { runCommand, runTask } from "../../utils/index.js"; + +async function myAction(): Promise { + // Use runTask for async operations with spinners + const result = await runTask( + "Doing something...", + async () => { + // Your async operation here + return someResult; + }, + { + successMessage: "Done!", + errorMessage: "Failed to do something", + } + ); + + log.success("Operation completed!"); +} + +export const myCommand = new Command("") + .description("") + .option("-f, --flag", "Some flag") + .action(async (options) => { + await runCommand(myAction); + }); +``` + +### 2. Register in CLI entry point + +```typescript +// src/cli/index.ts +import { myCommand } from "./commands//.js"; + +// ... +program.addCommand(myCommand); +``` + +### 3. Command wrapper options + +```typescript +// Standard command with simple intro tag +await runCommand(myAction); + +// Command with full ASCII art banner (for special commands like create) +await runCommand(myAction, { fullBanner: true }); + +// Command with no intro (rare) +await runCommand(myAction, { skipIntro: true }); +``` + +## Making API Calls + +Use the HTTP clients from `@core/api/index.js`: + +### Authenticated API calls (most common) + +```typescript +import { base44Client, getAppClient } from "@core/api/index.js"; + +// For general Base44 API calls +const response = await base44Client.get("api/endpoint"); +const data = await response.json(); + +// For app-specific API calls (requires BASE44_CLIENT_ID env var) +const appClient = getAppClient(); +const response = await appClient.get("entities"); +const entities = await response.json(); + +// POST with JSON body +const response = await base44Client.post("api/endpoint", { + json: { key: "value" }, +}); +``` + +### OAuth endpoints (login flow only) + +```typescript +import { oauthClient } from "@core/api/index.js"; + +// Used only in auth/api.ts for device code flow +const response = await oauthClient.post("oauth/device/code", { + json: { client_id: AUTH_CLIENT_ID, scope: "apps:read apps:write" }, +}); +``` + +### Token refresh + +The `base44Client` automatically handles token refresh: +1. Before each request, checks if token is expired +2. If expired, refreshes token and saves new tokens +3. On 401 response, attempts refresh and retries once + ## Resource Pattern Resources are project-specific collections (entities, functions) that can be loaded from the filesystem. -### Resource Interface (`config/resource.ts`) +### Resource Interface (`resources/types.ts`) + ```typescript export interface Resource { - readAll(dir: string): Promise; + readAll: (dir: string) => Promise; + push?: (items: T[]) => Promise; } ``` ### Resource Implementation (`resources//resource.ts`) + ```typescript export const entityResource: Resource = { readAll: readAllEntities, + push: pushEntities, }; ``` ### Adding a New Resource + 1. Create folder in `src/core/resources//` 2. Add `schema.ts` with Zod schemas 3. Add `config.ts` with file reading logic 4. Add `resource.ts` implementing `Resource` -5. Add `index.ts` barrel exports -6. Register in `config/project.ts` resources list -7. Add typed field to `ProjectData` interface +5. Add `api.ts` for API calls (if needed) +6. Add `index.ts` barrel exports +7. Update `resources/index.ts` to export the new resource +8. Register in `project/config.ts` (add to `readProjectConfig`) +9. Add typed field to `ProjectData` interface ## Path Aliases @@ -94,20 +228,22 @@ Single alias defined in `tsconfig.json`: - `@core/*` → `./src/core/*` ```typescript -import { readProjectConfig } from "@core/config/project.js"; +import { readProjectConfig } from "@core/project/index.js"; import { entityResource } from "@core/resources/entity/index.js"; +import { base44Client } from "@core/api/index.js"; ``` ## Important Rules 1. **npm only** - Never use yarn -2. **Zod validation** - Required for all external data -3. **@clack/prompts** - For all user interaction +2. **Zod validation** - Required for all external data (API responses, config files) +3. **@clack/prompts** - For all user interaction (prompts, spinners, logs) 4. **ES Modules** - Use `.js` extensions in imports 5. **Cross-platform** - Use `path` module utilities, never hardcode separators 6. **Command wrapper** - All commands use `runCommand()` utility 7. **Task wrapper** - Use `runTask()` for async operations with spinners -8. **Keep AGENTS.md updated** - Update this file when architecture changes +8. **consts.ts has no imports** - Keep `consts.ts` dependency-free to avoid circular deps +9. **Keep AGENTS.md updated** - Update this file when architecture changes ## Development @@ -116,6 +252,7 @@ npm run build # tsdown - bundles to single file in dist/cli/index.js npm run typecheck # tsc --noEmit - type checking only npm run dev # tsx for development npm test # vitest +npm run lint # eslint ``` ### Node.js Version @@ -128,5 +265,5 @@ This project requires Node.js >= 20.19.0. A `.node-version` file is provided for - `cli/AGENTS.md` - This file - `cli/src/core/` - Core module - `cli/src/cli/` - CLI commands -- `cli/tsdown.config.ts` - Build configuration -- `cli/.node-version` - Node.js version pinning \ No newline at end of file +- `cli/tsdown.config.mjs` - Build configuration +- `cli/.node-version` - Node.js version pinning diff --git a/README.md b/README.md index b6ba88aa5..adc2ce5d0 100644 --- a/README.md +++ b/README.md @@ -5,45 +5,140 @@ A unified command-line interface for managing Base44 applications, entities, fun ## Installation ```bash -# Using npm -npm install +# Using npm (globally) +npm install -g base44 -# Build the project -npm run build +# Or run directly with npx +npx base44 +``` + +## Quick Start + +```bash +# 1. Login to Base44 +base44 login + +# 2. Create a new project +base44 create + +# 3. Push entities to Base44 +base44 entities push +``` + +## Commands + +### Authentication + +| Command | Description | +|---------|-------------| +| `base44 login` | Authenticate with Base44 using device code flow | +| `base44 whoami` | Display current authenticated user | +| `base44 logout` | Logout from current device | + +### Project Management + +| Command | Description | +|---------|-------------| +| `base44 create` | Create a new Base44 project from a template | + +### Entities + +| Command | Description | +|---------|-------------| +| `base44 entities push` | Push local entity schemas to Base44 | -# Run the CLI -npm start # Using node directly -./dist/cli/index.js # Run executable directly +## Configuration + +### Project Configuration + +Base44 projects are configured via a `config.jsonc` (or `config.json`) file in the `base44/` subdirectory: + +```jsonc +// base44/config.jsonc +{ + "id": "your-app-id", // Set after project creation + "name": "My Project", + "entitiesDir": "./entities", // Default: ./entities + "functionsDir": "./functions" // Default: ./functions +} +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `BASE44_CLIENT_ID` | Your app ID | - | + +You can set these in a `.env.local` file in your `base44/` directory: + +```bash +# base44/.env.local +BASE44_CLIENT_ID=your-app-id +``` + +## Project Structure + +A typical Base44 project has this structure: + +``` +my-project/ +├── base44/ +│ ├── config.jsonc # Project configuration +│ ├── .env.local # Environment variables (git-ignored) +│ ├── entities/ # Entity schema files +│ │ ├── user.jsonc +│ │ └── product.jsonc +├── src/ # Your frontend code +└── package.json ``` ## Development +### Prerequisites + +- Node.js >= 20.19.0 +- npm + +### Setup + ```bash -# Run in development mode -npm run dev +# Clone the repository +git clone https://github.com/base44/cli.git +cd cli -# Build the project -npm run build +# Install dependencies +npm install -# Run the built CLI -npm run start +# Build +npm run build -# Clean build artifacts -npm run clean +# Run in development mode +npm run dev -- +``` -# Lint the code -npm run lint +### Available Scripts +```bash +npm run build # Build with tsdown +npm run typecheck # Type check with tsc +npm run dev # Run in development mode with tsx +npm run lint # Lint with ESLint +npm test # Run tests with Vitest ``` -## Commands +### Running the Built CLI -### Authentication +```bash +# After building +npm start -- + +# Or directly +./dist/cli/index.js +``` +## Contributing -- `base44 login` - Authenticate with Base44 using device code flow -- `base44 whoami` - Display current authenticated user -- `base44 logout` - Logout from current device +See [AGENTS.md](./AGENTS.md) for development guidelines and architecture documentation. -### Project +## License -- `base44 show-project` - Display project configuration, entities, and functions +ISC diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts index 73c00f654..1e2d97bbb 100644 --- a/src/cli/commands/project/create.ts +++ b/src/cli/commands/project/create.ts @@ -4,17 +4,11 @@ import { log, group, text, select } from "@clack/prompts"; import type { Option } from "@clack/prompts"; import chalk from "chalk"; import kebabCase from "lodash.kebabcase"; -import { loadProjectEnv } from "@core/config.js"; import { createProjectFiles, listTemplates } from "@core/project/index.js"; import type { Template } from "@core/project/index.js"; -import { runTask, printBanner, onPromptCancel } from "../../utils/index.js"; +import { runCommand, runTask, onPromptCancel } from "../../utils/index.js"; async function create(): Promise { - printBanner(); - - // Load .env.local from project root (if in a project) - await loadProjectEnv(); - const templates = await listTemplates(); const templateOptions: Array> = templates.map((t) => ({ value: t, @@ -83,14 +77,5 @@ async function create(): Promise { export const createCommand = new Command("create") .description("Create a new Base44 project") .action(async () => { - try { - await create(); - } catch (e) { - if (e instanceof Error) { - log.error(e.stack ?? e.message); - } else { - log.error(String(e)); - } - process.exit(1); - } + await runCommand(create, { fullBanner: true }); }); diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 571a06aab..6101d614b 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -1,22 +1,44 @@ import { intro, log } from "@clack/prompts"; import chalk from "chalk"; import { loadProjectEnv } from "@core/config.js"; +import { printBanner } from "./banner.js"; const base44Color = chalk.bgHex("#E86B3C"); +export interface RunCommandOptions { + /** + * Use the full ASCII art banner instead of the simple intro tag. + * Useful for commands like `create` that want more visual impact. + * @default false + */ + fullBanner?: boolean; +} + /** - * Wraps a command function with the Base44 intro banner. + * Wraps a command function with the Base44 intro banner and error handling. * All CLI commands should use this utility to ensure consistent branding. * Also loads .env.local from the project root if available. * * @param commandFn - The async function to execute as the command + * @param options - Optional configuration for the command wrapper + * + * @example + * // Standard command with simple intro + * export const myCommand = new Command("my-command") + * .action(async () => { + * await runCommand(myAction); + * }); */ export async function runCommand( - commandFn: () => Promise + commandFn: () => Promise, + options?: RunCommandOptions ): Promise { - intro(base44Color(" Base 44 ")); + if (options?.fullBanner) { + printBanner(); + } else { + intro(base44Color(" Base 44 ")); + } - // Load .env.local from project root (if in a project) await loadProjectEnv(); try { diff --git a/src/cli/utils/runTask.ts b/src/cli/utils/runTask.ts index 1aa4c9ef3..ff339bef8 100644 --- a/src/cli/utils/runTask.ts +++ b/src/cli/utils/runTask.ts @@ -6,8 +6,21 @@ import { spinner } from "@clack/prompts"; * * @param startMessage - Message to show when spinner starts * @param operation - The async operation to execute - * @param options - Optional configuration + * @param options - Optional configuration for success/error messages * @returns The result of the operation + * + * @example + * const data = await runTask( + * "Fetching data...", + * async () => { + * const response = await fetch(url); + * return response.json(); + * }, + * { + * successMessage: "Data fetched successfully", + * errorMessage: "Failed to fetch data", + * } + * ); */ export async function runTask( startMessage: string, diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index df7016577..642843f52 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -10,11 +10,11 @@ import type { TokenResponse, UserInfoResponse, } from "./schema.js"; -import { AUTH_CLIENT_ID } from "../config.js"; -import authClient from "./authClient.js"; +import { AUTH_CLIENT_ID } from "../consts.js"; +import { oauthClient } from "../clients/index.js"; export async function generateDeviceCode(): Promise { - const response = await authClient.post("oauth/device/code", { + const response = await oauthClient.post("oauth/device/code", { json: { client_id: AUTH_CLIENT_ID, scope: "apps:read apps:write", @@ -50,7 +50,7 @@ export async function getTokenFromDeviceCode( searchParams.set("device_code", deviceCode); searchParams.set("client_id", AUTH_CLIENT_ID); - const response = await authClient.post("oauth/token", { + const response = await oauthClient.post("oauth/token", { body: searchParams.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -99,7 +99,7 @@ export async function renewAccessToken( searchParams.set("refresh_token", refreshToken); searchParams.set("client_id", AUTH_CLIENT_ID); - const response = await authClient.post("oauth/token", { + const response = await oauthClient.post("oauth/token", { body: searchParams.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -134,7 +134,7 @@ export async function renewAccessToken( export async function getUserInfo( accessToken: string ): Promise { - const response = await authClient.get("oauth/userinfo", { + const response = await oauthClient.get("oauth/userinfo", { headers: { Authorization: `Bearer ${accessToken}` }, }); diff --git a/src/core/auth/authClient.ts b/src/core/auth/authClient.ts deleted file mode 100644 index 1a1a4d192..000000000 --- a/src/core/auth/authClient.ts +++ /dev/null @@ -1,16 +0,0 @@ -import ky from "ky"; -import { getBase44ApiUrl } from "../config.js"; - -/** - * Separate ky instance for OAuth endpoints. - * These don't need Authorization headers (they use client_id + tokens in body). - */ -const authClient = ky.create({ - prefixUrl: getBase44ApiUrl(), - headers: { - "User-Agent": "Base44 CLI", - }, -}); - -export default authClient; - diff --git a/src/core/auth/config.ts b/src/core/auth/config.ts index 78902f202..2e0aad394 100644 --- a/src/core/auth/config.ts +++ b/src/core/auth/config.ts @@ -10,6 +10,16 @@ const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; // Lock to prevent concurrent token refreshes let refreshPromise: Promise | null = null; +/** + * Reads and validates the stored authentication data. + * + * @returns The parsed authentication data (tokens, user info). + * @throws {Error} If not logged in or if auth data is corrupted. + * + * @example + * const auth = await readAuth(); + * console.log(`Logged in as: ${auth.email}`); + */ export async function readAuth(): Promise { try { const parsed = await readJsonFile(getAuthFilePath()); @@ -25,12 +35,6 @@ export async function readAuth(): Promise { return result.data; } catch (error) { - if (error instanceof Error && error.message.includes("Authentication")) { - throw error; - } - if (error instanceof Error && error.message.includes("File not found")) { - throw new Error("Authentication file not found. Please login first."); - } throw new Error( `Failed to read authentication file: ${ error instanceof Error ? error.message : "Unknown error" @@ -73,18 +77,10 @@ export async function deleteAuth(): Promise { } } -/** - * Checks if the access token is expired or about to expire. - */ export function isTokenExpired(auth: AuthData): boolean { return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS; } -/** - * Refreshes the access token and saves the new tokens. - * Returns the new access token, or null if refresh failed. - * Uses a lock to prevent concurrent refresh requests. - */ export async function refreshAndSaveTokens(): Promise { // If a refresh is already in progress, wait for it if (refreshPromise) { diff --git a/src/core/auth/schema.ts b/src/core/auth/schema.ts index abe39a2d8..494914d9c 100644 --- a/src/core/auth/schema.ts +++ b/src/core/auth/schema.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -// Auth data schema (stored locally) export const AuthDataSchema = z.object({ accessToken: z.string().min(1, "Token cannot be empty"), refreshToken: z.string().min(1, "Refresh token cannot be empty"), diff --git a/src/core/utils/httpClient.ts b/src/core/clients/base44-client.ts similarity index 70% rename from src/core/utils/httpClient.ts rename to src/core/clients/base44-client.ts index 61255cc9d..cfffdb599 100644 --- a/src/core/utils/httpClient.ts +++ b/src/core/clients/base44-client.ts @@ -1,3 +1,8 @@ +/** + * Authenticated HTTP client for Base44 API. + * Automatically handles token refresh and retry on 401 responses. + */ + import ky from "ky"; import type { KyRequest, KyResponse, NormalizedOptions } from "ky"; import { getBase44ApiUrl, getBase44ClientId } from "../config.js"; @@ -42,7 +47,11 @@ async function handleUnauthorized( }); } -const base44Client = ky.create({ +/** + * Base44 API client with automatic authentication. + * Use this for general API calls that require authentication. + */ +export const base44Client = ky.create({ prefixUrl: getBase44ApiUrl(), headers: { "User-Agent": "Base44 CLI", @@ -74,12 +83,23 @@ const base44Client = ky.create({ /** * Returns an HTTP client scoped to the current app. + * Use this for API calls to app-specific endpoints (entities, functions, etc.). + * + * @throws {Error} If BASE44_CLIENT_ID environment variable is not set. + * + * @example + * const appClient = getAppClient(); + * const response = await appClient.get("entities"); */ -function getAppClient() { +export function getAppClient() { + const clientId = getBase44ClientId(); + if (!clientId) { + throw new Error( + "BASE44_CLIENT_ID environment variable is required. Set it in your .env.local file." + ); + } + return base44Client.extend({ - prefixUrl: new URL(`/api/apps/${getBase44ClientId()}/`, getBase44ApiUrl()) - .href, + prefixUrl: new URL(`/api/apps/${clientId}/`, getBase44ApiUrl()).href, }); } - -export { base44Client, getAppClient }; diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts new file mode 100644 index 000000000..a39127612 --- /dev/null +++ b/src/core/clients/index.ts @@ -0,0 +1,2 @@ +export { oauthClient } from "./oauth-client.js"; +export { base44Client, getAppClient } from "./base44-client.js"; diff --git a/src/core/clients/oauth-client.ts b/src/core/clients/oauth-client.ts new file mode 100644 index 000000000..ae8dd2224 --- /dev/null +++ b/src/core/clients/oauth-client.ts @@ -0,0 +1,15 @@ +/** + * HTTP client for OAuth endpoints. + * Used only for the login flow (device code, token exchange). + * These endpoints don't need Authorization headers - they use client_id + tokens in body. + */ + +import ky from "ky"; +import { getBase44ApiUrl } from "../config.js"; + +export const oauthClient = ky.create({ + prefixUrl: getBase44ApiUrl(), + headers: { + "User-Agent": "Base44 CLI", + }, +}); diff --git a/src/core/config.ts b/src/core/config.ts index f14dfc0a5..c98be400e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,43 +2,32 @@ import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { config } from "dotenv"; +import { PROJECT_SUBDIR } from "./consts.js"; import { findProjectRoot } from "./project/index.js"; // After bundling, import.meta.url points to dist/cli/index.js // Templates are copied to dist/cli/templates/ const __dirname = dirname(fileURLToPath(import.meta.url)); -// Static constants -export const PROJECT_SUBDIR = "base44"; -export const FUNCTION_CONFIG_FILE = "function.jsonc"; -export const AUTH_CLIENT_ID = "base44_cli"; - -// Path helpers -export function getBase44Dir() { +export function getBase44GlobalDir(): string { return join(homedir(), ".base44"); } -export function getAuthFilePath() { - return join(getBase44Dir(), "auth", "auth.json"); +export function getAuthFilePath(): string { + return join(getBase44GlobalDir(), "auth", "auth.json"); } -export function getTemplatesDir() { +export function getTemplatesDir(): string { return join(__dirname, "templates"); } -export function getProjectConfigPatterns() { - return [ - `${PROJECT_SUBDIR}/config.jsonc`, - `${PROJECT_SUBDIR}/config.json`, - "config.jsonc", - "config.json", - ]; +export function getTemplatesIndexPath(): string { + return join(getTemplatesDir(), "templates.json"); } /** * Load .env.local from the project root if it exists. * Values won't override existing process.env variables. - * Safe to call multiple times - only loads once. */ export async function loadProjectEnv(projectRoot?: string): Promise { const found = projectRoot ? { root: projectRoot } : await findProjectRoot(); @@ -51,19 +40,10 @@ export async function loadProjectEnv(projectRoot?: string): Promise { config({ path: envPath, override: false, quiet: true }); } -/** - * Get the Base44 API URL. - * Priority: process.env.BASE44_API_URL > .env.local > default - */ export function getBase44ApiUrl(): string { return process.env.BASE44_API_URL || "https://app.base44.com"; } -/** - * Get the Base44 Client ID (app ID). - * Priority: process.env.BASE44_CLIENT_ID > .env.local - * Returns undefined if not set. - */ export function getBase44ClientId(): string | undefined { return process.env.BASE44_CLIENT_ID; } diff --git a/src/core/consts.ts b/src/core/consts.ts new file mode 100644 index 000000000..c7d52f39f --- /dev/null +++ b/src/core/consts.ts @@ -0,0 +1,14 @@ +// Project structure +export const PROJECT_SUBDIR = "base44"; +export const FUNCTION_CONFIG_FILE = "function.jsonc"; +export function getProjectConfigPatterns(): string[] { + return [ + `${PROJECT_SUBDIR}/config.jsonc`, + `${PROJECT_SUBDIR}/config.json`, + "config.jsonc", + "config.json", + ]; +} + +// Auth +export const AUTH_CLIENT_ID = "base44_cli"; diff --git a/src/core/index.ts b/src/core/index.ts index 992fe1908..c1608eb16 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,8 @@ export * from "./auth/index.js"; export * from "./resources/index.js"; export * from "./project/index.js"; +export * from "./clients/index.js"; export * from "./utils/index.js"; export * from "./errors.js"; +export * from "./consts.js"; export * from "./config.js"; \ No newline at end of file diff --git a/src/core/project/api.ts b/src/core/project/api.ts index 0a9efc3b3..1b80fa750 100644 --- a/src/core/project/api.ts +++ b/src/core/project/api.ts @@ -1,4 +1,4 @@ -import { base44Client } from "@core/utils/httpClient.js"; +import { base44Client } from "@core/clients/index.js"; import { CreateProjectResponseSchema } from "./schema.js"; export async function createProject(projectName: string, description?: string) { diff --git a/src/core/project/baseResource.ts b/src/core/project/baseResource.ts deleted file mode 100644 index 139ce2922..000000000 --- a/src/core/project/baseResource.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Resource { - readAll: (dir: string) => Promise; - push?: (items: T[]) => Promise; -} diff --git a/src/core/project/config.ts b/src/core/project/config.ts index 7cbc194f0..85b5cfa5e 100644 --- a/src/core/project/config.ts +++ b/src/core/project/config.ts @@ -1,36 +1,11 @@ import { join, dirname } from "node:path"; -import { z } from "zod"; import { globby } from "globby"; -import { getProjectConfigPatterns, PROJECT_SUBDIR } from "../config.js"; +import { getProjectConfigPatterns, PROJECT_SUBDIR } from "../consts.js"; import { readJsonFile } from "../utils/fs.js"; import { entityResource } from "../resources/entity/index.js"; -import type { Entity } from "../resources/entity/index.js"; import { functionResource } from "../resources/function/index.js"; -import type { FunctionConfig } from "../resources/function/index.js"; - -export const ProjectConfigSchema = z.looseObject({ - name: z.string().min(1, "Project name cannot be empty"), - entitiesDir: z.string().default("./entities"), - functionsDir: z.string().default("./functions"), -}); - -export type ProjectConfig = z.infer; - -export interface ProjectWithPaths extends ProjectConfig { - root: string; - configPath: string; -} - -export interface ProjectRoot { - root: string; - configPath: string; -} - -export interface ProjectData { - project: ProjectWithPaths; - entities: Entity[]; - functions: FunctionConfig[]; -} +import type { ProjectData, ProjectRoot } from "./types.js"; +import { ProjectConfigSchema } from "./schema.js"; async function findConfigInDir(dir: string): Promise { const files = await globby(getProjectConfigPatterns(), { @@ -40,6 +15,19 @@ async function findConfigInDir(dir: string): Promise { return files[0] ?? null; } +/** + * Searches for a Base44 project root by looking for config files. + * Walks up the directory tree from the starting path until it finds a config file. + * + * @param startPath - Directory to start searching from. Defaults to cwd. + * @returns Project root info if found, null otherwise. + * + * @example + * const found = await findProjectRoot(); + * if (found) { + * console.log(`Project found at: ${found.root}`); + * } + */ export async function findProjectRoot( startPath?: string ): Promise { @@ -56,6 +44,17 @@ export async function findProjectRoot( return null; } +/** + * Reads and validates a Base44 project configuration from the filesystem. + * Also loads all entities and functions defined in the project. + * + * @param projectRoot - Optional path to start searching from. Defaults to cwd. + * @returns Project configuration including entities and functions. + * @throws {Error} If no config file is found or if the config is invalid. + * + * @example + * const { project, entities, functions } = await readProjectConfig(); + */ export async function readProjectConfig( projectRoot?: string ): Promise { @@ -80,8 +79,7 @@ export async function readProjectConfig( const result = ProjectConfigSchema.safeParse(parsed); if (!result.success) { - const errors = result.error.issues.map((e) => e.message).join(", "); - throw new Error(`Invalid project configuration: ${errors}`); + throw new Error(`Invalid project configuration: ${result.error.message}`); } const project = result.data; diff --git a/src/core/project/create.ts b/src/core/project/create.ts index 2d5a1341e..44cc38650 100644 --- a/src/core/project/create.ts +++ b/src/core/project/create.ts @@ -1,5 +1,5 @@ import { globby } from "globby"; -import { getProjectConfigPatterns } from "../config.js"; +import { getProjectConfigPatterns } from "../consts.js"; import { createProject } from "./api.js"; import { renderTemplate } from "./template.js"; import type { Template } from "./schema.js"; diff --git a/src/core/project/index.ts b/src/core/project/index.ts index 32fc54046..c625e3681 100644 --- a/src/core/project/index.ts +++ b/src/core/project/index.ts @@ -1,4 +1,3 @@ -export type * from "./baseResource.js"; export * from "./config.js"; export * from "./schema.js"; export * from "./api.js"; diff --git a/src/core/project/schema.ts b/src/core/project/schema.ts index c146b97cf..980e71ee4 100644 --- a/src/core/project/schema.ts +++ b/src/core/project/schema.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -// Template schemas export const TemplateSchema = z.object({ id: z.string(), name: z.string(), @@ -15,7 +14,6 @@ export const TemplatesConfigSchema = z.object({ export type Template = z.infer; export type TemplatesConfig = z.infer; -// App config schemas const SiteConfigSchema = z.object({ buildCommand: z.string().optional(), serveCommand: z.string().optional(), @@ -23,15 +21,16 @@ const SiteConfigSchema = z.object({ installCommand: z.string().optional(), }); -export const AppConfigSchema = z.object({ +export const ProjectConfigSchema = z.object({ name: z.string().min(1, "App name cannot be empty"), description: z.string().optional(), site: SiteConfigSchema.optional(), - domains: z.array(z.string()).optional(), + entitiesDir: z.string().optional().default("entities"), + functionsDir: z.string().optional().default("functions"), }); export type SiteConfig = z.infer; -export type AppConfig = z.infer; +export type ProjectConfig = z.infer; export const CreateProjectResponseSchema = z.looseObject({ id: z.string(), diff --git a/src/core/project/template.ts b/src/core/project/template.ts index 6bc60f0fd..7182edaf2 100644 --- a/src/core/project/template.ts +++ b/src/core/project/template.ts @@ -1,7 +1,7 @@ -import { join, isAbsolute } from "node:path"; +import { join } from "node:path"; import { globby } from "globby"; import ejs from "ejs"; -import { getTemplatesDir } from "../config.js"; +import { getTemplatesDir, getTemplatesIndexPath } from "../config.js"; import { readJsonFile, writeFile, copyFile } from "../utils/fs.js"; import { TemplatesConfigSchema } from "./schema.js"; import type { Template } from "./schema.js"; @@ -13,8 +13,7 @@ export interface TemplateData { } export async function listTemplates(): Promise { - const configPath = join(getTemplatesDir(), "templates.json"); - const parsed = await readJsonFile(configPath); + const parsed = await readJsonFile(getTemplatesIndexPath()); const result = TemplatesConfigSchema.parse(parsed); return result.templates; } @@ -29,11 +28,6 @@ export async function renderTemplate( destPath: string, data: TemplateData ): Promise { - // Validate template path to prevent directory traversal - if (template.path.includes("..") || isAbsolute(template.path)) { - throw new Error(`Invalid template path: ${template.path}`); - } - const templateDir = join(getTemplatesDir(), template.path); // Get all files in the template directory diff --git a/src/core/project/types.ts b/src/core/project/types.ts new file mode 100644 index 000000000..10311a0cf --- /dev/null +++ b/src/core/project/types.ts @@ -0,0 +1,19 @@ +import type { Entity } from "../resources/entity/index.js"; +import type { FunctionConfig } from "../resources/function/index.js"; +import type { ProjectConfig } from "./schema.js"; + +interface ProjectWithPaths extends ProjectConfig { + root: string; + configPath: string; +} + +export interface ProjectRoot { + root: string; + configPath: string; +} + +export interface ProjectData { + project: ProjectWithPaths; + entities: Entity[]; + functions: FunctionConfig[]; +} diff --git a/src/core/resources/entity/api.ts b/src/core/resources/entity/api.ts index 296818b84..1c39fd308 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,4 +1,4 @@ -import { getAppClient } from "@core/utils/index.js"; +import { getAppClient } from "@core/clients/index.js"; import { SyncEntitiesResponseSchema } from "./schema.js"; import type { SyncEntitiesResponse, Entity } from "./schema.js"; diff --git a/src/core/resources/entity/resource.ts b/src/core/resources/entity/resource.ts index f27b0cef5..23e67b044 100644 --- a/src/core/resources/entity/resource.ts +++ b/src/core/resources/entity/resource.ts @@ -1,4 +1,4 @@ -import type { Resource } from "@core/project/baseResource.js"; +import type { Resource } from "../types.js"; import type { Entity } from "./schema.js"; import { readAllEntities } from "./config.js"; import { pushEntities } from "./api.js"; diff --git a/src/core/resources/entity/schema.ts b/src/core/resources/entity/schema.ts index 2a75fea9f..b7fe01301 100644 --- a/src/core/resources/entity/schema.ts +++ b/src/core/resources/entity/schema.ts @@ -1,37 +1,9 @@ import { z } from "zod"; -const EntityPropertySchema = z.object({ - type: z.string(), - description: z.string().optional(), - enum: z.array(z.string()).optional(), - default: z.union([z.string(), z.number(), z.boolean()]).optional(), - format: z.string().optional(), - items: z.any().optional(), - relation: z - .object({ - entity: z.string(), - type: z.string(), - }) - .optional(), -}); - -const EntityPoliciesSchema = z.object({ - read: z.string().optional(), - create: z.string().optional(), - update: z.string().optional(), - delete: z.string().optional(), -}); - export const EntitySchema = z.object({ name: z.string().min(1, "Entity name cannot be empty"), - type: z.literal("object"), - properties: z.record(z.string(), EntityPropertySchema), - required: z.array(z.string()).optional(), - policies: EntityPoliciesSchema.optional(), }); -export type EntityProperty = z.infer; -export type EntityPolicies = z.infer; export type Entity = z.infer; export const SyncEntitiesResponseSchema = z.object({ diff --git a/src/core/resources/function/config.ts b/src/core/resources/function/config.ts index bc9024b09..243576e7a 100644 --- a/src/core/resources/function/config.ts +++ b/src/core/resources/function/config.ts @@ -1,5 +1,5 @@ import { globby } from "globby"; -import { FUNCTION_CONFIG_FILE } from "../../config.js"; +import { FUNCTION_CONFIG_FILE } from "../../consts.js"; import { readJsonFile, pathExists } from "../../utils/fs.js"; import { FunctionConfigSchema } from "./schema.js"; import type { FunctionConfig } from "./schema.js"; diff --git a/src/core/resources/function/resource.ts b/src/core/resources/function/resource.ts index ec0866b40..d930a881a 100644 --- a/src/core/resources/function/resource.ts +++ b/src/core/resources/function/resource.ts @@ -1,4 +1,4 @@ -import type { Resource } from "@core/project/baseResource.js"; +import type { Resource } from "../types.js"; import type { FunctionConfig } from "./schema.js"; import { readAllFunctions } from "./config.js"; diff --git a/src/core/resources/index.ts b/src/core/resources/index.ts index 7a55108f3..d24f1a8f5 100644 --- a/src/core/resources/index.ts +++ b/src/core/resources/index.ts @@ -1,2 +1,3 @@ +export type { Resource } from "./types.js"; export * from "./entity/index.js"; export * from "./function/index.js"; diff --git a/src/core/resources/types.ts b/src/core/resources/types.ts new file mode 100644 index 000000000..156a41947 --- /dev/null +++ b/src/core/resources/types.ts @@ -0,0 +1,22 @@ +/** + * Base interface for all project resources (entities, functions, etc.). + * Resources are project-specific collections that can be loaded from the filesystem + * and optionally pushed to the Base44 API. + * + * @template T - The type of items in this resource collection + */ +export interface Resource { + /** + * Read all items of this resource type from a directory. + * @param dir - The directory to read from + * @returns Array of parsed and validated items + */ + readAll: (dir: string) => Promise; + + /** + * Push items to the Base44 API (optional). + * @param items - The items to push + * @returns API response + */ + push?: (items: T[]) => Promise; +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index b6995bfe1..844adcf0a 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1,2 +1 @@ export * from "./fs.js"; -export * from "./httpClient.js"; From 0eaa2cf05fa33cf227de9cdd4ed0e179b5491f85 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Tue, 13 Jan 2026 13:24:37 +0200 Subject: [PATCH 2/2] align fixtures in tests --- tests/core/project.test.ts | 3 --- tests/fixtures/basic/config.jsonc | 1 - tests/fixtures/invalid-entity/config.jsonc | 1 - tests/fixtures/invalid-json/config.jsonc | 2 +- tests/fixtures/with-entities/config.jsonc | 2 -- tests/fixtures/with-functions-and-entities/config.jsonc | 2 -- 6 files changed, 1 insertion(+), 10 deletions(-) diff --git a/tests/core/project.test.ts b/tests/core/project.test.ts index e32be2921..7cdad13fb 100644 --- a/tests/core/project.test.ts +++ b/tests/core/project.test.ts @@ -9,7 +9,6 @@ describe("readProjectConfig", () => { it("reads basic project config", async () => { const result = await readProjectConfig(resolve(FIXTURES_DIR, "basic")); - expect(result.project.id).toBe("test-basic-project"); expect(result.project.name).toBe("Basic Test Project"); expect(result.entities).toEqual([]); expect(result.functions).toEqual([]); @@ -20,7 +19,6 @@ describe("readProjectConfig", () => { resolve(FIXTURES_DIR, "with-entities") ); - expect(result.project.id).toBe("test-entities-project"); expect(result.entities).toHaveLength(2); expect(result.entities.map((e) => e.name)).toContain("User"); expect(result.entities.map((e) => e.name)).toContain("Product"); @@ -32,7 +30,6 @@ describe("readProjectConfig", () => { resolve(FIXTURES_DIR, "with-functions-and-entities") ); - expect(result.project.id).toBe("test-full-project"); expect(result.entities).toHaveLength(1); expect(result.entities[0].name).toBe("Order"); expect(result.functions).toHaveLength(1); diff --git a/tests/fixtures/basic/config.jsonc b/tests/fixtures/basic/config.jsonc index 317890462..e00eaedf1 100644 --- a/tests/fixtures/basic/config.jsonc +++ b/tests/fixtures/basic/config.jsonc @@ -1,5 +1,4 @@ { - "id": "test-basic-project", "name": "Basic Test Project", "createdAt": "2024-01-01T00:00:00Z" } diff --git a/tests/fixtures/invalid-entity/config.jsonc b/tests/fixtures/invalid-entity/config.jsonc index 4fb46fdc1..dc29f355f 100644 --- a/tests/fixtures/invalid-entity/config.jsonc +++ b/tests/fixtures/invalid-entity/config.jsonc @@ -1,5 +1,4 @@ { - "id": "test-invalid-entity", "name": "Invalid Entity Project", "createdAt": "2024-01-01T00:00:00Z" } diff --git a/tests/fixtures/invalid-json/config.jsonc b/tests/fixtures/invalid-json/config.jsonc index ec01d2493..83e080c09 100644 --- a/tests/fixtures/invalid-json/config.jsonc +++ b/tests/fixtures/invalid-json/config.jsonc @@ -1,5 +1,5 @@ { - "id": "broken-project" "name": "Missing comma above" + "description": "wow" } diff --git a/tests/fixtures/with-entities/config.jsonc b/tests/fixtures/with-entities/config.jsonc index 78acf7564..8447db21a 100644 --- a/tests/fixtures/with-entities/config.jsonc +++ b/tests/fixtures/with-entities/config.jsonc @@ -1,6 +1,4 @@ { - "id": "test-entities-project", "name": "Entities Test Project", "createdAt": "2024-01-01T00:00:00Z" } - diff --git a/tests/fixtures/with-functions-and-entities/config.jsonc b/tests/fixtures/with-functions-and-entities/config.jsonc index 2bd5ad55b..92fad3a83 100644 --- a/tests/fixtures/with-functions-and-entities/config.jsonc +++ b/tests/fixtures/with-functions-and-entities/config.jsonc @@ -1,6 +1,4 @@ { - "id": "test-full-project", "name": "Full Test Project", "createdAt": "2024-01-01T00:00:00Z" } -