Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7c1bba1
feat(cli): manage per-environment variables and secrets
mridang Sep 20, 2026
446f6cf
fix(cli): address review on the variables commands
mridang Sep 20, 2026
1bcae38
fix(cli): restore SKILLS.md indentation and show the resolved server
mridang Sep 20, 2026
26a263f
refactor(cli): rename variables remove to delete
mridang Sep 20, 2026
a445d53
refactor(cli): simplify the server-resolution comment on variables
mridang Sep 20, 2026
f86c126
refactor(cli): align the variables commands with existing CLI patterns
mridang Sep 21, 2026
1e8ea10
refactor(cli): drop the casts from the variables helpers
mridang Sep 21, 2026
5ed3efd
fix(cli): escape control characters in rendered variable values
mridang Sep 21, 2026
7e368ef
refactor(cli): inline single-use helpers and drop mutation
mridang Sep 21, 2026
45eb9f1
fix(cli): keep an empty prompt submission an empty string
mridang Sep 21, 2026
c5a8c48
fix(cli): keep the retry hint faithful and name telemetry per the plan
mridang Sep 21, 2026
01b8e20
Merge branch 'main' into claude/heuristic-kapitsa-51ce4e
mridang Sep 21, 2026
499ae75
Merge remote-tracking branch 'origin/main' into claude/heuristic-kapi…
mridang Sep 21, 2026
10be9e1
Merge remote-tracking branch 'origin/main' into claude/heuristic-kapi…
mridang Sep 21, 2026
e3b9d9e
feat(cli): name the owner of every variables command explicitly
mridang Sep 21, 2026
f2ccf4f
Merge remote-tracking branch 'origin/main' into claude/heuristic-kapi…
mridang Sep 21, 2026
970e8cb
feat(cli): store numbers and booleans with variables set --type
mridang Sep 21, 2026
65596d6
refactor(cli): rename variables set --type to --as
mridang Sep 21, 2026
e67c1f2
fix(cli): follow the dry-run contract and guard the variables endpoints
mridang Sep 21, 2026
f92d964
refactor(cli): render variables output the way the resource commands do
mridang Sep 21, 2026
6b0ba0f
refactor(cli): drop the dead --environment flag from plan, apply and …
mridang Sep 22, 2026
cd86ee4
test(cli): pin that plan, apply and resource commands refuse --enviro…
mridang Sep 22, 2026
540d7ca
Merge branch 'main' of github.com:zitadel/nextgen into refactor/cli-e…
mridang Sep 22, 2026
1ae708b
Merge branch 'main' into refactor/cli-environment-flag
mridang Sep 22, 2026
7a258e3
Merge branch 'main' into refactor/cli-environment-flag
mridang Sep 23, 2026
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
5 changes: 5 additions & 0 deletions .changeset/drop-dead-environment-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zitadel/cli": minor
---

**Breaking:** remove `--environment` (`-e`) from `plan`, `apply` and the resource commands (`users`, `teams`, `sessions`, `events`, `grants`, `idps`, `projects`, `schemas`, `environments`, `releases`, `flow-definitions`, `branding`). It only accepted `development`, `preview` or `production`, which are not the platform's environment names, and it never reached the platform: its one effect was to read a server URL from `environments.<name>.server` in `zitadel.json`, which nothing writes. Passing it now fails as an unknown flag; drop it from scripts. `--server`, `ZITADEL_API_BASE` and the top-level `server` in `zitadel.json` still choose the server. `--environment` now means one thing: a real environment on the platform, as the `variables` commands use it.
Comment thread
mridang marked this conversation as resolved.
934 changes: 404 additions & 530 deletions apps/cli/README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/cli/SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ docker --image <ref>` remains the explicit image override for debugging.

- `plan` — validate config and preview the sync diff without mutating anything.
- `apply` — validate and upload repo config to the platform.
- `plan`, `apply` and the resource commands take no `--environment`: they
work on the project's resources and do not select an environment.
`variables` is the only command group that addresses an environment; putting
config onto an environment is `deploy`'s job (ADR 035).
- `plan` and `apply --dry-run` also emit `data.warnings`: non-blocking
findings as `{path, rule, message}`, the same text the human plan prints as
`# warning:` lines and `apply` prints through stderr. They never fail a run.
Expand Down
10 changes: 0 additions & 10 deletions apps/cli/src/commands/apply.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Flags } from "@oclif/core";
import { consola } from "consola";

