Readiness: Implemented
The tools
Environments
Two questions with one answer here: which Node, Bun or pnpm each command runs
on — declared in uf.config.js where it is used, locked in uf.lock, installed
into a store shared by every repository on the machine — and where the values
in process.env and import.meta.env come from, which is the .env cascade at
the bottom of this page.
What you will be able to do: declare the runtime and package manager each
command runs on, lock them so every machine runs the same releases, see which
release a command will use and which key chose it, install them ahead of time,
move a project off env.toolchain, and decide where the values in
process.env come from.
What you need first: a project — Your first project. A release you declare with a version is installed for you, so the machine does not need to have it already.
Declaring the tools
uf.config.js// @flow
import { defineConfig } from "@uniflowed/config";
export default defineConfig({
runtime: "node@24",
packageManager: "pnpm@10",
build: { runtime: "node@26" },
test: { runtime: "bun@1.4" },
});
Each key names a tool where it is used, which is also what the tool is for. This project serves on Node 24, builds on Node 26, runs its tests on Bun 1.4 and installs with pnpm 10, and every machine that checks it out runs those four.
| Command | Runs on |
|---|---|
uf dev, uf build, uf preview | build.runtime, then runtime |
uf test | test.runtime, then the runtime test.runner brings, then runtime |
uf start, uf run, uf exec | runtime |
uf install, uf add, uf remove, uf update and the other package commands | packageManager, then pm.packageManager, then package.json#packageManager, then the lockfiles |
The first key in a row that says something decides. A project that writes none
of them runs as it did before they existed: on the host
app.runtime.capabilityJsHost finds, with the manager
Dependencies describes.
runtime, build.runtime and test.runtime name node, bun or deno, and
packageManager names npm, pnpm, yarn or bun. Two more keys sit beside
them. build.builder is the builder uf dev and uf build drive: "vite",
which is @uniflowed/vite and the default, or a module of your own.
test.runner is "uf", the runner built into uf and the default, or
"bun@1.4", which runs the suite with bun test on that Bun — see
Choosing a runner for what only uf's own
runner does.
Writing a version
A tool is written name[@version]:
| Written | Means |
|---|---|
"node" | whichever node is on PATH — a machine without one is refused, not handed another runtime |
"node@26" | the newest 26.x, resolved the first time a command needs it and locked in uf.lock |
"node@26.8.2" | exactly that release |
"node@^26", "node@>=24" | refused |
A prefix is the one to reach for. It keeps the repository on a line of releases without anyone having to choose a patch, and the lock makes every machine agree on the patch anyway. A range is refused, because a range is not an environment: it answers "what could this build with", and the question a repository has to answer is what it is built with.
error: /app/uf.config.js: build.runtime is `node@^26`, which is a range, and a range is not an environment: it can resolve to a different release tomorrow. Write a version prefix instead — `node@26` is the newest 26.x, resolved once and locked in uf.lock
The first run
uf build› build.runtime is `node@26`: locked at 26.8.2 in uf.lock
› installing node@26.8.2 for build.runtime — once, into the store every project on this machine shares
The first command that needs node@26 reads the publisher's release list —
nodejs.org's for Node, the npm registry's for Bun, Deno, npm, pnpm and Yarn —
and writes the newest match into uf.lock:
uf.lock{
"toolchain": {
"node@26": "26.8.2"
}
}
Commit it. From then on the lock is the answer on every machine, CI included,
and no release list is read to decide it again; moving to a newer 26.x is
uf env update, below. A uf.lock that holds only this record says nothing
about which package manager installs the project.
The release is installed the first time a command needs it, and each of those
firsts is said once, as above. Offline, or on a platform the publisher ships no
build for, the command stops and names the key and the spec rather than running
on whatever node is on PATH:
error: test.runtime is `node@99.0.1`, and node@99.0.1 could not be installed
Starting the pinned binary is half of running on it. Vite's workers, a plugin
that shells out, a test that spawns node and every #!/usr/bin/env node bin
in node_modules/.bin find their runtime on PATH, so the runtime's directory
goes first on PATH for everything the command starts: a node that
uf build starts above is 26.8.2 as well.
The package manager is the same. With packageManager: "pnpm@10", uf install
runs the pnpm uf.lock locks, from the store, and the node that pnpm starts
on is runtime's. uf install --frozen-lockfile, the install CI runs, changes
no lockfile, so a prefix uf.lock does not lock yet is refused there rather
than resolved: run uf install once and commit uf.lock.
Seeing what runs
uf explain build 3. JavaScript host
provider node 26.8.2
what runs the builder and any JavaScript plugin; `build.runtime` names node@26, locked at 26.8.2 in uf.lock; in the store, and first on PATH for every process it starts
uf explain <command> names the release the command runs on, the key that
chose it, the lock, and whether the store has it yet. It reads rather than
resolves, so explaining a command never downloads what it explains. uf inspect
lists every tool at once:
uf inspect tools
runtime node@24 (runtime) · locked at 24.21.0
build runtime node@26 (build.runtime) · locked at 26.8.2
test runtime bun@1.4 (test.runtime) · locked at 1.4.2
test runner uf (uf's default)
package manager pnpm@10 (packageManager) · locked at 10.34.5
builder vite (uf's default)
uf env doctoruf env doctor · my-app
──────────────────────
✓ node@24 24.21.0 runtime
✓ node@26 26.8.2 build runtime
! bun@1.4 1.4.2 not installed test runtime
› `uf env install` installs what is missing now; otherwise the first command that needs a tool installs it
✓ pnpm@10 10.34.5 package manager
! 1 tool not installed yet · 4 tools checked
uf env doctor asks whether this machine has what the project runs on, and
answers per tool. A release the store lacks is a warning — the first command
that needs it installs it — and a tool written without a version that is not on
PATH is an error, with what to do about it underneath. A project that declares
nothing is told about the runtime and package manager uf falls back to.
--verbose adds where each tool is and where the store and the links are;
--json has all of it.
Installing ahead of time
uf env installuf env install · my-app
───────────────────────
- node@24 (24.21.0) runtime installed
- node@26 (26.8.2) build runtime installed
- bun@1.4 (1.4.2) test runtime installed
- pnpm@10 (10.34.5) package manager installed
› locked node@24 at 24.21.0, node@26 at 26.8.2, bun@1.4 at 1.4.2, pnpm@10 at 10.34.5 in uf.lock
✓ 6 executables linked into ~/.local/share/uf/envs/my-app-4f2a1c8e93b0d571/bin
Everything a command would install the first time it needs it, installed now —
what a CI image, or a laptop about to go offline, wants. uf env install
installs every tool the project declares at a version: runtime,
build.runtime, test.runtime, packageManager, the older env.toolchain,
and an exact version in package.json#engines. A prefix nothing has locked yet
is resolved and locked on the way.
Each tool is unpacked once, into a store keyed by tool, version and platform —
node-26.8.2-darwin-arm64 — and every repository that wants that exact release
uses the same copy. Two projects on the same Node share it; a third on a
different one gets its own, and neither disturbs the other.
uf writes in four places and no others: the store, the per-project link
directory beside it, the toolchain record in uf.lock, and .uf/profile in
the repository once you have picked an environment. There is no sudo step, no
shell profile to edit, and no version manager to initialise in your .zshrc.
The download's checksum is verified before anything is unpacked, against the
digest the publisher ships beside the archive.
The store lives under $XDG_DATA_HOME/uf/store, and $UF_STORE overrides
it; the links live beside it under uf/envs, and $UF_ENVS overrides those.
uf env listuf env · my-app
───────────────
this project
- node@24 (24.21.0) runtime installed
- node@26 (26.8.2) build runtime installed
- bun@1.4 (1.4.2) test runtime installed
- pnpm@10 (10.34.5) package manager installed
the store
at ~/.local/share/uf/store
- bun-1.4.2-darwin-arm64
- node-24.21.0-darwin-arm64
- node-26.8.2-darwin-arm64
- pnpm-10.34.5-darwin-arm64
shows each tool, what it is for and whether the store has it, beside what the
store holds for every repository. It reads uf.lock and fetches nothing, so a
prefix nothing has locked yet is shown as not locked.
uf env updatereads each publisher's release list again and moves every prefix in uf.lock to
its newest release, printing what moved — node@26 26.8.2 → 26.9.0. It
installs nothing: the change is a line in uf.lock to review, and the next
command that needs the new release installs it.
Using the tools outside a command
uf env exec node --versionv24.21.0
uf env exec puts the project's tools in front of PATH for one command and
runs it, exiting with whatever the command exited with. The node there is
runtime's, the one the application itself runs on. The shell you are in is
unchanged, and so is every other repository on the machine.
The links are beside the store rather than inside the project, in a directory
named for the project and keyed by its path — which keeps the property that
made them per-project in the first place: two checkouts on different Node
versions sit beside each other and neither is "active". What it drops is a
directory nobody wrote, nobody reads and every .gitignore had to know about.
$UF_ENVS moves them.
That is the whole model, and it is nix develop's: an environment is something
a directory has, not something a shell is switched into. Nothing has to be
activated, nothing has to be deactivated, and a script that runs uf env exec
gets the same tools whether it was started from your shell, from an editor, or
from CI.
Collecting what nothing uses
uf env gc repositories that are gone
- /Users/you/src/old-project (gone)
removed
- node-24.14.0-darwin-arm64
- pnpm-9.15.0-darwin-arm64
✓ removed 2 entries, forgot 1 root; 0 entries in use
A store that only grows is a store nobody trusts. Every uf env install
registers the repository as a root, and so does a command that installs a
tool the first time it needs it. The collector keeps exactly what some live
root reaches: a repository that was deleted stops holding its tools, in the
same pass that notices it is gone.
uf env gc --dry-runsays what would go and removes nothing, which is the version to run first.
Moving from env.toolchain
Before these keys, a repository pinned its tools in one list:
export default defineConfig({
env: { toolchain: { node: "24.14.0", pnpm: "9.15.0" } },
});
That list says which tools a repository has and nothing about what each one
is for, so no command ran on them; only uf env exec used them. It still works
— uf env install installs it — and every command that reads the config says
what to write instead:
! env.toolchain says which tools this project has and not what each is for; declare each where it is used instead — `runtime: "node@24.14.0"` and `packageManager: "pnpm@9.15.0"`
The other keys these replace work the same way: each keeps working, and each says what replaces it.
| Was | Is |
|---|---|
env: { toolchain: { … } } | runtime, build.runtime, test.runtime and packageManager, each where it is used |
pm: { packageManager: "pnpm" } | packageManager: "pnpm", which can pin a release as well |
builder: { module: "@uniflowed/vite" } | build: { builder: "vite" } |
test: { runner: { … } } | test: { runner: "uf" }, or nothing, since it is the default |
A test.runner object that sets applicationTarget to something other than
what app.framework implies has no other spelling yet. Keep it until
#953 gives it one; uf says so
rather than telling you to delete it.
An old key and its replacement can sit side by side while they say the same thing. When they disagree, the config is refused naming both, because which one you meant is not a question uf gets to answer by precedence:
error: /app/uf.config.js: runtime is `node@26` and env.toolchain.node is `24.14.0` — two spellings of one tool that disagree, and uf does not pick one. env.toolchain.node is the deprecated spelling: delete `env.toolchain.node`, and write `runtime: "node@24.14.0"` if 24.14.0 is the release you mean.
What the toolchain half does not do
It does not manage the uf binary itself — that is uf use and uf self-update.
It does not read .nvmrc or .tool-versions: uf.config.js is the one place a
uf project writes down what it runs on, and a second source of truth for the
same question is the problem, not the feature. package.json#engines is read
for one thing, which is that uf env install installs an exact version written
there. No command runs on it, because a range there is a statement about
compatibility rather than a choice of release. And uf build --compile still
embeds the host app.runtime.capabilityJsHost finds rather than runtime
(#1036).
Environment variables
Everything above is about tools. This is about values: a URL, a feature flag, a database password — the things that differ between your laptop, CI and production, and that must not be committed as code.
.env# Anything the browser may see starts with VITE_.
VITE_API_URL=https://api.example.com
# Anything else is the server's alone.
DATABASE_URL=postgres://localhost/app
app/$page.js// @flow
import * as React from "@uniflowed/react";
export component Page() {
// Substituted at build time, and it is in the bundle the browser downloads.
return <a href={String(import.meta.env.VITE_API_URL)}>the API</a>;
}
Server code — a route handler, a loader, a component that only ever renders on
the server — reads every variable through process.env instead, including the
ones with no prefix.
uf dev, uf build, uf preview, uf start, uf test, uf run and uf exec
all read the same files the same way. A value that reaches the dev server and
not the build is a bug, not a setting.
Which files, and in what order
A command runs in a mode, and the mode picks two of the four files:
| File | When it is read | Commit it? |
|---|---|---|
.env | always | yes |
.env.local | always | no — this is your machine's |
.env.<mode> | in that mode | yes |
.env.<mode>.local | in that mode | no |
They are read in that order, and the last one to set a name wins. So
.env.production beats .env, and .env.production.local beats both. A file
that is not there is skipped, which is why .env.local can be in
.gitignore and nothing breaks on a machine that has never had one.
uf new writes .env.local and .env.*.local into .gitignore, because
those two are where a credential goes; the tracked .env and .env.<mode> are
the project's defaults and belong in the repository. Vite's dev server refuses
to serve any of them over HTTP whatever else is configured — see
docs/security.md.
The mode
development for uf dev, production for uf build, uf preview and uf start, test for uf test, development for uf run and uf exec. It is
also what the browser reads as import.meta.env.MODE.
Four ways to say otherwise, and they are tried in this order:
--mode <name>on the command:uf build --mode stagingreads.env.stagingand builds withimport.meta.env.MODE === "staging".- The profile
uf env use <name>recorded, in.uf/profile. It is a local choice — a scaffolded project already ignores.uf/— and it lasts until you change it. env.activeinuf.config.js, for a project whose default mode is not one of the usual ones.- The command's own default, above.
A mode becomes the end of a file name, so it may hold letters, digits, _, -
and . and nothing else. local is refused, because .env.local is already
the file that overrides every mode.
The mode chooses files; it does not choose optimisations. uf build --mode staging is still a production build — minified, with import.meta.env.PROD
true — because that is what building means. The one variable that changes it is
NODE_ENV, which is an ordinary variable here and which Vite reads: a file that
sets NODE_ENV=development makes even uf build produce a development build.
If you did not mean that, do not put NODE_ENV in a .env file.
uf env use staging✓ mode staging: `.env`, `.env.local`, `.env.staging` and `.env.staging.local`, in .uf/profile
The environment always wins
A variable that is already set when uf starts is never overwritten by a file:
DATABASE_URL=postgres://prod/app uf builduses that one, whatever .env.production says. That is what makes a .env file
a default rather than a policy, and it is why a CI secret, a container's
environment and a docker run -e all keep working with no configuration at all.
uf inspect prints the mode, the files that were read and how many variables
came out of them — names only, never values, because that output ends up in
issues.
What reaches the browser
Only a name that starts with the client prefix — VITE_, until a project
sets another. That value is substituted into the JavaScript the browser
downloads, so it is public: anyone who opens the site can
read it. Everything else is available to server code through process.env and
is never written into a client bundle, even if a module that ships to the
browser asks for it — import.meta.env.DATABASE_URL in client code is
undefined, not the password.
That boundary is Vite's, uf does not widen it, and there is a test that a value
without the prefix is absent from every file in dist/
(crates/uf_cli/tests/vite.rs). A project that prefers another prefix sets
Vite's own option, vite: { envPrefix: "PUBLIC_" }, and uf honours it. An empty
prefix is refused — by Vite, which throws on it, and by uf, which goes on using
VITE_ — because it would put every variable in the bundle.
The rule to hold on to: if it is a secret, it must not start with the prefix.
The file format
One NAME=value a line, and:
# A comment. Blank lines are ignored.
export ALSO_FINE=yes # `export ` is allowed, and ignored
QUOTED=" spaces are kept " # a trailing comment needs a space in front
LITERAL='no $expansion, no \n escapes, exactly as written'
MULTILINE="first line
second line"
ESCAPES="a tab\there and a newline\nthere"
HASH=pass#word # no space in front, so it is part of the value
HOST=example.com
API_URL=https://${HOST}/api # or $HOST, without the braces
PRICE=costs \$5 # a literal dollar
- A name starts with a letter or
_and continues with letters, digits or_. - Double quotes understand
\n,\r,\t,\\,\",\'and\$; a backslash before anything else is a backslash, so a Windows path survives. - Single quotes are exactly as written: no escapes, no expansion.
- Both may span lines.
$NAMEand${NAME}expand to something already defined — earlier in this file, in a file earlier in the cascade, or in the environment. The environment's value is the one that expands, since that is the value the name will have.
A file that does not parse stops the command, and the message names the file, the line and what to do:
error: failed to load the environment files for mode development
caused by: /app/.env:2: `${HSOT}` is not defined; define it earlier, set it in
the environment, or write `\${HSOT}` for a literal dollar
An undefined expansion is an error rather than an empty string, and that is a
decision: https://$HSOT/api silently becoming https:///api is a typo that
reaches production and fails somewhere else entirely.
Naming the files yourself
uf.config.js// @flow
export default {
env: {
files: ["config/shared.env", "config/local.env"],
},
};
Replaces the cascade entirely: those files, in that order, later winning,
relative to the project root, and no .env anything. Empty by default, which is
what selects the four files above.
Inside the project, and only inside it. An absolute path, a .., a ~ and a
symlink that points out are all refused, whichever of the files it is: a
uf.config.js in a repository you have just cloned is not a thing that should
be able to read ~/.aws/credentials — which parses as NAME=value like
anything else — into the environment of every command you then run. See
docs/security.md.
What the variables half does not do
- It does not reload in place.
uf devdoes watch the files the cascade selects, and starts the server again when one of them changes, saying which file it was — so an edit no longer needs you to restart the command by hand. What it will not do is swap the value into a running page. A restart is the honest granularity: a prefixed value reaches the browser by being substituted into the bundle, so a new one has to be substituted again and every module that read it has to be evaluated again.uf build,uf previewanduf startread the files once, which is all a command that ends has to do. - It does not travel to a deployment.
uf build --adapter nodewrites a directory that a host runs with plainnode server.js, and that process is not uf: it reads its environment from the host, which is where a deployment's secrets belong anyway. The values a build needed are already in the bundle; the ones the server needs are the host's to provide. - It is not a secret store. A
.envfile is a file on a disk. uf refuses to serve one over HTTP and refuses to put an unprefixed one in a bundle, and neither of those makes it encrypted.
Where to go next
Tasks is next: the named commands uf run starts, on the same
runtime and with the same environment files. uf in CI is where a
committed uf.lock and uf install --frozen-lockfile go.
Edit this pagedocs/app/guide/env/$page.mdx