@clubedge/create-clubedge-app 0.45.0 → 0.46.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.
Files changed (156) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -11
  3. package/dist/cli/args.js +1 -1
  4. package/dist/cli/ui.js +3 -2
  5. package/dist/core/env.js +17 -1
  6. package/dist/core/manifest-fields.js +12 -0
  7. package/dist/core/plan.js +11 -1
  8. package/dist/core/update.js +24 -7
  9. package/dist/index.js +12 -8
  10. package/dist/modify.js +192 -39
  11. package/package.json +3 -3
  12. package/skills/clubedge-starter/SKILL.md +3 -0
  13. package/template/.github/workflows/expo-release.yml +65 -0
  14. package/template/.github/workflows/expo-update.yml +56 -0
  15. package/template/.github/workflows/shell-release.yml +145 -0
  16. package/template/README.md +12 -0
  17. package/template/SETUP.md +121 -5
  18. package/template/apps/api/.env.example +24 -0
  19. package/template/apps/api/clubedge.stack.json +12 -0
  20. package/template/apps/api/package.json +4 -1
  21. package/template/apps/api/src/app.ts +10 -0
  22. package/template/apps/api/src/env/production.test.ts +18 -0
  23. package/template/apps/api/src/env/production.ts +28 -0
  24. package/template/apps/api/src/routes/push.test.ts +160 -0
  25. package/template/apps/api/src/routes/push.ts +146 -0
  26. package/template/apps/api/src/server/index.ts +2 -0
  27. package/template/apps/api/src/server/push.ts +40 -0
  28. package/template/apps/api/src/server/variants/push.native.ts +78 -0
  29. package/template/apps/api-fastapi/.env.example +24 -0
  30. package/template/apps/api-fastapi/Dockerfile +1 -0
  31. package/template/apps/api-fastapi/clubedge.stack.json +24 -0
  32. package/template/apps/api-fastapi/package.json +1 -1
  33. package/template/apps/api-fastapi/pyproject.toml +3 -1
  34. package/template/apps/api-fastapi/src/clubedge_api/app.py +18 -0
  35. package/template/apps/api-fastapi/src/clubedge_api/production_env.py +27 -0
  36. package/template/apps/api-fastapi/src/clubedge_api/push.py +141 -0
  37. package/template/apps/api-fastapi/src/clubedge_api/push_expo.py +67 -0
  38. package/template/apps/api-fastapi/src/clubedge_api/push_native.py +316 -0
  39. package/template/apps/api-fastapi/src/clubedge_api/push_service.py +21 -0
  40. package/template/apps/api-fastapi/src/clubedge_api/routes/push.py +163 -0
  41. package/template/apps/api-fastapi/src/clubedge_api/server.py +5 -0
  42. package/template/apps/api-fastapi/src/clubedge_api/variants/push_service_native.py +19 -0
  43. package/template/apps/api-fastapi/tests/test_production_env.py +11 -0
  44. package/template/apps/api-fastapi/tests/test_push.py +289 -0
  45. package/template/apps/api-fastapi/tests/test_push_expo.py +90 -0
  46. package/template/apps/api-fastapi/tests/test_push_native.py +202 -0
  47. package/template/apps/api-fastapi/uv.lock +197 -1
  48. package/template/apps/capacitor/.env.example +13 -0
  49. package/template/apps/capacitor/_gitignore +2 -0
  50. package/template/apps/capacitor/capacitor.config.ts +19 -0
  51. package/template/apps/capacitor/clubedge.stack.json +9 -1
  52. package/template/apps/capacitor/e2e/README.md +23 -0
  53. package/template/apps/capacitor/e2e/account.yaml +116 -0
  54. package/template/apps/capacitor/package.json +5 -2
  55. package/template/apps/capacitor/scripts/build.mjs +17 -6
  56. package/template/apps/capacitor/scripts/push.mjs +80 -0
  57. package/template/apps/capacitor/scripts/release.mjs +133 -0
  58. package/template/apps/capacitor/scripts/version.mjs +129 -0
  59. package/template/apps/capacitor/version.json +3 -0
  60. package/template/apps/expo/.env.example +7 -0
  61. package/template/apps/expo/app.config.ts +93 -33
  62. package/template/apps/expo/app.json +5 -0
  63. package/template/apps/expo/clubedge.stack.json +33 -2
  64. package/template/apps/expo/e2e/README.md +26 -0
  65. package/template/apps/expo/e2e/account.yaml +111 -0
  66. package/template/apps/expo/eas.json +33 -0
  67. package/template/apps/expo/package.json +9 -6
  68. package/template/apps/expo/scripts/set-version.mjs +66 -0
  69. package/template/apps/expo/src/app/(app)/index.tsx +2 -0
  70. package/template/apps/expo/src/app/(app)/settings/index.tsx +4 -2
  71. package/template/apps/expo/src/components/ota-banner.tsx +69 -0
  72. package/template/apps/expo/src/components/push-settings.tsx +56 -0
  73. package/template/apps/expo/src/components/sign-out-sheet.tsx +3 -0
  74. package/template/apps/expo/src/components/update-gate.tsx +5 -1
  75. package/template/apps/expo/src/lib/api.ts +2 -1
  76. package/template/apps/expo/src/lib/ota.ts +30 -0
  77. package/template/apps/expo/src/lib/push-token.ts +20 -0
  78. package/template/apps/expo/src/lib/push.ts +161 -0
  79. package/template/apps/expo/src/lib/send.ts +27 -0
  80. package/template/apps/expo/src/variants/api.auth.ts +6 -11
  81. package/template/apps/expo/src/variants/app-layout.auth.tsx +2 -0
  82. package/template/apps/expo/src/variants/push-token.native.ts +14 -0
  83. package/template/apps/expo/variants/eas.updates.json +36 -0
  84. package/template/apps/start/clubedge.stack.json +3 -0
  85. package/template/apps/start/package.json +3 -3
  86. package/template/apps/vite/clubedge.stack.json +5 -0
  87. package/template/apps/vite/package.json +3 -3
  88. package/template/apps/vite/server.mjs +3 -0
  89. package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +7 -0
  90. package/template/apps/vite/src/main.tsx +5 -2
  91. package/template/apps/vite/src/routes/settings.tsx +19 -4
  92. package/template/apps/web/clubedge.stack.json +3 -0
  93. package/template/apps/web/package.json +3 -3
  94. package/template/clubedge/modules/ota.json +15 -0
  95. package/template/clubedge/modules/push.json +36 -0
  96. package/template/clubedge.template.json +19 -2
  97. package/template/package.json +1 -1
  98. package/template/packages/auth/package.json +1 -1
  99. package/template/packages/auth-appwrite/package.json +1 -1
  100. package/template/packages/auth-better-auth/package.json +1 -1
  101. package/template/packages/auth-firebase/package.json +1 -1
  102. package/template/packages/auth-supabase/package.json +1 -1
  103. package/template/packages/cache/package.json +1 -1
  104. package/template/packages/cache-redis/package.json +1 -1
  105. package/template/packages/contract/openapi.yaml +149 -0
  106. package/template/packages/contract/package.json +1 -1
  107. package/template/packages/contract/src/app-update.test.ts +21 -0
  108. package/template/packages/contract/src/app-update.ts +4 -2
  109. package/template/packages/contract/src/conformance.ts +43 -0
  110. package/template/packages/contract/src/index.ts +1 -1
  111. package/template/packages/contract/src/schema.ts +179 -0
  112. package/template/packages/core/package.json +1 -1
  113. package/template/packages/db/package.json +1 -1
  114. package/template/packages/email/package.json +1 -1
  115. package/template/packages/email-resend/package.json +1 -1
  116. package/template/packages/email-smtp/package.json +1 -1
  117. package/template/packages/jobs/package.json +1 -1
  118. package/template/packages/jobs-bullmq/package.json +1 -1
  119. package/template/packages/jobs-inngest/package.json +1 -1
  120. package/template/packages/jobs-pg-boss/package.json +1 -1
  121. package/template/packages/local-db/package.json +1 -1
  122. package/template/packages/mobile/package.json +9 -6
  123. package/template/packages/mobile/src/push-settings.tsx +95 -0
  124. package/template/packages/mobile/src/push.test.ts +153 -0
  125. package/template/packages/mobile/src/push.ts +126 -0
  126. package/template/packages/observability/package.json +1 -1
  127. package/template/packages/observability-otel/package.json +1 -1
  128. package/template/packages/observability-sentry/package.json +1 -1
  129. package/template/packages/payments/package.json +1 -1
  130. package/template/packages/payments-stripe/package.json +1 -1
  131. package/template/packages/push/drizzle/0000_initial.sql +11 -0
  132. package/template/packages/push/drizzle/meta/0000_snapshot.json +96 -0
  133. package/template/packages/push/drizzle/meta/_journal.json +13 -0
  134. package/template/packages/push/drizzle.config.ts +12 -0
  135. package/template/packages/push/package.json +35 -0
  136. package/template/packages/push/scripts/migrate.ts +14 -0
  137. package/template/packages/push/src/devices.test.ts +133 -0
  138. package/template/packages/push/src/devices.ts +78 -0
  139. package/template/packages/push/src/index.ts +12 -0
  140. package/template/packages/push/src/schema.ts +29 -0
  141. package/template/packages/push/src/types.ts +59 -0
  142. package/template/packages/push/tsconfig.json +4 -0
  143. package/template/packages/push-expo/package.json +21 -0
  144. package/template/packages/push-expo/src/index.test.ts +82 -0
  145. package/template/packages/push-expo/src/index.ts +70 -0
  146. package/template/packages/push-expo/tsconfig.json +4 -0
  147. package/template/packages/push-native/package.json +21 -0
  148. package/template/packages/push-native/src/index.test.ts +245 -0
  149. package/template/packages/push-native/src/index.ts +291 -0
  150. package/template/packages/push-native/tsconfig.json +4 -0
  151. package/template/packages/storage/package.json +1 -1
  152. package/template/packages/storage-s3/package.json +1 -1
  153. package/template/packages/storage-supabase/package.json +1 -1
  154. package/template/packages/ui/package.json +2 -2
  155. package/template/pnpm-lock.yaml +512 -362
  156. package/template.lock.json +63 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.46.1
