Skip to content

Commit 6f8dd2d

Browse files
authored
feat: scaffold React, Vue, Angular, and Nuxt apps from the CLI
Adds setup --framework react|vue|angular|nuxt to @zitadel/cli. Each framework scaffolds its auth entry/pages and wires a dev proxy forwarding /__nextgen to the backend with an sk_<project_id> bearer: a Vite config merge for React/Vue, a proxy.conf.cjs wired into angular.json for Angular, and the @zitadel/sdk-nuxt module for Nuxt. Adds a --dev-port flag and the supporting detectors, scaffolders, and patchers.
1 parent 1a22fd8 commit 6f8dd2d

67 files changed

Lines changed: 3023 additions & 153 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/cli-spa-frameworks.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zitadel/cli": minor
3+
---
4+
5+
Add `setup --framework react|vue|angular|nuxt` support to the CLI. Each framework scaffolds its auth entry/pages and wires `/__nextgen/*` calls to the backend with a `sk_<project_id>` bearer attached: React and Vue get a dev proxy magicast-merged into the Vite config (`vite.config.*`) that reads the project id from `ZITADEL_PROJECT_ID`; Angular gets a `proxy.conf.cjs` wired into `angular.json` that reads it from `zitadel.json`; and Nuxt registers the `@zitadel/sdk-nuxt` module in the Nuxt config (`nuxt.config.*`), which adds the proxy via server middleware. A `--dev-port` flag sets the scaffolded dev-server port.

apps/cli/README.md

Lines changed: 46 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @zitadel/cli
22

3-
Scaffolds Zitadel auth (login, register, profile, middleware) into a Next.js app.
3+
Scaffolds Zitadel auth (login, register, profile, middleware) into a Next.js, React, Vue, Angular, or Nuxt app.
44

55
```sh
66
npx @zitadel/cli@alpha start
@@ -74,44 +74,43 @@ passkey can sign in with either credential.
7474
<summary>Full command reference</summary>
7575

7676
<!-- commands -->
77-
78-
- [`zitadel apply`](#zitadel-apply)
79-
- [`zitadel autocomplete [SHELL]`](#zitadel-autocomplete-shell)
80-
- [`zitadel commands`](#zitadel-commands)
81-
- [`zitadel doctor`](#zitadel-doctor)
82-
- [`zitadel eject`](#zitadel-eject)
83-
- [`zitadel help [COMMAND]`](#zitadel-help-command)
84-
- [`zitadel logs`](#zitadel-logs)
85-
- [`zitadel plan`](#zitadel-plan)
86-
- [`zitadel reset`](#zitadel-reset)
87-
- [`zitadel search`](#zitadel-search)
88-
- [`zitadel setup`](#zitadel-setup)
89-
- [`zitadel start`](#zitadel-start)
90-
- [`zitadel status`](#zitadel-status)
91-
- [`zitadel stop`](#zitadel-stop)
92-
- [`zitadel uninstall`](#zitadel-uninstall)
93-
- [`zitadel version`](#zitadel-version)
94-
- [`zitadel which`](#zitadel-which)
77+
* [`zitadel apply`](#zitadel-apply)
78+
* [`zitadel autocomplete [SHELL]`](#zitadel-autocomplete-shell)
79+
* [`zitadel commands`](#zitadel-commands)
80+
* [`zitadel doctor`](#zitadel-doctor)
81+
* [`zitadel eject`](#zitadel-eject)
82+
* [`zitadel help [COMMAND]`](#zitadel-help-command)
83+
* [`zitadel logs`](#zitadel-logs)
84+
* [`zitadel plan`](#zitadel-plan)
85+
* [`zitadel reset`](#zitadel-reset)
86+
* [`zitadel search`](#zitadel-search)
87+
* [`zitadel setup`](#zitadel-setup)
88+
* [`zitadel start`](#zitadel-start)
89+
* [`zitadel status`](#zitadel-status)
90+
* [`zitadel stop`](#zitadel-stop)
91+
* [`zitadel uninstall`](#zitadel-uninstall)
92+
* [`zitadel version`](#zitadel-version)
93+
* [`zitadel which`](#zitadel-which)
9594

9695
## `zitadel apply`
9796

9897
Validate and upload repo config to the platform.
9998

10099
```
101100
USAGE
102-
$ zitadel apply [--json] [-c <value>] [-s <value>] [-n] [-f] [--dry-run] [--verbose] [--debug]
103-
[-e development|preview|production]
101+
$ zitadel apply [--json] [-c <value>] [-s <value>] [-n] [-f] [--dry-run] [--verbose] [--debug] [-e
102+
development|preview|production]
104103
105104
FLAGS
106-
-c, --cwd=<value> Project directory to operate on.
107-
-e, --environment=<option> Target environment (default: development).
108-
<options: development|preview|production>
109-
-f, --force Overwrite protected files on conflict.
110-
-n, --non-interactive Disable prompts. Required when scripting or running as an agent.
111-
-s, --server=<value> Override the resolved server URL.
112-
--debug Debug logging.
113-
--dry-run Preview without mutating files or the platform.
114-
--verbose Verbose logging.
105+
-c, --cwd=<value> Project directory to operate on.
106+
-e, --environment=<option> Target environment (default: development).
107+
<options: development|preview|production>
108+
-f, --force Overwrite protected files on conflict.
109+
-n, --non-interactive Disable prompts. Required when scripting or running as an agent.
110+
-s, --server=<value> Override the resolved server URL.
111+
--debug Debug logging.
112+
--dry-run Preview without mutating files or the platform.
113+
--verbose Verbose logging.
115114
116115
GLOBAL FLAGS
117116
--json Format output as json.
@@ -288,19 +287,19 @@ Validate config without mutation and preview the sync diff.
288287

