@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.
Files changed (3) hide show
  1. package/README.md +119 -24
  2. package/dist/cli/index.js +251 -234
  3. 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
- # Build the project
12
- npm run build
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
- # Run the CLI
15
- npm start # Using node directly
16
- ./dist/cli/index.js # Run executable directly
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
- # Run in development mode
23
- npm run dev
105
+ # Clone the repository
106
+ git clone https://github.com/base44/cli.git
107
+ cd cli
24
108
 
25
- # Build the project
26
- npm run build
109
+ # Install dependencies
110
+ npm install
27
111
 
28
- # Run the built CLI
29
- npm run start
112
+ # Build
113
+ npm run build
30
114
 
31
- # Clean build artifacts
32
- npm run clean
115
+ # Run in development mode
116
+ npm run dev -- <command>
117
+ ```
33
118
 
34
- # Lint the code
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
- ## Commands
129
+ ### Running the Built CLI
40
130
 
41
- ### Authentication
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
- - `base44 login` - Authenticate with Base44 using device code flow
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
- ### Project
142
+ ## License
48
143
 
49
- - `base44 show-project` - Display project configuration, entities, and functions
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 { dirname, isAbsolute, join, resolve } from "node:path";
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 EntityPropertySchema = z.object({
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/config.ts
369
- const ProjectConfigSchema = z.looseObject({
370
- name: z.string().min(1, "Project name cannot be empty"),
371
- entitiesDir: z.string().default("./entities"),
372
- functionsDir: z.string().default("./functions")
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(join(getTemplatesDir(), "templates.json"));
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
- const PROJECT_SUBDIR = "base44";
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(getBase44Dir(), "auth", "auth.json");
409
+ return join(getBase44GlobalDir(), "auth", "auth.json");
517
410
  }
518
411
  function getTemplatesDir() {
519
412
  return join(__dirname, "templates");
520
413
  }
521
- function getProjectConfigPatterns() {
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/auth/authClient.ts
438
+ //#region src/core/clients/oauth-client.ts
561
439
  /**
562
- * Separate ky instance for OAuth endpoints.
563
- * These don't need Authorization headers (they use client_id + tokens in body).
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 authClient = ky.create({
444
+ const oauthClient = ky.create({
566
445
  prefixUrl: getBase44ApiUrl(),
567
446
  headers: { "User-Agent": "Base44 CLI" }
568
447
  });
569
- var authClient_default = authClient;
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 authClient_default.post("oauth/device/code", {
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 authClient_default.post("oauth/token", {
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 authClient_default.post("oauth/token", {
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 authClient_default.get("oauth/userinfo", { headers: { Authorization: `Bearer ${accessToken}` } });
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
- intro(base44Color(" Base 44 "));
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(`Verification code: ${chalk.bold(deviceCodeResponse.userCode)}\nPlease confirm this code at: ${deviceCodeResponse.verificationUri}`);
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
- try {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/cli",
3
- "version": "0.0.1-pr.17.16cb029",
3
+ "version": "0.0.1-pr.18.893dad9",
4
4
  "description": "Base44 CLI - Unified interface for managing Base44 applications",
5
5
  "type": "module",
6
6
  "main": "./dist/cli/index.js",