# Command reference

Global options (before the command):

| Flag | Meaning |
| ---- | ------- |
| `-p, --profile <name>` | Connection to use (default: the configured default profile) |
| `--json` | Emit machine-readable JSON on stdout; progress on stderr (or `TILLIT_JSON=1`) |
| `-V, --version` | Print version |
| `-h, --help` | Help for any command |

Exit codes: `0` success, non-zero on error (and on `deploy` if any change failed).
In `--json` mode, errors are `{"ok": false, "error": "<message>"}` on stderr.

---

## configure (alias: init)

Bootstrap or update a connection. Interactive with no flags; fully
non-interactive when the required flags are present.

| Flag | Notes |
| ---- | ----- |
| `-t, --tenant <tenant>` | required |
| `-e, --environment <env>` | `prod` \| `stage` \| `sandbox` \| `dev` |
| `-r, --region <region>` | required — `eu` \| `us` \| `au`. Selects the regional API gateway (`/eu/api`, `/us/api`, `/au/api`) |
| `-a, --auth <method>` | `basic` \| `apikey` |
| `-u, --username` / `-w, --password` | for `basic` |
| `--api-key` / `--api-secret` / `--token-url` | for `apikey` (Cognito) |
| `--scopes <a,b>` | optional OAuth scopes for `apikey` |
| `--base-url <url>` | override the derived base URL (host only, no path) |
| `--default` | make this the default connection |

Profile is saved as `{tenant}-{environment}` in `~/.tillit/config.json`.

Profiles saved before this release lack a region; commands that need a
connection will fail with a clear "re-run `tllt configure --region eu|us|au`"
message until the profile is updated.

```bash
tllt --json configure -t acme -e prod -r eu -a basic -u alice -w 's3cret' --default
```

```json
{ "ok": true, "profile": "acme-prod", "tenant": "acme", "environment": "prod",
  "region": "eu", "baseUrl": "https://acme.tillit.cloud", "authMethod": "basic",
  "profiles": [ ... ] }
```

## profiles (alias: connections)

List configured connections. `*` marks the default.

```json
{ "ok": true, "profiles": [
  { "name": "acme-prod", "tenant": "acme", "baseUrl": "https://acme.tillit.cloud",
    "method": "basic", "isDefault": true } ] }
```

## schema

Describe an entity's data model (fields, types, required/unique, enums, and
relationship targets). See [`schemas.md`](schemas.md).

| Arg/Flag | Notes |
| -------- | ----- |
| `[entity]` | entity name (e.g. `Asset`); omit to list all entities |
| `--api <api>` | force `do` or `scheduler` (otherwise inferred) |
| `-p, --profile <name>` | connection to fetch DO schemas from (global flag) |

```bash
tllt --json schema Asset
```
```json
{ "ok": true, "schema": { "api": "do", "entity": "Asset", "naturalKey": "name",
  "fields": [ { "name": "type", "type": "enum", "required": true,
               "enum": ["AREA","LINE","EQUIPMENT"] } ],
  "relationships": [ { "name": "assetClass", "kind": "many-to-one",
                       "references": "AssetClass", "keyField": "name", "required": true } ] } }
```

## import

Snapshot a connection's live configuration into `./{profile}/{api}/...` (the
current directory, so it can live in its own git repo).

| Flag | Notes |
| ---- | ----- |
| `-p, --profile <name>` | which connection (global flag) |
| `--api <api>` | limit to `do` or `scheduler` |
| `--entity <list>` | refresh only these entities (comma-separated); the rest of the snapshot is left untouched. Top-level entity folders are cleared first; nested sub-entities are overwritten in place |
| `-d, --dir <path>` | output directory (default: current directory) |

```json
{ "ok": true, "profile": "acme-prod", "baseUrl": "https://acme.tillit.cloud",
  "entities": [ { "api": "do", "entity": "Asset", "count": 26 }, ... ],
  "skipped": [ { "api": "scheduler", "reason": "..." } ],
  "path": "/cwd/acme-prod" }
```

