@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.
- package/CHANGELOG.md +5 -0
- package/README.md +11 -11
- package/dist/cli/ui.js +1 -1
- package/dist/core/customize.js +5 -2
- package/dist/core/lockfile-peers.js +174 -0
- package/package.json +3 -3
- package/template/.env.example +4 -0
- package/template/README.md +7 -0
- package/template/SETUP.md +11 -0
- package/template/_gitignore +2 -0
- package/template/apps/start/.env.example +4 -0
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/scripts/check-services.ts +22 -0
- package/template/apps/start/src/server/storage.ts +13 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check-services.ts +22 -0
- package/template/apps/web/src/server/storage.ts +13 -0
- package/template/clubedge.template.json +19 -1
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/local-db/package.json +24 -0
- package/template/packages/local-db/scripts/serve.ts +66 -0
- package/template/packages/local-db/src/server.test.ts +119 -0
- package/template/packages/local-db/src/server.ts +153 -0
- package/template/packages/local-db/tsconfig.json +4 -0
- package/template/packages/storage/package.json +3 -2
- package/template/packages/storage/src/file-system.test.ts +110 -0
- package/template/packages/storage/src/file-system.ts +124 -0
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +31 -9
- 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.
|
|
15
|
+
create-clubedge-app v0.7.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
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.
|
|
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.
|
|
31
|
-
`
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/dist/core/customize.js
CHANGED
|
@@ -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.
|
|
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.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.7.0",
|
|
26
|
+
"starterCommit": "3b8114760b8151c2538b57c06620ff2b884f2d56"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/.env.example
CHANGED
|
@@ -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
|
package/template/README.md
CHANGED
|
@@ -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
|
|
package/template/_gitignore
CHANGED
|
@@ -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
|
|
@@ -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,
|
|
@@ -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
|
],
|
package/template/package.json
CHANGED