@fluid-app/fluid-cli-member-site
v0.1.445
Published
Fluid CLI plugin for member sites — list, create, pull, push, publish, lint and preview sites with the member or company site API, and run a site locally with its own database
Readme
@fluid-app/fluid-cli-member-site
Fluid CLI plugin for member sites. Adds fluid member-site, which works with
the member site API and the company member sites API: it lists, creates, pulls,
pushes, publishes, lints and previews sites, and turns on and manages a member
site's database. The plugin also adds fluid sites, which publishes and lints a
site built as a bundle, for companies on the new member sites plan, and
fluid vault, which manages the credentials of the company vault.
This is not fluid mysite, which is an unrelated command, and not fluid theme.
Installation
npm install -g @fluid-app/fluid-cli @fluid-app/fluid-cli-member-siteRequires @fluid-app/fluid-cli as the core CLI.
Which login builds which site
The credential follows the owner of the site:
| Site owner | API | Login |
| ---------- | ------------------------------------ | --------------------------------------- |
| Member | /api/member/v2026-10/sites | The member's own login |
| Member | /api/company/v2026-10/member-sites | A company admin, with --as admin |
| Company | /api/company/v2026-10/member-sites | A signed-in admin who may change themes |
With --as admin, a command on an existing site (list, pull, push,
publish, deploy, sites share, sites branch and the other sites
commands on a pulled folder) goes through the company API, for a member's site
as for a company site. The server lets an admin of the company change a
member's site; a Fluid root admin with no admin record in the company can read
it only. Two things stay with the member's own login: create without
--company makes a member site only with it, and the database commands read
a member's own site through the member API.
The plugin keeps no credential of its own. It uses the login, profiles and
.fluidrc of the core CLI, so FLUID_TOKEN, a project's .fluidrc profile and
the active profile apply as they do for every other command. The API base
follows the login: FLUID_API_BASE if set, else the base the stored profile was
made for (a .fluidrc profile, else the active one). There is no default base,
so a token from FLUID_TOKEN, or a profile with no recorded base, needs
FLUID_API_BASE. With neither, the command stops with "No API base for this
login: run fluid login or set FLUID_API_BASE" before it makes any request.
The core CLI stores a token and does not record whose it is, so the plugin
cannot tell a member's token from an admin's before it sends a request. Say
which it is with --as member or --as admin. A member login on a company site
is refused before any request, with the login it needs; an admin login works
on either kind of existing site. Without --as, a pulled folder uses the login
it was pulled with when that was an admin's on a member's site (its
.member-site.json records "login": "admin"), and otherwise the site's owner
kind picks the API; the server decides, and a refusal names the login the API
that answered needs.
fluid login signs in admins and reps, and either login builds member sites.
A rep's login writes to the member API as that rep. An admin who is also a rep
of the same company builds both kinds of site after one normal fluid login:
--as admin builds company sites, and --as member builds the admin's own
member sites, acting as that person's own rep membership, with no second
sign-in. On a company on the identity service the CLI narrows the login to a
rep session; on the legacy sign-in the member API accepts the admin's login for
that person's own rep membership.
An admin who is not a rep of the company has no member sites there. On the
identity service the CLI says "You are not a rep at , so you cannot
build a member site there. Use --as admin for company sites." before any
request; on the legacy sign-in the member API refuses the login. A token set in
FLUID_TOKEN is sent as it is: an admin's opaque admin token is refused by the
member API, while an admin's legacy login JWT acts only for that person's own
rep membership, as fluid login does. A member who is not a rep signs in to
this command with the member's own token, set in FLUID_TOKEN.
Commands
fluid member-site list
With a member login, lists the member's own sites. With an admin login, lists
every site in the company with its owner and status. A member's site is
changed with --as admin, as a company site is.
fluid member-site list
fluid member-site list --as memberWithout --as, the company API is tried first and the member API when the
company API refuses the login.
fluid member-site create <handle>
Creates a site as a draft. The handle is lowercase letters, digits and single hyphens, at most 40 characters. The name defaults to the handle in words.
fluid member-site create rileys-shop --name "Riley's Shop" # a member's site
fluid member-site create fall-launch --company --as admin # a company site--company creates a company-owned site and needs an admin login.
fluid member-site pull <site> [dir]
Pulls a site into a local folder. <site> is the site's handle or id, and
dir defaults to the handle. Every text file is written at its API path, the
pages are written to config/pages.json, and a binding file records where the
folder came from.
fluid member-site pull rileys-shop --as member # a member's site
fluid member-site pull fall-launch ./site --as admin # a company siterileys-shop/
layouts/site.liquid sections/hero.liquid snippets/card.liquid
pages/index.liquid pages/about.liquid
assets/site.css assets/site.js
config/settings.json config/menus.json config/pages.json
config/access.json (only when the site declares what it uses)
.member-site.json- The folder is the API: a path here is the path the file API uses. The home
page is
pages/index.liquid. config/access.jsonis the site's declaration of the viewer data it uses and the actions it takes as the viewer:{ "uses": ["member.email", "orders: read"] }. A site has it once it has a draft declaration, whoever owns the site: a company site's is written with an admin login that may change themes. It is a draft until the site is published. Once published, it limits the owner's code to the scopes it lists, and it is what a share holder gets: a person a share of the site matches (a named member, a downline, a segment, or a company site's audience) reaches their own data through the site's script, as themselves and with no prompt, for the listed scopes exceptmessages: write,shares: write,widgets: write,contacts: read,contacts: write,carts: write,mysite: write,agreements: write,ai: write,database: readanddatabase: write, and exceptPATCH /me, the notification-preferences write and the calendar RSVP. A share holder'sorders: readreturns only orders they bought, never ones credited to them as a rep. A site with no published declaration gives a share holder nothing. A viewer who is neither its owner nor matched by a share gets nothing from the site.config/pages.jsonholds the site's defaultlayoutand, for each page, itsslug,title,position,published,layoutandmenu(the menu's slug). It holds no page content, which is inpages/<slug>.liquid. A page whose content is a template the site does not own has a row there and no file..member-site.jsonis the binding file. It records the site'ssite_id,handleandowner_kind, theapi_baseit was pulled from, the settingslock_version, the site's defaultlayout, each page'sid,lock_versionand row by slug (the row lets a push tell which fields you changed), and for every file thelock_version,updated_atand theversionthe file list reports (a binding from a pull before the API reported one has none), plus a SHA-256 of the content written. It is local state and is never pushed.- A site holds text assets only (css, js, json, txt, html). The file list holds no other file, and a listed asset that is not text is skipped and named in the summary. Use images and fonts from the media library by URL.
- A pull reads every file before it writes any, so a failed read leaves the folder as it was. A file already identical on disk is left alone, so a second pull of an unchanged site changes nothing.
- A folder that is not empty and is not bound to this site is refused, and so
is a pull that would overwrite a file edited since the last pull.
--forcewrites anyway. Files that are not on the server are never removed, with one exception: when the site no longer hasconfig/access.jsonand the local copy is unchanged since the last pull or push, the pull deletes it, so the next push does not bring back a declaration another writer removed. An edited copy is kept unless--forceis passed. - A pull does not write through a symbolic link. A file or folder of the site
that is, or is inside, a link is refused, with or without
--force, before any file is written. Each file is written to a new file that replaces the old one. - An admin pulls a member's site with
--as admin, through the company API. A member login cannot pull a company site: that stops before any file or page request, and names the login the site needs.
fluid member-site push [dir]
Pushes the changes in a pulled folder to its site. Run it in the folder, or pass
the folder. The folder's binding names the site and its owner kind, so a member's
site goes through the member API and a company site through the company API. An
admin login (--as admin) goes through the company API for either, and a member
login on a company site is refused before any request.
fluid member-site push --dry-run # print the plan, send nothing
fluid member-site push --as member
fluid member-site push --publish # push, then publish onceA push compares the folder with the binding and sends only what changed. A pull followed by a push with no edit sends nothing.
Every write and delete carries the version the binding recorded for it, and the
server refuses it with 409 if the file or page changed since. A push never
overwrites or deletes a copy that moved on the server. A path the binding never
saw is created with must_not_exist, so a file another writer created after the
pull is not replaced.
What it sends, in this order:
config/settings.json, with the settingslock_versionthe binding recorded. It goes first because every page write moves that version, so a settings write that came after one would answer 409 to the push's own work.- Each changed or new layout, section, snippet and asset, and
config/access.json(PUT files/:path), in path order, so the declaration goes ahead of the layouts. A changed file carries its recordedversion; a new one carriesmust_not_exist. A company site'sconfig/access.jsonis pushed the same way. - Each new page (
POST pages) with its file's content, then each changed page (PATCH pages/:id) with the changed row fields and the changed content, each with the page'slock_version. config/menus.json, when it changed, with its recordedversion. It replaces every menu of the site. A menus write moves the site's version and the version of every page assigned to a menu it drops, so the push reads the pages again before the next step.- Each page's menu assignment (
PATCH pages/:idwithmenu), with the page's re-read version. A page body never carriesmenu, so it is never sent before its menu exists. - Each removed page (
DELETE pages/:id?lock_version=), then each removed layout, section, snippet, asset andconfig/access.json(DELETE files/:path?version=; removing the declaration clears its draft). Removals go last, so a write that fails never leaves a menu pointing at a page that is gone.
After each response the binding takes the versions it returned and is saved, so
a push that stops can be run again after a pull. A removed page or file leaves the
binding when the server removes it. After the last write the push reads the
settings once more to record their version, and keeps it only when the settings
are still the ones in the folder. A second push straight after the first sends
nothing and does not conflict with it. A push without --publish never
publishes. A publish by someone else moves the version of every page, layout,
section and snippet it publishes, so the push stops at the first of those it
writes: pull again first.
A page is removed by taking its row out of config/pages.json and its file out of
the folder. A page renamed in config/pages.json is a new page and a removal: the
new page is created first and the old one is deleted after it. config/settings.json
and config/menus.json cannot be removed. If either file is missing, the push says
so and leaves the server's copy.
What it lists and does not send: a change to the site's default layout in
config/pages.json, which is a change to the site row.
What takes effect without a publish. Before the first write, the push lists the
files and rows that are live as soon as they are written: assets (changed, new or
removed), any removed file, config/menus.json, and page rows (a new page, a
changed row field, a menu assignment, a removed page). Changes to layouts,
sections, snippets, page content and the settings are saved as drafts, but
removing a layout, section or snippet deletes it and its published copy at once,
so the site stops rendering it without a publish. A push that changes none of
these lists none.
Without --publish, a push never calls the publish route.
Everything that can be refused is refused before the first request, and every refusal is listed at once:
- a file that is not a text asset: an asset must be css, js, json, txt or html, valid UTF-8 and not empty. Use images and fonts from the media library by URL;
- a path outside
layouts,sections,snippets,pages,assetsandconfig, a path in a subfolder, a symbolic link, and a name outside lowercase letters, digits, hyphens and underscores (a page's name is lowercase letters and digits separated by single hyphens). Names that start with a dot are ignored; - a page file with no row in
config/pages.json, and a new page row with no file; a file for a page whose template the site does not own; - a page row removed while its file is still in the folder, and a page file
removed while its row is still in
config/pages.json: a page goes with its file, so remove both or neither; - a changed, new-to-the-binding or removed file, or changed menus, whose version the binding does not hold (a binding from before the API reported versions). Pull the site again first;
- a
config/pages.jsonthat is not valid, a slugindex(the home page is/), a menu thatconfig/menus.jsondoes not define, settings that are not a JSON object, and menus that are not JSON or not shaped as the API takes them (an object with amenusarray, and the required keys and types of each menu and item; whether a linked page exists is left to the server); - new pages that do not fit in the site's 50-page limit. Removals are sent after
the new pages are created, so a renamed page's old page still counts. Push the
removals on their own first. This needs the site's current page count, so
--dry-run, which sends no request, does not check it and says so; - a menu link whose
urlthe server's URL parser refuses: anything but a well-formedhttporhttpsaddress with a host, as written. A space or non-ASCII character in the address, or a space around it, is refused rather than corrected.
A --dry-run prints the plan, removals included, and sends no request. With
--publish it also says that a publish would follow.
The first stale version stops the push. It names the file or page, tells you to pull, lists what was already written, and sends nothing after it. A path that was to be created and now exists on the server is reported as created by another writer, and a removal the server refused is reported as not deleted. The push is not atomic: the API has no batch write, so what was written stays written. A stale settings version also follows any page another writer changed, because the settings version is the site's.
With --publish, the push calls the publish route once, after the last write
succeeded, and prints what publish prints (below). It is never called after a
write failed or a change was refused, and a push with nothing to send still
publishes. If the publish itself is refused, the pushed changes stay saved as
drafts and the command says the site was not published.
fluid member-site publish [dir]
Publishes the site of a pulled folder: the drafts of its layouts, sections,
snippets, pages and settings become the live site. Run it in the folder, or pass
the folder. A member's site is published through the member API
(POST sites/:id/publish) with the member's login, and a company site through
the company API (POST member-sites/:id/publish) with an admin's login, chosen
by the binding as for push.
fluid member-site publish
fluid member-site publish ./rileys-shop --as memberIt makes one publish request and sends no file, so a local edit that was not
pushed is not published. It prints the site's status and its new
content_version.
- A site that an admin took down cannot be published again. The command prints the API's message, exits non-zero and does not retry.
- A 409 is reported as a conflict: the site was changed while it was being published, so it was not published. Run the command again.
- A publish moves the version of every layout, section, snippet and page file it
publishes, of every page, and of the settings. After it succeeds the command
records a new version in the binding only when it can prove the publish
published what the folder holds, so the next push does not conflict with the
publish but still finds a change another writer made. A layout, section,
snippet or page file takes the server's version when it is the one computed
from the file's content in the folder. A page takes its
lock_versionwhen that moved by exactly one. The settings take the site'slock_versionwhen the publish response shows the settings the folder holds and the version moved by what one publish moves it. Anything else keeps its old version, the command names it, and the next push stops on it and asks for a pull. A file edited in the folder and not pushed is not proven either, and keeps its old version, so push it before publishing. Assets and menus are not changed by a publish, and keep their versions. If the versions cannot be read after the publish, the site is still published, and the command says to pull before the next push.
fluid member-site lint [dir]
Checks a site folder on disk and makes no request. It needs no login and no binding file, so it works on a folder that was never pulled. Run it in the folder, or pass the folder.
fluid member-site lint
fluid member-site lint ./rileys-shopA clean folder prints nothing and exits 0. Any error exits 1, and each finding names its file. Warnings are printed and do not fail the command.
Liquid in layouts, sections, snippets and pages is checked the way
fluid theme lintchecks it, and its messages are the theme linter's: the{% schema %}block and the pairing of block tags such as{% if %}and{% endif %}. The theme linter's cart-code, missing-section and routed-template checks are theme rules and are not run.The file rules a push enforces, with the same messages: a path outside
layouts,sections,snippets,pages,assetsandconfig, a name outside the allowed characters, a subfolder, a symbolic link, and an asset that is binary, not css, js, json, txt or html, or empty. Every file is judged, also one a pull wrote.config/pages.jsonmust be valid, and lists at most 50 pages. The count is the rows in the file, so a page with no file counts.config/menus.jsonmust have the shape the API takes, and each menu link (page) must name a page thatconfig/pages.jsonlists. A folder with noconfig/pages.jsonlists no pages.config/access.jsonmust be what the file API accepts, and each problem is an error: not valid JSON, a key other thanusesor a repeated key, an entry that is not a string or is listed twice, an unknown field, family or mode,session: read, more than 64 entries, or more than 4 KiB.Warnings for a site's use of viewer data. A Liquid file that reads
member.<field>is warned whatever the declaration holds, because member data is leaving member-site Liquid: read it in a script withfluid.fetch("/api/member/v2026-10/me"), which needsprofile: read.When
config/access.jsonis valid, eachfluid.fetch(call (alsowindow.fluid.fetch() in a layout, section, snippet, page or.jsasset is checked againstsrc/lint/path-scopes.json, a copy of the SDK's route table that a test holds equal to the SDK's. The lint takes the path from a string literal, or from a template literal whose${...}parts are whole path segments (`/api/member/v2026-10/orders/${id}`), and the method from amethod: "POST"string literal in an object-literal second argument (GETwhen there is none). It moves the path asfluid.fetchdoes: a Member API path (/api/member/v2026-10/...) stays, and any other/api/path moves under/api/member-storefront/. It ignores the query string. Then:- a scope the declaration holds, or
session: read, gives no finding; - a scope the declaration lacks is warned, naming the scope;
- a route no site can reach is warned, with the reason;
- a call the lint cannot check is warned too, never skipped: a path or method
that is a variable or is computed, an interpolation that is not a whole
segment, a path outside
/api/, or a path and method no route matches. Such a call is refused at run time if its scope is not declared.
A folder with no
config/access.jsonis not checked for calls, because a site with no declaration does not limit its owner, with one exception the lint does not check: the familydatabase. A page's script reaches a site's database (/api/member/v2026-10/sites/<id>/database, its tables and its rows) only when the published declaration listsdatabase: readordatabase: write, so a call to it from a site with no declaration is refused at run time with the scope named. A share holder never reaches the database, declared or not, and gets nothing from a site with no declaration. The owner's own bearer, which the CLI and Mist use, needs no declaration.- a scope the declaration holds, or
fluid member-site dev [dir]
Shows a site's draft while you edit it. It saves each change you make in a pulled folder as a draft and serves the draft on a local port, rendered by the server with the same renderer members get, not by a local one. Run it in the folder, or pass the folder.
fluid member-site dev
fluid member-site dev ./rileys-shop --as member --port 9600
fluid member-site dev --allow-production- It does not write production unless you say so. A session saves the
folder when it starts and again on every change, and assets, removals, menus
and page rows are live as soon as they are written. So
devstops, before it makes any request, when the login's API base is production, with a message that names the base and the flag, and writes nothing. Pass--allow-productionto save to production anyway. A base that is not production keeps syncing without it: a local one (localhost, a loopback address (any of127.0.0.0/8, or::1),*.localhost), a stage one (*.stage.fluid.app) and a name under.test. Any other base, a custom or unknown host included, is taken for production.fluid theme devis never pointed at a live theme either: it works on a development theme unless--themenames another one, and this site has no development copy to use in its place, so the choice is a flag. - It saves drafts and never publishes. A save is
pushwithout--publish: the same plan, the same refusals, the same version on every write, and the same stop at a stale version (pull, then rundevagain). It never calls the publish route. What takes effect without a publish is listed before each save, as forpush: assets, removals, menus and page rows are live as soon as they are written. Runfluid member-site publishwhen the draft is ready. - It starts only when it can show a draft. First it reads the site, then it asks the server for the draft preview route. A server without it stops the command with "Draft preview is not available on this server yet" before anything is saved. It never shows the published page in its place. Then what the folder holds is saved, so the page shows the folder, and a refused or stale save stops the command. The folder is watched before it is read for that first save, so an edit made while it runs is saved before the preview is served, and none is missed.
- After that, each change is saved after a short pause, and the command keeps serving when a save is refused, so you can fix the file. Reload the preview to see a save. There is no live reload yet.
- The preview is
http://127.0.0.1:<port>(--port, default 9500;--port 0takes a free one)./is the home page and/<slug>another page, read throughGET .../sites/:id/preview/pages(/:slug)of the member API, orGET .../member-sites/:id/preview/pages(/:slug)of the company API for a company site. A text asset is at/files/assets/<file>(served to the preview's own origin and the address bar only: see below), read through.../preview/files/*path. A page links its assets at the published frame address and its pages at the site's shell address, and neither opens a draft, so the preview rewrites both to its own paths. - The credential stays in the CLI. The server listens on
127.0.0.1only. The bearer is added to the request to the API by this process, and is never in a URL, a page or a header sent to the browser. Nothing the browser sends (cookies, its ownAuthorization) goes to the API. A request is refused unless itsHostis the local address, itsOriginis the preview's own, and it is not a cross-site request that a page could read. A preview is served with the sandbox policy the frame has,Cache-Control: private, no-storeandReferrer-Policy: no-referrer. The sandbox gives the page an opaque origin (Origin: null), and so does every other sandboxed page, so the origin cannot say whose request it is. The preview gives each session a random token and links its assets at/files/assets/<token>/<file>. OnlyGETof such a path, as a script or a stylesheet, is let read with CORS (a module script needs it), and only then isAccess-Control-Allow-Origin: nullsent. A request from an opaque origin for any other path is refused. An asset path without the token (/files/assets/<file>, or the published frame address) is served only whenSec-Fetch-Siteissame-originornone, so a page of another site that loads it as a stylesheet or script without CORS gets nothing and cannot read what the CLI's login can. The preview rewrites the frame addresses in a stylesheet or script to the path with the token. The token is not the credential and authorizes nothing at the API. - With
--as, the login is checked as forpush: a member's site needs a member login, a company site an admin's.
Sites published as a bundle: fluid sites
fluid sites is for a company on the new member sites plan, whose sites are a
static build (any framework) uploaded as a bundle. It is a second command group
of this same plugin, beside fluid member-site, which keeps working on the
drafts of a pulled site and is not changed. Nothing here guesses the plan from the
folder: the command a person runs says which plan they mean.
The quickest way from an empty folder to a live address:
npm create @fluid-app/fluid-site my-thing # or: fluid sites init my-thing
cd my-thing
fluid deploy # builds, creates the site, publishes, prints https://…The same flow step by step:
fluid sites create rileys-shop --name "Riley's Shop" # a draft site, and ./.member-site.json
# build the site with any framework, into ./dist
fluid sites lint # check the build, no request
fluid sites publish # upload it and go live
fluid sites visibility public # let anyone open itfluid sites create <handle> [dir] and fluid sites list
fluid sites create makes the site as fluid member-site create does (above):
the same options (--name, --company, --as), the same request and the same
first line of output. Then it binds the folder: it writes .member-site.json
into dir (default: the current folder, made if it is missing) as
fluid member-site pull would for a site with no files, and pulls nothing, so
fluid sites publish can run there next with no pull step.
fluid sites create rileys-shop --name "Riley's Shop" # a member's site, bound to .
fluid sites create fall-launch ./fall-launch --company --as adminBefore any request, a folder bound to another site is refused, and so is a folder
that cannot be written to; nothing is created in either case. Running it again
on a folder already bound to that handle sends no request, creates no site, and
says the next step is to build and run fluid sites publish. The handle is lowercase letters,
digits and single hyphens, at most 40 characters, and --company needs an admin
login.
fluid sites list is fluid member-site list under its new name: the same
options and output, with the hint for an empty list naming
fluid sites create.
fluid sites list
fluid sites list --as memberfluid member-site create and fluid member-site list remain as aliases until
the old commands retire, and behave as before: member-site create binds no
folder and takes no folder argument.
fluid sites publish needs the .member-site.json file, which fluid sites
create, fluid deploy and fluid sites pull write, naming the site and the
login kind it needs. fluid sites publish and fluid sites lint take --dir, which names the build output's folder. It is
not the same --dir as the database commands', which names the pulled folder.
fluid sites publish [dir] [--dir <build>] [--as <login>] [--confirm <id>]... [--finish-pending] [--confirm-pending <id>]... [--json]
fluid sites publish # ./dist, as built, or a plain HTML site's own pages
fluid sites publish ./rileys-shop --dir build --as member
fluid sites publish --confirm 2026-11-05-drop-notes # confirm a destructive schema step
fluid sites publish --finish-pending # finish an earlier publish's pending database change
fluid sites publish --finish-pending --confirm-pending 2026-11-05-drop-notes # and confirm a destructive step of that bundle--dir names the build output's folder, relative to the site's folder (the
default is dist). A plain HTML site with no build (an index.html at the top
of its folder, no dist/index.html, and no package.json build script) is
published from its own folder, serving exactly what fluid deploy would: its
servable files, leaving out config/, db/, functions/, node_modules/,
package.json, every dot name (the binding, .env*, .npmrc,
.mist-desktop/, .fluid/) and files a site cannot serve, which a line names.
A link there is left out the same way. From a build output, a publish leaves out
the dot files a site cannot serve (such as a .gitkeep copied from public/)
and .DS_Store, Thumbs.db and desktop.ini; any other file a site cannot
serve is refused. Its dist/ folder, which has no
index.html when this applies, is not served either, and the saved source
leaves it out as it does for every bundle site. Any other folder with no build output
(no dist/index.html, or no folder at --dir) is refused, naming the missing
file; for a site that is not
built as a bundle, use fluid member-site publish. The command never falls back
to it. The build folder, with its links resolved, must be inside
the site's folder and not the folder itself, and must not hold
.member-site.json, config/ or db/ at its top. A symbolic link anywhere in
the build is refused.
The folder holds the build and the site's metadata:
<site>/
dist/ the build output, served as is
db/schema.json optional: the site's database
config/access.json optional: the Fluid data the site uses
config/routes.json optional: extra routes; the default is a single-page-app fallback
config/meta.json optional: the title and preview of each pageThe command:
- Hashes every file of the build (SHA-256, as a stream) and gives each the
content type of its extension. A file under
assets/whose name carries a content hash (index-Bx3zQw9a.js,app.3f2a9c1b.css) is markedimmutable. - Reads the four metadata files above. They are sent with the bundle and never served as files. A file that is not there adds nothing.
- Checks all of it with the same contracts the server uses, and stops before any
request when something fails (see
fluid sites lint). - Starts the bundle. The server answers with the blobs the site does not store yet, each with a signed URL.
- Uploads those blobs, six at a time, each to its own URL with exactly the headers the answer gave. A blob the site stores already, from an earlier publish or another file, is never sent again, so publishing the same build twice uploads nothing the second time, and changing one file uploads one blob. A network failure or a 5xx answer is repeated twice. The signed URLs are never printed.
- Publishes. Nothing is served until this step succeeds, and a failure leaves the site's current version serving.
A publish that runs out of time (the server gives it 27 seconds) answers
publish_timeout. It is safe to repeat, so the command sends it up to three
times. If it still runs out of time, the command says so, exits non-zero and
says it is safe to run the same command again: the files are already uploaded,
so the repeat sends none. Only a 504 the API names publish_timeout is sent
again. A 504 with no such name comes from a proxy in front of the API, and the
publish may still be running or have finished, so it is not sent again: the
command says the outcome is unknown and that this build may still go live. It
tells you to wait a minute, check what the site shows, and only then run the
command again, without rebuilding first. Running it again with the same build is
safe; running it again with a changed build may race the first publish, and the
older build could be the one that stays current. A publish that was sent again and is then refused with a 409 may follow an
attempt that finished, so the command tells you to open the site and check that it
shows this build before you run it again; the API cannot yet say which bundle is
current.
A 404 from the start prints "The site was not found for this login, or its company is not on the new member sites yet. Check the folder's binding; if this is an old member site, publish it with
fluid member-site publish", and exits non-zero. The API answers a missing, deleted or other person's site and a company that is not on the new member sites with the same 404, so the CLI does not guess which.A 409
uploads_in_progresssays the site has 5 uploads in progress, and that one expires 60 minutes after it started.A 422 prints the contract that failed and where, and the blobs that were not stored (
blobs_not_verified). Nothing was published.configanddbas symbolic links are refused, and so is any metadata file reached through one, so nothing outside the site's folder is sent. Adist/index.htmlthat is a link is refused as a build file.Server functions: each
functions/<name>.ts(or.mts,.js,.mjs) thatconfig/functions.jsonnames is bundled with its imports into one ES module with esbuild and sent with the bundle. The module runs on Cloudflare Workers without Node's built-in modules and mustexport defaultits handler; folders insidefunctions/hold shared code. A function'stimeoutMsdefaults to the 30,000 ms cap. Before any request the command refuses a function the config does not name, a name the config gives with no file, two sources for one name, a module over 10 MiB, a build error (an import ofnode:fs, for example), a missing default export, and a build file under/api/, where the functions answer. Afunctionsentry that is a link or a file, and aconfig/functions.jsonreached through a link, are refused. Under an approved snapshot the function sources are read from the snapshot. Where the server has no function runtime, the publish is refused with 422functions_not_availableand nothing changes.--confirm <id>confirms a destructive step of the site's database schema. Repeat it for each migration ofdb/schema.json(itsmigrations) you confirm; the ids are sent asconfirmMigrations. A change that drops, renames or retypes a table or column, or makes a column required, applies only when a migration of the schema names it and its id is confirmed. Without the id the server refuses the publish with 422destructive_change_unconfirmed, and the command lists each unconfirmed migration with its id and its steps from the localdb/schema.json(for exampledrop table notes,rename column posts.body to text), prints the command to run again with a--confirmfor each, and exits non-zero. An id the local schema does not name is still listed. Nothing was published and the current version keeps serving. The site stores the files already, so running the command again uploads nothing new. An id must be 1 to 100 characters, and the command refuses any other before it sends anything.A 409
schema_change_pendingmeans an earlier publish's database change timed out and may have run, and it has to be finished before a new publish. The answer names that bundle (details.pendingBundle), and publishing that bundle again finishes it: the database treats the repeat as done. It is notsuperseded, where a newer bundle won and there is nothing to do. Nothing from this build was published, and the current version keeps serving. The command prints the pending bundle and why, and then:- on a terminal it asks "Publish the pending bundle ... again to finish it?" (a bare Enter is yes). Yes publishes the pending bundle, with no upload because the site stores its files already, and prints that it finished;
- with
--finish-pendingit does the same without asking, with or without a terminal; - with no terminal, or with
--json, and without the flag, it asks nothing and sends nothing: it prints the command to run, which is the same command with--finish-pending.
--confirmapplies only to this build and is never sent with the pending bundle: a migration id is unique only within one schema, so an id you confirmed for your build could match a different destructive migration of the pending bundle, which you have not seen. The pending bundle is finished with no confirmation. If it answers 422destructive_change_unconfirmed, the command lists the pending bundle's own ids (its answer does not carry their steps, so check that bundle's schema) and prints the command to run with--confirm-pending <id>for each.--confirm-pendingis repeatable, and is sent only with the pending bundle, never with your build. The command exits non-zero either way, because this build was not published, and it does not publish it after the pending bundle: that makes the pending bundle the current version, and whether your build follows it is your call. Run the command again; the site stores your files already, so nothing is uploaded again.--jsonprints the result as exactly one JSON object on stdout, and every progress line goes to stderr, and it never asks a question. The exit codes are the same as without it.{"ok":true,"site":"<handle>","bundle":"<id>","manifestSha256":"<sha256>"} {"ok":false,"error":"destructive_change_unconfirmed","unconfirmed":["<id>", ...]} {"ok":false,"error":"validation","cause":<the gateway's cause>} {"ok":false,"error":"schema_change_pending","pendingBundle":"<id>","finished":<true|false>,"unconfirmed":["<id>", ...],"message":"<what the command prints>"} {"ok":false,"error":"<code or failed>","message":"<what the command prints>"}finishedis true when--finish-pendingpublished the pending bundle; this build was still not published, so the exit code is non-zero. When the pending bundle itself needs confirmation, the object also hasunconfirmed, its ids as the server sent them, to pass back with--confirm-pending.The ids and the cause are as the server sent them, JSON-encoded and not escaped for a terminal, so a program can pass them back with
--confirm.erroris the API'sdetails.errorcode, orfailedwhen there is none.A 422
validationmeans the gateway cannot plan the change, such as a drop no migration names. The command prints the gateway's reason (details.cause, such as the table and column) and exits non-zero. Changedb/schema.jsonand run it again.A site with a database
schemais published only when the site has an active database; otherwise the server refuses it withno_database.sites publishdeclares the site's folder as a tree input (withoutnode_modulesand.git), asmember-site pushdoes, and the binding. Run against an approved inputs snapshot, a bundle publish reads every file and every metadata file from it, so what goes live is what was approved, whatever the folder holds when it runs. A link is still looked for on the disk and refused.
fluid sites lint [dir] [--dir <build>]
Checks a bundle site's folder on disk and makes no request. It needs no login. A
plain HTML site with no build (an index.html and no package.json build
script, as fluid sites init --template html writes) is checked as fluid
deploy publishes it: its servable files are copied to .fluid/deploy, checked
there, and the copy is removed. Any other folder with no build output is
refused, naming the missing file, and is never checked as a pulled site: use
fluid member-site lint for that.
fluid sites lint
fluid sites lint ./rileys-shop --dir buildBundle checks, the ones fluid sites publish runs before it sends anything, with
the validators of @fluid-app/sites-contracts:
- Every file of the build has an extension a site may serve (
.html,.css,.js,.json,.svg, images, fonts,.txt,.xml,.pdf,.mp4,.webm,.mp3and the like), a normalised path, none under/_fluid/, and no symbolic link. A bundle holds at most 2,000 files, 25 MiB each and 250 MiB in all. - The build has an
index.htmlwhen there is noconfig/routes.json, because the default route serves it for every path. db/schema.json,config/routes.json,config/access.jsonandconfig/meta.jsonare valid JSON and pass their contracts, and each route names a file of the build.
Each problem is an error, and names its file.
A clean folder prints nothing and exits 0. Any error exits 1.
fluid sites visibility <public|private> [dir] [--as <login>]
Sets who may open the folder's site once it is published: public lets anyone
open it, private only who it is shared with. A new site is private. The
folder's .member-site.json names the site, as for fluid sites publish, and
--as is the login that owns it: member for your own site, admin for a
company site. Any other value is refused before a request is sent. It works for
a site built with fluid member-site too, which has no visibility command of its
own.
fluid sites visibility public
fluid sites visibility private ./rileys-shop --as memberIt prints the site and its new visibility, and says so when nobody can open it
whatever its visibility: a draft until it is published, a site a company admin
locked (only company admins), and a site a company admin took down. The site
version the change moves is recorded in .member-site.json, so the folder's
next settings push does not conflict with it.
fluid sites preview-url [dir]
Signs you in, as your own member, on the storefront that serves the folder's
published site, so a headless browser can open a private site you may view.
It mints the single-use sign-in handoff Mist's site preview uses (the Member
API's sign-in-handoffs) with your login, redeems it on the storefront with a
secret that never leaves the command, and prints { ok, siteUrl, cookies },
with the cookies in the shape Playwright's context.addCookies takes; a
refusal prints { ok: false, error, message }.
Pipe it straight into the script that drives the browser, so the cookie is never written to a file:
fluid sites preview-url | node preview.mjs// preview.mjs
import { chromium } from "playwright";
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const preview = JSON.parse(Buffer.concat(chunks).toString());
if (!preview.ok) throw new Error(preview.message);
const browser = await chromium.launch();
const context = await browser.newContext();
await context.addCookies(preview.cookies);
const page = await context.newPage();
await page.goto(preview.siteUrl);The cookie is your own live storefront sign-in, for up to 12 hours. Keep it
out of files in the site's folder, which fluid sites publish commits to the
site's repository, and out of logs. At a company on identity sign-in, signing
out in that browser ends your identity session, so the CLI is signed out too. Mist's agent is refused the command,
so a cookie never lands in a chat; Mist's own previews sign in without one. It needs a member account at
the site's company: a company admin with none gets a refusal, and can't preview
a private site this way yet.
fluid sites share list|add|remove [--as <login>]
Who the folder's site is shared with. A private site opens only to its shares.
The folder's .member-site.json names the site, and --as is the login that
shares it: admin for a company site or another member's, member for your own.
fluid sites share list # who, permission, when, and each share's id
fluid sites share add [email protected] --as admin # a member, by email or full name
fluid sites share add --everyone --as admin # every member of the company
fluid sites share add <member id> --permission copy --as member
fluid sites share remove [email protected] --as admin # by share id, member or label; asks firstA company admin names a member by email, matched exactly, or by full name,
which is shared only when exactly one member of the company has it; a partial
or repeated name is refused, and the command lists who it could mean, so a site
never opens to the wrong person. A member login cannot look up the company's
members, so it names the member by id. --permission is view (the default
for a new share), copy, or edit, which lets the audience edit the site's
drafts beside the owner (it does not grant copy). Adding an audience that
already has a share changes its permission only when --permission names a
different one, and otherwise says nothing changed.
On a production API, add asks before it changes who can open the live site,
and remove always asks, unless --yes. remove takes a share id, a member's
email, name or id, or any label share list shows, and refuses a label two
shares have; list --json prints the
shares as one JSON line. Messages go to standard error, so standard output
holds only the table or the JSON.
fluid sites dev [dir] [--port <port>] [--viewer guest] [--reset-db] [--confirm <id>...] [--confirm-plan <fingerprint>] [--dir <build>]
Runs the folder's site at 127.0.0.1 (port 9500 unless --port) with a
database of its own, and sends nothing to Fluid. With a dev script in
package.json it starts that script, with the lockfile's package manager or
npm when that one is not installed; without one it serves the build output,
dist or the folder --dir names.
fluid sites dev
fluid sites dev --port 9511 --viewer guest
fluid sites dev --reset-dbfluid.dbis answered from.fluid/dev.sqlitein the folder, never from the live site's rows. The command adds.fluid/to.gitignore, creating the file when there is none.--reset-dbremoves.fluid/dev.sqliteand.fluid/dev-db.jsonfirst and starts again fromdb/schema.json.db/schema.jsonis applied on start and on every save. A file that is not valid is reported and the last good schema kept, and a drop is refused unless a migration step names it. A migration that drops or changes data waits for a yes at the terminal;--confirm <id>...applies those migrations by id without asking, and--confirm-plan <fingerprint>limits them to the held plan aschema_heldevent names, so a schema saved since is not confirmed. Give the folder, if any, before--confirm.- The viewer is a local stand-in owner,
local-owner, so rows written locally carrycreated_by: "local-owner", and/_fluid/apianswers 503.--viewer guestbrowses as a signed-out visitor, with no token sent. - When
config/functions.jsondeclares functions,/api/<name>runs them locally: each call buildsfunctions/<name>.tsinto.fluid/fn/and runs it in its own Node process through the gateway's own shim, with the same access rules, CORS preflight, header stripping,Cache-Controland timeout as the live site.ctx.secretscomes from.fluid/secrets.env(NAME=valuelines; never published and never saved to the site's source), and no other environment variable reaches a function. AnyAuthorization: Bearer fsk_…counts as a key, and a call counts as the page's when the browser sends it from the site (Sec-Fetch-Site,OriginorReferer).ctx.fluid.dbis the local database as the site;ctx.fluid.apineeds a viewer and answers 401fn_api_needs_viewerwithout one. A scheduled function fires on its cron, or at once withcurl -X POST http://127.0.0.1:9500/_fluid/fn/_dev/fire/<name>. A function'sconsole.logprints as[fn <name>] ….
Server functions: fluid sites functions new and fluid sites keys
A function is functions/<name>.ts, whose default export takes a Web
Request and a ctx and returns a Response. It answers at /api/<name> (and
/api/<name>/<sub-path>) on the site's own origin; the page calls it with
fluid.fn.fetch("<name>"). config/functions.json says who may call each one.
fluid sites publish and fluid deploy build and ship them with the site,
then print each function's live address (https://<label>.sites.fluid.app/api/<name>)
when the server says where they answer (the site's functions_url; --json
includes them as functions).
fluid sites functions new hello # access public
fluid sites functions new rsvp --access signed_in # the site's page, signed in
fluid sites functions new order-webhook --access key # callers with an API key
fluid sites functions new nightly --schedule "0 3 * * *" # UTC, at most every 5 minutes
fluid sites keys create zapier # prints the secret alone on stdout, once
fluid sites keys create zapier --json # the key, secret included, as one JSON line
fluid sites keys list # names, first characters, last use; never a secret
fluid sites keys revoke zapier --yes # by name, id or secret; callers are refused at oncefunctions newwrites a starter that fits the access and schedule, adds the function toconfig/functions.json(creating it), and writesfunctions/fluid-env.d.ts, the global types (FluidFunction,FluidContext,FluidCaller,FluidQuery) the starter references. It refuses a name that is taken or not lowercase letters, digits and single hyphens (40 characters at most);fluid sites lintholds a site to 20 functions. Its folder is--folder.- Access:
publicis anyone;signed_inis only the site's own page, with a signed-in viewer;keyis a caller that sendsAuthorization: Bearer fsk_…. Each function may also listcorsorigins and atimeoutMs(at most 30000). ctx:caller(page,public,keyorschedule),secrets(the site's env vars,fluid sites env set),fluid.db(the site's database as the site, past its table rules),fluid.api.fetch(path)(the v2026-10 API as the viewer),site,fnandrequestId.- A call has 1 s of CPU, 30 s of wall time, 128 MB, 50 outgoing requests and
10 MB bodies; a site has 600 calls a minute.
fluid sites logsshows one line per call;console.logoutput is printed only byfluid sites dev. keys createwrites only the secret to standard output, so it pipes; every message goes to standard error. A site holds at most 20 keys, and a name is 1 to 64 characters.revokeasks first unless--yes.
fluid deploy [dir] [--public] [--as <login>] [--no-source] [--no-media]
One command from a project folder to a live site. It reads what the folder is,
builds it, makes its site the first time, publishes it with fluid sites
publish's own code and prints its address.
| The folder holds | What fluid deploy does |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| vite in package.json | Builds with the project's package manager, publishes dist |
| astro | The same, dist |
| next with output: "export" in next.config | The same, out |
| next with no static export | Stops: a Site serves built files. It explains, shows Fluid's recommendation when the server gives one, and suggests fluid mist create |
| index.html and no package.json (or one with no build script) | No build: publishes the folder's servable files, leaving out config/, db/, dot files and files a site cannot serve |
| a package.json with a build script and none of the above | Builds, then publishes the first of dist, build, out that holds an index.html |
The package manager is package.json's packageManager, else the lockfile
(pnpm-lock.yaml, yarn.lock, bun.lock(b), package-lock.json), else npm.
It installs first when there is no node_modules.
A folder with no .member-site.json gets a new site, named after package.json's
name (or the folder), and is bound to it: a company site with --as admin,
your own with --as member, and with neither, a company site when the login
may make one, else your own. Then the first deploy asks once whether the site
is public (no is the default); --public answers yes with no question, and
without a terminal the site stays private and the command says how to open it
up. A later deploy uses the login the binding names and asks nothing.
Like fluid sites publish, it first commits the folder and pushes it to the
site's source repository. When Fluid cannot set that repository up, it says
so once and publishes without the source, which a later publish saves.
--no-source skips the step at once, for example when GitHub cannot be
reached.
Before it builds a Vite project with files under public/media/, it runs
fluid sites media sync (below). With a member login the media stays in the
bundle, and the deploy says so. --no-media skips the sync.
fluid deploy
fluid deploy ./launch-page --public
FLUID_TOKEN=… FLUID_API_BASE=https://api.fluid.app fluid deploy --as admin # CIfluid sites media sync [dir] [--as <login>] [--json]
Uploads the new or changed files under public/media/ to the company's Fluid
DAM through upload.fluid.app/upload (the service fluid assets upload uses;
no DAM asset API is called), and records each file's CDN address
and SHA-256 in config/media.json. fluidSite() from @fluid-app/sites/vite
reads that file at build time: references point at the DAM, and the files stay
out of dist. A file whose bytes match a recorded one is not uploaded again,
so a repeat sync of an unchanged folder sends nothing. Commit config/media.json.
The DAM takes uploads from an admin login. With a member login (--as member,
a member's site, or a login the DAM refuses) nothing is uploaded and the command
says so; the media stays in the bundle.
DAM media is public by URL, whatever the site's visibility. A CDN
address works for anyone who has it, without the site's audience check, and
keeps working after someone's access to a private site is revoked. Only a
file served from the bundle sits behind that check. Keep anything that must
stay private in the bundle (outside public/media/), or out of the site.
fluid sites init [dir] [--template vite-react|html|company-pulse|astro]
Starts a site in a new or empty folder (a .git folder is fine), ready for
fluid deploy. Nothing is sent to Fluid. npm create @fluid-app/fluid-site is the same
command for someone who has not installed the CLI.
vite-react(default): the public member-site-template, downloaded fromcodeload.github.comonly, at most 5 MiB, and unpacked as plain files and folders. An archive with a link, an absolute path, a..path or its own.member-site.jsonis refused whole, and nothing is left behind. Its package.json is renamed after the folder. When its package-lock.json pins an@fluid-app/sitesolder than 0.1.71, whosefluid.fncalls a route that answers 404, the range becomes^0.1.71, so the firstnpm installresolves a newer SDK (npm cirefuses until then).html:index.html,app.js,styles.css,config/access.jsonandconfig/meta.json, written here, with no build step.company-pulse: a one-company health page (status with reasons, GMV today, this week and this month, and items to act on), written here with no build. It reads fake data fromsample.json; itsREADME.mdsays how to plug live data into the oneloadData()function inapp.js.astro: coming soon. Start one withnpm create astro@latest;fluid deploybuilds and publishes it as it is.
Hosting: env, deployments, rollback, logs, traffic, convert
These act on the folder's site, named by its .member-site.json, through the
v2026-10 hosting routes of the member API (/api/member/v2026-10/sites/:id/…)
or the company API (/api/company/v2026-10/member-sites/:id/…). Each takes
--as. On a server that has not been updated yet, each says " isn't
available on this server yet" rather than that the site is missing: a 404 is
checked against the site itself.
fluid sites env list
fluid sites env set PUBLIC_TITLE "Fall launch"
printf %s "$STRIPE_KEY" | fluid sites env set STRIPE_KEY --secret --stdin
fluid sites env set API_URL https://api.example.com --folder ./site
fluid sites env unset STRIPE_KEY --yes
fluid sites deployments [--json] # newest first; * marks the current one
fluid sites rollback # back to the newest kept one that is not current
fluid sites rollback dep-41 --yes # to a deployment, by its id or its bundle's id
fluid sites logs --since 1h --limit 200
fluid sites logs --tail # asks every 2 seconds; Ctrl-C stops
# From any folder, by the site's handle or id:
fluid sites keys create playbook-team --site playbook --as admin \
| fluid sites env set TEAM_AGENT_KEY --secret --stdin --site playbook --as admin
fluid sites traffic --period 7d # 24h (default), 7d or 30d; --json for the whole report
fluid sites convert # company site to an App, admin loginenv: a name starts with an upper-case letter, then upper-case letters, digits and underscores, at most 64 characters, and never starts withFLUID_. A value is 1 to 5120 bytes, and a site holds at most 64. When the server saved a value but its deployed functions did not get it, run the samesetagain. A secret (--secret) is never shown again, by the server or by the CLI, which prints no value it sets.--stdinreads the value from standard input (one final line break is dropped), keeping it out of the shell's history.env set's folder is--folder, since its arguments are the name and the value.unsetasks first unless--yes.--site <handle>:keys,env,logs,deployments,rollbackandvisibilitytake the site's handle or id instead of a folder, so they run from anywhere. The login looks the site up asfluid sites pulldoes. A folder and--sitetogether are refused.visibilitywith--siterecords no version, since there is no folder to record it in; pull a folder of the site before its next push.rollbackmakes a deployment the server keeps the current version again, at once. With no id it is the newest deployment that is not current and whose files the server still keeps;deploymentsmarks the ones it no longer keeps, and naming one of those is refused. It asks first unless--yes. A lone argument that is a site's folder is the folder.logsprints oldest first, from the last 7 days unless--sincesays otherwise.--sincetakes15m,1h,2dor an ISO time, and is sent as an ISO time;--limittakes 1 to 500.--tailprints each line once.trafficprints visits, unique visitors, top pages, referrers and 4xx/5xx errors, and names any other field of the report, which--jsonprints as the server sent it. When the server cannot read traffic just then, it says so; try again a few minutes later.convertturns a company Site into an App (Mist) for a project that needs a custom domain or heavier server code. It needs an admin login with theapplication_themes.updateandmist.createpermissions; a member's own site is refused plainly. It asks first unless--yes, starts the conversion (or follows one already running) to the end, then prints the App's address, which the site's old address now redirects to. If the conversion is still running after about five minutes, or fails, the command exits non-zero; running it again follows a running conversion and shows its progress. A site that is a branch, private or members-only, unpublished, archived, taken down, holds a database, or was converted already is refused with the reason.
fluid sites publish, fluid sites list and fluid sites branch show the
site's address (https://<store>/sites/<handle>) when the server sends one, and
fluid sites publish --json includes it as url.
fluid sites dev serves the sites SDK at /_fluid/sdk/v1/sites.js, as a
published site does, so a page with no build step can load it locally. It is
the served build of @fluid-app/sites; an install without it answers 503. With
no dev script and no build output, fluid sites dev serves a plain HTML site
from its own folder.
Company vault: fluid vault
The company vault holds credentials, such as a third-party API token, that Fluid adds to the outbound requests of the company's sites' server functions and bots, so the value never reaches their code. A value is write-only: it is sent once, and no answer and no command shows it again.
The vault belongs to the company, not to a site's folder, so these commands take
no folder. They use the company API
(/api/company/v2026-10/vault/credentials) with the stored login, which must be
an admin's.
A credential reaches live sites and bots, so set and remove change the vault
only when the admin login is stated with --as admin, and on a production API
only with
