@clubedge/create-clubedge-app 0.6.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 (39) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +11 -11
  3. package/dist/cli/ui.js +1 -1
  4. package/dist/core/customize.js +5 -2
  5. package/dist/core/lockfile-peers.js +174 -0
  6. package/package.json +3 -3
  7. package/template/.env.example +4 -0
  8. package/template/README.md +7 -0
  9. package/template/SETUP.md +11 -0
  10. package/template/_gitignore +2 -0
  11. package/template/apps/start/.env.example +4 -0
  12. package/template/apps/start/package.json +1 -1
  13. package/template/apps/start/scripts/check-services.ts +22 -0
  14. package/template/apps/start/src/server/storage.ts +13 -0
  15. package/template/apps/web/package.json +1 -1
  16. package/template/apps/web/scripts/check-services.ts +22 -0
  17. package/template/apps/web/src/server/storage.ts +13 -0
  18. package/template/clubedge.template.json +19 -1
  19. package/template/package.json +1 -1
  20. package/template/packages/auth/package.json +1 -1
  21. package/template/packages/auth-better-auth/package.json +1 -1
  22. package/template/packages/auth-supabase/package.json +1 -1
  23. package/template/packages/cache/package.json +1 -1
  24. package/template/packages/cache-redis/package.json +1 -1
  25. package/template/packages/core/package.json +1 -1
  26. package/template/packages/db/package.json +1 -1
  27. package/template/packages/local-db/package.json +24 -0
  28. package/template/packages/local-db/scripts/serve.ts +66 -0
  29. package/template/packages/local-db/src/server.test.ts +119 -0
  30. package/template/packages/local-db/src/server.ts +153 -0
  31. package/template/packages/local-db/tsconfig.json +4 -0
  32. package/template/packages/storage/package.json +3 -2
  33. package/template/packages/storage/src/file-system.test.ts +110 -0
  34. package/template/packages/storage/src/file-system.ts +124 -0
  35. package/template/packages/storage-s3/package.json +1 -1
  36. package/template/packages/storage-supabase/package.json +1 -1
  37. package/template/packages/ui/package.json +1 -1
  38. package/template/pnpm-lock.yaml +31 -9
  39. package/template.lock.json +9 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
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
+
5
10
  ## 0.6.0
6
11
 
7
12
  - Default to Starter `v0.6.0`, which adds the local infrastructure module and `pnpm check:services`.
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.6.0
15
+ create-clubedge-app v0.7.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.6.0
19
- commit bf5a5e801254816455cbb837902283b788ab7595
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.6.0` | `clubedge-starter@v0.6.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.6.0` tag is expected to resolve to commit
31
- `bf5a5e801254816455cbb837902283b788ab7595`. 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.6.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,7 +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), `none` | `docker` |
95
+ | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
96
96
 
97
97
  ```sh
98
98
  # Only the monorepo, UI, database, and tooling
@@ -110,7 +110,7 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
110
110
  --auth <id> Authentication: supabase (default), better-auth, or none
111
111
  --storage <id> File storage: s3 (default), supabase, or none
112
112
  --cache <id> Cache and rate limits: redis (default) or memory
113
- --infra <id> Local services: docker (default), supabase, or none
113
+ --infra <id> Local services: docker (default), supabase, local, or none
114
114
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
115
115
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
116
116
  --dry-run Show what would be created without writing anything
@@ -136,7 +136,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
136
136
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
137
137
 
138
138
  # Reproduce a specific Starter release or commit
139
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.6.0
139
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.7.0
140
140
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
141
141
 
142
142
  # Test local Starter changes before releasing them
@@ -181,7 +181,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
181
181
  1. Make and validate changes in `clubedge-starter`.
182
182
  2. Run lint, typecheck, unit tests, browser tests, and production build.
183
183
  3. Update the Starter package versions.
184
- 4. Commit `main`, create a release tag such as `v0.6.0`, and publish the Starter release.
184
+ 4. Commit `main`, create a release tag such as `v0.7.0`, and publish the Starter release.
185
185
 
186
186
  ### CLI maintainers
187
187
 
package/dist/cli/ui.js CHANGED
@@ -67,7 +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, or none
70
+ --infra <id> Local services: docker (default), supabase, local, or none
71
71
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
72
72
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
73
73
  --dry-run Show what would be created without writing anything
@@ -1,5 +1,6 @@
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
5
  export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
