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 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 an agent-facing MCP endpoint, then runs the
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 + MCP
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, MCP) |
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, MCP) |
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` registry auth, reading `GITEA_NPM_TOKEN` from the environment — never committed |
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>` | Server port | from `server.config.ts` / env (`4000` in server-only fallback) |
138
- | `-H, --host <host>` | Server host | `localhost` |
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`) | `0.0.0.0` |
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
- Next.js is started on `0.0.0.0:3790`. Both run together via `concurrently --kill-others`.
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
- The app URL comes from `--app` or `SPFN_OPS_APP`. The ops token resolves `--token` →
415
- `SPFN_OPS_TOKEN` → macOS keychain, and its lifecycle is managed with:
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 # committed reference — every key, placeholder values
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/mcp.ts # authenticated /mcp endpoint + starter app_status tool
485
- server/router.ts # authRouter + mcpRouter + global authenticate
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 + MCP operator key (gitignored)
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
- and MCP keys are generated with cryptographic randomness in ignored local env files;
494
- `.env.example` contains placeholders only. Add only the provider keys you use, then run
495
- `pnpm spfn db migrate`. The starter MCP endpoint accepts `SPFN_MCP_API_KEY` as a Bearer
496
- token for first-party operation; replace that validator with OAuth before third-party access.
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
- `@spfn/mcp`, auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
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 MCP wired together, so you get a
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 MCP server needs the same.
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) — what `--mode full` wires in for operating the app.
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.