Skip to content

feat: add the zitadel claim command - #754

Merged
IAM-marco merged 8 commits into
mainfrom
feat/613-cli-claim-command
Aug 5, 2026
Merged

feat: add the zitadel claim command#754
IAM-marco merged 8 commits into
mainfrom
feat/613-cli-claim-command

Conversation

@IAM-marco

Copy link
Copy Markdown
Contributor

Summary

Closes #613. Part of #96.

  • Adds zitadel claim, which attaches a project to a team so it becomes permanent. Shaped like the device-authorization grant per ADR 046: the CLI mints a challenge with the project secret, hands a URL to a browser, and polls until the human finishes there. The project secret never leaves the machine; the browser only sees the challenge id.
  • Nothing about the project changes. Issuer, users, passkeys, and applications keep working, and the secret is not rotated (an explicit non-goal in ADR 046), so the command only records claimed_at and team_id in .zitadel/secret.
  • The claim link is printed before any browser launch is attempted. That is what lets a headless box, an SSH session, a machine without xdg-open, --no-open, and an agent all take one path with no special case.
  • Already-claimed is a skipped envelope, not an error, whether known locally or learned from a 409, so an agent retry is a clean no-op. An expired link exits E_VALIDATION pointing at a fresh run. --timeout <seconds> bounds the wait below the link's own expiry.
  • Supporting pieces, split into their own commits: an atomic .zitadel/secret writer, a cross-platform browser opener (no new runtime dependency), and a test seam on the api-mock store snapshot.

Validation

moon run api:build
moon run cli:test          # 982 passed (107 files)
moon run api-mock:test     # 60 passed
moon run cli:lint          # 0 errors (24 pre-existing warnings, none from new files)
moon run api-mock:lint     # clean
moon run cli:readme        # regenerated, vocabulary gate passes

Also smoke-tested by hand against a running api-mock: the link box, the timeout error, the success narration writing claimed_at/team_id while leaving project_secret untouched, and the idempotent already-claimed skip.

Release notes / changeset

  • Changeset: .changeset/cli-claim-command.md — minor bump for @zitadel/cli, adding the claim command. (@zitadel/api-mock is private, so its change ships nothing.)

Notes

