@base44-preview/cli 0.0.1-pr.17.16cb029 → 0.0.1-pr.18.893dad9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -24
- package/dist/cli/index.js +251 -234
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,45 +5,140 @@ A unified command-line interface for managing Base44 applications, entities, fun
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
# Using npm
|
|
9
|
-
npm install
|
|
8
|
+
# Using npm (globally)
|
|
9
|
+
npm install -g base44
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# Or run directly with npx
|
|
12
|
+
npx base44 <command>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 1. Login to Base44
|
|
19
|
+
base44 login
|
|
20
|
+
|
|
21
|
+
# 2. Create a new project
|
|
22
|
+
base44 create
|
|
23
|
+
|
|
24
|
+
# 3. Push entities to Base44
|
|
25
|
+
base44 entities push
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
### Authentication
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|---------|-------------|
|
|
34
|
+
| `base44 login` | Authenticate with Base44 using device code flow |
|
|
35
|
+
| `base44 whoami` | Display current authenticated user |
|
|
36
|
+
| `base44 logout` | Logout from current device |
|
|
37
|
+
|
|
38
|
+
### Project Management
|
|
39
|
+
|
|
40
|
+
| Command | Description |
|
|
41
|
+
|---------|-------------|
|
|
42
|
+
| `base44 create` | Create a new Base44 project from a template |
|
|
43
|
+
|
|
44
|
+
### Entities
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---------|-------------|
|
|
48
|
+
| `base44 entities push` | Push local entity schemas to Base44 |
|
|
13
49
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
### Project Configuration
|
|
53
|
+
|
|
54
|
+
Base44 projects are configured via a `config.jsonc` (or `config.json`) file in the `base44/` subdirectory:
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
// base44/config.jsonc
|
|
58
|
+
{
|
|
59
|
+
"id": "your-app-id", // Set after project creation
|
|
60
|
+
"name": "My Project",
|
|
61
|
+
"entitiesDir": "./entities", // Default: ./entities
|
|
62
|
+
"functionsDir": "./functions" // Default: ./functions
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Environment Variables
|
|
67
|
+
|
|
68
|
+
| Variable | Description | Default |
|
|
69
|
+
|----------|-------------|---------|
|
|
70
|
+
| `BASE44_CLIENT_ID` | Your app ID | - |
|
|
71
|
+
|
|
72
|
+
You can set these in a `.env.local` file in your `base44/` directory:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# base44/.env.local
|
|
76
|
+
BASE44_CLIENT_ID=your-app-id
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Project Structure
|
|
80
|
+
|
|
81
|
+
A typical Base44 project has this structure:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
my-project/
|
|
85
|
+
├── base44/
|
|
86
|
+
│ ├── config.jsonc # Project configuration
|
|
87
|
+
│ ├── .env.local # Environment variables (git-ignored)
|
|
88
|
+
│ ├── entities/ # Entity schema files
|
|
89
|
+
│ │ ├── user.jsonc
|
|
90
|
+
│ │ └── product.jsonc
|
|
91
|
+
├── src/ # Your frontend code
|
|
92
|
+
└── package.json
|
|
17
93
|
```
|
|
18
94
|
|
|
19
95
|
## Development
|
|
20
96
|
|
|
97
|
+
### Prerequisites
|
|
98
|
+
|
|
99
|
+
- Node.js >= 20.19.0
|
|
100
|
+
- npm
|
|
101
|
+
|
|
102
|
+
### Setup
|
|
103
|
+
|
|
21
104
|
```bash
|
|
22
|
-
#
|
|
23
|
-
|
|
105
|
+
# Clone the repository
|
|
106
|
+
git clone https://github.com/base44/cli.git
|
|
107
|
+
cd cli
|
|
24
108
|
|
|
25
|
-
#
|
|
26
|
-
npm
|
|
109
|
+
# Install dependencies
|
|
110
|
+
npm install
|
|
27
111
|
|
|
28
|
-
#
|
|
29
|
-
npm run
|
|
112
|
+
# Build
|
|
113
|
+
npm run build
|
|
30
114
|
|
|
31
|
-
#
|
|
32
|
-
npm run
|
|
115
|
+
# Run in development mode
|
|
116
|
+
npm run dev -- <command>
|
|
117
|
+
```
|
|
33
118
|
|
|
34
|
-
|
|
35
|
-
npm run lint
|
|
119
|
+
### Available Scripts
|
|
36
120
|
|
|
121
|
+
```bash
|
|
122
|
+
npm run build # Build with tsdown
|
|
123
|
+
npm run typecheck # Type check with tsc
|
|
124
|
+
npm run dev # Run in development mode with tsx
|
|
125
|
+
npm run lint # Lint with ESLint
|
|
126
|
+
npm test # Run tests with Vitest
|
|
37
127
|
```
|
|
38
128
|
|
|
39
|
-
|
|
129
|
+
### Running the Built CLI
|
|
40
130
|
|
|
41
|
-
|
|
131
|
+
```bash
|
|
132
|
+
# After building
|
|
133
|
+
npm start -- <command>
|
|
134
|
+
|
|
135
|
+
# Or directly
|
|
136
|
+
./dist/cli/index.js <command>
|
|
137
|
+
```
|
|
138
|
+
## Contributing
|
|
42
139
|
|
|
43
|
-
|
|
44
|
-
- `base44 whoami` - Display current authenticated user
|
|
45
|
-
- `base44 logout` - Logout from current device
|
|
140
|
+
See [AGENTS.md](./AGENTS.md) for development guidelines and architecture documentation.
|
|
46
141
|
|
|
47
|
-
|
|
142
|
+
## License
|
|
48
143
|
|
|
49
|
-
|
|
144
|
+
ISC
|
package/dist/cli/index.js
CHANGED
|
@@ -4,14 +4,14 @@ import chalk from "chalk";
|
|
|
4
4
|
import { cancel, group, intro, log, select, spinner, text } from "@clack/prompts";
|
|
5
5
|
import pWaitFor from "p-wait-for";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import
|
|
7
|
+
import ky from "ky";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { config } from "dotenv";
|
|
11
12
|
import { globby } from "globby";
|
|
12
13
|
import { access, copyFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
13
14
|
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
14
|
-
import ky from "ky";
|
|
15
15
|
import ejs from "ejs";
|
|
16
16
|
import kebabCase from "lodash.kebabcase";
|
|
17
17
|
|
|
@@ -27,12 +27,14 @@ const DeviceCodeResponseSchema = z.object({
|
|
|
27
27
|
device_code: z.string().min(1, "Device code cannot be empty"),
|
|
28
28
|
user_code: z.string().min(1, "User code cannot be empty"),
|
|
29
29
|
verification_uri: z.url("Invalid verification URL"),
|
|
30
|
+
verification_uri_complete: z.url("Invalid complete verification URL"),
|
|
30
31
|
expires_in: z.number().int().positive("Expires in must be a positive integer"),
|
|
31
32
|
interval: z.number().int().positive("Interval in must be a positive integer")
|
|
32
33
|
}).transform((data) => ({
|
|
33
34
|
deviceCode: data.device_code,
|
|
34
35
|
userCode: data.user_code,
|
|
35
36
|
verificationUri: data.verification_uri,
|
|
37
|
+
verificationUriComplete: data.verification_uri_complete,
|
|
36
38
|
expiresIn: data.expires_in,
|
|
37
39
|
interval: data.interval
|
|
38
40
|
}));
|
|
@@ -74,6 +76,20 @@ var AuthValidationError = class extends Error {
|
|
|
74
76
|
}
|
|
75
77
|
};
|
|
76
78
|
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/core/consts.ts
|
|
81
|
+
const PROJECT_SUBDIR = "base44";
|
|
82
|
+
const FUNCTION_CONFIG_FILE = "function.jsonc";
|
|
83
|
+
function getProjectConfigPatterns() {
|
|
84
|
+
return [
|
|
85
|
+
`${PROJECT_SUBDIR}/config.jsonc`,
|
|
86
|
+
`${PROJECT_SUBDIR}/config.json`,
|
|
87
|
+
"config.jsonc",
|
|
88
|
+
"config.json"
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
const AUTH_CLIENT_ID = "base44_cli";
|
|
92
|
+
|
|
77
93
|
//#endregion
|
|
78
94
|
//#region src/core/utils/fs.ts
|
|
79
95
|
async function pathExists(path) {
|
|
@@ -122,35 +138,7 @@ async function deleteFile(filePath) {
|
|
|
122
138
|
|
|
123
139
|
//#endregion
|
|
124
140
|
//#region src/core/resources/entity/schema.ts
|
|
125
|
-
const
|
|
126
|
-
type: z.string(),
|
|
127
|
-
description: z.string().optional(),
|
|
128
|
-
enum: z.array(z.string()).optional(),
|
|
129
|
-
default: z.union([
|
|
130
|
-
z.string(),
|
|
131
|
-
z.number(),
|
|
132
|
-
z.boolean()
|
|
133
|
-
]).optional(),
|
|
134
|
-
format: z.string().optional(),
|
|
135
|
-
items: z.any().optional(),
|
|
136
|
-
relation: z.object({
|
|
137
|
-
entity: z.string(),
|
|
138
|
-
type: z.string()
|
|
139
|
-
}).optional()
|
|
140
|
-
});
|
|
141
|
-
const EntityPoliciesSchema = z.object({
|
|
142
|
-
read: z.string().optional(),
|
|
143
|
-
create: z.string().optional(),
|
|
144
|
-
update: z.string().optional(),
|
|
145
|
-
delete: z.string().optional()
|
|
146
|
-
});
|
|
147
|
-
const EntitySchema = z.object({
|
|
148
|
-
name: z.string().min(1, "Entity name cannot be empty"),
|
|
149
|
-
type: z.literal("object"),
|
|
150
|
-
properties: z.record(z.string(), EntityPropertySchema),
|
|
151
|
-
required: z.array(z.string()).optional(),
|
|
152
|
-
policies: EntityPoliciesSchema.optional()
|
|
153
|
-
});
|
|
141
|
+
const EntitySchema = z.object({ name: z.string().min(1, "Entity name cannot be empty") });
|
|
154
142
|
const SyncEntitiesResponseSchema = z.object({
|
|
155
143
|
created: z.array(z.string()),
|
|
156
144
|
updated: z.array(z.string()),
|
|
@@ -174,114 +162,6 @@ async function readAllEntities(entitiesDir) {
|
|
|
174
162
|
return await Promise.all(files.map((filePath) => readEntityFile(filePath)));
|
|
175
163
|
}
|
|
176
164
|
|
|
177
|
-
//#endregion
|
|
178
|
-
//#region src/core/auth/config.ts
|
|
179
|
-
const TOKEN_REFRESH_BUFFER_MS = 60 * 1e3;
|
|
180
|
-
let refreshPromise = null;
|
|
181
|
-
async function readAuth() {
|
|
182
|
-
try {
|
|
183
|
-
const parsed = await readJsonFile(getAuthFilePath());
|
|
184
|
-
const result = AuthDataSchema.safeParse(parsed);
|
|
185
|
-
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
186
|
-
return result.data;
|
|
187
|
-
} catch (error) {
|
|
188
|
-
if (error instanceof Error && error.message.includes("Authentication")) throw error;
|
|
189
|
-
if (error instanceof Error && error.message.includes("File not found")) throw new Error("Authentication file not found. Please login first.");
|
|
190
|
-
throw new Error(`Failed to read authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
async function writeAuth(authData) {
|
|
194
|
-
const result = AuthDataSchema.safeParse(authData);
|
|
195
|
-
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
196
|
-
try {
|
|
197
|
-
await writeJsonFile(getAuthFilePath(), result.data);
|
|
198
|
-
} catch (error) {
|
|
199
|
-
throw new Error(`Failed to write authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
async function deleteAuth() {
|
|
203
|
-
try {
|
|
204
|
-
await deleteFile(getAuthFilePath());
|
|
205
|
-
} catch (error) {
|
|
206
|
-
throw new Error(`Failed to delete authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Checks if the access token is expired or about to expire.
|
|
211
|
-
*/
|
|
212
|
-
function isTokenExpired(auth) {
|
|
213
|
-
return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Refreshes the access token and saves the new tokens.
|
|
217
|
-
* Returns the new access token, or null if refresh failed.
|
|
218
|
-
* Uses a lock to prevent concurrent refresh requests.
|
|
219
|
-
*/
|
|
220
|
-
async function refreshAndSaveTokens() {
|
|
221
|
-
if (refreshPromise) return refreshPromise;
|
|
222
|
-
refreshPromise = (async () => {
|
|
223
|
-
try {
|
|
224
|
-
const auth = await readAuth();
|
|
225
|
-
const tokenResponse = await renewAccessToken(auth.refreshToken);
|
|
226
|
-
await writeAuth({
|
|
227
|
-
...auth,
|
|
228
|
-
accessToken: tokenResponse.accessToken,
|
|
229
|
-
refreshToken: tokenResponse.refreshToken,
|
|
230
|
-
expiresAt: Date.now() + tokenResponse.expiresIn * 1e3
|
|
231
|
-
});
|
|
232
|
-
return tokenResponse.accessToken;
|
|
233
|
-
} catch {
|
|
234
|
-
await deleteAuth();
|
|
235
|
-
return null;
|
|
236
|
-
} finally {
|
|
237
|
-
refreshPromise = null;
|
|
238
|
-
}
|
|
239
|
-
})();
|
|
240
|
-
return refreshPromise;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
//#endregion
|
|
244
|
-
//#region src/core/utils/httpClient.ts
|
|
245
|
-
const retriedRequests = /* @__PURE__ */ new WeakSet();
|
|
246
|
-
/**
|
|
247
|
-
* Handles 401 responses by refreshing the token and retrying the request.
|
|
248
|
-
* Only retries once per request to prevent infinite loops.
|
|
249
|
-
*/
|
|
250
|
-
async function handleUnauthorized(request, _options, response) {
|
|
251
|
-
if (response.status !== 401) return;
|
|
252
|
-
if (retriedRequests.has(request)) return;
|
|
253
|
-
const newAccessToken = await refreshAndSaveTokens();
|
|
254
|
-
if (!newAccessToken) return;
|
|
255
|
-
retriedRequests.add(request);
|
|
256
|
-
return ky(request, { headers: { Authorization: `Bearer ${newAccessToken}` } });
|
|
257
|
-
}
|
|
258
|
-
const base44Client = ky.create({
|
|
259
|
-
prefixUrl: getBase44ApiUrl(),
|
|
260
|
-
headers: { "User-Agent": "Base44 CLI" },
|
|
261
|
-
hooks: {
|
|
262
|
-
beforeRequest: [async (request) => {
|
|
263
|
-
try {
|
|
264
|
-
const auth = await readAuth();
|
|
265
|
-
if (isTokenExpired(auth)) {
|
|
266
|
-
const newAccessToken = await refreshAndSaveTokens();
|
|
267
|
-
if (newAccessToken) {
|
|
268
|
-
request.headers.set("Authorization", `Bearer ${newAccessToken}`);
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
request.headers.set("Authorization", `Bearer ${auth.accessToken}`);
|
|
273
|
-
} catch {}
|
|
274
|
-
}],
|
|
275
|
-
afterResponse: [handleUnauthorized]
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
/**
|
|
279
|
-
* Returns an HTTP client scoped to the current app.
|
|
280
|
-
*/
|
|
281
|
-
function getAppClient() {
|
|
282
|
-
return base44Client.extend({ prefixUrl: new URL(`/api/apps/${getBase44ClientId()}/`, getBase44ApiUrl()).href });
|
|
283
|
-
}
|
|
284
|
-
|
|
285
165
|
//#endregion
|
|
286
166
|
//#region src/core/resources/entity/api.ts
|
|
287
167
|
async function pushEntities(entities) {
|
|
@@ -365,18 +245,50 @@ async function readAllFunctions(functionsDir) {
|
|
|
365
245
|
const functionResource = { readAll: readAllFunctions };
|
|
366
246
|
|
|
367
247
|
//#endregion
|
|
368
|
-
//#region src/core/project/
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
248
|
+
//#region src/core/project/schema.ts
|
|
249
|
+
const TemplateSchema = z.object({
|
|
250
|
+
id: z.string(),
|
|
251
|
+
name: z.string(),
|
|
252
|
+
description: z.string(),
|
|
253
|
+
path: z.string()
|
|
373
254
|
});
|
|
255
|
+
const TemplatesConfigSchema = z.object({ templates: z.array(TemplateSchema) });
|
|
256
|
+
const SiteConfigSchema = z.object({
|
|
257
|
+
buildCommand: z.string().optional(),
|
|
258
|
+
serveCommand: z.string().optional(),
|
|
259
|
+
outputDirectory: z.string().optional(),
|
|
260
|
+
installCommand: z.string().optional()
|
|
261
|
+
});
|
|
262
|
+
const ProjectConfigSchema = z.object({
|
|
263
|
+
name: z.string().min(1, "App name cannot be empty"),
|
|
264
|
+
description: z.string().optional(),
|
|
265
|
+
site: SiteConfigSchema.optional(),
|
|
266
|
+
entitiesDir: z.string().optional().default("entities"),
|
|
267
|
+
functionsDir: z.string().optional().default("functions")
|
|
268
|
+
});
|
|
269
|
+
const CreateProjectResponseSchema = z.looseObject({ id: z.string() });
|
|
270
|
+
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/core/project/config.ts
|
|
374
273
|
async function findConfigInDir(dir) {
|
|
375
274
|
return (await globby(getProjectConfigPatterns(), {
|
|
376
275
|
cwd: dir,
|
|
377
276
|
absolute: true
|
|
378
277
|
}))[0] ?? null;
|
|
379
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Searches for a Base44 project root by looking for config files.
|
|
281
|
+
* Walks up the directory tree from the starting path until it finds a config file.
|
|
282
|
+
*
|
|
283
|
+
* @param startPath - Directory to start searching from. Defaults to cwd.
|
|
284
|
+
* @returns Project root info if found, null otherwise.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* const found = await findProjectRoot();
|
|
288
|
+
* if (found) {
|
|
289
|
+
* console.log(`Project found at: ${found.root}`);
|
|
290
|
+
* }
|
|
291
|
+
*/
|
|
380
292
|
async function findProjectRoot(startPath) {
|
|
381
293
|
let current = startPath || process.cwd();
|
|
382
294
|
while (current !== dirname(current)) {
|
|
@@ -389,6 +301,17 @@ async function findProjectRoot(startPath) {
|
|
|
389
301
|
}
|
|
390
302
|
return null;
|
|
391
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Reads and validates a Base44 project configuration from the filesystem.
|
|
306
|
+
* Also loads all entities and functions defined in the project.
|
|
307
|
+
*
|
|
308
|
+
* @param projectRoot - Optional path to start searching from. Defaults to cwd.
|
|
309
|
+
* @returns Project configuration including entities and functions.
|
|
310
|
+
* @throws {Error} If no config file is found or if the config is invalid.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* const { project, entities, functions } = await readProjectConfig();
|
|
314
|
+
*/
|
|
392
315
|
async function readProjectConfig(projectRoot) {
|
|
393
316
|
let found;
|
|
394
317
|
if (projectRoot) {
|
|
@@ -402,10 +325,7 @@ async function readProjectConfig(projectRoot) {
|
|
|
402
325
|
const { root, configPath } = found;
|
|
403
326
|
const parsed = await readJsonFile(configPath);
|
|
404
327
|
const result = ProjectConfigSchema.safeParse(parsed);
|
|
405
|
-
if (!result.success) {
|
|
406
|
-
const errors = result.error.issues.map((e) => e.message).join(", ");
|
|
407
|
-
throw new Error(`Invalid project configuration: ${errors}`);
|
|
408
|
-
}
|
|
328
|
+
if (!result.success) throw new Error(`Invalid project configuration: ${result.error.message}`);
|
|
409
329
|
const project = result.data;
|
|
410
330
|
const configDir = dirname(configPath);
|
|
411
331
|
const [entities, functions] = await Promise.all([entityResource.readAll(join(configDir, project.entitiesDir)), functionResource.readAll(join(configDir, project.functionsDir))]);
|
|
@@ -420,29 +340,6 @@ async function readProjectConfig(projectRoot) {
|
|
|
420
340
|
};
|
|
421
341
|
}
|
|
422
342
|
|
|
423
|
-
//#endregion
|
|
424
|
-
//#region src/core/project/schema.ts
|
|
425
|
-
const TemplateSchema = z.object({
|
|
426
|
-
id: z.string(),
|
|
427
|
-
name: z.string(),
|
|
428
|
-
description: z.string(),
|
|
429
|
-
path: z.string()
|
|
430
|
-
});
|
|
431
|
-
const TemplatesConfigSchema = z.object({ templates: z.array(TemplateSchema) });
|
|
432
|
-
const SiteConfigSchema = z.object({
|
|
433
|
-
buildCommand: z.string().optional(),
|
|
434
|
-
serveCommand: z.string().optional(),
|
|
435
|
-
outputDirectory: z.string().optional(),
|
|
436
|
-
installCommand: z.string().optional()
|
|
437
|
-
});
|
|
438
|
-
const AppConfigSchema = z.object({
|
|
439
|
-
name: z.string().min(1, "App name cannot be empty"),
|
|
440
|
-
description: z.string().optional(),
|
|
441
|
-
site: SiteConfigSchema.optional(),
|
|
442
|
-
domains: z.array(z.string()).optional()
|
|
443
|
-
});
|
|
444
|
-
const CreateProjectResponseSchema = z.looseObject({ id: z.string() });
|
|
445
|
-
|
|
446
343
|
//#endregion
|
|
447
344
|
//#region src/core/project/api.ts
|
|
448
345
|
async function createProject(projectName, description) {
|
|
@@ -457,7 +354,7 @@ async function createProject(projectName, description) {
|
|
|
457
354
|
//#endregion
|
|
458
355
|
//#region src/core/project/template.ts
|
|
459
356
|
async function listTemplates() {
|
|
460
|
-
const parsed = await readJsonFile(
|
|
357
|
+
const parsed = await readJsonFile(getTemplatesIndexPath());
|
|
461
358
|
return TemplatesConfigSchema.parse(parsed).templates;
|
|
462
359
|
}
|
|
463
360
|
/**
|
|
@@ -466,7 +363,6 @@ async function listTemplates() {
|
|
|
466
363
|
* - All other files are copied directly
|
|
467
364
|
*/
|
|
468
365
|
async function renderTemplate(template, destPath, data) {
|
|
469
|
-
if (template.path.includes("..") || isAbsolute(template.path)) throw new Error(`Invalid template path: ${template.path}`);
|
|
470
366
|
const templateDir = join(getTemplatesDir(), template.path);
|
|
471
367
|
const files = await globby("**/*", {
|
|
472
368
|
cwd: templateDir,
|
|
@@ -506,30 +402,21 @@ async function createProjectFiles(options) {
|
|
|
506
402
|
//#endregion
|
|
507
403
|
//#region src/core/config.ts
|
|
508
404
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
509
|
-
|
|
510
|
-
const FUNCTION_CONFIG_FILE = "function.jsonc";
|
|
511
|
-
const AUTH_CLIENT_ID = "base44_cli";
|
|
512
|
-
function getBase44Dir() {
|
|
405
|
+
function getBase44GlobalDir() {
|
|
513
406
|
return join(homedir(), ".base44");
|
|
514
407
|
}
|
|
515
408
|
function getAuthFilePath() {
|
|
516
|
-
return join(
|
|
409
|
+
return join(getBase44GlobalDir(), "auth", "auth.json");
|
|
517
410
|
}
|
|
518
411
|
function getTemplatesDir() {
|
|
519
412
|
return join(__dirname, "templates");
|
|
520
413
|
}
|
|
521
|
-
function
|
|
522
|
-
return
|
|
523
|
-
`${PROJECT_SUBDIR}/config.jsonc`,
|
|
524
|
-
`${PROJECT_SUBDIR}/config.json`,
|
|
525
|
-
"config.jsonc",
|
|
526
|
-
"config.json"
|
|
527
|
-
];
|
|
414
|
+
function getTemplatesIndexPath() {
|
|
415
|
+
return join(getTemplatesDir(), "templates.json");
|
|
528
416
|
}
|
|
529
417
|
/**
|
|
530
418
|
* Load .env.local from the project root if it exists.
|
|
531
419
|
* Values won't override existing process.env variables.
|
|
532
|
-
* Safe to call multiple times - only loads once.
|
|
533
420
|
*/
|
|
534
421
|
async function loadProjectEnv(projectRoot) {
|
|
535
422
|
const found = projectRoot ? { root: projectRoot } : await findProjectRoot();
|
|
@@ -540,38 +427,154 @@ async function loadProjectEnv(projectRoot) {
|
|
|
540
427
|
quiet: true
|
|
541
428
|
});
|
|
542
429
|
}
|
|
543
|
-
/**
|
|
544
|
-
* Get the Base44 API URL.
|
|
545
|
-
* Priority: process.env.BASE44_API_URL > .env.local > default
|
|
546
|
-
*/
|
|
547
430
|
function getBase44ApiUrl() {
|
|
548
431
|
return process.env.BASE44_API_URL || "https://app.base44.com";
|
|
549
432
|
}
|
|
550
|
-
/**
|
|
551
|
-
* Get the Base44 Client ID (app ID).
|
|
552
|
-
* Priority: process.env.BASE44_CLIENT_ID > .env.local
|
|
553
|
-
* Returns undefined if not set.
|
|
554
|
-
*/
|
|
555
433
|
function getBase44ClientId() {
|
|
556
434
|
return process.env.BASE44_CLIENT_ID;
|
|
557
435
|
}
|
|
558
436
|
|
|
559
437
|
//#endregion
|
|
560
|
-
//#region src/core/
|
|
438
|
+
//#region src/core/clients/oauth-client.ts
|
|
561
439
|
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
440
|
+
* HTTP client for OAuth endpoints.
|
|
441
|
+
* Used only for the login flow (device code, token exchange).
|
|
442
|
+
* These endpoints don't need Authorization headers - they use client_id + tokens in body.
|
|
564
443
|
*/
|
|
565
|
-
const
|
|
444
|
+
const oauthClient = ky.create({
|
|
566
445
|
prefixUrl: getBase44ApiUrl(),
|
|
567
446
|
headers: { "User-Agent": "Base44 CLI" }
|
|
568
447
|
});
|
|
569
|
-
|
|
448
|
+
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/core/auth/config.ts
|
|
451
|
+
const TOKEN_REFRESH_BUFFER_MS = 60 * 1e3;
|
|
452
|
+
let refreshPromise = null;
|
|
453
|
+
/**
|
|
454
|
+
* Reads and validates the stored authentication data.
|
|
455
|
+
*
|
|
456
|
+
* @returns The parsed authentication data (tokens, user info).
|
|
457
|
+
* @throws {Error} If not logged in or if auth data is corrupted.
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* const auth = await readAuth();
|
|
461
|
+
* console.log(`Logged in as: ${auth.email}`);
|
|
462
|
+
*/
|
|
463
|
+
async function readAuth() {
|
|
464
|
+
try {
|
|
465
|
+
const parsed = await readJsonFile(getAuthFilePath());
|
|
466
|
+
const result = AuthDataSchema.safeParse(parsed);
|
|
467
|
+
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
468
|
+
return result.data;
|
|
469
|
+
} catch (error) {
|
|
470
|
+
throw new Error(`Failed to read authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async function writeAuth(authData) {
|
|
474
|
+
const result = AuthDataSchema.safeParse(authData);
|
|
475
|
+
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
476
|
+
try {
|
|
477
|
+
await writeJsonFile(getAuthFilePath(), result.data);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
throw new Error(`Failed to write authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function deleteAuth() {
|
|
483
|
+
try {
|
|
484
|
+
await deleteFile(getAuthFilePath());
|
|
485
|
+
} catch (error) {
|
|
486
|
+
throw new Error(`Failed to delete authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function isTokenExpired(auth) {
|
|
490
|
+
return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
491
|
+
}
|
|
492
|
+
async function refreshAndSaveTokens() {
|
|
493
|
+
if (refreshPromise) return refreshPromise;
|
|
494
|
+
refreshPromise = (async () => {
|
|
495
|
+
try {
|
|
496
|
+
const auth = await readAuth();
|
|
497
|
+
const tokenResponse = await renewAccessToken(auth.refreshToken);
|
|
498
|
+
await writeAuth({
|
|
499
|
+
...auth,
|
|
500
|
+
accessToken: tokenResponse.accessToken,
|
|
501
|
+
refreshToken: tokenResponse.refreshToken,
|
|
502
|
+
expiresAt: Date.now() + tokenResponse.expiresIn * 1e3
|
|
503
|
+
});
|
|
504
|
+
return tokenResponse.accessToken;
|
|
505
|
+
} catch {
|
|
506
|
+
await deleteAuth();
|
|
507
|
+
return null;
|
|
508
|
+
} finally {
|
|
509
|
+
refreshPromise = null;
|
|
510
|
+
}
|
|
511
|
+
})();
|
|
512
|
+
return refreshPromise;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/core/clients/base44-client.ts
|
|
517
|
+
/**
|
|
518
|
+
* Authenticated HTTP client for Base44 API.
|
|
519
|
+
* Automatically handles token refresh and retry on 401 responses.
|
|
520
|
+
*/
|
|
521
|
+
const retriedRequests = /* @__PURE__ */ new WeakSet();
|
|
522
|
+
/**
|
|
523
|
+
* Handles 401 responses by refreshing the token and retrying the request.
|
|
524
|
+
* Only retries once per request to prevent infinite loops.
|
|
525
|
+
*/
|
|
526
|
+
async function handleUnauthorized(request, _options, response) {
|
|
527
|
+
if (response.status !== 401) return;
|
|
528
|
+
if (retriedRequests.has(request)) return;
|
|
529
|
+
const newAccessToken = await refreshAndSaveTokens();
|
|
530
|
+
if (!newAccessToken) return;
|
|
531
|
+
retriedRequests.add(request);
|
|
532
|
+
return ky(request, { headers: { Authorization: `Bearer ${newAccessToken}` } });
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Base44 API client with automatic authentication.
|
|
536
|
+
* Use this for general API calls that require authentication.
|
|
537
|
+
*/
|
|
538
|
+
const base44Client = ky.create({
|
|
539
|
+
prefixUrl: getBase44ApiUrl(),
|
|
540
|
+
headers: { "User-Agent": "Base44 CLI" },
|
|
541
|
+
hooks: {
|
|
542
|
+
beforeRequest: [async (request) => {
|
|
543
|
+
try {
|
|
544
|
+
const auth = await readAuth();
|
|
545
|
+
if (isTokenExpired(auth)) {
|
|
546
|
+
const newAccessToken = await refreshAndSaveTokens();
|
|
547
|
+
if (newAccessToken) {
|
|
548
|
+
request.headers.set("Authorization", `Bearer ${newAccessToken}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
request.headers.set("Authorization", `Bearer ${auth.accessToken}`);
|
|
553
|
+
} catch {}
|
|
554
|
+
}],
|
|
555
|
+
afterResponse: [handleUnauthorized]
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
/**
|
|
559
|
+
* Returns an HTTP client scoped to the current app.
|
|
560
|
+
* Use this for API calls to app-specific endpoints (entities, functions, etc.).
|
|
561
|
+
*
|
|
562
|
+
* @throws {Error} If BASE44_CLIENT_ID environment variable is not set.
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* const appClient = getAppClient();
|
|
566
|
+
* const response = await appClient.get("entities");
|
|
567
|
+
*/
|
|
568
|
+
function getAppClient() {
|
|
569
|
+
const clientId = getBase44ClientId();
|
|
570
|
+
if (!clientId) throw new Error("BASE44_CLIENT_ID environment variable is required. Set it in your .env.local file.");
|
|
571
|
+
return base44Client.extend({ prefixUrl: new URL(`/api/apps/${clientId}/`, getBase44ApiUrl()).href });
|
|
572
|
+
}
|
|
570
573
|
|
|
571
574
|
//#endregion
|
|
572
575
|
//#region src/core/auth/api.ts
|
|
573
576
|
async function generateDeviceCode() {
|
|
574
|
-
const response = await
|
|
577
|
+
const response = await oauthClient.post("oauth/device/code", {
|
|
575
578
|
json: {
|
|
576
579
|
client_id: AUTH_CLIENT_ID,
|
|
577
580
|
scope: "apps:read apps:write"
|
|
@@ -588,7 +591,7 @@ async function getTokenFromDeviceCode(deviceCode) {
|
|
|
588
591
|
searchParams.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
|
589
592
|
searchParams.set("device_code", deviceCode);
|
|
590
593
|
searchParams.set("client_id", AUTH_CLIENT_ID);
|
|
591
|
-
const response = await
|
|
594
|
+
const response = await oauthClient.post("oauth/token", {
|
|
592
595
|
body: searchParams.toString(),
|
|
593
596
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
594
597
|
throwHttpErrors: false
|
|
@@ -610,7 +613,7 @@ async function renewAccessToken(refreshToken) {
|
|
|
610
613
|
searchParams.set("grant_type", "refresh_token");
|
|
611
614
|
searchParams.set("refresh_token", refreshToken);
|
|
612
615
|
searchParams.set("client_id", AUTH_CLIENT_ID);
|
|
613
|
-
const response = await
|
|
616
|
+
const response = await oauthClient.post("oauth/token", {
|
|
614
617
|
body: searchParams.toString(),
|
|
615
618
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
616
619
|
throwHttpErrors: false
|
|
@@ -627,25 +630,49 @@ async function renewAccessToken(refreshToken) {
|
|
|
627
630
|
return result.data;
|
|
628
631
|
}
|
|
629
632
|
async function getUserInfo(accessToken) {
|
|
630
|
-
const response = await
|
|
633
|
+
const response = await oauthClient.get("oauth/userinfo", { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
631
634
|
if (!response.ok) throw new AuthApiError(`Failed to fetch user info: ${response.status}`);
|
|
632
635
|
const result = UserInfoSchema.safeParse(await response.json());
|
|
633
636
|
if (!result.success) throw new AuthValidationError(`Invalid UserInfo response from server: ${result.error.message}`);
|
|
634
637
|
return result.data;
|
|
635
638
|
}
|
|
636
639
|
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/cli/utils/banner.ts
|
|
642
|
+
const orange = chalk.hex("#E86B3C");
|
|
643
|
+
const BANNER = `
|
|
644
|
+
${orange("██████╗ █████╗ ███████╗███████╗ ██╗ ██╗██╗ ██╗")}
|
|
645
|
+
${orange("██╔══██╗██╔══██╗██╔════╝██╔════╝ ██║ ██║██║ ██║")}
|
|
646
|
+
${orange("██████╔╝███████║███████╗█████╗ ███████║███████║")}
|
|
647
|
+
${orange("██╔══██╗██╔══██║╚════██║██╔══╝ ╚════██║╚════██║")}
|
|
648
|
+
${orange("██████╔╝██║ ██║███████║███████╗ ██║ ██║")}
|
|
649
|
+
${orange("╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝")}
|
|
650
|
+
`;
|
|
651
|
+
function printBanner() {
|
|
652
|
+
console.log(BANNER);
|
|
653
|
+
}
|
|
654
|
+
|
|
637
655
|
//#endregion
|
|
638
656
|
//#region src/cli/utils/runCommand.ts
|
|
639
657
|
const base44Color = chalk.bgHex("#E86B3C");
|
|
640
658
|
/**
|
|
641
|
-
* Wraps a command function with the Base44 intro banner.
|
|
659
|
+
* Wraps a command function with the Base44 intro banner and error handling.
|
|
642
660
|
* All CLI commands should use this utility to ensure consistent branding.
|
|
643
661
|
* Also loads .env.local from the project root if available.
|
|
644
662
|
*
|
|
645
663
|
* @param commandFn - The async function to execute as the command
|
|
664
|
+
* @param options - Optional configuration for the command wrapper
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* // Standard command with simple intro
|
|
668
|
+
* export const myCommand = new Command("my-command")
|
|
669
|
+
* .action(async () => {
|
|
670
|
+
* await runCommand(myAction);
|
|
671
|
+
* });
|
|
646
672
|
*/
|
|
647
|
-
async function runCommand(commandFn) {
|
|
648
|
-
|
|
673
|
+
async function runCommand(commandFn, options) {
|
|
674
|
+
if (options?.fullBanner) printBanner();
|
|
675
|
+
else intro(base44Color(" Base 44 "));
|
|
649
676
|
await loadProjectEnv();
|
|
650
677
|
try {
|
|
651
678
|
await commandFn();
|
|
@@ -664,8 +691,21 @@ async function runCommand(commandFn) {
|
|
|
664
691
|
*
|
|
665
692
|
* @param startMessage - Message to show when spinner starts
|
|
666
693
|
* @param operation - The async operation to execute
|
|
667
|
-
* @param options - Optional configuration
|
|
694
|
+
* @param options - Optional configuration for success/error messages
|
|
668
695
|
* @returns The result of the operation
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* const data = await runTask(
|
|
699
|
+
* "Fetching data...",
|
|
700
|
+
* async () => {
|
|
701
|
+
* const response = await fetch(url);
|
|
702
|
+
* return response.json();
|
|
703
|
+
* },
|
|
704
|
+
* {
|
|
705
|
+
* successMessage: "Data fetched successfully",
|
|
706
|
+
* errorMessage: "Failed to fetch data",
|
|
707
|
+
* }
|
|
708
|
+
* );
|
|
669
709
|
*/
|
|
670
710
|
async function runTask(startMessage, operation, options) {
|
|
671
711
|
const s = spinner();
|
|
@@ -691,21 +731,6 @@ const onPromptCancel = () => {
|
|
|
691
731
|
process.exit(0);
|
|
692
732
|
};
|
|
693
733
|
|
|
694
|
-
//#endregion
|
|
695
|
-
//#region src/cli/utils/banner.ts
|
|
696
|
-
const orange = chalk.hex("#E86B3C");
|
|
697
|
-
const BANNER = `
|
|
698
|
-
${orange("██████╗ █████╗ ███████╗███████╗ ██╗ ██╗██╗ ██╗")}
|
|
699
|
-
${orange("██╔══██╗██╔══██╗██╔════╝██╔════╝ ██║ ██║██║ ██║")}
|
|
700
|
-
${orange("██████╔╝███████║███████╗█████╗ ███████║███████║")}
|
|
701
|
-
${orange("██╔══██╗██╔══██║╚════██║██╔══╝ ╚════██║╚════██║")}
|
|
702
|
-
${orange("██████╔╝██║ ██║███████║███████╗ ██║ ██║")}
|
|
703
|
-
${orange("╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝")}
|
|
704
|
-
`;
|
|
705
|
-
function printBanner() {
|
|
706
|
-
console.log(BANNER);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
734
|
//#endregion
|
|
710
735
|
//#region src/cli/commands/auth/login.ts
|
|
711
736
|
async function generateAndDisplayDeviceCode() {
|
|
@@ -715,13 +740,13 @@ async function generateAndDisplayDeviceCode() {
|
|
|
715
740
|
successMessage: "Device code generated",
|
|
716
741
|
errorMessage: "Failed to generate device code"
|
|
717
742
|
});
|
|
718
|
-
log.info(`
|
|
743
|
+
log.info(`Your code is: ${chalk.bold(deviceCodeResponse.userCode)}\nPlease visit: ${deviceCodeResponse.verificationUriComplete}`);
|
|
719
744
|
return deviceCodeResponse;
|
|
720
745
|
}
|
|
721
746
|
async function waitForAuthentication(deviceCode, expiresIn, interval) {
|
|
722
747
|
let tokenResponse;
|
|
723
748
|
try {
|
|
724
|
-
await runTask("Waiting for authentication...", async () => {
|
|
749
|
+
await runTask("Waiting for you to complete authentication...", async () => {
|
|
725
750
|
await pWaitFor(async () => {
|
|
726
751
|
const result = await getTokenFromDeviceCode(deviceCode);
|
|
727
752
|
if (result !== null) {
|
|
@@ -828,8 +853,6 @@ const entitiesPushCommand = new Command("entities").description("Manage project
|
|
|
828
853
|
//#endregion
|
|
829
854
|
//#region src/cli/commands/project/create.ts
|
|
830
855
|
async function create() {
|
|
831
|
-
printBanner();
|
|
832
|
-
await loadProjectEnv();
|
|
833
856
|
const templateOptions = (await listTemplates()).map((t) => ({
|
|
834
857
|
value: t,
|
|
835
858
|
label: t.name,
|
|
@@ -875,13 +898,7 @@ async function create() {
|
|
|
875
898
|
log.success(`Project ${chalk.bold(name)} has been initialized!`);
|
|
876
899
|
}
|
|
877
900
|
const createCommand = new Command("create").description("Create a new Base44 project").action(async () => {
|
|
878
|
-
|
|
879
|
-
await create();
|
|
880
|
-
} catch (e) {
|
|
881
|
-
if (e instanceof Error) log.error(e.stack ?? e.message);
|
|
882
|
-
else log.error(String(e));
|
|
883
|
-
process.exit(1);
|
|
884
|
-
}
|
|
901
|
+
await runCommand(create, { fullBanner: true });
|
|
885
902
|
});
|
|
886
903
|
|
|
887
904
|
//#endregion
|
package/package.json
CHANGED