@clubedge/create-clubedge-app 0.5.0 → 0.7.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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -10
  3. package/dist/cli/args.js +2 -1
  4. package/dist/cli/ui.js +1 -0
  5. package/dist/core/customize.js +12 -3
  6. package/dist/core/lockfile-peers.js +174 -0
  7. package/dist/core/manifest.js +1 -0
  8. package/dist/core/plan.js +15 -1
  9. package/dist/core/source.js +12 -9
  10. package/dist/index.js +1 -1
  11. package/package.json +3 -3
  12. package/template/.env.example +28 -10
  13. package/template/README.md +45 -1
  14. package/template/SETUP.md +54 -1
  15. package/template/_gitignore +5 -0
  16. package/template/apps/start/.env.example +28 -10
  17. package/template/apps/start/package.json +4 -2
  18. package/template/apps/start/scripts/check-services.ts +128 -0
  19. package/template/apps/start/src/server/storage.ts +13 -0
  20. package/template/apps/web/package.json +5 -3
  21. package/template/apps/web/scripts/check-services.ts +128 -0
  22. package/template/apps/web/src/server/storage.ts +13 -0
  23. package/template/clubedge.template.json +52 -1
  24. package/template/docker-compose.dev.yml +42 -0
  25. package/template/package.json +7 -2
  26. package/template/packages/auth/package.json +1 -1
  27. package/template/packages/auth-better-auth/package.json +1 -1
  28. package/template/packages/auth-supabase/package.json +1 -1
  29. package/template/packages/cache/package.json +1 -1
  30. package/template/packages/cache-redis/package.json +1 -1
  31. package/template/packages/core/package.json +1 -1
  32. package/template/packages/db/package.json +1 -1
  33. package/template/packages/local-db/package.json +24 -0
  34. package/template/packages/local-db/scripts/serve.ts +66 -0
  35. package/template/packages/local-db/src/server.test.ts +119 -0
  36. package/template/packages/local-db/src/server.ts +153 -0
  37. package/template/packages/local-db/tsconfig.json +4 -0
  38. package/template/packages/storage/package.json +3 -2
  39. package/template/packages/storage/src/file-system.test.ts +110 -0
  40. package/template/packages/storage/src/file-system.ts +124 -0
  41. package/template/packages/storage-s3/package.json +1 -1
  42. package/template/packages/storage-s3/src/index.test.ts +22 -0
  43. package/template/packages/storage-s3/src/index.ts +7 -1
  44. package/template/packages/storage-supabase/package.json +1 -1
  45. package/template/packages/ui/package.json +1 -1
  46. package/template/playwright.config.ts +3 -1
  47. package/template/pnpm-lock.yaml +37 -9
  48. package/template/supabase/config.toml +40 -0
  49. package/template.lock.json +13 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.7.0
6
+
7
+ - Default to Starter `v0.7.0`, which adds `--infra local`: PostgreSQL through PGlite and a local folder for files, so `pnpm dev` runs with nothing installed.
8
+ - Remove optional peer links to packages that only left-out modules installed, so generated projects no longer install them. For example, a project without `--infra local` no longer installs PGlite through drizzle-orm, and a project without Better Auth no longer installs its optional peers.
9
+
10
+ ## 0.6.0
11
+
12
+ - Default to Starter `v0.6.0`, which adds the local infrastructure module and `pnpm check:services`.
13
+ - Add `--infra docker|supabase|none`: Docker Compose services, the Supabase CLI's local stack, or no local services. The environment examples point at the chosen services.
14
+ - Let module options set root `package.json` scripts; scripts that only left-out options define are removed, so each project gets the `infra:*` scripts for its option.
15
+ - Fix Starter tag verification on Windows, where an annotated tag resolved to the tag object instead of its commit and scaffolding a downloaded Starter release failed.
16
+
5
17
  ## 0.5.0
6
18
 
7
19
  - Default to Starter `v0.5.0`, which adds optional modules, Better Auth, and template schema 3.
