CLI Commands Reference
The Uniweb CLI (uniweb) scaffolds projects, builds foundations and sites, generates documentation, diagnoses issues, and manages translations.
Quick Reference
uniweb create [name] # Create a new project (default: starter)
uniweb add <type> [name] # Add a project, foundation, site, or extension
uniweb dev # Start a dev server for a site
uniweb build # Build the current project
uniweb snapshot # Compose a preview image of a site (its site.yml preview:)
uniweb export # Build dist/ for any static host (no Uniweb account)
uniweb inspect <path> # Show the parsed content shape of a section or page
uniweb rename <type> <old> <new> # Rename a foundation, site, or extension workspace-wide
uniweb docs # Generate component documentation
uniweb doctor # Diagnose project configuration
uniweb validate # Check content against the data schemas your foundation declares
uniweb update # Reconcile workspace state with the running CLI
uniweb i18n <command> # Manage translations
uniweb login # Authenticate with Uniweb platform
uniweb register # Register a foundation + its data schemas
uniweb publish # Publish a site to Uniweb hosting (brings the foundation along)
uniweb deploy # Ship a site to a host (asks where, if not yet configured)
uniweb add ci --host=<adapter> # Set up CI so every push deploys (+ PR previews)
uniweb push # Push local site content to the Uniweb backend
uniweb pull # Pull backend site content to local files
uniweb refresh # Catch up with the git remote and the backend (never pushes)
uniweb sync # Catch up, then push (refresh + push)
uniweb clone <site-uuid> # Start a local project from a backend site
uniweb status # Show a site's sync state (unpushed content)
uniweb forget --server <url> # Forget one backend (--all: make a copied project a new one)
uniweb site <list|unpublish|delete> # The sites in a workspace — list them, take one offline, delete oneuniweb create
Create a new Uniweb project.
uniweb create [name] [options]
uniweb create . # Scaffold into the current directoryArguments
| Argument | Description |
|---|---|
name | Project directory name (prompted if omitted), or . to scaffold into the current directory |
Options
| Option | Description |
|---|---|
--template <type> | Template to use (default: starter) |
--blank | Create an empty workspace (grow with uniweb add) |
--name <name> | Project name (overrides slugified basename when used with .) |
--no-git | Skip git repository initialization |
In-place mode (uniweb create .)
Scaffolds into the current directory instead of creating a new one. Pairs with the GitHub-first workflow — create the repo on GitHub, clone it locally, then run uniweb create . inside the clone:
gh repo create my-site --public --clone
cd my-site
uniweb create . --template marketing
uniweb add ci --host=github-pages # Optional: add CI for GitHub PagesNaming. The project name comes from the cwd basename, slugified to a valid npm name (MyProject → myproject, my_site → my-site). If the slug is empty, the verb errors and asks you to pass --name=<slug> explicitly. The site is named after the project too, unless the template gives its site a name of its own in site.yml — the official templates do, so a site made from marketing starts as Product Launch.
Conflict handling. Pre-existing README.md and .gitignore are overwritten — the scaffold's versions (project-shaped README, Vite/Node-aware ignores) are more useful than what gh repo create puts in a fresh repo. Any other collision aborts with the list of conflicting files; move or remove them and re-run. Files outside the scaffold's write set (e.g. LICENSE) are left alone.
Git. git init is skipped when a repository already holds the directory — the clone you run it in, or a repository it sits inside. A folder a repository ignores gets a repository of its own.
Default Behavior
Without --template or --blank, the CLI scaffolds a working project with foundation + site + starter content. In interactive mode, you're prompted to choose a template. In non-interactive mode (CI, scripts, agents), it defaults to starter.
Templates
Built-in:
| Template | Description |
|---|---|
starter | Foundation + site + starter content (default) |
none | Foundation + site with no content |
Official templates: uniweb template list prints the ones your CLI scaffolds, and the templates repository lists them all, each with a live demo. A CLI scaffolds the templates of the release it was published with, so npx uniweb@latest template list shows the current set.
External templates:
| Format | Example |
|---|---|
| Local directory | ./path/to/template |
| npm package | @myorg/uniweb-template |
| GitHub repo | github:user/repo |
| GitHub URL | https://github.com/user/repo |
Examples
# Foundation + site + starter content (default)
uniweb create my-site
# Interactive (prompts for template)
uniweb create
# Foundation + site with no content
uniweb create my-site --template none
# Empty workspace (grow with add)
uniweb create my-workspace --blank
# Use official marketing template
uniweb create my-site --template marketing
# Use npm package template
uniweb create my-site --template @acme/corporate-template
# Use GitHub template
uniweb create my-site --template github:myorg/custom-template
# Use local template
uniweb create my-site --template ./my-template
# Scaffold into the current directory (e.g., a freshly-cloned GitHub repo)
uniweb create .
uniweb create . --template docs
uniweb create . --name=my-appBackward compatibility:
--template blankstill works as an alias for--blank.
Troubleshooting Template Downloads
Official templates are fetched from GitHub Releases. If download fails:
- Check network access — Corporate networks may block GitHub API
- Use the starter — Run
uniweb create my-site --template starterto use the built-in starter - Check rate limits — GitHub API has rate limits for unauthenticated requests
uniweb add
Add a project, foundation, site, or extension to an existing workspace.
uniweb add project [name] [options]
uniweb add foundation [name] [options]
uniweb add site [name] [options]
uniweb add extension <name> [options]
uniweb add section <Name> [options]
uniweb add ci [options]Run this from a workspace root (a directory with pnpm-workspace.yaml). If there's no workspace yet, create one with uniweb create --blank.
Common Options
| Option | Description |
|---|---|
--from <template> | Apply content from a template after scaffolding |
--path <dir> | Custom directory for the package |
Foundation Options
| Option | Description |
|---|---|
--project <name> | The project's foundation, at <name>/src/ (package <name>-src). Takes no name of its own |
Site Options
| Option | Description |
|---|---|
--foundation <name> | Foundation to wire to (prompted if multiple exist) |
--project <name> | The project's one site, at <name>/site/ (package <name>-site). Takes no name of its own — for several sites, name each folder: add site <name>/sites/<site> |
Extension Options
| Option | Description |
|---|---|
--site <name> | Site to wire the extension URL into |
Section Options
Adds a section type to a foundation — a component file and a meta.js beside it.
| Option | Description |
|---|---|
--foundation <name> | Foundation to add the section to (prompted if several exist) |
--starter | Generate starter content for the section type from its content: declaration — what an author would begin editing |
--preset <name> | With --starter: use that preset's params as the frontmatter |
--write <file> | With --starter: write the markdown to a file instead of printing it |
--json | With --starter: emit the parsed declaration, the content structure and the ProseMirror document |
--starter works two ways. On a section type that already exists it generates content from that section's real declaration and writes nothing. On a new one it scaffolds the section and gives its meta.js a content: declaration derived from the family the name resolves to, so the three pieces agree: a declaration, a component, and content that fills it.
uniweb add section Hero --starter # an existing section — nothing is written
uniweb add section Pricing --starter # scaffold it, plus content that fills it
uniweb add section Hero --starter --json # the declaration and the content, pipeableThe content is derived from what the component says it expects — there is no key to author it in. See Component Metadata → Content.
CI Options
uniweb add ci scaffolds a deploy workflow for a host, and PR-preview workflows where the host supports them. Its options are listed by uniweb add --help; the deployment guides cover the hosts themselves — Deploying.
Placement
The CLI creates exactly the folder you ask for. The name argument is taken verbatim as the folder name (and the package name) — no silent nesting under foundations/ or sites/. Pass a slash-bearing argument or --path <parent> when you want the foundation or site to live under a parent directory.
Resolution rules (foundation example; same shape for site):
| Input | Folder | Package name |
|---|---|---|
add foundation (no name) | src/ | src (folder name = package name) |
add foundation ui | ui/ | ui |
add foundation foundations/ui | foundations/ui/ | ui (last segment of the path) |
add foundation ui --path libs | libs/ui/ | ui |
add foundation --path foundations/effects | foundations/effects/ | effects (last segment of --path) |
add foundation --project docs | docs/src/ | docs-src (co-located convention) |
add foundation core --project docs | — refused | --project names the folder itself; the command stops and names add foundation docs/core |
Same shape for add site (default folder site/, default package site, co-located <project>/site/ with package name <project>-site). add site guide --project docs stops and names add site docs/sites/guide, the form for a project with several sites.
Collision handling. Before scaffolding, the CLI checks two things:
- The target folder doesn't exist. If it does, the command stops with the suggested alternatives (different name, or
--pathto a different parent). - The package name isn't already in use. Detection uses
classifyPackagefrom@uniweb/build, so a foundation and a site can't collide on the same name even if they live in different folders. If the name is taken, the command stops and suggests picking a different one.
Why no silent nesting? The framework classifies packages by their contents (presence of package.json::main matching _entry.generated.js, presence of site.yml, etc.) — not by their location. So the folder name is purely organizational; no specific layout is required. The CLI honors that: if you write add foundation ui, you get ui/. If you want it under foundations/, ask for it explicitly with add foundation foundations/ui.
Extension placement:
The same rule: add extension effects creates effects/, add extension extensions/effects creates extensions/effects/, and --path is the folder it goes in. An extension needs a name, or a --path that names its folder.
A site loads a workspace extension from its own origin at /<name>/entry.js. The build finds it in <name>/ or extensions/<name>/, in the folder the site is in or a folder above it up to the workspace root — the nearest first — and copies it into the site's output; uniweb dev serves it from the same folder. add extension --site writes that URL into the site's site.yml when the new extension is the one the site will find there; otherwise it writes nothing and says why.
Package Naming
The package name equals the name you provide (or the default src for foundations, site for sites). For add project, names are suffixed for workspace uniqueness: {name}-src and {name}-site. A foundation whose name a site already uses becomes {name}-src, and an extension whose name is taken becomes {name}-ext; any other collision stops the command with guidance.
A package name is a workspace name. What a foundation or extension registers as is name in its main.js, which add writes too: the name you gave, before any suffix — or, for add foundation with no name, the workspace's. If another foundation in the workspace already registers as that, it gets the suffixed package name instead, so no two register as one. See uniweb register → Identity.
The --from Flag
The --from flag applies content from a template after scaffolding structure. Structural files (package.json, vite.config.js) come from the CLI; content (section types, pages, theme, and a foundation's main.js) comes from the template. A template's main.js that names no foundation is given the name add would have written.
# Scaffold a foundation with marketing sections
uniweb add foundation marketing --from marketing
# Scaffold a site with docs pages
uniweb add site blog --from docs --foundation marketingWhen applying site content, the CLI reports which section types the template expects. If your foundation doesn't provide them, you'll get a build error — a clear signal of what to add.
Examples
# Add a co-located foundation + site pair
uniweb add project docs
uniweb add project docs --from academic
# Add a foundation (first goes to root)
uniweb add foundation
uniweb add foundation ui
# Add a site wired to a specific foundation
uniweb add site blog --foundation marketing
# Add an extension and wire it to a site
uniweb add extension effects --site site
# Co-located layout via --project flag
uniweb add foundation --project docs
uniweb add site --project docsWorkspace Config
The add command lists the new package in pnpm-workspace.yaml and package.json::workspaces — only where no pattern there covers its folder already, so a workspace listing */sites/* keeps its file unchanged when a site is added under it. It edits just the packages: list: comments and the file's other settings stay as they are. It also updates the root package.json scripts (dev, build, preview, and dev:<site> / preview:<site> for each site after the first), removing the per-site scripts it wrote for a site that no longer exists; a script you wrote yourself is left alone. You don't need to manage these manually.
uniweb dev
Start a development server for a site.
uniweb dev # the workspace's single site
uniweb dev <site> # a specific site by package name
uniweb dev --site <name> # same, explicit flag formA thin wrapper around the package manager's workspace-filtered dev script (pnpm --filter <site> dev, or npm -w <site> run dev). It picks the single site automatically. In a multi-site workspace the first site runs by default, with a notice pointing at --site for explicit selection.
Markdown, theme.yml, and component edits hot-reload. New section types are picked up without restarting.
A site's workspace extensions (extensions: [/effects/entry.js]) are served from their builds. uniweb dev builds any that isn't built, or whose sources changed since its last build, before the server starts. Nothing watches an extension while the server runs: after editing one, run uniweb build and reload.
A site whose foundation is a released version — foundation: '@org/name@1.2.0', as in every uniweb clone — has no build of that foundation in the project. uniweb dev asks the backend the site is on (the one you are logged in to) where that version is served, and runs the dev server against it for that run only: nothing is written, and site.yml keeps the version. Images the site keeps as the backend's own URLs are fetched from that backend as well. When the backend does not say where the version is served, uniweb dev says so and does not start — pushing, pulling and publishing do not need it. Running vite directly skips the question, and the site's build stops at the version it cannot resolve.
uniweb inspect
Print the parsed content shape of a markdown file or folder — the { content, params, … } object components actually receive. This is the fastest way to answer "why isn't my section getting X?" without reasoning through the parsing rules.
uniweb inspect pages/home/hero.md # one section
uniweb inspect pages/home/ # every section on the page
uniweb inspect pages/home/hero.md --full # include empty fields (matches runtime)
uniweb inspect pages/home/hero.md --sequence # include the sequence array
uniweb inspect pages/home/hero.md --raw # the ProseMirror AST instead| Option | Description |
|---|---|
--full | Include empty fields, matching exactly what the runtime hands the component |
--sequence | Include the sequence array (all elements in document order) |
--raw | Print the ProseMirror AST rather than the flat shape |
Paths resolve against the current working directory, so run it from the site package (the one containing pages/).
uniweb build
Build the current project (foundation, site, or workspace).
uniweb build [options]The CLI auto-detects the project type:
| Indicator | Type |
|---|---|
src/sections/, src/components/, or src/main.js | Foundation |
site.yml or pages/ | Site |
pnpm-workspace.yaml | Workspace (builds all) |
When run at workspace root, builds all foundations first, then extensions, then sites.
Options
| Option | Description |
|---|---|
--target <type> | Force build type: foundation or site |
--prerender | Force static HTML generation (overrides site.yml) |
--no-prerender | Skip static HTML generation (overrides site.yml) |
--foundation-dir <path> | Path to foundation (for site prerendering) |
--platform <name> | Deployment platform (e.g., vercel) |
Foundation Build
When run in a foundation directory:
- Discovers section types from
src/sections/andsrc/components/(withmeta.js) - Generates entry point (
_entry.generated.js) - Runs Vite build
- Processes preview images (converts to WebP)
- Generates
meta/schema.jsonwith full metadata
Output:
dist/
├── entry.js # Bundled components
├── entry.js.map # Source map
├── meta/
│ ├── schema.json # Component metadata
│ └── previews/ # Preview images
│ └── [Component]/
│ └── [preset].webp
└── assets/
└── style.css # Compiled CSScd foundation
uniweb buildSite Build
When run in a site directory:
- Runs Vite build for the site
- If
build.prerender: trueinsite.yml, generates static HTML for all pages
cd site
uniweb buildPre-rendering (SSG)
Pre-rendering generates static HTML at build time for fast loads and SEO.
Enable in site.yml:
build:
prerender: trueOr via CLI:
uniweb build --prerenderOutput:
dist/
├── index.html # Homepage
├── about.html # /about page
├── docs/
│ └── getting-started.html
└── assets/Examples
# Build entire workspace (from root)
uniweb build
# Build foundation only
cd foundation && uniweb build
# Build site with prerendering
cd site && uniweb build --prerender
# Build for Vercel deployment
uniweb build --platform verceluniweb snapshot
Compose a preview image of a site from the site itself. It opens the site in a headless browser, captures what a visitor sees, and arranges the captures on a background in the site's theme colors.
uniweb snapshot # build, capture, write site/public/preview.webp
uniweb snapshot --dev # capture the dev server instead (no build)
uniweb snapshot --url https://example.com --out card.webp # a site that is already runningBy default the image becomes the site's card image. The command writes site/public/preview.webp and sets preview: /preview.webp in site.yml (Site Configuration). It sets preview when site.yml has none, or has only the value the app records for a card image it generated. A URL or image path you wrote stays, and the command says so. --no-set-preview leaves site.yml alone.
Requirements
The command uses the @uniweb/snapshot package, which a new project doesn't include. Add it once:
pnpm add -D -w @uniweb/snapshotIt drives a browser that is already installed, Google Chrome or Microsoft Edge, and downloads nothing. To use another Chromium, set UNIWEB_SNAPSHOT_BROWSER to its executable, or install one with npx playwright-core install chromium --only-shell.
Layouts
| Layout | For | Shows |
|---|---|---|
split | pages that scroll | The first view in a browser window, beside a long strip of the page |
device | pages that don't scroll as a page, such as a docs shell or an app | The page in a desktop browser window, with a phone showing it in front |
With --layout auto (the default), a page at least 1.6 viewports tall gets split and anything shorter gets device. With --tone auto, a light page goes on a deep background and a dark page on a light one.
The look
How the two frames sit together. Lengths are pixels on a 1600×1000 image, and scale with it.
| Option | Default | Description |
|---|---|---|
--gap <px> | 48 for split | Space between the two frames |
--overlap <px> | 31 for device | How far they overlap instead; use --gap or --overlap, not both |
--strip <width> | fit | split only: fit, or 1:N for a strip one wide by N tall. A narrower strip shows more of a long page, and a short page as a smaller card |
--side <side> | right | Where the strip or the phone goes: right, left |
--frame <style> | browser | browser, a window with a title bar, or plain |
--tone <name> | auto | The background: auto, light, deep |
Choosing a look
What looks best depends on the site, so compare before you choose:
uniweb snapshot --compareThis captures the site once and puts several looks on one sheet: the current look, then one change each (spacing, strip width, side, frame, tone, and the other layout). Each look is captioned with the flags that produce it. The sheet goes to site/.uniweb/snapshot/compare.webp, which a new project already ignores. site.yml isn't touched.
Take the look you like with its flags, and add --save to keep it:
uniweb snapshot --strip 1:2.5 --save--save writes this run's flags into site/snapshot.yml. Later runs start from that file, and flags still override it. Keep the file with the site, and anyone who runs uniweb snapshot gets the same look.
# site/snapshot.yml
route: /research
strip: '1:2.5'
side: leftIt takes the same settings as the flags, without the dashes: route, layout, tone, gap, overlap, strip, side, frame, size, scale, quality, hide (a list), and out (relative to the site folder). Nothing else reads this file: building and publishing the site leave it alone.
Options
| Option | Description |
|---|---|
<site>, --site <name> | The site (default: the one you're in, or the only one) |
--dev | Capture the site's dev server instead of a build |
--url <address> | Capture a site that is already running |
--no-build | Capture the existing dist/ without rebuilding |
--route <path> | The page to capture (default: the home page) |
--layout <name> | auto (default), split, device |
--gap, --overlap, --strip, --side, --frame, --tone | The look |
--compare | Put several looks on one sheet instead of writing the image |
--save | Keep this run's flags in site/snapshot.yml |
--size <WxH> | Image size (default 1600x1000; 1200x630 suits social cards) |
--scale <n> | 1 (default), or 2 for a double-density image |
--quality <n> | Encoder quality, 1–100 (default 82) |
--out <file> | Where to write the image, or with --compare the sheet: .webp, .png, .jpg or .avif |
--hide <selector> | Hide matching elements before capturing, such as a cookie banner (repeatable) |
--no-set-preview | Write the image without changing site.yml |
Examples
# The card from a specific page
uniweb snapshot --route /pricing
# A narrower strip on the left, kept for next time
uniweb snapshot --strip 1:3 --side left --save
# A social image, leaving site.yml alone
uniweb snapshot --size 1200x630 --out site/public/og.png --no-set-preview
# Without the chat widget
uniweb snapshot --hide '#chat-launcher'uniweb docs
Generate documentation from your foundation's component schemas.
uniweb docs [subcommand] [options]Subcommands
| Subcommand | Description |
|---|---|
| (none) | Generate COMPONENTS.md from foundation schema |
site | Show site.yml configuration reference |
page | Show page.yml configuration reference |
meta | Show component meta.js reference |
Options
| Option | Description |
|---|---|
--output <file> | Output filename (default: COMPONENTS.md) |
--from-source | Read meta.js files directly instead of schema.json |
--target <path> | Specify foundation directory |
Generated Documentation
The COMPONENTS.md file includes for each component:
- Title and description
- Family (the standard section type it claims, when declared)
- Content expectations (what markdown elements it uses)
- Parameters with types, options, and defaults
- Available presets
Examples
# Generate COMPONENTS.md in current foundation
cd foundation
uniweb docs
# Generate with custom filename
uniweb docs --output REFERENCE.md
# Generate from source (no build required)
uniweb docs --from-source
# Show site.yml reference
uniweb docs site
# Show page.yml reference
uniweb docs page
# Show meta.js reference
uniweb docs metauniweb doctor
Diagnose project configuration issues.
uniweb doctorThe doctor command checks your workspace and reports errors and warnings. Run it from the workspace root or from any site/foundation directory.
What It Checks
Workspace structure:
- Discovers all foundations, extensions, and sites
For each site:
site.ymlexists and references a valid foundation- Foundation dependency in
package.json(correctfile:path) - The installed foundation is the one in your workspace, and resolves the
@uniweb/*versions it declares — afile:dependency satisfied by a copy rather than a link leaves the dev server serving code your builds never see - Foundation is built (
dist/entry.jsexists)
For each extension:
- Declares
extension: trueinmain.js - Doesn't declare theme variables (
vars) or layouts - Extension is built
- Extension URLs in
site.ymlresolve to built extensions
Cross-references:
- Warns if a foundation with
extension: trueis wired as a primary foundation instead of listed underextensions:insite.yml
Examples
# From workspace root
uniweb doctor
# From a site directory (finds workspace root automatically)
cd site && uniweb doctoruniweb validate
Check a project's file-based data against the data schemas your foundation declares for the sections that consume it.
uniweb validate [path]Where doctor checks your project against framework conventions, validate checks your data against the contracts you declared in meta.js (data: { … }) — "does my content match what I said it should be?" A violation fails it (exit 1); --lax reports without failing. It never blocks a build: the live render path stays tolerant (applies field defaults, ignores the rest), so this is a pre-ship / CI gate you run on purpose — and the one uniweb push and uniweb publish run before they send anything.
What It Checks
For each section with a file-based data input, it resolves the schema bound to that input and checks every record — and it checks every record file in records/ against the schema its folder names (records/member/ → @/member), whether or not a section reads it, since a push sends them all. Each record is checked for:
- Missing required fields
- Type mismatches against the field's declared type
- Values outside an enum
- Format violations (
url,email, and adatethat is not a realYYYY-MM-DDday or adatetimethat is not a day and a time) - Nested object and array fields, recursively
A record of a sections: schema is checked in either shape a file can hold it: flat — its single sections' fields at the top of the file, the brief's first — or written by section, each section under its own key, a list section as a list of records and child sections included.
It does not flag unknown or extra fields, and it does not flag an absent optional field that has a default — the runtime fills those.
Deferred
Data that isn't in your project is reported as deferred, never silently skipped: a remote (url:) source, fetched where the page renders. Validate it by pointing the source at live data. A data block whose component declares its schema inline is reported as deferred too.
Everything else with a schema is checked — a schema whose root is a list, like @std/nav, as the list: the records a query delivers, a data block's array, or the list a record file holds under its section's key. A reference field (ref) must name a record of its schema — by its file's name or slug: — and one that names none is reported; an options value is not checked against the list it names.
Options
| Option | Description |
|---|---|
--lax | Report violations without failing (exit 0) |
--json | Machine-readable output for CI annotations |
--site <name> | Check one site in a multi-site workspace |
Exit codes: 0 clean (or --lax), 1 violations, 2 setup error (e.g. not in a workspace).
It also runs when you ship
uniweb push and uniweb publish run the same check first and stop before sending anything if a record doesn't conform. They register your foundation before sending content, so the backend checks the records against the very schemas checked here — a record that does not conform would be refused on arrival, or lose a value on the way. They also refuse what a push cannot carry: a field the record's schema does not declare, a markdown body with no field in the schema to hold it, and a record written by section.
uniweb deploy to a static host runs the check too, and warns and carries on. Nothing downstream enforces a schema there, and a schema can be newer than the content that was valid when it was authored — refusing to deploy over that would make your site hostage to a schema release.
The check is silent when everything conforms, and also when there's nothing to check against — a site whose foundation: is a registry ref or a URL has no schemas on disk. Pass --no-validate to any of those commands to skip it; on push and publish that leaves the records to the backend to accept or refuse.
Examples
# Check every site in the workspace — fails (exit 1) on any violation; wire into CI
uniweb validate
# Report violations without failing
uniweb validate --lax
# Machine-readable output
uniweb validate --json
# One site in a multi-site workspace
uniweb validate --site bloguniweb update
Reconcile a workspace's state with the running CLI. Two convergence steps:
- Align workspace deps — edit every
package.jsonso@uniweb/*anduniwebversions match the CLI's bundled matrix, then run the workspace's package manager (auto-detected from the lockfile:pnpm-lock.yaml→ pnpm,yarn.lock→ yarn,package-lock.json→ npm). - Refresh
AGENTS.mdfrom the CLI's bundled partial.
uniweb update [options]It does not update the CLI itself
This is the part worth reading twice, because the name suggests otherwise.
uniweb update reconciles a project against the version matrix of the CLI that runs it. It never upgrades that CLI. So if the CLI is out of date, the command is a no-op that reports everything aligned — correctly, because the project matches the matrix it was asked about.
That matters most when the CLI is project-local, which is the usual case: pnpm uniweb update runs the copy in your node_modules, pinned by your own package.json, and that copy has no way to know a newer release exists.
To move forward:
npx uniweb@latest update # fetch the latest CLI, align the project, and bump the pinnpx runs the newest published CLI, which carries its own matrix — and because the project's uniweb dependency is one of the deps it aligns, the pin moves too. A globally installed CLI is upgraded through its package manager instead (npm i -g uniweb@latest, pnpm add -g uniweb@latest).
When a newer CLI exists, update says so at the end of the run and names the command for your situation.
Why both steps together
AGENTS.md is regenerated from the CLI's current partials and stamped with the CLI version. Refreshing it while the workspace's declared @uniweb/* deps lag the CLI silently produces a doc that documents features the installed code doesn't have. The verb's drift gate refuses that combination unless you pass --allow-mismatch. A --dry-run moves no deps, so it says what the real run would do: refresh AGENTS.md once the deps it lists are installed.
Project notes survive in one place. Everything in AGENTS.md is rewritten except a block that starts with a <!-- project-notes:start --> line and ends with a <!-- project-notes:end --> line, each on a line of its own. update carries every such block over verbatim, to the end of the new file. If the markers don't pair up, it leaves AGENTS.md as it is and says why. Notes of any length are better kept in a file of your own, with a line in the block pointing to it.
Options
| Option | Description |
|---|---|
--deps-only | Only align deps; skip AGENTS.md. |
--agents-only | Only refresh AGENTS.md; skip deps. |
--no-deps | Skip the deps-alignment step. |
--no-agents | Skip the AGENTS.md step. |
--dry-run | Print the survey and would-be writes; make no changes. |
--verbose | List every surveyed dep. By default only those needing attention are listed, with the aligned ones collapsed to a count. |
--allow-mismatch | Allow AGENTS.md to refresh even when declared deps lag. |
--yes | Skip confirmation prompts. |
--non-interactive | Auto-detected; never auto-installs from a script. |
What you see
The verb prints a version survey first, regardless of flags:
uniweb CLI: v1.2.3
AGENTS.md stamp: v1.1.0
Workspace deps (declared):
(root)/
✗ uniweb ~1.1.0 → ~1.2.3 minor · BREAKING
foundation/
✗ @uniweb/core ~1.1.2 → ~1.1.4 patch
✗ @uniweb/kit ~1.0.6 → ~1.2.0 minor · BREAKING
✗ @uniweb/build ~1.3.1 → ~1.3.2 patch
site/
✗ @uniweb/runtime ~1.1.0 → ~1.1.3 patch
✗ @uniweb/build ~1.3.1 → ~1.3.2 patch
⚠ 2 packages cross a breaking boundary:
uniweb ~1.1.0 → ~1.2.3 (minor)
@uniweb/kit ~1.0.6 → ~1.2.0 (minor)
A minor version is where Uniweb's breaking changes go (a major is rarer still),
so these are releases you may need to act on: build and check before shipping.update writes tilde ranges (~1.0.1). A patch release — a fix or a feature — installs within one by itself; a new minor version is where Uniweb puts breaking changes, so it arrives only through update, which lists every package that crosses one.
Each row is one declared dep in one workspace package.json that needs attention: one the CLI ships a newer version of, labelled with how far it moves — patch, or minor · BREAKING and major · BREAKING for a version that may ask you to act — or one ahead of CLI, left untouched since update never downgrades. The packages that cross a breaking boundary are named again after the table, so the log of an update --yes still says what it crossed. Deps already aligned are collapsed to a count rather than listed, because on the command's most common outcome — a no-op — every row would otherwise read ~1.0.1 → ~1.0.1 aligned and bury the lines that matter. --verbose lists them all. See Framework Versions for what each part of a version means.
After the survey, each step prompts in TTY; --yes accepts the defaults; non-interactive prints the plan without mutating.
Install failures
If the package manager's install step fails after package.json edits succeed, the edits are kept. The verb prints a git checkout -- revert command and the install command to retry. There's no automatic rollback — install failures usually need human attention (peer-dep conflicts, lockfile contention) that hiding the diff would only obscure.
Project-local installs
When the running CLI lives in node_modules (project-local), its version is pinned by your project's package.json — so it aligns the project to whatever matrix that pinned version carries, which may be an old one. Both steps still run; they just answer about the pinned release. npx uniweb@latest update is how you move off it.
Examples
# Reconcile against the CLI you already have
uniweb update
# Reconcile against the LATEST release, and move the project's pin with it
npx uniweb@latest update
# Just align workspace deps and run install
uniweb update --deps-only
# CI: print plan without mutating, exit non-zero on drift
uniweb update --dry-run --non-interactive
# CI: align deps and refresh AGENTS.md without prompts
uniweb update --yes --non-interactiveuniweb i18n
Manage internationalization and translations.
uniweb i18n <command> [options]Commands
| Command | Description |
|---|---|
extract | Extract translatable strings to manifest |
sync | Update manifest with content changes |
status | Show translation coverage per locale |
Options
| Option | Description |
|---|---|
--target <path> | Specify site directory |
--verbose | Show detailed output |
--dry-run | Show changes without writing (sync only) |
--locale <code> | Filter to specific locale (status only) |
Workflow
1. Extract strings:
uniweb i18n extractParses all content and generates locales/manifest.json with translatable strings keyed by content hash.
2. Provide translations:
Create locales/{locale}.json with translations:
{
"a1b2c3d4": "Translated text here"
}3. Check coverage:
uniweb i18n statusShows which strings are translated per locale.
4. After content changes:
uniweb i18n syncDetects changes, updates manifest, flags strings needing re-translation.
Examples
# Extract all translatable strings
uniweb i18n extract
# Check translation status
uniweb i18n status
# Status for specific locale
uniweb i18n status --locale es
# Sync after content changes (dry run)
uniweb i18n sync --dry-run
# Sync and update manifest
uniweb i18n syncuniweb login
Log in to a Uniweb backend. You are logged in to one backend at a time: logging in to another logs you out of the first, once the new login succeeds — a cancelled or failed login leaves you where you were. One login is kept although it exits non-zero: it signs you in but chooses no workspace (see Which workspace), and says so. The session is stored in ~/.uniweb/registry-auth.json; uniweb logout removes it.
uniweb login [options]Which backend
--server <url> names it. Without the flag, login logs in to https://uniweb.app (or UNIWEB_SERVER, when set) — to work with any other backend, name it.
⭐ The backend you are logged in to is where the backend commands go — push, pull, publish, status, register, clone — so logging in is how you switch between backends. Logging in to the backend you are already on does nothing; add --password, --browser, --token-paste or --token to log in again. No command talks to a backend you are not logged in to. A script can aim a single run elsewhere with UNIWEB_SERVER, without touching the login.
Which workspace
A login works in one workspace — your personal one, an organization you belong to, or a sub-organization or project in one, named like @studio:acme — and every push, pull and publish works in it: a site it creates is created there, and a site kept in another workspace is refused. If you belong to no organization or project, it is your personal workspace. Otherwise login asks, or you name it with --org @acme or --personal. Already logged in, uniweb login --org @other (or --personal) switches the workspace on the backend you are logged in to, without logging in again — with no --server needed, whichever backend that is.
Options
| Option | Description |
|---|---|
--server <url> | The backend to log in to |
--org @org | Work in @org — an organization, sub-organization or project you belong to (@studio:acme); a unit with no handle is named by its uuid |
--personal | Work in your personal workspace |
--browser | Log in through the browser |
--password | Log in with a username and password |
--token-paste | Paste a token instead of opening a browser |
--token <bearer> | Store this token, after the backend confirms it |
Without a method flag, login asks which one to use. Without a terminal it needs UNIWEB_USERNAME and UNIWEB_PASSWORD, or --token.
Without a terminal — an agent, a script — sign in with uniweb login --server <url> --token $TOKEN --org @acme (or --personal): the token is checked against that backend before it is stored, and a login that belongs to organizations must name its workspace — one that names none signs you in but exits 2, and uniweb login --org @acme (or --personal) finishes it without signing in again. To authenticate a single process without storing anything, set UNIWEB_TOKEN instead — with UNIWEB_SERVER for a backend other than the default, and UNIWEB_WORKSPACE=@acme (or personal) for its workspace. Such a process needs no login: uniweb login there stores nothing — it checks the token with the backend the process's commands go to, says which account and workspace they work as, and exits non-zero if the token is refused or the workspace is unsettled. The backend commands themselves take no --server or --token.
Examples
# Log in to the default backend, https://uniweb.app
uniweb login
# Log in to a local development server — push, pull and publish now go there
uniweb login --server http://localhost:8080
# Work in an organization's workspace (switches without logging in again)
uniweb login --org @acme
# Switch back to the default backend (this logs you out of the local server)
uniweb login
# Log out — of the one backend you are logged in to
uniweb logoutWhen It's Needed
Login is required for every command that talks to a backend, publish, register, push and pull among them. The CLI prompts you to log in automatically if you run one of these commands without credentials. (uniweb deploy --host=<adapter> authenticates with the third-party host, not with Uniweb.)
uniweb register
Register a foundation — together with the data schemas it renders — to the Uniweb registry. Re-registering ships a new version; a registered version is immutable.
uniweb register [options]Run from a foundation directory, a workspace root (you're prompted if there are several foundations), or a schemas-only package — a package that exports schemas, or a bare schemas/*.yml folder — which registers just the data schemas (no foundation).
Options
| Option | Description |
|---|---|
--scope @scope | A foundation whose name has no scope yet: register it under @scope — your personal scope or an organization's (resolves @/x → @scope/x) — and write the scope into its name in main.js. Refused when the name already carries a different scope. A schemas-only package: publish under @scope; default package.json::uniweb.scope. |
--org @org | Work in @org for this command: a name with no scope yet registers under it. A sub-organization or project names no scope: working in one, a name with no scope yet registers under its organization's — @studio for @studio:acme — when you are a member of that organization, and is otherwise asked for one, as when no workspace is chosen. |
--personal | Work in your personal workspace for this command: a name with no scope yet registers under your personal scope. |
--schema-only | Register the data schemas only; skip the foundation code delivery. |
--dry-run | Print the .uwx (and the code-file plan); submit nothing. |
-o <file> | Write the .uwx to a file; submit nothing. |
--json | Porcelain: one compact JSON line on stdout ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}); human output to stderr. |
Note: foundation propagation controls (
--propagate) and access policy (--edit-access) from the legacypublisharen't wired intoregisteryet — see Propagation below. The retired--localflag is gone; to register on a local backend, log in to it (uniweb login --server <url>).
Identity (scope + name)
A registered foundation's name has two pieces — a scope (the org it's registered under) and its name: @acme/marketing. Sites pin it by that name.
Name
The name is name in the foundation's main.js, else package.json's name:
// src/main.js
export default {
name: 'marketing',
}uniweb create and uniweb add write one — the project's name, or the name you gave add. Lowercase letters, digits and hyphens, and — once it has one — the organization it registers under: @acme/marketing (see Scope).
src and foundation are refused — they name the folder the code lives in, and every project in an org would register the same one. A foundation with no other name (a project scaffolded before names moved to main.js has package.json name src) is asked for one the first time you register, with a suggestion made from its folder; the answer is written to main.js, so it is asked once. Non-interactively (--non-interactive, CI, --json) and in a preview (--dry-run, -o), register refuses before anything is built or sent, and prints the line to add. The same holds when uniweb push or uniweb publish releases a local foundation, since they release through register.
The @uniweb scope is refused too — it names the Uniweb framework's own packages and the system account of every Uniweb backend, so nothing registers under it, neither a foundation nor a data schema. uniweb create and uniweb add refuse a name typed under it (uniweb create @uniweb/marketing) before writing anything. register refuses a foundation already named under it — asking for another name at a terminal, and refusing otherwise, as above — and any scope it would register under that is @uniweb, whether it comes from --scope, package.json::uniweb.scope or the workspace you work in. So do push and publish, which release through register.
package.json::uniweb.id and uniweb.scope, which used to set the name and the scope, are no longer read for a foundation: register refuses them and prints the main.js line that replaces them.
Scope
A scope is a namespace, and it is part of the name: a foundation named @acme/marketing registers under @acme. So do the data schemas it defines — @/article registers as @acme/article — which is the name a site's records and queries use for them. A scope is either your personal scope, @<your handle>, which needs no organization and only you can publish into, or an organization's, which its members can publish into. Scopes belong to their owners on one backend: a name registers on another backend only where its scope has the same owner there — and on another backend your handle, and so your personal scope, may be someone else's.
A name with no scope has not been registered yet. The first register chooses one:
--scope @scopeflag — explicit.- (real submit only) the workspace you work in — see the workspace you work in: an organization's scope when you work in that organization, your personal scope when you work in your personal workspace. Said, not asked — so a team logged in to
@acmeregisters@acme/…, which any of its members can release. - (real submit only) when no workspace is chosen — you belong to organizations and named none — a choice between your personal scope and your organizations at a terminal. Without one,
registerrefuses: choose a workspace, or pass--scope.
…and writes it into the name in main.js (name: '@acme/marketing'), so it is chosen once and later runs need no flag. A --scope that names a different scope than the name's is refused; to move a foundation to another scope, change its name and register it there. A preview (--dry-run, -o) writes nothing: a name with no scope previews under --scope, or unscoped.
A foundation with no resolvable scope is rejected — bare @/… names can't be registered.
A schemas-only package has no main.js to carry a scope: it registers under --scope @scope, else package.json::uniweb.scope, else one derived from your login as above, which is then saved to uniweb.scope.
Renaming
A registered version is immutable, so there is no registry-rename flag. To rename:
- The workspace package (pnpm links,
file:deps,site.yml::foundationrefs) →uniweb rename foundation <old> <new>. It does not change what the foundation registers as, oncemain.jsnames it. - The registered name → change
nameinmain.jsand register again; consuming sites repoint theirfoundation:ref, and the old versions stay reachable under the old name. Moving a foundation to another organization is the same act — its scope is part of its name.
Foundation runtime policy
A foundation's build records the @uniweb/runtime version it actually linked against — read from node_modules, not chosen — into dist/runtime-pin.json, and uniweb register sends it with the foundation, so the version it built against travels with it. (What consumes that is covered below: today it is carried, not enforced.)
There is nothing to declare for this. Whether two runtime versions are compatible is determined by the framework, not by a per-foundation setting: a foundation author has no way to evaluate whether anything their code can reach has changed between two runtime releases.
The escape hatch. For the one case only a foundation author can know — the foundation reaches into undocumented runtime internals, or has been audited against exactly one runtime build and must not move — declare:
{
"name": "@myorg/foundation",
"version": "1.0.0",
"uniweb": {
"runtimePolicy": "exact"
}
}That freezes sites using the foundation on the recorded version. Use it only when one of those two things is true: a frozen foundation stops receiving runtime fixes, including security ones. Sites cannot override the choice.
The field is emitted into dist/runtime-pin.json alongside the resolved runtime version:
// dist/runtime-pin.json (auto-generated)
{ "runtime": "0.11.4", "policy": "exact" }policy appears only when set.
auto-patchandauto-minorare no longer recommended. Earlier releases documented them here, which asked foundation authors to infer a compatibility rule from framework version numbers — not a judgement they are positioned to make, and not one the version numbers reliably carried. Both values are still accepted so existing foundations keep working; neither is worth setting on a new one.
The pin is a compatibility floor, not a selector
This is the part that is easy to read backwards, so it is worth stating plainly.
runtime-pin.json records what your build binds to. It does not choose the runtime a site runs, and structurally it cannot: a site loads a primary foundation plus any extensions, and each of those emits its own pin — while a site has exactly one runtime. Pins are plural; the choice is singular. The runtime a site runs is selected by site.yml::runtime.
The pin's use is validation: checking that a site's chosen runtime satisfies the floor of every foundation that site loads. That is max() over the primary foundation's floor and each extension's — so only something holding all of them can compute it, which is why it is not done at build time.
uniweb register reads the pin and carries it with the foundation, so a floor stated at build time reaches whatever resolves the site later. ⚠️ The check itself is not implemented anywhere yet — today the floor is stated and carried, not enforced. Declare runtimePolicy for when it lands; don't design around it having an effect now.
What happens when fields aren't set
| Scenario | What happens |
|---|---|
uniweb.runtimePolicy not set in package.json | dist/runtime-pin.json is emitted with the runtime version and no policy field. This is the normal case. |
@uniweb/runtime not resolvable at build time | The build skips emitting runtime-pin.json and succeeds. The runtime arrives transitively through @uniweb/build, so this only affects unusual workspace setups. |
runtime-pin.json missing from a built foundation | register sends no floor for it. ⚠️ That means unknown, not unconstrained — a foundation whose floor nobody stated cannot be shown compatible with any runtime, so a validator should treat it as blocking rather than skip it. |
(An earlier version of this page described a resolver that applied runtimePolicy at serve time, a publish step that rejected a foundation whose pinned runtime wasn't deployed, and a legacy fallback path for a missing pin. None of those existed. They were removed on 2026-08-06 after the pin's consumers were checked directly and found to be none.)
Propagation (currently silent)
Today every register is silent: the version is uploaded and stored, sites that pin it exactly resolve to it, but sites on earlier versions don't move until they re-pin.
Automatic propagation — a gated rollout that moves consenting sites forward (canary → a percentage → the full population, with health gates between waves) — is a registry capability being brought to register; the explicit opt-in control (the legacy --propagate) isn't wired yet. Until it lands, sites move forward by re-pinning their foundation: version.
(Not via runtimePolicy — that governs the runtime, not the foundation, and as noted above it is recorded rather than consumed today. Two different artifacts, two different versions.)
What Happens
- Settles the name and its scope — the scope in
main.js's name, else--scope, else one derived from your login membership, written into the name — before anything is built, so the build carries the name the foundation registers as. - Builds-if-stale, then reads
dist/meta/schema.jsonfor the foundation version and the data schemas it declares. - Submits a names-only
.uwx; the registry authorizes the org scope against your membership. - A version already registered is immutable — the schema submit no-ops (the CLI resumes any unfinished code delivery; completed files are idempotent).
- Delivers the foundation's
dist/code (skipped by--schema-only).
Examples
# Register the foundation + its data schemas under an org you belong to
uniweb register --scope @myorg
# Once main.js names it '@myorg/marketing' — the first register writes that — no flag is needed
uniweb register
# Register just the data schemas (from a foundation or a schemas-only package)
uniweb register --schema-only
# Preview the .uwx (and the code-file plan); submit nothing
uniweb register --dry-run
# Register on a local backend: log in there first
uniweb login --server http://localhost:8080
uniweb registerAfter Registering
✓ Registered @myorg/marketing@1.0.0 + 2 data schema(s)uniweb push
Push a site's content to the Uniweb backend — the local → backend direction of the git-style site-content sync. It sends two lanes: the static half (pages, sections, layout, theme, foundation ref) and the dynamic half (records).
uniweb login
uniweb pushRun from a site, or a workspace with one site. The first push creates the site (the backend mints its id and uniweb push records it in sync.json); later pushes update it.
A push never overwrites someone else's work blind. If the site changed on the backend since your last pull — typically an author editing in the Uniweb apps — the push is refused before anything is written, and it reports which files changed. Edits to different sections do not collide. Merge the changes in with uniweb pull (or uniweb refresh), then push again; --force overwrites the backend's changes deliberately. A push is also refused when the copy has not taken the site from the backend — a fresh clone that has not pulled yet, or a branch that no longer holds what the last pull merged (after git merge --abort, say) — because it could not tell what it would be changing.
In a git repository, a push sends what you have committed. If the site's content has changes you have not committed, the push is refused before anything is sent, and it lists the files: commit them and push again, or run uniweb push --commit -m "…" to commit them first — the site's content only, so anything else you have staged stays staged. The commit a push is made from is where the next pull merges from, yours and a teammate's, which is how that pull tells your changes from the backend's. What the push then writes into your files — the ids the backend assigned, in sync.json and in new record files — it commits itself, as uniweb. Outside a repository, the files are sent as they are.
What the backend removes from your content, the push names. The backend stores rich text only as far as a page can show it, and removes the rest as it stores it — a link a page would never follow, a style name that is not one. The push prints each removal beside the file it came from, and says so when this version of Uniweb would have kept it. The file keeps what was removed, so a pull would write the file without it.
The services you ask for — services: in site.yml — go up with the content, and only what you changed since your last sync. If the site's services changed in the meantime, the push shows the difference and offers to update site.yml rather than send your older choice; if you both changed the same service, it asks which to keep.
When the site uses a local foundation whose code changed since its last release, push brings it along the way publish does — it releases the code before the content goes up, because the Uniweb apps can only open a site against a released foundation. --no-release sends the content against the version already released.
A registered version never changes, so changed code is released under a new one: when the foundation's package.json still names the registered version, push releases the change as the next version (1.4.2 → 1.4.3) and writes that version into package.json — commit it. Unchanged code is not released again. The version picked is always the next patch (a pre-release gets its next pre-release); for a change that is not backwards compatible, set a higher minor or major version in package.json yourself before you push.
If the registry holds a version newer than yours — released from another copy of the project — push stops before sending anything, since your copy may not have that release's code. Pull the change first, or pass --bump to release yours above it.
The workspace you work in — and who owns a new site
Every push, pull and publish works in one workspace: your personal one, or an organization's. It is chosen when you log in (see uniweb login), and it decides two things:
- A site a push or publish creates is created there — owned by it, with its storage billed to it — and that is not editable from the CLI afterwards. The command says where before it creates anything.
- A site kept in another workspace stops the command, which names that workspace and how to switch — rather than acting in the wrong one.
To work in another workspace for a single command, name it:
uniweb push --org @acme # work in @acme for this push
uniweb push --personal # work in your personal workspace for this pushWhich site this is — sync.json
The first push to a backend records what that backend assigned — the site's id, its owner, the ids of its records and uploaded files — in sync.json, beside site.yml. It belongs in your history — it is how a teammate's clone reaches the same site instead of creating a second one — so in a git repository the push commits it, in a commit of its own. The CLI writes it; you never edit it.
A project can sync with more than one backend — say, a local development server and production. Each gets its own section of sync.json, so the two sites never mix. push, pull and publish go to the backend you are logged in to — see uniweb login; a script can aim a single run with UNIWEB_SERVER. They never talk to a backend you are not logged in to: logged in nowhere, they ask you to log in first, to https://uniweb.app unless you name another. If the backend they go to has no site for this project while it has one elsewhere, push and publish say so before creating a new site there.
Record files keep their own $uuid. It is the record's id in your project, not any backend's, and sync.json maps it to each backend's id for the same record.
Copying a project
A copy made with cp -r carries the original's sync.json, so its first push would update the original's site. To start a new site from a copy, run uniweb forget --all in the copy first.
Until you do, a push or publish from either of the two is refused when both are in the same workspace:
✗ Another project in this workspace holds the same site on https://uniweb.app:
../my-site
One of them is a copy of the other, so a push from either one updates that site.
In the copy, run: uniweb forget --allA copy placed outside the workspace cannot be told apart from a teammate's clone — both hold the same site, and for the clone that is correct — so there, running the command is up to you. uniweb create --template never copies a template's sync.json or deploy.yml: a project made from a template always starts as a new site.
Options
| Option | Description |
|---|---|
--dry-run | Report what would be pushed; submit nothing |
-o <file> | Write the .uwx package(s) instead of submitting |
--org @org | Work in @org for this push, instead of your login's workspace — see the workspace you work in |
--personal | Work in your personal workspace for this push |
--all | Send every record (bypass the changed-only cache) |
--force | Overwrite changes made on the backend since your last pull, instead of refusing |
--commit | In a git repository, commit the site's content before pushing it — with -m <message>, or Update the site's content |
--no-release | Send the content against the foundation version already released; release nothing |
--bump | When the registry holds a newer version of the foundation than yours, release yours above it instead of stopping |
--foundation <dir> | Use this local foundation for the data-schema shape |
--no-validate | Skip the content-conformance check, which stops a push whose records do not conform — see uniweb validate |
uniweb push sends content but does not make it live — run uniweb publish afterward. (uniweb publish can also bring everything along itself — foundation, content, go-live — in one step.)
uniweb pull
Bring the backend's copy of a site back down to local files — the backend → local direction, the read-side mirror of uniweb push. It projects the returned content to site.yml/theme.yml, pages/**, and the record files — the services the site has included, into site.yml's services:.
uniweb login
uniweb pullIn a git repository, a pull is a merge — like git pull, with the backend as the remote. It commits the backend's content ("The site's content on https://uniweb.app") and merges that commit into your branch, from the last state of the site your history shares with the backend:
- Your work is kept, committed or not: uncommitted changes are set aside for the merge and put back after it. Where they overlap what the merge brought, that is a conflict like any other: the markers are in your file, git keeps your changes in the stash as well, and pull exits non-zero — settle the markers and
git addeach file, thengit stash drop. A push is refused while any file of the site is in conflict. - A teammate's work arriving through git is kept like your own, and a file the backend never had — a section nobody has pushed yet — is never deleted.
- What changed on the backend arrives: edits, new pages and sections, renames, and pages and sections the backend removed — removed here too, unless you changed them, which is a conflict.
- Edits to different parts of a file combine silently; only a genuine overlap gets conflict markers. Pull then exits non-zero, so
uniweb pull && uniweb pushnever pushes conflict markers. Settle the conflict as in any merge, thengit commit. - When the backend has nothing new, the pull says so — and lists the files where yours hold changes it does not have yet, which
uniweb pushsends.
The first pull of a project whose history shares nothing with the backend yet — a repository started from files that were never pulled into it — has no common state to merge from: a file both sides hold alike merges silently, and every difference is a conflict to settle, your version against the backend's. A fresh clone of a repository that has been pushed from but never pulled into is in the same position when the backend cannot say which commit the last push was made from; when it can, the pull merges from that commit. uniweb pull --force takes the backend's version of every file that differs instead, and lists them; what they held stays in your history, in the commit before. Either way, later pulls merge from there. A pull in a repository needs git 2.27 or later.
What your repository ignores travels in no commit, so no merge can bring it: media a pull fetches into a folder you keep out of git are placed where your files lack them, and never over a file you have.
Outside a git repository there is nothing to merge against: pull rewrites your files to match the backend, deleting pages and sections that no longer exist there (guarded so an empty payload never wipes the tree), and asks before it overwrites anything of yours — refusing when it cannot ask. A site in a folder that the repository around it ignores — a scratch directory, say — is outside git too: that repository can never hold its history.
A project that was never pushed has no id to pull by — pull is a no-op with a clear message.
Options
| Option | Description |
|---|---|
--force | Take the backend's version, discarding your local changes to the site's content |
--no-delete | Keep local files that have no backend item |
--no-records | Pull pages only; skip the records lane |
--no-assets | Don't download media files the project doesn't have yet; the content keeps their URLs. assets.download: false in site.yml makes this the project's default |
--dry-run | Report what it would fetch; write nothing |
--org @org | Work in @org for this pull, instead of your login's workspace |
uniweb refresh
Catch up with everything outside your working copy: your teammates' commits on the git remote and content authors' edits on the backend. Running only git pull misses every edit made in the Uniweb apps; running only uniweb pull misses your teammates.
uniweb refreshIt runs git pull first — merging, never rebasing, since a rebase would rewrite the commits a pull of the backend made — then uniweb pull, and ends by reporting what it checked, what it skipped, and whether you have unpushed changes. It never pushes — it cannot ship anything, so it is safe to run at the start of every working session. It stops if git pull fails or leaves conflicts — where it could not put your uncommitted changes back, too — and exits non-zero if the merge leaves conflicts.
Options
| Option | Description |
|---|---|
--no-git | Skip the git remote; backend only |
--no-backend | Skip the backend; git only |
uniweb sync
Catch up, then share: uniweb refresh followed by uniweb push, and nothing more — uniweb refresh && uniweb push behaves identically. If the refresh leaves conflicts, sync stops before pushing.
uniweb syncIt does not publish: your changes reach the backend's draft, and going live stays a separate step (uniweb publish). Not to be confused with uniweb i18n sync, which updates the translation manifest.
Options
sync accepts the options of both halves. The common ones:
| Option | Description |
|---|---|
--no-git | Skip the git remote half of the refresh |
--force | Passed to the push only: overwrite changes made on the backend since your last pull |
--commit | Passed to the push only: commit the site's content first, with -m <message> |
uniweb clone
Materialize a backend site as a brand-new local file project — the "git clone" of the site-content model. Use it to bootstrap a project from a site that already lives on the backend (typically authored in the Uniweb apps).
uniweb login
uniweb clone <site-uuid> [name|.]clone scaffolds a full site package whose foundation is loaded by URL (the site carries its own foundation ref), records the site's id in sync.json, installs dependencies, and then runs the project-local uniweb pull to fill in the content. Like uniweb create, it puts a new project under git — unless a repository already holds its folder — and the first commit holds the site as the backend has it, so your first uniweb pull merges from it. Sites are private — authenticate with uniweb login first.
Preview the clone with uniweb dev, which asks the backend where the site's foundation version is served.
Options
| Option | Description |
|---|---|
<name> / . | New workspace named <name>, or . for in-place / the current workspace |
--path <dir> | Place the site under <dir>/ (segregated layout) |
--project <dir> | Co-locate as <dir>/site. Takes no name: with <name> as well, clone stops and names <dir>/sites/<name> |
--no-records | Pull pages only; skip records |
--no-git | Don't start a git repository for the new project |
--org @org | Work in @org for this clone, instead of your login's workspace |
uniweb publish
Publish a site to Uniweb hosting — the smart path for going live. Run it from a site directory and it resolves which site, brings the foundation along (releasing the site's local foundation to the catalog under your @org when its code changed), syncs the content, and makes the site live. This is the command to reach for when you mean "make my site live."
uniweb login
uniweb publishpublish pushes your local content first — the same push as uniweb push, refused the same ways: if the site changed on the backend since your last pull, or, in a git repository, if its content is not committed (--commit commits it first) — and then makes the backend's current version live, including edits made in the Uniweb apps. What goes live is always the backend's copy of the site, whether the publish comes from the CLI or from the apps.
The first publish of a site also creates it on the backend, in the workspace you work in — see the workspace you work in under uniweb push.
When the backend needs something first. If the site can't go live until something is settled — a plan, a payment, a change to what the site's plan covers — publish shows the backend's message and opens the page in the app where you settle it; run uniweb publish again afterwards. Your content is already pushed, so nothing is lost. When the only thing owed is a change to a plan the site already has, such as one more language, the backend may let you confirm it at the terminal instead. publish then shows its sentence, which names the change, its price and the card, and asks:
1) Confirm and publish
2) Open it in the app
3) Cancel (default)For a change that charges, Enter cancels and only you can confirm it. --yes doesn't answer it, and with --yes or no one at the terminal (--non-interactive, CI, or no TTY) nothing is asked: publish prints the message and the link and exits non-zero. A change that only lowers what the site pays asks Continue and publish? instead, and Enter continues; --yes answers it without asking, with or without a terminal.
Where it goes: the backend you are logged in to
uniweb publish goes live on the backend you are logged in to — the one you last chose with uniweb login, as push and pull do. To go live somewhere else, log in there (uniweb login --server <url>); a script can aim a single run with UNIWEB_SERVER. Not logged in, it asks you to log in first — to https://uniweb.app unless you pass --server.
The publish is recorded in deploy.yml under the target for that backend. If no target names it, one is added, named after the backend (localhost:8080, say); your default: and other targets are left alone. uniweb deploy --host=uniweb is uniweb publish, so it goes to the same place. A Uniweb target's backend: in deploy.yml records where that target's publishes went; it does not route. Naming one whose backend is not the one you are logged in to (uniweb deploy --target staging) is refused — log in there first.
Options
| Option | Description |
|---|---|
--dry-run | Resolve everything; release, sync and publish nothing. |
--yes | Skip confirmations (CI); never block on a prompt. Answers a change that only lowers what the site pays — never one that charges. |
--force | Overwrite changes made on the backend since your last pull, instead of refusing — as uniweb push --force. |
--commit | In a git repository, commit the site's content before publishing it — with -m <message> — as uniweb push --commit. |
--no-release | Ship the content against the foundation version already released; release nothing. Refused if the foundation was never released. |
--bump | When the registry holds a newer version of the foundation than yours, release yours above it instead of stopping — as uniweb push --bump. |
--no-save | Skip recording this publish in deploy.yml. |
--no-validate | Skip the content-conformance check, which stops a publish whose records do not conform — see uniweb validate. |
--org @org | Work in @org for this publish, instead of your login's workspace. |
--personal | Work in your personal workspace for this publish. |
Unknown flags are rejected.
uniweb publish,push,pull,refresh,sync,clone,register,status, andforgetexit with an error on a flag they do not recognize, rather than ignoring it.--serverand--tokenare not flags of these commands: they go to the backend you are logged in to, with that login's session, and passing either is rejected with a pointer touniweb login.
uniweb status
Show how a site's local files compare to the Uniweb backend — its sync identity, unpushed content, and the foundation it references. Local and offline by default: it runs the same emit + diff as push, with no backend round-trip.
uniweb status
uniweb status --json # { synced, uuid, foundation, changed, unchanged }
uniweb status --remote # also asks the backend (it may ask you to log in)
uniweb status --remote --json # adds { remote: { site_state, site_state_detail, site, … } }Run from a site, or a workspace with one site. The content diff is exactly what uniweb push would send — so changed: 0 means a push would be a no-op.
--remote asks the backend about the site this directory names on it — in the workspace you work in, or the one named with --org / --personal:
site_state | means |
|---|---|
live | the backend holds the site — or holds it in another workspace, which site_state_detail names |
gone | the backend says it holds no such site: it was deleted there, or the backend was rebuilt. uniweb forget --server <url> clears the binding, and the next push creates a new site |
unknown | anything else — no answer, a refused credential, a backend that cannot say |
null | this directory names no site on that backend |
gone is said only on the backend's own word about this site — never for a failure to reach it — so a script can forget a binding on gone without risking a second copy of a site that still exists. --remote also says whether the synced draft differs from what is live, and whether a newer version of the foundation is registered.
It also lists who has the site open in the Uniweb app right now, so you can follow up with them:
→ Open in the app now:
Ada Lovelace (@ada)
pages/about/1-hero.md (/about) · changed 3 min ago
layout/header.md · changed 1 h agoEach line is a tab that person has open, named by its file in your copy. A page or section added in the app since your last pull has no file yet, so it shows as, for example, a section not in this copy (/blog). "Changed" is when that page or section last changed, by anyone, not necessarily that person. This is presence, not a lock: nothing stops a push because someone has the site open.
uniweb forget
Remove what this project recorded about where it synced — local files only. Every site stays where it is on its backend.
uniweb forget --server <url> # one backend
uniweb forget --all # everything — for a copy that should become a new project--server <url> removes that backend's section of sync.json and of the local cache, and the records of past publishes to it in deploy.yml. Its targets in deploy.yml stay — a target is where you chose to ship — so the next push or publish there creates a new site. Other backends are untouched. Use it when you are done with a backend: a scratch server you pushed a template to, or one you will start over on.
--all deletes sync.json, deploy.yml and the local cache: everything that names the sites and destinations of the project this one was copied from. deploy.yml goes whole, targets included, because a copy's targets are the original's destinations. To duplicate a project:
cp -r my-site my-site-2
cd my-site-2
uniweb forget --allThe next push from the copy creates a new site. Neither form touches site.yml or your record files — a record's $uuid is its own id, not a backend's.
--server is required even when the project has synced with only one backend: forgetting a backend you still use means its next push creates a second site there, so you name it. Both forms are safe to repeat; nothing recorded means nothing to do.
⚠️ sync.json and deploy.yml are committed files. If you run --all in the original by mistake, restore them from git.
uniweb site
The sites in a workspace on the backend you are logged in to — list them, take one offline, or delete one.
uniweb site list [--json] # each site's name, uuid, and whether it is published
uniweb site unpublish [<site-uuid>] # take a site offline — its content stays
uniweb site delete [<site-uuid>] # delete a site — finalThe workspace is the one you chose at login, or --org @handle / --personal for this command. Without a uuid, unpublish and delete act on the site this project is synced to on that backend. uniweb site list shows each site's uuid — the one uniweb clone takes, too.
delete is final: there is no trash and no restore. Only the site's owner, or an admin of its workspace, can delete it.
- A published site is not deleted — unpublish it first.
- Anything else still active is named when the delete is refused — a hosting plan, a custom domain, form messages visitors sent — and is resolved in the app.
- Records the site pushed stay in the workspace.
- Deleting the site this project is synced to also removes the project's record of it, as
uniweb forget --serverdoes, so the project's next push creates a new site.
Both unpublish and delete ask before they act. In a script, pass --yes; without a terminal and without --yes, they refuse and exit with code 2, so nothing is reported done that was not.
With --json, list prints one JSON object on stdout:
{ "backend": "https://…", "workspace": "@acme", "sites": [
{ "uuid": "…", "name": "Blog", "status": "published", "published": true, "url": "https://…", "updated_at": "…" }
] }status is null for a site that was never published.
uniweb deploy
Ship a site to a host. Run it with no destination configured and it asks:
? Where should this site go?
❯ GitHub Pages · free, CI on push
Cloudflare Pages · free, CI on push
Netlify · free, CI on push
Vercel · free tier, CI on push
S3 + CloudFront · your AWS account
Uniweb Cloud · paid, dynamic + visual editing
Somewhere else · export a folderPick a host and it asks how — set up a workflow so every push deploys, or upload from this machine now. Uniweb Cloud runs uniweb publish; Somewhere else runs uniweb export. Your answer is recorded in deploy.yml, so later runs go straight there.
uniweb deploy [options]Run from a site directory or workspace root. If the workspace has multiple sites, you're prompted to choose one.
Options
| Option | Description |
|---|---|
--host <adapter> | The host to ship to: github-pages, cloudflare-pages, netlify, vercel, s3-cloudfront. --host=uniweb delegates to uniweb publish. Overrides the target in deploy.yml. |
--host (no value) | Open the wizard, even when deploy.yml records a target. |
--target <name> | Pick a named target from deploy.yml (default: its default: field). |
--dry-run | Show what would be deployed without deploying. |
--no-save | Skip recording this deploy under deploys: in deploy.yml. |
--no-validate | Skip the content-conformance check. For a static host it only warns; --host=uniweb is uniweb publish, which stops on it — see uniweb validate. |
How the destination is resolved
First match wins, and nothing is assumed when they all miss:
--host <name>— explicit.--hostwith no value — an explicit "ask me"; opens the wizard even ifdeploy.ymlhas a target.--target <name>— a named target fromdeploy.yml.deploy.yml'sdefault:target.- Nothing configured — the wizard asks. Non-interactively,
deployexits with the list of real options rather than picking one for you.
What Happens
- Resolves the destination (above) and validates the adapter and its required config.
- Builds a self-contained
dist/with the adapter'spostBuildhook (e.g._redirects,.nojekyll,cloudfront-function.js). The foundation is bundled into the site'sdist/— there is no separate foundation step. - Hands
dist/to the adapter'sdeployhook, which drives that host's own CLI —wrangler,netlify,vercel,aws, orgitfor GitHub Pages. A missing tool or credential surfaces as a message naming exactly what to install or export.
Examples
# Ask where this site should go
uniweb deploy
# Push straight to a host
uniweb deploy --host=cloudflare-pages
uniweb deploy --host=s3-cloudfront
# Preview what would be deployed
uniweb deploy --host=cloudflare-pages --dry-run
# Set it up to deploy on every push instead
uniweb add ci --host=cloudflare-pagesConfiguring the destination in deploy.yml
Destination config lives in deploy.yml, a sibling of site.yml — not in site.yml itself. Safe to commit.
# site/deploy.yml
default: production
targets:
production:
host: s3-cloudfront
bucket: my-bucket
distributionId: E1ABC...
region: us-east-1
profile: my-aws-profile # optional; sets AWS_PROFILE for the subprocess
preview:
host: cloudflare-pages
projectName: my-site-previewEach adapter reads its own keys: bucket / distributionId / region for s3-cloudfront, projectName for cloudflare-pages, siteId for netlify, branch for github-pages.
Credentials are never read from deploy.yml — it's a committed file. Host tokens come from the environment (CLOUDFLARE_API_TOKEN, NETLIFY_AUTH_TOKEN, VERCEL_TOKEN, the AWS credential chain) or from that host's own login session.
deploy also maintains a deploys: block recording when each target was last shipped to, and its URL where the host reports one. Turn that off per-run with --no-save, or permanently with saveDeploys: false.
Static Hosting Alternative
For hosts not covered by a built-in adapter, use uniweb export to produce dist/ and upload manually — see Deployment for per-host recipes.
Project Structure
The CLI produces these workspace layouts:
Default (create)
my-project/
├── src/ # React components — the foundation package
│ ├── main.js # Foundation declarations (name, vars, defaultLayout, …)
│ ├── styles.css
│ ├── sections/
│ ├── components/
│ ├── package.json # name: "src"
│ └── vite.config.js
├── site/ # Content and configuration
│ ├── pages/
│ ├── layout/
│ ├── site.yml
│ ├── theme.yml
│ └── package.json
├── package.json
└── pnpm-workspace.yamlAfter Growing with add
my-project/
├── src/ # Original foundation (name: "src")
├── site/ # Original site
├── foundations/
│ └── blog/ # Added: uniweb add foundation foundations/blog
├── sites/
│ └── docs/ # Added: uniweb add site sites/docs
├── extensions/
│ └── effects/ # Added: uniweb add extension extensions/effects
├── package.json
└── pnpm-workspace.yamlCo-located Layout (add project)
my-workspace/
├── marketing/
│ ├── src/ # name: "marketing-src"
│ └── site/ # name: "marketing-site"
├── docs/
│ ├── src/ # name: "docs-src"
│ └── site/ # name: "docs-site"
├── package.json
└── pnpm-workspace.yamlCreated with uniweb add project marketing and uniweb add project docs, or equivalently with --project flags on individual add foundation/add site commands.
Environment Detection
The CLI auto-detects context:
| Directory Contains | Detected As |
|---|---|
src/sections/, src/components/, or src/main.js | Foundation |
site.yml or pages/ | Site |
pnpm-workspace.yaml | Workspace (builds all) |
Workspace builds discover foundations, extensions, and sites from the patterns in pnpm-workspace.yaml, read the way pnpm reads them: *, ? and [...] within a folder name, ** for any depth, {a,b}, and ! to exclude — */src and */sites/* included. A folder a wildcard finds counts when it holds a package.json. Every command that looks for packages (build, dev, update, doctor, …) reads them the same way, and the build checks each package for foundation or site markers.
Exit Codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Error (invalid arguments, build failure, etc.) |
See Also
- Site Configuration —
site.ymlreference - Page Configuration —
page.ymlreference - Component Metadata —
meta.jsreference - Internationalization — Translation workflow
- Registering a Foundation — Making a foundation a catalog product
- Deployment — Static hosting and platform deployment