@zitadel/cli 0.1.0-alpha.9 → 1.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +250 -62
  2. package/SKILLS.md +221 -37
  3. package/dist/branding-cygSBPkV.mjs +79 -0
  4. package/dist/branding-cygSBPkV.mjs.map +1 -0
  5. package/dist/claim-state-DlfD6Res.mjs +66 -0
  6. package/dist/claim-state-DlfD6Res.mjs.map +1 -0
  7. package/dist/commands/apply.mjs +27 -8
  8. package/dist/commands/apply.mjs.map +1 -1
  9. package/dist/commands/branding/eject.mjs +104 -0
  10. package/dist/commands/branding/eject.mjs.map +1 -0
  11. package/dist/commands/claim.mjs +402 -0
  12. package/dist/commands/claim.mjs.map +1 -0
  13. package/dist/commands/doctor.mjs +594 -41
  14. package/dist/commands/doctor.mjs.map +1 -1
  15. package/dist/commands/eject.mjs +18 -6
  16. package/dist/commands/eject.mjs.map +1 -1
  17. package/dist/commands/logs.mjs +2 -2
  18. package/dist/commands/plan.mjs +19 -6
  19. package/dist/commands/plan.mjs.map +1 -1
  20. package/dist/commands/reset.mjs +2 -2
  21. package/dist/commands/schemas/list.mjs +146 -0
  22. package/dist/commands/schemas/list.mjs.map +1 -0
  23. package/dist/commands/setup.mjs +629 -214
  24. package/dist/commands/setup.mjs.map +1 -1
  25. package/dist/commands/start.mjs +4 -4
  26. package/dist/commands/start.mjs.map +1 -1
  27. package/dist/commands/status.mjs +52 -13
  28. package/dist/commands/status.mjs.map +1 -1
  29. package/dist/commands/stop.mjs +3 -3
  30. package/dist/commands/stop.mjs.map +1 -1
  31. package/dist/designs-Ckz18Dpo.mjs +38 -0
  32. package/dist/designs-Ckz18Dpo.mjs.map +1 -0
  33. package/dist/{docker-CnGQK3ZK.mjs → docker-DcRGTOJa.mjs} +5 -4
  34. package/dist/docker-DcRGTOJa.mjs.map +1 -0
  35. package/dist/environment-rjRVkJjW.mjs +17 -0
  36. package/dist/environment-rjRVkJjW.mjs.map +1 -0
  37. package/dist/journey-guidance-BGrOX_gT.mjs +40 -0
  38. package/dist/journey-guidance-BGrOX_gT.mjs.map +1 -0
  39. package/dist/oclif-CanO3zdt.mjs +1769 -0
  40. package/dist/oclif-CanO3zdt.mjs.map +1 -0
  41. package/dist/{orca-U142Wrau.mjs → orca-BzQIQqzt.mjs} +1663 -303
  42. package/dist/orca-BzQIQqzt.mjs.map +1 -0
  43. package/dist/package-manager-DFdLcDx1.mjs +194 -0
  44. package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
  45. package/dist/{ports-B09RjuHx.mjs → ports-BM20XIZb.mjs} +25 -20
  46. package/dist/ports-BM20XIZb.mjs.map +1 -0
  47. package/dist/{processes-Cw8TO1SY.mjs → processes-DKHPkU8O.mjs} +1 -1
  48. package/dist/{processes-Cw8TO1SY.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
  49. package/dist/{project-Cd0L3PtM.mjs → project-CHhot85s.mjs} +57 -4
  50. package/dist/project-CHhot85s.mjs.map +1 -0
  51. package/dist/sync-BBlACKzT.mjs +1633 -0
  52. package/dist/sync-BBlACKzT.mjs.map +1 -0
  53. package/dist/user-schema-DTuOsdKE.mjs +91 -0
  54. package/dist/user-schema-DTuOsdKE.mjs.map +1 -0
  55. package/oclif.manifest.json +406 -5
  56. package/package.json +10 -5
  57. package/dist/docker-CnGQK3ZK.mjs.map +0 -1
  58. package/dist/docker-guidance-ypN3IM3o.mjs +0 -21
  59. package/dist/docker-guidance-ypN3IM3o.mjs.map +0 -1
  60. package/dist/oclif-B7lBzh3R.mjs +0 -808
  61. package/dist/oclif-B7lBzh3R.mjs.map +0 -1
  62. package/dist/orca-U142Wrau.mjs.map +0 -1
  63. package/dist/ports-B09RjuHx.mjs.map +0 -1
  64. package/dist/project-Cd0L3PtM.mjs.map +0 -1
  65. package/dist/sync-BojoQm2P.mjs +0 -733
  66. package/dist/sync-BojoQm2P.mjs.map +0 -1
@@ -1,96 +1,181 @@
1
- import { D as toZitadelError, E as ZitadelError, b as publicCliCommand, n as DEFAULT_SERVER, t as BaseCommand } from "../oclif-B7lBzh3R.mjs";
2
- import { i as issuerFromPort, n as inspectScaffoldTarget, r as RENDERER_IDS, t as createOrca } from "../orca-U142Wrau.mjs";
3
- import { n as hasZitadelSecret, t as hasZitadelConfig } from "../project-Cd0L3PtM.mjs";
4
- import { t as listListeningPorts } from "../ports-B09RjuHx.mjs";
1
+ import { n as brandingDesignLabel, t as BRANDING_DESIGN_INFO } from "../designs-Ckz18Dpo.mjs";
2
+ import { D as toZitadelError, E as ZitadelError, T as stableStringify, j as publicCliCommand, k as normalizePublicCliProse, n as DEFAULT_SERVER, t as BaseCommand, u as detectHealthyLocalServer } from "../oclif-CanO3zdt.mjs";
3
+ import { n as claimCommand, r as claimState, t as claimAction } from "../claim-state-DlfD6Res.mjs";
4
+ import { n as customizeAndPublishActions, r as verifyLoginAction, t as brandingGuidanceAction } from "../journey-guidance-BGrOX_gT.mjs";
5
+ import { i as RENDERER_IDS, n as inspectScaffoldTarget, r as AVAILABLE_RENDERER_IDS, s as issuerFromPort, t as createOrca } from "../orca-BzQIQqzt.mjs";
6
+ import { n as hasZitadelSecret, t as hasZitadelConfig } from "../project-CHhot85s.mjs";
7
+ import { a as updateState, t as SCHEMAS_DIR } from "../user-schema-DTuOsdKE.mjs";
8
+ import { a as runPackageCommand, c as writeScaffoldManifest, i as installCommandFor, n as detectPackageManager, r as devCommandFor } from "../package-manager-DFdLcDx1.mjs";
9
+ import { i as toBrandingWireBody, t as BRANDING_DIR } from "../branding-cygSBPkV.mjs";
10
+ import { c as writeBackResource, o as hashForState, u as FLOWS_DIR } from "../sync-BBlACKzT.mjs";
11
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
12
+ import { basename, dirname, join } from "node:path";
5
13
  import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
6
14
  import { Flags } from "@oclif/core";
7
15
  import { createZitadelClient } from "@zitadel/api/client";
16
+ import { BRANDING_DESIGNS, DEFAULT_BRANDING_CONFIG_PATH, DEFAULT_BRANDING_TEMPLATE_PATH, DEFAULT_FLOW_CONFIG_PATH, DEFAULT_FLOW_SCHEMA_URI, DEFAULT_SCHEMA_CONFIG_PATH, DEFAULT_SETUP_PRESET, DEFAULT_SETUP_USE_CASE, SETUP_PRESETS, SETUP_USE_CASES, brandingReadmeContent, flowsReadmeContent, getDefaultBrandingConfig, getDefaultHumanUserSchema, getDefaultLoginFlow, schemasReadmeContent } from "@zitadel/config/defaults";
8
17
  import { consola as consola$1 } from "consola";
9
- import { basename, join } from "node:path";
10
- import { readFile, stat } from "node:fs/promises";
11
- import { spawn } from "node:child_process";
18
+ import { BRANDING_FILE_SCHEMA_REF } from "@zitadel/config/meta-schemas";
19
+ import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
12
20
  import pc from "picocolors";
13
- //#region src/lib/package-manager.ts
14
- async function detectPackageManager(cwd) {
15
- const declared = await packageManagerFromManifest(cwd);
16
- if (declared) return declared;
17
- if (await exists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
18
- if (await exists(join(cwd, "yarn.lock"))) return "yarn";
19
- if (await exists(join(cwd, "bun.lock"))) return "bun";
20
- if (await exists(join(cwd, "bun.lockb"))) return "bun";
21
- if (await exists(join(cwd, "package-lock.json"))) return "npm";
22
- return "npm";
23
- }
24
- function installCommandFor(packageManager) {
25
- return command(packageManager, ["install"]);
26
- }
27
- function devCommandFor(packageManager) {
28
- switch (packageManager) {
29
- case "npm": return command("npm", ["run", "dev"]);
30
- case "pnpm": return command("pnpm", ["dev"]);
31
- case "yarn": return command("yarn", ["dev"]);
32
- case "bun": return command("bun", ["run", "dev"]);
33
- }
21
+ //#region src/lib/orca/patchers/posture.ts
22
+ /**
23
+ * Frameworks whose patchers add route files without owning the app shell —
24
+ * the only ones where a pre-existing app keeps a layout for the widget
25
+ * posture to inherit (ADR 044). The SPA families write the app's root
26
+ * component, so nothing survives for a widget to embed into; they keep the
27
+ * page posture until a non-destructive route/layout insertion contract
28
+ * exists for their routers.
29
+ */
30
+ const ROUTE_BASED_FRAMEWORKS = new Set(["next", "nuxt"]);
31
+ /**
32
+ * The default embedding posture of the scaffolded auth/profile pages
33
+ * (ADR 044), derived from the same hinge as the framework homepage: whether
34
+ * setup created the app skeleton itself. A fresh scaffold has no design to
35
+ * respect — full-page chrome is the strongest start. A pre-existing
36
+ * route-based app has its own shell and theme, so the pages embed
37
+ * `variant="widget"` cards in a layout-neutral wrapper instead of painting
38
+ * token-colored chrome underneath the host layout.
39
+ *
40
+ * Derived once at setup time and recorded in the scaffold manifest;
41
+ * `doctor --fix` restores from the record rather than re-deriving (a
42
+ * manifest-less legacy scaffold could not answer the hinge).
43
+ */
44
+ function derivePosture(frameworkId, scaffoldedFramework) {
45
+ if (!ROUTE_BASED_FRAMEWORKS.has(frameworkId)) return "page";
46
+ return scaffoldedFramework ? "page" : "widget";
34
47
  }
35
- async function runPackageCommand(packageCommand, options) {
36
- await new Promise((resolve, reject) => {
37
- const child = spawn(packageCommand.command, packageCommand.args, {
38
- cwd: options.cwd,
39
- env: options.env ?? process.env,
40
- stdio: options.redirectStdoutToStderr ? [
41
- "ignore",
42
- "pipe",
43
- "pipe"
44
- ] : "inherit"
45
- });
46
- if (options.redirectStdoutToStderr) {
47
- child.stdout?.on("data", (chunk) => process.stderr.write(chunk));
48
- child.stderr?.on("data", (chunk) => process.stderr.write(chunk));
49
- }
50
- child.on("error", reject);
51
- child.on("close", (code, signal) => {
52
- if (code === 0) {
53
- resolve();
54
- return;
55
- }
56
- const detail = signal ? `signal ${signal}` : `exit ${String(code ?? 1)}`;
57
- const error = /* @__PURE__ */ new Error(`${packageCommand.display} failed with ${detail}`);
58
- Object.assign(error, {
59
- code: code ?? 1,
60
- signal
61
- });
62
- reject(error);
63
- });
48
+ //#endregion
49
+ //#region src/lib/setup-resources.ts
50
+ /**
51
+ * Scaffolds the versioned local default resources for a new project, uploads
52
+ * them through the schema/flow APIs, and seeds `.zitadel/state.json` with the
53
+ * IDs, hashes, and flow metadata the sync engine expects. Setup calls this only
54
+ * after the framework patcher has created `.zitadel/{flows,schemas}` and the
55
+ * initial state file.
56
+ *
57
+ * The schema is uploaded without an `$id`: the server assigns an opaque id on
58
+ * `POST /schemas`, and the flow file can only be rendered after that id comes
59
+ * back because `flow_definition.user_schema` must reference it.
60
+ */
61
+ async function materializeSetupResources(opts) {
62
+ await mkdir(join(opts.cwd, FLOWS_DIR), { recursive: true });
63
+ await mkdir(join(opts.cwd, SCHEMAS_DIR), { recursive: true });
64
+ const filesWritten = [];
65
+ const preset = opts.preset ?? DEFAULT_SETUP_PRESET;
66
+ const useCase = opts.useCase ?? DEFAULT_SETUP_USE_CASE;
67
+ const { $id: _templateId, ...schemaBody } = getDefaultHumanUserSchema({
68
+ preset,
69
+ useCase
64
70
  });
71
+ if (await writeResourceFile(opts.cwd, DEFAULT_SCHEMA_CONFIG_PATH, schemaBody, opts.force)) filesWritten.push(join(opts.cwd, DEFAULT_SCHEMA_CONFIG_PATH));
72
+ const schemaId = requiredString((await opts.client.createSchema(schemaBody, { project_id: opts.projectId })).id, "created schema id");
73
+ let schemaHash = hashForState({ normalize: normalizeSchemaBody }, schemaBody);
74
+ try {
75
+ const canonical = (await opts.client.getSchemaById(encodeURIComponent(schemaId))).schema;
76
+ schemaHash = (await writeBackResource(opts.cwd, DEFAULT_SCHEMA_CONFIG_PATH, { normalize: normalizeSchemaBody }, canonical)).hash;
77
+ } catch (err) {
78
+ consola$1.debug(`fetch created schema ${schemaId} during setup failed:`, err);
79
+ }
80
+ await updateState(opts.cwd, DEFAULT_SCHEMA_CONFIG_PATH, {
81
+ id: schemaId,
82
+ hash: schemaHash
83
+ });
84
+ const flowBody = getDefaultLoginFlow({
85
+ userSchemaUrl: schemaId,
86
+ preset,
87
+ useCase
88
+ });
89
+ if (await writeResourceFile(opts.cwd, DEFAULT_FLOW_CONFIG_PATH, flowBody, opts.force)) filesWritten.push(join(opts.cwd, DEFAULT_FLOW_CONFIG_PATH));
90
+ const flow = await opts.client.createFlowDefinition({
91
+ project_id: opts.projectId,
92
+ schema_uri: DEFAULT_FLOW_SCHEMA_URI,
93
+ flow_definition: flowBody
94
+ });
95
+ let flowHash = hashForState({ normalize: normalizeFlowBody }, flowBody);
96
+ if (flow.flow_definition) flowHash = (await writeBackResource(opts.cwd, DEFAULT_FLOW_CONFIG_PATH, {
97
+ normalize: normalizeFlowBody,
98
+ normalizeWrite: normalizeFlowBody
99
+ }, flow.flow_definition)).hash;
100
+ await updateState(opts.cwd, DEFAULT_FLOW_CONFIG_PATH, {
101
+ id: requiredString(flow.id, "created flow definition id"),
102
+ hash: flowHash,
103
+ name: flowBody.name,
104
+ status: flowBody.status
105
+ });
106
+ if (opts.design) {
107
+ await mkdir(join(opts.cwd, BRANDING_DIR), { recursive: true });
108
+ const { branding, template } = getDefaultBrandingConfig(opts.design);
109
+ const descriptor = {
110
+ $schema: BRANDING_FILE_SCHEMA_REF,
111
+ ...branding
112
+ };
113
+ if (await writeResourceFile(opts.cwd, DEFAULT_BRANDING_CONFIG_PATH, descriptor, opts.force)) filesWritten.push(join(opts.cwd, DEFAULT_BRANDING_CONFIG_PATH));
114
+ if (await writeRawFile(opts.cwd, DEFAULT_BRANDING_TEMPLATE_PATH, template, opts.force)) filesWritten.push(join(opts.cwd, DEFAULT_BRANDING_TEMPLATE_PATH));
115
+ const brandingNormalize = (data) => toBrandingWireBody(opts.cwd, data);
116
+ const created = await opts.client.createBranding(brandingNormalize(descriptor), { project_id: opts.projectId });
117
+ await updateState(opts.cwd, DEFAULT_BRANDING_CONFIG_PATH, {
118
+ id: requiredString(created.id, "created branding revision id"),
119
+ hash: hashForState({ normalize: brandingNormalize }, descriptor)
120
+ });
121
+ const brandingReadme = join(BRANDING_DIR, "README.md");
122
+ if (await writeReadmeFile(opts.cwd, brandingReadme, normalizePublicCliProse(brandingReadmeContent(), opts.cliVersion))) filesWritten.push(join(opts.cwd, brandingReadme));
123
+ }
124
+ const schemasReadme = join(SCHEMAS_DIR, "README.md");
125
+ const flowsReadme = join(FLOWS_DIR, "README.md");
126
+ if (await writeReadmeFile(opts.cwd, schemasReadme, normalizePublicCliProse(schemasReadmeContent(), opts.cliVersion))) filesWritten.push(join(opts.cwd, schemasReadme));
127
+ if (await writeReadmeFile(opts.cwd, flowsReadme, normalizePublicCliProse(flowsReadmeContent(), opts.cliVersion))) filesWritten.push(join(opts.cwd, flowsReadme));
128
+ return { filesWritten };
65
129
  }
66
- function command(packageManager, args) {
67
- return {
68
- command: packageManager,
69
- args,
70
- display: [packageManager, ...args].join(" ")
71
- };
72
- }
73
- async function packageManagerFromManifest(cwd) {
130
+ /**
131
+ * Write a non-JSON scaffold file (the `.liquid` template) with the same
132
+ * conflict semantics as {@link writeResourceFile}: `--force` overwrites,
133
+ * otherwise an existing file is an `E_CONFLICT`.
134
+ */
135
+ async function writeRawFile(cwd, relPath, content, force) {
136
+ const dest = join(cwd, relPath);
137
+ await mkdir(dirname(dest), { recursive: true });
74
138
  try {
75
- const raw = await readFile(join(cwd, "package.json"), "utf8");
76
- const parsed = JSON.parse(raw);
77
- return typeof parsed.packageManager === "string" ? packageManagerFromString(parsed.packageManager) : void 0;
78
- } catch {
79
- return;
139
+ await writeFile(dest, content, force ? void 0 : { flag: "wx" });
140
+ return true;
141
+ } catch (error) {
142
+ if (isErrno(error, "EEXIST")) throw new ZitadelError("E_CONFLICT", `${relPath} already exists`, { hint: "Move the file aside or rerun setup with --force if you want setup to replace it." });
143
+ throw error;
80
144
  }
81
145
  }
82
- function packageManagerFromString(value) {
83
- const name = value.split("@")[0];
84
- return name === "npm" || name === "pnpm" || name === "yarn" || name === "bun" ? name : void 0;
146
+ /**
147
+ * Write a README file, but never overwrite an existing one. A developer who
148
+ * has edited the README should keep their edits when `setup --force` is
149
+ * re-run.
150
+ */
151
+ async function writeReadmeFile(cwd, relPath, content) {
152
+ const dest = join(cwd, relPath);
153
+ await mkdir(dirname(dest), { recursive: true });
154
+ try {
155
+ await writeFile(dest, content, { flag: "wx" });
156
+ return true;
157
+ } catch (error) {
158
+ if (isErrno(error, "EEXIST")) return false;
159
+ throw error;
160
+ }
85
161
  }
86
- async function exists(path) {
162
+ async function writeResourceFile(cwd, relPath, body, force) {
163
+ const contents = `${stableStringify(body)}\n`;
87
164
  try {
88
- await stat(path);
165
+ await writeFile(join(cwd, relPath), contents, force ? void 0 : { flag: "wx" });
89
166
  return true;
90
- } catch {
91
- return false;
167
+ } catch (error) {
168
+ if (isErrno(error, "EEXIST")) throw new ZitadelError("E_CONFLICT", `${relPath} already exists`, { hint: "Move the file aside or rerun setup with --force if you want setup to replace it." });
169
+ throw error;
92
170
  }
93
171
  }
172
+ function requiredString(value, label) {
173
+ if (typeof value === "string" && value.length > 0) return value;
174
+ throw new ZitadelError("E_VALIDATION", `Missing ${label} in server response.`);
175
+ }
176
+ function isErrno(error, code) {
177
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
178
+ }
94
179
  //#endregion
95
180
  //#region src/commands/setup/install.ts
96
181
  async function installDependenciesForSetup(input) {
@@ -105,6 +190,7 @@ async function installDependenciesForSetup(input) {
105
190
  reason: "no-dependency-changes"
106
191
  },
107
192
  devCommand: devCommand.display,
193
+ cliVersion: input.cliVersion,
108
194
  issuer: input.issuer,
109
195
  includeInstallCommand: false
110
196
  });
@@ -116,6 +202,7 @@ async function installDependenciesForSetup(input) {
116
202
  reason: input.dryRun ? "dry-run" : "skip-install"
117
203
  },
118
204
  devCommand: devCommand.display,
205
+ cliVersion: input.cliVersion,
119
206
  issuer: input.issuer,
120
207
  includeInstallCommand: true
121
208
  });
@@ -137,22 +224,30 @@ async function installDependenciesForSetup(input) {
137
224
  command: installCommand.display
138
225
  },
139
226
  devCommand: devCommand.display,
227
+ cliVersion: input.cliVersion,
140
228
  issuer: input.issuer,
141
229
  includeInstallCommand: false
142
230
  });
143
231
  }
144
232
  function outcome(input) {
145
- const startAction = `Start your project: ${input.devCommand} (then open ${input.issuer})`;
146
- const verifyAction = "Verify auth in the browser: register a user, log out, log in again with the same user, and confirm /profile shows Signed in.";
233
+ const planCommand = publicCliCommand("plan", input.cliVersion);
234
+ const statusCommand = publicCliCommand("status", input.cliVersion);
235
+ const verifyActions = [
236
+ ...input.includeInstallCommand ? [`Install dependencies: ${input.install.command}`] : [],
237
+ `Start your project: ${input.devCommand} (then open ${input.issuer}/login)`,
238
+ verifyLoginAction()
239
+ ];
240
+ const breadcrumb = `Once login works: ${statusCommand} shows your next steps; customizing is covered in your README's Zitadel section.`;
147
241
  return {
148
242
  install: input.install,
149
243
  devCommand: input.devCommand,
150
- nextActions: input.includeInstallCommand ? [
151
- `Install dependencies: ${input.install.command}`,
152
- startAction,
153
- verifyAction
154
- ] : [startAction, verifyAction],
155
- nextCommands: input.includeInstallCommand ? [input.install.command, input.devCommand] : [input.devCommand]
244
+ boxActions: [...verifyActions, breadcrumb],
245
+ nextActions: [...verifyActions, ...customizeAndPublishActions(input.cliVersion)],
246
+ nextCommands: input.includeInstallCommand ? [
247
+ input.install.command,
248
+ input.devCommand,
249
+ planCommand
250
+ ] : [input.devCommand, planCommand]
156
251
  };
157
252
  }
158
253
  function installFailed(error, installCommand, devCommand, cwd) {
@@ -188,6 +283,54 @@ function bail(value) {
188
283
  }
189
284
  }
190
285
  //#endregion
286
+ //#region src/commands/setup/prompts/design.ts
287
+ /**
288
+ * Sentinel select value for "keep the built-in login" — clack option values
289
+ * must be strings, and `undefined` in {@link SetupAnswers.design} is the
290
+ * real representation of that choice.
291
+ */
292
+ const BUILT_IN = "built-in";
293
+ /**
294
+ * "How should the login look?" — the last wizard question (#676). Asks after
295
+ * the sign-in preset so the order tells a story: the use case owns what is
296
+ * collected, the preset owns how users authenticate, the design owns how
297
+ * that experience looks.
298
+ *
299
+ * The built-in default is preselected and writes nothing. Picking a starter
300
+ * design forks its Liquid template into `.zitadel/branding/` and publishes
301
+ * it as branding revision 1 — from then on the template is repo-owned and
302
+ * stops tracking built-in improvements, so the option labels make the
303
+ * file-write explicit and keep ownership an opt-in. `--design` is
304
+ * authoritative; non-interactive runs keep the built-in template.
305
+ */
306
+ var DesignPrompt = class {
307
+ async ask(answers, ctx) {
308
+ if (ctx.designFromFlag) return answers;
309
+ const value = await select({
310
+ message: "How should the login look? A starter design adds its editable template to .zitadel/branding/.",
311
+ initialValue: answers.design ?? BUILT_IN,
312
+ options: [{
313
+ value: BUILT_IN,
314
+ label: "Built-in",
315
+ hint: "no files added; pick a design anytime later with `branding eject`"
316
+ }, ...BRANDING_DESIGNS.map((design) => ({
317
+ value: design,
318
+ label: BRANDING_DESIGN_INFO[design].label,
319
+ hint: BRANDING_DESIGN_INFO[design].hint
320
+ }))]
321
+ });
322
+ bail(value);
323
+ if (value === BUILT_IN) return {
324
+ ...answers,
325
+ design: void 0
326
+ };
327
+ return {
328
+ ...answers,
329
+ design: value
330
+ };
331
+ }
332
+ };
333
+ //#endregion
191
334
  //#region src/commands/setup/prompts/dev-port.ts
192
335
  /**
193
336
  * "Dev server port" — defaults to the detected port. The validated answer
@@ -231,64 +374,27 @@ var FrameworkConfirmPrompt = class {
231
374
  }
232
375
  };
233
376
  //#endregion
234
- //#region src/lib/prober/http.ts
235
- /**
236
- * Fetch `url` with a per-call timeout and pass the resulting `Response` to
237
- * `predicate`. Returns the predicate's value, or `null` on any failure: a
238
- * network error, an `AbortError` from the timeout, a thrown predicate, or the
239
- * predicate returning `null`.
240
- *
241
- * Never throws. The predicate decides what counts as a match — it gets the
242
- * raw `Response` and may read `.json()` / `.headers` / `.status` as needed.
243
- *
244
- * `timeoutMs` defaults to 500ms. The implementation uses
245
- * `AbortSignal.timeout`, so the underlying fetch is cancelled when the
246
- * timeout fires (no orphaned sockets).
247
- */
248
- async function probeUrl(url, predicate, opts) {
249
- const timeoutMs = opts?.timeoutMs ?? 500;
250
- try {
251
- return await predicate(await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }));
252
- } catch {
253
- return null;
254
- }
255
- }
256
- /**
257
- * Probe many URLs in parallel and return the non-null matches, preserving
258
- * the input order. Each URL is bounded by `timeoutMs` independently — one
259
- * slow target can't hold up the others.
260
- */
261
- async function probeUrls(urls, predicate, opts) {
262
- const list = [...urls];
263
- return (await Promise.all(list.map(async (url) => {
264
- const value = await probeUrl(url, predicate, opts);
265
- return value === null ? null : {
266
- url,
267
- value
268
- };
269
- }))).filter((match) => match !== null);
270
- }
271
- //#endregion
272
377
  //#region src/commands/setup/prompts/server.ts
273
378
  /** Sentinel returned by the choice select when the user picks "Custom URL". */
274
379
  const CUSTOM = "__custom__";
275
- /** Per-probe timeout for the localhost OIDC scan — generous enough for a TLS handshake on a busy laptop. */
276
- const OIDC_PROBE_TIMEOUT_MS = 300;
277
380
  /**
278
- * "Which server should `zitadel.json` point to?" — Zitadel Cloud, a localhost
279
- * OIDC server we discovered, or a custom URL.
381
+ * "Which server should `zitadel.json` point to?" — Zitadel Cloud, the managed
382
+ * local server we detected, or a custom URL.
280
383
  *
281
- * Before asking, it scans the loopback for listening ports (via the generic
282
- * `lib/prober`) and `GET`s `/.well-known/openid-configuration` on each. Every
283
- * 2xx response with a string `issuer` becomes an extra option in the choice
284
- * list. Picking a discovered URL writes it directly to `answers.server` and
285
- * skips the follow-up text prompt. Picking "Custom URL" still asks for a
384
+ * Before asking, it looks for a healthy local Zitadel server the same way the
385
+ * rest of the CLI does (`detectHealthyLocalServer`: the runtime metadata that
386
+ * `zitadel start` wrote to `.zitadel/local/runtime.json`, falling back to a
387
+ * `/healthz` probe on the default localhost port). A detected server becomes
388
+ * an extra option in the choice list *and* the preselected answer a user
389
+ * who just ran `zitadel start` almost certainly wants it, and `start`'s own
390
+ * next-step hint says `setup --server local`. Picking it writes the URL
391
+ * directly to `answers.server`; picking "Custom URL" still asks for a
286
392
  * validated URL exactly as before.
287
393
  */
288
394
  var ServerPrompt = class {
289
395
  async ask(answers, ctx) {
290
396
  if (ctx.serverFlag) return answers;
291
- const discovered = await discoverLocalOidc();
397
+ const detected = await detectLocalServer(ctx.cwd);
292
398
  const choice = await select({
293
399
  message: "Which server should zitadel.json point to?",
294
400
  options: [
@@ -297,17 +403,17 @@ var ServerPrompt = class {
297
403
  label: "Zitadel Cloud (api.zitadel.cloud)",
298
404
  hint: "recommended for real projects"
299
405
  },
300
- ...discovered.map((server) => ({
301
- value: server,
302
- label: server,
303
- hint: "detected — OIDC"
304
- })),
406
+ ...detected ? [{
407
+ value: detected,
408
+ label: `Local Zitadel server (${detected})`,
409
+ hint: "detected — started with zitadel start"
410
+ }] : [],
305
411
  {
306
412
  value: CUSTOM,
307
413
  label: "Custom URL (self-hosted)"
308
414
  }
309
415
  ],
310
- initialValue: answers.server ?? "https://api.zitadel.cloud"
416
+ initialValue: detected ?? answers.server ?? "https://api.zitadel.cloud"
311
417
  });
312
418
  bail(choice);
313
419
  if (choice !== CUSTOM) return {
@@ -334,38 +440,89 @@ var ServerPrompt = class {
334
440
  }
335
441
  };
336
442
  /**
337
- * Returns the loopback origins (`http://localhost:<port>`) whose
338
- * `/.well-known/openid-configuration` endpoint responds with a valid OIDC
339
- * discovery document. Sniffs purely via the generic prober — the only
340
- * Zitadel-shaped detail here is the OIDC predicate, which is the standard
341
- * "any OIDC server" contract (no `zitadel`-keyword check), so Keycloak/dex/
342
- * etc. also surface and the user picks the right one.
443
+ * Wraps {@link detectHealthyLocalServer} in a spinner: the unhealthy paths
444
+ * block on up to two 1.5s `/healthz` probes, long enough that the wizard
445
+ * should say what it is doing.
343
446
  */
344
- async function discoverLocalOidc() {
447
+ async function detectLocalServer(cwd) {
345
448
  const s = spinner();
346
- s.start("Scanning localhost for OIDC servers");
347
- try {
348
- const ports = await listListeningPorts();
349
- if (ports.length === 0) {
350
- s.stop("No local servers detected.");
351
- return [];
352
- }
353
- const origins = (await probeUrls(ports.map((port) => `http://localhost:${port}/.well-known/openid-configuration`), async (response) => {
354
- if (!response.ok) return null;
355
- try {
356
- const body = await response.json();
357
- return typeof body.issuer === "string" ? body.issuer : null;
358
- } catch {
359
- return null;
449
+ s.start("Checking for a local Zitadel server");
450
+ const detected = await detectHealthyLocalServer(cwd);
451
+ s.stop(detected ? `Found local Zitadel server at ${detected}.` : "No local Zitadel server detected.");
452
+ return detected;
453
+ }
454
+ //#endregion
455
+ //#region src/commands/setup/prompts/sign-in-preset.ts
456
+ /**
457
+ * "How should users sign in?" — picks the schema+flow preset the scaffold
458
+ * starts from (#448: app-type/preset selection before any `.zitadel/` file
459
+ * is written). `--preset` is authoritative; non-interactive runs keep the
460
+ * password-first default seeded by the command.
461
+ */
462
+ var SignInPresetPrompt = class {
463
+ async ask(answers, ctx) {
464
+ if (ctx.presetFromFlag) return answers;
465
+ const labels = {
466
+ "password-first": "Password first — email + password, passkey optional during registration",
467
+ "passkey-first": "Passkey first — one-tap passkey up front, email + password fallback"
468
+ };
469
+ const value = await select({
470
+ message: "How should users sign in?",
471
+ initialValue: answers.preset,
472
+ options: SETUP_PRESETS.map((preset) => ({
473
+ value: preset,
474
+ label: labels[preset]
475
+ }))
476
+ });
477
+ bail(value);
478
+ return {
479
+ ...answers,
480
+ preset: value
481
+ };
482
+ }
483
+ };
484
+ //#endregion
485
+ //#region src/commands/setup/prompts/use-case.ts
486
+ /**
487
+ * "Who will sign in to your app?" — picks the use case, which owns the
488
+ * schema field set the scaffold collects (#448). Orthogonal to the sign-in
489
+ * preset, so this asks first: what a user *is* before *how* they sign in.
490
+ * `--use-case` is authoritative; non-interactive runs keep the minimal
491
+ * default seeded by the command.
492
+ */
493
+ var UseCasePrompt = class {
494
+ async ask(answers, ctx) {
495
+ if (ctx.useCaseFromFlag) return answers;
496
+ const options = {
497
+ minimal: {
498
+ label: "Just me or a small group",
499
+ hint: "collects email"
500
+ },
501
+ consumer: {
502
+ label: "Consumers",
503
+ hint: "collects email, given & family name"
504
+ },
505
+ business: {
506
+ label: "Business",
507
+ hint: "collects email, given & family name, company"
360
508
  }
361
- }, { timeoutMs: OIDC_PROBE_TIMEOUT_MS })).map((match) => new URL(match.url).origin);
362
- s.stop(origins.length === 0 ? "No local OIDC servers found." : `Found ${origins.length} local OIDC server${origins.length === 1 ? "" : "s"}.`);
363
- return origins;
364
- } catch (error) {
365
- s.stop("Discovery skipped.");
366
- throw error;
509
+ };
510
+ const value = await select({
511
+ message: "Who will sign in to your app? Collected fields stay editable after setup.",
512
+ initialValue: answers.useCase,
513
+ options: SETUP_USE_CASES.map((useCase) => ({
514
+ value: useCase,
515
+ label: options[useCase].label,
516
+ hint: options[useCase].hint
517
+ }))
518
+ });
519
+ bail(value);
520
+ return {
521
+ ...answers,
522
+ useCase: value
523
+ };
367
524
  }
368
- }
525
+ };
369
526
  //#endregion
370
527
  //#region src/commands/setup/prompts/pick-framework.ts
371
528
  /**
@@ -405,11 +562,24 @@ var PickFrameworkPrompt = class {
405
562
  const SETUP_PROMPTS = [
406
563
  new FrameworkConfirmPrompt(),
407
564
  new ServerPrompt(),
408
- new DevPortPrompt()
565
+ new DevPortPrompt(),
566
+ new UseCasePrompt(),
567
+ new SignInPresetPrompt(),
568
+ new DesignPrompt()
409
569
  ];
410
570
  //#endregion
411
571
  //#region src/commands/setup/summary.ts
412
572
  /**
573
+ * Renders a path under `cwd` with stable POSIX separators. Setup's summary
574
+ * matches generated artifacts against repository-style suffixes such as
575
+ * `.zitadel/schemas/default-human-user.json`; normalizing here keeps those
576
+ * matches working when Node returns Windows paths with backslashes.
577
+ */
578
+ function relativeDisplayPath(cwd, absolute) {
579
+ const boundary = absolute[cwd.length];
580
+ return (absolute.startsWith(cwd) && (boundary === "/" || boundary === "\\") ? absolute.slice(cwd.length + 1) : absolute).replaceAll("\\", "/");
581
+ }
582
+ /**
413
583
  * Renders the section list as a single multi-line string. Labels are
414
584
  * padded to a common width per section so the `✓ label value` columns
415
585
  * line up, the title prints in dim gray, and `✓` is green. Values come
@@ -438,6 +608,8 @@ const path = (s) => pc.cyan(s);
438
608
  const url = (s) => pc.cyan(s);
439
609
  /** Yellow, for opaque ids the user shouldn't try to read. */
440
610
  const id = (s) => pc.yellow(s);
611
+ /** Dim, for separators and secondary detail. */
612
+ const dim = (s) => pc.dim(s);
441
613
  /**
442
614
  * Reads the project root to identify the framework version, TS presence,
443
615
  * and which package manager the user runs. Returns safe defaults (npm
@@ -498,6 +670,29 @@ function stripRange(range) {
498
670
  function fileNameOf(p) {
499
671
  return basename(p);
500
672
  }
673
+ /**
674
+ * Layout caveats for the chosen login design, shown to humans after the
675
+ * summary box and carried in the JSON envelope's `warnings`.
676
+ *
677
+ * The split family's brand pane is keyed to the **login's own container**
678
+ * width — a `@container (max-width: 48rem)` query on the widget's mount, not
679
+ * a viewport media query. Above that the pane renders; below it the pane is
680
+ * `display: none` and the compact brand mark takes its place — and the split
681
+ * template only emits that mark when `branding.json` names `logo_url` or
682
+ * `hero_url`, so without one the narrow layout loses the branding entirely.
683
+ *
684
+ * Independent of posture, because the container query is: a full-page login
685
+ * hits the same collapse on a phone that an embedded card hits in a sidebar.
686
+ * Gating on `widget` would have been a guess about the container, and a wrong
687
+ * one in both directions — the wrapper setup scaffolds for a widget is
688
+ * full-width (the pane renders there), and a page-posture login on a phone is
689
+ * exactly the narrow case the advice is for. `hero` stays quiet: its compact
690
+ * fallback is editable text, so a narrow container never leaves it blank.
691
+ */
692
+ function designWarnings(design) {
693
+ if (design !== "split" && design !== "split-right") return [];
694
+ return [`The ${design} design shows its brand pane only when the login's container is wide; a narrow container — a card-width embed, or any phone-width viewport — collapses it to the compact brand mark. Set logo_url (or hero_url) in .zitadel/branding/branding.json so that mark isn't empty.`];
695
+ }
501
696
  //#endregion
502
697
  //#region src/commands/setup/index.ts
503
698
  /**
@@ -507,13 +702,23 @@ function fileNameOf(p) {
507
702
  * load.
508
703
  */
509
704
  const FRAMEWORK_OPTIONS = createOrca().availableFrameworks().map((framework) => framework.id);
705
+ /**
706
+ * `--renderer` offers only ids `getRenderer` will resolve: a
707
+ * declared-but-unpublished renderer (ADR 006) keeps its registry entry to
708
+ * reserve the id, but is surfaced as unavailable in the flag description
709
+ * instead of in `options`, so `--help` never advertises a value that is
710
+ * guaranteed to fail and an explicit pass is rejected at parse time — before
711
+ * any remote project is created.
712
+ */
713
+ const UNAVAILABLE_RENDERER_IDS = RENDERER_IDS.filter((id) => !AVAILABLE_RENDERER_IDS.includes(id));
714
+ const RENDERER_FLAG_DESCRIPTION = UNAVAILABLE_RENDERER_IDS.length === 0 ? "Renderer (default: react)." : `Renderer (default: react). Not yet available: ${UNAVAILABLE_RENDERER_IDS.join(", ")}.`;
510
715
  /** `zitadel setup` — create a project and scaffold local auth.
511
716
  *
512
717
  * Detects (or, for an empty directory, scaffolds then re-detects) the
513
718
  * framework, runs the wizard prompts to fill in any answers not pre-supplied
514
- * by flags, creates the remote project (whose default user schema and login
515
- * flow are provisioned server-side), and patches the local files via
516
- * `Orca`'s framework patcher.
719
+ * by flags, creates the remote project without server fallback defaults,
720
+ * patches the local files via `Orca`'s framework patcher, then scaffolds and
721
+ * uploads editable schema/flow config from `.zitadel/**`.
517
722
  *
518
723
  * Every interactive question lives in {@link SETUP_PROMPTS} (the main wizard
519
724
  * — each entry is a small class) and {@link PickFrameworkPrompt} (the
@@ -528,18 +733,30 @@ var Setup = class Setup extends BaseCommand {
528
733
  options: FRAMEWORK_OPTIONS
529
734
  }),
530
735
  renderer: Flags.string({
531
- description: "Renderer (default: react).",
532
- options: [...RENDERER_IDS]
736
+ description: RENDERER_FLAG_DESCRIPTION,
737
+ options: [...AVAILABLE_RENDERER_IDS]
533
738
  }),
534
739
  "dev-port": Flags.integer({ description: "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." }),
535
- "skip-install": Flags.boolean({ description: "Do not install dependencies after setup updates package.json." })
740
+ "skip-install": Flags.boolean({ description: "Do not install dependencies after setup updates package.json." }),
741
+ preset: Flags.string({
742
+ description: "Sign-in preset for the scaffolded schema and login flow (default: password-first).",
743
+ options: [...SETUP_PRESETS]
744
+ }),
745
+ "use-case": Flags.string({
746
+ description: "Use case for the scaffolded schema fields: who signs in to the app (default: minimal).",
747
+ options: [...SETUP_USE_CASES]
748
+ }),
749
+ design: Flags.string({
750
+ description: "Login design to eject into .zitadel/branding/ and publish as branding revision 1. Skips the wizard's design question. When omitted in non-interactive runs, the login uses the built-in template; run the `branding eject` command later to customize. Split-family designs (split, split-right, hero) collapse their brand pane by container width: narrow containers — including widget-posture embeds at card width — render the compact brand mark instead (logo_url, else hero_url, from .zitadel/branding/branding.json; hero falls back to editable text).",
751
+ options: [...BRANDING_DESIGNS]
752
+ })
536
753
  };
537
754
  async run() {
538
755
  const { flags } = await this.parse(Setup);
539
756
  try {
540
757
  await this.toMeta(flags);
541
758
  } catch (error) {
542
- throw localSetupHint(error, flags.framework, this.config.version);
759
+ throw localSetupHint(error, retryOptionsFromFlags(flags), this.config.version);
543
760
  }
544
761
  const { cwd, nonInteractive, dryRun, force } = this.meta;
545
762
  if (await hasZitadelConfig(cwd)) return this.emit({
@@ -564,6 +781,17 @@ var Setup = class Setup extends BaseCommand {
564
781
  consola$1.success(`Scaffolded ${framework.id} skeleton`);
565
782
  } else throw error;
566
783
  }
784
+ this.recordTelemetry({
785
+ framework: framework.id,
786
+ renderer: flags.renderer ?? "react",
787
+ scaffolded_skeleton: scaffoldedFramework,
788
+ skip_install: Boolean(flags["skip-install"]),
789
+ dev_port_explicit: flags["dev-port"] !== void 0,
790
+ preset: flags.preset ?? DEFAULT_SETUP_PRESET,
791
+ use_case: flags["use-case"] ?? DEFAULT_SETUP_USE_CASE,
792
+ design: flags.design ?? "built-in",
793
+ step: "framework_resolved"
794
+ });
567
795
  if (flags["dev-port"] !== void 0) {
568
796
  const devPort = flags["dev-port"];
569
797
  if (!Number.isInteger(devPort) || devPort < 1 || devPort > 65535) throw new ZitadelError("E_VALIDATION", `--dev-port must be an integer in 1..65535, got ${devPort}`);
@@ -575,18 +803,30 @@ var Setup = class Setup extends BaseCommand {
575
803
  }
576
804
  let answers = {
577
805
  server: this.meta.source,
578
- devPort: framework.devPort
806
+ devPort: framework.devPort,
807
+ preset: flags.preset ?? DEFAULT_SETUP_PRESET,
808
+ useCase: flags["use-case"] ?? DEFAULT_SETUP_USE_CASE,
809
+ design: flags.design
579
810
  };
580
811
  if (!nonInteractive && !dryRun) {
581
812
  intro("Zitadel setup");
582
813
  const promptCtx = {
583
814
  framework,
815
+ cwd,
584
816
  serverFlag: this.meta.serverFlag,
585
- devPortFromFlag: flags["dev-port"] !== void 0
817
+ devPortFromFlag: flags["dev-port"] !== void 0,
818
+ presetFromFlag: flags.preset !== void 0,
819
+ useCaseFromFlag: flags["use-case"] !== void 0,
820
+ designFromFlag: flags.design !== void 0
586
821
  };
587
822
  for (const prompt of SETUP_PROMPTS) answers = await prompt.ask(answers, promptCtx);
588
823
  outro("Configuration captured");
589
824
  }
825
+ this.recordTelemetry({
826
+ preset: answers.preset,
827
+ use_case: answers.useCase,
828
+ design: answers.design ?? "built-in"
829
+ });
590
830
  const issuer = issuerFromPort(answers.devPort);
591
831
  framework = {
592
832
  ...framework,
@@ -595,8 +835,18 @@ var Setup = class Setup extends BaseCommand {
595
835
  };
596
836
  consola$1.start(`Creating project on ${answers.server}${dryRun ? " (dry run)" : ""}`);
597
837
  const unauthClient = createZitadelClient({ baseUrl: answers.server });
598
- const project = dryRun ? dryRunProject(issuer) : await createProjectWithLocalHint(unauthClient, answers.server, this.meta.cliVersion, issuer, framework.id);
838
+ const projectName = defaultProjectName(cwd, framework.id);
839
+ const project = dryRun ? dryRunProject(issuer) : await createProjectWithLocalHint(unauthClient, answers.server, this.meta.cliVersion, projectName, issuer, {
840
+ ...retryOptionsFromFlags(flags),
841
+ framework: framework.id,
842
+ preset: answers.preset,
843
+ useCase: answers.useCase,
844
+ design: answers.design,
845
+ devPort: answers.devPort
846
+ });
599
847
  consola$1.success(`Created project ${project.id}`);
848
+ this.recordTelemetry({ step: "project_created" });
849
+ const posture = derivePosture(framework.id, scaffoldedFramework);
600
850
  const ctx = {
601
851
  framework,
602
852
  rendererId: flags.renderer ?? "react",
@@ -604,7 +854,10 @@ var Setup = class Setup extends BaseCommand {
604
854
  issuer,
605
855
  server: answers.server,
606
856
  cliVersion: this.meta.cliVersion,
607
- scaffoldedFramework
857
+ scaffoldedFramework,
858
+ posture,
859
+ preset: answers.preset,
860
+ useCase: answers.useCase
608
861
  };
609
862
  consola$1.start(`Patching project files${dryRun ? " (dry run)" : ""}`);
610
863
  const result = await orca.patcherFor(framework.id).patch(ctx, {
@@ -613,12 +866,62 @@ var Setup = class Setup extends BaseCommand {
613
866
  force
614
867
  });
615
868
  for (const file of result.filesWritten) {
616
- const sentence = describeWrittenFile(relativeDisplay(cwd, file), dryRun);
869
+ const sentence = describeWrittenFile(relativeDisplayPath(cwd, file), dryRun);
617
870
  if (sentence) consola$1.info(sentence);
618
871
  }
619
- for (const file of result.filesSkipped) consola$1.info(`Left ${relativeDisplay(cwd, file)} unchanged (already matches target)`);
620
- consola$1.success(`Patched ${result.filesWritten.length} file${result.filesWritten.length === 1 ? "" : "s"}` + (result.filesSkipped.length > 0 ? ` (${result.filesSkipped.length} unchanged)` : ""));
872
+ for (const file of result.filesSkipped) consola$1.info(`Left ${relativeDisplayPath(cwd, file)} unchanged (already matches target)`);
873
+ let resourceResult;
874
+ try {
875
+ resourceResult = dryRun ? { filesWritten: [] } : await materializeSetupResources({
876
+ cwd,
877
+ client: createZitadelClient({
878
+ baseUrl: answers.server,
879
+ token: project.project_secret
880
+ }),
881
+ projectId: project.id,
882
+ force,
883
+ preset: answers.preset,
884
+ useCase: answers.useCase,
885
+ design: answers.design,
886
+ cliVersion: this.meta.cliVersion
887
+ });
888
+ } catch (error) {
889
+ await rm(join(cwd, "zitadel.json"), { force: true });
890
+ await rm(join(cwd, ".zitadel/secret"), { force: true });
891
+ const cause = toZitadelError(error);
892
+ throw new ZitadelError(cause.code, `Default resource setup failed: ${cause.message}`, {
893
+ hint: "The project was created but its default schema/flow upload did not finish. Re-run `zitadel setup` to start over (add --force to overwrite partially written .zitadel files).",
894
+ nextCommands: ["zitadel setup --force"],
895
+ details: cause.details
896
+ });
897
+ }
898
+ for (const file of resourceResult.filesWritten) {
899
+ const sentence = describeWrittenFile(relativeDisplayPath(cwd, file), dryRun);
900
+ if (sentence) consola$1.info(sentence);
901
+ }
902
+ const allFilesWritten = [...result.filesWritten, ...resourceResult.filesWritten];
903
+ consola$1.success(`Patched ${allFilesWritten.length} file${allFilesWritten.length === 1 ? "" : "s"}` + (result.filesSkipped.length > 0 ? ` (${result.filesSkipped.length} unchanged)` : ""));
904
+ this.recordTelemetry({
905
+ step: "files_patched",
906
+ files_written_count: allFilesWritten.length
907
+ });
908
+ if (!dryRun) try {
909
+ await writeScaffoldManifest({
910
+ cwd,
911
+ actions: orca.patcherFor(framework.id).artifacts({
912
+ framework,
913
+ rendererId: ctx.rendererId
914
+ }),
915
+ written: [...result.filesWritten, ...result.filesSkipped],
916
+ scaffoldedFramework,
917
+ devPort: answers.devPort,
918
+ posture
919
+ });
920
+ } catch (error) {
921
+ consola$1.debug("Failed to record the scaffold manifest", error);
922
+ }
621
923
  const installOutcome = await installDependenciesForSetup({
924
+ cliVersion: this.meta.cliVersion,
622
925
  cwd,
623
926
  depsAdded: result.depsAdded,
624
927
  dryRun,
@@ -628,7 +931,22 @@ var Setup = class Setup extends BaseCommand {
628
931
  scaffoldedFramework,
629
932
  skipInstall: Boolean(flags["skip-install"])
630
933
  });
631
- const writtenRel = result.filesWritten.map((file) => relativeDisplay(cwd, file));
934
+ this.recordTelemetry({
935
+ step: "dependencies_installed",
936
+ package_manager: installOutcome.install.package_manager
937
+ });
938
+ const writtenRel = allFilesWritten.map((file) => relativeDisplayPath(cwd, file));
939
+ const claimNudge = claimState({
940
+ secret: {},
941
+ server: answers.server
942
+ }).kind === "detached" ? {
943
+ actions: [claimAction(this.meta.cliVersion)],
944
+ commands: [claimCommand(this.meta.cliVersion)]
945
+ } : {
946
+ actions: [],
947
+ commands: []
948
+ };
949
+ const warnings = designWarnings(answers.design);
632
950
  if (!this.jsonEnabled()) {
633
951
  const sections = buildSummary({
634
952
  projectFacts: await detectProjectFacts(cwd, framework.id),
@@ -636,14 +954,15 @@ var Setup = class Setup extends BaseCommand {
636
954
  project,
637
955
  server: answers.server,
638
956
  issuer,
639
- scaffoldedFramework
957
+ scaffoldedFramework,
958
+ design: answers.design
640
959
  });
641
960
  consola$1.box({
642
961
  title: "Zitadel is ready",
643
962
  message: [
644
963
  renderSummary(sections),
645
964
  "",
646
- installOutcome.nextActions.join("\n")
965
+ [...installOutcome.boxActions, ...claimNudge.actions].join("\n")
647
966
  ].join("\n"),
648
967
  style: {
649
968
  padding: 1,
@@ -651,9 +970,11 @@ var Setup = class Setup extends BaseCommand {
651
970
  borderColor: "green"
652
971
  }
653
972
  });
973
+ for (const warning of warnings) consola$1.warn(warning);
654
974
  }
655
975
  return this.emit({
656
976
  status: "ok",
977
+ warnings,
657
978
  pretty: "",
658
979
  data: {
659
980
  title: "Zitadel is ready.",
@@ -663,11 +984,21 @@ var Setup = class Setup extends BaseCommand {
663
984
  },
664
985
  framework: framework.id,
665
986
  server: answers.server,
666
- files_written: result.filesWritten.map((file) => relativeDisplay(cwd, file)),
667
- files_skipped: result.filesSkipped.map((file) => relativeDisplay(cwd, file)),
987
+ files_written: allFilesWritten.map((file) => relativeDisplayPath(cwd, file)),
988
+ files: result.files.map((file) => ({
989
+ path: relativeDisplayPath(cwd, file.path),
990
+ kind: file.kind,
991
+ action: file.action
992
+ })),
993
+ files_skipped: result.filesSkipped.map((file) => relativeDisplayPath(cwd, file)),
668
994
  install: installOutcome.install,
669
- next_actions: installOutcome.nextActions,
670
- next_commands: installOutcome.nextCommands
995
+ design: answers.design ?? null,
996
+ next_actions: [
997
+ ...installOutcome.nextActions,
998
+ brandingGuidanceAction(answers.design, this.meta.cliVersion),
999
+ ...claimNudge.actions
1000
+ ],
1001
+ next_commands: [...installOutcome.nextCommands, ...claimNudge.commands]
671
1002
  }
672
1003
  });
673
1004
  }
@@ -696,20 +1027,59 @@ async function resolveScaffoldFramework(framework, nonInteractive, orca) {
696
1027
  function dryRunProject(issuer) {
697
1028
  return {
698
1029
  id: "dry-run-0000",
699
- projectSecret: "sk_proj_dry_run_full",
700
- previewSecret: "sk_proj_dry_run_preview",
701
- previewOrigins: [issuer],
702
- createdAt: "2026-04-21T14:03:11.000Z"
1030
+ project_secret: "sk_proj_dry_run_full",
1031
+ preview_secret: "sk_proj_dry_run_preview",
1032
+ preview_origins: [issuer],
1033
+ created_at: "2026-04-21T14:03:11.000Z"
703
1034
  };
704
1035
  }
705
- async function createProjectWithLocalHint(client, server, cliVersion, issuer, framework) {
1036
+ /**
1037
+ * Reconstructs the flag list of the current invocation for retry guidance,
1038
+ * ending in `--server local`. Flags whose resolved value equals the default
1039
+ * are omitted — the retry reproduces the same outcome without them.
1040
+ */
1041
+ function setupRetryFlags(opts) {
1042
+ const parts = [];
1043
+ if (opts.framework) parts.push(`--framework ${opts.framework}`);
1044
+ if (opts.preset && opts.preset !== DEFAULT_SETUP_PRESET) parts.push(`--preset ${opts.preset}`);
1045
+ if (opts.useCase && opts.useCase !== DEFAULT_SETUP_USE_CASE) parts.push(`--use-case ${opts.useCase}`);
1046
+ if (opts.design) parts.push(`--design ${opts.design}`);
1047
+ if (opts.renderer && opts.renderer !== "react") parts.push(`--renderer ${opts.renderer}`);
1048
+ if (opts.devPort !== void 0) parts.push(`--dev-port ${opts.devPort}`);
1049
+ if (opts.nonInteractive) parts.push("--non-interactive");
1050
+ parts.push("--server local");
1051
+ return parts.join(" ");
1052
+ }
1053
+ /**
1054
+ * Retry options straight from parsed flags, for failures before the wizard
1055
+ * resolves answers. `--non-interactive` is echoed only when explicitly passed
1056
+ * — TTY/JSON-inferred non-interactivity re-infers itself on the retry.
1057
+ */
1058
+ function retryOptionsFromFlags(flags) {
1059
+ return {
1060
+ framework: flags.framework,
1061
+ preset: flags.preset,
1062
+ useCase: flags["use-case"],
1063
+ design: flags.design,
1064
+ renderer: flags.renderer,
1065
+ devPort: flags["dev-port"],
1066
+ nonInteractive: Boolean(flags["non-interactive"])
1067
+ };
1068
+ }
1069
+ async function createProjectWithLocalHint(client, server, cliVersion, projectName, issuer, retry) {
706
1070
  try {
707
- return await client.createProject({ previewOrigins: [issuer] });
1071
+ const payload = {
1072
+ name: projectName,
1073
+ preview_origins: [issuer],
1074
+ seed_defaults: false
1075
+ };
1076
+ return await client.createProject(payload);
708
1077
  } catch (error) {
709
1078
  const normalized = toZitadelError(error);
1079
+ const retryFlags = setupRetryFlags(retry);
710
1080
  throw new ZitadelError(normalized.code, normalized.message, {
711
- hint: `${normalized.hint ? `${normalized.hint} ` : ""}If you meant to use a local Zitadel server, start it first and retry setup with --framework ${framework} --server local.`,
712
- nextCommands: [publicCliCommand("start", cliVersion), publicCliCommand(`setup --framework ${framework} --server local`, cliVersion)],
1081
+ hint: `${normalized.hint ? `${normalized.hint} ` : ""}If you meant to use a local Zitadel server, start it first and retry setup with ${retryFlags}.`,
1082
+ nextCommands: [publicCliCommand("start", cliVersion), publicCliCommand(`setup ${retryFlags}`, cliVersion)],
713
1083
  details: {
714
1084
  server,
715
1085
  original: normalized.details
@@ -717,20 +1087,20 @@ async function createProjectWithLocalHint(client, server, cliVersion, issuer, fr
717
1087
  });
718
1088
  }
719
1089
  }
720
- function localSetupHint(error, framework, cliVersion) {
1090
+ function defaultProjectName(cwd, framework) {
1091
+ const fromDirectory = basename(cwd).trim();
1092
+ return fromDirectory.length > 0 ? fromDirectory : `zitadel-${framework}-app`;
1093
+ }
1094
+ function localSetupHint(error, retry, cliVersion) {
721
1095
  const normalized = toZitadelError(error);
722
1096
  if (normalized.code !== "E_LOCAL_SERVER_NOT_RUNNING") return error;
723
- const setupCommand = framework ? `setup --framework ${framework} --server local` : "setup --server local";
1097
+ const setupCommand = `setup ${setupRetryFlags(retry)}`;
724
1098
  return new ZitadelError(normalized.code, normalized.message, {
725
1099
  hint: `${normalized.hint ? `${normalized.hint} ` : ""}Start local Zitadel first, then rerun setup. After setup succeeds, follow its next_commands to start the app and verify registration, logout, and login in the browser.`,
726
1100
  nextCommands: [publicCliCommand("start", cliVersion), publicCliCommand(setupCommand, cliVersion)],
727
1101
  details: normalized.details
728
1102
  });
729
1103
  }
730
- /** Renders an absolute path relative to `cwd` for human-readable output. */
731
- function relativeDisplay(cwd, path) {
732
- return path.startsWith(cwd) ? path.slice(cwd.length + 1) : path;
733
- }
734
1104
  /**
735
1105
  * Replaces the user's `$HOME` with `~` in a path for compact terminal output.
736
1106
  * Falls back to the raw path when `HOME` isn't set or doesn't match.
@@ -751,15 +1121,13 @@ function pickWrittenFile(written, suffix) {
751
1121
  }
752
1122
  /**
753
1123
  * Translates a patcher-written path into a single sentence the user can
754
- * read at narration speed. Returns `null` for directories and other
755
- * scaffolding artefacts that aren't worth narrating individually the
756
- * file count in the closing `success(...)` and the summary's INSTALLED
757
- * section already cover them. The verb tense flips for `--dry-run` so
758
- * the user sees a preview ("Would write ...") instead of a claim that
759
- * something happened.
1124
+ * read at narration speed. The patch result's `filesWritten` carries
1125
+ * deduplicated file paths only (directories stay in the typed `files`
1126
+ * rows), so no artefact filtering is needed here. The verb tense flips
1127
+ * for `--dry-run` so the user sees a preview ("Would write ...") instead
1128
+ * of a claim that something happened.
760
1129
  */
761
1130
  function describeWrittenFile(relPath, dryRun) {
762
- if (relPath === ".zitadel" || relPath === ".zitadel/flows" || relPath === ".zitadel/schemas") return null;
763
1131
  const verb = dryRun ? "Would write" : "Wrote";
764
1132
  const sentence = SENTENCE_BY_PATH[relPath];
765
1133
  if (sentence) return `${verb} ${sentence.subject} (${path(relPath)})`;
@@ -777,8 +1145,21 @@ const SENTENCE_BY_PATH = {
777
1145
  "zitadel.json": { subject: "the Zitadel project configuration" },
778
1146
  ".env.example": { subject: "the .env example template" },
779
1147
  ".env.local": { subject: "the local development environment variables" },
780
- ".zitadel/state.json": { subject: "the empty sync state file" },
781
- "app/page.tsx": { subject: "the auth home page" },
1148
+ ".zitadel/state.json": { subject: "the sync state file" },
1149
+ ".zitadel/flows/default-login.json": { subject: "the editable default login flow" },
1150
+ ".zitadel/flows/README.md": { subject: "the flows folder README" },
1151
+ ".zitadel/schemas/default-human-user.json": { subject: "the editable default human user schema" },
1152
+ ".zitadel/schemas/README.md": { subject: "the schemas folder README" },
1153
+ ".zitadel/meta/flow-definition.json": { subject: "the flow dialect spec (editor $schema)" },
1154
+ ".zitadel/meta/user-schema.json": { subject: "the user-schema dialect spec" },
1155
+ ".zitadel/meta/user-property.json": { subject: "the user-property dialect spec" },
1156
+ ".zitadel/meta/branding.json": { subject: "the branding dialect spec" },
1157
+ ".zitadel/branding/branding.json": { subject: "the branding descriptor (layout + asset URLs)" },
1158
+ ".zitadel/branding/login.liquid": { subject: "the editable login template" },
1159
+ ".zitadel/branding/README.md": { subject: "the branding folder README" },
1160
+ "AGENTS.md": { subject: "the agent guidance (golden journey + config dialect)" },
1161
+ "README.md": { subject: "the README's Zitadel section" },
1162
+ "app/page.tsx": { subject: "the home page redirect" },
782
1163
  "app/login/page.tsx": { subject: "the login page" },
783
1164
  "app/register/page.tsx": { subject: "the registration page" },
784
1165
  "app/profile/page.tsx": { subject: "the profile page" },
@@ -789,7 +1170,7 @@ const SENTENCE_BY_PATH = {
789
1170
  };
790
1171
  /** Builds the section list driving {@link renderSummary} for the setup command. */
791
1172
  function buildSummary(opts) {
792
- const { projectFacts, writtenRel, project, server, issuer, scaffoldedFramework } = opts;
1173
+ const { projectFacts, writtenRel, project, server, issuer, scaffoldedFramework, design } = opts;
793
1174
  const sdkPackage = "@zitadel/sdk-next";
794
1175
  const packageJsonHit = pickWrittenFile(writtenRel, "package.json");
795
1176
  const detected = [{
@@ -807,7 +1188,7 @@ function buildSummary(opts) {
807
1188
  secondary: path(fileNameOf(packageJsonHit))
808
1189
  });
809
1190
  for (const [label, suffix] of [
810
- ["Home page", "app/page.tsx"],
1191
+ ["Home redirect", "app/page.tsx"],
811
1192
  ["Login page", "app/login/page.tsx"],
812
1193
  ["Register page", "app/register/page.tsx"],
813
1194
  ["Profile page", "app/profile/page.tsx"],
@@ -821,6 +1202,28 @@ function buildSummary(opts) {
821
1202
  value: path(hit)
822
1203
  });
823
1204
  }
1205
+ const customizeRows = [];
1206
+ for (const [label, suffix, dir] of [
1207
+ [
1208
+ "User schema",
1209
+ ".zitadel/schemas/default-human-user.json",
1210
+ ".zitadel/schemas/"
1211
+ ],
1212
+ [
1213
+ "Login flow",
1214
+ ".zitadel/flows/default-login.json",
1215
+ ".zitadel/flows/"
1216
+ ],
1217
+ [
1218
+ "Login template",
1219
+ ".zitadel/branding/login.liquid",
1220
+ ".zitadel/branding/"
1221
+ ]
1222
+ ]) if (pickWrittenFile(writtenRel, suffix)) customizeRows.push({
1223
+ label,
1224
+ value: path(dir),
1225
+ secondary: "see its README.md"
1226
+ });
824
1227
  const projectRows = [
825
1228
  {
826
1229
  label: "Project id",
@@ -833,6 +1236,14 @@ function buildSummary(opts) {
833
1236
  {
834
1237
  label: "App will run",
835
1238
  value: url(issuer)
1239
+ },
1240
+ design ? {
1241
+ label: "Login design",
1242
+ value: brandingDesignLabel(design),
1243
+ secondary: `${design} · ${path(".zitadel/branding/")}`
1244
+ } : {
1245
+ label: "Login design",
1246
+ value: dim("built-in template")
836
1247
  }
837
1248
  ];
838
1249
  return [
@@ -844,6 +1255,10 @@ function buildSummary(opts) {
844
1255
  title: "Installed",
845
1256
  rows: installedRows
846
1257
  },
1258
+ {
1259
+ title: "Customize",
1260
+ rows: customizeRows
1261
+ },
847
1262
  {
848
1263
  title: "Project",
849
1264
  rows: projectRows