@clubedge/create-clubedge-app 0.3.0 → 0.4.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 (81) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +29 -13
  3. package/dist/cli/args.js +5 -0
  4. package/dist/cli/ui.js +2 -0
  5. package/dist/core/customize.js +40 -1
  6. package/dist/core/manifest.js +41 -3
  7. package/dist/core/plan.js +68 -3
  8. package/dist/index.js +42 -7
  9. package/dist/utils/fs.js +3 -0
  10. package/package.json +5 -4
  11. package/template/.dockerignore +3 -0
  12. package/template/.github/workflows/ci.yml +51 -4
  13. package/template/.prettierignore +1 -0
  14. package/template/AGENTS.md +3 -2
  15. package/template/CONTRIBUTING.md +2 -2
  16. package/template/README.md +18 -5
  17. package/template/SETUP.md +23 -1
  18. package/template/_gitignore +4 -0
  19. package/template/apps/start/.env.example +27 -0
  20. package/template/apps/start/AGENTS.md +11 -0
  21. package/template/apps/start/Dockerfile +38 -0
  22. package/template/apps/start/package.json +37 -0
  23. package/template/apps/start/public/.well-known/logo.svg +1 -0
  24. package/template/apps/start/public/favicon.ico +0 -0
  25. package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
  26. package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
  27. package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
  28. package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
  29. package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
  30. package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
  31. package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
  32. package/template/apps/start/src/config/site.json +11 -0
  33. package/template/apps/start/src/config/site.ts +20 -0
  34. package/template/apps/start/src/env/server.test.ts +48 -0
  35. package/template/apps/start/src/env/server.ts +67 -0
  36. package/template/apps/start/src/lib/login-url.test.ts +16 -0
  37. package/template/apps/start/src/lib/login-url.ts +16 -0
  38. package/template/apps/start/src/routeTree.gen.ts +220 -0
  39. package/template/apps/start/src/router.tsx +27 -0
  40. package/template/apps/start/src/routes/__root.tsx +59 -0
  41. package/template/apps/start/src/routes/api/health.ts +15 -0
  42. package/template/apps/start/src/routes/auth/callback.ts +10 -0
  43. package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
  44. package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
  45. package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
  46. package/template/apps/start/src/routes/dashboard.tsx +351 -0
  47. package/template/apps/start/src/routes/index.tsx +233 -0
  48. package/template/apps/start/src/routes/login.tsx +164 -0
  49. package/template/apps/start/src/server/auth-forms.test.ts +169 -0
  50. package/template/apps/start/src/server/auth-forms.ts +118 -0
  51. package/template/apps/start/src/server/auth.functions.ts +27 -0
  52. package/template/apps/start/src/server/auth.ts +51 -0
  53. package/template/apps/start/src/server/cache.ts +38 -0
  54. package/template/apps/start/src/server/cookies.test.ts +41 -0
  55. package/template/apps/start/src/server/cookies.ts +31 -0
  56. package/template/apps/start/src/server/db.ts +16 -0
  57. package/template/apps/start/src/server/storage.ts +39 -0
  58. package/template/apps/start/src/server/users.ts +17 -0
  59. package/template/apps/start/src/start.ts +40 -0
  60. package/template/apps/start/src/styles.css +2 -0
  61. package/template/apps/start/tsconfig.json +19 -0
  62. package/template/apps/start/vite.config.ts +27 -0
  63. package/template/apps/web/package.json +2 -2
  64. package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
  65. package/template/clubedge.template.json +17 -1
  66. package/template/e2e/app.ts +12 -0
  67. package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -2
  68. package/template/eslint.config.mjs +4 -0
  69. package/template/package.json +3 -3
  70. package/template/packages/auth/package.json +1 -1
  71. package/template/packages/cache/package.json +1 -1
  72. package/template/packages/core/package.json +1 -1
  73. package/template/packages/db/package.json +1 -1
  74. package/template/packages/storage/package.json +1 -1
  75. package/template/packages/ui/package.json +1 -1
  76. package/template/playwright.config.ts +6 -2
  77. package/template/pnpm-lock.yaml +1382 -19
  78. package/template/turbo.json +1 -1
  79. package/template/vitest.config.ts +2 -0
  80. package/template.lock.json +50 -5
  81. /package/template/{apps/web/e2e → e2e}/home.spec.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.4.0
