The CLI

@fougere/cli creates a workspace, hosts a Frond in a separate process, and invokes an operation from a shell. The CLI itself uses entities and handlers, so its options are validated from their schemas.

Alpha. On npm under the alpha tag — npx fougere@alpha runs it without installing, npm create fougere enters it at new. The commands and flags below are what ships.

fougere new — compose a workspace

fougere new shop --frond blog --app nuxt

With no --frond, --app or --bare, it asks in three steps — the project, its fronds, its apps — and draws beside each one the tree it will write and the command above that writes it again with no prompt. + adds a second piece of the same template, r renames one, ← goes back. A name that already exists is offered for replacement, and the old project goes only once the new one is whole; --force replaces without asking. Nothing touches the disk before the last ⏎, and a write that fails halfway leaves nothing either. Without a terminal it asks nothing and refuses, naming the flags.

The composer offers nuxt, next, react, svelte and admin — the hosts door:check runs end to end from a fresh workspace; --app also takes oclif.

An app's shell is its host's own. fougere new runs the host's official tool — create-nuxt, create-next-app, create-vite, sv create — with a major pinned, so the versions, the tsconfig and the config file are what that tool writes today, not what someone copied once. Fougere then adds its part, which each host declares in its package (template/scaffold.json):

HostIts toolWhat Fougere adds
nuxtcreate-nuxt@3@fougere/nuxt in modules
nextcreate-next-app@16withFougere(nextConfig), the call and session routes, @fronds/facade in paths
react, admincreate-vite@9 --template react-tsfougere() first in plugins, @fronds/facade in paths
sveltesv@0.17 create --template minimalfougere() first in plugins
oclif—the whole starter, copied: no tool writes a terminal app's shell

Every app also gets Fougere's dependencies and its first page, and loses what the page replaces. With the blog frond, each app also gets /posts: one page that lists the posts, creates a draft with useFormFor(Post) and publishes it with useCommand(post, 'publish') — the three primitives, in that host's own syntax. It lives in the CLI, never in the frond: a frond names no host. None of them names the workspace: a host finds its fronds by walking up from the app to the nearest directory that holds fronds/, so root is only written when they sit elsewhere. The shell is the one step that needs the network.

fougere new shop --frond blog --app "nuxt:web,oclif:ops"

The command creates Fronds first, followed by the apps that consume them. It produces a workspace whose members are the apps:

shop/
  fougere.config.ts          ← sources, auth, topology
  package.json               ← "workspaces": ["apps/*"], and the Fronds' dependencies
  tests/boots.test.ts        ← `npm test`: the whole project boots, as `fronds:` places it
  fronds/blog/               ← the domain
    entities/Post.ts
    handlers/PostHandler.ts
  apps/nuxt/                 ← the consumer — create-nuxt's shell
    nuxt.config.ts             ← + @fougere/nuxt
    app/pages/index.vue        ← Fougere's first page
FlagWhat it does
--frond blog,api:catalogFronds to add — template:name renames; the template name is the default name
--app nuxt:websame, for apps
--barethe empty shell, nothing composed
--locallink @fougere/* to a local Fougere checkout (development)
--forceoverwrite an existing directory
--package-manager <npm|pnpm|bun|yarn>Write the project for this one, whoever ran the command

Which package manager

The project is written for one package manager — its scripts, its workspace file, its build permissions, its packageManager field. Nothing asks which, unless nothing can tell:

  1. --package-manager, when the command states it;
  2. the one that launched the command — npm create, pnpx, bunx, yarn dlx;
  3. the project the new one is created inside — its lockfile or its packageManager;
  4. asked, when a terminal is there to answer; npm otherwise, said in the output.

npm, pnpm, bun and yarn 4 are written for. yarn 1 has neither dlx nor workspaces foreach, so a project it starts is written for npm.

A Frond is not a member of the workspace: @fronds/<name> resolves by the paths of its tsconfig.json and by each host, never through node_modules, so a Frond created a second ago answers without an install.

Arguments or interactive mode. With --frond and --app, the command asks no questions and can run in a script or CI job. Without these options, it prompts for templates in a TTY. Interactive mode is not available without a TTY.

The generated Frond contains an entity, the five operations Crud gives, a business operation, and a repository holding the one query worth a name:

// repositories/PostRepository.ts
export class PostCard extends Post.pick('id', 'title', 'status') {}

export default class PostRepository extends Repository(Post) {
  published(): Promise<PostCard[]> {
    return this.output(PostCard).findAllBy({ status: 'published' });
  }
}

// handlers/PostHandler.ts
export default class PostHandler extends Crud(Post) {
  constructor(private posts: PostRepository) {
    super(posts);
  }

  /** The draft→published transition. */
  async publish(id: Post['id']): Promise<Post> { … }

  /** Only published posts, projected to the card. */
  async listPublished(): Promise<PostCard[]> {
    return this.posts.published();
  }
}

