@zitadel/cli 0.1.0-alpha.8 → 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 +227 -38
  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 +687 -55
  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 +12 -4
  21. package/dist/commands/reset.mjs.map +1 -1
  22. package/dist/commands/schemas/list.mjs +146 -0
  23. package/dist/commands/schemas/list.mjs.map +1 -0
  24. package/dist/commands/setup.mjs +642 -287
  25. package/dist/commands/setup.mjs.map +1 -1
  26. package/dist/commands/start.mjs +73 -6
  27. package/dist/commands/start.mjs.map +1 -1
  28. package/dist/commands/status.mjs +52 -13
  29. package/dist/commands/status.mjs.map +1 -1
  30. package/dist/commands/stop.mjs +59 -6
  31. package/dist/commands/stop.mjs.map +1 -1
  32. package/dist/designs-Ckz18Dpo.mjs +38 -0
  33. package/dist/designs-Ckz18Dpo.mjs.map +1 -0
  34. package/dist/{docker-BA78SdC2.mjs → docker-DcRGTOJa.mjs} +61 -11
  35. package/dist/docker-DcRGTOJa.mjs.map +1 -0
  36. package/dist/environment-rjRVkJjW.mjs +17 -0
  37. package/dist/environment-rjRVkJjW.mjs.map +1 -0
  38. package/dist/journey-guidance-BGrOX_gT.mjs +40 -0
  39. package/dist/journey-guidance-BGrOX_gT.mjs.map +1 -0
  40. package/dist/oclif-CanO3zdt.mjs +1769 -0
  41. package/dist/oclif-CanO3zdt.mjs.map +1 -0
  42. package/dist/{orca-CfKDQRop.mjs → orca-BzQIQqzt.mjs} +1663 -303
  43. package/dist/orca-BzQIQqzt.mjs.map +1 -0
  44. package/dist/package-manager-DFdLcDx1.mjs +194 -0
  45. package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
  46. package/dist/ports-BM20XIZb.mjs +116 -0
  47. package/dist/ports-BM20XIZb.mjs.map +1 -0
  48. package/dist/processes-DKHPkU8O.mjs +120 -0
  49. package/dist/processes-DKHPkU8O.mjs.map +1 -0
  50. package/dist/{project-CKAHtHML.mjs → project-CHhot85s.mjs} +57 -4
  51. package/dist/project-CHhot85s.mjs.map +1 -0
  52. package/dist/sync-BBlACKzT.mjs +1633 -0
  53. package/dist/sync-BBlACKzT.mjs.map +1 -0
  54. package/dist/user-schema-DTuOsdKE.mjs +91 -0
  55. package/dist/user-schema-DTuOsdKE.mjs.map +1 -0
  56. package/oclif.manifest.json +412 -5
  57. package/package.json +10 -5
  58. package/dist/docker-BA78SdC2.mjs.map +0 -1
  59. package/dist/docker-guidance-BvfpmsDj.mjs +0 -21
  60. package/dist/docker-guidance-BvfpmsDj.mjs.map +0 -1
  61. package/dist/oclif-VkCTGIEk.mjs +0 -818
  62. package/dist/oclif-VkCTGIEk.mjs.map +0 -1
  63. package/dist/orca-CfKDQRop.mjs.map +0 -1
  64. package/dist/project-CKAHtHML.mjs.map +0 -1
  65. package/dist/sync-B5lqgQO3.mjs +0 -733
  66. package/dist/sync-B5lqgQO3.mjs.map +0 -1
@@ -1,95 +1,181 @@
1
- import { D as ZitadelError, O as toZitadelError, n as DEFAULT_SERVER, t as BaseCommand, x as publicCliCommand } from "../oclif-VkCTGIEk.mjs";
2
- import { n as RENDERER_IDS, r as issuerFromPort, t as createOrca } from "../orca-CfKDQRop.mjs";
3
- import { n as hasZitadelSecret, t as hasZitadelConfig } from "../project-CKAHtHML.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";
4
13
  import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
5
14
  import { Flags } from "@oclif/core";
6
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";
7
17
  import { consola as consola$1 } from "consola";
8
- import { basename, join } from "node:path";
9
- import { readFile, stat } from "node:fs/promises";
10
- import { execFile, spawn } from "node:child_process";
18
+ import { BRANDING_FILE_SCHEMA_REF } from "@zitadel/config/meta-schemas";
19
+ import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
11
20
  import pc from "picocolors";