No real end-to-end run is possible yet. The generated claim operations still answer 501 via api.UnimplementedHandler (that is #611) and the browser page is #615, so packages/api-mock is the only working backend. Journey coverage is #612.

Testing the browser leg. claim/complete is cookie-gated and Express-only, and the mock signs handoff tokens with a per-process RSA key, so the browser leg cannot be driven from outside the mock's process at all. The suite calls the mock's completeClaimChallenge mutator directly — the same function the Express route delegates to. The --server local success case drives a small node:http stub for the same reason.

Two contract details post-date the ticket text, and the implementation follows the contract: claim/status requires ?challenge_id= and 403s if the polling secret is not the one that called init; and the completed status carries no new secret, so the removed pre-#150 command's new_project_secret handling was deliberately not revived.

Unrelated README churn. moon run cli:readme picked up two pieces of staleness in the committed README (doctor --fix and setup --renderer descriptions had drifted from source). They are accurate to current code, but they are not from this change. Generation is pinned to COLUMNS=120, the width the committed file was produced at; anything else re-wraps the whole document.

Flake watch. While verifying I twice saw unrelated timing-sensitive tests fail (stop --all, doctor port-occupied); both shell out to node-based fake binaries with 1s execFile timeouts. Baseline without these changes was 3/3 green, and with them 3/3 solo plus 2/2 under parallel task load, so I believe it was machine load rather than anything here. Worth flagging: this suite adds ~8s of mostly-idle wall time, which makes an already fragile situation marginally more likely to tip.

🤖 Generated with Claude Code

IAM-marco and others added 4 commits August 4, 2026 19:02
A test driving the mock over HTTP never sees the `claim/init` response
body, so it has no way to learn the `challenge_id` that
`completeClaimChallenge` and `expireClaimChallenge` take. Add the ids to
`snapshotPlatformStore()`, matching the `projectIds` / `schemaIds`
convention already there, so the CLI suite can play the browser leg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ields

`.zitadel/secret` has only ever been written once, by setup's patcher.
Claiming needs to update it in place, so add `writeZitadelSecret` and
the optional `claimed_at` / `team_id` the claim records.

The write is atomic (temp file, chmod, rename) because the target holds
credentials: a crash mid-write must not leave a truncated or
world-readable file. Serialisation matches the patcher's original write
exactly, so an update neither churns the formatting nor loosens the
0600 permissions `doctor` asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing in the CLI opened a browser before. Rather than take a runtime
dependency on a published package, resolve the opener directly:
`BROWSER` first, then macOS `open`, Windows `cmd /c start`, the WSL
bridge (`wslview`, else PowerShell), and on Linux and the BSDs the first
of `xdg-open`, `gio open`, `x-www-browser`, `sensible-browser`,
`gnome-open`, `kde-open` that is actually on PATH.

Failing to open is never an error. A machine with no display, no opener
installed, or an SSH session resolves to a result the caller reports
rather than an exception, because the caller shows the URL either way.
X11 forwarding sets DISPLAY, so SSH is excluded explicitly: otherwise a
remote shell would launch a browser on the wrong machine.

Every environment probe goes through an injectable deps bag, so the
platform matrix is testable without touching the real machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attaches a project to a team so it becomes permanent. Shaped like the
device-authorization grant per ADR 046: the CLI mints a challenge with
the project secret, hands a URL to a browser, and polls until the human
finishes there. The project secret never leaves the machine and the
browser only ever sees the challenge id.

Nothing about the project changes. The issuer, users, passkeys, and
applications keep working, and the secret is not rotated, so this is
purely additive: the CLI records `claimed_at` and `team_id` and stops.

The link is printed before any browser launch is attempted, which is
what lets a headless box, an SSH session, a machine without `xdg-open`,
`--no-open`, and an agent all take the same path with no special case.

Already-claimed is a skip, not an error, whether it is known locally or
learned from a 409, so a retry is a clean no-op. An expired link exits
E_VALIDATION pointing at a fresh run; `--timeout` bounds the wait below
the link's own expiry for callers that cannot block for ten minutes.

Tests run against the api-mock, the only working claim backend until the
server handler lands (#611). `claim/complete` is cookie-gated and
Express-only, so the browser leg is played through the mock's own
mutator rather than over HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 17:03
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nextgen Ready Ready Preview Aug 5, 2026 8:36pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
nextgen-docs Skipped Skipped Aug 5, 2026 8:36pm
nextgen-mock-zitadel Skipped Skipped Aug 5, 2026 8:36pm

Request Review

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: cdf7f29

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@zitadel/cli Minor
@zitadel/testing Minor
@zitadel/server Minor
@zitadel/server-linux-x64 Minor
@zitadel/server-linux-arm64 Minor
@zitadel/server-darwin-x64 Minor
@zitadel/server-darwin-arm64 Minor
@zitadel/server-win32-x64 Minor
@zitadel/api Minor
@zitadel/config Minor
@zitadel/components Minor
@zitadel/sdk-core Minor
@zitadel/sdk-next Minor
@zitadel/sdk-nuxt Minor
@zitadel/sdk-react Minor
@zitadel/sdk-vue Minor
@zitadel/sdk-angular Minor
@zitadel/sdk-solid Minor
@zitadel/sdk-svelte Minor
@zitadel/sdk-qwik Minor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new zitadel claim CLI command (plus supporting utilities) to attach an existing project to a team via a browser-based challenge flow, persisting claimed_at/team_id into .zitadel/secret. This extends the CLI’s agent-facing surface area and the api-mock’s claim lifecycle tooling to support unit/integration testing of the flow.

Changes:

  • Introduces apps/cli/src/commands/claim.ts implementing the init → browser link → status polling → local secret write workflow.
  • Adds new CLI utilities and tests: atomic .zitadel/secret writer and cross-platform “open in browser” helper with unit coverage.
  • Extends @zitadel/api-mock store snapshot to expose claim challenge IDs for test orchestration, and updates CLI/docs/changeset surfaces.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/api-mock/src/spec-conformance.spec.ts Adds spec-conformance coverage asserting store snapshot exposes minted claim challenge IDs.
packages/api-mock/src/platform-handlers.ts Extends platform store snapshot type to include claimChallengeIds.
apps/cli/tsdown.config.ts Registers the new commands/claim entry for CLI build output.
apps/cli/tests/unit/lib/project.test.ts Adds unit tests for writeZitadelSecret (atomic write, mode, determinism, key preservation).
apps/cli/tests/unit/lib/browser.test.ts Adds unit tests for cross-platform browser opening logic via injectable deps.
apps/cli/tests/unit/commands/claim.test.ts Adds unit tests for zitadel claim behavior against api-mock + local-server stub paths.
apps/cli/tests/helpers/oclif-build.ts Includes claim in the oclif build/command ID list used by tests.
apps/cli/src/lib/project.ts Adds claimed_at/team_id fields and writeZitadelSecret for atomic secret persistence.
apps/cli/src/lib/browser.ts Introduces openInBrowser helper with platform/WSL/headless detection and opener fallback chain.
apps/cli/src/commands/claim.ts Implements the zitadel claim command: init challenge, print/open link, poll status, write secret.
apps/cli/SKILLS.md Documents the claim command in the agent-facing contract.
apps/cli/README.md Regenerates CLI docs including the new claim command and unrelated regenerated sections.
apps/cli/AGENTS.md Updates CLI telemetry guidance to include the claim command dimensions.
.changeset/cli-claim-command.md Adds a changeset for a minor @zitadel/cli bump introducing zitadel claim.

Comment on lines +76 to +80
const client = createZitadelClient({
baseUrl: this.meta.source,
token: secret.project_secret,
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in e9e1d40 — this was a real bug, not just a contract violation.

The worse half is the inconsistency it could leave behind: --dry-run skipped only the local write, so a developer who followed the printed link and signed in would genuinely claim the project on the platform while the run deliberately declined to record it. The local file and the platform would then disagree, with no obvious way to notice.

claim now returns before initClaim and reports status: "skipped", reason: "dry-run". Nothing is lost by not previewing further: a claim is decided in a browser, not by anything the CLI could compute, so there was no diff to render. This also matches the precedent setup already set, where --dry-run substitutes a stand-in project rather than calling the platform.

Verified by pointing a dry run at an unreachable port — it exits 0 without a network call.

Comment thread apps/cli/src/commands/claim.ts Outdated
Comment on lines +92 to +99
// Trust the server's expiry rather than assuming the TTL: the contract
// documents `expires_at` as the authority and the CLI should not go stale
// if the platform ever retunes it.
const expiresAt = Date.parse(challenge.expires_at);
const deadline = Math.min(
Number.isNaN(expiresAt) ? Number.POSITIVE_INFINITY : expiresAt,
flags.timeout === undefined ? Number.POSITIVE_INFINITY : Date.now() + flags.timeout * 1000,
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in e9e1d40. Trusting the contract is fine for the happy path, but "unbounded wait" is the wrong failure mode for a response the CLI does not control — a proxy or a misrouted request can produce a body that parses as JSON without a usable timestamp.

It now falls back to the 10-minute TTL that ADR 046 documents, so the loop always has a finite deadline. I pulled the computation out into an exported claimDeadline() specifically so the fallback is testable without waiting out a real link; tests/unit/commands/claim-deadline.test.ts covers the fallback, the interaction with --timeout in both directions, and asserts the result is never infinite.

Comment on lines +66 to +75
export async function openInBrowser(
url: string,
overrides: Partial<BrowserDeps> = {},
): Promise<OpenResult> {
const deps: BrowserDeps = { ...defaultDeps(), ...overrides };

const browser = deps.env.BROWSER?.trim();
if (browser === "none") {
return { opened: false, reason: "disabled" };
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, fixed in e9e1d40. The seam was quietly misleading: overriding env looked sufficient but silently did not affect executable resolution.

openInBrowser now resolves env first and builds the default deps around it, so the default lookupPath closes over the effective environment and an env.PATH override redirects resolution on its own. lookupPath takes the PATH string as an argument rather than reading process.env internally, so there is no hidden global read left in the module.

Added two tests that write a real executable into a temp dir and pass it only through env.PATH — one asserting it is found, one asserting an empty PATH yields no-opener. Both fail against the previous implementation.

Three fixes from Copilot's review of #754.

`--dry-run` no longer mints a challenge. It promised to mutate neither
files nor the platform but only skipped the local write, so it still
called `initClaim` — a platform write — and a developer who then finished
the browser step would really claim the project while the run
deliberately skipped recording it, leaving the local file disagreeing
with the platform. It now stops before `initClaim` and reports
`reason: "dry-run"`. Nothing is lost: a claim is decided in a browser,
not by anything the CLI could compute, so there was never a preview to
render. This follows the precedent setup already set, where `--dry-run`
substitutes a stand-in rather than calling the platform.

An unparseable `expires_at` no longer means no deadline at all. It fell
through to `Infinity`, so a malformed or misrouted response would leave
the CLI polling forever; it now falls back to the TTL the ADR documents.
The computation moved into an exported `claimDeadline` so the fallback is
testable without waiting out a real ten-minute link.

The default `lookupPath` now reads `PATH` from the injected `env` rather
than `process.env`, so overriding `env` alone redirects executable
resolution as the deps bag implies. Previously a caller had to override
`lookupPath` too, which made the seam quietly misleading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#764 renamed the remaining camelCase wire fields, so the api-mock's
`POST /projects` now answers `project_secret` / `preview_origins`. The
claim suite's fixture still read `projectSecret`, wrote `undefined` into
`.zitadel/secret`, and every test that needed a usable project failed
somewhere else entirely — as ".zitadel/secret is missing required
fields" from inside the command under test.

Follow the rename, and assert the fixture actually got a secret so the
next wire change fails at the fixture with the offending payload instead
of surfacing as an unrelated error seven tests deep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel
vercel Bot temporarily deployed to Preview – nextgen-docs August 5, 2026 20:36 Inactive
@vercel
vercel Bot temporarily deployed to Preview – nextgen-mock-zitadel August 5, 2026 20:36 Inactive
@IAM-marco
IAM-marco merged commit a1d01cd into main Aug 5, 2026
14 checks passed
@IAM-marco
IAM-marco deleted the feat/613-cli-claim-command branch August 5, 2026 20:49
@github-project-automation github-project-automation Bot moved this from Inbox to Done in Engineering Kanban Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Claim G1: CLI zitadel claim command

3 participants