package/README.md CHANGED
@@ -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.5.0
15
+ create-clubedge-app v0.7.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.5.0
19
- commit d9375c2c3b520323ff0255ef8ec65da19251c3c1
18
+ Clubedge Starter v0.7.0
19
+ commit 3b8114760b8151c2538b57c06620ff2b884f2d56
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.5.0` | `clubedge-starter@v0.5.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.7.0` | `clubedge-starter@v0.7.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.5.0` tag is expected to resolve to commit
31
- `d9375c2c3b520323ff0255ef8ec65da19251c3c1`. The CLI verifies this before
30
+ The `v0.7.0` tag is expected to resolve to commit
31
+ `3b8114760b8151c2538b57c06620ff2b884f2d56`. 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.5.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.7.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
 
@@ -92,6 +92,7 @@ Authentication, file storage, and caching are modules. Each has a default, and a
92
92
  | `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
93
93
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
94
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
+ | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
95
96
 
96
97
  ```sh
97
98
  # Only the monorepo, UI, database, and tooling
@@ -109,6 +110,7 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
109
110
  --auth <id> Authentication: supabase (default), better-auth, or none
110
111
  --storage <id> File storage: s3 (default), supabase, or none
111
112
  --cache <id> Cache and rate limits: redis (default) or memory
113
+ --infra <id> Local services: docker (default), supabase, local, or none
112
114
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
113
115
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
114
116
  --dry-run Show what would be created without writing anything
@@ -134,7 +136,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
134
136
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
135
137
 
136
138
  # Reproduce a specific Starter release or commit
137
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.5.0
139
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.7.0
138
140
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
139
141
 
140
142
  # Test local Starter changes before releasing them
@@ -145,7 +147,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
145
147
 
146
148
  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.
147
149
 
148
- 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. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. 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.
150
+ 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. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, the root `package.json` scripts it sets, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. 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.
149
151
 
150
152
  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:
151
153
 
@@ -179,7 +181,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
179
181
  1. Make and validate changes in `clubedge-starter`.
180
182
  2. Run lint, typecheck, unit tests, browser tests, and production build.
181
183
  3. Update the Starter package versions.
182
- 4. Commit `main`, create a release tag such as `v0.5.0`, and publish the Starter release.
184
+ 4. Commit `main`, create a release tag such as `v0.7.0`, and publish the Starter release.
183
185
 
184
186
  ### CLI maintainers
185
187
 
package/dist/cli/args.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  /** Module flags the CLI accepts. Which options exist is up to the Starter's manifest. */
3
- export const MODULE_FLAGS = ["auth", "storage", "cache"];
3
+ export const MODULE_FLAGS = ["auth", "storage", "cache", "infra"];
4
4
  export class UsageError extends Error {
5
5
  name = "UsageError";
6
6
  }
@@ -17,6 +17,7 @@ export function parseCliArgs(argv) {
17
17
  auth: { type: "string" },
18
18
  storage: { type: "string" },
19
19
  cache: { type: "string" },
20
+ infra: { type: "string" },
20
21
  ref: { type: "string" },
21
22
  "template-dir": { type: "string" },
22
23
  install: { type: "boolean", default: true },
package/dist/cli/ui.js CHANGED
@@ -67,6 +67,7 @@ Options:
67
67
  --auth <id> Authentication: supabase (default), better-auth, or none
68
68
  --storage <id> File storage: s3 (default), supabase, or none
69
69
  --cache <id> Cache and rate limits: redis (default) or memory
70
+ --infra <id> Local services: docker (default), supabase, local, or none
70
71
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
71
72
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
72
73
  --dry-run Show what would be created without writing anything
@@ -1,9 +1,16 @@
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
+ import { pruneOptionalPeers } from "./lockfile-peers.js";
3
4
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
4
- export function customizeRootManifest(source, { packageName }, provenance, dockerImage) {
5
+ export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
5
6
  const manifest = JSON.parse(source);
6
7
  manifest.name = packageName;
8
+ if (manifest.scripts && typeof manifest.scripts === "object") {
9
+ for (const name of scripts.remove)
10
+ delete manifest.scripts[name];
11
+ }
12
+ if (Object.keys(scripts.set).length)
13
+ manifest.scripts = { ...manifest.scripts, ...scripts.set };
7
14
  manifest.clubedge = {
8
15
  cliVersion: provenance.cliVersion,
9
16
  starterRepository: provenance.starterRepository,
@@ -69,7 +76,9 @@ export function customizeAppManifest(source, appPackage) {
69
76
  /**
70
77
  * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
71
78
  * works. Importers are keyed by workspace path at two-space indentation; package entries that
72
- * are no longer used are harmless, and pnpm prunes them on the next lockfile update.
79
+ * are no longer used are harmless, and pnpm prunes them on the next lockfile update. Optional
80
+ * peer links to packages that only left-out importers installed are removed, so those packages
81
+ * are not installed either.
73
82
  */
74
83
  export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
75
84
  const output = [];
@@ -129,7 +138,7 @@ export function customizeLockfile(source, { remove, rename, removeDependencies =
129
138
  skippingDependency = false;
130
139
  output.push(line);
131
140
  }
132
- return output.join("\n");
141
+ return pruneOptionalPeers(output.join("\n"));
133
142
  }
134
143
  export class ConditionalError extends Error {
135
144
  name = "ConditionalError";
@@ -0,0 +1,174 @@
1
+ // pnpm links a package's optional peer dependency whenever that peer is installed anywhere in the
2
+ // workspace, and records the link in lockfile keys: drizzle-orm@0.45.4(@electric-sql/pglite@0.5.8).
3
+ // When a left-out workspace package was the only reason the peer was installed, the link would
4
+ // keep installing it, so these helpers remove such links from the lockfile.
5
+ function unquote(text) {
6
+ return text.replace(/^(['"])(.*)\1$/, "$2");
7
+ }
8
+ /** "drizzle-orm@0.45.4(pglite@0.5.8)" -> "drizzle-orm@0.45.4"; "0.45.4(x@1)" -> "0.45.4". */
9
+ function withoutSuffix(text) {
10
+ const start = text.indexOf("(");
11
+ return start === -1 ? text : text.slice(0, start);
12
+ }
13
+ function parse(source) {
14
+ const roots = [];
15
+ const optionalPeers = new Map();
16
+ const snapshots = new Map();
17
+ let section = "";
18
+ let key = "";
19
+ let group = "";
20
+ let entry = "";
21
+ for (const line of source.split("\n")) {
22
+ if (/^\S/.test(line)) {
23
+ section = line.replace(/:.*$/, "");
24
+ continue;
25
+ }
26
+ const keyMatch = /^ {2}(\S.*?):(?: \{\})?$/.exec(line);
27
+ if (keyMatch) {
28
+ key = unquote(keyMatch[1]);
29
+ group = entry = "";
30
+ if (section === "snapshots")
31
+ snapshots.set(key, snapshots.get(key) ?? []);
32
+ continue;
33
+ }
34
+ const groupMatch = /^ {4}(\S+):$/.exec(line);
35
+ if (groupMatch) {
36
+ group = groupMatch[1];
37
+ entry = "";
38
+ continue;
39
+ }
40
+ const entryMatch = /^ {6}(\S.*?):(?: (.*))?$/.exec(line);
41
+ if (entryMatch) {
42
+ entry = unquote(entryMatch[1]);
43
+ const value = entryMatch[2] ? unquote(entryMatch[2]) : "";
44
+ if (section === "snapshots" && (group === "dependencies" || group === "optionalDependencies")) {
45
+ snapshots.get(key).push({ name: entry, ref: value });
46
+ }
47
+ continue;
48
+ }
49
+ const fieldMatch = /^ {8}(\S+): (.*)$/.exec(line);
50
+ if (!fieldMatch)
51
+ continue;
52
+ const [, field, value] = fieldMatch;
53
+ if (section === "importers" && field === "version")
54
+ roots.push(`${entry}@${unquote(value)}`);
55
+ if (section === "packages" && group === "peerDependenciesMeta" && field === "optional" && value === "true") {
56
+ const peers = optionalPeers.get(key) ?? new Set();
57
+ peers.add(entry);
58
+ optionalPeers.set(key, peers);
59
+ }
60
+ }
61
+ return { roots, optionalPeers, snapshots };
62
+ }
63
+ /**
64
+ * Finds optional peers that only optional-peer links keep installed: packages that no importer
65
+ * reaches through regular or required dependencies. Returns them as "name@version".
66
+ */
67
+ export function findUnneededOptionalPeers(source) {
68
+ const { roots, optionalPeers, snapshots } = parse(source);
69
+ const reached = new Set();
70
+ const peerLinks = new Set();
71
+ const queue = roots.filter((key) => snapshots.has(key));
72
+ while (queue.length) {
73
+ const key = queue.pop();
74
+ if (reached.has(key))
75
+ continue;
76
+ reached.add(key);
77
+ const peers = optionalPeers.get(withoutSuffix(key));
78
+ for (const { name, ref } of snapshots.get(key) ?? []) {
79
+ if (peers?.has(name)) {
80
+ peerLinks.add(`${name}@${withoutSuffix(ref)}`);
81
+ continue;
82
+ }
83
+ const target = `${name}@${ref}`;
84
+ if (snapshots.has(target))
85
+ queue.push(target);
86
+ }
87
+ }
88
+ const reachedPackages = new Set([...reached].map(withoutSuffix));
89
+ return [...peerLinks].filter((peer) => !reachedPackages.has(peer)).sort();
90
+ }
91
+ /** Removes every "(peer)" or "(peer(...))" segment from lockfile keys and versions. */
92
+ function removeSuffixSegments(text, peer) {
93
+ const marker = `(${peer}`;
94
+ let result = "";
95
+ let index = 0;
96
+ for (let found = text.indexOf(marker); found !== -1; found = text.indexOf(marker, index)) {
97
+ const next = text[found + marker.length];
98
+ if (next !== ")" && next !== "(") {
99
+ // A different package whose name merely starts with this one.
100
+ result += text.slice(index, found + marker.length);
101
+ index = found + marker.length;
102
+ continue;
103
+ }
104
+ let depth = 0;
105
+ let end = found;
106
+ for (; end < text.length; end++) {
107
+ if (text[end] === "(")
108
+ depth++;
109
+ else if (text[end] === ")" && --depth === 0)
110
+ break;
111
+ }
112
+ result += text.slice(index, found);
113
+ index = end + 1;
114
+ }
115
+ return result + text.slice(index);
116
+ }
117
+ /**
118
+ * Drops optional-peer links to packages nothing else installs (see findUnneededOptionalPeers),
119
+ * so `pnpm install --frozen-lockfile` no longer installs them. Snapshot entries that become
120
+ * identical are merged.
121
+ */
122
+ export function pruneOptionalPeers(source) {
123
+ const peers = findUnneededOptionalPeers(source);
124
+ if (!peers.length)
125
+ return source;
126
+ let text = source;
127
+ for (const peer of peers)
128
+ text = removeSuffixSegments(text, peer);
129
+ const output = [];
130
+ const seenKeys = new Set();
131
+ let section = "";
132
+ let skippingEntry = false;
133
+ let pendingGroup = null;
134
+ for (const line of text.split("\n")) {
135
+ if (/^\S/.test(line)) {
136
+ section = line.replace(/:.*$/, "");
137
+ skippingEntry = false;
138
+ pendingGroup = null;
139
+ output.push(line);
140
+ continue;
141
+ }
142
+ if (section !== "snapshots") {
143
+ output.push(line);
144
+ continue;
145
+ }
146
+ const keyMatch = /^ {2}(\S.*?):(?: \{\})?$/.exec(line);
147
+ if (keyMatch) {
148
+ pendingGroup = null;
149
+ // Removing a link can make two snapshot keys identical; keep the first entry.
150
+ skippingEntry = seenKeys.has(keyMatch[1]);
151
+ seenKeys.add(keyMatch[1]);
152
+ if (!skippingEntry)
153
+ output.push(line);
154
+ continue;
155
+ }
156
+ if (skippingEntry)
157
+ continue;
158
+ if (/^ {4}\S+:$/.test(line)) {
159
+ // Held until the group gets an entry, so a group whose only entry was removed disappears.
160
+ pendingGroup = line;
161
+ continue;
162
+ }
163
+ const dependency = /^ {6}(\S.*?): (.*)$/.exec(line);
164
+ if (dependency && peers.includes(`${unquote(dependency[1])}@${withoutSuffix(unquote(dependency[2]))}`)) {
165
+ continue;
166
+ }
167
+ if (pendingGroup !== null && line.trim() !== "") {
168
+ output.push(pendingGroup);
169
+ pendingGroup = null;
170
+ }
171
+ output.push(line);
172
+ }
173
+ return output.join("\n");
174
+ }
@@ -102,6 +102,7 @@ function parseModules(value) {
102
102
  files: optionalStringArray(option, "files", optionPrefix),
103
103
  replace: optionalStringRecord(option, "replace", optionPrefix),
104
104
  requires: parseRequires(option.requires, optionPrefix),
105
+ scripts: optionalStringRecord(option, "scripts", optionPrefix),
105
106
  };
106
107
  }
107
108
  const defaultOption = requireString(entry, "default", prefix);
package/dist/core/plan.js CHANGED
@@ -97,8 +97,22 @@ function resolveModuleFiles(manifest, selection) {
97
97
  }
98
98
  }
99
99
  }
100
+ // Selected options set their scripts; scripts only left-out options define are removed.
101
+ const scripts = { set: {}, remove: [] };
102
+ const offeredScripts = new Set();
103
+ for (const [id, module] of Object.entries(manifest.modules)) {
104
+ for (const [optionId, option] of Object.entries(module.options)) {
105
+ for (const [name, command] of Object.entries(option.scripts)) {
106
+ offeredScripts.add(name);
107
+ if (selection[id] === optionId)
108
+ scripts.set[name] = command;
109
+ }
110
+ }
111
+ }
112
+ scripts.remove = [...offeredScripts].filter((name) => !(name in scripts.set));
100
113
  const removedPackages = [...offeredPackages].filter(([name]) => !usedPackages.has(name));
101
114
  return {
115
+ scripts,
102
116
  removedPackageNames: removedPackages.map(([name]) => name),
103
117
  removedPackageDirectories: removedPackages.map(([, directory]) => directory),
104
118
  replacedTargets: new Set(replacements.values()),
@@ -165,7 +179,7 @@ export function createPlan(input) {
165
179
  edits.push({
166
180
  path: "package.json",
167
181
  description: `Name the package "${identity.packageName}" and record its origin`,
168
- apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
182
+ apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage, moduleFiles.scripts),
169
183
  });
170
184
  }
171
185
  const appManifest = `${manifest.app}/package.json`;
@@ -50,15 +50,18 @@ export async function readDirectoryTemplate(directory) {
50
50
  }
51
51
  /** Resolves a tag to its commit with `git ls-remote`, following annotated tags. */
52
52
  export async function resolveTagCommit(repository, tag) {
53
- const output = await runCapture("git", [
54
- "ls-remote",
55
- `https://github.com/${repository}.git`,
56
- `refs/tags/${tag}^{}`,
57
- `refs/tags/${tag}`,
58
- ]);
59
- const lines = output.split("\n").map((line) => line.split(/\s+/));
60
- const peeled = lines.find(([, ref]) => ref?.endsWith("^{}"));
61
- return (peeled ?? lines[0])?.[0] || undefined;
53
+ // List every tag rather than passing a `refs/tags/<tag>^{}` pattern: on Windows the command
54
+ // runs through cmd.exe, which strips `^`, so the peeled ref would never match.
55
+ const output = await runCapture("git", ["ls-remote", "--tags", `https://github.com/${repository}.git`]);
56
+ return pickTagCommit(output, tag);
57
+ }
58
+ /** Picks a tag's commit from `git ls-remote` output, preferring the peeled ref of an annotated tag. */
59
+ export function pickTagCommit(output, tag) {
60
+ const refs = new Map(output
61
+ .split("\n")
62
+ .map((line) => line.trim().split(/\s+/))
63
+ .map(([id, ref]) => [ref, id]));
64
+ return refs.get(`refs/tags/${tag}^{}`) ?? refs.get(`refs/tags/${tag}`);
62
65
  }
