Skip to content

The dotkit command line ​

Design, not yet implemented

This page is the approved design (issue #3). Stream B (#5) implements it; until then the commands below do not exist. When they do, this page becomes their reference. To see it move, play the mock: node spikes/cli-mock/dotkit.mjs tour.

Principles ​

  • Plain and calm. Say what happens to the person's app, not which tool does it. OpenTofu, Dagger and cloud vocabulary appear only under --verbose. No emoji.
  • Every prompt has a flag. --yes never prompts. An agent can do anything a person can.
  • Two audiences, two channels. Humans read stderr; --json puts one document on stdout.
  • Motion only where a person watches. No header, animation or colour when piped, in CI or with --json. NO_COLOR keeps the static header without colour; DOTKIT_REDUCED_MOTION keeps header and colour without animation.
saynot
Starting the local cloudBootstrapping MiniStack emulator container
Your app: http://localhost:4566/_alb/shop-web/Outputs: web_runtime_url = "http://..."
Docker is not reachable, so the local cloud cannot start.Cannot connect to the Docker daemon at unix:///var/run/docker.sock

init and up open with the gear, drawn in braille from the logo's geometry in the terminal's own text colour, so it reads on light and dark themes. init animates it once (about 2 s: the gear spins in, the teeth land, the centre dot turns green); up shows the final frame at once.

text
     ⢠⣶⣶⡄
  ⣾⣿⣷⣿⣿⣿⣿⣾⣿⣷
 ⣀⣽⣿⠏⠁  ⠈⠹⣿⣯⣀    DOTkit
⢸⣿⣿⣿  ⣿⣿  ⣿⣿⣿⡇
 ⠉⣻⣿⣆⡀  ⢀⣰⣿⣟⠉    is an Ops Toolkit
  ⢿⣿⡿⣿⣿⣿⣿⢿⣿⡿
     ⠘⠿⠿⠃

DOT bold, kit and the tagline dim. In a terminal under 9 rows, the gear gives way to one line, DOTkit · is an Ops Toolkit. The reference for #5 is spikes/cli-mock/: the gear geometry and braille raster in gear.mjs, colours and spinner in terminal.mjs, ported into packages/cli/src/ui/.

Colour ​

The palette of the site and the logo animation; everything else is the terminal's own colours.

rolewheretruecolor256 colours16 colours
verified● done, the gear's centre dot#34da9542green
in motionthe spinner, flowing green to indigo#34da95 → #3e63dd42 → 63green
failure✖ and the failed step's namethe terminal's redredred
ink, hintstext; durations and hints dimmedthe terminal's foreground

Detected once per run: COLORTERM=truecolor|24bit → truecolor, a TERM with 256color → 256, otherwise 16. NO_COLOR, --json, a pipe or CI=true → no colour at all; every state still reads from its glyph (●, ✖, the spinner) and its words.

dotkit init ​

Detection first, then only real choices. Provider and target are shown, not asked, while each has a single option.

text
┌  Set up shop for DOTkit
│
◇  Found in this folder
│  Dockerfile     port 3000 (EXPOSE)   → build this image
│  package.json   name "shop"          → app name
│  score.yaml     none                 → will be created
│
◆  App name
│  shop
│
◆  Image
│  ● Build ./Dockerfile
│  ○ Use an image from a registry
│
◆  Components  (space to select, enter to confirm)
│  ◼ openobserve   logs and traces, with a search UI
│
◆  Wire web to openobserve?  Yes / No
│  + OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS
│
◇  Runs on
│  local: MiniStack on your Docker, the same code path as AWS serverless
│  Other environments later: dotkit env add
│
◇  Wrote score.yaml
│
└  Next: dotkit up
   Same without questions: dotkit init --name shop --image . --yes && dotkit add openobserve --wire
foundreaddrives
Dockerfilethe first EXPOSEimage . (build) and service.ports target port
package.jsonnameapp name
pyproject.toml[project] nameapp name
go.modlast segment of moduleapp name
score.yamlexistskept as is; the wizard only offers components it does not have
nothingthe folder nameapp name; image defaults to "from a registry", and a fourth question asks the port (--port)

Detection reads files only; it never runs the person's code or package manager. Flags: --name, --image <ref|.>, --port <n>, --provider, --target, --yes; components through dotkit add <component>, the one way to add one, now or later: picking one in the wizard runs the same flow, wiring question included. With --yes, an answer that is neither detected nor given fails with exit 2 naming its flag: no guessing. dotkit.yaml is written only when an answer differs from the defaults.

dotkit add ​

A component's manifest lists the lines that wire an app to it (contract §3, defaults); add shows them and asks before touching your container.

text
$ dotkit add openobserve --as obs
◆  Wire web to openobserve?  Yes / No
│  + OTEL_EXPORTER_OTLP_ENDPOINT: ${resources.obs.otlp_endpoint}
│  + OTEL_EXPORTER_OTLP_HEADERS: 'Authorization=Basic ${resources.obs.auth_token}'
│
◇  Wrote score.yaml: resources.obs, and the two lines in web
│
└  Next: dotkit up

Nothing is written until every question is answered. Flags: --as <key> names the resource (default: the component's name; this page uses obs, as the contract's example does, which gives the obs_* output names); --wire (the answer --yes gives) or --no-wire; --container <name> when the app has more than one container, otherwise add asks which. A key that already exists is left as is, and add only offers the wiring if it is missing. Unwired, the component runs but your app's logs and traces do not reach it, and up says so in its results.

dotkit up ​

A numbered list. The running step has a spinner (a braille ratchet, ⣾⣷⣯⣟⡿⢿⣻⣽, its colour flowing green to indigo), a live phrase, and its elapsed time; a finished step collapses to a green ●, its name and duration. Deploy adds a bar fed by the plan's resource count.

text
  shop · local · aws/serverless

  ● 1  Prepare secrets                   0.1s
  ● 2  Generate the infrastructure       0.4s
  ● 3  Start the local cloud             3.2s
  ● 4  Prepare state storage             0.6s
  ● 5  Prepare the deployer               18s
  ● 6  Build your image                   42s
  ⣟ 7  Deploy  ████████████░░░░░░░░   7/12   creating the load balan…   1m 04s
text
  ● 7  Deploy                          1m 51s

  Your app      http://localhost:4566/_alb/shop-web/
  openobserve   http://localhost:4566/_alb/shop-obs/
                sign in as root@dotkit.local, password: obs root_password in .dotkit/local/secrets.json
                your app's logs and traces land here

  Ready in 2m 58s.   Changes: dotkit plan   Stop: dotkit down
  • Step 3 only for an emulator environment; step 6 only when an image is .. A skipped step is not shown and the numbers stay consecutive, so a cloud environment without a build runs 1-5.
  • --env may be left out when there is one environment (with no dotkit.yaml: local).
  • The live phrase comes from OpenTofu's progress lines, mapped to words by the provider package (resource type to "creating the load balancer"), so the CLI stays provider-neutral.
  • The live line never wraps, because a wrapped line cannot be redrawn in place. It is cut to the terminal width in this order: the phrase is shortened (creating the load balan…), then dropped, then the bar goes, then the step's name is shortened; the spinner, the step number, the count and the time always stay. The layout is chosen once per step, so it never jumps.
  • Without motion (NO_COLOR, DOTKIT_REDUCED_MOTION), a step prints ○ 7 Deploy started when it starts, then its finished line: nothing redraws, and a long step still shows it is running.
  • --verbose: the raw OpenTofu and Dagger lines, indented under their step.
  • No TTY or CI=true: [7/7] Deploy ... done (1m 51s), one line per step, then the results.
  • After a failed run, the next command starts with one line about it: Last run: up failed at Deploy (11/12) at 14:32 · log: .dotkit/local/runs/…-up.log.

dotkit plan ​

text
  shop · local · aws/serverless

  ● 1-5  Prepared                        19s
  ● 6    Build your image                 4s
  ● 7    Compare with what runs          21s

  3 to add · 1 to change · 0 to destroy

  web           1 to change   new image
  openobserve   3 to add      storage, two secrets

  Nothing changes until: dotkit up      Details: dotkit plan --verbose

Grouped by component; shared pieces (network, registry) appear as shared when they change. With an image ., plan builds it too (from cache when nothing changed; nothing is pushed) so it can compare digests: new image means your code changed. How the digest is known without a push is #9's to settle.

dotkit down and dotkit output ​

text
  This removes what dotkit created in local: 12 resources (web, openobserve).
  Kept: your files, .dotkit/local/secrets.json, the state history.
◆ Remove them?  No / Yes

  ● Remove  12/12                        46s
  Removed. Start again: dotkit up   Forget everything: dotkit purge --env local

Without a TTY, every command that would ask (init, add, down, unlock) needs its flag or --yes, or it stops with exit 2 naming the flag (contract §8).

text
  url                 http://localhost:4566/_alb/shop-web/
  obs_ui_url          http://localhost:4566/_alb/shop-obs/
  obs_otlp_endpoint   http://localhost:4566/_alb/shop-obs/api/default
  obs_auth_token      ••••••••   dotkit output obs_auth_token --show-secrets

Names are the root outputs of the generated code (contract §3b): url for the app, then <resource>_<output> for each component. The OpenObserve login is not an output: the email is the root_email input, the password the generated root_password in .dotkit/<env>/secrets.json.

Failures and run records ​

A failure is never just "run it again": it says what changed before it, so the person (or the agent) knows whether a rerun is safe.

text
  ✖ 7  Deploy  11/12                                        6m 12s
       web did not become healthy in time. Its last log lines:
         │ Error: Cannot find module '/app/server.js'
         │ Node.js v24.14.1
       Changed before the failure: 11 of 12 resources created and kept; nothing deleted.
       web's service is marked for replacement: the next up recreates it.
       Next: fix the start command in your Dockerfile, then dotkit up (it rebuilds the image)
       Full log, with web's last 50 lines: .dotkit/local/runs/2026-10-06T12-32-08-up.log
situation"changed" saysnext
failed before Deploy (Docker down, invalid score.yaml, lock held)nothing in the environment changedfix the cause, rerun
failed during Deploywhat was created, changed or deleted, from the statefix, up continues; plan shows what remains; down undoes
a run was killed and left the state lockednothing; the lock's id, operation and timeemulator envs: dotkit unlock --env local; cloud envs: not in v2, the error says why
a contract rule refused the inputnothingthe file and key to change, or the contract's exception rule
text
  ✖ 2  Generate the infrastructure
       score.yaml: containers.web.volumes is not supported (contract §2, accepted Score features).
       Changed: nothing.   Next: remove the key, or propose a change (exception rule):
       dotkit.dev/contract/component-contract

Run records. generate, plan, up, down and unlock write .dotkit/<env>/runs/<UTC time>-<command>.log (everything --verbose shows) and a .json (the --json document plus what changed), appended as the run goes, so a crash, a kill or Ctrl-C still leaves them; the last 20 are kept and purge removes them. When a deploy fails, the record also keeps the last 50 log lines of each unhealthy workload, when the platform kept them; otherwise the failure says so and names where to look (the emulator's container, or the provider's console). No secret ever reaches them: the writer masks every secret the CLI holds (the values of secrets.json, the state passphrase, your provider credentials, the registry login, the sensitive root outputs such as obs_auth_token), each also base64- and URL-encoded, whatever its length; files are mode 600. Values the CLI never holds are printed by OpenTofu as (sensitive value). The same mask applies wherever the CLI shows a workload's lines: the record, the failure block on stderr and --json. The one gap: an app that logs a secret the CLI does not hold. Conformance step 1 checks the record against the same set (contract §5.7, §7). unlock works in emulator environments, where the state and its lock live on your machine; it shows the lock and frees it after you confirm.

--json and CI ​

With --json, stdout carries exactly one JSON document when the command ends; everything for humans goes to stderr (plain lines, no header, no animation). Exit code 0 success, 1 a step failed, 2 invalid input (files, flags).

json
{
  "command": "up",
  "ok": true,
  "environment": {
    "name": "local",
    "provider": "aws",
    "target": "serverless",
    "region": "us-east-1",
    "emulator": "ministack",
    "emulatorEndpoint": "http://localhost:4566",
    "workloadEndpoint": "http://host.docker.internal:4566"
  },
  "steps": [
    { "id": "secrets", "status": "done", "seconds": 0.1 },
    { "id": "generate", "status": "done", "seconds": 0.4 },
    { "id": "deploy", "status": "done", "seconds": 111, "resources": 12 }
  ],
  "plan": { "add": 12, "change": 0, "destroy": 0 },
  "outputs": {
    "url": "http://localhost:4566/_alb/shop-web/",
    "obs_ui_url": "http://localhost:4566/_alb/shop-obs/",
    "obs_auth_token": { "sensitive": true }
  },
  "errors": []
}

Step ids: secrets, generate, emulator, bootstrap, init, build, deploy (up); compare (plan); remove (down); unlock. The example is shortened: steps lists every step run and outputs every root output. A failed step has "status": "failed", and errors holds { "step", "cause", "fix", "changed", "log" } plus "contract": "§2" when a contract rule decided it; changed is { "created", "changed", "deleted" } counts from the state. Sensitive outputs never carry a value in JSON; dotkit output <name> --show-secrets --json is the only way to read one, and it warns.

contextheaderanimationcolouroutput
interactive terminalyes (init animates)yesyesstderr
piped, or CI=truenononoplain lines, stderr
--jsonnononoone document, stdout
NO_COLORstaticnonostderr
DOTKIT_REDUCED_MOTIONstaticnoyesstderr

Released under the MIT License