Skip to content

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, deploy

Shape ​

totis <noun> <verb> [args] [flags]

Flags may come before or after the arguments. Every command takes:

FlagMeaning
--jsonprint the result as JSON (tables are the default)
--platform URLthe platform to talk to (see Targeting)
--org, --project, --envthe scope (see Targeting)
--appthe app; defaults to the only app of totis.yaml
-f FILEthe descriptor, instead of the nearest totis.yaml
--context NAMEa named context instead of the one matching the platform
--yesconfirm 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 (mode 0600). totis login URL reuses a context already pointing at that URL, else creates one named after the host; --context NAME picks 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_PLATFORM or totis.yaml) uses the logged-in context whose console is that platform. There is none: it stops with not 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 context default on the first run. The old file is left for scripts that still read it; totis logout on default removes it.
  • The platform must be https. Plain http is accepted only for localhost, 127.0.0.1, ::1 and *.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 local prints the host it talks to on stderr (→ platform.example.com (context platform.example.com)), so production is never hit by mistake.
  • totis login signs 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 as cli on 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 create prints that page's URL, so a leaked token can never mint more.
  • totis login URL --with-token reads a tot_… 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.secrets

Targeting ​

Each of platform, org, project and env resolves on its own, first match wins:

  1. the flag (--platform, --org, --project, --env);
  2. TOTIS_PLATFORM, TOTIS_ORG, TOTIS_PROJECT, TOTIS_ENV;
  3. the nearest totis.yaml (the current folder, then its parents; -f names one);
  4. the context: its default org (set at login, changed with totis orgs use); project and env default to default and production.

--app limits deploy and destroy to the listed slugs; other commands default it to the only app of totis.yaml.

Commands ​

CommandWhat it does
orgs list / orgs use SLUGyour orgs; set the context's default org
projects list / projects create SLUGprojects (a new one gets production)
projects envs list / projects envs create SLUGenvironments of --project
apps list / apps create SLUG [--public] [--starter]apps in the environment
apps delete SLUG / apps info / apps url / apps source / apps manifestdetails, 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 NAMEthe names totis.yaml binds org connections by
secrets listnames 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 SLUGdatabases in the environment
db tables DB / db describe DB TABLEcatalog
db query DB "SQL" [--file F] [--param V]… [--max-rows N] / db shell DBSQL over the data API; --param is JSON ('"text"', 42) or a bare string
kv list / kv create SLUG / kv delete SLUGnamespaces
kv keys NS [--prefix P] / kv get NS KEY / kv put NS KEY [--ttl S] [--metadata JSON] / kv del NS KEYkeys over the data API; put reads the value like secrets put
queues list / queues create SLUG / queues delete SLUGqueues 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 listthe 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 IDset replaces the list; no arguments clears it
origin get / origin set URL / origin clearthe app origin
domains list / domains add D / domains remove Dcustom 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,bcreating 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 orgworkload 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
versionCLI 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.toml imports 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.json is not read: a folder with one and no totis.yaml is 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.

KeyUnitMeaning
maxConcurrentrunsconcurrent invocations of the app
cpuMillismsCPU time per invocation
memoryBytesbytesmemory per invocation
diskBytesbytesscratch disk per invocation
requestsPerMinuterequestsrate limit; 0 is unlimited (refused under a finite ceiling)
streamSecondsslongest 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.net

totis 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
MarkMeaning
+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 changes and deploys nothing.
  • --dry-run stops after the plan: exit 0 when nothing would change, 2 when something would.
  • --prune also removes the extras (storage and email bindings, custom domains, and schedules when the app declares no crons), after its own confirmation. Env-level KV, queues and databases another app may use are never pruned; totis destroy removes 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 NAME regenerates a generate secret and the server keeps the old value as NAME_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: trueregenerates 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": trueparses 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

TinyS3 is AGPLv3 licensed. Cloudflare and Cloudflare Workers are trademarks of Cloudflare, Inc. Amazon S3 and AWS are trademarks of Amazon.com, Inc. or its affiliates. MinIO is a trademark of MinIO, Inc. They are named only to describe compatibility; totis.ai is not affiliated with, endorsed by, or sponsored by any of them.