@clubedge/create-clubedge-app 0.32.0 → 0.33.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 (118) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +30 -11
  3. package/dist/cli/ui.js +5 -4
  4. package/dist/core/customize.js +27 -7
  5. package/dist/core/execute.js +2 -2
  6. package/dist/core/manifest-fields.js +120 -0
  7. package/dist/core/manifest-legacy.js +110 -0
  8. package/dist/core/manifest-packs.js +200 -0
  9. package/dist/core/manifest.js +121 -180
  10. package/dist/core/plan.js +215 -56
  11. package/dist/core/project.js +5 -3
  12. package/dist/core/update.js +13 -14
  13. package/dist/deploy.js +6 -0
  14. package/dist/index.js +77 -33
  15. package/dist/inspect.js +1 -1
  16. package/dist/modify.js +23 -18
  17. package/package.json +3 -3
  18. package/template/SETUP.md +22 -0
  19. package/template/apps/api/.env.example +8 -0
  20. package/template/apps/api/Dockerfile +39 -0
  21. package/template/apps/api/build.mjs +18 -0
  22. package/template/apps/api/clubedge.stack.json +33 -0
  23. package/template/apps/api/package.json +29 -0
  24. package/template/apps/api/scripts/check-env.ts +10 -0
  25. package/template/apps/api/scripts/check-services.ts +11 -0
  26. package/template/apps/api/src/app.test.ts +31 -0
  27. package/template/apps/api/src/app.ts +43 -0
  28. package/template/apps/api/src/config/site.json +11 -0
  29. package/template/apps/api/src/config/site.ts +14 -0
  30. package/template/apps/api/src/env.ts +37 -0
  31. package/template/apps/api/src/index.ts +11 -0
  32. package/template/apps/api/src/server/auth.ts +9 -0
  33. package/template/apps/api/src/server/db.ts +24 -0
  34. package/template/apps/api/src/server/index.ts +12 -0
  35. package/template/apps/api/tsconfig.json +13 -0
  36. package/template/apps/start/clubedge.stack.json +186 -0
  37. package/template/apps/start/package.json +1 -1
  38. package/template/apps/vite/.env.example +3 -0
  39. package/template/apps/vite/Caddyfile +22 -0
  40. package/template/apps/vite/Dockerfile +35 -0
  41. package/template/apps/vite/clubedge.stack.json +15 -0
  42. package/template/apps/vite/index.html +29 -0
  43. package/template/apps/vite/package.json +32 -0
  44. package/template/apps/vite/public/.well-known/logo.svg +1 -0
  45. package/template/apps/vite/public/favicon.ico +0 -0
  46. package/template/apps/vite/src/components/branding/clubedge-mark.tsx +24 -0
  47. package/template/apps/vite/src/components/dashboard/account-button.tsx +9 -0
  48. package/template/apps/vite/src/components/dashboard/dashboard-shell.tsx +590 -0
  49. package/template/apps/vite/src/components/landing/copy-command.tsx +46 -0
  50. package/template/apps/vite/src/components/not-found.tsx +17 -0
  51. package/template/apps/vite/src/components/route-error.tsx +23 -0
  52. package/template/apps/vite/src/components/theme/theme-provider.tsx +41 -0
  53. package/template/apps/vite/src/components/theme/theme-toggle.tsx +21 -0
  54. package/template/apps/vite/src/config/site.json +11 -0
  55. package/template/apps/vite/src/config/site.ts +20 -0
  56. package/template/apps/vite/src/main.tsx +14 -0
  57. package/template/apps/vite/src/router.tsx +52 -0
  58. package/template/apps/vite/src/routes/dashboard.tsx +313 -0
  59. package/template/apps/vite/src/routes/index.tsx +226 -0
  60. package/template/apps/vite/src/styles.css +55 -0
  61. package/template/apps/vite/tsconfig.json +19 -0
  62. package/template/apps/vite/turbo.json +15 -0
  63. package/template/apps/vite/vite.config.ts +39 -0
  64. package/template/apps/web/clubedge.stack.json +173 -0
  65. package/template/apps/web/package.json +1 -1
  66. package/template/clubedge/modules/auth.json +70 -0
  67. package/template/clubedge/modules/cache.json +15 -0
  68. package/template/clubedge/modules/deploy.json +27 -0
  69. package/template/clubedge/modules/email.json +23 -0
  70. package/template/clubedge/modules/infra.json +53 -0
  71. package/template/clubedge/modules/jobs.json +39 -0
  72. package/template/clubedge/modules/observability.json +25 -0
  73. package/template/clubedge/modules/payments.json +20 -0
  74. package/template/clubedge/modules/storage.json +26 -0
  75. package/template/clubedge.template.json +40 -600
  76. package/template/docker-compose.yml +19 -0
  77. package/template/e2e/app.ts +12 -1
  78. package/template/eslint.config.mjs +1 -0
  79. package/template/package.json +2 -2
  80. package/template/packages/auth/package.json +1 -1
  81. package/template/packages/auth-appwrite/package.json +1 -1
  82. package/template/packages/auth-better-auth/package.json +1 -1
  83. package/template/packages/auth-firebase/package.json +1 -1
  84. package/template/packages/auth-supabase/package.json +1 -1
  85. package/template/packages/cache/package.json +1 -1
  86. package/template/packages/cache-redis/package.json +1 -1
  87. package/template/packages/contract/openapi.yaml +101 -0
  88. package/template/packages/contract/package.json +24 -0
  89. package/template/packages/contract/src/conformance.ts +83 -0
  90. package/template/packages/contract/src/index.ts +24 -0
  91. package/template/packages/contract/src/remote.test.ts +14 -0
  92. package/template/packages/contract/src/schema.test.ts +15 -0
  93. package/template/packages/contract/src/schema.ts +167 -0
  94. package/template/packages/contract/tsconfig.json +7 -0
  95. package/template/packages/core/package.json +1 -1
  96. package/template/packages/db/package.json +1 -1
  97. package/template/packages/db/scripts/load-env.ts +3 -2
  98. package/template/packages/email/package.json +1 -1
  99. package/template/packages/email-resend/package.json +1 -1
  100. package/template/packages/email-smtp/package.json +1 -1
  101. package/template/packages/jobs/package.json +1 -1
  102. package/template/packages/jobs-bullmq/package.json +1 -1
  103. package/template/packages/jobs-inngest/package.json +1 -1
  104. package/template/packages/jobs-pg-boss/package.json +1 -1
  105. package/template/packages/local-db/package.json +1 -1
  106. package/template/packages/observability/package.json +1 -1
  107. package/template/packages/observability-otel/package.json +1 -1
  108. package/template/packages/observability-sentry/package.json +1 -1
  109. package/template/packages/payments/package.json +1 -1
  110. package/template/packages/payments-stripe/package.json +1 -1
  111. package/template/packages/storage/package.json +1 -1
  112. package/template/packages/storage-s3/package.json +1 -1
  113. package/template/packages/storage-supabase/package.json +1 -1
  114. package/template/packages/ui/package.json +1 -1
  115. package/template/playwright.config.ts +11 -4
  116. package/template/pnpm-lock.yaml +278 -23
  117. package/template/turbo.json +5 -1
  118. package/template.lock.json +64 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.33.0
