Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/cli-claim-nudges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@zitadel/cli": minor
---

Report whether a project is attached to a team in `setup`, `status`, and `doctor`, so the temporary nature of a fresh project is visible without having to know `zitadel claim` exists.

`setup` closes with an ownership line and points at `claim`, `status` carries `data.project.claim` (`detached`, or `attached` with the owning `team_id` and `claimed_at`), and `doctor` grows a `claim` check. All three read `claimed_at`/`team_id` from `.zitadel/secret`, which `zitadel claim` already writes, so nothing here costs a platform call and everything keeps working offline.

A project with no team is a **warning**, never a failure: it works exactly like one with a team, so `doctor` still exits 0, and `--fix` deliberately does nothing because claiming needs a human in a browser. The messaging frames unattached projects as temporary without promising deletion, since nothing deletes them today.

Nudges appear only for projects whose `server` in `zitadel.json` is the Zitadel cloud. Local and self-hosted projects have no team to attach to, so `zitadel setup --server local` stays quiet about it.
10 changes: 10 additions & 0 deletions apps/cli/SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ the CLI's help layer, not the envelope.
there is nothing to preview, because a claim is decided in a browser.
Flags: `--no-open` (print the link instead of launching a browser),
`--timeout <seconds>` (stop waiting sooner than the link's own expiry).
`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": "team_01H…", "claimed_at": "2026-08-01T09:00:00.000Z"}`,
and `doctor` reports a
`claim` check. A project with no team is only ever a **warning**, never a
Comment on lines +148 to +153
failure — it works exactly like one with a team, so `doctor` still exits 0
and `--fix` deliberately does nothing (a claim needs a human in a browser).
All three stay silent about teams when the project's `server` in
`zitadel.json` is local or self-hosted, where there is nothing to attach.
- `status` — summarize the local runtime and project state.
- `eject` (alias `uninstall`) — remove managed files and local Zitadel state;
requires `--force` when non-interactive.
Expand Down
81 changes: 81 additions & 0 deletions apps/cli/src/commands/doctor/checks/claim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { claimState, type ClaimState } from "../../../lib/claim-state";
import { readProjectServer, readZitadelConfig, readZitadelSecret } from "../../../lib/project";
import type { CheckContext, CheckOutcome, SanityCheck } from "./types";

/**
* Reports whether the project is attached to a team, reading `claimed_at` and
* `team_id` from `.zitadel/secret`.
*
* Always advisory, 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, which is the developer's call and not a defect. That
* matters mechanically too: `doctor` turns any `fail` into a thrown
* `E_VALIDATION`, so failing here would break every scripted `zitadel doctor`
* against a project nobody had claimed yet.
*
* Implements {@link SanityCheck} directly rather than via the abstract base,
* for the same reason `ManagedFilesCheck` does: the base's `verify`-throws
* contract can only express pass/fail, and this check is only ever warn or
* pass.
*/
export class ClaimCheck implements SanityCheck {
readonly name = "claim";
readonly path = ".zitadel/secret";

async run(ctx: CheckContext): Promise<CheckOutcome> {
let state: ClaimState;
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)),
});
} catch {
// A missing or unparseable secret/config is already reported, loudly and
// with a repair path, by the `secret` and `config` checks. Implementing
// `SanityCheck` directly means nothing wraps a throw here, so swallowing
// it is what keeps a broken project reporting its real problem instead of
// crashing the whole battery on a nudge.
return {
name: this.name,
status: "pass",
message: "Skipped: could not read the project files that record the owning team",
path: this.path,
};
}

if (state.kind === "detached") {
return {
name: this.name,
status: "warn",
message:
"This project is temporary until you attach it to a team. Run `zitadel claim` " +
"to make it permanent; nothing about the project changes.",
Comment on lines +53 to +55

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.

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.

path: this.path,
};
}

return {
name: this.name,
status: "pass",
message:
state.kind === "attached"
? `Project is attached to team ${state.team_id}`
: // Local and self-hosted servers have no team to attach to, so the
// check passes rather than warning about an impossible action.
"Project is not on a server where teams apply",
path: this.path,
};
}

/**
* Deliberately a no-op. `doctor --fix` calls `fix` on every non-passing
* check, but claiming requires a human to sign in through a browser, so there
* is nothing safe to automate here.
*/
async fix(_ctx: CheckContext): Promise<void> {
return;
}
}
3 changes: 3 additions & 0 deletions apps/cli/src/commands/doctor/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DependencyCheck } from "./dependency";
import { ManagedFilesCheck } from "./managed-files";
import { ProjectMatchCheck } from "./project-match";
import { SchemaCheck } from "./schema";
import { ClaimCheck } from "./claim";

