Readiness: Implemented
Reference
Commands
Every subcommand, what it does, and what it exits with. Global flags come before the subcommand; a subcommand's own flags come after it.
Global flags
| Flag | Meaning |
|---|---|
--cwd DIR | Treat DIR as the project root instead of the working directory |
--color auto|always|never | Colour output. auto colours a terminal and nothing else |
-h, --help | Help for uf or for a subcommand: -h gives a summary, --help everything. uf --help groups the commands by what they are for, and every page wraps to the terminal |
-V, --version | The version |
In an interactive terminal, incomplete commands offer a selection menu for subcommands and known values. Missing paths or names without known choices ask for a value. Escape cancels. Explicit help and noninteractive argument errors retain their usual behavior.
Project
Your first project is the guide to uf new and uf init, and
Routing to the route table uf routes prints and extends.
uf new PATH [TEMPLATE]
Scaffolds an application into a new directory: uf.config.js, app.js, a
layout, a page and a package.json. In a terminal, select a package manager
and optionally select multiple IDEs with Space, then confirm with Enter. An
empty IDE selection skips setup. Escape cancels before writing the project.
The selected IDEs get Flow settings and their uf integration installed through
uf editor setup and uf editor install. VS Code and Cursor use the checksummed
release extension; editors needing a manual activation step print that step.
Dependency installation remains a separate uf install.
react is the default template, so uf new my-site is the usual form, and
monorepo is the other. A word in the template's place that is not a template
is refused rather than read as a directory: uf new my-site raect says so, and
scaffolding into raect/ would be the worse answer.
For scripts, choose explicitly and skip prompts:
uf new my-site --package-manager pnpm --editors vscode,zed --yes
uf new my-lib --lib --package-manager npm@11 --no-editors
uf new my-site --yes
--package-manager accepts uf, npm, pnpm, yarn or bun; the external
managers may have a version prefix or exact version. The choice is written to
uf.config.js, so uf install uses it. --editors accepts comma-separated or
repeated IDE names. --no-editors skips that picker. --yes skips both prompts
and keeps any explicit choices. Without a terminal, omitted choices preserve
the existing scaffold defaults. These setup options apply to built-in templates.
uf new PATH monorepo
Scaffolds a repository of two workspace packages: apps/web, an application
shaped like the react template, and npm/ui, a library shaped like
--lib, which the application imports by name. The root holds what a
repository of several packages needs and one package does not:
- a
package.jsonwhoseworkspacesfield listsapps/*andnpm/*— the field npm, Yarn and Bun read. The application depends on the library as"@NAME/ui": "*", which a workspace install links rather than fetches. pnpm readspnpm-workspace.yaml; selecting pnpm creates it with local workspace linking enabled. Selecting Yarn writes anode-moduleslinker configuration. - a
uf.config.jsthatuf fmt,uf lint,uf checkanduf testrun at the root read for every package, so formatting and lint levels are written once. Each package keeps its own config for its router or its library build. - tasks that reach one package with the
#memberselector, by path:uf build#npm/uibuilds the library anduf build#apps/webbuilds the application.uf dev#apps/webstarts its development server. A package is also selected by its manifest's name,@NAME/web. The task guide explains how to connect these commands withdependsOn.
Every template — react, monorepo and --lib — is formatted, linted, type
checked, tested and built in CI, in crates/uf_cli/tests/templates.rs.
uf new PATH SOURCE
A template from somewhere else, pinned to its exact bytes:
| Source | Pinned by |
|---|---|
github:owner/repo#<commit> | the commit id, written in full |
git+https://host/repo.git#<commit> | the commit id, written in full |
https://host/template.tar.gz --integrity sha512-<base64> | the digest; sha256:<hex> works too |
A branch, a tag or a short commit id is refused, because each is a name that
can come to mean other code after you read it, and a tarball without
--integrity is refused rather than trusted. A tarball is hashed before it is
unpacked, and one whose digest does not match is never unpacked. After a git
checkout, the commit that arrived is compared with the one you named. https
and file are the only transports.
The template is copied, and nothing in it runs: no install, no lifecycle
script, no generator. Git runs with your own and the system's configuration
ignored and its hooks switched off. A symbolic link in a template is refused,
and the fetch's .git directory is left behind. Nothing is written until the
whole template has arrived and passed its check, so a refused template leaves
no half of a project.
When the template's package.json declares an install-time lifecycle script,
uf new says so: uf install refuses to run one until pm.allowLifecycleScripts
allows it, and that is when to read it. --name does not apply, because a
remote template's manifest is written as its author wrote it.
--name sets the package's name when it should not be the directory's, and
--force writes into a directory that already holds files.
uf init [TEMPLATE]
The same scaffold, into the current directory. That is the whole difference
between the two commands, and it is the reason there are two: uf create app
took one optional argument that was a template when it named one and a
directory when it did not, so the two things it could do had no separate
spellings.
uf create is still accepted and is no longer listed.
It takes the template, library, name and force flags with the same meaning:
| Flag | Meaning |
|---|---|
--lib | Scaffold a library rather than an application — see below |
--name NAME | The package's name, when it should not be the directory's |
--force | Write into a directory that already holds files. Without it, a directory with files in it is refused |
--integrity DIGEST | The digest a remote tarball template must hash to, sha512-<base64> or sha256:<hex> — see a template from somewhere else |
uf migrate
Adopts an existing application's tools into uf.config.js: Babel, Flow,
Prettier, ESLint, Jest and Vite configuration are read without running their
modules, named package scripts become tasks, and the common test, lint, format
and build scripts become uf commands.
| Flag | Meaning |
|---|---|
--dry-run | Print the proposed files and the settings it could not map, and write nothing |
--json | The same plan as JSON, for review or for a script |
What it changed, the original contents and every setting it could not map are
written to .uf/migration-report.json. A JavaScript configuration module is
left in place for you to remove once its imports and side effects are reviewed;
only a static file it replaced completely is deleted. A value already in
uf.config.js wins over the one it would have migrated, and the conflict is
reported. Migrating to uf is the guide.
uf codemod
Rewrites a uf project's own source for a newer uf: configuration keys that moved, and spellings a release replaced. The edits are made on Flow's syntax tree, so comments and unrelated formatting survive, and running the same migration twice changes nothing the second time.
| Flag | Meaning |
|---|---|
--from VERSION | The version the project is on. Defaults to the installed node_modules/@uniflowed/core |
--to VERSION | The version to migrate to, without a uf@ prefix. Defaults to this binary's own version |
--dry-run | Print the proposed edits and write nothing |
--json | The plan as JSON |
A downgrade, or a target newer than the binary, is refused. An edit that needs a decision — two values that conflict, a legacy option with no single new spelling — is reported and left unchanged. See upgrading an existing uf project.
uf new NAME --lib
Scaffolds a library rather than an application: no router, no document, and a
package.json shaped for publishing. uf init --lib does it in place.
Its uf.config.js says app: { router: { enabled: false } }, and that one
line is what makes uf build build a library rather
than look for an application entry. The manifest it writes ships the Flow
source and the compiled build, named by export conditions; see
build.lib.
uf info
Prints the version, the resolved JavaScript host, and the project uf found. The first thing to run when something is behaving unexpectedly.
Given a package, it prints what the registry says about that instead —
uf info PACKAGE [FIELD].
uf inspect
Prints what uf resolved from your config — the effective options, the route
table, and the config handed to Vite. --json for the machine-readable form.
uf explain COMMAND
Says what a command will do and which provider does each part — dev,
build, preview, start, doc, test, fmt, lint or check. --json
for the machine-readable form.
--target TARGET describes uf dev for that application target. For native,
ios or android the stages name the project's own React Native dev server
and its version, the Metro config file, and what a device connects to, rather
than the builder. Without it, uf explain dev describes the project's default
target, so a react-native framework project gets the native plan. It is
refused for any other command, because only uf dev's providers change with
the target.
uf dev
1. configuration
provider uf
what uf.config.js, with `vite` merged over what uf generates
2. JavaScript host
provider node
3. dev server
provider vite (@uniflowed/vite driver)
4. Flow to JavaScript
provider uf transform (in this binary)
5. rendering
provider @uniflowed/router
configuration
- ./uf.config.js
- package.json
uf inspect answers "what is my configuration"; this answers the question
people actually ask when a command surprises them, which is "who is doing
this, and which file decided that". An integrated toolchain that cannot say
what it is doing is a black box — see Architecture.
uf routes list
Prints the route table uf build counts from: the path, the parameters it
captures, how many middlewares run before it answers, and the page file each
one resolved to.
It is the same discover_routes the build runs, so a route that is missing
here is a route that will be missing from the site. A directory uf reserves
without serving, or a [...rest] with a segment after it, is reported as the
error uf build would give rather than silently left out of the table.
uf routes add PATH [--layout] [--loader] [--middleware]
Writes a route: the directory, $page.js, and whichever of the rest was
asked for.
uf routes add /articles/[slug] --loader
PATH is a URL path in the spelling the directories already use —
/articles/[slug], /docs/[...path], /(marketing)/about — so what you type
is what appears in RoutePath. The page is typed from the path: a [slug]
becomes +slug: string and a [...path] becomes
+path: $ReadOnlyArray<string>.
--loader is the one flag that is not a file. A loader is an export of the
page module rather than a name uf reserves, so --loader adds the export and
the data prop rather than writing a file of its own.
What it writes is exported by name — Page, Layout, middleware — which is
the spelling uf new uses and the one react/no-default-export-component does
not report. A scaffold that made uf lint speak on its first run would be
teaching that the rule is noise.
The file names come from the same grammar uf build discovers routes with, so
a scaffold uf writes is a route uf finds. A spelling uf reserves without
serving — (....)photo — is refused here with the sentence uf build and
uf lint give for it, rather than written now and reported later. A @team
and an intercepting route like (.)photo are refused for the opposite reason:
uf serves both, and neither is a URL — which is what this command's argument
is. Nothing is overwritten: a route whose page already exists is an error, and
a run that stops has written none of its files.
uf prepare regenerates router.js, so the new path joins RoutePath on the
next run of it.
uf ui add NAME... [--overwrite]
Writes styled components into the project, with the components they import.
uf ui add button dialog tabs select
Each component is one file in app/components/ui/, or in the directory
ui.directory names. dialog.js is @uniflowed/ui's
dialog dressed in @uniflowed/stylex's tokens, and from then on the file is the
project's to change. The behaviour stays imported from
@uniflowed/ui, so a fix to a focus trap still arrives by upgrading it; what
the file owns is the look. The text is StyleX — stylex.create and the
tokens — not a plain-CSS translation of the same component. A project that
does not use StyleX imports @uniflowed/ui and styles the parts itself.
Owned or headless is that choice.
The components are part of the uf binary, so adding one needs no network and
writes the version that matches the uf running it. A package a component
imports that package.json does not name is added first, through uf add, at
that uf's own version, and an install that fails leaves every file unwritten.
What happens to a file that is already there depends on what it is:
| The file | What uf ui add does |
|---|---|
| Not there | Writes it |
Written by uf ui add, untouched, and already this uf's version | Nothing |
| Written by an older uf, and untouched | Brings it up to this uf's version |
Edited, or not written by uf ui add, and named | Refuses, names the file, and writes nothing; --overwrite replaces it |
Edited, or not written by uf ui add, and only imported by one that was named | Keeps it, because it still satisfies the import |
The last line of every file it writes records the component, the uf that wrote
it and a digest of what was written. That line is how uf ui add tells an
edited copy from an untouched one, and how uf ui diff says whose change a
difference is.
uf ui list [--json]
Every component this uf carries, what each one is, and whether the project has
it: not added, added and untouched, added by an older uf, edited, or a file of
the project's own under the same name. --json adds the packages each component
imports and the components it needs.
uf ui diff [NAME...] [--json]
How the project's copies differ from this uf's components, as a unified diff
with the registry's version on the old side, so a + line is in the copy and
not in the registry and the diff of an edited copy reads as the edit. With no
names, every component the project has is compared.
Above each diff is the sentence that makes it more than diff: that the copy is
untouched, that every line is the project's own edit because the registry has
not moved since the copy was written, or that the lines mix the project's edits
with the registry's changes.
uf ui update [NAME...] [--dry-run] [--offline] [--json]
Brings the project's copies up to this uf's components. When the project has edited a copy, this uf's changes are merged into it rather than written over the edit. With no names, every component the project has is updated.
uf ui update
uf ui update dialog --dry-run
| The copy | What uf ui update does |
|---|---|
| Untouched, and already this uf's version | Nothing |
| Untouched, written by an older uf | Replaces it with this uf's version |
| Edited, and this uf's version has not moved | Keeps it: every difference is the project's |
| Edited, and this uf's version has moved | Merges three ways, from the text the copy began as |
Not written by uf ui add | Leaves it alone |
A three-way merge needs a third text: the registry text the copy began as.
The stamp on the copy's last line records that text's SHA-256, so any
candidate can be checked against it. uf ui update looks in two places, in
this order:
- The project's git history. A copy is usually committed as
uf ui addwrote it and edited afterwards, so the first update finds its base here, offline. - The uf source at the release the stamp names (the tag
uf@<version>), fetched withcurl. A merged copy is stamped against this uf's text, which was never committed to the project, so later updates find their base here.UF_UI_REGISTRY_URLreplaces the address, with{version}and{name}in it. Point it at a mirror, or at afile://checkout on a machine with no network.--offlineskips this step.
A candidate is used only if it hashes to the recorded digest. A mirror or a
rewritten history can therefore make a merge impossible, but it cannot make a
merge wrong. When no base is found, the copy is left alone and the command
names the alternatives: uf ui diff to see both changes, or uf ui add --overwrite to take this uf's version.
The merged copy is stamped against this uf's text. It then reads as edited
since this uf's version, with every remaining difference the project's own, so a
later uf ui add still refuses to replace it. Edits that overlap are written
with diff3 conflict markers, which show the text the copy began as between
||||||| and =======. The run names each conflicted file and exits non-zero,
and uf check fails on the file until the markers are gone. Components that
the new versions import and the project lacks are written, and missing
packages are added first, the same as uf ui add.
Running and building
Dev and build is the guide to uf dev and uf build,
Targets to serving and deploying a build, and
Tasks to uf run and uf exec.
uf dev
Starts the dev server. --port PORT overrides dev.port, and fails if that
port is taken rather than moving to the next free one — a port you typed is the
port you meant, and a server listening one along is a server your bookmark, your
proxy rule and your container mapping cannot reach. dev.port keeps the moving
behaviour unless dev.strictPort says otherwise; it is a preference the project
wrote down once, not an instruction you just gave. --host HOST binds a
routable address, which requires a non-empty dev.allowedHosts.
--port 0 asks the operating system for a free one and prints the answer as the
local URL, which is the whole of what it is for: a caller that has to know the
port — a test, a script, a container that reads it back — otherwise has to pick
one, release it, and hope nothing took it in between. That race is real, and it
is why this exists; see ubugeeei-prod/uf#234.
--mode MODE runs in that mode instead of development, which is what chooses
.env.<mode> and what the browser reads as import.meta.env.MODE; see
Environments.
--target TARGET chooses the application target — web, native, ios or
android, with react-native accepted for native — and a react-native
framework project defaults to native, as it does for uf build. A native
target is developed with the project's own React Native dev server rather than
the builder: expo start when Expo is installed, react-native start when
React Native CLI is. Before starting it, uf loads the project's Metro config and
refuses one that does not compose withUniflowedMetro(), and prints the address
a device connects to. Everything after -- goes to that server untouched —
uf dev --target native -- --tunnel — and UF_BINARY names the uf that
started it, so the bundle is compiled by the same binary. --host is refused
for a native target, because each server spells binding its own way; pass that
server's flag after -- instead. See React Native.
--json prints one JSON object per line instead of the terminal display, for a
harness or an editor that drives the server: an event field names what
happened — config-loaded, phase, log, listening with the local and
network URLs, source-changed, env-changed, diagnostic, error — and the
other fields are that event's. Web target only; a native target refuses it.
For the web target, it also runs the server-component analysis, and runs it again whenever a module
changes, so a Server Component that reaches for useEffect or localStorage
is named where it was written rather than in a build days later. It is a report
and not a gate: uf dev does not refuse to serve a module over it, and nothing
here changes an exit code.
uf build
Builds the client and server bundles and prerenders what can be prerendered.
--size-report prints the cost of each chunk. A route that a
$middleware.js guards and the prerender wrote a document for is listed
under guards, and in .uf/build/meta/uf-build-manifest.json as
prerenderedUnderMiddleware — a static host serves that file without running
the guard, and which half of dist/ you deploy is the one thing the build
cannot know.
Every route the build wrote no document for is listed too, under answered by a server and in the same manifest as rendering.perRequest: a page with
parameters and no generateStaticParams, a page that exports dynamic = "force-dynamic", a $route.js handler. Those routes need a process, and
until they were reported the only way to find out that dist/ did not contain
one was a 404 after the deploy.
Which of the two a project will accept is
app.rendering.modes and
build.staticBuild. A project that has
said it deploys to a static host gets a build error naming the route
instead of a dist/ with a hole in it, and a project that has said it renders
everything per request gets no prerendered documents at all. The summary's
rendering row says which of the three happened.
The builder that does all of this is build.builder,
which is @uniflowed/vite unless a project names another, and it runs on
build.runtime, then runtime.
uf explain build prints the builder that will run, its version, and the
release it runs on.
The build's own notes go in .uf/build/meta/ rather than in the output
directory: uf-build-manifest.json, uf-rsc-manifest.json and
uf-bundle-report.json describe the application instead of being part of it,
and everything in dist/ is served. What the build does write into dist/ for
a reader that is not a person is sitemap.xml and robots.txt, when
site.url says where the application is served
from.
--mode MODE builds in that mode instead of production: it chooses
.env.<mode> out of the cascade and is what import.meta.env.MODE reads, so
uf build --mode staging is how one project produces a staging bundle. See
Environments.
The two bundles are not the same route table. A route with no "use client"
module reachable from its page, its layouts or its fallbacks keeps its page out
of the client one, so nothing in the browser imports it and nothing only it
reached is emitted — its stylesheet excepted, which is still linked, because a
route that ships no JavaScript still has to look right. The summary says
pages in the client bundle N of M when that removed something, and says
nothing when it did not; uf-rsc-manifest.json carries the decision per module
as proximity.
Two flags write something you can take away with you: --adapter TARGET
writes a directory for one deployment target — a host with a JavaScript
runtime, a container image, a Cloudflare Worker or a Lambda function — and
--compile writes a single executable file that needs not even a runtime.
Both are described below, beside the two ways to serve a build in place.
Everything above is the build of an application. A project whose
app.router.enabled is false takes a different one.
uf build --analyze
Writes a per-route analysis of the bundles to
.uf/build/meta/uf-bundle-analysis.json, and beside it
uf-bundle-analysis.html: the same analysis as a page that opens from disk,
with no server. For every route it lists each module the route ships to the
browser and runs on the server, what each weighs, and the chain of imports from
the route's own files to the module. That chain answers "why is this in my
client bundle". uf explain build points at the page.
A route's own files are its $page.js and every other $ file of its
directory and the directories above it: layouts, loading and error boundaries,
templates, middleware. What every page loads whatever the route — the router,
React, what app.js imports — is listed once, under shared, rather than
under every route. A client component a server component renders belongs to
the route that renders it, and its chain starts at that route's page and
crosses into the browser at the component.
The sizes are each module's code as the bundler rendered it into its chunk,
before the chunk is minified, compressed alone. They rank modules. What a
shipped file weighs is uf-bundle-report.json's answer, and the two are not
meant to add up.
| Field | Meaning |
|---|---|
version | 1 |
gzipLevel, brotliQuality | The settings every compressed size was measured at |
builds[] | One per bundle: name (client, ssr, rsc), side (client or server), modules (how many it has code for) and size |
shared.client, shared.server | What every route loads: size, and modules[] |
routes[].path | The route, as uf routes prints it |
routes[].files | The route's own files that are in a bundle, page first |
routes[].client, routes[].server | What the route loads beyond shared: size, and modules[] |
routes[].total | client and server: what the route costs, shared included |
modules[].id | The module, relative to the project; a virtual module keeps its id |
modules[].build | The bundle that has its code |
modules[].chunk | The file its code is in |
modules[].size | raw, gzip and brotli, in bytes |
modules[].chain | Module ids from one of the route's files — under shared, from a bundle's entry — to this module |
modules[].dynamic | Whether the chain goes through an import() |
modules[].routes | How many routes list it; under shared, every route |
Every modules[] is sorted largest gzip first.
The analysis is made from uf-module-graph.json, which the builder writes in
the same directory when it is asked to: every module of each bundle, what it
imports, and which chunk its code went into. @uniflowed/vite writes it. With a
builder that does not, --analyze fails and names the file, instead of
reporting an application that ships nothing.
uf build, for a library
A project with the file-system router off is a library, and uf build
compiles its entries to the output directory instead of bundling an
application. Nothing above happens: there is no route table, no client entry,
no server bundle, no prerender and no sitemap.xml.
uf build
build library
because `app.router.enabled` is false, so this project is a
library rather than an application
engine vite
entries index.js
formats es
external packages 0
Every package the manifest declares stays an import — dependencies,
peerDependencies, optionalDependencies, and the host's built-in modules —
which is the opposite of the application build and the reason a library does
not ship a second copy of React to everyone who installs it.
build.lib is where the entries, the formats and
any extra externals go, and its defaults are what uf new --lib scaffolds, so
most libraries write none of it.
--watch builds once and then again whenever a source file changes —
JavaScript, declarations, budgets and the manifest, the whole build each time.
A failed rebuild is reported and the watch goes on, so the next save can repair
it. It is for libraries only: on an application it is refused with a pointer to
uf dev, which is what watches one.
--compile and --adapter are refused here, by name and before anything is
built: both write a deployment that serves an application, and a library is
imported. What sends a library anywhere is uf publish.
The build warns when package.json names an exports target inside the output
directory that it did not write — a package that installs and cannot be
imported, which every other check passes.
Serving a build
Three commands serve what uf build wrote. They are not alternatives you pick
by taste; they differ in what has to be installed on the machine that answers
the request. Deploy a web build is the guide version of this
choice; this section is the reference for each command's exact behaviour.
| Command | Server | The host needs | Use it to |
|---|---|---|---|
uf preview | Vite's preview server | the project, its dependencies and Vite | check a build on your own machine |
uf start | uf's own node:http server | a JavaScript host, node_modules, and the build | serve from the checkout you built in |
uf build --adapter TARGET | the target's own entry, inside the output | whatever the target is | copy a directory to a host, or upload it |
uf build --compile | that server, inside the executable | nothing at all | deploy one file |
What they must not differ in is the answer, and they do not: an asset or a
prerendered document is served first, then a route handler is offered the
request for any method, and anything left is rendered. That order is Vite's
rather than a preference — its preview server runs its own file middleware
before anything uf mounts behind it — so uf start matches uf preview, and
every adapter's output and a compiled binary match both. A route that works
when you check it works when you deploy it, whichever of them you deployed
with.
They are the same code, not merely the same idea: the application is
@uniflowed/server/fetch in every one of them, and what differs is the host
half — @uniflowed/server/node for a socket and a directory,
@uniflowed/server/edge for a Worker, @uniflowed/server/lambda for an
invocation.
uf preview
Serves the build through Vite's own preview server, on 4173. It is the step
between uf build and deploying: the one place you find out that what worked
in uf dev also works bundled, minified, hashed and served from dist/.
It serves the whole build, not only the files. Vite's preview server is a
static file server, and a uf application is more than static files — a route
handler answers a POST, and a route with parameters and no
generateStaticParams was never prerendered — so uf's request handler is
mounted behind Vite's, and both are answered. Anything you set under vite.preview
in uf.config.js — proxy, https, headers, cors — is in effect, because
this is Vite's server.
--port PORT binds that port or fails, the same rule uf dev follows.
--host HOST needs a non-empty dev.allowedHosts, because Vite validates a
preview's Host header against it and a preview bound to a routable address
with nothing allowed would refuse every request. --mode MODE chooses the
.env files, production by default.
For a project that emits no server — build.staticBuild, or a
rendering.modes that allows only ssg — nothing is mounted behind the file
server, and that is the point rather than a limitation: what a static host does
with the output directory is exactly what this then does with it, so the
preview is right about the deployment that is actually happening.
uf start
Serves the same build through uf's own server, with no bundler in the process. This is what a deployment runs: a host serving a built application should not need the tool that built it.
It answers exactly what uf preview answers — one request handler, two front
doors — so a route that works when you check it works when you deploy it.
Static files from dist/ first, then route handlers, then a render for
anything the build did not prerender.
Binds 0.0.0.0:3000, and reads HOST and PORT from the environment, which
is how a container or a process manager says which socket to take. --host and
--port override both, and the port you name is the port it binds or it does
not start.
The .env files are read when the server starts, in production mode unless
--mode says another, so a value the server reads through process.env can be
changed without rebuilding. A variable already set in the environment — which is
how a container or a secret manager provides one — always wins over a file. See
Environments.
A project whose build emits no server is refused, by name, rather than
served: build.staticBuild says the build leaves documents behind, and a
uf start that quietly served them would be uf answering requests the real
host answers. Deploy the output directory to any static host, or check it with
uf preview.
uf build --adapter TARGET
Writes .uf/deploy/TARGET/: everything the target needs and nothing that is
still in your checkout — no node_modules, no source, no uf. Eight targets
are implemented. Seven of them run an application and share every line of it;
the eighth runs none:
| Target | What you get | What runs it |
|---|---|---|
node | handler.js, server.js (ticking any declared schedule), chunks/, static/, package.json | cd .uf/deploy/node && node server.js |
bun | the same file names, with Bun.serve and Bun.file behind them | cd .uf/deploy/bun && bun server.js |
deno | the same file names, with Deno.serve and Deno.open behind them | cd .uf/deploy/deno && deno run --allow-net --allow-read --allow-env --allow-write=.uf server.js |
container | the same, plus Dockerfile and .dockerignore | docker build -t NAME .uf/deploy/container |
edge | handler.js, worker.js, wrangler.json (with triggers.crons and a scheduled() for any declared schedule), chunks/, static/ | cd .uf/deploy/edge && npx wrangler deploy |
serverless | handler.js, lambda.js, chunks/, static/, package.json | zip it, upload it, set the handler to lambda.handler |
vercel | .vercel/output/config.json and one function, .vercel/output/functions/uf.func/: handler.js, index.js, .vc-config.json, chunks/, static/, package.json | cd .uf/deploy/vercel && vercel deploy --prebuilt |
static | your build, file for file, and nothing else | upload it to any static host |
handler.js is the seam and is identical work in the seven that have one: it is
@uniflowed/server/fetch's handler over your server bundle, it touches no
filesystem, and it is the same function uf preview and uf start answer
through. An adapter is the file beside it and an answer for where static/
lives — not a second application.
static has no handler.js at all, because a static host runs no
application — see static below, whose whole implementation is the
refusal.
The one thing handler.js does say per target is what that target can do,
because two of the answers differ:
| Target | A body reaches the client as it is produced | The process outlives the response |
|---|---|---|
node, bun, deno, container | yes | yes |
edge | yes | no — the isolate may be torn down once the response is out |
serverless | no — a Lambda response is a JSON value, so the body is read to the end first | no |
vercel | yes — the function declares response streaming | no — an invocation is frozen once its response is sent |
A route handler that returns an event stream, takes a WebSocket, or queues work
asks through those, and is refused by name rather than by a connection nobody
serves: an event stream on serverless fails where the handler builds it
instead of timing out, and a WebSocket upgrader or an in-process queue handed
to a target that cannot keep it fails where the host is wired instead of on the
first connection. Routing has the whole table and the API.
The difference from uf start is what the host must already have. uf start
reads dist/ and .uf/build/server and needs the project's node_modules
around them, because the server bundle leaves react and react-dom as bare
imports. An adapter's output links them in, so the directory is the whole of
what has to travel.
No server adapter's output has ever been deployed. uf is developed in a
sandbox with no cloud credentials, no Docker daemon and no ability to bind a
socket. What its tests establish is that each directory has the file set the
platform documents, that the emitted entry answers when it is driven in
process, and that all six answer identically — byte for byte, the same as
uf start, including a loader's redirect(), whose whole answer is a
Location header. None of that is a deployment. The first person to run
wrangler deploy or upload the zip is finding something out, and
#956 is where to say what. The
Deploy parity workflow is written to deploy the node/container, edge and
serverless outputs and ask them the same questions over the network; it skips
each platform by name until the repository has that platform's credentials, and
it has not run against any of them yet. Deploy has the list of
secrets, the adapter contract a platform can implement itself, and what happens
to a tab left open across a deploy.
static is the exception, and only because there is nothing to deploy to: a
directory of files is what every static host serves, and uf's own
documentation site is built through this target in the test suite.
node and container
cd into the copy and node server.js; --port and PORT choose the port,
--host and HOST the address, the same way uf start reads them.
container writes exactly what node writes, plus a Dockerfile and a
.dockerignore. The Dockerfile is a template and its first line says so.
It has no build stage — the build already happened and the directory is its
result — and nothing to install, because the whole claim of an adapter's output
is that it needs no node_modules. It pins node:24-alpine, runs as the
node user — who owns /app, so the route cache can create .uf/cache there —
and exposes 3000. uf does not build the image, does not tag it,
does not push it, and does not keep the base image current: it is a file in
your output that you are expected to read and edit.
edge
The target is Cloudflare Workers, and the wrangler.json beside
worker.js is what makes the directory one. You need Wrangler
installed and a Cloudflare account authenticated with it; uf runs neither.
Three lines of that file are load-bearing, and it is yours to edit once you have read them:
"compatibility_flags": ["nodejs_compat"].handler.jsimportsnode:async_hooks, because@uniflowed/server's request context is anAsyncLocalStorage— that is what makesheaders()andcookies()work without threading a request through every component. Without the flag the script does not link at all. It is the only Node built-in the bundle imports, which a test asserts, so nothing else about your compatibility date is uf's business."compatibility_date": "2024-09-23"— the earliest date at whichnodejs_compatprovides that module. It is pinned rather than set to the day of your build, because a date ahead of the runtime you deploy onto is an error from Wrangler and a build whose output changes with the calendar is a build nobody can reproduce. Bump it yourself when you want newer runtime behaviour."run_worker_first": true, with"binding": "ASSETS"and"not_found_handling": "none". Cloudflare's default is to serve a matching asset without invoking your script; uf asks for the asset from inside the Worker instead, so the file-then-handler-then-render order is uf's own and is the same orderuf startuses. A miss comes back as a 404 the Worker falls through, so the 404 a visitor sees is your$not-foundpage rather than Cloudflare's.
A Node built-in the Worker only stubs is named at the build. At uf's
compatibility date a Worker provides node:child_process, node:cluster,
node:fs, node:http, node:http2, node:https, node:repl and node:wasi
as modules whose every function throws. uf build --adapter edge prints a
warning for each one the server bundle reaches, naming the file that imports
it: your own code first, then a dependency by its package name. It is a warning
and not a refusal, because a wrangler.json of your own at a newer date may
provide the module. A request that does call one answers 500, and the log line
names the function as a Node API this Worker does not provide. The list is a
measurement, taken again against workerd by tools/ci/edge-worker-smoke.sh on
every CI run.
Logs are filed at their level. The generated worker.js writes each log
level through the console method of the same name. A Worker's own log store
reads the method, so an info access line stays information rather than
showing up as an error in wrangler tail. A logger your application installs
with installLogger is kept. This applies only inside a Worker: imported under
Node, for example by a test, worker.js keeps writing every level to stderr.
Streamed responses are sent uncompressed. A document, a navigation's RSC
payload and an event stream that the application answers carry
Content-Encoding: identity, because a Worker's response is otherwise
compressed for a client that accepts it and a compressor holds a streamed
shell until the body ends: under wrangler dev a static shell the Worker sent
at 4 ms reached a browser-like client at 1.2 s, together with the hole it was
sent ahead of. Assets, JSON and every other type keep whatever compression the
platform applies, and a response that names its own encoding keeps it. The
cost is compression on those three types; whether Cloudflare's production
edge buffers the same way has not been measured by uf
(#1494).
React is linked from its server.edge.js build for this target, so a document
comes out of renderToReadableStream rather than the Node renderer. That is a
different code path from every other target, and the test that says all six
adapters answer identically is the one that covers it.
What uf does not do: create the Worker, upload the assets, configure a route or
a custom domain, add a KV namespace or any other binding, or set a secret. Add
your own bindings to wrangler.json after reading it — it is regenerated on
every build, so keep a copy or commit a wrangler.jsonc of your own and point
main at the generated worker.js.
after() is different here, and only here. A Worker has no line at which
the last byte went out, so settle is handed to ctx.waitUntil: an after()
callback begins once the response has been decided rather than once it has
been sent. For a streamed document those are a document apart.
serverless
The target is AWS Lambda, and the event format is payload format 2.0 — what a Lambda Function URL always sends and what an API Gateway HTTP API sends by default. An API Gateway REST API sends format 1.0 and an ALB sends its own shape; neither is supported, and an event that is neither is refused with a message saying which format the handler reads rather than answered from fields that happen to be undefined.
Zip the directory, upload it, and set the function's handler to
lambda.handler on a Node.js 20 or later runtime. uf does not create the
function, does not zip it for you, does not configure a Function URL or an API
Gateway, and writes no infrastructure-as-code of any kind.
Two limits are worth knowing before you choose this target:
- The response is buffered.
createFetchHandleranswers a document as a stream so a<Suspense>fallback reaches the browser first; a Lambda response in this format is a JSON value, so the whole body is read before the invocation returns and none of that streaming survives. Response streaming exists —awslambda.streamifyResponse, on a Function URL whose invoke mode isRESPONSE_STREAM— and uf does not implement it, because the wrapper is a global the managed runtime injects and nothing outside a real invocation can drive it. AWS's 6 MB response payload limit applies to what comes back. static/is inside the package. The function answers a prerendered document and a hashed asset the moment it is uploaded, with no other infrastructure existing — which is the shape that works, not the shape to keep. Every byte is then billed as invocation time and counted against the package limit. Put CloudFront or S3 in front of it and the application half answers alone, exactly as it does on a Worker.
Cookies are handled where the format puts them: the event's cookies array
becomes the request's Cookie header, and every Set-Cookie a response sets
comes back in the result's cookies rather than in headers, because
iterating headers would join two cookies into one field nothing can parse
apart. A response body whose media type is not text comes back base64-encoded.
vercel
The target is Vercel, as its Build Output API (v3): the
directory vercel deploy --prebuilt uploads without building anything again.
.vercel/output/config.json routes every request to one Node.js function,
functions/uf.func, whose .vc-config.json names index.js as the handler
for Vercel's Node.js launcher, asks for response streaming, and turns off
Vercel's request helpers (uf parses the request itself). index.js is
@uniflowed/server/vercel's listener over the same handler.js: files from
static/ first, behind app.router's redirects and headers, then the
application — the order node server.js answers in.
Link the directory to a project once (vercel link inside
.uf/deploy/vercel), then deploy it with vercel deploy --prebuilt. uf does
not create the project, link it, or hold a token.
Three things to know before choosing it:
- Every request reaches the function, files included. Vercel's CDN could
serve
static/itself, and would be faster; uf does not route files there yet, becauseapp.router's rules have to be applied in front of them and translating those into Vercel's route grammar would be a second implementation of them. A function invocation per asset is the cost. - Regenerated pages need a provider. A function instance's memory and its
/tmpgo with the instance, so a build that regenerates pages names a module inrendering.cache.storeor is refused by name, as onserverless. - A declared schedule is refused, as on
serverless: uf writes nocronsintoconfig.jsonyet.
Work that outlives the response — after(), a durable cache write — is handed
to Vercel's waitUntil when the runtime's request context offers one, and is
awaited before the handler returns where it does not.
static
The output is your build — dist/, file for file, at the top of
.uf/deploy/static/ rather than inside a static/ beside a server. There is
no handler.js, no server.js and no package.json, because a static host
serves whatever is in the directory and a file the application never mentions
is a URL you did not mean to publish. Upload it to a CDN, an object store, or
anything else that returns files; uf names no host, because naming one would
be uf choosing a hosting company on your behalf.
The implementation is the refusal. uf build has always written dist/,
so this target adds no output. What it adds is that a project a static host
cannot serve is now rejected by name instead of published with holes in it:
| What | Why a file is not it |
|---|---|
a $route.js | it answers a request, and a POST has no file behind it |
a $middleware.js | it runs once per request, before the route resolves, and nothing runs on a static host |
a route the prerender wrote no document for — parameters and no generateStaticParams | there is nothing to upload for it |
a "use server" export the browser can call | an action is a POST back to the application, at the page's own URL |
Each finding names the URL, the file it is in, and what to do instead — which
is usually --adapter node, since the application is the same file in all
six server targets and the project does not have to change to use one. One
prerendered document is enough for a parameterised route: a
generateStaticParams that named three slugs wrote three files, and whether it
named every slug is a question about your data that no build can ask.
bun
The same two files as node, with @uniflowed/server/bun in place of
@uniflowed/server/node: Bun.serve for the socket and Bun.file for a
static asset. Everything else — the application, the path policy, the
containment and symlink checks, the draft rule — is the same code the node
adapter runs, so the two are one implementation with one line swapped.
It exists because the benchmark this adapter was waiting on was run, on the
same emitted handler.js, and Bun won both halves of a request:
| path | node | bun | |
|---|---|---|---|
dynamic — /api/health, c=32 | 14,006/s | 18,480/s | +32% |
| static — 215 KB asset, c=32 | 5,742/s | 7,251/s | +26% |
| static — 508 B asset, c=64 | 18,125/s | 27,862/s | +54% |
The static half is the reason bun.js exists rather than the node output
being blessed for Bun. Bun.serve handed a Node stream measures 5,670/s on
that 215 KB asset against node:http's 5,732 — level. The win is Bun.file,
and nothing else. #391 has
the method and the limits.
deno
The same two files as node, with @uniflowed/server/deno in place of
@uniflowed/server/node: Deno.serve for the socket and Deno.open for
static assets. The generated command includes --allow-net, --allow-read,
--allow-env and --allow-write=.uf, because those are the capabilities
the artefact uses: take a socket, read static/, honour HOST/PORT, and keep
the route cache's regenerated pages in .uf/cache (a build with isr creates
it at start-up). Writing is scoped to .uf, the directory uf keeps its own
state in.
app.runtime.deploy.adapters lists what this toolchain can actually produce,
and uf explain build names the adapter a build will run.
uf build --compile
Writes dist/NAME, a single executable file that serves the application, in
addition to everything uf build already wrote. It answers with the
prerendered documents, the hashed assets, the route handlers, and a fresh
render for any route that could not be prerendered — all of it inside the file,
so the file can be copied into an empty directory on an empty machine and
started. --port and PORT choose the port, --host and HOST the address,
the same way uf start reads them.
The difference from uf build --adapter node is one more step down: that
writes a directory and needs a JavaScript runtime to run it, and this carries
the runtime too and needs nothing — which is also the trade, because the copy
inside it is fixed at build time and changing a single asset means building
again.
Producing one needs a runtime to embed, and which one is the project's
choice: uf reads app.runtime.capabilityJsHost, the setting uf dev and
uf test fall back to when no runtime is declared, and uses the first of the
accepted hosts that can build a binary. It does not read
runtime yet
(#1036). Running one still
needs nothing.
| host | how | cross-compiles | needs |
|---|---|---|---|
bun | bun build --compile | yes | bun on PATH |
node | node --build-sea, a single-executable application | no | Node 25.5 or newer |
deno | — | — | not written; uf says so and names the two that are |
The summary's standalone block names the runtime and its version, because
"compiled" is now one of two node: API surfaces and which one you shipped is
a fact about the artefact. A host that cannot be used is passed over with a
reason — node below the floor, no bun installed — and a project that set
autoDetect: false has one candidate, so the reason is the refusal.
Single-executable support is compiled into a node binary as well as being a
version of it, and a few distributions ship it turned off; that shows up as
sentinel NODE_SEA_FUSE_… not found. uf cannot see that in advance, so it
tries the next accepted host instead and says so — neither backend compiles or
evaluates your application, it only wraps a runtime around it, so a failure
there is never yours. A project with autoDetect: false has one host and gets
that failure as the build's.
--target TRIPLE builds for a machine that is not this one — the triples uf
publishes itself for, so one vocabulary rather than two:
$ uf build --compile --target x86_64-unknown-linux-gnu
aarch64-apple-darwin, x86_64-apple-darwin, aarch64-unknown-linux-gnu,
x86_64-unknown-linux-gnu, aarch64-unknown-linux-musl,
x86_64-unknown-linux-musl, aarch64-pc-windows-msvc and
x86_64-pc-windows-msvc. Anything else is refused with that list before the
build starts. Cross-compiling is Bun's backend only, so a project pinned to
Node is told that Node cannot do it rather than handed a binary for the wrong
machine — and a project on the default configuration falls through to Bun,
which the summary names.
It works by downloading that platform's runtime, about 90 MB, and uf says so
before the bundle rather than going quiet in the middle. The download is cached
at .uf/cache/bun inside the project, so a build behaves the same on a
laptop and on a sandboxed runner that cannot write to $HOME; set
BUN_INSTALL_CACHE_DIR yourself to share one cache across checkouts. A
runtime already in the cache is not fetched again. Which platforms a given Bun
actually has runtimes for changes between its releases, so a target Bun
declines is reported with the triple you typed and the Bun that declined it.
If the project declares permissions, the
compiled binary carries them: Node bakes --permission and its --allow-fs-*
flags into the executable, so the set is in force on a machine uf will never
see. Bun has no permission model and no equivalent, so --compile on Bun
refuses a project that declared one rather than shipping a binary that looks
sandboxed and is not. Unlike a uf test run, uf adds no grants of its own — a
compiled binary loads no project — so what you declared is the whole of what
it may reach.
A project that imports a native .node addon cannot be a single file, and the
build says which addon and which importer rather than producing something that
fails on its first request — but an addon loaded through a runtime string, with
process.dlopen or require(variable), is invisible to any bundler and will
still compile and still fail when a request reaches it.
uf doc
Generates an offline API site from JSDoc attached to exported Flow declarations. Meta's Flow parser reads Flow-only syntax directly; ox-content renders the Markdown body. The page uses uf's paper and ink colours, brand type, module sidebar and continuous seam, and follows the system's light or dark theme.
uf doc
uf doc --out dist/api
uf doc --format markdown
uf doc --json
The default docs/api/ contains index.html, api.md and local uf design
assets. Open index.html directly or deploy the directory to a static host.
Reading it needs no JavaScript, external theme or font request. --format markdown writes only api.md; --json reports without writing files.
Raw HTML in documentation comments is escaped in the generated site.
uf run SCRIPT [ARGS...]
Runs a task from uf.config.js, and everything its dependsOn reaches,
forwarding everything after the script name. The task has the project's .env
values in it, as every command uf runs does; --mode MODE, before the script
name, chooses which files those came from. An option uf run also has is
reached past --; any other option after the script name is the task's.
With no script, uf run offers the project's tasks as a list to pick from —
type to filter, arrows to move, Enter to run — when stdin and stderr are a
terminal and CI is unset. Everywhere else, and with --list, it prints them
as a table, each with the arguments it declares.
Declared arguments. A task whose args
declare its arguments takes them after the script name in that order, or as
--name value / --name=value in any order; the values are appended to the
command in declaration order, one word each, followed by anything the
declaration does not name. A value outside an argument's choices is refused
by name before anything runs, and an argument that is not given takes its
default. One that is required and still missing is asked for at a terminal —
a filterable list for an argument with choices, a line to type into for one
without — after which uf prints the command it resolved to and the uf run
line that runs it without asking. Anywhere a picker is not offered, it is an
error naming every missing argument and its choices, exit status 1. uf prepare
appends its staged files without filling declared arguments, so each takes its
default.
uf starts the command itself when it can. A command that is a program and
its arguments — with the quoting, escaping and word splitting a POSIX shell
would apply, plus any NAME=value written in front of the program — is
started directly, with no shell on any platform. That covers every task in
this repository's own uf.config.js. A program written as a path
(tools/ci/publishable.sh) is resolved against the directory the task runs
in; a bare name (cargo) is looked up on PATH.
A command that asks the shell to do something — |, &&, ;, a
redirection, $VAR, a backtick, an unquoted *, a leading ~, a newline
with a command on both sides of it, or a first word that is a shell keyword or
a built-in with no program behind it (cd, exit, export) — is handed to
sh -c, exactly as before. On a
machine with no POSIX shell — Windows, unless one is installed — uf refuses
that task by name and says which construct needed one, instead of failing to
find a program nobody wrote. Two consequences worth knowing:
- uf runs programs, not built-ins.
echoin a direct command is theechoonPATH, which does not interpret\nwhere a shell's built-in does.printfmeans the same thing either way. - a script a task names needs its
#!line. A shell runs a file it cannot execute with itself; starting a program directly has no such fallback. - a task written as a program and its arguments is the portable one. Anything
else is a POSIX shell script, and uf will not reinterpret it under
cmd.exe's rules.
Dependencies with no path between them run at once — four at a time, or
one per core when the machine has fewer, and -j N / --concurrency N says
otherwise. -j 1 is one at a time. While more than one task can be running,
each line of output is written behind the name of the task that produced it;
the task you asked for runs after everything it depends on, so it is alone and
its output is untouched.
A task that declares inputs is
cached: uf hashes the files those patterns match, and a second run whose
files all hash the same is answered from .uf/cache/task — output and all,
so a replayed run reads like the run it is replaying. A task that declares
no inputs runs every time. That is the default and it is not a soft one:
uf knows what a task starts and not what it opens once it is running, and a
cache that guessed would fail by reporting a check as passed without looking.
--force runs everything whatever the cache holds, and still records what it
finds. --why prints, for every task, the reason it ran or was replayed —
npm/core/index.js changed, declares no inputs, so it always runs,
dist/app.js is missing.
Across a workspace. -r / --recursive runs the task in every workspace
member that defines it, each after the members its package.json depends on;
the workspace root's own task is not one of them. --filter SELECTOR runs it
only in the members the selector picks, implies -r, and may be repeated: a
name, a glob over names and paths, a path (./npm/ui), name... for a
member and everything it depends on, name^... for only those, ...name for a
member and everything that depends on it, and ...^name for only those. A
selector that picks no member is an error. member#task in dependsOn names a
task in another member, by the member's name or path, from the workspace root or
from inside a member. Each member's results are kept in its own
.uf/cache/task, a task that depends on a task in another member is keyed on
that task's result as well, and --why prints the graph first when a run spans
more than one member.
uf exec PACKAGE [ARGS...]
Runs a package's binary, forwarding everything after the name to it and
exiting with its status. Also spelled ufx.
An installed binary — anything in this project's node_modules/.bin — runs
without ceremony. A package uf implements itself, such as @uniflowed/create,
runs in this process.
A package the project has not installed is refused, and uf exec says so
and exits non-zero. Fetching an unpinned name from a registry and executing it
is the most dangerous thing a package manager does, and uf already refuses to
run a dependency's install scripts without being asked; --yes is how you ask
here, and it delegates to whichever package manager the project uses (npx --yes, pnpm dlx, yarn dlx, bunx).
Correctness
Type checking is the guide to uf check,
Formatting and linting to uf lint and uf fmt, and
Testing to uf test.
uf check
Type-checks the project with Flow. --json for the machine-readable report.
--fix and --fix-unsafe are uf lint's, and mean the same thing here: they
apply the lint fixes. Type inference has no fix catalogue — a type error is
not something uf knows how to rewrite. --explain-any <package> lists every
place a dependency typed from its TypeScript declarations is any: each hole
the translation left, and each error Flow reports inside the translation. Every
entry gives the declaration file, the line and the declaration. --no-lint
type-checks the same files without linting them: the report's diagnostics is
empty and typeCheck is what it would have been, for a caller that only wants
to know what Flow says and would otherwise spend most of a warm run on rules.
The --json report has two lists of diagnostics
uf check is uf lint plus inference, and the report keeps the two kinds of
finding apart — so errors is a total, and diagnostics is not where the
type errors are:
| Field | What is in it |
|---|---|
errors, warnings | The sum across both lists |
diagnostics | Lint findings only — the same array uf lint --json writes |
typeCheck.diagnostics | Type errors, from the inference backend |
typeCheck.status | checked, or why inference did not run |
typeCheck.backend, .filesChecked, .filesFromCache, .elapsedMs | What inference did and how long it took |
typeCheck.translatedPackages | Each dependency typed from its TypeScript declarations, with its holes and findings counts |
typeCheck.explainAny | With --explain-any, that package's holes and findings, each with path, line and declaration |
unavailableRules | Lint rules that need Flow type inference. uf lint does not run them; uf check does, and then drops them from this list |
A reader who counts diagnostics and reports that gets zero for a project with
twenty type errors, because a project can fail uf check on inference alone
and have nothing to say about its lints. Read errors for the verdict and both
arrays for the detail.
uf lint
Runs uf's rules and Flow's built-in lints. --json for the report.
| Flag | Meaning |
|---|---|
--fix | Rewrite the files, applying every fix that cannot change what the program does |
--fix-unsafe | Also apply the fixes whose correctness rests on something the rule could not check |
--json | The whole report on stdout, machine-readable |
--rules | List every rule, its level in this project and what fixes it, instead of linting |
Without a --fix flag nothing is written. That is the --check-like use, and
it is what CI and a git hook run.
The report groups the findings under the file they are in, and ends on one
line — the verdict, the number of files read and how long it took — followed
by what to do next: how many findings --fix, --fix-unsafe or uf fmt
would clear, and how many enabled rules were skipped because they need type
inference. It does not list the files again at the end; --json has every
finding with its path.
uf lint --rules lints nothing. It lists every rule with the level a run in
this project would use — uf.config.js over the defaults, deprecated rule
names included, so a rule the project turned off says off — the command that
fixes its findings (--fix, --fix-unsafe, uf fmt, or nothing) and what it
checks. A level marked * belongs to a rule that needs Flow type inference,
which uf check runs and uf lint does not, so a lint run names it under
unavailableRules instead of running it. With --json each entry is the one uf inspect --json
prints under lintRules, plus level and fix, where fix is "safe",
"unsafe", "formatter" or null. It exits 0, and it cannot be combined
with --fix, --fix-unsafe or a path.
What "safe" and "unsafe" mean here
The catalogue lives in crates/uf_cli/src/fix.rs and carries the distinction
itself, so every caller — the command line, uf prepare, and the language
server — draws the line in the same place.
A safe fix is one where the program after the edit means what the program
before it meant: the replacement is a synonym the language already treats as
the original. bool → boolean (flow/deprecated-type) is the one uf has.
Nothing is being decided on your behalf, so --fix applies it without asking.
An unsafe fix is the edit the rule is asking for, spelled correctly, whose
correctness rests on something the rule could not check. export let count = 0 → export const count = 0 (flow/non-const-var-export) is one: the file
still parses and still formats, and it throws at run time if anything reassigns
the binding. import.meta.hot.accept(…) → import.meta.hot?.accept(…)
(vite/hot-needs-optional-chaining) is the other, and it is the clearer
illustration — in a dev server the two spellings are the same program, and in a
build they are not, which is the bug being fixed and is still a change. Neither
is a guess at intent — a fix uf cannot spell at all is in neither tier — but
both can change what the program does, so they wait to be asked for by name.
Read the diff before committing it.
Most rules have no fix in either tier, and that is deliberate rather than
unfinished: flow/unclear-type has three plausible answers, which is none, and
the module header lists the rest with the reason for each.
What --fix guarantees
- What is written parses. Every candidate goes back through the formatter, which prints from a syntax tree and fails on text the parser refuses. A file whose fixed form does not parse is left exactly as it was and named in the report.
uf fmt --checkstill passes. A file the formatter was content with is reprinted around the edit, because four bytes is enough to push a line past the print width. A fileuf fmtalready wanted to change is written as the edit left it — imposing a layout is a different command.- Twice is once. Each file is linted, fixed and linted again until its text
stops changing, so a second
uf lint --fixwrites nothing. - Two fixes over the same bytes cannot corrupt each other. One of an overlapping pair is applied and the other is dropped; the loop above lints the result and plans the dropped one again against text that now exists.
uf lint --fix does not format. The answer to uniflowed/no-tabs is uf fmt,
and findings like it are counted in the report rather than quietly reformatted.
Exit status
The status describes what is left, never what was done. A run that fixed forty findings and left one error fails; a run that fixed nothing and left none passes.
| Status | Meaning |
|---|---|
0 | No errors remain. Warnings may — they do not fail uf lint with or without --fix |
1 | Errors remain, a file could not be read, or writing a fix failed |
There is no third status for "fixed everything": the two the command has are
the two a shell script branches on, and uf lint --fix; uf lint cannot
disagree with itself, because the report --fix prints comes from linting the
project it has just rewritten.
uf fmt
Formats the project. --check reports what would change and exits non-zero
instead of writing.
JSON, CSS and TypeScript go to fmt.nonFlow.formatter — Biome by default —
which uf runs rather than ships. A formatter uf chose and the project does not
have is reported, with the files it left alone, and does not change the exit
code; one the project named in uf.config.js is an error, because the project
asked for it.
uf fmt stays a separate command rather than folding into uf check the way
some toolchains fold their formatter into one pass. Three commands where
another tool has one is the trade uf makes on purpose: uf fmt --check is the
cheapest of the three and the one a hook wants first, and a uf check that
also rewrote files would be a type checker that writes.
uf test [PATH...]
Runs the test suite. A PATH argument keeps only files whose path contains it.
| Flag | Meaning |
|---|---|
-t PATTERN, --filter PATTERN | Only tests whose fully qualified name contains PATTERN |
-j N, --threads N | At most N files at once; defaults to one per core |
--bail[=N] | Stop after N failures; N defaults to 1 |
--retry N | Re-run a failing test up to N more times |
--watch | Re-run affected tests when a source file changes |
--watch-interval MS | How often --watch looks for changes |
--changed REF | Only the test files a change since REF reaches: files that import a changed file, directly or through other modules. A change is anything that differs from where HEAD's history left REF, staged or not, and any untracked file git does not ignore. A change to uf.config.js, a package.json, a lockfile or a .env file runs the whole suite |
--host HOST | Run on this installed JavaScript host — node, bun or deno — instead of test.runtime |
--browser | Run each test file in a page of a real browser instead of on the DOM shim, so layout and computed styles are the engine's. uf downloads no browser: UF_BROWSER chooses an installed one, and with none installed the run is refused rather than falling back to the shim |
-u, --update-snapshots | Rewrite each snapshot that did not match. Off unless asked for |
--shard INDEX/COUNT | Run one part of a suite split across machines, like 2/3. Every shard cuts the same partition, weighted by .uf/test-timings.json, and writes its record to .uf/test-shards |
--merge-shards [DIR] | Run nothing: report the shard records in DIR (default .uf/test-shards) as one run — summary, --json, JUnit and coverage — and fail if the suite failed |
--bench | Run the benchmarks bench() declares instead of the tests, one file at a time unless -j says otherwise, and fail on a median past its baseline |
--baseline FILE | Read the benchmark baseline from FILE instead of .uf/bench-baseline.json |
--save-baseline | Save this run's medians as the baseline rather than comparing against it |
--bench-threshold PERCENT | How far past its baseline a median may go before the run fails; 20 unless given |
--list | Print what would run without running it |
--json | The whole report on stdout, machine-readable |
--mode MODE | Run in that mode instead of test, which chooses .env.<mode> |
--coverage | Measure which Flow lines the suite executed. Node only |
--coverage-reporter FORMAT | text, lcov or cobertura; repeat for more than one. Overrides test.coverage.reporters |
--coverage-dir DIR | Where the reports go, overriding test.coverage.directory |
--reporter junit | Write the results as JUnit XML. Needs --reporter-outfile |
--reporter-outfile FILE | Where --reporter writes |
Each file is reported on stdout as it finishes — its mark, duration and
counts, and under it any failure with its code frame — and the summary follows
once the run is over, naming the failures again. On an interactive terminal a
live region on stderr has a row for each file being run — its cases so far out
of those it declares, and the case that just finished — and a last row with the
run's cases, files done of the total, failures and elapsed time; it is redrawn
in place and removed before the summary. With CI or NO_COLOR set, or on a
pipe, the per-file lines still stream and nothing is redrawn. --json prints
one document at the end and nothing while it runs.
With test.splitFiles set in uf.config.js, a file long enough to hold up the
run — longer than half a worker's share of the suite, by the durations the last
run recorded — runs as shares on several workers, each importing the file and
running its own contiguous run of cases. The report is the one an unsplit run
makes. It is off unless a project turns it on, because it holds only for a
suite whose cases do not rely on the ones above them having run in the same
process.
Coverage is reported against the Flow source, mapped back through the
transform's own source map, and a threshold in uf.config.js that is not
reached fails the run. --watch and --coverage cannot be combined.
uf prepare
Runs the checks and code generation a commit should not go without. Six steps, in order, and every one of them does something:
| Step | What it does |
|---|---|
discover-staged-files | Asks git for the files in the index, and narrows the checks to them |
generate-router-types | Writes router.js from the reserved page files |
generate-server-action-types | Writes server-actions.js: every callable server action, keyed by module#export, with its real type read off the declaring module |
run-staged-tasks | Runs the tasks staged names, each once, with the staged files its glob matches appended |
run-lint | uf lint over the staged files |
run-format-check | uf fmt --check over the staged files |
Every step reads what is staged, not what is on disk. A file that is half
staged — git add -p — has its staged content put in the working tree for the
run, and its unstaged half put back afterwards; the copy waits under
.uf/prepare/unstaged/ while the run holds it, and a run that was killed is
undone by the next one before it does anything else.
A staged task that rewrites a fully staged file has fixed what is being committed, and uf stages the rewrite, so the fix lands in the commit. One that rewrites a half-staged file has rewritten a mixture that exists only for the run: the rewrite is not kept, the step fails, and the commit stops, so the fix can be made by hand to the half that is staged.
uf prepare --install-hooks wires this into git. It writes a dispatcher to
.githooks/pre-commit that runs uf prepare in the project, and points this
clone's core.hooksPath at .githooks; commit the file, and every other clone
runs the command once. There is no postinstall script, for the same reason
there are no lifecycle scripts. A pre-commit uf did not write, or a
core.hooksPath already set to another directory, is refused rather than
overwritten.
With --fix those last two write instead of only reporting: the lint step
applies uf lint's safe fixes and the format step formats. Safe only,
never the unsafe tier — a hook that runs on every commit is the last place an
edit that can change what the program does should arrive unasked-for, and
uf lint --fix-unsafe is how you ask.
A --fix run that changed a file fails, even when nothing is wrong any
more. What it wrote is in the working tree and not in the index, so the
commit git is about to make is not the code uf just fixed. Failing stops that
commit and leaves you a diff to read and stage; uf prepare does not stage it
for you, because adding content to a commit whose message is already written is
how a hook gets uninstalled.
The record of the run goes to .uf/prepare.json, including the steps that did
not run: a generation failure stops the run, and the checks after it are
recorded as not-run rather than as anything that could be read as a pass.
Both generated files are checked whether or not they are staged — a scaffolded project git-ignores them, so they never are, and a commit hook that writes a file its own linter rejects is a hook nobody keeps.
Outside a git working tree there is no staged set, so every file is checked and the run says why. With git and an empty index there is nothing to check, and the checks are skipped.
Editors and agents
IDE setup is the guide to uf lsp and uf editor, and
Agents to uf mcp.
uf lsp
The language server every integration in
editors/ starts. It
speaks JSON-RPC over stdin and stdout, and reads the project's uf.config.js
once, when it starts.
| Method | What it answers |
|---|---|
textDocument/publishDiagnostics | Pushed on open and on every change, from the linter uf lint runs; and Flow's type errors, from the checker uf check runs, once typing pauses |
textDocument/formatting | One edit over the whole document, from the printer uf fmt runs |
textDocument/codeAction | quickfix where a rule's fix is mechanical, and where Flow can rewrite a type error inside this file. A rewrite that needs another file's signature is not offered. source.fixAll.uf is every safe lint fix at once, and does not apply type rewrites. source.organizeImports sorts the file's imports the way Flow does, and is not part of that fix-all |
textDocument/hover | The rule behind a diagnostic, what an import specifier names, what a rule id in a suppression comment means, and a key of uf.config.js; anywhere else in a Flow file, the inferred type under the cursor, printed as Flow |
textDocument/definition | Where the name under the cursor is declared: across files, into a package under node_modules, and into the project's flow-typed/ |
textDocument/typeDefinition | Where the named types in the type under the cursor are declared |
textDocument/completion | In uf.config.js: the keys valid at the cursor, with their documentation and type; the values of a key whose type is a fixed set; and in a tool spec, the names its key takes and, after @, that tool's versions. In any other Flow file: after value., the members of value's type with their types, and elsewhere the names in scope. ", @ and . trigger it |
workspace/symbol | Symbols in the project's own files whose names contain the query. Packages under node_modules are left out |
textDocument/selectionRange | The ranges the selection can grow through, innermost first, from the parse |
textDocument/linkedEditingRange | The opening and closing names of the JSX tag under the cursor. A cursor on either name links both |
textDocument/foldingRange | Every multi-line declaration in the file's outline |
Hover over an expression, both definitions and completion outside uf.config.js
come from the checker uf check runs, over the same batch — every source, what
it imports, the packages under node_modules it reaches, and flow-typed/ —
read in the background when the client says initialized and kept in memory.
An edit re-infers the edited file, and the files that import it, when they are
next asked about. A file that does not parse has no inference to ask, so hover
and definitions answer null there; completion still answers after value..
A build without the checker advertises neither definition and completes
uf.config.js alone.
Completion is read from @uniflowed/config's own Flow type — the declaration
defineConfig checks the file against — compiled into the binary, so it needs
nothing installed and offers no key uf does not read. It answers in a file that
is half-typed and does not parse, which is when it is wanted. A key the object
already has is not offered again; a string typed without quotes is written in
the project's fmt.quotes; and nothing is offered under vite, whose options
uf passes through unread rather than re-declaring.
A tool spec — runtime, build.runtime, test.runtime, packageManager,
test.runner, build.builder — completes the names that key takes, and after
the @ that tool's versions, newest first: each major, which resolves to its
newest release and is locked in uf.lock, then every release — a hundred at a
time, with the list marked incomplete past that so it narrows as you type
(node@24. is every 24.x). They come from
the publisher's release list uf resolves node@26 against, cached under
$XDG_CACHE_HOME/uf/index, and a completion request never waits for one.
With no list cached, the answer is empty and marked incomplete while the list is
fetched on a thread of its own, so the versions appear as you type on; a list
older than a day is offered as it is and refreshed behind the request.
UF_INDEX_CACHE moves the cache, and UF_TOOL_INDEX_BASE the publishers.
Not served: inlay hints. Flow type errors are pushed as diagnostics beside the
lint findings, source flow.
tests/library/lsp.test.js drives the binary and asserts the capabilities
above, and that inlay hints are not advertised.
uf editor install EDITOR
Installs uf's integration for one editor on this machine. EDITOR is
vscode (or code), cursor, zed, jetbrains (or webstorm, idea),
neovim (or nvim), vim, helix (or hx) or emacs.
uf editor install vscode
| Editor | What it does |
|---|---|
| VS Code, Cursor | Downloads uf-vscode-<version>.vsix and its .sha256 from the GitHub release of the running uf, checks the digest, and runs code --install-extension <file> --force (cursor for Cursor). A file that fails the check is never handed to the editor |
| Zed | Writes the extension's sources to $XDG_DATA_HOME/uf/editors/zed. Zed has no command-line install, so the next step is zed: install dev extension on that directory |
| JetBrains | Writes the LSP4IJ template to $XDG_DATA_HOME/uf/editors/jetbrains/lsp4ij-template, to import from LSP4IJ |
| Neovim | Writes uf.lua to $XDG_CONFIG_HOME/nvim/lua/ (~/.config/nvim/lua/) |
| Vim | Writes uf.vim to ~/.vim/plugin/ |
| Emacs | Writes uf.el to $XDG_DATA_HOME/uf/editors/emacs and prints the load-path line |
| Helix | Nothing: Helix is configured per project, with uf editor setup helix |
The files are part of the uf binary, so they match the uf that writes them and need no network. A file in the editor's own configuration directory that uf did not write is not replaced: the command stops and names it. An earlier copy uf wrote is brought up to date.
--version VERSIONinstalls the extension from that release,0.2.0oruf@0.2.0, instead of the running uf's.--vsix FILEinstalls a.vsixyou built, with no download and no check.--forcereplaces a file uf did not write.--dry-runprints what would be downloaded and written, and does nothing.
UF_EDITOR_RELEASE_BASE replaces the release host with
<base>/<version>/<asset>, the layout UF_RELEASE_BASE uses for the
installer. Releases before the one that added this command did not attach the
.vsix. For those, the error says so and points at --vsix.
uf editor setup EDITOR
Writes the project's settings for one editor: the ones that stop the editor's own JavaScript or TypeScript server from checking the project's Flow files, and the ones that make uf the formatter. Each is what that editor's section in IDE setup tells you to commit.
uf editor setup vscode
| Editor | File |
|---|---|
| VS Code, Cursor | .vscode/settings.json (both validation switches, uf as the formatter for javascript and javascriptreact, fix-all on save) and .vscode/extensions.json (the recommendation). Cursor gets only the validation name it has |
| Zed | .zed/settings.json: uf in and vtsls out of the JavaScript servers, uf as the formatter, format on save |
| Helix | .helix/languages.toml |
| Neovim | .nvim.lua, for exrc |
| Emacs | .dir-locals.el, turning off lsp-mode's TypeScript clients |
| JetBrains, Vim | No file: the steps are printed |
It adds what is missing and prints each line it adds. A value the project
already set is kept and reported, whichever way it was set. Comments and order
in a settings file are kept, because the file is edited in place rather than
rewritten. A file uf cannot merge into (a languages.toml that already
configures JavaScript, an existing .dir-locals.el) is left alone, and the
lines to add by hand are printed. Running it again adds nothing. It needs a
uf.config.js in the directory or above it.
--checkwrites nothing and exits1if anything would be added, so CI can hold a project to its settings.
uf mcp
The Model Context Protocol server a coding agent starts. It speaks JSON-RPC over
stdin and stdout, one message per line; it is not a daemon and has no port. The
project is decided once, when it starts: the working directory, or the one
--cwd names, walked up to the nearest uf.config.js, package.json or .git.
| Tool | Runs | What it does to the checkout |
|---|---|---|
uf_check, uf_lint, uf_info, uf_routes, uf_explain | uf check --json, uf lint --json, uf info, uf routes list, uf explain <command> --json | changes none of your files |
uf_test | uf test --json | runs the project's own code |
uf_fmt_write, uf_lint_fix | uf fmt, uf lint --fix --json | writes to files in the checkout; the fixes are the safe tier only |
There is no tool for uf build, uf dev or uf install, and the server offers
no resources and no prompts. Agents has registering it with a
client and what each call returns.
uf completion SHELL
Prints a completion script for bash, zsh, fish, elvish or
powershell. Load it from the shell's startup file:
| Shell | Line |
|---|---|
| bash | eval "$(uf completion bash)" |
| zsh | eval "$(uf completion zsh)" |
| fish | uf completion fish | source |
| elvish | eval (uf completion elvish | slurp) |
| PowerShell | uf completion powershell | Out-String | Invoke-Expression |
The script decides nothing itself. It hands the words typed so far to the uf
on PATH, which answers from the same parser and the project's own
uf.config.js — so a task added to the config completes at once, with nothing
to regenerate. The same script registers ufr and ufx.
Messages
Two commands over @uniflowed/i18n's catalogue: one that takes it out to a
translator, one that reads it back. They share a file, and the shape of that
file is the same in both directions, so a vendor who fills in the extraction
has produced something uf i18n merge reads with no conversion step in
between.
The walk is Rust rather than the package for the reason the formatter and the
checker are: it is repeated, repository-wide, CPU-bound work. The half that is
a parse of one message stays in @uniflowed/i18n, and neither command
implements MessageFormat 2 — see
the package reference for what that division means.
uf i18n extract
Writes every message the project declares as one JSON file:
{
"format": "uf-i18n-catalogue/1",
"sourceLocale": "en-US",
"locale": "en-US",
"messages": {
"greeting": {
"source": "Hello, {$name}!",
"translation": "Hello, {$name}!",
"parameters": { "name": "string" },
"declaredAt": "src/messages.js:10",
"digest": "2f1c0b4a7d9e5a63"
}
}
}
The text is MessageFormat 2, unchanged from the source — uf copies the literal
and never parses it. translation starts equal to source, which gives a
translator something to edit rather than an empty box and is how uf i18n merge later tells a translated entry from one that came back untouched.
digest covers the source and the parameters together, and nothing else: a
message that moved to another file is not a message that changed.
--locale TAG names the locale the messages are written in. Without it uf
reads the project's defineCatalogue calls and uses the one literal tag they
name, and says so rather than guessing when they name none or several.
--out PATH writes somewhere other than i18n/<locale>.json. --json
reports on stdout and writes no file.
A message(…) uf cannot read fails the command and nothing is written.
A source that is not a literal or a module-level const holding one, a
parameter that is not one of the four kinds, a message declared under no name,
two messages under one key: each is reported with its file and line. A
catalogue quietly missing a message is invisible in the extraction, invisible
in review, and visible only to somebody reading English on a Japanese page.
uf i18n merge FILE
Reads a translated catalogue and writes the locale module defineLocales
loads — a // @flow module default-exporting the keys and their translations,
next to FILE and named for the locale, which is where
() => import("./ja-JP.js") already points. --out PATH puts it elsewhere;
--json reports and writes nothing.
uf extracts the project again first, because a translation round takes weeks and the English does not stop. Every returned entry is held against what its message says today:
| What uf found | What it does |
|---|---|
| The message changed since the file went out | Names it, writes nothing, exits non-zero |
| The key is no longer declared | Reports it, leaves it out |
| A key the file does not carry | Reports it as still to translate |
translation still equal to source | Reports it, leaves it out |
Only the first is a failure. The rest are what a translation in progress looks
like, and a key left out here is one translate falls back to the source for
and lists on the catalogue as untranslated.
What merging does not check is whether a translation's placeholders agree
with the message's parameters. That needs MessageFormat 2 parsed, and it is
checked by translate when the locale loads, against the real parser, in the
one place this repository implements it.
SQL
uf sqlc runs sqlc with uf as its Flow plugin. The
project keeps an ordinary sqlc.yaml whose plugin is process: { cmd: uf };
sqlc starts uf /plugin.CodegenService/Generate, and uf answers that
argument itself, before any command is parsed. SQL with sqlc
is the guide, and says what is still Experimental.
uf sqlc generate and uf sqlc diff find sqlc as $SQLC when that is set
(even when the path does not exist), then as sqlc on PATH, then as
.uf/sqlc/sqlc when uf sqlc install wrote the pinned release there. The
directory of the running uf is put first on PATH for the child, so the
plugin sqlc starts is this uf rather than an older one earlier on the path.
Neither command downloads sqlc.
uf sqlc install
Downloads the pinned sqlc (1.31.1, the release uf's own fixtures were captured
with) into .uf/sqlc and prints its absolute path on stdout, alone, so
SQLC=$(uf sqlc install) works. The archive is checked against the SHA-256
embedded for this platform before it is unpacked. A matching version already
in .uf/sqlc is left in place; --force downloads it again. --version names
another release and requires --sha256, the digest of that release's
.tar.gz for this platform. The version file .uf/sqlc/version records what
was installed. Generate and diff only pick that copy up automatically when the
version file is the pinned release; point SQLC at the printed path for any
other version. .uf/ is gitignored. In CI, leave SQLC unset and run
uf sqlc install before uf sqlc diff, which is what tools/ci/sqlc.sh does.
uf sqlc generate
Runs sqlc generate, which writes the Flow modules each codegen entry asks
for. --file FILE (-f) names the sqlc config when it is not sqlc.yaml,
sqlc.yml or sqlc.json in the project root. A failure is sqlc's, printed as
sqlc printed it, and the command exits non-zero.
uf sqlc diff
Runs sqlc diff, and fails when generating again would change any file: the
check for CI. --file FILE (-f) is as for generate.
Dependencies
Dependencies is the guide to these commands, with what each one printed.
With packageManager: "uf", dependency installation, addition, removal,
updates and audit run in uf's native manager. Its lock graph records exact
versions and archive integrity in uf.lock. A shared store deduplicates file
content across versions and projects. Dependencies are hardlinked into an
isolated node_modules graph, with a copy fallback across filesystems.
UF_PM_STORE can select another absolute store location.
With an external manager selected, uf runs that manager and reports it.
Lifecycle hooks remain disabled by default. pm.allowLifecycleScripts: true
explicitly enables install hooks. Native packages that execute hooks receive
writable copies so their build cannot modify the shared file store.
External operations report which manager ran, what named it, and the exact command it spawned. Native operations report the shared store and install results. A command that delegates without saying to whom is the black box red line 7 forbids.
uf install
With packageManager: "uf", resolves the native lock graph, verifies archive
integrity and links packages from the shared store. The report includes package,
download, hardlink and copy counts. A warm frozen install uses the lock and store
without requesting package metadata.
With an external manager selected:
Installs dependencies with the selected package manager, and refuses lifecycle
scripts while doing it — the manager is passed --ignore-scripts, and a
manifest that declares scripts of its own fails before anything is fetched.
Yarn 2+ answers --ignore-scripts with "Unsupported option name" on every
command, so it is given YARN_ENABLE_SCRIPTS=false instead — the setting it
reads — and the command row shows it in front of the program.
On a terminal, the phases are drawn as they happen and redrawn where they stand: resolving, fetching, linking, auditing, each with what it is working on, how many it has done and how long it has taken. Off a terminal — a pipe, a file, a CI log — nothing is redrawn and nothing is animated.
Afterwards it reports what changed rather than a count: the packages added, removed, updated and moved between the lockfile before the install and the one after, the time each phase took, which manager ran and what named it, and what to run next. An install that changed nothing says so in a line and stops.
The manager's own output is passed through untouched, on the stream it wrote it to. A failed install still says why in the manager's words.
It also records what the workspace resolved to, in .uf/install.json: the
resolver, the lockfile and store manifest it wrote, how many packages and store
entries there are, and the runtime engine, acquisition and hosts the config
implies. That file is a record and not an input — nothing reads it back — and
it is what uf upgrade used to write under a name that promised a new uf
(#424).
uf install --frozen-lockfile
What CI runs. Installs exactly what the lockfiles pin, and fails rather than
resolving a lockfile that has drifted from the manifests: npm ci, pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile.
Two lockfiles are checked, and they catch different drift. uf.lock is
derived from the workspace's manifests, so uf checks it first and fails when
rewriting it would change it — putting back the file it was handed, so the
failing step can still print a diff of the real one. The manager's own lockfile
is then checked by the manager, which is the case where package.json has not
moved but package-lock.json no longer pins what it asks for.
uf install --prod
Leaves devDependencies out of node_modules — what a server or a production
image installs — while the lockfile still describes all of them. --production
is the same flag.
| Manager | uf install --prod | uf install --frozen-lockfile --prod |
|---|---|---|
| npm | npm install --omit=dev | npm ci --omit=dev |
| pnpm | pnpm install --prod | pnpm install --frozen-lockfile --prod |
| Yarn 1 | yarn install --production | yarn install --frozen-lockfile --production |
| Yarn 2+ | yarn workspaces focus --all --production | refused |
| Bun | bun install --omit=dev | bun install --frozen-lockfile --production |
Bun's own --production freezes the lockfile too, so the form that may still
bring a stale lockfile up to date is --omit=dev.
Yarn 2+ has one production install, yarn workspaces focus, and it never
writes the lockfile, so it has nothing to refuse. uf refuses the frozen form by
name rather than run an install that cannot fail for the reason
--frozen-lockfile exists: check the lockfile with uf install --frozen-lockfile, then install with uf install --prod. On Yarn 2 and 3 that
command comes from the workspace-tools plugin, which Yarn 4 includes, and a
failed uf install --prod there says to run yarn plugin import workspace-tools.
uf add [--dev|--optional|--peer] SPEC...
Adds dependencies: resolves them, installs them, and writes both the manifest and the lockfile.
A specifier reaches the manager exactly as written, so react, react@^19,
react@next, react@npm:preact and ./npm/ui all mean what they mean
there. It is passed as a single argument and never through a shell, so a range
with a space in it — uf add "react@>=18 <20" — stays one specifier. A
specifier that starts with - is refused before anything runs, because every
manager would read it as a flag.
--dev, --optional and --peer write devDependencies,
optionalDependencies and peerDependencies; the three are mutually
exclusive, and with none of them the package goes in dependencies. The
summary names the field the package actually landed in, read back out of
package.json rather than repeated from the flag.
In a pnpm workspace, uf add at the workspace root passes pnpm's
--workspace-root. pnpm refuses to add to a root without it —
ERR_PNPM_ADDING_TO_ROOT — and the advice it gives, to run the command again
with -w, was advice nobody could follow through uf, which never passed that
flag. The root is where a workspace's development dependencies belong, so the
command now works there.
It is passed only where the project root is the workspace root. Run inside a
member package, uf add resolves to that member and the flag is not passed, so
the dependency still lands where you are. Yarn 1 has the same refusal, on
yarn remove as well as yarn add, and is given its own spelling of the flag,
--ignore-workspace-root-check, in the same place and nowhere else. npm, Yarn
2+ and Bun add to a workspace root without being asked twice, and none of them
is given a flag.
Running it twice changes nothing the second time, and says so in a line.
--filter and -w
--filter SELECTOR runs uf add, uf remove or uf update for the workspace
members the selector picks instead of this project, and -w
(--workspace-root) for the workspace root, from anywhere inside the workspace:
uf add zod --filter @acme/api --filter @acme/web
uf remove lodash --filter './npm/*'
uf add --dev vitest -w
The selector is uf run --filter's — a name, a glob, a
path, name... for a member and what it depends on, ...name for what depends
on it — and a selector that picks nothing is an error listing the members,
before anything is installed. The members are what package.json#workspaces,
pnpm-workspace.yaml and the members' own uf.config.js say they are.
uf runs the manager once in each chosen member's directory, and every manager
reads that the same way: the member's package.json changes, and the workspace
root's lockfile is the one written. The flags the managers have for it — npm's
--workspace, pnpm's --filter, yarn workspace NAME — take different
selectors, and bun's add has none, so uf does not use them. It also means a
path specifier is read from the member: file:../../vendor/ui, not
./vendor/ui. The summary prints a command row per member and a workspace
column in the manifest table.
Yarn 2+'s yarn up moves a package in every workspace that declares it,
wherever it runs, so uf update --filter is refused there by name.
uf remove NAME...
Takes packages out of every dependency field that lists them, out of the
lockfile and out of node_modules. A name the manifest never listed is not an
error: the project ends up the way it was asked to be either way.
--filter SELECTOR and -w (--workspace-root) choose which workspace
members it changes, as for uf add — see --filter and -w.
uf update [PACKAGE...]
In a terminal, the plain command offers a release range and a multiple
selection list showing the declared range, proposed version, change level and
manifest. Space toggles packages; Enter applies the selection. Escape or an
empty selection leaves the manifests unchanged. Explicit --latest, --minor
and --patch retain their scriptable behavior.
Two different things go by this name, and uf update does both.
--filter SELECTOR and -w (--workspace-root) choose the workspace members
it runs for, as for uf add — see --filter and -w.
With no flag it is the manager's own update: the lockfile moves to the newest
versions the manifest's ranges already allow, and package.json is not
touched. Then uf reports the other half — every dependency whose newest
published version the declared range does not allow:
outside the range
package declared newest step manifest
react ^18.2.0 19.2.8 major .
react ^17.0.0 19.2.8 major npm/ui
vitest ~1.0.0 5.0.0 major .
classnames 2.2.6 2.5.1 minor .
› 4 dependencies are newer than their range allows
› uf update --latest rewrites the ranges; --minor and --patch cap the step
--latest rewrites those ranges to the newest published version and installs.
--minor and --patch cap how far a range may move — --minor holds the
major, --patch holds the minor too — which is the difference between a
Friday-afternoon update and a Monday-morning one. --dry-run prints the table
and changes nothing.
A rewrite keeps the comparator the project chose: ^1.2.3 becomes ^2.0.0,
~1.2.3 becomes ~2.0.0, and a pin stays a pin. Only the range moves in the
file — indentation, key order and the trailing newline are left exactly as they
were.
Prereleases are not offered unless the range's own base is one: a project on
^18.2.0 is not asking to be moved to 19.0.0-rc.1.
Four range shapes are rewritten — ^1.2.3, ~1.2.3, 1.2.3, >=1.2.3 — and
everything else is counted and left alone. workspace:*, catalog:, npm:,
file:, a git URL and a range with more than one comparator each say something
a single comparator cannot restate, and a tool that rewrote one would be
throwing away a decision somebody made on purpose.
Reading which versions exist is the one thing here uf does not delegate, because
no manager will answer it: npm outdated, pnpm outdated and yarn npm outdated each report a different thing in a different shape, bun has no JSON
at all, and none of the four rewrites a range. uf asks the registry the same
question the manager's resolver would — a packument, over the registry in your
config — and the install that follows is still the manager's. A registry that
cannot be reached is reported next to the packages it could not answer for; it
does not fail the command.
This is not uf self-update, which replaces the uf
binary rather than moving your dependencies.
uf pm approve-builds [NAME...]
Lists every package in the installed tree that would run code when it installs, with the hooks it declares and whether this project has approved it:
manager pnpm
approvals in pnpm.onlyBuiltDependencies
package version runs approved
esbuild 0.24.0 postinstall no
sharp 0.33.5 postinstall no
› 2 packages would run code at install time and are not approved, so uf does not let them run
› uf pm approve-builds <name>... records the ones you have read
Naming one records it. --dry-run says what would be approved and writes
nothing. A name that is not in the tree is refused with the names that are — on
a security command a typo that silently does nothing is worse than an error.
The approved set lives in the root package.json, in the field your package
manager already reads (pnpm.onlyBuiltDependencies, trustedDependencies,
dependenciesMeta), so the manager is what enforces it. npm and Yarn 1 have no
per-package control and are told so plainly. docs/security.md has the full
reasoning, including why an empty list still means "none"
(#495).
Approving does not install; uf install is what runs the builds.
uf patch PACKAGE
Opens a copy of a dependency somewhere you can edit it, and prints the directory:
uf patch left-pad
Edit what is in there, then:
uf patch --commit /var/folders/.../left-pad-1.3.0
which turns your edits into a patch file, records it in the manifest, and installs. Every install after that reapplies it.
pnpm and Yarn 2+ only. npm, bun and Yarn 1 have nothing equivalent, and uf
refuses by name rather than handing the manager a subcommand it has never heard
of — a passthrough fails too, with Unknown command: patch and a suggestion to
read npm help, which tells you nothing. The refusal names the two managers
that can and points at patch-package, which uf does not install for you:
this is the one command whose entire purpose is editing somebody else's code,
and it is not the place for uf to add a dependency the project did not choose
(#494).
The open half changes nothing about your project, so it prints the three
delegation rows and then gets out of the way — the directory path is the answer
and it should be the last thing on the screen. The commit half installs, so it
refuses lifecycle scripts, rewrites uf.lock, and reports what moved in the
tree, like every other command that writes.
uf catalog
Prints the versions this workspace shares — every package more than one manifest declares — and, underneath, every package whose manifests disagree:
shared
package range declared in
eslint differs 2 packages
react differs 3 packages
disagreements
package manifest range
eslint . ^9.0.0
eslint npm/ui ^8.57.0
react . ^18.2.0
react npm/api ^17.0.0
react npm/ui ^18.2.0
! 2 packages declared at more than one range
› uf catalog set eslint <range> makes every manifest agree
That second table is the bug a catalogue exists to prevent, and nothing else reports it.
uf catalog set NAME RANGE
Writes one range into every manifest that declares NAME, then installs.
--dry-run prints the table and changes nothing. Only the range moves in each
file; indentation and key order are left as they were.
Why this and not pnpm's catalog:. pnpm names a version once in
pnpm-workspace.yaml and lets a package write "react": "catalog:". No other
manager has the concept — npm, yarn and bun read catalog: as a specifier they
cannot resolve and refuse the install. That left uf two options, and both were
worse than this one: a passthrough to pnpm does nothing in four projects out of
five, and resolving catalog: for the other managers means uf rewriting
everybody's package.json on the way into an install and putting it back
afterwards — uf standing between the manifests and the installer, and a
workspace full of manifests nobody wrote if you interrupt it.
So uf does not invent a fifth catalogue. It offers what catalogs are for, in a
form all five managers already understand: one place to change a version, and a
check that every package agrees. What you give up is the indirection — a
manifest here says ^19.0.0 rather than catalog:. What you get is a file that
says what will be installed, on every manager, and one command instead of an
edit and a hope.
A project already using pnpm's catalogs keeps them: uf counts the catalog:
declarations, reports them, and leaves pnpm to resolve them, because pnpm can
(#496).
uf why NAME
Explains why a package is in the tree, answered by the manager out of its own
lockfile — npm explain, pnpm why, yarn why, bun why — so the chain
printed is the chain that was actually installed. It writes nothing at all, not
even uf.lock.
uf ls [NAME...]
Lists the installed tree, through the same manager — npm ls, pnpm list,
yarn list on Yarn 1, yarn info --all on Yarn 2+, bun pm ls. A package
name narrows it.
uf audit [NAME...] [--prod] [--json]
With the native manager, checks locked package versions through each configured registry's advisory API. Private scopes stay on their configured registry. The report shows severity, affected versions, advisory links and the next available stable release outside the reported vulnerable ranges, when one can be established. A suggested release can require compatibility changes.
--prod excludes development dependencies; --json prints the native report.
A vulnerable tree exits nonzero. A failed registry response also exits nonzero
and reports the audit as incomplete. These two flags are native manager options.
With an external manager, audits the installed tree against the registry's
advisories: npm audit, pnpm audit, yarn audit, yarn npm audit on Yarn 2+,
bun audit, or aube audit.
uf search TERM...
Searches the registry. uf's native manager, npm and pnpm can; Yarn, bun and Aube cannot, and uf says so rather than running a manager the project did not choose:
error: bun has no `search`: npm and pnpm can search the registry;
`uf exec --yes npm search` runs npm's without changing what this project
installs with
That refusal is the design rather than a gap. A tool that quietly reached for another manager would resolve against that manager's registry configuration and could write its own lockfile, which is the one thing a manager-agnostic front end must not do.
External manager queries take package names and no flags. A manager's own flags are
its own surface — reached with uf exec — because uf cannot know which of them
only read: pnpm audit --fix rewrites a lockfile.
uf uninstall is uf remove, which npm and pnpm both spell that way.
uf info PACKAGE [FIELD]
Prints what the registry says about a package, through the project's own
manager — npm view, pnpm view, yarn info, yarn npm info on Yarn 2+,
bun info — so the answer comes from the registry that manager installs from. A
second word narrows it to one field, uf info react version, which Yarn 2+ is
handed as --fields version. With no package, uf info prints uf's own
version, host and paths, as it always has.
uf dedupe [--check]
Collapses the versions the declared ranges allow to be one, and reports the tree
it leaves: npm dedupe, pnpm dedupe, yarn dedupe on Yarn 2+, each with
lifecycle scripts refused like every command that installs.
Yarn 1's yarn dedupe exists only to say it is unnecessary — yarn install
already dedupes — and bun has none. On those two uf refuses by name and says what
to run instead: uf install on Yarn 1, and uf update on bun, which resolves
every range to its newest match again.
--check answers the same question without changing anything, for CI:
uf dedupe --check # 0: nothing to collapse; 1: the report names what would
| npm | pnpm | Yarn 1 | Yarn 2+ | Bun | |
|---|---|---|---|---|---|
uf dedupe --check | npm dedupe --dry-run --json | pnpm dedupe --check | refused | yarn dedupe --check | refused |
The managers disagree about how to answer, so uf holds their output back and prints its own report instead of three different ones:
- pnpm and Yarn 2+ have a check of their own, and exit non-zero when the lockfile would change.
- npm has none.
npm dedupe --dry-runchanges nothing, but it exits 0 whatever it finds, so uf asks for the summary in JSON and reads the answer out of it.
Where uf can read the packages and the versions that would become one out of that answer, the report names them, one line each. Where a manager says only that there is something, uf says that, and prints what the manager printed rather than inventing a list. Nothing is installed either way, so the check is safe to run against a tree CI has already built.
uf link [NAME|DIR]
Links a package you are developing into this project, or makes this one linkable:
uf link ../ui # link that directory into this project
uf link # inside ../ui: make it linkable from other projects
uf link @acme/ui # in one of them: link the package registered above
A target is a path when it is written as one — ., .., or starting with
./, ../ or / — and a package name otherwise, so a name that happens to
match a directory beside the project still means the package.
| npm | pnpm | Yarn 1 | Yarn 2+ | Bun | |
|---|---|---|---|---|---|
uf link | npm link | pnpm link; pnpm 12 refuses it | yarn link | refused | bun link |
uf link NAME | npm link NAME | pnpm link NAME; pnpm 12 refuses it | yarn link NAME | refused | bun link NAME |
uf link DIR | npm link DIR | pnpm link DIR | refused | yarn link DIR | refused |
Yarn 1 and bun link by name only, and Yarn 2+ by path only. uf refuses the form a
manager does not have and names the one it does. pnpm 12 links by path only too.
uf cannot tell which pnpm a project runs, so pnpm refuses those two forms itself
and uf adds the command to run instead. Linking installs, so dependency scripts
stay refused: --ignore-scripts, or --config.ignore-scripts=true for
pnpm link, which rejects the flag.
What a link did is read from node_modules, not taken from the manager's exit
code. npm, Yarn 1 and bun write nothing but the link itself, with no manifest
entry and no lockfile entry. So the report names the package and where
node_modules now leads for it, above anything the manifest and the tree gained:
$ uf link ../lib
manager npm
chosen by no lockfile or packageManager field
command npm link --ignore-scripts ../lib
lockfile package-lock.json · not written
linked
@acme/ui ../lib
✓ linked @acme/ui in 161.9ms
If the manager exits 0 but node_modules/<name> is still the installed package,
is missing, or is a link to nothing, uf link fails and says which of the three
it found.
Yarn 2+ records a link before it makes one: it writes a portal: resolution, and
a resolution only affects a package the project already depends on. Linking a
package nothing uses yet is reported as recorded, with a warning, rather than
as linked. Yarn 4 also refuses to link a package whose manifest says
"private": true.
Registering changes nothing in the project, so uf link with nothing named prints
the three rows and the manager's own answer.
uf unlink [NAME|DIR]
Undoes uf link, in whichever direction it went:
uf unlink @acme/ui # in a project: take the link out, put back what package.json declares
uf unlink ../ui # the same, named by the directory the link leads to
uf unlink # inside ../ui: stop it being linkable from other projects
In a project, the link is taken out of node_modules. If package.json
declares the package, the release it declares is installed in its place. With
nothing named, uf removes the entry uf link left in the manager's global
registry.
| npm | pnpm | Yarn 1 | Yarn 2+ | Bun | |
|---|---|---|---|---|---|
uf unlink | npm uninstall --global NAME | pnpm remove --global NAME | yarn unlink | refused | bun unlink |
uf unlink NAME | npm uninstall --no-save NAME | pnpm unlink NAME | yarn unlink NAME | yarn unlink NAME | uf removes the link |
uf unlink DIR | the name DIR/package.json gives | the same | the same | yarn unlink DIR | the same |
On npm, Yarn 1 and bun, uf install then runs if package.json declares the
package. pnpm and Yarn 2+ reinstall on their own. Unlinking installs, so
dependency scripts stay refused, as they do for uf link.
uf unlink checks the filesystem before it runs anything and again afterwards:
- Nothing to unlink is not an error. uf says what it found, runs nothing, and exits 0 when:
node_modules/NAMEis not a link;- it is the path dependency
package.jsondeclares; - it links somewhere other than
DIR; - with nothing named, the manager has nothing registered under the package's name.
- What was removed is read from the filesystem, not from the manager. The report names the package and where its link led, or the registry entry it removed. Its last line says what
node_modulesholds now. - Where a manager cannot undo its own link, uf does:
- bun has no
unlink NAME(it answers "not implemented yet"), so uf removes the link fromnode_modulesitself, and only if it is a link. - pnpm 10's
pnpm unlinkanswers "Nothing to unlink" about a link its ownpnpm linkmade, and leaves that link's override inpnpm-workspace.yaml. uf removes that one entry fromoverridesand installs. It does so only when the file is laid out the way pnpm writes it, and otherwise says which entry to delete.
- bun has no
- npm's
unlinkis not used.npm unlink NAMEisnpm uninstall NAME, which also takes a declared package out ofpackage.json.--no-saveremoves only the link. - pnpm 12 writes a dependency when it links a package the project does not declare.
uf unlinkremoves pnpm's override, then warns that the package is still linked, becausepackage.jsonnow declares it aslink:….uf removetakes that out.
Each manager's registry is found the way that manager finds it:
- npm:
npm root --global, with whatever npm masked in the answer put back. npm redacts its own output, and a UUID counts: a global directory under one is printed as…/***/lib/node_modulesbynpm root --global, bynpm prefix --global, by--jsonand by--parseablealike, and npm 11 refusesnpm config get prefixas a protected option. uf puts a masked segment back as the one directory of its parent that the rest of the path fits. Where none fits, or more than one, uf says it cannot find the registry rather than guess at a directory it would then remove something from. - pnpm:
PNPM_HOME. - Yarn 1:
XDG_DATA_HOME, or~/.config/yarn/link. - bun:
BUN_INSTALL. - Yarn 2+ keeps no registry, so
uf unlinkwith nothing named is refused there.
uf clean [--deps] [--dry-run]
Removes what a rebuild would write again: the build's output, the documentation
site's output if it is elsewhere, and .uf/ — uf's own per-project state, which is the
transform and check caches and the resolved config.
Not node_modules unless --deps says so. The line is the network:
everything removed by default, the checkout can produce again on its own, and a
command that deleted the install because somebody wanted their dist/ gone
would turn a two-second mistake into a two-minute one.
No flag removes a lockfile. uf.lock and the manager's own are inputs —
checked in, reviewed, and the reason an install is reproducible — and no amount
of cleaning should make the next install resolve something different from the
last one.
It prints what it removed and how much. --dry-run prints the same table and
removes nothing.
A configured outDir outside the project is refused rather than removed:
build.outDir comes from uf.config.js, and a command that deletes must not
be one typo away from taking a directory nobody named.
uf gc [--global] [--dry-run]
Collects the current project's .uf/cache and .uf/exec-cache without removing
its profile, lockfile, dependencies or source files.
--global also collects caches in registered projects, cached tool release
indexes, unused environment tools and old uf versions. It retains every live
project's uf pin, the active binaries, the running version and the rollback
version. An unreadable project configuration stops collection before deletion.
--dry-run lists the candidates without removing them. Symlinked cache paths
are skipped.
Environment
Environments is the guide to these commands.
uf use TOOLCHAIN
Switches the active uf toolchain: uf use uf@0.1.0. It takes a uf@<version>
and nothing else — the host a project runs on is host.runtime in
uf.config.js, not this command, and a name that is not uf is refused rather
than installed as one.
A version this machine already has is activated: uf, ufr and ufx in
~/.local/bin are relinked at it, all three together. A version it does not
have is downloaded and verified first, by the same installer
uf self-update uses — so uf use uf@0.9.9 for a release
that does not exist fails and installs nothing, where it used to succeed by
copying the running binary into a directory named after a version it was not
(#534).
The exception, and the only one, is the version uf already is: a uf built
from source can install itself into the store under its own version, with no
network. runtime.json beside the runtime records which of the three it was —
release, mirror or running-binary.
It switches the way uf self-update does — one rename per
name, uf last — and records the version it replaced, so
uf self-update --rollback returns to it.
uf env doctor
Checks that this machine has what the project runs on: every tool it declares —
runtime, build.runtime, test.runtime, packageManager, env.toolchain,
an exact package.json#engines — and, where it declares none, the runtime and
package manager uf falls back to. One row per tool, with the release found and
what it is for; a row with something wrong says what to do about it.
uf env doctor · my-app
──────────────────────
! node@26 not locked yet runtime, build runtime
! pnpm@10.4.1 10.4.1 not installed package manager
› `uf env install` installs what is missing now; otherwise the first command that needs a tool installs it
! 2 tools not installed yet · 2 tools checked
A release the store does not have yet is a warning rather than an error, since
the first command that needs it installs it. A tool named without a version
that is not on PATH is an error: the command that needs it will fail.
| Flag | Meaning |
|---|---|
--verbose, -v | Also where each tool is, which key declared it, and where the store, the links and uf.lock are |
--json | Every row, with status (ok, warn, error), found, fix, declaredBy and location, plus platform, store, bin, lock and ok |
It reads uf.lock and fetches, installs and writes nothing. It exits 0
whatever it finds — including over a uf.config.js it cannot read, which is
reported as a row — so read ok in --json to fail a script on it.
uf env use TOOL_OR_PROFILE
Selects a project's runtime with uf env use node, bun or deno. In a
terminal, choose a stable release from a menu. Without a terminal, specify a
version explicitly: uf env use node@26
or uf env use bun@1.3.0. The tool is acquired before the configuration is
written. npm, pnpm and yarn select a project's package manager in the same
way.
uf env use uf pins the running uf release in uf.config.js; uf@VERSION
acquires and pins that exact release without changing the machine's active
binaries. uf env use cfw chooses the Cloudflare Workers edge deployment
adapter and preserves the local JavaScript runtime.
A profile name, such as staging, selects dotenv files:
Records the profile every later command runs in, in .uf/profile. uf env use staging makes .env.staging and .env.staging.local the mode files
for uf dev, uf build, uf test and the rest, until you change it or pass
--mode. The name becomes the end of a file name, so it may hold letters,
digits, _, - and .; local is refused, because .env.local is already
the file that overrides every mode. See Environments.
uf env install
Installs every tool uf.config.js declares at a version — runtime,
build.runtime, test.runtime, packageManager and the older env.toolchain,
plus exact package.json#engines — into a store shared by every repository on
the machine, and links them into this one. A version prefix such as node@26
is resolved against its publisher's release list the first time and locked in
uf.lock; after that the lock is the answer. Nothing is installed globally and
PATH is not changed. See Environments.
uf env list
Lists each tool the project declares, what it is used for and whether the store
has it, beside what the store holds. Reads uf.lock and fetches nothing, so a
prefix nothing has resolved yet is shown as not locked.
uf env update
Resolves every version prefix again against its publisher's current release
list and moves uf.lock to the newest release of each, printing what moved.
Installs nothing; uf env install installs what moved.
uf env exec COMMAND
Runs a command with this project's toolchain in front of PATH, and exits with
whatever the command exited with.
uf env gc
Deletes store entries no repository is using. --dry-run says what would go and
removes nothing.
uf self-update
Replaces this uf with the newest release, or with the one named —
uf self-update 0.0.0-alpha.35, or uf@0.0.0-alpha.35 as the tags spell it. It
resolves the release, downloads uf-<target>.tar.gz, checks it against the
sha256 published beside it — and against its Sigstore signature when
cosign is installed — refuses an archive whose members escape their own
directory, unpacks it into the runtime store, and switches uf, ufr and
ufx to it. A version the store already holds is switched to without
downloading anything, and asking for the version that is already active
changes nothing.
Everything up to the switch is the installer's —
infra/cloudflare/setup-assets/install.sh, the script
curl -fsSL https://setup.uniflowed.dev | sh runs — embedded in the uf binary
at build time and piped into sh. uf does not carry a second downloader: a
checked download written twice is a checked download to get wrong twice, and
the shell one is the copy every new user's first command already runs.
Embedding rather than fetching it means a self-update executes only what this
uf was built from, and the only thing crossing the network is an archive whose
digest is checked.
An interrupted update leaves a working uf. Each of the three names is made
beside itself and renamed over the old one, uf last, and a runtime is only
ever renamed into the store whole — so a process killed at any point leaves
every name running the old version or the new one, never a missing or
half-written binary. Running the same command again finishes the switch. The
installer switches the same way.
It reads no project, so a uf.config.js uf cannot parse is not a reason to be
unable to install a uf that can. UF_VERSION pins a version when none is
named, UF_RELEASE_BASE points at a mirror, and UF_INSTALL_ROOT and
UF_BIN_DIR move the store and the links — each meaning here exactly what it
means to the installer.
uf upgrade was the name this was meant to be, and never what it did: it
re-read the workspace and wrote a plan file, fetching nothing and replacing no
binary (#424,
#499). It has been retired.
Typing it now names the three commands it could have meant —
uf self-update for a newer uf, uf update for newer
dependencies, and uf install, which is where re-reading the
workspace and recording the plan now lives, in .uf/install.json.
uf self-update --check
Resolves the newest release and says whether it is newer than the active uf,
and changes nothing: no download, no link, no record. The newest is the newest
whatever UF_VERSION pins, and versions are compared as semver, so alpha.10
is newer than alpha.9. It exits 0 whether there is an update or not.
uf self-update --rollback
Switches back to the version that was active before the last switch. Every
switch records the version it replaces in previous-version, beside the
runtimes — uf self-update, uf use and the installer
alike, so a rollback after curl … | sh returns to what that replaced. Nothing
a switch replaces is deleted, so a rollback needs no network. It records what
it rolled back from, too, and a second --rollback undoes the first.
It refuses, changing nothing, when no version is recorded, and when the
recorded one is no longer in the store — naming uf self-update <version>,
which downloads it again.
uf self-uninstall [--dry-run] [--yes]
Removes uf from this machine, after listing exactly what that means:
uf,ufrandufxin~/.local/bin(UF_BIN_DIR), when they are links into the runtime store;- every runtime in
~/.local/share/uf/runtimes(UF_INSTALL_ROOT), and the record of the version to roll back to; - the toolchain store
uf env installfills, the project environments linked to it and the roots that keep it; uf env's cache of release lists, andactive-runtime.json.
The links go first, so an uninstall that is interrupted leaves a uf that is
off PATH rather than one that points at nothing. A uf in the link directory
that is not a link into the store — installed by Nix or cargo install, or
copied there — is listed as left alone and stays. So do the link directory
itself, every project's .uf and uf.lock, and whatever line puts
~/.local/bin on PATH: the installer prints that line for you to add, and
never writes it.
It asks before removing anything. --yes answers for a script, and
--dry-run prints the list and stops. With no terminal to ask on and neither
flag, it removes nothing and says to pass --yes. A variable that would have it
remove your home directory, or a directory holding it, is refused before
anything goes.
It is not uf uninstall, which is uf remove: npm and pnpm
spell removing a dependency that way, and a command that deletes the toolchain
must not be one mistyped verb away from it.
Publishing
uf release BUMP
Plans the next version — patch, minor, major or alpha — writes its
CHANGELOG.md section from the commits since the last release tag, and writes
the plan to .uf/release.json. It does not move the version in the workspace
or publish a release. UF maintainers use uf run release (a minor bump; uf's
releases are 0.x.0) to create a release PR, validate it, and publish after
merge. See
Contributing.
The version it plans is the one compiled into the binary running it, which is
the right source — the binary that cuts a release is the binary being released
— and it means an out-of-date binary plans a version the tree has already
published. Rewriting that version's section replaces a release's notes with a
later release's commits, so uf release refuses instead: a version that is
already tagged is refused outright, and a section that exists but has no tag is
a release being prepared and is refused until --force says the rewrite is
meant (#457).
It also names the commits in the range whose subject carries no (#NNN).
GitHub writes that number when it squash-merges, and a commit that did not get
one is invisible to everything downstream that identifies a change by its
number — so those are the lines to place by hand, and the ones
tools/ci/changelog-covers-the-release.sh asks to see cited by their summary or
their hash (#443).
uf publish
Publishes the package to the registry.
Exit codes
| Code | Meaning |
|---|---|
0 | Success; for fmt --check, nothing would change |
1 | The command ran and reported a problem: a failing test, a lint error, a type error, a file that needs formatting |
2 | uf could not run the command at all: an argument it cannot parse |
uf exec and uf env exec are the exceptions, and deliberately: both hand
control to another program, and both exit with whatever that program exited
with. A 2 from ufx tsc is tsc's 2, not uf's — wrapping it in uf's 1
would make every code a child can produce indistinguishable from every other,
which is the whole reason a script reads one.
A missing uf.config.js is not one of these — a project without one gets the
defaults, and every read-only command works. Two cases that should be 2 are
1 today, and the reason is written down rather than rounded off: a
uf.config.js uf cannot parse, and a missing @uniflowed/vite or
@uniflowed/host. Every command returns the same error type, so telling "could
not run" from "ran and found a problem" needs a distinction the command layer
does not make yet.
Additional environment tools
uf env use aube selects an official aube release for packageManager.
uf env use nub selects an official Nub release for runtime; Nub executes
on Node.js and uses uf's Node Flow loader for tests. Both commands show a
version selector in a terminal. Automation supplies an exact version, such as
uf env use aube@2.5.0 or uf env use nub@0.9.5.
uf acquires the platform's native npm carrier directly and checks the publisher's integrity. No npm installation or package lifecycle script runs. macOS and Linux on x64 and arm64 are supported. Advanced aube link and patch operations and Nub standalone compilation have no uf adapter yet.
Edit this pagedocs/app/reference/cli/$page.mdx