6
+
7
+ - Default to Starter `v0.4.0`, which adds the TanStack Start app and template schema 2.
8
+ - Add `--framework next|tanstack-start`, and ask which framework to use when the Starter offers several. Next.js stays the default.
9
+ - Read template schema 2, which describes each framework's app, environment example, and Dockerfile. The selected app is moved to `apps/web`, the others are left out, and `pnpm-lock.yaml` is kept in step so frozen installs work.
10
+ - Record the chosen framework in the generated `package.json` and `README.md`.
11
+ - Test generated projects for both frameworks in CI and in the release smoke test.
12
+
5
13
  ## 0.3.0
6
14
 
7
15
  - Default to Starter `v0.3.0`, which adds the `clubedge.template.json` template manifest.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/Clubedge/create-clubedge-app/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Clubedge/create-clubedge-app/actions/workflows/ci.yml)
4
4
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
5
5
 
6
- Create a Next.js application from the [Clubedge Starter](https://github.com/Clubedge/clubedge-starter) reference implementation.
6
+ Create a Next.js or TanStack Start application from the [Clubedge Starter](https://github.com/Clubedge/clubedge-starter) reference implementation.
7
7
 
8
8
  The CLI downloads a tested Starter release, names the generated project, creates `apps/web/.env.local` from the example, initializes Git, and installs dependencies. The Starter remains the source of truth for the generated architecture.
9
9
 
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
12
12
  Each CLI release is tested against one specific Starter release:
13
13
 
14
14
  ```text
15
- create-clubedge-app v0.3.0
15
+ create-clubedge-app v0.4.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.3.0
19
- commit 60f1691920c91a5cf83e480a3ce98c3ddefdef55
18
+ Clubedge Starter v0.4.0
19
+ commit fc01d33bb1a3944e099a1750679be87a96ff9a22
20
20
  ```
21
21
 
22
22
  The current relationship is:
23
23
 
24
24
  | CLI release | Default Starter release | Meaning |
25
25
  | --- | --- | --- |
26
- | `create-clubedge-app@0.3.0` | `clubedge-starter@v0.3.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.4.0` | `clubedge-starter@v0.4.0` | The CLI's required, tested default |
27
27
 
28
28
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
29
29
 
30
- The `v0.3.0` tag is expected to resolve to commit
31
- `60f1691920c91a5cf83e480a3ce98c3ddefdef55`. The CLI verifies this before
30
+ The `v0.4.0` tag is expected to resolve to commit
31
+ `fc01d33bb1a3944e099a1750679be87a96ff9a22`. The CLI verifies this before
32
32
  scaffolding with the default reference and fails rather than silently using a
33
33
  moved tag.
34
34
 
35
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.3.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.
35
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.4.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.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -68,11 +68,27 @@ When no destination is provided, the CLI asks where to create the project:
68
68
  pnpm dlx @clubedge/create-clubedge-app
69
69
  ```
70
70
 
71
+ ### Choosing a framework
72
+
73
+ The Starter ships the same application for two React frameworks. Next.js is the default; pick TanStack Start with `--framework`, or choose interactively when the Starter offers more than one:
74
+
75
+ ```sh
76
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
77
+ ```
78
+
79
+ | `--framework` | App | Production output |
80
+ | ---------------- | --------------------------- | ----------------------------- |
81
+ | `next` | Next.js App Router | Next.js standalone build |
82
+ | `tanstack-start` | TanStack Start with Vite | Nitro `.output` server bundle |
83
+
84
+ 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.
85
+
71
86
  ## Options
72
87
 
73
88
  ```text
74
89
  Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
75
90
 
91
+ --framework <id> App framework: next (default) or tanstack-start
76
92
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
77
93
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
78
94
  --dry-run Show what would be created without writing anything
@@ -85,20 +101,20 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
85
101
 
86
102
  The target directory must be empty or not exist. The CLI never deletes or overwrites existing files, and if scaffolding fails it removes whatever it wrote, so a failed run leaves nothing behind.
87
103
 
88
- Every generated project records its CLI version, Starter repository, Starter ref, and Starter commit in `package.json` and `README.md`.
104
+ Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, and framework in `package.json` and `README.md`.
89
105
 
90
106
  ```sh
91
107
  # Preview the files and edits without writing anything
92
108
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
93
109
 
94
110
  # Non-interactive, for scripts and CI
95
- pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git
111
+ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstack-start
96
112
 
97
113
  # Try an unreleased Starter change
98
114
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
99
115
 
100
116
  # Reproduce a specific Starter release or commit
101
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.3.0
117
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.4.0
102
118
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
103
119
 
104
120
  # Test local Starter changes before releasing them
@@ -109,7 +125,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
109
125
 
110
126
  Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
111
127
 
112
- The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
128
+ The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
113
129
 
114
130
  The CLI first builds a plan (the files to copy and the edits to make), which `--dry-run` prints, and then applies it. The source is organized the same way:
115
131
 
@@ -143,7 +159,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
143
159
  1. Make and validate changes in `clubedge-starter`.
144
160
  2. Run lint, typecheck, unit tests, browser tests, and production build.
145
161
  3. Update the Starter package versions.
146
- 4. Commit `main`, create a release tag such as `v0.3.0`, and publish the Starter release.
162
+ 4. Commit `main`, create a release tag such as `v0.4.0`, and publish the Starter release.
147
163
 
148
164
  ### CLI maintainers
149
165
 
package/dist/cli/args.js CHANGED
@@ -11,6 +11,7 @@ export function parseCliArgs(argv) {
11
11
  allowNegative: true,
12
12
  strict: true,
13
13
  options: {
14
+ framework: { type: "string" },
14
15
  ref: { type: "string" },
15
16
  "template-dir": { type: "string" },
16
17
  install: { type: "boolean", default: true },
@@ -29,6 +30,9 @@ export function parseCliArgs(argv) {
29
30
  const { values, positionals } = parsed;
30
31
  if (positionals.length > 1)
31
32
  throw new UsageError("Only one project directory can be provided.");
33
+ if (values.framework !== undefined && !values.framework.trim()) {
34
+ throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
35
+ }
32
36
  if (values.ref !== undefined && !values.ref.trim()) {
33
37
  throw new UsageError("--ref requires a tag, branch, or commit.");
34
38
  }
@@ -37,6 +41,7 @@ export function parseCliArgs(argv) {
37
41
  }
38
42
  return {
39
43
  projectDirectory: positionals[0],
44
+ framework: values.framework?.trim(),
40
45
  ref: values.ref,
41
46
  templateDir: values["template-dir"],
42
47
  install: values.install,
package/dist/cli/ui.js CHANGED
@@ -63,6 +63,7 @@ Usage:
63
63
  pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
64
64
 
65
65
  Options:
66
+ --framework <id> App framework: next (default) or tanstack-start
66
67
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
67
68
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
68
69
  --dry-run Show what would be created without writing anything
@@ -74,6 +75,7 @@ Options:
74
75
 
75
76
  Examples:
76
77
  pnpm dlx @clubedge/create-clubedge-app my-app
78
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
77
79
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
78
80
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
79
81
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
@@ -9,6 +9,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
9
9
  starterRepository: provenance.starterRepository,
10
10
  starterRef: provenance.starterRef,
11
11
  ...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
12
+ ...(provenance.framework ? { framework: provenance.framework } : {}),
12
13
  };
13
14
  for (const script of ["docker:build", "docker:start"]) {
14
15
  if (typeof manifest.scripts?.[script] === "string") {
@@ -21,9 +22,10 @@ export function customizeReadme(source, { displayName }, provenance) {
21
22
  const commitLine = provenance.starterCommit
22
23
  ? `\n- Starter commit: \`${provenance.starterCommit}\``
23
24
  : "";
25
+ const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
24
26
  const origin = `## Generated from\n\n` +
25
27
  `- Starter repository: \`${provenance.starterRepository}\`\n` +
26
- `- Starter ref: \`${provenance.starterRef}\`${commitLine}\n` +
28
+ `- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}\n` +
27
29
  `- CLI version: \`${provenance.cliVersion}\``;
28
30
  // Only the first heading is the project title; leave every other line as written.
29
31
  return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
@@ -54,3 +56,40 @@ export function customizeLegacySource(source, { displayName }) {
54
56
  .replaceAll(">Clubedge<", `>${displayName}<`)
55
57
  .replaceAll("Starter workspace", "Application workspace");
56
58
  }
59
+ /** Gives the selected framework's app the package name generated projects use. */
60
+ export function customizeAppManifest(source, appPackage) {
61
+ const manifest = JSON.parse(source);
62
+ manifest.name = appPackage;
63
+ return json(manifest);
64
+ }
65
+ /**
66
+ * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
67
+ * works. Importers are keyed by workspace path at two-space indentation; package entries that
68
+ * are no longer used are harmless, and pnpm prunes them on the next lockfile update.
69
+ */
70
+ export function customizeLockfile(source, { remove, rename }) {
71
+ const output = [];
72
+ let inImporters = false;
73
+ let skipping = false;
74
+ for (const line of source.split("\n")) {
75
+ if (/^\S/.test(line)) {
76
+ inImporters = line === "importers:";
77
+ skipping = false;
78
+ }
79
+ else if (inImporters) {
80
+ // An importer key, with its value on the next lines or inline ("packages/core: {}").
81
+ const key = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
82
+ if (key !== undefined) {
83
+ skipping = remove.includes(key);
84
+ if (!skipping && key === rename?.from) {
85
+ output.push(` ${rename.to}:`);
86
+ continue;
87
+ }
88
+ }
89
+ if (skipping)
90
+ continue;
91
+ }
92
+ output.push(line);
93
+ }
94
+ return output.join("\n");
95
+ }
@@ -1,7 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  export const MANIFEST_FILE = "clubedge.template.json";
4
- export const SUPPORTED_SCHEMA_VERSION = 1;
4
+ export const SUPPORTED_SCHEMA_VERSION = 2;
5
+ /** Schema 1 Starters ship a single Next.js app at the generated location. */
6
+ function singleNextFramework(app, envExample) {
7
+ return {
8
+ defaultFramework: "next",
9
+ frameworks: { next: { name: "Next.js", app, envExample, dockerfile: "Dockerfile" } },
10
+ };
11
+ }
5
12
  /** Starter releases before v0.3.0 have no manifest; this describes their layout. */
6
13
  export const legacyManifest = {
7
14
  schemaVersion: 1,
@@ -11,6 +18,7 @@ export const legacyManifest = {
11
18
  siteConfig: "apps/web/src/config/site.json",
12
19
  env: { example: ".env.example", target: "apps/web/.env.local" },
13
20
  dockerImage: "clubedge-starter",
21
+ ...singleNextFramework("apps/web", ".env.example"),
14
22
  exclude: [],
15
23
  };
16
24
  export class ManifestError extends Error {
@@ -43,17 +51,47 @@ export function parseTemplateManifest(source) {
43
51
  if (data.exclude !== undefined && !isStringArray(data.exclude)) {
44
52
  throw new ManifestError(`${MANIFEST_FILE}: exclude must be an array of paths.`);
45
53
  }
54
+ const app = requireString(data, "app");
55
+ const envExample = requireString(env, "example", "env.");
46
56
  return {
47
57
  schemaVersion,
48
58
  name: requireString(data, "name"),
49
59
  packageManager: "pnpm",
50
- app: requireString(data, "app"),
60
+ app,
61
+ ...(schemaVersion >= 2 ? { appPackage: requireString(data, "appPackage") } : {}),
51
62
  siteConfig: requireString(data, "siteConfig"),
52
- env: { example: requireString(env, "example", "env."), target: requireString(env, "target", "env.") },
63
+ env: { example: envExample, target: requireString(env, "target", "env.") },
53
64
  dockerImage: requireString(data, "dockerImage"),
65
+ ...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
54
66
  exclude: data.exclude ?? [],
55
67
  };
56
68
  }
69
+ function parseFrameworks(data) {
70
+ const { frameworks } = data;
71
+ if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
72
+ throw new ManifestError(`${MANIFEST_FILE}: frameworks must describe at least one framework.`);
73
+ }
74
+ const parsed = {};
75
+ for (const [id, entry] of Object.entries(frameworks)) {
76
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) {
77
+ throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
78
+ }
79
+ if (!isRecord(entry))
80
+ throw new ManifestError(`${MANIFEST_FILE}: frameworks.${id} must be an object.`);
81
+ const prefix = `frameworks.${id}.`;
82
+ parsed[id] = {
83
+ name: requireString(entry, "name", prefix),
84
+ app: requireString(entry, "app", prefix),
85
+ envExample: requireString(entry, "envExample", prefix),
86
+ dockerfile: requireString(entry, "dockerfile", prefix),
87
+ };
88
+ }
89
+ const defaultFramework = requireString(data, "defaultFramework");
90
+ if (!parsed[defaultFramework]) {
91
+ throw new ManifestError(`${MANIFEST_FILE}: defaultFramework "${defaultFramework}" is not in frameworks.`);
92
+ }
93
+ return { defaultFramework, frameworks: parsed };
94
+ }
57
95
  export async function readTemplateManifest(templateRoot) {
58
96
  let source;
59
97
  try {
package/dist/core/plan.js CHANGED
@@ -1,16 +1,59 @@
1
- import { customizeLegacySource, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
1
+ import { customizeAppManifest, customizeLegacySource, customizeLockfile, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
2
+ export class FrameworkError extends Error {
3
+ name = "FrameworkError";
4
+ }
5
+ const LOCKFILE = "pnpm-lock.yaml";
6
+ const isWithin = (path, directory) => path === directory || path.startsWith(`${directory.replace(/\/$/, "")}/`);
2
7
  function isExcluded(path, exclude) {
3
- return exclude.some((entry) => path === entry || path.startsWith(`${entry.replace(/\/$/, "")}/`));
8
+ return exclude.some((entry) => isWithin(path, entry));
9
+ }
10
+ /** Resolves the requested framework, or the default, with a helpful error for unknown ids. */
11
+ export function selectFramework(manifest, requested) {
12
+ const id = requested ?? manifest.defaultFramework;
13
+ const framework = manifest.frameworks[id];
14
+ if (!framework) {
15
+ const choices = Object.keys(manifest.frameworks).join(", ");
16
+ throw new FrameworkError(`Unknown framework "${id}". This Starter offers: ${choices}.`);
17
+ }
18
+ return { id, ...framework };
19
+ }
20
+ /**
21
+ * Where a Starter file lands in the generated project, or null when it belongs to a framework
22
+ * that was not selected. The selected app moves to `manifest.app`, and its environment example
23
+ * and Dockerfile move to the generated project's locations.
24
+ */
25
+ function relocate(path, manifest, selected, others) {
26
+ if (path === selected.envExample)
27
+ return manifest.env.example;
28
+ if (path === selected.dockerfile)
29
+ return "Dockerfile";
30
+ if (isWithin(path, selected.app))
31
+ return manifest.app + path.slice(selected.app.length);
32
+ const belongsToOther = others.some((other) => isWithin(path, other.app) || path === other.envExample || path === other.dockerfile);
33
+ return belongsToOther ? null : path;
4
34
  }
5
35
  export function createPlan(input) {
6
36
  const { manifest, identity, source } = input;
37
+ const framework = selectFramework(manifest, input.framework);
38
+ const others = Object.entries(manifest.frameworks)
39
+ .filter(([id]) => id !== framework.id)
40
+ .map(([, other]) => other);
41
+ const multipleFrameworks = others.length > 0;
7
42
  const provenance = {
8
43
  cliVersion: input.cliVersion,
9
44
  starterRepository: source.repository,
10
45
  starterRef: source.ref,
11
46
  ...(source.commit ? { starterCommit: source.commit } : {}),
47
+ ...(multipleFrameworks ? { framework: framework.id, frameworkName: framework.name } : {}),
12
48
  };
13
- const files = source.files.filter(({ to }) => !isExcluded(to, manifest.exclude) && to !== manifest.env.target);
49
+ const files = [];
50
+ for (const file of source.files) {
51
+ if (isExcluded(file.to, manifest.exclude))
52
+ continue;
53
+ const to = relocate(file.to, manifest, framework, others);
54
+ if (to !== null && to !== manifest.env.target)
55
+ files.push({ from: file.from, to });
56
+ }
14
57
  const has = (path) => files.some(({ to }) => to === path);
15
58
  const edits = [];
16
59
  if (has("package.json")) {
@@ -20,6 +63,26 @@ export function createPlan(input) {
20
63
  apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
21
64
  });
22
65
  }
66
+ const appManifest = `${manifest.app}/package.json`;
67
+ if (manifest.appPackage && framework.app !== manifest.app && has(appManifest)) {
68
+ const appPackage = manifest.appPackage;
69
+ edits.push({
70
+ path: appManifest,
71
+ description: `Name the ${framework.name} app "${appPackage}"`,
72
+ apply: (text) => customizeAppManifest(text, appPackage),
73
+ });
74
+ }
75
+ if (multipleFrameworks && has(LOCKFILE)) {
76
+ const changes = {
77
+ remove: others.map((other) => other.app).filter((app) => app !== framework.app),
78
+ ...(framework.app !== manifest.app ? { rename: { from: framework.app, to: manifest.app } } : {}),
79
+ };
80
+ edits.push({
81
+ path: LOCKFILE,
82
+ description: `Keep only the ${framework.name} app in the lockfile`,
83
+ apply: (text) => customizeLockfile(text, changes),
84
+ });
85
+ }
23
86
  if (has("README.md")) {
24
87
  edits.push({
25
88
  path: "README.md",
@@ -47,6 +110,7 @@ export function createPlan(input) {
47
110
  targetDirectory: input.targetDirectory,
48
111
  identity,
49
112
  provenance,
113
+ framework: { id: framework.id, name: framework.name },
50
114
  files,
51
115
  edits,
52
116
  envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
@@ -57,6 +121,7 @@ export function createPlan(input) {
57
121
  export function describePlan(plan) {
58
122
  return [
59
123
  `Create ${plan.targetDirectory}`,
124
+ ...(plan.provenance.framework ? [`Use ${plan.framework.name}`] : []),
60
125
  `Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
61
126
  (plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
62
127
  ...plan.edits.map((edit) => `Edit ${edit.path}: ${edit.description}`),
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import { cancel, intro, isCancel, log, outro, spinner, text } from "@clack/prompts";
1
+ import { cancel, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
2
2
  import { relative, resolve } from "node:path";
3
3
  import process from "node:process";
4
4
  import { parseCliArgs, UsageError } from "./cli/args.js";
5
5
  import { dim, helpText, showBanner } from "./cli/ui.js";
6
6
  import { assertTargetIsUsable, executePlan } from "./core/execute.js";
7
7
  import { readTemplateManifest } from "./core/manifest.js";
8
- import { createPlan, describePlan } from "./core/plan.js";
8
+ import { createPlan, describePlan, selectFramework } from "./core/plan.js";
9
9
  import { resolveTemplateSource } from "./core/source.js";
10
10
  import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
11
11
  import { initializeGit } from "./steps/git.js";
@@ -30,6 +30,27 @@ async function askProjectDirectory() {
30
30
  }
31
31
  return value;
32
32
  }
33
+ /**
34
+ * The framework from --framework, a prompt when the Starter offers several, or the default.
35
+ * Returns null when the prompt is cancelled.
36
+ */
37
+ async function chooseFramework(manifest, requested, interactive) {
38
+ if (requested)
39
+ return selectFramework(manifest, requested).id;
40
+ const ids = Object.keys(manifest.frameworks);
41
+ if (!interactive || ids.length < 2)
42
+ return manifest.defaultFramework;
43
+ const value = await select({
44
+ message: "Which framework should the app use?",
45
+ initialValue: manifest.defaultFramework,
46
+ options: ids.map((id) => ({
47
+ value: id,
48
+ label: manifest.frameworks[id].name,
49
+ ...(id === manifest.defaultFramework ? { hint: "default" } : {}),
50
+ })),
51
+ });
52
+ return isCancel(value) ? null : value;
53
+ }
33
54
  async function createProject(args) {
34
55
  const interactive = Boolean(process.stdin.isTTY) && !args.yes;
35
56
  const requested = args.projectDirectory ?? (interactive ? await askProjectDirectory() : "my-app");
@@ -43,29 +64,43 @@ async function createProject(args) {
43
64
  progress.stop("Could not prepare the Starter");
44
65
  throw error;
45
66
  });
67
+ // The spinner runs while the manifest is read and again while files are written.
68
+ let spinning = "preparing";
46
69
  try {
47
70
  const manifest = await readTemplateManifest(source.root);
71
+ progress.stop(`Starter ${source.ref} ready`);
72
+ spinning = null;
73
+ const framework = await chooseFramework(manifest, args.framework, interactive);
74
+ if (framework === null) {
75
+ cancel("Operation cancelled.");
76
+ return;
77
+ }
48
78
  const plan = createPlan({
49
79
  targetDirectory,
50
80
  identity,
51
81
  source,
52
82
  manifest,
83
+ framework,
53
84
  cliVersion,
54
85
  git: args.git,
55
86
  install: args.install,
56
87
  });
57
88
  if (args.dryRun) {
58
- progress.stop("Dry run: nothing was written");
59
89
  log.message(describePlan(plan).map((line) => `• ${line}`).join("\n"));
60
- outro("Run again without --dry-run to create the project.");
90
+ outro("Dry run: nothing was written. Run again without --dry-run to create the project.");
61
91
  return;
62
92
  }
63
- progress.message(`Writing ${plan.files.length} files`);
93
+ spinning = "writing";
94
+ progress.start(`Writing ${plan.files.length} files`);
64
95
  await executePlan(plan, source);
65
- progress.stop(`Created ${identity.packageName} from Starter ${source.ref}`);
96
+ progress.stop(`Created ${identity.packageName} with ${plan.framework.name} from Starter ${source.ref}`);
97
+ spinning = null;
66
98
  }
67
99
  catch (error) {
68
- progress.stop("Project scaffold failed; nothing was left behind");
100
+ if (spinning === "preparing")
101
+ progress.stop("Could not read the Starter");
102
+ if (spinning === "writing")
103
+ progress.stop("Project scaffold failed; nothing was left behind");
69
104
  throw error;
70
105
  }
71
106
  finally {
package/dist/utils/fs.js CHANGED
@@ -33,6 +33,9 @@ function mapBasename(path, map) {
33
33
  const ignoredDirectories = new Set([
34
34
  ".git",
35
35
  ".next",
36
+ ".nitro",
37
+ ".output",
38
+ ".tanstack",
36
39
  ".turbo",
37
40
  ".pnpm-store",
38
41
  "node_modules",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.3.0",
4
- "description": "Create an application from the Clubedge Starter reference implementation.",
3
+ "version": "0.4.0",
4
+ "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
7
7
  "bin": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.3.0",
26
- "starterCommit": "60f1691920c91a5cf83e480a3ce98c3ddefdef55"
25
+ "starterRef": "v0.4.0",
26
+ "starterCommit": "fc01d33bb1a3944e099a1750679be87a96ff9a22"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "create-clubedge-app",
39
39
  "clubedge",
40
40
  "nextjs",
41
+ "tanstack-start",
41
42
  "starter",
42
43
  "scaffolding"
43
44
  ],
@@ -6,3 +6,6 @@ node_modules
6
6
  .env*
7
7
  coverage
8
8
  playwright-report
9
+ .output
10
+ .nitro
11
+ .tanstack
@@ -23,21 +23,68 @@ jobs:
23
23
  - run: pnpm lint
24
24
  - run: pnpm typecheck
25
25
  - run: pnpm test
26
- - run: pnpm exec playwright install --with-deps chromium
27
- - run: pnpm test:e2e
26
+ # Generated projects build with the env example copied into place; build the same way.
27
+ - run: cp .env.example apps/web/.env.local && cp apps/start/.env.example apps/start/.env.local
28
28
  - run: pnpm build
29
+ - name: Serve both production builds
30
+ run: |
31
+ set -euo pipefail
32
+ PORT=3001 pnpm --filter @clubedge/web start > "$RUNNER_TEMP/web.log" 2>&1 &
33
+ PORT=3002 pnpm --filter @clubedge/start start > "$RUNNER_TEMP/start.log" 2>&1 &
34
+ trap 'tail -n 50 "$RUNNER_TEMP/web.log" "$RUNNER_TEMP/start.log"' EXIT
35
+ for port in 3001 3002; do
36
+ for attempt in $(seq 1 30); do
37
+ curl --fail --silent --output /dev/null "http://127.0.0.1:$port/api/health" && break
38
+ sleep 1
39
+ done
40
+ for path in / /login /dashboard /api/health; do
41
+ curl --fail --silent --show-error --output /dev/null "http://127.0.0.1:$port$path"
42
+ done
43
+ done
29
44
  - run: pnpm audit --audit-level high
30
45
 
46
+ # The same browser suite runs against every framework.
47
+ e2e:
48
+ runs-on: ubuntu-latest
49
+ strategy:
50
+ fail-fast: false
51
+ matrix:
52
+ app: [web, start]
53
+ env:
54
+ E2E_APP: ${{ matrix.app }}
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ - uses: pnpm/action-setup@v4
58
+ - uses: actions/setup-node@v4
59
+ with:
60
+ node-version: 22
61
+ cache: pnpm
62
+ - run: pnpm install --frozen-lockfile
63
+ - run: pnpm exec playwright install --with-deps chromium
64
+ - run: pnpm test:e2e
65
+
31
66
  docker:
32
67
  runs-on: ubuntu-latest
68
+ strategy:
69
+ fail-fast: false
70
+ matrix:
71
+ include:
72
+ - app: web
73
+ build: docker build --tag clubedge-starter:ci .
74
+ - app: start
75
+ build: >-
76
+ docker build --tag clubedge-starter:ci --file apps/start/Dockerfile
77
+ --build-arg APP_DIR=apps/start --build-arg APP_PACKAGE=@clubedge/start .
33
78
  steps:
34
79
  - uses: actions/checkout@v4
35
80
  - name: Build the production image
36
- run: docker build --tag clubedge-starter:ci .
81
+ run: ${{ matrix.build }}
37
82
  - name: Start the image and check health
38
83
  run: |
39
84
  set -euo pipefail
40
- docker run --detach --name app --publish 3000:3000 --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres clubedge-starter:ci
85
+ docker run --detach --name app --publish 3000:3000 \
86
+ --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
87
+ clubedge-starter:ci
41
88
  trap 'docker logs app; docker rm --force app' EXIT
42
89
  for attempt in $(seq 1 30); do
43
90
  if curl --fail --silent http://127.0.0.1:3000/api/health; then
@@ -1 +1,2 @@
1
1
  pnpm-lock.yaml
2
+ **/routeTree.gen.ts
@@ -4,9 +4,10 @@
4
4
 
5
5
  - `packages/{core,auth,cache,db,storage}` are framework-agnostic. Never import `next`, `react`, `server-only`, `@tanstack/*`, or app code (`@/…`) there. Accept configuration, clients, and cookies as function arguments.
6
6
  - `packages/core` depends on no other workspace package.
7
- - `apps/web/src/server/` is the composition root: the only place that reads environment variables, bridges Next.js cookies to `CookieStore`, and picks providers. Routes, server actions, and components import from `@/server/*`, never from `@supabase/*`, `@aws-sdk/*`, `redis`, or `postgres`.
7
+ - Each app's `src/server/` is its composition root: the only place that reads environment variables, bridges the framework's cookies to `CookieStore`, and picks providers. Routes, server actions, server functions, and components import from `@/server/*`, never from `@supabase/*`, `@aws-sdk/*`, `redis`, or `postgres`.
8
+ - `apps/web` (Next.js) and `apps/start` (TanStack Start) are the same product. Make user-facing changes in both, keep their markup and redirects identical, and run the shared browser suite against each (`E2E_APP=start pnpm test:e2e`).
8
9
  - Expected failures (bad credentials, invalid codes) are returned as `Result` values from `@clubedge/core`; exceptions are for unexpected errors.
9
- - Project identity (name, description, links) lives in `apps/web/src/config/site.json`; `create-clubedge-app` rewrites that file, so do not hard-code the project name elsewhere.
10
+ - Project identity (name, description, links) lives in each app's `src/config/site.json` (keep them identical); `create-clubedge-app` rewrites that file, so do not hard-code the project name elsewhere.
10
11
  - `pnpm lint` enforces these boundaries. Run `pnpm lint`, `pnpm typecheck`, and `pnpm test` before finishing a change.
11
12
 
12
13
  <!-- BEGIN:nextjs-agent-rules -->
@@ -33,8 +33,8 @@ Run `pnpm test:e2e` for changes that affect browser behavior. It requires a loca
33
33
 
34
34
  ## Project conventions
35
35
 
36
- - Keep application code in `apps/web`, reusable shadcn/ui code in `packages/ui`, and framework-agnostic infrastructure in `packages/{core,auth,db,storage,cache}`.
37
- - Reach providers through `apps/web/src/server` rather than scattering provider SDK calls through routes and components. `pnpm lint` enforces these boundaries.
36
+ - Keep application code in `apps/web` (Next.js) and `apps/start` (TanStack Start), reusable shadcn/ui code in `packages/ui`, and framework-agnostic infrastructure in `packages/{core,auth,db,storage,cache}`. A user-facing change belongs in both apps.
37
+ - Reach providers through each app's `src/server` rather than scattering provider SDK calls through routes and components. `pnpm lint` enforces these boundaries.
38
38
  - Keep server secrets out of client code and out of committed files. Update `.env.example` when adding configuration, using placeholders only.
39
39
  - For database changes, update the Drizzle schema and generate, review, and commit the migration. Do not maintain a second migration history for the same application tables.
40
40
  - For shared UI changes, preserve compatibility between both `components.json` files and use the established package exports.