@clubedge/create-clubedge-app 0.44.0 → 0.46.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 (206) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +27 -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/merge.js +26 -12
  8. package/dist/core/package-json-merge.js +106 -0
  9. package/dist/core/plan.js +11 -1
  10. package/dist/core/update.js +24 -7
  11. package/dist/index.js +12 -8
  12. package/dist/modify.js +192 -39
  13. package/package.json +3 -3
  14. package/skills/clubedge-starter/SKILL.md +3 -0
  15. package/template/.github/workflows/ci.yml +1 -1
  16. package/template/.github/workflows/expo-release.yml +65 -0
  17. package/template/.github/workflows/expo-update.yml +56 -0
  18. package/template/.github/workflows/shell-release.yml +145 -0
  19. package/template/.railway/railway.ts +9 -0
  20. package/template/README.md +36 -15
  21. package/template/SETUP.md +169 -27
  22. package/template/apps/api/.env.example +46 -5
  23. package/template/apps/api/clubedge.stack.json +12 -0
  24. package/template/apps/api/package.json +4 -1
  25. package/template/apps/api/src/app.test.ts +24 -1
  26. package/template/apps/api/src/app.ts +55 -3
  27. package/template/apps/api/src/config/site.json +3 -1
  28. package/template/apps/api/src/env/production.test.ts +18 -0
  29. package/template/apps/api/src/env/production.ts +28 -0
  30. package/template/apps/api/src/routes/app-version.test.ts +68 -7
  31. package/template/apps/api/src/routes/app-version.ts +10 -3
  32. package/template/apps/api/src/routes/push.test.ts +160 -0
  33. package/template/apps/api/src/routes/push.ts +146 -0
  34. package/template/apps/api/src/server/app-version.test.ts +109 -7
  35. package/template/apps/api/src/server/app-version.ts +94 -44
  36. package/template/apps/api/src/server/index.ts +2 -0
  37. package/template/apps/api/src/server/push.ts +40 -0
  38. package/template/apps/api/src/server/variants/push.native.ts +78 -0
  39. package/template/apps/api-fastapi/.env.example +50 -9
  40. package/template/apps/api-fastapi/Dockerfile +1 -0
  41. package/template/apps/api-fastapi/clubedge.stack.json +24 -0
  42. package/template/apps/api-fastapi/package.json +1 -1
  43. package/template/apps/api-fastapi/pyproject.toml +3 -1
  44. package/template/apps/api-fastapi/src/clubedge_api/app.py +59 -3
  45. package/template/apps/api-fastapi/src/clubedge_api/app_version.py +160 -28
  46. package/template/apps/api-fastapi/src/clubedge_api/contract.py +4 -0
  47. package/template/apps/api-fastapi/src/clubedge_api/production_env.py +27 -0
  48. package/template/apps/api-fastapi/src/clubedge_api/push.py +141 -0
  49. package/template/apps/api-fastapi/src/clubedge_api/push_expo.py +67 -0
  50. package/template/apps/api-fastapi/src/clubedge_api/push_native.py +316 -0
  51. package/template/apps/api-fastapi/src/clubedge_api/push_service.py +21 -0
  52. package/template/apps/api-fastapi/src/clubedge_api/routes/app_version.py +17 -8
  53. package/template/apps/api-fastapi/src/clubedge_api/routes/push.py +163 -0
  54. package/template/apps/api-fastapi/src/clubedge_api/server.py +5 -0
  55. package/template/apps/api-fastapi/src/clubedge_api/variants/push_service_native.py +19 -0
  56. package/template/apps/api-fastapi/src/config/site.json +3 -1
  57. package/template/apps/api-fastapi/tests/test_app_version.py +247 -20
  58. package/template/apps/api-fastapi/tests/test_production_env.py +11 -0
  59. package/template/apps/api-fastapi/tests/test_push.py +289 -0
  60. package/template/apps/api-fastapi/tests/test_push_expo.py +90 -0
  61. package/template/apps/api-fastapi/tests/test_push_native.py +202 -0
  62. package/template/apps/api-fastapi/uv.lock +197 -1
  63. package/template/apps/capacitor/.env.example +13 -0
  64. package/template/apps/capacitor/_gitignore +2 -0
  65. package/template/apps/capacitor/assets/splash-dark.svg +1 -0
  66. package/template/apps/capacitor/assets/splash-icon.png +0 -0
  67. package/template/apps/capacitor/assets/splash.svg +1 -0
  68. package/template/apps/capacitor/capacitor.config.ts +12 -0
  69. package/template/apps/capacitor/clubedge.stack.json +9 -1
  70. package/template/apps/capacitor/package.json +5 -2
  71. package/template/apps/capacitor/scripts/build.mjs +13 -4
  72. package/template/apps/capacitor/scripts/icons.mjs +113 -14
  73. package/template/apps/capacitor/scripts/push.mjs +80 -0
  74. package/template/apps/capacitor/scripts/release.mjs +133 -0
  75. package/template/apps/capacitor/scripts/version.mjs +129 -0
  76. package/template/apps/capacitor/version.json +3 -0
  77. package/template/apps/expo/.env.example +7 -0
  78. package/template/apps/expo/app.config.ts +76 -30
  79. package/template/apps/expo/app.json +5 -0
  80. package/template/apps/expo/clubedge.stack.json +39 -3
  81. package/template/apps/expo/eas.json +33 -0
  82. package/template/apps/expo/package.json +11 -3
  83. package/template/apps/expo/scripts/set-version.mjs +66 -0
  84. package/template/apps/expo/src/app/(app)/_layout.tsx +4 -23
  85. package/template/apps/expo/src/app/(app)/index.tsx +166 -44
  86. package/template/apps/expo/src/app/(app)/settings/_layout.tsx +12 -0
  87. package/template/apps/expo/src/app/(app)/settings/delete-account.tsx +76 -0
  88. package/template/apps/expo/src/app/(app)/settings/edit-profile.tsx +44 -0
  89. package/template/apps/expo/src/app/(app)/settings/index.tsx +92 -0
  90. package/template/apps/expo/src/app/(app)/settings/password.tsx +49 -0
  91. package/template/apps/expo/src/app/_layout.tsx +12 -1
  92. package/template/apps/expo/src/app/forgot-password.tsx +47 -39
  93. package/template/apps/expo/src/app/reset-password.tsx +6 -8
  94. package/template/apps/expo/src/app/sign-in.tsx +29 -12
  95. package/template/apps/expo/src/app/sign-up.tsx +42 -14
  96. package/template/apps/expo/src/app/welcome.tsx +99 -0
  97. package/template/apps/expo/src/components/ota-banner.tsx +69 -0
  98. package/template/apps/expo/src/components/push-settings.tsx +56 -0
  99. package/template/apps/expo/src/components/sign-out-sheet.tsx +141 -0
  100. package/template/apps/expo/src/components/tab-bar.tsx +143 -0
  101. package/template/apps/expo/src/components/ui.tsx +545 -81
  102. package/template/apps/expo/src/components/update-gate.tsx +331 -37
  103. package/template/apps/expo/src/config/site.json +3 -1
  104. package/template/apps/expo/src/lib/account.ts +62 -0
  105. package/template/apps/expo/src/lib/app-update.ts +36 -0
  106. package/template/apps/expo/src/lib/auth-client.ts +10 -2
  107. package/template/apps/expo/src/lib/ota.ts +30 -0
  108. package/template/apps/expo/src/lib/push-token.ts +20 -0
  109. package/template/apps/expo/src/lib/push.ts +161 -0
  110. package/template/apps/expo/src/lib/theme.ts +152 -15
  111. package/template/apps/expo/src/types/images.d.ts +13 -0
  112. package/template/apps/expo/src/variants/app-layout.auth.tsx +19 -0
  113. package/template/apps/expo/src/variants/auth.better-auth.ts +12 -2
  114. package/template/apps/expo/src/variants/auth.supabase.ts +8 -5
  115. package/template/apps/expo/src/variants/layout.auth.tsx +24 -13
  116. package/template/apps/expo/src/variants/push-token.native.ts +14 -0
  117. package/template/apps/expo/variants/eas.updates.json +36 -0
  118. package/template/apps/start/clubedge.stack.json +3 -0
  119. package/template/apps/start/package.json +1 -1
  120. package/template/apps/start/src/config/site.json +3 -1
  121. package/template/apps/vite/.env.example +4 -4
  122. package/template/apps/vite/clubedge.stack.json +5 -0
  123. package/template/apps/vite/package.json +1 -1
  124. package/template/apps/vite/server.mjs +5 -1
  125. package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +7 -0
  126. package/template/apps/vite/src/config/site.json +3 -1
  127. package/template/apps/vite/src/main.tsx +7 -3
  128. package/template/apps/vite/src/routes/settings.tsx +19 -4
  129. package/template/apps/web/clubedge.stack.json +3 -0
  130. package/template/apps/web/package.json +1 -1
  131. package/template/apps/web/src/config/site.json +3 -1
  132. package/template/clubedge/modules/ota.json +15 -0
  133. package/template/clubedge/modules/push.json +36 -0
  134. package/template/clubedge.template.json +19 -3
  135. package/template/package.json +3 -2
  136. package/template/packages/auth/package.json +1 -1
  137. package/template/packages/auth-appwrite/package.json +1 -1
  138. package/template/packages/auth-better-auth/package.json +1 -1
  139. package/template/packages/auth-firebase/package.json +1 -1
  140. package/template/packages/auth-supabase/package.json +1 -1
  141. package/template/packages/cache/package.json +1 -1
  142. package/template/packages/cache-redis/package.json +1 -1
  143. package/template/packages/contract/openapi.yaml +234 -3
  144. package/template/packages/contract/package.json +1 -1
  145. package/template/packages/contract/src/app-update.test.ts +321 -0
  146. package/template/packages/contract/src/app-update.ts +237 -0
  147. package/template/packages/contract/src/app-version.test.ts +76 -0
  148. package/template/packages/contract/src/app-version.ts +71 -0
  149. package/template/packages/contract/src/conformance.ts +85 -1
  150. package/template/packages/contract/src/index.ts +2 -0
  151. package/template/packages/contract/src/schema.ts +223 -4
  152. package/template/packages/core/package.json +1 -1
  153. package/template/packages/db/package.json +1 -1
  154. package/template/packages/email/package.json +1 -1
  155. package/template/packages/email-resend/package.json +1 -1
  156. package/template/packages/email-smtp/package.json +1 -1
  157. package/template/packages/jobs/package.json +1 -1
  158. package/template/packages/jobs-bullmq/package.json +1 -1
  159. package/template/packages/jobs-inngest/package.json +1 -1
  160. package/template/packages/jobs-pg-boss/package.json +1 -1
  161. package/template/packages/local-db/package.json +1 -1
  162. package/template/packages/mobile/package.json +5 -2
  163. package/template/packages/mobile/src/demo/api.ts +7 -1
  164. package/template/packages/mobile/src/demo/demo.test.ts +2 -0
  165. package/template/packages/mobile/src/push-settings.tsx +95 -0
  166. package/template/packages/mobile/src/push.test.ts +153 -0
  167. package/template/packages/mobile/src/push.ts +126 -0
  168. package/template/packages/mobile/src/update-notice.test.ts +26 -2
  169. package/template/packages/mobile/src/update-notice.tsx +55 -41
  170. package/template/packages/observability/package.json +1 -1
  171. package/template/packages/observability-otel/package.json +1 -1
  172. package/template/packages/observability-sentry/package.json +1 -1
  173. package/template/packages/payments/package.json +1 -1
  174. package/template/packages/payments-stripe/package.json +1 -1
  175. package/template/packages/push/drizzle/0000_initial.sql +11 -0
  176. package/template/packages/push/drizzle/meta/0000_snapshot.json +96 -0
  177. package/template/packages/push/drizzle/meta/_journal.json +13 -0
  178. package/template/packages/push/drizzle.config.ts +12 -0
  179. package/template/packages/push/package.json +35 -0
  180. package/template/packages/push/scripts/migrate.ts +14 -0
  181. package/template/packages/push/src/devices.test.ts +133 -0
  182. package/template/packages/push/src/devices.ts +78 -0
  183. package/template/packages/push/src/index.ts +12 -0
  184. package/template/packages/push/src/schema.ts +29 -0
  185. package/template/packages/push/src/types.ts +59 -0
  186. package/template/packages/push/tsconfig.json +4 -0
  187. package/template/packages/push-expo/package.json +21 -0
  188. package/template/packages/push-expo/src/index.test.ts +82 -0
  189. package/template/packages/push-expo/src/index.ts +70 -0
  190. package/template/packages/push-expo/tsconfig.json +4 -0
  191. package/template/packages/push-native/package.json +21 -0
  192. package/template/packages/push-native/src/index.test.ts +245 -0
  193. package/template/packages/push-native/src/index.ts +291 -0
  194. package/template/packages/push-native/tsconfig.json +4 -0
  195. package/template/packages/storage/package.json +1 -1
  196. package/template/packages/storage-s3/package.json +1 -1
  197. package/template/packages/storage-supabase/package.json +1 -1
  198. package/template/packages/ui/package.json +1 -1
  199. package/template/pnpm-lock.yaml +298 -5
  200. package/template/supabase/config.toml +10 -0
  201. package/template.lock.json +77 -7
  202. package/template/apps/expo/src/app/(app)/settings.tsx +0 -168
  203. package/template/apps/expo/src/lib/update.test.ts +0 -34
  204. package/template/apps/expo/src/lib/update.ts +0 -40
  205. package/template/packages/mobile/src/version.test.ts +0 -65
  206. package/template/packages/mobile/src/version.ts +0 -37
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.46.0
6
+
7
+ - **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.
8
+ - 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.
9
+ - 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`.
10
+ - 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.
11
+ - The framework and the API stay as the project was created.
12
+ - 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.
13
+ - **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)).
14
+ - **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:
15
+ - store releases for both mobile apps, signed, from your computer or a workflow, with build numbers made from each app's version;
16
+ - push notifications for both apps (`--push expo` or `--push native`);
17
+ - over-the-air updates for the native app (`--ota eas`).
18
+
19
+ ## 0.45.0
20
+
21
+ - **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:
22
+ - an update policy for apps installed on phones: a minimum announced for a date, values per platform, and APIs that answer apps below the minimum `426 update_required`;
23
+ - a new design for the native app (`--mobile expo`);
24
+ - splash screens that are never cropped or stretched.
25
+ - **`package.json` merges by key** (#158) when `add`, `remove`, or `upgrade` merges the Starter's changes into your project:
26
+ - A key only one side changed takes that side's value, and objects merge key by key, so your dependencies and the Starter's no longer collide line by line.
27
+ - A key both sides changed differently is a conflict, with the usual markers around that key's lines only.
28
+ - Your key order and indentation are kept. A file that doesn't parse, such as one with markers left from an earlier merge, merges by line as before.
29
+ - **The Starter pin** keeps the release a change came with in notes such as "since CLI 0.44.0, with Starter v0.28.0", which moving the pin to v0.29.0 had changed.
30
+ - CI uses astral-sh/setup-uv 10.3.2 (#154).
31
+
5
32
  ## 0.44.0
6
33
 
7
34
  - **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):
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.28.0
246
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.30.0
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,21 +260,33 @@ 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.28.0 # or a specific tag, branch, or commit
267
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.30.0 # 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.
267
271
 
268
272
  - Files you never changed are updated, added, or deleted.
269
273
  - 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.
274
+ - `package.json` files merge by key, not by line: a script, dependency, or override you added next to the Starter's keeps both, and markers wrap only a key you both changed to different values, such as the same dependency's range. The file keeps your key order, indentation, and line endings.
270
275
  - Your own files are never touched.
271
276
  - 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.
272
277
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
273
278
 
274
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`.
275
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
+
276
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.
277
291
 
