feat: report team attachment in setup, status, and doctor - #776
feat: report team attachment in setup, status, and doctor#776IAM-marco wants to merge 6 commits into
Conversation
Adds the single place that answers "is this project attached to a team?",
so the surfaces that nudge toward `zitadel claim` cannot drift from each
other or from the command itself.
The answer comes from `.zitadel/secret` alone, which `zitadel claim`
already writes. There is no ambient claim-state endpoint to call:
`getClaimStatus` answers for one live challenge and rejects a secret that
did not mint it, and claim attributes were deliberately kept off
`GET /projects/{id}` because they belong to the grant rather than the
project. Reading locally is what keeps `status` and `doctor` fast and
working offline.
Claiming is a cloud concept, so the helper reports `not-applicable` for
local and self-hosted servers rather than nudging toward an action that
cannot happen there. The gate reads the `server` recorded in
`zitadel.json` via the new `readProjectServer`, not the server a command
happens to be pointed at: `doctor` pins its own source to the local
runtime URL and `status --server local` overrides it, so either would
answer the wrong question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`setup` is where a developer first meets their project, so it is where the temporary nature of it has to be visible. Adds an ownership row to the summary box and the claim nudge to both the box actions and the envelope's `next_actions`/`next_commands`, so humans and agents get the same signal. The nudge lands after the install and verify actions: attaching a team is the step after the app demonstrably works, not a precondition for trying it. A project this command just created is never attached yet, so the only branch here is whether attaching applies to this server at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carries the attachment state on `data.project.claim` (`detached`, or `attached` with `team_id` and `claimed_at`) and adds the nudge to the guidance when no team is attached. The field is omitted entirely off the cloud, where it would only invite agents to act on it. `projectStatus` already read both `zitadel.json` and `.zitadel/secret`, so this costs no extra I/O and no platform call. The nudge is additive to the existing journey staging rather than a stage of its own, because attaching a team is orthogonal to whether login works yet. That also means it survives an unreachable platform: the journey guidance is staged on the user-presence probe and is withheld when the probe cannot answer, but the claim state is a local read, so going offline no longer hides it. The test covering that case now says so explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reports whether the project is attached to a team, and surfaces `zitadel claim` in the advisory when it is not. Always a warning, never a failure. An unattached project works exactly like an attached one, same issuer, same users, same applications, so the only thing missing is durability and that is the developer's call rather than a defect. It matters mechanically too: `doctor` turns any `fail` into a thrown `E_VALIDATION`, so failing here would break every scripted `zitadel doctor` run against a project nobody had claimed yet. Implements `SanityCheck` directly rather than extending the abstract base, for the same reason `ManagedFilesCheck` does: the base's verify-throws contract can only express pass or fail. That also means nothing wraps a throw from `run`, so an unreadable secret or config is caught and reported as a skip. Otherwise a missing `.zitadel/secret` would crash the whole battery on a nudge and hide the `secret` check that actually reports the problem, with its repair path. `fix` stays the inherited no-op. `doctor --fix` calls it on every non-passing check, but a claim needs a human to sign in through a browser, so there is nothing safe to automate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the new envelope fields in the CLI's agent contract so an agent can branch on attachment state, and adds the changeset for the release notes. Both stay on the vocabulary `zitadel claim` established. The CLI's vocabulary gate bans "unclaimed" in README.md and SKILLS.md, and keeping it out of the strings themselves is what makes that gate easy to keep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds local “claim/ownership” awareness to the CLI so new cloud projects are clearly labeled as temporary until attached to a team, without requiring a platform call.
Changes:
- Introduces
claim-statehelpers to compute claim status from.zitadel/secretand generate user-facing nudges. - Surfaces claim state and nudges in
setup,status, anddoctor(including a newClaimCheck). - Adds/updates unit tests and docs to cover detached/attached/off-cloud behavior.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/cli/src/lib/claim-state.ts | New claim state model + copy/nudge helpers |
| apps/cli/src/lib/project.ts | Adds readProjectServer for “where the project lives” semantics |
| apps/cli/src/commands/status.ts | Emits claim state + appends claim guidance/commands for detached cloud projects |
| apps/cli/src/commands/setup/index.ts | Adds ownership summary row + claim nudges for new cloud projects |
| apps/cli/src/commands/doctor/index.ts | Adds top-level claim guidance when claim check warns |
| apps/cli/src/commands/doctor/checks/index.ts | Registers new ClaimCheck |
| apps/cli/src/commands/doctor/checks/claim.ts | New check that warns on detached cloud projects using local secret/config |
| apps/cli/tests/unit/lib/claim-state.test.ts | Covers claim-state classification and copy constraints |
| apps/cli/tests/unit/commands/status.test.ts | Covers status claim field + nudges/commands behavior |
| apps/cli/tests/unit/commands/setup.test.ts | Covers setup nudges on cloud vs silence off-cloud |
| apps/cli/tests/unit/commands/doctor/checks.test.ts | Adds unit coverage for ClaimCheck behavior |
| apps/cli/tests/unit/commands/doctor.test.ts | Ensures doctor warns (not fails) and suggests claim appropriately |
| apps/cli/SKILLS.md | Documents new claim reporting behavior across commands |
| .changeset/cli-claim-nudges.md | Releases feature as a minor change |
| export type ClaimState = | ||
| | { kind: "attached"; team_id: string; claimed_at?: string } | ||
| | { kind: "detached" } | ||
| | { kind: "not-applicable" }; |
| if (claimed_at && team_id) { | ||
| return { kind: "attached", team_id, claimed_at }; | ||
| } |
| @@ -0,0 +1,81 @@ | |||
| import { claimState, type ClaimState } from "../../../lib/claim-state"; | |||
| let state; | ||
| try { | ||
| state = claimState({ | ||
| secret: await readZitadelSecret(ctx.cwd), | ||
| // From `zitadel.json`, not the command's own source: `doctor` pins its | ||
| // source to the local runtime URL, so asking it where the project lives | ||
| // would report every project as local. | ||
| server: readProjectServer(await readZitadelConfig(ctx.cwd)), | ||
| }); |
| message: | ||
| "This project is temporary until you attach it to a team. Run `zitadel claim` " + | ||
| "to make it permanent; nothing about the project changes.", |
There was a problem hiding this comment.
Keeping this one as-is, deliberately. Check messages in this battery quote the bare command: schema.ts:37 says "New projects scaffold editable defaults via zitadel setup". The publicCliCommand form belongs to next_actions/next_commands, which is exactly where this check's advisory already puts it via claimAction(cliVersion)/claimCommand(cliVersion) (doctor/index.ts:263-266), so a doctor run already prints the runnable npx @zitadel/cli@<version> claim right below the warning.
Inlining the versioned npx string into the check message too would duplicate it on every run and make the one-line warning considerably longer, without telling the reader anything the advisory does not.
| if (warnings.some((check) => check.name === "claim")) { | ||
| nextActions.push(claimAction(cliVersion)); | ||
| nextCommands.push(claimCommand(cliVersion)); | ||
| } |
There was a problem hiding this comment.
Already handled: advisoryForWarnings ends with return { nextActions: unique(nextActions), nextCommands: unique(nextCommands) } (doctor/index.ts:279, helper at :490), so every branch including this one is de-duplicated before returning. That is also why the existing docker-cli and managed-runtime-processes branches push unconditionally. No change needed.
| `setup`, `status`, and `doctor` report whether a team is attached, reading | ||
| `claimed_at`/`team_id` from `.zitadel/secret` (no platform call). `status` | ||
| carries `data.project.claim` as `{"kind": "detached"}` or | ||
| `{"kind": "attached", "team_id", "claimed_at"}`, and `doctor` reports a | ||
| `claim` check. A project with no team is only ever a **warning**, never a |
| const json = parseJson(res.stdout) as { | ||
| data: { | ||
| project: { claim?: { kind: string; team_id?: string } }; | ||
| next_actions: string[]; | ||
| next_commands: string[]; | ||
| }; | ||
| }; | ||
| expect(json.data.project.claim).toEqual({ | ||
| kind: "attached", | ||
| team_id: "team-001", | ||
| claimed_at: "2026-01-02T00:00:00.000Z", | ||
| }); |
Review follow-ups on the nudges. `ClaimState`'s `attached` variant made `claimed_at` optional even though `claimState` only ever produces that variant when both `claimed_at` and `team_id` are present, so the optionality described a state that cannot occur. Making it required lets the type say what the constructor guarantees, and the test that built an `attached` value without a timestamp now has to supply one. The doctor check declared `state` untyped, leaving it an evolving `any` and its `ClaimState` import unused. It is annotated now. The `attached` example in the agent contract was not valid JSON: it listed the key names with no values, which is the one thing a document aimed at agents should not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 933f086 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 |
Summary
Closes #614. Part of #96.
setup,status, anddoctornow say whether a project is attached to a team, so a developer meets the temporary nature of a fresh project without first knowingzitadel claimexists..zitadel/secretalone, which G1 (feat: add the zitadel claim command #754) already writes. There is no ambient claim-state endpoint to call:getClaimStatusanswers for one live challenge and rejects a secret that did not mint it, and claim attributes were deliberately kept offGET /projects/{id}because they belong to the grant, not the project (breakdown §3.5). Reading locally is also what keepsstatusanddoctorfast and working offline.doctorwarns and still exits 0. Failing would have broken every scriptedzitadel doctorrun against a project nobody had claimed yet.zitadel setup --server localis the documented dev loop, so an ungated nudge would advertise an impossible action for the life of every local project. The gate reads theserverinzitadel.json, not the server a command is pointed at:doctorpins its source to the local runtime URL andstatus --server localoverrides it.namecolumn yet (Resource model - Teams #598), and the claim responses carry onlyteam_id. It frames projects as temporary without promising deletion, since the 14-day lifetime is unenforced.The ticket was written before G1 shipped and asked for an API call that does not exist and a team name that cannot be resolved; it has been updated to match, and #774 tracks the two follow-ups.
Two judgement calls worth a look
An existing test's expectation changed.
status.test.tsassertednext_actionsis empty when the platform is unreachable. The claim nudge legitimately fires there, because it is a local read, which is the point of reading locally. The assertion now distinguishes probe-dependent journey guidance (still withheld) from the claim nudge (still shown), rather than suppressing the nudge to keep the old line green.The setup summary renders every row with a green
✓, so the nudge reads as✓ Ownership temporary until you attach it to a team. The value is dimmed. If that checkmark reads wrong, the alternative is dropping the row and keeping only the action line below the box.Validation
Smoke-tested by hand against the built CLI, covering every state:
doctorwarns and exits 0,statusand the setup box carry the nudge[ok] claim: Project is attached to team team-abc, no nudge anywherezitadel.jsonsayshttp://localhost:8080: silent even when invoked with--server https://api.zitadel.cloud, confirming the gate reads the project's recorded server rather than the flagdoctor --fixon a detached project: warning persists,.zitadel/secretbyte-identicalNotes
moon run cli:readmeproduces a ~130-line diff that is pure terminal-width reflow, unrelated to this change, so it is not included. Worth knowing that task is not width-stable.Release notes / changeset
.changeset/cli-claim-nudges.md— minor bump for@zitadel/cli.🤖 Generated with Claude Code