create is not redefined: status is readOnly, so the entity already refuses it on the way in. output(PostCard) reads only the card's fields, so the body never leaves. The handler never touches a storage: it reaches rows through the operations it inherits (super.findById) and through the repository it asked for.

fougere dev, build, serve — one Frond, its own process

fougere dev blog --port 4100     # from the sources, booted again at every change
fougere build                    # the project as JavaScript, in .fougere/build/
fougere serve blog --port 4100   # what the build wrote, with Node alone

Each starts that Frond alone and exposes it over JSON-RPC on POST /_fougere/call. The consuming app sets its address in fronds: to route calls to that host. See the gradient.

dev transpiles the sources as they load, so a Frond created a second ago answers. serve loads no TypeScript and no loader: fougere build compiled what the config and the statement reach, and @fronds/<name> resolves through the table it wrote beside them. Without a build, serve refuses and says to run one. It needs Node 22.15 or later, or Bun.

It does not follow fronds: itself: a host is the Frond, it does not route back out.

serve() binds 127.0.0.1 unless hosts says otherwise: a receiver reads identity off the wire, so the default keeps it on the machine and widening it is a decision written down.

fougere call — invoke an operation

fougere call post.list
fougere call post.create --title "Hello" --body "…"

The command uses the same envelope, validation, and typed errors as other clients. A VALIDATION_FAILED error therefore keeps the format received by a page.

fougere sync — consume a remote Frond

fougere sync blog --from http://blog-service:4100

Asks the host rpc.discover, and writes local entity files rebuilt from the cards it answers with:

// .fougere/remotes/blog/entities/Post.ts — generated
import { Card } from '@fougere/schema';

export class Post extends Card.fromDescriptor<{
  id: string;
  title: string;
  createdAt: Date;
}>({ /* the card, verbatim */ }).toSchema() {}

One class, like the one you would have written by hand: Post is the value — a validator that validates locally, without an additional request to the host — and the type of a row it hands back, so post.titel does not compile. Both are read off the same card.

Next to it, handlers/PostHandler.ts states what the frond serves, so a consumer can write Facade<PostHandler> without holding the handler's code:

export interface PostHandler {
  list(invocation?: Invocation): Promise<Post[] & { total?: number; hasMore?: boolean }>;
  findById(invocation?: Invocation): Promise<Post | undefined>;
}

The command also writes .fougere/remotes.json, which the Nuxt module reads to alias @fronds/blog. Pages therefore use the same import path for a local or remote entity.

Re-running it removes what the host no longer serves. The barrel loses the export by itself, but the file used to stay — and exports lists './entities/*' as a wildcard, so @fronds/blog/entities/Ticket.js kept resolving to a class that validates perfectly and that nothing behind it answers for. Only files carrying the generated header are removed; anything you put in that folder yourself is left alone.

The host returns entities associated with a façade, and the facts its fronds announce. An entity that is neither is not in the discovery result — a shape nobody exposed and nobody declared to leave stays home.

A fact has no operation, so it gets a class and no Handler interface beside it:

// .fougere/remotes/blog/entities/PostPublished.ts — generated
export class PostPublished extends Card.fromDescriptor<{ id: string; title: string; at: Date }>(…).toSchema() {}

To have an arriving fact validated, re-export that class into one of your own fronds — the boot validates against an entity of a scanned frond, and .fougere/remotes/ is a package, not a frond:

// fronds/search/entities/PostPublished.ts
export { default } from '../../../.fougere/remotes/blog/entities/PostPublished.js';

The rules

codewhat it means
directory-unreadable, handler-parse-failed, heritage-unresolvedwhat the scan could not do — a rule about an absence is only sound if the analysis attests it looked
operation-unboundan operation declares parameters and has no binding plan: it is served, and it receives none of them
cross-frond-importa relative import that resolves into another frond

The last one is a warning, not a refusal — it resolves today and the app runs. What it states is that a colocation constraint is holding the app together and nothing declares it: '../../user/entities/User.js' says these two folders are neighbours, in a string the scan, the identity card and fronds: all ignore. It keeps working right up to the day that folder is not there, and then it fails as a file path rather than as a model.