export type { SanityCheck, CheckContext, CheckOutcome } from "./types";
export { AbstractSanityCheck } from "./types";
Expand All @@ -28,6 +29,7 @@ export { SchemaCheck } from "./schema";
export { DependencyCheck } from "./dependency";
export { ManagedFilesCheck } from "./managed-files";
export { ProjectMatchCheck } from "./project-match";
export { ClaimCheck } from "./claim";

/** Every diagnostic the `doctor` command runs, in display order. */
export const SANITY_CHECKS: ReadonlyArray<SanityCheck> = [
Expand All @@ -41,4 +43,5 @@ export const SANITY_CHECKS: ReadonlyArray<SanityCheck> = [
new DependencyCheck(),
new ManagedFilesCheck(),
new ProjectMatchCheck(),
new ClaimCheck(),
];
6 changes: 6 additions & 0 deletions apps/cli/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Flags } from "@oclif/core";
import consola from "consola";

import { claimAction, claimCommand } from "../../lib/claim-state";
import { ZitadelError } from "../../lib/errors";
import { assertServerPackageAvailable } from "../../lib/local-server/binary";
import { dockerAvailable, imageAvailable } from "../../lib/local-server/docker";
Expand Down Expand Up @@ -259,6 +260,11 @@ function advisoryForWarnings(
nextCommands.push(...advice.nextCommands);
}

if (warnings.some((check) => check.name === "claim")) {
nextActions.push(claimAction(cliVersion));
nextCommands.push(claimCommand(cliVersion));
}
Comment on lines +263 to +266

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.

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.