63
66
  export async function downloadGitHubTemplate(ref, { repository = starterPin.starterRepository, expectedCommit } = {}) {
64
67
  if (expectedCommit) {
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ async function chooseModules(manifest, requested, interactive) {
61
61
  if (!interactive || unasked.length === 0)
62
62
  return requested;
63
63
  const customize = await confirm({
64
- message: "Customize the included services (auth, storage, cache)?",
64
+ message: "Customize the included services (auth, storage, cache, local infrastructure)?",
65
65
  initialValue: false,
66
66
  });
67
67
  if (isCancel(customize))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
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",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.5.0",
26
- "starterCommit": "d9375c2c3b520323ff0255ef8ec65da19251c3c1"
25
+ "starterRef": "v0.7.0",
26
+ "starterCommit": "3b8114760b8151c2538b57c06620ff2b884f2d56"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -1,12 +1,20 @@
1
1
  NODE_ENV=development
2
2
  NEXT_PUBLIC_APP_URL=http://localhost:3000
3
3
 
4
- # Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db).
5
- DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
4
+ # Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db). # clubedge:only infra=none
5
+ DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres # clubedge:only infra=none
6
+ # Local Supabase database, started by pnpm infra:up. # clubedge:only infra=supabase
7
+ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # clubedge:only infra=supabase
8
+ # Local PostgreSQL from docker-compose.dev.yml, started by pnpm infra:up. # clubedge:only infra=docker
9
+ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app # clubedge:only infra=docker
10
+ # Local PostgreSQL (PGlite) that pnpm dev starts; no Docker needed. # clubedge:only infra=local
11
+ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5433/postgres # clubedge:only infra=local
6
12
 
7
13
  # clubedge:if auth=supabase
8
14
  # Supabase Auth (publishable key only; never put a service role key in the browser)
9
- NEXT_PUBLIC_SUPABASE_URL=
15
+ NEXT_PUBLIC_SUPABASE_URL= # clubedge:only infra!=supabase
16
+ # Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
17
+ NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
10
18
  NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
11
19
  # clubedge:end
12
20
 
@@ -19,20 +27,30 @@ BETTER_AUTH_SECRET=
19
27
  # clubedge:if cache=redis
20
28
  # Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
21
29
  # process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
22
- REDIS_URL=
30
+ REDIS_URL= # clubedge:only infra!=docker
31
+ REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
23
32
  # clubedge:end
24
33
 
25
34
  # clubedge:if storage=s3
26
35
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
27
- STORAGE_BUCKET=
28
- STORAGE_REGION=auto
29
- STORAGE_ENDPOINT=
30
- STORAGE_ACCESS_KEY_ID=
31
- STORAGE_SECRET_ACCESS_KEY=
36
+ STORAGE_BUCKET= # clubedge:only infra!=docker
37
+ STORAGE_REGION=auto # clubedge:only infra!=docker
38
+ STORAGE_ENDPOINT= # clubedge:only infra!=docker
39
+ STORAGE_ACCESS_KEY_ID= # clubedge:only infra!=docker
40
+ STORAGE_SECRET_ACCESS_KEY= # clubedge:only infra!=docker
41
+ # Local S3-compatible storage from docker-compose.dev.yml; it accepts any credentials. # clubedge:only infra=docker
42
+ STORAGE_BUCKET=app # clubedge:only infra=docker
43
+ STORAGE_REGION=us-east-1 # clubedge:only infra=docker
44
+ STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
45
+ STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
46
+ STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
32
47
  STORAGE_PUBLIC_URL=
48
+ # Without an S3 service, files are stored in this folder (relative to the app). # clubedge:only infra=local
49
+ STORAGE_DIRECTORY=../../.data/storage # clubedge:only infra=local
33
50
  # clubedge:end
34
51
 
35
52
  # clubedge:if storage=supabase
36
53
  # Supabase Storage bucket, accessed with the signed-in user's Supabase session
37
- SUPABASE_STORAGE_BUCKET=
54
+ SUPABASE_STORAGE_BUCKET= # clubedge:only infra!=supabase
55
+ SUPABASE_STORAGE_BUCKET=app # clubedge:only infra=supabase
38
56
  # clubedge:end
@@ -93,7 +93,41 @@ macOS/Linux:
93
93
  cp .env.example apps/web/.env.local
94
94
  ```
95
95
 
96
- Set `DATABASE_URL` in `apps/web/.env.local`, then start the app. [SETUP.md](SETUP.md) describes every variable and provider.
96
+ <!-- clubedge:if infra=docker -->
97
+
98
+ Start PostgreSQL and the other local services with Docker, apply the migrations, and check that the app can reach every service. The example environment already points at them.
99
+
100
+ ```sh
101
+ pnpm infra:up
102
+ pnpm db:migrate
103
+ pnpm check:services
104
+ ```
105
+
106
+ <!-- clubedge:end -->
107
+ <!-- clubedge:if infra=supabase -->
108
+
109
+ Start the local Supabase stack (it needs Docker), copy the publishable key that `pnpm infra:status` prints into `apps/web/.env.local`, and apply the migrations.
110
+
111
+ ```sh
112
+ pnpm infra:up
113
+ pnpm infra:status
114
+ pnpm db:migrate
115
+ pnpm check:services
116
+ ```
117
+
118
+ <!-- clubedge:end -->
119
+ <!-- clubedge:if infra=local -->
120
+
121
+ Nothing else to install: `pnpm dev` starts a local PostgreSQL database (PGlite, stored in `.data/`) next to the app and applies the migrations. Files go to a local folder until you configure object storage.
122
+
123
+ <!-- clubedge:end -->
124
+ <!-- clubedge:if infra=none -->
125
+
126
+ Set `DATABASE_URL` and your providers' settings in `apps/web/.env.local`, then run `pnpm db:migrate` and `pnpm check:services`.
127
+
128
+ <!-- clubedge:end -->
129
+
130
+ Then start the app. [SETUP.md](SETUP.md) describes every variable and provider.
97
131
 
98
132
  ```sh
99
133
  pnpm dev
@@ -125,6 +159,16 @@ Run these from the repository root:
125
159
 
126
160
  Playwright's first run may require installing Chromium with `pnpm exec playwright install chromium`. CI runs the browser checks automatically.
127
161
 
162
+ Local services:
163
+
164
+ - `pnpm check:services`: check every configured service with a real round trip.
165
+ - `pnpm infra:up` and `pnpm infra:down`: start and stop the local services from `docker-compose.dev.yml`. <!-- clubedge:only infra=docker -->
166
+ - `pnpm infra:reset`: stop them and delete their data. `pnpm infra:logs` follows their logs. <!-- clubedge:only infra=docker -->
167
+ - `pnpm infra:up` and `pnpm infra:down`: start and stop the local Supabase stack. <!-- clubedge:only infra=supabase -->
168
+ - `pnpm infra:status`: print its URLs and keys. `pnpm infra:reset` stops it and deletes its data. <!-- clubedge:only infra=supabase -->
169
+ - `pnpm infra:up`: run only the local database, for example for `pnpm check:services` or browser tests. <!-- clubedge:only infra=local -->
170
+ - `pnpm infra:reset`: delete the local database and files in `.data/` (stop `pnpm dev` first). <!-- clubedge:only infra=local -->
171
+
128
172
  <!-- clubedge:if starter-repository -->
129
173
 
130
174
  ## Working on this repository
package/template/SETUP.md CHANGED
@@ -31,7 +31,10 @@ macOS/Linux:
31
31
  cp .env.example apps/web/.env.local
32
32
  ```
33
33
 
34
- The example file leaves provider URLs and keys blank so the app can build and start without connecting to real services. At minimum, set `DATABASE_URL`. The variables are:
34
+ The example file points at the local services described in [Local services](#local-services) and leaves provider keys blank, so the app builds and starts before anything is configured. <!-- clubedge:only infra!=none -->
35
+ The example file leaves provider URLs and keys blank so the app can build and start without connecting to real services. At minimum, set `DATABASE_URL`. <!-- clubedge:only infra=none -->
36
+
37
+ The variables are:
35
38
 
36
39
  - `DATABASE_URL` (required): PostgreSQL connection URL, such as a Supabase transaction pooler URL.
37
40
  - `NEXT_PUBLIC_APP_URL` (recommended): the app's origin, defaulting to `http://localhost:3000`. <!-- clubedge:only framework=next -->
@@ -83,6 +86,56 @@ Put the result in `BETTER_AUTH_SECRET`. Until it is set, the dashboard stays vie
83
86
 
84
87
  <!-- clubedge:end -->
85
88
 
89
+ ## Local services
90
+
91
+ <!-- clubedge:if infra=docker -->
92
+
93
+ `docker-compose.dev.yml` runs the services this project uses on your machine, listening on `127.0.0.1` only. Install Docker Desktop (or Docker Engine with Compose), then:
94
+
95
+ ```sh
96
+ pnpm infra:up # start and wait until every service is healthy
97
+ pnpm db:migrate # create the tables
98
+ pnpm check:services
99
+ ```
100
+
101
+ - PostgreSQL 17 on port 5432, database `app`, user and password `postgres`. Data survives restarts in a Docker volume.
102
+ - Redis on port 6379. <!-- clubedge:only cache=redis -->
103
+ - S3-compatible storage (adobe/s3mock) on port 9090 with the bucket `app`. It accepts any credentials and is for development only. <!-- clubedge:only storage=s3 -->
104
+
105
+ `pnpm infra:down` stops the services, `pnpm infra:reset` also deletes their data, and `pnpm infra:logs` follows their logs. The file is for development; in production, point the same variables at managed services.
106
+
107
+ <!-- clubedge:end -->
108
+ <!-- clubedge:if infra=supabase -->
109
+
110
+ `supabase/config.toml` configures the Supabase CLI's local stack: PostgreSQL, Auth, Storage, the Studio dashboard, and an inbox that captures emails. The CLI runs it in Docker, so install Docker Desktop first. Then:
111
+
112
+ ```sh
113
+ pnpm infra:up # start the stack (the first run downloads its images)
114
+ pnpm infra:status # print the local URLs and keys
115
+ pnpm db:migrate
116
+ pnpm check:services
117
+ ```
118
+
119
+ The database is on port 54322 and the API on port 54321, matching the example environment. Copy the publishable key from `pnpm infra:status` into the Supabase variables. Email confirmation is off locally, so sign-up signs the user in directly. `pnpm infra:down` stops the stack and `pnpm infra:reset` also deletes its data.
120
+
121
+ <!-- clubedge:end -->
122
+ <!-- clubedge:if infra=local -->
123
+
124
+ This project runs its development services without Docker or any other installation:
125
+
126
+ - **PostgreSQL:** `pnpm dev` also starts [PGlite](https://pglite.dev), PostgreSQL compiled to WebAssembly, and serves it on `127.0.0.1:5433`. Each start applies the migrations. Data is kept in `.data/pglite`. `pnpm infra:up` runs the database on its own, for `pnpm check:services` or browser tests.
127
+ - **Files:** while `STORAGE_DIRECTORY` is set, uploads are stored in that folder (`.data/storage`) instead of S3. Set the S3 variables and remove `STORAGE_DIRECTORY` to use object storage. <!-- clubedge:only storage=s3 -->
128
+ - **Cache:** without `REDIS_URL`, the cache and rate limits use process memory. <!-- clubedge:only cache=redis -->
129
+
130
+ PGlite runs a single database session, so connections take turns. That is plenty for development but not for production: deploy against a managed PostgreSQL by setting `DATABASE_URL`. Supabase Auth and Supabase Storage need a Supabase project, local or hosted. Better Auth works fully offline. `pnpm infra:reset` deletes everything in `.data/`.
131
+
132
+ <!-- clubedge:end -->
133
+ <!-- clubedge:if infra=none -->
134
+
135
+ This project does not include local service definitions. Point `DATABASE_URL` and the provider variables at services you run yourself or at hosted ones, such as Supabase or Neon for PostgreSQL and Upstash for Redis, then run `pnpm check:services` to confirm the app can reach them.
136
+
137
+ <!-- clubedge:end -->
138
+
86
139
  ## Set up the database
87
140
 
88
141
  Application tables are managed with Drizzle. Keep one migration source of truth for application tables: do not also create an independent Supabase migration history for those same tables.
@@ -17,3 +17,8 @@ test-results/
17
17
  dist/
18
18
  # Generated by TanStack Router during dev, build, and typecheck
19
19
  routeTree.gen.ts
20
+ # Supabase CLI working files
21
+ supabase/.temp/
22
+ supabase/.branches/
23
+ # Local database and files for --infra local
24
+ .data/