build: migrate project to pnpm

This commit is contained in:
saltbo
2026-06-01 10:44:33 -04:00
parent 312413d0a8
commit 30bc6e1a12
26 changed files with 11157 additions and 18291 deletions
+18 -24
View File
@@ -17,19 +17,15 @@ jobs:
if: github.repository == 'saltbo/zpan'
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/cache@v4
id: deps
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
- if: steps.deps.outputs.cache-hit != 'true'
run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npx vitest run --project unit --coverage
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm exec vitest run --project unit --coverage
- uses: codecov/codecov-action@v5
if: always()
with:
@@ -37,7 +33,7 @@ jobs:
flags: unit
fail_ci_if_error: false
handle_no_reports_found: true
- run: npx vitest run --project integration --coverage
- run: pnpm exec vitest run --project integration --coverage
- uses: codecov/codecov-action@v5
if: always()
with:
@@ -46,7 +42,7 @@ jobs:
fail_ci_if_error: false
handle_no_reports_found: true
- run: mkdir -p dist
- run: npm run test:cf
- run: pnpm test:cf
e2e-node:
name: E2E (Node)
@@ -56,19 +52,18 @@ jobs:
BETTER_AUTH_SECRET: ci-test-secret-that-is-at-least-32-chars
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Install cloudflared
run: |
curl -L --fail --output cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x cloudflared
- run: CLOUDFLARED_BIN=./cloudflared npm run e2e:cloud -- --runtime node
- run: CLOUDFLARED_BIN=./cloudflared pnpm e2e:cloud -- --runtime node
- uses: actions/upload-artifact@v4
if: failure()
with:
@@ -82,19 +77,18 @@ jobs:
needs: check
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Install cloudflared
run: |
curl -L --fail --output cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x cloudflared
- run: CLOUDFLARED_BIN=./cloudflared npm run e2e:cloud:cf
- run: CLOUDFLARED_BIN=./cloudflared pnpm e2e:cloud:cf
- uses: actions/upload-artifact@v4
if: failure()
with:
+7 -5
View File
@@ -73,9 +73,11 @@ jobs:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
@@ -87,7 +89,7 @@ jobs:
- name: Setup SAM CLI
uses: aws-actions/setup-sam@v2
- run: npm ci
- run: pnpm install --frozen-lockfile
- name: Ensure SAM artifact bucket exists
id: bucket
@@ -106,7 +108,7 @@ jobs:
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npx drizzle-kit migrate
run: pnpm exec drizzle-kit migrate
- name: Resolve existing deployment state
id: state
@@ -139,10 +141,10 @@ jobs:
- name: Build
run: |
npx vite build --mode node
pnpm exec vite build --mode node
# Bundle Lambda entry; @libsql/client is external (native binding)
npx tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client
pnpm exec tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client
# Assemble minimal Lambda deployment package
mkdir -p dist-lambda-pkg
+7 -6
View File
@@ -89,12 +89,13 @@ jobs:
# ------------------------------------------------------------------
# Step 3 — Node.js setup + install dependencies.
# ------------------------------------------------------------------
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
node-version: 24
cache: pnpm
- run: npm ci
- run: pnpm install --frozen-lockfile
# ------------------------------------------------------------------
# Step 4 — Log in to Azure using the service-principal credentials.
@@ -202,7 +203,7 @@ jobs:
# Step 7 — Build frontend + Azure Functions bundle.
# ------------------------------------------------------------------
- name: Build
run: npm run build:azure
run: pnpm build:azure
# ------------------------------------------------------------------
# Step 8 — Run database migrations against Turso.
@@ -211,7 +212,7 @@ jobs:
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npm run db:migrate
run: pnpm db:migrate
# ------------------------------------------------------------------
# Step 9 — Install Azure Functions Core Tools and publish.
@@ -219,7 +220,7 @@ jobs:
# are already in place before this step runs.
# ------------------------------------------------------------------
- name: Install Azure Functions Core Tools
run: npm install -g azure-functions-core-tools@4 --unsafe-perm true
run: pnpm add --global azure-functions-core-tools@4
- name: Publish to Azure Functions
working-directory: azure-functions
+4 -2
View File
@@ -71,9 +71,11 @@ jobs:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
@@ -90,8 +92,8 @@ jobs:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: |
npm ci
npm run db:migrate
pnpm install --frozen-lockfile
pnpm db:migrate
- name: Store required secrets in Secret Manager
env:
+12 -10
View File
@@ -63,11 +63,13 @@ jobs:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: npm ci
- run: pnpm install --frozen-lockfile
- name: Ensure D1 database exists
id: d1
@@ -75,10 +77,10 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
DB_ID=$(npx wrangler d1 list --json | jq -r '.[] | select(.name == "zpan-db") | .uuid')
DB_ID=$(pnpm exec wrangler d1 list --json | jq -r '.[] | select(.name == "zpan-db") | .uuid')
if [ -z "$DB_ID" ]; then
# wrangler d1 create does not support --json; parse the TOML snippet it prints.
DB_ID=$(npx wrangler d1 create zpan-db | awk -F'"' '/database_id/{print $2; exit}')
DB_ID=$(pnpm exec wrangler d1 create zpan-db | awk -F'"' '/database_id/{print $2; exit}')
echo "Created D1 database: $DB_ID"
else
echo "Reusing D1 database: $DB_ID"
@@ -142,10 +144,10 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx wrangler d1 migrations apply DB --remote
run: pnpm exec wrangler d1 migrations apply DB --remote
- name: Build
run: npx vite build
run: pnpm exec vite build
- name: Deploy
env:
@@ -156,7 +158,7 @@ jobs:
# built Worker (dist/zpan/wrangler.json). Cd'ing into dist/zpan instead
# causes wrangler 4.x to error on conflicting base paths between the
# two configs.
run: npx wrangler deploy
run: pnpm exec wrangler deploy
- name: Set BETTER_AUTH_SECRET (first deploy only)
env:
@@ -164,12 +166,12 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
EXISTS=$(npx wrangler secret list --format json | jq -r '.[] | select(.name == "BETTER_AUTH_SECRET") | .name')
EXISTS=$(pnpm exec wrangler secret list --format json | jq -r '.[] | select(.name == "BETTER_AUTH_SECRET") | .name')
if [ -n "$USER_SECRET" ]; then
echo "$USER_SECRET" | npx wrangler secret put BETTER_AUTH_SECRET
echo "$USER_SECRET" | pnpm exec wrangler secret put BETTER_AUTH_SECRET
echo "Set BETTER_AUTH_SECRET from GitHub secret"
elif [ -z "$EXISTS" ]; then
openssl rand -base64 32 | npx wrangler secret put BETTER_AUTH_SECRET
openssl rand -base64 32 | pnpm exec wrangler secret put BETTER_AUTH_SECRET
echo "Auto-generated BETTER_AUTH_SECRET"
else
echo "BETTER_AUTH_SECRET already set, skipping"
@@ -180,5 +182,5 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
echo "${{ steps.r2.outputs.url }}" | npx wrangler secret put PUBLIC_IMAGES_URL
echo "${{ steps.r2.outputs.url }}" | pnpm exec wrangler secret put PUBLIC_IMAGES_URL
echo "Set PUBLIC_IMAGES_URL = ${{ steps.r2.outputs.url }}"
+10 -8
View File
@@ -66,28 +66,30 @@ jobs:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: npm ci
- run: pnpm install --frozen-lockfile
- name: Apply Turso migrations
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npx drizzle-kit migrate
run: pnpm exec drizzle-kit migrate
- name: Ensure BETTER_AUTH_SECRET is set (first deploy only)
env:
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
EXISTING=$(npx netlify env:get BETTER_AUTH_SECRET 2>&1 || true)
EXISTING=$(pnpm exec netlify env:get BETTER_AUTH_SECRET 2>&1 || true)
if [ -n "$USER_SECRET" ]; then
npx netlify env:set BETTER_AUTH_SECRET "$USER_SECRET" --context production
pnpm exec netlify env:set BETTER_AUTH_SECRET "$USER_SECRET" --context production
echo "Set BETTER_AUTH_SECRET from GitHub secret"
elif [ -z "$EXISTING" ]; then
npx netlify env:set BETTER_AUTH_SECRET "$(openssl rand -base64 32)" --context production
pnpm exec netlify env:set BETTER_AUTH_SECRET "$(openssl rand -base64 32)" --context production
echo "Auto-generated BETTER_AUTH_SECRET"
else
echo "BETTER_AUTH_SECRET already set, skipping"
@@ -95,8 +97,8 @@ jobs:
- name: Build
run: |
npm run build
npm run build:netlify
pnpm build
pnpm build:netlify
- name: Deploy
id: deploy
@@ -104,7 +106,7 @@ jobs:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: |
OUTPUT=$(npx netlify deploy \
OUTPUT=$(pnpm exec netlify deploy \
--prod \
--dir=dist \
--functions=netlify/functions \
+10 -8
View File
@@ -61,21 +61,23 @@ jobs:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run migrations (Turso)
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npm run db:migrate
run: pnpm db:migrate
- name: Build
run: npm run build:vercel
run: pnpm build:vercel
- name: Link or create Vercel project
env:
@@ -84,7 +86,7 @@ jobs:
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
run: |
cp deploy/vercel/vercel.json vercel.json
npx vercel link --yes \
pnpm exec vercel link --yes \
--token "$VERCEL_TOKEN" \
--scope "$VERCEL_ORG_ID"
@@ -93,15 +95,15 @@ jobs:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
EXISTS=$(npx vercel env ls production --token "$VERCEL_TOKEN" 2>/dev/null | grep -c "^BETTER_AUTH_SECRET" || true)
EXISTS=$(pnpm exec vercel env ls production --token "$VERCEL_TOKEN" 2>/dev/null | grep -c "^BETTER_AUTH_SECRET" || true)
if [ -n "$USER_SECRET" ]; then
# User supplied their own secret — persist it (upsert).
echo "$USER_SECRET" | npx vercel env add BETTER_AUTH_SECRET production --token "$VERCEL_TOKEN" --force
echo "$USER_SECRET" | pnpm exec vercel env add BETTER_AUTH_SECRET production --token "$VERCEL_TOKEN" --force
echo "Set BETTER_AUTH_SECRET from GitHub secret."
elif [ "$EXISTS" -eq 0 ]; then
# First deploy and no user secret — auto-generate and persist.
SECRET=$(openssl rand -base64 32)
echo "$SECRET" | npx vercel env add BETTER_AUTH_SECRET production --token "$VERCEL_TOKEN"
echo "$SECRET" | pnpm exec vercel env add BETTER_AUTH_SECRET production --token "$VERCEL_TOKEN"
echo "auto_generated=true" >> "$GITHUB_OUTPUT"
echo "Auto-generated BETTER_AUTH_SECRET and stored in Vercel project env."
else
@@ -120,7 +122,7 @@ jobs:
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
TRUSTED_ORIGINS: ${{ secrets.TRUSTED_ORIGINS }}
run: |
DEPLOY_URL=$(npx vercel deploy --prod --token "$VERCEL_TOKEN" \
DEPLOY_URL=$(pnpm exec vercel deploy --prod --token "$VERCEL_TOKEN" \
--env TURSO_DATABASE_URL="$TURSO_DATABASE_URL" \
--env TURSO_AUTH_TOKEN="$TURSO_AUTH_TOKEN" \
--env BETTER_AUTH_URL="$BETTER_AUTH_URL" \
+7 -6
View File
@@ -13,16 +13,17 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: mkdir -p dist # required by vitest cloudflare config
- run: npm run test:cf
- run: pnpm test:cf
docker:
name: Docker Image
+2 -2
View File
@@ -34,7 +34,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, etc.). PRs target `master`.
## Pre-commit Hooks
Husky runs `npm run typecheck` + lint-staged (biome auto-fix) on every `git commit`. **Never** bypass with `--no-verify`. **Never** run `npm install --ignore-scripts` — the `prepare` script must run so hooks are installed. If a hook fails, fix the underlying issue and re-commit.
Husky runs `pnpm typecheck` + lint-staged (biome auto-fix) on every `git commit`. **Never** bypass with `--no-verify`. **Never** run `pnpm install --ignore-scripts` — the `prepare` script must run so hooks are installed. If a hook fails, fix the underlying issue and re-commit.
## API Client (Hono RPC)
@@ -59,7 +59,7 @@ All shared types live in `shared/`. **Never** create duplicate type definitions
## Migrations
**Always** generate migrations with `npm run db:generate` (drizzle-kit). **Never** hand-author the `.sql` file or the `migrations/meta/_journal.json` entry.
**Always** generate migrations with `pnpm db:generate` (drizzle-kit). **Never** hand-author the `.sql` file or the `migrations/meta/_journal.json` entry.
Why: drizzle-kit migrator orders migrations by the `when` timestamp in `_journal.json`. Hand-written entries with guessed timestamps break the ordering silently — drizzle sees the new migration's `when` as older than the last applied one and skips it, leaving dev databases out of sync while CI (which starts from empty) passes cleanly. Past incident: v2.3.0 T1/T2 shipped journal entries with `when = 1745000000000` (April 2025) while the last applied migration was `when = 1776200000000` (March 2026); every developer already at 0009 silently missed 0010/0011.
+18 -18
View File
@@ -9,14 +9,14 @@ Prerequisites: Node.js 24+ (managed by [Volta](https://volta.sh/))
```sh
git clone git@github.com:saltbo/zpan.git
cd zpan
npm install
pnpm install
```
## Development
```sh
npm run dev # CF Workers mode with HMR (default, uses staging D1)
npm run dev:node # Node.js mode with HMR (SQLite, reads .dev.vars)
pnpm dev # CF Workers mode with HMR (default, uses staging D1)
pnpm dev:node # Node.js mode with HMR (SQLite, reads .dev.vars)
```
## Quality Gates
@@ -24,18 +24,18 @@ npm run dev:node # Node.js mode with HMR (SQLite, reads .dev.vars)
Every commit and PR must pass these checks. Husky enforces them on pre-commit.
```sh
npm run lint # Biome — lint + format check
npm run typecheck # TypeScript strict mode
npm test # Unit + integration tests (Node runtime, 90% coverage gate)
npm run test:cf # Integration tests (Cloudflare Workers runtime)
npm run e2e # Playwright E2E tests
pnpm lint # Biome — lint + format check
pnpm typecheck # TypeScript strict mode
pnpm test # Unit + integration tests (Node runtime, 90% coverage gate)
pnpm test:cf # Integration tests (Cloudflare Workers runtime)
pnpm e2e # Playwright E2E tests
```
## Adding a Feature
1. **Write code** in the relevant directory (`server/`, `src/`, `shared/`)
2. **Write tests** — co-locate with source as `*.test.ts` (Node) or `*.cf-test.ts` (CF Workers)
3. **Run checks** — `npm run lint && npm run typecheck && npm test && npm run test:cf`
3. **Run checks** — `pnpm lint && pnpm typecheck && pnpm test && pnpm test:cf`
4. **Coverage** — new code must maintain 90%+ line coverage on `server/`
5. **Commit** — use [Conventional Commits](https://www.conventionalcommits.org) (`feat:`, `fix:`, `docs:`, etc.)
6. **PR** — target the `master` branch
@@ -78,17 +78,17 @@ For admin feature testing, read admin credentials from the local `.dev.vars` fil
Schema is defined in `server/db/schema.ts` and `server/db/auth-schema.ts`.
```sh
npm run db:generate # Generate migration SQL after schema changes
npm run db:migrate # Apply migrations (Node/SQLite)
npm run db:migrate:d1 # Apply migrations (D1 local)
pnpm db:generate # Generate migration SQL after schema changes
pnpm db:migrate # Apply migrations (Node/SQLite)
pnpm db:migrate:d1 # Apply migrations (D1 local)
wrangler d1 migrations apply zpan-db --remote # Apply migrations (D1 production)
```
To reset local databases with seed data (admin user + dev storage):
```sh
npm run db:reset # Reset Node database (zpan.db)
npm run db:reset:d1 # Reset D1 local database (.wrangler)
pnpm db:reset # Reset Node database (zpan.db)
pnpm db:reset:d1 # Reset D1 local database (.wrangler)
```
Migration files live in `migrations/` at project root. Always commit them.
@@ -100,13 +100,13 @@ When deploying the Node/Docker image against a Turso (libSQL) database, set `TUR
```sh
TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
npm run db:migrate
pnpm db:migrate
```
For local libSQL files the token can be omitted:
```sh
TURSO_DATABASE_URL=file:./zpan.db npm run db:migrate
TURSO_DATABASE_URL=file:./zpan.db pnpm db:migrate
```
Migrations run automatically at Docker container startup when `TURSO_DATABASE_URL` is set. See [docs/deploy/docker.md](docs/deploy/docker.md) for the full Docker + Turso setup.
@@ -116,8 +116,8 @@ Migrations run automatically at Docker container startup when `TURSO_DATABASE_UR
Primary target is Cloudflare Workers. Node.js (Docker) is the backup runtime.
```sh
npm run build # Build frontend to dist/
npm run deploy # Build + deploy to Cloudflare Workers
pnpm build # Build frontend to dist/
pnpm deploy # Build + deploy to Cloudflare Workers
```
## Project Structure
+6 -4
View File
@@ -6,12 +6,14 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
corepack enable \
&& pnpm install --frozen-lockfile
COPY . .
RUN npm run build:node \
&& npm prune --omit=dev
RUN pnpm build:node \
&& pnpm prune --prod
FROM node:24-slim
WORKDIR /app
+1 -1
View File
@@ -1,6 +1,6 @@
[build]
# Build the React SPA then compile the Netlify Function.
command = "npm run build && npm run build:netlify"
command = "pnpm build && pnpm build:netlify"
publish = "dist"
[functions]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": 2,
"buildCommand": "npm run build:vercel",
"buildCommand": "pnpm build:vercel",
"outputDirectory": "dist",
"functions": {
"api/entry-vercel.js": {
+6 -6
View File
@@ -38,7 +38,7 @@ CF Workers (wrangler) Docker (Node.js)
| ORM | **Drizzle** | Prisma, Kysely | First-class D1 support, type-safe, SQL-like syntax, lightweight bundle for CF Workers |
| Auth | **Better Auth** | Lucia, Auth.js, custom JWT | Drizzle adapter, D1 support, built-in social login / OIDC / organization plugin, active development |
| S3 SDK | **@aws-sdk/client-s3** | minio-js, custom fetch | Industry standard, works with every S3-compatible provider including R2 |
| Package Manager | **npm** | pnpm, yarn, bun | Standard, no extra tooling, Volta manages Node version |
| Package Manager | **pnpm** | npm, yarn, bun | Fast, strict dependency installs with a committed lockfile; Volta pins the version |
| Frontend Framework | **React 19 + Vite** | Vue, Solid, Svelte | Largest ecosystem, Capacitor-ready for future native apps if needed |
| UI Components | **shadcn/ui + Tailwind CSS 4** | Ant Design, MUI | Zero runtime overhead (source code, not npm dep), best responsive/mobile support, modern aesthetic preferred by international users |
| Admin Scaffold | **[shadcn-admin](https://github.com/satnaing/shadcn-admin)** (11.6k stars) | Custom build | Fork as starting point for the **admin backend panel**. Includes sidebar, navigation, search, dark mode, responsive layout, settings pages. Same stack: Vite + TanStack Router + shadcn/ui. The user-facing frontend is custom-built |
@@ -97,7 +97,7 @@ zpan/
├── e2e/ # Playwright E2E tests
├── wrangler.toml # Cloudflare Workers config
├── biome.json # Lint + format config
└── package.json # Single package, npm
└── package.json # Single package, pnpm
```
## API Communication
@@ -138,10 +138,10 @@ Coverage gate: 90% on server. Playwright tests live in `e2e/` at the repo root,
GitHub Actions:
**On PR:**
- `npm lint` — Biome lint + format check
- `npm typecheck` — TypeScript compilation
- `npm test` — Vitest unit + API tests
- `npm e2e` — Playwright tests
- `pnpm lint` — Biome lint + format check
- `pnpm typecheck` — TypeScript compilation
- `pnpm test` — Vitest unit + API tests
- `pnpm e2e` — Playwright tests
**On merge to master:**
- Build + deploy to CF Workers (preview / production)
+4 -4
View File
@@ -100,8 +100,8 @@ The workflow triggers automatically on every push to `master` **and** can be tri
- Storage Account (required by the Functions runtime)
- Consumption plan (Y1 / Dynamic SKU)
- Function App (Node 22, runtime v4)
6. **Build** — `npm run build:azure` produces the `azure-functions/` publish directory.
7. **Migrate** — `npm run db:migrate` applies Drizzle migrations to Turso.
6. **Build** — `pnpm build:azure` produces the `azure-functions/` publish directory.
7. **Migrate** — `pnpm db:migrate` applies Drizzle migrations to Turso.
8. **Publish** — `func azure functionapp publish <name>` uploads the bundle.
9. **Set `BETTER_AUTH_SECRET`** — checks whether the setting already exists; generates and sets it if missing.
10. **Update `APP_URL`** — patches the real function-app URL into its own app settings.
@@ -130,13 +130,13 @@ curl https://<your-func-app>.azurewebsites.net/api/health
TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
npm run dev:node
pnpm dev:node
```
### Local Azure Functions emulation
```sh
npm run build:azure
pnpm build:azure
cd azure-functions
func start
```
+1 -1
View File
@@ -84,7 +84,7 @@ The workflow follows the standard 8-step deployment contract:
3. **Resolve release tag** — defaults to the latest `saltbo/zpan` release; override via `workflow_dispatch` input.
4. **Checkout** upstream code at the resolved tag.
5. **Authenticate** to Google Cloud using the service account key.
6. **Apply Turso migrations** (`npm run db:migrate`).
6. **Apply Turso migrations** (`pnpm db:migrate`).
7. **Store required secrets** in Secret Manager (`turso-database-url`, `better-auth-secret`).
8. **Build + deploy** — Cloud Build builds the image; `gcloud run services replace deploy/cloud-run/service.yaml` creates or updates the service; a post-deploy step wires `BETTER_AUTH_URL` (from the real service URL) and `TURSO_AUTH_TOKEN` (if provided) into the running service via `gcloud run services update --update-secrets`.
+2 -2
View File
@@ -56,10 +56,10 @@ When `TURSO_DATABASE_URL` is present:
```sh
TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
npm run db:migrate
pnpm db:migrate
```
`drizzle.config.ts` automatically switches to the `turso` dialect when `TURSO_DATABASE_URL` is set, so `npm run db:generate` and `npm run db:migrate` work against Turso without any extra flags.
`drizzle.config.ts` automatically switches to the `turso` dialect when `TURSO_DATABASE_URL` is set, so `pnpm db:generate` and `pnpm db:migrate` work against Turso without any extra flags.
### Obtaining a Turso auth token
+2 -2
View File
@@ -28,7 +28,7 @@ ZPan runs on Netlify via [Netlify Functions v2](https://docs.netlify.com/functio
Or via CLI:
```sh
npm install -g netlify-cli
pnpm add --global netlify-cli
netlify login
netlify sites:create --name my-zpan
```
@@ -111,7 +111,7 @@ In the Netlify dashboard → **Domain management → Add a domain**. No changes
```sh
TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
npm run db:migrate
pnpm db:migrate
```
### Free-tier limits
+3 -3
View File
@@ -46,7 +46,7 @@ Set these in your fork's GitHub repository under **Settings → Secrets and vari
3. **Link your Vercel project** locally (first time only):
```sh
cp deploy/vercel/vercel.json vercel.json
npx vercel link
pnpm exec vercel link
# Note the VERCEL_ORG_ID and VERCEL_PROJECT_ID from .vercel/project.json
```
@@ -59,7 +59,7 @@ Set these in your fork's GitHub repository under **Settings → Secrets and vari
Install the Vercel CLI and run:
```sh
npm install -g vercel
pnpm add --global vercel
cp deploy/vercel/vercel.json vercel.json
# Using a local libSQL file (no Turso token needed)
@@ -72,7 +72,7 @@ The app will be available at `http://localhost:3000`.
## Build Output
`npm run build:vercel` produces:
`pnpm build:vercel` produces:
| Path | Contents |
|------|---------|
+3 -3
View File
@@ -2,8 +2,8 @@ pre-commit:
parallel: true
commands:
lint:
run: npm run lint
run: pnpm lint
typecheck:
run: npm run typecheck
run: pnpm typecheck
test:
run: npm test
run: pnpm test
-18157
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -4,6 +4,7 @@
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
"packageManager": "pnpm@10.30.2",
"scripts": {
"dev": "CLOUDFLARE_ENV=staging vite dev",
"dev:node": "node --env-file=.dev.vars node_modules/vite/bin/vite.js dev --mode node",
@@ -13,7 +14,7 @@
"build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client",
"build:netlify": "tsup server/entry-netlify.ts --format esm --outDir netlify/functions --external @libsql/client",
"build:azure": "vite build && tsup server/entry-azure.ts --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist",
"deploy": "npm run db:migrate:d1:prod && wrangler deploy",
"deploy": "pnpm db:migrate:d1:prod && wrangler deploy",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate:d1": "wrangler d1 migrations apply zpan-db-staging --local --env staging",
@@ -64,6 +65,7 @@
"@tanstack/react-router": "^1.93.0",
"@tanstack/react-table": "^8.21.3",
"@uiw/react-md-editor": "^4.1.0",
"@uiw/react-markdown-preview": "5.2.0",
"@vidstack/react": "^1.12.13",
"better-auth": "^1.6.2",
"better-sqlite3": "^11.0.0",
@@ -142,8 +144,34 @@
"lint-staged": {
"*.{ts,tsx,js,jsx,json,jsonc}": "biome check --write --no-errors-on-unmatched"
},
"pnpm": {
"overrides": {
"@better-auth/core": "1.6.2",
"@better-auth/drizzle-adapter": "1.6.2",
"@better-auth/kysely-adapter": "1.6.2",
"@better-auth/memory-adapter": "1.6.2",
"@better-auth/mongo-adapter": "1.6.2",
"@better-auth/prisma-adapter": "1.6.2",
"@better-auth/telemetry": "1.6.2",
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"undici": "7.24.8"
},
"onlyBuiltDependencies": [
"@swc/core",
"better-sqlite3",
"esbuild",
"sharp",
"workerd"
]
},
"volta": {
"node": "24.14.1",
"npm": "11.11.0"
"pnpm": "10.30.2"
}
}
+10987
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -47,11 +47,11 @@ const e2eEnv = {
if (runtime === 'cf') {
if (local) rmSync('.wrangler/state/v3/d1', { recursive: true, force: true })
writeDevVars(e2eEnv)
await run('npx', ['wrangler', 'd1', 'migrations', 'apply', 'DB', '--local'], e2eEnv)
await run('pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', 'DB', '--local'], e2eEnv)
}
try {
await run('npx', ['playwright', 'test', spec, `--project=${project}`], e2eEnv)
await run('pnpm', ['exec', 'playwright', 'test', spec, `--project=${project}`], e2eEnv)
} finally {
if (tunnel) {
try {
@@ -16,7 +16,7 @@
* the poll-pending stage against the live API, then use locally-signed PASETO
* certs to test the full feature-gate chain end-to-end.
*
* Run with: npx vitest run server/licensing/e2e-cloud-integration.test.ts
* Run with: pnpm exec vitest run server/licensing/e2e-cloud-integration.test.ts
*/
import { generateKeys, sign } from 'paseto-ts/v4'
+13 -13
View File
@@ -1306,7 +1306,7 @@ describe('Quota Store API', () => {
body: JSON.stringify({
amount: 4096,
currency: 'usd',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
count: 2,
}),
})
@@ -1335,7 +1335,7 @@ describe('Quota Store API', () => {
expect(JSON.parse(generateInit.body as string)).toEqual({
amount: 4096,
currency: 'usd',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
count: 2,
})
expect(String(listUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards?status=active`)
@@ -2090,7 +2090,7 @@ describe('Quota Store API', () => {
trafficBytes: 0,
source: 'stripe',
packageName: 'Storage Pack',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
})
const res = await postWebhook(app, payload)
@@ -2115,7 +2115,7 @@ describe('Quota Store API', () => {
trafficBytes: 0,
source: 'stripe',
packageName: 'Storage Pack',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
})
const res = await app.request('/api/store/webhook', {
@@ -2149,7 +2149,7 @@ describe('Quota Store API', () => {
trafficBytes: 0,
source: 'stripe',
packageName: 'Storage Pack',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
})
const first = await postWebhook(app, payload)
@@ -2175,7 +2175,7 @@ describe('Quota Store API', () => {
WHERE org_id = ${orgId} AND resource_type = 'storage'
`)
expect(entitlement).toEqual([
{ bytes: 4096, status: 'active', sourceId: 'order-1', expiresAt: Date.parse('2026-06-01T00:00:00.000Z') },
{ bytes: 4096, status: 'active', sourceId: 'order-1', expiresAt: Date.parse('2099-06-01T00:00:00.000Z') },
])
const audit = await db.all<{ action: string; metadata: string }>(
sql`SELECT action, metadata FROM activity_events WHERE org_id = ${orgId} ORDER BY created_at DESC LIMIT 1`,
@@ -2210,7 +2210,7 @@ describe('Quota Store API', () => {
packageId: 'pkg-monthly-storage-traffic',
packageName: 'Team Plan',
occurredAt: '2026-05-01T00:00:00.000Z',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
customerId: 'buyer-1',
customerEmail: 'buyer@example.com',
})
@@ -2245,14 +2245,14 @@ describe('Quota Store API', () => {
{ resourceType: 'traffic', bytes: 2048 },
])
expect(new Date(entitlements[0].startsAt).toISOString()).toBe('2026-05-01T00:00:00.000Z')
expect(new Date(entitlements[0].expiresAt).toISOString()).toBe('2026-06-01T00:00:00.000Z')
expect(new Date(entitlements[0].expiresAt).toISOString()).toBe('2099-06-01T00:00:00.000Z')
expect(JSON.parse(entitlements[0].metadata)).toMatchObject({
eventId: 'evt-subscription-initial-entitlement',
source: 'stripe_subscription',
packageId: 'pkg-monthly-storage-traffic',
packageName: 'Team Plan',
trafficOveragePriceCents: 25,
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
customerId: 'buyer-1',
customerEmail: 'buyer@example.com',
})
@@ -2303,7 +2303,7 @@ describe('Quota Store API', () => {
source: 'stripe_subscription',
packageName: 'Team Plan',
occurredAt: '2026-05-01T00:00:00.000Z',
expiresAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2099-06-01T00:00:00.000Z',
}),
)
const renewal = await postWebhook(
@@ -2319,7 +2319,7 @@ describe('Quota Store API', () => {
source: 'stripe_subscription',
packageName: 'Team Plan Plus',
occurredAt: '2026-06-01T00:00:00.000Z',
expiresAt: '2026-07-01T00:00:00.000Z',
expiresAt: '2099-07-01T00:00:00.000Z',
}),
)
@@ -2332,8 +2332,8 @@ describe('Quota Store API', () => {
ORDER BY resource_type
`)
expect(entitlements).toEqual([
{ resourceType: 'storage', bytes: 8192, expiresAt: Date.parse('2026-07-01T00:00:00.000Z') },
{ resourceType: 'traffic', bytes: 4096, expiresAt: Date.parse('2026-07-01T00:00:00.000Z') },
{ resourceType: 'storage', bytes: 8192, expiresAt: Date.parse('2099-07-01T00:00:00.000Z') },
{ resourceType: 'traffic', bytes: 4096, expiresAt: Date.parse('2099-07-01T00:00:00.000Z') },
])
const quotaRes = await app.request('/api/quotas/me', { headers })