A per-API failure (e.g. wrong auth method for that API) is **skipped**, not fatal.

## diff

Compare two snapshots and print the changeset that would make `to` match `from`.

| Flag | Notes |
| ---- | ----- |
| `-f, --from <profile>` | required — desired state |
| `-t, --to <profile>` | required — target |
| `-o, --out <file>` | also write the changeset JSON to a file |
| `-d, --dir <path>` | snapshots directory (default: current directory) |

```json
{ "ok": true, "from": "acme-stage", "to": "acme-prod",
  "counts": { "create": 1, "update": 1, "delete": 1 },
  "changes": [ { "op": "update", "api": "do", "entity": "Asset", "key": "Filler 1",
                 "rel": "do/Asset/Filler 1.json", "meta": {...}, "record": {...},
                 "fields": [ { "field": "active", "before": true, "after": false },
                             { "field": "calendar", "after": { "name": "Two shift" } } ] } ] }
```

Each `update` lists its changed top-level `fields`: `before` is the target's
value, `after` the desired one. A side that lacks the field omits that key, so
a missing field is never confused with an explicit `null`.

## deploy

Apply the difference between two snapshots to the live `to` target.

| Flag | Notes |
| ---- | ----- |
| `-f, --from <profile>` | desired state (or use `--changeset`) |
| `-t, --to <profile>` | required — the connection being changed |
| `--changeset <file>` | apply a saved changeset instead of computing one |
| `--api <api>` | limit to `do` or `scheduler` |
| `--dry-run` | print the plan, including each update's `fields`, and change nothing (no credentials needed) |
| `--prune` | also delete target records missing from the source (destructive) |
| `--prune-entity <list>` | prune only the listed entities (narrower than `--prune`) |
| `--protect-entity <list>` | entities another system owns (e.g. `Operation` maintained by a master-data sync) — every change to them is skipped. Config-as-code and an external sync must not both own an entity |
| `--reconcile` | after applying, re-import the touched entities and fold the server-normalized values back into the `--from` snapshot. The server normalizes on write (numbers vs strings, defaulted fields), so without this every future diff shows phantom updates. Field by field: a normalized value is adopted, but a value the target does not hold is **kept and reported** (`notApplied`) and fails the deploy — a 2xx is not proof it landed. A record the server re-keyed (a timestamp normalized inside its natural key) takes the live name; a record the import filter hides (an inactive Asset) is dropped from the snapshot and listed; any other record missing on the target is kept and fails the deploy. Live-only records are listed, never added. Entities with any failed change are skipped. Requires `--from` |
| `--allow-partial` | apply the changes that pass validation and skip the rest. Without it, one invalid change means nothing is applied |
| `-y, --yes` | skip the confirmation prompt (required for unattended runs) |
| `-d, --dir <path>` | snapshots directory (default: current directory) |

Without `--prune`, deletes are excluded. Without `--yes` (and not in `--json`
mode) it prompts before applying. Deploy warns loudly when 25+ records of one
entity would be deleted.

Safety behaviours baked in:

- **Create-if-exists**: a `create` whose natural key already exists on the
  target is applied as an update instead of creating a duplicate (stale
  snapshots used to triplicate sub-entities like `OrderTemplateComponent`).
- **Validation before the first write**: every change is checked against the
  live target and the rest of the changeset before anything is written. If any
  fails, nothing is applied and every failure is reported at once (see codes
  below). A reference may point at a live record or at one the same deploy
  creates.
- **Reference order**: DO entities are written after the entities they
  reference, and records of one entity after the ones they reference (a new
  Asset after its new parent), whatever the folder order; scheduler follows its
  descriptor order. Deletes run last, in exactly the reverse order, and never
  block the deploy: one that can't run is reported when attempted.
