lyrn forms
$ lyrn formsspa Single-page app: Vite, React, TypeScript, Tailwind, dowel --with router Screens at addresses: react-router, unknown ones sent home --with tanstack-query Server state through TanStack Query, on the line's defaults --with auth A cookie session and a sign-in screen until someone signs in --with pwa Installable and offline: a manifest, its icons, a service workercli Command-line tool: Rust, clap, anyhow, dialoguer --with keyring Secrets in the OS keyring, never in a config file --with self-update A `self-update` command that reads the releases pagedesktop Desktop app: Tauri 2 around the spa stack --with i18n i18next, with a gate holding every locale to the sourceservice HTTP service: axum, sqlx, Postgres --with spa A web UI compiled into the binary and served by it --with demo Made-up data in an empty database; one with real data refuses itworkspace Cargo workspace: a library crate plus the CLI that uses it --with keyring Secrets in the OS keyring, never in a config file --with self-update A `self-update` command that reads the releases pagemono pnpm monorepo publishing a TypeScript package to npm --with stand A Vite page in the workspace where the package runsplugin Plugin for a host of the line: an executable speaking JSON over stdio --host kilna a desktop workbench for content makers --host kasl a work activity tracker for the command linetauri-plugin Tauri 2 plugin: a Rust crate and the npm package that calls itdocs Documentation site added to an existing repository: Starlight, llms.txtA form is the shape of the repository, not a choice of framework. The line runs one stack; a form decides what kind of thing is being built with it.
Every form that starts a repository also writes what every repository of the
line carries beside its code: CONTRIBUTING.md with the form’s own gate and
layout, a security policy, a code of conduct, llms.txt, issue and pull
request templates, an audit workflow with its deny.toml or license check, two
first decisions in docs/adr/, and the mark’s three masters in assets/.
What the standard puts in says why each
is there.
A single-page application:
| Build | Vite 8 |
| UI | React 19, TypeScript |
| Styling | Tailwind 4 and the dowel theme |
| Tests | Vitest with jsdom, Testing Library |
| Lint | eslint, typescript-eslint, dowel’s rules |
| Primitives | dowel’s Button, copied from the registry and held to it |
A product of the line presses dowel’s Button rather than a <button> - dowel’s
lint refuses the raw element outside src/components/ui/ - so the project
starts with the copy, taken from the registry of the dowel-ui it pins.
pnpm lint compares every copy there with the registry of the dowel-ui
actually installed: an upgrade that forgets to take the copies again, or a
copy edited in place, fails the gate.
Add-ons
Section titled “Add-ons”Each one is what the line’s signed-in, routed, data-fetching frontends already do, taken from the products that do it:
$ lyrn new my-app --with router,tanstack-query,authrouter puts the screens at addresses with React Router, declared in
src/App.tsx; an address nothing answers goes home rather than to a blank
page, as it does in kasl-server and kilna.
tanstack-query adds one query client on kilna’s defaults: fresh for 30
seconds, no refetch every time the window regains focus, and a failed query
fails at once instead of retrying three times behind a spinner.
auth keeps a cookie session the way all four of the line’s signed-in
frontends do: the cookie is HttpOnly, so the app asks the server who is signed
in (GET /api/me) and shows a sign-in screen until someone is. The screen is
dowel’s Field, Input, Panel and Alert, copied in with the add-on; it tells a
wrong password from a server out of reach. /api is forwarded to
localhost:8080 in development, where the service form listens, and a test
helper stands in for the server so the tests run the real provider.
pwa makes the app installable and able to open without a network, the
way rhapsod is: a manifest, the 192px and 512px icons an install asks for plus
the 180px one iOS takes, and a service worker that goes to the network first
and falls back to the last build it cached - never for /api, since stale data
shown as current is worse than an error. It registers in production builds
only, under a build id, so every build gets a cache of its own and the last
one is dropped.
main.tsx is where the add-ons meet: the providers they need are one line
each, outermost first, so any set of them nests without re-indenting the
rest.
The favicon
Section titled “The favicon”Every spa starts with the line’s umbrella mark - lambda on graphite - at level
S, the level drawn for 27px and under, copied from dowel-ui/marks. The same
gate that holds the primitives holds it: the favicon has to be one of the
line’s marks, byte for byte, and at level S. When the product has a mark of its
own, its <code>-S.svg from the same package replaces the file and the gate
agrees. The PWA icons, which have no SVG twin, are the plated mark rendered at
their sizes; lyrn.toml records mark = "placeholder" until the accent is
chosen.
A Rust command-line tool:
| Arguments | clap, with derive |
| Errors | anyhow, printed with their cause chain |
| Prompts | dialoguer |
| Tests | tests/cli.rs, running the built binary; tests/installers.rs, holding the installers |
| Icon | assets/icon.ico, embedded by build.rs - the line’s mark until the product has its own |
| Release | Three targets, installers, an npm wrapper, crates.io over OIDC |
The binary is built with lto, one codegen unit and a stripped symbol table:
a release binary is downloaded far more often than it is built.
Add-ons
Section titled “Add-ons”The line’s four CLIs agree on that core and disagree about everything else, so the rest is optional. Neither is generated unless asked for - a project should not carry code it never calls, or the dependencies behind it.
$ lyrn new my-tool --form cli --with keyring,self-updatekeyring adds a secret command that stores values in the OS keyring.
A secret never goes in a config file: config files are copied into backups,
synced between machines, pasted into issues and committed by accident.
self-update adds a command that reports whether a newer release exists.
It reads the tag from the redirect /releases/latest performs rather than the
REST API, which is capped at 60 unauthenticated calls an hour per address.
The installers
Section titled “The installers”tests/installers.rs holds the two installers to three mistakes the line’s
tools each shipped once. Run in Git Bash, MSYS2 or Cygwin, the Unix script has
to send the user to install.ps1 instead of saying there is no Windows build -
checked by running it with uname answering as each of them, so a case written
below the catch-all does not pass. The fallback cargo install it suggests has
to name the crate that is published. And the PowerShell script has to keep the
user PATH an expandable string, which SetEnvironmentVariable silently
would not.
Two more are the release engine’s contract. <APP>_NO_PATH=1 leaves the PATH
alone, read before the first edit: furca and lyrn check --release --install
install a release into a scratch directory to prove it, and that directory must
not stay on the user’s PATH. And on Windows the script moves a running
<name>.exe aside before copying the new one - Windows will not overwrite a
program that runs, but will rename it - and deletes what it moved on the next
install.
The npm launcher waits through Ctrl+C. The keystroke reaches the launcher as well as the binary; one that died of it at once would hand the prompt back while the binary was still finishing, and lose its exit code.
desktop
Section titled “desktop”The spa stack with a Rust shell around it:
| Shell | Tauri 2 |
| Frontend | Everything the spa form writes |
| Commands | src-tauri/src/commands.rs, reachable from the webview |
| Permissions | src-tauri/capabilities/, granted per window |
| CI | The frontend gate, plus Rust on three platforms |
main.rs is deliberately thin - the application lives in lib.rs, which is
what lets tests reach it without opening a window. On Windows the binary is
built with windows_subsystem = "windows", or a console would sit behind the
app in release builds.
The icons
Section titled “The icons”src-tauri/icons/ ships the line’s umbrella mark - a lambda on a graphite
tile - as a placeholder. It is there because a Tauri build on Windows fails
outright without an .ico, and a generated project has to build on the first
try. It says “a project of this line whose own mark is not drawn yet” rather
than nothing at all, and lyrn.toml records the same fact as
mark = "placeholder", so tooling does not have to guess it from the bytes.
The placeholder follows the line’s level rule, which applies to each image
inside the .ico, not to the file as a whole: the filled tile at 27px and
below, where an outline would collapse into noise, and the outlined mark on a
dark plate above that.
Once the mark exists, lyrn assets --from <dir> takes
its masters into assets/, draws the icons from them and sets
mark = "chosen". Not pnpm tauri icon, which cuts every size from one
picture. Two things lyrn assets keeps that a single picture cannot:
- The largest image comes first. Windows picks by nearest size and ignores
order, but
tauri-codegentakes the first entry verbatim for the window, so a 16px one there leaves the title bar blurred. - The level rule, per size. A single drawing scaled to every size gives either a flat lozenge on the desktop or mush in the title bar, depending on which drawing you picked.
service
Section titled “service”An HTTP service:
| Server | axum |
| Database | Postgres through sqlx, migrations as plain SQL |
| Config | Environment only - the same binary runs anywhere |
| Errors | One type, mapped to status codes once |
| Deploy | A Dockerfile and a compose file for a stand |
Migrations run at startup, so a binary and the schema it expects travel together and there is no window where one is ahead of the other.
The tests talk to a real Postgres and skip themselves when DATABASE_URL
is unset - right on a laptop without one. The CI the form ships provides a
database, so the suite is never green and blind.
--with spa
Section titled “--with spa”Adds a web UI under frontend/, compiled into the binary with rust-embed. A
self-hosted install is then one file, and the UI can never be from a different
build than the API it calls.
Anything the API has not claimed falls through to the app, which owns its own
paths - without that, a refresh on any route but / would 404: the server has
no such file, and only the app knows what to draw there.
Left out, --with spa is what a service with no interface of its own needs -
a sync relay, a webhook receiver, an API somebody else’s frontend calls.
The web UI is the spa form’s plain one; the spa form’s own add-ons stay with it.
--with demo
Section titled “--with demo”Made-up data for a demonstration, the way kasl-server’s KASL_DEMO works.
<NAME>_DEMO=true fills an empty database at startup - fixed rows, so every
demonstration and every screenshot shows the same screens - and marks it as a
demo in a table of its own; /api/health then answers "demo": true, so a
screen can say so.
The guard is the point: a database that already holds real data refuses to start with the flag on, so a stray flag on a real installation can never mix invented rows into real ones. The label lives with the data rather than in the flag, so a demo database stays recognisable when the flag is gone. Its tests run against the real Postgres like the rest, each inside a transaction that is rolled back.
workspace
Section titled “workspace”The cli form with its logic moved into a library crate of its own:
| Layout | crates/<name>-core (the library) and crates/<name> (the binary) |
| Version | One, in [workspace.package] - both crates release together |
| Errors | An enum in the library, anyhow chains at the binary’s edge |
| Docs | rustdoc with doctests, checked by CI |
| Publish | Two crates in order, the library first |
Reach for it when the logic is worth depending on without the command line attached - which on this line is most of them once they grow: sefy publishes its core so a plugin can link it, and a library is the only shape another program can call.
The library owns the reasons things fail. Its errors are an enum a caller can
match on, not a formatted string; the binary turns them into a chain a person
reads. That is the whole reason for the split, and it is why the form ships a
greet that returns Result rather than a function that cannot fail.
Publishing two crates
Section titled “Publishing two crates”publish.yml publishes the library, then the binary: crates.io resolves the
dependency at publish time and refuses a package whose dependency it cannot see
yet, and cargo publish waits for each version to reach the index before it
returns. A version crates.io holds already is skipped, so a run started again
picks up where one stopped.
Both crates need their own trusted publisher on crates.io, and the first version of each has to go up by hand: a publisher cannot be attached to a crate that does not exist. Missing the second one fails with a message about an invalid token, which is not what is wrong.
The icon
Section titled “The icon”The .ico the binary embeds lives in crates/<name>/assets/, not in the
repository’s assets/ beside the masters. cargo install builds the published
crate, and a crate carries only what is under its own directory: an icon reached
through ../../assets builds on the machine that has the repository and is
missing from crates.io. build.rs embeds it unconditionally, so a package
without it fails to build instead of shipping unmarked.
The MSRV job
Section titled “The MSRV job”CI reads rust-version as the maximum across the workspace’s packages
rather than from the first one cargo happens to list. .packages[0] is not a
promise, and testing whichever crate came first is green on half a repository.
Add-ons
Section titled “Add-ons”The same two the cli form has, in the same place: keyring and
self-update live in the binary, because a library has no business reaching
for the user’s keychain or replacing an executable on disk.
A pnpm workspace that publishes a TypeScript package:
| Layout | packages/<name> publishes; the root is private: true |
| Build | tsc, not a bundler - a library ships modules |
| Resolution | nodenext in the package, bundler everywhere else |
| Tests | vitest at the workspace root, one runner for every member |
| Publish | npm over OIDC, with provenance |
The line has no standalone publishable TypeScript package: dowel, kjui and
lyrnui are all monorepos whose root is private and whose shipped artefact sits
under packages/. So this form is the monorepo, and the package lives inside
it - one member today, room for the second the day it is needed.
Why not a bundler
Section titled “Why not a bundler”The package is compiled by tsc with moduleResolution: nodenext, and its
sources import each other with the .js extension the emitted files will have.
Under bundler - which is right for everything a bundler consumes, and is what
the workspace’s own tsconfig.base.json keeps - an extensionless specifier
typechecks, tsc emits it unchanged, and the package builds, packs and
publishes. Node’s resolver then refuses it. The failure belongs entirely to
whoever installs the package, and nothing in this repository would have caught
it (ADR 0003).
So CI installs the packed tarball into a scratch directory and imports it with
a plain node. A different process, outside the workspace, resolving the way a
consumer’s does - which is the only thing that can answer this question.
--with stand
Section titled “--with stand”Adds a second workspace member: a Vite page that imports the package by its published name. pnpm resolves that to the workspace member, so the page renders the built package rather than its source - if the build is broken, the stand is too, which is the point of having one.
With a stand, pnpm lint builds the package first: the stand’s typecheck
resolves the package through its exports, which point at dist.
Its test asks for a DOM in the file itself, with an @vitest-environment
docblock, rather than in the runner’s config. Only the stand renders anything,
and a jsdom the package’s tests never touch costs more than twenty seconds of
start-up - once enough to push a healthy run past the worker timeout.
Translation
Section titled “Translation”--with i18n is offered by desktop; spa gets it in 2.6, with its own add-ons. It writes i18next with English
and Russian, and tools/check-locales.mjs into pnpm lint.
English is the source, never a fallback. A missing translation fails the
build rather than quietly rendering in English, because a half-translated
screen is the kind of thing nobody reports and everybody notices. The gate also
holds {{placeholder}} names to the source, so a translation cannot silently
drop the one that carries a number.
plugin
Section titled “plugin”A plugin for an application of the line: an ordinary executable that speaks JSON over stdio.
| Shape | One binary, <host>-plugin-<name> |
| Protocol | --manifest on stdout, run with an invocation on stdin |
| Errors | A reason on stderr and a non-zero exit, or an error in the reply |
| Tests | tests/protocol.rs, running the binary as the host runs it |
| Release | Three targets, binaries attached to the GitHub release |
$ lyrn new wordcount --form plugin --host kilnaIt is not a library and not a sandboxed runtime. Rust has no stable ABI, so a dynamically loaded plugin would break on every host release; and the integrations a plugin exists for - a mail client, a corporate API, a device - are exactly the ones a sandbox forbids. The generated ADR 0003 says so in the repository itself.
The plugin lives for the duration of one call and holds no state. The host sends the whole subject rather than an identifier, so the first thing a plugin does is never a request for the data back.
--host
Section titled “--host”The form has no default host, because the answer changes what the generated project is: the protocol version it declares, the points it may extend, and the name the host discovers it by.
Only applications that accept plugins today are offered. lyrn forms lists
them. Asking for one whose turn has not come is refused with that reason rather
than accepted:
$ lyrn new demo --form plugin --host turnouterror: `turnout` does not accept plugins yet; when it does it will be listed here (known: kilna, kasl)The hosts differ in what a plugin is handed and in what becomes of the fields
it answers with under meta, and the generated README and code say which:
| Host | A command runs on | subject |
meta |
|---|---|---|---|
| kilna | a work (first), a release | the row, with each version role’s latest text under bodies |
merged into the subject’s fields |
| kasl | a day (first), an inbox issue | the day as kasl report --json prints it, or the issue as kasl inbox show --json prints it |
shown under the plugin’s message, never kept |
A kasl plugin may also hear events and answer as an inbox source; those parts of its manifest are written by hand, from kasl’s “Writing a plugin” page - a form generates the one command every plugin has.
A preset written against a protocol nobody implements cannot be checked by anything: the project would compile and its protocol test would pass, because both would be measured against an invention.
tauri-plugin
Section titled “tauri-plugin”A Tauri 2 plugin: the Rust crate an application registers, and the npm package its webview calls.
| Rust | tauri-plugin-<name>, published to crates.io |
| Webview | tauri-plugin-<name>-api, published to npm |
| Permissions | Generated from COMMANDS in build.rs |
| Tests | Both halves, each holding the strings the other depends on |
| Release | One tag, both registries |
Both halves ship from one repository and under one tag. They agree on three spellings - the plugin name, the command key, and the permission generated from it - and a disagreement between them is not a build failure. It is a permission error in the webview, at run time, in the application that installed the plugin.
CI holds all three against each other after generating the project, because neither half’s own gate can see the other.
A documentation site for a repository that has none. It is the one form that adds to an existing tree rather than starting its own, so it is run inside the repository:
$ cd my-tool$ lyrn new my-tool --form docs --accent '#3fa873'Added 21 files in `.`.| Site | Astro and Starlight, a pnpm project of its own under docs/ |
| Pages | A front page, Getting Started, a guide and a reference page to replace |
| For agents | llms.txt, llms-full.txt and a .md twin of every page |
| Checks | The site’s address, and every internal link against it |
| The mark | The site’s header, favicon and touch icon, which lyrn assets draws again from the masters |
| Publishing | .github/workflows/docs.yml, to GitHub Pages |
| Audit | .github/workflows/docs-audit.yml: every package of the site against the advisories |
What it will not touch
Section titled “What it will not touch”Everything it writes is under docs/, plus two workflows. A repository
generated by lyrn already has docs/adr/, and the site lands beside it. If any
file it would write is already there, nothing is written and every file in the
way is named:
$ lyrn new my-tool --form docserror: nothing was written: these files are already in `.`: docs/package.json .github/workflows/docs.ymlA repository with a documentation site of another kind - mdBook, MkDocs, Docusaurus, VitePress, Sphinx, Jekyll, or Astro already - is refused the same way. Two sites would be two truths about one product.
For agents
Section titled “For agents”The build writes, next to the HTML, the same documentation in the form an agent
reads: llms.txt as an index, llms-full.txt as one document, and each page as
Markdown at its own address plus .md. They are made from the pages during
astro build, into the output rather than the source tree, so there is no
second copy to go stale.
A page may render Starlight’s components. Cards, tabs, asides, steps, link cards and badges are turned into the Markdown they stand for; any other component fails the build and names itself, because its content would otherwise be missing from the twin without anyone noticing.
The audit
Section titled “The audit”The repository’s own audit.yml was written before the site existed and does
not look inside docs/, so the form adds a workflow of its own for the site:
pnpm audit over every package, on changes to the site’s dependencies and on
Monday mornings. Every package counts, not only the production ones - the site
is all build tooling, and what the build writes is what ships. An advisory
accepted on purpose goes into docs/pnpm-workspace.yaml under
auditConfig.ignoreGhsas, with the reason in a comment above it; the site
starts with one, in a CSS parser it only ever feeds its own files.
The mark
Section titled “The mark”lyrn assets draws every raster of the mark from its
masters in assets/ - logo.svg (L), logo-m.svg (M), logo-s.svg (S) - by
the line’s rule: a size takes the level that reads at it, S at 27px and under,
M up to 63px, L above, and the rule applies to each image inside the .ico,
largest first. It writes the site’s header (L) and favicon (S), the touch icon,
the canonical rasters in assets/, and the icons the repository’s build reads,
wherever its form keeps them - lyrn.toml says which form that is:
| Form | Icons the build reads |
|---|---|
cli |
assets/icon.ico |
workspace |
crates/<name>/assets/icon.ico, inside the crate that is published |
desktop |
src-tauri/icons/icon.ico and icon.png |
spa with pwa |
Every icon public/manifest.webmanifest lists, and the touch icon |
A form whose build reads no icon gets none: an icon nothing reads is a copy
that drifts from the masters unnoticed. With assets/banner.svg beside the
masters it also draws the 1280×640 social preview, which is uploaded to the
repository’s settings by hand.
The site starts with the line’s umbrella mark in those places, the same one the repository’s masters hold until the product has its own.
The address
Section titled “The address”astro.config.mjs states the site’s address once. The build accepts one of
two layouts - a github.io project site under the repository’s name, or a custom
domain served from the root with public/CNAME naming it - and fails on a mix,
listing every internal link that does not start with the base path. Moving to a
domain is that one line plus the CNAME file; the build points at whatever was
left behind.
The generated project’s two checks are the same files that build this site.
The release contour
Section titled “The release contour”Every form that starts a repository writes the same contour around its code, so a release goes out the same way from each:
release.toml- what furca’s release engine releases the repository from: the manifests that carry the version, the gate, the CI workflow and the one that publishes, the packages and their registries, the files the release page carries, and what follows the publish -[post.install]where the installers put a program on a machine,[post.deploy]where turnout puts a service or an app on its stand.furca release planreads it on the first day;lyrn checkholds it on every commit..github/workflows/release.yml- the release page of a tag. The cli, workspace and plugin forms build an archive for each target and attach it; the others attach nothing. The notes are the changelog’s section for the tag, then.github/release-notes.md..github/release-notes.md- theChecked livelist every release’s notes end with: a box for how the release reaches a machine - installed from its page, deployed to its stand, added to an app - and one for running it by hand. They are ticked on the release page once they hold;lyrn check --releasesays which are still empty..github/workflows/publish.yml- where the form publishes to a registry, every job of it waits forrelease.ymlto finish green on the tag’s commit. An npm wrapper downloads its binary from the release page, so a package published first answersnpm installwith a 404 until the archives arrive; and a release that failed to build reaches no registry at all.
Coming in 2.x
Section titled “Coming in 2.x”The egui shape the line already uses.