La CLI

@fougere/cli crée un workspace, héberge une Frond dans un processus séparé et appelle une opération depuis un shell. La CLI utilise elle-même des entités et des handlers ; ses options sont donc validées à partir de leurs schémas.

Alpha. Sur npm sous le tag alphanpx fougere@alpha la lance sans installer, npm create fougere l'entre directement sur new. Les commandes et flags ci-dessous sont ce qui existe.

fougere new — composer un workspace

fougere new shop --frond blog --app nuxt

La commande crée d'abord les Fronds, puis les apps qui les consomment. Elle produit un workspace pnpm :

shop/
  fougere.config.ts          ← db, auth, topologie
  pnpm-workspace.yaml        ← packages: [fronds/*, apps/*]
  fronds/blog/               ← le domaine
    entities/Post.ts
    handlers/PostHandler.ts
  apps/nuxt/                 ← le consommateur
    nuxt.config.ts
    app/pages/index.vue
FlagCe qu'il fait
--frond blog,api:catalogles Fronds à ajouter — template:nom renomme ; le nom du template est le nom par défaut
--app nuxt:webpareil, pour les apps
--barela coquille vide, rien de composé
--flatun seul domaine : pas de fronds/, pas de workspace — la racine de l'app est la Frond
--locallie @fougere/* à un checkout local de Fougere (développement)
--forceécrase un dossier existant

Arguments ou mode interactif. Avec --frond et --app, la commande n'affiche aucune question et peut être utilisée dans un script ou en CI. Sans ces options, elle demande les templates dans un TTY. Le mode interactif n'est pas disponible sans TTY.

Structure à plat. fougere new shop --flat --frond blog écrit une app Nuxt dont la racine porte le domaine — pas de segment fronds/, pas de pnpm-workspace.yaml, un seul pnpm install (la forme à plat). --app est refusé avec lui : l'app est la racine. Un deuxième domaine ira plus tard dans fronds/billing/ et la Frond racine ne bouge pas. Le projet généré porte aussi un tsconfig.frond.json dont le include nomme les dossiers de la convention. pnpm typecheck peut ainsi vérifier le domaine sans charger Nuxt.

La Frond générée contient une entité, deux vues dérivées (NewPost pour l'entrée et PostCard pour la liste) et une opération métier en plus du CRUD :

export class NewPost extends Post.pick('title', 'body') {}

export default class PostHandler extends Crud(Post) {
  async create(input: NewPost): Promise<Post> {}

  /** La transition draft→published — une opération, pas une écriture de champ. */
  async publish(id: string): Promise<Post> {}
}

Les vues définissent les contrats d'entrée et de sortie de ces opérations.

fougere serve — une Frond, son propre process

fougere serve blog --port 4100

Démarre cette Frond seule et l'expose en JSON-RPC sur POST /_fougere/call. L'app qui la consomme indique son adresse dans remotes: pour router les appels vers cet hôte. Voir le gradient.

Elle ne suit pas remotes: elle-même : un hôte est la Frond, il ne route pas vers l'extérieur.

serve() écoute 127.0.0.1 sauf si hosts dit autre chose : un récepteur lit l'identité sur le fil, donc le défaut le garde sur la machine et l'élargir est une décision écrite.

fougere call — appeler une opération

fougere call post.list
fougere call post.create --title "Bonjour" --body ""

La commande utilise la même enveloppe, la même validation et les mêmes erreurs typées que les autres clients. Une erreur VALIDATION_FAILED conserve donc le format reçu par une page.

fougere sync — consommer une Frond distante

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

Demande rpc.discover à l'hôte et écrit localement les entités reconstruites depuis les cartes qu'il renvoie :

// .fougere/remotes/blog/entities/Post.ts — généré
import { reconstruct } from '@fougere/schema';

export class Post extends reconstruct<{
  id: string;
  title: string;
  createdAt: Date;
}>({ /* la carte, telle quelle */ }) {}

Une classe, comme celle qu'on aurait écrite à la main : Post est la valeur — un juge qui valide localement, sans requête supplémentaire vers l'hôte — et le type d'une ligne qu'il renvoie, si bien que post.titel ne compile pas. Les deux se lisent sur la même carte.

À côté, handlers/PostHandler.ts énonce ce que la frond sert, pour qu'un consommateur écrive Facade<PostHandler> sans détenir le code du handler :

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

La commande écrit aussi .fougere/remotes.json, que le module Nuxt lit pour aliaser @frond/blog. Les pages utilisent ainsi le même chemin d'import pour une entité locale ou distante.

La relancer supprime ce que l'hôte ne sert plus. Le barrel perd son export tout seul, mais le fichier restait — et exports liste './entities/*' en joker, si bien que @frond/blog/entities/Ticket.js résolvait encore vers une classe qui valide parfaitement et dont plus rien ne répond derrière. Seuls les fichiers portant l'en-tête généré sont supprimés ; ce que vous déposez vous-même dans ce dossier est laissé intact.

L'hôte renvoie les entités associées à une façade, et les faits que ses fronds annoncent. Une entité qui n'est ni l'un ni l'autre n'est pas dans le résultat de découverte — une forme que personne n'expose et que personne n'a déclarée sortante reste chez elle.

Un fait n'a pas d'opération : il obtient une classe et aucune interface Handler à côté.

// .fougere/remotes/blog/entities/PostPublished.ts — généré
export class PostPublished extends reconstruct<{ id: string; title: string; at: Date }>(…) {}

Pour qu'un fait qui arrive soit jugé, réexportez cette classe dans une de vos propres fronds — le boot valide contre une entité d'une frond scannée, et .fougere/remotes/ est un paquet, pas une frond :

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

Les règles

codece que ça veut dire
directory-unreadable, handler-parse-failed, heritage-unresolvedce que le scan n'a pas pu faire — une règle sur une absence n'est fondée que si l'analyse atteste avoir regardé
operation-unboundune opération déclare des paramètres et n'a aucun plan de liaison : elle est servie, et elle n'en reçoit aucun
cross-frond-importun import relatif qui résout dans une autre frond

La dernière est un avertissement, pas un refus — ça résout aujourd'hui et l'app tourne. Ce qu'elle énonce, c'est qu'une contrainte de colocalisation tient l'application et que rien ne la déclare : '../../user/entities/User.js' dit que ces deux dossiers sont voisins, dans une chaîne que le scan, la carte d'identité et remotes: ignorent tous. Ça marche jusqu'au jour où ce dossier n'est plus là, et ça casse alors comme un chemin de fichier, pas comme un modèle.

'@frond/user/entities/User.js' énonce la même dépendance en termes que le modèle lit, et c'est la forme qu'écrit fougere sync — elle survit donc au déménagement de la frond. Le nom résout pareil, que la frond soit locale ou synchronisée.

fougere graph — lire le modèle

fougere graph

Affiche les entités, leurs références et le nombre de références entrantes. Au-delà de six entités, la commande propose aussi des regroupements calculés depuis ce graphe :

  Post → Author, Category (2 incoming)
  Author

La page chemin le plus court décrit les usages actuels et envisagés de ce graphe.

fougere build-frond — publier les entités d'une Frond

fougere build-frond blog

Compile fronds/blog/entities/** vers dist/ avec les déclarations de types et configure le package.json de la Frond. Un consommateur situé dans un autre dépôt peut ensuite installer ce paquet. Voir où le code vit.

Suite : La philosophie.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.