@betterstart/cli 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,48 +1,61 @@
1
1
  # @betterstart/cli
2
2
 
3
- Scaffold a full-featured, code-generated CMS into any Next.js 16 application. The CMS lives in a self-contained `cms/` directory — you own the code, eject anytime.
3
+ > **Warning:** This project is a work in progress. Expect bugs, breaking changes, and incomplete features.
4
4
 
5
- ## Quick Start
5
+ Drop a production-ready CMS into any Next.js 16 app with one command. You get a full admin panel — auth, CRUD, rich text editing, file uploads, forms — living in a self-contained `cms/` directory. You own the code. No vendor lock-in.
6
6
 
7
7
  ```bash
8
- npx @betterstart/cli init my-app --preset blog
8
+ npx @betterstart/cli init
9
9
  ```
10
10
 
11
- This will:
11
+ ## What you get
12
12
 
13
- 1. Create a new Next.js app (or detect your existing project)
14
- 2. Scaffold the CMS into `cms/` with auth, database, admin UI, and API routes
15
- 3. Install all dependencies
16
- 4. Generate blog schemas (posts + categories) with full CRUD
13
+ - **Admin panel** with sidebar navigation, search, filters, bulk actions, and CSV export
14
+ - **Authentication** via Better Auth (email/password, role-based: admin, editor, member)
15
+ - **22 field types** including rich text (TipTap), markdown (CodeMirror + Shiki), image/video uploads, relationships, tabs, groups, lists, and curriculum builder
16
+ - **Public forms** with multi-step support, email notifications, Mailchimp integration, and a submissions admin
17
+ - **Three schema types** — entities (full CRUD), singles (settings pages), and forms (public-facing with submission tracking)
18
+ - **Caching** with Next.js 16 `'use cache'` directive, `cacheLife`, `cacheTag`, and `updateTag`
17
19
 
18
20
  ## Commands
19
21
 
20
22
  ### `betterstart init [name]`
21
23
 
22
- Scaffold CMS into a new or existing Next.js project.
24
+ Scaffold the CMS into a new or existing Next.js project.
23
25
 
24
26
  ```bash
25
- betterstart init # Existing project
26
- betterstart init my-app # Fresh project
27
- betterstart init --preset blank # No starter content
28
- betterstart init --preset blog # Blog (default)
29
- betterstart init --preset full # Blog + navigation + contact form
27
+ betterstart init # Interactive setup in current project
28
+ betterstart init my-app # Create new Next.js app + CMS
29
+ betterstart init --preset blog -y # Non-interactive with blog preset
30
30
  ```
31
31
 
32
+ **Presets:**
33
+
34
+ | Preset | What it includes |
35
+ |--------|-----------------|
36
+ | `blank` | Settings singleton only |
37
+ | `blog` | Settings + Categories + Posts (with many-to-many) |
38
+ | `full` | Blog + Navigation + Contact form |
39
+
40
+ Init runs 12 scaffolders in sequence: base directories, tsconfig aliases, Tailwind tokens, env vars, database, auth, UI components (206 files), layouts, API routes, linter config, dependency installation (~130 packages), and preset generation.
41
+
32
42
  ### `betterstart generate <schema>`
33
43
 
34
- Generate entity or form CRUD from a JSON schema in `cms/schemas/`.
44
+ Generate everything from a JSON schema file in `cms/schemas/`.
35
45
 
36
46
  ```bash
37
- betterstart generate posts # Entity CRUD
38
- betterstart generate contact # Form (auto-detected)
39
- betterstart generate posts --force # Overwrite existing files
40
- betterstart generate posts --skip-migration # Skip db:push
47
+ betterstart generate posts # Entity CRUD (12 files)
48
+ betterstart generate settings # Single-type page (7 files)
49
+ betterstart generate contact # Form + admin (14 files)
50
+ betterstart generate posts --force # Overwrite existing files
51
+ betterstart generate posts --skip-migration # Skip drizzle-kit push
41
52
  ```
42
53
 
54
+ Schema type is auto-detected: files in `schemas/forms/` or with `submitButtonText`/`steps` are forms, `type: "single"` is a single, everything else is an entity.
55
+
43
56
  ### `betterstart remove <schema>`
44
57
 
45
- Remove all generated files for an entity or form.
58
+ Remove all generated files for a schema and clean up `schema.ts` + `navigation.ts`.
46
59
 
