spfn 0.2.0-beta.9 → 0.3.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +939 -203
  3. package/bin/spfn.js +53 -4
  4. package/dist/index.d.ts +1059 -1
  5. package/dist/index.js +11407 -2402
  6. package/dist/templates/.dockerignore +1 -0
  7. package/dist/templates/Dockerfile +7 -4
  8. package/dist/templates/README.md +116 -0
  9. package/dist/templates/docker-compose.production.yml +9 -2
  10. package/dist/templates/lib/api-client.ts +1 -1
  11. package/dist/templates/modes/full/src/app/auth/callback/page.tsx +1 -0
  12. package/dist/templates/modes/full/src/app/login/page.tsx +68 -0
  13. package/dist/templates/modes/full/src/i18n/catalogs.ts +16 -0
  14. package/dist/templates/modes/full/src/i18n/server.ts +9 -0
  15. package/dist/templates/modes/full/src/server/router.ts +30 -0
  16. package/dist/templates/modes/full/src/server/routes/examples.ts +94 -0
  17. package/dist/templates/modes/full/src/server/routes/ops.ts +52 -0
  18. package/dist/templates/modes/full/src/server/routes/root.ts +15 -0
  19. package/dist/templates/modes/full/src/server/server.config.ts +12 -0
  20. package/dist/templates/server/config/env.config.ts +10 -75
  21. package/dist/templates/server/entities/README.md +21 -0
  22. package/dist/templates/server/entities/config.ts +1 -1
  23. package/dist/templates/server/entities/example.entity.ts +1 -1
  24. package/dist/templates/server/repositories/example.repository.ts +1 -1
  25. package/dist/templates/server/router.ts +2 -5
  26. package/dist/templates/server/routes/examples.ts +2 -12
  27. package/dist/templates/server/routes/root.ts +2 -3
  28. package/dist/templates/server/server.config.ts +2 -4
  29. package/dist/templates/vercel/npmrc +1 -0
  30. package/dist/templates/vercel/route.ts +53 -0
  31. package/dist/templates/vercel/vercel.json +6 -0
  32. package/package.json +36 -23
  33. package/dist/commands/generate/templates/contract.template +0 -58
  34. package/dist/commands/generate/templates/entity.template +0 -28
  35. package/dist/commands/generate/templates/init-migration.template +0 -3
  36. package/dist/commands/generate/templates/repository.template +0 -56
  37. package/dist/commands/generate/templates/route.template +0 -65
  38. package/dist/commands/generate/templates/schema.template +0 -13
  39. package/dist/templates/Dockerfile.optimized +0 -67
  40. package/dist/templates/config/index.ts +0 -22
  41. package/dist/templates/config/schema.ts +0 -42
  42. package/dist/templates/server/routes/health.ts +0 -18
package/README.md CHANGED
@@ -1,260 +1,996 @@
1
- # spfn
1
+ # spfn — the SPFN CLI (backend layer for Next.js)
2
2
 
3
- > Superfunction CLI - The Backend Layer for Next.js
3
+ `spfn` takes a Next.js idea from prototype to production with a consistent full-stack
4
+ architecture. It can scaffold either a core-only backend or a production baseline with
5
+ authentication, internationalization, and a terminal operations surface, then runs the
6
+ dev/build/start lifecycle, database tooling, RPC codegen, and environment validation.
4
7
 
