spfn 0.3.0-beta.4 → 0.3.0-beta.6

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
@@ -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
 
@@ -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
@@ -262,7 +270,7 @@ loaded `.env` chain.
262
270
  | `db generate` (`g`) | Generate migrations from schema changes (timestamp-prefixed) |
263
271
  | `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 |
264
272
  | `db migrate` (`m`) | Run pending migrations. `--with-backup` snapshots first |
265
- | `db status` | Show which migrations are applied and which are pending, for the project and for each installed function package |
273
+ | `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 |
266
274
  | `db studio` | Open Drizzle Studio. `-p, --port` (auto-finds a free port) |
267
275
  | `db check` | Verify the database connection |
268
276
  | `db drop` | Drop all tables — **destructive**, double-prompts (see [Pitfalls](#pitfalls)) |
@@ -384,6 +392,31 @@ Schema-driven: a secret declared with `envSecret({ generate: 'base64url32' })` c
384
392
  minted/rotated automatically (`secret generate`/`rotate`); one without `generate` is an
385
393
  external value you paste in (`secret set`).
386
394
 
395
+ ### `spfn cloud`
396
+
397
+ Free-tier management for apps deployed to your own **Vercel Hobby + Supabase Free**
398
+ accounts: see the plan limits, watch live usage against them, keep the Supabase
399
+ project from pausing, and sync env vars/API keys. Account tokens and key values live
400
+ in the OS keychain and never appear in command output.
401
+
402
+ | Subcommand | Description |
403
+ |------------|-------------|
404
+ | `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 |
405
+ | `cloud limits` | The free-plan limits (constants verified against the official docs, date shown) — works before `link` |
406
+ | `cloud usage` | Current usage: Vercel billing feed (rolling 30 days), Supabase DB size + last-24h API requests |
407
+ | `cloud status` | Usage measured against the limits on one screen; items at ≥80% get a migration warning |
408
+ | `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 |
409
+ | `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) |
410
+ | `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_*` |
411
+
412
+ Free-tier behavior worth knowing (also printed by `cloud limits`): Vercel Hobby
413
+ allows one cron at most once per day and pauses a capability when its rolling
414
+ 30-day limit is hit (it never bills); Supabase Free quota is summed per
415
+ organization (except DB size), and org totals like egress/MAU have no public API —
416
+ `cloud status` shows per-item numbers and points at the dashboard for the rest.
417
+ Hobby is limited to personal, non-commercial use — a monetized app needs Vercel Pro
418
+ or a migration off the free tier.
419
+
387
420
  ### `spfn setup icons`
388
421
 
389
422
  Install and configure SVGR for SVG-as-component imports (Next.js only).
@@ -418,6 +451,29 @@ listSignups GET /_ops/signups
418
451
  Add `--json` for the raw JSON Schema. The server still validates every call — `--describe`
419
452
  reports what it will accept, and the app's answer decides.
420
453
 
454
+ #### Capability modules
455
+
456
+ An app can also mount ops commands a package described, with
457
+ [`defineOpsModule`](../core/README.md#can-a-package-ship-ops-commands). Those commands are
458
+ named `<module>.<command>` and carry a summary, an effect and their scopes, so the CLI can
459
+ group them and say what each one does. From **0.3.0-beta.5**:
460
+
461
+ ```bash
462
+ spfn ops modules # what is mounted, and from where
463
+ spfn ops modules --json # same, machine-readable
464
+ spfn ops list --module ledger # just that module's commands
465
+ spfn ops call ledger.compact --yes # effect=destructive needs this
466
+ ```
467
+
468
+ `spfn ops call` refuses a command the app declared `effect: destructive` unless `--yes` is
469
+ given. It refuses the same way when the app announced module metadata this CLI could not
470
+ validate: the effect is then unknown rather than absent, and an unknown effect is not
471
+ treated as a safe one. The command still lists — an operator reading a short list would
472
+ otherwise take it for the app's whole surface — and the warning names what was dropped.
473
+
474
+ Everything in the manifest is the app's own text written to your terminal, so control
475
+ characters in it are replaced before anything is printed.
476
+
421
477
  The app URL comes from `--app` or `SPFN_OPS_APP`, and it must be **https** — every one of
422
478
  these commands carries a secret, and `token issue` carries an administrator's password.
423
479
  `http` is accepted only against `localhost`, `127.0.0.1` and `::1`, where there is no
@@ -459,6 +515,162 @@ Because the package is the app's, it is resolved **from the directory the comman
459
515
  Run `spfn ops token` from the app's root; running it elsewhere reports the package as
460
516
  missing, and the message names the directory it looked in.
461
517
 
518
+ ### `spfn kit`
519
+
520
+ Install, verify and update a **Superfunction Kit** — a licensed product that ships as a
521
+ signed release: an SPFN scaffold, an exact dependency graph, files the Kit manages on the
522
+ customer's behalf, and its own tooling. `spfn kit` is the generic installer for all of
523
+ them. It hard-codes no product: which Kit, which packages and which files are managed all
524
+ come from the signed setup descriptor and release manifest, and every judgement specific to
525
+ a product comes from that product's own `/tooling` entry, which the CLI *discovers* among
526
+ the packages the manifest installs.
527
+
528
+ There is one binary and one command group. No Kit gets its own CLI.
529
+
530
+ | Subcommand | Description |
531
+ |------------|-------------|
532
+ | `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 |
533
+ | `kit restore` | Reinstall the exact release a clean clone records, using the committed lock and this machine's credential. Rewrites no source file |
534
+ | `kit status` | Read-only report: installed release, activation, credential, managed drift, migrations, open operation. Anything the CLI could not determine is reported as `unknown` |
535
+ | `kit check` | Read-only contract check with stable diagnostic codes, the path each is about, and the command that would fix it |
536
+ | `kit plan [--to <release>]` | What an update would change, with the approval digest. Writes nothing |
537
+ | `kit update [--to <release>] [--approve-plan <digest>]` | Update through the signed update edges, then gates, lock and commit |
538
+ | `kit resume [operation-id]` | Continue an operation that stopped — after re-reading the project and confirming the recorded checkpoints still hold |
539
+ | `kit abandon [operation-id]` | Record that an operation will not be finished, and report what it left behind. Deletes and rolls back nothing |
540
+
541
+ **Secrets never travel as arguments.** There is no `--license-key <value>` option, by
542
+ design: a secret on a command line is in the process table, the shell history and every log
543
+ that records an argv. A key arrives either through a masked prompt or through
544
+ `--license-key-stdin`. Once activation succeeds the key is discarded and only the local
545
+ credential the server issued remains, in the OS keychain under its own service
546
+ (`superfunction.spfn.kit`), separate from the env secrets `spfn secret` manages. The
547
+ short-lived registry session is handed to the package-manager child process in its
548
+ environment and nowhere else, as npm configuration addressed to the registry it opens
549
+ (`npm_config_//host/npm/:_authToken`). The committed `.npmrc` maps the release's scopes to
550
+ that registry and carries no credential at all — not a value, and not a variable naming
551
+ one: pnpm 10 and later ignore a credential that reaches them from a project `.npmrc`, because that
552
+ file is committed and a hostile edit could send the secret to another registry.
553
+
554
+ **`--json` is the agent surface.** Every subcommand takes it, prints newline-delimited
555
+ events with a stable `code`, `phase` and safe next command, and never opens a prompt. A
556
+ JSON-mode command that needs a secret exits `2` and reports `input: masked-stdin`.
557
+
558
+ | Exit | Meaning |
559
+ |-----:|---------|
560
+ | `0` | Completed, or an idempotent no-op |
561
+ | `2` | Waiting for a person: a secret on stdin, or an exact plan approval |
562
+ | `3` | Recoverable failure — `spfn kit resume` can continue it |
563
+ | `4` | Refused before any write: drift, compatibility, entitlement or a busy project |
564
+ | `5` | An external service could not be reached |
565
+ | `10` | This CLI and the release speak different protocol versions |
566
+
567
+ **What an install does not do.** It stops at a verified local repository: no cloud account
568
+ is linked, nothing is pushed, nothing is deployed. That is a checkpoint for the agent to
569
+ continue from, not a finished product install.
570
+
571
+ **Approval is exact.** A breaking release or an external effect requires the digest of the
572
+ very plan being run (`--approve-plan`), which can only be obtained by reading the plan.
573
+ There is no blanket `--yes`.
574
+
575
+ **One operation at a time.** A project holds a filesystem lock while a write operation
576
+ runs. A lock left behind by a dead process is not simply deleted — it is reconciled against
577
+ the operation journal, and reclaimed only when that journal agrees the work is over or the
578
+ caller is resuming exactly the operation it belongs to.
579
+
580
+ Generated state lives under `.spfn/`: `license.json` and `kit-lock.json` are committed and
581
+ hold public identifiers only; `operations/` is per-machine and gitignored.
582
+
583
+ **Exactness is proved, not assumed.** Before the package manager runs, every package the
584
+ signed manifest names is fetched through the licensed registry proxy and checked twice: the
585
+ version's integrity against the digest the manifest pinned, then the bytes that actually
586
+ arrived against that same digest. The first says the registry agrees with the release; only
587
+ the second says the file on this disk is the file the release described. The project's base
588
+ arrives the same way — one scaffold archive, verified against the manifest's integrity
589
+ before a single file is expanded, and refused outright if it names a path that would leave
590
+ the project directory or overwrite a file that is already there.
591
+
592
+ **An artifact is written as what it is.** A managed bridge is a file, and its
593
+ bytes are the file. The scaffold and the Agent Pack are archives, and the CLI expands them —
594
+ the pack into `.spfn/agent-pack/`, because a release's guides, schemas and checklists are a
595
+ directory and belong to the release rather than among customer source. Both are proven
596
+ against the manifest's digest before they are opened, and both refuse an entry that is a
597
+ symlink or that would be written outside the project. What the pack expanded to is recorded
598
+ in `.spfn/agent-pack.json` so drift can compare the tree file by file.
599
+
600
+ **A materialize that stopped can be resumed.** Coming back to a half-written tree compares
601
+ rather than overwrites: a file already holding exactly the bytes the release would write
602
+ counts as done and the resume continues past it, and a file holding anything else is refused
603
+ with that file left exactly as it was found. An update is the one operation that replaces —
604
+ and only after drift has already been refused, so every managed file is known to hold the
605
+ previous release's bytes rather than somebody's edit.
606
+
607
+ **Release files are paid content, and are fetched as such.** Managed files, agent packs and
608
+ the scaffold archive go out with the same bearer the private registry takes, and a refusal
609
+ comes back in the same vocabulary — so "this machine's credential has been replaced" never
610
+ arrives disguised as "that file is missing". The setup descriptor, the release catalog and
611
+ the manifests stay public and carry no bearer: they are locators and promises about a
612
+ release, and nothing anyone paid for is inside them.
613
+
614
+ **Credentials rotate before they expire, not after.** A local credential opens the registry
615
+ for a limited window. When that window is close to closing, the CLI asks the control plane
616
+ for a replacement and writes it to the keychain *before* using it — a rotation that was not
617
+ recorded can never have happened. A credential another machine has already replaced is
618
+ reported as stale rather than as missing: the two mean different things, and only one of
619
+ them means someone else's machine changed.
620
+
621
+ **Nothing secret is ever an argument.** That now includes the keychain write itself: the
622
+ `security` command goes in on stdin and the value goes in hex-encoded, so a local `ps` sees
623
+ `security -i` and nothing else. It also means a keychain item can hold a value a quoted
624
+ command line could not have carried at all, such as a multi-line key.
625
+
626
+ **Machine-local state stays local.** `spfn kit` writes `.spfn/.gitignore` covering
627
+ `operations/` the moment it creates that directory — in its own directory, never in the
628
+ project's root `.gitignore`, which belongs to the customer. Without it a release whose
629
+ scaffold forgot the rule would commit an operation journal and a lock naming the machine's
630
+ hostname and process id.
631
+
632
+ All ten places `spfn kit` touches the outside world are real implementations now. Six reach
633
+ the network or a release artifact — signed catalogs and manifests, licence activation,
634
+ credential rotation, the registry proxy, release artifacts and the scaffold — and four run
635
+ something on this machine: `pnpm install --frozen-lockfile`, the migrations through
636
+ `spfn db status --json` and `spfn db migrate`, the release's gates as the project's own
637
+ scripts, and Git for `init`, `status` and the first commit. Nothing else: no remote is
638
+ added and nothing is pushed.
639
+
640
+ What still stops a real install is the trust root. The list of keys this CLI will accept a
641
+ signed release from is **empty in this build**, because the release signing key is not
642
+ published yet — so every signed document fails verification with `KIT_MANIFEST_INVALID`,
643
+ which is the correct behaviour for a CLI that cannot yet tell a real release from a forged
644
+ one. `SPFN_KIT_TRUSTED_KEYS` supplies a list for a staging run or a release rehearsal, as a
645
+ JSON array of `{ "keyId", "publicKey" }` with base64 SPKI keys. It *replaces* the built-in
646
+ list rather than adding to it.
647
+
648
+ `SPFN_KIT_SETUP_ALLOWLIST` names the origins a setup link may be fetched from, as a
649
+ comma-separated list of bare origins. It follows the same rule as the key list — it
650
+ *replaces* the shipped one, so it can only ever narrow what this CLI will fetch a descriptor
651
+ from — and it is the one place plain `http` is accepted, for `localhost` and `127.0.0.1`
652
+ only, matched literally so a name like `127.0.0.1.example.test` is not one of them. A path,
653
+ a query, a fragment, userinfo, or a non-loopback `http` entry makes the whole variable
654
+ invalid rather than being quietly trimmed. A setup link may carry an explicit port, which is
655
+ what lets a certification environment serve one; the link itself must still be `https`
656
+ unless its origin is loopback *and* on the list.
657
+
658
+ `SPFN_KIT_CONTROL_PLANE_URL` and `SPFN_KIT_REGISTRY_URL` point a project that has *not yet
659
+ been activated* at a staging or local control plane. They are ignored once it has: the
660
+ addresses a checkout recorded when it was licensed are the addresses it keeps, so a stray
661
+ shell variable cannot move an activated project onto another service.
662
+
663
+ `status` and `check` never depend on any of it: an unreachable remote must not hide local
664
+ state, so they read the lock, the license file, the drift and the open operation from disk
665
+ and report everything else as `unknown`.
666
+
667
+ The command surface, journal, lock, keychain, verification and the whole install →
668
+ activation → exact frozen install → restore path are exercised in `test/kit/`: the remote
669
+ half against a loopback HTTP fixture answering with the licence service's and the registry
670
+ proxy's own statuses and error bodies, the scaffold against real archives on real temporary
671
+ directories, and one integration case that runs the whole install with pnpm resolving from
672
+ a registry on 127.0.0.1, the gate as a real child process and a real Git commit at the end.
673
+
462
674
  ---
463
675
 
464
676
  ## Scaffold structure