289288
```
290289
USAGE
291-
$ zitadel plan [--json] [-c <value>] [-s <value>] [-n] [-f] [--dry-run] [--verbose] [--debug]
292-
[-e development|preview|production]
290+
$ zitadel plan [--json] [-c <value>] [-s <value>] [-n] [-f] [--dry-run] [--verbose] [--debug] [-e
291+
development|preview|production]
293292
294293
FLAGS
295-
-c, --cwd=<value> Project directory to operate on.
296-
-e, --environment=<option> Target environment (default: development).
297-
<options: development|preview|production>
298-
-f, --force Overwrite protected files on conflict.
299-
-n, --non-interactive Disable prompts. Required when scripting or running as an agent.
300-
-s, --server=<value> Override the resolved server URL.
301-
--debug Debug logging.
302-
--dry-run Preview without mutating files or the platform.
303-
--verbose Verbose logging.
294+
-c, --cwd=<value> Project directory to operate on.
295+
-e, --environment=<option> Target environment (default: development).
296+
<options: development|preview|production>
297+
-f, --force Overwrite protected files on conflict.
298+
-n, --non-interactive Disable prompts. Required when scripting or running as an agent.
299+
-s, --server=<value> Override the resolved server URL.
300+
--debug Debug logging.
301+
--dry-run Preview without mutating files or the platform.
302+
--verbose Verbose logging.
304303
305304
GLOBAL FLAGS
306305
--json Format output as json.
@@ -356,17 +355,19 @@ Create a Zitadel project and scaffold local auth.
356355
```
357356
USAGE
358357
$ zitadel setup [--json] [-c <value>] [-s <value>] [-n] [-f] [--dry-run] [--verbose] [--debug]
359-
[--framework next] [--renderer react|web-component] [--skip-install]
358+
[--framework next|nuxt|react|vue|angular] [--renderer react|web-component] [--dev-port <value>] [--skip-install]
360359
361360
FLAGS
362361
-c, --cwd=<value> Project directory to operate on.
363362
-f, --force Overwrite protected files on conflict.
364363
-n, --non-interactive Disable prompts. Required when scripting or running as an agent.
365364
-s, --server=<value> Override the resolved server URL.
366365
--debug Debug logging.
366+
--dev-port=<value> Dev-server port; also the issuer origin registered with Zitadel. Defaults to the detected
367+
port. Use distinct ports to run several scaffolded apps side by side.
367368
--dry-run Preview without mutating files or the platform.
368369
--framework=<option> Framework to target.
369-
<options: next>
370+
<options: next|nuxt|react|vue|angular>
370371
--renderer=<option> Renderer (default: react).
371372
<options: react|web-component>
372373
--skip-install Do not install dependencies after setup updates package.json.
@@ -380,6 +381,8 @@ DESCRIPTION
380381
381382
EXAMPLES
382383
$ zitadel setup --framework next
384+
385+
$ zitadel setup --framework react --dev-port 3000
383386
```
384387

385388
## `zitadel start`
@@ -537,7 +540,6 @@ EXAMPLES
537540
```
538541

