@lumerahq/cli 0.7.0

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 (37) hide show
  1. package/README.md +118 -0
  2. package/dist/auth-7RGL7GXU.js +311 -0
  3. package/dist/chunk-2CR762KB.js +18 -0
  4. package/dist/chunk-AVKPM7C4.js +199 -0
  5. package/dist/chunk-D2BLSEGR.js +59 -0
  6. package/dist/chunk-NDLYGKS6.js +77 -0
  7. package/dist/chunk-V2XXMMEI.js +147 -0
  8. package/dist/dev-UTZC4ZJ7.js +87 -0
  9. package/dist/index.js +157 -0
  10. package/dist/init-OQCIET53.js +363 -0
  11. package/dist/migrate-2DZ6RQ5K.js +190 -0
  12. package/dist/resources-PNK3NESI.js +1350 -0
  13. package/dist/run-4NDI2CN4.js +257 -0
  14. package/dist/skills-56EUKHGY.js +414 -0
  15. package/dist/status-BEVUV6RY.js +131 -0
  16. package/package.json +37 -0
  17. package/templates/default/CLAUDE.md +245 -0
  18. package/templates/default/README.md +59 -0
  19. package/templates/default/biome.json +33 -0
  20. package/templates/default/index.html +13 -0
  21. package/templates/default/package.json.hbs +46 -0
  22. package/templates/default/platform/automations/.gitkeep +0 -0
  23. package/templates/default/platform/collections/example_items.json +28 -0
  24. package/templates/default/platform/hooks/.gitkeep +0 -0
  25. package/templates/default/pyproject.toml.hbs +14 -0
  26. package/templates/default/scripts/seed-demo.py +35 -0
  27. package/templates/default/src/components/Sidebar.tsx +84 -0
  28. package/templates/default/src/components/StatCard.tsx +31 -0
  29. package/templates/default/src/components/layout.tsx +13 -0
  30. package/templates/default/src/lib/queries.ts +27 -0
  31. package/templates/default/src/main.tsx +137 -0
  32. package/templates/default/src/routes/__root.tsx +10 -0
  33. package/templates/default/src/routes/index.tsx +90 -0
  34. package/templates/default/src/routes/settings.tsx +25 -0
  35. package/templates/default/src/styles.css +40 -0
  36. package/templates/default/tsconfig.json +23 -0
  37. package/templates/default/vite.config.ts +27 -0
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @lumerahq/cli
2
+
3
+ CLI for building and deploying Lumera apps.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @lumerahq/cli
9
+ # or use directly with npx/pnpm dlx
10
+ pnpm dlx @lumerahq/cli init my-app
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ lumera init [name] # Scaffold a new Lumera app
17
+ lumera login # Authenticate with Lumera
18
+ lumera logout # Clear stored credentials
19
+ lumera whoami # Show current user
20
+ lumera status # Show project info
21
+
22
+ lumera app dev # Start dev server
23
+ lumera app deploy # Deploy frontend to S3
24
+ lumera app destroy # Delete app from Lumera
25
+
26
+ lumera platform plan # Preview infrastructure changes
27
+ lumera platform apply # Apply collections, automations, hooks
28
+ lumera platform pull # Pull remote state to local
29
+ lumera platform destroy # Delete remote resources
30
+
31
+ lumera run <script> # Run Python scripts locally
32
+ ```
33
+
34
+ ## Scaffolding Projects
35
+
36
+ ### Interactive Mode
37
+
38
+ ```bash
39
+ lumera init # Prompts for project name and directory
40
+ lumera init my-app # Prompts for directory only
41
+ ```
42
+
43
+ ### Non-Interactive Mode
44
+
45
+ For CI/CD or scripted environments, use `-y` (or `--yes`) flag:
46
+
47
+ ```bash
48
+ lumera init my-app -y # Creates ./my-app
49
+ lumera init my-app -y --dir ./apps # Creates ./apps
50
+ lumera init my-app -y --force # Overwrites if directory exists
51
+ lumera init my-app -y --install # Also runs pnpm install
52
+ ```
53
+
54
+ ### Init Options
55
+
56
+ | Flag | Short | Description |
57
+ |------|-------|-------------|
58
+ | `--yes` | `-y` | Non-interactive mode (project name required) |
59
+ | `--dir <path>` | `-d` | Target directory (defaults to project name) |
60
+ | `--force` | `-f` | Overwrite existing directory without prompting |
61
+ | `--install` | `-i` | Install dependencies after scaffolding |
62
+ | `--help` | `-h` | Show help |
63
+
64
+ ### CI/CD Example
65
+
66
+ ```bash
67
+ # Full non-interactive setup
68
+ lumera init my-app -y -f -i
69
+ ```
70
+
71
+ ## Authentication
72
+
73
+ ### Interactive Login
74
+
75
+ ```bash
76
+ lumera login
77
+ ```
78
+
79
+ Opens a browser for OAuth authentication. Credentials are stored locally.
80
+
81
+ ### CI/CD Usage
82
+
83
+ For automated environments, use the `LUMERA_TOKEN` environment variable:
84
+
85
+ ```bash
86
+ export LUMERA_TOKEN=your_api_token
87
+ lumera app deploy
88
+ ```
89
+
90
+ The CLI checks for credentials in this order:
91
+ 1. `LUMERA_TOKEN` environment variable
92
+ 2. Project-local credentials (`.lumera/credentials.json`)
93
+ 3. Global credentials (`~/.config/lumera/credentials.json`)
94
+
95
+ ## Security
96
+
97
+ ### Credential Storage
98
+
99
+ - **Never commit credentials**: The `.lumera/` directory is gitignored by default in scaffolded projects
100
+ - **Token scope**: Use tokens with minimal required permissions for CI/CD
101
+ - **Rotation**: Rotate tokens periodically, especially after team member offboarding
102
+
103
+ ### Environment Variables
104
+
105
+ - `LUMERA_TOKEN` - API token for authentication (recommended for CI/CD)
106
+ - `LUMERA_BASE_URL` - Override base URL (defaults to `https://app.lumerahq.com`)
107
+ - `LUMERA_API_URL` - Override API URL (defaults to `https://app.lumerahq.com/api`)
108
+
109
+ ### Best Practices
110
+
111
+ 1. Use `lumera login` for local development (stores credentials securely)
112
+ 2. Use `LUMERA_TOKEN` for CI/CD pipelines
113
+ 3. Never hardcode tokens in scripts or config files
114
+ 4. Keep `.lumera/` in `.gitignore` to prevent accidental credential commits
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,311 @@
1
+ import {
2
+ getCredentials,
3
+ getTokenSource
4
+ } from "./chunk-NDLYGKS6.js";
5
+ import {
6
+ getBaseUrl
7
+ } from "./chunk-D2BLSEGR.js";
8
+
9
+ // src/commands/auth.ts
10
+ import pc from "picocolors";
11
+ import { createServer } from "http";
12
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+ import open from "open";
16
+ import crypto from "crypto";
17
+ var GLOBAL_CONFIG_DIR = join(homedir(), ".config", "lumera");
18
+ var GLOBAL_CREDS_PATH = join(GLOBAL_CONFIG_DIR, "credentials.json");
19
+ function getLocalCredsPath(cwd = process.cwd()) {
20
+ return join(cwd, ".lumera", "credentials.json");
21
+ }
22
+ function isLumeraProject() {
23
+ const cwd = process.cwd();
24
+ const hasPackageJson = existsSync(join(cwd, "package.json"));
25
+ const hasPlatformDir = existsSync(join(cwd, "platform"));
26
+ return hasPackageJson && hasPlatformDir;
27
+ }
28
+ async function findAvailablePort(startPort) {
29
+ return new Promise((resolve, reject) => {
30
+ const server = createServer();
31
+ server.once("error", (err) => {
32
+ if (err.code === "EADDRINUSE") {
33
+ resolve(findAvailablePort(startPort + 1));
34
+ } else {
35
+ reject(err);
36
+ }
37
+ });
38
+ server.once("listening", () => {
39
+ const address = server.address();
40
+ const port = typeof address === "object" && address ? address.port : startPort;
41
+ server.close(() => resolve(port));
42
+ });
43
+ server.listen(startPort, "127.0.0.1");
44
+ });
45
+ }
46
+ async function fetchUserInfo(token) {
47
+ const baseUrl = getBaseUrl();
48
+ try {
49
+ const response = await fetch(`${baseUrl}/api/me`, {
50
+ headers: {
51
+ Authorization: `Bearer ${token}`
52
+ }
53
+ });
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to fetch user info: ${response.status}`);
56
+ }
57
+ const data = await response.json();
58
+ const user = data.user || {};
59
+ return {
60
+ email: user.email || "unknown",
61
+ company: user.company?.name || user.company_name || "unknown"
62
+ };
63
+ } catch {
64
+ return { email: "unknown", company: "unknown" };
65
+ }
66
+ }
67
+ function showLoginHelp() {
68
+ console.log(`
69
+ ${pc.dim("Usage:")}
70
+ lumera login [options]
71
+
72
+ ${pc.dim("Description:")}
73
+ Login to Lumera via browser-based authentication.
74
+ By default, credentials are stored in the current project.
75
+
76
+ ${pc.dim("Options:")}
77
+ --global, -g Store credentials globally (~/.config/lumera/)
78
+ --help, -h Show this help
79
+
80
+ ${pc.dim("Token storage:")}
81
+ Project: .lumera/credentials.json (default, requires platform/ directory)
82
+ Global: ~/.config/lumera/credentials.json (with --global)
83
+ `);
84
+ }
85
+ async function login(args) {
86
+ if (args.includes("--help") || args.includes("-h")) {
87
+ showLoginHelp();
88
+ return;
89
+ }
90
+ const isGlobal = args.includes("--global") || args.includes("-g");
91
+ if (!isGlobal && !isLumeraProject()) {
92
+ console.log();
93
+ console.log(pc.red(" \u2717 Not in a Lumera project directory"));
94
+ console.log(pc.dim(" Expected package.json and platform/ directory."));
95
+ console.log();
96
+ console.log(pc.dim(" Options:"));
97
+ console.log(pc.dim(" \u2022 Run from a Lumera project directory"));
98
+ console.log(pc.dim(" \u2022 Use --global to store credentials globally"));
99
+ console.log();
100
+ process.exit(1);
101
+ }
102
+ console.log();
103
+ console.log(pc.cyan(pc.bold(" Lumera Login")));
104
+ console.log();
105
+ const port = await findAvailablePort(9876);
106
+ const state = crypto.randomUUID();
107
+ const baseUrl = getBaseUrl();
108
+ let resolveLogin;
109
+ let rejectLogin;
110
+ const loginPromise = new Promise((resolve, reject) => {
111
+ resolveLogin = resolve;
112
+ rejectLogin = reject;
113
+ });
114
+ const server = createServer((req, res) => {
115
+ const reqUrl = new URL(req.url, `http://127.0.0.1:${port}`);
116
+ res.setHeader("Access-Control-Allow-Origin", "*");
117
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
118
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
119
+ if (req.method === "OPTIONS") {
120
+ res.writeHead(204);
121
+ res.end();
122
+ return;
123
+ }
124
+ if (reqUrl.pathname === "/callback") {
125
+ const token = reqUrl.searchParams.get("token");
126
+ const returnedState = reqUrl.searchParams.get("state");
127
+ const error = reqUrl.searchParams.get("error");
128
+ if (error) {
129
+ res.writeHead(200, { "Content-Type": "text/html" });
130
+ res.end(`
131
+ <html>
132
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
133
+ <div style="text-align: center;">
134
+ <h1 style="color: #dc2626;">Login Failed</h1>
135
+ <p>${error}</p>
136
+ <p style="color: #6b7280;">You can close this tab.</p>
137
+ </div>
138
+ </body>
139
+ </html>
140
+ `);
141
+ rejectLogin(new Error(error));
142
+ return;
143
+ }
144
+ if (!token || !returnedState) {
145
+ res.writeHead(400, { "Content-Type": "text/html" });
146
+ res.end(`
147
+ <html>
148
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
149
+ <div style="text-align: center;">
150
+ <h1 style="color: #dc2626;">Invalid Callback</h1>
151
+ <p>Missing token or state parameter.</p>
152
+ <p style="color: #6b7280;">You can close this tab.</p>
153
+ </div>
154
+ </body>
155
+ </html>
156
+ `);
157
+ rejectLogin(new Error("Invalid callback - missing token or state"));
158
+ return;
159
+ }
160
+ res.writeHead(200, { "Content-Type": "text/html" });
161
+ res.end(`
162
+ <html>
163
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
164
+ <div style="text-align: center;">
165
+ <h1 style="color: #16a34a;">Login Successful!</h1>
166
+ <p style="color: #6b7280;">You can close this tab and return to the terminal.</p>
167
+ </div>
168
+ </body>
169
+ </html>
170
+ `);
171
+ resolveLogin({ token, state: returnedState });
172
+ } else {
173
+ res.writeHead(404);
174
+ res.end("Not found");
175
+ }
176
+ });
177
+ server.listen(port, "127.0.0.1");
178
+ const authUrl = `${baseUrl}/cli/auth?callback=http://127.0.0.1:${port}/callback&state=${state}`;
179
+ console.log(pc.dim(` Opening browser to ${baseUrl}/cli/auth...`));
180
+ console.log();
181
+ try {
182
+ await open(authUrl);
183
+ } catch {
184
+ console.log(pc.yellow(" Could not open browser automatically."));
185
+ console.log(pc.dim(" Please open this URL manually:"));
186
+ console.log();
187
+ console.log(pc.cyan(` ${authUrl}`));
188
+ console.log();
189
+ }
190
+ console.log(pc.dim(" Waiting for authentication..."));
191
+ console.log();
192
+ const timeout = setTimeout(() => {
193
+ server.close();
194
+ rejectLogin(new Error("Login timed out after 5 minutes"));
195
+ }, 5 * 60 * 1e3);
196
+ try {
197
+ const result = await loginPromise;
198
+ clearTimeout(timeout);
199
+ server.close();
200
+ if (result.state !== state) {
201
+ console.log(pc.red(" \u2717 Invalid state - possible CSRF attack"));
202
+ process.exit(1);
203
+ }
204
+ const userInfo = await fetchUserInfo(result.token);
205
+ const credentials = {
206
+ token: result.token,
207
+ user: userInfo.email,
208
+ company: userInfo.company,
209
+ created: (/* @__PURE__ */ new Date()).toISOString()
210
+ };
211
+ if (isGlobal) {
212
+ mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
213
+ writeFileSync(GLOBAL_CREDS_PATH, JSON.stringify(credentials, null, 2));
214
+ console.log(pc.green(" \u2713"), `Logged in as ${pc.bold(userInfo.email)} (${userInfo.company})`);
215
+ console.log(pc.dim(` Token saved to ~/.config/lumera/credentials.json`));
216
+ } else {
217
+ const localDir = join(process.cwd(), ".lumera");
218
+ mkdirSync(localDir, { recursive: true });
219
+ writeFileSync(getLocalCredsPath(), JSON.stringify(credentials, null, 2));
220
+ console.log(pc.green(" \u2713"), `Logged in as ${pc.bold(userInfo.email)} (${userInfo.company})`);
221
+ console.log(pc.dim(` Token saved to .lumera/credentials.json`));
222
+ }
223
+ console.log();
224
+ } catch (error) {
225
+ clearTimeout(timeout);
226
+ server.close();
227
+ if (error instanceof Error) {
228
+ console.log(pc.red(` \u2717 ${error.message}`));
229
+ } else {
230
+ console.log(pc.red(" \u2717 Login failed"));
231
+ }
232
+ process.exit(1);
233
+ }
234
+ }
235
+ function showLogoutHelp() {
236
+ console.log(`
237
+ ${pc.dim("Usage:")}
238
+ lumera logout [options]
239
+
240
+ ${pc.dim("Description:")}
241
+ Clear stored Lumera credentials.
242
+ By default, removes project-local credentials.
243
+
244
+ ${pc.dim("Options:")}
245
+ --global, -g Remove global credentials
246
+ --help, -h Show this help
247
+ `);
248
+ }
249
+ async function logout(args) {
250
+ if (args.includes("--help") || args.includes("-h")) {
251
+ showLogoutHelp();
252
+ return;
253
+ }
254
+ const isGlobal = args.includes("--global") || args.includes("-g");
255
+ console.log();
256
+ if (isGlobal) {
257
+ if (existsSync(GLOBAL_CREDS_PATH)) {
258
+ rmSync(GLOBAL_CREDS_PATH);
259
+ console.log(pc.green(" \u2713"), "Logged out");
260
+ console.log(pc.dim(` Deleted ~/.config/lumera/credentials.json`));
261
+ } else {
262
+ console.log(pc.yellow(" \u26A0"), "No global credentials found");
263
+ }
264
+ } else {
265
+ const localPath = getLocalCredsPath();
266
+ if (existsSync(localPath)) {
267
+ rmSync(localPath);
268
+ console.log(pc.green(" \u2713"), "Removed project credentials");
269
+ console.log(pc.dim(` Deleted .lumera/credentials.json`));
270
+ } else {
271
+ console.log(pc.yellow(" \u26A0"), "No project credentials found");
272
+ }
273
+ }
274
+ console.log();
275
+ }
276
+ async function whoami() {
277
+ console.log();
278
+ if (process.env.LUMERA_TOKEN) {
279
+ const userInfo = await fetchUserInfo(process.env.LUMERA_TOKEN);
280
+ console.log(` ${pc.dim("User:")} ${pc.bold(userInfo.email)}`);
281
+ console.log(` ${pc.dim("Company:")} ${userInfo.company}`);
282
+ console.log(` ${pc.dim("Source:")} environment (LUMERA_TOKEN)`);
283
+ console.log();
284
+ return;
285
+ }
286
+ const credentials = getCredentials();
287
+ const source = getTokenSource();
288
+ if (!credentials) {
289
+ console.log(pc.yellow(" Not logged in"));
290
+ console.log();
291
+ console.log(pc.dim(" Run `lumera login` to authenticate."));
292
+ console.log();
293
+ return;
294
+ }
295
+ let email = credentials.user || "unknown";
296
+ let company = credentials.company || "unknown";
297
+ if (email === "unknown" || company === "unknown") {
298
+ const userInfo = await fetchUserInfo(credentials.token);
299
+ email = userInfo.email;
300
+ company = userInfo.company;
301
+ }
302
+ console.log(` ${pc.dim("User:")} ${pc.bold(email)}`);
303
+ console.log(` ${pc.dim("Company:")} ${company}`);
304
+ console.log(` ${pc.dim("Source:")} ${source}`);
305
+ console.log();
306
+ }
307
+ export {
308
+ login,
309
+ logout,
310
+ whoami
311
+ };
@@ -0,0 +1,18 @@
1
+ // src/lib/env.ts
2
+ import { config } from "dotenv";
3
+ import { existsSync } from "fs";
4
+ import { resolve } from "path";
5
+ function loadEnv(cwd = process.cwd()) {
6
+ const envPath = resolve(cwd, ".env");
7
+ const envLocalPath = resolve(cwd, ".env.local");
8
+ if (existsSync(envPath)) {
9
+ config({ path: envPath });
10
+ }
11
+ if (existsSync(envLocalPath)) {
12
+ config({ path: envLocalPath, override: true });
13
+ }
14
+ }
15
+
16
+ export {
17
+ loadEnv
18
+ };
@@ -0,0 +1,199 @@
1
+ // src/lib/deploy.ts
2
+ import { execSync, spawn } from "child_process";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { resolve, dirname } from "path";
5
+ import archiver from "archiver";
6
+ import pc from "picocolors";
7
+ function formatTimestampVersion(date = /* @__PURE__ */ new Date()) {
8
+ const pad = (value) => String(value).padStart(2, "0");
9
+ return [
10
+ date.getUTCFullYear(),
11
+ pad(date.getUTCMonth() + 1),
12
+ pad(date.getUTCDate())
13
+ ].join("") + "T" + [
14
+ pad(date.getUTCHours()),
15
+ pad(date.getUTCMinutes()),
16
+ pad(date.getUTCSeconds())
17
+ ].join("") + "Z";
18
+ }
19
+ function resolveVersion(cwd) {
20
+ const pkgPath = resolve(cwd, "package.json");
21
+ try {
22
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
23
+ if (pkg?.version && pkg.version !== "0.0.0") return String(pkg.version);
24
+ } catch {
25
+ }
26
+ try {
27
+ const sha = execSync("git rev-parse --short HEAD", {
28
+ cwd,
29
+ stdio: ["ignore", "pipe", "ignore"]
30
+ }).toString().trim();
31
+ if (sha) return sha;
32
+ } catch {
33
+ }
34
+ return formatTimestampVersion();
35
+ }
36
+ async function createTarball(distDir) {
37
+ return new Promise((resolve2, reject) => {
38
+ const chunks = [];
39
+ const archive = archiver("tar", { gzip: true });
40
+ archive.on("data", (chunk) => chunks.push(chunk));
41
+ archive.on("end", () => resolve2(Buffer.concat(chunks)));
42
+ archive.on("error", reject);
43
+ archive.directory(distDir, false);
44
+ archive.finalize();
45
+ });
46
+ }
47
+ async function ensureAppRecord(apiBase, token, appName, appTitle) {
48
+ const filterParam = encodeURIComponent(JSON.stringify({ external_id: appName }));
49
+ const searchRes = await fetch(
50
+ `${apiBase}/pb/collections/lm_custom_apps/records?filter=${filterParam}`,
51
+ { headers: { Authorization: `Bearer ${token}` } }
52
+ );
53
+ if (!searchRes.ok) {
54
+ throw new Error(`Failed to check app record: ${await searchRes.text()}`);
55
+ }
56
+ const existing = (await searchRes.json()).items?.[0];
57
+ const appData = {
58
+ external_id: appName,
59
+ name: appTitle,
60
+ hosting_type: "s3"
61
+ };
62
+ const endpoint = existing ? `${apiBase}/pb/collections/lm_custom_apps/records/${existing.id}` : `${apiBase}/pb/collections/lm_custom_apps/records`;
63
+ const res = await fetch(endpoint, {
64
+ method: existing ? "PATCH" : "POST",
65
+ headers: {
66
+ Authorization: `Bearer ${token}`,
67
+ "Content-Type": "application/json"
68
+ },
69
+ body: JSON.stringify(appData)
70
+ });
71
+ if (!res.ok) {
72
+ throw new Error(`Failed to register app: ${await res.text()}`);
73
+ }
74
+ }
75
+ async function deploy(options) {
76
+ const {
77
+ token,
78
+ appName,
79
+ appTitle,
80
+ distDir,
81
+ apiUrl
82
+ } = options;
83
+ if (!existsSync(distDir)) {
84
+ throw new Error(`dist directory not found: ${distDir}`);
85
+ }
86
+ console.log(pc.dim("Ensuring app record..."));
87
+ await ensureAppRecord(apiUrl, token, appName, appTitle);
88
+ console.log(pc.cyan(`Deploying ${appTitle}...`));
89
+ const version = options.version || resolveVersion(dirname(distDir));
90
+ console.log(pc.dim(`Version: ${version}`));
91
+ const tarball = await createTarball(distDir);
92
+ console.log(pc.dim(`Package: ${(tarball.length / 1024).toFixed(1)} KB`));
93
+ const formData = new FormData();
94
+ formData.append("tarball", new Blob([new Uint8Array(tarball)]), "dist.tar.gz");
95
+ formData.append("version", version);
96
+ const res = await fetch(`${apiUrl}/custom-apps/${appName}/deploy`, {
97
+ method: "POST",
98
+ headers: { Authorization: `Bearer ${token}` },
99
+ body: formData
100
+ });
101
+ if (!res.ok) {
102
+ throw new Error(`Deploy failed: ${await res.text()}`);
103
+ }
104
+ const result = await res.json();
105
+ console.log(pc.green(`
106
+ Deployed! ${result.url}`));
107
+ return { url: result.url, version };
108
+ }
109
+ async function registerDevApp(apiBase, token, appName, appTitle, appUrl) {
110
+ const filterParam = encodeURIComponent(JSON.stringify({ external_id: appName }));
111
+ const searchRes = await fetch(
112
+ `${apiBase}/pb/collections/lm_custom_apps/records?filter=${filterParam}`,
113
+ { headers: { Authorization: `Bearer ${token}` } }
114
+ );
115
+ if (!searchRes.ok) {
116
+ throw new Error(`Failed to check app record: ${await searchRes.text()}`);
117
+ }
118
+ const existing = (await searchRes.json()).items?.[0];
119
+ const appData = {
120
+ dev_iframe_url: appUrl
121
+ };
122
+ if (!existing) {
123
+ appData.external_id = appName;
124
+ appData.name = appTitle;
125
+ }
126
+ const endpoint = existing ? `${apiBase}/pb/collections/lm_custom_apps/records/${existing.id}` : `${apiBase}/pb/collections/lm_custom_apps/records`;
127
+ const res = await fetch(endpoint, {
128
+ method: existing ? "PATCH" : "POST",
129
+ headers: {
130
+ Authorization: `Bearer ${token}`,
131
+ "Content-Type": "application/json"
132
+ },
133
+ body: JSON.stringify(appData)
134
+ });
135
+ if (!res.ok) {
136
+ throw new Error(`Failed to register app: ${await res.text()}`);
137
+ }
138
+ }
139
+ async function dev(options) {
140
+ const {
141
+ token,
142
+ appName,
143
+ appTitle,
144
+ port,
145
+ appUrl = `http://localhost:${port}`,
146
+ apiUrl
147
+ } = options;
148
+ console.log();
149
+ console.log(pc.cyan(pc.bold(` ${appTitle} - Dev Server`)));
150
+ console.log();
151
+ console.log(pc.dim(" Configuration:"));
152
+ console.log(pc.dim(` Port: ${port}`));
153
+ console.log(pc.dim(` URL: ${appUrl}`));
154
+ console.log();
155
+ console.log(pc.dim(" Registering app with Lumera..."));
156
+ try {
157
+ await registerDevApp(apiUrl, token, appName, appTitle, appUrl);
158
+ console.log(pc.green(" \u2713 App registered"));
159
+ } catch (err) {
160
+ console.log(pc.yellow(" \u26A0 Failed to register app (continuing anyway)"));
161
+ if (err instanceof Error) {
162
+ console.log(pc.dim(` ${err.message}`));
163
+ }
164
+ }
165
+ console.log();
166
+ console.log(pc.green(" Starting dev server..."));
167
+ console.log();
168
+ console.log(pc.cyan(pc.bold(" Open in Lumera (dev mode):")));
169
+ console.log(pc.cyan(` https://app.lumerahq.com/custom-apps/${appName}?dev`));
170
+ console.log();
171
+ const safeEnvPrefixes = ["VITE_", "LUMERA_", "NODE_", "npm_"];
172
+ const safeEnvKeys = ["PATH", "HOME", "USER", "SHELL", "TERM", "LANG", "LC_ALL", "TMPDIR", "TZ"];
173
+ const filteredEnv = Object.fromEntries(
174
+ Object.entries(process.env).filter(
175
+ ([key]) => safeEnvPrefixes.some((prefix) => key.startsWith(prefix)) || safeEnvKeys.includes(key)
176
+ )
177
+ );
178
+ const vite = spawn("pnpm", ["exec", "vite", "--port", String(port)], {
179
+ stdio: "inherit",
180
+ env: {
181
+ ...filteredEnv,
182
+ VITE_PARENT_ORIGIN: "https://app.lumerahq.com"
183
+ }
184
+ });
185
+ setTimeout(() => {
186
+ console.log();
187
+ console.log(pc.cyan(pc.bold(` \u279C Lumera: https://app.lumerahq.com/custom-apps/${appName}?dev`)));
188
+ }, 2e3);
189
+ vite.on("close", (code) => {
190
+ process.exit(code || 0);
191
+ });
192
+ process.on("SIGINT", () => vite.kill("SIGINT"));
193
+ process.on("SIGTERM", () => vite.kill("SIGTERM"));
194
+ }
195
+
196
+ export {
197
+ deploy,
198
+ dev
199
+ };
@@ -0,0 +1,59 @@
1
+ // src/lib/config.ts
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { resolve, join } from "path";
4
+ function findProjectRoot(startDir = process.cwd()) {
5
+ let dir = startDir;
6
+ while (dir !== "/") {
7
+ if (existsSync(join(dir, "package.json"))) {
8
+ return dir;
9
+ }
10
+ dir = resolve(dir, "..");
11
+ }
12
+ throw new Error("Could not find project root (no package.json found)");
13
+ }
14
+ function readPackageJson(projectRoot) {
15
+ const pkgPath = join(projectRoot, "package.json");
16
+ if (!existsSync(pkgPath)) {
17
+ throw new Error(`package.json not found at ${pkgPath}`);
18
+ }
19
+ return JSON.parse(readFileSync(pkgPath, "utf-8"));
20
+ }
21
+ function getAppName(projectRoot) {
22
+ const pkg = readPackageJson(projectRoot);
23
+ return pkg.name;
24
+ }
25
+ function getAppTitle(projectRoot) {
26
+ const pkg = readPackageJson(projectRoot);
27
+ return pkg.lumera?.name || pkg.name;
28
+ }
29
+ function detectProjectVersion(projectRoot) {
30
+ const pkg = readPackageJson(projectRoot);
31
+ if (pkg.lumera?.version !== void 0) {
32
+ return pkg.lumera.version;
33
+ }
34
+ if (existsSync(join(projectRoot, "lumera_platform"))) {
35
+ return 0;
36
+ }
37
+ if (existsSync(join(projectRoot, "platform"))) {
38
+ return 1;
39
+ }
40
+ return 1;
41
+ }
42
+ function getBaseUrl() {
43
+ let base = process.env.LUMERA_BASE_URL || process.env.LUMERA_API_URL || "https://app.lumerahq.com";
44
+ base = base.replace(/\/api\/?$/, "").replace(/\/$/, "");
45
+ return base;
46
+ }
47
+ function getApiUrl() {
48
+ return `${getBaseUrl()}/api`;
49
+ }
50
+
51
+ export {
52
+ findProjectRoot,
53
+ readPackageJson,
54
+ getAppName,
55
+ getAppTitle,
56
+ detectProjectVersion,
57
+ getBaseUrl,
58
+ getApiUrl
59
+ };