6
+
7
+ - **Starter v0.30.1** by default (#167). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.30.1) cover:
8
+ - the native app (`--mobile expo`) reaching its API again: since v0.29.0, each of its calls to the API threw after the API had answered, so the account never loaded, and saving a name or deleting the account failed;
9
+ - getting the fix to installed apps: a new build in the stores, then `APP_MINIMUM_VERSION` on the API, which sends older installs to the update screen, or an over-the-air update with `--ota eas`;
10
+ - end-to-end tests of both mobile apps on an Android emulator;
11
+ - Expo SDK 58's latest patch versions.
12
+
13
+ ## 0.46.0
14
+
15
+ - **Add and remove the apps installed on devices** (#161), so a project created without them can take them later, and drop them again, without starting over: `pnpm clubedge add mobile expo` for the native app, `pnpm clubedge add shell capacitor` for the shell that packages the website, and `pnpm clubedge remove mobile` or `pnpm clubedge remove shell`. The shell needs Starter v0.27.0 or later, and the native app v0.28.0.
16
+ - Adding an app brings its folder, its CI job and the Starter's other sections for it, its dependencies, and its local env file. `package.json` records it, so later changes and upgrades keep it.
17
+ - The app must fit the project, and the CLI says which project can have it when this one can't. When the project's option for a module is one the app lacks, such as a sign-in the native app doesn't have yet, switch it in the same command: `pnpm clubedge add mobile expo auth better-auth`.
18
+ - Removing an app deletes the Starter's files and lists what was never the Starter's, such as the shell's `android/` and `ios/` projects, to delete once you no longer need them.
19
+ - The framework and the API stay as the project was created.
20
+ - When a change keeps a variable in a local env file, the comments above it that you never edited now follow the example's, so a later change that removes the variable removes them too.
21
+ - **Module options can name the registry packages only they need** (#162), in a new `dependencies` field. A project without the option leaves them out, such as the notification plugins in projects without push. Stack packs can use it too ([stack packs](https://github.com/Clubedge/create-clubedge-app/blob/main/docs/stack-packs.md)).
22
+ - **Starter v0.30.0** by default (#163). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.30.0) cover:
23
+ - store releases for both mobile apps, signed, from your computer or a workflow, with build numbers made from each app's version;
24
+ - push notifications for both apps (`--push expo` or `--push native`);
25
+ - over-the-air updates for the native app (`--ota eas`).
26
+
5
27
  ## 0.45.0
6
28
 
7
29
  - **Starter v0.29.0** by default (#159). Its template schema is still 6. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.29.0) cover:
package/README.md CHANGED
@@ -165,6 +165,8 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono --mobi
165
165
 
166
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
167
 
168
+ A project created without them can add them later, and drop them again: `pnpm clubedge add mobile expo`, `pnpm clubedge add shell capacitor`, or `pnpm clubedge remove mobile` (since CLI 0.46.0, see [Change an existing project](#change-an-existing-project)).
169
+
168
170
  #### Without a website
169
171
 
170
172
  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).
@@ -241,7 +243,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
241
243
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
242
244
 
243
245
  # Reproduce a specific Starter release or commit
244
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.29.0
246
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.30.1
245
247
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
246
248
 
247
249
  # Test local Starter changes before releasing them
@@ -250,7 +252,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
250
252
 
251
253
  ## Change an existing project
252
254
 
253
- Inside a project created by the CLI, `add` and `remove` change modules, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them, with a list where you can pick several modules at once:
255
+ Inside a project created by the CLI, `add` and `remove` change modules and the apps installed on devices, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them, with a list where you can pick several modules and apps at once:
254
256
 
255
257
  ```sh
256
258
  pnpm clubedge add # choose a module and option from a list
@@ -258,9 +260,11 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
258
260
  pnpm dlx @clubedge/create-clubedge-app remove storage
259
261
  pnpm clubedge add email resend payments stripe # several modules as one change
260
262
  pnpm clubedge remove storage payments
263
+ pnpm clubedge add mobile expo # the native app; add shell capacitor packages the website
264
+ pnpm clubedge remove mobile
261
265
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
262
266
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
263
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.29.0 # or a specific tag, branch, or commit
267
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.30.1 # or a specific tag, branch, or commit
264
268
  ```
265
269
 
266
270
  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.
@@ -274,6 +278,15 @@ The CLI renders the project twice, from the Starter commit and modules recorded
274
278
 
275
279
  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`.
276
280
 
281
+ ### Apps installed on devices
282
+
283
+ Since CLI 0.46.0, `add` and `remove` also take the apps that run on phones rather than on a server: the native app (`add mobile expo`) and the shell that packages the website (`add shell capacitor`), with Starter v0.27.0 or later for the shell and v0.28.0 for the native app. The project's framework and API stay as they were created, since its deployment and data are built around them.
284
+
285
+ - Adding an app brings its folder, its CI job and the rest of the Starter's sections for it, its dependencies in `pnpm-lock.yaml`, and its local env file, made from its example as a new project's is. `package.json` records it, so later changes and upgrades keep it.
286
+ - The app must fit the project: the native app calls an API, so it needs `--framework vite` or `none` with an API, and the shell packages the Vite frontend. The CLI says which project can have the app when this one cannot.
287
+ - An app implements the modules it uses, such as the native app's sign-in. When the project's option is one it lacks, switch it in the same command: `add mobile expo auth better-auth`.
288
+ - Removing an app deletes the Starter's files and leaves what was never the Starter's, such as its env file, its `node_modules`, and the shell's `android/` and `ios/` projects. The CLI lists them, to delete once you no longer need them.
289
+
277
290
  The project must be a Git repository without uncommitted changes, so you can review the result with `git diff` and undo it. Pass `--force` to skip this check. Add `--no-install` to skip `pnpm install`. Afterward, run `pnpm db:migrate` and `pnpm check:services` if the change added tables or services.
278
291
 
279
292
  pnpm cannot read a `package.json` with conflict markers, so a change that leaves conflicts in one skips `pnpm install` and says so: resolve them, then run `pnpm install`. Until then, `pnpm clubedge` does not start either, and the CLI's own commands (`pnpm dlx @clubedge/create-clubedge-app status`, for example) stop with a message that names the file and the command that compares it with the Starter's version in `.clubedge/incoming/`. When the install fails for another reason, the CLI still lists what is left to resolve, then exits with an error.
@@ -400,27 +413,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
400
413
  Each CLI release is tested against one specific Starter release:
401
414
 
402
415
  ```text
403
- create-clubedge-app v0.45.0
416
+ create-clubedge-app v0.46.1
404
417
  |
405
418
  v
406
- Clubedge Starter v0.29.0
407
- commit 29350332db4bc44acc1e7b989c04825ff4c0f7b7
419
+ Clubedge Starter v0.30.1
420
+ commit 6d04531ffb2cd9f0e76b6e2b3b542f733b66aed6
408
421
  ```
409
422
 
410
423
  The current relationship is:
411
424
 
412
425
  | CLI release | Default Starter release | Meaning |
413
426
  | --- | --- | --- |
414
- | `create-clubedge-app@0.45.0` | `clubedge-starter@v0.29.0` | The CLI's required, tested default |
427
+ | `create-clubedge-app@0.46.1` | `clubedge-starter@v0.30.1` | The CLI's required, tested default |
415
428
 
416
429
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
417
430
 
418
- The `v0.29.0` tag is expected to resolve to commit
419
- `29350332db4bc44acc1e7b989c04825ff4c0f7b7`. The CLI verifies this before
431
+ The `v0.30.1` tag is expected to resolve to commit
432
+ `6d04531ffb2cd9f0e76b6e2b3b542f733b66aed6`. The CLI verifies this before
420
433
  scaffolding with the default reference and fails rather than silently using a
421
434
  moved tag.
422
435
 
423
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.29.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.
436
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.30.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.
424
437
 
425
438
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
426
439
 
@@ -475,7 +488,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
475
488
  1. Make and validate changes in `clubedge-starter`.
476
489
  2. Run lint, typecheck, unit tests, browser tests, and production build.
477
490
  3. Update the Starter package versions.
478
- 4. Commit `main`, create a release tag such as `v0.29.0`, and publish the Starter release.
491
+ 4. Commit `main`, create a release tag such as `v0.30.1`, and publish the Starter release.
479
492
 
480
493
  ### CLI maintainers
481
494
 
@@ -531,6 +544,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
531
544
  | 0.43.0 | v0.27.1 | Upgrades keep each app's own version; add, remove, and upgrade that change nothing skip the Starter download |
532
545
  | 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 |
533
546
  | 0.45.0 | v0.29.0 | Starter v0.29.0: an update policy for installed apps, a new design for the native app, and clean splash screens; `package.json` merged by key |
547
+ | 0.46.0 | v0.30.0 | Add and remove the mobile apps in an existing project; Starter v0.30.0: store releases, push notifications, and over-the-air updates; module options' `dependencies` |
534
548
 
535
549
  ## Community
536
550
 
package/dist/cli/args.js CHANGED
@@ -153,7 +153,7 @@ function parseCommand(positionals) {
153
153
  const module = rest[0];
154
154
  if (name === "add") {
155
155
  if (rest.length > 1 && rest.length % 2 === 1) {
156
- throw new UsageError("Usage: add [module option]..., for example: add cache redis, or add email resend payments stripe");
156
+ throw new UsageError("Usage: add [module option]..., for example: add cache redis, add email resend payments stripe, or add mobile expo");
157
157
  }
158
158
  const changes = rest.length === 1
159
159
  ? [{ module: rest[0] }]
package/dist/cli/ui.js CHANGED
@@ -101,8 +101,9 @@ Usage:
101
101
  upgrade, status, doctor, try, deploy, or neon. Without a module or option, add and remove ask
102
102
  for them.
103
103
 
104
- add and remove change modules of an existing project, several at once as one
105
- change, and upgrade moves it to a newer Starter (this CLI's release unless --ref
104
+ add and remove change modules of an existing project, and its apps installed on
105
+ devices (add mobile expo, add shell capacitor, remove mobile), several at once as
106
+ one change, and upgrade moves it to a newer Starter (this CLI's release unless --ref
106
107
  is given) and lists the Starter releases it crosses. The change is merged into
107
108
  your code; lines you changed that it also changes get conflict markers. Commit
108
109
  first so you can review the result with git diff.
package/dist/core/env.js CHANGED
@@ -49,12 +49,28 @@ export function updateEnvFile(local, baseExample, targetExample) {
49
49
  const start = generated ? at - comments.length : at;
50
50
  lines.splice(start, at - start + 1, ...(generated ? replacement : replacement.slice(-1)));
51
51
  }
52
+ /**
53
+ * Gives a variable whose value stays the target's comments, if the developer kept the base's:
54
+ * another change of the selection then still finds them as generated, and removes them with it.
55
+ */
56
+ function refreshComments(key) {
57
+ const at = find(lines, key);
58
+ const comments = commentsAbove(lines, at);
59
+ const baseComments = commentsAbove(base, find(base, key));
60
+ const targetComments = commentsAbove(target, find(target, key));
61
+ if (sameLines(comments, baseComments) && !sameLines(baseComments, targetComments)) {
62
+ lines.splice(at - comments.length, comments.length, ...targetComments);
63
+ }
64
+ }
52
65
  for (const [key, before] of baseValues) {
53
66
  if (!mine.has(key))
54
67
  continue;
55
68
  const after = targetValues.get(key);
56
- if (after === before || mine.get(key) === after)
69
+ if (after === before || mine.get(key) === after) {
70
+ if (after !== undefined)
71
+ refreshComments(key);
57
72
  continue;
73
+ }
58
74
  const generated = mine.get(key) === before;
59
75
  if (after === undefined) {
60
76
  (generated ? result.removed : result.unused).push(key);
@@ -89,6 +89,17 @@ export function parseRequires(file, value, prefix) {
89
89
  }
90
90
  return requires;
91
91
  }
92
+ /** An npm package name, scoped or not, such as expo-notifications or @capacitor/push-notifications. */
93
+ const PACKAGE_NAME = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
94
+ /** `dependencies`: packages from the registry, by name. */
95
+ function parseDependencies(file, value, prefix) {
96
+ if (value === undefined)
97
+ return [];
98
+ if (!isStringArray(value) || !value.every((name) => PACKAGE_NAME.test(name))) {
99
+ throw new ManifestError(`${file}: ${prefix}dependencies must be an array of package names.`);
100
+ }
101
+ return value;
102
+ }
92
103
  /** A module's name, default, and options; `prefix` locates it in its file. */
93
104
  export function parseModule(file, entry, prefix) {
94
105
  const { fail, requireString } = fields(file, prefix);
@@ -111,6 +122,7 @@ export function parseModule(file, entry, prefix) {
111
122
  options[optionId] = {
112
123
  name: read.requireString(values, "name"),
113
124
  packages: read.optionalStringRecord(values, "packages"),
125
+ dependencies: parseDependencies(file, values.dependencies, optionPrefix),
114
126
  files: read.optionalStringArray(values, "files"),
115
127
  replace: read.optionalStringRecord(values, "replace"),
116
128
  requires: parseRequires(file, values.requires, optionPrefix),
package/dist/core/plan.js CHANGED
@@ -238,6 +238,8 @@ function resolveModuleFiles(manifest, selection, stacks) {
238
238
  const keptPaths = new Set();
239
239
  const usedPackages = new Map();
240
240
  const offeredPackages = new Map();
241
+ const usedDependencies = new Set();
242
+ const offeredDependencies = new Set();
241
243
  const variantSources = new Set();
242
244
  /** Variant source path to the default file it replaces. */
243
245
  const replacements = new Map();
@@ -254,6 +256,11 @@ function resolveModuleFiles(manifest, selection, stacks) {
254
256
  if (selected)
255
257
  usedPackages.set(name, directory);
256
258
  }
259
+ for (const name of option.dependencies) {
260
+ offeredDependencies.add(name);
261
+ if (selected)
262
+ usedDependencies.add(name);
263
+ }
257
264
  for (const [target, variant] of Object.entries(option.replace)) {
258
265
  variantSources.add(variant);
259
266
  if (selected)
@@ -303,9 +310,12 @@ function resolveModuleFiles(manifest, selection, stacks) {
303
310
  if (Object.keys(rename).length)
304
311
  scripts.rename = rename;
305
312
  const removedPackages = [...offeredPackages].filter(([name]) => !usedPackages.has(name));
313
+ // Registry packages only left-out options list go the way of left-out workspace packages:
314
+ // out of each app's package.json and the lockfile's importers.
315
+ const removedDependencies = [...offeredDependencies].filter((name) => !usedDependencies.has(name));
306
316
  return {
307
317
  scripts,
308
- removedPackageNames: removedPackages.map(([name]) => name),
318
+ removedPackageNames: [...removedPackages.map(([name]) => name), ...removedDependencies],
309
319
  removedPackageDirectories: removedPackages.map(([, directory]) => directory),
310
320
  replacedTargets: new Set(replacements.values()),
311
321
  replacements,
@@ -1,6 +1,6 @@
1
1
  // Changes an existing project from one generated state (base) to another (target): different
2
- // modules for add and remove, or a newer Starter for upgrade. The project's own edits survive
3
- // through a three-way merge, see merge.ts.
2
+ // modules or apps for add and remove, or a newer Starter for upgrade. The project's own edits
3
+ // survive through a three-way merge, see merge.ts.
4
4
  import { mkdir, readdir, rm, rmdir, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { customizeAppManifest } from "./customize.js";
@@ -14,15 +14,17 @@ export async function planUpdate(project, base, target) {
14
14
  source: side.source,
15
15
  manifest: side.manifest,
16
16
  identity: project.identity,
17
- stacks: project.stacks,
17
+ stacks: side.stacks ?? project.stacks,
18
18
  modules: side.modules,
19
19
  cliVersion: side.cliVersion,
20
20
  });
21
21
  const before = await render(base);
22
22
  const after = await render(target);
23
- // The lockfile and the local env files are handled on their own below.
24
- const envFiles = [...after.envFiles, ...before.envFiles.filter(({ to }) => !after.envFiles.some((file) => file.to === to))];
25
- const special = [LOCKFILE, ...envFiles.map(({ to }) => to)];
23
+ // The lockfile and the local env files are handled on their own below. The env file of an app
24
+ // the change removes is left as it is: it is the developer's, and Git ignores it.
25
+ const envFiles = after.envFiles;
26
+ const removedEnvFiles = before.envFiles.filter(({ to }) => !after.envFiles.some((file) => file.to === to));
27
+ const special = [LOCKFILE, ...[...envFiles, ...removedEnvFiles].map(({ to }) => to)];
26
28
  const strip = (files) => new Map([...files].filter(([path]) => !special.includes(path)));
27
29
  const baseFiles = strip(before.files);
28
30
  const targetFiles = strip(after.files);
@@ -80,7 +82,14 @@ export async function planUpdate(project, base, target) {
80
82
  const text = (files) => files.get(envFile.from)?.toString("utf8") ?? "";
81
83
  const local = current.get(envFile.to)?.toString("utf8");
82
84
  const update = local === undefined ? null : updateEnvFile(local, text(before.files), text(after.files));
83
- return { path: envFile.to, update, changed: update !== null && update.text !== local };
85
+ const added = !before.envFiles.some(({ to }) => to === envFile.to);
86
+ const example = after.files.get(envFile.from);
87
+ return {
88
+ path: envFile.to,
89
+ update,
90
+ changed: update !== null && update.text !== local,
91
+ ...(local === undefined && added && example ? { create: example } : {}),
92
+ };
84
93
  });
85
94
  return { project, actions, lockfile, envs };
86
95
  }
@@ -137,5 +146,13 @@ export async function applyUpdate(plan) {
137
146
  for (const env of plan.envs) {
138
147
  if (env.update && env.changed)
139
148
  await writeFile(join(root, env.path), env.update.text);
149
+ if (env.create) {
150
+ await mkdir(dirname(join(root, env.path)), { recursive: true });
151
+ // An env file written since the plan was made is never replaced.
152
+ await writeFile(join(root, env.path), env.create, { flag: "wx" }).catch((error) => {
153
+ if (error.code !== "EEXIST")
154
+ throw error;
155
+ });
156
+ }
140
157
  }
141
158
  }
package/dist/index.js CHANGED
@@ -77,13 +77,12 @@ async function chooseStacks(manifest, requested, interactive) {
77
77
  * offered. Returns null when a prompt is cancelled.
78
78
  */
79
79
  async function chooseModules(manifest, stacks, requested, interactive) {
80
- const unasked = Object.keys(manifest.modules).filter((id) => requested[id] === undefined);
80
+ // Modules with a choice for these apps: a module only a mobile app uses has one for a website.
81
+ const unasked = Object.keys(manifest.modules).filter((id) => requested[id] === undefined && moduleChoices(manifest, stacks, id, requested).length > 1);
81
82
  if (!interactive || unasked.length === 0)
82
83
  return requested;
83
84
  const customize = await confirm({
84
- message: `Customize the included services (${Object.values(manifest.modules)
85
- .map((module) => module.name.toLowerCase())
86
- .join(", ")})?`,
85
+ message: `Customize the included services (${unasked.map((id) => manifest.modules[id].name.toLowerCase()).join(", ")})?`,
87
86
  initialValue: false,
88
87
  });
89
88
  if (isCancel(customize))
@@ -101,8 +100,13 @@ async function chooseModules(manifest, stacks, requested, interactive) {
101
100
  // Modules answered later are checked when the plan is created.
102
101
  return value === undefined || allowed.includes(value);
103
102
  }));
104
- if (available.length === 0)
103
+ // With one option left, there is nothing to ask. It is still the answer: later modules'
104
+ // requirements check it, and the plan takes it rather than the default.
105
+ if (available.length < 2) {
106
+ if (available[0] !== undefined)
107
+ chosen[id] = available[0][0];
105
108
  continue;
109
+ }
106
110
  const value = await select({
107
111
  message: module.name,
108
112
  initialValue: available.some(([optionId]) => optionId === module.default)
@@ -259,7 +263,7 @@ async function createProject(args) {
259
263
  const directory = relative(process.cwd(), targetDirectory) || ".";
260
264
  const scripts = JSON.parse(await readFile(join(targetDirectory, "package.json"), "utf8")).scripts ?? {};
261
265
  const later = scripts.clubedge
262
- ? `Change modules later with \`pnpm clubedge add\` or \`pnpm clubedge remove\`, and get Starter updates with \`pnpm clubedge upgrade\`.\n\n`
266
+ ? `Change modules and mobile apps later with \`pnpm clubedge add\` or \`pnpm clubedge remove\`, and get Starter updates with \`pnpm clubedge upgrade\`.\n\n`
263
267
  : "";
264
268
  outro(`Next steps:\n cd ${directory}\n${migrate ? ` ${pnpmCommand} ${migrate}\n` : ""} ${pnpmCommand} dev\n\n` +
265
269
  `See SETUP.md for database and provider configuration.\n\n` +
@@ -318,8 +322,8 @@ export async function main(argv = process.argv.slice(2)) {
318
322
  }
319
323
  else {
320
324
  const intros = {
321
- add: "Add a module to this project",
322
- remove: "Remove a module from this project",
325
+ add: "Add a module or an app to this project",
326
+ remove: "Remove a module or an app from this project",
323
327
  upgrade: "Upgrade this project to a newer Starter",
324
328
  };
325
329
  intro(intros[args.command.name]);