12
- //#region src/lib/package-manager.ts
13
- async function detectPackageManager(cwd) {
14
- const declared = await packageManagerFromManifest(cwd);
15
- if (declared) return declared;
16
- if (await exists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
17
- if (await exists(join(cwd, "yarn.lock"))) return "yarn";
18
- if (await exists(join(cwd, "bun.lock"))) return "bun";
19
- if (await exists(join(cwd, "bun.lockb"))) return "bun";
20
- if (await exists(join(cwd, "package-lock.json"))) return "npm";
21
- return "npm";
22
- }
23
- function installCommandFor(packageManager) {
24
- return command(packageManager, ["install"]);
25
- }
26
- function devCommandFor(packageManager) {
27
- switch (packageManager) {
28
- case "npm": return command("npm", ["run", "dev"]);
29
- case "pnpm": return command("pnpm", ["dev"]);
30
- case "yarn": return command("yarn", ["dev"]);
31
- case "bun": return command("bun", ["run", "dev"]);
32
- }
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";
33
47
  }
34
- async function runPackageCommand(packageCommand, options) {
35
- await new Promise((resolve, reject) => {
36
- const child = spawn(packageCommand.command, packageCommand.args, {
37
- cwd: options.cwd,
38
- env: options.env ?? process.env,
39
- stdio: options.redirectStdoutToStderr ? [
40
- "ignore",
41
- "pipe",
42
- "pipe"
43
- ] : "inherit"
44
- });
45
- if (options.redirectStdoutToStderr) {
46
- child.stdout?.on("data", (chunk) => process.stderr.write(chunk));
47
- child.stderr?.on("data", (chunk) => process.stderr.write(chunk));
48
- }
49
- child.on("error", reject);
50
- child.on("close", (code, signal) => {
51
- if (code === 0) {
52
- resolve();
53
- return;
54
- }
55
- const detail = signal ? `signal ${signal}` : `exit ${String(code ?? 1)}`;
56
- const error = /* @__PURE__ */ new Error(`${packageCommand.display} failed with ${detail}`);
57
- Object.assign(error, {
58
- code: code ?? 1,
59
- signal
60
- });
61
- reject(error);
62
- });
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
63
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 };
64
129
  }
65
- function command(packageManager, args) {
66
- return {
67
- command: packageManager,
68
- args,
69
- display: [packageManager, ...args].join(" ")
70
- };
71
- }
72
- 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 });
73
138
  try {
74
- const raw = await readFile(join(cwd, "package.json"), "utf8");
75
- const parsed = JSON.parse(raw);
76
- return typeof parsed.packageManager === "string" ? packageManagerFromString(parsed.packageManager) : void 0;
77
- } catch {
78
- 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;
79
144
  }
80
145
  }
81
- function packageManagerFromString(value) {
82
- const name = value.split("@")[0];
83
- 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
+ }
84
161
  }
85
- async function exists(path) {
162
+ async function writeResourceFile(cwd, relPath, body, force) {
163
+ const contents = `${stableStringify(body)}\n`;
86
164
  try {
87
- await stat(path);
165
+ await writeFile(join(cwd, relPath), contents, force ? void 0 : { flag: "wx" });
88
166
  return true;
89
- } catch {
90
- 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;
91
170
  }
92
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
+ }
93
179
  //#endregion
94
180
  //#region src/commands/setup/install.ts
95
181
  async function installDependenciesForSetup(input) {
@@ -104,6 +190,7 @@ async function installDependenciesForSetup(input) {
104
190
  reason: "no-dependency-changes"
105
191
  },
106
192
  devCommand: devCommand.display,
193
+ cliVersion: input.cliVersion,
107
194
  issuer: input.issuer,
108
195
  includeInstallCommand: false
109
196
  });
@@ -115,6 +202,7 @@ async function installDependenciesForSetup(input) {
115
202
  reason: input.dryRun ? "dry-run" : "skip-install"
116
203
  },
117
204
  devCommand: devCommand.display,
205
+ cliVersion: input.cliVersion,
118
206
  issuer: input.issuer,
119
207
  includeInstallCommand: true
120
208
  });
@@ -136,22 +224,30 @@ async function installDependenciesForSetup(input) {
136
224
  command: installCommand.display
137
225
  },
138
226
  devCommand: devCommand.display,
227
+ cliVersion: input.cliVersion,
139
228
  issuer: input.issuer,