import { createZitadelClient } from "@zitadel/api/client";

import { BaseCommand, CommandGroups, type JsonEnvelope } from "../lib/oclif";
import { environmentSchema } from "../lib/environment";
import {
buildSyncPlan,
collectPlanWarnings,
Expand All @@ -30,14 +28,6 @@ export default class Apply extends BaseCommand {
static override description = "Validate and upload repo config to the platform.";
static override group = CommandGroups.configuration;
static override groupOrder = 2;
static override flags = {
environment: Flags.string({
char: "e",
description: "Target environment (default: development).",
options: [...environmentSchema.options],
}),
};

async run(): Promise<JsonEnvelope> {
const { flags } = await this.parse(Apply);
await this.toMeta(flags);
Expand Down
10 changes: 0 additions & 10 deletions apps/cli/src/commands/plan.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Flags } from "@oclif/core";
import { consola } from "consola";

import { createZitadelClient } from "@zitadel/api/client";

import { BaseCommand, CommandGroups, type JsonEnvelope } from "../lib/oclif";
import { environmentSchema } from "../lib/environment";
import {
buildSyncPlan,
collectPlanWarnings,
Expand All @@ -27,14 +25,6 @@ export default class Plan extends BaseCommand {
static override description = "Validate config without mutation and preview the sync diff.";
static override group = CommandGroups.configuration;
static override groupOrder = 1;
static override flags = {
environment: Flags.string({
char: "e",
description: "Target environment (default: development).",
options: [...environmentSchema.options],
}),
};

async run(): Promise<JsonEnvelope> {
const { flags } = await this.parse(Plan);
await this.toMeta(flags);
Expand Down
9 changes: 0 additions & 9 deletions apps/cli/src/commands/resources.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Flags } from "@oclif/core";
import { createZitadelClient, type ZitadelClient } from "@zitadel/api/client";
import {
CreateGrantBody,
Expand Down Expand Up @@ -62,7 +61,6 @@ import type {
} from "@zitadel/api/generated/model";
import { consola } from "consola";

import { environmentSchema } from "../lib/environment";
import { CommandGroups } from "../lib/oclif/groups";
import { ZitadelError } from "../lib/errors";
import { buildResourceCommands, type ResourceRegistry } from "../lib/oclif/crud";
Expand Down Expand Up @@ -557,13 +555,6 @@ export const RESOURCES = {
*/
export const RESOURCE_COMMANDS = buildResourceCommands<Platform>(RESOURCES, {
operations: FILTER_OPERATIONS,
flags: {
environment: Flags.string({
char: "e",
description: "Target environment (default: development).",
options: [...environmentSchema.options],
}),
},
connect: async ({ cwd, source }) => {
Comment thread
mridang marked this conversation as resolved.
const secret = await readZitadelSecret(cwd);
// Which project and server a verb is about is worth stating to a human, and
Expand Down
15 changes: 0 additions & 15 deletions apps/cli/src/lib/environment.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,7 @@
import { z } from "zod";

import type { ZitadelClient } from "@zitadel/api/client";

import { ZitadelError } from "./errors";

/**
* CLI-side deployment environment. Not an API model — it gates which
* `zitadel.json` environment block and server the commands target.
* Project request/response shapes live in `@zitadel/api`
* (generated from the OpenAPI spec).
*
* This is the older of the CLI's two meanings of "environment", used by
* `plan`, `apply` and the resource commands to pick a server. It does not match
* the platform's environments, which are open DNS-style names seeded as `dev`,
* `staging` and `prod`; {@link assertEnvironmentName} is the grammar for those.
*/
export const environmentSchema = z.enum(["development", "preview", "production"]);

/**
* The platform's environment-name grammar, from `environment-name.yaml`: a
* lowercase DNS-style label. Checked before any request so a malformed name
Expand Down
3 changes: 1 addition & 2 deletions apps/cli/src/lib/oclif/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,10 @@ export abstract class BaseCommand extends Command {
): Promise<GlobalOptions> {
const cwd = resolveCwd(typeof flags.cwd === "string" ? flags.cwd : undefined);
const serverFlag = typeof flags.server === "string" ? flags.server : undefined;
const environment = typeof flags.environment === "string" ? flags.environment : "development";
const source =
options.resolveServer === false
? { value: options.source ?? "", origin: "default" as const }
: await resolveServer({ cwd, env: process.env, serverFlag, environment });
: await resolveServer({ cwd, env: process.env, serverFlag });
const json = this.jsonEnabled();
const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
const verbose = Boolean(flags.verbose);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/lib/oclif/crud/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type BodyField = Readonly<{
}>;

/** Flags the write commands own; a body field of the same name stays raw-body only. */
const RESERVED = new Set(["data", "file", "json", "cwd", "server", "force", "environment", "help"]);
const RESERVED = new Set(["data", "file", "json", "cwd", "server", "force", "help"]);

const kebab = (name: string): string => name.replaceAll("_", "-");

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/lib/oclif/crud/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export type ResourceCommandOptions<Ctx> = Readonly<{
connect: (meta: GlobalOptions) => Promise<Ctx>;
/** Filter operations the query endpoints accept (`equals`, `contains`, …). */
operations: readonly string[];
/** Extra flags added to every generated command (e.g. an environment selector). */
/** Extra flags added to every generated command. */
flags?: Interfaces.FlagInput;
/** Wire vocabulary; each property falls back to {@link DEFAULT_WIRE}. */
wire?: Partial<WireConventions>;
Expand Down
11 changes: 4 additions & 7 deletions apps/cli/src/lib/oclif/environment-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,10 @@ export abstract class EnvironmentCommand extends BaseCommand {
private projectLevel = false;

/**
* Validate the owner flags, then build the invocation context without them.
* Validate the owner flags, then build the invocation context.
*
* `--environment` names an owner on the one instance the CLI talks to; it is
* not a `zitadel.json` block. `BaseCommand.toMeta` passes a
* `flags.environment` to the server resolver, where a matching
* `environments.<name>.server` would redirect the request, so it is withheld
* and the server resolves as it does for a command with no such flag.
* `--environment` names an owner on the one instance the CLI talks to; it
* plays no part in choosing that instance.
*/
protected override async toMeta(
flags: Record<string, unknown>,
Expand All @@ -69,7 +66,7 @@ export abstract class EnvironmentCommand extends BaseCommand {
this.requestedEnvironment = flags.environment;
}
this.projectLevel = flags["project-level"] === true;
return super.toMeta({ ...flags, environment: undefined }, options);
return super.toMeta(flags, options);
}

/**
Expand Down
38 changes: 6 additions & 32 deletions apps/cli/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "node:path";

import { ZitadelError } from "./errors";
import { resolveLocalServer } from "./local-server/runtime";
import { isObject, parseJsonObject } from "./json";
import { parseJsonObject } from "./json";

/**
* Server URL used when nothing else resolves. Also surfaced in hints and
Expand All @@ -19,26 +19,24 @@ export const DEFAULT_SERVER = "https://api.zitadel.cloud";
*/
export type ResolvedServer = {
value: string;
origin: "flag" | "env" | "config-env" | "config-top" | "default" | "local";
origin: "flag" | "env" | "config-top" | "default" | "local";
};

/**
* Inputs to {@link resolveServer}. Passed explicitly (cwd, env) rather
* than read from globals so resolution is pure and testable. `serverFlag`
* and `environment` come from the parsed CLI invocation.
* comes from the parsed CLI invocation.
*/
export type ResolveServerInput = {
cwd: string;
env: NodeJS.ProcessEnv;
serverFlag?: string;
environment?: string;
};

/**
* Resolves which server the CLI should target, applying a fixed
* precedence: explicit `--server` flag, then `ZITADEL_API_BASE`, then the
* selected environment block in `zitadel.json`, then the config's
* top-level `server`, falling back to {@link DEFAULT_SERVER}. Every
* `server` in `zitadel.json`, falling back to {@link DEFAULT_SERVER}. Every
* candidate is validated to a normalised origin; an invalid URL throws a
* `ZitadelError` rather than silently falling through.
*/
Expand All @@ -52,14 +50,8 @@ export async function resolveServer(input: ResolveServerInput): Promise<Resolved
}

const config = await readConfig(input.cwd);
if (config) {
const envBranch = readEnvServer(config, input.environment);
if (envBranch) {
return validate(input.cwd, { value: envBranch, origin: "config-env" });
}
if (typeof config.server === "string") {
return validate(input.cwd, { value: config.server, origin: "config-top" });
}
if (typeof config?.server === "string") {
return validate(input.cwd, { value: config.server, origin: "config-top" });
}

return { value: DEFAULT_SERVER, origin: "default" };
Expand Down Expand Up @@ -105,21 +97,3 @@ async function readConfig(cwd: string): Promise<Record<string, unknown> | undefi
throw error;
}
}

function readEnvServer(
config: Record<string, unknown>,
environment: string | undefined,
): string | undefined {
if (!environment) {
return undefined;
}
const envs = config.environments;
if (!isObject(envs)) {
return undefined;
}
const branch = envs[environment];
if (!isObject(branch)) {
return undefined;
}
return typeof branch.server === "string" ? branch.server : undefined;
}
20 changes: 20 additions & 0 deletions apps/cli/tests/unit/commands/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ describe("apply command pre-flight", () => {
expect(json.code).toBe("E_VALIDATION");
expect(json.message).toContain("Missing environment variables");
});

it.each(["-e", "--environment"])("refuses %s, which addresses no environment", async (flag) => {
const cwd = await makeCwd({});

const res = await runCliForTest([
"apply",
flag,
"prod",
"--cwd",
cwd,
"--json",
"--server",
"https://api.zitadel.cloud",
]);

expect(res.exitCode).toBe(3);
const json = parseJson(res.stdout) as { code: string; message: string };
expect(json.code).toBe("E_VALIDATION");
expect(json.message).toContain("Nonexistent flag");
});
});

const VALID_USER_SCHEMA = {
Expand Down
20 changes: 20 additions & 0 deletions apps/cli/tests/unit/commands/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,24 @@ describe("plan command", () => {
expect(json.status).toBe("error");
expect(json.code).toBe("E_VALIDATION");
});

it.each(["-e", "--environment"])("refuses %s, which addresses no environment", async (flag) => {
const cwd = await makeProject();

const res = await runCliForTest([
"plan",
flag,
"prod",
"--cwd",
cwd,
"--json",
"--server",
"https://api.zitadel.cloud",
]);

expect(res.exitCode).toBe(3);
const json = parseJson(res.stdout) as { code: string; message: string };
expect(json.code).toBe("E_VALIDATION");
expect(json.message).toContain("Nonexistent flag");
});
});
22 changes: 22 additions & 0 deletions apps/cli/tests/unit/commands/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ describe("resource registry", () => {
}
});

it("gives no generated command an --environment flag, since none addresses one", () => {
const generated = Object.entries(COMMANDS).filter(([id]) => id.split(":")[0] in RESOURCES);
expect(generated.length).toBeGreaterThan(0);
for (const [id, command] of generated) {
const flags = { ...command.baseFlags, ...command.flags };
expect(Object.keys(flags), id).not.toContain("environment");
expect(
Object.values(flags).map((flag) => flag.char),
id,
).not.toContain("e");
}
});

it("refuses -e on a generated command before any request", async () => {
const cwd = await makeProject();
const res = await run(cwd, ["users", "list", "-e", "prod"]);
expect(res.exitCode).toBe(3);
const json = parseJson(res.stdout) as { code: string; message: string };
expect(json.code).toBe("E_VALIDATION");
expect(json.message).toContain("Nonexistent flag");
});

it("advertises only filter and sort fields the generated query schemas accept", () => {
// Only a spec carrying `body` is sent as a structured query, and that body
// is the authority on which fields exist. A GET list has no such schema,
Expand Down
22 changes: 0 additions & 22 deletions apps/cli/tests/unit/lib/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,10 @@ import type { ZitadelClient } from "@zitadel/api/client";

import {
assertEnvironmentName,
environmentSchema,
listEnvironmentNames,
ownerLabel,
} from "../../../src/lib/environment";

describe("environmentSchema", () => {
it.each(["development", "preview", "production"])("parses the valid environment %s", (value) => {
expect(environmentSchema.parse(value)).toBe(value);
});

it("rejects an unknown environment string", () => {
const result = environmentSchema.safeParse("staging");
expect(result.success).toBe(false);
});

it("rejects a non-string value", () => {
const result = environmentSchema.safeParse(42);
expect(result.success).toBe(false);
});

it("rejects an empty string", () => {
const result = environmentSchema.safeParse("");
expect(result.success).toBe(false);
});
});

describe("assertEnvironmentName", () => {
it.each(["dev", "prod", "staging", "staging-eu", "a".repeat(63)])(
"accepts the platform-legal name %s",
Expand Down
Loading
Loading