@clubedge/create-clubedge-app 0.43.1 → 0.44.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 (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +43 -24
  3. package/dist/cli/ui.js +2 -1
  4. package/dist/core/customize.js +34 -8
  5. package/dist/core/deploy.js +39 -12
  6. package/dist/core/manifest-fields.js +5 -1
  7. package/dist/core/manifest-packs.js +107 -14
  8. package/dist/core/manifest.js +5 -1
  9. package/dist/core/plan.js +95 -24
  10. package/dist/core/project.js +5 -2
  11. package/dist/deploy-docker.js +8 -4
  12. package/dist/deploy-preview.js +6 -1
  13. package/dist/deploy.js +95 -46
  14. package/dist/index.js +1 -1
  15. package/dist/mcp-server.js +4 -2
  16. package/dist/modify.js +1 -1
  17. package/package.json +3 -3
  18. package/skills/clubedge-starter/SKILL.md +1 -1
  19. package/template/.github/workflows/ci.yml +106 -5
  20. package/template/.github/workflows/preview.yml +13 -5
  21. package/template/.prettierignore +10 -0
  22. package/template/AGENTS.md +9 -3
  23. package/template/README.md +124 -25
  24. package/template/SETUP.md +237 -51
  25. package/template/_gitignore +13 -0
  26. package/template/apps/api/.env.example +18 -8
  27. package/template/apps/api/clubedge.stack.json +3 -1
  28. package/template/apps/api/package.json +3 -2
  29. package/template/apps/api/src/app.ts +23 -0
  30. package/template/apps/api/src/env/production.test.ts +28 -6
  31. package/template/apps/api/src/env/production.ts +17 -2
  32. package/template/apps/api/src/rate-limit.ts +1 -1
  33. package/template/apps/api/src/server/auth.ts +8 -0
  34. package/template/apps/api/src/server/index.ts +2 -0
  35. package/template/apps/api/src/server/variants/auth.better-auth.ts +60 -3
  36. package/template/apps/api-fastapi/.env.example +5 -3
  37. package/template/apps/api-fastapi/clubedge.stack.json +1 -0
  38. package/template/apps/api-fastapi/package.json +1 -1
  39. package/template/apps/api-fastapi/pyproject.toml +1 -1
  40. package/template/apps/api-fastapi/src/clubedge_api/production_env.py +2 -0
  41. package/template/apps/api-fastapi/tests/test_production_env.py +9 -2
  42. package/template/apps/api-fastapi/uv.lock +1 -1
  43. package/template/apps/capacitor/package.json +1 -1
  44. package/template/apps/expo/.env.example +24 -0
  45. package/template/apps/expo/app.config.ts +48 -0
  46. package/template/apps/expo/assets/adaptive-icon.png +0 -0
  47. package/template/apps/expo/assets/icon.png +0 -0
  48. package/template/apps/expo/assets/splash-icon.png +0 -0
  49. package/template/apps/expo/clubedge.stack.json +46 -0
  50. package/template/apps/expo/package.json +42 -0
  51. package/template/apps/expo/src/app/(app)/_layout.tsx +31 -0
  52. package/template/apps/expo/src/app/(app)/index.tsx +73 -0
  53. package/template/apps/expo/src/app/(app)/settings.tsx +168 -0
  54. package/template/apps/expo/src/app/_layout.tsx +17 -0
  55. package/template/apps/expo/src/app/forgot-password.tsx +72 -0
  56. package/template/apps/expo/src/app/reset-password.tsx +80 -0
  57. package/template/apps/expo/src/app/sign-in.tsx +67 -0
  58. package/template/apps/expo/src/app/sign-up.tsx +78 -0
  59. package/template/apps/expo/src/components/ui.tsx +206 -0
  60. package/template/apps/expo/src/components/update-gate.tsx +54 -0
  61. package/template/apps/expo/src/config/site.json +11 -0
  62. package/template/apps/expo/src/lib/api.ts +6 -0
  63. package/template/apps/expo/src/lib/auth-client.ts +72 -0
  64. package/template/apps/expo/src/lib/auth-enabled.ts +1 -0
  65. package/template/apps/expo/src/lib/auth.ts +2 -0
  66. package/template/apps/expo/src/lib/config.ts +21 -0
  67. package/template/apps/expo/src/lib/session.tsx +44 -0
  68. package/template/apps/expo/src/lib/theme.ts +39 -0
  69. package/template/apps/expo/src/lib/update.test.ts +34 -0
  70. package/template/apps/expo/src/lib/update.ts +40 -0
  71. package/template/apps/expo/src/types/expo.d.ts +3 -0
  72. package/template/apps/expo/src/variants/api.auth.ts +18 -0
  73. package/template/apps/expo/src/variants/auth-enabled.auth.ts +1 -0
  74. package/template/apps/expo/src/variants/auth.better-auth.ts +98 -0
  75. package/template/apps/expo/src/variants/auth.supabase.ts +146 -0
  76. package/template/apps/expo/src/variants/layout.auth.tsx +56 -0
  77. package/template/apps/expo/tsconfig.json +12 -0
  78. package/template/apps/start/package.json +1 -1
  79. package/template/apps/vite/auth.server.mjs +34 -10
  80. package/template/apps/vite/package.json +3 -2
  81. package/template/apps/web/package.json +1 -1
  82. package/template/clubedge.template.json +42 -3
  83. package/template/docker-compose.yml +15 -12
  84. package/template/eslint.config.mjs +9 -0
  85. package/template/package.json +7 -1
  86. package/template/packages/auth/package.json +1 -1
  87. package/template/packages/auth-appwrite/package.json +1 -1
  88. package/template/packages/auth-better-auth/package.json +2 -2
  89. package/template/packages/auth-better-auth/src/index.ts +26 -5
  90. package/template/packages/auth-firebase/package.json +1 -1
  91. package/template/packages/auth-supabase/package.json +1 -1
  92. package/template/packages/auth-supabase/src/browser.ts +3 -3
  93. package/template/packages/cache/package.json +1 -1
  94. package/template/packages/cache-redis/package.json +1 -1
  95. package/template/packages/contract/package.json +1 -1
  96. package/template/packages/core/package.json +1 -1
  97. package/template/packages/db/package.json +1 -1
  98. package/template/packages/email/package.json +1 -1
  99. package/template/packages/email-resend/package.json +1 -1
  100. package/template/packages/email-smtp/package.json +1 -1
  101. package/template/packages/jobs/package.json +1 -1
  102. package/template/packages/jobs-bullmq/package.json +1 -1
  103. package/template/packages/jobs-inngest/package.json +1 -1
  104. package/template/packages/jobs-pg-boss/package.json +1 -1
  105. package/template/packages/local-db/package.json +1 -1
  106. package/template/packages/mobile/package.json +1 -1
  107. package/template/packages/observability/package.json +1 -1
  108. package/template/packages/observability-otel/package.json +1 -1
  109. package/template/packages/observability-sentry/package.json +1 -1
  110. package/template/packages/payments/package.json +1 -1
  111. package/template/packages/payments-stripe/package.json +1 -1
  112. package/template/packages/storage/package.json +1 -1
  113. package/template/packages/storage-s3/package.json +1 -1
  114. package/template/packages/storage-supabase/package.json +1 -1
  115. package/template/packages/ui/package.json +1 -1
  116. package/template/pnpm-lock.yaml +5919 -485
  117. package/template.lock.json +36 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.44.0
6
+
7
+ - **The native app with Expo**: default to Starter `v0.28.0` (#151). `--mobile expo` adds `apps/mobile`, an Android and iOS app with its own native screens, which calls the project's API (#147):
8
+ - Template schema 6 adds the `mobile` role: an app with its own screens that calls an API. Like a shell, it runs no server, so it has no Dockerfile or deployment, and `deploy` leaves it out. Older CLIs refuse a schema 6 Starter and ask for an update.
9
+ - It needs an API, `--api hono` or `--api fastapi`: with React + Vite, or without a website. Next.js and TanStack Start have no separate API for it to call, so they refuse `--mobile expo`. It can sit next to the Capacitor app (`--shell capacitor`).
10
+ - A mobile stack lists the module options it implements, and the others are refused with it: Expo signs in with Better Auth, Supabase, or nothing.
11
+ - Interactive runs ask about it after the framework and the API.
12
+ - The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.28.0) cover the app's screens, sign-in, update notice, and builds.
13
+ - **Projects without a website** (#148): `--framework none --api hono` (or `fastapi`), with or without `--mobile expo`, creates an API, and the native app that calls it directly:
14
+ - Schema 6 slots can be optional. The project records `framework: none`, so `add`, `remove`, and `upgrade` keep it without a web app.
15
+ - A slot names the files and root scripts only its apps use, left out when it is empty, such as the browser tests, and the slot whose package the root scripts use instead, so `pnpm dev` starts the API.
16
+ - Templates list the selections they refuse, with the reason (`rules`). Prompts and `add` offer only what the rules allow, and a refused option fails with the reason. Without a website, the Starter refuses payments, Inngest, FastAPI with Better Auth, and deploy targets other than Docker and Railway.
17
+ - `deploy` publishes the API itself: as the public app behind Caddy with Docker Compose, or as one public service on Railway, with Better Auth's URL and secret. Vercel, Netlify, Fly.io, and Render deploy a web app, so they refuse a project without one, and `deploy --preview` refuses it with the Railway commands for a preview by hand.
18
+ - **Audit exceptions per stack** (#150): a stack lists the advisories `pnpm audit` skips because of its dependencies (`auditIgnores`), and projects without the stack audit them again. The Starter's Expo stack skips two in Expo's development CLI that have no usable fix.
19
+ - The showcase waits for the billing portal's subscription before recording it, so its video no longer ends on a spinner (#146).
20
+
5
21
  ## 0.43.1
6
22
 
7
23
  - **`try` shows Railway's reason when Railway refuses a VM** (#144):
package/README.md CHANGED
@@ -102,7 +102,7 @@ pnpm dev
102
102
 
103
103
  Or start it with npm: `npx @clubedge/create-clubedge-app my-app`. The generated project is a pnpm workspace, so dependencies are installed with pnpm either way; the CLI falls back to `corepack pnpm` when `pnpm` is not on your path. Without a destination, the CLI asks where to create the project.
104
104
 
105
- The generated project includes `apps/web/.env.local` (and `apps/api/.env.local` with a separate API, `apps/shell/.env.local` with a mobile app), created from the example and pointing at the local services you chose. Its `SETUP.md` describes every variable and provider. The root route is a landing page named after your project, and the dashboard is at `/dashboard`.
105
+ The generated project includes `apps/web/.env.local` (and `apps/api/.env.local` with a separate API, `apps/shell/.env.local` with a mobile shell, `apps/mobile/.env.local` with a native app), created from the example and pointing at the local services you chose. Its `SETUP.md` describes every variable and provider. The root route is a landing page named after your project, and the dashboard is at `/dashboard`.
106
106
 
107
107
  The default Starter release ships inside the CLI package, so GitHub is only contacted when you pass `--ref`. Internet access is needed to install dependencies.
108
108
 
@@ -145,18 +145,35 @@ With the Hono API, the decoupled stacks support every module option the full-sta
145
145
 
146
146
  ### A mobile app
147
147
 
148
- Since CLI 0.42.0, with Starter v0.27.0, the Vite frontend can also be packaged as an Android and iOS app with [Capacitor](https://capacitorjs.com). Earlier Starters have no mobile shell, and the CLI refuses `--shell` with them.
148
+ A project can have a mobile app in two ways, and both at once:
149
+
150
+ - **A shell** packages the Vite frontend as an Android and iOS app with [Capacitor](https://capacitorjs.com), so the app shows the same pages as the website (since CLI 0.42.0, with Starter v0.27.0).
151
+ - **A native app** has its own screens, built with [Expo](https://expo.dev) and React Native, and calls the same API (since CLI 0.44.0, with Starter v0.28.0).
152
+
153
+ Earlier Starters have neither, and the CLI refuses `--shell` and `--mobile` with them.
149
154
 
150
155
  ```sh
151
156
  pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono --shell capacitor
157
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono --mobile expo
152
158
  ```
153
159
 
154
- | Flag | App | Where |
155
- | ------------------- | --------------------------------------------------------- | ------------ |
156
- | `--shell capacitor` | The Vite app, packaged for Android and iOS with Capacitor | `apps/shell` |
157
- | `--shell none` | No mobile app (the default) | |
160
+ | Flag | App | Where |
161
+ | ------------------------------- | ------------------------------------------------------------ | ------------- |
162
+ | `--shell capacitor` | The Vite app, packaged for Android and iOS with Capacitor | `apps/shell` |
163
+ | `--mobile expo` | A native Android and iOS app with Expo, calling the same API | `apps/mobile` |
164
+ | `--shell none`, `--mobile none` | No mobile app (the default) | |
165
+
166
+ A shell packages the frontend's static files, so it needs `--framework vite`, and the frontend still needs its API (`--api hono` or `--api fastapi`). A native app calls the API through the website, as the shell does, so it needs one too. Next.js and TanStack Start render their pages on their own server and have no separate API for an app to call: the CLI refuses `--shell` and `--mobile` with them, and interactive runs only ask about a mobile app after a frontend and its API. A shell and a native app run no server, so `deploy` deploys the frontend and the API as above and leaves them out. The native app's Starter lists the sign-in options it supports, so the CLI refuses an `--auth` option the app does not have yet.
167
+
168
+ #### Without a website
169
+
170
+ A project can also be only an API and its native app: `--framework none` leaves the web app out (since CLI 0.44.0, with Starter v0.28.0).
171
+
172
+ ```sh
173
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework none --api hono --mobile expo
174
+ ```
158
175
 
159
- A shell packages the frontend's static files, so it needs `--framework vite`, and the frontend still needs its API (`--api hono` or `--api fastapi`). Next.js and TanStack Start render their pages on their own server, so they cannot be packaged: the CLI refuses `--shell` with them, and interactive runs only ask about a mobile app after a frontend and its API. The shell runs no server, so `deploy` deploys the frontend and the API as above and leaves it out.
176
+ The app then calls the API at the API's own address. A project needs a web app or an API, so `--framework none` needs `--api`. The project records `framework: none`, so `add`, `remove`, and `upgrade` keep it without a web app, and the commands that manage env files leave `apps/web/.env.local` out. `deploy` publishes the API itself on Railway or with Docker Compose (see their sections below); Fly.io, Vercel, Netlify, and Render deploy a web app, so it refuses them for a project without one.
160
177
 
161
178
  ## Choosing modules
162
179
 
@@ -194,9 +211,10 @@ Usage:
194
211
  pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
195
212
 
196
213
  Options:
197
- --framework <id> App framework, such as next or tanstack-start (or vite, a frontend)
214
+ --framework <id> App framework, such as next or tanstack-start (or vite, a frontend, or none)
198
215
  --api <id> A separate API for a frontend, such as hono or fastapi
199
216
  --shell <id> A mobile app that packages a frontend, such as capacitor
217
+ --mobile <id> A native mobile app that calls the API, such as expo
200
218
  --<module> <option> A module option, such as --auth none; the Starter lists its modules
201
219
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
202
220
  (upgrade: the Starter to upgrade to)
@@ -223,7 +241,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
223
241
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
224
242
 
225
243
  # Reproduce a specific Starter release or commit
226
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.27.1
244
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.28.0
227
245
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
228
246
 
229
247
  # Test local Starter changes before releasing them
@@ -242,7 +260,7 @@ pnpm clubedge add email resend payments stripe # several modules as
242
260
  pnpm clubedge remove storage payments
243
261
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
244
262
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
245
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.27.1 # or a specific tag, branch, or commit
263
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.28.0 # or a specific tag, branch, or commit
246
264
  ```
247
265
 
248
266
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -250,7 +268,7 @@ The CLI renders the project twice, from the Starter commit and modules recorded
250
268
  - Files you never changed are updated, added, or deleted.
251
269
  - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve. The Starter's whole new version of each such file is saved in `.clubedge/incoming/` to compare with yours (`git diff --no-index <file> .clubedge/incoming/<file>`); Git ignores that folder, so delete it when you are done.
252
270
  - Your own files are never touched.
253
- - Each app's local env file (`apps/web/.env.local`, and `apps/api/.env.local` or `apps/shell/.env.local` when the project has them) keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
271
+ - Each app's local env file (`apps/web/.env.local`, and `apps/api/.env.local`, `apps/shell/.env.local`, or `apps/mobile/.env.local` when the project has them) keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
254
272
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
255
273
 
256
274
  Several modules in one command are applied as one change, and their requirements are checked together, so `add auth supabase storage supabase` works even though Supabase Storage needs Supabase Auth. When lines conflict, the CLI lists each file with its number of conflict blocks. Before an upgrade, it lists the Starter releases it crosses with the sections of their release notes, such as `v0.13.0 Observability, Tests, Also`.
@@ -322,7 +340,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
322
340
  pnpm clubedge deploy --dry-run # show the plan, create nothing
323
341
  ```
324
342
 
325
- With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package and the databases selected by the modules. Full-stack projects get one public app service. A Vite project gets two services: the frontend has the public `*.up.railway.app` domain, and the API stays private; the frontend's Node server (`node server.mjs`) forwards the API routes to it over Railway's private network. API variables, including `FRONTEND_URL`, go on the API service; `VITE_*` values go on the frontend service for its build. With Better Auth, the frontend's Node server runs auth: it gets the same `DATABASE_URL` as the API, a generated `BETTER_AUTH_SECRET`, the email settings, and `BETTER_AUTH_URL`, the public frontend URL both services use. Each service's unset variables are reported from its own env example. The API's migration command runs before the API release, and the frontend and API deployments are both awaited. Database variables are references to Railway's Postgres and Redis by default. Values from `.env.production` (or `--env-file`) take precedence; external database URLs stay external, and localhost URLs are ignored. `.railway/railway.ts` describes the selected service topology and health checks; `deploy` applies it and adds Railway's TypeScript SDK (`railway`) as a dev dependency. Commit the generated file and package changes. It needs Railway CLI 5.42.1 or newer. Running `deploy` again reuses the services, so it also adds variables and ships new releases. Railway bills usage on your plan.
343
+ With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package and the databases selected by the modules. Full-stack projects get one public app service. A project without a website (`--framework none`) also gets one, the API, with the public `*.up.railway.app` domain its mobile app calls, its migrations before each release, and, with Better Auth, which then runs in the API, a generated `BETTER_AUTH_SECRET` and its own public URL as `BETTER_AUTH_URL`. A Vite project gets two services: the frontend has the public `*.up.railway.app` domain, and the API stays private; the frontend's Node server (`node server.mjs`) forwards the API routes to it over Railway's private network. API variables, including `FRONTEND_URL`, go on the API service; `VITE_*` values go on the frontend service for its build. With Better Auth, the frontend's Node server runs auth: it gets the same `DATABASE_URL` as the API, a generated `BETTER_AUTH_SECRET`, the email settings, and `BETTER_AUTH_URL`, the public frontend URL both services use. Each service's unset variables are reported from its own env example. The API's migration command runs before the API release, and the frontend and API deployments are both awaited. Database variables are references to Railway's Postgres and Redis by default. Values from `.env.production` (or `--env-file`) take precedence; external database URLs stay external, and localhost URLs are ignored. `.railway/railway.ts` describes the selected service topology and health checks; `deploy` applies it and adds Railway's TypeScript SDK (`railway`) as a dev dependency. Commit the generated file and package changes. It needs Railway CLI 5.42.1 or newer. Running `deploy` again reuses the services, so it also adds variables and ships new releases. Railway bills usage on your plan.
326
344
 
327
345
  ### Deploy to Fly.io (experimental)
328
346
 
@@ -344,7 +362,7 @@ In a project with `--deploy render`, `pnpm clubedge deploy` uses Render's API. R
344
362
 
345
363
  In a project with `--deploy docker` (the default), `pnpm clubedge deploy` prepares the project for a server of your own and creates nothing anywhere else. It writes three files to commit:
346
364
 
347
- - `compose.yaml` runs each app's image behind [Caddy](https://caddyserver.com), the only service that publishes ports (80, and 443 over TCP and UDP): it serves `DOMAIN` over HTTPS, with certificates it obtains and renews by itself. A full-stack app (Next.js or TanStack Start) is one service, `web`, built from the root `Dockerfile`; a frontend and its API are two, the frontend `web`, which Caddy serves, and `api`, which stays on the Compose network behind the frontend's `/api` proxy. Each service has an image, `${IMAGE_PREFIX:-<project>}-<service>:${TAG:-latest}`, and its build: `docker compose up -d` pulls the released images when `IMAGE_PREFIX` points at GitHub's registry, and `--build` builds them on the server. The public URL defaults to `https://$DOMAIN`: Next.js also gets it as a build argument, and Better Auth on a Vite frontend as `BETTER_AUTH_URL`.
365
+ - `compose.yaml` runs each app's image behind [Caddy](https://caddyserver.com), the only service that publishes ports (80, and 443 over TCP and UDP): it serves `DOMAIN` over HTTPS, with certificates it obtains and renews by itself. A full-stack app (Next.js or TanStack Start) is one service, `web`, built from the root `Dockerfile`; a frontend and its API are two, the frontend `web`, which Caddy serves, and `api`, which stays on the Compose network behind the frontend's `/api` proxy; without a website, `api` is the one service, which Caddy serves, and Better Auth in it gets `BETTER_AUTH_URL`. Each service has an image, `${IMAGE_PREFIX:-<project>}-<service>:${TAG:-latest}`, and its build: `docker compose up -d` pulls the released images when `IMAGE_PREFIX` points at GitHub's registry, and `--build` builds them on the server. The public URL defaults to `https://$DOMAIN`: Next.js also gets it as a build argument, and Better Auth on a Vite frontend as `BETTER_AUTH_URL`.
348
366
  - `Caddyfile`: every request for `DOMAIN` goes to the public app.
349
367
  - `.github/workflows/release-images.yml`: each published GitHub release builds every app's image for `linux/amd64` and `linux/arm64` and pushes it to `ghcr.io/<owner>/<repository>-<service>`, tagged with its version (`1.2.3`), `1.2`, and `latest`; run by hand on the default branch, it pushes `edge`. It never runs for tag pushes, so a deleted tag leaves no images behind. The values an image compiles in come from repository variables: `NEXT_PUBLIC_APP_URL` for Next.js, the `VITE_*` values for a Vite frontend.
350
368
 
@@ -354,7 +372,7 @@ Then point the domain's DNS A and AAAA records at the server, publish a release,
354
372
  # .env.production: DOMAIN=example.com, the app's variables, and, to run the released images,
355
373
  # IMAGE_PREFIX=ghcr.io/<owner>/<repository> (TAG=1.2.3 pins a release; latest otherwise)
356
374
  docker login ghcr.io # while the packages are private: a token with read:packages
357
- docker compose --env-file .env.production run --rm web node /app/migrate.mjs # api for a frontend and its API
375
+ docker compose --env-file .env.production run --rm web node /app/migrate.mjs # api when the project has one
358
376
  docker compose --env-file .env.production up -d # --build builds the images on the server
359
377
  ```
360
378
 
@@ -372,7 +390,7 @@ In a project that deploys to Vercel or Railway, `pnpm clubedge deploy --preview`
372
390
  NEON_API_KEY=... NEON_PROJECT_ID=... pnpm clubedge deploy --preview
373
391
  ```
374
392
 
375
- With `NEON_API_KEY` and `NEON_PROJECT_ID` (in the shell, or in the env file), the preview gets its own [Neon branch](https://neon.com/docs/introduction/branching), `preview/<git branch>`: a copy of the production database made in seconds that shares its storage until it changes. `deploy` creates it, or reuses it on later runs, applies this branch's migrations to it, and points the preview's `DATABASE_URL` and `DATABASE_URL_DIRECT` at it. On Vercel, the preview is a preview deployment with the project's Preview variables, and the branch's URLs are set as Preview variables of your Git branch, which needs the Vercel project connected to its Git repository; without the Neon settings, it uses the Preview `DATABASE_URL`. On Railway, it is an environment named `preview-<git branch>`, copied from production with its own domain; without the Neon settings, it gets its own empty copy of Railway's Postgres, and when production's database comes from elsewhere, `deploy --preview` refuses, since the copy would use production's data. Production is not changed. It deploys a single app for now: a Vite frontend and its API are two services, so `deploy --preview` refuses them and explains how to copy production's Railway environment yourself.
393
+ With `NEON_API_KEY` and `NEON_PROJECT_ID` (in the shell, or in the env file), the preview gets its own [Neon branch](https://neon.com/docs/introduction/branching), `preview/<git branch>`: a copy of the production database made in seconds that shares its storage until it changes. `deploy` creates it, or reuses it on later runs, applies this branch's migrations to it, and points the preview's `DATABASE_URL` and `DATABASE_URL_DIRECT` at it. On Vercel, the preview is a preview deployment with the project's Preview variables, and the branch's URLs are set as Preview variables of your Git branch, which needs the Vercel project connected to its Git repository; without the Neon settings, it uses the Preview `DATABASE_URL`. On Railway, it is an environment named `preview-<git branch>`, copied from production with its own domain; without the Neon settings, it gets its own empty copy of Railway's Postgres, and when production's database comes from elsewhere, `deploy --preview` refuses, since the copy would use production's data. Production is not changed. It deploys a single web app for now: a Vite frontend and its API are two services, and a project without a website runs only its API, so `deploy --preview` refuses them and explains how to copy production's Railway environment yourself.
376
394
 
377
395
  Projects created with `--infra neon` also have `.github/workflows/preview.yml`, which does this for every pull request in CI: a Neon branch per pull request with its migrations, the schema diff as a comment, a Vercel or Railway preview, and cleanup when the pull request closes. The Starter's SETUP.md lists the secrets it needs.
378
396
 
@@ -381,27 +399,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
381
399
  Each CLI release is tested against one specific Starter release:
382
400
 
383
401
  ```text
384
- create-clubedge-app v0.43.1
402
+ create-clubedge-app v0.44.0
385
403
  |
386
404
  v
387
- Clubedge Starter v0.27.1
388
- commit 997d71d74775352d30dc2bb25017f46ef43f4fd8
405
+ Clubedge Starter v0.28.0
406
+ commit 090b05a9561f6eb7bb3bc0ab0b15ada7702aef8c
389
407
  ```
390
408
 
391
409
  The current relationship is:
392
410
 
393
411
  | CLI release | Default Starter release | Meaning |
394
412
  | --- | --- | --- |
395
- | `create-clubedge-app@0.43.1` | `clubedge-starter@v0.27.1` | The CLI's required, tested default |
413
+ | `create-clubedge-app@0.44.0` | `clubedge-starter@v0.28.0` | The CLI's required, tested default |
396
414
 
397
415
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
398
416
 
399
- The `v0.27.1` tag is expected to resolve to commit
400
- `997d71d74775352d30dc2bb25017f46ef43f4fd8`. The CLI verifies this before
417
+ The `v0.28.0` tag is expected to resolve to commit
418
+ `090b05a9561f6eb7bb3bc0ab0b15ada7702aef8c`. The CLI verifies this before
401
419
  scaffolding with the default reference and fails rather than silently using a
402
420
  moved tag.
403
421
 
404
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.27.1`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
422
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.28.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
405
423
 
406
424
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
407
425
 
@@ -409,7 +427,7 @@ Every generated project records its CLI version, Starter repository, Starter ref
409
427
 
410
428
  Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
411
429
 
412
- The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, the root `package.json` scripts it sets, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. Schema 4 moves each app's description into its own stack pack, placed in a slot (`--framework`, `--api`), and schema 5 adds the shell role for a mobile app (`--shell`); see [docs/stack-packs.md](docs/stack-packs.md). The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
430
+ The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, the root `package.json` scripts it sets, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. Schema 4 moves each app's description into its own stack pack, placed in a slot (`--framework`, `--api`), schema 5 adds the shell role for a mobile app that packages the frontend (`--shell`), and schema 6 the mobile role for a native app (`--mobile`); see [docs/stack-packs.md](docs/stack-packs.md). The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
413
431
 
414
432
  The CLI first builds a plan (the files to copy and the edits to make), which `--dry-run` prints, and then applies it. The source is organized the same way:
415
433
 
@@ -456,7 +474,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
456
474
  1. Make and validate changes in `clubedge-starter`.
457
475
  2. Run lint, typecheck, unit tests, browser tests, and production build.
458
476
  3. Update the Starter package versions.
459
- 4. Commit `main`, create a release tag such as `v0.27.1`, and publish the Starter release.
477
+ 4. Commit `main`, create a release tag such as `v0.28.0`, and publish the Starter release.
460
478
 
461
479
  ### CLI maintainers
462
480
 
@@ -510,6 +528,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
510
528
  | 0.41.0 | v0.26.0 | A conflicted package.json no longer breaks later commands (`--detailed-exit-code`); doctor finds conflict markers; projects own their version and README |
511
529
  | 0.42.0 | v0.27.0 | Starter v0.27.0: mobile apps with Capacitor (`--shell capacitor`, template schema 5); client addresses per platform; Docker Compose behind Caddy, with images on GHCR |
512
530
  | 0.43.0 | v0.27.1 | Upgrades keep each app's own version; add, remove, and upgrade that change nothing skip the Starter download |
531
+ | 0.44.0 | v0.28.0 | Starter v0.28.0: the native app with Expo (`--mobile expo`, template schema 6); projects without a website (`--framework none`), with template rules; audit exceptions per stack |
513
532
 
514
533
  ## Community
515
534
 
package/dist/cli/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import { isOptionalSlot } from "../core/manifest.js";
2
3
  import { NO_STACK, stacksIn } from "../core/plan.js";
3
4
  import { cliUrl, cliVersion, siteUrl, starterPin } from "../package-info.js";
4
5
  // Each glyph is 6 rows tall. Rows are padded to a common width when rendered.
@@ -72,7 +73,7 @@ export function selectionHelp(manifest) {
72
73
  }
73
74
  return [
74
75
  ...Object.entries(manifest.slots).flatMap(([id, slot]) => {
75
- const stacks = [...stacksIn(manifest, id), ...(slot.default === undefined ? [NO_STACK] : [])];
76
+ const stacks = [...stacksIn(manifest, id), ...(isOptionalSlot(slot) ? [NO_STACK] : [])];
76
77
  return stacks.length > 1 ? [line(`--${id} <id>`, `${slot.name}: ${choices(stacks, slot.default ?? NO_STACK)}`)] : [];
77
78
  }),
78
79
  ...Object.entries(manifest.modules).map(([id, module]) => line(`--${id} <id>`, `${module.name}: ${choices(Object.keys(module.options), module.default)}`)),
@@ -21,7 +21,12 @@ function recordStacks(stacks) {
21
21
  ...(Object.keys(apps).length ? { apps } : {}),
22
22
  };
23
23
  }
24
- export function customizeRootManifest(source, { packageName, version = "0.1.0" }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
24
+ /** A command with one package name replaced where it stands on its own, as in `--filter=<name>`. */
25
+ function renamePackage(command, from, to) {
26
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
+ return command.replace(new RegExp(`(^|[\\s='"])${escaped}(?=$|[\\s'"])`, "g"), `$1${to}`);
28
+ }
29
+ export function customizeRootManifest(source, { packageName, version = "0.1.0" }, provenance, dockerImage, scripts = { set: {}, remove: [] }, droppedAdvisories = []) {
25
30
  const manifest = JSON.parse(source);
26
31
  manifest.name = packageName;
27
32
  manifest.version = version;
@@ -31,6 +36,23 @@ export function customizeRootManifest(source, { packageName, version = "0.1.0" }
31
36
  }
32
37
  if (Object.keys(scripts.set).length)
33
38
  manifest.scripts = { ...manifest.scripts, ...scripts.set };
39
+ for (const [from, to] of Object.entries(scripts.rename ?? {})) {
40
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
41
+ if (typeof command === "string")
42
+ manifest.scripts[name] = renamePackage(command, from, to);
43
+ }
44
+ }
45
+ // Advisories that only left-out stacks need are audited again; settings left empty go too.
46
+ const auditConfig = manifest.pnpm?.auditConfig;
47
+ if (droppedAdvisories.length && Array.isArray(auditConfig?.ignoreGhsas)) {
48
+ auditConfig.ignoreGhsas = auditConfig.ignoreGhsas.filter((id) => !droppedAdvisories.includes(id));
49
+ if (!auditConfig.ignoreGhsas.length)
50
+ delete auditConfig.ignoreGhsas;
51
+ if (!Object.keys(auditConfig).length)
52
+ delete manifest.pnpm.auditConfig;
53
+ if (!Object.keys(manifest.pnpm).length)
54
+ delete manifest.pnpm;
55
+ }
34
56
  manifest.clubedge = {
35
57
  cliVersion: provenance.cliVersion,
36
58
  starterRepository: provenance.starterRepository,
@@ -173,20 +195,24 @@ export class ConditionalError extends Error {
173
195
  const MARKER = /^\s*(?:\/\/|#|%%|<!--|\{?\/\*)\s*clubedge:(if|end)\b\s*(.*?)\s*(?:-->|\*\/\}?)?\s*$/;
174
196
  // A single line kept only when its condition holds: "- Item <!-- clubedge:only auth=supabase -->".
175
197
  const LINE_MARKER = /\s*(?:\/\/|#|<!--|\{?\/\*)\s*clubedge:only\s+(.+?)\s*(?:-->|\*\/\}?)?\s*$/;
198
+ /** The parts of a condition such as `framework=none && payments!=none`, as markers and rules write them. */
199
+ export function conditionParts(expression, location) {
200
+ return expression.split("&&").map((part) => {
201
+ const match = /^\s*([a-z][a-z0-9-]*)\s*(!?=)\s*([a-z0-9|-]+)\s*$/.exec(part);
202
+ if (!match)
203
+ throw new ConditionalError(`${location}: cannot read the condition "${expression}".`);
204
+ return { key: match[1], equals: match[2] === "=", values: match[3].split("|") };
205
+ });
206
+ }
176
207
  function evaluateCondition(expression, selection, location) {
177
208
  // Maintainer-only sections of the Starter repository never reach a generated project.
178
209
  if (expression.trim() === "starter-repository")
179
210
  return false;
180
- return expression.split("&&").every((part) => {
181
- const match = /^\s*([a-z][a-z0-9-]*)\s*(!?=)\s*([a-z0-9|-]+)\s*$/.exec(part);
182
- if (!match)
183
- throw new ConditionalError(`${location}: cannot read the condition "${expression}".`);
184
- const [, key, operator, values] = match;
211
+ return conditionParts(expression, location).every(({ key, equals, values }) => {
185
212
  const selected = selection[key];
186
213
  if (selected === undefined)
187
214
  throw new ConditionalError(`${location}: unknown key "${key}".`);
188
- const matches = values.split("|").includes(selected);
189
- return operator === "=" ? matches : !matches;
215
+ return values.includes(selected) === equals;
190
216
  });
191
217
  }
192
218
  /**
@@ -188,6 +188,21 @@ export function planServicePair(input) {
188
188
  const unset = frontendKeys.filter((key) => set[key] === undefined && !existing.frontend[key] && !optional.includes(key));
189
189
  return { frontend: { set, unset }, api };
190
190
  }
191
+ /**
192
+ * The variables of an API deployed without a web app, as the public service its mobile app calls.
193
+ * It gets what planVariables provides, with the platform's client-address settings. With Better
194
+ * Auth, which then runs in the API, BETTER_AUTH_URL is the API's public URL, unless the env file
195
+ * names another public one, and the secret is generated unless the service already has one.
196
+ */
197
+ export function planApiAlone(input) {
198
+ const plan = planVariables(input);
199
+ const configured = input.envFile.BETTER_AUTH_URL;
200
+ if (input.modules.auth === "better-auth")
201
+ plan.set.BETTER_AUTH_URL = configured && !isLocalUrl(configured) ? configured : input.url;
202
+ // The image listens on every interface: its Dockerfile sets HOST.
203
+ plan.unset = plan.unset.filter((key) => plan.set[key] === undefined && key !== "HOST");
204
+ return plan;
205
+ }
191
206
  /**
192
207
  * DATABASE_URL_DIRECT for a database hosted elsewhere: the env file's, or for a pooled Neon URL the
193
208
  * same URL without -pooler. Never next to the platform's own database (no external URL), where it
@@ -307,7 +322,8 @@ export const railwayIacPath = ".railway/railway.ts";
307
322
  * the variables stay managed by `deploy` and the dashboard, and the region is Railway's default.
308
323
  */
309
324
  export function railwayIacSource(service, services) {
310
- if (services && Object.keys(services).length > 1) {
325
+ // A frontend and its API, or an API without a web app: each app builds its own Dockerfile.
326
+ if (services && Object.values(services).some(({ role }) => role !== "fullstack")) {
311
327
  const identifier = (slot) => slot.replace(/[^a-zA-Z0-9_$]/g, "_");
312
328
  const apiEntry = Object.entries(services).find(([, item]) => item.role === "api");
313
329
  const api = apiEntry?.[1];
@@ -651,10 +667,16 @@ export const fullstackService = {
651
667
  healthPath: "/api/health",
652
668
  platforms: ["docker"],
653
669
  };
654
- /** The service the public reaches: a frontend, whose API stays private, else the full-stack app. */
670
+ /**
671
+ * The service the public reaches: a frontend, whose API stays private, else the full-stack app,
672
+ * else an API without a web app, which its mobile app calls directly.
673
+ */
655
674
  export function publicService(services) {
656
675
  const apps = Object.values(services);
657
- return apps.find(({ role }) => role === "frontend") ?? apps.find(({ role }) => role === "fullstack") ?? fullstackService;
676
+ return (apps.find(({ role }) => role === "frontend") ??
677
+ apps.find(({ role }) => role === "fullstack") ??
678
+ apps.find(({ role }) => role === "api") ??
679
+ fullstackService);
658
680
  }
659
681
  /**
660
682
  * The values an app's image compiles in, which its Dockerfile takes as build arguments, from the
@@ -671,8 +693,8 @@ export function buildArgumentKeys(service, keys) {
671
693
  * ports: it serves DOMAIN over HTTPS, with certificates it obtains and renews. `frontendKeys` are
672
694
  * the variables the public app's env example lists: the values its image compiles in become build
673
695
  * arguments, and a frontend's others runtime variables, such as Better Auth's when the frontend's
674
- * server runs auth. Compose reads their values from its --env-file; the public URL defaults to
675
- * https://DOMAIN.
696
+ * server runs auth. For an API without a web app, the public app, they are the API's. Compose reads
697
+ * their values from its --env-file; the public URL defaults to https://DOMAIN.
676
698
  */
677
699
  export function dockerComposeSource(project, services, frontendKeys = []) {
678
700
  const apps = Object.values(services);
@@ -686,22 +708,27 @@ export function dockerComposeSource(project, services, frontendKeys = []) {
686
708
  // client-address settings default to Caddy's.
687
709
  const runtimeKeys = frontendKeys.filter((key) => !key.startsWith("VITE_") && !["API_URL", "PORT", ...clientAddressKeys].includes(key));
688
710
  // Better Auth runs in the frontend's server: the API asks it for the session over the Compose network.
689
- const authUrl = api && frontendKeys.includes("BETTER_AUTH_URL") ? `http://${web.id}:${web.port}` : null;
711
+ const authUrl = api && api !== web && frontendKeys.includes("BETTER_AUTH_URL") ? `http://${web.id}:${web.port}` : null;
690
712
  const environment = (service) => {
713
+ // A frontend has no env_file: CLIENT_IP_HEADER from the env file needs a line of its own.
714
+ const address = [
715
+ " # Caddy, the one proxy in front, sets X-Forwarded-For to the client's address.",
716
+ ...(service.role === "frontend" ? clientAddressKeys : Object.keys(behindCaddy)).map((key) => ` ${key}: ${fromEnv(key, behindCaddy[key])}`),
717
+ ];
691
718
  if (service.role === "api") {
719
+ // The API listens on every interface, whatever HOST the env file has from development.
720
+ if (service === web) {
721
+ // Without a web app, the API is the public app, and runs Better Auth itself.
722
+ const auth = frontendKeys.includes("BETTER_AUTH_URL") ? [` BETTER_AUTH_URL: ${fromEnv("BETTER_AUTH_URL", publicUrl)}`] : [];
723
+ return [" HOST: 0.0.0.0", ...address, ...auth];
724
+ }
692
725
  return [
693
- // The API listens on every interface, whatever HOST the env file has from development.
694
726
  " HOST: 0.0.0.0",
695
727
  ` # Reached only through ${web.id}, which passes on the client's address as the one X-Forwarded-For entry.`,
696
728
  ...clientAddressKeys.map((key) => ` ${key}: ${JSON.stringify(behindWeb[key] ?? "")}`),
697
729
  ...(authUrl ? [` BETTER_AUTH_URL: ${authUrl}`] : []),
698
730
  ];
699
731
  }
700
- // A frontend has no env_file: CLIENT_IP_HEADER from the env file needs a line of its own.
701
- const address = [
702
- " # Caddy, the one proxy in front, sets X-Forwarded-For to the client's address.",
703
- ...(service.role === "frontend" ? clientAddressKeys : Object.keys(behindCaddy)).map((key) => ` ${key}: ${fromEnv(key, behindCaddy[key])}`),
704
- ];
705
732
  if (service.role !== "frontend")
706
733
  return [...appUrlKeys.map((key) => ` ${key}: ${fromEnv(key, publicUrl)}`), ...address];
707
734
  return [
@@ -4,7 +4,11 @@ export const MANIFEST_FILE = "clubedge.template.json";
4
4
  export class ManifestError extends Error {
5
5
  name = "ManifestError";
6
6
  }
7
- export const stackRoles = ["fullstack", "frontend", "api", "shell"];
7
+ export const stackRoles = ["fullstack", "frontend", "api", "shell", "mobile"];
8
+ /** The template schema each role came with: a Starter on an older schema is read without it. */
9
+ export const roleSchema = { fullstack: 4, frontend: 4, api: 4, shell: 5, mobile: 6 };
10
+ /** Whether a role's apps run a server: a shell and a mobile app are installed on devices instead. */
11
+ export const runsServer = (role) => role !== "shell" && role !== "mobile";
8
12
  /**
9
13
  * The slot for the app itself. Starters before schema 4 call it the framework, and projects
10
14
  * record their choice for it as `framework`.