140
229
  includeInstallCommand: false
141
230
  });
142
231
  }
143
232
  function outcome(input) {
144
- const startAction = `Start your project: ${input.devCommand} (then open ${input.issuer})`;
145
- 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.`;
146
241
  return {
147
242
  install: input.install,
148
243
  devCommand: input.devCommand,
149
- nextActions: input.includeInstallCommand ? [
150
- `Install dependencies: ${input.install.command}`,
151
- startAction,
152
- verifyAction
153
- ] : [startAction, verifyAction],
154
- 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]
155
251
  };
156
252
  }
157
253
  function installFailed(error, installCommand, devCommand, cwd) {
@@ -187,6 +283,54 @@ function bail(value) {
187
283
  }
188
284
  }
189
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
190
334
  //#region src/commands/setup/prompts/dev-port.ts
191
335
  /**
192
336
  * "Dev server port" — defaults to the detected port. The validated answer
@@ -230,137 +374,27 @@ var FrameworkConfirmPrompt = class {
230
374
  }
231
375
  };
232
376
  //#endregion
233
- //#region src/lib/prober/ports.ts
234
- /**
235
- * Enumerate TCP ports currently in LISTEN state on the loopback interface
236
- * (`127.0.0.1`, `::1`, or the wildcard `*`). Spawns `lsof -iTCP -sTCP:LISTEN
237
- * -P -n -F n` and parses its machine-readable output. Returns the unique,
238
- * numerically-sorted list of ports.
239
- *
240
- * Never throws. Returns `[]` whenever lsof is unavailable (e.g. Windows,
241
- * unusual PATH), the spawn errors, exits non-zero, or the call exceeds
242
- * `timeoutMs` (default 1000ms). The caller treats an empty list the same as
243
- * "no listeners worth probing."
244
- */
245
- async function listListeningPorts(opts) {
246
- const timeoutMs = opts?.timeoutMs ?? 1e3;
247
- try {
248
- return parseLsofPorts(await runLsof(timeoutMs));
249
- } catch {
250
- return [];
251
- }
252
- }
253
- /**
254
- * Spawn `lsof` with the canned argv and resolve to its stdout. Hand-rolled
255
- * rather than `util.promisify(execFile)` because the latter resolves with a
256
- * `{stdout, stderr}` object via its custom-promisify symbol — a heavier shape
257
- * to mock and worse to read at the call site, where we only ever want stdout.
258
- */
259
- function runLsof(timeoutMs) {
260
- return new Promise((resolve, reject) => {
261
- execFile("lsof", [
262
- "-iTCP",
263
- "-sTCP:LISTEN",
264
- "-P",
265
- "-n",
266
- "-F",
267
- "n"
268
- ], {
269
- timeout: timeoutMs,
270
- encoding: "utf8"
271
- }, (err, stdout) => {
272
- if (err) {
273
- reject(err);
274
- return;
275
- }
276
- resolve(stdout);
277
- });
278
- });
279
- }
280
- /**
281
- * Parse the `n` records emitted by `lsof -F n`. Each record is a single line
282
- * `n<address>` where `<address>` ends in `:<port>` (e.g. `n*:8080`,
283
- * `n127.0.0.1:3000`, `n[::1]:5050`). Only loopback/wildcard hosts are kept;
284
- * external interface bindings are ignored.
285
- */
286
- function parseLsofPorts(stdout) {
287
- const ports = /* @__PURE__ */ new Set();
288
- for (const line of stdout.split(/\r?\n/)) {
289
- if (!line.startsWith("n")) continue;
290
- const address = line.slice(1);
291
- const colon = address.lastIndexOf(":");
292
- if (colon < 0) continue;
293
- const host = address.slice(0, colon);
294
- const portStr = address.slice(colon + 1);
295
- if (!isLoopback(host)) continue;
296
- const port = Number.parseInt(portStr, 10);
297
- if (Number.isFinite(port) && port > 0 && port < 65536) ports.add(port);
298
- }
299
- return [...ports].sort((a, b) => a - b);
300
- }
301
- /** Recognised loopback host strings as emitted by `lsof -F n`. */
302
- function isLoopback(host) {
303
- return host === "*" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
304
- }
305
- //#endregion
306
- //#region src/lib/prober/http.ts
307
- /**
308
- * Fetch `url` with a per-call timeout and pass the resulting `Response` to
309
- * `predicate`. Returns the predicate's value, or `null` on any failure: a
310
- * network error, an `AbortError` from the timeout, a thrown predicate, or the
311
- * predicate returning `null`.
312
- *
313
- * Never throws. The predicate decides what counts as a match — it gets the
314
- * raw `Response` and may read `.json()` / `.headers` / `.status` as needed.
315
- *
316
- * `timeoutMs` defaults to 500ms. The implementation uses
317
- * `AbortSignal.timeout`, so the underlying fetch is cancelled when the
318
- * timeout fires (no orphaned sockets).
319
- */
320
- async function probeUrl(url, predicate, opts) {
321
- const timeoutMs = opts?.timeoutMs ?? 500;
322
- try {
323
- return await predicate(await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }));
324
- } catch {
325
- return null;
326
- }
327
- }
328
- /**
329
- * Probe many URLs in parallel and return the non-null matches, preserving
330
- * the input order. Each URL is bounded by `timeoutMs` independently — one
331
- * slow target can't hold up the others.
332
- */
333
- async function probeUrls(urls, predicate, opts) {
334
- const list = [...urls];
335
- return (await Promise.all(list.map(async (url) => {
336
- const value = await probeUrl(url, predicate, opts);
337
- return value === null ? null : {
338
- url,
339
- value
340
- };
341
- }))).filter((match) => match !== null);
342
- }
343
- //#endregion
344
377
  //#region src/commands/setup/prompts/server.ts
345
378
  /** Sentinel returned by the choice select when the user picks "Custom URL". */
346
379
  const CUSTOM = "__custom__";
347
- /** Per-probe timeout for the localhost OIDC scan — generous enough for a TLS handshake on a busy laptop. */
348
- const OIDC_PROBE_TIMEOUT_MS = 300;
349
380
  /**
350
- * "Which server should `zitadel.json` point to?" — Zitadel Cloud, a localhost
351
- * 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.
352
383
  *
353
- * Before asking, it scans the loopback for listening ports (via the generic
354
- * `lib/prober`) and `GET`s `/.well-known/openid-configuration` on each. Every
355
- * 2xx response with a string `issuer` becomes an extra option in the choice
356
- * list. Picking a discovered URL writes it directly to `answers.server` and
357
- * 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
358
392
  * validated URL exactly as before.
359
393
  */
360
394
  var ServerPrompt = class {
361
395
  async ask(answers, ctx) {
362
396
  if (ctx.serverFlag) return answers;
363
- const discovered = await discoverLocalOidc();
397
+ const detected = await detectLocalServer(ctx.cwd);
364
398
  const choice = await select({
365
399
  message: "Which server should zitadel.json point to?",
366
400
  options: [
@@ -369,17 +403,17 @@ var ServerPrompt = class {
369
403
  label: "Zitadel Cloud (api.zitadel.cloud)",
370
404
  hint: "recommended for real projects"
371
405
  },
372
- ...discovered.map((server) => ({
373
- value: server,
374
- label: server,
375
- hint: "detected — OIDC"
376
- })),
406
+ ...detected ? [{
407
+ value: detected,
408
+ label: `Local Zitadel server (${detected})`,
409
+ hint: "detected — started with zitadel start"
410
+ }] : [],
377
411
  {
378
412
  value: CUSTOM,
379
413
  label: "Custom URL (self-hosted)"
380
414
  }
381
415
  ],
382
- initialValue: answers.server ?? "https://api.zitadel.cloud"
416
+ initialValue: detected ?? answers.server ?? "https://api.zitadel.cloud"
383
417
  });
384
418
  bail(choice);
385
419
  if (choice !== CUSTOM) return {
@@ -406,38 +440,89 @@ var ServerPrompt = class {
406
440
  }
407
441
  };
408
442
  /**
409
- * Returns the loopback origins (`http://localhost:<port>`) whose
410
- * `/.well-known/openid-configuration` endpoint responds with a valid OIDC
411
- * discovery document. Sniffs purely via the generic prober — the only
412
- * Zitadel-shaped detail here is the OIDC predicate, which is the standard
413
- * "any OIDC server" contract (no `zitadel`-keyword check), so Keycloak/dex/
414
- * 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.
415
446
  */
416
- async function discoverLocalOidc() {
447
+ async function detectLocalServer(cwd) {
417
448
  const s = spinner();
418
- s.start("Scanning localhost for OIDC servers");
419
- try {
420
- const ports = await listListeningPorts();
421
- if (ports.length === 0) {
422
- s.stop("No local servers detected.");
423
- return [];
424
- }
425
- const origins = (await probeUrls(ports.map((port) => `http://localhost:${port}/.well-known/openid-configuration`), async (response) => {
426
- if (!response.ok) return null;
427
- try {
428
- const body = await response.json();
429
- return typeof body.issuer === "string" ? body.issuer : null;
430
- } catch {
431
- 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"
432
508
  }
433
- }, { timeoutMs: OIDC_PROBE_TIMEOUT_MS })).map((match) => new URL(match.url).origin);
434
- s.stop(origins.length === 0 ? "No local OIDC servers found." : `Found ${origins.length} local OIDC server${origins.length === 1 ? "" : "s"}.`);
435
- return origins;
436
- } catch (error) {
437
- s.stop("Discovery skipped.");
438
- 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
+ };
439
524
  }
440
- }
525
+ };
441
526
  //#endregion