const managedRuntimeWarning = warnings.find((check) => check.name === "managed-runtime-processes");
if (hasManagedRuntimeProcesses(managedRuntimeWarning)) {
nextActions.push(
Expand Down
32 changes: 29 additions & 3 deletions apps/cli/src/commands/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@zitadel/config/defaults";
import { consola } from "consola";

import { claimAction, claimCommand, claimState, claimSummary } from "../../lib/claim-state";
import { toZitadelError, ZitadelError } from "../../lib/errors";
import { BaseCommand, type JsonEnvelope } from "../../lib/oclif";
import {
Expand All @@ -42,6 +43,7 @@ import { installDependenciesForSetup } from "./install";
import { PickFrameworkPrompt, SETUP_PROMPTS, type SetupAnswers } from "./prompts";
import {
detectProjectFacts,
dim as styleDim,
fileNameOf,
formatFrameworkLine,
id as styleId,
Expand Down Expand Up @@ -379,6 +381,18 @@ export default class Setup extends BaseCommand {
});

const writtenRel = allFilesWritten.map((file) => relativeDisplay(cwd, file));
// The nudge rides both surfaces from one decision: the box for humans, the
// envelope for agents. It lands after the install/verify actions because
// attaching a team is the step *after* the app demonstrably works, not a
// precondition for trying it. Empty off the cloud, where nothing can be
// attached.
const claimNudge =
claimState({ secret: {}, server: answers.server }).kind === "detached"
? {
actions: [claimAction(this.meta.cliVersion)],
commands: [claimCommand(this.meta.cliVersion)],
}
: { actions: [], commands: [] };
// The structured report is human-only. Under `--json` we let the
// envelope returned from `this.emit(...)` be the sole stdout
// payload (oclif requires single-doc JSON).
Expand All @@ -398,7 +412,11 @@ export default class Setup extends BaseCommand {
// pre-coloured rows (path/url/id helpers) survive intact.
consola.box({
title: "Zitadel is ready",
message: [renderSummary(sections), "", installOutcome.boxActions.join("\n")].join("\n"),
message: [
renderSummary(sections),
"",
[...installOutcome.boxActions, ...claimNudge.actions].join("\n"),
].join("\n"),
style: { padding: 1, borderStyle: "rounded", borderColor: "green" },
});
}
Expand Down Expand Up @@ -427,8 +445,8 @@ export default class Setup extends BaseCommand {
})),
files_skipped: result.filesSkipped.map((file) => relativeDisplay(cwd, file)),
install: installOutcome.install,
next_actions: installOutcome.nextActions,
next_commands: installOutcome.nextCommands,
next_actions: [...installOutcome.nextActions, ...claimNudge.actions],
next_commands: [...installOutcome.nextCommands, ...claimNudge.commands],
},
});
}
Expand Down Expand Up @@ -743,6 +761,14 @@ function buildSummary(opts: {
{ label: "App will run", value: styleUrl(issuer) },
];

// A project this command just created is never attached to a team yet, so the
// only question here is whether attaching is a thing at all on this server.
// `claimSummary` returns undefined off the cloud, which drops the row.
const ownership = claimSummary(claimState({ secret: {}, server }));
if (ownership) {
projectRows.push({ label: "Ownership", value: styleDim(ownership) });
}

return [
{ title: "Detected", rows: detected },
{ title: "Installed", rows: installedRows },
Expand Down
39 changes: 27 additions & 12 deletions apps/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createZitadelClient } from "@zitadel/api/client";

import { claimAction, claimCommand, claimState, type ClaimState } from "../lib/claim-state";
import { isProcessRunning } from "../lib/local-server/binary";
import { inspectContainer } from "../lib/local-server/docker";
import { customizeAndPublishActions, verifyLoginAction } from "../lib/journey-guidance";
Expand All @@ -15,6 +16,7 @@ import {
hasZitadelConfig,
hasZitadelSecret,
readDevelopmentIssuer,
readProjectServer,
readZitadelConfig,
readZitadelSecret,
} from "../lib/project";
Expand Down Expand Up @@ -60,12 +62,7 @@ export default class Status extends BaseCommand {
project.lifecycle === "configured"
? await detectUserPresence(this.meta.cwd, this.meta.source)
: "unknown";
const nextCommands = nextCommandsFor(
serverLifecycle,
project.lifecycle,
users,
this.meta.cliVersion,
);
const nextCommands = nextCommandsFor(serverLifecycle, project, users, this.meta.cliVersion);
const nextActions = nextActionsFor(project, users, this.meta.cliVersion);

return this.emit({
Expand Down Expand Up @@ -139,6 +136,12 @@ type ProjectStatus =
lifecycle: "configured";
project_id: string;
issuer?: string;
/**
* Whether the project is attached to a team, from the local
* `.zitadel/secret`. Omitted entirely off the cloud, where there is
* nothing to attach and the field would only invite agents to act on it.
*/
claim?: ClaimState;
};

async function projectStatus(cwd: string): Promise<ProjectStatus> {
Expand All @@ -159,10 +162,15 @@ async function projectStatus(cwd: string): Promise<ProjectStatus> {
}

const secret = await readZitadelSecret(cwd);
// Gated on the server recorded in `zitadel.json`, not `this.meta.source`:
// `status --server local` rewrites the source for the health probe, but it
// does not move the project, so the source would answer the wrong question.
const claim = claimState({ secret, server: readProjectServer(config) });
return {
lifecycle: "configured",
project_id: String(config.project ?? secret.project_id ?? ""),
issuer: readDevelopmentIssuer(config),
...(claim.kind === "not-applicable" ? {} : { claim }),
};
}

Expand Down Expand Up @@ -213,34 +221,41 @@ function nextActionsFor(project: ProjectStatus, users: UserPresence, cliVersion:
if (project.lifecycle !== "configured") {
return [];
}
// Additive to the journey staging rather than a stage of its own: attaching a
// team is orthogonal to whether login works yet, so it appends to whichever
// stage the user is in instead of displacing it.
const claim = project.claim?.kind === "detached" ? [claimAction(cliVersion)] : [];
if (users === "none") {
return [verifyLoginAction(project.issuer)];
return [verifyLoginAction(project.issuer), ...claim];
}
if (users === "some") {
return customizeAndPublishActions(cliVersion);
return [...customizeAndPublishActions(cliVersion), ...claim];
}
return [];
return claim;
}

function nextCommandsFor(
serverLifecycle: string,
projectLifecycle: ProjectStatus["lifecycle"],
project: ProjectStatus,
users: UserPresence,
cliVersion: string,
): string[] {
const commands: string[] = [];
if (serverLifecycle !== "running") {
commands.push(publicCliCommand("start", cliVersion));
}
if (projectLifecycle === "not-configured") {
if (project.lifecycle === "not-configured") {
commands.push(publicCliCommand("setup --server local", cliVersion));
} else if (projectLifecycle === "orphaned-config") {
} else if (project.lifecycle === "orphaned-config") {
commands.push(
publicCliCommand("setup --force", cliVersion),
publicCliCommand("doctor --fix", cliVersion),
);
} else {
commands.push(publicCliCommand("doctor", cliVersion));
if (project.claim?.kind === "detached") {
commands.push(claimCommand(cliVersion));
}
if (users === "none") {
// Staged like next_actions: before the first login is proven in a
// browser, publishing is premature — `plan` previews safely and the
Expand Down
Loading
Loading