@lumerahq/cli 0.10.0 → 0.10.1

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.
@@ -0,0 +1,104 @@
1
+ // src/lib/templates.ts
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "fs";
3
+ import { homedir, tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { spawnSync } from "child_process";
6
+ var GITHUB_OWNER = "lumerahq";
7
+ var GITHUB_REPO = "app-templates";
8
+ var GITHUB_REF = "main";
9
+ function getCacheDir() {
10
+ return join(homedir(), ".lumera", "templates");
11
+ }
12
+ function readCacheMeta() {
13
+ const metaPath = join(getCacheDir(), ".cache-meta.json");
14
+ if (!existsSync(metaPath)) return null;
15
+ try {
16
+ return JSON.parse(readFileSync(metaPath, "utf-8"));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ async function fetchLatestSha() {
22
+ const url = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/commits/${GITHUB_REF}`;
23
+ const res = await fetch(url, {
24
+ headers: { Accept: "application/vnd.github.sha" }
25
+ });
26
+ if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
27
+ return (await res.text()).trim();
28
+ }
29
+ async function downloadAndExtract(commitSha) {
30
+ const cacheDir = getCacheDir();
31
+ const tarballUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/archive/refs/heads/${GITHUB_REF}.tar.gz`;
32
+ const res = await fetch(tarballUrl);
33
+ if (!res.ok) throw new Error(`Failed to download templates: ${res.status}`);
34
+ const tmpFile = join(tmpdir(), `lumera-templates-${Date.now()}.tar.gz`);
35
+ const buffer = Buffer.from(await res.arrayBuffer());
36
+ writeFileSync(tmpFile, buffer);
37
+ mkdirSync(cacheDir, { recursive: true });
38
+ for (const entry of readdirSync(cacheDir)) {
39
+ if (entry === ".cache-meta.json") continue;
40
+ rmSync(join(cacheDir, entry), { recursive: true, force: true });
41
+ }
42
+ const result = spawnSync("tar", ["xzf", tmpFile, "-C", cacheDir, "--strip-components=1"], { stdio: "ignore" });
43
+ if (result.status !== 0) throw new Error("Failed to extract template archive");
44
+ unlinkSync(tmpFile);
45
+ const meta = { commitSha, fetchedAt: (/* @__PURE__ */ new Date()).toISOString() };
46
+ writeFileSync(join(cacheDir, ".cache-meta.json"), JSON.stringify(meta));
47
+ return cacheDir;
48
+ }
49
+ async function ensureRemoteCache() {
50
+ const cacheDir = getCacheDir();
51
+ const cached = readCacheMeta();
52
+ const latestSha = await fetchLatestSha();
53
+ if (cached?.commitSha === latestSha && existsSync(cacheDir)) {
54
+ return cacheDir;
55
+ }
56
+ console.log(" Fetching latest templates...");
57
+ return downloadAndExtract(latestSha);
58
+ }
59
+ function listTemplates(baseDir) {
60
+ if (!existsSync(baseDir)) return [];
61
+ const templates = [];
62
+ for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
63
+ if (!entry.isDirectory()) continue;
64
+ const metaPath = join(baseDir, entry.name, "template.json");
65
+ if (!existsSync(metaPath)) continue;
66
+ try {
67
+ const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
68
+ if (!raw.name || !raw.title || !raw.description || !raw.category) {
69
+ console.warn(`Warning: Skipping template "${entry.name}" \u2014 template.json is missing required fields (name, title, description, category)`);
70
+ continue;
71
+ }
72
+ templates.push(raw);
73
+ } catch {
74
+ console.warn(`Warning: Skipping template "${entry.name}" \u2014 invalid template.json`);
75
+ }
76
+ }
77
+ return templates.sort((a, b) => {
78
+ if (a.name === "default") return -1;
79
+ if (b.name === "default") return 1;
80
+ return a.title.localeCompare(b.title);
81
+ });
82
+ }
83
+ async function resolveTemplate(name) {
84
+ const cacheDir = await ensureRemoteCache();
85
+ const dir = join(cacheDir, name);
86
+ if (!existsSync(dir) || !existsSync(join(dir, "template.json"))) {
87
+ const available = listTemplates(cacheDir).map((t) => t.name).join(", ");
88
+ throw new Error(
89
+ `Template "${name}" not found. Available: ${available || "none"}
90
+ Cache location: ${cacheDir}
91
+ Try deleting the cache and retrying: rm -rf ${cacheDir}`
92
+ );
93
+ }
94
+ return dir;
95
+ }
96
+ async function listAllTemplates() {
97
+ const cacheDir = await ensureRemoteCache();
98
+ return listTemplates(cacheDir);
99
+ }
100
+
101
+ export {
102
+ resolveTemplate,
103
+ listAllTemplates
104
+ };
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ ${pc.dim("Development:")}
32
32
 
33
33
  ${pc.dim("Project:")}
34
34
  ${pc.cyan("init")} [name] Scaffold a new project
35
+ ${pc.cyan("templates")} List available templates
35
36
  ${pc.cyan("status")} Show project info
36
37
  ${pc.cyan("migrate")} Upgrade legacy project
37
38
 
@@ -118,7 +119,10 @@ async function main() {
118
119
  break;
119
120
  // Project
120
121
  case "init":
121
- await import("./init-EDSRR3YM.js").then((m) => m.init(args.slice(1)));
122
+ await import("./init-OH433IPH.js").then((m) => m.init(args.slice(1)));
123
+ break;
124
+ case "templates":
125
+ await import("./templates-67O6PVFK.js").then((m) => m.templates(args.slice(1)));
122
126
  break;
123
127
  case "status":
124
128
  await import("./status-E4IHEUKO.js").then((m) => m.status(args.slice(1)));
@@ -2,6 +2,10 @@ import {
2
2
  installAllSkills,
3
3
  syncClaudeMd
4
4
  } from "./chunk-UP3GV4HN.js";
5
+ import {
6
+ listAllTemplates,
7
+ resolveTemplate
8
+ } from "./chunk-WTDV3MTG.js";
5
9
  import "./chunk-D2BLSEGR.js";
6
10
 
7
11
  // src/commands/init.ts
@@ -9,51 +13,35 @@ import pc from "picocolors";
9
13
  import prompts from "prompts";
10
14
  import { execSync } from "child_process";
11
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from "fs";
12
- import { join, dirname, resolve } from "path";
13
- import { fileURLToPath } from "url";
14
- var __filename = fileURLToPath(import.meta.url);
15
- var __dirname = dirname(__filename);
16
- function getTemplatesDir() {
17
- const candidates = [
18
- // From dist/index.js or dist/commands/init.js
19
- resolve(__dirname, "../templates/default"),
20
- resolve(__dirname, "../../templates/default"),
21
- // From src during development
22
- resolve(__dirname, "../../../templates/default")
23
- ];
24
- for (const candidate of candidates) {
25
- if (existsSync(candidate)) {
26
- return candidate;
27
- }
28
- }
29
- throw new Error(`Templates directory not found. Searched: ${candidates.join(", ")}`);
30
- }
16
+ import { join, resolve } from "path";
31
17
  function toTitleCase(str) {
32
18
  return str.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
33
19
  }
20
+ var TEMPLATE_DEFAULTS = {
21
+ projectName: "my-lumera-app",
22
+ projectTitle: "My Lumera App"
23
+ };
34
24
  function processTemplate(content, vars) {
35
25
  let result = content;
36
26
  for (const [key, value] of Object.entries(vars)) {
37
- result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
27
+ const defaultValue = TEMPLATE_DEFAULTS[key];
28
+ if (defaultValue && defaultValue !== value) {
29
+ result = result.replaceAll(defaultValue, value);
30
+ }
38
31
  }
39
32
  return result;
40
33
  }
41
- function copyDir(src, dest, vars) {
34
+ var TEMPLATE_EXCLUDE = /* @__PURE__ */ new Set(["template.json"]);
35
+ function copyDir(src, dest, vars, isRoot = true) {
42
36
  if (!existsSync(dest)) {
43
37
  mkdirSync(dest, { recursive: true });
44
38
  }
45
39
  for (const entry of readdirSync(src, { withFileTypes: true })) {
40
+ if (isRoot && TEMPLATE_EXCLUDE.has(entry.name)) continue;
46
41
  const srcPath = join(src, entry.name);
47
- let destName = entry.name;
48
- if (destName.endsWith(".hbs")) {
49
- destName = destName.slice(0, -4);
50
- }
51
- if (destName === "gitignore") {
52
- destName = ".gitignore";
53
- }
54
- const destPath = join(dest, destName);
42
+ const destPath = join(dest, entry.name);
55
43
  if (entry.isDirectory()) {
56
- copyDir(srcPath, destPath, vars);
44
+ copyDir(srcPath, destPath, vars, false);
57
45
  } else {
58
46
  const content = readFileSync(srcPath, "utf-8");
59
47
  const processed = processTemplate(content, vars);
@@ -122,6 +110,7 @@ function parseArgs(args) {
122
110
  const result = {
123
111
  projectName: void 0,
124
112
  directory: void 0,
113
+ template: void 0,
125
114
  install: false,
126
115
  yes: false,
127
116
  force: false,
@@ -139,6 +128,8 @@ function parseArgs(args) {
139
128
  result.force = true;
140
129
  } else if (arg === "--dir" || arg === "-d") {
141
130
  result.directory = args[++i];
131
+ } else if (arg === "--template" || arg === "-t") {
132
+ result.template = args[++i];
142
133
  } else if (!arg.startsWith("-") && !result.projectName) {
143
134
  result.projectName = arg;
144
135
  }
@@ -151,27 +142,21 @@ ${pc.dim("Usage:")}
151
142
  lumera init [name] [options]
152
143
 
153
144
  ${pc.dim("Description:")}
154
- Scaffold a new Lumera project.
145
+ Scaffold a new Lumera project from a template.
155
146
 
156
147
  ${pc.dim("Options:")}
157
- --yes, -y Non-interactive mode (requires project name)
158
- --dir, -d <path> Target directory (defaults to project name)
159
- --force, -f Overwrite existing directory without prompting
160
- --install, -i Install dependencies after scaffolding
161
- --help, -h Show this help
162
-
163
- ${pc.dim("Interactive mode:")}
164
- lumera init # Prompt for project name and directory
165
- lumera init my-project # Prompt for directory only
166
-
167
- ${pc.dim("Non-interactive mode:")}
168
- lumera init my-project -y # Create ./my-project
169
- lumera init my-project -y -d ./apps # Create ./apps
170
- lumera init my-project -y -f # Overwrite if exists
171
- lumera init my-project -y -i # Create and install deps
148
+ --template, -t <name> Template to use (run ${pc.cyan("lumera templates")} to see options)
149
+ --yes, -y Non-interactive mode (requires project name)
150
+ --dir, -d <path> Target directory (defaults to project name)
151
+ --force, -f Overwrite existing directory without prompting
152
+ --install, -i Install dependencies after scaffolding
153
+ --help, -h Show this help
172
154
 
173
- ${pc.dim("CI/CD example:")}
174
- lumera init my-app -y -f -i # Full non-interactive setup
155
+ ${pc.dim("Examples:")}
156
+ lumera init my-app # Interactive mode
157
+ lumera init my-app -t invoice-processing # Use a specific template
158
+ lumera init my-app -y # Non-interactive (default template)
159
+ lumera init my-app -t invoice-processing -y -i # Full non-interactive setup
175
160
  `);
176
161
  }
177
162
  async function init(args) {
@@ -191,6 +176,35 @@ async function init(args) {
191
176
  console.log(pc.dim(" Usage: lumera init <name> -y"));
192
177
  process.exit(1);
193
178
  }
179
+ let templateName = opts.template;
180
+ if (!templateName && !nonInteractive) {
181
+ try {
182
+ const available = await listAllTemplates();
183
+ if (available.length > 1) {
184
+ const response = await prompts({
185
+ type: "select",
186
+ name: "template",
187
+ message: "Choose a template",
188
+ choices: available.map((t) => ({
189
+ title: `${t.title} ${pc.dim(`(${t.name})`)}`,
190
+ description: t.description,
191
+ value: t.name
192
+ })),
193
+ initial: 0
194
+ });
195
+ if (!response.template) {
196
+ console.log(pc.red("Cancelled"));
197
+ process.exit(1);
198
+ }
199
+ templateName = response.template;
200
+ }
201
+ } catch {
202
+ }
203
+ }
204
+ if (!templateName) {
205
+ templateName = "default";
206
+ }
207
+ const templateDir = await resolveTemplate(templateName);
194
208
  if (!projectName) {
195
209
  const response = await prompts({
196
210
  type: "text",
@@ -233,7 +247,6 @@ async function init(args) {
233
247
  }
234
248
  }
235
249
  const projectTitle = toTitleCase(projectName);
236
- const projectInitial = projectTitle.charAt(0).toUpperCase();
237
250
  const targetDir = resolve(process.cwd(), directory);
238
251
  if (existsSync(targetDir)) {
239
252
  if (nonInteractive) {
@@ -260,15 +273,17 @@ async function init(args) {
260
273
  }
261
274
  mkdirSync(targetDir, { recursive: true });
262
275
  console.log();
263
- console.log(pc.dim(` Creating ${projectName} in ${directory}...`));
276
+ if (templateName !== "default") {
277
+ console.log(pc.dim(` Creating ${projectName} from template ${pc.cyan(templateName)}...`));
278
+ } else {
279
+ console.log(pc.dim(` Creating ${projectName}...`));
280
+ }
264
281
  console.log();
265
- const templatesDir = getTemplatesDir();
266
282
  const vars = {
267
283
  projectName,
268
- projectTitle,
269
- projectInitial
284
+ projectTitle
270
285
  };
271
- copyDir(templatesDir, targetDir, vars);
286
+ copyDir(templateDir, targetDir, vars);
272
287
  function listFiles(dir, prefix = "") {
273
288
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
274
289
  const relativePath = prefix + entry.name;
@@ -352,7 +367,9 @@ async function init(args) {
352
367
  console.log(pc.cyan(" pnpm install"));
353
368
  }
354
369
  console.log(pc.cyan(" lumera login"));
355
- console.log(pc.cyan(" pnpm dev"));
370
+ console.log(pc.cyan(" lumera apply"));
371
+ console.log(pc.cyan(" lumera run scripts/seed-demo.py"));
372
+ console.log(pc.cyan(" lumera dev"));
356
373
  console.log();
357
374
  }
358
375
  export {
@@ -0,0 +1,70 @@
1
+ import {
2
+ listAllTemplates
3
+ } from "./chunk-WTDV3MTG.js";
4
+
5
+ // src/commands/templates.ts
6
+ import pc from "picocolors";
7
+ function showHelp() {
8
+ console.log(`
9
+ ${pc.dim("Usage:")}
10
+ lumera templates [options]
11
+
12
+ ${pc.dim("Description:")}
13
+ List available project templates.
14
+
15
+ ${pc.dim("Options:")}
16
+ --verbose, -v Show full descriptions
17
+ --help, -h Show this help
18
+
19
+ ${pc.dim("Examples:")}
20
+ lumera templates # List available templates
21
+ lumera templates -v # Show with descriptions
22
+ lumera init my-app -t invoice-processing # Use a template
23
+ `);
24
+ }
25
+ async function templates(args) {
26
+ if (args.includes("--help") || args.includes("-h")) {
27
+ showHelp();
28
+ return;
29
+ }
30
+ const verbose = args.includes("--verbose") || args.includes("-v");
31
+ console.log();
32
+ console.log(pc.cyan(pc.bold(" Available Templates")));
33
+ console.log();
34
+ try {
35
+ const allTemplates = await listAllTemplates();
36
+ if (allTemplates.length === 0) {
37
+ console.log(pc.dim(" No templates available."));
38
+ console.log();
39
+ return;
40
+ }
41
+ const byCategory = /* @__PURE__ */ new Map();
42
+ for (const t of allTemplates) {
43
+ const cat = t.category || "General";
44
+ if (!byCategory.has(cat)) byCategory.set(cat, []);
45
+ byCategory.get(cat).push(t);
46
+ }
47
+ for (const [category, items] of byCategory) {
48
+ console.log(pc.bold(` ${category}`));
49
+ console.log();
50
+ for (const t of items) {
51
+ console.log(` ${pc.green(t.title)} ${pc.dim(`(${t.name})`)}`);
52
+ if (verbose) {
53
+ console.log(` ${pc.dim(t.description)}`);
54
+ console.log();
55
+ }
56
+ }
57
+ if (!verbose) console.log();
58
+ }
59
+ console.log(pc.dim(` ${allTemplates.length} template${allTemplates.length === 1 ? "" : "s"} available.`));
60
+ console.log();
61
+ console.log(pc.dim(" Usage:"), pc.cyan("lumera init <name> --template <template-name>"));
62
+ console.log();
63
+ } catch (err) {
64
+ console.error(pc.red(` Error: ${err}`));
65
+ process.exit(1);
66
+ }
67
+ }
68
+ export {
69
+ templates
70
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumerahq/cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "CLI for building and deploying Lumera apps",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,80 +0,0 @@
1
- # {{projectTitle}} — Architecture
2
-
3
- ## Overview
4
-
5
- {{projectTitle}} is a Lumera embedded app — a React frontend served inside the Lumera platform iframe, backed by collections, hooks, and automations managed through the Lumera CLI.
6
-
7
- ## System Diagram
8
-
9
- ```
10
- ┌─────────────────────────────────────────────────┐
11
- │ Lumera Platform │
12
- │ │
13
- │ ┌───────────┐ postMessage ┌────────────┐ │
14
- │ │ Host UI │ ◄──────────────► │ App │ │
15
- │ │ │ (auth, init) │ (iframe) │ │
16
- │ └─────┬─────┘ └─────┬──────┘ │
17
- │ │ │ │
18
- │ │ REST API │ │
19
- │ ▼ ▼ │
20
- │ ┌──────────────────────────────────────────┐ │
21
- │ │ Lumera API │ │
22
- │ │ - Collections (CRUD, SQL, search) │ │
23
- │ │ - Automations (run, poll, cancel) │ │
24
- │ │ - File storage (upload, download) │ │
25
- │ └──────────────┬───────────────────────────┘ │
26
- │ │ │
27
- │ ▼ │
28
- │ ┌──────────────────────────────────────────┐ │
29
- │ │ Tenant Database (PocketBase/SQLite) │ │
30
- │ │ - example_items │ │
31
- │ │ - (add your collections here) │ │
32
- │ └──────────────────────────────────────────┘ │
33
- └─────────────────────────────────────────────────┘
34
- ```
35
-
36
- ## Frontend (`src/`)
37
-
38
- React app using TanStack Router (file-based routing) and TanStack Query for data fetching. Embedded in Lumera via iframe with postMessage bridge for authentication.
39
-
40
- | Directory | Purpose |
41
- |------------------|--------------------------------------|
42
- | `src/routes/` | Pages — file names map to URL paths |
43
- | `src/components/`| Shared React components |
44
- | `src/lib/` | API helpers, query functions |
45
- | `src/main.tsx` | App entry — auth bridge, router init |
46
-
47
- **Key patterns:**
48
- - Auth context flows from `main.tsx` via `AuthContext`
49
- - Data fetching uses `pbList`, `pbSql` from `@lumerahq/ui/lib`
50
- - Styling via Tailwind CSS 4 with theme tokens in `styles.css`
51
-
52
- ## Platform Resources (`platform/`)
53
-
54
- Declarative definitions deployed via `lumera apply`.
55
-
56
- | Directory | Purpose |
57
- |--------------------------|----------------------------------|
58
- | `platform/collections/` | Collection schemas (JSON) |
59
- | `platform/automations/` | Background Python scripts |
60
- | `platform/hooks/` | Server-side JS on collection events |
61
-
62
- ## Scripts (`scripts/`)
63
-
64
- Local Python scripts run via `lumera run`. Used for seeding data, migrations, and ad-hoc operations. All scripts should be idempotent.
65
-
66
- ## Data Flow
67
-
68
- 1. **User opens app** → Lumera host sends auth payload via postMessage
69
- 2. **App authenticates** → Stores session token in `AuthContext`
70
- 3. **App fetches data** → Calls Lumera API via `@lumerahq/ui/lib` helpers
71
- 4. **Data mutations** → API calls trigger collection hooks if configured
72
- 5. **Background work** → Automations run async via `createRun` / `pollRun`
73
-
74
- ## Collections
75
-
76
- | Collection | Purpose |
77
- |------------------|----------------------------|
78
- | `example_items` | Starter collection (replace with your own) |
79
-
80
- _Update this table as you add collections._