442
527
  //#region src/commands/setup/prompts/pick-framework.ts
443
528
  /**
@@ -477,11 +562,24 @@ var PickFrameworkPrompt = class {
477
562
  const SETUP_PROMPTS = [
478
563
  new FrameworkConfirmPrompt(),
479
564
  new ServerPrompt(),
480
- new DevPortPrompt()
565
+ new DevPortPrompt(),
566
+ new UseCasePrompt(),
567
+ new SignInPresetPrompt(),
568
+ new DesignPrompt()
481
569
  ];
482
570
  //#endregion
483
571
  //#region src/commands/setup/summary.ts
484
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
+ /**
485
583
  * Renders the section list as a single multi-line string. Labels are
486
584
  * padded to a common width per section so the `✓ label value` columns
487
585
  * line up, the title prints in dim gray, and `✓` is green. Values come
@@ -510,6 +608,8 @@ const path = (s) => pc.cyan(s);
510
608
  const url = (s) => pc.cyan(s);
511
609
  /** Yellow, for opaque ids the user shouldn't try to read. */
512
610
  const id = (s) => pc.yellow(s);
611
+ /** Dim, for separators and secondary detail. */
612
+ const dim = (s) => pc.dim(s);
513
613
  /**
514
614
  * Reads the project root to identify the framework version, TS presence,
515
615
  * and which package manager the user runs. Returns safe defaults (npm
@@ -570,6 +670,29 @@ function stripRange(range) {
570
670
  function fileNameOf(p) {
571
671
  return basename(p);
572
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
+ }
573
696
  //#endregion
574
697
  //#region src/commands/setup/index.ts
575
698
  /**
@@ -579,13 +702,23 @@ function fileNameOf(p) {
579
702
  * load.
580
703
  */