539542
_See code: [@oclif/plugin-which](https://github.com/oclif/plugin-which/blob/3.2.55/src/commands/which.ts)_
540-
541543
<!-- commandsstop -->
542544

543545
</details>

apps/cli/SKILLS.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ description: >-
44
Set up and manage Zitadel authentication in a local project with the
55
agent-friendly `zitadel` CLI. Use when the user wants to add login,
66
registration, or session handling, create a Zitadel project, scaffold auth
7-
routes for a Next.js App Router app, or plan and apply Zitadel config changes
8-
from repo state.
7+
for a Next.js, React, Vue, Angular, or Nuxt app, or plan and apply Zitadel
8+
config changes from repo state.
99
---
1010

1111
# Zitadel CLI
@@ -56,7 +56,12 @@ layer, not the envelope.
5656
and login flow are provisioned server-side at creation, so setup neither
5757
scaffolds nor uploads them. Agents must pass `--framework` when scaffolding
5858
into a fresh directory; interactive humans can omit it and choose from the
59-
prompt. Flags: `--framework`, `--renderer`.
59+
prompt. Flags: `--framework next|react|vue|angular|nuxt`, `--renderer
60+
react|web-component` (selects the Next.js auth-page renderer; accepted for any
61+
framework and recorded in `zitadel.json` branding, but only Next varies its
62+
generated templates by it), `--dev-port` (dev-server port, also the issuer
63+
origin registered with Zitadel — use distinct ports to run several scaffolded
64+
apps side by side), `--skip-install`.
6065
- `plan` — validate config and preview the sync diff without mutating anything.
6166
- `apply` — validate and upload repo config to the platform.
6267
- `doctor` — verify generated app files and local state once `zitadel.json`

apps/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@zitadel/api": "workspace:*",
6262
"ajv": "catalog:",
6363
"consola": "^3.4.2",
64+
"magicast": "^0.5.3",
6465
"safe-stable-stringify": "catalog:",
6566
"zod": "catalog:",
6667
"picocolors": "^1.1.1"

apps/cli/src/commands/eject.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ async function resolveEjectActions(cwd: string): Promise<EjectActions> {
2121
directories: [".zitadel"],
2222
envBackups: [".env.local"],
2323
dependencies: [],
24+
configEdits: [],
2425
};
2526
const orca = createOrca();
2627
const framework = await orca.tryDetect(cwd);
@@ -155,7 +156,21 @@ export default class Eject extends BaseCommand {
155156
removed.push(rel);
156157
}
157158

158-
if (removed.length === 0 && backedUp.length === 0) {
159+
// In-place config merges (vite.config.ts / angular.json / nuxt.config.ts)
160+
// can't be auto-reverted, so surface them as manual cleanup steps. The
161+
// Angular patcher also edits package.json (a `dev` script, not a config
162+
// block), so word that one accurately.
163+
const manualSteps = actions.configEdits.map((rel) => {
164+
if (rel === "package.json" || rel.endsWith("/package.json")) {
165+
return `Remove the "dev" script setup added to ${rel}`;
166+
}
167+
if (rel === "angular.json" || rel.endsWith("/angular.json")) {
168+
return `Remove the Zitadel proxyConfig (and dev-server port) from the serve target in ${rel}`;
169+
}
170+
return `Remove the Zitadel configuration block from ${rel}`;
171+
});
172+
173+
if (removed.length === 0 && backedUp.length === 0 && manualSteps.length === 0) {
159174
return this.emit({ status: "skipped", reason: "nothing-to-eject", data: { cwd } });
160175
}
161176

@@ -169,6 +184,7 @@ export default class Eject extends BaseCommand {
169184
files_preserved: preserved,
170185
backed_up: backedUp,
171186
next_commands: nextCommands,
187+
manual_steps: manualSteps,
172188
},
173189
});
174190
}

