Publish
The bash command is ship (not publish). ship create binds one folder to one URL. The mount tracks branch head and redeploys on every autosave checkpoint. Run create once per path. Do not mint a second mount to “refresh” a link. Do not run repo save as a ritual.
A folder with sails.site.json at the mount root compiles as a static site. Any other folder is served as files. Tailwind CSS and TypeScript/TSX transpile when served.
Done looks like
ship list shows a row with a public_url you can open. For --policy 644, that URL returns 200 without sign-in. Edit a file under the mount path; the same URL updates. You did not run ship create again.
Worked example: public site once
cp -r /templates/static-site-demo ./site
ship create site --policy 644
ship list
<name> is required and unique in the repo. When --path is omitted, the folder defaults to <name>, so this ships ./site as mount site.
Expected create output is JSON (shape, not exact ids):
{
"id": "…",
"name": "site",
"path": "site",
"policy": "644",
"public_url": "https://{id}.sails.app/"
}
Open public_url. Edit under site/. Stop. Autosave already checkpointed.
Commands
ship help
ship create <name> [--path PATH] [--branch name] [--hostname host] [--policy MODE] [--mcp] [--stage-of LIVE]
ship stage <live-name-or-id> [--path DEST] [--name NAME] [--hostname H] [--policy MODE]
ship list
ship update <id> [--name NAME] [--policy MODE] [--stage-of LIVE]
ship verify <id>
ship analytics <id-or-name> [--since 7d] [--granularity day|hour] [--visitor UUID] [--exclude-visitor UUID] [--visitors-limit 50]
ship promote <id-or-name>
ship delete <id> # tombstone 7 days; ship restore
ship delete <id> --now # hard-delete the URL
ship restore <id>
| Flag / command | What it does |
|---|---|
--path | Folder to mount (default <name>) |
--policy | HTTP access for the whole mount (static + *.api.sh) |
--hostname | Platform host, custom domain, or host/prefix |
--mcp | Streamable HTTP MCP at /mcp for *.mcp.sh |
ship stage | Copy live folder to {path}-staging (mode 0700) and link with --stage-of |
ship promote | Three-way-merge staging folder into live (see .shipignore) |
ship delete | Tombstone 7 days (restorable). Avoid --now unless the URL should die |
Policy
| Policy | Who can load the URL |
|---|---|
| 644 | Anyone (public web) |
| 640 | Owner or default-group members (others sign in, then 403) |
| 600 | Owner only (default) |
Anonymous visitors on 600 / 640 go to /auth/login. Public sites need --policy 644. Policy does not rewrite per-file VFS modes in the workspace.
Who can ship
| Actor | create / update / delete / restore / promote / stage | Custom --hostname / ship verify |
|---|---|---|
| Repository owner | Any path, including repo root | Yes |
| write member / agent | Non-root path they have Unix write on (and inside an agent jail) | No |
| read member | No | No |
Agents can ship a preview URL for a folder they can write. Binding a custom hostname stays owner-only.
Hostnames
Platform hosts (*.sails.app, *.localtest.me, *.localhost) include the org slug by default. ship create crm --hostname crm binds {org}-crm.sails.app, not crm.sails.app. Those hosts are auto-verified.
Custom domains do not get the org prefix. The first host on a new domain needs DNS TXT. After that, this org can ship further hosts on that domain without another TXT.
--hostname host/prefix (example: {org}.sails.app/admin) mounts under a path. Ship the host root first in this repository. You cannot hang a prefix on another repo’s host.
ship update cannot change --hostname. To move a custom domain, delete the old mount and ship create the new one. Keep at least one verified host on the domain if you want to skip a new TXT.
Custom domain TXT
Do not invent a TXT value. Sails prints a one-time token.
ship create site --path ./site --hostname example.com --policy 644
ship list
ship verify <id>
Until verify succeeds, public_url stays {published-path-id}.sails.app. hostname already shows example.com. Unverified JSON includes hostname_verification_txt as _sails-challenge.example.com = <token>. Copy the token (the part after =).
DNS (zone example.com): Type TXT, name _sails-challenge, value = the token only. Then CNAME the hostname to cname.sails.app (apex often needs ALIAS / ANAME / CNAME-flattening). ship verify checks ownership only — it does not wait for the CNAME.
Staging → promote
Use a folder pair, not a hostname swap. Both URLs keep serving their own folders.
ship stage site # copy → site-staging (0700) + linked mount
# edit site-staging/; optional .env with KEY=ssec_…
# list .env in site/.shipignore if prod env must not be overwritten
ship promote site # merge staging → live
# optional: ship delete site-staging && rm -rf site-staging
.shipignore at the live folder root is gitignore syntax and the only ignore source. .shipignore itself is never promoted. Do not default-ignore *.json — sails.site.json must promote. Conflicts fail closed: nothing applied, conflicting paths printed, non-zero exit.
Analytics
HTML page views are recorded automatically (no SDK). Assets are not counted. Unique visitors use a sails_vid cookie. Bots are classified from User-Agent and excluded from headline counts.
ship analytics site --since 7d --granularity day
ship analytics site --exclude-visitor 0199a0c0-1111-7000-8000-000000000001
Custom events (both strings, max 128 chars):
navigator.sendBeacon(
"/sails/analytics/events",
JSON.stringify({ name: "signup", value: "pro" }),
);
On a path-prefixed URL, POST /prefix/sails/analytics/events or POST /sails/analytics/events with a same-origin Referer. In published *.api.sh / *.mcp.sh, only ship analytics is allowed — create / update / delete / list / verify stay blocked.
Failure modes
| What you see | What it means | What to do |
|---|---|---|
Second ship create for the same folder | You tried to mint a “fresh” URL | Keep the first mount. ship list is the URL |
ship delete --now then a new URL | You hard-deleted the old links | Prefer plain ship delete (7-day tombstone) unless the URL must die |
Agent cannot --hostname | Custom hosts are owner-only | Owner binds the host; agent can still ship a preview path |
ship create denied for repo root | Write members / agents cannot ship / | Ship a subfolder they can write |
Custom domain still shows *.sails.app | TXT not verified | Copy hostname_verification_txt, add the TXT, ship verify <id> |
| Promote fails closed | Merge conflict | Resolve conflicting paths in staging; promote again |
You typed publish create | Binary is ship | ship help |