@clubedge/create-clubedge-app 0.45.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 (146) hide show
  1. package/CHANGELOG.md +14 -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 +12 -0
  51. package/template/apps/capacitor/clubedge.stack.json +9 -1
  52. package/template/apps/capacitor/package.json +5 -2
  53. package/template/apps/capacitor/scripts/build.mjs +13 -4
  54. package/template/apps/capacitor/scripts/push.mjs +80 -0
  55. package/template/apps/capacitor/scripts/release.mjs +133 -0
  56. package/template/apps/capacitor/scripts/version.mjs +129 -0
  57. package/template/apps/capacitor/version.json +3 -0
  58. package/template/apps/expo/.env.example +7 -0
  59. package/template/apps/expo/app.config.ts +76 -33
  60. package/template/apps/expo/app.json +5 -0
  61. package/template/apps/expo/clubedge.stack.json +33 -2
  62. package/template/apps/expo/eas.json +33 -0
  63. package/template/apps/expo/package.json +5 -2
  64. package/template/apps/expo/scripts/set-version.mjs +66 -0
  65. package/template/apps/expo/src/app/(app)/index.tsx +2 -0
  66. package/template/apps/expo/src/app/(app)/settings/index.tsx +4 -2
  67. package/template/apps/expo/src/components/ota-banner.tsx +69 -0
  68. package/template/apps/expo/src/components/push-settings.tsx +56 -0
  69. package/template/apps/expo/src/components/sign-out-sheet.tsx +3 -0
  70. package/template/apps/expo/src/components/update-gate.tsx +5 -1
  71. package/template/apps/expo/src/lib/ota.ts +30 -0
  72. package/template/apps/expo/src/lib/push-token.ts +20 -0
  73. package/template/apps/expo/src/lib/push.ts +161 -0
  74. package/template/apps/expo/src/variants/app-layout.auth.tsx +2 -0
  75. package/template/apps/expo/src/variants/push-token.native.ts +14 -0
  76. package/template/apps/expo/variants/eas.updates.json +36 -0
  77. package/template/apps/start/clubedge.stack.json +3 -0
  78. package/template/apps/start/package.json +1 -1
  79. package/template/apps/vite/clubedge.stack.json +5 -0
  80. package/template/apps/vite/package.json +1 -1
  81. package/template/apps/vite/server.mjs +3 -0
  82. package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +7 -0
  83. package/template/apps/vite/src/main.tsx +5 -2
  84. package/template/apps/vite/src/routes/settings.tsx +19 -4
  85. package/template/apps/web/clubedge.stack.json +3 -0
  86. package/template/apps/web/package.json +1 -1
  87. package/template/clubedge/modules/ota.json +15 -0
  88. package/template/clubedge/modules/push.json +36 -0
  89. package/template/clubedge.template.json +18 -2
  90. package/template/package.json +1 -1
  91. package/template/packages/auth/package.json +1 -1
  92. package/template/packages/auth-appwrite/package.json +1 -1
  93. package/template/packages/auth-better-auth/package.json +1 -1
  94. package/template/packages/auth-firebase/package.json +1 -1
  95. package/template/packages/auth-supabase/package.json +1 -1
  96. package/template/packages/cache/package.json +1 -1
  97. package/template/packages/cache-redis/package.json +1 -1
  98. package/template/packages/contract/openapi.yaml +149 -0
  99. package/template/packages/contract/package.json +1 -1
  100. package/template/packages/contract/src/conformance.ts +43 -0
  101. package/template/packages/contract/src/schema.ts +179 -0
  102. package/template/packages/core/package.json +1 -1
  103. package/template/packages/db/package.json +1 -1
  104. package/template/packages/email/package.json +1 -1
  105. package/template/packages/email-resend/package.json +1 -1
  106. package/template/packages/email-smtp/package.json +1 -1
  107. package/template/packages/jobs/package.json +1 -1
  108. package/template/packages/jobs-bullmq/package.json +1 -1
  109. package/template/packages/jobs-inngest/package.json +1 -1
  110. package/template/packages/jobs-pg-boss/package.json +1 -1
  111. package/template/packages/local-db/package.json +1 -1
  112. package/template/packages/mobile/package.json +5 -2
  113. package/template/packages/mobile/src/push-settings.tsx +95 -0
  114. package/template/packages/mobile/src/push.test.ts +153 -0
  115. package/template/packages/mobile/src/push.ts +126 -0
  116. package/template/packages/observability/package.json +1 -1
  117. package/template/packages/observability-otel/package.json +1 -1
  118. package/template/packages/observability-sentry/package.json +1 -1
  119. package/template/packages/payments/package.json +1 -1
  120. package/template/packages/payments-stripe/package.json +1 -1
  121. package/template/packages/push/drizzle/0000_initial.sql +11 -0
  122. package/template/packages/push/drizzle/meta/0000_snapshot.json +96 -0
  123. package/template/packages/push/drizzle/meta/_journal.json +13 -0
  124. package/template/packages/push/drizzle.config.ts +12 -0
  125. package/template/packages/push/package.json +35 -0
  126. package/template/packages/push/scripts/migrate.ts +14 -0
  127. package/template/packages/push/src/devices.test.ts +133 -0
  128. package/template/packages/push/src/devices.ts +78 -0
  129. package/template/packages/push/src/index.ts +12 -0
  130. package/template/packages/push/src/schema.ts +29 -0
  131. package/template/packages/push/src/types.ts +59 -0
  132. package/template/packages/push/tsconfig.json +4 -0
  133. package/template/packages/push-expo/package.json +21 -0
  134. package/template/packages/push-expo/src/index.test.ts +82 -0
  135. package/template/packages/push-expo/src/index.ts +70 -0
  136. package/template/packages/push-expo/tsconfig.json +4 -0
  137. package/template/packages/push-native/package.json +21 -0
  138. package/template/packages/push-native/src/index.test.ts +245 -0
  139. package/template/packages/push-native/src/index.ts +291 -0
  140. package/template/packages/push-native/tsconfig.json +4 -0
  141. package/template/packages/storage/package.json +1 -1
  142. package/template/packages/storage-s3/package.json +1 -1
  143. package/template/packages/storage-supabase/package.json +1 -1
  144. package/template/packages/ui/package.json +1 -1
  145. package/template/pnpm-lock.yaml +152 -0
  146. package/template.lock.json +58 -2