apps/cli/src/commands/setup/index.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,20 @@ const FRAMEWORK_OPTIONS = createOrca()
4949
*/
5050
export default class Setup extends BaseCommand {
5151
static override description = "Create a Zitadel project and scaffold local auth.";
52-
static override examples = ["<%= config.bin %> setup --framework next"];
52+
static override examples = [
53+
"<%= config.bin %> setup --framework next",
54+
"<%= config.bin %> setup --framework react --dev-port 3000",
55+
];
5356
static override flags = {
5457
framework: Flags.string({ description: "Framework to target.", options: FRAMEWORK_OPTIONS }),
5558
renderer: Flags.string({
5659
description: "Renderer (default: react).",
5760
options: [...RENDERER_IDS],
5861
}),
62+
"dev-port": Flags.integer({
63+
description:
64+
"Dev-server port; also the issuer origin registered with Zitadel. Defaults to the detected port. Use distinct ports to run several scaffolded apps side by side.",
65+
}),
5966
"skip-install": Flags.boolean({
6067
description: "Do not install dependencies after setup updates package.json.",
6168
}),
@@ -103,33 +110,66 @@ export default class Setup extends BaseCommand {
103110
}
104111
}
105112

113+
// An explicit --dev-port overrides the detected port for the whole run, so
114+
// the issuer and the registered origin track the requested port (and several
115+
// apps can be scaffolded on distinct ports). The config edits set the
116+
// dev-server port only when it is unset, so a project that already pins a
117+
// different port in vite.config.*/angular.json keeps it — pass --dev-port to
118+
// match that pin (or remove it) if the origin check rejects requests.
119+
if (flags["dev-port"] !== undefined) {
120+
const devPort = flags["dev-port"];
121+
if (!Number.isInteger(devPort) || devPort < 1 || devPort > 65535) {
122+
throw new ZitadelError(
123+
"E_VALIDATION",
124+
`--dev-port must be an integer in 1..65535, got ${devPort}`,
125+
);
126+
}
127+
framework = {
128+
...framework,
129+
devPort,
130+
url: issuerFromPort(devPort),
131+
};
132+
}
133+
106134
let answers: SetupAnswers = {
107135
server: this.meta.source,
108136
devPort: framework.devPort,
109137
};
110138

111139
if (!nonInteractive && !dryRun) {
112140
intro("Zitadel setup");
113-
const promptCtx = { framework, serverFlag: this.meta.serverFlag };
141+
const promptCtx = {
142+
framework,
143+
serverFlag: this.meta.serverFlag,
144+
devPortFromFlag: flags["dev-port"] !== undefined,
145+
};
114146
for (const prompt of SETUP_PROMPTS) {
115147
answers = await prompt.ask(answers, promptCtx);
116148
}
117149
outro("Configuration captured");
118150
}
119151

120152
const issuer = issuerFromPort(answers.devPort);
153+
// The DevPortPrompt can change the port interactively, so fold the answer
154+
// back into the framework: the patched dev-server config reads
155+
// `framework.devPort`, and it must agree with the issuer and the registered
156+
// origin (both derived from `answers.devPort`).
157+
framework = { ...framework, devPort: answers.devPort, url: issuer };
121158

122159
// `POST /projects` is unauthenticated. Creating the project also
123160
// provisions its default user schema and login flow server-side, so the
124161
// CLI no longer builds, scaffolds, or uploads those resources here.
125162
consola.start(`Creating project on ${answers.server}${dryRun ? " (dry run)" : ""}`);
126163
const unauthClient = createZitadelClient({ baseUrl: answers.server });
164+
// Register the app's own origin so the backend's origin check allows
165+
// requests the dev proxy forwards from it.
127166
const project = dryRun
128-
? dryRunProject()
167+
? dryRunProject(issuer)
129168
: await createProjectWithLocalHint(
130169
unauthClient,
131170
answers.server,
132171
this.meta.cliVersion,
172+
issuer,
133173
);
134174
consola.success(`Created project ${project.id}`);
135175

@@ -235,12 +275,12 @@ async function resolveScaffoldFramework(
235275
}
236276

237277
/** A deterministic stand-in project for `--dry-run`, so no remote call is made. */
238-
function dryRunProject(): CreateProject201 {
278+
function dryRunProject(issuer: string): CreateProject201 {
239279
return {
240280
id: "dry-run-0000",
241281
projectSecret: "sk_proj_dry_run_full",
242282
previewSecret: "sk_proj_dry_run_preview",
243-
previewOrigins: [],
283+
previewOrigins: [issuer],
244284
createdAt: "2026-04-21T14:03:11.000Z",
245285
};
246286
}
@@ -249,9 +289,12 @@ async function createProjectWithLocalHint(
249289
client: ReturnType<typeof createZitadelClient>,
250290
server: string,
251291
cliVersion: string,
292+
issuer: string,
252293
): Promise<CreateProject201> {
253294
try {
254-
return await client.createProject({ previewOrigins: [] });
295+
// Register the app's own origin so the backend's origin check allows the
296+
// requests the dev proxy forwards from it.
297+
return await client.createProject({ previewOrigins: [issuer] });
255298
} catch (error) {
256299
const normalized = toZitadelError(error);
257300
throw new ZitadelError(normalized.code, normalized.message, {

apps/cli/src/commands/setup/prompts/dev-port.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import type { PromptContext, SetupAnswers, SetupPrompt } from "./types";
88
* becomes the issuer URL (`http://localhost:<port>`) via `issuerFromPort`.
99
*/
1010
export class DevPortPrompt implements SetupPrompt {
11-
async ask(answers: SetupAnswers, _ctx: PromptContext): Promise<SetupAnswers> {
11+
async ask(answers: SetupAnswers, ctx: PromptContext): Promise<SetupAnswers> {
12+
// `--dev-port <n>` is authoritative: skip the prompt so an interactive
13+
// answer can't override the explicit flag (the base command already folded
14+
// it into answers.devPort).
15+
if (ctx.devPortFromFlag) {
16+
return answers;
17+
}
1218
const value = await text({
1319
message: "Dev server port",
1420
placeholder: String(answers.devPort),

apps/cli/src/commands/setup/prompts/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export type PromptContext = {
2828
* passed a flag" from "we fell back to the cloud default".
2929
*/
3030
readonly serverFlag?: string;
31+
/**
32+
* Whether `--dev-port` was passed explicitly. When set, the flag is
33+
* authoritative and {@link import("./dev-port").DevPortPrompt} skips itself so
34+
* an interactive answer can't override a scripted/flagged port.
35+
*/
36+
readonly devPortFromFlag?: boolean;
3137
};
3238

3339
/**

0 commit comments

Comments
 (0)