47
60
  ```bash
48
61
  betterstart remove posts
@@ -50,83 +63,197 @@ betterstart remove posts
50
63
 
51
64
  ### `betterstart seed`
52
65
 
53
- Create an initial admin user.
66
+ Create an initial admin user interactively.
54
67
 
55
68
  ```bash
56
69
  betterstart seed
57
70
  ```
58
71
 
59
- ## What Gets Generated
72
+ ### `betterstart setup-r2`
73
+
74
+ Configure Cloudflare R2 for file uploads.
75
+
76
+ ### `betterstart update-deps`
60
77
 
61
- For each entity schema, the CLI generates 12 files:
78
+ Install or update all CMS dependencies to their latest versions.
62
79
 
63
- | # | File | Description |
64
- |---|------|-------------|
65
- | 1 | `cms/db/schema.ts` | Drizzle table definition (appended) |
66
- | 2 | `cms/lib/actions/<name>.ts` | Server actions (CRUD) |
67
- | 3 | `cms/hooks/use-<name>.ts` | React Query hook |
68
- | 4 | `cms/lib/cache/<name>.ts` | Cache tags + queries |
69
- | 5 | `app/(cms)/cms/(authenticated)/<name>/columns.tsx` | Table column definitions |
70
- | 6 | `app/(cms)/cms/(authenticated)/<name>/<name>-table.tsx` | Data table component |
71
- | 7 | `app/(cms)/cms/(authenticated)/<name>/<name>-page-content.tsx` | Page content (client) |
72
- | 8 | `app/(cms)/cms/(authenticated)/<name>/page.tsx` | Page (server) |
73
- | 9 | `app/(cms)/cms/(authenticated)/<name>/<name>-form.tsx` | Create/edit form |
74
- | 10 | `app/(cms)/cms/(authenticated)/<name>/new/page.tsx` | Create page |
75
- | 11 | `app/(cms)/cms/(authenticated)/<name>/[id]/edit/page.tsx` | Edit page |
76
- | 12 | `cms/data/navigation.ts` | Sidebar nav (appended) |
80
+ ### `betterstart update-styles`
77
81
 
78
- ## Schema Format
82
+ Replace `cms-globals.css` with the latest version from the CLI templates.
79
83
 
80
- ### Entity Schema
84
+ ## Generated file structure
85
+
86
+ ```
87
+ cms/
88
+ db/schema.ts # Drizzle table definitions
89
+ lib/actions/<name>.ts # Server actions (CRUD, search, filter, export)
90
+ lib/cache/ # Cache tags, cached queries, revalidation
91
+ hooks/use-<name>.ts # React Query hooks
92
+ components/forms/ # Public form components
93
+ lib/emails/ # React Email notification templates
94
+ data/navigation.ts # Sidebar navigation config
95
+
96
+ app/(cms)/cms/(authenticated)/
97
+ <entity>/
98
+ page.tsx # Server page with Suspense
99
+ columns.tsx # TanStack Table column definitions
100
+ <name>-table.tsx # Data table with pagination + sorting
101
+ page-content.tsx # Client component (search, filters, bulk actions)
102
+ <name>-form.tsx # Create/edit form with Zod validation
103
+ new/page.tsx # Create page
104
+ [id]/edit/page.tsx # Edit page
105
+ forms/<form>/
106
+ page.tsx # Submissions list
107
+ [id]/page.tsx # View submission
108
+ settings/page.tsx # Notification + export settings
109
+ ```
110
+
111
+ ## Schema examples
112
+
113
+ ### Entity
81
114
 
82
115
  ```json
83
116
  {
84
117
  "name": "posts",
85
- "label": "Posts",
118
+ "label": "Blog Posts",
86
119
  "icon": "FileText",
120
+ "navGroup": { "label": "Blog", "icon": "BookOpen" },
87
121
  "fields": [
88
122
  { "name": "title", "type": "string", "label": "Title", "required": true },
89
- { "name": "slug", "type": "string", "label": "Slug", "required": true },
90
- { "name": "content", "type": "richtext", "label": "Content" },
123
+ { "name": "slug", "type": "string", "label": "Slug", "hidden": true },
124
+ { "name": "content", "type": "richtext", "label": "Content", "output": "html" },
125
+ { "name": "image", "type": "image", "label": "Featured Image" },
91
126
  { "name": "published", "type": "boolean", "label": "Published" },
92
- { "name": "publishedAt", "type": "date", "label": "Publish Date" },
93
- { "name": "image", "type": "image", "label": "Featured Image" }
94
- ]
127
+ {
128
+ "name": "categories",
129
+ "type": "relationship",
130
+ "relationship": "categories",
131
+ "label": "Categories",
132
+ "multiple": true
133
+ }
134
+ ],
135
+ "columns": [
136
+ { "accessorKey": "title", "header": "Title", "type": "text", "sortable": true },
137
+ { "accessorKey": "published", "header": "Status", "type": "badge" }
138
+ ],
139
+ "actions": { "create": true, "edit": true, "delete": true, "draft": true },
140
+ "search": { "fields": ["title"] },
141
+ "autoSlugify": { "enabled": true, "sourceField": "title", "targetField": "slug" }
95
142
  }
