ship-create 1.1.0 → 1.3.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/package.json +1 -1
- package/templates/.claude/commands/ship.md +46 -0
- package/templates/.claude/skills/ship-method/SKILL.md +24 -1
- package/templates/.claude/skills/ship-method/theme-guide.md +72 -0
- package/templates/.cursorrules +5 -0
- package/templates/.windsurfrules +5 -0
- package/templates/AGENTS.md +5 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/starter-kit/.env.example +32 -0
- package/templates/starter-kit/README.md +26 -16
- package/templates/starter-kit/drizzle.config.ts +27 -0
- package/templates/starter-kit/lib/auth.ts +30 -0
- package/templates/starter-kit/lib/db/index.test.ts +53 -0
- package/templates/starter-kit/lib/db/index.ts +64 -0
- package/templates/starter-kit/lib/db/schema.pg.ts +58 -0
- package/templates/starter-kit/lib/db/schema.sqlite.ts +58 -0
- package/templates/starter-kit/lib/storage.test.ts +32 -0
- package/templates/starter-kit/lib/storage.ts +62 -0
- package/templates/starter-kit/package.json +11 -2
- package/templates/starter-kit/vitest.config.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Drive this project through the SHIP Method — Structure, Human Flow, Instruction, Publish.
|
|
3
|
+
argument-hint: "[status | structure | human-flow | instruction | publish]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are driving this project through **The SHIP Method** via the `/ship` shortcut. FIRST invoke the `ship-method` skill — it owns the gate definitions, the "never invent business facts" rule, and the Theme & First Screen procedure. This command is a thin orchestrator on top of it; do not duplicate or contradict the skill.
|
|
7
|
+
|
|
8
|
+
Argument passed: "$ARGUMENTS"
|
|
9
|
+
|
|
10
|
+
## Step 1 — Locate the docs
|
|
11
|
+
|
|
12
|
+
Determine where this project's SHIP docs live and use whichever set exists:
|
|
13
|
+
- `ship-create`-generated project: `docs/PROJECT.md`, `docs/HUMAN_FLOW.md`, `docs/AI_BUILD_SPEC.md`.
|
|
14
|
+
- The SHIP Method OS repo itself: `01-STRUCTURE/PROJECT.md`, `02-HUMAN-FLOW/HUMAN_FLOW.md`, `03-INSTRUCTION/AI_BUILD_SPEC.md`.
|
|
15
|
+
|
|
16
|
+
## Step 2 — Detect the current phase
|
|
17
|
+
|
|
18
|
+
Walk the gates in order and find the FIRST one not yet satisfied. A gate is unsatisfied if its file is missing OR its core sections still contain `[bracket placeholders]` or unedited template / "Worked Mini-Example" content instead of this project's real content.
|
|
19
|
+
|
|
20
|
+
1. **S — Structure** — `PROJECT.md` core sections filled: Vision, Problem Statement, Target Audience, MVP Scope.
|
|
21
|
+
2. **H — Human Flow** — `HUMAN_FLOW.md` core sections filled: Core Screens, Happy Path, and at least one error/empty state.
|
|
22
|
+
3. **I — Instruction** — `AI_BUILD_SPEC.md` exists with functional requirements, data model, and API contract.
|
|
23
|
+
4. **P — Publish** — Theme & First Screen done, feature code built from the spec, and the pre-launch checklist walked.
|
|
24
|
+
|
|
25
|
+
## Step 3 — Handle the argument
|
|
26
|
+
|
|
27
|
+
- `status` → report each gate as pass/fail with one line on what's missing, then STOP (do not drive).
|
|
28
|
+
- `structure`/`s`, `human-flow`/`h`, `instruction`/`i`, `publish`/`p` → jump to that phase regardless of detection (used to revisit an earlier phase).
|
|
29
|
+
- empty or anything else → resume at the first incomplete gate from Step 2.
|
|
30
|
+
|
|
31
|
+
## Step 4 — Drive the phase
|
|
32
|
+
|
|
33
|
+
Announce the phase and what is missing, e.g. "You're at **S — Structure**. `PROJECT.md` has N unfilled sections — let's fill them."
|
|
34
|
+
|
|
35
|
+
Then, **one question at a time** (pull questions from the relevant section headers):
|
|
36
|
+
- Ask → wait for the answer → **draft the real content into the doc** for that section.
|
|
37
|
+
- Never invent business facts (market size, pricing, metrics, quotes). If the user doesn't know, write a clearly-labeled placeholder like `[TBD: ...]` and move on.
|
|
38
|
+
- When the phase's required sections are filled, summarize what was written, confirm with the user, then advance to the next phase by re-running this flow.
|
|
39
|
+
|
|
40
|
+
For **P — Publish**, follow the `ship-method` skill's Theme & First Screen step (use `theme-guide.md`): derive 2-3 themes from `PROJECT.md`, let the user pick one, apply it, build the real homepage from `HUMAN_FLOW.md`, then build the remaining feature code from the spec, then walk `QA_CHECKLIST.md` / `LAUNCH_CHECKLIST.md`.
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- Ask exactly one question per message.
|
|
45
|
+
- Do not generate feature/business-logic code until Gates 1-3 pass (scaffolding/config is fine anytime).
|
|
46
|
+
- `/ship` keeps no saved state — it re-detects the phase every run, so it always resumes correctly.
|
|
@@ -20,6 +20,7 @@ The single most common reason AI-built products end up broken, scope-creeped, or
|
|
|
20
20
|
- The user asks you to generate code for a feature that doesn't have a filled spec yet
|
|
21
21
|
- The user asks "is this ready to build?" or "is this ready to ship?"
|
|
22
22
|
- You're about to scaffold, design a schema, or write business logic and no `PROJECT.md` / `HUMAN_FLOW.md` exists yet for it
|
|
23
|
+
- The user runs the `/ship` shortcut — drive them through the gates below one phase at a time, drafting their answers into the docs
|
|
23
24
|
|
|
24
25
|
## The Checklist
|
|
25
26
|
|
|
@@ -28,7 +29,8 @@ Work through these gates in order. Do not let scope, urgency, or the user saying
|
|
|
28
29
|
- [ ] **Gate 1 — Structure exists.** Find or create `PROJECT.md` (template: `01-STRUCTURE/PROJECT.md` in this repo, or `docs/PROJECT.md` if this is a `ship-create`-generated project). Vision, Problem Statement, Target Audience, and MVP Scope must be filled — not placeholder brackets.
|
|
29
30
|
- [ ] **Gate 2 — Human Flow exists.** Find or create `HUMAN_FLOW.md`. Every core screen needs a happy path and at least one error/empty state defined before it gets built.
|
|
30
31
|
- [ ] **Gate 3 — Instruction exists.** Functional requirements, data model, and API contract are written somewhere concrete (`AI_BUILD_SPEC.md` / `docs/PROJECT.md`'s feature section) before you generate feature code.
|
|
31
|
-
- [ ] **Gate 4 —
|
|
32
|
+
- [ ] **Gate 4 — Theme & First Screen.** Once Gates 1-3 pass and before final polish/ship: derive 2-3 business-appropriate themes from `PROJECT.md`, let the user pick one, apply it (`app/globals.css`, `app/layout.tsx`), and record it in the design system; then read `HUMAN_FLOW.md` and replace the starter's generic `app/page.tsx` ("Pick your area") with the real entry point. See `theme-guide.md` in this skill folder.
|
|
33
|
+
- [ ] **Gate 5 — Publish readiness.** Before telling the user something is "done," check it against the relevant checklist (`QA_CHECKLIST.md`, `LAUNCH_CHECKLIST.md`) rather than declaring success from a clean build alone.
|
|
32
34
|
|
|
33
35
|
## How to Apply This
|
|
34
36
|
|
|
@@ -37,6 +39,26 @@ Work through these gates in order. Do not let scope, urgency, or the user saying
|
|
|
37
39
|
3. **If all gates are filled:** proceed normally — generate code straight from the spec, and flag if a code request contradicts what's already written in `PROJECT.md` or `HUMAN_FLOW.md` rather than silently overriding it.
|
|
38
40
|
4. **When asked "is this ready to build/ship?":** walk the checklist above explicitly and report which gates pass/fail, rather than giving a vague yes/no.
|
|
39
41
|
|
|
42
|
+
## Theme & First Screen (Gate 4)
|
|
43
|
+
|
|
44
|
+
Run this once Gates 1-3 pass, before final polish or shipping. The agent already
|
|
45
|
+
knows the business from `PROJECT.md`, so it can theme and build the front door
|
|
46
|
+
accurately.
|
|
47
|
+
|
|
48
|
+
1. **Derive & choose a theme.** From `PROJECT.md`, produce 2-3 candidate themes
|
|
49
|
+
(palette as HSL token values + a font pairing). Present them and let the user
|
|
50
|
+
pick — never pick silently, never require brand assets the user didn't give.
|
|
51
|
+
2. **Apply it.** Write the chosen tokens into `app/globals.css` (`:root` and
|
|
52
|
+
`.dark`), set fonts in `app/layout.tsx`, and record the choice in
|
|
53
|
+
`12-DESIGN-SYSTEM/DESIGN_SYSTEM.md` (or `docs/DESIGN_SYSTEM.md`).
|
|
54
|
+
3. **Build the first screen.** Read `HUMAN_FLOW.md`, decide the real entry point
|
|
55
|
+
for this business, and replace the starter's `app/page.tsx` ("Pick your
|
|
56
|
+
area") with it — landing/sale for marketing-led products, app home/dashboard
|
|
57
|
+
redirect for tools. Adjust each area's main page to match.
|
|
58
|
+
|
|
59
|
+
Full procedure and the business-type → palette/font table: `theme-guide.md` in
|
|
60
|
+
this skill folder. (In this OS repo the app lives under `starter-kit/`.)
|
|
61
|
+
|
|
40
62
|
## Reference Files (when present in this repo)
|
|
41
63
|
|
|
42
64
|
| Need | File |
|
|
@@ -47,6 +69,7 @@ Work through these gates in order. Do not let scope, urgency, or the user saying
|
|
|
47
69
|
| Pre-launch checks | `04-PUBLISH/QA_CHECKLIST.md`, `04-PUBLISH/LAUNCH_CHECKLIST.md` |
|
|
48
70
|
| Product-type starter pack | `06-TEMPLATES/<TYPE>_TEMPLATE.md` |
|
|
49
71
|
| Design consistency | `12-DESIGN-SYSTEM/DESIGN_SYSTEM.md` |
|
|
72
|
+
| Business-type → palette/font theme guide | `theme-guide.md` (this skill folder) |
|
|
50
73
|
| Stack decisions | `13-TECH-STACK/STACK_DECISION_MATRIX.md` |
|
|
51
74
|
|
|
52
75
|
If this is a project generated by `ship-create`, the same files exist under `docs/` instead of the numbered folders above.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Theme & First Screen — Reference Guide
|
|
2
|
+
|
|
3
|
+
Used by the SHIP Method's **Theme & First Screen** step (first step of the
|
|
4
|
+
PUBLISH phase). It tells you how to turn the business described in `PROJECT.md`
|
|
5
|
+
into (a) 2-3 theme candidates the user can choose from, and (b) a real homepage.
|
|
6
|
+
|
|
7
|
+
## Where things live
|
|
8
|
+
|
|
9
|
+
In a `ship-create`-generated project the Next.js app is at the repo root:
|
|
10
|
+
- `app/globals.css` — theme tokens (HSL triplets) under `:root` and `.dark`
|
|
11
|
+
- `app/layout.tsx` — fonts via `next/font/google`, mapped to `--font-*` variables
|
|
12
|
+
- `app/page.tsx` — the root page (ships as a generic "Pick your area" screen)
|
|
13
|
+
|
|
14
|
+
In **this OS repo**, the same app lives under `starter-kit/` (e.g.
|
|
15
|
+
`starter-kit/app/globals.css`). Adjust paths accordingly.
|
|
16
|
+
|
|
17
|
+
Record the chosen theme in the design system doc:
|
|
18
|
+
`12-DESIGN-SYSTEM/DESIGN_SYSTEM.md` (or `docs/DESIGN_SYSTEM.md` in generated
|
|
19
|
+
projects).
|
|
20
|
+
|
|
21
|
+
## Step 1 — Derive 2-3 theme candidates
|
|
22
|
+
|
|
23
|
+
Read `PROJECT.md` (vision, business type, target audience, any stated brand
|
|
24
|
+
colors). Map the business to a palette direction and a font pairing using the
|
|
25
|
+
table below as a starting point — then tailor, don't copy blindly.
|
|
26
|
+
|
|
27
|
+
| Business / mood | Palette direction | Font pairing (display / sans) |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Finance, B2B, trust | Cool navy/slate base, restrained single accent | Serif or grotesk display / neutral sans |
|
|
30
|
+
| Legal, premium, luxury | Deep neutral (ink/charcoal), gold or burgundy accent | Classic serif / humanist sans |
|
|
31
|
+
| Kids, play, consumer fun | Bright, high-saturation, multiple accents | Rounded geometric sans / rounded sans |
|
|
32
|
+
| Health, wellness, calm | Soft greens/teals, low saturation | Humanist serif / soft sans |
|
|
33
|
+
| Tech, SaaS, developer | Dark base, one electric accent | Geometric sans / mono accents |
|
|
34
|
+
| Food, hospitality, warm | Warm earth tones, appetizing accent | Editorial serif / friendly sans |
|
|
35
|
+
| Creative, agency, bold | High-contrast, expressive accent | Display serif or bold grotesk / clean sans |
|
|
36
|
+
|
|
37
|
+
Each candidate MUST specify, as HSL triplets, values for every token in
|
|
38
|
+
`globals.css`: `--background --foreground --card --card-foreground --primary
|
|
39
|
+
--primary-foreground --secondary --secondary-foreground --accent
|
|
40
|
+
--accent-foreground --muted --muted-foreground --destructive
|
|
41
|
+
--destructive-foreground --success --success-foreground --border --input --ring`
|
|
42
|
+
plus a `--radius`, and a font pairing for `--font-display`, `--font-sans`,
|
|
43
|
+
`--font-mono`.
|
|
44
|
+
|
|
45
|
+
Present the candidates to the user (a one-line mood + the key colors each).
|
|
46
|
+
Let the user pick one. Do NOT pick silently. Do NOT require brand assets — use
|
|
47
|
+
them only if `PROJECT.md` provides them.
|
|
48
|
+
|
|
49
|
+
## Step 2 — Apply the chosen theme
|
|
50
|
+
|
|
51
|
+
1. In `app/globals.css`, set the token values under BOTH `:root` and `.dark`
|
|
52
|
+
(this kit keeps them identical). Keep the HSL-triplet format (e.g.
|
|
53
|
+
`--primary: 14 92% 58%;`) so `hsl(var(--x))` keeps working.
|
|
54
|
+
2. In `app/layout.tsx`, swap the `next/font/google` imports to the chosen
|
|
55
|
+
fonts, keeping the variable names `--font-display`, `--font-sans`,
|
|
56
|
+
`--font-mono`. The starter uses `Fraunces` (display), `Work_Sans` (sans),
|
|
57
|
+
`JetBrains_Mono` (mono) as the reference pattern.
|
|
58
|
+
3. Record the final palette + fonts in the design system doc as the source of
|
|
59
|
+
truth for later UI work.
|
|
60
|
+
|
|
61
|
+
## Step 3 — Build the real first screen
|
|
62
|
+
|
|
63
|
+
Read `HUMAN_FLOW.md` and decide the true entry point for THIS business:
|
|
64
|
+
- Marketing-led (course, membership, leadgen) → root `app/page.tsx` becomes the
|
|
65
|
+
landing/sale page.
|
|
66
|
+
- Tool/dashboard/internal → root becomes the app home, or redirects to the
|
|
67
|
+
primary dashboard route.
|
|
68
|
+
|
|
69
|
+
Replace the generic "Pick your area" content in `app/page.tsx` with real content
|
|
70
|
+
drawn from the spec (no lorem). Adjust the main page of each area's route to
|
|
71
|
+
match. Then remind the user to update `FEATURE_MATRIX.md` and `QA_CHECKLIST.md`
|
|
72
|
+
if scope changed.
|
package/templates/.cursorrules
CHANGED
|
@@ -39,6 +39,11 @@ Full reference docs live in: `01-STRUCTURE/`, `02-HUMAN-FLOW/`, `03-INSTRUCTION/
|
|
|
39
39
|
|
|
40
40
|
6. **Never invent business facts** (market size, pricing, real metrics, real user quotes) into these docs. Draft clearly-labeled placeholders or ask the user instead.
|
|
41
41
|
|
|
42
|
+
7. **Once the spec is complete, run the "Theme & First Screen" step before polishing or shipping.** When `01-STRUCTURE/PROJECT.md`, `02-HUMAN-FLOW/HUMAN_FLOW.md`, and `03-INSTRUCTION/AI_BUILD_SPEC.md` are filled (no `[bracket placeholders]`), and before calling anything ship-ready:
|
|
43
|
+
- **Theme:** Derive 2-3 theme candidates (color palette as HSL token values + a font pairing) from the business in `PROJECT.md`, present them, let the user pick one, then apply it to the app's `app/globals.css` and `app/layout.tsx` and record the choice in `12-DESIGN-SYSTEM/DESIGN_SYSTEM.md`.
|
|
44
|
+
- **Home:** Read `02-HUMAN-FLOW/HUMAN_FLOW.md`, determine the real entry point for this business, and replace the starter kit's generic `app/page.tsx` ("Pick your area") with it.
|
|
45
|
+
- Don't pick a theme silently and don't require brand assets the user didn't provide. See the `ship-method` skill's `theme-guide.md` for the business-type → palette/font reference. (In `ship-create` projects these docs live under `docs/`; in this OS repo the app lives under `starter-kit/`.)
|
|
46
|
+
|
|
42
47
|
## Quick Orientation for a New Agent Session
|
|
43
48
|
|
|
44
49
|
If you (the AI agent) are opening this repo for the first time in a session:
|
package/templates/.windsurfrules
CHANGED
|
@@ -39,6 +39,11 @@ Full reference docs live in: `01-STRUCTURE/`, `02-HUMAN-FLOW/`, `03-INSTRUCTION/
|
|
|
39
39
|
|
|
40
40
|
6. **Never invent business facts** (market size, pricing, real metrics, real user quotes) into these docs. Draft clearly-labeled placeholders or ask the user instead.
|
|
41
41
|
|
|
42
|
+
7. **Once the spec is complete, run the "Theme & First Screen" step before polishing or shipping.** When `01-STRUCTURE/PROJECT.md`, `02-HUMAN-FLOW/HUMAN_FLOW.md`, and `03-INSTRUCTION/AI_BUILD_SPEC.md` are filled (no `[bracket placeholders]`), and before calling anything ship-ready:
|
|
43
|
+
- **Theme:** Derive 2-3 theme candidates (color palette as HSL token values + a font pairing) from the business in `PROJECT.md`, present them, let the user pick one, then apply it to the app's `app/globals.css` and `app/layout.tsx` and record the choice in `12-DESIGN-SYSTEM/DESIGN_SYSTEM.md`.
|
|
44
|
+
- **Home:** Read `02-HUMAN-FLOW/HUMAN_FLOW.md`, determine the real entry point for this business, and replace the starter kit's generic `app/page.tsx` ("Pick your area") with it.
|
|
45
|
+
- Don't pick a theme silently and don't require brand assets the user didn't provide. See the `ship-method` skill's `theme-guide.md` for the business-type → palette/font reference. (In `ship-create` projects these docs live under `docs/`; in this OS repo the app lives under `starter-kit/`.)
|
|
46
|
+
|
|
42
47
|
## Quick Orientation for a New Agent Session
|
|
43
48
|
|
|
44
49
|
If you (the AI agent) are opening this repo for the first time in a session:
|
package/templates/AGENTS.md
CHANGED
|
@@ -39,6 +39,11 @@ Full reference docs live in: `01-STRUCTURE/`, `02-HUMAN-FLOW/`, `03-INSTRUCTION/
|
|
|
39
39
|
|
|
40
40
|
6. **Never invent business facts** (market size, pricing, real metrics, real user quotes) into these docs. Draft clearly-labeled placeholders or ask the user instead.
|
|
41
41
|
|
|
42
|
+
7. **Once the spec is complete, run the "Theme & First Screen" step before polishing or shipping.** When `01-STRUCTURE/PROJECT.md`, `02-HUMAN-FLOW/HUMAN_FLOW.md`, and `03-INSTRUCTION/AI_BUILD_SPEC.md` are filled (no `[bracket placeholders]`), and before calling anything ship-ready:
|
|
43
|
+
- **Theme:** Derive 2-3 theme candidates (color palette as HSL token values + a font pairing) from the business in `PROJECT.md`, present them, let the user pick one, then apply it to the app's `app/globals.css` and `app/layout.tsx` and record the choice in `12-DESIGN-SYSTEM/DESIGN_SYSTEM.md`.
|
|
44
|
+
- **Home:** Read `02-HUMAN-FLOW/HUMAN_FLOW.md`, determine the real entry point for this business, and replace the starter kit's generic `app/page.tsx` ("Pick your area") with it.
|
|
45
|
+
- Don't pick a theme silently and don't require brand assets the user didn't provide. See the `ship-method` skill's `theme-guide.md` for the business-type → palette/font reference. (In `ship-create` projects these docs live under `docs/`; in this OS repo the app lives under `starter-kit/`.)
|
|
46
|
+
|
|
42
47
|
## Quick Orientation for a New Agent Session
|
|
43
48
|
|
|
44
49
|
If you (the AI agent) are opening this repo for the first time in a session:
|
package/templates/CLAUDE.md
CHANGED
|
@@ -39,6 +39,11 @@ Full reference docs live in: `01-STRUCTURE/`, `02-HUMAN-FLOW/`, `03-INSTRUCTION/
|
|
|
39
39
|
|
|
40
40
|
6. **Never invent business facts** (market size, pricing, real metrics, real user quotes) into these docs. Draft clearly-labeled placeholders or ask the user instead.
|
|
41
41
|
|
|
42
|
+
7. **Once the spec is complete, run the "Theme & First Screen" step before polishing or shipping.** When `01-STRUCTURE/PROJECT.md`, `02-HUMAN-FLOW/HUMAN_FLOW.md`, and `03-INSTRUCTION/AI_BUILD_SPEC.md` are filled (no `[bracket placeholders]`), and before calling anything ship-ready:
|
|
43
|
+
- **Theme:** Derive 2-3 theme candidates (color palette as HSL token values + a font pairing) from the business in `PROJECT.md`, present them, let the user pick one, then apply it to the app's `app/globals.css` and `app/layout.tsx` and record the choice in `12-DESIGN-SYSTEM/DESIGN_SYSTEM.md`.
|
|
44
|
+
- **Home:** Read `02-HUMAN-FLOW/HUMAN_FLOW.md`, determine the real entry point for this business, and replace the starter kit's generic `app/page.tsx` ("Pick your area") with it.
|
|
45
|
+
- Don't pick a theme silently and don't require brand assets the user didn't provide. See the `ship-method` skill's `theme-guide.md` for the business-type → palette/font reference. (In `ship-create` projects these docs live under `docs/`; in this OS repo the app lives under `starter-kit/`.)
|
|
46
|
+
|
|
42
47
|
## Quick Orientation for a New Agent Session
|
|
43
48
|
|
|
44
49
|
If you (the AI agent) are opening this repo for the first time in a session:
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Pick exactly one provider. This is a setup-time choice, not runtime-switchable.
|
|
2
|
+
DB_PROVIDER=supabase # supabase | neon | cloudflare-d1 | postgres
|
|
3
|
+
|
|
4
|
+
# --- supabase | postgres | neon (Postgres-family — all use DATABASE_URL) ---
|
|
5
|
+
# Supabase: Project Settings -> Database -> Connection string (use the "Transaction pooler" URI for serverless)
|
|
6
|
+
# Neon: Project Dashboard -> Connection Details -> select "Pooled connection"
|
|
7
|
+
# Plain Postgres: your host's connection string, e.g. postgres://user:pass@host:5432/dbname
|
|
8
|
+
DATABASE_URL=
|
|
9
|
+
|
|
10
|
+
# --- cloudflare-d1 only ---
|
|
11
|
+
# D1 has no connection string at runtime — the binding comes from wrangler.toml
|
|
12
|
+
# and the Cloudflare Workers runtime, not an env var. The vars below are only
|
|
13
|
+
# used by `drizzle-kit` migration commands run from your local machine.
|
|
14
|
+
CLOUDFLARE_ACCOUNT_ID=
|
|
15
|
+
CLOUDFLARE_D1_DATABASE_ID=
|
|
16
|
+
CLOUDFLARE_API_TOKEN=
|
|
17
|
+
|
|
18
|
+
# IMPORTANT: DB_PROVIDER=cloudflare-d1 requires deploying this app to
|
|
19
|
+
# Cloudflare Workers/Pages via @opennextjs/cloudflare. It will NOT work on
|
|
20
|
+
# Vercel. See 13-TECH-STACK/DB_PROVIDER_GUIDE.md before picking this option.
|
|
21
|
+
|
|
22
|
+
# --- Auth.js (required regardless of DB_PROVIDER) ---
|
|
23
|
+
AUTH_SECRET=
|
|
24
|
+
# Add at least one provider's keys here once chosen, e.g.:
|
|
25
|
+
# AUTH_GITHUB_ID=
|
|
26
|
+
# AUTH_GITHUB_SECRET=
|
|
27
|
+
|
|
28
|
+
# --- Storage (optional — only needed once you wire up file uploads) ---
|
|
29
|
+
STORAGE_PROVIDER=supabase # supabase | cloudflare-r2 | vercel-blob (only supabase is implemented)
|
|
30
|
+
SUPABASE_URL=
|
|
31
|
+
SUPABASE_SERVICE_ROLE_KEY=
|
|
32
|
+
SUPABASE_STORAGE_BUCKET=uploads
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# SHIP Starter Kit
|
|
2
2
|
|
|
3
|
-
This is the code foundation for **The SHIP Method OS** — a Next.js
|
|
3
|
+
This is the code foundation for **The SHIP Method OS** — a Next.js 16 (App
|
|
4
4
|
Router) + TypeScript + Tailwind CSS starter that gives every downstream agent
|
|
5
5
|
a consistent, working UI shell to build on top of.
|
|
6
6
|
|
|
7
|
-
It is currently **mock-data only**.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
button styles or color
|
|
7
|
+
It is currently **mock-data only**. Backend code (`lib/db/`, `lib/auth.ts`,
|
|
8
|
+
`lib/storage.ts`) is included but has no live database connection configured
|
|
9
|
+
by default. Every list, table, and metric you see is sourced from
|
|
10
|
+
`lib/mock-data.ts`. That's intentional: the goal of this layer is a correct,
|
|
11
|
+
consistent shared foundation (design tokens, primitives, layout) that other
|
|
12
|
+
agents can build real features on without re-deciding button styles or color
|
|
13
|
+
values per screen.
|
|
13
14
|
|
|
14
15
|
## Running it
|
|
15
16
|
|
|
@@ -51,14 +52,23 @@ shared primitives in `components/ui/` and the `cn()` helper in
|
|
|
51
52
|
|
|
52
53
|
## Next step: real data
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
`lib/
|
|
56
|
-
|
|
55
|
+
This kit ships with a real, pluggable backend in `lib/db/`, `lib/auth.ts`,
|
|
56
|
+
and `lib/storage.ts` — but it has no live database connection configured
|
|
57
|
+
yet. To turn it on:
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
1. Copy `.env.example` to `.env` and pick a `DB_PROVIDER`: `supabase`,
|
|
60
|
+
`neon`, `cloudflare-d1`, or `postgres`. Read
|
|
61
|
+
`../13-TECH-STACK/DB_PROVIDER_GUIDE.md` first if you're unsure which —
|
|
62
|
+
it covers free-tier limits and which providers can deploy where.
|
|
63
|
+
2. Fill in that provider's connection details in `.env` (see the comments
|
|
64
|
+
in `.env.example`).
|
|
65
|
+
3. Run `npx drizzle-kit generate` then apply the generated migration to
|
|
66
|
+
create the `user`/`account`/`session`/`verificationToken` tables.
|
|
67
|
+
4. Add at least one Auth.js provider (OAuth or credentials) to the empty
|
|
68
|
+
`providers: []` array in `lib/auth.ts` — auth won't work until you do.
|
|
69
|
+
5. Replace the contents of `lib/mock-data.ts` (or its call sites) with
|
|
70
|
+
real queries against `getDb()` from `lib/db`.
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
Before writing your own tables, also read
|
|
73
|
+
`../03-INSTRUCTION/DATABASE_SPEC.md` for the schema-design template this
|
|
74
|
+
app's data model should follow.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit"
|
|
2
|
+
|
|
3
|
+
const provider = process.env.DB_PROVIDER
|
|
4
|
+
|
|
5
|
+
const config =
|
|
6
|
+
provider === "cloudflare-d1"
|
|
7
|
+
? defineConfig({
|
|
8
|
+
dialect: "sqlite",
|
|
9
|
+
schema: "./lib/db/schema.sqlite.ts",
|
|
10
|
+
out: "./drizzle/sqlite",
|
|
11
|
+
driver: "d1-http",
|
|
12
|
+
dbCredentials: {
|
|
13
|
+
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "",
|
|
14
|
+
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID ?? "",
|
|
15
|
+
token: process.env.CLOUDFLARE_API_TOKEN ?? "",
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
: defineConfig({
|
|
19
|
+
dialect: "postgresql",
|
|
20
|
+
schema: "./lib/db/schema.pg.ts",
|
|
21
|
+
out: "./drizzle/postgres",
|
|
22
|
+
dbCredentials: {
|
|
23
|
+
url: process.env.DATABASE_URL ?? "",
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export default config
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import NextAuth from "next-auth"
|
|
2
|
+
import { DrizzleAdapter } from "@auth/drizzle-adapter"
|
|
3
|
+
import { getDb } from "./db"
|
|
4
|
+
import * as pgSchema from "./db/schema.pg"
|
|
5
|
+
import * as sqliteSchema from "./db/schema.sqlite"
|
|
6
|
+
|
|
7
|
+
const provider = process.env.DB_PROVIDER
|
|
8
|
+
const schema = (provider === "cloudflare-d1" ? sqliteSchema : pgSchema) as any
|
|
9
|
+
|
|
10
|
+
// D1's binding only exists inside a Cloudflare request context, so the
|
|
11
|
+
// adapter is built per-request via NextAuth's config-function form instead
|
|
12
|
+
// of once at module scope (which is enough for every other provider).
|
|
13
|
+
export const { handlers, signIn, signOut, auth } = NextAuth(async () => {
|
|
14
|
+
if (provider === "cloudflare-d1") {
|
|
15
|
+
const { getCloudflareContext } = await import("@opennextjs/cloudflare")
|
|
16
|
+
const { env } = getCloudflareContext()
|
|
17
|
+
return {
|
|
18
|
+
adapter: DrizzleAdapter(getDb((env as { DB: unknown }).DB), schema),
|
|
19
|
+
// No providers configured yet — add at least one (OAuth or
|
|
20
|
+
// credentials) before this auth setup is functional.
|
|
21
|
+
providers: [],
|
|
22
|
+
session: { strategy: "database" },
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
adapter: DrizzleAdapter(getDb(), schema),
|
|
27
|
+
providers: [],
|
|
28
|
+
session: { strategy: "database" },
|
|
29
|
+
}
|
|
30
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
|
2
|
+
import { getDb } from "./index"
|
|
3
|
+
|
|
4
|
+
describe("getDb", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("throws when DB_PROVIDER is missing", () => {
|
|
10
|
+
vi.stubEnv("DB_PROVIDER", "")
|
|
11
|
+
expect(() => getDb()).toThrow(/DB_PROVIDER/)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("throws when DB_PROVIDER is invalid", () => {
|
|
15
|
+
vi.stubEnv("DB_PROVIDER", "mongodb")
|
|
16
|
+
expect(() => getDb()).toThrow(/DB_PROVIDER/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("throws when DB_PROVIDER=postgres and DATABASE_URL is missing", () => {
|
|
20
|
+
vi.stubEnv("DB_PROVIDER", "postgres")
|
|
21
|
+
vi.stubEnv("DATABASE_URL", "")
|
|
22
|
+
expect(() => getDb()).toThrow(/DATABASE_URL/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("constructs a client for DB_PROVIDER=postgres given a connection string", () => {
|
|
26
|
+
vi.stubEnv("DB_PROVIDER", "postgres")
|
|
27
|
+
vi.stubEnv("DATABASE_URL", "postgres://user:pass@localhost:5432/db")
|
|
28
|
+
expect(() => getDb()).not.toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("constructs a client for DB_PROVIDER=supabase given a connection string", () => {
|
|
32
|
+
vi.stubEnv("DB_PROVIDER", "supabase")
|
|
33
|
+
vi.stubEnv("DATABASE_URL", "postgres://user:pass@localhost:5432/db")
|
|
34
|
+
expect(() => getDb()).not.toThrow()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("constructs a client for DB_PROVIDER=neon given a connection string", () => {
|
|
38
|
+
vi.stubEnv("DB_PROVIDER", "neon")
|
|
39
|
+
vi.stubEnv("DATABASE_URL", "postgres://user:pass@neon.example.com/db")
|
|
40
|
+
expect(() => getDb()).not.toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("throws when DB_PROVIDER=cloudflare-d1 and no binding is passed", () => {
|
|
44
|
+
vi.stubEnv("DB_PROVIDER", "cloudflare-d1")
|
|
45
|
+
expect(() => getDb()).toThrow(/d1Binding/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("constructs a client for DB_PROVIDER=cloudflare-d1 given a binding", () => {
|
|
49
|
+
vi.stubEnv("DB_PROVIDER", "cloudflare-d1")
|
|
50
|
+
const fakeBinding = {} as never
|
|
51
|
+
expect(() => getDb(fakeBinding)).not.toThrow()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"
|
|
2
|
+
import postgres from "postgres"
|
|
3
|
+
import { drizzle as drizzleNeon } from "drizzle-orm/neon-http"
|
|
4
|
+
import { neon } from "@neondatabase/serverless"
|
|
5
|
+
import { drizzle as drizzleD1 } from "drizzle-orm/d1"
|
|
6
|
+
import * as pgSchema from "./schema.pg"
|
|
7
|
+
import * as sqliteSchema from "./schema.sqlite"
|
|
8
|
+
|
|
9
|
+
export type DbProvider = "supabase" | "neon" | "cloudflare-d1" | "postgres"
|
|
10
|
+
|
|
11
|
+
function getProvider(): DbProvider {
|
|
12
|
+
const provider = process.env.DB_PROVIDER
|
|
13
|
+
if (
|
|
14
|
+
provider !== "supabase" &&
|
|
15
|
+
provider !== "neon" &&
|
|
16
|
+
provider !== "cloudflare-d1" &&
|
|
17
|
+
provider !== "postgres"
|
|
18
|
+
) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Invalid or missing DB_PROVIDER env var: "${provider}". Must be one of: supabase, neon, cloudflare-d1, postgres. See .env.example.`
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
return provider
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// D1's binding only exists inside a Cloudflare request context (there is no
|
|
27
|
+
// connection string for it), so callers on that path must pass it in.
|
|
28
|
+
export function getDb(d1Binding?: unknown) {
|
|
29
|
+
const provider = getProvider()
|
|
30
|
+
|
|
31
|
+
switch (provider) {
|
|
32
|
+
case "supabase":
|
|
33
|
+
case "postgres": {
|
|
34
|
+
const connectionString = process.env.DATABASE_URL
|
|
35
|
+
if (!connectionString) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`DATABASE_URL is required when DB_PROVIDER is "${provider}". See .env.example.`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
const client = postgres(connectionString)
|
|
41
|
+
return drizzlePg(client, { schema: pgSchema })
|
|
42
|
+
}
|
|
43
|
+
case "neon": {
|
|
44
|
+
const connectionString = process.env.DATABASE_URL
|
|
45
|
+
if (!connectionString) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'DATABASE_URL is required when DB_PROVIDER is "neon". See .env.example.'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const client = neon(connectionString)
|
|
51
|
+
return drizzleNeon(client, { schema: pgSchema })
|
|
52
|
+
}
|
|
53
|
+
case "cloudflare-d1": {
|
|
54
|
+
if (!d1Binding) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'DB_PROVIDER is "cloudflare-d1" but no d1Binding was passed to getDb(). ' +
|
|
57
|
+
"D1's binding comes from the Cloudflare runtime context (e.g. getCloudflareContext().env.DB " +
|
|
58
|
+
"from @opennextjs/cloudflare) — it cannot be constructed from an env var alone."
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return drizzleD1(d1Binding as never, { schema: sqliteSchema })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { integer, timestamp, pgTable, primaryKey, text } from "drizzle-orm/pg-core"
|
|
2
|
+
import type { AdapterAccountType } from "next-auth/adapters"
|
|
3
|
+
|
|
4
|
+
export const users = pgTable("user", {
|
|
5
|
+
id: text("id")
|
|
6
|
+
.primaryKey()
|
|
7
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
8
|
+
name: text("name"),
|
|
9
|
+
email: text("email").unique(),
|
|
10
|
+
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
|
11
|
+
image: text("image"),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const accounts = pgTable(
|
|
15
|
+
"account",
|
|
16
|
+
{
|
|
17
|
+
userId: text("userId")
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
20
|
+
type: text("type").$type<AdapterAccountType>().notNull(),
|
|
21
|
+
provider: text("provider").notNull(),
|
|
22
|
+
providerAccountId: text("providerAccountId").notNull(),
|
|
23
|
+
refresh_token: text("refresh_token"),
|
|
24
|
+
access_token: text("access_token"),
|
|
25
|
+
expires_at: integer("expires_at"),
|
|
26
|
+
token_type: text("token_type"),
|
|
27
|
+
scope: text("scope"),
|
|
28
|
+
id_token: text("id_token"),
|
|
29
|
+
session_state: text("session_state"),
|
|
30
|
+
},
|
|
31
|
+
(account) => ({
|
|
32
|
+
compoundKey: primaryKey({
|
|
33
|
+
columns: [account.provider, account.providerAccountId],
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const sessions = pgTable("session", {
|
|
39
|
+
sessionToken: text("sessionToken").primaryKey(),
|
|
40
|
+
userId: text("userId")
|
|
41
|
+
.notNull()
|
|
42
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
43
|
+
expires: timestamp("expires", { mode: "date" }).notNull(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const verificationTokens = pgTable(
|
|
47
|
+
"verificationToken",
|
|
48
|
+
{
|
|
49
|
+
identifier: text("identifier").notNull(),
|
|
50
|
+
token: text("token").notNull(),
|
|
51
|
+
expires: timestamp("expires", { mode: "date" }).notNull(),
|
|
52
|
+
},
|
|
53
|
+
(verificationToken) => ({
|
|
54
|
+
compositePk: primaryKey({
|
|
55
|
+
columns: [verificationToken.identifier, verificationToken.token],
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
|
2
|
+
import type { AdapterAccountType } from "next-auth/adapters"
|
|
3
|
+
|
|
4
|
+
export const users = sqliteTable("user", {
|
|
5
|
+
id: text("id")
|
|
6
|
+
.primaryKey()
|
|
7
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
8
|
+
name: text("name"),
|
|
9
|
+
email: text("email").unique(),
|
|
10
|
+
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
|
|
11
|
+
image: text("image"),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const accounts = sqliteTable(
|
|
15
|
+
"account",
|
|
16
|
+
{
|
|
17
|
+
userId: text("userId")
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
20
|
+
type: text("type").$type<AdapterAccountType>().notNull(),
|
|
21
|
+
provider: text("provider").notNull(),
|
|
22
|
+
providerAccountId: text("providerAccountId").notNull(),
|
|
23
|
+
refresh_token: text("refresh_token"),
|
|
24
|
+
access_token: text("access_token"),
|
|
25
|
+
expires_at: integer("expires_at"),
|
|
26
|
+
token_type: text("token_type"),
|
|
27
|
+
scope: text("scope"),
|
|
28
|
+
id_token: text("id_token"),
|
|
29
|
+
session_state: text("session_state"),
|
|
30
|
+
},
|
|
31
|
+
(account) => ({
|
|
32
|
+
compoundKey: primaryKey({
|
|
33
|
+
columns: [account.provider, account.providerAccountId],
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const sessions = sqliteTable("session", {
|
|
39
|
+
sessionToken: text("sessionToken").primaryKey(),
|
|
40
|
+
userId: text("userId")
|
|
41
|
+
.notNull()
|
|
42
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
43
|
+
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const verificationTokens = sqliteTable(
|
|
47
|
+
"verificationToken",
|
|
48
|
+
{
|
|
49
|
+
identifier: text("identifier").notNull(),
|
|
50
|
+
token: text("token").notNull(),
|
|
51
|
+
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
|
52
|
+
},
|
|
53
|
+
(verificationToken) => ({
|
|
54
|
+
compositePk: primaryKey({
|
|
55
|
+
columns: [verificationToken.identifier, verificationToken.token],
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest"
|
|
2
|
+
import { createStorageClient } from "./storage"
|
|
3
|
+
|
|
4
|
+
describe("createStorageClient", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("throws when STORAGE_PROVIDER=supabase and SUPABASE_URL is missing", () => {
|
|
10
|
+
vi.stubEnv("STORAGE_PROVIDER", "supabase")
|
|
11
|
+
vi.stubEnv("SUPABASE_URL", "")
|
|
12
|
+
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
13
|
+
expect(() => createStorageClient()).toThrow(/SUPABASE_URL/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("constructs a client when STORAGE_PROVIDER=supabase with credentials set", () => {
|
|
17
|
+
vi.stubEnv("STORAGE_PROVIDER", "supabase")
|
|
18
|
+
vi.stubEnv("SUPABASE_URL", "https://example.supabase.co")
|
|
19
|
+
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "fake-key")
|
|
20
|
+
expect(() => createStorageClient()).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("throws a clear not-implemented error for cloudflare-r2", () => {
|
|
24
|
+
vi.stubEnv("STORAGE_PROVIDER", "cloudflare-r2")
|
|
25
|
+
expect(() => createStorageClient()).toThrow(/not implemented/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("throws a clear not-implemented error for vercel-blob", () => {
|
|
29
|
+
vi.stubEnv("STORAGE_PROVIDER", "vercel-blob")
|
|
30
|
+
expect(() => createStorageClient()).toThrow(/not implemented/)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createClient } from "@supabase/supabase-js"
|
|
2
|
+
|
|
3
|
+
export interface StorageClient {
|
|
4
|
+
upload(
|
|
5
|
+
path: string,
|
|
6
|
+
file: Blob | Buffer,
|
|
7
|
+
contentType: string
|
|
8
|
+
): Promise<{ path: string; publicUrl: string }>
|
|
9
|
+
getPublicUrl(path: string): string
|
|
10
|
+
remove(path: string): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class SupabaseStorageClient implements StorageClient {
|
|
14
|
+
private client: ReturnType<typeof createClient>
|
|
15
|
+
private bucket: string
|
|
16
|
+
|
|
17
|
+
constructor(supabaseUrl: string, supabaseKey: string, bucket: string) {
|
|
18
|
+
this.client = createClient(supabaseUrl, supabaseKey)
|
|
19
|
+
this.bucket = bucket
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async upload(path: string, file: Blob | Buffer, contentType: string) {
|
|
23
|
+
const { data, error } = await this.client.storage
|
|
24
|
+
.from(this.bucket)
|
|
25
|
+
.upload(path, file, { contentType, upsert: true })
|
|
26
|
+
if (error) throw error
|
|
27
|
+
return { path: data.path, publicUrl: this.getPublicUrl(data.path) }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getPublicUrl(path: string): string {
|
|
31
|
+
const { data } = this.client.storage.from(this.bucket).getPublicUrl(path)
|
|
32
|
+
return data.publicUrl
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async remove(path: string): Promise<void> {
|
|
36
|
+
const { error } = await this.client.storage.from(this.bucket).remove([path])
|
|
37
|
+
if (error) throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type StorageProvider = "supabase" | "cloudflare-r2" | "vercel-blob"
|
|
42
|
+
|
|
43
|
+
export function createStorageClient(): StorageClient {
|
|
44
|
+
const storageProvider = (process.env.STORAGE_PROVIDER || "supabase") as StorageProvider
|
|
45
|
+
|
|
46
|
+
if (storageProvider === "supabase") {
|
|
47
|
+
const url = process.env.SUPABASE_URL
|
|
48
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
49
|
+
const bucket = process.env.SUPABASE_STORAGE_BUCKET || "uploads"
|
|
50
|
+
if (!url || !key) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required when STORAGE_PROVIDER=supabase. See .env.example."
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
return new SupabaseStorageClient(url, key, bucket)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
`STORAGE_PROVIDER="${storageProvider}" is not implemented yet — only "supabase" is wired up. ` +
|
|
60
|
+
"See 13-TECH-STACK/DB_PROVIDER_GUIDE.md for how to add Cloudflare R2 or Vercel Blob."
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -6,18 +6,25 @@
|
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
-
"lint": "next lint"
|
|
9
|
+
"lint": "next lint",
|
|
10
|
+
"test": "vitest run"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
13
|
+
"@auth/drizzle-adapter": "^1.7.4",
|
|
14
|
+
"@neondatabase/serverless": "^0.10.3",
|
|
12
15
|
"@radix-ui/react-avatar": "^1.1.0",
|
|
13
16
|
"@radix-ui/react-dialog": "^1.1.1",
|
|
14
17
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
|
15
18
|
"@radix-ui/react-slot": "^1.1.0",
|
|
16
19
|
"@radix-ui/react-tabs": "^1.1.0",
|
|
20
|
+
"@supabase/supabase-js": "^2.46.1",
|
|
17
21
|
"class-variance-authority": "^0.7.0",
|
|
18
22
|
"clsx": "^2.1.1",
|
|
23
|
+
"drizzle-orm": "^0.36.0",
|
|
19
24
|
"lucide-react": "^0.439.0",
|
|
20
25
|
"next": "^16.2.9",
|
|
26
|
+
"next-auth": "5.0.0-beta.25",
|
|
27
|
+
"postgres": "^3.4.5",
|
|
21
28
|
"react": "18.3.1",
|
|
22
29
|
"react-dom": "18.3.1",
|
|
23
30
|
"tailwind-merge": "^2.5.2"
|
|
@@ -27,10 +34,12 @@
|
|
|
27
34
|
"@types/react": "^18.3.4",
|
|
28
35
|
"@types/react-dom": "^18.3.0",
|
|
29
36
|
"autoprefixer": "^10.4.20",
|
|
37
|
+
"drizzle-kit": "^0.28.1",
|
|
30
38
|
"eslint": "^8.57.0",
|
|
31
39
|
"eslint-config-next": "14.2.5",
|
|
32
40
|
"postcss": "^8.4.41",
|
|
33
41
|
"tailwindcss": "^3.4.10",
|
|
34
|
-
"typescript": "^5.5.4"
|
|
42
|
+
"typescript": "^5.5.4",
|
|
43
|
+
"vitest": "^2.1.5"
|
|
35
44
|
}
|
|
36
45
|
}
|