@base44-preview/cli 0.0.1-pr.10.0e28f2b
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 +49 -0
- package/dist/cli/index.js +641 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Base44 CLI
|
|
2
|
+
|
|
3
|
+
A unified command-line interface for managing Base44 applications, entities, functions, deployments, and related services.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Using npm
|
|
9
|
+
npm install
|
|
10
|
+
|
|
11
|
+
# Build the project
|
|
12
|
+
npm run build
|
|
13
|
+
|
|
14
|
+
# Run the CLI
|
|
15
|
+
npm start # Using node directly
|
|
16
|
+
./dist/cli/index.js # Run executable directly
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Development
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Run in development mode
|
|
23
|
+
npm run dev
|
|
24
|
+
|
|
25
|
+
# Build the project
|
|
26
|
+
npm run build
|
|
27
|
+
|
|
28
|
+
# Run the built CLI
|
|
29
|
+
npm run start
|
|
30
|
+
|
|
31
|
+
# Clean build artifacts
|
|
32
|
+
npm run clean
|
|
33
|
+
|
|
34
|
+
# Lint the code
|
|
35
|
+
npm run lint
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
### Authentication
|
|
42
|
+
|
|
43
|
+
- `base44 login` - Authenticate with Base44 using device code flow
|
|
44
|
+
- `base44 whoami` - Display current authenticated user
|
|
45
|
+
- `base44 logout` - Logout from current device
|
|
46
|
+
|
|
47
|
+
### Project
|
|
48
|
+
|
|
49
|
+
- `base44 show-project` - Display project configuration, entities, and functions
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { intro, log, spinner } from "@clack/prompts";
|
|
5
|
+
import pWaitFor from "p-wait-for";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import ky from "ky";
|
|
10
|
+
import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
11
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
12
|
+
import { globby } from "globby";
|
|
13
|
+
|
|
14
|
+
//#region src/core/auth/schema.ts
|
|
15
|
+
const AuthDataSchema = z.object({
|
|
16
|
+
accessToken: z.string().min(1, "Token cannot be empty"),
|
|
17
|
+
refreshToken: z.string().min(1, "Refresh token cannot be empty"),
|
|
18
|
+
expiresAt: z.number().int().positive("Expires at must be a positive integer"),
|
|
19
|
+
email: z.email(),
|
|
20
|
+
name: z.string().min(1, "Name cannot be empty")
|
|
21
|
+
});
|
|
22
|
+
const DeviceCodeResponseSchema = z.object({
|
|
23
|
+
device_code: z.string().min(1, "Device code cannot be empty"),
|
|
24
|
+
user_code: z.string().min(1, "User code cannot be empty"),
|
|
25
|
+
verification_uri: z.url("Invalid verification URL"),
|
|
26
|
+
verification_uri_complete: z.url("Invalid complete verification URL"),
|
|
27
|
+
expires_in: z.number().int().positive("Expires in must be a positive integer"),
|
|
28
|
+
interval: z.number().int().positive("Interval in must be a positive integer")
|
|
29
|
+
}).transform((data) => ({
|
|
30
|
+
deviceCode: data.device_code,
|
|
31
|
+
userCode: data.user_code,
|
|
32
|
+
verificationUri: data.verification_uri,
|
|
33
|
+
verificationUriComplete: data.verification_uri_complete,
|
|
34
|
+
expiresIn: data.expires_in,
|
|
35
|
+
interval: data.interval
|
|
36
|
+
}));
|
|
37
|
+
const TokenResponseSchema = z.object({
|
|
38
|
+
access_token: z.string().min(1, "Token cannot be empty"),
|
|
39
|
+
token_type: z.string().min(1, "Token type cannot be empty"),
|
|
40
|
+
expires_in: z.number().int().positive("Expires in must be a positive integer"),
|
|
41
|
+
refresh_token: z.string().min(1, "Refresh token cannot be empty"),
|
|
42
|
+
scope: z.string().optional()
|
|
43
|
+
}).transform((data) => ({
|
|
44
|
+
accessToken: data.access_token,
|
|
45
|
+
tokenType: data.token_type,
|
|
46
|
+
expiresIn: data.expires_in,
|
|
47
|
+
refreshToken: data.refresh_token,
|
|
48
|
+
scope: data.scope
|
|
49
|
+
}));
|
|
50
|
+
const OAuthErrorSchema = z.object({
|
|
51
|
+
error: z.string(),
|
|
52
|
+
error_description: z.string().optional()
|
|
53
|
+
});
|
|
54
|
+
const UserInfoSchema = z.object({
|
|
55
|
+
email: z.email(),
|
|
56
|
+
name: z.string()
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/core/errors.ts
|
|
61
|
+
var AuthApiError = class extends Error {
|
|
62
|
+
constructor(message, cause) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.cause = cause;
|
|
65
|
+
this.name = "AuthApiError";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var AuthValidationError = class extends Error {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "AuthValidationError";
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/core/consts.ts
|
|
77
|
+
const PROJECT_SUBDIR = "base44";
|
|
78
|
+
const FUNCTION_CONFIG_FILE = "function.jsonc";
|
|
79
|
+
function getBase44Dir() {
|
|
80
|
+
return join(homedir(), ".base44");
|
|
81
|
+
}
|
|
82
|
+
function getAuthFilePath() {
|
|
83
|
+
return join(getBase44Dir(), "auth", "auth.json");
|
|
84
|
+
}
|
|
85
|
+
function getProjectConfigPatterns() {
|
|
86
|
+
return [
|
|
87
|
+
`${PROJECT_SUBDIR}/config.jsonc`,
|
|
88
|
+
`${PROJECT_SUBDIR}/config.json`,
|
|
89
|
+
"config.jsonc",
|
|
90
|
+
"config.json"
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const AUTH_CLIENT_ID = "base44_cli";
|
|
94
|
+
const DEFAULT_API_URL = "https://app.base44.com";
|
|
95
|
+
function getBase44ApiUrl() {
|
|
96
|
+
return process.env.BASE44_API_URL || DEFAULT_API_URL;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/core/auth/authClient.ts
|
|
101
|
+
/**
|
|
102
|
+
* Separate ky instance for OAuth endpoints.
|
|
103
|
+
* These don't need Authorization headers (they use client_id + tokens in body).
|
|
104
|
+
*/
|
|
105
|
+
const authClient = ky.create({
|
|
106
|
+
prefixUrl: getBase44ApiUrl(),
|
|
107
|
+
headers: { "User-Agent": "Base44 CLI" }
|
|
108
|
+
});
|
|
109
|
+
var authClient_default = authClient;
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/core/utils/fs.ts
|
|
113
|
+
function pathExists(path) {
|
|
114
|
+
return access(path).then(() => true).catch(() => false);
|
|
115
|
+
}
|
|
116
|
+
async function readJsonFile(filePath) {
|
|
117
|
+
if (!await pathExists(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
118
|
+
try {
|
|
119
|
+
const fileContent = await readFile(filePath, "utf-8");
|
|
120
|
+
const errors = [];
|
|
121
|
+
const result = parse(fileContent, errors, { allowTrailingComma: true });
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
const errorMessages = errors.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`).join(", ");
|
|
124
|
+
throw new Error(`File contains invalid JSONC: ${filePath} (${errorMessages})`);
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof Error && error.message.includes("invalid JSONC")) throw error;
|
|
129
|
+
throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function writeJsonFile(filePath, data) {
|
|
133
|
+
try {
|
|
134
|
+
const dir = dirname(filePath);
|
|
135
|
+
if (!await pathExists(dir)) await mkdir(dir, { recursive: true });
|
|
136
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(`Failed to write file ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function deleteFile(filePath) {
|
|
142
|
+
if (!await pathExists(filePath)) return;
|
|
143
|
+
try {
|
|
144
|
+
await unlink(filePath);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new Error(`Failed to delete file ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/core/auth/config.ts
|
|
152
|
+
const TOKEN_REFRESH_BUFFER_MS = 60 * 1e3;
|
|
153
|
+
let refreshPromise = null;
|
|
154
|
+
async function readAuth() {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = await readJsonFile(getAuthFilePath());
|
|
157
|
+
const result = AuthDataSchema.safeParse(parsed);
|
|
158
|
+
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
159
|
+
return result.data;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof Error && error.message.includes("Authentication")) throw error;
|
|
162
|
+
if (error instanceof Error && error.message.includes("File not found")) throw new Error("Authentication file not found. Please login first.");
|
|
163
|
+
throw new Error(`Failed to read authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function writeAuth(authData) {
|
|
167
|
+
const result = AuthDataSchema.safeParse(authData);
|
|
168
|
+
if (!result.success) throw new Error(`Invalid authentication data: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
169
|
+
try {
|
|
170
|
+
await writeJsonFile(getAuthFilePath(), result.data);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error(`Failed to write authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function deleteAuth() {
|
|
176
|
+
try {
|
|
177
|
+
await deleteFile(getAuthFilePath());
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new Error(`Failed to delete authentication file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Checks if the access token is expired or about to expire.
|
|
184
|
+
*/
|
|
185
|
+
function isTokenExpired(auth) {
|
|
186
|
+
return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Refreshes the access token and saves the new tokens.
|
|
190
|
+
* Returns the new access token, or null if refresh failed.
|
|
191
|
+
* Uses a lock to prevent concurrent refresh requests.
|
|
192
|
+
*/
|
|
193
|
+
async function refreshAndSaveTokens() {
|
|
194
|
+
if (refreshPromise) return refreshPromise;
|
|
195
|
+
refreshPromise = (async () => {
|
|
196
|
+
try {
|
|
197
|
+
const auth = await readAuth();
|
|
198
|
+
const tokenResponse = await renewAccessToken(auth.refreshToken);
|
|
199
|
+
await writeAuth({
|
|
200
|
+
...auth,
|
|
201
|
+
accessToken: tokenResponse.accessToken,
|
|
202
|
+
refreshToken: tokenResponse.refreshToken,
|
|
203
|
+
expiresAt: Date.now() + tokenResponse.expiresIn * 1e3
|
|
204
|
+
});
|
|
205
|
+
return tokenResponse.accessToken;
|
|
206
|
+
} catch {
|
|
207
|
+
await deleteAuth();
|
|
208
|
+
return null;
|
|
209
|
+
} finally {
|
|
210
|
+
refreshPromise = null;
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
return refreshPromise;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/core/utils/httpClient.ts
|
|
218
|
+
const retriedRequests = /* @__PURE__ */ new WeakSet();
|
|
219
|
+
/**
|
|
220
|
+
* Handles 401 responses by refreshing the token and retrying the request.
|
|
221
|
+
* Only retries once per request to prevent infinite loops.
|
|
222
|
+
*/
|
|
223
|
+
async function handleUnauthorized(request, _options, response) {
|
|
224
|
+
if (response.status !== 401) return;
|
|
225
|
+
if (retriedRequests.has(request)) return;
|
|
226
|
+
const newAccessToken = await refreshAndSaveTokens();
|
|
227
|
+
if (!newAccessToken) return;
|
|
228
|
+
retriedRequests.add(request);
|
|
229
|
+
request.headers.set("Authorization", `Bearer ${newAccessToken}`);
|
|
230
|
+
return ky(request);
|
|
231
|
+
}
|
|
232
|
+
const httpClient = ky.create({
|
|
233
|
+
prefixUrl: getBase44ApiUrl(),
|
|
234
|
+
headers: { "User-Agent": "Base44 CLI" },
|
|
235
|
+
hooks: {
|
|
236
|
+
beforeRequest: [async (request) => {
|
|
237
|
+
try {
|
|
238
|
+
const auth = await readAuth();
|
|
239
|
+
if (isTokenExpired(auth)) {
|
|
240
|
+
const newAccessToken = await refreshAndSaveTokens();
|
|
241
|
+
if (newAccessToken) {
|
|
242
|
+
request.headers.set("Authorization", `Bearer ${newAccessToken}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
request.headers.set("Authorization", `Bearer ${auth.accessToken}`);
|
|
247
|
+
} catch {}
|
|
248
|
+
}],
|
|
249
|
+
afterResponse: [handleUnauthorized]
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
var httpClient_default = httpClient;
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/core/auth/api.ts
|
|
256
|
+
async function generateDeviceCode() {
|
|
257
|
+
const response = await authClient_default.post("oauth/device/code", {
|
|
258
|
+
json: {
|
|
259
|
+
client_id: AUTH_CLIENT_ID,
|
|
260
|
+
scope: "apps:read apps:write"
|
|
261
|
+
},
|
|
262
|
+
throwHttpErrors: false
|
|
263
|
+
});
|
|
264
|
+
if (!response.ok) throw new AuthApiError(`Failed to generate device code: ${response.status} ${response.statusText}`);
|
|
265
|
+
const result = DeviceCodeResponseSchema.safeParse(await response.json());
|
|
266
|
+
if (!result.success) throw new AuthValidationError(`Invalid device code response from server: ${result.error.message}`);
|
|
267
|
+
return result.data;
|
|
268
|
+
}
|
|
269
|
+
async function getTokenFromDeviceCode(deviceCode) {
|
|
270
|
+
const searchParams = new URLSearchParams();
|
|
271
|
+
searchParams.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
|
272
|
+
searchParams.set("device_code", deviceCode);
|
|
273
|
+
searchParams.set("client_id", AUTH_CLIENT_ID);
|
|
274
|
+
const response = await authClient_default.post("oauth/token", {
|
|
275
|
+
body: searchParams.toString(),
|
|
276
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
277
|
+
throwHttpErrors: false
|
|
278
|
+
});
|
|
279
|
+
const json = await response.json();
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
const errorResult = OAuthErrorSchema.safeParse(json);
|
|
282
|
+
if (!errorResult.success) throw new AuthValidationError(`Token request failed: ${errorResult.error.message}`);
|
|
283
|
+
const { error, error_description } = errorResult.data;
|
|
284
|
+
if (error === "authorization_pending" || error === "slow_down") return null;
|
|
285
|
+
throw new AuthApiError(error_description ?? `OAuth error: ${error}`);
|
|
286
|
+
}
|
|
287
|
+
const result = TokenResponseSchema.safeParse(json);
|
|
288
|
+
if (!result.success) throw new AuthValidationError(`Invalid token response from server: ${result.error.message}`);
|
|
289
|
+
return result.data;
|
|
290
|
+
}
|
|
291
|
+
async function renewAccessToken(refreshToken) {
|
|
292
|
+
const searchParams = new URLSearchParams();
|
|
293
|
+
searchParams.set("grant_type", "refresh_token");
|
|
294
|
+
searchParams.set("refresh_token", refreshToken);
|
|
295
|
+
searchParams.set("client_id", AUTH_CLIENT_ID);
|
|
296
|
+
const response = await authClient_default.post("oauth/token", {
|
|
297
|
+
body: searchParams.toString(),
|
|
298
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
299
|
+
throwHttpErrors: false
|
|
300
|
+
});
|
|
301
|
+
const json = await response.json();
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
const errorResult = OAuthErrorSchema.safeParse(json);
|
|
304
|
+
if (!errorResult.success) throw new AuthApiError(`Token refresh failed: ${response.statusText}`);
|
|
305
|
+
const { error, error_description } = errorResult.data;
|
|
306
|
+
throw new AuthApiError(error_description ?? `OAuth error: ${error}`);
|
|
307
|
+
}
|
|
308
|
+
const result = TokenResponseSchema.safeParse(json);
|
|
309
|
+
if (!result.success) throw new AuthValidationError(`Invalid token response from server: ${result.error.message}`);
|
|
310
|
+
return result.data;
|
|
311
|
+
}
|
|
312
|
+
async function getUserInfo() {
|
|
313
|
+
const response = await httpClient_default.get("oauth/userinfo");
|
|
314
|
+
if (!response.ok) throw new AuthApiError(`Failed to fetch user info: ${response.status}`);
|
|
315
|
+
const result = UserInfoSchema.safeParse(await response.json());
|
|
316
|
+
if (!result.success) throw new AuthValidationError(`Invalid UserInfo response from server: ${result.error.message}`);
|
|
317
|
+
return result.data;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/cli/utils/runCommand.ts
|
|
322
|
+
const base44Color = chalk.bgHex("#E86B3C");
|
|
323
|
+
/**
|
|
324
|
+
* Wraps a command function with the Base44 intro banner.
|
|
325
|
+
* All CLI commands should use this utility to ensure consistent branding.
|
|
326
|
+
*
|
|
327
|
+
* @param commandFn - The async function to execute as the command
|
|
328
|
+
*/
|
|
329
|
+
async function runCommand(commandFn) {
|
|
330
|
+
intro(base44Color(" Base 44 "));
|
|
331
|
+
try {
|
|
332
|
+
await commandFn();
|
|
333
|
+
} catch (e) {
|
|
334
|
+
if (e instanceof Error) log.error(e.stack ?? e.message);
|
|
335
|
+
else log.error(String(e));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/cli/utils/runTask.ts
|
|
342
|
+
/**
|
|
343
|
+
* Wraps an async operation with automatic spinner management.
|
|
344
|
+
* The spinner is automatically started, and stopped on both success and error.
|
|
345
|
+
*
|
|
346
|
+
* @param startMessage - Message to show when spinner starts
|
|
347
|
+
* @param operation - The async operation to execute
|
|
348
|
+
* @param options - Optional configuration
|
|
349
|
+
* @returns The result of the operation
|
|
350
|
+
*/
|
|
351
|
+
async function runTask(startMessage, operation, options) {
|
|
352
|
+
const s = spinner();
|
|
353
|
+
s.start(startMessage);
|
|
354
|
+
try {
|
|
355
|
+
const result = await operation();
|
|
356
|
+
s.stop(options?.successMessage || startMessage);
|
|
357
|
+
return result;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
s.stop(options?.errorMessage || "Failed");
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/cli/commands/auth/login.ts
|
|
366
|
+
async function generateAndDisplayDeviceCode() {
|
|
367
|
+
const deviceCodeResponse = await runTask("Generating device code...", async () => {
|
|
368
|
+
return await generateDeviceCode();
|
|
369
|
+
}, {
|
|
370
|
+
successMessage: "Device code generated",
|
|
371
|
+
errorMessage: "Failed to generate device code"
|
|
372
|
+
});
|
|
373
|
+
log.info(`Your code is: ${chalk.bold(deviceCodeResponse.userCode)}\nPlease visit: ${deviceCodeResponse.verificationUriComplete}`);
|
|
374
|
+
return deviceCodeResponse;
|
|
375
|
+
}
|
|
376
|
+
async function waitForAuthentication(deviceCode, expiresIn, interval) {
|
|
377
|
+
let tokenResponse;
|
|
378
|
+
try {
|
|
379
|
+
await runTask("Waiting for you to complete authentication...", async () => {
|
|
380
|
+
await pWaitFor(async () => {
|
|
381
|
+
const result = await getTokenFromDeviceCode(deviceCode);
|
|
382
|
+
if (result !== null) {
|
|
383
|
+
tokenResponse = result;
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
return false;
|
|
387
|
+
}, {
|
|
388
|
+
interval: interval * 1e3,
|
|
389
|
+
timeout: expiresIn * 1e3
|
|
390
|
+
});
|
|
391
|
+
}, {
|
|
392
|
+
successMessage: "Authentication completed!",
|
|
393
|
+
errorMessage: "Authentication failed"
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof Error && error.message.includes("timed out")) throw new Error("Authentication timed out. Please try again.");
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
if (tokenResponse === void 0) throw new Error("Failed to retrieve authentication token.");
|
|
400
|
+
return tokenResponse;
|
|
401
|
+
}
|
|
402
|
+
async function saveAuthData(response, userInfo) {
|
|
403
|
+
const expiresAt = Date.now() + response.expiresIn * 1e3;
|
|
404
|
+
await writeAuth({
|
|
405
|
+
accessToken: response.accessToken,
|
|
406
|
+
refreshToken: response.refreshToken,
|
|
407
|
+
expiresAt,
|
|
408
|
+
email: userInfo.email,
|
|
409
|
+
name: userInfo.name
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async function login() {
|
|
413
|
+
const deviceCodeResponse = await generateAndDisplayDeviceCode();
|
|
414
|
+
const token = await waitForAuthentication(deviceCodeResponse.deviceCode, deviceCodeResponse.expiresIn, deviceCodeResponse.interval);
|
|
415
|
+
const userInfo = await getUserInfo();
|
|
416
|
+
await saveAuthData(token, userInfo);
|
|
417
|
+
log.success(`Successfully logged as ${chalk.bold(userInfo.email)}`);
|
|
418
|
+
}
|
|
419
|
+
const loginCommand = new Command("login").description("Authenticate with Base44").action(async () => {
|
|
420
|
+
await runCommand(login);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/cli/commands/auth/whoami.ts
|
|
425
|
+
async function whoami() {
|
|
426
|
+
const auth = await readAuth();
|
|
427
|
+
log.info(`Logged in as: ${auth.name} (${auth.email})`);
|
|
428
|
+
}
|
|
429
|
+
const whoamiCommand = new Command("whoami").description("Display current authenticated user").action(async () => {
|
|
430
|
+
await runCommand(whoami);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/cli/commands/auth/logout.ts
|
|
435
|
+
async function logout() {
|
|
436
|
+
await deleteAuth();
|
|
437
|
+
log.info("Logged out successfully");
|
|
438
|
+
}
|
|
439
|
+
const logoutCommand = new Command("logout").description("Logout from current device").action(async () => {
|
|
440
|
+
await runCommand(logout);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/core/resources/entity/schema.ts
|
|
445
|
+
const EntityPropertySchema = z.object({
|
|
446
|
+
type: z.string(),
|
|
447
|
+
description: z.string().optional(),
|
|
448
|
+
enum: z.array(z.string()).optional(),
|
|
449
|
+
default: z.union([
|
|
450
|
+
z.string(),
|
|
451
|
+
z.number(),
|
|
452
|
+
z.boolean()
|
|
453
|
+
]).optional(),
|
|
454
|
+
format: z.string().optional(),
|
|
455
|
+
items: z.any().optional(),
|
|
456
|
+
relation: z.object({
|
|
457
|
+
entity: z.string(),
|
|
458
|
+
type: z.string()
|
|
459
|
+
}).optional()
|
|
460
|
+
});
|
|
461
|
+
const EntityPoliciesSchema = z.object({
|
|
462
|
+
read: z.string().optional(),
|
|
463
|
+
create: z.string().optional(),
|
|
464
|
+
update: z.string().optional(),
|
|
465
|
+
delete: z.string().optional()
|
|
466
|
+
});
|
|
467
|
+
const EntitySchema = z.object({
|
|
468
|
+
name: z.string().min(1, "Entity name cannot be empty"),
|
|
469
|
+
type: z.literal("object"),
|
|
470
|
+
properties: z.record(z.string(), EntityPropertySchema),
|
|
471
|
+
required: z.array(z.string()).optional(),
|
|
472
|
+
policies: EntityPoliciesSchema.optional()
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/core/resources/entity/config.ts
|
|
477
|
+
async function readEntityFile(entityPath) {
|
|
478
|
+
const parsed = await readJsonFile(entityPath);
|
|
479
|
+
const result = EntitySchema.safeParse(parsed);
|
|
480
|
+
if (!result.success) throw new Error(`Invalid entity configuration in ${entityPath}: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
481
|
+
return result.data;
|
|
482
|
+
}
|
|
483
|
+
async function readAllEntities(entitiesDir) {
|
|
484
|
+
if (!await pathExists(entitiesDir)) return [];
|
|
485
|
+
const files = await globby("*.{json,jsonc}", {
|
|
486
|
+
cwd: entitiesDir,
|
|
487
|
+
absolute: true
|
|
488
|
+
});
|
|
489
|
+
return await Promise.all(files.map((filePath) => readEntityFile(filePath)));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/core/resources/entity/resource.ts
|
|
494
|
+
const entityResource = { readAll: readAllEntities };
|
|
495
|
+
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/core/resources/function/schema.ts
|
|
498
|
+
const HttpTriggerSchema = z.object({
|
|
499
|
+
id: z.string().optional(),
|
|
500
|
+
name: z.string().optional(),
|
|
501
|
+
description: z.string().optional(),
|
|
502
|
+
type: z.literal("http"),
|
|
503
|
+
path: z.string().min(1, "Path cannot be empty")
|
|
504
|
+
});
|
|
505
|
+
const ScheduleTriggerSchema = z.object({
|
|
506
|
+
id: z.string().optional(),
|
|
507
|
+
name: z.string().optional(),
|
|
508
|
+
description: z.string().optional(),
|
|
509
|
+
type: z.literal("schedule"),
|
|
510
|
+
scheduleMode: z.enum(["recurring", "once"]).optional(),
|
|
511
|
+
cron: z.string().min(1, "Cron expression cannot be empty"),
|
|
512
|
+
isActive: z.boolean().optional(),
|
|
513
|
+
timezone: z.string().optional()
|
|
514
|
+
});
|
|
515
|
+
const EventTriggerSchema = z.object({
|
|
516
|
+
id: z.string().optional(),
|
|
517
|
+
name: z.string().optional(),
|
|
518
|
+
description: z.string().optional(),
|
|
519
|
+
type: z.literal("event"),
|
|
520
|
+
entity: z.string().min(1, "Entity name cannot be empty"),
|
|
521
|
+
event: z.string().min(1, "Event type cannot be empty")
|
|
522
|
+
});
|
|
523
|
+
const TriggerSchema = z.discriminatedUnion("type", [
|
|
524
|
+
HttpTriggerSchema,
|
|
525
|
+
ScheduleTriggerSchema,
|
|
526
|
+
EventTriggerSchema
|
|
527
|
+
]);
|
|
528
|
+
const FunctionConfigSchema = z.object({
|
|
529
|
+
entry: z.string().min(1, "Entry point cannot be empty"),
|
|
530
|
+
triggers: z.array(TriggerSchema).optional()
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/core/resources/function/config.ts
|
|
535
|
+
async function readFunctionConfig(configPath) {
|
|
536
|
+
const parsed = await readJsonFile(configPath);
|
|
537
|
+
const result = FunctionConfigSchema.safeParse(parsed);
|
|
538
|
+
if (!result.success) throw new Error(`Invalid function configuration in ${configPath}: ${result.error.issues.map((e) => e.message).join(", ")}`);
|
|
539
|
+
return result.data;
|
|
540
|
+
}
|
|
541
|
+
async function readAllFunctions(functionsDir) {
|
|
542
|
+
if (!await pathExists(functionsDir)) return [];
|
|
543
|
+
const configFiles = await globby(`*/${FUNCTION_CONFIG_FILE}`, {
|
|
544
|
+
cwd: functionsDir,
|
|
545
|
+
absolute: true
|
|
546
|
+
});
|
|
547
|
+
return await Promise.all(configFiles.map((configPath) => readFunctionConfig(configPath)));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/core/resources/function/resource.ts
|
|
552
|
+
const functionResource = { readAll: readAllFunctions };
|
|
553
|
+
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/core/config/project.ts
|
|
556
|
+
const ProjectConfigSchema = z.looseObject({
|
|
557
|
+
name: z.string().min(1, "Project name cannot be empty"),
|
|
558
|
+
entitySrc: z.string().default("./entities"),
|
|
559
|
+
functionSrc: z.string().default("./functions")
|
|
560
|
+
});
|
|
561
|
+
async function findConfigInDir(dir) {
|
|
562
|
+
return (await globby(getProjectConfigPatterns(), {
|
|
563
|
+
cwd: dir,
|
|
564
|
+
absolute: true
|
|
565
|
+
}))[0] ?? null;
|
|
566
|
+
}
|
|
567
|
+
async function findProjectRoot(startPath) {
|
|
568
|
+
let current = startPath || process.cwd();
|
|
569
|
+
while (current !== dirname(current)) {
|
|
570
|
+
const configPath = await findConfigInDir(current);
|
|
571
|
+
if (configPath) return {
|
|
572
|
+
root: current,
|
|
573
|
+
configPath
|
|
574
|
+
};
|
|
575
|
+
current = dirname(current);
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
async function readProjectConfig(projectRoot) {
|
|
580
|
+
let found;
|
|
581
|
+
if (projectRoot) {
|
|
582
|
+
const configPath$1 = await findConfigInDir(projectRoot);
|
|
583
|
+
found = configPath$1 ? {
|
|
584
|
+
root: projectRoot,
|
|
585
|
+
configPath: configPath$1
|
|
586
|
+
} : null;
|
|
587
|
+
} else found = await findProjectRoot();
|
|
588
|
+
if (!found) throw new Error(`Project root not found. Please ensure config.jsonc or config.json exists in the project directory or ${PROJECT_SUBDIR}/ subdirectory.`);
|
|
589
|
+
const { root, configPath } = found;
|
|
590
|
+
const parsed = await readJsonFile(configPath);
|
|
591
|
+
const result = ProjectConfigSchema.safeParse(parsed);
|
|
592
|
+
if (!result.success) {
|
|
593
|
+
const errors = result.error.issues.map((e) => e.message).join(", ");
|
|
594
|
+
throw new Error(`Invalid project configuration: ${errors}`);
|
|
595
|
+
}
|
|
596
|
+
const project = result.data;
|
|
597
|
+
const configDir = dirname(configPath);
|
|
598
|
+
const [entities, functions] = await Promise.all([entityResource.readAll(join(configDir, project.entitySrc)), functionResource.readAll(join(configDir, project.functionSrc))]);
|
|
599
|
+
return {
|
|
600
|
+
project: {
|
|
601
|
+
...project,
|
|
602
|
+
root,
|
|
603
|
+
configPath
|
|
604
|
+
},
|
|
605
|
+
entities,
|
|
606
|
+
functions
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/cli/commands/project/show-project.ts
|
|
612
|
+
async function showProject() {
|
|
613
|
+
const projectData = await runTask("Reading project configuration", async () => {
|
|
614
|
+
return await readProjectConfig();
|
|
615
|
+
}, {
|
|
616
|
+
successMessage: "Project configuration loaded",
|
|
617
|
+
errorMessage: "Failed to load project configuration"
|
|
618
|
+
});
|
|
619
|
+
const jsonOutput = JSON.stringify(projectData, null, 2);
|
|
620
|
+
log.info(jsonOutput);
|
|
621
|
+
}
|
|
622
|
+
const showProjectCommand = new Command("show-project").description("Display project configuration, entities, and functions").action(async () => {
|
|
623
|
+
await runCommand(showProject);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region package.json
|
|
628
|
+
var version = "0.0.1";
|
|
629
|
+
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region src/cli/index.ts
|
|
632
|
+
const program = new Command();
|
|
633
|
+
program.name("base44").description("Base44 CLI - Unified interface for managing Base44 applications").version(version);
|
|
634
|
+
program.addCommand(loginCommand);
|
|
635
|
+
program.addCommand(whoamiCommand);
|
|
636
|
+
program.addCommand(logoutCommand);
|
|
637
|
+
program.addCommand(showProjectCommand);
|
|
638
|
+
program.parse();
|
|
639
|
+
|
|
640
|
+
//#endregion
|
|
641
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@base44-preview/cli",
|
|
3
|
+
"version": "0.0.1-pr.10.0e28f2b",
|
|
4
|
+
"description": "Base44 CLI - Unified interface for managing Base44 applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cli/index.js",
|
|
7
|
+
"bin": "./dist/cli/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/cli/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsdown",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"dev": "tsx src/cli/index.ts",
|
|
18
|
+
"start": "node dist/cli/index.js",
|
|
19
|
+
"clean": "rm -rf dist",
|
|
20
|
+
"lint": "eslint src",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"base44",
|
|
26
|
+
"cli",
|
|
27
|
+
"command-line"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "ISC",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/base44/cli"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@clack/prompts": "^0.11.0",
|
|
37
|
+
"chalk": "^5.6.2",
|
|
38
|
+
"commander": "^12.1.0",
|
|
39
|
+
"globby": "^16.1.0",
|
|
40
|
+
"jsonc-parser": "^3.3.1",
|
|
41
|
+
"ky": "^1.14.2",
|
|
42
|
+
"p-wait-for": "^6.0.0",
|
|
43
|
+
"zod": "^4.3.5"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@stylistic/eslint-plugin": "^5.6.1",
|
|
47
|
+
"@types/node": "^22.10.5",
|
|
48
|
+
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
|
49
|
+
"@typescript-eslint/parser": "^8.51.0",
|
|
50
|
+
"eslint": "^9.39.2",
|
|
51
|
+
"eslint-plugin-import": "^2.32.0",
|
|
52
|
+
"eslint-plugin-unicorn": "^62.0.0",
|
|
53
|
+
"tsdown": "^0.12.4",
|
|
54
|
+
"tsx": "^4.19.2",
|
|
55
|
+
"typescript": "^5.7.2",
|
|
56
|
+
"typescript-eslint": "^8.52.0",
|
|
57
|
+
"vitest": "^4.0.16"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20.19.0"
|
|
61
|
+
}
|
|
62
|
+
}
|