spfn 0.3.0-beta.1 → 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.
- package/README.md +354 -30
- package/dist/index.d.ts +1059 -1
- package/dist/index.js +7629 -445
- package/dist/templates/Dockerfile +6 -3
- package/dist/templates/README.md +30 -8
- package/dist/templates/docker-compose.production.yml +9 -2
- package/dist/templates/modes/full/src/server/router.ts +3 -5
- package/dist/templates/modes/full/src/server/routes/ops.ts +52 -0
- package/dist/templates/modes/full/src/server/routes/root.ts +2 -2
- package/dist/templates/modes/full/src/server/server.config.ts +0 -2
- package/dist/templates/server/entities/README.md +21 -0
- package/dist/templates/server/entities/config.ts +1 -1
- package/dist/templates/server/router.ts +0 -2
- package/dist/templates/server/routes/root.ts +1 -1
- package/dist/templates/server/server.config.ts +0 -2
- package/dist/templates/vercel/npmrc +0 -3
- package/package.json +3 -2
- package/dist/templates/modes/full/src/server/config/env.config.ts +0 -23
- package/dist/templates/modes/full/src/server/mcp.ts +0 -75
- package/dist/templates/modes/full/src/server/routes/health.ts +0 -9
- package/dist/templates/server/routes/health.ts +0 -18
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`spfn` takes a Next.js idea from prototype to production with a consistent full-stack
|
|
4
4
|
architecture. It can scaffold either a core-only backend or a production baseline with
|
|
5
|
-
authentication, internationalization, and
|
|
5
|
+
authentication, internationalization, and a terminal operations surface, then runs the
|
|
6
6
|
dev/build/start lifecycle, database tooling, RPC codegen, and environment validation.
|
|
7
7
|
|
|
8
8
|
Consistent is the point rather than a nicety: what it scaffolds is one fixed shape per
|
|
@@ -31,7 +31,7 @@ is not supported — see [the root README](../../README.md#what-do-i-need-instal
|
|
|
31
31
|
## Usage
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
# Prototype-to-Production baseline: core + auth + i18n +
|
|
34
|
+
# Prototype-to-Production baseline: core + auth + i18n + ops
|
|
35
35
|
npx spfn@beta create my-app --mode full
|
|
36
36
|
cd my-app
|
|
37
37
|
docker compose up -d # Postgres + Redis
|
|
@@ -54,7 +54,8 @@ with `--pm`. In a pnpm workspace, `create` installs from the workspace root.
|
|
|
54
54
|
## Commands
|
|
55
55
|
|
|
56
56
|
Registered top-level commands: `create`, `init`, `add`, `dev`, `build`, `start`,
|
|
57
|
-
`provision`, `codegen`, `contract`, `key`, `setup`, `db`, `env`, `ops`, `secret
|
|
57
|
+
`provision`, `codegen`, `contract`, `key`, `setup`, `db`, `env`, `ops`, `secret`,
|
|
58
|
+
`cloud`, `kit`.
|
|
58
59
|
|
|
59
60
|
### `spfn create <name>`
|
|
60
61
|
|
|
@@ -71,7 +72,7 @@ pass `--mode full`.
|
|
|
71
72
|
|--------|-------------|
|
|
72
73
|
| `--pm <manager>` | Force package manager: `npm` \| `pnpm` \| `yarn` \| `bun` |
|
|
73
74
|
| `--shadcn` | Also run `shadcn init` |
|
|
74
|
-
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n,
|
|
75
|
+
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
|
|
75
76
|
| `--skip-install` | Skip dependency install |
|
|
76
77
|
| `--skip-git` | Skip `git init` |
|
|
77
78
|
| `-y, --yes` | Skip prompts, use defaults |
|
|
@@ -86,7 +87,7 @@ already exists). See [Scaffold structure](#scaffold-structure) for what lands on
|
|
|
86
87
|
|
|
87
88
|
| Option | Description |
|
|
88
89
|
|--------|-------------|
|
|
89
|
-
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n,
|
|
90
|
+
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
|
|
90
91
|
| `-y, --yes` | Skip prompts, use defaults |
|
|
91
92
|
|
|
92
93
|
Generated projects pin `drizzle-orm` and `drizzle-kit` to `1.0.0-rc.4`, matching
|
|
@@ -117,7 +118,14 @@ its backend as Vercel Functions:
|
|
|
117
118
|
|------|------------|
|
|
118
119
|
| `src/app/api/backend/[[...route]]/route.ts` | `hono/vercel` adapter, mounts the SPFN app under `/api/backend` |
|
|
119
120
|
| `vercel.json` | build config (`pnpm spfn:build`) |
|
|
120
|
-
| `.npmrc` | `@spfn`
|
|
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.
|
|
121
129
|
|
|
122
130
|
Existing files are never overwritten; they are reported and skipped. The runtime behind
|
|
123
131
|
the adapter is `createServerlessApp()` from `@spfn/core/server`. Afterwards, point
|
|
@@ -134,13 +142,18 @@ no pre-build needed.
|
|
|
134
142
|
|--------|-------------|---------|
|
|
135
143
|
| `--server-only` | Run only the SPFN/Hono server (also auto-selected if Next.js isn't a dependency) | off |
|
|
136
144
|
| `--watch` | Restart the server on `src/server` changes (chokidar) | off |
|
|
137
|
-
| `-p, --port <port>` |
|
|
138
|
-
| `-H, --host <host>` |
|
|
139
|
-
| `--routes <path>` | Routes directory path | server default |
|
|
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` |
|
|
140
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 |
|
|
141
149
|
|
|
142
150
|
Note: hot reload is **off by default** — pass `--watch` to restart on file changes.
|
|
143
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
|
+
|
|
144
157
|
Pending migrations stop the boot — see [Database](#spfn-db) for what the refusal looks
|
|
145
158
|
like and how to override it.
|
|
146
159
|
|
|
@@ -165,11 +178,19 @@ if `.spfn/server`, `.spfn/prod-server.mjs`, or `.next` are missing.
|
|
|
165
178
|
|--------|-------------|---------|
|
|
166
179
|
| `--server-only` | Run only the SPFN server | off |
|
|
167
180
|
| `--next-only` | Run only Next.js | off |
|
|
168
|
-
| `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `8790` |
|
|
169
|
-
| `-h, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `
|
|
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` |
|
|
170
183
|
| `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
|
|
171
184
|
|
|
172
|
-
|
|
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`.
|
|
173
194
|
|
|
174
195
|
Pending migrations stop the boot unless `--allow-pending-migrations` or
|
|
175
196
|
`SPFN_ALLOW_PENDING_MIGRATIONS=true` is set — see [Database](#spfn-db). `--next-only`
|
|
@@ -255,7 +276,7 @@ loaded `.env` chain.
|
|
|
255
276
|
| `db generate` (`g`) | Generate migrations from schema changes (timestamp-prefixed) |
|
|
256
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 |
|
|
257
278
|
| `db migrate` (`m`) | Run pending migrations. `--with-backup` snapshots first |
|
|
258
|
-
| `db status` | Show which migrations are applied and which are pending, for the project and for each installed function package |
|
|
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 |
|
|
259
280
|
| `db studio` | Open Drizzle Studio. `-p, --port` (auto-finds a free port) |
|
|
260
281
|
| `db check` | Verify the database connection |
|
|
261
282
|
| `db drop` | Drop all tables — **destructive**, double-prompts (see [Pitfalls](#pitfalls)) |
|
|
@@ -268,6 +289,74 @@ loaded `.env` chain.
|
|
|
268
289
|
> `db push` is for development. For production, use `db generate` + `db migrate` to keep
|
|
269
290
|
> migration history.
|
|
270
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
|
+
|
|
271
360
|
**A server refuses to start while migrations are pending.** Bumping `@spfn/auth` and
|
|
272
361
|
skipping `db migrate` used to boot fine, pass the health check, and then fail every
|
|
273
362
|
request that touched a new column as an opaque 500. `spfn dev` and `spfn start` now
|
|
@@ -377,6 +466,31 @@ Schema-driven: a secret declared with `envSecret({ generate: 'base64url32' })` c
|
|
|
377
466
|
minted/rotated automatically (`secret generate`/`rotate`); one without `generate` is an
|
|
378
467
|
external value you paste in (`secret set`).
|
|
379
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
|
+
|
|
380
494
|
### `spfn setup icons`
|
|
381
495
|
|
|
382
496
|
Install and configure SVGR for SVG-as-component imports (Next.js only).
|
|
@@ -411,8 +525,37 @@ listSignups GET /_ops/signups
|
|
|
411
525
|
Add `--json` for the raw JSON Schema. The server still validates every call — `--describe`
|
|
412
526
|
reports what it will accept, and the app's answer decides.
|
|
413
527
|
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
|
|
535
|
+
```bash
|
|
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
|
|
540
|
+
```
|
|
541
|
+
|
|
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:
|
|
416
559
|
|
|
417
560
|
```bash
|
|
418
561
|
spfn ops token issue --name laptop --scopes 'waitlist:read' --app <url>
|
|
@@ -442,6 +585,166 @@ point, which that release added. The CLI does not depend on the package in any f
|
|
|
442
585
|
loads it from the app at run time, and tells a missing package apart from one too old to
|
|
443
586
|
carry the entry point, so the message names the thing to do.
|
|
444
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
|
+
|
|
445
748
|
---
|
|
446
749
|
|
|
447
750
|
## Scaffold structure
|
|
@@ -468,11 +771,25 @@ docker-compose.yml # Postgres + Redis (dev)
|
|
|
468
771
|
docker-compose.production.yml
|
|
469
772
|
Dockerfile, .dockerignore
|
|
470
773
|
next.config.ts # patched when auth is enabled: /_auth/:path* rewrite → SPFN API
|
|
471
|
-
.env.example
|
|
774
|
+
.env.local.example # committed reference — Next.js keys, placeholder values
|
|
775
|
+
.env.server.example # committed reference — backend keys, placeholder values
|
|
472
776
|
.env.local # generated, gitignored (values loaded by Next.js)
|
|
473
777
|
.env.server # generated, gitignored (server secrets: DB, cache)
|
|
474
778
|
```
|
|
475
779
|
|
|
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
|
+
|
|
476
793
|
Full mode overlays the Prototype-to-Production baseline:
|
|
477
794
|
|
|
478
795
|
```
|
|
@@ -481,24 +798,30 @@ src/
|
|
|
481
798
|
app/auth/callback/page.tsx # OAuth session handoff
|
|
482
799
|
i18n/catalogs.ts # application-owned en/ko starter messages
|
|
483
800
|
i18n/server.ts # configured server-side i18n registry
|
|
484
|
-
server/
|
|
485
|
-
server/router.ts # authRouter +
|
|
801
|
+
server/routes/ops.ts # ops routes under /_ops + the manifest `spfn ops` reads
|
|
802
|
+
server/router.ts # authRouter + opsRouter + global authenticate
|
|
486
803
|
server/server.config.ts # createAuthLifecycle + i18n startup
|
|
487
804
|
next.config.ts # /_auth/* callback rewrite
|
|
488
805
|
.env.local # generated auth session secret (gitignored)
|
|
489
|
-
.env.server # auth keyring
|
|
806
|
+
.env.server # auth keyring (gitignored)
|
|
490
807
|
```
|
|
491
808
|
|
|
492
809
|
The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
|
|
493
|
-
|
|
494
|
-
`.env.example`
|
|
495
|
-
`pnpm spfn db migrate`.
|
|
496
|
-
|
|
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`.
|
|
497
820
|
|
|
498
821
|
`init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
|
|
499
822
|
`spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
|
|
500
823
|
`@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
|
|
501
|
-
|
|
824
|
+
auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
|
|
502
825
|
existing range still permits older Node versions), excludes `src/server` from the root
|
|
503
826
|
`tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
|
|
504
827
|
`.gitignore`.
|
|
@@ -570,7 +893,7 @@ docker compose -f docker-compose.production.yml up --build -d
|
|
|
570
893
|
|
|
571
894
|
The Dockerfile (`node:22-alpine`) installs with `pnpm --frozen-lockfile`, runs
|
|
572
895
|
`pnpm run spfn:build`, prunes dev deps, exposes `3790`/`8790`, health-checks
|
|
573
|
-
`http://localhost:8790/health`, and starts via `pnpm run spfn:start`.
|
|
896
|
+
`http://localhost:8790/_core/health`, and starts via `pnpm run spfn:start`.
|
|
574
897
|
|
|
575
898
|
Run migrations against the target DB before/with deploy:
|
|
576
899
|
|
|
@@ -585,7 +908,7 @@ the gate is one that never served the 500s. If a rollout has to proceed anyway,
|
|
|
585
908
|
logged as a warning instead.
|
|
586
909
|
|
|
587
910
|
A readiness probe can catch the same drift on a cluster the local gate never sees. When
|
|
588
|
-
detailed health is on, `GET /health` carries a `migrations` object with per-package
|
|
911
|
+
detailed health is on, `GET /_core/health` carries a `migrations` object with per-package
|
|
589
912
|
applied/pending counts — assert `migrations.pending === 0` in the probe to hold a
|
|
590
913
|
drifted pod out of rotation. Reporting drift does not, by itself, change the overall
|
|
591
914
|
health `status`.
|
|
@@ -604,8 +927,8 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
|
|
|
604
927
|
something with an AI coding agent and now want a real backend under it, `init` is the one.
|
|
605
928
|
|
|
606
929
|
**`bare` or `full`?**
|
|
607
|
-
`full` is the recommended baseline: core, auth, i18n and
|
|
608
|
-
working authenticated app on day one. `bare` is core only — the architecture with nothing
|
|
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
|
|
609
932
|
else decided. Automation should always pass `--mode` explicitly, because a `--yes` run
|
|
610
933
|
without one still produces `bare` for backward compatibility.
|
|
611
934
|
|
|
@@ -620,7 +943,7 @@ pointing at your own PostgreSQL works too. PostgreSQL itself is not optional.
|
|
|
620
943
|
|
|
621
944
|
**Which Node version do I need?**
|
|
622
945
|
20 or later, in both modes. `@spfn/core` runs on `@hono/node-server` 2, which declares
|
|
623
|
-
that floor, and full mode's
|
|
946
|
+
that floor, and full mode's `@spfn/auth` needs the same.
|
|
624
947
|
|
|
625
948
|
**When do I have to run codegen by hand?**
|
|
626
949
|
Whenever routes change outside `spfn dev`, which runs a codegen watcher for you. A stale or
|
|
@@ -668,5 +991,6 @@ committed.
|
|
|
668
991
|
|
|
669
992
|
- [`@spfn/core`](../core/README.md) — server, route DSL, codegen, db, client runtime.
|
|
670
993
|
- [`@spfn/auth`](../auth/README.md) — what `--mode full` wires in for accounts and roles.
|
|
671
|
-
- [`@spfn/mcp`](../mcp/README.md) —
|
|
994
|
+
- [`@spfn/mcp`](../mcp/README.md) — an agent-facing MCP endpoint, added on demand with
|
|
995
|
+
`spfn add @spfn/mcp`.
|
|
672
996
|
- Project root README — framework overview and getting started.
|