278
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.
@@ -399,27 +413,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
399
413
  Each CLI release is tested against one specific Starter release:
400
414
 
401
415
  ```text
402
- create-clubedge-app v0.44.0
416
+ create-clubedge-app v0.46.0
403
417
  |
404
418
  v
405
- Clubedge Starter v0.28.0
406
- commit 090b05a9561f6eb7bb3bc0ab0b15ada7702aef8c
419
+ Clubedge Starter v0.30.0
420
+ commit 4d341955bd13cb170c7385acb745c3d374259897
407
421
  ```
408
422
 
409
423
  The current relationship is:
410
424
 
411
425
  | CLI release | Default Starter release | Meaning |
412
426
  | --- | --- | --- |
413
- | `create-clubedge-app@0.44.0` | `clubedge-starter@v0.28.0` | The CLI's required, tested default |
427
+ | `create-clubedge-app@0.46.0` | `clubedge-starter@v0.30.0` | The CLI's required, tested default |
414
428
 
415
429
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
416
430
 
417
- The `v0.28.0` tag is expected to resolve to commit
418
- `090b05a9561f6eb7bb3bc0ab0b15ada7702aef8c`. The CLI verifies this before
431
+ The `v0.30.0` tag is expected to resolve to commit
432
+ `4d341955bd13cb170c7385acb745c3d374259897`. The CLI verifies this before
419
433
  scaffolding with the default reference and fails rather than silently using a