- **No fallback for deletes**: a delete in a dataTemplate missing on the target
  fails instead of falling back to the live template (where it would remove a
  same-named record).
- **Failed writes are failures**: a write the server rejects is reported with
  `code` and the HTTP `status`, never as applied.
- **Translations**: after deploying `Translation` records, the compiled
  per-language file (`core/translations/file/{lang}`) is regenerated — the UI
  reads that S3 file, not the table, so without the regen the deploy is
  invisible in the app.
- **Articles**: after each `Article` create/update, the record's `content`
  field is uploaded to the fresh `preSignedUrl` from the response — article
  bodies live on S3, not in the record, and must be re-uploaded on every write.

```json
{ "ok": true, "to": "acme-prod", "counts": { "create": 1, "update": 1, "delete": 0 },
  "applied": [ { "op": "create", "api": "do", "entity": "Asset", "key": "Filler 2", "ok": true } ],
  "translationFilesRegenerated": ["da", "en"],
  "reconciled": { "entities": ["Asset"], "copied": 26,
                  "notApplied": [], "missingOnTarget": [], "liveOnly": [],
                  "rekeyed": [], "filteredOut": [] } }
```

When validation fails, nothing is written and the exit code is 1:

```json
{ "ok": false, "code": "validation_failed", "to": "acme-prod",
  "error": "Validation failed for 1 change(s); nothing was applied.",
  "failures": [ { "op": "create", "api": "do", "entity": "Asset", "key": "Filler 3", "ok": false,
                  "code": "unresolved_ref",
                  "error": "Asset \"Filler 3\": assetClass \"fillers\" matches no AssetClass on the target or in this deploy (did you mean \"Fillers\"?).",
                  "detail": { "refs": [ { "field": "assetClass", "entity": "AssetClass", "key": "fillers",
                                          "candidates": ["Fillers"] } ] } } ] }
```

