@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.
- package/README.md +118 -0
- package/dist/auth-7RGL7GXU.js +311 -0
- package/dist/chunk-2CR762KB.js +18 -0
- package/dist/chunk-AVKPM7C4.js +199 -0
- package/dist/chunk-D2BLSEGR.js +59 -0
- package/dist/chunk-NDLYGKS6.js +77 -0
- package/dist/chunk-V2XXMMEI.js +147 -0
- package/dist/dev-UTZC4ZJ7.js +87 -0
- package/dist/index.js +157 -0
- package/dist/init-OQCIET53.js +363 -0
- package/dist/migrate-2DZ6RQ5K.js +190 -0
- package/dist/resources-PNK3NESI.js +1350 -0
- package/dist/run-4NDI2CN4.js +257 -0
- package/dist/skills-56EUKHGY.js +414 -0
- package/dist/status-BEVUV6RY.js +131 -0
- package/package.json +37 -0
- package/templates/default/CLAUDE.md +245 -0
- package/templates/default/README.md +59 -0
- package/templates/default/biome.json +33 -0
- package/templates/default/index.html +13 -0
- package/templates/default/package.json.hbs +46 -0
- package/templates/default/platform/automations/.gitkeep +0 -0
- package/templates/default/platform/collections/example_items.json +28 -0
- package/templates/default/platform/hooks/.gitkeep +0 -0
- package/templates/default/pyproject.toml.hbs +14 -0
- package/templates/default/scripts/seed-demo.py +35 -0
- package/templates/default/src/components/Sidebar.tsx +84 -0
- package/templates/default/src/components/StatCard.tsx +31 -0
- package/templates/default/src/components/layout.tsx +13 -0
- package/templates/default/src/lib/queries.ts +27 -0
- package/templates/default/src/main.tsx +137 -0
- package/templates/default/src/routes/__root.tsx +10 -0
- package/templates/default/src/routes/index.tsx +90 -0
- package/templates/default/src/routes/settings.tsx +25 -0
- package/templates/default/src/styles.css +40 -0
- package/templates/default/tsconfig.json +23 -0
- 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
|
+
};
|