420
434
  moved tag.
421
435
 
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.
436
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.30.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.
423
437
 
424
438
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
425
439
 
@@ -474,7 +488,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
474
488
  1. Make and validate changes in `clubedge-starter`.
475
489
  2. Run lint, typecheck, unit tests, browser tests, and production build.
476
490
  3. Update the Starter package versions.
477
- 4. Commit `main`, create a release tag such as `v0.28.0`, and publish the Starter release.
491
+ 4. Commit `main`, create a release tag such as `v0.30.0`, and publish the Starter release.
478
492
 
479
493
  ### CLI maintainers
480
494
 
@@ -529,6 +543,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
529
543
  | 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 |
530
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 |
531
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 |
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` |
532
548
 
533
549
  ## Community
534
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),
@@ -1,7 +1,8 @@
1
1
  // Three-way merge between what the CLI generated (base), what it would generate now (target),
2
2
  // and the project as it is (current). Changes from base to target are applied to the project
3
3
  // without losing the project's own edits. Pure: callers read and write the files.
4
- import { diff3Merge } from "node-diff3";
4
+ import { diff3Merge, diffComm } from "node-diff3";
5
+ import { mergePackageJson } from "./package-json-merge.js";
5
6
  export const CONFLICT_LABELS = { yours: "yours", theirs: "clubedge" };
6
7
  /**
7
8
  * Where the Starter's versions of files left to resolve are saved, next to the project's own,
@@ -35,28 +36,41 @@ export function sameContent(a, b) {
35
36
  return a.equals(b);
36
37
  return normalize(a) === normalize(b);
37
38
  }
38
- /** Merges base -> target into current. Resolves null for binary files, which cannot be merged. */
39
- export function mergeText(current, base, target) {
40
- if (isBinary(current) || isBinary(base) || isBinary(target))
41
- return null;
42
- const split = (content) => normalize(content).split("\n");
43
- const regions = diff3Merge(split(current), split(base), split(target), { excludeFalseConflicts: true });
39
+ /** The merged text, with conflict markers around each region where the two sides differ. */
40
+ function withMarkers(regions) {
44
41
  const lines = [];
45
42
  let conflict = false;
46
43
  for (const region of regions) {
47
- if (region.ok) {
44
+ if ("ok" in region) {
48
45
  lines.push(...region.ok);
49
46
  continue;
50
47
  }
51
- if (!region.conflict)
52
- continue;
53
48
  conflict = true;
54
- lines.push(`<<<<<<< ${CONFLICT_LABELS.yours}`, ...region.conflict.a, "=======", ...region.conflict.b, `>>>>>>> ${CONFLICT_LABELS.theirs}`);
49
+ lines.push(`<<<<<<< ${CONFLICT_LABELS.yours}`, ...region.yours, "=======", ...region.theirs, `>>>>>>> ${CONFLICT_LABELS.theirs}`);
55
50
  }
56
51
  return { text: lines.join("\n"), conflict };
57
52
  }
53
+ /** Merges base -> target into current. Resolves null for binary files, which cannot be merged. */
54
+ export function mergeText(current, base, target) {
55
+ if (isBinary(current) || isBinary(base) || isBinary(target))
56
+ return null;
57
+ const split = (content) => normalize(content).split("\n");
58
+ const regions = diff3Merge(split(current), split(base), split(target), { excludeFalseConflicts: true });
59
+ return withMarkers(regions.flatMap((region) => region.ok ? [{ ok: region.ok }] : region.conflict ? [{ yours: region.conflict.a, theirs: region.conflict.b }] : []));
60
+ }
61
+ /**
62
+ * Merges a package.json by key (see package-json-merge.ts), so edits to different keys never
63
+ * conflict, and markers wrap only the lines of a key both sides changed differently. Resolves
64
+ * null when a side does not parse, for mergeText to merge it by line.
65
+ */
66
+ export function mergePackageJsonText(current, base, target) {
67
+ const merged = mergePackageJson(normalize(current), normalize(base), normalize(target));
68
+ if (!merged)
69
+ return null;
70
+ return withMarkers(diffComm(merged.yours.split("\n"), merged.theirs.split("\n")).map((region) => region.common !== undefined ? { ok: region.common } : { yours: region.buffer1, theirs: region.buffer2 }));
71
+ }
58
72
  function merged(path, current, base, target, binaryReason) {
59
- const result = mergeText(current, base, target);
73
+ const result = (/(^|\/)package\.json$/.test(path) ? mergePackageJsonText(current, base, target) : null) ?? mergeText(current, base, target);
60
74
  if (!result)
61
75
  return { path, kind: "keep", reason: binaryReason, incoming: target };
62
76
  const content = withLineEndings(result.text, current);
@@ -0,0 +1,106 @@
1
+ // package.json files merge by key instead of by line (#142), so edits to different keys never
2
+ // conflict: a script the project adds after the Starter's, or an override next to one the
3
+ // Starter adds. A key both sides changed to different values is a conflict. The merged file is
4
+ // written twice, with your value for each such key and with the Starter's, and merge.ts puts
5
+ // conflict markers around the lines where the two differ, which are that key's.
6
+ /** A key both sides changed to different values; undefined is a side that removed it. */
7
+ class Conflict {
8
+ yours;
9
+ theirs;
10
+ constructor(yours, theirs) {
11
+ this.yours = yours;
12
+ this.theirs = theirs;
13
+ }
14
+ }
15
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Conflict);
16
+ const own = (object, key) => (Object.hasOwn(object, key) ? object[key] : undefined);
17
+ /** Sets a key as data, so a key named __proto__ cannot change the object's prototype. */
18
+ function set(object, key, value) {
19
+ Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true });
20
+ }
21
+ /** Equal JSON values; objects regardless of their key order. */
22
+ function same(a, b) {
23
+ if (a === b)
24
+ return true;
25
+ if (Array.isArray(a) && Array.isArray(b))
26
+ return a.length === b.length && a.every((item, i) => same(item, b[i]));
27
+ if (isObject(a) && isObject(b)) {
28
+ const keys = Object.keys(a);
29
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && same(a[key], b[key]));
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * One key's value: whoever changed it, or a conflict when both did, differently. Objects both
35
+ * changed merge key by key; arrays and scalars are single values. Undefined is a removed key.
36
+ */
37
+ function merge(base, yours, theirs) {
38
+ if (same(yours, theirs) || same(base, theirs))
39
+ return yours;
40
+ if (same(base, yours))
41
+ return theirs;
42
+ if (isObject(yours) && isObject(theirs))
43
+ return mergeObjects(isObject(base) ? base : {}, yours, theirs);
44
+ return new Conflict(yours, theirs);
45
+ }
46
+ /**
47
+ * Your keys in your order; a key only the Starter has goes after the key it follows in the
48
+ * Starter's file, or first. At the root, `clubedge` (the provenance) is always the Starter's.
49
+ */
50
+ function mergeObjects(base, yours, theirs, root = false) {
51
+ const order = Object.keys(yours);
52
+ let previous;
53
+ for (const key of Object.keys(theirs)) {
54
+ if (!order.includes(key))
55
+ order.splice(previous === undefined ? 0 : order.indexOf(previous) + 1, 0, key);
56
+ previous = key;
57
+ }
58
+ const result = {};
59
+ for (const key of order) {
60
+ const value = root && key === "clubedge" ? own(theirs, key) : merge(own(base, key), own(yours, key), own(theirs, key));
61
+ if (value !== undefined)
62
+ set(result, key, value);
63
+ }
64
+ return result;
65
+ }
66
+ /** The merged value with one side's value for each conflict. */
67
+ function resolve(value, side) {
68
+ if (value instanceof Conflict)
69
+ return value[side];
70
+ if (!isObject(value))
71
+ return value;
72
+ const result = {};
73
+ for (const [key, item] of Object.entries(value)) {
74
+ const resolved = resolve(item, side);
75
+ if (resolved !== undefined)
76
+ set(result, key, resolved);
77
+ }
78
+ return result;
79
+ }
80
+ /**
81
+ * The file's indentation, as npm reads it: the whitespace before the first key, after the
82
+ * opening brace's line. A file on one line has none, and stays on one line.
83
+ */
84
+ function indentation(text) {
85
+ return /^\s*\{\n([ \t]*)"/.exec(text)?.[1] ?? "";
86
+ }
87
+ /**
88
+ * Merges base -> target into current, all LF text. Resolves the result written with your value
89
+ * for each conflict and with the Starter's (equal when there are none), in your file's
90
+ * indentation and final newline; null when a side is not a JSON object, to merge by line.
91
+ */
92
+ export function mergePackageJson(current, base, target) {
93
+ let parsed;
94
+ try {
95
+ parsed = [JSON.parse(current), JSON.parse(base), JSON.parse(target)];
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const [yours, before, theirs] = parsed;
101
+ if (!isObject(yours) || !isObject(before) || !isObject(theirs))
102
+ return null;
103
+ const merged = mergeObjects(before, yours, theirs, true);
104
+ const write = (side) => JSON.stringify(resolve(merged, side), null, indentation(current)) + (current.endsWith("\n") ? "\n" : "");
105
+ return { yours: write("yours"), theirs: write("theirs") };
106
+ }
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]);