package/dist/modify.js CHANGED
@@ -1,17 +1,19 @@
1
1
  // `add`, `remove`, and `upgrade` change an existing project. The project's recorded Starter
2
- // commit and modules (base) and the new state (target: other modules, or a newer Starter) are
3
- // rendered in memory, and the difference is merged into the project (see core/update.ts).
2
+ // commit, apps, and modules (base) and the new state (target: other modules or apps, or a newer
3
+ // Starter) are rendered in memory, and the difference is merged into the project (see
4
+ // core/update.ts).
4
5
  import { confirm, isCancel, log, multiselect, outro, select, spinner } from "@clack/prompts";
5
- import { readFile } from "node:fs/promises";
6
+ import { readdir, readFile } from "node:fs/promises";
6
7
  import { join } from "node:path";
7
8
  import process from "node:process";
8
9
  import { UsageError } from "./cli/args.js";
9
10
  import { dim } from "./cli/ui.js";
11
+ import { runsServer } from "./core/manifest-fields.js";
10
12
  import { readTemplateManifest } from "./core/manifest.js";
11
13
  import { compareVersions } from "./core/health.js";
12
14
  import { countConflicts } from "./core/merge.js";
13
15
  import { describeReleases, fetchReleases, releasesBetween } from "./core/releases.js";
14
- import { moduleChoices, selectModules, selectStacks } from "./core/plan.js";
16
+ import { moduleChoices, NO_STACK, selectModules, selectStacks, slotChoices, stacksIn } from "./core/plan.js";
15
17
  import { readProject } from "./core/project.js";
16
18
  import { downloadGitHubTemplate, readDirectoryTemplate, resolveTemplateSource, } from "./core/source.js";
17
19
  import { applyUpdate, INCOMING_DIR, planUpdate } from "./core/update.js";
@@ -19,8 +21,27 @@ import { cliVersion, starterPin } from "./package-info.js";
19
21
  import { parseEnvFile } from "./core/deploy.js";
20
22
  import { describeNewDatabase, projectDatabaseEnv, provisionNeonDatabase } from "./neon.js";
21
23
  import { detectPnpm, installDependencies } from "./steps/install.js";
22
- import { listTemplateFiles } from "./utils/fs.js";
24
+ import { listTemplateFiles, pathExists } from "./utils/fs.js";
23
25
  import { commandSucceeds, runCapture } from "./utils/process.js";
