@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.
- package/CHANGELOG.md +14 -0
- package/README.md +25 -11
- package/dist/cli/args.js +1 -1
- package/dist/cli/ui.js +3 -2
- package/dist/core/env.js +17 -1
- package/dist/core/manifest-fields.js +12 -0
- package/dist/core/plan.js +11 -1
- package/dist/core/update.js +24 -7
- package/dist/index.js +12 -8
- package/dist/modify.js +192 -39
- package/package.json +3 -3
- package/skills/clubedge-starter/SKILL.md +3 -0
- package/template/.github/workflows/expo-release.yml +65 -0
- package/template/.github/workflows/expo-update.yml +56 -0
- package/template/.github/workflows/shell-release.yml +145 -0
- package/template/README.md +12 -0
- package/template/SETUP.md +121 -5
- package/template/apps/api/.env.example +24 -0
- package/template/apps/api/clubedge.stack.json +12 -0
- package/template/apps/api/package.json +4 -1
- package/template/apps/api/src/app.ts +10 -0
- package/template/apps/api/src/env/production.test.ts +18 -0
- package/template/apps/api/src/env/production.ts +28 -0
- package/template/apps/api/src/routes/push.test.ts +160 -0
- package/template/apps/api/src/routes/push.ts +146 -0
- package/template/apps/api/src/server/index.ts +2 -0
- package/template/apps/api/src/server/push.ts +40 -0
- package/template/apps/api/src/server/variants/push.native.ts +78 -0
- package/template/apps/api-fastapi/.env.example +24 -0
- package/template/apps/api-fastapi/Dockerfile +1 -0
- package/template/apps/api-fastapi/clubedge.stack.json +24 -0
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +3 -1
- package/template/apps/api-fastapi/src/clubedge_api/app.py +18 -0
- package/template/apps/api-fastapi/src/clubedge_api/production_env.py +27 -0
- package/template/apps/api-fastapi/src/clubedge_api/push.py +141 -0
- package/template/apps/api-fastapi/src/clubedge_api/push_expo.py +67 -0
- package/template/apps/api-fastapi/src/clubedge_api/push_native.py +316 -0
- package/template/apps/api-fastapi/src/clubedge_api/push_service.py +21 -0
- package/template/apps/api-fastapi/src/clubedge_api/routes/push.py +163 -0
- package/template/apps/api-fastapi/src/clubedge_api/server.py +5 -0
- package/template/apps/api-fastapi/src/clubedge_api/variants/push_service_native.py +19 -0
- package/template/apps/api-fastapi/tests/test_production_env.py +11 -0
- package/template/apps/api-fastapi/tests/test_push.py +289 -0
- package/template/apps/api-fastapi/tests/test_push_expo.py +90 -0
- package/template/apps/api-fastapi/tests/test_push_native.py +202 -0
- package/template/apps/api-fastapi/uv.lock +197 -1
- package/template/apps/capacitor/.env.example +13 -0
- package/template/apps/capacitor/_gitignore +2 -0
- package/template/apps/capacitor/capacitor.config.ts +12 -0
- package/template/apps/capacitor/clubedge.stack.json +9 -1
- package/template/apps/capacitor/package.json +5 -2
- package/template/apps/capacitor/scripts/build.mjs +13 -4
- package/template/apps/capacitor/scripts/push.mjs +80 -0
- package/template/apps/capacitor/scripts/release.mjs +133 -0
- package/template/apps/capacitor/scripts/version.mjs +129 -0
- package/template/apps/capacitor/version.json +3 -0
- package/template/apps/expo/.env.example +7 -0
- package/template/apps/expo/app.config.ts +76 -33
- package/template/apps/expo/app.json +5 -0
- package/template/apps/expo/clubedge.stack.json +33 -2
- package/template/apps/expo/eas.json +33 -0
- package/template/apps/expo/package.json +5 -2
- package/template/apps/expo/scripts/set-version.mjs +66 -0
- package/template/apps/expo/src/app/(app)/index.tsx +2 -0
- package/template/apps/expo/src/app/(app)/settings/index.tsx +4 -2
- package/template/apps/expo/src/components/ota-banner.tsx +69 -0
- package/template/apps/expo/src/components/push-settings.tsx +56 -0
- package/template/apps/expo/src/components/sign-out-sheet.tsx +3 -0
- package/template/apps/expo/src/components/update-gate.tsx +5 -1
- package/template/apps/expo/src/lib/ota.ts +30 -0
- package/template/apps/expo/src/lib/push-token.ts +20 -0
- package/template/apps/expo/src/lib/push.ts +161 -0
- package/template/apps/expo/src/variants/app-layout.auth.tsx +2 -0
- package/template/apps/expo/src/variants/push-token.native.ts +14 -0
- package/template/apps/expo/variants/eas.updates.json +36 -0
- package/template/apps/start/clubedge.stack.json +3 -0
- package/template/apps/start/package.json +1 -1
- package/template/apps/vite/clubedge.stack.json +5 -0
- package/template/apps/vite/package.json +1 -1
- package/template/apps/vite/server.mjs +3 -0
- package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +7 -0
- package/template/apps/vite/src/main.tsx +5 -2
- package/template/apps/vite/src/routes/settings.tsx +19 -4
- package/template/apps/web/clubedge.stack.json +3 -0
- package/template/apps/web/package.json +1 -1
- package/template/clubedge/modules/ota.json +15 -0
- package/template/clubedge/modules/push.json +36 -0
- package/template/clubedge.template.json +18 -2
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-appwrite/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-firebase/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/contract/openapi.yaml +149 -0
- package/template/packages/contract/package.json +1 -1
- package/template/packages/contract/src/conformance.ts +43 -0
- package/template/packages/contract/src/schema.ts +179 -0
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-bullmq/package.json +1 -1
- package/template/packages/jobs-inngest/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/mobile/package.json +5 -2
- package/template/packages/mobile/src/push-settings.tsx +95 -0
- package/template/packages/mobile/src/push.test.ts +153 -0
- package/template/packages/mobile/src/push.ts +126 -0
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +1 -1
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/push/drizzle/0000_initial.sql +11 -0
- package/template/packages/push/drizzle/meta/0000_snapshot.json +96 -0
- package/template/packages/push/drizzle/meta/_journal.json +13 -0
- package/template/packages/push/drizzle.config.ts +12 -0
- package/template/packages/push/package.json +35 -0
- package/template/packages/push/scripts/migrate.ts +14 -0
- package/template/packages/push/src/devices.test.ts +133 -0
- package/template/packages/push/src/devices.ts +78 -0
- package/template/packages/push/src/index.ts +12 -0
- package/template/packages/push/src/schema.ts +29 -0
- package/template/packages/push/src/types.ts +59 -0
- package/template/packages/push/tsconfig.json +4 -0
- package/template/packages/push-expo/package.json +21 -0
- package/template/packages/push-expo/src/index.test.ts +82 -0
- package/template/packages/push-expo/src/index.ts +70 -0
- package/template/packages/push-expo/tsconfig.json +4 -0
- package/template/packages/push-native/package.json +21 -0
- package/template/packages/push-native/src/index.test.ts +245 -0
- package/template/packages/push-native/src/index.ts +291 -0
- package/template/packages/push-native/tsconfig.json +4 -0
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +152 -0
- 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
|
|
3
|
-
// rendered in memory, and the difference is merged into the project (see
|
|
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("
|
|
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
|
-
? "
|
|
115
|
-
: "
|
|
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:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
130
|
-
// only work together (Supabase Auth with Supabase Storage) can be
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
|
|
165
|
-
const
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 =
|
|
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:
|
|
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.
|
|
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.
|
|
28
|
-
"starterCommit": "
|
|
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
|