581
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(", ")}.`;
582
715
  /** `zitadel setup` — create a project and scaffold local auth.
583
716
  *
584
717
  * Detects (or, for an empty directory, scaffolds then re-detects) the
585
718
  * framework, runs the wizard prompts to fill in any answers not pre-supplied
586
- * by flags, creates the remote project (whose default user schema and login
587
- * flow are provisioned server-side), and patches the local files via
588
- * `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/**`.
589
722
  *
590
723
  * Every interactive question lives in {@link SETUP_PROMPTS} (the main wizard
591
724
  * — each entry is a small class) and {@link PickFrameworkPrompt} (the
@@ -600,18 +733,30 @@ var Setup = class Setup extends BaseCommand {
600
733
  options: FRAMEWORK_OPTIONS
601
734
  }),
602
735
  renderer: Flags.string({
603
- description: "Renderer (default: react).",
604
- options: [...RENDERER_IDS]
736
+ description: RENDERER_FLAG_DESCRIPTION,
737
+ options: [...AVAILABLE_RENDERER_IDS]
605
738
  }),
606
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." }),
607
- "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
+ })
608
753
  };
609
754
  async run() {
610
755
  const { flags } = await this.parse(Setup);
611
756
  try {
612
757
  await this.toMeta(flags);
613
758
  } catch (error) {
614
- throw localSetupHint(error, flags.framework, this.config.version);
759
+ throw localSetupHint(error, retryOptionsFromFlags(flags), this.config.version);
615
760
  }
616
761
  const { cwd, nonInteractive, dryRun, force } = this.meta;
617
762
  if (await hasZitadelConfig(cwd)) return this.emit({
@@ -627,13 +772,26 @@ var Setup = class Setup extends BaseCommand {
627
772
  framework = await orca.detect(cwd, flags.framework);
628
773
  consola$1.success(`Detected ${framework.id}${framework.devPort ? ` (dev port ${framework.devPort})` : ""}`);
629
774
  } catch (error) {
630
- if (error instanceof ZitadelError && error.code === "E_FRAMEWORK_NOT_DETECTED" && await orca.isFreshScaffoldTarget(cwd)) {
775
+ if (error instanceof ZitadelError && error.code === "E_FRAMEWORK_NOT_DETECTED") {
776
+ const target = await inspectScaffoldTarget(cwd);
777
+ if (!target.scaffoldable) throw frameworkDetectionWithScaffoldTarget(error, cwd, target);
631
778
  consola$1.info("Fresh app directory — scaffolding a fresh project");
632
779
  framework = await orca.scaffold(cwd, await resolveScaffoldFramework(flags.framework, nonInteractive, orca));
633
780
  scaffoldedFramework = true;
634
781
  consola$1.success(`Scaffolded ${framework.id} skeleton`);
635
782
  } else throw error;
636
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
+ });
637
795
  if (flags["dev-port"] !== void 0) {
638
796
  const devPort = flags["dev-port"];
639
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}`);
@@ -645,18 +803,30 @@ var Setup = class Setup extends BaseCommand {
645
803
  }
646
804
  let answers = {
647
805
  server: this.meta.source,
648
- 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
649
810
  };
650
811
  if (!nonInteractive && !dryRun) {
651
812
  intro("Zitadel setup");
652
813
  const promptCtx = {
653
814
  framework,
815
+ cwd,
654
816
  serverFlag: this.meta.serverFlag,
655
- 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
656
821
  };
657
822
  for (const prompt of SETUP_PROMPTS) answers = await prompt.ask(answers, promptCtx);
658
823
  outro("Configuration captured");
659
824
  }
825
+ this.recordTelemetry({
826
+ preset: answers.preset,
827
+ use_case: answers.useCase,
828
+ design: answers.design ?? "built-in"
829
+ });
660
830
  const issuer = issuerFromPort(answers.devPort);
661
831
  framework = {
662
832
  ...framework,
@@ -665,8 +835,18 @@ var Setup = class Setup extends BaseCommand {
665
835
  };
666
836
  consola$1.start(`Creating project on ${answers.server}${dryRun ? " (dry run)" : ""}`);
667
837
  const unauthClient = createZitadelClient({ baseUrl: answers.server });
668
- 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
+ });
669
847
  consola$1.success(`Created project ${project.id}`);
848
+ this.recordTelemetry({ step: "project_created" });
849
+ const posture = derivePosture(framework.id, scaffoldedFramework);
670
850
  const ctx = {
671
851
  framework,
672
852
  rendererId: flags.renderer ?? "react",
@@ -674,7 +854,10 @@ var Setup = class Setup extends BaseCommand {
674
854
  issuer,
675
855
  server: answers.server,
676
856
  cliVersion: this.meta.cliVersion,
677
- scaffoldedFramework
857
+ scaffoldedFramework,
858
+ posture,
859
+ preset: answers.preset,
860
+ useCase: answers.useCase
678
861
  };
679
862
  consola$1.start(`Patching project files${dryRun ? " (dry run)" : ""}`);
680
863
  const result = await orca.patcherFor(framework.id).patch(ctx, {
@@ -683,12 +866,62 @@ var Setup = class Setup extends BaseCommand {
683
866
  force
684
867
  });
685
868
  for (const file of result.filesWritten) {
686
- const sentence = describeWrittenFile(relativeDisplay(cwd, file), dryRun);
869
+ const sentence = describeWrittenFile(relativeDisplayPath(cwd, file), dryRun);
870
+ if (sentence) consola$1.info(sentence);
871
+ }
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);
687
900
  if (sentence) consola$1.info(sentence);
688
901
  }
689
- for (const file of result.filesSkipped) consola$1.info(`Left ${relativeDisplay(cwd, file)} unchanged (already matches target)`);
690
- consola$1.success(`Patched ${result.filesWritten.length} file${result.filesWritten.length === 1 ? "" : "s"}` + (result.filesSkipped.length > 0 ? ` (${result.filesSkipped.length} unchanged)` : ""));
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
+ }
691
923
  const installOutcome = await installDependenciesForSetup({
924
+ cliVersion: this.meta.cliVersion,
692
925
  cwd,
693
926
  depsAdded: result.depsAdded,
694
927
  dryRun,
@@ -698,7 +931,22 @@ var Setup = class Setup extends BaseCommand {
698
931
  scaffoldedFramework,
699
932
  skipInstall: Boolean(flags["skip-install"])
700
933
  });
701
- 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);
702
950
  if (!this.jsonEnabled()) {
703
951
  const sections = buildSummary({
704
952
  projectFacts: await detectProjectFacts(cwd, framework.id),
@@ -706,14 +954,15 @@ var Setup = class Setup extends BaseCommand {
706
954
  project,
707
955
  server: answers.server,
708
956
  issuer,
709
- scaffoldedFramework
957
+ scaffoldedFramework,
958
+ design: answers.design
710
959
  });
711
960
  consola$1.box({
712
961
  title: "Zitadel is ready",
713
962
  message: [
714
963
  renderSummary(sections),
715
964
  "",
716
- installOutcome.nextActions.join("\n")
965
+ [...installOutcome.boxActions, ...claimNudge.actions].join("\n")
717
966
  ].join("\n"),
718
967
  style: {
719
968
  padding: 1,
@@ -721,9 +970,11 @@ var Setup = class Setup extends BaseCommand {
721
970
  borderColor: "green"
722
971
  }
723
972
  });
973
+ for (const warning of warnings) consola$1.warn(warning);
724
974
  }
725
975
  return this.emit({
726
976
  status: "ok",
977
+ warnings,
727
978
  pretty: "",
728
979
  data: {
729
980
  title: "Zitadel is ready.",
@@ -733,15 +984,35 @@ var Setup = class Setup extends BaseCommand {
733
984
  },
734
985
  framework: framework.id,
735
986
  server: answers.server,
736
- files_written: result.filesWritten.map((file) => relativeDisplay(cwd, file)),
737
- 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)),
738
994
  install: installOutcome.install,
739
- next_actions: installOutcome.nextActions,
740
- 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]
741
1002
  }
742
1003
  });
743
1004
  }
744
1005
  };
1006
+ function frameworkDetectionWithScaffoldTarget(error, cwd, target) {
1007
+ return new ZitadelError(error.code, "Could not detect a supported app framework, and this directory is not a fresh scaffold target", {
1008
+ hint: `${target.reason ?? "Directory is not empty."} Run setup from an empty directory to scaffold a new app, or run setup from an existing supported app project.`,
1009
+ details: {
1010
+ cwd,
1011
+ entries: target.entries,
1012
+ reason: target.reason
1013
+ }
1014
+ });
1015
+ }
745
1016
  /**
746
1017
  * Resolves which framework to scaffold into an empty directory: the explicit
747
1018
  * `--framework`, else PickFrameworkPrompt, else a hard error in non-interactive
@@ -756,20 +1027,59 @@ async function resolveScaffoldFramework(framework, nonInteractive, orca) {
756
1027
  function dryRunProject(issuer) {
757
1028
  return {
758
1029
  id: "dry-run-0000",
759
- projectSecret: "sk_proj_dry_run_full",
760
- previewSecret: "sk_proj_dry_run_preview",
761
- previewOrigins: [issuer],
762
- 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"
1034
+ };
1035
+ }
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"])
763
1067
  };
764
1068
  }
765
- async function createProjectWithLocalHint(client, server, cliVersion, issuer, framework) {
1069
+ async function createProjectWithLocalHint(client, server, cliVersion, projectName, issuer, retry) {
766
1070
  try {
767
- 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);
768
1077
  } catch (error) {
769
1078
  const normalized = toZitadelError(error);
1079
+ const retryFlags = setupRetryFlags(retry);
770
1080
  throw new ZitadelError(normalized.code, normalized.message, {
771
- 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.`,
772
- 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)],
773
1083
  details: {
774
1084
  server,
775
1085
  original: normalized.details
@@ -777,20 +1087,20 @@ async function createProjectWithLocalHint(client, server, cliVersion, issuer, fr
777
1087
  });
778
1088
  }
779
1089
  }
780
- 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) {
781
1095
  const normalized = toZitadelError(error);
782
1096
  if (normalized.code !== "E_LOCAL_SERVER_NOT_RUNNING") return error;
783
- const setupCommand = framework ? `setup --framework ${framework} --server local` : "setup --server local";
1097
+ const setupCommand = `setup ${setupRetryFlags(retry)}`;
784
1098
  return new ZitadelError(normalized.code, normalized.message, {
785
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.`,
786
1100
  nextCommands: [publicCliCommand("start", cliVersion), publicCliCommand(setupCommand, cliVersion)],
787
1101
  details: normalized.details
788
1102
  });
789
1103
  }
790
- /** Renders an absolute path relative to `cwd` for human-readable output. */
791
- function relativeDisplay(cwd, path) {
792
- return path.startsWith(cwd) ? path.slice(cwd.length + 1) : path;
793
- }
794
1104
  /**
795
1105
  * Replaces the user's `$HOME` with `~` in a path for compact terminal output.
796
1106
  * Falls back to the raw path when `HOME` isn't set or doesn't match.
@@ -811,15 +1121,13 @@ function pickWrittenFile(written, suffix) {
811
1121
  }
812
1122
  /**
813
1123
  * Translates a patcher-written path into a single sentence the user can
814
- * read at narration speed. Returns `null` for directories and other
815
- * scaffolding artefacts that aren't worth narrating individually the
816
- * file count in the closing `success(...)` and the summary's INSTALLED
817
- * section already cover them. The verb tense flips for `--dry-run` so
818
- * the user sees a preview ("Would write ...") instead of a claim that
819
- * 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.
820
1129
  */