5
6
  const manifest = JSON.parse(source);
@@ -75,7 +76,9 @@ export function customizeAppManifest(source, appPackage) {
75
76
  /**
76
77
  * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
77
78
  * works. Importers are keyed by workspace path at two-space indentation; package entries that
78
- * 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.
79
82
  */
80
83
  export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
81
84
  const output = [];
@@ -135,7 +138,7 @@ export function customizeLockfile(source, { remove, rename, removeDependencies =
135
138
  skippingDependency = false;
136
139
  output.push(line);
137
140
  }
138
- return output.join("\n");
141
+ return pruneOptionalPeers(output.join("\n"));
139
142
  }
140
143
  export class ConditionalError extends Error {
141
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.6.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.6.0",
26
- "starterCommit": "bf5a5e801254816455cbb837902283b788ab7595"
25
+ "starterRef": "v0.7.0",
26
+ "starterCommit": "3b8114760b8151c2538b57c06620ff2b884f2d56"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -7,6 +7,8 @@ DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].poole
7
7
  DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # clubedge:only infra=supabase
8
8
  # Local PostgreSQL from docker-compose.dev.yml, started by pnpm infra:up. # clubedge:only infra=docker
9
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
10
12
 
11
13
  # clubedge:if auth=supabase
12
14
  # Supabase Auth (publishable key only; never put a service role key in the browser)
@@ -43,6 +45,8 @@ STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
43
45
  STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
44
46
  STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
45
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
46
50
  # clubedge:end
47
51
 
48
52
  # clubedge:if storage=supabase
@@ -115,6 +115,11 @@ pnpm db:migrate
115
115
  pnpm check:services
116
116
  ```
117
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
+
118
123
  <!-- clubedge:end -->
119
124
  <!-- clubedge:if infra=none -->
120
125
 
@@ -161,6 +166,8 @@ Local services:
161
166
  - `pnpm infra:reset`: stop them and delete their data. `pnpm infra:logs` follows their logs. <!-- clubedge:only infra=docker -->
162
167
  - `pnpm infra:up` and `pnpm infra:down`: start and stop the local Supabase stack. <!-- clubedge:only infra=supabase -->
163
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 -->
164
171
 
165
172
  <!-- clubedge:if starter-repository -->
166
173
 
package/template/SETUP.md CHANGED
@@ -118,6 +118,17 @@ pnpm check:services
118
118
 
119
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
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
+
121
132
  <!-- clubedge:end -->
122
133
  <!-- clubedge:if infra=none -->
123
134
 
@@ -20,3 +20,5 @@ routeTree.gen.ts
20
20
  # Supabase CLI working files
21
21
  supabase/.temp/
22
22
  supabase/.branches/
23
+ # Local database and files for --infra local
24
+ .data/
@@ -8,6 +8,8 @@ DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].poole
8
8
  DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # clubedge:only infra=supabase
9
9
  # Local PostgreSQL from docker-compose.dev.yml, started by pnpm infra:up. # clubedge:only infra=docker
10
10
  DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app # clubedge:only infra=docker
11
+ # Local PostgreSQL (PGlite) that pnpm dev starts; no Docker needed. # clubedge:only infra=local
12
+ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5433/postgres # clubedge:only infra=local
11
13
 
12
14
  # clubedge:if auth=supabase
13
15
  # Supabase Auth (publishable key only; never use a service role key here).
@@ -45,6 +47,8 @@ STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
45
47
  STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
46
48
  STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
47
49
  STORAGE_PUBLIC_URL=
50
+ # Without an S3 service, files are stored in this folder (relative to the app). # clubedge:only infra=local
51
+ STORAGE_DIRECTORY=../../.data/storage # clubedge:only infra=local
48
52
  # clubedge:end
49
53
 
50
54
  # clubedge:if storage=supabase
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -8,6 +8,9 @@ import { createRedisCache } from "@clubedge/cache-redis";
8
8
  // clubedge:if storage=s3
9
9
  import { createS3Storage } from "@clubedge/storage-s3";
10
10
  // clubedge:end
11
+ // clubedge:if storage=s3 && infra=local
12
+ import { createFileSystemStorage } from "@clubedge/storage/file-system";
13
+ // clubedge:end
11
14
 
12
15
  type Check = { name: string; skip?: string; run: () => Promise<string> };
13
16
 
@@ -59,6 +62,25 @@ async function main() {
59
62
  },
60
63
  },
61
64
  // clubedge:end
65
+ // clubedge:if storage=s3 && infra=local
66
+ {
67
+ name: "Local folder storage",
68
+ skip: env.STORAGE_DIRECTORY ? undefined : "STORAGE_DIRECTORY is not set",
69
+ async run() {
70
+ const storage = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY! });
71
+ const key = `check-services/${Date.now()}.txt`;
72
+ await storage.upload({
73
+ key,
74
+ body: new TextEncoder().encode("ok"),
75
+ contentType: "text/plain",
76
+ });
77
+ const text = await new Response(await storage.download(key)).text();
78
+ await storage.delete(key);
79
+ if (text !== "ok") throw new Error(`read back "${text}"`);
80
+ return `wrote, read back, and deleted a file in ${env.STORAGE_DIRECTORY}`;
81
+ },
82
+ },
83
+ // clubedge:end
62
84
  // clubedge:if storage=s3
63
85
  {
64
86
  name: "File storage",
@@ -2,6 +2,9 @@ import "@tanstack/react-start/server-only";
2
2
  import { z } from "zod";
3
3
  import type { StorageProvider } from "@clubedge/storage";
4
4
  import { createS3Storage } from "@clubedge/storage-s3";
5
+ // clubedge:if infra=local
6
+ import { createFileSystemStorage } from "@clubedge/storage/file-system";
7
+ // clubedge:end
5
8
  import { optional, parseEnv } from "@/env/server";
6
9
 
7
10
  export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
@@ -14,6 +17,9 @@ const storageEnvSchema = z.object({
14
17
  STORAGE_ACCESS_KEY_ID: optional(z.string()),
15
18
  STORAGE_SECRET_ACCESS_KEY: optional(z.string()),
16
19
  STORAGE_PUBLIC_URL: optional(z.url()),
20
+ // clubedge:if infra=local
21
+ STORAGE_DIRECTORY: optional(z.string()),
22
+ // clubedge:end
17
23
  });
18
24
 
19
25
  let provider: StorageProvider | undefined;
@@ -21,6 +27,13 @@ let provider: StorageProvider | undefined;
21
27
  function resolveProvider(): StorageProvider {
22
28
  if (provider) return provider;
23
29
  const env = parseEnv(storageEnvSchema, "Storage");
30
+ // clubedge:if infra=local
31
+ // In development without an object storage service, a local folder stands in for S3.
32
+ if (env.STORAGE_DIRECTORY) {
33
+ provider = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY });
34
+ return provider;
35
+ }
36
+ // clubedge:end
24
37
  provider = createS3Storage({
25
38
  bucket: env.STORAGE_BUCKET || undefined,
26
39
  region: env.STORAGE_REGION,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -8,6 +8,9 @@ import { createRedisCache } from "@clubedge/cache-redis";
8
8
  // clubedge:if storage=s3
9
9
  import { createS3Storage } from "@clubedge/storage-s3";
10
10
  // clubedge:end
11
+ // clubedge:if storage=s3 && infra=local
12
+ import { createFileSystemStorage } from "@clubedge/storage/file-system";
13
+ // clubedge:end
11
14
 
12
15
  type Check = { name: string; skip?: string; run: () => Promise<string> };
13
16
 
@@ -59,6 +62,25 @@ async function main() {
59
62
  },
60
63
  },
61
64
  // clubedge:end
65
+ // clubedge:if storage=s3 && infra=local
66
+ {
67
+ name: "Local folder storage",
68
+ skip: env.STORAGE_DIRECTORY ? undefined : "STORAGE_DIRECTORY is not set",
69
+ async run() {
70
+ const storage = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY! });
71
+ const key = `check-services/${Date.now()}.txt`;
72
+ await storage.upload({
73
+ key,
74
+ body: new TextEncoder().encode("ok"),
75
+ contentType: "text/plain",
76
+ });
77
+ const text = await new Response(await storage.download(key)).text();
78
+ await storage.delete(key);
79
+ if (text !== "ok") throw new Error(`read back "${text}"`);
80
+ return `wrote, read back, and deleted a file in ${env.STORAGE_DIRECTORY}`;
81
+ },
82
+ },
83
+ // clubedge:end
62
84
  // clubedge:if storage=s3
63
85
  {
64
86
  name: "File storage",
@@ -2,6 +2,9 @@ import "server-only";
2
2
  import { z } from "zod";
3
3
  import type { StorageProvider } from "@clubedge/storage";
4
4
  import { createS3Storage } from "@clubedge/storage-s3";
5
+ // clubedge:if infra=local
6
+ import { createFileSystemStorage } from "@clubedge/storage/file-system";
7
+ // clubedge:end
5
8
  import { optional, parseEnv } from "@/env/server";
6
9
 
7
10
  export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
@@ -14,6 +17,9 @@ const storageEnvSchema = z.object({
14
17
  STORAGE_ACCESS_KEY_ID: optional(z.string()),
15
18
  STORAGE_SECRET_ACCESS_KEY: optional(z.string()),
16
19
  STORAGE_PUBLIC_URL: optional(z.url()),
20
+ // clubedge:if infra=local
21
+ STORAGE_DIRECTORY: optional(z.string()),
22
+ // clubedge:end
17
23
  });
18
24
 
19
25
  let provider: StorageProvider | undefined;
@@ -21,6 +27,13 @@ let provider: StorageProvider | undefined;
21
27
  function resolveProvider(): StorageProvider {
22
28
  if (provider) return provider;
23
29
  const env = parseEnv(storageEnvSchema, "Storage");
30
+ // clubedge:if infra=local
31
+ // In development without an object storage service, a local folder stands in for S3.
32
+ if (env.STORAGE_DIRECTORY) {
33
+ provider = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY });
34
+ return provider;
35
+ }
36
+ // clubedge:end
24
37
  provider = createS3Storage({
25
38
  bucket: env.STORAGE_BUCKET || undefined,
26
39
  region: env.STORAGE_REGION,
@@ -169,6 +169,7 @@
169
169
  "name": "Docker Compose",
170
170
  "files": ["docker-compose.dev.yml"],
171
171
  "scripts": {
172
+ "dev": "turbo run dev --filter=@clubedge/web",
172
173
  "infra:up": "docker compose -f docker-compose.dev.yml up -d --wait",
173
174
  "infra:down": "docker compose -f docker-compose.dev.yml down",
174
175
  "infra:reset": "docker compose -f docker-compose.dev.yml down --volumes",
@@ -179,14 +180,29 @@
179
180
  "name": "Supabase CLI",
180
181
  "files": ["supabase"],
181
182
  "scripts": {
183
+ "dev": "turbo run dev --filter=@clubedge/web",
182
184
  "infra:up": "npx --yes supabase@2 start",
183
185
  "infra:down": "npx --yes supabase@2 stop",
184
186
  "infra:reset": "npx --yes supabase@2 stop --no-backup",
185
187
  "infra:status": "npx --yes supabase@2 status"
186
188
  }
187
189
  },
190
+ "local": {
191
+ "name": "No Docker (PGlite and a local folder)",
192
+ "packages": {
193
+ "@clubedge/local-db": "packages/local-db"
194
+ },
195
+ "scripts": {
196
+ "dev": "turbo run dev --filter=@clubedge/web --filter=@clubedge/local-db",
197
+ "infra:up": "pnpm --filter @clubedge/local-db dev",
198
+ "infra:reset": "node -e \"require('node:fs').rmSync('.data', { recursive: true, force: true })\""
199
+ }
200
+ },
188
201
  "none": {
189
- "name": "None (bring your own services)"
202
+ "name": "None (bring your own services)",
203
+ "scripts": {
204
+ "dev": "turbo run dev --filter=@clubedge/web"
205
+ }
190
206
  }
191
207
  }
192
208
  }
@@ -202,10 +218,12 @@
202
218
  "apps/start/src/env/auth.ts",
203
219
  "apps/start/src/routes/dashboard.tsx",
204
220
  "apps/start/src/routes/index.tsx",
221
+ "apps/start/src/server/storage.ts",
205
222
  "apps/web/scripts/check-services.ts",
206
223
  "apps/web/src/app/dashboard/page.tsx",
207
224
  "apps/web/src/app/page.tsx",
208
225
  "apps/web/src/env/auth.ts",
226
+ "apps/web/src/server/storage.ts",
209
227
  "docker-compose.dev.yml",
210
228
  "supabase/config.toml"
211
229
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-better-auth",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Better Auth adapter for @clubedge/auth, storing users and sessions in your own PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-supabase",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Supabase Auth adapter for @clubedge/auth.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",