npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-site

Requires @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 member

Without --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 site
rileys-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.json is 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 except messages: write, shares: write, widgets: write, contacts: read, contacts: write, carts: write, mysite: write, agreements: write, ai: write, database: read and database: write, and except PATCH /me, the notification-preferences write and the calendar RSVP. A share holder's orders: read returns 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.json holds the site's default layout and, for each page, its slug, title, position, published, layout and menu (the menu's slug). It holds no page content, which is in pages/<slug>.liquid. A page whose content is a template the site does not own has a row there and no file.
  • .member-site.json is the binding file. It records the site's site_id, handle and owner_kind, the api_base it was pulled from, the settings lock_version, the site's default layout, each page's id, lock_version and row by slug (the row lets a push tell which fields you changed), and for every file the lock_version, updated_at and the version the 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. --force writes anyway. Files that are not on the server are never removed, with one exception: when the site no longer has config/access.json and 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 --force is 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 once

A 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:

  1. config/settings.json, with the settings lock_version the 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.
  2. 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 recorded version; a new one carries must_not_exist. A company site's config/access.json is pushed the same way.
  3. 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's lock_version.
  4. config/menus.json, when it changed, with its recorded version. 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.
  5. Each page's menu assignment (PATCH pages/:id with menu), with the page's re-read version. A page body never carries menu, so it is never sent before its menu exists.
  6. Each removed page (DELETE pages/:id?lock_version=), then each removed layout, section, snippet, asset and config/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, assets and config, 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.json that is not valid, a slug index (the home page is /), a menu that config/menus.json does 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 a menus array, 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 url the server's URL parser refuses: anything but a well-formed http or https address 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 member

It 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_version when that moved by exactly one. The settings take the site's lock_version when 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-shop

A 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 lint checks 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, assets and config, 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.json must 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.json must have the shape the API takes, and each menu link (page) must name a page that config/pages.json lists. A folder with no config/pages.json lists no pages.

  • config/access.json must be what the file API accepts, and each problem is an error: not valid JSON, a key other than uses or 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 with fluid.fetch("/api/member/v2026-10/me"), which needs profile: read.

  • When config/access.json is valid, each fluid.fetch( call (also window.fluid.fetch() in a layout, section, snippet, page or .js asset is checked against src/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 a method: "POST" string literal in an object-literal second argument (GET when there is none). It moves the path as fluid.fetch does: 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.json is not checked for calls, because a site with no declaration does not limit its owner, with one exception the lint does not check: the family database. 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 lists database: read or database: 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.

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 dev stops, 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-production to save to production anyway. A base that is not production keeps syncing without it: a local one (localhost, a loopback address (any of 127.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 dev is never pointed at a live theme either: it works on a development theme unless --theme names 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 push without --publish: the same plan, the same refusals, the same version on every write, and the same stop at a stale version (pull, then run dev again). It never calls the publish route. What takes effect without a publish is listed before each save, as for push: assets, removals, menus and page rows are live as soon as they are written. Run fluid member-site publish when 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 0 takes a free one). / is the home page and /<slug> another page, read through GET .../sites/:id/preview/pages(/:slug) of the member API, or GET .../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.1 only. 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 own Authorization) goes to the API. A request is refused unless its Host is the local address, its Origin is 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-store and Referrer-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>. Only GET of such a path, as a script or a stylesheet, is let read with CORS (a module script needs it), and only then is Access-Control-Allow-Origin: null sent. 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 when Sec-Fetch-Site is same-origin or none, 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 for push: 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 it

fluid 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 admin

Before 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 member

fluid 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 page

The command:

  1. 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 marked immutable.
  2. 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.
  3. Checks all of it with the same contracts the server uses, and stops before any request when something fails (see fluid sites lint).
  4. Starts the bundle. The server answers with the blobs the site does not store yet, each with a signed URL.
  5. 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.
  6. 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_progress says 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.

  • config and db as symbolic links are refused, and so is any metadata file reached through one, so nothing outside the site's folder is sent. A dist/index.html that is a link is refused as a build file.

  • Server functions: each functions/<name>.ts (or .mts, .js, .mjs) that config/functions.json names 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 must export default its handler; folders inside functions/ hold shared code. A function's timeoutMs defaults 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 of node:fs, for example), a missing default export, and a build file under /api/, where the functions answer. A functions entry that is a link or a file, and a config/functions.json reached 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 422 functions_not_available and nothing changes.

  • --confirm <id> confirms a destructive step of the site's database schema. Repeat it for each migration of db/schema.json (its migrations) you confirm; the ids are sent as confirmMigrations. 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 422 destructive_change_unconfirmed, and the command lists each unconfirmed migration with its id and its steps from the local db/schema.json (for example drop table notes, rename column posts.body to text), prints the command to run again with a --confirm for 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_pending means 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 not superseded, 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-pending it 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.

    --confirm applies 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 422 destructive_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-pending is 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.

  • --json prints 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>"}

    finished is true when --finish-pending published 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 has unconfirmed, 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. error is the API's details.error code, or failed when there is none.

  • A 422 validation means 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. Change db/schema.json and run it again.

  • A site with a database schema is published only when the site has an active database; otherwise the server refuses it with no_database.

  • sites publish declares the site's folder as a tree input (without node_modules and .git), as member-site push does, 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 build

Bundle 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, .mp3 and 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.html when there is no config/routes.json, because the default route serves it for every path.
  • db/schema.json, config/routes.json, config/access.json and config/meta.json are 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 member

It 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 first

A 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-db
  • fluid.db is answered from .fluid/dev.sqlite in the folder, never from the live site's rows. The command adds .fluid/ to .gitignore, creating the file when there is none. --reset-db removes .fluid/dev.sqlite and .fluid/dev-db.json first and starts again from db/schema.json.
  • db/schema.json is 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 a schema_held event 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 carry created_by: "local-owner", and /_fluid/api answers 503. --viewer guest browses as a signed-out visitor, with no token sent.
  • When config/functions.json declares functions, /api/<name> runs them locally: each call builds functions/<name>.ts into .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-Control and timeout as the live site. ctx.secrets comes from .fluid/secrets.env (NAME=value lines; never published and never saved to the site's source), and no other environment variable reaches a function. Any Authorization: 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, Origin or Referer). ctx.fluid.db is the local database as the site; ctx.fluid.api needs a viewer and answers 401 fn_api_needs_viewer without one. A scheduled function fires on its cron, or at once with curl -X POST http://127.0.0.1:9500/_fluid/fn/_dev/fire/<name>. A function's console.log prints 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 once
  • functions new writes a starter that fits the access and schedule, adds the function to config/functions.json (creating it), and writes functions/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 lint holds a site to 20 functions. Its folder is --folder.
  • Access: public is anyone; signed_in is only the site's own page, with a signed-in viewer; key is a caller that sends Authorization: Bearer fsk_…. Each function may also list cors origins and a timeoutMs (at most 30000).
  • ctx: caller (page, public, key or schedule), 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, fn and requestId.
  • 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 logs shows one line per call; console.log output is printed only by fluid sites dev.
  • keys create writes 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. revoke asks 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   # CI

fluid 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 from codeload.github.com only, 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.json is refused whole, and nothing is left behind. Its package.json is renamed after the folder. When its package-lock.json pins an @fluid-app/sites older than 0.1.71, whose fluid.fn calls a route that answers 404, the range becomes ^0.1.71, so the first npm install resolves a newer SDK (npm ci refuses until then).
  • html: index.html, app.js, styles.css, config/access.json and config/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 from sample.json; its README.md says how to plug live data into the one loadData() function in app.js.
  • astro: coming soon. Start one with npm create astro@latest; fluid deploy builds 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 login
  • env: a name starts with an upper-case letter, then upper-case letters, digits and underscores, at most 64 characters, and never starts with FLUID_. 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 same set again. A secret (--secret) is never shown again, by the server or by the CLI, which prints no value it sets. --stdin reads 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. unset asks first unless --yes.
  • --site <handle>: keys, env, logs, deployments, rollback and visibility take the site's handle or id instead of a folder, so they run from anywhere. The login looks the site up as fluid sites pull does. A folder and --site together are refused. visibility with --site records no version, since there is no folder to record it in; pull a folder of the site before its next push.
  • rollback makes 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; deployments marks 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.
  • logs prints oldest first, from the last 7 days unless --since says otherwise. --since takes 15m, 1h, 2d or an ISO time, and is sent as an ISO time; --limit takes 1 to 500. --tail prints each line once.
  • traffic prints visits, unique visitors, top pages, referrers and 4xx/5xx errors, and names any other field of the report, which --json prints as the server sent it. When the server cannot read traffic just then, it says so; try again a few minutes later.
  • convert turns 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 the application_themes.update and mist.create permissions; 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