5
- The official CLI tool for SPFN framework. Initialize projects, generate boilerplate code, and manage your database.
8
+ Consistent is the point rather than a nicety: what it scaffolds is one fixed shape per
9
+ feature, so a coding agent has no architecture left to invent and the codebase does not
10
+ acquire several. That problem has a name —
11
+ [architecture drift](https://superfunction.xyz/architecture-drift).
6
12
 
7
- ## Usage
13
+ > Beta: install with the `@beta` tag (`spfn@beta`). The binary is `spfn`.
14
+
15
+ ## Install
8
16
 
9
- > ⚠️ **Alpha Release**: SPFN is currently in alpha. Use `@alpha` tag for installation.
17
+ No global install needed — run through your package manager's dlx/npx:
10
18
 
11
- ### Quick Start (New Project)
12
19
  ```bash
13
- # Create new project with all SPFN features pre-configured
14
- npx spfn@alpha create my-app
15
- cd my-app
16
- docker compose up -d
17
- npm run spfn:dev
20
+ npx spfn@beta <command>
21
+ pnpm dlx spfn@beta <command>
18
22
  ```
19
23
 
20
- ### Add to Existing Next.js Project
24
+ Or add it as a project dependency (`spfn init`/`spfn create` do this for you), then
25
+ call it via `pnpm spfn <command>` / `npm run spfn:<script>`.
26
+
27
+ Requirements: Node.js 20+ in both modes, Next.js 16.2.11+ (App Router, `src/` dir),
28
+ PostgreSQL 14+ (Redis optional). Next.js 15
29
+ is not supported — see [the root README](../../README.md#what-do-i-need-installed).
30
+
31
+ ## Usage
32
+
21
33
  ```bash
22
- # Using npx (no installation required) - Recommended
23
- npx spfn@alpha init
34
+ # Prototype-to-Production baseline: core + auth + i18n + ops
35
+ npx spfn@beta create my-app --mode full
36
+ cd my-app
37
+ docker compose up -d # Postgres + Redis
38
+ # .env.local & .env.server are generated — keep both gitignored
39
+ pnpm spfn db migrate # Apply auth migrations
40
+ pnpm spfn:dev # Next.js :3790 + SPFN API :8790
41
+
42
+ # Core-only full-stack skeleton
43
+ npx spfn@beta create my-api --mode bare
24
44
 
25
- # Or install globally (alpha version)
26
- npm install -g spfn@alpha
27
- spfn init
45
+ # Add SPFN to an existing Next.js project
46
+ npx spfn@beta init --mode full
28
47
  ```
29
48
 
49
+ The package manager is auto-detected (pnpm > yarn > bun > npm) from lockfiles; override
50
+ with `--pm`. In a pnpm workspace, `create` installs from the workspace root.
51
+
52
+ ---
53
+
30
54
  ## Commands
31
55
 
32
- ### Create New Project
56
+ Registered top-level commands: `create`, `init`, `add`, `dev`, `build`, `start`,
57
+ `provision`, `codegen`, `contract`, `key`, `setup`, `db`, `env`, `ops`, `secret`,
58
+ `cloud`, `kit`.
59
+
60
+ ### `spfn create <name>`
61
+
62
+ Runs `create-next-app` with SPFN-recommended flags (TypeScript, App Router, `src/`,
63
+ Tailwind, import alias `@/*`, no ESLint), sets up SVGR icons, then runs `init`.
64
+
65
+ Choose `full` for the recommended Prototype-to-Production baseline or `bare` for the
66
+ historical core-only skeleton. Without `--mode`, interactive runs show a mode selector
67
+ with `full` recommended. For backward compatibility, non-interactive `--yes` runs without
68
+ an explicit mode continue to generate `bare`; automation that wants full should always
69
+ pass `--mode full`.
70
+
71
+ | Option | Description |
72
+ |--------|-------------|
73
+ | `--pm <manager>` | Force package manager: `npm` \| `pnpm` \| `yarn` \| `bun` |
74
+ | `--shadcn` | Also run `shadcn init` |
75
+ | `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
76
+ | `--skip-install` | Skip dependency install |
77
+ | `--skip-git` | Skip `git init` |
78
+ | `-y, --yes` | Skip prompts, use defaults |
79
+
80
+ ### `spfn init`
81
+
82
+ Adds SPFN to an existing Next.js project: copies the selected server templates, wires the RPC
83
+ proxy route, Docker files, deploy + codegen config, updates `package.json` scripts/deps,
84
+ and installs. Full mode also adds the `/_auth/:path*` → SPFN API rewrite to
85
+ `next.config` (OAuth callbacks return to the app origin; merged manually if a `rewrites()`
86
+ already exists). See [Scaffold structure](#scaffold-structure) for what lands on disk.
87
+
88
+ | Option | Description |
89
+ |--------|-------------|
90
+ | `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
91
+ | `-y, --yes` | Skip prompts, use defaults |
92
+
93
+ Generated projects pin `drizzle-orm` and `drizzle-kit` to `1.0.0-rc.4`, matching
94
+ `@spfn/core` and the rest of the published SPFN database packages.
95
+
96
+ ### `spfn add <package>`
97
+
98
+ Installs an SPFN ecosystem package and applies its pre-built migrations. The package
99
+ name must be scoped (contain `/`).
100
+
33
101
  ```bash
34
- spfn create <name> # Create new Next.js project with SPFN (all-in-one)
35
- spfn create my-app # Example: Create project with TypeScript, App Router, SVGR, and SPFN
36
- spfn create my-app --shadcn # Include shadcn/ui component library
102
+ pnpm spfn add @spfn/cms
103
+ pnpm spfn add @mycompany/spfn-analytics
37
104
  ```
38
105
 
39
- ### Project Initialization
40
- ```bash
41
- spfn init # Initialize SPFN in existing Next.js project
42
- spfn init -y # Skip prompts, use defaults
106
+ How it works: if not already present it installs the package, then reads the package's
107
+ `spfn` field in its `package.json` (`migrations`, `setupMessage`) and applies any
108
+ function migrations to `DATABASE_URL`. If `DATABASE_URL` is unset, migration is skipped
109
+ with a hint to run `spfn db push` later. Works with published and workspace packages.
110
+
111
+ ### `spfn add vercel`
112
+
113
+ `vercel` is not a package — it is a built-in target, and the one argument to `add` that is
114
+ not a scoped package name. It scaffolds the three files a Next.js + SPFN app needs to run
115
+ its backend as Vercel Functions:
116
+
117
+ | File | What it is |
118
+ |------|------------|
119
+ | `src/app/api/backend/[[...route]]/route.ts` | `hono/vercel` adapter, mounts the SPFN app under `/api/backend` |
120
+ | `vercel.json` | build config (`pnpm spfn:build`) |
121
+ | `.npmrc` | which registry the `@spfn` scope resolves to — a mapping, not a credential |
122
+
123
+ The registry token does **not** go in that `.npmrc`. pnpm 10 and later refuse to expand an
124
+ environment variable in a registry credential that came from a project `.npmrc` — the file is
125
+ committed, and a hostile edit could redirect the secret to a registry nobody chose. pnpm drops
126
+ the line with a warning and the install fails as unauthorized. So the credential goes in Vercel's
127
+ `NPM_RC` environment variable, which Vercel writes to the build container's user-level `~/.npmrc`,
128
+ where pnpm still expands it. `spfn add vercel` prints the exact block to paste.
129
+
130
+ Existing files are never overwritten; they are reported and skipped. The runtime behind
131
+ the adapter is `createServerlessApp()` from `@spfn/core/server`. Afterwards, point
132
+ `SPFN_API_URL` at `https://<your-domain>/api/backend`, and make sure `hono` is a direct
133
+ dependency of the app so `hono/vercel` resolves.
134
+
135
+ ### `spfn dev`
136
+
137
+ Starts the SPFN server + Next.js (and a codegen watcher). The server must report ready
138
+ (via a `.spfn/server-ready` signal file) before Next.js launches. Runs through `tsx`,
139
+ no pre-build needed.
140
+
141
+ | Option | Description | Default |
142
+ |--------|-------------|---------|
143
+ | `--server-only` | Run only the SPFN/Hono server (also auto-selected if Next.js isn't a dependency) | off |
144
+ | `--watch` | Restart the server on `src/server` changes (chokidar) | off |
145
+ | `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `spfn.config.js` `ports.server`, then `8790` |
146
+ | `-H, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `spfn.config.js` `host`, then `localhost` |
147
+ | `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
148
+ | `--no-agent-files` | Do not write the SPFN instruction block into `AGENTS.md` (same as `SPFN_AGENT_FILES=0`) | writes on |
149
+
150
+ Note: hot reload is **off by default** — pass `--watch` to restart on file changes.
151
+
152
+ On startup `spfn dev` refreshes a marked block of SPFN conventions in the project's
153
+ `AGENTS.md` (creating the file, and a `CLAUDE.md` that references it, when absent) so
154
+ coding agents read the right idioms. Only the marked region is rewritten, and only when
155
+ its content actually changed.
156
+
157
+ Pending migrations stop the boot — see [Database](#spfn-db) for what the refusal looks
158
+ like and how to override it.
159
+
160
+ ### `spfn build`
161
+
162
+ Runs codegen, builds Next.js (via the project's `build` script), and compiles
163
+ `src/server/**/*.ts` → `.spfn/server` with tsup. Also writes `.spfn/prod-server.mjs`
164
+ (the production entry consumed by `spfn start`).
165
+
166
+ | Option | Description |
167
+ |--------|-------------|
168
+ | `--server-only` | Build only the SPFN server (skip Next.js) |
169
+ | `--next-only` | Build only Next.js (skip the SPFN server) |
170
+ | `--turbo` | Use Turbopack for the Next.js build |
171
+
172
+ ### `spfn start`
173
+
174
+ Starts the production servers from build output. Requires `spfn build` first — it errors
175
+ if `.spfn/server`, `.spfn/prod-server.mjs`, or `.next` are missing.
176
+
177
+ | Option | Description | Default |
178
+ |--------|-------------|---------|
179
+ | `--server-only` | Run only the SPFN server | off |
180
+ | `--next-only` | Run only Next.js | off |
181
+ | `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `spfn.config.js` `ports.server`, then `8790` |
182
+ | `-h, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `spfn.config.js` `host`, then `localhost` |
183
+ | `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
184
+
185
+ Both run together via `concurrently --kill-others`.
186
+
187
+ Neither flag has a default value, deliberately. A default is indistinguishable
188
+ from a value the operator typed, and it was forwarded as `SPFN_PORT` either way —
189
+ which overrode the app's own configuration. Pass nothing and `spfn.config.js`
190
+ decides; pass a flag and it wins.
191
+
192
+ Next.js is started on the port `spfn.config.js` gives as `ports.next` (`3790` by
193
+ default), overridable with `NEXT_PORT`.
194
+
195
+ Pending migrations stop the boot unless `--allow-pending-migrations` or
196
+ `SPFN_ALLOW_PENDING_MIGRATIONS=true` is set — see [Database](#spfn-db). `--next-only`
197
+ skips the check: no SPFN server starts, so nothing can drift.
198
+
199
+ ### `spfn codegen`
200
+
201
+ Manages code generators driven by `.spfnrc.ts`. The default generator is
202
+ `@spfn/core:route-map`, which emits `src/generated/route-map.ts` from `src/server/router.ts`
203
+ so the RPC proxy can resolve routes without importing server code. Generators also run
204
+ automatically during `spfn dev` and `spfn build`.
205
+
206
+ | Subcommand | Description |
207
+ |------------|-------------|
208
+ | `codegen init` | Create `.spfnrc.ts` (`--with-example` shows custom-generator usage) |
209
+ | `codegen list` (`ls`) | List configured generators and their watch patterns |
210
+ | `codegen run` | Run all generators once (no watch) |
211
+
212
+ `package.json` exposes this as the `codegen` script (`spfn codegen run`).
213
+
214
+ To add a custom generator, implement the `Generator` interface from `@spfn/core/codegen`
215
+ and reference it in `.spfnrc.ts`:
216
+
217
+ ```ts
218
+ // .spfnrc.ts
219
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
220
+
221
+ export default defineConfig({
222
+ generators: [
223
+ defineGenerator({
224
+ name: '@spfn/core:route-map',
225
+ routerPath: './src/server/router.ts',
226
+ outputPath: './src/generated/route-map.ts',
227
+ }),
228
+ ],
229
+ });
43
230
  ```
44
231
 
45
- **What `spfn init` creates:**
46
- - `src/lib/contracts/` - API contracts (shared between frontend and backend)
47
- - `src/server/routes/` - Backend route handlers
48
- - `src/server/entities/` - Database entities (Drizzle ORM)
49
- - `docker-compose.yml` - PostgreSQL + Redis for local development
50
- - `Dockerfile`, `.dockerignore`, `docker-compose.production.yml` - Production deployment
51
- - `.env.local.example` - Environment variable template
52
- - `spfn.config.js` - Deployment configuration with JSDoc type hints
53
- - `.spfnrc.json` - Code generation configuration
54
-
55
- **SPFN's Contract-Based Architecture:**
56
- - **Contracts** (`src/lib/contracts/`): Define API endpoints with absolute paths (e.g., `/users/:id`)
57
- - **Handlers** (`src/server/routes/`): Import contracts and implement business logic
58
- - **Frontend**: Import contracts for type-safe API calls
59
- - **Auto-generated Client**: `src/lib/api/` is auto-generated from contracts (via `spfn dev` or `spfn build`)
60
-
61
- ### Generate Function Modules
232
+ ### `spfn contract`
233
+
234
+ Manages the route contract — what a **separately deployed** client (a mobile app, an external
235
+ API consumer) is promised. A web client needs none of this: it derives its types from
236
+ `AppRouter` in the same build, so a broken response already fails the compile.
237
+
238
+ Requires the `@spfn/core:contract` generator in `.spfnrc.ts`. Every command regenerates the
239
+ contract from the router first, so a stale `contracts/current.json` is never what gets checked
240
+ or released.
241
+
62
242
  ```bash
63
- spfn generate fn <name> # Generate new SPFN function module (interactive)
64
- spfn g fn <name> # Short alias
243
+ # Regenerate and compare against the newest released snapshot
244
+ spfn contract check
65
245
 
66
- # With options
67
- spfn g fn blog -e posts,comments -y # Create blog module with posts & comments entities
68
- spfn g fn shop -d "E-commerce shop" -e products,orders,customers
246
+ # Cut a release — writes contracts/released/1.3.0.json. Commit it.
247
+ spfn contract release 1.3.0
248
+
249
+ # List released snapshots
250
+ spfn contract list
69
251
  ```
70
252
 
71
- **What `spfn generate fn` creates:**
72
- - `packages/<name>/` - New function module in monorepo
73
- - `src/server/entities/schema.ts` - **Exported schema** (ensures CREATE SCHEMA in migrations)
74
- - `src/server/entities/*.ts` - Drizzle ORM entity definitions (import schema)
75
- - `src/server/repositories/` - CRUD repository layer
76
- - `src/server/routes/` - RESTful API route handlers
77
- - `src/lib/contracts/` - TypeBox API contracts
78
- - `package.json` - Pre-configured with SPFN metadata
79
- - `tsup.config.ts` - Build configuration
80
- - `drizzle.config.ts` - Database migration setup
81
-
82
- **Database Schema Naming:**
83
- - Scope and module name are automatically converted to safe PostgreSQL schema names
84
- - Examples: `@my-company/blog` → `my_company_blog`, `@spfn/cms` → `spfn_cms`
85
- - Special characters (`.`, `!`, `-`) are converted to underscores
86
- - Names starting with numbers get `_` prefix (e.g., `@123company` → `_123company`)
87
- - Ensures PostgreSQL compatibility (lowercase, numbers, underscores only)
88
-
89
- **Options:**
90
- - `-e, --entities <list>` - Comma-separated entity names
91
- - `-d, --description <text>` - Module description
92
- - `--skip-routes` - Generate entities without routes
93
- - `--skip-cache` - Skip cache generation
94
- - `-y, --yes` - Skip all prompts
95
-
96
- **Example workflow:**
97
- ```bash
98
- # 1. Generate module
99
- spfn g fn blog -e posts,comments -y
253
+ | Subcommand | Description | Exit code |
254
+ |------------|-------------|-----------|
255
+ | `contract check` | Compares the current contract against the newest released snapshot | 1 when a promise is broken |
256
+ | `contract release <version>` | Writes the snapshot every later build is compared against | 1 when the contract is broken, the version already exists, or it is not newer than the newest one |
257
+ | `contract list` (`ls`) | Lists released snapshots | 0 |
258
+
259
+ `--dir <path>` overrides the contracts directory; by default it comes from the generator's
260
+ `outputDir` in `.spfnrc.ts`.
261
+
262
+ **`spfn build` runs the same gate.** A broken contract fails the build with a non-zero exit
263
+ code — that is the point of hanging the check off codegen rather than leaving it to a
264
+ separate step.
265
+
266
+ See [`@spfn/core` contract docs](../core/src/contract/README.md) for the case table and the
267
+ removal rules.
268
+
269
+ ### `spfn db`
270
+
271
+ Wraps Drizzle Kit with auto-generated config. Most commands read `DATABASE_URL` from the
272
+ loaded `.env` chain.
273
+
274
+ | Subcommand | Description |
275
+ |------------|-------------|
276
+ | `db generate` (`g`) | Generate migrations from schema changes (timestamp-prefixed) |
277
+ | `db push` | Diff with Drizzle Kit's current PostgreSQL engine and apply the selected DDL atomically. Destructive changes need confirmation; `--force` applies them, `--dry-run` previews |
278
+ | `db migrate` (`m`) | Run pending migrations. `--with-backup` snapshots first |
279
+ | `db status` | Show which migrations are applied and which are pending, for the project and for each installed function package. `--json` prints one machine-readable report instead |
280
+ | `db studio` | Open Drizzle Studio. `-p, --port` (auto-finds a free port) |
281
+ | `db check` | Verify the database connection |
282
+ | `db drop` | Drop all tables — **destructive**, double-prompts (see [Pitfalls](#pitfalls)) |
283
+ | `db backup` | Create a backup (`-f sql|custom`, `-o`, `-s`, `--data-only`, `--schema-only`, `--tag`, `--env`) |
284
+ | `db restore [file]` | Restore from a backup (`--drop`, `-s`, `--data-only`, `--schema-only`, `-v`) |
285
+ | `db backup:list` | List backups |
286
+ | `db backup:clean` | Prune backups (`-k, --keep <n>`, `-o, --older-than <days>`) |
287
+ | `db reindex` | Convert sequential migration prefixes to timestamps (`--dry-run`) |
288
+
289
+ > `db push` is for development. For production, use `db generate` + `db migrate` to keep
290
+ > migration history.
291
+
292
+ **Which files are the schema.** `db push`, `db generate` and `db studio` resolve the
293
+ project schema in the same order:
294
+
295
+ 1. `db push --schema <path>`: a file, a directory or a glob.
296
+ 2. `./drizzle.config.ts`, when the project has one: its `schema` entry, and for `db push`
297
+ a non-empty `schemaFilter`.
298
+ 3. Every entity file under `src/server/entities/`, barrel files (`index.*`, `config.*`)
299
+ excluded.
300
+ 4. The registry — `src/server/entities/config.ts`, or the file `DRIZZLE_SCHEMA_PATH`
301
+ names — loaded alone, in two cases: the scan finds no entity file (the folder holds
302
+ only the registry and the tables live elsewhere), or the registry exports a table,
303
+ enum, view or sequence none of the scanned files define while the folder defines
304
+ nothing the registry lacks (the tables moved out and a re-exported file was left
305
+ behind). The command says which and names the objects. A registry whose objects
306
+ are all among the scanned files (the scaffold) changes nothing. When each side
307
+ defines objects the other lacks, no choice keeps every table, so the command stops
308
+ and names both sets: re-export the folder's entities from the registry, move the
309
+ leftover files out, or name the schema in `drizzle.config.ts`.
310
+
311
+ An entry named through 1–2 is expanded the way drizzle-kit expands it and nothing is
312
+ filtered: a file is loaded as-is, a directory is read one level deep, a glob uses glob
313
+ syntax (`**`, `*`, `?`, `{a,b}`, `[…]`) with parentheses taken literally, so
314
+ `src/server/(workspace)/entities/*.ts` works, and a directory the glob matches is read
315
+ one level deep. Symlinks are followed (a link cycle ends where a directory was already
316
+ visited); a walk skips dot-directories like drizzle-kit's glob does. The accepted
317
+ extensions are drizzle-kit's (`.ts .mts .cts .tsx .js .mjs .cjs .jsx`). Two deliberate
318
+ differences: declaration files (`.d.ts`, `.d.mts`, `.d.cts`) are skipped, and a walk
319
+ never enters `node_modules`, so a `**` pattern cannot pull a dependency's file into
320
+ the schema. A registry loaded as-is (2 or 4) contributes only what it exports:
321
+ re-export with `export *` so a `pgEnum` or `pgSchema` defined next to a table comes
322
+ along. `db push` diffs the PostgreSQL schemas the declared `schemaFilter` names (else
323
+ `public`) plus every schema the loaded modules name — tables, `pgSchema()` objects,
324
+ enums, views, sequences; `--schema` replaces the files but keeps that declared filter.
325
+
326
+ **A function package's own schema is never the project's.** An installed package that
327
+ ships migrations (`@spfn/auth` → `spfn_auth`) creates and alters its own tables when
328
+ `db push` or `db migrate` runs its migrations, so the project never diffs or generates
329
+ them. Whichever files are loaded, `db push` drops the objects living in such a schema
330
+ from the diff and leaves that schema out of the filter it derives — a registry that
331
+ re-exports one package table for a relation no longer proposes `DROP TABLE` for the
332
+ package tables it does not re-export. A `schemaFilter` declared in `drizzle.config.ts`
333
+ still stands verbatim; naming a package schema there is the user's own choice. Schemas
334
+ the project itself owns (`pgSchema('billing')`) are unaffected: they are created and
335
+ diffed as before.
336
+
337
+ `db generate` cannot be filtered the same way — drizzle-kit loads the schema files
338
+ itself and its `generate` reads no `schemaFilter` — so it stops instead when the files
339
+ it would read reach a package's objects:
340
+
341
+ ```
342
+ ❌ The schema read from entity registry ./src/server/entities/config.ts reaches
343
+ users (@spfn/mockfn), which the package migrates itself. …
344
+ ```
345
+
346
+ Keep package re-exports out of the registry: a relation can `import` the package's
347
+ table and reference it without re-exporting it.
348
+
349
+ Earlier releases gave `db push` the folder scan alone, so a project whose tables lived
350
+ outside `src/server/entities/` and were re-exported from the registry pushed nothing
351
+ (`No schema files found`), and a project with a `drizzle.config.ts` had `db push` and
352
+ `db generate` reading different files. `db push` prints which source it used, exits 1
353
+ when a path named through 1–2 has no schema files, and exits 1 when `drizzle.config.ts`
354
+ cannot be loaded rather than pushing something `db generate` would not read. One
355
+ consequence for code calling `getDrizzleConfig({ schema, expandGlobs: true })`
356
+ directly: an explicit glob is no longer barrel-filtered, so a glob that matches both a
357
+ barrel and the files it re-exports now makes drizzle-kit report a duplicate table, as
358
+ it would for the same glob in a hand-written `drizzle.config.ts`.
359
+
360
+ **A server refuses to start while migrations are pending.** Bumping `@spfn/auth` and
361
+ skipping `db migrate` used to boot fine, pass the health check, and then fail every
362
+ request that touched a new column as an opaque 500. `spfn dev` and `spfn start` now
363
+ compare the migrations each installed function package ships (and `src/server/drizzle`,
364
+ where present) against what the database records as applied, print the ones still
365
+ waiting, and stop:
100
366
 
101
- # 2. Build the module
102
- cd packages/blog
103
- npm run build
367
+ ```
368
+ ❌ Refusing to start: 1 pending migration(s) in @spfn/auth
369
+ @spfn/auth: 1 pending migration(s) (12/13 applied)
370
+ - 20260805143152_client_identity
104
371
 
105
- # 3. Install in your app
106
- spfn add @spfn/blog
372
+ Run: pnpm spfn db migrate
107
373
  ```
108
374
 
109
- ### Install Ecosystem Packages
375
+ `--allow-pending-migrations` starts anyway and logs the same list as a warning.
376
+ `SPFN_ALLOW_PENDING_MIGRATIONS=true` does the same where no flag can be passed — a
377
+ container's env, a CI job. The check is skipped when the app initializes no database
378
+ or no package ships migrations, and a database it cannot reach is reported as
379
+ "could not verify", never as drift.
380
+
381
+ `db push` and `db migrate` also replay migrations shipped by installed SPFN function
382
+ packages (`@spfn/auth`, `@spfn/cms`, …) into per-package tracking tables
383
+ (`drizzle.__spfn_fn_<pkg>_migrations`). The CLI applies these with a built-in runner
384
+ that reads both migration layouts — drizzle-kit ≤0.31 (`NNNN_name.sql` +
385
+ `meta/_journal.json`) and drizzle-kit 1.0 (`<timestamp>_name/migration.sql`) — so a
386
+ package's layout never has to match the CLI's bundled drizzle version. `db push`
387
+ validates every package's migration folder before applying the project schema, and a
388
+ function-migration failure after a successful schema apply exits 1 with a message
389
+ making clear the project schema was already committed.
390
+
391
+ Database TLS is controlled by `DATABASE_URL`. Loopback URLs (`localhost`, `127.0.0.1`,
392
+ and `::1`) default to `ssl: false`; add an explicit `sslmode` when the local server uses
393
+ TLS. For a TLS connection with a self-signed certificate, set
394
+ `SPFN_DB_INSECURE_TLS=1` to disable certificate verification. This opt-in never enables
395
+ TLS by itself and `sslmode=disable` remains authoritative.
396
+
397
+ ### `spfn env`
398
+
399
+ Schema-driven environment variable tooling (schema comes from a package's `envSchema`,
400
+ default `@spfn/core`). Routes vars to the right file: `NEXT_PUBLIC_*` → `.env`/`.env.local`,
401
+ server vars → `.env.server`.
402
+
403
+ | Subcommand | Description |
404
+ |------------|-------------|
405
+ | `env list` | List vars from the schema (`-g` groups by target file) |
406
+ | `env stats` | Show variable statistics |
407
+ | `env search <query>` | Search vars by key or description |
408
+ | `env init` | Generate `.env` template files (`-e <env>` for per-env, `-f` to overwrite) |
409
+ | `env check` | Check `.env` files against the schema (`-e <env>` for a full env chain) |
410
+ | `env validate` | Validate `process.env` against the schema — for CI/CD (`-e <env>`, `-s` strict) |
411
+
412
+ All accept `-p, --package <pkg>` (`env validate` uses `-p, --packages <pkgs...>`).
413
+
414
+ ### `spfn key [preset]`
415
+
416
+ Generate cryptographically random secrets (base64url, 256-bit default).
417
+
110
418
  ```bash
111
- spfn add <package> # Install SPFN ecosystem package with automatic DB setup
112
- spfn add @spfn/cms # Install CMS package
113
- spfn add @company/plugin # Install third-party SPFN package
419
+ spfn key # generic 256-bit secret
420
+ spfn key auth-encryption -c # preset key, copy to clipboard
421
+ spfn key --list # list presets
422
+ spfn key gen -b 64 # raw value only, no metadata (alias of `key generate`)
114
423
  ```
115
424
 
116
- **What `spfn add` does:**
117
- 1. ✅ Installs the package via pnpm/npm
118
- 2. ✅ Discovers package's pre-built migrations
119
- 3. ✅ Applies package migrations to your database
120
- 4. ✅ Shows package-specific setup guide
425
+ Presets: `auth-encryption`, `nextauth-secret`, `jwt-secret`, `session-secret`, `api-key`.
426
+ Options: `-l, --list`, `-b, --bytes <n>` (1–128), `-e, --env <name>`, `-c, --copy`.
427
+ The command prints the value to stdout for you to paste into an env file — it does **not**
428
+ write any file.
429
+
430
+ ### `spfn secret`
431
+
432
+ Unified secret management: local secrets live in the OS keychain, deployed secrets in
433
+ encrypted SOPS files. The runtime never sees a reference — `spfn dev` injects local
434
+ values into the server process and GitOps injects them in production, so the app always
435
+ reads plain `process.env`.
436
+
437
+ | Subcommand | Description |
438
+ |------------|-------------|
439
+ | `secret set [key]` | Store a value (masked prompt). `--env local` → keychain; other envs → SOPS |
440
+ | `secret list` | List declared secrets and their status per env (never prints values) |
441
+ | `secret generate [key]` | Mint values for schema secrets with a `generate` strategy (`-a/--all`) |
442
+ | `secret rotate [key]` | Rotate values; external secrets are flagged for manual reissue (`-a/--all`) |
443
+ | `secret keygen` | Generate an age key pair for the SOPS no-cloud backend |
444
+ | `secret recipients <add\|remove\|list> [age1…]` | Manage `.sops.yaml` recipients + re-encrypt |
445
+ | `secret check` | Static lint — flag plaintext secret leaks |
446
+
447
+ Options: `-e, --env <env>` (`local` default; also `development`/`staging`/`production`),
448
+ `-p, --package <pkg>` (schema source, default `@spfn/core`).
449
+
450
+ **Local (keychain).** `spfn secret set DB_URL` stores the value in the OS keychain
451
+ (macOS `security`, Windows Credential Manager via optional `@napi-rs/keyring`, Linux
452
+ libsecret) and writes a `secret:keychain:spfn_DB_URL` reference into `.env.server`. The
453
+ reference is not sensitive; the real value never lands in the repo. `spfn dev` resolves
454
+ and injects it. Note: injection happens only when the server is started via `spfn dev` —
455
+ running the app another way (a bare `node`, tests) would see the raw reference, so use
456
+ `spfn dev` locally (a runtime resolver for other runners is planned).
457
+
458
+ **Deployed (SOPS).** `spfn secret set DB_URL --env production` writes the value into
459
+ `secrets/production.enc.json`, encrypted by SOPS. The backend (age / GCP KMS / AWS KMS)
460
+ is chosen by `.sops.yaml` creation rules — KMS needs no local key file (IAM + cloud
461
+ auth), age is the no-cloud fallback (`secret keygen` + `secret recipients add`). Commit
462
+ the encrypted file; your GitOps step decrypts it into env at deploy time. `sops`/`age`
463
+ are needed only for the deployed envs, never for local keychain use.
464
+
465
+ Schema-driven: a secret declared with `envSecret({ generate: 'base64url32' })` can be
466
+ minted/rotated automatically (`secret generate`/`rotate`); one without `generate` is an
467
+ external value you paste in (`secret set`).
468
+
469
+ ### `spfn cloud`
470
+
471
+ Free-tier management for apps deployed to your own **Vercel Hobby + Supabase Free**
472
+ accounts: see the plan limits, watch live usage against them, keep the Supabase
473
+ project from pausing, and sync env vars/API keys. Account tokens and key values live
474
+ in the OS keychain and never appear in command output.
475
+
476
+ | Subcommand | Description |
477
+ |------------|-------------|
478
+ | `cloud link` | Connect accounts: Vercel access token + Supabase personal access token (masked prompt or `VERCEL_TOKEN`/`SUPABASE_ACCESS_TOKEN` env), pick the project on each side. Identifiers land in gitignored `.spfn/cloud.json`; tokens go to the keychain |
479
+ | `cloud limits` | The free-plan limits (constants verified against the official docs, date shown) — works before `link` |
480
+ | `cloud usage` | Current usage: Vercel billing feed (rolling 30 days), Supabase DB size + last-24h API requests |
481
+ | `cloud status` | Usage measured against the limits on one screen; items at ≥80% get a migration warning |
482
+ | `cloud keepalive` | Daily cron hitting `/api/backend/_core/health?detailed=true` (the detailed check runs a DB query, which is what prevents the ~7-idle-day pause). Vercel cron by default; `--github-actions --url <deployed-url>` for a workflow instead |
483
+ | `cloud env pull` | Supabase keys → local: project URL + anon key into `.env.local`, service-role key into the keychain (`.env.server` gets a reference). `--db-url` also composes `DATABASE_URL` (prompts for the DB password) |
484
+ | `cloud env push KEY…` | Push local env values to the Vercel project env by name. Values resolve from `.env`/`.env.local`/`.env.server`+keychain; everything is sent encrypted except `NEXT_PUBLIC_*` |
485
+
486
+ Free-tier behavior worth knowing (also printed by `cloud limits`): Vercel Hobby
487
+ allows one cron at most once per day and pauses a capability when its rolling
488
+ 30-day limit is hit (it never bills); Supabase Free quota is summed per
489
+ organization (except DB size), and org totals like egress/MAU have no public API —
490
+ `cloud status` shows per-item numbers and points at the dashboard for the rest.
491
+ Hobby is limited to personal, non-commercial use — a monetized app needs Vercel Pro
492
+ or a migration off the free tier.
493
+
494
+ ### `spfn setup icons`
495
+
496
+ Install and configure SVGR for SVG-as-component imports (Next.js only).
497
+
498
+ ### `spfn ops`
499
+
500
+ Invoke a running app's ops surface — the routes it exposes with
501
+ [`createOpsRouter`](../core/README.md#how-do-i-operate-the-app-from-the-terminal).
502
+ Commands are discovered from the app's `GET /_ops/_manifest`, so nothing is generated or
503
+ configured locally.
121
504
 
122
- **Example: Installing @spfn/cms**
123
505
  ```bash
124
- $ pnpm spfn add @spfn/cms
506
+ spfn ops list --app https://api.example.com # what can this app do?
507
+ spfn ops call listSignups --query limit=50 # invoke a command
508
+ spfn ops call refundOrder --param id=42 --data '{"reason":"duplicate"}'
509
+ spfn ops call listSignups --describe # what does it take?
510
+ ```
125
511
 
126
- 📦 Setting up @spfn/cms...
127
- ✓ Package installed
512
+ `--describe` answers from the manifest's schemas, so the usage is the running app's own,
513
+ not a local copy that can drift:
128
514
 
129
- 🗄️ Setting up database for @spfn/cms...
130
- ✓ Migrations applied
515
+ ```
516
+ listSignups GET /_ops/signups
131
517
 
132
- ✅ @spfn/cms installed successfully!
518
+ query parameters (--query)
519
+ limit number optional 1–100, default 10
520
+ state string required one of: pending, approved
133
521
 
134
- 📚 Setup Guide:
135
- 1. Import CMS components: import { useLabels } from '@spfn/cms'
136
- 2. View labels in Drizzle Studio: pnpm spfn db studio
137
- 3. Learn more: https://github.com/spfnio/spfn
522
+ Invoke: spfn ops call listSignups --query limit=<value>
138
523
  ```
139
524
 
140
- **How it works:**
141
- - SPFN automatically discovers schemas from packages via `package.json`:
142
- ```json
143
- {
144
- "name": "@spfn/cms",
145
- "spfn": {
146
- "schemas": ["./dist/server/entities/*.js"],
147
- "migrations": { "dir": "./migrations" },
148
- "setupMessage": "📚 Next steps: ..."
149
- }
150
- }
151
- ```
152
- - Packages include pre-built migrations in their `migrations/` directory
153
- - Package migrations are applied first, then project migrations
154
- - Works with both published npm packages and local development (workspace packages)
155
-
156
- ### Development & Production
525
+ Add `--json` for the raw JSON Schema. The server still validates every call — `--describe`
526
+ reports what it will accept, and the app's answer decides.
527
+
528
+ #### Capability modules
529
+
530
+ An app can also mount ops commands a package described, with
531
+ [`defineOpsModule`](../core/README.md#can-a-package-ship-ops-commands). Those commands are
532
+ named `<module>.<command>` and carry a summary, an effect and their scopes, so the CLI can
533
+ group them and say what each one does. From **0.3.0-beta.5**:
534
+
157
535
  ```bash
158
- # Development
159
- spfn dev # Start both Next.js (3790) + API server (8790)
160
- spfn dev --server-only # Start API server only (8790)
161
- spfn dev --no-watch # Disable hot reload
162
-
163
- # Production
164
- spfn build # Build Next.js + compile server
165
- spfn start # Start production server
166
- spfn start --server-only # Start API server only (no Next.js)
536
+ spfn ops modules # what is mounted, and from where
537
+ spfn ops modules --json # same, machine-readable
538
+ spfn ops list --module ledger # just that module's commands
539
+ spfn ops call ledger.compact --yes # effect=destructive needs this
167
540
  ```
168
541
 
169
- ### Database Management
542
+ `spfn ops call` refuses a command the app declared `effect: destructive` unless `--yes` is
543
+ given. It refuses the same way when the app announced module metadata this CLI could not
544
+ validate: the effect is then unknown rather than absent, and an unknown effect is not
545
+ treated as a safe one. The command still lists — an operator reading a short list would
546
+ otherwise take it for the app's whole surface — and the warning names what was dropped.
547
+
548
+ Everything in the manifest is the app's own text written to your terminal, so control
549
+ characters in it are replaced before anything is printed.
550
+
551
+ The app URL comes from `--app` or `SPFN_OPS_APP`, and it must be **https** — every one of
552
+ these commands carries a secret, and `token issue` carries an administrator's password.
553
+ `http` is accepted only against `localhost`, `127.0.0.1` and `::1`, where there is no
554
+ network to listen on. A URL with a base path (`https://example.com/api`) is kept whole:
555
+ both the ops calls and the administrator sign-in go through it.
556
+
557
+ The ops token resolves `--token` → `SPFN_OPS_TOKEN` → macOS keychain, and its lifecycle is
558
+ managed with:
559
+
170
560
  ```bash
171
- spfn db generate # Generate database migrations
172
- spfn db push # Push schema to database (no migrations)
173
- spfn db migrate # Run pending migrations
174
- spfn db studio # Open Drizzle Studio (database GUI)
175
- spfn db check # Check database connection
176
- spfn db drop # Drop all tables (⚠️ dangerous!)
561
+ spfn ops token issue --name laptop --scopes 'waitlist:read' --app <url>
562
+ spfn ops token issue --name ci --scopes '*' --no-expiry --to-keychain --app <url>
563
+ spfn ops token list --app <url>
564
+ spfn ops token revoke <id> --app <url>
565
+ spfn ops token store --app <url> # hidden prompt → keychain
566
+ spfn ops token forget --app <url>
177
567
  ```
178
568
 
179
- ### Setup Features
180
- ```bash
181
- spfn setup icons # Setup SVGR for SVG icon management
569
+ `issue`, `list` and `revoke` call the app's own admin-only routes, so the CLI prompts for
570
+ an administrator's email and password first — no database access. Only the token's SHA-256
571
+ hash is ever stored, so the secret exists in the clear once, in the issuance answer;
572
+ `--to-keychain` delivers it without printing it.
573
+
574
+ `--expires-days` takes 1 to 36500 days (about a century), or `--no-expiry` for a token that
575
+ never expires. The upper bound is there because a day count becomes a date by arithmetic,
576
+ and a big enough count produces an invalid date rather than a distant one.
577
+
578
+ SPFN authenticates a request with a JWT the client signs itself, so the CLI generates a key
579
+ pair for the command, signs the one call it needs, and revokes the key before the command
580
+ ends — on the failing path as much as the succeeding one. Nothing is written to disk.
581
+
582
+ These three commands need `@spfn/auth` **0.3.0-beta.2 or later** installed in the app: the
583
+ ops token lives in its schema, and the signing comes from its `@spfn/auth/crypto` entry
584
+ point, which that release added. The CLI does not depend on the package in any form — it
585
+ loads it from the app at run time, and tells a missing package apart from one too old to
586
+ carry the entry point, so the message names the thing to do.
587
+
588
+ Because the package is the app's, it is resolved **from the directory the command runs in**.
589
+ Run `spfn ops token` from the app's root; running it elsewhere reports the package as
590
+ missing, and the message names the directory it looked in.
591
+
592
+ ### `spfn kit`
593
+
594
+ Install, verify and update a **Superfunction Kit** — a licensed product that ships as a
595
+ signed release: an SPFN scaffold, an exact dependency graph, files the Kit manages on the
596
+ customer's behalf, and its own tooling. `spfn kit` is the generic installer for all of
597
+ them. It hard-codes no product: which Kit, which packages and which files are managed all
598
+ come from the signed setup descriptor and release manifest, and every judgement specific to
599
+ a product comes from that product's own `/tooling` entry, which the CLI *discovers* among
600
+ the packages the manifest installs.
601
+
602
+ There is one binary and one command group. No Kit gets its own CLI.
603
+
604
+ | Subcommand | Description |
605
+ |------------|-------------|
606
+ | `kit install <setup-url> <dir>` | Install the newest entitled stable release into a new or empty directory: verify the setup link, activate the license, create the SPFN base, materialize the managed files, install the exact graph, run migrations, run the gates, write the lock and make the first commit |
607
+ | `kit restore` | Reinstall the exact release a clean clone records, using the committed lock and this machine's credential. Rewrites no source file |
608
+ | `kit status` | Read-only report: installed release, activation, credential, managed drift, migrations, open operation. Anything the CLI could not determine is reported as `unknown` |
609
+ | `kit check` | Read-only contract check with stable diagnostic codes, the path each is about, and the command that would fix it |
610
+ | `kit plan [--to <release>]` | What an update would change, with the approval digest. Writes nothing |
611
+ | `kit update [--to <release>] [--approve-plan <digest>]` | Update through the signed update edges, then gates, lock and commit |
612
+ | `kit resume [operation-id]` | Continue an operation that stopped — after re-reading the project and confirming the recorded checkpoints still hold |
613
+ | `kit abandon [operation-id]` | Record that an operation will not be finished, and report what it left behind. Deletes and rolls back nothing |
614
+
615
+ **Secrets never travel as arguments.** There is no `--license-key <value>` option, by
616
+ design: a secret on a command line is in the process table, the shell history and every log
617
+ that records an argv. A key arrives either through a masked prompt or through
618
+ `--license-key-stdin`. Once activation succeeds the key is discarded and only the local
619
+ credential the server issued remains, in the OS keychain under its own service
620
+ (`superfunction.spfn.kit`), separate from the env secrets `spfn secret` manages. The
621
+ short-lived registry session is handed to the package-manager child process in its
622
+ environment and nowhere else, as npm configuration addressed to the registry it opens
623
+ (`npm_config_//host/npm/:_authToken`). The committed `.npmrc` maps the release's scopes to
624
+ that registry and carries no credential at all — not a value, and not a variable naming
625
+ one: pnpm 10 and later ignore a credential that reaches them from a project `.npmrc`, because that
626
+ file is committed and a hostile edit could send the secret to another registry.
627
+
628
+ **`--json` is the agent surface.** Every subcommand takes it, prints newline-delimited
629
+ events with a stable `code`, `phase` and safe next command, and never opens a prompt. A
630
+ JSON-mode command that needs a secret exits `2` and reports `input: masked-stdin`.
631
+
632
+ | Exit | Meaning |
633
+ |-----:|---------|
634
+ | `0` | Completed, or an idempotent no-op |
635
+ | `2` | Waiting for a person: a secret on stdin, or an exact plan approval |
636
+ | `3` | Recoverable failure — `spfn kit resume` can continue it |
637
+ | `4` | Refused before any write: drift, compatibility, entitlement or a busy project |
638
+ | `5` | An external service could not be reached |
639
+ | `10` | This CLI and the release speak different protocol versions |
640
+
641
+ **What an install does not do.** It stops at a verified local repository: no cloud account
642
+ is linked, nothing is pushed, nothing is deployed. That is a checkpoint for the agent to
643
+ continue from, not a finished product install.
644
+
645
+ **Approval is exact.** A breaking release or an external effect requires the digest of the
646
+ very plan being run (`--approve-plan`), which can only be obtained by reading the plan.
647
+ There is no blanket `--yes`.
648
+
649
+ **One operation at a time.** A project holds a filesystem lock while a write operation
650
+ runs. A lock left behind by a dead process is not simply deleted — it is reconciled against
651
+ the operation journal, and reclaimed only when that journal agrees the work is over or the
652
+ caller is resuming exactly the operation it belongs to.
653
+
654
+ Generated state lives under `.spfn/`: `license.json` and `kit-lock.json` are committed and
655
+ hold public identifiers only; `operations/` is per-machine and gitignored.
656
+
657
+ **Exactness is proved, not assumed.** Before the package manager runs, every package the
658
+ signed manifest names is fetched through the licensed registry proxy and checked twice: the
659
+ version's integrity against the digest the manifest pinned, then the bytes that actually
660
+ arrived against that same digest. The first says the registry agrees with the release; only
661
+ the second says the file on this disk is the file the release described. The project's base
662
+ arrives the same way — one scaffold archive, verified against the manifest's integrity
663
+ before a single file is expanded, and refused outright if it names a path that would leave
664
+ the project directory or overwrite a file that is already there.
665
+
666
+ **An artifact is written as what it is.** A managed bridge is a file, and its
667
+ bytes are the file. The scaffold and the Agent Pack are archives, and the CLI expands them —
668
+ the pack into `.spfn/agent-pack/`, because a release's guides, schemas and checklists are a
669
+ directory and belong to the release rather than among customer source. Both are proven
670
+ against the manifest's digest before they are opened, and both refuse an entry that is a
671
+ symlink or that would be written outside the project. What the pack expanded to is recorded
672
+ in `.spfn/agent-pack.json` so drift can compare the tree file by file.
673
+
674
+ **A materialize that stopped can be resumed.** Coming back to a half-written tree compares
675
+ rather than overwrites: a file already holding exactly the bytes the release would write
676
+ counts as done and the resume continues past it, and a file holding anything else is refused
677
+ with that file left exactly as it was found. An update is the one operation that replaces —
678
+ and only after drift has already been refused, so every managed file is known to hold the
679
+ previous release's bytes rather than somebody's edit.
680
+
681
+ **Release files are paid content, and are fetched as such.** Managed files, agent packs and
682
+ the scaffold archive go out with the same bearer the private registry takes, and a refusal
683
+ comes back in the same vocabulary — so "this machine's credential has been replaced" never
684
+ arrives disguised as "that file is missing". The setup descriptor, the release catalog and
685
+ the manifests stay public and carry no bearer: they are locators and promises about a
686
+ release, and nothing anyone paid for is inside them.
687
+
688
+ **Credentials rotate before they expire, not after.** A local credential opens the registry
689
+ for a limited window. When that window is close to closing, the CLI asks the control plane
690
+ for a replacement and writes it to the keychain *before* using it — a rotation that was not
691
+ recorded can never have happened. A credential another machine has already replaced is
692
+ reported as stale rather than as missing: the two mean different things, and only one of
693
+ them means someone else's machine changed.
694
+
695
+ **Nothing secret is ever an argument.** That now includes the keychain write itself: the
696
+ `security` command goes in on stdin and the value goes in hex-encoded, so a local `ps` sees
697
+ `security -i` and nothing else. It also means a keychain item can hold a value a quoted
698
+ command line could not have carried at all, such as a multi-line key.
699
+
700
+ **Machine-local state stays local.** `spfn kit` writes `.spfn/.gitignore` covering
701
+ `operations/` the moment it creates that directory — in its own directory, never in the
702
+ project's root `.gitignore`, which belongs to the customer. Without it a release whose
703
+ scaffold forgot the rule would commit an operation journal and a lock naming the machine's
704
+ hostname and process id.
705
+
706
+ All ten places `spfn kit` touches the outside world are real implementations now. Six reach
707
+ the network or a release artifact — signed catalogs and manifests, licence activation,
708
+ credential rotation, the registry proxy, release artifacts and the scaffold — and four run
709
+ something on this machine: `pnpm install --frozen-lockfile`, the migrations through
710
+ `spfn db status --json` and `spfn db migrate`, the release's gates as the project's own
711
+ scripts, and Git for `init`, `status` and the first commit. Nothing else: no remote is
712
+ added and nothing is pushed.
713
+
714
+ What still stops a real install is the trust root. The list of keys this CLI will accept a
715
+ signed release from is **empty in this build**, because the release signing key is not
716
+ published yet — so every signed document fails verification with `KIT_MANIFEST_INVALID`,
717
+ which is the correct behaviour for a CLI that cannot yet tell a real release from a forged
718
+ one. `SPFN_KIT_TRUSTED_KEYS` supplies a list for a staging run or a release rehearsal, as a
719
+ JSON array of `{ "keyId", "publicKey" }` with base64 SPKI keys. It *replaces* the built-in
720
+ list rather than adding to it.
721
+
722
+ `SPFN_KIT_SETUP_ALLOWLIST` names the origins a setup link may be fetched from, as a
723
+ comma-separated list of bare origins. It follows the same rule as the key list — it
724
+ *replaces* the shipped one, so it can only ever narrow what this CLI will fetch a descriptor
725
+ from — and it is the one place plain `http` is accepted, for `localhost` and `127.0.0.1`
726
+ only, matched literally so a name like `127.0.0.1.example.test` is not one of them. A path,
727
+ a query, a fragment, userinfo, or a non-loopback `http` entry makes the whole variable
728
+ invalid rather than being quietly trimmed. A setup link may carry an explicit port, which is
729
+ what lets a certification environment serve one; the link itself must still be `https`
730
+ unless its origin is loopback *and* on the list.
731
+
732
+ `SPFN_KIT_CONTROL_PLANE_URL` and `SPFN_KIT_REGISTRY_URL` point a project that has *not yet
733
+ been activated* at a staging or local control plane. They are ignored once it has: the
734
+ addresses a checkout recorded when it was licensed are the addresses it keeps, so a stray
735
+ shell variable cannot move an activated project onto another service.
736
+
737
+ `status` and `check` never depend on any of it: an unreachable remote must not hide local
738
+ state, so they read the lock, the license file, the drift and the open operation from disk
739
+ and report everything else as `unknown`.
740
+
741
+ The command surface, journal, lock, keychain, verification and the whole install →
742
+ activation → exact frozen install → restore path are exercised in `test/kit/`: the remote
743
+ half against a loopback HTTP fixture answering with the licence service's and the registry
744
+ proxy's own statuses and error bodies, the scaffold against real archives on real temporary
745
+ directories, and one integration case that runs the whole install with pnpm resolving from
746
+ a registry on 127.0.0.1, the gate as a real child process and a real Git commit at the end.
747
+
748
+ ---
749
+
750
+ ## Scaffold structure
751
+
752
+ Both modes produce the core full-stack skeleton:
753
+
754
+ ```
755
+ src/
756
+ app/api/rpc/[routeName]/route.ts # RPC proxy — re-exports { GET, POST } from @spfn/core/nextjs/server
757
+ generated/route-map.ts # generated by codegen (run `spfn codegen run` if missing)
758
+ lib/
759
+ api-client.ts # createApi<AppRouter>() — the type-safe client
760
+ server/
761
+ router.ts # defineRouter({ ...routes }) → export type AppRouter
762
+ server.config.ts # defineServerConfig().port(8790).host('0.0.0.0').routes(appRouter)
763
+ config/env.config.ts # environment schema
764
+ entities/ # Drizzle tables (example.entity.ts, config.ts)
765
+ repositories/ # BaseRepository subclasses (example.repository.ts)
766
+ routes/ # route DSL handlers: root.ts, health.ts, examples.ts
767
+ tsconfig.json, tsup.config.ts
768
+ .spfnrc.ts # codegen config (route-map generator)
769
+ spfn.config.js # deployment config (subdomain/region/domains) — committed
770
+ docker-compose.yml # Postgres + Redis (dev)
771
+ docker-compose.production.yml
772
+ Dockerfile, .dockerignore
773
+ next.config.ts # patched when auth is enabled: /_auth/:path* rewrite → SPFN API
774
+ .env.local.example # committed reference — Next.js keys, placeholder values
775
+ .env.server.example # committed reference — backend keys, placeholder values
776
+ .env.local # generated, gitignored (values loaded by Next.js)
777
+ .env.server # generated, gitignored (server secrets: DB, cache)
182
778
  ```
183
779
 
184
- ### Utilities
185
- ```bash
186
- spfn key # Generate encryption key for .env
780
+ The reference is split by consumer, never combined, and a key's file follows who
781
+ reads it rather than whether it is secret:
782
+
783
+ - `.env.local` / `.env.local.example` — the Next.js process (server routes, proxy,
784
+ SSR) and, through `NEXT_PUBLIC_*`, the browser: `SPFN_API_URL`, `SPFN_APP_URL`,
785
+ `SPFN_AUTH_SESSION_SECRET`, `SPFN_AUTH_SESSION_TTL`, `SPFN_AUTH_CSRF`.
786
+ - `.env.server` / `.env.server.example` — the SPFN backend only, never loaded by
787
+ Next.js: `NODE_ENV`, `SPFN_LOG_LEVEL`, `DATABASE_URL`, `CACHE_URL`, `DB_POOL_*`,
788
+ and every OAuth, token, and admin secret.
789
+
790
+ So the session secret is secret and still lives in `.env.local` — the Next.js proxy
791
+ verifies cookies with it — while `DATABASE_URL` never appears there at all.
792
+
793
+ Full mode overlays the Prototype-to-Production baseline:
794
+
795
+ ```
796
+ src/
797
+ app/login/page.tsx # provider login starter UI
798
+ app/auth/callback/page.tsx # OAuth session handoff
799
+ i18n/catalogs.ts # application-owned en/ko starter messages
800
+ i18n/server.ts # configured server-side i18n registry
801
+ server/routes/ops.ts # ops routes under /_ops + the manifest `spfn ops` reads
802
+ server/router.ts # authRouter + opsRouter + global authenticate
803
+ server/server.config.ts # createAuthLifecycle + i18n startup
804
+ next.config.ts # /_auth/* callback rewrite
805
+ .env.local # generated auth session secret (gitignored)
806
+ .env.server # auth keyring (gitignored)
807
+ ```
808
+
809
+ The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
810
+ keys are generated with cryptographic randomness in ignored local env files;
811
+ `.env.local.example` and `.env.server.example` contain placeholders only. Add only the provider keys you use, then run
812
+ `pnpm spfn db migrate`.
813
+
814
+ Operating the app is [`spfn ops`](#spfn-ops), not a dashboard: the starter
815
+ `src/server/routes/ops.ts` exposes two read commands, and `spfn ops` discovers them from
816
+ the running server's manifest. Issuing the first token signs in as an administrator, so
817
+ uncomment `SPFN_AUTH_ADMIN_ACCOUNTS` in `.env.server` and restart before
818
+ `spfn ops token issue`. The ops surface adds no dependency — the router comes from
819
+ `@spfn/core/ops` and the tokens from `@spfn/auth`.
820
+
821
+ `init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
822
+ `spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
823
+ `@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
824
+ auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
825
+ existing range still permits older Node versions), excludes `src/server` from the root
826
+ `tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
827
+ `.gitignore`.
828
+
829
+ ### Route DSL (the current architecture)
830
+
831
+ Routes are defined with the `route` builder and collected by `defineRouter`. There is no
832
+ separate "contract" layer — the router's type *is* the contract; the client infers from it.
833
+
834
+ ```ts
835
+ // src/server/routes/examples.ts
836
+ import { route } from '@spfn/core/route';
837
+ import { Type } from '@sinclair/typebox';
838
+
839
+ export const getExample = route.get('/examples/:id')
840
+ .input({ params: Type.Object({ id: Type.String() }) })
841
+ .handler(async (c) =>
842
+ {
843
+ const { params } = await c.data();
844
+ return { id: params.id };
845
+ });
187
846
  ```
188
847
 
189
- ## Configuration
190
-
191
- ### spfn.config.js
192
-
193
- SPFN uses `spfn.config.js` for deployment configuration with full JSDoc type support for IDE autocomplete.
194
-
195
- **Basic Configuration:**
196
- ```javascript
197
- /**
198
- * @type {import('spfn').SpfnConfig}
199
- */
200
- export default {
201
- packageManager: 'pnpm',
202
- deployment: {
203
- // Your app's subdomain on spfn.app
204
- // Creates region-specific domains:
205
- // - myapp.us.spfn.app (Next.js)
206
- // - api-myapp.us.spfn.app (API)
207
- subdomain: 'myapp',
208
-
209
- // Optional: Deployment region (defaults to 'us')
210
- // Available: 'us' (Virginia, default), 'kr' (Seoul), 'jp', 'sg', 'eu' (coming soon)
211
- region: 'us',
212
-
213
- // Optional: Add custom domains
214
- customDomains: {
215
- nextjs: ['www.example.com', 'example.com'],
216
- spfn: ['api.example.com']
217
- },
218
-
219
- // Optional: Environment variables for both Next.js and SPFN backend
220
- // ⚠️ WARNING: These values are committed to Git
221
- // Do NOT put sensitive credentials here!
222
- env: {
223
- NEXT_PUBLIC_API_URL: 'https://api-myapp.us.spfn.app',
224
- NODE_ENV: 'production'
225
- }
226
- }
227
- }
848
+ ```ts
849
+ // src/server/router.ts
850
+ import { defineRouter } from '@spfn/core/route';
851
+ import { getExample } from './routes/examples';
852
+
853
+ export const appRouter = defineRouter({ getExample });
854
+ export type AppRouter = typeof appRouter;
855
+ ```
856
+
857
+ ```ts
858
+ // src/lib/api-client.ts
859
+ import { createApi } from '@spfn/core/nextjs';
860
+ import type { AppRouter } from '@/server/router';
861
+
862
+ export const api = createApi<AppRouter>();
863
+ const example = await api.getExample.call({ params: { id: '123' } });
228
864
  ```
229
865
 
230
- **Features:**
231
- - **JSDoc Type Hints** - IDE autocomplete via `@type {import('spfn').SpfnConfig}`
232
- - **Multi-Region Deployment** - Deploy to Seoul (kr), Virginia (us), and more
233
- - **Dual Domain Setup** - Automatic `{subdomain}.{region}.spfn.app` and `api-{subdomain}.{region}.spfn.app`
234
- - **Custom Domains** - Support for multiple custom domains
235
- - **Environment Variables** - Shared between Next.js and SPFN backend
236
- - **ESM/CJS Support** - Works with both module systems
866
+ Client calls go through the Next.js RPC proxy (`/api/rpc/[routeName]`), which forwards to
867
+ the SPFN API with cookie forwarding and interceptors, resolving routes via the generated
868
+ `route-map.ts`.
237
869
 
238
- **Security Note:**
239
- - `spfn.config.js` is committed to Git
240
- - Only use for non-sensitive configuration
241
- - For secrets (DB passwords, API keys), use CI/CD secrets management
870
+ ---
242
871
 
243
- ## Documentation
872
+ ## Deployment
244
873
 
245
- For complete documentation and guides, see:
246
- - **[SPFN Framework](../../README.md)** - Getting started
247
- - **[@spfn/core](../core/README.md)** - API reference and core concepts
874
+ Two targets, both shipping from the same repository.
248
875
 
249
- ## Requirements
876
+ **Vercel — serverless, one origin, no container.** Run `spfn add vercel` (above) and
877
+ deploy. Frontend and backend share a single Vercel origin. One caveat: the in-process job
878
+ worker does not run there, so enqueuing works but nothing drains the queue — schedule a
879
+ route that processes a batch (Vercel Cron), or run jobs on an always-on target.
250
880
 
251
- - Node.js 18+
252
- - Next.js 15+ (App Router)
253
- - PostgreSQL (optional: Redis)
881
+ **Always-on — a long-lived process.** `spfn build` then `spfn start`, or the generated
882
+ Docker files. Background jobs, WebSocket events and the periodic database health check all
883
+ need this path.
254
884
 
255
- ## Links
885
+ ```bash
886
+ # Build + run locally
887
+ pnpm spfn:build
888
+ pnpm spfn:start # Next.js :3790 + SPFN API :8790
889
+
890
+ # Docker (single image runs both)
891
+ docker compose -f docker-compose.production.yml up --build -d
892
+ ```
893
+
894
+ The Dockerfile (`node:22-alpine`) installs with `pnpm --frozen-lockfile`, runs
895
+ `pnpm run spfn:build`, prunes dev deps, exposes `3790`/`8790`, health-checks
896
+ `http://localhost:8790/_core/health`, and starts via `pnpm run spfn:start`.
897
+
898
+ Run migrations against the target DB before/with deploy:
899
+
900
+ ```bash
901
+ docker exec <container> npx spfn db migrate
902
+ ```
256
903
 
257
- - 🌐 Website: [superfunction.xyz](https://superfunction.xyz)
258
- - 📦 npm: [spfn](https://npmjs.com/package/spfn) (CLI)
259
- - 📦 npm: [@spfn/core](https://npmjs.com/package/@spfn/core) (Core)
260
- - 💬 GitHub: [spfn/spfn](https://github.com/spfn/spfn)
904
+ Forget, and the container will not come up: the server refuses to serve while migrations
905
+ are pending and prints which ones. That is the intended failure — a deploy that stops at
906
+ the gate is one that never served the 500s. If a rollout has to proceed anyway, set
907
+ `SPFN_ALLOW_PENDING_MIGRATIONS=true` in the container's environment; the pending list is
908
+ logged as a warning instead.
909
+
910
+ A readiness probe can catch the same drift on a cluster the local gate never sees. When
911
+ detailed health is on, `GET /_core/health` carries a `migrations` object with per-package
912
+ applied/pending counts — assert `migrations.pending === 0` in the probe to hold a
913
+ drifted pod out of rotation. Reporting drift does not, by itself, change the overall
914
+ health `status`.
915
+
916
+ `spfn.config.js` (committed) configures the managed `*.spfn.app` deployment: `subdomain`,
917
+ `region` (`us` default, `kr`, …), `customDomains`, and non-secret `env`. Its `SpfnConfig`
918
+ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
919
+
920
+ ---
921
+
922
+ ## FAQ
923
+
924
+ **`create` or `init` — which one?**
925
+ `create` starts a new project: it runs `create-next-app` with SPFN's flags and then runs
926
+ `init` inside it. `init` adds SPFN to a Next.js app that already exists. If you built
927
+ something with an AI coding agent and now want a real backend under it, `init` is the one.
928
+
929
+ **`bare` or `full`?**
930
+ `full` is the recommended baseline: core, auth, i18n and the ops surface wired together, so
931
+ you get a working authenticated app on day one, operable from the terminal. `bare` is core only — the architecture with nothing
932
+ else decided. Automation should always pass `--mode` explicitly, because a `--yes` run
933
+ without one still produces `bare` for backward compatibility.
934
+
935
+ **Why doesn't my server restart when I edit a file?**
936
+ Because hot reload is off by default. `spfn dev --watch` restarts the server on `src/server`
937
+ changes. Next.js reloads on its own either way.
938
+
939
+ **Do I have to use Docker?**
940
+ No. Vercel is a first-class target and needs no container. Docker is the always-on path,
941
+ and `docker compose up -d` is also the convenient way to get PostgreSQL and Redis locally —
942
+ pointing at your own PostgreSQL works too. PostgreSQL itself is not optional.
943
+
944
+ **Which Node version do I need?**
945
+ 20 or later, in both modes. `@spfn/core` runs on `@hono/node-server` 2, which declares
946
+ that floor, and full mode's `@spfn/auth` needs the same.
947
+
948
+ **When do I have to run codegen by hand?**
949
+ Whenever routes change outside `spfn dev`, which runs a codegen watcher for you. A stale or
950
+ missing `src/generated/route-map.ts` makes the RPC proxy answer 404 — run `spfn codegen run`
951
+ and commit the result.
952
+
953
+ **Where do secrets go?**
954
+ `.env.server` (gitignored, server-only) for backend values, `.env.local` for the session
955
+ cookie secret that the Next.js runtime itself needs. Never `spfn.config.js` — that file is
956
+ committed.
957
+
958
+ ## Pitfalls
959
+
960
+ - **`.env.server` is gitignored and server-only.** Put backend-only DB/secret values there,
961
+ not in `.env` (committed). Full mode's session-cookie secret is the intentional exception:
962
+ it lives in gitignored `.env.local` because Next.js must encrypt the cookie. There is no
963
+ `.env.server.local`. `spfn init` generates `.env.server`; put DB/secret values there.
964
+ Load order is the standard dotenv chain ending with `.env.server`.
965
+ - **Never commit secrets in `spfn.config.js`.** It's checked into Git; its `env` block is
966
+ for non-sensitive values only. Use CI/CD secret management for credentials.
967
+ - **`spfn dev` does not hot-reload by default.** Add `--watch` to restart on `src/server`
968
+ changes.
969
+ - **`spfn start` needs a prior `spfn build`.** It hard-fails without `.spfn/server`,
970
+ `.spfn/prod-server.mjs`, and `.next`.
971
+ - **Destructive DB commands are guarded.** `db drop` double-confirms (and verifies the
972
+ target); `db push` applies the selected statements in one transaction and withholds
973
+ destructive ones unless `--force`/confirmed. Prefer `db generate` + `db migrate` for
974
+ production; `db push` is dev-only.
975
+ - **`spfn add` requires a scoped package name** (must contain `/`) and only applies
976
+ migrations when `DATABASE_URL` is set — otherwise it skips with a hint. The single
977
+ exception is `spfn add vercel`, a built-in target rather than a package.
978
+ - **Package manager is auto-detected from lockfiles.** If detection is wrong (e.g. mixed
979
+ lockfiles), pass `--pm` to `create`. In a pnpm workspace, `create` installs from the
980
+ workspace root, not the new project dir.
981
+ - **Make scaffold mode explicit in automation.** Interactive runs recommend `full`, while
982
+ historical `--yes` calls without `--mode` remain `bare`. Pass `--mode full` or
983
+ `--mode bare` so scripts state their intended architecture.
984
+ - **Regenerate the route map after route changes outside dev.** If
985
+ `src/generated/route-map.ts` is missing or stale, run `spfn codegen run` — the RPC proxy
986
+ depends on it.
987
+
988
+ ---
989
+
990
+ ## Related
991
+
992
+ - [`@spfn/core`](../core/README.md) — server, route DSL, codegen, db, client runtime.
993
+ - [`@spfn/auth`](../auth/README.md) — what `--mode full` wires in for accounts and roles.
994
+ - [`@spfn/mcp`](../mcp/README.md) — an agent-facing MCP endpoint, added on demand with
995
+ `spfn add @spfn/mcp`.
996
+ - Project root README — framework overview and getting started.