| `code` | Meaning | `detail` |
| ------ | ------- | -------- |
| `missing_required` | server-enforced NOT NULLs the published schema omits (e.g. `PropertyValue.red/green/blue`) or a non-nullable reference is empty | `fields` (and `refs` if some also don't resolve) |
| `unresolved_ref` | a reference matches no live record and nothing this deploy creates | `refs`: `field`, `entity`, `key`, `candidates` (case-insensitive near matches) |
| `target_missing` | an update whose natural key isn't live (stale `--to` snapshot). A delete of a record already gone does not block | `key` |
| `no_routing` | no `_meta.json` above the record; re-import | — |
| `context_unavailable` | the target can't be resolved (unknown scheduler entity, missing dataTemplate) | — |
| `write_failed` | the server rejected the write | `status` |
| `fk_violation` | a delete is blocked by rows that still reference it | `table`, `column`, `id`, plus a `hint` |

With `--reconcile`, `reconciled.notApplied` lists each record whose values didn't
take, as `{api, entity, key, rel, fields: [{field, desired, live}]}`;
`missingOnTarget` and `liveOnly` list records on only one side, `rekeyed`
(`{api, entity, from, to}`) records renamed to the live natural key, and
`filteredOut` records dropped because the import filter hides them. `notApplied`
or `missingOnTarget` entries make the deploy `ok: false`.

## api

Authenticated pass-through call to the tenant APIs — the region path prefix,
`tillit-tenant` header and bearer/basic auth are handled from the profile, so
ad-hoc calls don't need hand-rolled curl.

| Arg/Flag | Notes |
| -------- | ----- |
| `<method>` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE` |
| `<path>` | relative path, e.g. `core/assets`. A `scheduler/...` prefix routes to the scheduler API |
| `[body]` | JSON body inline, or `@file.json` |
| `--api <api>` | force `do` or `scheduler` |
| `-q, --query <k=v>` | query parameter (repeatable) |
| `--paginate` | GET only: follow pages until exhausted. **Never trust a single raw GET for counts** — many list endpoints silently cap at 20 rows (gateway lists at 200) and ignore `size` |

```bash
tllt api GET core/order-instances -q status.equals=RUNNING -q size=50
tllt api GET core/order-components --paginate
tllt api POST core/translations/update-language-file '{"language":"da"}'
tllt api POST scheduler/graphql '{"query":"{ orders { id orderItems { id } } }"}'
tllt api --api scheduler GET AAR/1/operations -q size=10000
```

## check

Read-only tenant integrity checks for the debris that bulk config work leaves
behind. Exit code 1 when problems are found.

| Check | What it finds |
| ----- | ------------- |
| `do-duplicates` | DO records sharing a natural key (duplicate `OrderTemplateComponent`s from re-deploys, colliding `EventRelay`s, …) |
| `orders-without-items` | Scheduler order shells with no items. The gateway `DELETE /scheduler/orders/{id}` removes the items but leaves the shell; the gateway list hides it, but the Scheduler UI (GraphQL) shows it as "order with no operation". Fix via GraphQL `deleteOrder` |
| `operations-no-routes` | Operations no route references (dead weight from aborted bulk runs) |
| `property-value-case` | PropertyValues under one property differing only by case — changeover matching is case-sensitive |

| Flag | Notes |
| ---- | ----- |
| `--limit <n>` | max problems listed per check (default 20; the JSON result always carries all) |

## wireflow (alias: wf)

Download, upload and test Wire Flow workflows. A workflow is BPMN XML plus a
config object in S3, not a schema-driven entity, so `import`/`diff`/`deploy` do
not manage it. **Read [`wireflow.md`](wireflow.md) before authoring BPMN** — the
authoring traps all fail late.

| Subcommand | What it does |
| ---------- | ------------ |
| `list` | List the tenant's workflows |
| `pull [id]` | Download BPMN + config to `{profile}/wireflow/{id}/`. All workflows if no id |
| `validate <id>` | Compile to a state machine **without deploying** (`asl-preview`) |
| `push <id>` | Save + deploy a new version |
| `test <id>` | Invoke once and report the execution result |
| `versions <id>` | List versions and which one is active |
| `activate <id>` | Point the schedule/alias at a version (default: latest) |
| `create` | Create a new workflow from a BPMN file (does not deploy) |

| Flag | Applies to | Meaning |
| ---- | ---------- | ------- |
| `--file <path>` | `push`, `validate`, `create` | BPMN file (default: `{profile}/wireflow/{id}/workflow.bpmn`) |
| `--activate` | `push` | Also point the schedule at the new version |
| `--draft` | `push` | Save without deploying a version |
| `--print` | `validate` | Print the compiled ASL |
| `--input <json>` | `test` | Workflow input, inline JSON or `@file.json` (default `{}`) |
| `[version]` (positional) | `activate` | Version to activate. **Positional, not a flag** — `--version` is intercepted by the CLI's own version option |
| `-d, --dir <path>` | `pull`, `push`, `validate` | Snapshots directory (default: cwd) |

```bash
tllt --json wireflow list
tllt --json wireflow pull                       # every flow
tllt --json wireflow validate manage_orders     # cheap pre-flight
tllt --json wireflow push manage_orders --activate
tllt --json wireflow test manage_orders --input '{}'
tllt --json wireflow versions manage_orders     # confirm what is actually live
```

**`push` deploys but does not activate.** The response carries
`"activated": false` and the timer keeps running the previously activated
version; `push` prints the activate command when that happens. Pushing identical
content returns the version it already had rather than minting a new one.

`test` runs the deployed workflow for real against whatever it writes to — on an
order-management flow that means real order transitions.

Failures come back in three shapes and the CLI labels each: a Zod `issues`
rejection of the request body, a BPMN-validator `errors` array (per element), and
a `502 "Saved but deployment failed"` where the BPMN **is** saved but the
deployed version is unchanged. `validate` catches only the middle one — cron and
JSONata errors surface at deploy.