821
1130
  function describeWrittenFile(relPath, dryRun) {
822
- if (relPath === ".zitadel" || relPath === ".zitadel/flows" || relPath === ".zitadel/schemas") return null;
823
1131
  const verb = dryRun ? "Would write" : "Wrote";
824
1132
  const sentence = SENTENCE_BY_PATH[relPath];
825
1133
  if (sentence) return `${verb} ${sentence.subject} (${path(relPath)})`;
@@ -837,8 +1145,21 @@ const SENTENCE_BY_PATH = {
837
1145
  "zitadel.json": { subject: "the Zitadel project configuration" },
838
1146
  ".env.example": { subject: "the .env example template" },
839
1147
  ".env.local": { subject: "the local development environment variables" },
840
- ".zitadel/state.json": { subject: "the empty sync state file" },
841
- "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" },
842
1163
  "app/login/page.tsx": { subject: "the login page" },
843
1164
  "app/register/page.tsx": { subject: "the registration page" },
844
1165
  "app/profile/page.tsx": { subject: "the profile page" },
@@ -849,7 +1170,7 @@ const SENTENCE_BY_PATH = {
849
1170
  };
850
1171
  /** Builds the section list driving {@link renderSummary} for the setup command. */
851
1172
  function buildSummary(opts) {
852
- const { projectFacts, writtenRel, project, server, issuer, scaffoldedFramework } = opts;
1173
+ const { projectFacts, writtenRel, project, server, issuer, scaffoldedFramework, design } = opts;
853
1174
  const sdkPackage = "@zitadel/sdk-next";
854
1175
  const packageJsonHit = pickWrittenFile(writtenRel, "package.json");
855
1176
  const detected = [{
@@ -867,7 +1188,7 @@ function buildSummary(opts) {
867
1188
  secondary: path(fileNameOf(packageJsonHit))
868
1189
  });
869
1190
  for (const [label, suffix] of [
870
- ["Home page", "app/page.tsx"],
1191
+ ["Home redirect", "app/page.tsx"],
871
1192
  ["Login page", "app/login/page.tsx"],
872
1193
  ["Register page", "app/register/page.tsx"],
873
1194
  ["Profile page", "app/profile/page.tsx"],
@@ -881,6 +1202,28 @@ function buildSummary(opts) {
881
1202
  value: path(hit)
882
1203
  });
883
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
+ });
884
1227
  const projectRows = [
885
1228
  {
886
1229
  label: "Project id",
@@ -893,6 +1236,14 @@ function buildSummary(opts) {
893
1236
  {
894
1237
  label: "App will run",
895
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")
896
1247
  }
897
1248
  ];
898
1249
  return [
@@ -904,6 +1255,10 @@ function buildSummary(opts) {
904
1255
  title: "Installed",
905
1256
  rows: installedRows
906
1257
  },
1258
+ {
1259
+ title: "Customize",
1260
+ rows: customizeRows
1261
+ },
907
1262
  {
908
1263
  title: "Project",
909
1264
  rows: projectRows