feat: add resource commands to the CLI - #1210
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
🦋 Changeset detectedLatest commit: d400e72 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
mridang
force-pushed
the
feat/cli-resource-commands
branch
from
September 12, 2026 00:56
b42d9be to
b45d654
Compare
mridang
force-pushed
the
feat/cli-resource-commands
branch
from
September 12, 2026 01:05
b45d654 to
8f21a69
Compare
mridang
force-pushed
the
feat/cli-resource-commands
branch
from
September 12, 2026 01:11
8f21a69 to
bc39c60
Compare
mridang
force-pushed
the
feat/cli-resource-commands
branch
from
September 12, 2026 01:56
bc39c60 to
2d2488b
Compare
mridang
force-pushed
the
feat/cli-resource-commands
branch
from
September 12, 2026 01:58
2d2488b to
4749c1d
Compare
mridang
force-pushed
the
feat/cli-resource-commands
branch
from
September 12, 2026 02:13
4749c1d to
3c347e8
Compare
vitorbari
approved these changes
Sep 21, 2026
| // The revision routes are not modelled here — the CLI has no grammar for a | ||
| // sub-resource listing yet, so `idps revisions` is deliberately absent. | ||
| idps: { | ||
| group: CommandGroups.resources, |
Contributor
There was a problem hiding this comment.
IDPs are in between Resources and Configuration. At the moment, they are configured as code under .zitadel, but for b2b cases, they will probably also be managed in runtime. For now, I lean towards CommandGroups.configuration.
Add list/get/create/update/delete verbs for users, teams, sessions, events, grants, and projects, built from one registry by a generic oclif factory. Switch command discovery to the explicit strategy.
Adds the command grammar, flag conventions, discovery, output and safety rules.
…tory Also corrects ADR 061 on how credential fields are detected.
Adds read-only commands for the configuration resources and declares paging, draining and the id argument per resource.
Reads the generated client and records what the registry calls, so an unexposed operation must carry a written reason.
Cursor property names and the query body shape move behind an overridable default, so the factory carries no platform's spelling.
Sessions used a revoke verb while teams kept delete and reported the outcome. The verb is now always delete; the outcome stays in the result.
ADR 061 is taken by the egress policy ADR, merged in #1207.
#1121 removed update and delete; the coverage guard caught the stale entries.
…ulary Addresses the review on the ADR PR: the envelope carries cli_version, command and source; six lists read through GET, not three; deploy replaces plan/apply; and the environments naming conflict with ADR 035 is recorded.
delete means gone; a session is revoked and a team deactivated, following gh's rule that a lifecycle change gets its own verb.
No sentence now runs past 35 words and none stacks dashes. Command registration moves to the design doc.
A filter field can declare its default, so schemas list shows the current revisions. Resolution by object type or flow name lives in the registry.
#1249 requires every command to declare a group. Configuration resources join the configuration group; runtime resources get their own.
#1246 added revisions to GET /flow_definitions, so flows match schemas.
The coverage test caught idps (#1217), which has a query endpoint and so cannot be excluded. Variables are configuration and carry a reason.
They are a management resource, not file-authored. The reason is the shape: no id, a map-keyed list and a merge write.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1221. Partially addresses #537 (the
releases listhalf).Implements ADR 064, which landed separately in #1222 along with the design doc, so this PR now carries the code, tests and changeset only.
Summary
zitadel <resource> <verb>surface for the runtime resources the API already exposes:users,teams,sessions,events,grants, andprojectsgainlist,get,create,update, anddelete(deleteeverywhere). Configuration resources —schemas,environments,releases,flow-definitions,branding— gainlistandgetonly: they are authored as files and shipped through a release (ADR 007, ADR 035), so a write verb here would be a second writer over the same state, while reading what the server currently holds is how you check that a deploy landed.--filter field=operation:valueworks the same whether the endpoint takes a structured query body (POST /users/query) or flat query parameters (GET /events), and the registry declares per field which operations it accepts, its closed values, whether repeats widen, and which parameter each operation travels as — soeventstakes--filter created_at=greater_than_or_equal:…even though the endpoint spells that ascreated_after. This abstracts the transport, never the capability: an operation the endpoint cannot honour is refused locally naming the field, so nothing--helpoffers can fail at the server.GET /branding) simply has no paging flags instead of advertising ones the server ignores, and a list whose partial answer reads as a complete one (schemas, a revision history) drains on a bare invocation while--limitor--page-tokenstill return one page. The positional argument is named after what the resource is addressed by, so it iszitadel environments get <name>, not a fictional id.createandupdatetake the body two ways. Each field of the generated request schema becomes its own flag (--name,--principal-type, with closed value sets enforced at parse time and required fields marked in--help), and a schema-defined open record such as a user's attributes becomes a repeatable--attributes, wherekey=valueis a string andkey:=valueparses the value as JSON so a field the user schema types as a number, boolean, array, or object keeps that type (--attributes age:=42,--attributes postcode=02139). The whole body can still be passed as--data '<json>'or--file <path>, and a field flag overrides the same key in it. A required field missing from both is reported by name (grants create is missing required fields: --principal-type, --relation) rather than as a schema issue.apps/cli/src/lib/oclif/crud/turns a registry entry into oclif command classes: oneResourceCommandbase owns parse → meta → execute → emit, and each verb is a*Operationclass with a staticdescribe()for its oclif statics and anexecute()for its work. Pure helpers (paging,table,body,query) are split out and unit-tested on their own. The Zitadel registry inapps/cli/src/commands/resources.tssupplies the client calls, the project-secret connector, and the environment flag; adding a backend resource is one registry entry.zitadel resourcesreports the whole surface in one call — every resource, its verbs, its filter and sort fields, and the body fields of its writes (flag, kind, required, allowed values). It is a projection of the registry, contacts no server, and saves an agent eight--helpinvocations.stripe resourcesandkubectl api-resourcesplay the same role.--forceis no longer a global flag. What it permits differs per command — overwrite a managed file onsetup, delete a resource onteams delete— so each command that honours it now declares its own with wording that says what it does (Delete the team without the confirmation prompt. Required when non-interactive.). Commands that never honoured it no longer advertise it.listtakes--fields id,attributes.emailto choose its columns, dot-paths included, with an unknown path refused and the available ones named;--jsonis unaffected. The default columns forusersnow show the person's identifier rather than the schema URL, which was identical on every row.getrenders one record for a person on a terminal — headed by whatever identifies it, one labelled field per line in the registry's order, omitting fields the record does not carry — and still prints the whole object when piped or with--json, the splitgh viewmakes.--fieldsapplies to it too.--file -with nothing piped in fails immediately instead of waiting on the keyboard, which looked like a hang.dist/commandspattern to the explicit strategy (src/index.tsexports the command table), and the tsdown build becomes a single entry. Existing commands are unchanged.--allto drain,--filter field=op:valuevalidated locally against the generated Zod schema,--forceon destructive verbs,--dry-run, the{ items, count, next_page_token }envelope) are documented indocs/design/cli/resource-commands.mdand summarised inSKILLS.md.Drift detection
The registry is hand-maintained, so an endpoint the API gains is invisible to the CLI until someone adds an entry.
tests/unit/commands/api-coverage.test.tsis what notices. Its source of truth is the generated client, not the OpenAPI document: every operation is an exported function paired with an exported URL builder, so the operations and their collections are read by calling them, and what the CLI uses is read by invoking each registry verb against a recording proxy client. The comparison is therefore between the calls the commands really make and the calls the client really offers.Every operation the client exposes must be either called by the registry or listed with a written reason. Of 65 operations, 32 are called; the other 33 carry reasons — the login and auth protocol (10), configuration writes owned by
plan/apply(6), bootstrap and claim (5), end-user self-service on the user's own credential (4), health probes (3), WebAuthn registration (2), and three genuine gaps (setUserPassword,listUserTeams,listUserPasskeys) recorded as follow-ups rather than left implicit.One part of the classification is not a judgement: a collection with
POST /<collection>/queryis a resource by the API's own definition (ADR 031), so the test requires commands for it and accepts no exclusion — proved by attempting to waveteamsthrough and getting "teams has a query endpoint, so it cannot be listed in NOT_RESOURCES". That covers five of the eleven; the other six list throughGET, which is the deviation recorded as an open question, and until it is resolved a new collection without a query endpoint still needs a person to classify it. Each failure mode was verified by deliberately breaking it.Generic factory
crud/is generic over the platform connection and over the wire vocabulary. The cursor property names and the structured-query body shape were previously hardcoded ADR 027 / ADR 031 conventions sitting inside the platform-agnostic half; they now live incrud/wire.tsas an overridable default. A test drives the whole factory with a deliberately foreign vocabulary —size/cursor/next_cursor, and a query body nesting its filters underwhere— and asserts both what goes out and what comes back, so "platform-agnostic" is demonstrated rather than asserted.Resources and operations
usersteamsgrantssessionsrevokedprojectseventsschemasenvironmentsreleasesflow-definitionsbrandingzitadel resources --jsonreturns this table, plus each resource's filter fields with the operations each accepts, its paging behaviour, and the body fields of its writes, as JSON.Most dashes are operations the API has no endpoint for. The exceptions are deliberate and worth naming:
POST /projectsandPOST /sessionsdo exist, but project creation is unauthenticated bootstrap that mints secrets into.zitadel/secret, which iszitadel setup's job, and a session is minted for an end user by the SDKs and the login flow, with no browser on the other end of a terminal. For the configuration resources every write endpoint is omitted on purpose, becauseplan/applyalready writes them from.zitadel/.The hand-written
schemas listcommand is replaced by the generated one, which is a breaking change for anyone scripting it — see Release notes below.Against the CLI guidelines
The surface was checked against clig.dev. Nine findings applied to these commands; all nine are fixed here. The rest are CLI-wide and left for a follow-up.
-dis conventionally--debug--datano longer claims the short form--plain) gets one tab-separated record per line, no header or footerpassword,client_secret,userToken, …) is refused, pointing at--data/--file/ stdin;passwordlessand similar are unaffected-a, --allis the convention--allgained-a--allshows a spinner while it drains, on a terminal only--fieldspicks the columns, asgh --json <fields>andkubectl -o custom-columnsdogetlays a record out on a terminal and emits the object when piped, asgh viewdoes--file -fails fast when stdin is a terminaldata.next_commandswith a runnable next page — the cursor plus the same--limit,--sortand--filter, since a token is only valid with the query that issued it;--allsuggests nothingDeliberately out of scope, since they change behaviour for
setup,doctorand the rest:-hdoes not work anywhere (only--helpdoes), there is no--quiet,--non-interactiveis not aliased to the conventional--no-input, our-nmeans non-interactive where the convention is dry-run, typo suggestions do not propose a near match, and the shared fetch runtime sets no network timeout. An ADR should record the conventions this surface commits to.Review findings
All eleven review comments are addressed.
--dataaccepted the credentials--attributesrefuses, and the hint recommended--data "$(cat body.json)", which the shell expands into argv--databody is refused too, and every hint and doc now names only--file <path>and--file -zitadel resourcesdescribed one schema for both writes, so update looked as demanding as createcreate_fieldsandupdate_fieldsare reported separatelydeleteddeactivated, sessionsrevoked, the restdeletedget --fieldswas validated only when stdout was a terminal--allcopied every record once per remaining pagecontians:) was silently read as anequalsvalueMath.max, which throws on very large resultsHardening after the review rounds
Three gaps found while re-reading the merged result, beyond what the reviews raised.
--allfollowednext_page_tokenwith no guard, so a server returning a cursor it had already issued would fetch the same page forever behind a spinnerE_VALIDATION, naming the token and the page count--fieldswas checked against the records a page returned, so the same argument failed on a full page and passed on an empty oneattributesis reported as a prefix and accepts any keyzitadel resourcestold agents a resource has adeleteverb but not which property its envelope carries, though teams answerdeactivateddelete_outcomeis reported per resourceValidation
corepack pnpm run testinapps/cli— 123 files, 1280 tests pass. (doctorfails only under parallel load on a machine where something already holds port 8080; it passes in isolation and does so identically on the base commit.)corepack pnpm exec tsc --noEmit -p tsconfig.app.jsoninapps/cli— no errors in the new files (the package'stypechecktask is documented as vacuous, so this was run by hand).corepack pnpm exec oxlinton the new files — 0 warnings.COLUMNS=80 corepack pnpm run readme— README regenerated.CGO_ENABLED=0 go build(sqlite): every one of the 22 commands was run, covering both body modes (field flags and--data/--file, including a field flag overriding--data), the generated--helpforusers createandgrants create, cursor pagination with--limit/--page-token/--all, filters and sorting,--dry-run, the--forceguard, and error mapping. The full transcript is under Sample session below.zitadel resourcesand the per-command--forcehelp were checked after the change. The guideline fixes were re-checked live:cut -f1,3over a piped list,--plain,-a,-drejected,--dataintact, a refusedpassword=attribute, and the same value accepted through--file -.schemas list(draining, and narrowed with--filter object_type=human-user),environments listandenvironments get staging(addressed by name),flow-definitions list, andbranding listreturning the standard envelope from an endpoint that answers with a bare array and has no cursor. One finding, recorded in the ADR rather than worked around:GET /releasesanswered500on a project with no releases, reproduced with plaincurl, soreleases listsurfaces that server error rather than hiding it.Release notes / changeset
.changeset/add-cli-resource-commands.md—@zitadel/climinor: resource commands for the six runtime resources, plus read-onlylist/getfor the five configuration resources.Breaking —
schemas list. The hand-written command is replaced by the generated one, so it now behaves like every other list. Its--jsonenvelope changes from{ object_type, revisions, count }to the standard{ items, count, next_page_token };--object-typebecomes an optional--filter object_type=<value>instead of a required flag; and the interactive revision picker is gone, sinceschemas get <id>reads one revision. A barezitadel schemas liststill returns the complete revision history, because the registry declares that this list drains — the behaviour #947 added deliberately, and the two-page drain test from that PR is carried over.Notes
docs/design/cli/resource-commands.md: bulk delete, typed filter values, and read-only verbs for config resources.users listcannot filter by email today because the query endpoint only acceptscreated_at,id,schema,status,team_id, andlifecycle_owner_team_id; that is a backend ask, not a CLI gap.events listis empty until a project is claimed: the API hides events for unclaimed projects (ADR 049 pre-claim gate). The sample session stands in forzitadel claimby inserting the owning-team grant a claim writes, because the real claim completes in a browser.not_equals,not_contains, andless_than_or_equalreturn 501 on both users and teams, so they pass the CLI's local validation and fail at the server. That is a backend gap. Separately, the CLI's existing error mapping reports every 5xx, including that 501, asE_NETWORK, which is misleading and predates this PR.Scripting with jq
With
--json, stdout carries only the envelope: progress lines are silenced, and errors print an error envelope to stdout. Every command pipes straight intojq. These ran against the same local server:On an error the CLI exits non-zero (3 for validation, 4 for not-found or network) but
jqexits 0, so a plain pipeline hides the failure. Scripts should useset -o pipefail, as in the last example, or branch on.status.Sample session
Every resource command run against a local server (click to expand)
Every command below was run against a local Zitadel server built from this branch (
CGO_ENABLED=0 go build, sqlite,http://localhost:8093). Output is verbatim; only ANSI colours were stripped. Human-mode runs show what a person sees;--jsonruns show the envelope an agent parses.Setup
zitadel setupnormally creates the project and writes.zitadel/secret. To keep the demo framework-free, the project is created with the same API call setup makes, and the secret file is written by hand. Every resource command reads its credential and project id from that file.The project's default human-user schema is
https://nextgen.com/api/schemas/default-human-user.json; user bodies reference it.projects
Projects are created by
setup, so the resource exposes list, get, and update only.users
Create three users, then page through them.
Bodies go in two ways. One flag per schema field, with a repeatable
key=valueflag for the schema-defined attributes:Or the whole body as JSON, which still works for anything the flags cannot express:
--helplists the fields, marks the required ones, and groups them:Listing and pagination
One page by default.
--limitsets the page size;next_page_tokenis non-null while more remain.This transcript is captured through a pipe, so lists appear in their machine form: one tab-separated record per line, no header and no footer. On a terminal the same command prints an aligned table with a row count.
--plainforces the machine form anywhere.--alldrains every page and returnsnext_page_token: null.Choosing columns
--fieldsoverrides the resource's own columns;--jsonis unaffected.Filtering and sorting
Get, update, delete
teams
A team delete deactivates rather than removes the row (ADR 024), so it still reads back:
grants
A grant gives a user or team a role on the project.
A value outside a field's allowed set is rejected at parse time, before any request:
sessions
Sessions are minted by the login flow, not by the CLI, so the resource has no
create. Two are minted here with the same API call the SDK middleware makes.events
Events are read-only. The API hides a project's events until the project is claimed by a team (ADR 049 pre-claim gate), so an unclaimed project always lists nothing:
$ zitadel events list --limit 3zitadel claimcompletes in a browser with a signed-in platform user, which this headless run cannot do. As a stand-in, the demo writes the one row a completed claim writes — an active owning-team grant (relation = 'team') — directly into the local sqlite database:$ sqlite3 zitadel.db "INSERT INTO authz_assignments (… principal_id='team_01M2A9SSRXAXWGY2RGWQ56RH1J', object_type='project', relation='team' …)"Safety rails and errors
Destructive verbs refuse to run unattended without
--force, and say how to retry:--dry-runshows what would happen and makes no request:A credential is refused anywhere on the command line — as a field flag and inside an inline
--databody alike, since argv is visible to other processes:Filters and bodies are validated locally against the API schema, before any request:
A required field left out of both the flags and the body is reported as the flags that are missing:
A field flag overrides the same key in
--data, so a template can be loaded and one value changed:Some operations the API spec advertises are not implemented by the server yet. They pass local validation and the server answers 501; the CLI's existing error mapping reports every 5xx as
E_NETWORK:Server errors map to the CLI's error codes: