feat: add the zitadel claim command - #754
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
🦋 Changeset detectedLatest commit: cdf7f29 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 |
There was a problem hiding this comment.
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.tsimplementing the init → browser link → status polling → local secret write workflow. - Adds new CLI utilities and tests: atomic
.zitadel/secretwriter and cross-platform “open in browser” helper with unit coverage. - Extends
@zitadel/api-mockstore 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. |
| const client = createZitadelClient({ | ||
| baseUrl: this.meta.source, | ||
| token: secret.project_secret, | ||
| }); | ||
|
|
There was a problem hiding this comment.
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.
| // 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, | ||
| ); |
There was a problem hiding this comment.
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.
| 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" }; | ||
| } |
There was a problem hiding this comment.
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>
Summary
Closes #613. Part of #96.
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.claimed_atandteam_idin.zitadel/secret.xdg-open,--no-open, and an agent all take one path with no special case.skippedenvelope, not an error, whether known locally or learned from a409, so an agent retry is a clean no-op. An expired link exitsE_VALIDATIONpointing at a fresh run.--timeout <seconds>bounds the wait below the link's own expiry..zitadel/secretwriter, a cross-platform browser opener (no new runtime dependency), and a test seam on the api-mock store snapshot.Validation
Also smoke-tested by hand against a running
api-mock: the link box, the timeout error, the success narration writingclaimed_at/team_idwhile leavingproject_secretuntouched, and the idempotent already-claimed skip.Release notes / changeset
.changeset/cli-claim-command.md— minor bump for@zitadel/cli, adding theclaimcommand. (@zitadel/api-mockis private, so its change ships nothing.)Notes
No real end-to-end run is possible yet. The generated claim operations still answer
501viaapi.UnimplementedHandler(that is #611) and the browser page is #615, sopackages/api-mockis the only working backend. Journey coverage is #612.Testing the browser leg.
claim/completeis 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'scompleteClaimChallengemutator directly — the same function the Express route delegates to. The--server localsuccess case drives a smallnode:httpstub for the same reason.Two contract details post-date the ticket text, and the implementation follows the contract:
claim/statusrequires?challenge_id=and403s if the polling secret is not the one that calledinit; and the completed status carries no new secret, so the removed pre-#150 command'snew_project_secrethandling was deliberately not revived.Unrelated README churn.
moon run cli:readmepicked up two pieces of staleness in the committed README (doctor --fixandsetup --rendererdescriptions had drifted from source). They are accurate to current code, but they are not from this change. Generation is pinned toCOLUMNS=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,doctorport-occupied); both shell out to node-based fake binaries with 1sexecFiletimeouts. 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