Metadata-Version: 2.4
Name: devnomads-cli
Version: 0.14.0
Summary: Manage your DevNomads services from the command line
Author-email: DevNomads <support@devnomads.nl>
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: typer>=0.19
Requires-Dist: httpx>=0.27
Requires-Dist: rich>=13
Requires-Dist: cryptography>=42
Requires-Dist: devnomads[acme]>=0.6.2
Dynamic: license-file

# dncli

Manage your [DevNomads](https://devnomads.nl) services from the
command line: inspect your services, manage DNS zones and records,
and move zones over from other providers - from a shell or a script.

The command is **`dncli`** - named to avoid clashing with the macOS
`dnctl` traffic-shaper at `/usr/sbin/dnctl`. Install it from PyPI
as **`devnomads-cli`**.

## Install

Install dncli with [uv](https://docs.astral.sh/uv/). It gives dncli
its own isolated environment and puts the `dncli` command on your
PATH:

```sh
uv tool install devnomads-cli
```

No uv yet? Install it first:

```sh
curl -LsSf https://astral.sh/uv/install.sh | sh                               # macOS, Linux
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"  # Windows
```

pipx (`pipx install devnomads-cli`) and pip (`pip install
devnomads-cli`) work as well.

### Updates

Upgrade with:

```sh
dncli upgrade
```

It uses the tool you installed dncli with (uv, pipx, or pip in a
virtualenv); where it can't, such as a system-wide pip install or on
Windows, it prints the command to run instead. `dncli upgrade --dry-run`
shows what it would do.

dncli checks PyPI at most once a day and tells you when a new version
is out. It only does so on an interactive terminal, never in pipes,
scripts or CI; set `DN_NO_UPDATE_CHECK=1` to turn it off.

dncli also warns when the DevNomads API reports a major version it was
not built for, and tells you when the API no longer supports your
dncli version. Update dncli in both cases.

### Shell completion

Enable shell completion (bash, zsh, fish, PowerShell):

```sh
dncli --install-completion
```

## Getting started

Create an API key in the [DevNomads
portal](https://portal.devnomads.nl) under **Profiel -> API
Sleutels**, then store it:

```sh
dncli configure
```

The key is written to `~/.config/dncli/credentials`, readable only
by you. From here every command works:

```sh
dncli services list
dncli domains list
dncli containers show <id>
```

Every DevNomads product has its own command group - `apps`,
`buckets`, `containers`, `databases`, `domains`, `emails`,
`forwards`, `proxies`, `servers`, `sites` and more, plus `keys` for
your SSH keys and `jobs` for background work. Explore with
`dncli --help` and `dncli <group> --help`.

In a terminal, commands accept any unambiguous prefix: `dncli e l` is
`dncli emails list`. An ambiguous prefix lists the candidates. Scripts
(no terminal on stdin) must spell commands out, so a truncated name can
never run a different command; dncli then names the command you meant.

`dncli commands` lists every command with its options, and marks the
ones that change something, ask for confirmation, run in the
background or start billing. `dncli commands --out json sites` gives
the full detail for one group, for scripts and AI agents.

## Logging in to a site

`dncli sites ssh` opens an SSH session as the site's system user,
`site<service_id>`, so you don't need to know that name:

```sh
dncli sites ssh example.com
dncli sites ssh example.com -- ls -la      # run one command
dncli sites ssh example.com --dry-run      # print the ssh command
```

The API doesn't say which host to SSH into, so the first time for a
site cluster dncli asks for it and remembers the answer per cluster in
`ssh-hosts` in its config directory. Pass `--host` to give or change
it without the question, for example in a script. Anything after `--`
goes to ssh: extra options (`-- -p 2222`) or a remote command.

ssh logs in with your SSH key when the site has it (`dncli sites keys
attach`); otherwise ssh itself asks for the site's password. dncli never
sees or stores that password. Scripts and other runs without a terminal
cannot answer a password prompt, so they need key login.

## Publishing a site

`dncli sites publish` uploads a folder (the current one by default) to
a site with rsync, over the same SSH login as `dncli sites ssh`:

```sh
dncli sites publish example.com ./dist --dry-run   # show what would change
dncli sites publish example.com ./dist             # new and changed files
dncli sites publish example.com ./dist --delete    # also remove files not in ./dist
```

Files go to `/home/site<service_id>/domains/<domain>/public_html`. `.git/`
is never uploaded; `--exclude` leaves out more (repeat it). You need
rsync locally and on the server.

**Release mode.** `--release` switches a site to versioned uploads:
each publish goes into `public_html/releases/<timestamp>/` (unchanged
files are hard links to the previous release, so only changes are
transferred) and the `public_html/current` symlink switches to it in
one step, so visitors never see a half-finished upload. dncli points
the site's document root into `current` through the API, e.g. `/public`
becomes `/current/public`. After that every publish makes a release;
`--keep` (default 5) sets how many stay.

```sh
dncli sites publish example.com ./dist --release   # switch, asks first
dncli sites releases example.com                   # list releases
dncli sites rollback example.com                   # back to the previous one
dncli sites publish example.com ./dist --no-release  # back to plain mode
```

Every release starts from your folder alone: files the site writes
into its own folder (uploads, caches) are not carried over. Switching
mode changes the live site, so dncli asks first (`--yes` skips the
question); the old mode's files stay on the server, no longer served.

## Managing DNS

```sh
dncli dns zones list
dncli dns zones show example.com
dncli dns records list example.com
dncli dns records set example.com www A 192.0.2.1 --ttl 3600
dncli dns records delete example.com www A
```

Anywhere a zone is expected you may pass the service id of a domain
instead of its name, so the ids from `dncli domains list` work here
too:

```sh
dncli dns records list 42
```

An id always means that domain's own zone. A delegated subzone such
as `foo.example.com` has no domain service of its own, so address it
by name.

## Moving a zone from another provider

`dncli dns transfer` copies a zone from your current DNS provider
into DevNomads. It reads the records at the source (never writes
there), shows you the changes, and applies them after confirmation:

```sh
dncli configure --provider transip       # store source credentials
dncli dns transfer --from transip --zone example.com --dry-run
dncli dns transfer --from transip --zone example.com
```

Supported source providers: TransIP and AuroraDNS. The zone must
already exist at DevNomads; register or transfer the domain in the
portal first.

## Multiple accounts

Store each account as a named profile and select it per command or
per shell:

```sh
dncli configure --profile acme
dncli services list --profile acme
export DN_PROFILE=acme
```

Manage stored profiles:

```sh
dncli configure list                 # profiles, with keys masked
dncli configure set-key acme         # replace a profile's API key
dncli configure delete acme          # remove a profile
```

`set-key --stdin` reads the new key from standard input, and
`delete --yes` skips the question, for use in scripts.

## Scripting

Output is a human-readable table on a terminal and JSON when piped,
so pipelines get parseable output without any flags; `--out
table|json|csv|tsv` forces a specific format. Data goes to stdout,
everything else (warnings, prompts, status messages) to stderr, so
`dncli ... | jq .` is always safe.

The `csv` and `tsv` formats use the same columns as the table view, so
`dncli servers list --out csv` is ready for a spreadsheet.

List tables show a focused set of columns: fields that are empty for
every row, and nested collections (a server's IPs, a container's
instances, a mailbox list) are left out of the overview. Use `<group>
show <id>` or `--out json` for the complete record.

To pick exactly the columns you want, pass `--columns` (`-c`) a
comma-separated list, e.g. `dncli services list -c entity,started_at`.
It applies to every format, projecting json and csv/tsv to just those
fields, and reports an error on an unknown column name.

Commands that take a service id also accept a unique entity name and
resolve it for you, so `dncli domains show example.com` works as well
as `dncli domains show 1234`. Clients, SSH keys, clusters, hypervisors,
database users and volumes take a name too, as an argument or an
option: `dncli databases permissions create stelling_bg25 --user-id stelling`. The
help of each such option says which field it matches. Names match
regardless of case. A purely numeric value is always treated as an id;
a name that matches more than one entity is rejected, so pass the id
when a name is not unique.

Every command that changes something takes `--dry-run`: it resolves
names to ids and prints the request it would send, without sending it
(secrets masked).

Passwords stay off the command line with `--password -`, which reads
the password from stdin, or `--generate-password`, which creates a
strong one and prints it once on stderr.

List options on update commands (`--alias`, `--backend`) replace the
whole list; `--add-alias` and `--remove-alias` change one entry and
keep the rest.

Some changes run in the background: creating a site, server, proxy or
domain, for example, queues one or more jobs in a batch. Such a command
prints the queued jobs and returns right away; add `--wait` to follow
the batch until every job has finished and see how each one ended.
With `--wait` the exit status is 0 only when every job completed (1
when a job failed, 7 when `--timeout` ran out). You
can look a batch up later with `dncli jobs batches show <batch_id>`.

The exit status is 0 when the API accepted the request, so `dncli ...
&& next-step` is safe in scripts. A failure says what kind it is:

| Exit | Meaning |
|---|---|
| 1 | other errors, e.g. a background job failed |
| 2 | wrong command, option or value, or `--yes` needed |
| 3 | not found (404, or no entity with that name) |
| 4 | API key missing or rejected, or no permission |
| 5 | the API rejected the request (validation), or a name is ambiguous |
| 6 | rate limited, server error or network failure: retry later |
| 7 | `--wait` timed out; the jobs keep running |

When output is JSON and stderr is not a terminal, the error is one
JSON line on stderr, e.g. `{"error": {"message": "...", "kind":
"invalid", "exit": 5, "status": 422}}`; option parsing errors (exit 2)
stay plain text. Every command takes `--timeout
<seconds>` (default 300) for how long to wait for the API, including
the time spent waiting on `--wait`.

In CI and pipelines, skip the credentials file and pass the key via
the environment:

```sh
export DN_API_KEY=...        # beats any stored profile
dncli services list | jq -r '.[].entity'
```

## AI agents

dncli ships with a skill that teaches AI coding agents how to use it
safely: where ids come from, what costs money or deletes data, and to
ask before changing anything. It works with Claude Code, Codex, Gemini
CLI, GitHub Copilot, Cursor and OpenCode:

```sh
dncli skill install                      # every agent found on this machine
dncli skill install --agent codex        # a specific agent; repeat for more
dncli skill install --project            # into the current project instead
```

Run it again after upgrading dncli to update the skill; `dncli skill
show` prints it.

## Certificates

`dncli` issues Let's Encrypt certificates using the DNS-01 challenge:

```sh
dncli certs issue example.com -d www.example.com --wildcard
```

The first argument is the primary domain (the certificate CN); add
extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
shortcut that adds `*.<domain>` as a SAN - a wildcard is never
accepted as the CN itself. Every name must live in
one of your DevNomads DNS zones, and that zone must be delegated to the
DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
`three.dns.infrapod.eu`) - otherwise issuance is refused, since the
DNS-01 challenge records would not be visible to the CA.

The certificate is always written to `~/.config/dncli/certs/<domain>/`
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
Pass `--out <file>` to additionally export a single PEM bundle of the
key, the certificate, and the full chain (intermediate plus any
cross-signed roots), in that order. Add `-v`/`--verbose` for detailed ACME
progress.

Keys are ECDSA P-384 by default. Pick another with `--key-type`
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).

Let's Encrypt is the certificate authority by default. Pass
`--provider zerossl` to use ZeroSSL instead. ZeroSSL requires External
Account Binding credentials (create an EAB key pair in the ZeroSSL
dashboard under Developer > EAB Credentials), supplied with `--eab-kid`
and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
`DN_ZEROSSL_EAB_HMAC_KEY` environment variables:

```sh
dncli certs issue example.com --provider zerossl \
  --eab-kid <kid> --eab-hmac-key <hmac>
```

`--staging` (the Let's Encrypt staging CA) applies only to the
letsencrypt provider. `certs renew` takes the same `--provider` and EAB
options.

Each CA gets its own ACME account: the key lives in
`~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
and `account-<provider>.pem` for ZeroSSL and the staging CA.

Re-running `certs issue` is a no-op while the existing certificate is
still valid for more than 21 days; pass `--force` to re-issue anyway.

List what you have issued and re-export any of them - as a PEM bundle
or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
`provider` column showing the issuing CA (recorded at issue time, or
inferred from the certificate's issuer for older certificates):

```sh
dncli certs list
dncli certs export example.com --out bundle.pem    # omit --out to print
dncli certs export example.com --format pfx --out bundle.pfx
dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
```

The `.pfx` is unencrypted unless you pass `--passphrase` (alias
`--password`). Both bundle formats carry the same items: key,
certificate, and the full chain. The chain includes the cross-signs:
with Let's Encrypt's current hierarchy the path to a trusted root runs
through the intermediate (e.g. YE2), Root YE (cross-signed by ISRG
Root X2) and ISRG Root X2 (cross-signed by ISRG Root X1).

`dncli certs delete <domain>` removes a stored certificate and its
private key from disk (after confirmation; `--yes` skips it). By
default it only deletes the local files; add `--revoke` to also revoke
the certificate at the CA that issued it (the files are kept if
revocation fails).

A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:

```sh
# dehydrated/config
CHALLENGETYPE="dns-01"
HOOK="dncli-dns-hook"
```

## License

MIT.
