@base44-preview/cli 0.0.1-pr.16.c796b32 → 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
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,6 +4,7 @@ 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 ky from "ky";
|
|
7
8
|
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
@@ -11,7 +12,6 @@ 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
|
|
|
@@ -76,6 +76,20 @@ var AuthValidationError = class extends Error {
|
|
|
76
76
|
}
|
|
77
77
|
};
|
|
78
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
|
+
|
|
79
93
|
//#endregion
|
|
80
94
|
//#region src/core/utils/fs.ts
|
|
81
95
|
async function pathExists(path) {
|
|
@@ -124,35 +138,7 @@ async function deleteFile(filePath) {
|
|
|
124
138
|
|
|
125
139
|
//#endregion
|
|
126
140
|
//#region src/core/resources/entity/schema.ts
|
|
127
|
-
const
|
|
128
|
-
type: z.string(),
|
|
129
|
-
description: z.string().optional(),
|
|
130
|
-
enum: z.array(z.string()).optional(),
|
|
131
|
-
default: z.union([
|
|
132
|
-
z.string(),
|
|
133
|
-
z.number(),
|
|
134
|
-
z.boolean()
|
|
135
|
-
]).optional(),
|
|
136
|
-
format: z.string().optional(),
|
|
137
|
-
items: z.any().optional(),
|
|
138
|
-
relation: z.object({
|
|
139
|
-
entity: z.string(),
|
|
140
|
-
type: z.string()
|
|
141
|
-
}).optional()
|
|
142
|
-
});
|
|
143
|
-
const EntityPoliciesSchema = z.object({
|
|
144
|
-
read: z.string().optional(),
|
|
145
|
-
create: z.string().optional(),
|
|
146
|
-
update: z.string().optional(),
|
|
147
|
-
delete: z.string().optional()
|
|
148
|
-
});
|
|
149
|
-
const EntitySchema = z.object({
|
|
150
|
-
name: z.string().min(1, "Entity name cannot be empty"),
|
|
151
|
-
type: z.literal("object"),
|
|
152
|
-
properties: z.record(z.string(), EntityPropertySchema),
|
|
153
|
-
required: z.array(z.string()).optional(),
|
|
154
|
-
policies: EntityPoliciesSchema.optional()
|
|
155
|
-
});
|
|
141
|
+
const EntitySchema = z.object({ name: z.string().min(1, "Entity name cannot be empty") });
|
|
156
142
|
const SyncEntitiesResponseSchema = z.object({
|
|
157
143
|
created: z.array(z.string()),
|
|
158
144
|
updated: z.array(z.string()),
|
|
@@ -176,114 +162,6 @@ async function readAllEntities(entitiesDir) {
|
|
|
176
162
|
return await Promise.all(files.map((filePath) => readEntityFile(filePath)));
|
|
177
163
|
}
|
|
178
164
|
|
|
179
|
-
//#endregion
|
|
180
|
-
//#region src/core/auth/config.ts
|
|
181
|
-
const TOKEN_REFRESH_BUFFER_MS = 60 * 1e3;
|
|
182
|
-
let refreshPromise = null;
|
|
183
|
-
async function readAuth() {
|
|
184
|
-
try {
|
|
185
|
-
const parsed = await readJsonFile(getAuthFilePath());
|
|
186
|
-
const result = AuthDataSchema.safeParse(parsed);
|
|
187
|
-
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
188
|
-
return result.data;
|
|
189
|
-
} catch (error) {
|
|
190
|
-
if (error instanceof Error && error.message.includes("Authentication")) throw error;
|
|
191
|
-
if (error instanceof Error && error.message.includes("File not found")) throw new Error("Authentication file not found. Please login first.");
|
|
192
|
-
throw new Error(`Failed to read authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
async function writeAuth(authData) {
|
|
196
|
-
const result = AuthDataSchema.safeParse(authData);
|
|
197
|
-
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
198
|
-
try {
|
|
199
|
-
await writeJsonFile(getAuthFilePath(), result.data);
|
|
200
|
-
} catch (error) {
|
|
201
|
-
throw new Error(`Failed to write authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
async function deleteAuth() {
|
|
205
|
-
try {
|
|
206
|
-
await deleteFile(getAuthFilePath());
|
|
207
|
-
} catch (error) {
|
|
208
|
-
throw new Error(`Failed to delete authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Checks if the access token is expired or about to expire.
|
|
213
|
-
*/
|
|
214
|
-
function isTokenExpired(auth) {
|
|
215
|
-
return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* Refreshes the access token and saves the new tokens.
|
|
219
|
-
* Returns the new access token, or null if refresh failed.
|
|
220
|
-
* Uses a lock to prevent concurrent refresh requests.
|
|
221
|
-
*/
|
|
222
|
-
async function refreshAndSaveTokens() {
|
|
223
|
-
if (refreshPromise) return refreshPromise;
|
|
224
|
-
refreshPromise = (async () => {
|
|
225
|
-
try {
|
|
226
|
-
const auth = await readAuth();
|
|
227
|
-
const tokenResponse = await renewAccessToken(auth.refreshToken);
|
|
228
|
-
await writeAuth({
|
|
229
|
-
...auth,
|
|
230
|
-
accessToken: tokenResponse.accessToken,
|
|
231
|
-
refreshToken: tokenResponse.refreshToken,
|
|
232
|
-
expiresAt: Date.now() + tokenResponse.expiresIn * 1e3
|
|
233
|
-
});
|
|
234
|
-
return tokenResponse.accessToken;
|
|
235
|
-
} catch {
|
|
236
|
-
await deleteAuth();
|
|
237
|
-
return null;
|
|
238
|
-
} finally {
|
|
239
|
-
refreshPromise = null;
|
|
240
|
-
}
|
|
241
|
-
})();
|
|
242
|
-
return refreshPromise;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
//#endregion
|
|
246
|
-
//#region src/core/utils/httpClient.ts
|
|
247
|
-
const retriedRequests = /* @__PURE__ */ new WeakSet();
|
|
248
|
-
/**
|
|
249
|
-
* Handles 401 responses by refreshing the token and retrying the request.
|
|
250
|
-
* Only retries once per request to prevent infinite loops.
|
|
251
|
-
*/
|
|
252
|
-
async function handleUnauthorized(request, _options, response) {
|
|
253
|
-
if (response.status !== 401) return;
|
|
254
|
-
if (retriedRequests.has(request)) return;
|
|
255
|
-
const newAccessToken = await refreshAndSaveTokens();
|
|
256
|
-
if (!newAccessToken) return;
|
|
257
|
-
retriedRequests.add(request);
|
|
258
|
-
return ky(request, { headers: { Authorization: `Bearer ${newAccessToken}` } });
|
|
259
|
-
}
|
|
260
|
-
const base44Client = ky.create({
|
|
261
|
-
prefixUrl: getBase44ApiUrl(),
|
|
262
|
-
headers: { "User-Agent": "Base44 CLI" },
|
|
263
|
-
hooks: {
|
|
264
|
-
beforeRequest: [async (request) => {
|
|
265
|
-
try {
|
|
266
|
-
const auth = await readAuth();
|
|
267
|
-
if (isTokenExpired(auth)) {
|
|
268
|
-
const newAccessToken = await refreshAndSaveTokens();
|
|
269
|
-
if (newAccessToken) {
|
|
270
|
-
request.headers.set("Authorization", `Bearer ${newAccessToken}`);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
request.headers.set("Authorization", `Bearer ${auth.accessToken}`);
|
|
275
|
-
} catch {}
|
|
276
|
-
}],
|
|
277
|
-
afterResponse: [handleUnauthorized]
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
/**
|
|
281
|
-
* Returns an HTTP client scoped to the current app.
|
|
282
|
-
*/
|
|
283
|
-
function getAppClient() {
|
|
284
|
-
return base44Client.extend({ prefixUrl: new URL(`/api/apps/${getBase44ClientId()}/`, getBase44ApiUrl()).href });
|
|
285
|
-
}
|
|
286
|
-
|
|
287
165
|
//#endregion
|
|
288
166
|
//#region src/core/resources/entity/api.ts
|
|
289
167
|
async function pushEntities(entities) {
|
|
@@ -367,18 +245,50 @@ async function readAllFunctions(functionsDir) {
|
|
|
367
245
|
const functionResource = { readAll: readAllFunctions };
|
|
368
246
|
|
|
369
247
|
//#endregion
|
|
370
|
-
//#region src/core/project/
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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()
|
|
375
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
|
|
376
273
|
async function findConfigInDir(dir) {
|
|
377
274
|
return (await globby(getProjectConfigPatterns(), {
|
|
378
275
|
cwd: dir,
|
|
379
276
|
absolute: true
|
|
380
277
|
}))[0] ?? null;
|
|
381
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
|
+
*/
|
|
382
292
|
async function findProjectRoot(startPath) {
|
|
383
293
|
let current = startPath || process.cwd();
|
|
384
294
|
while (current !== dirname(current)) {
|
|
@@ -391,6 +301,17 @@ async function findProjectRoot(startPath) {
|
|
|
391
301
|
}
|
|
392
302
|
return null;
|
|
393
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
|
+
*/
|
|
394
315
|
async function readProjectConfig(projectRoot) {
|
|
395
316
|
let found;
|
|
396
317
|
if (projectRoot) {
|
|
@@ -404,10 +325,7 @@ async function readProjectConfig(projectRoot) {
|
|
|
404
325
|
const { root, configPath } = found;
|
|
405
326
|
const parsed = await readJsonFile(configPath);
|
|
406
327
|
const result = ProjectConfigSchema.safeParse(parsed);
|
|
407
|
-
if (!result.success) {
|
|
408
|
-
const errors = result.error.issues.map((e) => e.message).join(", ");
|
|
409
|
-
throw new Error(`Invalid project configuration: ${errors}`);
|
|
410
|
-
}
|
|
328
|
+
if (!result.success) throw new Error(`Invalid project configuration: ${result.error.message}`);
|
|
411
329
|
const project = result.data;
|
|
412
330
|
const configDir = dirname(configPath);
|
|
413
331
|
const [entities, functions] = await Promise.all([entityResource.readAll(join(configDir, project.entitiesDir)), functionResource.readAll(join(configDir, project.functionsDir))]);
|
|
@@ -422,29 +340,6 @@ async function readProjectConfig(projectRoot) {
|
|
|
422
340
|
};
|
|
423
341
|
}
|
|
424
342
|
|
|
425
|
-
//#endregion
|
|
426
|
-
//#region src/core/project/schema.ts
|
|
427
|
-
const TemplateSchema = z.object({
|
|
428
|
-
id: z.string(),
|
|
429
|
-
name: z.string(),
|
|
430
|
-
description: z.string(),
|
|
431
|
-
path: z.string()
|
|
432
|
-
});
|
|
433
|
-
const TemplatesConfigSchema = z.object({ templates: z.array(TemplateSchema) });
|
|
434
|
-
const SiteConfigSchema = z.object({
|
|
435
|
-
buildCommand: z.string().optional(),
|
|
436
|
-
serveCommand: z.string().optional(),
|
|
437
|
-
outputDirectory: z.string().optional(),
|
|
438
|
-
installCommand: z.string().optional()
|
|
439
|
-
});
|
|
440
|
-
const AppConfigSchema = z.object({
|
|
441
|
-
name: z.string().min(1, "App name cannot be empty"),
|
|
442
|
-
description: z.string().optional(),
|
|
443
|
-
site: SiteConfigSchema.optional(),
|
|
444
|
-
domains: z.array(z.string()).optional()
|
|
445
|
-
});
|
|
446
|
-
const CreateProjectResponseSchema = z.looseObject({ id: z.string() });
|
|
447
|
-
|
|
448
343
|
//#endregion
|
|
449
344
|
//#region src/core/project/api.ts
|
|
450
345
|
async function createProject(projectName, description) {
|
|
@@ -459,7 +354,7 @@ async function createProject(projectName, description) {
|
|
|
459
354
|
//#endregion
|
|
460
355
|
//#region src/core/project/template.ts
|
|
461
356
|
async function listTemplates() {
|
|
462
|
-
const parsed = await readJsonFile(
|
|
357
|
+
const parsed = await readJsonFile(getTemplatesIndexPath());
|
|
463
358
|
return TemplatesConfigSchema.parse(parsed).templates;
|
|
464
359
|
}
|
|
465
360
|
/**
|
|
@@ -476,8 +371,13 @@ async function renderTemplate(template, destPath, data) {
|
|
|
476
371
|
});
|
|
477
372
|
for (const file of files) {
|
|
478
373
|
const srcPath = join(templateDir, file);
|
|
479
|
-
|
|
480
|
-
|
|
374
|
+
try {
|
|
375
|
+
if (file.endsWith(".ejs")) await writeFile$1(join(destPath, file.replace(/\.ejs$/, "")), await ejs.renderFile(srcPath, data));
|
|
376
|
+
else await copyFile$1(srcPath, join(destPath, file));
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
+
throw new Error(`Failed to process template file "${file}": ${message}`);
|
|
380
|
+
}
|
|
481
381
|
}
|
|
482
382
|
}
|
|
483
383
|
|
|
@@ -502,30 +402,21 @@ async function createProjectFiles(options) {
|
|
|
502
402
|
//#endregion
|
|
503
403
|
//#region src/core/config.ts
|
|
504
404
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
505
|
-
|
|
506
|
-
const FUNCTION_CONFIG_FILE = "function.jsonc";
|
|
507
|
-
const AUTH_CLIENT_ID = "base44_cli";
|
|
508
|
-
function getBase44Dir() {
|
|
405
|
+
function getBase44GlobalDir() {
|
|
509
406
|
return join(homedir(), ".base44");
|
|
510
407
|
}
|
|
511
408
|
function getAuthFilePath() {
|
|
512
|
-
return join(
|
|
409
|
+
return join(getBase44GlobalDir(), "auth", "auth.json");
|
|
513
410
|
}
|
|
514
411
|
function getTemplatesDir() {
|
|
515
412
|
return join(__dirname, "templates");
|
|
516
413
|
}
|
|
517
|
-
function
|
|
518
|
-
return
|
|
519
|
-
`${PROJECT_SUBDIR}/config.jsonc`,
|
|
520
|
-
`${PROJECT_SUBDIR}/config.json`,
|
|
521
|
-
"config.jsonc",
|
|
522
|
-
"config.json"
|
|
523
|
-
];
|
|
414
|
+
function getTemplatesIndexPath() {
|
|
415
|
+
return join(getTemplatesDir(), "templates.json");
|
|
524
416
|
}
|
|
525
417
|
/**
|
|
526
418
|
* Load .env.local from the project root if it exists.
|
|
527
419
|
* Values won't override existing process.env variables.
|
|
528
|
-
* Safe to call multiple times - only loads once.
|
|
529
420
|
*/
|
|
530
421
|
async function loadProjectEnv(projectRoot) {
|
|
531
422
|
const found = projectRoot ? { root: projectRoot } : await findProjectRoot();
|
|
@@ -536,38 +427,154 @@ async function loadProjectEnv(projectRoot) {
|
|
|
536
427
|
quiet: true
|
|
537
428
|
});
|
|
538
429
|
}
|
|
539
|
-
/**
|
|
540
|
-
* Get the Base44 API URL.
|
|
541
|
-
* Priority: process.env.BASE44_API_URL > .env.local > default
|
|
542
|
-
*/
|
|
543
430
|
function getBase44ApiUrl() {
|
|
544
431
|
return process.env.BASE44_API_URL || "https://app.base44.com";
|
|
545
432
|
}
|
|
546
|
-
/**
|
|
547
|
-
* Get the Base44 Client ID (app ID).
|
|
548
|
-
* Priority: process.env.BASE44_CLIENT_ID > .env.local
|
|
549
|
-
* Returns undefined if not set.
|
|
550
|
-
*/
|
|
551
433
|
function getBase44ClientId() {
|
|
552
434
|
return process.env.BASE44_CLIENT_ID;
|
|
553
435
|
}
|
|
554
436
|
|
|
555
437
|
//#endregion
|
|
556
|
-
//#region src/core/
|
|
438
|
+
//#region src/core/clients/oauth-client.ts
|
|
557
439
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
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.
|
|
560
443
|
*/
|
|
561
|
-
const
|
|
444
|
+
const oauthClient = ky.create({
|
|
562
445
|
prefixUrl: getBase44ApiUrl(),
|
|
563
446
|
headers: { "User-Agent": "Base44 CLI" }
|
|
564
447
|
});
|
|
565
|
-
|
|
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
|
+
}
|
|
566
573
|
|
|
567
574
|
//#endregion
|
|
568
575
|
//#region src/core/auth/api.ts
|
|
569
576
|
async function generateDeviceCode() {
|
|
570
|
-
const response = await
|
|
577
|
+
const response = await oauthClient.post("oauth/device/code", {
|
|
571
578
|
json: {
|
|
572
579
|
client_id: AUTH_CLIENT_ID,
|
|
573
580
|
scope: "apps:read apps:write"
|
|
@@ -584,7 +591,7 @@ async function getTokenFromDeviceCode(deviceCode) {
|
|
|
584
591
|
searchParams.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
|
585
592
|
searchParams.set("device_code", deviceCode);
|
|
586
593
|
searchParams.set("client_id", AUTH_CLIENT_ID);
|
|
587
|
-
const response = await
|
|
594
|
+
const response = await oauthClient.post("oauth/token", {
|
|
588
595
|
body: searchParams.toString(),
|
|
589
596
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
590
597
|
throwHttpErrors: false
|
|
@@ -606,7 +613,7 @@ async function renewAccessToken(refreshToken) {
|
|
|
606
613
|
searchParams.set("grant_type", "refresh_token");
|
|
607
614
|
searchParams.set("refresh_token", refreshToken);
|
|
608
615
|
searchParams.set("client_id", AUTH_CLIENT_ID);
|
|
609
|
-
const response = await
|
|
616
|
+
const response = await oauthClient.post("oauth/token", {
|
|
610
617
|
body: searchParams.toString(),
|
|
611
618
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
612
619
|
throwHttpErrors: false
|
|
@@ -623,25 +630,49 @@ async function renewAccessToken(refreshToken) {
|
|
|
623
630
|
return result.data;
|
|
624
631
|
}
|
|
625
632
|
async function getUserInfo(accessToken) {
|
|
626
|
-
const response = await
|
|
633
|
+
const response = await oauthClient.get("oauth/userinfo", { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
627
634
|
if (!response.ok) throw new AuthApiError(`Failed to fetch user info: ${response.status}`);
|
|
628
635
|
const result = UserInfoSchema.safeParse(await response.json());
|
|
629
636
|
if (!result.success) throw new AuthValidationError(`Invalid UserInfo response from server: ${result.error.message}`);
|
|
630
637
|
return result.data;
|
|
631
638
|
}
|
|
632
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
|
+
|
|
633
655
|
//#endregion
|
|
634
656
|
//#region src/cli/utils/runCommand.ts
|
|
635
657
|
const base44Color = chalk.bgHex("#E86B3C");
|
|
636
658
|
/**
|
|
637
|
-
* Wraps a command function with the Base44 intro banner.
|
|
659
|
+
* Wraps a command function with the Base44 intro banner and error handling.
|
|
638
660
|
* All CLI commands should use this utility to ensure consistent branding.
|
|
639
661
|
* Also loads .env.local from the project root if available.
|
|
640
662
|
*
|
|
641
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
|
+
* });
|
|
642
672
|
*/
|
|
643
|
-
async function runCommand(commandFn) {
|
|
644
|
-
|
|
673
|
+
async function runCommand(commandFn, options) {
|
|
674
|
+
if (options?.fullBanner) printBanner();
|
|
675
|
+
else intro(base44Color(" Base 44 "));
|
|
645
676
|
await loadProjectEnv();
|
|
646
677
|
try {
|
|
647
678
|
await commandFn();
|
|
@@ -660,8 +691,21 @@ async function runCommand(commandFn) {
|
|
|
660
691
|
*
|
|
661
692
|
* @param startMessage - Message to show when spinner starts
|
|
662
693
|
* @param operation - The async operation to execute
|
|
663
|
-
* @param options - Optional configuration
|
|
694
|
+
* @param options - Optional configuration for success/error messages
|
|
664
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
|
+
* );
|
|
665
709
|
*/
|
|
666
710
|
async function runTask(startMessage, operation, options) {
|
|
667
711
|
const s = spinner();
|
|
@@ -687,21 +731,6 @@ const onPromptCancel = () => {
|
|
|
687
731
|
process.exit(0);
|
|
688
732
|
};
|
|
689
733
|
|
|
690
|
-
//#endregion
|
|
691
|
-
//#region src/cli/utils/banner.ts
|
|
692
|
-
const orange = chalk.hex("#E86B3C");
|
|
693
|
-
const BANNER = `
|
|
694
|
-
${orange("██████╗ █████╗ ███████╗███████╗ ██╗ ██╗██╗ ██╗")}
|
|
695
|
-
${orange("██╔══██╗██╔══██╗██╔════╝██╔════╝ ██║ ██║██║ ██║")}
|
|
696
|
-
${orange("██████╔╝███████║███████╗█████╗ ███████║███████║")}
|
|
697
|
-
${orange("██╔══██╗██╔══██║╚════██║██╔══╝ ╚════██║╚════██║")}
|
|
698
|
-
${orange("██████╔╝██║ ██║███████║███████╗ ██║ ██║")}
|
|
699
|
-
${orange("╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝")}
|
|
700
|
-
`;
|
|
701
|
-
function printBanner() {
|
|
702
|
-
console.log(BANNER);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
734
|
//#endregion
|
|
706
735
|
//#region src/cli/commands/auth/login.ts
|
|
707
736
|
async function generateAndDisplayDeviceCode() {
|
|
@@ -824,8 +853,6 @@ const entitiesPushCommand = new Command("entities").description("Manage project
|
|
|
824
853
|
//#endregion
|
|
825
854
|
//#region src/cli/commands/project/create.ts
|
|
826
855
|
async function create() {
|
|
827
|
-
printBanner();
|
|
828
|
-
await loadProjectEnv();
|
|
829
856
|
const templateOptions = (await listTemplates()).map((t) => ({
|
|
830
857
|
value: t,
|
|
831
858
|
label: t.name,
|
|
@@ -871,13 +898,7 @@ async function create() {
|
|
|
871
898
|
log.success(`Project ${chalk.bold(name)} has been initialized!`);
|
|
872
899
|
}
|
|
873
900
|
const createCommand = new Command("create").description("Create a new Base44 project").action(async () => {
|
|
874
|
-
|
|
875
|
-
await create();
|
|
876
|
-
} catch (e) {
|
|
877
|
-
if (e instanceof Error) log.error(e.stack ?? e.message);
|
|
878
|
-
else log.error(String(e));
|
|
879
|
-
process.exit(1);
|
|
880
|
-
}
|
|
901
|
+
await runCommand(create, { fullBanner: true });
|
|
881
902
|
});
|
|
882
903
|
|
|
883
904
|
//#endregion
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { useState, useEffect } from
|
|
2
|
-
import { base44 } from
|
|
3
|
-
import { Button } from
|
|
4
|
-
import { Checkbox } from
|
|
5
|
-
import { Input } from
|
|
6
|
-
import {
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { base44 } from "@/api/base44Client";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Base44Logo } from "@/components/Base44Logo";
|
|
7
|
+
import { Plus, Trash2, CheckCircle2 } from "lucide-react";
|
|
7
8
|
|
|
8
9
|
const Task = base44.entities.Task;
|
|
9
10
|
|
|
10
11
|
export default function App() {
|
|
11
12
|
const [tasks, setTasks] = useState([]);
|
|
12
|
-
const [newTaskTitle, setNewTaskTitle] = useState(
|
|
13
|
+
const [newTaskTitle, setNewTaskTitle] = useState("");
|
|
13
14
|
const [isLoading, setIsLoading] = useState(true);
|
|
14
15
|
|
|
15
16
|
const fetchTasks = async () => {
|
|
@@ -26,7 +27,7 @@ export default function App() {
|
|
|
26
27
|
e.preventDefault();
|
|
27
28
|
if (!newTaskTitle.trim()) return;
|
|
28
29
|
await Task.create({ title: newTaskTitle.trim(), completed: false });
|
|
29
|
-
setNewTaskTitle(
|
|
30
|
+
setNewTaskTitle("");
|
|
30
31
|
fetchTasks();
|
|
31
32
|
};
|
|
32
33
|
|
|
@@ -41,7 +42,9 @@ export default function App() {
|
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
const clearCompleted = async () => {
|
|
44
|
-
await Promise.all(
|
|
45
|
+
await Promise.all(
|
|
46
|
+
tasks.filter((t) => t.completed).map((t) => Task.delete(t.id))
|
|
47
|
+
);
|
|
45
48
|
fetchTasks();
|
|
46
49
|
};
|
|
47
50
|
|
|
@@ -53,10 +56,13 @@ export default function App() {
|
|
|
53
56
|
<div className="max-w-lg mx-auto px-6 py-16">
|
|
54
57
|
{/* Header */}
|
|
55
58
|
<div className="text-center mb-12">
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
<h1 className="text-3xl font-semibold text-slate-900 tracking-tight">
|
|
60
|
+
<span className="inline-flex items-center gap-2 align-middle">
|
|
61
|
+
<Base44Logo className="w-9 h-9" />
|
|
62
|
+
<span className="font-bold">Base44</span>
|
|
63
|
+
<span>Tasks</span>
|
|
64
|
+
</span>
|
|
65
|
+
</h1>
|
|
60
66
|
{totalCount > 0 && (
|
|
61
67
|
<p className="text-slate-500 mt-2 text-sm">
|
|
62
68
|
{completedCount} of {totalCount} completed
|
|
@@ -105,7 +111,11 @@ export default function App() {
|
|
|
105
111
|
onCheckedChange={(checked) => toggleTask(task.id, checked)}
|
|
106
112
|
className="w-5 h-5 rounded-md border-slate-300 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
|
|
107
113
|
/>
|
|
108
|
-
<span
|
|
114
|
+
<span
|
|
115
|
+
className={`flex-1 text-slate-700 transition-all ${
|
|
116
|
+
task.completed ? "line-through text-slate-400" : ""
|
|
117
|
+
}`}
|
|
118
|
+
>
|
|
109
119
|
{task.title}
|
|
110
120
|
</span>
|
|
111
121
|
<Button
|
|
@@ -124,7 +134,10 @@ export default function App() {
|
|
|
124
134
|
{/* Footer */}
|
|
125
135
|
{completedCount > 0 && (
|
|
126
136
|
<div className="mt-8 text-center">
|
|
127
|
-
<button
|
|
137
|
+
<button
|
|
138
|
+
onClick={clearCompleted}
|
|
139
|
+
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
|
140
|
+
>
|
|
128
141
|
Clear completed
|
|
129
142
|
</button>
|
|
130
143
|
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function Base44Logo({ className = "w-8 h-8" }) {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
5
|
+
fill="none"
|
|
6
|
+
viewBox="0 0 31 31"
|
|
7
|
+
className={className}
|
|
8
|
+
>
|
|
9
|
+
<path
|
|
10
|
+
fill="#FF631F"
|
|
11
|
+
d="M24.16 26.904c.04 0 .057.05.026.075a14.97 14.97 0 0 1-9.147 3.1c-3.44 0-6.612-1.156-9.146-3.1-.032-.024-.014-.075.026-.075zm3.923-4.373a15 15 0 0 1-1.842 2.544.14.14 0 0 1-.104.046H3.942a.14.14 0 0 1-.104-.046 15 15 0 0 1-1.842-2.544.056.056 0 0 1 .049-.083h25.99c.043 0 .07.046.048.083m1.698-4.5a15 15 0 0 1-.762 2.564.11.11 0 0 1-.103.07H1.163a.11.11 0 0 1-.104-.07 15 15 0 0 1-.762-2.564.056.056 0 0 1 .055-.067h29.375c.035 0 .061.032.054.067M14.938 0C23.29-.056 30.078 6.7 30.078 15.04q0 .55-.038 1.09a.056.056 0 0 1-.056.051H.094a.056.056 0 0 1-.055-.052A15 15 0 0 1 0 15.054C-.007 6.87 6.755.055 14.938 0"
|
|
12
|
+
></path>
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
}
|
package/package.json
CHANGED