6
+
7
+ - **A frontend and a separate API**: default to Starter `v0.22.0` (#98). `--framework vite --api hono` creates a React + Vite frontend in `apps/web` and a Hono API in `apps/api`, joined by one HTTP contract:
8
+ - `packages/contract/openapi.yaml` is the contract. The frontend calls the API only through the client generated from it, and `@clubedge/contract/conformance` is the test suite every API passes, in process or over HTTP against any running API.
9
+ - `pnpm dev` and `pnpm start` run both, and the frontend forwards `/api` to the API, so the browser sees one origin. `docker compose up --build` runs both images.
10
+ - A first version: on these stacks the modules are `none` (cache: memory, deploy: Docker). `deploy` explains that it sets up one app and doesn't deploy the pair yet (#96).
11
+ - CI generates the pair from the packed CLI and runs its full pipeline; the smoke check accepts a single-page app's shell for a missing page (#97).
12
+ - **Stack packs** (template schema 4, #95): a Starter describes each app in its own `clubedge.stack.json` and each module in `clubedge/modules/<id>.json`, so adding a stack adds a folder and changes nothing shared. See [docs/stack-packs.md](docs/stack-packs.md).
13
+ - Slots place apps in generated projects: `--framework` for the web app and `--api` for a separate API. A frontend needs an API, and a full-stack app takes none.
14
+ - The CLI checks that the selected stacks implement each chosen module option, and only offers those options in prompts and `add`.
15
+ - Stacks outside Node.js declare a toolchain, such as uv, whose install runs after `pnpm install`.
16
+ - A slot can name packages only its apps use, left out when it is empty, and its own site config, named after the project (#96).
17
+ - Starters with schema 1 to 3 are read into the same model, so projects created from them still add, remove, and upgrade. Projects generated from the same selection are byte-identical to 0.32.0.
18
+
5
19
  ## 0.32.0
6
20
 
7
21
  - **Pluggable authentication**: default to Starter `v0.21.0` (#93). Authentication now has four providers behind one interface, so the pages, settings, payments, and jobs stay the same whichever signs users in:
package/README.md CHANGED
@@ -66,6 +66,23 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
66
66
 
67
67
  Either way, the app lives in `apps/web` and uses the same shared packages, so the project layout, root scripts, and Docker commands are identical.
68
68
 
69
+ ### A frontend and a separate API
70
+
71
+ A frontend can instead call a separate API, so the API can be written in another language later:
72
+
73
+ ```sh
74
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono
75
+ ```
76
+
77
+ | Flag | App | Where |
78
+ | ------------------ | ----------------------------------------------------- | ----------- |
79
+ | `--framework vite` | React + Vite single-page app (TanStack Router) | `apps/web` |
80
+ | `--api hono` | Hono API on Node, reusing the Starter's packages | `apps/api` |
81
+
82
+ They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend calls the API only through the client generated from it, and every API passes the same conformance suite. `pnpm dev` starts both, and the frontend forwards `/api` to the API. A full-stack framework (`next`, `tanstack-start`) has its own server, so it takes no `--api`.
83
+
84
+ This is a first version: on these stacks the modules are `none` (cache: memory, deploy: Docker), and `deploy` doesn't set them up yet; SETUP.md in the project describes running both images. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks are described.
85
+
69
86
  ## Choosing modules
70
87
 
71
88
  Authentication, file storage, caching, email, payments, background jobs, observability, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
@@ -102,7 +119,8 @@ Usage:
102
119
  pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
103
120
 
104
121
  Options:
105
- --framework <id> App framework, such as next or tanstack-start
122
+ --framework <id> App framework, such as next or tanstack-start (or vite, a frontend)
123
+ --api <id> A separate API for a frontend, such as hono
106
124
  --<module> <option> A module option, such as --auth none; the Starter lists its modules
107
125
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
108
126
  (upgrade: the Starter to upgrade to)
@@ -129,7 +147,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
129
147
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
130
148
 
131
149
  # Reproduce a specific Starter release or commit
132
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.21.0
150
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.22.0
133
151
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
134
152
 
135
153
  # Test local Starter changes before releasing them
@@ -148,7 +166,7 @@ pnpm clubedge add email resend payments stripe # several modules as
148
166
  pnpm clubedge remove storage payments
149
167
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
150
168
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
151
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.21.0 # or a specific tag, branch, or commit
169
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.22.0 # or a specific tag, branch, or commit
152
170
  ```
153
171
 
154
172
  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.
@@ -259,27 +277,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
259
277
  Each CLI release is tested against one specific Starter release:
260
278
 
261
279
  ```text
262
- create-clubedge-app v0.32.0
280
+ create-clubedge-app v0.33.0
263
281
  |
264
282
  v
265
- Clubedge Starter v0.21.0
266
- commit 8f1201f2d526347ccf0365a701aaa20e883f8d8a
283
+ Clubedge Starter v0.22.0
284
+ commit d0f3f9cdd4d568504cec4cf4681c7faf97842405
267
285
  ```
268
286
 
269
287
  The current relationship is:
270
288
 
271
289
  | CLI release | Default Starter release | Meaning |
272
290
  | --- | --- | --- |
273
- | `create-clubedge-app@0.32.0` | `clubedge-starter@v0.21.0` | The CLI's required, tested default |
291
+ | `create-clubedge-app@0.33.0` | `clubedge-starter@v0.22.0` | The CLI's required, tested default |
274
292
 
275
293
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
276
294
 
277
- The `v0.21.0` tag is expected to resolve to commit
278
- `8f1201f2d526347ccf0365a701aaa20e883f8d8a`. The CLI verifies this before
295
+ The `v0.22.0` tag is expected to resolve to commit
296
+ `d0f3f9cdd4d568504cec4cf4681c7faf97842405`. The CLI verifies this before
279
297
  scaffolding with the default reference and fails rather than silently using a
280
298
  moved tag.
281
299
 
282
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.21.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.
300
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.22.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.
283
301
 
284
302
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
285
303
 
@@ -334,7 +352,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
334
352
  1. Make and validate changes in `clubedge-starter`.
335
353
  2. Run lint, typecheck, unit tests, browser tests, and production build.
336
354
  3. Update the Starter package versions.
337
- 4. Commit `main`, create a release tag such as `v0.21.0`, and publish the Starter release.
355
+ 4. Commit `main`, create a release tag such as `v0.22.0`, and publish the Starter release.
338
356
 
339
357
  ### CLI maintainers
340
358
 
@@ -377,6 +395,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
377
395
  | 0.30.1 | v0.19.1 | Railway previews work with a workspace token and answer on the port their domain targets |
378
396
  | 0.31.0 | v0.20.0 | Pluggable background jobs: `--jobs bullmq` and `--jobs inngest` next to pg-boss, and a conformance suite every provider passes |
379
397
  | 0.32.0 | v0.21.0 | Pluggable authentication: `--auth firebase` and `--auth appwrite` next to Supabase and Better Auth, and a conformance suite every provider passes |
398
+ | 0.33.0 | v0.22.0 | Starter v0.22.0: stack packs (template schema 4), and a React + Vite frontend with a separate Hono API (`--framework vite --api hono`) |
380
399
  | next | | Show the Starter's version of a conflicted file next to yours |
381
400
 
382
401
  ## Community
package/dist/cli/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import { NO_STACK, stacksIn } from "../core/plan.js";
2
3
  import { cliUrl, cliVersion, siteUrl, starterPin } from "../package-info.js";
3
4
  // Each glyph is 6 rows tall. Rows are padded to a common width when rendered.
4
5
  const glyphs = {
@@ -69,11 +70,11 @@ export function selectionHelp(manifest) {
69
70
  line("--<module> <option>", "A module option, such as --auth none; the Starter lists its modules"),
70
71
  ].join("\n");
71
72
  }
72
- const frameworks = Object.keys(manifest.frameworks);
73
73
  return [
74
- ...(frameworks.length > 1
75
- ? [line("--framework <id>", `App framework: ${choices(frameworks, manifest.defaultFramework)}`)]
76
- : []),
74
+ ...Object.entries(manifest.slots).flatMap(([id, slot]) => {
75
+ const stacks = [...stacksIn(manifest, id), ...(slot.default === undefined ? [NO_STACK] : [])];
76
+ return stacks.length > 1 ? [line(`--${id} <id>`, `${slot.name}: ${choices(stacks, slot.default ?? NO_STACK)}`)] : [];
77
+ }),
77
78
  ...Object.entries(manifest.modules).map(([id, module]) => line(`--${id} <id>`, `${module.name}: ${choices(Object.keys(module.options), module.default)}`)),
78
79
  ].join("\n");
79
80
  }
@@ -1,7 +1,26 @@
1
1
  // Pure text transforms applied to template files. Keeping them free of I/O makes each one easy
2
2
  // to test and lets --dry-run describe them without touching the disk.
3
3
  import { pruneOptionalPeers } from "./lockfile-peers.js";
4
+ import { FRAMEWORK_SLOT } from "./manifest-fields.js";
4
5
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
6
+ /**
7
+ * The stacks a project recorded in its package.json `clubedge` field, by slot. Projects have
8
+ * recorded the framework slot as `framework` since before slots existed; other slots are
9
+ * recorded under `apps`, such as `"apps": { "api": "fastapi" }`. */
10
+ export function recordedStacks(origin) {
11
+ const apps = origin.apps && typeof origin.apps === "object" ? origin.apps : {};
12
+ return {
13
+ ...(typeof origin.framework === "string" ? { [FRAMEWORK_SLOT]: origin.framework } : {}),
14
+ ...apps,
15
+ };
16
+ }
17
+ function recordStacks(stacks) {
18
+ const { [FRAMEWORK_SLOT]: framework, ...apps } = stacks;
19
+ return {
20
+ ...(framework ? { framework } : {}),
21
+ ...(Object.keys(apps).length ? { apps } : {}),
22
+ };
23
+ }
5
24
  export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
6
25
  const manifest = JSON.parse(source);
7
26
  manifest.name = packageName;
@@ -16,7 +35,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
16
35
  starterRepository: provenance.starterRepository,
17
36
  starterRef: provenance.starterRef,
18
37
  ...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
19
- ...(provenance.framework ? { framework: provenance.framework } : {}),
38
+ ...recordStacks(provenance.stacks ?? {}),
20
39
  ...(provenance.modules ? { modules: provenance.modules } : {}),
21
40
  };
22
41
  for (const script of ["docker:build", "docker:start"]) {
@@ -30,13 +49,13 @@ export function customizeReadme(source, { displayName }, provenance) {
30
49
  const commitLine = provenance.starterCommit
31
50
  ? `\n- Starter commit: \`${provenance.starterCommit}\``
32
51
  : "";
33
- const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
52
+ const stackLines = (provenance.stackSummary ?? []).map((line) => `\n- ${line}`).join("");
34
53
  const modulesLine = provenance.moduleSummary?.length
35
54
  ? `\n- Modules: ${provenance.moduleSummary.join(", ")}`
36
55
  : "";
37
56
  const origin = `## Generated from\n\n` +
38
57
  `- Starter repository: \`${provenance.starterRepository}\`\n` +
39
- `- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}${modulesLine}\n` +
58
+ `- Starter ref: \`${provenance.starterRef}\`${commitLine}${stackLines}${modulesLine}\n` +
40
59
  `- CLI version: \`${provenance.cliVersion}\``;
41
60
  // Only the first heading is the project title; leave every other line as written.
42
61
  return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
@@ -67,20 +86,20 @@ export function customizeLegacySource(source, { displayName }) {
67
86
  .replaceAll(">Clubedge<", `>${displayName}<`)
68
87
  .replaceAll("Starter workspace", "Application workspace");
69
88
  }
70
- /** Gives the selected framework's app the package name generated projects use. */
89
+ /** Gives a selected stack's app the package name generated projects use. */
71
90
  export function customizeAppManifest(source, appPackage) {
72
91
  const manifest = JSON.parse(source);
73
92
  manifest.name = appPackage;
74
93
  return json(manifest);
75
94
  }
76
95
  /**
77
- * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
96
+ * Keeps pnpm-lock.yaml in step with the selected stacks so `pnpm install --frozen-lockfile`
78
97
  * works. Importers are keyed by workspace path at two-space indentation; package entries that
79
98
  * are no longer used are harmless, and pnpm prunes them on the next lockfile update. Optional
80
99
  * peer links to packages that only left-out importers installed are removed, so those packages
81
100
  * are not installed either.
82
101
  */
83
- export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
102
+ export function customizeLockfile(source, { remove, rename = [], removeDependencies = [] }) {
84
103
  const output = [];
85
104
  let inImporters = false;
86
105
  let skippingImporter = false;
@@ -106,8 +125,9 @@ export function customizeLockfile(source, { remove, rename, removeDependencies =
106
125
  skippingImporter = remove.includes(importer);
107
126
  skippingDependency = false;
108
127
  pendingGroup = null;
128
+ const renamed = rename.find(({ from }) => from === importer);
109
129
  if (!skippingImporter)
110
- output.push(importer === rename?.from ? ` ${rename.to}:` : line);
130
+ output.push(renamed ? ` ${renamed.to}:` : line);
111
131
  continue;
112
132
  }
113
133
  if (skippingImporter)
@@ -48,8 +48,8 @@ export async function executePlan(plan, source) {
48
48
  const path = join(target, edit.path);
49
49
  await writeFile(path, edit.apply(await readFile(path, "utf8")));
50
50
  }
51
- if (plan.envFile) {
52
- await copyFile(join(target, plan.envFile.from), join(target, plan.envFile.to), constants.COPYFILE_EXCL);
51
+ for (const envFile of plan.envFiles) {
52
+ await copyFile(join(target, envFile.from), join(target, envFile.to), constants.COPYFILE_EXCL);
53
53
  }
54
54
  }
55
55
  catch (error) {
@@ -0,0 +1,120 @@
1
+ // Field readers shared by the manifest schemas. Each one names the file and the field in its
2
+ // error, so a Starter maintainer sees exactly what to fix.
3
+ export const MANIFEST_FILE = "clubedge.template.json";
4
+ export class ManifestError extends Error {
5
+ name = "ManifestError";
6
+ }
7
+ export const stackRoles = ["fullstack", "frontend", "api"];
8
+ /**
9
+ * The slot for the app itself. Starters before schema 4 call it the framework, and projects
10
+ * record their choice for it as `framework`.
11
+ */
12
+ export const FRAMEWORK_SLOT = "framework";
13
+ /** Ids of slots, stacks, modules, and options: lowercase words joined by dashes. */
14
+ export const ID = /^[a-z][a-z0-9-]*$/;
15
+ export function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ export function isStringArray(value) {
19
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
20
+ }
21
+ /** Reads the fields of one JSON object, reporting errors against `file` and `prefix`. */
22
+ export function fields(file, prefix = "") {
23
+ const fail = (message) => {
24
+ throw new ManifestError(`${file}: ${message}`);
25
+ };
26
+ return {
27
+ fail,
28
+ requireString(record, key) {
29
+ const value = record[key];
30
+ if (typeof value !== "string" || !value)
31
+ fail(`${prefix}${key} must be a non-empty string.`);
32
+ return value;
33
+ },
34
+ optionalString(record, key) {
35
+ const value = record[key];
36
+ if (value === undefined)
37
+ return undefined;
38
+ if (typeof value !== "string" || !value)
39
+ fail(`${prefix}${key} must be a non-empty string.`);
40
+ return value;
41
+ },
42
+ optionalStringArray(record, key) {
43
+ const value = record[key];
44
+ if (value === undefined)
45
+ return [];
46
+ if (!isStringArray(value))
47
+ fail(`${prefix}${key} must be an array of paths.`);
48
+ return value;
49
+ },
50
+ optionalStringRecord(record, key) {
51
+ const value = record[key];
52
+ if (value === undefined)
53
+ return {};
54
+ if (!isRecord(value) || !Object.values(value).every((item) => typeof item === "string" && item)) {
55
+ fail(`${prefix}${key} must map names to paths.`);
56
+ }
57
+ return value;
58
+ },
59
+ requireRecord(record, key) {
60
+ const value = record[key];
61
+ if (!isRecord(value))
62
+ fail(`${prefix}${key} must be an object.`);
63
+ return value;
64
+ },
65
+ requireId(id, kind) {
66
+ if (!ID.test(id))
67
+ fail(`"${id}" is not a valid ${kind} id.`);
68
+ return id;
69
+ },
70
+ };
71
+ }
72
+ /** `requires`: module id to the options allowed, as one option or several. */
73
+ export function parseRequires(file, value, prefix) {
74
+ if (value === undefined)
75
+ return {};
76
+ if (!isRecord(value))
77
+ throw new ManifestError(`${file}: ${prefix}requires must be an object.`);
78
+ const requires = {};
79
+ for (const [module, allowed] of Object.entries(value)) {
80
+ const list = typeof allowed === "string" ? [allowed] : allowed;
81
+ if (!isStringArray(list) || list.length === 0) {
82
+ throw new ManifestError(`${file}: ${prefix}requires.${module} must name one or more options.`);
83
+ }
84
+ requires[module] = list;
85
+ }
86
+ return requires;
87
+ }
88
+ /** A module's name, default, and options; `prefix` locates it in its file. */
89
+ export function parseModule(file, entry, prefix) {
90
+ const { fail, requireString } = fields(file, prefix);
91
+ if (!isRecord(entry))
92
+ fail(`${prefix.slice(0, -1) || "the module"} must be an object.`);
93
+ const record = entry;
94
+ const optionsValue = record.options;
95
+ if (!isRecord(optionsValue) || Object.keys(optionsValue).length === 0) {
96
+ fail(`${prefix}options must describe at least one option.`);
97
+ }
98
+ const options = {};
99
+ for (const [optionId, option] of Object.entries(optionsValue)) {
100
+ const optionPrefix = `${prefix}options.${optionId}.`;
101
+ if (!ID.test(optionId))
102
+ fail(`"${optionId}" is not a valid option id in ${prefix ? prefix.slice(0, -1) : "options"}.`);
103
+ if (!isRecord(option))
104
+ fail(`${optionPrefix.slice(0, -1)} must be an object.`);
105
+ const read = fields(file, optionPrefix);
106
+ const values = option;
107
+ options[optionId] = {
108
+ name: read.requireString(values, "name"),
109
+ packages: read.optionalStringRecord(values, "packages"),
110
+ files: read.optionalStringArray(values, "files"),
111
+ replace: read.optionalStringRecord(values, "replace"),
112
+ requires: parseRequires(file, values.requires, optionPrefix),
113
+ scripts: read.optionalStringRecord(values, "scripts"),
114
+ };
115
+ }
116
+ const defaultOption = requireString(record, "default");
117
+ if (!options[defaultOption])
118
+ fail(`${prefix}default "${defaultOption}" is not in its options.`);
119
+ return { name: requireString(record, "name"), default: defaultOption, options };
120
+ }
@@ -0,0 +1,110 @@
1
+ // Schema 1 to 3: one clubedge.template.json with every framework and module file. These
2
+ // Starters predate slots and stack packs, so each framework becomes a full-stack stack in the
3
+ // "framework" slot, implementing every module option.
4
+ import { fields, FRAMEWORK_SLOT, ID, isRecord, isStringArray, MANIFEST_FILE, ManifestError, parseModule } from "./manifest-fields.js";
5
+ const frameworkSlot = (path, appPackage, env, defaultFramework) => ({
6
+ name: "Framework",
7
+ question: "Which framework should the app use?",
8
+ path,
9
+ ...(appPackage ? { package: appPackage } : {}),
10
+ env,
11
+ dockerfile: "Dockerfile",
12
+ roles: ["fullstack"],
13
+ default: defaultFramework,
14
+ packages: {},
15
+ });
16
+ const fullstack = (name, app, envExample, dockerfile) => ({
17
+ name,
18
+ role: "fullstack",
19
+ slot: FRAMEWORK_SLOT,
20
+ app,
21
+ envExample,
22
+ dockerfile,
23
+ modules: {},
24
+ });
25
+ /** Starter releases before v0.3.0 have no manifest; this describes their layout. */
26
+ export const legacyManifest = {
27
+ schemaVersion: 1,
28
+ name: "clubedge-starter",
29
+ packageManager: "pnpm",
30
+ siteConfig: "apps/web/src/config/site.json",
31
+ dockerImage: "clubedge-starter",
32
+ slots: {
33
+ [FRAMEWORK_SLOT]: frameworkSlot("apps/web", undefined, { example: ".env.example", target: "apps/web/.env.local" }, "next"),
34
+ },
35
+ stacks: { next: fullstack("Next.js", "apps/web", ".env.example", "Dockerfile") },
36
+ modules: {},
37
+ conditional: [],
38
+ exclude: [],
39
+ };
40
+ export function parseLegacyManifest(data, schemaVersion) {
41
+ const { fail, requireString, optionalStringArray } = fields(MANIFEST_FILE);
42
+ const env = data.env;
43
+ if (data.packageManager !== "pnpm")
44
+ fail(`packageManager must be "pnpm".`);
45
+ if (!isRecord(env))
46
+ fail("env must be an object.");
47
+ if (data.exclude !== undefined && !isStringArray(data.exclude))
48
+ fail("exclude must be an array of paths.");
49
+ const app = requireString(data, "app");
50
+ const envFiles = {
51
+ example: fields(MANIFEST_FILE, "env.").requireString(env, "example"),
52
+ target: fields(MANIFEST_FILE, "env.").requireString(env, "target"),
53
+ };
54
+ const appPackage = schemaVersion >= 2 ? requireString(data, "appPackage") : undefined;
55
+ const { defaultFramework, stacks } = schemaVersion >= 2
56
+ ? parseFrameworks(data)
57
+ : { defaultFramework: "next", stacks: { next: fullstack("Next.js", app, envFiles.example, "Dockerfile") } };
58
+ const modules = schemaVersion >= 3 ? parseModules(data.modules) : {};
59
+ // Every framework of these Starters implements every option.
60
+ for (const stack of Object.values(stacks)) {
61
+ stack.modules = Object.fromEntries(Object.entries(modules).map(([id, module]) => [id, Object.keys(module.options)]));
62
+ }
63
+ return {
64
+ schemaVersion,
65
+ name: requireString(data, "name"),
66
+ packageManager: "pnpm",
67
+ siteConfig: requireString(data, "siteConfig"),
68
+ dockerImage: requireString(data, "dockerImage"),
69
+ slots: { [FRAMEWORK_SLOT]: frameworkSlot(app, appPackage, envFiles, defaultFramework) },
70
+ stacks,
71
+ modules,
72
+ conditional: schemaVersion >= 3 ? optionalStringArray(data, "conditional") : [],
73
+ exclude: data.exclude ?? [],
74
+ };
75
+ }
76
+ function parseModules(value) {
77
+ if (value === undefined)
78
+ return {};
79
+ if (!isRecord(value))
80
+ throw new ManifestError(`${MANIFEST_FILE}: modules must be an object.`);
81
+ const modules = {};
82
+ for (const [id, entry] of Object.entries(value)) {
83
+ if (!ID.test(id) || id === FRAMEWORK_SLOT) {
84
+ throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid module id.`);
85
+ }
86
+ modules[id] = { ...parseModule(MANIFEST_FILE, entry, `modules.${id}.`), scope: "app" };
87
+ }
88
+ return modules;
89
+ }
90
+ function parseFrameworks(data) {
91
+ const { fail, requireString } = fields(MANIFEST_FILE);
92
+ const { frameworks } = data;
93
+ if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
94
+ fail("frameworks must describe at least one framework.");
95
+ }
96
+ const stacks = {};
97
+ for (const [id, entry] of Object.entries(frameworks)) {
98
+ if (!ID.test(id))
99
+ fail(`"${id}" is not a valid framework id.`);
100
+ if (!isRecord(entry))
101
+ fail(`frameworks.${id} must be an object.`);
102
+ const read = fields(MANIFEST_FILE, `frameworks.${id}.`);
103
+ const framework = entry;
104
+ stacks[id] = fullstack(read.requireString(framework, "name"), read.requireString(framework, "app"), read.requireString(framework, "envExample"), read.requireString(framework, "dockerfile"));
105
+ }
106
+ const defaultFramework = requireString(data, "defaultFramework");
107
+ if (!stacks[defaultFramework])
108
+ fail(`defaultFramework "${defaultFramework}" is not in frameworks.`);
109
+ return { defaultFramework, stacks };
110
+ }