96
143
  ```
97
144
 
98
- Field types: `string`, `text`, `richtext`, `number`, `boolean`, `date`, `image`, `select`, `relationship`.
99
-
100
- ### Form Schema
145
+ ### Single
101
146
 
102
147
  ```json
103
148
  {
104
- "name": "contact",
105
- "label": "Contact Form",
106
- "type": "form",
107
- "submitButtonText": "Send Message",
149
+ "name": "settings",
150
+ "type": "single",
151
+ "label": "Settings",
152
+ "icon": "Settings",
108
153
  "fields": [
109
- { "name": "name", "type": "text", "label": "Name", "required": true },
110
- { "name": "email", "type": "email", "label": "Email", "required": true },
111
- { "name": "message", "type": "textarea", "label": "Message", "required": true }
154
+ { "name": "siteName", "type": "string", "label": "Site Name" },
155
+ { "name": "description", "type": "text", "label": "Description" }
112
156
  ]
113
157
  }
114
158
  ```
115
159
 
116
- ## Tech Stack
160
+ ### Form (multi-step)
117
161
 
118
- - **Framework:** Next.js 16 (App Router)
119
- - **Database:** Drizzle ORM + Neon PostgreSQL
120
- - **Auth:** Better Auth (email/password, roles)
121
- - **UI:** Radix UI + Tailwind CSS v4
122
- - **Rich Text:** TipTap
123
- - **Storage:** Cloudflare R2
124
- - **Email:** Resend + React Email (opt-in)
162
+ ```json
163
+ {
164
+ "name": "candidateApplication",
165
+ "label": "Candidate Applications",
166
+ "icon": "UserPlus",
167
+ "submitButtonText": "Submit Application",
168
+ "successMessage": "Thanks for applying!",
169
+ "notificationEmail": "hr@example.com",
170
+ "steps": [
171
+ {
172
+ "name": "basicInfo",
173
+ "label": "Basic Information",
174
+ "fields": [
175
+ { "name": "name", "type": "text", "label": "Full Name", "required": true },
176
+ { "name": "email", "type": "email", "label": "Email", "required": true }
177
+ ]
178
+ },
179
+ {
180
+ "name": "experience",
181
+ "label": "Experience",
182
+ "fields": [
183
+ { "name": "resume", "type": "upload", "label": "Resume" }
184
+ ]
185
+ }
186
+ ]
187
+ }
188
+ ```
189
+
190
+ ## Field types
191
+
192
+ ### Entity/Single (22 types)
193
+
194
+ | Category | Types |
195
+ |----------|-------|
196
+ | Text | `string`, `text`, `varchar` |
197
+ | Rich content | `richtext`, `markdown` |
198
+ | Numbers | `number`, `decimal`, `serial` |
199
+ | Date/time | `date`, `timestamp`, `time` |
200
+ | Boolean | `boolean` |
201
+ | Media | `image`, `video`, `media`, `icon` |
202
+ | Selection | `select`, `relationship` |
203
+ | Layout | `group`, `tabs`, `separator` |
204
+ | Complex | `list`, `curriculum` |
205
+
206
+ ### Form (17 types)
207
+
208
+ `text`, `textarea`, `email`, `phone`, `number`, `url`, `date`, `select`, `radio`, `checkbox`, `multiselect`, `file`, `upload`, `group`, `list`, `timezone`, `dynamicFields`
209
+
210
+ ## Tech stack
211
+
212
+ | Concern | Technology |
213
+ |---------|-----------|
214
+ | Framework | Next.js 16 (App Router) |
215
+ | Database | Drizzle ORM + Neon PostgreSQL |
216
+ | Auth | Better Auth |
217
+ | UI | Radix primitives + Tailwind CSS v4 |
218
+ | Rich text | TipTap |
219
+ | Markdown | CodeMirror + Shiki + KaTeX |
220
+ | Tables | TanStack React Table |
221
+ | Data fetching | TanStack React Query |
222
+ | Forms | React Hook Form + Zod |
223
+ | URL state | nuqs |
224
+ | Storage | Cloudflare R2 |
225
+ | Email | Resend + React Email (opt-in) |
226
+ | Icons | Lucide React |
227
+
228
+ ## Configuration
229
+
230
+ Create `cms.config.ts` in your project root (generated by `init`):
231
+
232
+ ```typescript
233
+ import { defineConfig } from '@betterstart/cli'
234
+
235
+ export default defineConfig({
236
+ database: {
237
+ provider: 'neon',
238
+ },
239
+ features: {
240
+ email: true,
241
+ },
242
+ })
243
+ ```
244
+
245
+ All generated code uses `@cms/*` path aliases:
246
+
247
+ ```typescript
248
+ import { db } from '@cms/db'
249
+ import { posts } from '@cms/db/schema'
250
+ import { getPosts } from '@cms/actions/posts'
251
+ ```
125
252
 
126
253
  ## Requirements
127
254
 
128
255
  - Node.js >= 22
129
- - Next.js 16 with TypeScript and Tailwind CSS
256
+ - Next.js 16 with TypeScript and Tailwind CSS v4
130
257
 
131
258
  ## License
132
259
 
package/dist/cli.js CHANGED
@@ -2359,6 +2359,8 @@ function generateFieldJSX(field) {
2359
2359
  const hint = field.hint || "";
2360
2360
  const hintJSX = hint ? `
2361
2361
  <FormDescription>${escapeJsx(hint)}</FormDescription>` : "";
2362
+ const hintPlainJSX = hint ? `
2363
+ <p className="text-sm text-muted-foreground">${escapeJsx(hint)}</p>` : "";
2362
2364
  const requiredStar = field.required ? ' <span className="text-destructive">*</span>' : "";
2363
2365
  switch (field.type) {
2364
2366
  case "textarea":
@@ -2459,7 +2461,7 @@ ${optionItems}
2459
2461
  case "upload":
2460
2462
  return generateTextFieldJSX(name, label, placeholder, hintJSX, requiredStar, "file");
2461
2463
  case "list":
2462
- return generateListFieldJSX(field, name, label, hintJSX, requiredStar);
2464
+ return generateListFieldJSX(field, name, label, hintPlainJSX, requiredStar);
2463
2465
  default:
2464
2466
  return generateTextFieldJSX(name, label, placeholder, hintJSX, requiredStar, "text");
2465
2467
  }
@@ -2533,7 +2535,7 @@ ${selectItems}
2533
2535
  return `${nf.name}: ''`;
2534
2536
  }).join(", ");
2535
2537
  return ` <div className="space-y-4">
2536
- <FormLabel>${label}${requiredStar}</FormLabel>${hintJSX}
2538
+ <label className="text-sm font-medium leading-none">${label}${requiredStar}</label>${hintJSX}
2537
2539
  {${name}FieldArray.fields.map((item, index) => (
2538
2540
  <div key={item.id} className="flex items-end gap-2 rounded-lg border p-4">
2539
2541
  <div className="flex-1 space-y-4">
@@ -2558,6 +2560,38 @@ ${nestedFieldsJSX}
2558
2560
  // src/generators/form-pipeline/form-database.ts
2559
2561
  import fs8 from "fs";
2560
2562
  import path8 from "path";
2563
+ function findTableEnd(content, startIndex) {
2564
+ let depth = 0;
2565
+ let inString = false;
2566
+ let stringChar = "";
2567
+ for (let i = startIndex; i < content.length; i++) {
2568
+ const char = content[i];
2569
+ const prev = i > 0 ? content[i - 1] : "";
2570
+ if ((char === '"' || char === "'" || char === "`") && prev !== "\\") {
2571
+ if (!inString) {
2572
+ inString = true;
2573
+ stringChar = char;
2574
+ } else if (char === stringChar) {
2575
+ inString = false;
2576
+ }
2577
+ continue;
2578
+ }
2579
+ if (inString) continue;
2580
+ if (char === "(" || char === "{" || char === "[") depth++;
2581
+ if (char === ")" || char === "}" || char === "]") depth--;
2582
+ if (depth === 0 && char === ")") {
2583
+ let end = i + 1;
2584
+ while (end < content.length && (content[end] === ";" || content[end] === " " || content[end] === " ")) {
2585
+ end++;
2586
+ }
2587
+ if (end < content.length && content[end] === "\n") {
2588
+ end++;
2589
+ }
2590
+ return end;
2591
+ }
2592
+ }
2593
+ return content.length;
2594
+ }
2561
2595
  function generateFormDatabase(schema, cwd, dbSchemaPath, options) {
2562
2596
  const tableName = `${toCamelCase(schema.name)}Submissions`;
2563
2597
  const fields = getAllFormSchemaFields(schema);
@@ -2606,8 +2640,13 @@ ${match}`
2606
2640
  if (!options.force) {
2607
2641
  return { files: [dbSchemaPath] };
2608
2642
  }
2609
- const regex = new RegExp(`\\nexport const ${tableName} = pgTable\\([\\s\\S]*?\\n\\}\\)\\n`, "g");
2610
- content = content.replace(regex, "");
2643
+ const marker = `export const ${tableName} =`;
2644
+ const start = content.indexOf(marker);
2645
+ if (start !== -1) {
2646
+ const end = findTableEnd(content, start);
2647
+ const actualStart = start > 0 && content[start - 1] === "\n" ? start - 1 : start;
2648
+ content = content.slice(0, actualStart) + content.slice(end);
2649
+ }
2611
2650
  }
2612
2651
  content += `
2613
2652
  ${tableSchema}`;
@@ -4811,7 +4850,7 @@ ${updated}`;
4811
4850
  }
4812
4851
  return updated;
4813
4852
  }
4814
- function findTableEnd(content, startIndex) {
4853
+ function findTableEnd2(content, startIndex) {
4815
4854
  let depth = 0;
4816
4855
  let inString = false;
4817
4856
  let stringChar = "";
@@ -4863,7 +4902,7 @@ function generateDatabase(schema, cwd, schemaDir, options = {}) {
4863
4902
  let updated = content;
4864
4903
  if (options.force && content.includes(`export const ${variableName} =`)) {
4865
4904
  const start = content.indexOf(`export const ${variableName} =`);
4866
- const end = findTableEnd(content, start);
4905
+ const end = findTableEnd2(content, start);
4867
4906
  updated = content.slice(0, start) + tableDef.trim() + content.slice(end);
4868
4907
  } else {
4869
4908
  updated = `${updated.trimEnd()}
@@ -4873,7 +4912,7 @@ ${tableDef}`;
4873
4912
  const jName = junctionNames[i];
4874
4913
  if (options.force && updated.includes(`export const ${jName} =`)) {
4875
4914
  const start = updated.indexOf(`export const ${jName} =`);
4876
- const end = findTableEnd(updated, start);
4915
+ const end = findTableEnd2(updated, start);
4877
4916
  updated = updated.slice(0, start) + junctionDefs[i].trim() + updated.slice(end);
4878
4917
  } else if (!updated.includes(`export const ${jName} =`)) {
4879
4918
  updated = `${updated.trimEnd()}
@@ -14247,7 +14286,7 @@ import fs39 from "fs";
14247
14286
  import path44 from "path";
14248
14287
  import readline from "readline";
14249
14288
  import { Command as Command4 } from "commander";
14250
- function findTableEnd2(content, startIndex) {
14289
+ function findTableEnd3(content, startIndex) {
14251
14290
  let depth = 0;
14252
14291
  let inString = false;
14253
14292
  let stringChar = "";
@@ -14281,7 +14320,7 @@ function removeTableFromSchema(schemaFilePath, name) {
14281
14320
  let changed = false;
14282
14321
  if (content.includes(`export const ${variableName} =`)) {
14283
14322
  const start = content.indexOf(`export const ${variableName} =`);
14284
- const end = findTableEnd2(content, start);
14323
+ const end = findTableEnd3(content, start);
14285
14324
  content = content.slice(0, start) + content.slice(end);
14286
14325
  changed = true;
14287
14326
  }
@@ -14293,7 +14332,7 @@ function removeTableFromSchema(schemaFilePath, name) {
14293
14332
  const jVarName = jMatch[1];
14294
14333
  const jStart = content.indexOf(`export const ${jVarName} =`);
14295
14334
  if (jStart !== -1) {
14296
- const jEnd = findTableEnd2(content, jStart);
14335
+ const jEnd = findTableEnd3(content, jStart);
14297
14336
  content = content.slice(0, jStart) + content.slice(jEnd);
14298
14337
  changed = true;
14299
14338
  regex.lastIndex = 0;