26
+ /**
27
+ * The slots add and remove can fill or empty: those of the apps installed on devices, a shell or a
28
+ * mobile app, which run no server. The framework and the API are chosen when a project is
29
+ * created, since its deployment, database, and data are built around them.
30
+ */
31
+ export function appSlots(manifest) {
32
+ return Object.keys(manifest.slots).filter((id) => manifest.slots[id].roles.every((role) => !runsServer(role)));
33
+ }
34
+ /** Choices in a sentence: "a", "a or b", or "a, b, or c". */
35
+ const orList = (names) => (names.length > 2 ? `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}` : names.join(" or "));
36
+ /** A slot's name inside a sentence: "the framework", "the mobile app", but "the API". */
37
+ const lowerName = (name) => (/^[A-Z]{2,}$/.test(name) ? name : name.toLowerCase());
38
+ /** The stack a project, or one side of a change, has in a slot, or none. */
39
+ const stackIn = (side, slotId) => side.stacks?.[slotId] ?? NO_STACK;
40
+ /** The stacks of an app slot that fit the project's other apps, for prompts. */
41
+ function addableStacks(manifest, project, slotId) {
42
+ const { [slotId]: _, ...others } = selectStacks(manifest, project.stacks);
43
+ return slotChoices(manifest, slotId, others).filter((id) => id !== NO_STACK && id !== stackIn(project, slotId));
44
+ }
24
45
  /** How to run the CLI in this project: its `clubedge` script when it has one. */