'@fronds/user/entities/User.js' says the same dependency in terms the model reads, and it is the form fougere sync writes — so it survives the frond moving out. The name resolves the same way whether the frond is local or synced.

fougere build — write down what a scan found

fougere build                       # writes .fougere/fronds.generated.ts
fougere build --out src/fronds.ts

The scan reads a filesystem and a TypeScript program. This writes its answer down as the statement you would write by hand — imports and frond() calls — so a deployment reads neither: hand its default export to createApp as fronds:, and its vocabulary beside it, and nothing touches a disk after that.

It is what makes the edge reachable at all: a Worker has no filesystem and cannot ship a compiler. It is also why the scan is optional — frond() declares the same thing by hand, and createApp({ fronds }) boots with neither disk nor tsc.

A generated file is not a second source. Nothing reads it but the boot, and regenerating it is the only way to change it.

fougere check — what the scan could not do

fougere check                 # from a project root; --root points elsewhere

It scans without booting, so it reports on a project it cannot start. Seven rules today, each a stable name shared with the scan's own diagnostics:

findingmeans
directory-unreadable, handler-parse-failed, heritage-unresolvedwhat the scan could not do — an unreadable directory, a handler that would not parse, an extends it could not follow
operation-unboundan operation whose parameters have no binding plan: it is served, and it receives nothing
input-contract-ambiguoustwo parameters could be the input contract, so provenance cannot pick one — refused rather than settled by parameter order
cross-frond-importa relative import that resolves into another Frond — a colocation constraint nothing declares, which holds until the day that Frond is not on this disk
frond-holds-several-domainspast six entities, the same threshold fougere graph uses
unknown-adapteran entity addressing an adapter no dependency of the project answers to — a typo, or a dependency never added
super-linear-patterna pattern whose nested repetition backtracks super-linearly: the regex is fixed at declaration, the string tested against it is the caller's to choose

Findings are blocking or warning, and only a blocking one exits non-zero — which is what makes it usable in CI. An unresolvable base class with no operation is ordinary, and a check that cries wolf stops being read.

fougere explain — the contract one operation is served under

fougere explain Post.publish          # from inside a project
fougere explain Post.publish --root ../blog
fougere explain --names operations    # one name per line, what completion reads
fougere explain --json

Prints the resolved contract of one operation — the handler and method that run, where each parameter comes from, which collectors fill it, the surfaces and adapters that serve it, and the two facts about where it sits:

Placement: commande / local
Reach:     stock — 1 hop(s)

placement says where it ANSWERS, reach where its work GOES. Comment a line out of fronds: and the same operation reports 0 hop(s) — nothing in the handler changed.

With no operation named, it lists what the project serves at all. --json emits a stable key order, which is the contract an agent reads.

fougere graph — read the model

fougere graph

Two altitudes of one picture. First where the Fronds run and which reaches which, read from fronds: and from what a handler asks for in its constructor — so it answers with nothing running and nothing booted:

  Fronds
    commande  local   → stock
    stock     remote  http://127.0.0.1:4100

Then the entities, their references, and the number of incoming ones. Beyond six entities, the command also suggests clusters calculated from that graph:

  Entity Graph
    Post → Author, Category (2 incoming)
    Author

A crossing whose far side was never scanned is left out rather than guessed: naming the Frond behind an address means reading its card, and a card is a discovery. An address is printed as host and port, never whole.

The shortest path page covers current and possible future uses of this graph.

fougere points — what plugs in where

fougere points
fougere points --json

Every place a piece plugs into the app, and what answers it. First what answers by name — the source types, the protocols, the generators, the formats, the extensions in the order they rise:

  By name
    source type  ·  sqlite, pg, memory
    protocol  ·  rest, graphql
    extension  ·  schema → seeds → presenters → log

Then, frond by frond, what answers by type — the class behind each port, what stands in front of a storage, the middlewares outside in:

  Frond billing
    Payment  ·  StripePayment
    middleware  ·  Attempted

It boots the app as its host would, with the schema check and the seeds left out, so it reads what actually booted and touches no row.

fougere load — the scenario the app already describes

fougere load                          # writes <root>/load.js
fougere load --facade http://127.0.0.1:4200/_fougere/call
fougere load --out ""                 # print it instead

Writes a k6 scenario covering every operation the default facade answers. What it derives is everything the code already states: the operations, a body for each from the entity's own fields, and the JSON-RPC envelope from the one function that frames a call.

What it does not derive, it marks in the file where it sits — the weights, the stages and the thresholds are facts about your users, not about your code:

export const options = {
  // Yours: a flat rate draws flat lines and there is nothing to read in them.
  stages: [ … ],
  // Yours: what counts as too slow is a fact about your users.
  thresholds: { … },
};

It reads the scan, never a boot: booting plants seeds, and a command that describes a project has no business writing to its database. A named surface is left out — the load of an app is what its default facade answers, and counting a surface would count one operation twice.

Regenerate rather than patch. A scenario written by hand drifts from the code, and then you are comparing two scripts instead of two deployments — which is the whole point when the same code runs as one process or as several.

fougere devtools — what the running apps are dispatching

fougere devtools                      # every address this project declares
fougere devtools --url http://127.0.0.1:4100
fougere devtools --json               # one page, then stop

Reads the call log an app keeps when it mounted @fougere/calls, over /_fougere/call like any other consumer. The reader pulls: no app registers anywhere, and an app started alone depends on nothing. The price is knowing the addresses, and the project already states them — fronds: says where a call goes, so it also says where the other half of a call can be watched.

The LOCAL one is the exception: a port belongs to the host and no project states one, so http://127.0.0.1:3000 is a convention — where Nuxt, Next and the site all sit. A host that serves elsewhere says so the way it names a log level:

FOUGERE_URL=http://127.0.0.1:4100 fougere devtools

--url still wins over both, and narrows the reading to that one address.

An address that did not answer is reported as a refusal rather than as silence, and the refusals are not one fact but four: not started, answering but serving no call log (Unknown rpc operation 'calls'. It serves discover.), quiet — and a stranger, answered HTTP 404 — not a Fougere receiver?, which is what something else on that port says for itself.

fougere freeze — record today's shapes under a name

fougere freeze v2
fougere freeze v2 --json

Writes two artefacts, and both are needed. A snapshot alone loses the intent — a field gone plus a field appeared cannot be told from a rename, and the two produce opposite DDL. A step alone corrupts in silence, with nothing to check it against. Replaying the step over the previous snapshot must reproduce this one, which is what catches a missing step or a hand-edited file.

Nothing reaches the disk while a question stands. When a removal and an addition could be one rename, the command asks — that intent is the only thing the code does not hold, and it left with the person who made the change.

A version is what a shared database receives: freeze in the change that moves an entity, and fougere migrate applies it everywhere else.

fougere migrate — bring the database up to what was frozen

fougere migrate                   # print the plan, move nothing
fougere migrate --apply           # run it
fougere migrate --latest --apply  # a local database: follow the entities as they are

By default it applies what fougere freeze recorded, and nothing else: a frond never frozen, or whose entities moved since its last version, is refused by name before any database is opened. --latest lifts that and follows the entities as they are — for your own database while you iterate. Refused, the command exits 1, so a release step stops there.

The one command that writes a schema — a boot only reads it, and refuses a database behind the entities. It boots the app the way its host would, seeds left out, so a frond an extension brings (auth's sessions, a workflow's journal) is migrated with the rest. Then, in this order: what touches live data — a rename declared by previous: or a frozen step, a drop — and the additive half, the missing tables and columns. Without --apply it writes nothing — the plan is the answer, and reading it is the point.

A table that keeps a column no field declares while it gains one is reported before anything runs: that is what an undeclared rename looks like. See How a schema moves.

A fresh table and a migrated one promise the same thing: a unique() a live table never carried is proposed as an index, and the statement fails on rows that already break it. That failure is the answer, not an accident.

fougere keys — the root a split deployment's grants are signed by

fougere keys              # once, at deployment time

Creates the authority and exits. Nothing stays alive, nothing is joined at boot, and no Frond reaches anything to prove who it is. A receiver ends up holding one public key, so a Frond granted tomorrow is admitted by a receiver deployed today without its config being touched.

The private key is written and never printed: it signs grants, and a grant is the only thing that has to travel.

fougere grant — vouch for one Frond

fougere grant billing

Binds a Frond's name to a fresh key, signed by the root. That is what lets a receiver establish its caller rather than believe a claim — serve() refuses to start beyond loopback with no way to verify, and this is what it verifies against.

fougere completion — shell completion

fougere completion zsh >> ~/.zshrc

zsh and bash; the shell is auto-detected when the argument is absent. The names it completes come out of the same entities the commands ride on, so a new command needs no second list.

fougere build-frond — publish a Frond's entities

fougere build-frond blog

Compiles fronds/blog/entities/** to dist/ with type declarations and configures the Frond's package.json. A consumer in another repository can then install that package. See where the code lives.

Next: Philosophy.

Built with Fougere and Nuxt, hosted on GitHub Pages.