@haakco/hostingctl 0.0.0-stage → 1.0.310

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,659 @@
1
- # Temporary Holding Version
1
+ # hostingctl
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The hosting platform's API from a terminal, for a client's laptop and an operator's — one binary, one
4
+ token, the same commands. It is **standalone**: no checkout, no database, no cluster credentials and no
5
+ Infisical. It speaks to the public API over HTTPS with a Bearer token, exactly as the web app does, and it
6
+ generates its own client from `api/openapi.json`, so its request and response types are the contract's own.
7
+
8
+ Generation does **not** decide which operations have a command — a generated function nobody calls is a
9
+ feature nobody can use. That accounting lives in `src/commands/operation-map.ts`, which names every published
10
+ operation and either the route that reaches it, a shared read, a protocol consumer, or the one policy conflict
11
+ with no route; `just cli-test` fails when an operation is unaccounted for or a mapped route does not exist.
12
+
13
+ All **370** published operations are accounted for: **362** are reachable from a command, 6 are protocol
14
+ consumers the API calls rather than a person (the asset reader, the broadcast authenticator, the state
15
+ manager's intent resolver, the webhook client, the GitHub webhook and first-party authorization), 1 is the
16
+ company read the `--company` resolver shares, and 1 is deployment rollback, which stays open because the
17
+ platform never downgrades a live release.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npx @haakco/hostingctl --version # no install at all
23
+ npm i -g @haakco/hostingctl # install, once
24
+ ```
25
+
26
+ Node 22 or newer runs the CLI, and nothing else — no checkout, no database, no cluster credentials. The
27
+ package also publishes a self-contained executable per platform (`@haakco/hostingctl-linux-x64` and its
28
+ four siblings, `@haakco/hostingctl-linux-arm64`, `@haakco/hostingctl-darwin-arm64`,
29
+ `@haakco/hostingctl-darwin-x64` and `@haakco/hostingctl-windows-x64`); that package's `bin` **is** the
30
+ native binary, so a machine with no Node installs the platform package directly and needs no runtime.
31
+
32
+ `hostingctl update` brings a copy to the published version: it detects how the copy was installed,
33
+ prints the command it will run, runs it, and reads the installed version again to confirm the change.
34
+ `hostingctl update --check` reports the published version and that command without changing anything.
35
+ For a global npm install the command is `npm i -g @haakco/hostingctl@latest`.
36
+
37
+ `hostingctl --version` reports the version of the copy that is running; a development build reports
38
+ `0.0.0-dev`.
39
+
40
+ ## Authenticate
41
+
42
+ **A client** signs in with the platform's **authorization-code/PKCE** flow. The CLI keeps the session in a
43
+ **mode-0600 credential file** at `~/.config/hostingctl/credentials.json`, inside a 0700 directory, and checks
44
+ that mode on read — a wider file is refused rather than read. Access tokens last 60 minutes; the refresh token
45
+ owns the lifetime and rotation is the mechanism, so a command spends the stored session and renews the access
46
+ token silently rather than holding a long-lived credential. The protocol, its rules and the reasons for them
47
+ are owned by
48
+ [authentication patterns](../docs/development/authentication_patterns.md) — this README does not restate
49
+ them.
50
+
51
+ **For local work**, use the development fixtures owned by
52
+ [test users](../docs/development/test_users.md): they exist only in local, `DevelopmentSeeder` creates
53
+ them, and their password is public development data rather than a credential.
54
+
55
+ Sign in with the public client's registered ID:
56
+
57
+ ```text
58
+ hostingctl login --api-url https://api.example.com/api/v1 --client-id <client-id>
59
+ ```
60
+
61
+ The browser completes sign-in and consent. Before the session is written, the API is asked who the new token
62
+ is (`GET /me`): a token Passport minted but the API will not act on is a failed sign-in, and the working session
63
+ it would have replaced is left alone.
64
+
65
+ Without `--token`, subsequent commands use that saved session. For automation, set `HOSTINGCTL_API_URL` and
66
+ `HOSTINGCTL_TOKEN` instead. The isolated local acceptance proved real browser consent and refresh-token
67
+ rotation on 2026-10-08.
68
+
69
+ ### Named sessions
70
+
71
+ One credential file holds several sessions. Each is a profile:
72
+
73
+ ```text
74
+ hostingctl login --profile staging --api-url https://api.staging.example.com/api/v1 --client-id <id>
75
+ hostingctl whoami --profile staging # or HOSTINGCTL_PROFILE=staging
76
+ hostingctl auth status # the session a command would use, and its expiry
77
+ hostingctl auth logout --profile staging # revoke at the API, then remove it here
78
+ ```
79
+
80
+ Signing in makes that profile the default; `--profile` or `HOSTINGCTL_PROFILE` selects another. A file in the
81
+ older single-session shape is read as the `default` profile and rewritten in the profile shape on the next
82
+ save, so no one has to migrate by hand. A session is **bound to the target it was issued for**: `--api-url`
83
+ naming a different host is refused before any request rather than spending the token there.
84
+
85
+ ### A machine with no browser
86
+
87
+ `--no-browser` prints the authorize URL, then reads the callback URL pasted on stdin. This is the case where
88
+ the browser runs on another machine and cannot reach this host's `127.0.0.1:8477` listener. Only the exact
89
+ registered redirect URI is accepted back, the state is compared, and neither the pasted URL nor its code is
90
+ ever echoed. The port is fixed because Passport matches redirect URIs exactly; if `8477` is in use the CLI
91
+ says which port to free instead of silently choosing another one.
92
+
93
+ ### The account and impersonation
94
+
95
+ `auth` also covers the account itself: `profile`, `password`, `register`, `forgot-password`,
96
+ `reset-password`, `verify-email`, `closure show|request`, `picture set|remove` and `notifications`
97
+ (including `read <id>` and `read-all`). Every secret comes from `--<name>-file <path>` or `--<name>-stdin`,
98
+ never from an argument, and is never printed. `closure request` re-reads the review and sends its digest, so a
99
+ decision made against a different review is refused rather than confirmed.
100
+
101
+ `auth impersonate start --target <user-uuid> --company <slug> --reason <text> --store-as <profile>` writes the
102
+ impersonation token to **its own profile**. The operator's session is never replaced and is not made non-default;
103
+ act as the target with `--profile <name>`, and `auth impersonate stop` ends it and removes that profile.
104
+
105
+ Repository operators can reuse the approved in-process login without copying a token:
106
+
107
+ ```text
108
+ just deploy-auth https://hosting-staging.haakdev.com OPERATOR_HOME_STAGING dev cli whoami --json
109
+ ```
110
+
111
+ The helper supplies the CLI's documented environment variables, refuses URL/token overrides and
112
+ preserves its exit status. Build the internal artifact with `pnpm --filter @haakco/hostingctl build`
113
+ first. This adds no Infisical dependency to the standalone CLI. The credential still has only its
114
+ own company authority; CLI writes require approval and `--confirm`.
115
+
116
+ `src/live.integration.test.ts` is the real call, gated on the environment:
117
+
118
+ ```text
119
+ HOSTINGCTL_API_URL=http://localhost:8000/api/v1 HOSTINGCTL_TOKEN=<token> \
120
+ pnpm --filter @haakco/hostingctl test
121
+ ```
122
+
123
+ ## Usage
124
+
125
+ ```text
126
+ hostingctl whoami Which account the token belongs to, and what it may do.
127
+ hostingctl whoami --json The same answer using the API's own field names.
128
+ ```
129
+
130
+ Every command takes `--json`, and automation should prefer it: the field names are the API's, never a
131
+ second naming invented here. Human-readable output is the default.
132
+
133
+ Read the private supplier traces for a domain with:
134
+
135
+ ```text
136
+ hostingctl registrar history <domain-uuid> --company acme --json
137
+ hostingctl registrar history <domain-uuid> --company acme --page 2 --json
138
+ hostingctl registrar supersede-transfer <domain-uuid> --company acme --operation <operation-uuid> --accept-terms --dry-run
139
+ ```
140
+
141
+ This requires domain management permission and returns up to 100 entries from the preceding month.
142
+ The API decrypts request and response bodies for this explicit read; the output can contain customer
143
+ contact details and should be handled as sensitive evidence.
144
+ The command prints a `--page` hint when older entries remain.
145
+
146
+ ### The command surface
147
+
148
+ `hostingctl --help` lists every top-level command, and `<command> --help` prints that command's own groups,
149
+ its flags and what each one does. The whole surface, with every flag, is in the
150
+ [command reference](https://hosting.haak.co/docs/cli-reference), which is generated from the CLI's own
151
+ metadata and compared against it in CI. The groups, in one line each:
152
+
153
+ | Command | Covers |
154
+ | --- | --- |
155
+ | `whoami`, `environments`, `system`, `diagnostics` | The session's identity and reach, every environment in a company, what the cluster still holds, and the platform's health answer |
156
+ | `company` | The company itself, its members and roles, its activity, its logo and favicon, and the invitation journey |
157
+ | `environment` | Create, read, scale, retry; profile coverage, domains and redirects, databases and exports, configuration and secrets, services, site access, shared runtime and application, tools, tuning, retention, portable export, and the release bundle |
158
+ | `application` | Create, rename, remove; build and deploy, build logs, runs and run logs, process schedules; recovery review and confirm; the HTTP cache and path routes; source binding; site access; the WordPress import and URL repair; the administrator credential |
159
+ | `access` | Who may reach one environment: settings, grants, SSH keys, the shared password, and the per-user view |
160
+ | `site-user` | The accounts that sign in to a company's sites |
161
+ | `keys` | The account's personal SSH keys and where each is granted |
162
+ | `email` | The mail service, its names, mailboxes, aliases, sending holds, send-as grants, IMAP sync (including CSV), and the platform's deliverability monitoring |
163
+ | `mailbox` | Mail-client setup, the fallback recovery address and reset links for one mailbox |
164
+ | `dns` | A managed name's zone, its zone-file export, records, signing, handover, and the reviewed template import |
165
+ | `domain`, `registrar` | The names a company holds, their proofs and releases; the registrar's state, price, consent writes and supplier traces |
166
+ | `cancellation`, `deletion-lock`, `backups`, `logs`, `services` | Cancelling an environment, arming its deletion lock, its backup tracks, its retained history, and its service inventory |
167
+ | `realm`, `repository` | One environment's deployment realm; the GitHub connections an application deploys from |
168
+ | `platform` | Platform-only work: domain claims and purge attestations, the client directory, company and account closure, alerts, the GitHub App, Yahoo feedback, and the prepared-content catalogue |
169
+ | `login`, `auth` | Signing in and out; the session's own reads, the account's settings, push tokens and impersonation |
170
+
171
+ Every write needs `--confirm`; `--dry-run` describes the change and sends nothing.
172
+
173
+ Options: `--api-url` (or `HOSTINGCTL_API_URL`) — the API base **including `/api/v1`**, which is
174
+ also the frontend's `VITE_API_URL` default — `--token` (or `HOSTINGCTL_TOKEN`), `--json`, `--help`.
175
+
176
+ Exit status: **0** ok, **1** refused by the API, **2** unusable — bad usage, no token, or an unreachable
177
+ API. A refusal prints the API's message, its actionable `detail` when it sent one, and its `X-Request-Id`,
178
+ which is what joins a failed command to the log line that explains it.
179
+
180
+ ## Access users
181
+
182
+ Create a native login using an existing public SSH key file, not a private key or a password on argv:
183
+
184
+ ```text
185
+ hostingctl access user add <uuid> --company acme --username acme_accept \
186
+ --name "Acme Accept" --public-key-file ~/.ssh/id_ed25519.pub --dry-run
187
+ hostingctl access user add <uuid> --company acme --username acme_accept \
188
+ --name "Acme Accept" --public-key-file ~/.ssh/id_ed25519.pub --confirm
189
+ hostingctl access users <uuid> --company acme --json
190
+ hostingctl access user remove <uuid> --company acme --access-user <user-uuid> --confirm
191
+ ```
192
+
193
+ Use the native user's `uuid` from the listing. Usernames are 3–32 lowercase letters, digits or underscores,
194
+ starting with a letter. `--key-name` overrides the key label, which defaults to `--name`. Missing, empty,
195
+ oversized and private-key files are refused before any request. Writes require `--confirm`;
196
+ `--dry-run` describes the change without sending it.
197
+
198
+ ## Mailbox declaration import
199
+
200
+ `hostingctl mailbox import preview <email-domain-uuid> --company <slug> --format <csv|yaml> --file <path>`
201
+ reviews a bounded 64 KiB declaration through the company-authorized API. Replace `preview` with
202
+ `confirm` and add `--confirm` to create missing mailboxes; `--dry-run` sends no request. Confirmation
203
+ re-previews the same captured contents and submits its review token. Matching active mailboxes are
204
+ unchanged; conflicts refuse the batch, and accepted creation is not native readiness.
205
+
206
+ See the [CSV/YAML examples and versioned schema](../frontend/docs-site/docs/email.md#create-several-hosting-mailboxes).
207
+ Passwords, message copying and DNS changes are not part of this declaration.
208
+
209
+ ## Mailbox setup and recovery
210
+
211
+ Read one mailbox's mail-client settings, hand them over, manage its fallback recovery address, and issue
212
+ reset links. These are the same `email.view`/`email.manage` operations the panel uses.
213
+
214
+ ```text
215
+ hostingctl mailbox setup <email-domain-uuid> <mailbox-uuid> --company acme
216
+ hostingctl mailbox instructions <email-domain-uuid> <mailbox-uuid> --company acme \
217
+ --recipient user@example.com --confirm
218
+ hostingctl mailbox fallback set <email-domain-uuid> <mailbox-uuid> --company acme \
219
+ --address backup@example.com --dry-run
220
+ hostingctl mailbox fallback remove <email-domain-uuid> <mailbox-uuid> --company acme --confirm
221
+ hostingctl mailbox recovery-link email <email-domain-uuid> <mailbox-uuid> --company acme \
222
+ --destination other@example.com --confirm
223
+ hostingctl mailbox recovery-link copy <email-domain-uuid> <mailbox-uuid> --company acme --confirm
224
+ ```
225
+
226
+ `setup` prints the mailbox address and username, the incoming IMAP and outgoing SMTP host, port and
227
+ security, the authentication requirement and the webmail URL — the deployment's named
228
+ `hosting.email.client_setup` values (`mail.hosting.haak.co` IMAP `993` TLS, SMTP `465` TLS, webmail
229
+ `webmail.hosting.haak.co/webmail`), never guessed from your domain or its MX records. Any part the
230
+ deployment left unconfigured prints `Unavailable`.
231
+
232
+ `fallback set` stores an address that stays **unconfirmed** until its verification link is completed;
233
+ `fallback remove`, and replacing it, revoke outstanding verification and recovery links. `instructions`
234
+ and `recovery-link email` go to `--recipient`/`--destination`, or the stored fallback when omitted, and
235
+ report the message as **queued**, not delivered. `recovery-link copy` prints the reset URL **once** with
236
+ its expiry and a sharing warning; anyone holding it can reset the mailbox password, and issuing any link
237
+ never changes the password. Every write needs `--confirm`; `--dry-run` describes the change without
238
+ sending it.
239
+
240
+ A mailbox owner recovering access needs no Hosting account:
241
+
242
+ ```text
243
+ hostingctl mailbox recovery request --address user@example.com --confirm
244
+ hostingctl mailbox recovery verify --token-file ~/token --confirm
245
+ hostingctl mailbox recovery reset --token-file ~/token --password-file ~/password --confirm
246
+ ```
247
+
248
+ These three commands are the only ones that run **without a token or session**; point them at the API
249
+ with `--api-url` or `HOSTINGCTL_API_URL`. The recovery token and the new password are read from a
250
+ `--<name>-file` (a regular file no wider than mode 600) or `--<name>-stdin`, never from an argument, and
251
+ are never printed. `verify` confirms a fallback address; `reset` sets a new confirmed password and
252
+ reports that the change is recorded and applied in the background. A password reset does not change your
253
+ mailbox's separate application passwords or your active sessions.
254
+
255
+ ## Platform domain decisions
256
+
257
+ System administrators confirm ownership of a name and move it between clients. These commands are
258
+ platform-only: a company-scoped or impersonated token is refused by the API, and the CLI reports that
259
+ refusal rather than working around it. Each acts on a domain claim by its own `uuid`.
260
+
261
+ ```text
262
+ hostingctl platform domain show <claim-uuid> --json
263
+ hostingctl platform domain attest <claim-uuid> --reason "the previous holder confirmed the move" --dry-run
264
+ hostingctl platform domain attest <claim-uuid> --reason "the previous holder confirmed the move" --confirm
265
+ hostingctl platform domain revoke <claim-uuid> --reason "the name was taken back" --confirm
266
+ hostingctl platform domain transfer <claim-uuid> --company beta --reason "client request" --confirm
267
+ ```
268
+
269
+ `show` reports the live ownership decision and the **recorded** routing observation separately, so neither
270
+ reads as the other. A transfer answers `pending` while the previous holder's routing withdrawal is only
271
+ asked for, and `completed` once the move is observed; the command prints which, and re-running `show`
272
+ watches it settle. Every decision needs a `--reason`; the three writes need `--confirm`, and `--dry-run`
273
+ describes the change without sending it. `--company` on `transfer` is the client the name moves to.
274
+
275
+ ## Deployment realm lifecycle
276
+
277
+ Observe one environment's recorded deployment realm, adopt it, retry a failed convergence and record a
278
+ workload's audience name. This is the operator path for the environment's CustD realm boundary.
279
+
280
+ ```text
281
+ hostingctl realm show <environment-uuid> --company acme
282
+ hostingctl realm adopt <environment-uuid> --custd-company <uuid> --owning-user <uuid> \
283
+ --registration-policy <invite_only|open> --expected-revision <n> [--login-paused] \
284
+ [--audiences <path>] --dry-run
285
+ hostingctl realm retry <environment-uuid> --company acme --confirm
286
+ hostingctl realm audience <environment-uuid> <workload-uuid> --company acme --audience acme --confirm
287
+ ```
288
+
289
+ `show` prints what Hosting has recorded for that environment's realm — its state, expected and observed
290
+ revisions, whether logins are paused and whether the routes are held — read from the deployment identity the
291
+ access view already carries, so no new endpoint is added for it. **Recorded convergence is not provider
292
+ health, a working sign-in or a released route:** `ready` means the recorded intent converged, and a route
293
+ that is not held is not thereby proven live. `--json` carries the API's own deployment-identity object.
294
+
295
+ `adopt` records the environment's realm allocation intent, and is **platform-addressed**: the staff actor who
296
+ may attest a CustD company's authority belongs to no company, so the token needs the platform permission for
297
+ it. The authority is existing tenant identity only — the CustD company (`--custd-company`) and its real
298
+ owning user (`--owning-user`) — and no provider project, environment or directory identifier is ever an
299
+ input. `--registration-policy` is `invite_only` or `open`; `--expected-revision` is the revision the caller
300
+ reviewed; `--login-paused` records paused logins; and `--audiences` names a JSON file of audience
301
+ registrations in the API's own shape
302
+ (`[{"audience":"acme","publicClient":true,"redirectUris":["https://acme.test/callback"]}]`), which the API
303
+ validates rather than this CLI inventing registration settings. An omitted file means no recorded audience,
304
+ and the API refuses a list that does not match the environment's recorded workload audiences.
305
+
306
+ `retry` asks the API to reconcile the realm from its recorded intent: the accepted answer records the retry,
307
+ and reconciliation observes the result. `audience` records one application workload's realm audience name
308
+ (`--audience`), which the adopted intent then has to match. All three writes need `--confirm`; `--dry-run`
309
+ describes the change and sends nothing.
310
+
311
+ ## Email service reads
312
+
313
+ Read the company's business-email service and the names, mailboxes and aliases attached to it. These
314
+ are the same `email.view` reads the panel uses; the CLI changes nothing here.
315
+
316
+ ```text
317
+ hostingctl email show --company acme
318
+ hostingctl email names --company acme --json
319
+ hostingctl email mailboxes <email-domain-uuid> --company acme
320
+ hostingctl email mailbox <email-domain-uuid> <mailbox-uuid> --company acme
321
+ hostingctl email aliases <email-domain-uuid> --company acme
322
+ ```
323
+
324
+ `show` reports the service state, its entitlement and whether enrollment is available. `names` reports
325
+ each attached name with the readiness checks the platform observed for it, kept apart from the desired
326
+ status: an attached name is not a name whose mail is proven. `mailboxes` and `aliases` follow every
327
+ page the API reports rather than truncating at the first, and `mailbox` reads one record independently
328
+ of index pagination.
329
+
330
+ ## Mailbox import from CSV or YAML
331
+
332
+ Create many mailboxes at once on one attached name from a `localPart,quota` CSV or a versioned YAML
333
+ document. Uploading and previewing writes nothing; the confirmed import is `email.manage`, the same
334
+ capability as creating one mailbox by hand.
335
+
336
+ ```text
337
+ hostingctl email mailbox-import preview <email-domain-uuid> --company acme --file mailboxes.csv
338
+ hostingctl email mailbox-import confirm <email-domain-uuid> --company acme --file mailboxes.yaml --dry-run
339
+ hostingctl email mailbox-import confirm <email-domain-uuid> --company acme --file mailboxes.yaml --confirm
340
+ ```
341
+
342
+ The `--file` is a `localPart,quota` CSV (`.csv`) or a versioned YAML document (`.yaml`/`.yml`), with the
343
+ same declarations in either format; the format is read from the file's extension and the file is read
344
+ from disk and never printed. `preview` classifies every declaration against the name and reports what
345
+ it would create, what already matches and what is refused, without writing anything. `confirm`
346
+ re-runs that review to obtain the digest the API requires, then creates the reviewed declarations. It
347
+ needs `--confirm`; `--dry-run` describes the batch without sending it. An import is create-only: a
348
+ repeat of matching declarations is a no-op, a conflicting declaration is a refusal, and a refused
349
+ batch creates nothing. Creating a mailbox does not deliver its password; each new mailbox receives its
350
+ credential through its own owner. Imports never configure source accounts, start a sync or change DNS.
351
+
352
+ ## Portable site pattern
353
+
354
+ Export one authorized environment's supported setup as YAML, then preview and import it into another
355
+ environment you are authorized for. The pattern is portable setup intent with logical references, not
356
+ a clone: no secret value, source-company identifier, observed readiness, message or file leaves the
357
+ platform.
358
+
359
+ ```text
360
+ hostingctl environment portable export <environment-uuid> --company acme
361
+ hostingctl environment portable export <environment-uuid> --company acme --selection selection.json --out acme.yaml
362
+ hostingctl environment portable preview <environment-uuid> --company acme --file acme.yaml
363
+ hostingctl environment portable preview <environment-uuid> --company acme --file acme.yaml --bindings bindings.json
364
+ hostingctl environment portable prepare <environment-uuid> --company acme --file acme.yaml --bindings bindings.json --dry-run
365
+ hostingctl environment portable prepare <environment-uuid> --company acme --file acme.yaml --bindings bindings.json --confirm
366
+ hostingctl environment portable confirm <environment-uuid> --company acme --file acme.yaml --bindings bindings.json --confirm
367
+ ```
368
+
369
+ `export` prints the pattern as YAML on stdout (pipe it, or use `--out <path>`). `--selection` is a JSON
370
+ file naming the sections (`hosting`, `domains`, `dns`, `email`, `access`, `sources`) and the linked names
371
+ to include; the API's own defaults apply when it is omitted, and any section it did not include is
372
+ reported on stderr so the document on stdout stays byte-identical.
373
+
374
+ `preview` classifies every declaration against the target and writes nothing; it reports each section
375
+ as create, reuse, conflict, unresolved, refused or unsupported, the declared secret names, the ones
376
+ still unset and any blockers. `--file` is the pattern and `--bindings` is a JSON file whose `bindings`
377
+ map the pattern's logical references to authorized target resources (never a name alone) and whose
378
+ `consents` grant the consequential operations a selected section needs. Neither file carries a secret
379
+ value.
380
+
381
+ The two files are small and explicit. A selection names what leaves the source:
382
+
383
+ ```json
384
+ {
385
+ "sections": ["hosting", "domains", "dns", "email", "access", "sources"],
386
+ "domains": ["shop.example.com"],
387
+ "emailDomains": ["example.com"],
388
+ "dns": ["example.com"],
389
+ "access": true,
390
+ "sources": true
391
+ }
392
+ ```
393
+
394
+ A bindings file maps the pattern's own references to target identifiers and grants the consequential
395
+ operations. `loginKeys` carries the **public** half of a key a declared SFTP/SSH login needs, and a
396
+ consent left `false` keeps its operation refused:
397
+
398
+ ```json
399
+ {
400
+ "bindings": {
401
+ "applications": { "web": "<target-application-uuid>" },
402
+ "repositories": { "web": "<target-repository-selection-uuid>" },
403
+ "domains": { "shop.example.com": "<company-domain-uuid>", "example.com": "<company-domain-uuid>" },
404
+ "emailDomains": { "example.com": "<email-domain-uuid>" },
405
+ "services": { "cache": "<service-uuid>" },
406
+ "siteUsers": { "person@example.com": "<site-user-uuid>" },
407
+ "accessUsers": { "deploy": "<access-user-uuid>" },
408
+ "members": { "person@example.com": "<company-member-user-uuid>" },
409
+ "aliasRecipients": ["external@example.com"],
410
+ "loginKeys": { "deploy": "ssh-ed25519 AAAA... deploy@laptop" }
411
+ },
412
+ "consents": { "serviceAddition": false, "emailInvitations": false, "autoDeploy": false }
413
+ }
414
+ ```
415
+
416
+ A binding always names a target that already exists: an existing workload, the repository selection
417
+ a source builds from (`repositories`), a company-owned website name and the customer DNS zone a
418
+ record set lives in (both `domains`), the attached email name (`emailDomains`), an existing access
419
+ login (`accessUsers`), an existing agency site user (`siteUsers`), the Company member a grant names
420
+ (`members`), and any alias destination the pattern does not itself declare (`aliasRecipients`). A
421
+ declaration that **creates** a resource — a new application, mailbox or login — needs no binding, and
422
+ a name that merely matches a target is never authorization.
423
+
424
+ `prepare` records a reviewed draft without applying resource changes. It may create binding metadata
425
+ and advance the configuration generation; this makes declared secret names writable. Set each unset
426
+ value through the existing write-only secret control, then `preview` and `confirm` again. `confirm`
427
+ re-checks current state, records create/reuse intent and queues normal reconciliation. It does not
428
+ purchase, cut over DNS or remove anything, and its answer is accepted intent rather than a readiness
429
+ claim. `prepare` and `confirm` need `--confirm`; `--dry-run` describes the change without sending it. A
430
+ target that changed since the review is refused by the API rather than applied against state nobody saw.
431
+
432
+ ## Managed DNS and import
433
+
434
+ Inspect a managed name's zone and its standard zone-file export, and review then apply an import. The
435
+ reads use `dns.view`; `dns import` is `dns.manage`.
436
+
437
+ ```text
438
+ hostingctl dns zone <domain-uuid> --company acme
439
+ hostingctl dns export <domain-uuid> --company acme --json
440
+ hostingctl dns import preview <domain-uuid> --company acme --file records.txt
441
+ hostingctl dns import apply <domain-uuid> --company acme --file records.txt --dry-run
442
+ hostingctl dns import apply <domain-uuid> --company acme --file records.txt --confirm
443
+ ```
444
+
445
+ `zone` reports the published record sets, the desired generation and the last generation observed
446
+ applied; `export` prints a heading and then the zone file the API rendered (its `--json` carries the
447
+ file alone in `zoneText` for piping). The import `--file` holds one record set per
448
+ line as `name TTL type value` — the grammar the panel's import box uses — with blank lines and `#`
449
+ comments skipped and repeated `name/type/TTL` lines folded into one set with several values. The file
450
+ is read from disk and never printed.
451
+
452
+ `preview` classifies every submitted set and writes nothing. `apply` re-runs that review to obtain the
453
+ generation and review token the API requires, then records the reviewed sets and queues the apply
454
+ pass, so an apply is desired state rather than an observed completion. It needs `--confirm`; `--dry-run`
455
+ describes the change without sending it. A zone that changed since the review is refused by the API
456
+ rather than applied against a state nobody saw. There is no `dnssec` read: DNSSEC is a write-only route
457
+ in the API, and the observed mode is part of `dns zone`.
458
+
459
+ ## Creating an environment
460
+
461
+ Create an environment from a package the platform sells. The CLI reads the package catalog from the API,
462
+ so it offers exactly the packages the platform offers, and it never names a cluster: placement belongs to
463
+ the platform and the create request carries no cluster field.
464
+
465
+ ```text
466
+ hostingctl environment create --company acme --name "Acme Shop" --dry-run
467
+ hostingctl environment create --company acme --name "Acme Shop" --slug acme-shop --package small_sites --confirm
468
+ ```
469
+
470
+ `--name` is the environment's display name. `--slug` is the name it answers on: omitted, the CLI derives one
471
+ from the name (lowercase, each run of other characters folded to a single hyphen, trimmed, capped at 63
472
+ characters), and a slug shorter than three characters or outside lowercase letters, digits and single
473
+ hyphens that start and end with a letter or digit is refused before any request. `--package` names one of the
474
+ catalog's keys and defaults to its first; a key the catalog does not offer is refused before anything is
475
+ sent.
476
+
477
+ The write needs `--confirm`; `--dry-run` describes it, reads the catalog so the package it names is the one
478
+ the API sells, and sends nothing. The accepted answer is recorded desired state rather than a ready
479
+ environment: reconciliation applies it afterwards, and `--json` carries the API's own created environment
480
+ object.
481
+
482
+ ## Environment databases
483
+
484
+ Read an environment's own SQL identities, declare one, replace who may consume it, and retire one while
485
+ keeping its data and its backups. These are the same environment-owned databases the configuration review
486
+ shows; no password is read and no credential is issued here.
487
+
488
+ ```text
489
+ hostingctl environment databases <environment-uuid> --company acme
490
+ hostingctl environment database create <environment-uuid> --company acme \
491
+ --key shop --engine mariadb --dry-run
492
+ hostingctl environment database bind <environment-uuid> <database-uuid> --company acme \
493
+ --bind <workload-uuid>[,<workload-uuid>] --confirm
494
+ hostingctl environment database bind <environment-uuid> <database-uuid> --company acme --clear-bindings --confirm
495
+ hostingctl environment database review <environment-uuid> <database-uuid> --company acme
496
+ hostingctl environment database retire <environment-uuid> <database-uuid> --company acme --dry-run
497
+ hostingctl environment database retire <environment-uuid> <database-uuid> --company acme \
498
+ --digest <review-digest> --generation <review-generation> --confirm
499
+ ```
500
+
501
+ `databases` lists each identity with its engine, its configuration key, the applications allowed to consume
502
+ it and any recorded retirement, and carries the API's `canManage` rather than assuming the account may
503
+ write. `create` declares one independent, environment-owned database: `--key` is the stable declaration key
504
+ and `--engine` is `mariadb`, `postgres` or `mysql`; `--bind` grants it to consuming workloads as it is made,
505
+ and omitting it creates a real zero-consumer database. The API owns the key grammar and refuses a bad one.
506
+
507
+ `bind` replaces the consumer list. `--bind <uuid>[,<uuid>…]` names the new list and `--clear-bindings`
508
+ detaches every application; one is required and they cannot be combined, so an omitted list never clears a
509
+ grant by accident. Detaching stops future credential delivery only — a password an application already holds
510
+ is not revoked — and the shared service refuses to detach a consumer until that workload is stopped and its
511
+ environment generation has reconciled.
512
+
513
+ `review` reads what retiring one identity would do, what it retains, and the digest and generation a confirmation
514
+ carries back. It reports the API's own `blockedReason` when it cannot proceed (a consumer is still attached, a
515
+ retirement is already open, the environment is cancelled). Copy the digest and generation from that review to
516
+ `retire`; the CLI sends those exact values, and the API refuses them if the inventory or generation changed.
517
+ Recording the request is **accepted work, not a
518
+ completed retirement**: it reports `pending` (or `failed`, which can be reviewed and requested again) until
519
+ a reconcile has observed the SQL server refusing new logins. New logins then stop working; existing SQL
520
+ sessions are **not** terminated and the applications using the database are neither stopped nor unbound. The
521
+ database, every row, the database name, the SQL user name, the credential version and the backup track are
522
+ retained — there is no delete, and no command here removes a database or its data.
523
+
524
+ All three writes need `--confirm`. `--dry-run` sends no mutation: `create` and `bind` still read the
525
+ environment's current metadata so a stale change is refused rather than applied, while `retire` sends no
526
+ request at all — it does not even read the review.
527
+
528
+ ## Environment configuration and secrets
529
+
530
+ Read an environment's desired state, review a YAML document against it, record a draft or apply it, and set
531
+ the declared secret values it needs. These are the same routes the panel uses; no value is ever printed.
532
+
533
+ ```text
534
+ hostingctl environment configuration show <environment-uuid> --company acme
535
+ hostingctl environment configuration preview <environment-uuid> --company acme --file acme.yaml
536
+ hostingctl environment configuration save <environment-uuid> --company acme --file acme.yaml --confirm
537
+ hostingctl environment configuration apply <environment-uuid> --company acme --file acme.yaml --confirm
538
+ hostingctl environment configuration apply <environment-uuid> --company acme --file acme.yaml \
539
+ --application-uuid <existing-workload-uuid> --confirm
540
+ hostingctl environment secret write <environment-uuid> --company acme \
541
+ --key app_key --value-source file --value-file ~/app-key --confirm
542
+ hostingctl environment secret write <environment-uuid> --company acme \
543
+ --key app_key --value-source generate --confirm
544
+ hostingctl environment secret import <environment-uuid> --company acme --file secrets.env --confirm
545
+ hostingctl environment secret local <environment-uuid> --company acme --out .env.local
546
+ ```
547
+
548
+ `show` names the current, active and known-good revisions with each declared secret and whether it holds a
549
+ value. `preview` classifies the document and writes nothing; `--apply` reviews it as an apply so the verdict
550
+ is the one an apply relies on. `save` records a **draft** revision (not applied); `apply` records it and queues
551
+ normal reconciliation. Both re-run the review to obtain the hash and generation the API requires, so a
552
+ document that changed since review is refused rather than applied against state nobody saw.
553
+ For a document with one application, `--application-uuid` explicitly adopts an existing workload in that
554
+ environment. The same UUID is sent in preview and save so the reviewed hash covers the intended adoption.
555
+
556
+ A secret value is never taken from an argument. `--value-source file|stdin` reads it from a private file
557
+ (mode 600) or `--value-stdin`. `--value-source generate` is only for the declared secret a Laravel
558
+ application binds to its `APP_KEY` environment variable — the fixture's `app_key`, or a member-scoped
559
+ `<memberKey>_app_key` — 32 random bytes, base64-encoded and `base64:` prefixed. The secret's own name never
560
+ decides this: the binding in the reviewed document does, so generation refuses a declared secret that is
561
+ not bound to `APP_KEY`. It also refuses to replace a value already in use unless `--replace` says to rotate
562
+ it, because rotating it invalidates data encrypted with the old key. `import` sets several declared secrets
563
+ from a `KEY=VALUE` file (mode 600) and is how an existing key is brought over unchanged. A value is read only
564
+ when the change is sent, so a dry run reads nothing, and no value appears in output, logs or a dry-run
565
+ description. Every write needs `--confirm`; `--dry-run` describes the change and sends nothing.
566
+
567
+ `local` is the opposite direction and the one to use for a development machine. It reads nothing from the
568
+ platform's secret store: it writes each declared secret's own **local development value** — the `local` field
569
+ in the document, a harmless default such as `password` — into an env file, and names the declared secrets
570
+ that have none so you supply those yourself rather than discovering a blank variable at runtime. Because a
571
+ stored value never appears in the document, there is no path by which a production credential can reach that
572
+ file. It writes to `--out` (mode 600, since you may add a real value to the same file) or to stdout, and it
573
+ needs no `--confirm` because it changes nothing on the platform.
574
+
575
+ ## Environment services
576
+
577
+ Read one environment's whole service inventory with the generation it was observed at:
578
+
579
+ ```text
580
+ hostingctl services <environment-uuid> --company acme
581
+ hostingctl services <environment-uuid> --company acme --json
582
+ ```
583
+
584
+ The header names the desired and applied generation and the observed status in words — `nothing observed yet`
585
+ when the environment has never been observed, and `ready` with `applied M of N (change outstanding)` when
586
+ the live generation is behind the desired one. Each service follows with whether it is installed and
587
+ running, its replicas, its reserved allocation when it has one, and its consumers.
588
+
589
+ ## Applications
590
+
591
+ One application's build, deployment, process runs, rename and removal:
592
+
593
+ ```text
594
+ hostingctl application list <environment-uuid> --company acme
595
+ hostingctl application show <environment-uuid> <workload-uuid> --company acme
596
+ hostingctl application deployment <environment-uuid> <workload-uuid> --company acme
597
+ hostingctl application build <environment-uuid> <workload-uuid> --company acme --source <selection-uuid> --confirm
598
+ hostingctl application deploy <environment-uuid> <workload-uuid> <release-uuid> --company acme --confirm
599
+ hostingctl application runs <environment-uuid> <workload-uuid> --company acme
600
+ hostingctl application run-log <environment-uuid> <workload-uuid> <run-name> --company acme
601
+ hostingctl application schedule-run <environment-uuid> <workload-uuid> <schedule-key> --company acme --confirm
602
+ hostingctl application runtime <environment-uuid> <workload-uuid> --company acme --mode stop --confirm
603
+ hostingctl application update <environment-uuid> <workload-uuid> --company acme --label "Shop" --confirm
604
+ hostingctl application removal <environment-uuid> <workload-uuid> --company acme
605
+ hostingctl application remove <environment-uuid> <workload-uuid> --company acme --confirm
606
+ ```
607
+
608
+ `deployment` reports the running, requested and interrupted builds and releases, the bound source and whether
609
+ a push would deploy. `build` queues a build of the application's applied configuration from `--source` (or the
610
+ bound selection), resolved to an exact commit by the source service rather than named here. `deploy` makes an
611
+ existing release the one the application runs. `runs` and `run-log` read the process run history and one run's
612
+ own output, which may report `unavailable` when the cluster cannot answer rather than an empty body.
613
+ `schedule-run` runs a **declared** schedule once, now, beside the schedule's own runs; it does not execute an
614
+ arbitrary command, for which the API has no route. `update` renames the label only, never the immutable name.
615
+ `runtime` records one application's desired runtime from `--mode stop|start|restart`: it reads the
616
+ environment's current generation and sends `running: false` for stop or `running: true` for start and
617
+ restart. Starting or restarting records a fresh runtime revision even if the application is already running,
618
+ and stopping is recorded as desired intent that reconciliation observes afterwards — the accepted answer is
619
+ not an observed stopped state. `--dry-run` makes no request at all, and a moved generation is the API's own
620
+ conflict rather than a silent refetch.
621
+
622
+ `removal` reviews what removing one application would affect; `remove` re-reads that review and sends its own
623
+ `inventoryDigest`, so a confirmation approves an exact inventory rather than a label and the environment
624
+ generation makes a stale review a conflict. Removal withdraws the runtime through normal reconciliation and
625
+ deletes no retained data; retiring an environment-owned database's data is a separate decision. Every write
626
+ needs `--confirm`; `--dry-run` describes the change and sends nothing.
627
+
628
+ ## Retained log history
629
+
630
+ Read the platform's bounded history for one of your resources. This is retained, sanitized operational
631
+ history, not the live container tail:
632
+
633
+ ```text
634
+ hostingctl logs environment <environment-uuid> --company acme
635
+ hostingctl logs dns <domain-uuid> --company acme --json
636
+ hostingctl logs mail <email-domain-uuid> --company acme
637
+ hostingctl logs environment <environment-uuid> --company acme \
638
+ --start 2026-10-14T11:00:00Z --end 2026-10-14T12:00:00Z --limit 100
639
+ ```
640
+
641
+ `environment`, `dns` and `mail` take the environment, DNS domain and mail domain uuid. The API bounds
642
+ every read: at most a six-hour window within the last 24 hours, defaulting to the most recent hour, and
643
+ 1–200 entries, defaulting to 100. `--start` and `--end` are ISO instants and `--limit` is a whole number;
644
+ the server enforces the window and the CLI refuses a malformed `--limit` before asking.
645
+
646
+ A `source` of `unavailable` means the platform could not read the retained history — no configured
647
+ reader, an active ownership interval that cannot be proven, or a boundary failure for an authorized
648
+ resource. An out-of-company UUID is refused before reading history. Unavailable is not the empty
649
+ result of a successful read that matched nothing; the human output distinguishes these states. The
650
+ environment family reads dedicated web-container output; DNS reports zone-transfer initiation, not full
651
+ query history; and mail reports a Postfix delivery attempt, which is transport acceptance rather than
652
+ proof of mailbox receipt.
653
+
654
+ ## What it never does
655
+
656
+ No cluster or database access, ever. Those credentials belong to the platform, and a client's laptop is not
657
+ where they live. Any rule the CLI appears to need — what a complete resource profile is, which statuses
658
+ exist, who may see an environment — comes from the API's own answer, because a CLI that re-derives platform
659
+ rules becomes a second owner of them.