The totis CLI
totis drives a TotisWorkers platform from a terminal or CI: everything the console does for apps and their resources, plus totis deploy, which reads the app folder's totis.yaml, plans what is missing, provisions it and deploys every app in one command.
It is a Go binary built from workers/cli (its one dependency is gopkg.in/yaml.v3):
bash
cd workers/cli && go build -o totis .A prebuilt macOS arm64 binary is committed as workers/cli/totis.
Deploying an app needs two things: its totis.yaml and a login.
bash
totis login https://platform.example.com # device flow: approve the code in the console
totis deploy # plan, confirm (production), provision, deployShape
totis <noun> <verb> [args] [flags]Flags may come before or after the arguments. Every command takes:
| Flag | Meaning |
|---|---|
--json | print the result as JSON (tables are the default) |
--platform URL | the platform to talk to (see Targeting) |
--org, --project, --env | the scope (see Targeting) |
--app | the app; defaults to the only app of totis.yaml |
-f FILE | the descriptor, instead of the nearest totis.yaml |
--context NAME | a named context instead of the one matching the platform |
--yes | confirm without being asked |
Exit codes: 0 ok, 1 API or usage error, 2 auth error (not logged in, 401, 403) — and for deploy --dry-run, changes pending — 3 not found (404).
Destructive commands (apps delete, db delete, kv delete, queues delete, queues purge, secrets delete, bindings remove, domains remove, tokens revoke, members remove, storage delete, email delete, context remove, destroy) ask you to type the slug back, or take --yes. Without a terminal and without --yes they stop with exit 1.
Contexts
Credentials are stored per platform URL (scheme, host and port, lower-case), so you can be logged in to production and a local server at the same time, and a token is only ever sent to the platform it was stored for.
bash
totis login https://platform.example.com # stored as the context platform.example.com
totis login http://console.localhost:8080 # stored as console.localhost-8080
totis login # the platform named by ./totis.yaml
totis context list
totis --platform http://console.localhost:8080 apps list- Each context is stored in
~/.totis/contexts/<name>.json(mode0600).totis login URLreuses a context already pointing at that URL, else creates one named after the host;--context NAMEpicks the name. Named contexts (totis context add NAME --console URL,context use) are aliases: a command with no platform target uses the current one. - A command aimed at a platform (from
--platform,TOTIS_PLATFORMortotis.yaml) uses the logged-in context whose console is that platform. There is none: it stops withnot logged in to URL — run totis login URL. - Tokens are bound to the platform host that issued them: the server refuses a token presented on any other host.
- An existing
~/.totis/credentials.json(from CLI 0.2) is copied to the contextdefaulton the first run. The old file is left for scripts that still read it;totis logoutondefaultremoves it. - The platform must be
https. Plainhttpis accepted only forlocalhost,127.0.0.1,::1and*.localhost; any other http URL is refused unless you pass--insecure, which warns every time because the token then travels in cleartext. Redirects are capped at 5, never downgrade https to http, and never carry the token or a request body to another host. - Any context whose name does not start with
localprints the host it talks to on stderr (→ platform.example.com (context platform.example.com)), so production is never hit by mistake. totis loginsigns the CLI in as you: the console asks only which organisation (and, if your console session is older than 10 minutes, asks you to confirm it's you with a password or two-factor code before approving), and the device token acts with your current role and projects there (it follows later role changes). It is valid for 90 days, listed asclion the Tokens page and revocable there. For a narrower credential (a maximum role, a project list, an expiry) for CI or scripts, create a token on the console's Tokens page. Tokens are only created there:totis tokens createprints that page's URL, so a leaked token can never mint more.totis login URL --with-tokenreads atot_…token from stdin instead of running the device flow, checks it against the server and stores it.
CI: TOTIS_TOKEN
The CLI reads five environment variables, for CI only: TOTIS_PLATFORM, TOTIS_ORG, TOTIS_PROJECT, TOTIS_ENV and TOTIS_TOKEN. With TOTIS_TOKEN=tot_… set no credentials file is read; the platform comes from --platform, TOTIS_PLATFORM or totis.yaml (a token without one is refused), and the org defaults to the token's own. The CLI says which token it uses, by id only.
bash
export TOTIS_TOKEN=$DEPLOY_TOKEN
totis deploy --dry-run || [ $? -eq 2 ] # 2: drift
totis deploy --yes --secret-file .env.secretsTargeting
Each of platform, org, project and env resolves on its own, first match wins:
- the flag (
--platform,--org,--project,--env); TOTIS_PLATFORM,TOTIS_ORG,TOTIS_PROJECT,TOTIS_ENV;- the nearest
totis.yaml(the current folder, then its parents;-fnames one); - the context: its default org (set at login, changed with
totis orgs use); project and env default todefaultandproduction.
--app limits deploy and destroy to the listed slugs; other commands default it to the only app of totis.yaml.
Commands
| Command | What it does |
|---|---|
orgs list / orgs use SLUG | your orgs; set the context's default org |
projects list / projects create SLUG | projects (a new one gets production) |
projects envs list / projects envs create SLUG | environments of --project |
apps list / apps create SLUG [--public] [--starter] | apps in the environment |
apps delete SLUG / apps info / apps url / apps source / apps manifest | details, the app URL, deployed source, stored manifest |
apps logs [--follow] | recent log lines; --follow polls every 2 s |
deploy [--app a,b] [--dry-run] [--prune] [--secret-file F] [--secret NAME] [--rotate NAME] | see deploy |
deploy worker.ts [--manifest F] [--site DIR] [--bundle] [--dry-run] [--public] | one-off: deploy a single file into an existing app, no totis.yaml |
destroy [--app a,b] | see destroy |
dev [DIR] [--port N] [--server PATH] [--reset] [--backup-s3-port N] [--no-backup-s3] | every app of totis.yaml on a local server; with docker on PATH it also runs a local TinyS3 (totis-dev-s3, 127.0.0.1:9100) as the instance backup target |
connections list / connections name NAME --storage ID|--email ID / connections unname NAME | the names totis.yaml binds org connections by |
secrets list | names and where each comes from (never values or fingerprints) |
secrets put NAME [--from-file F] | value from stdin (a no-echo prompt on a terminal) or a file; one trailing newline is dropped unless --keep-newline |
secrets generate NAME [--bytes 32] [--reveal] | the server makes a random value (base64url); it is printed only with --reveal. Do not use --reveal in CI: the value lands in the job log |
secrets delete NAME / secrets link NAME --org-secret SRC / secrets unlink NAME | |
bindings list / bindings set NAME --db SLUG | --storage ID --bucket B [--prefix P] | --email ID --senders a,b / bindings remove NAME | |
db list / db create SLUG / db delete SLUG | databases in the environment |
db tables DB / db describe DB TABLE | catalog |
db query DB "SQL" [--file F] [--param V]… [--max-rows N] / db shell DB | SQL over the data API; --param is JSON ('"text"', 42) or a bare string |
kv list / kv create SLUG / kv delete SLUG | namespaces |
kv keys NS [--prefix P] / kv get NS KEY / kv put NS KEY [--ttl S] [--metadata JSON] / kv del NS KEY | keys over the data API; put reads the value like secrets put |
queues list / queues create SLUG / queues delete SLUG | queues with depth and consumer |
queues send Q JSON|@file [--delay S] / queues peek Q [--failed] [--limit N] | |
queues retry-failed Q [--max N] / queues purge Q [--failed] | |
queues consumer set Q APP [--batch N --timeout-ms MS --concurrency N --visibility-ms MS] / queues consumer clear Q | |
jobs list | the org's job apps: schedule, state and last outcome |
jobs start [APP] [--params JSON|@file] [--wait] / jobs stop [APP] | developer role, audited; --wait follows the run's log and exits 1 unless it succeeds; a start that finds a run going is recorded as skipped and exits 1 |
jobs runs [APP] [--limit N] [--failed] / jobs logs [APP] [--run ID] [--follow] | run history; logs without --run shows the newest run |
jobs create SLUG [--schedule CRON …] [--webhook] | a job app; --webhook prints its secret URL once |
jobs webhook show|enable|set|disable [APP] | the job's webhook (Job webhooks): enable prints the secret URL once; --hmac-header H with --hmac-algorithm sha256|sha1, --hmac-encoding hex|base64, --hmac-prefix P, --hmac-timestamp-header H, --hmac-delivery-header H, --hmac-event-header H and --secret-stdin (a pasted secret; empty generates one) add a signature check, --no-hmac removes it; --basic/--no-basic; --allow-ip CIDR (repeatable or comma-separated, up to 32) and --clear-allow-ips |
jobs webhook rotate [APP] [--target url|basic|hmac] [--secret-stdin] | a new URL token (same hook id), basic password or hmac secret, printed once |
jobs webhook-test [APP] [--body JSON|@file] [--from-file F | --rotate] [--hmac-secret-file F] [--basic-file F] [--wait] | delivers to the real hook URL as a sender would; the URL (or its token) comes from stdin or --from-file, or --rotate mints one; it signs for the hook's hmac settings and adds basic auth when the hook has them |
schedules list / schedules set EXPR… / schedules run ID | set replaces the list; no arguments clears it |
origin get / origin set URL / origin clear | the app origin |
domains list / domains add D / domains remove D | custom domains |
domains check [D] | read-only: resolves each domain (or D), reports whether it reaches the app's host (CNAME or shared address) and whether TLS answers on 443; exit 2 when one does not |
storage list / storage create --endpoint URL --access-key K [--region R] / storage delete ID / storage test ID [--bucket B] | the secret key is read like a secret value |
email list / email delete ID / email test ID --to ADDR / email senders set ID a,b | creating a connection is console-only |
tokens list / tokens revoke ID | |
members list | |
data query DB "SQL" / data kv keys|get|put|del NS [KEY] / data queues info|send|receive|ack Q … | the data API for scripts |
metrics app|database|queue|kv NAME [--window 1h|6h|24h|7d|30d|90d] / metrics org | workload metrics: invocations, errors, CPU and wall p50/p95/p99, subrequests, host ops, storage; org lists the top workloads (plus an other row for workloads past the host's per-org key cap, owners/admins only). Invocation, error, status and outcome counts are worker-reported and advisory; CPU, wall, startup, host ops and rows are measured by the host |
logs search [APP] [--since 1h] [--until T] [--outcome O] [--status 5xx] [--trigger T] [--level L] [--run ID] [--q TEXT] [--limit N] [--cursor C] / logs tail [APP] [same filters] | invocation logs (developer role and up): one event per request, queue batch, cron or job run with request metadata (path without query string, client IP truncated to /24 or /48), console lines and exceptions (secret values redacted). search reads newest first (default the last hour) and prints --cursor for the next page; tail streams new events (SSE) until Ctrl-C or the server's 1 h limit. --since/--until take a duration (15m, 7d) or an RFC 3339 time |
version | CLI version, server API level (and the server build when logged in), and a warning when the server is older |
tokens create does not call the server: it prints the console Tokens page URL. members invite, members remove and email create are sent, but the server accepts them only from a signed-in console session, never from an API token (a leaked token must not mint more access); the CLI says so and exits 2.
totis.yaml
One file per app folder, any number of apps. It holds names and sources, never secret values. Unknown keys are errors, so a typo cannot deploy.
yaml
version: 1
platform: https://platform.example.com # required; picks the credential
org: voltmart
project: forms
env: production
production: true # deploy asks before changing anything
apps:
voltforms:
entry: src/index.ts # bundled with esbuild when it imports modules
public: true
site: { dir: public, not_found: single-page-application, run_worker_first: ["/api/*"] }
kv: { SESSIONS_KV: vf-sessions } # binding: slug
d1: { ARCHIVE: vf-archive }
analytics: { FORMS_AE: vf_traffic }
images: IMAGES
queues:
producers: { EVENTS_Q: vf-events }
consumers: { vf-events: { batch: 100, timeout: 2, concurrency: 1 } }
crons: { rollup: { cron: "*/5 * * * *", description: "analytics rollups" } }
secrets:
SESSION_HMAC_KEY: { generate: true } # made once on the server, never printed
FX_RATES_URL: { optional: true } # skipped when no value is given
WEBHOOK_SECRET: { org: ERP_WEBHOOK_SECRET, generate: true } # org secret, linked in
API_KEY: {} # required: --secret-file, stdin or a prompt
storage: { FILES: { connection: files, bucket: vf-files, prefix: vf/ } }
email: { EMAIL: { connection: mail, senders: [[email protected]] } }
settings: { maxConcurrent: 8, cpuMillis: default } # app limits; default clears
logs: { retentionDays: 14, sampleRate: 0.5 } # invocation log settings
domains: [forms.example.com] # custom domains; DNS is yours
origin: https://${FORMS_HOST} # ${VAR} only here and in email senders
voltforms-digest:
root: jobs/digest # entry and site are relative to it
kind: job
crons: { weekly: { cron: "0 8 * * 1" } }
triggers: { webhook: { auth: hmac } } # url | basic | hmac; header, allow_ips
email: { EMAIL: { connection: mail } }wrangler: wrangler.tomlimports a wrangler file's bindings (KV, D1, queues, crons, assets, analytics, images, origin, secrets, compatibility settings); a category set in both files is an error.- Connections are org connections referenced by name. Name one with
totis connections name mail --email ec_…; creating a connection stays in the console. - A secret given a value (
KEY: abc…or{ value: … }) is refused, as are YAML aliases. totis.jsonis not read: a folder with one and nototis.yamlis an error.
Workload settings
settings: holds the app limits, each a whole number in base units or default, which clears the app's value so it follows the instance default. The plan reads the app's GET /v1/apps/{id}/limits and shows ~ setting KEY current → desired; a key the server does not list, or a value above the app's ceiling, blocks the plan (!) with the server's ceiling. For an app not yet created the keys are checked against the list below and ceilings by the server.
| Key | Unit | Meaning |
|---|---|---|
maxConcurrent | runs | concurrent invocations of the app |
cpuMillis | ms | CPU time per invocation |
memoryBytes | bytes | memory per invocation |
diskBytes | bytes | scratch disk per invocation |
requestsPerMinute | requests | rate limit; 0 is unlimited (refused under a finite ceiling) |
streamSeconds | s | longest streamed response |
logs: holds the invocation log settings (owner role): retentionDays (1-30) and sampleRate (0-1). They have no default; leave the key out to keep the server's value.
Custom domains
domains: lists exact names or one-label wildcards (*.example.com), at most 20 per app. The plan shows + domain D; a domain on the server and not in the file is listed and detached only with --prune. A domain covering the platform host or the apps domain, listed twice, or already attached to another app in the env blocks the plan, naming that app. A domain held in another env or org is refused by the server when it is attached.
After a deploy the CLI prints the DNS records to create. The platform does not manage DNS or TLS for custom domains; the record is a CNAME to the app's own host (<label>.<apps domain>, which already resolves to the platform), or for an apex an ALIAS/ANAME (or A/AAAA records with that host's addresses):
deployed voltforms → https://voltforms-x1y2.apps.example.net
DNS records to create (TLS for these names is terminated by the operator):
CNAME forms.example.com → voltforms-x1y2.apps.example.nettotis domains check then verifies the records and TLS.
deploy
totis deploy validates the file, resolves the target and credential, checks the token's org, reads the server, prints a plan, asks for confirmation when production: true (y, or --yes), provisions what is missing and deploys each app that changed:
plan for https://platform.example.com voltmart/forms/production (/src/voltforms/totis.yaml):
+ project forms
voltforms (worker):
+ app voltforms
+ kv vf-sessions
+ queue vf-events
~ consumer vf-events → voltforms
~ schedules */5 * * * *
+ secret SESSION_HMAC_KEY (generated)
~ binding EMAIL → email mail
+ deploy src/index.ts
voltforms-digest (job):
+ app voltforms-digest (job)
+ webhook voltforms-digest (hmac)
+ deploy src/index.ts
? binding OLD extra| Mark | Meaning |
|---|---|
+ | created now |
~ | changed: consumers, schedules, origin, bindings, settings, log settings, public flag, or the code, manifest or site (~ deploy) |
! | blocks the run: a required secret with no value, an unknown connection, a kind mismatch |
? | on the server and not in the file: listed, never deleted unless --prune |
- Everything is created if missing and nothing is destroyed implicitly. Each step is idempotent, so a failed deploy is fixed by running it again. A second run with nothing changed prints
no changesand deploys nothing. --dry-runstops after the plan: exit0when nothing would change,2when something would.--prunealso removes the extras (storage and email bindings, custom domains, and schedules when the app declares nocrons), after its own confirmation. Env-level KV, queues and databases another app may use are never pruned;totis destroyremoves the ones only these apps use.- Secrets: one already set is left alone. A missing one takes its value from
--secret-file F(dotenv, mode 600),--secret NAME(stdin),generate: true(48 random bytes made on the server), or a no-echo prompt on a terminal; in CI a missing required secret blocks the plan.--rotate NAMEregenerates ageneratesecret and the server keeps the old value asNAME_PREV, so it never reaches the CLI. - The deploy then sends the code, a manifest built from the file (bindings, consumers, crons, site, origin, required secret names), and publishes the site. It reports each app's URL, the generated secret names, and a new webhook's URL and secret once.
destroy
totis destroy [--app a,b] deletes the file's apps and the KV namespaces, queues and databases (including the app's own DB) that no other app in the environment names. Each app slug is typed back to confirm; --yes is accepted only when the file says production: false.
Server routes the CLI added
| Route | |
|---|---|
POST /v1/apps/{id}/secrets/{NAME}, POST /v1/orgs/{org}/projects/{p}/envs/{e}/secrets/{NAME}, POST /v1/orgs/{org}/secrets/{NAME} | generate a random value; body { bytes?: 16–256 (32), reveal?: bool } → { ok, name, version, bytes, value? }. Same roles as setting the secret; audited as secret.generate by name |
the same POST …/secrets/{NAME} with rotate: true | regenerates the value and keeps the previous one as {NAME}_PREV, copied on the server; audited as secret.rotate |
GET /v1/orgs/{org}/connections[?name=N], PUT/DELETE /v1/orgs/{org}/connections/{name} | named org connections ({ name, kind, id }); naming needs the org-secrets permission; POST …/storage and POST …/email accept a name too |
POST /v1/orgs/{org}/projects/{p}/envs/{e}/apps with kind: "job" | idempotent by slug: an existing job answers 200 { id, slug, kind, existing: true } |
POST /v1/apps/{id}/deploy with "dryRun": true | parses the manifest and resolves every alias, consumer and secret, changing nothing → { ok, dryRun, result, warnings } |
GET /v1/version | { api } with no sign-in; { api, server } with a valid session or token. api is 2 for this CLI's routes |