25
46
  export async function cliCommand(project) {
26
47
  try {
@@ -100,25 +121,33 @@ export async function completeCommand(manifest, project, command, interactive) {
100
121
  return { name: "remove", modules: command.modules };
101
122
  if (!interactive) {
102
123
  throw new UsageError(command.name === "add"
103
- ? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
104
- : "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
124
+ ? "Usage: add <module> <option>, for example: add cache redis, or add mobile expo. Run it in a terminal to choose from a list."
125
+ : "Usage: remove <module>, for example: remove storage, or remove mobile. Run it in a terminal to choose from a list.");
105
126
  }
106
- // The modules: named on the command line, or picked from a list (several at once).
127
+ // The apps and modules: named on the command line, or picked from a list (several at once).
107
128
  let moduleIds = command.name === "add" ? command.changes.map((change) => change.module) : [];
108
129
  if (!moduleIds.length) {
130
+ const apps = appSlots(manifest).filter((id) => command.name === "add" ? addableStacks(manifest, project, id).length > 0 : stackIn(project, id) !== NO_STACK);
109
131
  const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
110
- if (!choices.length)
111
- throw new UsageError("No module can be removed: every removable module is already none.");
132
+ if (!choices.length && !apps.length)
133
+ throw new UsageError("Nothing can be removed: every removable module is already none, and the project has no app to remove.");
112
134
  const value = await multiselect({
113
135
  message: command.name === "add"
114
- ? "Which modules do you want to add or switch? (space to pick, enter to confirm)"
115
- : "Which modules do you want to remove? (space to pick, enter to confirm)",
136
+ ? "What do you want to add or switch? (space to pick, enter to confirm)"
137
+ : "What do you want to remove? (space to pick, enter to confirm)",
116
138
  required: true,
117
- options: choices.map(([id, module]) => ({
118
- value: id,
119
- label: module.name,
120
- hint: `now: ${module.options[project.modules[id] ?? ""]?.name ?? project.modules[id] ?? "not set"}`,
121
- })),
139
+ options: [
140
+ ...apps.map((id) => ({
141
+ value: id,
142
+ label: manifest.slots[id].name,
143
+ hint: `now: ${manifest.stacks[stackIn(project, id)]?.name ?? "none"}`,
144
+ })),
145
+ ...choices.map(([id, module]) => ({
146
+ value: id,
147
+ label: module.name,
148
+ hint: `now: ${module.options[project.modules[id] ?? ""]?.name ?? project.modules[id] ?? "not set"}`,
149
+ })),
150
+ ],
122
151
  });
123
152
  if (isCancel(value))
124
153
  return null;
@@ -126,8 +155,9 @@ export async function completeCommand(manifest, project, command, interactive) {
126
155
  }
127
156
  if (command.name === "remove")
128
157
  return { name: "remove", modules: moduleIds };
129
- // Then an option for each module that was not given one. They apply together, so options that
130
- // only work together (Supabase Auth with Supabase Storage) can be picked in one go.
158
+ // Then an option for each module, or a stack for each app, that was not given one. They apply
159
+ // together, so options that only work together (Supabase Auth with Supabase Storage) can be
160
+ // picked in one go.
131
161
  const changes = [];
132
162
  for (const moduleId of moduleIds) {
133
163
  const given = command.changes.find((change) => change.module === moduleId)?.option;
@@ -135,6 +165,24 @@ export async function completeCommand(manifest, project, command, interactive) {
135
165
  changes.push({ module: moduleId, option: given });
136
166
  continue;
137
167
  }
168
+ const slot = appSlots(manifest).includes(moduleId) ? manifest.slots[moduleId] : undefined;
169
+ if (slot) {
170
+ // With one stack that fits, there is nothing to ask; with none, the change says why.
171
+ const stacks = addableStacks(manifest, project, moduleId);
172
+ if (stacks.length > 1) {
173
+ const stack = await select({
174
+ message: `${slot.name}: which one?`,
175
+ options: stacks.map((id) => ({ value: id, label: manifest.stacks[id].name })),
176
+ });
177
+ if (isCancel(stack))
178
+ return null;
179
+ changes.push({ module: moduleId, option: stack });
180
+ }
181
+ else {
182
+ changes.push({ module: moduleId, option: stacks[0] ?? stacksIn(manifest, moduleId)[0] ?? NO_STACK });
183
+ }
184
+ continue;
185
+ }
138
186
  const module = manifest.modules[moduleId];
139
187
  if (!module) {
140
188
  throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
@@ -157,17 +205,63 @@ export async function completeCommand(manifest, project, command, interactive) {
157
205
  return { name: "add", changes };
158
206
  }
159
207
  /**
160
- * The selection after add or remove, validated against the Starter's manifest. Every change
161
- * applies before requirements are checked, so changes that only work together, such as Supabase
162
- * Auth with Supabase Storage, can be made in one command.
208
+ * Why a project cannot take the app added to `slotId`, in terms of what it would need: add and
209
+ * remove never change the framework or the API, so the way there is a new project. Null when
210
+ * the app fits its role, and the Starter's own rules decide.
163
211
  */
164
- export function changedModules(manifest, project, command, cli) {
165
- const changes = command.name === "add" ? command.changes : command.modules.map((module) => ({ module, option: "none" }));
212
+ function appConflict(manifest, stacks, slotId) {
213
+ const app = manifest.stacks[stacks[slotId] ?? ""];
214
+ if (!app)
215
+ return null;
216
+ const selected = Object.values(selectStacksLoosely(manifest, stacks)).flatMap((id) => manifest.stacks[id] ?? []);
217
+ const withRole = (role) => selected.find((stack) => stack.role === role);
218
+ const ids = (role) => Object.keys(manifest.stacks).filter((id) => manifest.stacks[id].role === role);
219
+ const fixed = "A project's framework and API are chosen when it is created, so add cannot change them";
220
+ if (app.role === "mobile" && !withRole("api")) {
221
+ const site = withRole("fullstack");
222
+ return (`${app.name} calls the project's API, and this project has none${site ? `: ${site.name} serves its own pages` : ""}. ` +
223
+ `${fixed}: for ${app.name}, create a project with --framework ${orList([...ids("frontend"), NO_STACK])}, and --api ${orList(ids("api"))}.`);
224
+ }
225
+ if (app.role === "shell" && !withRole("frontend")) {
226
+ const site = withRole("fullstack");
227
+ return (`${app.name} packages a frontend's static files, and ${site ? `${site.name} renders its pages on its server` : "this project has no website"}. ` +
228
+ `${fixed}: for ${app.name}, create a project with --framework ${orList(ids("frontend"))}.`);
229
+ }
230
+ return null;
231
+ }
232
+ /** The stack per slot as recorded, with each empty slot's default, without checking roles. */
233
+ function selectStacksLoosely(manifest, stacks) {
234
+ return Object.fromEntries(Object.entries(manifest.slots).map(([id, slot]) => [id, stacks[id] ?? slot.default ?? NO_STACK]));
235
+ }
236
+ /**
237
+ * The apps and modules after add or remove, validated against the Starter's manifest. Every
238
+ * change applies before requirements are checked, so changes that only work together, such as
239
+ * Supabase Auth with Supabase Storage, or the native app with an auth it implements, can be made
240
+ * in one command. `stacks` keeps the project's recorded form, with the changed app slots set.
241
+ */
242
+ export function changedSelection(manifest, project, command, cli) {
243
+ const changes = command.name === "add" ? command.changes : command.modules.map((module) => ({ module, option: NO_STACK }));
244
+ const apps = appSlots(manifest);
245
+ const stacks = { ...project.stacks };
246
+ const changedApps = [];
166
247
  const requested = { ...project.modules };
167
248
  for (const { module: id, option } of changes) {
249
+ const slot = manifest.slots[id];
250
+ if (slot) {
251
+ if (!apps.includes(id)) {
252
+ throw new UsageError(`The ${lowerName(slot.name)} is chosen when a project is created: add and remove change modules${apps.length ? ` and the apps installed on devices (${apps.join(", ")})` : ""}.`);
253
+ }
254
+ const offered = stacksIn(manifest, id);
255
+ if (option !== NO_STACK && !offered.includes(option)) {
256
+ throw new UsageError(`Unknown ${lowerName(slot.name)} "${option}". Choose one of: ${offered.join(", ")}.`);
257
+ }
258
+ stacks[id] = option;
259
+ changedApps.push(id);
260
+ continue;
261
+ }
168
262
  const module = manifest.modules[id];
169
263
  if (!module) {
170
- throw new UsageError(`Unknown module "${id}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
264
+ throw new UsageError(`Unknown module "${id}". Choose one of: ${Object.keys(manifest.modules).join(", ")}${apps.length ? `, or an app: ${apps.join(", ")}` : ""}.`);
171
265
  }
172
266
  if (command.name === "remove" && !module.options.none) {
173
267
  const others = Object.keys(module.options).filter((other) => other !== project.modules[id]);
@@ -178,9 +272,30 @@ export function changedModules(manifest, project, command, cli) {
178
272
  }
179
273
  requested[id] = option;
180
274
  }
181
- // Validates requirements between modules, such as Supabase Storage needing Supabase Auth, and
182
- // that the project's stacks implement the new options.
183
- return selectModules(manifest, requested, selectStacks(manifest, project.stacks));
275
+ // An added app must fit the project's other apps, and implement the modules it uses: a module
276
+ // option it lacks can be switched in the same command.
277
+ for (const slotId of changedApps) {
278
+ const conflict = appConflict(manifest, stacks, slotId);
279
+ if (conflict)
280
+ throw new UsageError(conflict);
281
+ const app = manifest.stacks[stacks[slotId]];
282
+ for (const [moduleId, implemented] of Object.entries(app?.modules ?? {})) {
283
+ const option = requested[moduleId];
284
+ if (option === undefined || implemented.includes(option))
285
+ continue;
286
+ const module = manifest.modules[moduleId];
287
+ throw new UsageError(`${app.name} does not implement ${module.options[option]?.name ?? option} (${moduleId} ${option}). It implements: ${implemented.join(", ")}. ` +
288
+ `Switch it in the same command, for example: ${cli} add ${slotId} ${stacks[slotId]} ${moduleId} ${implemented.find((id) => id !== NO_STACK) ?? implemented[0]}`);
289
+ }
290
+ }
291
+ // Validates the Starter's rules for the apps, requirements between modules, such as Supabase
292
+ // Storage needing Supabase Auth, and that the project's stacks implement the new options.
293
+ const selection = selectStacks(manifest, stacks);
294
+ return { stacks, modules: selectModules(manifest, requested, selection), apps: changedApps };
295
+ }
296
+ /** The modules after add or remove (see changedSelection). */
297
+ export function changedModules(manifest, project, command, cli) {
298
+ return changedSelection(manifest, project, command, cli).modules;
184
299
  }
185
300
  /**
186
301
  * The project's selection in a newer Starter. Modules the project predates get their "none"
@@ -219,7 +334,9 @@ function describe(plan) {
219
334
  lines.push(`${"update".padEnd(8)} pnpm-lock.yaml`);
220
335
  if (plan.lockfile.kind === "install")
221
336
  lines.push(`${"install".padEnd(8)} pnpm-lock.yaml ${dim("(pnpm install reconciles your dependency changes)")}`);
222
- for (const { path, update: env, changed } of plan.envs) {
337
+ for (const { path, update: env, changed, create } of plan.envs) {
338
+ if (create)
339
+ lines.push(`${"add".padEnd(8)} ${path} ${dim("(from its example)")}`);
223
340
  if (!env || !changed)
224
341
  continue;
225
342
  const parts = [
@@ -227,7 +344,7 @@ function describe(plan) {
227
344
  env.updated.length ? `updates ${env.updated.join(", ")}` : "",
228
345
  env.removed.length ? `removes ${env.removed.join(", ")}` : "",
229
346
  ].filter(Boolean);
230
- lines.push(`${"update".padEnd(8)} ${path} ${dim(`(${parts.join("; ")})`)}`);
347
+ lines.push(`${"update".padEnd(8)} ${path} ${dim(`(${parts.length ? parts.join("; ") : "comments follow the example"})`)}`);
231
348
  }
232
349
  return lines.join("\n");
233
350
  }
@@ -272,9 +389,10 @@ function report(plan) {
272
389
  .map((action) => ` ${action.path}: ${action.reason}${action.incoming ? dim(` The Starter's version: ${incoming(action.path)}`) : ""}`)
273
390
  .join("\n")}`);
274
391
  }
275
- for (const { path, update: env } of plan.envs) {
392
+ for (const { path, update: env, create } of plan.envs) {
276
393
  if (!env) {
277
- log.info(`${path} does not exist yet; create it from the example file.`);
394
+ if (!create)
395
+ log.info(`${path} does not exist yet; create it from the example file.`);
278
396
  continue;
279
397
  }
280
398
  if (env.customized.length) {
@@ -287,17 +405,29 @@ function report(plan) {
287
405
  }
288
406
  /** Works out what the command changes; resolves null when the project is already there. */
289
407
  async function planChange(project, command, base, baseManifest, target, cli) {
290
- const baseSide = { source: asRecorded(base, project), manifest: baseManifest, modules: project.modules, cliVersion: project.cliVersion };
408
+ const baseSide = {
409
+ source: asRecorded(base, project),
410
+ manifest: baseManifest,
411
+ stacks: project.stacks,
412
+ modules: project.modules,
413
+ cliVersion: project.cliVersion,
414
+ };
291
415
  if (command.name !== "upgrade") {
292
- const modules = changedModules(baseManifest, project, command, cli);
416
+ const { stacks, modules, apps } = changedSelection(baseManifest, project, command, cli);
417
+ const changedApps = apps.filter((id) => stackIn({ stacks }, id) !== stackIn(project, id));
293
418
  const changed = Object.keys(modules).filter((id) => modules[id] !== project.modules[id]);
294
- if (!changed.length)
419
+ if (!changed.length && !changedApps.length)
295
420
  return null;
421
+ const stackName = (id) => baseManifest.stacks[id]?.name ?? NO_STACK;
296
422
  return {
297
423
  base: baseSide,
298
- target: { ...baseSide, modules, cliVersion },
299
- summary: changed.map((id) => `${baseManifest.modules[id].name}: ${project.modules[id]} -> ${modules[id]}`).join(", "),
424
+ target: { ...baseSide, stacks, modules, cliVersion },
425
+ summary: [
426
+ ...changedApps.map((id) => `${baseManifest.slots[id].name}: ${stackName(stackIn(project, id))} -> ${stackName(stackIn({ stacks }, id))}`),
427
+ ...changed.map((id) => `${baseManifest.modules[id].name}: ${project.modules[id]} -> ${modules[id]}`),
428
+ ].join(", "),
300
429
  notes: [],
430
+ apps: changedApps,
301
431
  };
302
432
  }
303
433
  if (!target)
@@ -328,10 +458,11 @@ async function planChange(project, command, base, baseManifest, target, cli) {
328
458
  }
329
459
  return {
330
460
  base: baseSide,
331
- target: { source: target, manifest: targetManifest, modules, cliVersion },
461
+ target: { source: target, manifest: targetManifest, stacks: project.stacks, modules, cliVersion },
332
462
  summary: `Starter ${project.starterRef} -> ${target.ref}`,
333
463
  notes,
334
464
  releases,
465
+ apps: [],
335
466
  };
336
467
  }
337
468
  /**
@@ -360,6 +491,27 @@ async function addNeonDatabase(project, args, cli) {
360
491
  log.warn(`${error instanceof Error ? error.message : String(error)} Run \`${cli} neon create\` to try again.`);
361
492
  }
362
493
  }
494
+ /**
495
+ * Where an app the change adds is, and what is left of one it removes: the files that were never
496
+ * the Starter's, such as its env file, its dependencies, or native projects, stay.
497
+ */
498
+ async function reportApps(project, change) {
499
+ const { manifest } = change.target;
500
+ for (const slotId of change.apps) {
501
+ const slot = manifest.slots[slotId];
502
+ const stack = manifest.stacks[stackIn(change.target, slotId)];
503
+ if (stack) {
504
+ log.info(`${stack.name} is in ${slot.path}, with its settings in ${slot.env.target}. SETUP.md now has the steps to run and build it.`);
505
+ continue;
506
+ }
507
+ const folder = join(project.root, slot.path);
508
+ const left = (await pathExists(folder)) ? (await readdir(folder)).sort() : [];
509
+ if (!left.length)
510
+ continue;
511
+ const shown = `${left.slice(0, 4).join(", ")}${left.length > 4 ? ", ..." : ""}`;
512
+ log.info(`${slot.path} still holds what was never the Starter's (${shown}): its settings, dependencies, or native projects. Delete the folder once you no longer need them.`);
513
+ }
514
+ }
363
515
  export async function changeProject(args) {
364
516
  const command = args.command;
365
517
  if (command.name !== "add" && command.name !== "remove" && command.name !== "upgrade")
@@ -376,7 +528,7 @@ export async function changeProject(args) {
376
528
  }
377
529
  if (command.name === "add" &&
378
530
  command.changes.length > 0 &&
379
- command.changes.every((c) => c.option && project.modules[c.module] === c.option)) {
531
+ command.changes.every((c) => c.option && (project.modules[c.module] ?? project.stacks[c.module]) === c.option)) {
380
532
  outro("The project already uses that selection; nothing to change.");
381
533
  return "unchanged";
382
534
  }
@@ -452,6 +604,7 @@ export async function changeProject(args) {
452
604
  log.info("Run `pnpm install` to update dependencies.");
453
605
  }
454
606
  report(plan);
607
+ await reportApps(project, change);
455
608
  for (const note of change.notes)
456
609
  log.info(note);
457
610
  if (installError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Create React apps from Clubedge Starter with a CLI or local MCP server.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -24,8 +24,8 @@
24
24
  },
25
25
  "clubedge": {
26
26
  "starterRepository": "Clubedge/clubedge-starter",
27
- "starterRef": "v0.29.0",
28
- "starterCommit": "29350332db4bc44acc1e7b989c04825ff4c0f7b7"
27
+ "starterRef": "v0.30.0",
28
+ "starterCommit": "4d341955bd13cb170c7385acb745c3d374259897"
29
29
  },
30
30
  "license": "Apache-2.0",
31
31
  "repository": {
@@ -43,11 +43,14 @@ Run the CLI from the generated project directory:
43
43
  ```sh
44
44
  pnpm clubedge add payments stripe
45
45
  pnpm clubedge remove storage
46
+ pnpm clubedge add mobile expo
46
47
  pnpm clubedge upgrade
47
48
  ```
48
49
 
49
50
  Check the project's Git state first. These commands preserve user edits and can report merge conflicts that need review. After a change, follow the CLI's instructions and run relevant checks, migrations, or service setup for the modules involved.
50
51
 
52
+ `add` and `remove` also take the apps installed on devices: the native app (`mobile expo`) and the shell that packages the Vite frontend (`shell capacitor`). The framework and the API stay as the project was created; when the project cannot take an app, the CLI says which project can.
53
+
51
54
  ## Project guidance
52
55
 
53
56
  - Keep secrets in the project's environment files and never print or commit secret values. With a separate API, the database URLs belong in `apps/api/.env.local`, since the API owns the database.
@@ -0,0 +1,65 @@
1
+ name: Native app release
2
+
3
+ # Builds the native app (apps/mobile) in Expo's cloud with EAS Build, signed with the credentials
4
+ # EAS keeps for the project, and can submit the builds to the App Store and Google Play. It needs
5
+ # an EXPO_TOKEN repository secret (expo.dev, Account settings, Access tokens), the app linked to an
6
+ # EAS project with `eas init`, and its signing credentials set up once by an interactive
7
+ # `eas build` or `eas credentials` (SETUP.md, "Store releases").
8
+ on:
9
+ workflow_dispatch:
10
+ inputs:
11
+ platform:
12
+ description: The stores to build for
13
+ type: choice
14
+ options: [all, android, ios]
15
+ default: all
16
+ profile:
17
+ description: The build profile in apps/mobile/eas.json
18
+ type: choice
19
+ options: [production, preview]
20
+ default: production
21
+ submit:
22
+ description: Submit production builds to the stores once they are done
23
+ type: boolean
24
+ default: false
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ build:
31
+ runs-on: ubuntu-24.04
32
+ defaults:
33
+ run:
34
+ working-directory: apps/mobile
35
+ steps:
36
+ - uses: actions/checkout@v7
37
+ with:
38
+ persist-credentials: false
39
+ - uses: pnpm/action-setup@v6
40
+ - uses: actions/setup-node@v7
41
+ with:
42
+ node-version: 22
43
+ cache: pnpm
44
+ - run: pnpm install --frozen-lockfile
45
+ - name: Check the Expo token
46
+ env:
47
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
48
+ run: |
49
+ if [ -z "$EXPO_TOKEN" ]; then
50
+ echo "::error::Set an EXPO_TOKEN repository secret, from expo.dev (Account settings, Access tokens)."
51
+ exit 1
52
+ fi
53
+ # The builds run in Expo's cloud, with the variables of the profile's EAS environment, such
54
+ # as EXPO_PUBLIC_API_URL: this job queues them, and expo.dev shows their progress.
55
+ - name: Queue the builds
56
+ env:
57
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
58
+ PLATFORM: ${{ inputs.platform }}
59
+ PROFILE: ${{ inputs.profile }}
60
+ SUBMIT: ${{ inputs.submit && inputs.profile == 'production' }}
61
+ run: |
62
+ set -euo pipefail
63
+ args=(build --platform "$PLATFORM" --profile "$PROFILE" --non-interactive --no-wait)
64
+ if [ "$SUBMIT" = true ]; then args+=(--auto-submit); fi
65
+ pnpm dlx eas-cli@latest "${args[@]}"
@@ -0,0 +1,56 @@
1
+ name: Native app update
2
+
3
+ # Publishes the native app's JavaScript and assets over the air with EAS Update, to the installed
4
+ # apps of the same version on the channel you choose: each build profile in apps/mobile/eas.json
5
+ # names its channel. A change to native code or to app.config.ts needs a store release instead
6
+ # (Native app release). It needs the EXPO_TOKEN secret too (SETUP.md, "Over-the-air updates").
7
+ on:
8
+ workflow_dispatch:
9
+ inputs:
10
+ channel:
11
+ description: The channel whose builds get the update
12
+ type: choice
13
+ options: [production, preview]
14
+ default: production
15
+ message:
16
+ description: What the update changes, as expo.dev shows it
17
+ type: string
18
+ required: true
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ update:
25
+ runs-on: ubuntu-24.04
26
+ defaults:
27
+ run:
28
+ working-directory: apps/mobile
29
+ steps:
30
+ - uses: actions/checkout@v7
31
+ with:
32
+ persist-credentials: false
33
+ - uses: pnpm/action-setup@v6
34
+ - uses: actions/setup-node@v7
35
+ with:
36
+ node-version: 22
37
+ cache: pnpm
38
+ - run: pnpm install --frozen-lockfile
39
+ - name: Check the Expo token
40
+ env:
41
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
42
+ run: |
43
+ if [ -z "$EXPO_TOKEN" ]; then
44
+ echo "::error::Set an EXPO_TOKEN repository secret, from expo.dev (Account settings, Access tokens)."
45
+ exit 1
46
+ fi
47
+ # The bundle is built here, with the variables of the channel's EAS environment, such as
48
+ # EXPO_PUBLIC_API_URL, which it carries as the store builds do.
49
+ - name: Publish the update
50
+ env:
51
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
52
+ CHANNEL: ${{ inputs.channel }}
53
+ MESSAGE: ${{ inputs.message }}
54
+ run: |
55
+ pnpm dlx eas-cli@latest update --channel "$CHANNEL" --environment "$CHANNEL" \
56
+ --message "$MESSAGE" --non-interactive