@zitadel/cli 0.1.0 → 0.3.0

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.
@@ -0,0 +1,4444 @@
1
+ import consola$1, { consola } from "consola";
2
+ import { ApiError, NetworkError, apiErrorCode, apiErrorMessage } from "@zitadel/api/runtime/fetch";
3
+ import { createZitadelClient } from "@zitadel/api/client";
4
+ import { stringify } from "safe-stable-stringify";
5
+ import { copyFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
6
+ import { PublishPolicyBody, publishPolicyBodyOneConfigMinLengthMax } from "@zitadel/api/generated/endpoints/zitadelNextGen.zod";
7
+ import { randomUUID } from "node:crypto";
8
+ import { execFile } from "node:child_process";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir, release, tmpdir } from "node:os";
12
+ import { promisify } from "node:util";
13
+ import { BlockList, isIP } from "node:net";
14
+ import pc from "picocolors";
15
+ import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
16
+ import { RECOMMENDED_PASSWORD_MIN_LENGTH, brandingConfigSchema, flowConfigSchema, idpConnectionConfigSchema, policyConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
17
+ import { PURPOSE_FLIP_TARGETS, validateFlowDefinition, validateProject } from "@zitadel/config/validate";
18
+ import { CUSTOM_PROVIDER, IDP_PROVIDERS, RESERVED_SLUGS, credentialVariables, idpProvider, isLoopbackIssuer, isVariableReference, missingEndpoints, parseConnectionSlug, parseDisplayName, parseIssuer, parseProviderSelection, parseScopes, referencedVariable, selectionName } from "@zitadel/config/idp";
19
+ import { SETUP_PRESETS, getDefaultLoginFlow } from "@zitadel/config/defaults";
20
+ import { POLICY_FILE_SCHEMA_REF } from "@zitadel/config/meta-schemas";
21
+ import { lookup } from "node:dns/promises";
22
+ import { validateLoginTemplate } from "@zitadel/config/template";
23
+ import wrapAnsi from "wrap-ansi";
24
+ import * as rosie from "rosie-skills";
25
+ //#region src/lib/errors.ts
26
+ /**
27
+ * Maps each {@link ZitadelErrorCode} to the process exit code the CLI
28
+ * returns. The table is the single source of truth for exit semantics so
29
+ * scripts and CI can branch on stable, documented numbers.
30
+ */
31
+ const EXIT_CODES = {
32
+ E_ALREADY_INIT: 0,
33
+ E_FRAMEWORK_NOT_DETECTED: 3,
34
+ E_UNSUPPORTED_PROJECT_SHAPE: 3,
35
+ E_NETWORK: 4,
36
+ E_AUTH: 1,
37
+ E_CONFLICT: 5,
38
+ E_LOCAL_SERVER_NOT_RUNNING: 4,
39
+ E_NOT_FOUND: 4,
40
+ E_PORT_IN_USE: 5,
41
+ E_VALIDATION: 3,
42
+ E_CREDENTIAL_MISSING: 3,
43
+ E_NOT_IMPLEMENTED: 2,
44
+ E_CANCELLED: 130
45
+ };
46
+ /**
47
+ * The CLI's single error type. Carries a {@link ZitadelErrorCode} so the
48
+ * top-level handler can derive an exit code and structured output without
49
+ * pattern-matching on messages. Throwing this anywhere guarantees the user
50
+ * gets a categorised, hint-bearing failure instead of a raw stack trace.
51
+ */
52
+ var ZitadelError = class extends Error {
53
+ code;
54
+ hint;
55
+ nextCommands;
56
+ details;
57
+ constructor(code, message, opts = {}) {
58
+ super(message);
59
+ this.name = "ZitadelError";
60
+ this.code = code;
61
+ this.hint = opts.hint;
62
+ this.nextCommands = opts.nextCommands;
63
+ this.details = opts.details;
64
+ }
65
+ get exitCode() {
66
+ return EXIT_CODES[this.code] ?? 1;
67
+ }
68
+ };
69
+ /**
70
+ * Normalises any thrown value into a {@link ZitadelError}. Inspection is
71
+ * ordered most-specific-first (already-normalised, HTTP status, then
72
+ * errno/filesystem, no response, Zod-like, generic `Error`, then a
73
+ * catch-all) so the most actionable category and hint win. A cancelled
74
+ * request arrives already normalised, as the `E_CANCELLED` error that
75
+ * `./interrupt` aborts it with. This is the boundary that lets the rest
76
+ * of the CLI `throw` plain errors yet still produce consistent, categorised
77
+ * output. The original error shape is preserved under `details` for
78
+ * debugging without leaking it into the user-facing message.
79
+ */
80
+ function toZitadelError(error) {
81
+ if (error instanceof ZitadelError) return error;
82
+ if (error instanceof ApiError) {
83
+ const details = {
84
+ status: error.status,
85
+ url: error.url,
86
+ body: error.body
87
+ };
88
+ if (error.status === 404) {
89
+ if (apiErrorCode(error) === "req.not_found") return new ZitadelError("E_NOT_FOUND", `${error.url} has no such endpoint on this Zitadel server`, {
90
+ hint: "The server and this CLI are different versions. Run the CLI release that matches the server, or upgrade the server.",
91
+ details
92
+ });
93
+ return new ZitadelError("E_NOT_FOUND", !isPlatformErrorEnvelope(error.body) ? `${apiErrorMessage(error)} — ${error.url} has no such endpoint; is this a Zitadel platform API?` : apiErrorMessage(error), { details });
94
+ }
95
+ const code = error.status === 403 && error.body?.code === "proj.origin_not_allowed" ? "E_VALIDATION" : error.status === 401 || error.status === 403 ? "E_AUTH" : error.status === 409 ? "E_CONFLICT" : error.status >= 500 ? "E_NETWORK" : "E_VALIDATION";
96
+ const fields = listedInDetails(error.body, "fields");
97
+ const missing = listedInDetails(error.body, "missing");
98
+ const hint = [...fields.length > 0 ? [`The server rejected ${fields.length === 1 ? "this field" : "these fields"}: ${fields.join(", ")}.`] : [], ...missing.length > 0 ? [`The server reports ${missing.length === 1 ? "this endpoint" : "these endpoints"} missing: ${missing.join(", ")}.`] : []].join(" ");
99
+ return new ZitadelError(code, apiErrorMessage(error), {
100
+ ...hint !== "" ? { hint } : {},
101
+ details
102
+ });
103
+ }
104
+ if (isErrnoException(error)) {
105
+ const details = { original: pickErrorShape(error) };
106
+ if (error.code === "EACCES" || error.code === "EPERM") return new ZitadelError("E_AUTH", `Permission denied: ${error.message}`, {
107
+ hint: "Check file permissions or run with the right user.",
108
+ details
109
+ });
110
+ if (error.code === "EEXIST") return new ZitadelError("E_CONFLICT", error.message, {
111
+ hint: "A file already exists. Use --force to overwrite or remove it first.",
112
+ details
113
+ });
114
+ if (error.code === "ENOENT") return new ZitadelError("E_VALIDATION", error.message, {
115
+ hint: "A required file or directory is missing.",
116
+ details
117
+ });
118
+ }
119
+ if (error instanceof NetworkError) return new ZitadelError("E_NETWORK", error.message, {
120
+ hint: error.reason === "timeout" ? "The server accepted the connection but did not answer in time. Check that it is healthy, then retry." : "Check your connection and the server URL (ZITADEL_URL or --server).",
121
+ details: {
122
+ url: error.url,
123
+ reason: error.reason
124
+ }
125
+ });
126
+ if (isZodLikeError(error)) return new ZitadelError("E_VALIDATION", errorMessage(error), { details: { issues: error.issues } });
127
+ if (error instanceof Error) return new ZitadelError("E_VALIDATION", error.message, { details: { original: pickErrorShape(error) } });
128
+ return new ZitadelError("E_VALIDATION", "Unknown error", { details: error });
129
+ }
130
+ /**
131
+ * True when a response body looks like the platform's structured error
132
+ * envelope (`{ code, message, … }`). Its presence proves the request reached
133
+ * a real Zitadel platform API rather than an arbitrary HTTP server.
134
+ */
135
+ /**
136
+ * The names a platform error envelope lists under `key`, if any: the dotted
137
+ * field paths it blames (`fields`), or what it reports missing (`missing`).
138
+ *
139
+ * The envelope nests them under `details`, and the server wraps its own
140
+ * `details` map in the response's `details` field, so the names arrive at
141
+ * `body.details.details.<key>`. Both depths are read: the extra wrapper is
142
+ * the serialiser's doing rather than part of the contract, and a reader that
143
+ * insisted on it would go silent the day it goes away.
144
+ */
145
+ function listedInDetails(body, key) {
146
+ const seen = /* @__PURE__ */ new Set();
147
+ let node = body;
148
+ for (let depth = 0; depth < 4 && isObject$2(node); depth += 1) {
149
+ const listed = node[key];
150
+ if (Array.isArray(listed)) {
151
+ for (const name of listed) if (typeof name === "string" && name !== "") seen.add(name);
152
+ }
153
+ node = node.details;
154
+ }
155
+ return [...seen];
156
+ }
157
+ function isObject$2(value) {
158
+ return typeof value === "object" && value !== null;
159
+ }
160
+ /**
161
+ * Whether a request reached a server that has no such endpoint: a 404 without
162
+ * the platform's error envelope. A Zitadel API answers a miss it knows about
163
+ * with the envelope, so this is a route the server does not serve, such as one
164
+ * added in a later release.
165
+ */
166
+ function isMissingEndpoint(error) {
167
+ return error instanceof ApiError && error.status === 404 && !isPlatformErrorEnvelope(error.body);
168
+ }
169
+ function isPlatformErrorEnvelope(body) {
170
+ return typeof body === "object" && body !== null && typeof body.code === "string";
171
+ }
172
+ function isErrnoException(error) {
173
+ return error instanceof Error && typeof error.code === "string";
174
+ }
175
+ function isZodLikeError(error) {
176
+ return typeof error === "object" && error !== null && "issues" in error && Array.isArray(error.issues);
177
+ }
178
+ /** Whether a caught error is the given `errno` code. */
179
+ function isErrno$1(error, code) {
180
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
181
+ }
182
+ function errorMessage(error) {
183
+ if (error instanceof Error) return error.message;
184
+ if (typeof error === "string") return error;
185
+ return String(error);
186
+ }
187
+ function pickErrorShape(error) {
188
+ return {
189
+ name: error.name,
190
+ message: error.message,
191
+ code: error.code
192
+ };
193
+ }
194
+ //#endregion
195
+ //#region src/lib/interrupt.ts
196
+ /**
197
+ * Ctrl-C for the running command.
198
+ *
199
+ * Node exits on SIGINT only while nothing listens for it, and a spinner does
200
+ * listen — clack's stops its animation and returns — so a request stalled
201
+ * behind one used to outlive the keypress. Instead the command listens for
202
+ * itself: the first Ctrl-C aborts {@link interruptSignal}, which every platform
203
+ * request is bound to, so a waiting request rejects with `E_CANCELLED` and the
204
+ * command unwinds through its normal error path.
205
+ *
206
+ * Work that is not a request does not see the signal, so the first Ctrl-C also
207
+ * arms a short grace: a command still running when it lapses exits 130. A
208
+ * second Ctrl-C exits at once. A command with its own handler (`logs --follow`
209
+ * stops following) finishes inside the grace and exits as it always did.
210
+ */
211
+ const GRACE_MS = 1e3;
212
+ let controller = new AbortController();
213
+ let grace;
214
+ /**
215
+ * Aborted once Ctrl-C has been pressed in the current command, and stays
216
+ * aborted, so a caller that swallows the first rejection cannot start another
217
+ * request.
218
+ */
219
+ function interruptSignal() {
220
+ return controller.signal;
221
+ }
222
+ function onInterrupt() {
223
+ if (controller.signal.aborted) process.exit(130);
224
+ controller.abort(new ZitadelError("E_CANCELLED", "Cancelled", { hint: "The command was cancelled with Ctrl-C." }));
225
+ grace = setTimeout(() => process.exit(130), GRACE_MS);
226
+ grace.unref();
227
+ }
228
+ /** Starts listening as a command begins, with a signal of its own. */
229
+ function listenForInterrupt() {
230
+ stopListeningForInterrupt();
231
+ controller = new AbortController();
232
+ process.on("SIGINT", onInterrupt);
233
+ }
234
+ /**
235
+ * Stops listening as the command ends. Only matters when commands share a
236
+ * process, as specs do: the grace must not outlive the command that armed it.
237
+ */
238
+ function stopListeningForInterrupt() {
239
+ process.off("SIGINT", onInterrupt);
240
+ clearTimeout(grace);
241
+ grace = void 0;
242
+ }
243
+ //#endregion
244
+ //#region src/lib/api-client.ts
245
+ /**
246
+ * How long one platform request may take, response body included, before it
247
+ * fails as `E_NETWORK`. Without it a server that accepts the connection and
248
+ * never answers holds the command for the runtime's own limit — minutes.
249
+ */
250
+ const REQUEST_TIMEOUT_MS = 3e4;
251
+ /**
252
+ * Characters that can drive a terminal rather than print on it: the C0
253
+ * controls (ESC among them), DEL and the C1 controls (`\p{Cc}`), the format
254
+ * and bidi controls (`\p{Cf}`), and the line and paragraph separators. Newline
255
+ * and tab are left alone unless the caller asks otherwise, because they are
256
+ * how a description or a multi-line value is written.
257
+ */
258
+ const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
259
+ /**
260
+ * Escape every character that could drive the reader's terminal to its
261
+ * visible `\xNN`/`\uNNNN` spelling, or `\u{NNNNN}` above the BMP so the
262
+ * spelling cannot run into the character that follows it.
263
+ *
264
+ * Anything the server returns was written by someone — a user's name, a
265
+ * branding value, a variable — and an ESC or OSC sequence in it would
266
+ * otherwise clear the screen, set the window title, write the clipboard
267
+ * through OSC 52, or disguise a link through OSC 8 when printed. Ordinary text
268
+ * is unchanged, so readable values stay readable.
269
+ */
270
+ function escapeControlCharacters(text, opts = {}) {
271
+ const keepLayout = opts.keepLayout ?? true;
272
+ return text.replace(CONTROL, (char) => {
273
+ if (keepLayout && (char === "\n" || char === " ")) return char;
274
+ const code = char.codePointAt(0) ?? 0;
275
+ if (code <= 255) return `\\x${code.toString(16).padStart(2, "0")}`;
276
+ return code <= 65535 ? `\\u${code.toString(16).padStart(4, "0")}` : `\\u{${code.toString(16)}}`;
277
+ });
278
+ }
279
+ /**
280
+ * Escape an object key. Unlike a value, a key must stay distinct from its
281
+ * siblings: a key holding a raw ESC and one holding the literal text `\x1b`
282
+ * would otherwise both come out as `\x1b`, and one of the two fields would be
283
+ * lost. Doubling the backslash first makes the spelling unambiguous.
284
+ */
285
+ function escapeKey(key) {
286
+ return escapeControlCharacters(key.replaceAll("\\", "\\\\"));
287
+ }
288
+ /**
289
+ * Escape every string in a decoded JSON value, keys included, at any depth.
290
+ * Numbers, booleans and `null` pass through untouched; the result has the
291
+ * same shape as the input, with the same number of fields.
292
+ *
293
+ * The walk keeps its own work list rather than recursing. A user attribute
294
+ * holds arbitrary JSON, and a few thousand levels of nesting — which
295
+ * `JSON.parse` and `JSON.stringify` both accept — would otherwise exhaust the
296
+ * call stack, letting one writer make `list` and `get` fail for every reader.
297
+ */
298
+ function sanitizeResponse(value) {
299
+ const pending = [];
300
+ const copy = (item) => {
301
+ if (typeof item === "string") return escapeControlCharacters(item);
302
+ if (typeof item !== "object" || item === null) return item;
303
+ const target = Array.isArray(item) ? [] : {};
304
+ pending.push({
305
+ source: item,
306
+ target
307
+ });
308
+ return target;
309
+ };
310
+ const root = copy(value);
311
+ for (let next = pending.pop(); next; next = pending.pop()) {
312
+ const { source, target } = next;
313
+ if (Array.isArray(source)) {
314
+ for (const item of source) target.push(copy(item));
315
+ continue;
316
+ }
317
+ for (const [key, item] of Object.entries(source)) Object.defineProperty(target, escapeKey(key), {
318
+ value: copy(item),
319
+ enumerable: true,
320
+ writable: true,
321
+ configurable: true
322
+ });
323
+ }
324
+ return root;
325
+ }
326
+ /**
327
+ * Rebuild a rejected call's {@link ApiError} with the server's text escaped.
328
+ * The error envelope's `message` and `details` are printed as the failure, so
329
+ * they are as attacker-influenceable as any response body. It stays an
330
+ * `ApiError`, with the same status and URL, so `toZitadelError` classifies it
331
+ * as before. Exported for `zitadel api`, which sends its requests itself.
332
+ */
333
+ function sanitizeRejection(error) {
334
+ if (!(error instanceof ApiError)) throw error;
335
+ const sanitized = new ApiError(error.status, error.url, sanitizeResponse(error.body), escapeControlCharacters(error.message));
336
+ sanitized.stack = error.stack === void 0 ? void 0 : escapeControlCharacters(error.stack);
337
+ throw sanitized;
338
+ }
339
+ /**
340
+ * Build the typed Zitadel client the CLI talks to the server through.
341
+ *
342
+ * It is `@zitadel/api`'s client with every resolved response, and every
343
+ * server-supplied error, passed through {@link sanitizeResponse} — the approach
344
+ * the GitHub CLI takes with go-gh's `asciisanitizer`, widened to the format
345
+ * and bidi controls that can disguise text. Doing it once here, rather than
346
+ * in each renderer, covers every command, table, detail view and `--json`
347
+ * envelope, including ones not yet written; only a caller that asks for
348
+ * {@link SanitizeOptions.verbatim} bodies escapes for itself. The shared
349
+ * package is left raw: the console renders into a DOM, which does not
350
+ * interpret escape codes.
351
+ *
352
+ * Every request is also bounded by {@link REQUEST_TIMEOUT_MS} and cancelled by
353
+ * Ctrl-C (see `./interrupt`), unless the caller sets its own `signal` or
354
+ * `timeoutMs`.
355
+ */
356
+ function createZitadelClient$1(opts, sanitize = {}) {
357
+ const onResolve = sanitize.verbatim ? (value) => value : sanitizeResponse;
358
+ const client = createZitadelClient({
359
+ signal: interruptSignal(),
360
+ timeoutMs: REQUEST_TIMEOUT_MS,
361
+ ...opts
362
+ });
363
+ return new Proxy(client, { get(target, prop, receiver) {
364
+ const value = Reflect.get(target, prop, receiver);
365
+ if (typeof value !== "function") return value;
366
+ return (...args) => {
367
+ const result = value(...args);
368
+ return result instanceof Promise ? result.then(onResolve, sanitizeRejection) : result;
369
+ };
370
+ } });
371
+ }
372
+ //#endregion
373
+ //#region src/lib/json.ts
374
+ /**
375
+ * Serialise a value to pretty-printed JSON with object keys sorted at every
376
+ * depth. Determinism is the point: managed files written by the CLI must be
377
+ * byte-stable across runs so diffs stay clean and content hashes don't churn
378
+ * when only key ordering would otherwise differ. Delegates the deterministic
379
+ * sort to `safe-stable-stringify`, matching `JSON.stringify(value, null, 2)`
380
+ * formatting. The `?? "null"` only applies to `undefined`/function inputs,
381
+ * which the CLI never serialises.
382
+ */
383
+ function stableStringify(value) {
384
+ return stringify(value, null, 2) ?? "null";
385
+ }
386
+ /**
387
+ * Replace (or insert) one top-level key's value in a JSON document by
388
+ * splicing text, leaving every other byte of the source untouched. The
389
+ * counterpart of {@link stableStringify} for files the CLI does *not* own
390
+ * (the user's `package.json`): blank lines, inline nested objects, key
391
+ * order, indentation style, line endings, and the trailing newline all
392
+ * survive, because nothing outside the spliced value is reserialized. The
393
+ * replaced value itself is rendered at the document's detected indent (a
394
+ * compact document stays compact).
395
+ *
396
+ * Defensive by construction: the result is re-parsed and compared against
397
+ * the expected mutation, so a splice that would corrupt the document throws
398
+ * instead of writing it.
399
+ */
400
+ function setTopLevelJsonKey(source, path, key, value) {
401
+ const parsed = parseJsonObject(source, path);
402
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
403
+ const indent = detectIndent(source);
404
+ const rendered = renderValueAtDepthOne(value, indent, eol);
405
+ const layout = scanTopLevel(source);
406
+ const member = layout.members.find((candidate) => candidate.key === key);
407
+ let next;
408
+ if (member) next = `${source.slice(0, member.valueStart)}${rendered}${source.slice(member.valueEnd)}`;
409
+ else if (layout.members.length === 0) {
410
+ const inner = indent === 0 ? `${JSON.stringify(key)}:${rendered}` : `${eol}${indentString(indent)}${JSON.stringify(key)}: ${rendered}${eol}`;
411
+ next = `${source.slice(0, layout.open + 1)}${inner}${source.slice(layout.close)}`;
412
+ } else {
413
+ const last = layout.members.at(-1);
414
+ if (!last) throw new Error(`refusing to write ${path}: the JSON layout scan found no members`);
415
+ const inner = indent === 0 ? `,${JSON.stringify(key)}:${rendered}` : `,${eol}${indentString(indent)}${JSON.stringify(key)}: ${rendered}`;
416
+ next = `${source.slice(0, last.valueEnd)}${inner}${source.slice(last.valueEnd)}`;
417
+ }
418
+ const verification = parseJsonObject(next, path);
419
+ const expected = {
420
+ ...parsed,
421
+ [key]: value
422
+ };
423
+ if (JSON.stringify(verification) !== JSON.stringify(expected)) throw new Error(`refusing to write ${path}: the targeted JSON edit did not verify`);
424
+ return next;
425
+ }
426
+ /**
427
+ * Infers the indentation unit of a JSON document: the whitespace prefix of
428
+ * its first indented line, a compact document stays compact (`0`), and
429
+ * anything unrecognizable falls back to two spaces.
430
+ */
431
+ function detectIndent(source) {
432
+ const match = source.match(/\n([ \t]+)\S/);
433
+ if (match?.[1]) return match[1];
434
+ return source.trimEnd().includes("\n") ? 2 : 0;
435
+ }
436
+ function indentString(indent) {
437
+ return typeof indent === "number" ? " ".repeat(indent) : indent;
438
+ }
439
+ /** Serializes a value as it should appear as a top-level member's value. */
440
+ function renderValueAtDepthOne(value, indent, eol) {
441
+ if (indent === 0) return JSON.stringify(value);
442
+ const unit = indentString(indent);
443
+ return JSON.stringify(value, null, unit).split("\n").join(`${eol}${unit}`);
444
+ }
445
+ /**
446
+ * Single-pass, string-aware scan of a JSON object document, collecting the
447
+ * exact text span of every top-level member's value plus the root braces.
448
+ * Only structural understanding needed for the splice — the document has
449
+ * already been validated by `JSON.parse`, so this can assume well-formed
450
+ * input and does not re-validate.
451
+ */
452
+ function scanTopLevel(source) {
453
+ const members = [];
454
+ let open = -1;
455
+ let close = -1;
456
+ let depth = 0;
457
+ let index = 0;
458
+ while (index < source.length) {
459
+ const char = source.charAt(index);
460
+ if (char === "\"") {
461
+ const start = index;
462
+ index = skipString(source, index);
463
+ if (depth === 1 && isKeyPosition(source, start)) {
464
+ const key = JSON.parse(source.slice(start, index));
465
+ while (source[index] !== ":") index += 1;
466
+ index += 1;
467
+ while (index < source.length && isWhitespace(source.charAt(index))) index += 1;
468
+ const valueStart = index;
469
+ index = skipValue(source, index);
470
+ members.push({
471
+ key,
472
+ valueStart,
473
+ valueEnd: index
474
+ });
475
+ }
476
+ continue;
477
+ }
478
+ if (char === "{" || char === "[") {
479
+ depth += 1;
480
+ if (depth === 1 && open === -1) open = index;
481
+ } else if (char === "}" || char === "]") {
482
+ depth -= 1;
483
+ if (depth === 0) close = index;
484
+ }
485
+ index += 1;
486
+ }
487
+ return {
488
+ open,
489
+ close,
490
+ members
491
+ };
492
+ }
493
+ /** True when the string starting at `start` is a top-level key, not a value. */
494
+ function isKeyPosition(source, start) {
495
+ for (let index = start - 1; index >= 0; index -= 1) {
496
+ const char = source.charAt(index);
497
+ if (isWhitespace(char)) continue;
498
+ return char === "{" || char === ",";
499
+ }
500
+ return false;
501
+ }
502
+ /** Returns the index just past the value starting at `index`. */
503
+ function skipValue(source, index) {
504
+ const char = source.charAt(index);
505
+ if (char === "\"") return skipString(source, index);
506
+ if (char === "{" || char === "[") {
507
+ let depth = 0;
508
+ let cursor = index;
509
+ while (cursor < source.length) {
510
+ const current = source.charAt(cursor);
511
+ if (current === "\"") {
512
+ cursor = skipString(source, cursor);
513
+ continue;
514
+ }
515
+ if (current === "{" || current === "[") depth += 1;
516
+ if (current === "}" || current === "]") {
517
+ depth -= 1;
518
+ if (depth === 0) return cursor + 1;
519
+ }
520
+ cursor += 1;
521
+ }
522
+ return cursor;
523
+ }
524
+ let cursor = index;
525
+ while (cursor < source.length && !",}]".includes(source.charAt(cursor)) && !isWhitespace(source.charAt(cursor))) cursor += 1;
526
+ return cursor;
527
+ }
528
+ /** Returns the index just past the closing quote of the string at `index`. */
529
+ function skipString(source, index) {
530
+ let cursor = index + 1;
531
+ while (cursor < source.length) {
532
+ const char = source.charAt(cursor);
533
+ if (char === "\\") {
534
+ cursor += 2;
535
+ continue;
536
+ }
537
+ if (char === "\"") return cursor + 1;
538
+ cursor += 1;
539
+ }
540
+ return cursor;
541
+ }
542
+ function isWhitespace(char) {
543
+ return char === " " || char === " " || char === "\n" || char === "\r";
544
+ }
545
+ /**
546
+ * Parse `contents` as JSON and assert the root is a plain object (not an
547
+ * array or scalar). The CLI's config and secret files are always objects, so
548
+ * this guards callers from the `JSON.parse` return type of `any` and produces
549
+ * a `path`-qualified error message pointing at the offending file.
550
+ */
551
+ function parseJsonObject(contents, path) {
552
+ const value = JSON.parse(contents);
553
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must contain a JSON object`);
554
+ return value;
555
+ }
556
+ /**
557
+ * Narrows an unknown value to a plain (non-array, non-null) object. Shared by
558
+ * the commands and the file-writer that walk parsed JSON, so the predicate
559
+ * isn't reimplemented per call site.
560
+ */
561
+ function isObject$1(value) {
562
+ return typeof value === "object" && value !== null && !Array.isArray(value);
563
+ }
564
+ //#endregion
565
+ //#region src/lib/oclif/crud/paging.ts
566
+ /** The list commands' own: `--all`, and `--limit` with `--page-token`. */
567
+ const LIST_FLAGS = {
568
+ all: "--all",
569
+ onePage: "--limit and --page-token"
570
+ };
571
+ /**
572
+ * Walk a cursor-paginated endpoint. `request` performs one page; the
573
+ * response must carry the items under `items` and, while more remain, a
574
+ * the cursor property `nextPageToken` names. With `all` the pages are drained
575
+ * in order and `next`
576
+ * is `null`; otherwise one page is fetched and `next` is its token. `flags`
577
+ * names the caller's own flags in the errors.
578
+ */
579
+ const collectPages = async (request, { items, all, token, nextPageToken, flags = LIST_FLAGS }) => {
580
+ const collected = [];
581
+ const seen = /* @__PURE__ */ new Set();
582
+ let cursor = token;
583
+ for (;;) {
584
+ const response = await request(cursor);
585
+ const page = items === void 0 ? response : isObject$1(response) ? response[items] : void 0;
586
+ if (!Array.isArray(page)) throw new ZitadelError("E_VALIDATION", items === void 0 ? "Unexpected list response: expected an array" : `Unexpected list response: missing "${items}" array`, { details: { response } });
587
+ collected.push(...page);
588
+ const cursor_ = isObject$1(response) ? response[nextPageToken] : void 0;
589
+ const next = typeof cursor_ === "string" ? cursor_ || null : null;
590
+ if (!all) return {
591
+ items: collected,
592
+ next
593
+ };
594
+ if (!next) return {
595
+ items: collected,
596
+ next: null
597
+ };
598
+ if (seen.has(next)) throw new ZitadelError("E_VALIDATION", `The server repeated a page cursor, so ${flags.all} would never finish`, {
599
+ hint: `Fetch one page at a time with ${flags.onePage}, and report the repeated cursor.`,
600
+ details: {
601
+ cursor: next,
602
+ pages_fetched: seen.size + 1
603
+ }
604
+ });
605
+ seen.add(next);
606
+ cursor = next;
607
+ }
608
+ };
609
+ //#endregion
610
+ //#region src/lib/public-cli.ts
611
+ const CLI_PACKAGE_NAME = "@zitadel/cli";
612
+ function npmDistTagForCliVersion(cliVersion) {
613
+ return cliVersion.trim().replace(/^v/, "").match(/^\d+\.\d+\.\d+-([0-9A-Za-z][0-9A-Za-z-]*)/)?.[1] ?? "latest";
614
+ }
615
+ /**
616
+ * Whether a version is one the release train publishes: a release (`0.1.0`) or
617
+ * an alpha prerelease (`1.0.0-alpha.25`). Any other prerelease (`1.0.0-beta.2`,
618
+ * `0.0.0-test`) has only its dist-tag to point at.
619
+ */
620
+ function isTrainVersion(version) {
621
+ return /^\d+\.\d+\.\d+(-alpha\.\d+)?$/.test(version.trim().replace(/^v/, ""));
622
+ }
623
+ function npmSelectorForCliVersion(cliVersion) {
624
+ const normalized = cliVersion.trim().replace(/^v/, "");
625
+ if (isTrainVersion(normalized)) return normalized;
626
+ return npmDistTagForCliVersion(normalized);
627
+ }
628
+ /**
629
+ * The version a scaffold writes for the `@zitadel/*` packages it adds: the
630
+ * CLI's own exact version on the train, else its dist-tag (`beta` for
631
+ * `1.0.0-beta.2`). The packages publish as one lockstep train, so the exact pin
632
+ * keeps the app on the SDK that matches the files this CLI wrote, and lets
633
+ * doctor's `dependency-version` check compare it against the CLI.
634
+ */
635
+ function sdkVersionForCli(cliVersion) {
636
+ return npmSelectorForCliVersion(cliVersion);
637
+ }
638
+ /**
639
+ * Whether a value can sit in a suggested command unquoted on every shell the
640
+ * CLI runs under (POSIX shells, PowerShell and cmd.exe). Quoting differs
641
+ * between them, so a value that needs quoting is not put in a suggested
642
+ * command at all; callers hand it over as an argument list instead.
643
+ */
644
+ function isPortableShellWord(value) {
645
+ return /^[A-Za-z0-9_./-]+$/.test(value);
646
+ }
647
+ /**
648
+ * An option and its value as arguments for a suggested command. A value that
649
+ * starts with `-` would be read as the next flag, so it is joined with `=`
650
+ * instead. That token is not a portable shell word, so it also keeps the
651
+ * string form out of {@link portableCommands}.
652
+ *
653
+ * @param option - The option, with its dashes, e.g. `--schema`.
654
+ * @param value - The value it takes, as the user or a file name gave it.
655
+ */
656
+ function optionArgs(option, value) {
657
+ return value.startsWith("-") ? [`${option}=${value}`] : [option, value];
658
+ }
659
+ function publicCliCommand(args, cliVersion) {
660
+ const prefix = `npx ${CLI_PACKAGE_NAME}@${npmSelectorForCliVersion(cliVersion)}`;
661
+ return args.length > 0 ? `${prefix} ${args}` : prefix;
662
+ }
663
+ function normalizePublicCliCommand(command, cliVersion) {
664
+ if (command === "zitadel") return publicCliCommand("", cliVersion);
665
+ if (command.startsWith("zitadel ")) return publicCliCommand(command.slice(8), cliVersion);
666
+ return command;
667
+ }
668
+ function normalizePublicCliCommands(commands, cliVersion) {
669
+ return commands?.map((command) => normalizePublicCliCommand(command, cliVersion));
670
+ }
671
+ /**
672
+ * Rewrites `zitadel …` command mentions in scaffolded prose (the
673
+ * `.zitadel/**` READMEs) to the public `npx @zitadel/cli@<version> …` form.
674
+ *
675
+ * The checked-in README sources stay written with the bare `zitadel`
676
+ * command — that is the canonical, readable spelling — but the CLI is not a
677
+ * dependency of the scaffolded app, so the bare command does not exist on
678
+ * the user's PATH (and `npx zitadel` would fetch an unrelated npm package).
679
+ * Covers inline code spans (`` `zitadel deploy` ``) and fenced-block lines
680
+ * that start with `zitadel `; prose file names like `zitadel.json` and
681
+ * `zitadel.db` don't match because the word must end the span or be
682
+ * followed by a space.
683
+ */
684
+ function normalizePublicCliProse(content, cliVersion) {
685
+ const prefix = publicCliCommand("", cliVersion);
686
+ return rewriteCliCodeSpans(content, cliVersion).replace(/^(\s*)zitadel (.*)$/gm, (_match, indent, rest) => `${indent}${prefix} ${rest}`);
687
+ }
688
+ /** The inline-code-span half of {@link normalizePublicCliProse}. */
689
+ function rewriteCliCodeSpans(content, cliVersion) {
690
+ const prefix = publicCliCommand("", cliVersion);
691
+ return content.replace(/`zitadel( [^`\n]*)?`/g, (_match, args) => `\`${prefix}${args ?? ""}\``);
692
+ }
693
+ /**
694
+ * Rewrites `zitadel …` command mentions inside every string of a scaffolded
695
+ * JSON document — today the `.zitadel/meta/*.json` dialect files — returning
696
+ * a rewritten copy. The input is never mutated: the meta-schema bodies are
697
+ * imported JSON modules shared across calls.
698
+ *
699
+ * Those files are generated copies of the meta-schemas the server embeds
700
+ * (`api/openapi/endpoints/schemas/*.json`), where the bare `zitadel` spelling
701
+ * is the correct one — the server documents the product command, not one
702
+ * project's install. Their `description` strings surface as editor tooltips
703
+ * on the scaffolded `.zitadel/**` files, where the bare command does not
704
+ * exist on the user's PATH, so the copy is normalized on the way out instead
705
+ * of the shared source being edited (same fix PR #872 made for the READMEs).
706
+ *
707
+ * Unlike {@link normalizePublicCliProse} this rewrites inline code spans
708
+ * only: a JSON `description` is prose, so a value that merely begins with
709
+ * the word `zitadel` is a sentence, not a command line.
710
+ */
711
+ function normalizePublicCliJson(value, cliVersion) {
712
+ if (typeof value === "string") return rewriteCliCodeSpans(value, cliVersion);
713
+ if (Array.isArray(value)) return value.map((item) => normalizePublicCliJson(item, cliVersion));
714
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizePublicCliJson(item, cliVersion)]));
715
+ return value;
716
+ }
717
+ /** The version selector of a public `npx @zitadel/cli@<selector>` mention. */
718
+ const PUBLIC_CLI_VERSION = new RegExp(`${CLI_PACKAGE_NAME.replaceAll("/", "\\/")}@[^\\s\`]+`, "g");
719
+ /**
720
+ * Replaces the version in every public CLI mention inside a JSON document
721
+ * with `*`, so two documents {@link normalizePublicCliJson} wrote for
722
+ * different CLI versions compare equal when nothing else differs. Returns a
723
+ * rewritten copy.
724
+ */
725
+ function maskPublicCliVersion(value) {
726
+ if (typeof value === "string") return value.replace(PUBLIC_CLI_VERSION, `${CLI_PACKAGE_NAME}@*`);
727
+ if (Array.isArray(value)) return value.map((item) => maskPublicCliVersion(item));
728
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskPublicCliVersion(item)]));
729
+ return value;
730
+ }
731
+ //#endregion
732
+ //#region src/lib/suggestion.ts
733
+ /** Brands a suffix built by `environmentSelectorSuffix`; nothing else calls it. */
734
+ function asEnvironmentSelector(suffix) {
735
+ return suffix;
736
+ }
737
+ /**
738
+ * A `zitadel` command aimed at the same project as the run that suggests it.
739
+ * The selector is appended to `args`, so `args` must not end with a shell
740
+ * redirection; put that after the result instead.
741
+ */
742
+ function projectCommand(args, selector, cliVersion) {
743
+ return publicCliCommand(`${args}${selector}`, cliVersion);
744
+ }
745
+ /**
746
+ * A `zitadel` command that does not target a project: `setup`, `start`,
747
+ * `doctor`, the local runtime. Without `cliVersion` it is the bare `zitadel …`
748
+ * spelling, which the envelope rewrites to the public `npx` form on the way
749
+ * out; pass it when the command is also quoted in a hint.
750
+ */
751
+ function unscopedCommand(args, cliVersion) {
752
+ return cliVersion === void 0 ? `zitadel ${args}` : publicCliCommand(args, cliVersion);
753
+ }
754
+ /**
755
+ * Commands built from argument lists, for a caller that also reports the lists
756
+ * (`next_args`). Every argument must be a portable shell word, or none is
757
+ * returned and the lists stand alone, which no shell interprets. The lists
758
+ * carry whatever selector the command needs: they are the arguments as run.
759
+ */
760
+ function portableSuggestions(argLists, cliVersion, placeholders = []) {
761
+ const allowed = (word) => placeholders.includes(word) || isPortableShellWord(word);
762
+ if (!argLists.every((args) => args.every(allowed))) return [];
763
+ return argLists.map((args) => publicCliCommand(args.map((word) => placeholders.includes(word) ? `'${word}'` : word).join(" "), cliVersion));
764
+ }
765
+ /** A command that is not the CLI (`npm install …`, `rm …`), suggested as written. */
766
+ function shellCommand(command) {
767
+ return command;
768
+ }
769
+ /**
770
+ * A suggestion wrapped in shell around it: a pipe that feeds it a secret
771
+ * (`printf '%s' "$SECRET" | `) or the `< value.txt` redirection that must
772
+ * follow the selector. The command itself stays the one a builder made.
773
+ */
774
+ function inShell(command, shell) {
775
+ return `${shell.before ?? ""}${command}${shell.after ?? ""}`;
776
+ }
777
+ //#endregion
778
+ //#region src/lib/telemetry/dimensions/env-flag.ts
779
+ const DISABLED_VALUES$1 = /* @__PURE__ */ new Set([
780
+ "",
781
+ "0",
782
+ "false",
783
+ "off",
784
+ "no"
785
+ ]);
786
+ /**
787
+ * Whether an environment variable is set to an enabled value. A present-but-
788
+ * falsey string (`CI=false`, `CI=0`) counts as disabled — unlike a bare
789
+ * `Boolean(env.CI)` check, which is true for any non-empty string.
790
+ */
791
+ function envEnabled(value) {
792
+ return value !== void 0 && !DISABLED_VALUES$1.has(value.trim().toLowerCase());
793
+ }
794
+ //#endregion
795
+ //#region src/lib/telemetry/consent.ts
796
+ /** The cross-tool opt-out. */
797
+ const DO_NOT_TRACK = {
798
+ name: "DO_NOT_TRACK",
799
+ flag: "telemetry",
800
+ description: "Any value but `0` or an empty one turns telemetry off (https://consoledonottrack.com).",
801
+ precedence: "Turns telemetry off even with `--telemetry`."
802
+ };
803
+ /** The values of {@link ZITADEL_TELEMETRY}, trimmed and lowercased, that turn telemetry off. */
804
+ const DISABLED_VALUES = /* @__PURE__ */ new Set([
805
+ "",
806
+ "0",
807
+ "false",
808
+ "off",
809
+ "no"
810
+ ]);
811
+ /** The CLI's own opt-out. */
812
+ const ZITADEL_TELEMETRY = {
813
+ name: "ZITADEL_TELEMETRY",
814
+ flag: "telemetry",
815
+ description: `An empty value or one of ${[...DISABLED_VALUES].filter((value) => value !== "").map((value) => `\`${value}\``).join(", ")} turns telemetry off.`,
816
+ precedence: "Turns telemetry off even with `--telemetry`."
817
+ };
818
+ /**
819
+ * Resolve telemetry consent under an opt-out model: on by default, but any of
820
+ * several explicit signals turns it off, in precedence order.
821
+ *
822
+ * 1. `--no-telemetry` on the command line — the most explicit, per-invocation.
823
+ * 2. `DO_NOT_TRACK` — the cross-tool standard (https://consoledonottrack.com);
824
+ * any value other than `0`/empty disables.
825
+ * 3. `ZITADEL_TELEMETRY` set to a falsey token (`0`/`false`/`off`/`no`).
826
+ * 4. An automated test run (`VITEST`/`NODE_ENV=test`) — never emit synthetic
827
+ * traffic or pay the shutdown flush; spawned CLI subprocesses inherit it.
828
+ *
829
+ * The user's own choices (1–3) come first so `reason` reports them even in a
830
+ * test shell: the user agent reads it to decide whether to describe the
831
+ * user's environment.
832
+ *
833
+ * Consent being enabled does not by itself send anything; the caller still
834
+ * needs somewhere to send (a Mixpanel token or the Vercel copy) and fails open
835
+ * on any transport error.
836
+ */
837
+ function resolveConsent(input) {
838
+ if (input.flag === false) return {
839
+ enabled: false,
840
+ reason: "flag-opt-out"
841
+ };
842
+ const doNotTrack = input.env[DO_NOT_TRACK.name]?.trim();
843
+ if (doNotTrack && doNotTrack !== "0") return {
844
+ enabled: false,
845
+ reason: "do-not-track"
846
+ };
847
+ const explicit = input.env[ZITADEL_TELEMETRY.name]?.trim().toLowerCase();
848
+ if (explicit !== void 0 && DISABLED_VALUES.has(explicit)) return {
849
+ enabled: false,
850
+ reason: "env-opt-out"
851
+ };
852
+ if (input.env.VITEST || input.env.NODE_ENV === "test") return {
853
+ enabled: false,
854
+ reason: "test-runner"
855
+ };
856
+ return {
857
+ enabled: true,
858
+ reason: "enabled"
859
+ };
860
+ }
861
+ //#endregion
862
+ //#region src/lib/telemetry/identity.ts
863
+ /**
864
+ * Resolve the directory the CLI persists cross-invocation state in, honoring
865
+ * the platform conventions: `XDG_CONFIG_HOME` then `~/.config` on Unix, and
866
+ * `%APPDATA%` on Windows. The anonymous id lives here so it survives between
867
+ * runs without touching the user's project tree.
868
+ */
869
+ function telemetryConfigDir(env) {
870
+ if (process.platform === "win32" && env.APPDATA) return join(env.APPDATA, "zitadel", "cli");
871
+ const base = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
872
+ return join(base, "zitadel", "cli");
873
+ }
874
+ /**
875
+ * Load the persisted anonymous id, minting and storing a new one on first run.
876
+ *
877
+ * Fail-open: if the config dir cannot be resolved, read, or written — including
878
+ * an `os.homedir()` throw in a restricted/containerized environment, a
879
+ * read-only home, a CI sandbox, or a permissions error — fall back to an
880
+ * ephemeral per-process id and report `isFirstRun: false` so we neither crash
881
+ * nor nag the user with the notice on every run. Telemetry is best-effort,
882
+ * never load-bearing.
883
+ */
884
+ function loadOrCreateIdentity(env) {
885
+ let dir;
886
+ try {
887
+ dir = telemetryConfigDir(env);
888
+ } catch {
889
+ return {
890
+ distinctId: randomUUID(),
891
+ isFirstRun: false
892
+ };
893
+ }
894
+ const file = join(dir, "telemetry.json");
895
+ try {
896
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
897
+ if (typeof parsed.distinctId === "string" && parsed.distinctId.length > 0) return {
898
+ distinctId: parsed.distinctId,
899
+ isFirstRun: false
900
+ };
901
+ } catch {}
902
+ const distinctId = randomUUID();
903
+ try {
904
+ mkdirSync(dir, { recursive: true });
905
+ writeFileSync(file, `${JSON.stringify({ distinctId })}\n`, { mode: 384 });
906
+ return {
907
+ distinctId,
908
+ isFirstRun: true
909
+ };
910
+ } catch {
911
+ return {
912
+ distinctId,
913
+ isFirstRun: false
914
+ };
915
+ }
916
+ }
917
+ const ciProvider = new class CiProvider {
918
+ /** Provider-marker env var → reported name, in match order. */
919
+ static providers = [
920
+ ["GITHUB_ACTIONS", "github_actions"],
921
+ ["GITLAB_CI", "gitlab_ci"],
922
+ ["CIRCLECI", "circleci"],
923
+ ["BUILDKITE", "buildkite"],
924
+ ["JENKINS_URL", "jenkins"]
925
+ ];
926
+ value(env) {
927
+ const named = CiProvider.providers.find(([marker]) => envEnabled(env[marker]));
928
+ if (named) return named[1];
929
+ return envEnabled(env.CI) ? "unknown" : void 0;
930
+ }
931
+ }();
932
+ const hostAgent = new class HostAgent {
933
+ /** Predicate → reported name, in match order. */
934
+ static agents = [
935
+ [(env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT), "claude_code"],
936
+ [(env) => Boolean(env.CURSOR_TRACE_ID || env.CURSOR_AGENT), "cursor"],
937
+ [(env) => env.TERM_PROGRAM === "vscode", "vscode"]
938
+ ];
939
+ value(env) {
940
+ return HostAgent.agents.find(([matches]) => matches(env))?.[1] ?? "unknown";
941
+ }
942
+ }();
943
+ //#endregion
944
+ //#region src/lib/user-agent.ts
945
+ /**
946
+ * The `User-Agent` the CLI sends on every HTTP request it makes through
947
+ * undici — the Zitadel API, local-server health checks, branding asset probes
948
+ * — so a server (and its logs and session records) can tell CLI traffic apart
949
+ * from any other Node client, and tell CLI versions apart. Node's `fetch`
950
+ * otherwise sends the bare `node`.
951
+ *
952
+ * Space-separated `product/version` tokens (RFC 9110 §10.1.5), in the style of
953
+ * npm and the AWS CLI, most significant first:
954
+ *
955
+ * zitadel-cli/1.0.0 node/v24.12.0 darwin/24.6.0 arch/arm64 ci/github_actions host/claude_code
956
+ *
957
+ * `ci/` and `host/` use the vocabulary of the telemetry `ci_provider` and
958
+ * `host_agent` dimensions. They appear only when detected, and never once the
959
+ * user has opted out of telemetry: they describe the user's environment, so
960
+ * the same opt-out covers them.
961
+ */
962
+ const USER_AGENT_HEADER = "user-agent";
963
+ /**
964
+ * Consent outcomes that are the user's own choice. A test run also disables
965
+ * telemetry, but says nothing about what the user wants reported.
966
+ */
967
+ const OPT_OUT_REASONS = /* @__PURE__ */ new Set([
968
+ "flag-opt-out",
969
+ "do-not-track",
970
+ "env-opt-out"
971
+ ]);
972
+ /** The {@link UserAgentFacts} of the running process. */
973
+ function processUserAgentFacts(cliVersion, telemetryFlag) {
974
+ return {
975
+ cliVersion,
976
+ nodeVersion: process.version,
977
+ platform: process.platform,
978
+ osRelease: release(),
979
+ arch: process.arch,
980
+ env: process.env,
981
+ telemetryFlag
982
+ };
983
+ }
984
+ function buildUserAgent(facts) {
985
+ const tokens = [
986
+ product("zitadel-cli", facts.cliVersion),
987
+ product("node", facts.nodeVersion),
988
+ product(facts.platform, facts.osRelease),
989
+ product("arch", facts.arch)
990
+ ];
991
+ const consent = resolveConsent({
992
+ env: facts.env,
993
+ flag: facts.telemetryFlag
994
+ });
995
+ if (!OPT_OUT_REASONS.has(consent.reason)) {
996
+ const ci = ciProvider.value(facts.env);
997
+ if (ci !== void 0) tokens.push(product("ci", ci));
998
+ const host = hostAgent.value(facts.env);
999
+ if (host !== "unknown") tokens.push(product("host", host));
1000
+ }
1001
+ return tokens.join(" ");
1002
+ }
1003
+ /**
1004
+ * One `name/version` product token. Characters outside the RFC 9110 `token`
1005
+ * set (an OS release string is free-form) become `_`, so a stray space or
1006
+ * slash can never split one token into two.
1007
+ */
1008
+ function product(name, version) {
1009
+ return `${sanitizeToken(name)}/${sanitizeToken(version)}`;
1010
+ }
1011
+ function sanitizeToken(value) {
1012
+ return value.replace(/[^!#$%&'*+\-.^_`|~0-9A-Za-z]/g, "_") || "unknown";
1013
+ }
1014
+ /**
1015
+ * Stored on `globalThis`, like the dispatcher it wraps, so every copy of this
1016
+ * module in the process shares one value: with module-local state, a second
1017
+ * copy would compose a second interceptor whose stale value overwrote the
1018
+ * first's on every request.
1019
+ */
1020
+ const SLOT = Symbol.for("@zitadel/cli/user-agent");
1021
+ function slot() {
1022
+ const scope = globalThis;
1023
+ scope[SLOT] ??= {};
1024
+ return scope[SLOT];
1025
+ }
1026
+ /**
1027
+ * undici interceptor that sets the installed user agent on every request,
1028
+ * replacing whatever the caller or `fetch` put there. Exported so a module
1029
+ * that builds its own dispatcher (and so bypasses the global one) can
1030
+ * compose it; a no-op until {@link installUserAgent} runs.
1031
+ */
1032
+ const userAgentInterceptor = (dispatch) => (options, handler) => {
1033
+ const userAgent = slot().userAgent;
1034
+ return dispatch(userAgent === void 0 ? options : {
1035
+ ...options,
1036
+ headers: withUserAgent(options.headers, userAgent)
1037
+ }, handler);
1038
+ };
1039
+ /**
1040
+ * Make every request through the global dispatcher — which is what `fetch`
1041
+ * uses, including the `@zitadel/api` client — carry `userAgent`. Composes
1042
+ * onto the existing global dispatcher, so a proxy agent installed before
1043
+ * (`NODE_USE_ENV_PROXY`) keeps working. Calling it again only replaces the
1044
+ * value, unless something has since swapped the global dispatcher out, in
1045
+ * which case the interceptor is composed onto the new one.
1046
+ *
1047
+ * Importing the `undici` package installs its own `Agent` as the global
1048
+ * dispatcher when none is set yet, so Node's bundled `fetch` dispatches
1049
+ * through the package's undici. Keep its major version on the one the
1050
+ * supported Node release bundles.
1051
+ */
1052
+ function installUserAgent(userAgent) {
1053
+ const state = slot();
1054
+ state.userAgent = userAgent;
1055
+ const current = getGlobalDispatcher();
1056
+ if (current !== state.dispatcher) {
1057
+ state.dispatcher = current.compose(userAgentInterceptor);
1058
+ setGlobalDispatcher(state.dispatcher);
1059
+ }
1060
+ }
1061
+ /**
1062
+ * Rewrite `headers` — in any of the shapes undici accepts — as a flat
1063
+ * name/value list with every existing `user-agent` dropped and `userAgent`
1064
+ * appended. The flat list keeps repeated headers intact. A malformed
1065
+ * odd-length list is returned untouched, for undici to reject.
1066
+ */
1067
+ function withUserAgent(headers, userAgent) {
1068
+ if (Array.isArray(headers) && headers.length % 2 !== 0) return headers;
1069
+ const flat = [];
1070
+ for (const [name, value] of headerEntries(headers)) {
1071
+ if (name.toLowerCase() === USER_AGENT_HEADER) continue;
1072
+ const values = value === void 0 ? [] : Array.isArray(value) ? value : [value];
1073
+ for (const item of values) flat.push(name, item);
1074
+ }
1075
+ flat.push(USER_AGENT_HEADER, userAgent);
1076
+ return flat;
1077
+ }
1078
+ function headerEntries(headers) {
1079
+ if (headers == null) return [];
1080
+ if (Array.isArray(headers)) {
1081
+ const pairs = [];
1082
+ for (let index = 0; index < headers.length; index += 2) pairs.push([headers[index], headers[index + 1]]);
1083
+ return pairs;
1084
+ }
1085
+ if (Symbol.iterator in headers) return headers;
1086
+ return Object.entries(headers);
1087
+ }
1088
+ //#endregion
1089
+ //#region src/lib/warnings.ts
1090
+ /**
1091
+ * Warnings reported during this invocation. The CLI runs one command per
1092
+ * process, so a module-level list is the invocation's list; `BaseCommand`
1093
+ * clears it when a command starts and drains it into the envelope when the
1094
+ * command emits.
1095
+ */
1096
+ const reported = [];
1097
+ /**
1098
+ * Report a warning. This is the only way the CLI warns: never call
1099
+ * `consola.warn` directly (see `apps/cli/AGENTS.md`).
1100
+ *
1101
+ * A terminal shows the warning at once, which matters when it comes before a
1102
+ * long wait. `--json` silences that output, so the warning is also kept for
1103
+ * the envelope's `warnings`, and both kinds of reader see it.
1104
+ */
1105
+ function reportWarning(message) {
1106
+ consola.warn(message);
1107
+ reported.push(message);
1108
+ }
1109
+ /** Take every warning reported so far, leaving none behind. */
1110
+ function takeReportedWarnings() {
1111
+ return reported.splice(0);
1112
+ }
1113
+ //#endregion
1114
+ //#region src/lib/variables.ts
1115
+ /** The scope a variable write or delete addresses, from the project and `--preview`. */
1116
+ function variableScope(projectId, preview) {
1117
+ return preview ? {
1118
+ project_id: projectId,
1119
+ kind: "preview"
1120
+ } : { project_id: projectId };
1121
+ }
1122
+ /**
1123
+ * The scope of a read. A read names one kind, because the platform answers a
1124
+ * name with a value per kind and the two are never merged: `--preview` reads the
1125
+ * preview values, and without it the primary ones.
1126
+ */
1127
+ function variableReadScope(projectId, preview) {
1128
+ return {
1129
+ project_id: projectId,
1130
+ kind: preview ? "preview" : "primary"
1131
+ };
1132
+ }
1133
+ /** The values of one kind in a read, keyed by name. */
1134
+ function valuesOfKind(body, kind) {
1135
+ return Object.fromEntries(Object.entries(body).flatMap(([name, byKind]) => byKind[kind] === void 0 ? [] : [[name, byKind[kind]]]));
1136
+ }
1137
+ /**
1138
+ * Project one wire value into a row. Both endpoints answer in the same two
1139
+ * forms — a bare scalar for a non-secret, `{"secret": true}` for a secret — so
1140
+ * the collection and the single-name read share this.
1141
+ */
1142
+ function toVariableRow(name, value) {
1143
+ return typeof value === "object" && value !== null ? {
1144
+ name,
1145
+ secret: true
1146
+ } : {
1147
+ name,
1148
+ secret: false,
1149
+ value
1150
+ };
1151
+ }
1152
+ /**
1153
+ * Project the values into rows, sorted by name so output is stable between
1154
+ * runs.
1155
+ */
1156
+ function toVariableRows(values) {
1157
+ return Object.entries(values).map(([name, value]) => toVariableRow(name, value)).sort((a, b) => a.name.localeCompare(b.name));
1158
+ }
1159
+ /** Stands in for a secret's value, which the platform never discloses. */
1160
+ const SECRET_MARKER = "(secret)";
1161
+ /** Stands in for a kind the name holds no value for at all. */
1162
+ const NO_VALUE = "-";
1163
+ /**
1164
+ * Every name on the project with the primary value it holds and the preview
1165
+ * value beside it, sorted by name. A read without a kind answers both, so one
1166
+ * request covers the inventory; a name is listed when either kind holds a
1167
+ * value, so a preview-only name does not disappear (#1617).
1168
+ */
1169
+ function toInventoryRows(body) {
1170
+ return Object.entries(body).map(([name, byKind]) => {
1171
+ const primary = byKind.primary === void 0 ? void 0 : toVariableRow(name, byKind.primary);
1172
+ const preview = byKind.preview === void 0 ? void 0 : toVariableRow(name, byKind.preview);
1173
+ return {
1174
+ name,
1175
+ secret: primary?.secret ?? false,
1176
+ ...primary?.value === void 0 ? {} : { value: primary.value },
1177
+ ...preview === void 0 ? {} : { preview: {
1178
+ secret: preview.secret,
1179
+ ...preview.value === void 0 ? {} : { value: preview.value }
1180
+ } }
1181
+ };
1182
+ }).sort((a, b) => a.name.localeCompare(b.name));
1183
+ }
1184
+ /**
1185
+ * {@link variableCells} plus the preview column, for the inventory. A kind the
1186
+ * name holds nothing for prints `-`: an empty cell would read as an empty
1187
+ * value, and a secret prints its marker in either column.
1188
+ */
1189
+ function variableInventoryCells(rows) {
1190
+ const cell = (held) => held === void 0 ? NO_VALUE : held.secret ? SECRET_MARKER : held.value === void 0 ? NO_VALUE : renderScalar(held.value);
1191
+ return rows.map((row) => ({
1192
+ name: row.name,
1193
+ value: cell(row.value === void 0 && !row.secret ? void 0 : row),
1194
+ preview: cell(row.preview)
1195
+ }));
1196
+ }
1197
+ /**
1198
+ * Render a stored scalar for a terminal.
1199
+ *
1200
+ * A value is attacker-influenceable — anyone holding `variable.write` chooses
1201
+ * it — so it is never interpolated raw. The client already escapes what could
1202
+ * drive the terminal; this also escapes a newline and a tab, which would
1203
+ * break the table out of its own row or split a tab-separated line.
1204
+ */
1205
+ function renderScalar(value) {
1206
+ if (typeof value !== "string") return String(value);
1207
+ return escapeControlCharacters(value, { keepLayout: false });
1208
+ }
1209
+ /**
1210
+ * The rows as the cells the resource commands' renderers print, one per
1211
+ * column. Those renderers print a string verbatim, so every value is escaped
1212
+ * here first: a stored value is chosen by whoever holds `variable.write`, and a
1213
+ * newline or an ESC byte must not reach the reader's terminal. A secret prints
1214
+ * a marker rather than a value — there is none to print, and an empty cell would
1215
+ * read as an empty value rather than a withheld one.
1216
+ */
1217
+ function variableCells(rows) {
1218
+ return rows.map((row) => ({
1219
+ name: row.name,
1220
+ value: row.secret ? SECRET_MARKER : renderScalar(row.value)
1221
+ }));
1222
+ }
1223
+ /**
1224
+ * Names the platform accepts, from `variable-name.yaml`: word characters only,
1225
+ * which is exactly what the `${{ NAME }}` reference syntax can address
1226
+ * (ADR 062 §2). Checked before the patch so one bad line in a `.env` file is
1227
+ * named locally rather than failing the whole transaction at the edge.
1228
+ */
1229
+ const VARIABLE_NAME = /^\w+$/;
1230
+ /** Longest name the same schema accepts. */
1231
+ const VARIABLE_NAME_MAX = 255;
1232
+ /** Reject a name the platform would refuse, naming it. */
1233
+ function assertVariableName(name) {
1234
+ if (!VARIABLE_NAME.test(name)) throw new ZitadelError("E_VALIDATION", `Invalid variable name ${JSON.stringify(name)}.`, { hint: "A variable name contains only letters, digits and underscores." });
1235
+ if (name.length > VARIABLE_NAME_MAX) throw new ZitadelError("E_VALIDATION", `Variable name is ${name.length} characters; the limit is ${VARIABLE_NAME_MAX}.`, { hint: "Shorten the name." });
1236
+ }
1237
+ /**
1238
+ * The scalar types a variable can hold — the API's JSON scalars (ADR 062 §1).
1239
+ * The server resolves `${{ NAME }}` only in an OIDC connection's `client_id`
1240
+ * and `client_secret` (`flow_sso_redirect_issuer.go`, `flow_sso_callback.go`),
1241
+ * and both must hold a string, so no reference resolves to a number or boolean
1242
+ * yet.
1243
+ */
1244
+ const VARIABLE_TYPES = [
1245
+ "string",
1246
+ "number",
1247
+ "boolean"
1248
+ ];
1249
+ /**
1250
+ * A JSON number, spelled strictly. `Number()` alone would accept `0x10`, a
1251
+ * blank string, or surrounding whitespace, none of which is a number anyone
1252
+ * means when they type one.
1253
+ */
1254
+ const JSON_NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
1255
+ /**
1256
+ * Turn what was typed or piped into the scalar to store, as the type asks, or
1257
+ * say what is wrong with it. The problem carries its hint, so a caller that
1258
+ * only has the problem to show loses nothing.
1259
+ *
1260
+ * The type is stated, never guessed: an integer past 2^53 has no exact JSON
1261
+ * number, so an ID or a numeric credential would be stored with different
1262
+ * digits than were entered. It is refused rather than silently rounded.
1263
+ */
1264
+ function parseVariableValue(raw, type) {
1265
+ const refuse = (problem, hint) => ({
1266
+ ok: false,
1267
+ problem: `${problem} ${hint}`
1268
+ });
1269
+ switch (type) {
1270
+ case "string": return {
1271
+ ok: true,
1272
+ value: raw
1273
+ };
1274
+ case "boolean":
1275
+ if (raw === "true" || raw === "false") return {
1276
+ ok: true,
1277
+ value: raw === "true"
1278
+ };
1279
+ return refuse(`Expected true or false, got ${JSON.stringify(raw)}.`, "A boolean variable is exactly `true` or `false`.");
1280
+ case "number": {
1281
+ const value = Number(raw);
1282
+ if (!JSON_NUMBER.test(raw) || !Number.isFinite(value)) return refuse(`Expected a number, got ${JSON.stringify(raw)}.`, "Write it as a JSON number, such as 5, -1.5 or 1e3.");
1283
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) return refuse(`${raw} is too large to store exactly as a number.`, "Store it as a string, which keeps every digit.");
1284
+ return {
1285
+ ok: true,
1286
+ value
1287
+ };
1288
+ }
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Read a value from stdin, for the non-interactive path
1293
+ * (`variable set NAME --secret < secret.txt`).
1294
+ *
1295
+ * A single trailing newline is stripped — a here-doc or `echo` adds one and it
1296
+ * is not part of the credential — but nothing else is trimmed, because
1297
+ * whitespace inside a value is the caller's to decide.
1298
+ */
1299
+ async function readStdin(stream) {
1300
+ const chunks = [];
1301
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1302
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
1303
+ }
1304
+ //#endregion
1305
+ //#region src/lib/flows/env-refs.ts
1306
+ /**
1307
+ * Collects the environment variables a flows document depends on, sorted and
1308
+ * de-duplicated. Recognises two reference styles: inline `${VAR}` interpolations
1309
+ * inside string values, and keys ending in `_env` whose value names a single
1310
+ * variable. `deploy` uses this to fail before contacting the platform when
1311
+ * a required variable is absent.
1312
+ */
1313
+ function flowEnvRefs(value) {
1314
+ const refs = /* @__PURE__ */ new Set();
1315
+ const visit = (node) => {
1316
+ if (typeof node === "string") for (const match of node.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
1317
+ const ref = match[1];
1318
+ if (ref) refs.add(ref);
1319
+ }
1320
+ else if (Array.isArray(node)) node.forEach(visit);
1321
+ else if (isObject$1(node)) for (const [key, child] of Object.entries(node)) if (key.endsWith("_env") && typeof child === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(child)) refs.add(child);
1322
+ else visit(child);
1323
+ };
1324
+ visit(value);
1325
+ return [...refs].sort();
1326
+ }
1327
+ //#endregion
1328
+ //#region src/lib/flows/sign-in.ts
1329
+ /**
1330
+ * Check what a schema change from `before` to `after` does to a flow.
1331
+ *
1332
+ * Only new errors count. A schema-rule error the flow already had is `deploy`'s
1333
+ * to report, and refusing an unrelated change because of it would block the
1334
+ * developer from an edit that might be part of the fix.
1335
+ *
1336
+ * @param flow - The flow as it is on disk.
1337
+ * @param before - The schema as it is on disk.
1338
+ * @param after - The schema as the change leaves it.
1339
+ * @param flowAfter - The flow as the change leaves it. Password and passkey
1340
+ * never edit a flow, so it defaults to the flow itself; `auth-method sso
1341
+ * disable` removes the provider from it.
1342
+ */
1343
+ function checkFlow(flow, before, after, flowAfter = flow) {
1344
+ const shape = flowConfigSchema.safeParse(flowAfter);
1345
+ if (!shape.success) return {
1346
+ kind: "unchecked",
1347
+ issues: shape.error.issues.map((issue) => ({
1348
+ severity: "error",
1349
+ rule: "definition",
1350
+ message: `${issue.path.join(".") || "(root)"}: ${issue.message}`
1351
+ }))
1352
+ };
1353
+ const structural = validateFlowDefinition(flowAfter);
1354
+ if (structural.length > 0) return {
1355
+ kind: "unchecked",
1356
+ issues: structural
1357
+ };
1358
+ const key = (issue) => `${issue.rule}\u0000${issue.message}`;
1359
+ const existing = new Set(validateFlowDefinition(flow, before).filter((issue) => issue.severity === "error").map(key));
1360
+ return {
1361
+ kind: "checked",
1362
+ introduced: validateFlowDefinition(flowAfter, after).filter((issue) => issue.severity === "error" && !existing.has(key(issue)))
1363
+ };
1364
+ }
1365
+ /**
1366
+ * The sign-in methods (`password`, `passkey`, `sso`) that some active flow
1367
+ * lets an existing user sign in with.
1368
+ *
1369
+ * Only an active flow is served, so a draft offers nothing. Passkey counts
1370
+ * only through a `passkey` action: `passkey_register` enrols a new credential
1371
+ * and signs nobody in. SSO counts only through a step offering one of
1372
+ * `providers`.
1373
+ *
1374
+ * @param flows - The flows that run against the schema, whatever their status.
1375
+ * @param providers - The SSO providers the schema enables.
1376
+ */
1377
+ function offeredSignInMethods(flows, providers) {
1378
+ const journeys = flows.filter((flow) => flow.status === "active").map(loginJourney);
1379
+ const offered = (signsIn) => journeys.some(signsIn);
1380
+ return [
1381
+ ...offered(collectsPassword) ? ["password"] : [],
1382
+ ...offered(signsInWithPasskey) ? ["passkey"] : [],
1383
+ ...offered((flow) => offersSso(flow, providers)) ? ["sso"] : []
1384
+ ];
1385
+ }
1386
+ /**
1387
+ * Outcomes the engine answers by switching from login to register without a
1388
+ * `purpose` on the transition: the validator's flip table, plus
1389
+ * `sso_user_not_found`, which it leaves out of that table on purpose but
1390
+ * which routes to registration the same way.
1391
+ */
1392
+ const LOGIN_LEAVING_OUTCOMES = /* @__PURE__ */ new Set([...Object.keys(PURPOSE_FLIP_TARGETS.login ?? {}), "sso_user_not_found"]);
1393
+ /**
1394
+ * The flow cut down to the steps reachable from its `login` entry, following
1395
+ * transitions that stay in the login purpose. No `login` purpose means no
1396
+ * steps: such a flow signs nobody in.
1397
+ */
1398
+ function loginJourney(flow) {
1399
+ const entry = (isObject$1(flow.purposes) ? flow.purposes : {}).login;
1400
+ const byName = new Map(steps$1(flow).map((step) => [step.name, step]));
1401
+ const reached = /* @__PURE__ */ new Set();
1402
+ const queue = typeof entry === "string" ? [entry] : [];
1403
+ while (queue.length > 0) {
1404
+ const name = queue.shift();
1405
+ const step = byName.get(name);
1406
+ if (reached.has(name) || step === void 0) continue;
1407
+ reached.add(name);
1408
+ const transitions = isObject$1(step.transitions) ? Object.entries(step.transitions) : [];
1409
+ for (const [outcome, transition] of transitions) {
1410
+ if (LOGIN_LEAVING_OUTCOMES.has(outcome)) continue;
1411
+ if (isObject$1(transition) && typeof transition.target === "string" && (transition.action === void 0 || transition.action === null) && (transition.purpose === void 0 || transition.purpose === null || transition.purpose === "login")) queue.push(transition.target);
1412
+ }
1413
+ }
1414
+ return {
1415
+ ...flow,
1416
+ steps: steps$1(flow).filter((step) => reached.has(step.name))
1417
+ };
1418
+ }
1419
+ function steps$1(flow) {
1420
+ return Array.isArray(flow.steps) ? flow.steps.filter(isObject$1) : [];
1421
+ }
1422
+ /** Password is field-shaped: a step collects `x-auth-methods#password`. */
1423
+ function collectsPassword(flow) {
1424
+ return steps$1(flow).some((step) => Array.isArray(step.fields) && step.fields.includes("x-auth-methods#password"));
1425
+ }
1426
+ function signsInWithPasskey(flow) {
1427
+ return steps$1(flow).some((step) => Array.isArray(step.actions) && step.actions.some((action) => isObject$1(action) && action.kind === "passkey"));
1428
+ }
1429
+ function offersSso(flow, providers) {
1430
+ return steps$1(flow).some((step) => Array.isArray(step.sso_providers) && step.sso_providers.some((p) => typeof p === "string" && providers.includes(p)));
1431
+ }
1432
+ //#endregion
1433
+ //#region src/lib/flows/index.ts
1434
+ /**
1435
+ * Relative directory (from the project root) where local flow files
1436
+ * live. Owned here so callers (`commands/*`, `sync/syncers.ts`) and
1437
+ * tests share a single source of truth for the path; the runtime
1438
+ * never depends on it directly because `lib/flows` does not touch
1439
+ * the filesystem.
1440
+ */
1441
+ const FLOWS_DIR = ".zitadel/flows";
1442
+ //#endregion
1443
+ //#region src/lib/idp/connections.ts
1444
+ /**
1445
+ * Relative directory (from the project root) where local connection files
1446
+ * live, one per provider. Owned here alongside the only reader of it, and
1447
+ * re-exported from `lib/idp` so callers and tests share one source of truth
1448
+ * for the path, as `FLOWS_DIR` and `SCHEMAS_DIR` do for their domains.
1449
+ */
1450
+ const IDPS_DIR = ".zitadel/idps";
1451
+ /**
1452
+ * `$schema` pointer a scaffolded connection carries, relative to
1453
+ * {@link IDPS_DIR}: the meta-schema `zitadel setup` writes under
1454
+ * `.zitadel/meta/`, so an editor validates the file as it is typed.
1455
+ */
1456
+ const CONNECTION_SCHEMA_REF = "../meta/idp-connection.json";
1457
+ /**
1458
+ * Read every connection file under `.zitadel/idps/`, in lexical order so a
1459
+ * duplicate report is stable. A missing directory reads as no connections:
1460
+ * the first provider a Project enables creates it.
1461
+ *
1462
+ * Unlike `readJsonDir`, this keeps each file's name. Every decision below is
1463
+ * reported to the developer in terms of the file they have to look at.
1464
+ */
1465
+ async function readConnectionFiles(cwd) {
1466
+ const dir = join(cwd, IDPS_DIR);
1467
+ let entries;
1468
+ try {
1469
+ entries = await readdir(dir);
1470
+ } catch (error) {
1471
+ if (isErrno$1(error, "ENOENT")) return [];
1472
+ throw error;
1473
+ }
1474
+ const files = [];
1475
+ for (const name of entries.filter((e) => e.endsWith(".json")).sort()) {
1476
+ const path = `${IDPS_DIR}/${name}`;
1477
+ let body;
1478
+ try {
1479
+ body = JSON.parse(await readFile(join(dir, name), "utf8"));
1480
+ } catch (error) {
1481
+ throw new ZitadelError("E_VALIDATION", `${path} is not valid JSON`, {
1482
+ hint: "Fix or remove the file, then run the command again.",
1483
+ details: {
1484
+ file: path,
1485
+ parse_error: describeParseError(error)
1486
+ }
1487
+ });
1488
+ }
1489
+ if (!isObject$1(body)) throw new ZitadelError("E_VALIDATION", `${path} does not contain a JSON object`, { details: { file: path } });
1490
+ files.push({
1491
+ name,
1492
+ path,
1493
+ body
1494
+ });
1495
+ }
1496
+ return files;
1497
+ }
1498
+ /**
1499
+ * What went wrong parsing a file, without the text of the file. V8 quotes the
1500
+ * source it choked on, and this file is where a pasted secret would be.
1501
+ */
1502
+ function describeParseError(error) {
1503
+ const position = error instanceof Error ? /position (\d+)/.exec(error.message)?.[1] : void 0;
1504
+ return position === void 0 ? "invalid JSON" : `invalid JSON at position ${position}`;
1505
+ }
1506
+ /**
1507
+ * Whether a connection file describes the catalog entry's provider.
1508
+ *
1509
+ * `template` names the catalog entry the file was scaffolded from. When it was
1510
+ * edited away, the file still counts if it carries the catalog's own slug and
1511
+ * issuer. A different slug on the same issuer is a custom connection that
1512
+ * happens to point at the provider, which a Project may hold alongside the
1513
+ * catalog one, so the catalog provider does not claim it.
1514
+ */
1515
+ function describesProvider(file, entry) {
1516
+ if (file.body.template === entry.template) return true;
1517
+ const oidc = file.body.oidc;
1518
+ return String(file.body.slug ?? "").trim() === entry.slug && isObject$1(oidc) && oidc.issuer === entry.issuer;
1519
+ }
1520
+ /** The client id a connection file carries. */
1521
+ function clientIdOf(file) {
1522
+ return credentialOf(file, "client_id");
1523
+ }
1524
+ /** One credential field, from the file's `oidc` block. */
1525
+ function credentialOf(file, field) {
1526
+ const block = file.body.oidc;
1527
+ return isObject$1(block) && typeof block[field] === "string" ? block[field] : void 0;
1528
+ }
1529
+ /**
1530
+ * The variables a connection's credentials reference, falling back to the
1531
+ * names this CLI would have written.
1532
+ *
1533
+ * A connection is editable and may name its own variables, so publishing to a
1534
+ * slug-derived name would store the credential where the connection never
1535
+ * looks and report it as stored.
1536
+ */
1537
+ function credentialVariablesOf(file, slug) {
1538
+ const scaffolded = slug === void 0 ? void 0 : credentialVariables(slug);
1539
+ return {
1540
+ clientId: named(file, "client_id", scaffolded?.clientId),
1541
+ clientSecret: named(file, "client_secret", scaffolded?.clientSecret)
1542
+ };
1543
+ }
1544
+ /**
1545
+ * The variable a credential field names, as the type of its fallback.
1546
+ *
1547
+ * A field absent from the file is where this command would have pointed it and
1548
+ * will point it now, so it reads as `fallback`, which is `undefined` when the
1549
+ * caller has no slug to derive one from. A field holding a literal is the value
1550
+ * itself: there is no variable behind it, and naming one would publish where
1551
+ * the connection never looks. Whatever a `client_id` field references is a
1552
+ * client id's variable, whatever it is called, which is what the brand records;
1553
+ * the type parameter keeps the two credentials from being swapped.
1554
+ */
1555
+ function named(file, field, fallback) {
1556
+ const value = credentialOf(file, field);
1557
+ return value === void 0 ? fallback : referencedVariable(value);
1558
+ }
1559
+ /**
1560
+ * Stop when a connection document hands back a credential value.
1561
+ *
1562
+ * `client_secret` is always a `${{ NAME }}` reference — the syncer refuses to
1563
+ * upload anything else — so a value here is the server having resolved the
1564
+ * reference. Every path that reaches this has somewhere worse to put it than a
1565
+ * log: a plan preview prints it, and a file written from it would commit it to
1566
+ * `.zitadel/idps/`, which is the one place the secret must never reach.
1567
+ */
1568
+ function refuseResolvedSecret(definition, id) {
1569
+ const block = definition.oidc;
1570
+ const stored = isObject$1(block) ? block.client_secret : void 0;
1571
+ if (typeof stored === "string" && !isVariableReference(stored)) throw new ZitadelError("E_VALIDATION", `${id} came back holding a client_secret value`, {
1572
+ hint: "A stored connection must keep the ${{ NAME }} reference. Report this: the endpoint is resolving secrets, and the value would be written to disk.",
1573
+ details: {
1574
+ id,
1575
+ block: "oidc"
1576
+ }
1577
+ });
1578
+ }
1579
+ /**
1580
+ * Stop when two connections' credentials would land in the same variable.
1581
+ *
1582
+ * Two ways that happens. A slug may hold `-` and `_`
1583
+ * (`^[a-z0-9][a-z0-9_-]*$`) and the variable name replaces every
1584
+ * non-alphanumeric with `_`, so `google-work` and `google_work` both read
1585
+ * `GOOGLE_WORK_CLIENT_SECRET`. And a connection is editable, so one may simply
1586
+ * point at a name another already uses, or point both of its own credentials
1587
+ * at one name. Either way a publish overwrites what the other credential put
1588
+ * there, and the loser signs in with the wrong application — a failure that
1589
+ * surfaces at the provider, not here.
1590
+ *
1591
+ * What an existing file *references* is what counts, not what its slug would
1592
+ * derive: an `okta` connection referencing `GOOGLE_CLIENT_SECRET` collides
1593
+ * with Google however its slug reads.
1594
+ *
1595
+ * The derivation stays legible on purpose — these names get typed into
1596
+ * `variable set` by hand — so the ambiguity is refused rather than encoded
1597
+ * around.
1598
+ *
1599
+ * @param ours - The variables this run will publish to.
1600
+ * @param except - The connection being reused, which is not its own collision.
1601
+ */
1602
+ function refuseCollidingVariables(ours, slug, files, except) {
1603
+ if (ours.clientId !== void 0 && ours.clientId === ours.clientSecret) throw new ZitadelError("E_CONFLICT", `This connection points both credentials at ${ours.clientId}`, {
1604
+ hint: "Give client_id and client_secret separate ${{ NAME }} references, then run the command again.",
1605
+ details: {
1606
+ slug,
1607
+ variable: ours.clientId
1608
+ }
1609
+ });
1610
+ for (const file of files) {
1611
+ if (file === except) continue;
1612
+ const theirSlug = parseConnectionSlug(String(file.body.slug ?? ""));
1613
+ const theirs = credentialVariablesOf(file, theirSlug.ok ? theirSlug.value : void 0);
1614
+ const shared = [ours.clientId, ours.clientSecret].find((name) => name !== void 0 && (name === theirs.clientId || name === theirs.clientSecret));
1615
+ if (shared === void 0) continue;
1616
+ throw new ZitadelError("E_CONFLICT", `${file.path} and ${slug} both use ${shared}`, {
1617
+ hint: "Point one of them at a variable of its own, then run the command again.",
1618
+ details: {
1619
+ file: file.path,
1620
+ slug,
1621
+ other_slug: theirSlug.ok ? theirSlug.value : String(file.body.slug ?? ""),
1622
+ variable: shared
1623
+ }
1624
+ });
1625
+ }
1626
+ }
1627
+ /**
1628
+ * Decide whether a catalog provider already has a connection in this Project.
1629
+ *
1630
+ * Reuse is the default: a Project has one connection per provider, and a
1631
+ * second one for the same provider is nearly always an accident. Anything
1632
+ * ambiguous stops instead of guessing, because both alternatives — editing
1633
+ * the wrong connection, or silently adding a duplicate — are worse than
1634
+ * asking the developer which they meant.
1635
+ *
1636
+ * @param clientId - When given, a single match whose client id differs is a
1637
+ * conflict rather than a reuse: the developer is pointing at a different
1638
+ * OAuth application than the one already configured.
1639
+ */
1640
+ function planCatalogConnection(options) {
1641
+ const entry = options.provider;
1642
+ const slug = entry.slug;
1643
+ const matches = options.files.filter((file) => describesProvider(file, entry));
1644
+ if (matches.length > 1) {
1645
+ const names = matches.map((m) => m.path).join(", ");
1646
+ throw new ZitadelError("E_CONFLICT", `More than one ${entry.displayName} connection exists: ${names}`, {
1647
+ hint: "Keep the one this Project should use and remove the others, then run the command again.",
1648
+ details: {
1649
+ provider: slug,
1650
+ files: matches.map((m) => m.path)
1651
+ }
1652
+ });
1653
+ }
1654
+ const [match] = matches;
1655
+ if (match !== void 0) {
1656
+ refuseDifferentClientId(match, options.clientId);
1657
+ const matchSlug = parseConnectionSlug(String(match.body.slug ?? ""));
1658
+ if (!matchSlug.ok) throw new ZitadelError("E_VALIDATION", `${match.path} is a ${entry.displayName} connection with no usable slug`, {
1659
+ hint: "Give the connection file a slug -- the schemas and flows reference it by that name.",
1660
+ details: {
1661
+ file: match.path,
1662
+ problem: matchSlug.problem
1663
+ }
1664
+ });
1665
+ refuseCollidingVariables(credentialVariablesOf(match, matchSlug.value), matchSlug.value, options.files, match);
1666
+ return {
1667
+ action: "reuse",
1668
+ file: match,
1669
+ slug: matchSlug.value
1670
+ };
1671
+ }
1672
+ const name = `${slug}.json`;
1673
+ const path = `${IDPS_DIR}/${name}`;
1674
+ const occupied = options.files.find((file) => file.name === name);
1675
+ if (occupied !== void 0) {
1676
+ const theirSlug = String(occupied.body.slug ?? "").trim();
1677
+ throw new ZitadelError("E_CONFLICT", theirSlug === slug ? `${path} is a connection called ${slug}, but not to ${entry.displayName}: its issuer is not ${entry.issuer}` : `${path} already exists and its slug is ${theirSlug === "" ? "missing" : theirSlug}, not ${slug}`, {
1678
+ hint: theirSlug === slug ? `Point it at ${entry.displayName} if it is that connection. Otherwise change its slug, in the file and in the schemas and flows that name it, then run the command again.` : `Rename or remove that file, or give it the slug ${slug} if it is the ${entry.displayName} connection, then run the command again.`,
1679
+ details: {
1680
+ file: path,
1681
+ slug: occupied.body.slug ?? null
1682
+ }
1683
+ });
1684
+ }
1685
+ refuseCollidingVariables(credentialVariables(slug), slug, options.files);
1686
+ const alsoUsesIssuer = options.files.filter((file) => isObject$1(file.body.oidc) && file.body.oidc.issuer === entry.issuer).map((file) => file.path);
1687
+ return {
1688
+ action: "create",
1689
+ name,
1690
+ path,
1691
+ slug,
1692
+ ...alsoUsesIssuer.length > 0 ? { alsoUsesIssuer } : {}
1693
+ };
1694
+ }
1695
+ /**
1696
+ * Stop when the developer names a client id other than the one a connection
1697
+ * already holds.
1698
+ *
1699
+ * A scaffolded connection stores `${{ NAME }}`, not an id, so there is no
1700
+ * identity in the file to disagree with and the command stays idempotent.
1701
+ * A hand-written connection may still hold a literal, and that one is
1702
+ * worth refusing: rerunning with a different id would otherwise reuse a
1703
+ * file configured for another client.
1704
+ */
1705
+ function refuseDifferentClientId(match, clientId) {
1706
+ const existing = clientIdOf(match);
1707
+ const literal = existing !== void 0 && !isVariableReference(existing);
1708
+ if (clientId !== void 0 && literal && existing !== clientId) throw new ZitadelError("E_VALIDATION", `${match.path} already uses client id ${existing}, not ${clientId}`, {
1709
+ hint: "Edit the connection file to change its client id, or run the command without one to reuse it.",
1710
+ details: {
1711
+ file: match.path,
1712
+ stored_client_id: existing,
1713
+ supplied_client_id: clientId
1714
+ }
1715
+ });
1716
+ }
1717
+ /**
1718
+ * Decide what to do with a custom connection, identified by the slug the
1719
+ * developer supplied.
1720
+ *
1721
+ * Unlike {@link planConnection} this never looks at the template or the
1722
+ * issuer to find a connection: a Project may hold several connections to one
1723
+ * issuer, with different credentials or mappings, and what tells them apart
1724
+ * is the name. The slug is also what schemas and flows reference, so it is
1725
+ * the only thing that can say "the same connection".
1726
+ *
1727
+ * A slug already in use is reused only when it is the same provider — the
1728
+ * same issuer — so that repeating a command changes nothing. The same name on
1729
+ * a connection to somewhere else is somebody else's, and replacing it would
1730
+ * repoint every schema and flow that names it.
1731
+ */
1732
+ function planCustomConnection(options) {
1733
+ const { slug, issuer } = options.input;
1734
+ const name = `${slug}.json`;
1735
+ const path = `${IDPS_DIR}/${name}`;
1736
+ const match = checkCustomSlug(options.files, slug, options.slugFlag);
1737
+ if (match === void 0) return {
1738
+ action: "create",
1739
+ name,
1740
+ path,
1741
+ slug
1742
+ };
1743
+ refuseChangedIssuer(match, slug, issuer, options.slugFlag);
1744
+ const oidc = match.body.oidc;
1745
+ refuseDifferentClientId(match, options.clientId);
1746
+ refuseCollidingVariables(credentialVariablesOf(match, slug), slug, options.files, match);
1747
+ const storedScopes = isObject$1(oidc) && Array.isArray(oidc.scopes) ? oidc.scopes.join(" ") : "";
1748
+ const notApplied = [...match.body.display_name === options.input.displayName ? [] : ["display name"], ...options.scopesGiven !== true || storedScopes === options.input.scopes.join(" ") ? [] : ["scopes"]];
1749
+ return {
1750
+ action: "reuse",
1751
+ file: match,
1752
+ slug,
1753
+ ...notApplied.length > 0 ? { notApplied } : {}
1754
+ };
1755
+ }
1756
+ /**
1757
+ * What a custom connection's slug alone decides: the file it already names, or
1758
+ * a refusal. More than one file with the slug, or a file name the slug would
1759
+ * take that holds another connection, or variable names a new connection would
1760
+ * collide on, are all knowable before anything else about the connection is, so
1761
+ * a command can raise them as soon as it has the slug.
1762
+ *
1763
+ * @returns The single file that declares the slug, or `undefined` when the
1764
+ * slug is free and a new connection can be created.
1765
+ */
1766
+ function checkCustomSlug(files, slug, slugFlag) {
1767
+ const name = `${slug}.json`;
1768
+ const path = `${IDPS_DIR}/${name}`;
1769
+ const holders = files.filter((file) => String(file.body.slug ?? "").trim() === slug);
1770
+ if (holders.length > 1) throw new ZitadelError("E_CONFLICT", `More than one connection is named ${slug}: ${holders.map((h) => h.path).join(", ")}`, {
1771
+ hint: "A slug names one connection. Remove or rename all but one, then run the command again.",
1772
+ details: {
1773
+ slug,
1774
+ files: holders.map((h) => h.path)
1775
+ }
1776
+ });
1777
+ const [match] = holders;
1778
+ if (match !== void 0) return match;
1779
+ if (files.some((file) => file.name === name)) throw new ZitadelError("E_CONFLICT", `${path} already exists and is not the ${slug} connection`, {
1780
+ hint: `Rename or remove that file, or choose another ${slugFlag}, then run the command again.`,
1781
+ details: {
1782
+ file: path,
1783
+ slug
1784
+ }
1785
+ });
1786
+ refuseCollidingVariables(credentialVariables(slug), slug, files);
1787
+ }
1788
+ /**
1789
+ * Refuse an issuer other than the one an existing connection was written with.
1790
+ * An applied connection keeps its issuer, so naming another one for the same
1791
+ * slug is a conflict, raised as soon as both are known.
1792
+ */
1793
+ function refuseChangedIssuer(file, slug, issuer, slugFlag) {
1794
+ const oidc = file.body.oidc;
1795
+ const existingIssuer = isObject$1(oidc) && typeof oidc.issuer === "string" ? oidc.issuer : void 0;
1796
+ if (existingIssuer === issuer) return;
1797
+ throw new ZitadelError("E_CONFLICT", existingIssuer === void 0 ? `${file.path} is already named ${slug} and is not an OIDC connection to ${issuer}` : `${file.path} is already named ${slug} and connects to ${existingIssuer}, not ${issuer}`, {
1798
+ hint: `An applied connection keeps its issuer, and the server compares it exactly, so even a trailing slash names another provider. Choose another ${slugFlag} for this connection, then move the schemas and flows that name the old one to it.`,
1799
+ details: {
1800
+ file: file.path,
1801
+ slug,
1802
+ issuer,
1803
+ existing_issuer: existingIssuer ?? null
1804
+ }
1805
+ });
1806
+ }
1807
+ /**
1808
+ * The display name and issuer a custom connection's file holds, as written, for
1809
+ * the ones it has. Nothing is checked here: a hand-edited file may hold
1810
+ * anything. Scopes are left out because reusing a connection never applies them.
1811
+ */
1812
+ function storedCustomSettings(file) {
1813
+ const oidc = file.body.oidc;
1814
+ return {
1815
+ ...typeof file.body.display_name === "string" ? { displayName: file.body.display_name } : {},
1816
+ ...isObject$1(oidc) && typeof oidc.issuer === "string" ? { issuer: oidc.issuer } : {}
1817
+ };
1818
+ }
1819
+ /**
1820
+ * Decide what to do with the connection for a provider in this Project.
1821
+ *
1822
+ * A catalog provider has one connection, found by what it is. Reuse is the
1823
+ * default: a second one for the same provider is nearly always an accident.
1824
+ * A custom connection is found by the slug the developer gave it, because a
1825
+ * Project may hold several connections to one issuer and the name is the only
1826
+ * thing that tells them apart.
1827
+ *
1828
+ * Anything ambiguous stops instead of guessing, because both alternatives —
1829
+ * editing the wrong connection, or silently adding a duplicate — are worse
1830
+ * than asking the developer which they meant.
1831
+ *
1832
+ * @param clientId - When given, a single match whose client id differs is a
1833
+ * conflict rather than a reuse: the developer is pointing at a different
1834
+ * OAuth application than the one already configured.
1835
+ */
1836
+ function planConnection(options) {
1837
+ const { selection, files, clientId, scopesGiven, slugFlag } = options;
1838
+ return selection.kind === "custom" ? planCustomConnection({
1839
+ input: selection.connection,
1840
+ files,
1841
+ clientId,
1842
+ scopesGiven,
1843
+ slugFlag
1844
+ }) : planCatalogConnection({
1845
+ provider: idpProvider(selection.id),
1846
+ files,
1847
+ clientId,
1848
+ slugFlag
1849
+ });
1850
+ }
1851
+ //#endregion
1852
+ //#region src/lib/idp/documents.ts
1853
+ /**
1854
+ * Enabling a provider on an existing Project edits two files: the user schema
1855
+ * gains an `sso` authentication method, and the login flow gains the steps and
1856
+ * routes a provider round trip needs.
1857
+ *
1858
+ * Both functions are pure — they take parsed documents and return new ones, so
1859
+ * the caller owns reading, writing and reporting. Both are idempotent: running
1860
+ * them again on their own output changes nothing.
1861
+ *
1862
+ * Only the regions below are ever written. Anything a developer has edited by
1863
+ * hand is left exactly as it stands and reported back, because silently
1864
+ * rewriting it would discard deliberate work.
1865
+ */
1866
+ /**
1867
+ * Steps a flow starts at, by purpose, plus the steps that collect an
1868
+ * identifier. The provider button belongs on all of them: a developer landing
1869
+ * on the first screen expects it there, and someone who has already gone to
1870
+ * the email screen should not have to go back for it.
1871
+ *
1872
+ * On the shipped password-first flow the login entry *is* `identifier`, so
1873
+ * this is exactly `identifier` and `register` — which is what the design's
1874
+ * reference flow shows. On passkey-first it also picks up the passkey entry
1875
+ * screen, which the reference never considered.
1876
+ */
1877
+ function providerSteps(flow) {
1878
+ const purposes = isObject(flow.purposes) ? flow.purposes : {};
1879
+ const names = [
1880
+ purposes.login,
1881
+ purposes.register,
1882
+ "identifier",
1883
+ "register"
1884
+ ];
1885
+ const wanted = [];
1886
+ for (const name of names) if (typeof name === "string" && !wanted.includes(name) && stepNamed(flow, name) !== void 0) wanted.push(name);
1887
+ return wanted;
1888
+ }
1889
+ /**
1890
+ * The step a flow starts its login purpose at, which a transition that
1891
+ * re-purposes to `login` must target.
1892
+ *
1893
+ * The engine rejects a `purpose: "login"` transition pointing anywhere else,
1894
+ * so this cannot be hardcoded: `identifier` is only the password-first flow's
1895
+ * entry, and a passkey-first flow starts at its passkey screen.
1896
+ */
1897
+ function loginEntry(flow) {
1898
+ const purposes = isObject(flow.purposes) ? flow.purposes : {};
1899
+ const named = purposes.login;
1900
+ if (typeof named === "string" && stepNamed(flow, named) !== void 0) return named;
1901
+ return stepNamed(flow, "identifier") !== void 0 && purposes.login !== void 0 ? "identifier" : void 0;
1902
+ }
1903
+ /** Step the engine sends a new external identity to. */
1904
+ const REGISTER_SSO = "register-sso";
1905
+ /** Step shown when the provider's email already has an account. */
1906
+ const SSO_CONFLICT = "sso-conflict";
1907
+ /** The step that asks the identified user for a password. */
1908
+ const PASSWORD_STEP = "password";
1909
+ /** Outcomes the engine fires after a provider returns, and where they go. */
1910
+ function providerOutcomes(terminal) {
1911
+ return {
1912
+ sso_authenticated: terminal,
1913
+ sso_user_not_found: REGISTER_SSO,
1914
+ user_already_exists: SSO_CONFLICT
1915
+ };
1916
+ }
1917
+ /**
1918
+ * The step a completed sign-in ends at.
1919
+ *
1920
+ * Derived, not assumed to be called `done`. Step names are not fixed by the
1921
+ * contract — `purposes` names entry points, not terminals — and what marks a
1922
+ * terminal is the `complete` property, which both shipped flows carry. A flow
1923
+ * whose terminal is called something else was valid before this edit, and
1924
+ * writing `done` into the generated routes would point them at a step that
1925
+ * does not exist: `validateFlowDefinition` rejects that on the next `deploy`
1926
+ * ("transition ... targets unknown step"), so enabling a provider would break
1927
+ * a flow that worked.
1928
+ *
1929
+ * `undefined` when the flow has no single terminal to route to, which the
1930
+ * caller refuses rather than guesses at.
1931
+ */
1932
+ function terminalStep(flow) {
1933
+ const complete = steps(flow).filter((step) => step.complete !== void 0 && typeof step.name === "string");
1934
+ if (complete.length === 1) return complete[0]?.name;
1935
+ return stepNamed(flow, "done") === void 0 ? void 0 : "done";
1936
+ }
1937
+ function clone(value) {
1938
+ return structuredClone(value);
1939
+ }
1940
+ function isObject(value) {
1941
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1942
+ }
1943
+ function steps(flow) {
1944
+ return Array.isArray(flow.steps) ? flow.steps : [];
1945
+ }
1946
+ /**
1947
+ * Why this document cannot be edited for SSO, or `undefined` when it can.
1948
+ *
1949
+ * The editors below read a missing region as empty and write the generated one
1950
+ * in its place. That is right for a document that has simply not got there yet,
1951
+ * and wrong for one whose region holds something else: that value was put there
1952
+ * by hand, and replacing it is the one thing these functions promise not to do.
1953
+ * A flow whose `steps` is a string, for instance, would otherwise be read as
1954
+ * having no steps and written back with two — the hand-written value gone, and
1955
+ * nothing said about it.
1956
+ *
1957
+ * Reported rather than skipped: unlike a hand-edited step, which the caller can
1958
+ * leave alone and still produce a working flow, none of the edits can proceed
1959
+ * here, so a partial success would be a misleading thing to report.
1960
+ */
1961
+ function ssoEditRefusal(document, target) {
1962
+ const body = document;
1963
+ return target === "flow" ? flowRefusal(body) : schemaRefusal(body);
1964
+ }
1965
+ function flowRefusal(flow) {
1966
+ const providers = ssoProvidersRefusal(flow);
1967
+ if (providers !== void 0) return providers;
1968
+ if (terminalStep(flow) === void 0) return "no step marks where a completed sign-in ends";
1969
+ }
1970
+ /**
1971
+ * Why a flow's steps cannot be read for their provider lists, or `undefined`
1972
+ * when they can. This is all removing a provider needs
1973
+ * (`auth-method sso disable`): unlike enabling, it writes no route, so it does
1974
+ * not care where a completed sign-in ends.
1975
+ */
1976
+ function ssoProvidersRefusal(document) {
1977
+ const flow = document;
1978
+ if (flow.steps !== void 0 && !Array.isArray(flow.steps)) return "steps is not a list";
1979
+ for (const [index, step] of steps(flow).entries()) {
1980
+ if (!isObject(step)) return `steps[${index}] is not a step`;
1981
+ const name = typeof step.name === "string" ? step.name : String(index);
1982
+ if (step.transitions !== void 0 && !isObject(step.transitions)) return `steps.${name}.transitions is not an object`;
1983
+ if (step.sso_providers !== void 0 && !Array.isArray(step.sso_providers)) return `steps.${name}.sso_providers is not a list`;
1984
+ }
1985
+ }
1986
+ function schemaRefusal(schema) {
1987
+ const methods = schema["x-auth-methods"];
1988
+ if (methods === void 0) return;
1989
+ if (!isObject(methods)) return "x-auth-methods is not an object";
1990
+ if (methods.sso === void 0) return;
1991
+ if (!isObject(methods.sso)) return "x-auth-methods.sso is not an object";
1992
+ if (typeof methods.sso.enabled !== "boolean") return "x-auth-methods.sso.enabled is not a boolean";
1993
+ const providers = methods.sso.providers;
1994
+ if (providers !== void 0 && !Array.isArray(providers)) return "x-auth-methods.sso.providers is not a list";
1995
+ }
1996
+ function stepNamed(flow, name) {
1997
+ return steps(flow).find((step) => step.name === name);
1998
+ }
1999
+ /**
2000
+ * Enable the provider on a user schema.
2001
+ *
2002
+ * Only `x-auth-methods.sso` is touched: password and passkey configuration
2003
+ * belongs to whoever set it, and a provider is added to the list rather than
2004
+ * replacing it, so enabling a second one later keeps the first.
2005
+ */
2006
+ function applySsoToSchema(schema, slug) {
2007
+ const document = clone(schema);
2008
+ const methods = isObject(document["x-auth-methods"]) ? { ...document["x-auth-methods"] } : {};
2009
+ const existing = isObject(methods.sso) ? methods.sso : void 0;
2010
+ const providers = Array.isArray(existing?.providers) ? [...existing.providers] : [];
2011
+ if (existing?.enabled === true && providers.includes(slug)) return {
2012
+ document: schema,
2013
+ changed: false,
2014
+ skipped: []
2015
+ };
2016
+ if (!providers.includes(slug)) providers.push(slug);
2017
+ methods.sso = {
2018
+ ...existing,
2019
+ enabled: true,
2020
+ providers
2021
+ };
2022
+ document["x-auth-methods"] = methods;
2023
+ return {
2024
+ document,
2025
+ changed: true,
2026
+ skipped: []
2027
+ };
2028
+ }
2029
+ /**
2030
+ * The properties this project collects when someone registers.
2031
+ *
2032
+ * Read from the register purpose's own entry step rather than from the schema:
2033
+ * that step is where the project decided what to ask for, and the use case
2034
+ * chosen at setup already shaped it (`minimal` asks for an email, `business`
2035
+ * also for names and a company). Taking the schema instead would collect every
2036
+ * property it defines, including ones the project deliberately does not ask
2037
+ * about.
2038
+ *
2039
+ * Credential fields are dropped. The provider is the proof for an account
2040
+ * `create_user_with_sso` creates, so the scaffold asks for no password. An
2041
+ * author who adds a password field gets it stored with the user.
2042
+ *
2043
+ * Falls back to the identifier-collecting step, and then to nothing, so a flow
2044
+ * shaped unlike the shipped one still produces a step that can collect the one
2045
+ * property the mutation cannot do without.
2046
+ */
2047
+ function registrationFields(flow) {
2048
+ const purposes = isObject(flow.purposes) ? flow.purposes : {};
2049
+ const named = typeof purposes.register === "string" ? purposes.register : "register";
2050
+ for (const name of [
2051
+ named,
2052
+ "register",
2053
+ "identifier"
2054
+ ]) {
2055
+ const step = stepNamed(flow, name);
2056
+ const collected = (isObject(step) && Array.isArray(step.fields) ? step.fields : []).filter((field) => typeof field === "string" && !field.startsWith("x-auth-methods#"));
2057
+ if (collected.length > 0) return collected;
2058
+ }
2059
+ return [];
2060
+ }
2061
+ /**
2062
+ * The step that collects what the provider did not supply.
2063
+ *
2064
+ * It asks for everything registration asks for, because a provider is not
2065
+ * obliged to supply any of it: the engine creates the account outright when
2066
+ * the claims satisfy the schema and shows this step when they do not, and a
2067
+ * step that can only collect the identifier is a dead end for every property
2068
+ * beyond it.
2069
+ */
2070
+ function registerSsoStep(fields, terminal) {
2071
+ return {
2072
+ name: REGISTER_SSO,
2073
+ fields,
2074
+ actions: [{
2075
+ name: "submit",
2076
+ kind: "submit",
2077
+ primary: true,
2078
+ text_key: `${REGISTER_SSO}.action.submit`
2079
+ }],
2080
+ on_success: "create_user_with_sso",
2081
+ transitions: {
2082
+ submit: { target: terminal },
2083
+ user_already_exists: { target: SSO_CONFLICT }
2084
+ }
2085
+ };
2086
+ }
2087
+ /**
2088
+ * The step shown when the provider's email already has an account. It offers
2089
+ * only the methods the schema actually enables — a password box on a schema
2090
+ * without passwords is a dead end.
2091
+ */
2092
+ function ssoConflictStep(slug, methods, loginStep, terminal) {
2093
+ const actions = [];
2094
+ const fields = [];
2095
+ if (methods.password) {
2096
+ fields.push("x-auth-methods#password");
2097
+ actions.push({
2098
+ name: "submit",
2099
+ kind: "submit",
2100
+ primary: true,
2101
+ text_key: `${SSO_CONFLICT}.action.submit`
2102
+ });
2103
+ }
2104
+ if (methods.passkey) actions.push({
2105
+ name: "passkey",
2106
+ kind: "passkey",
2107
+ primary: false,
2108
+ text_key: `${SSO_CONFLICT}.action.passkey`
2109
+ });
2110
+ if (loginStep !== void 0) actions.push({
2111
+ name: "sign_in",
2112
+ kind: "navigate",
2113
+ primary: false,
2114
+ text_key: `${SSO_CONFLICT}.action.sign_in`
2115
+ });
2116
+ const transitions = {};
2117
+ if (methods.password) transitions.submit = { target: terminal };
2118
+ if (methods.passkey) transitions.passkey = { target: terminal };
2119
+ transitions.sso_authenticated = { target: terminal };
2120
+ transitions.user_already_exists = { target: SSO_CONFLICT };
2121
+ if (loginStep !== void 0) transitions.sign_in = {
2122
+ target: loginStep,
2123
+ purpose: "login"
2124
+ };
2125
+ transitions.sso_user_not_found = { target: REGISTER_SSO };
2126
+ return {
2127
+ name: SSO_CONFLICT,
2128
+ fields,
2129
+ actions,
2130
+ sso_providers: [slug],
2131
+ transitions
2132
+ };
2133
+ }
2134
+ /**
2135
+ * Whether a step is still what this generator would write for it.
2136
+ *
2137
+ * Compared key-sorted at every depth: the CLI writes its managed files with a
2138
+ * stable serialiser, so a step that has been through `setup`
2139
+ * has its keys in a different order from the object built here while being
2140
+ * the same step. Comparing the raw JSON would report the generator's own
2141
+ * output as hand-edited.
2142
+ */
2143
+ function matches(step, expected) {
2144
+ return canonical(withoutServerDefaults(step)) === canonical(withoutServerDefaults(expected));
2145
+ }
2146
+ /**
2147
+ * A step without what the platform leaves out when it answers.
2148
+ *
2149
+ * `setup` writes the canonical definition into the file, and the server
2150
+ * omits an action's `primary: false` (a default), an empty `fields` list, and
2151
+ * an empty `gates` map, so the step in the file is not byte for byte what this
2152
+ * generator wrote though it is the same step. Reading it as hand-edited would
2153
+ * skip the step on the next provider.
2154
+ */
2155
+ function withoutServerDefaults(step) {
2156
+ const copy = { ...step };
2157
+ for (const key of ["fields", "gates"]) {
2158
+ const value = copy[key];
2159
+ if (Array.isArray(value) ? value.length === 0 : isObject(value) && Object.keys(value).length === 0) delete copy[key];
2160
+ }
2161
+ if (Array.isArray(copy.actions)) copy.actions = copy.actions.map((action) => {
2162
+ if (!isObject(action) || action.primary !== false) return action;
2163
+ const { primary: _default, ...rest } = action;
2164
+ return rest;
2165
+ });
2166
+ return copy;
2167
+ }
2168
+ let shippedPasswordForms;
2169
+ /**
2170
+ * The password steps the shipped flows carry, one per preset, in the form
2171
+ * {@link withoutSsoRoutes} reduces any password step to. A step that reduces to
2172
+ * one of these is the one `setup` wrote, however many providers it offers.
2173
+ * The shipped steps carry no provider routes, so the terminal does not matter.
2174
+ */
2175
+ function shippedPasswordSteps() {
2176
+ shippedPasswordForms ??= SETUP_PRESETS.flatMap((preset) => {
2177
+ const step = stepNamed(getDefaultLoginFlow({ preset }), PASSWORD_STEP);
2178
+ return step === void 0 ? [] : [canonical(withoutSsoRoutes(step, "done"))];
2179
+ });
2180
+ return shippedPasswordForms;
2181
+ }
2182
+ /**
2183
+ * A password step without what offering providers adds to it: the provider
2184
+ * list, and the routes this generator writes for what comes back from one.
2185
+ *
2186
+ * A route that goes somewhere else is the developer's and stays, which is what
2187
+ * makes the step read as edited. The platform's own rewrite is not an edit
2188
+ * ({@link withoutServerDefaults}).
2189
+ */
2190
+ function withoutSsoRoutes(step, terminal) {
2191
+ const copy = { ...withoutServerDefaults(step) };
2192
+ delete copy.sso_providers;
2193
+ if (isObject(copy.transitions)) {
2194
+ const written = providerOutcomes(terminal);
2195
+ copy.transitions = Object.fromEntries(Object.entries(copy.transitions).filter(([outcome, route]) => !(outcome in written && isObject(route) && route.target === written[outcome] && Object.keys(route).length === 1)));
2196
+ }
2197
+ return copy;
2198
+ }
2199
+ /**
2200
+ * Whether a password step already lists the provider and carries the routes the
2201
+ * generator writes, whatever else it has been edited to say.
2202
+ */
2203
+ function offersAlready(step, slug, terminal) {
2204
+ if (!Array.isArray(step.sso_providers) || !step.sso_providers.includes(slug)) return false;
2205
+ const transitions = isObject(step.transitions) ? step.transitions : {};
2206
+ return Object.entries(providerOutcomes(terminal)).every(([outcome, target]) => {
2207
+ const route = transitions[outcome];
2208
+ return isObject(route) && route.target === target;
2209
+ });
2210
+ }
2211
+ /** The provider list a step should end up with: what it has, plus this slug. */
2212
+ function mergedProviders(step, slug) {
2213
+ const providers = Array.isArray(step.sso_providers) ? [...step.sso_providers] : [];
2214
+ return providers.includes(slug) ? providers : [...providers, slug];
2215
+ }
2216
+ /**
2217
+ * Offer a provider on a step, keeping any already there. Returns whether the
2218
+ * step changed, so a rerun is a no-op.
2219
+ */
2220
+ function addProvider(step, slug) {
2221
+ const providers = Array.isArray(step.sso_providers) ? [...step.sso_providers] : [];
2222
+ if (providers.includes(slug)) return false;
2223
+ providers.push(slug);
2224
+ step.sso_providers = providers;
2225
+ return true;
2226
+ }
2227
+ /** Stable JSON: object keys sorted at every depth, arrays left in order. */
2228
+ function canonical(value) {
2229
+ return JSON.stringify(sortKeys(value));
2230
+ }
2231
+ function sortKeys(value) {
2232
+ if (Array.isArray(value)) return value.map(sortKeys);
2233
+ if (isObject(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]));
2234
+ return value;
2235
+ }
2236
+ /** Outcome keys the previous CLI wrote, and the names the engine now raises. */
2237
+ const LEGACY_OUTCOMES = [["callback", "sso_authenticated"], ["identity_unknown", "sso_user_not_found"]];
2238
+ /**
2239
+ * Rename the previous outcome keys on every step, in place. The validator
2240
+ * rejects the old keys, so leaving them would make `auth-method sso enable`
2241
+ * write an invalid flow. Where a step already has the new key, that one wins. A
2242
+ * key that is also one of the step's action names is the action's transition
2243
+ * and stays. A new key that is an action name is refused: the old route would
2244
+ * be dropped for the action's. A legacy key with `purpose` or `action` is
2245
+ * refused: the new keys may declare neither, and dropping them would change the
2246
+ * author's routing.
2247
+ */
2248
+ function migrateLegacyOutcomes(document) {
2249
+ let changed = false;
2250
+ for (const step of steps(document)) {
2251
+ if (!isObject(step.transitions)) continue;
2252
+ const transitions = step.transitions;
2253
+ const actions = Array.isArray(step.actions) ? step.actions : [];
2254
+ const actionNames = new Set(actions.map((action) => isObject(action) ? action.name : void 0));
2255
+ for (const [old, next] of LEGACY_OUTCOMES) {
2256
+ if (!(old in transitions) || actionNames.has(old)) continue;
2257
+ if (actionNames.has(next)) throw new ZitadelError("E_VALIDATION", `steps.${String(step.name)}: transition "${old}" is renamed to "${next}", which is an action on this step`, {
2258
+ hint: `Rename the action "${next}" on step "${String(step.name)}" (and its transition), then run auth-method sso enable again.`,
2259
+ details: {
2260
+ step: step.name,
2261
+ action: next
2262
+ }
2263
+ });
2264
+ if (!(next in transitions)) {
2265
+ const transition = transitions[old];
2266
+ if (isObject(transition) && ((transition.purpose ?? null) !== null || (transition.action ?? null) !== null)) throw new ZitadelError("E_VALIDATION", `steps.${String(step.name)}: transition "${old}" declares purpose or action, which "${next}" cannot`, {
2267
+ hint: `Remove purpose and action from the transition "${old}" on step "${String(step.name)}", then run auth-method sso enable again.`,
2268
+ details: {
2269
+ step: step.name,
2270
+ transition: old
2271
+ }
2272
+ });
2273
+ transitions[next] = transition;
2274
+ }
2275
+ delete transitions[old];
2276
+ changed = true;
2277
+ }
2278
+ }
2279
+ return changed;
2280
+ }
2281
+ /**
2282
+ * Refuse a flow where a step this command writes declares an action named like
2283
+ * an outcome key the generator writes on that step. Those names were free
2284
+ * before the rename; the generated route would silently replace the action's.
2285
+ */
2286
+ function refuseOutcomeActionCollision(document) {
2287
+ const outcomes = [
2288
+ "sso_authenticated",
2289
+ "sso_user_not_found",
2290
+ "user_already_exists"
2291
+ ];
2292
+ const entries = [
2293
+ ...providerSteps(document).map((name) => [name, outcomes]),
2294
+ [SSO_CONFLICT, outcomes],
2295
+ [REGISTER_SSO, ["user_already_exists"]],
2296
+ ["register-password", ["user_already_exists"]]
2297
+ ];
2298
+ const written = /* @__PURE__ */ new Map();
2299
+ for (const [name, keys] of entries) written.set(name, /* @__PURE__ */ new Set([...written.get(name) ?? [], ...keys]));
2300
+ for (const [name, keys] of written) {
2301
+ const step = stepNamed(document, name);
2302
+ const actions = step !== void 0 && Array.isArray(step.actions) ? step.actions : [];
2303
+ for (const action of actions) if (isObject(action) && typeof action.name === "string" && keys.has(action.name)) throw new ZitadelError("E_VALIDATION", `steps.${name}: action "${action.name}" uses a name auth-method sso enable writes as an outcome on this step`, {
2304
+ hint: `Rename the action "${action.name}" on step "${name}" (and its transition), then run auth-method sso enable again.`,
2305
+ details: {
2306
+ step: name,
2307
+ action: action.name
2308
+ }
2309
+ });
2310
+ }
2311
+ }
2312
+ /**
2313
+ * Add the provider to a login flow.
2314
+ *
2315
+ * @param enabled - The authentication methods the target schema enables,
2316
+ * which decide what the conflict step may offer.
2317
+ */
2318
+ function applySsoToFlow(flow, slug, enabled) {
2319
+ const document = clone(flow);
2320
+ refuseOutcomeActionCollision(document);
2321
+ const skipped = [];
2322
+ let changed = false;
2323
+ const terminal = terminalStep(document) ?? "done";
2324
+ if (migrateLegacyOutcomes(document)) changed = true;
2325
+ for (const name of providerSteps(document)) {
2326
+ const step = stepNamed(document, name);
2327
+ if (step === void 0) continue;
2328
+ if (addProvider(step, slug)) changed = true;
2329
+ const transitions = isObject(step.transitions) ? { ...step.transitions } : {};
2330
+ for (const [outcome, target] of Object.entries(providerOutcomes(terminal))) {
2331
+ const current = transitions[outcome];
2332
+ if (isObject(current) && current.target === target) continue;
2333
+ transitions[outcome] = { target };
2334
+ changed = true;
2335
+ }
2336
+ step.transitions = transitions;
2337
+ }
2338
+ const password = stepNamed(document, PASSWORD_STEP);
2339
+ if (password !== void 0 && !providerSteps(document).includes(PASSWORD_STEP)) {
2340
+ if (offersAlready(password, slug, terminal)) {} else if (!shippedPasswordSteps().includes(canonical(withoutSsoRoutes(password, terminal)))) skipped.push({
2341
+ step: PASSWORD_STEP,
2342
+ region: `steps.${PASSWORD_STEP}`,
2343
+ reason: "hand-edited"
2344
+ });
2345
+ else {
2346
+ const offered = providerSteps(document).flatMap((name) => {
2347
+ const entry = stepNamed(document, name);
2348
+ return entry !== void 0 && Array.isArray(entry.sso_providers) ? entry.sso_providers : [];
2349
+ });
2350
+ for (const provider of /* @__PURE__ */ new Set([...offered, slug])) if (typeof provider === "string" && addProvider(password, provider)) changed = true;
2351
+ const transitions = isObject(password.transitions) ? { ...password.transitions } : {};
2352
+ for (const [outcome, target] of Object.entries(providerOutcomes(terminal))) {
2353
+ const current = transitions[outcome];
2354
+ if (!isObject(current) || current.target !== target) {
2355
+ transitions[outcome] = { target };
2356
+ changed = true;
2357
+ }
2358
+ }
2359
+ password.transitions = transitions;
2360
+ }
2361
+ }
2362
+ const registerPassword = stepNamed(document, "register-password");
2363
+ if (registerPassword !== void 0) {
2364
+ const transitions = isObject(registerPassword.transitions) ? { ...registerPassword.transitions } : {};
2365
+ const current = transitions.user_already_exists;
2366
+ if (!isObject(current) || current.target !== SSO_CONFLICT) {
2367
+ transitions.user_already_exists = { target: SSO_CONFLICT };
2368
+ registerPassword.transitions = transitions;
2369
+ changed = true;
2370
+ }
2371
+ }
2372
+ const list = steps(document);
2373
+ const terminalIndex = list.findIndex((step) => step.name === terminal);
2374
+ const insertAt = terminalIndex === -1 ? list.length : terminalIndex;
2375
+ const wanted = [{
2376
+ step: registerSsoStep(registrationFields(document), terminal),
2377
+ earlier: []
2378
+ }, {
2379
+ step: ssoConflictStep(slug, enabled, loginEntry(document), terminal),
2380
+ earlier: [true, false].flatMap((password) => [true, false].map((passkey) => ssoConflictStep(slug, {
2381
+ password,
2382
+ passkey
2383
+ }, loginEntry(document), terminal)))
2384
+ }];
2385
+ let offset = 0;
2386
+ for (const { step, earlier } of wanted) {
2387
+ const name = step.name;
2388
+ const existing = stepNamed(document, name);
2389
+ if (existing === void 0) {
2390
+ list.splice(insertAt + offset, 0, step);
2391
+ offset += 1;
2392
+ changed = true;
2393
+ continue;
2394
+ }
2395
+ const merged = step.sso_providers === void 0 ? void 0 : mergedProviders(existing, slug);
2396
+ const expected = merged === void 0 ? step : {
2397
+ ...step,
2398
+ sso_providers: merged
2399
+ };
2400
+ const candidate = merged === void 0 ? existing : {
2401
+ ...existing,
2402
+ sso_providers: merged
2403
+ };
2404
+ if (matches(candidate, expected)) {
2405
+ if (merged !== void 0 && addProvider(existing, slug)) changed = true;
2406
+ } else if (merged !== void 0 && earlier.some((form) => matches(candidate, {
2407
+ ...form,
2408
+ sso_providers: merged
2409
+ }))) {
2410
+ list[list.indexOf(existing)] = expected;
2411
+ changed = true;
2412
+ } else skipped.push({
2413
+ step: name,
2414
+ region: `steps.${name}`,
2415
+ reason: "hand-edited"
2416
+ });
2417
+ }
2418
+ document.steps = list;
2419
+ return changed || skipped.length > 0 ? {
2420
+ document,
2421
+ changed,
2422
+ skipped
2423
+ } : {
2424
+ document: flow,
2425
+ changed: false,
2426
+ skipped
2427
+ };
2428
+ }
2429
+ /**
2430
+ * Remove a provider from a user schema (`auth-method sso disable`, ADR 070).
2431
+ *
2432
+ * Only `x-auth-methods.sso` is touched. The provider leaves the list, and when
2433
+ * none is left `sso` becomes `{ "enabled": false }`: the meta-schema requires
2434
+ * at least one provider whenever the list is present. A provider the schema
2435
+ * does not offer is a no-op.
2436
+ */
2437
+ function removeSsoFromSchema(schema, slug) {
2438
+ const document = clone(schema);
2439
+ const methods = isObject(document["x-auth-methods"]) ? { ...document["x-auth-methods"] } : {};
2440
+ const sso = methods.sso;
2441
+ if (!isObject(sso) || !Array.isArray(sso.providers) || !sso.providers.includes(slug)) return {
2442
+ document: schema,
2443
+ changed: false,
2444
+ skipped: []
2445
+ };
2446
+ const providers = sso.providers.filter((provider) => provider !== slug);
2447
+ methods.sso = providers.length === 0 ? { enabled: false } : {
2448
+ ...sso,
2449
+ providers
2450
+ };
2451
+ document["x-auth-methods"] = methods;
2452
+ return {
2453
+ document,
2454
+ changed: true,
2455
+ skipped: []
2456
+ };
2457
+ }
2458
+ /**
2459
+ * Remove a provider from every step of a login flow that offers it.
2460
+ *
2461
+ * Only the `sso_providers` lists change; a list left empty is dropped. The
2462
+ * routes and steps `auth-method sso enable` added stay: without a provider they
2463
+ * are never reached, they validate as they are, and enabling a provider again
2464
+ * uses them as they stand rather than rebuilding them.
2465
+ */
2466
+ function removeSsoFromFlow(flow, slug) {
2467
+ const document = clone(flow);
2468
+ let changed = false;
2469
+ for (const step of steps(document)) {
2470
+ if (!Array.isArray(step.sso_providers) || !step.sso_providers.includes(slug)) continue;
2471
+ const providers = step.sso_providers.filter((provider) => provider !== slug);
2472
+ if (providers.length === 0) delete step.sso_providers;
2473
+ else step.sso_providers = providers;
2474
+ changed = true;
2475
+ }
2476
+ return changed ? {
2477
+ document,
2478
+ changed,
2479
+ skipped: []
2480
+ } : {
2481
+ document: flow,
2482
+ changed: false,
2483
+ skipped: []
2484
+ };
2485
+ }
2486
+ //#endregion
2487
+ //#region src/lib/idp/custom.ts
2488
+ /**
2489
+ * The flag keys the two commands define for a custom connection, once. Each
2490
+ * command builds its flag definitions from these, reads its parsed flags
2491
+ * through them, and the names a message suggests come from them, so renaming
2492
+ * one renames the flag, what is read from it, and what is suggested.
2493
+ */
2494
+ const ENABLE_FLAG_KEYS = {
2495
+ provider: "provider",
2496
+ slug: "slug",
2497
+ displayName: "display-name",
2498
+ issuer: "issuer",
2499
+ scopes: "scopes"
2500
+ };
2501
+ /** The `--sso-*` flags on `setup` that describe the same settings. */
2502
+ const SETUP_FLAG_KEYS = {
2503
+ provider: "sso",
2504
+ slug: "sso-slug",
2505
+ displayName: "sso-display-name",
2506
+ issuer: "sso-issuer",
2507
+ scopes: "sso-scopes"
2508
+ };
2509
+ const flagNamesOf = (keys) => ({
2510
+ provider: `--${keys.provider}`,
2511
+ slug: `--${keys.slug}`,
2512
+ displayName: `--${keys.displayName}`,
2513
+ issuer: `--${keys.issuer}`,
2514
+ scopes: `--${keys.scopes}`
2515
+ });
2516
+ /** The flags on `auth-method sso enable`, which a failed setup suggests running instead. */
2517
+ const ENABLE_FLAG_NAMES = flagNamesOf(ENABLE_FLAG_KEYS);
2518
+ /** The `--sso-*` flags on `setup` that describe the same settings. */
2519
+ const SETUP_FLAG_NAMES = flagNamesOf(SETUP_FLAG_KEYS);
2520
+ /**
2521
+ * What a command's parsed flags say about a custom connection. Typed by the
2522
+ * keys, so the flags a command parsed must carry them.
2523
+ */
2524
+ function customFlagValues(flags, keys) {
2525
+ const read = (key) => flags[key];
2526
+ return {
2527
+ slug: read(keys.slug),
2528
+ displayName: read(keys.displayName),
2529
+ issuer: read(keys.issuer),
2530
+ scopes: read(keys.scopes)
2531
+ };
2532
+ }
2533
+ /** The warning a loopback issuer earns on a server that cannot reach it, or `undefined`. */
2534
+ function loopbackIssuerWarning(selection, reach = "unknown") {
2535
+ if (selection.kind !== "custom" || !isLoopbackIssuer(selection.connection.issuer) || reach === "allowed") return;
2536
+ const { issuer } = selection.connection;
2537
+ return reach === "container" ? `${issuer} is on this machine, but the local server runs in Docker, where localhost is the container itself, so sign-in will fail. Start the server with \`zitadel start --runtime binary\` instead.` : `${issuer} is on this machine, and a server blocks requests to localhost unless NEXTGEN_HTTPCLIENT_ALLOW_LIST allows it, so sign-in will fail until it does. A server \`zitadel start\` runs allows it by default.`;
2538
+ }
2539
+ /**
2540
+ * What a command line describing a custom connection looks like, spelled with
2541
+ * the flags the command defines, for an error or a help example to show.
2542
+ */
2543
+ function customExample(flag, withScopes = false) {
2544
+ return [
2545
+ `${flag.provider} ${CUSTOM_PROVIDER}`,
2546
+ `${flag.slug} acme-sso`,
2547
+ `${flag.displayName} 'Acme SSO'`,
2548
+ `${flag.issuer} https://sso.acme.example`,
2549
+ ...withScopes ? [`${flag.scopes} 'openid profile email groups'`] : []
2550
+ ].join(" ");
2551
+ }
2552
+ /** The names of the custom settings that were given, as the command spells them. */
2553
+ function givenCustomFlags(values, flag) {
2554
+ return [
2555
+ [flag.slug, values.slug],
2556
+ [flag.displayName, values.displayName],
2557
+ [flag.issuer, values.issuer],
2558
+ [flag.scopes, values.scopes]
2559
+ ].filter(([, value]) => value !== void 0).map(([name]) => name);
2560
+ }
2561
+ /** One setting's complaint, under the flag that carried it. */
2562
+ function checkedUnder(name, parsed) {
2563
+ if (!parsed.ok) throw new ZitadelError("E_VALIDATION", `${name}: ${parsed.problem}`, {
2564
+ hint: `Fix ${name} and run the command again.`,
2565
+ details: { flag: name }
2566
+ });
2567
+ return parsed.value;
2568
+ }
2569
+ /**
2570
+ * Check the settings that were given, and only those, naming the flag that
2571
+ * carried a bad one. For a run that will ask for the rest: a slug the server
2572
+ * would refuse is refused before the first question, as a missing client id is.
2573
+ */
2574
+ function checkGivenCustomFlags(values, flag) {
2575
+ if (values.slug !== void 0) checkedUnder(flag.slug, parseConnectionSlug(values.slug, RESERVED_SLUGS));
2576
+ if (values.displayName !== void 0) checkedUnder(flag.displayName, parseDisplayName(values.displayName));
2577
+ if (values.issuer !== void 0) checkedUnder(flag.issuer, parseIssuer(values.issuer));
2578
+ if (values.scopes !== void 0) checkedUnder(flag.scopes, parseScopes(values.scopes));
2579
+ }
2580
+ /**
2581
+ * The provider a command line names, with its settings checked as a whole.
2582
+ *
2583
+ * A catalog provider is just its name. A custom one is described by the
2584
+ * settings that follow it, each checked against the contract the server
2585
+ * enforces on apply. Every complaint is raised here, so a caller that wants a
2586
+ * mistake named before a secret is asked for calls it before asking: being
2587
+ * turned away after typing a credential would mean typing it again. The
2588
+ * settings are flags on purpose — a name, issuer and scopes are what a script
2589
+ * or an agent supplies, and what a person can read back from their shell
2590
+ * history.
2591
+ */
2592
+ function selectionFromFlags(provider, values, flag, example) {
2593
+ const given = givenCustomFlags(values, flag);
2594
+ if (provider !== CUSTOM_PROVIDER) {
2595
+ if (given.length > 0) throw new ZitadelError("E_VALIDATION", `${given.join(", ")} only applies to a custom connection`, {
2596
+ hint: `Add ${flag.provider} ${CUSTOM_PROVIDER}, or remove ${given.length === 1 ? "it" : "them"}.`,
2597
+ details: { flags: given }
2598
+ });
2599
+ const selection = parseProviderSelection(provider);
2600
+ if (selection === void 0) throw new ZitadelError("E_VALIDATION", `Unknown provider ${provider}`, { hint: `Use one of ${[...IDP_PROVIDERS, CUSTOM_PROVIDER].join(", ")}.` });
2601
+ return selection;
2602
+ }
2603
+ const { slug, displayName, issuer } = values;
2604
+ if (slug === void 0 || displayName === void 0 || issuer === void 0) {
2605
+ const missing = [
2606
+ flag.slug,
2607
+ flag.displayName,
2608
+ flag.issuer
2609
+ ].filter((name) => !given.includes(name));
2610
+ throw new ZitadelError("E_VALIDATION", `A custom connection needs ${missing.join(", ")}`, {
2611
+ hint: `Describe the connection, e.g. ${example}`,
2612
+ details: { missing }
2613
+ });
2614
+ }
2615
+ const checked = checkedUnder;
2616
+ return {
2617
+ kind: "custom",
2618
+ connection: {
2619
+ slug: checked(flag.slug, parseConnectionSlug(slug, RESERVED_SLUGS)),
2620
+ displayName: checked(flag.displayName, parseDisplayName(displayName)),
2621
+ issuer: checked(flag.issuer, parseIssuer(issuer)),
2622
+ scopes: checked(flag.scopes, parseScopes(values.scopes))
2623
+ }
2624
+ };
2625
+ }
2626
+ /**
2627
+ * The flags that restate a custom connection, for a command offered as text.
2628
+ *
2629
+ * Suggested commands are run verbatim, so a display name with a space or a
2630
+ * metacharacter in it must arrive as one argument. Nothing here is a
2631
+ * credential: the name, issuer and scopes are the developer's own settings.
2632
+ */
2633
+ function customFlagText(custom, flag) {
2634
+ return flagText({
2635
+ slug: custom.slug,
2636
+ displayName: custom.displayName,
2637
+ issuer: custom.issuer,
2638
+ scopes: custom.scopes.join(" ")
2639
+ }, flag);
2640
+ }
2641
+ /**
2642
+ * The `auth-method sso enable` arguments that add `selection` to a project:
2643
+ * the provider, and for a custom connection the settings that describe it. A
2644
+ * suggestion builder wraps them with the selector its command needs.
2645
+ */
2646
+ function ssoEnableArgs(selection) {
2647
+ return `auth-method sso enable --provider ${selectionName(selection)}${selection.kind === "custom" ? ` ${customFlagText(selection.connection, ENABLE_FLAG_NAMES)}` : ""}`;
2648
+ }
2649
+ /** The same for whichever settings were given, each as one shell word. */
2650
+ function flagText(values, flag) {
2651
+ const word = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
2652
+ return [
2653
+ [flag.slug, values.slug],
2654
+ [flag.displayName, values.displayName],
2655
+ [flag.issuer, values.issuer],
2656
+ [flag.scopes, values.scopes]
2657
+ ].flatMap(([name, value]) => value === void 0 ? [] : [`${name} ${word(value)}`]).join(" ");
2658
+ }
2659
+ //#endregion
2660
+ //#region src/lib/idp/endpoints.ts
2661
+ /**
2662
+ * Ask where a provider lives, on a development run only.
2663
+ *
2664
+ * Someone working on the CLI runs it against a local stand-in constantly, and
2665
+ * before this the connection document had to be hand-edited afterwards and
2666
+ * re-applied. Someone who installed the CLI is configuring the real vendor and
2667
+ * must never meet the question — so it is gated on `developmentBuild`
2668
+ * (`isDevelopmentBuild()`, the explicit `ZITADEL_CLI_DEV` opt-in). A published
2669
+ * CLI never sets it, so the question stays out of its `--help` and prompts.
2670
+ *
2671
+ * Returns `undefined` for a normal run and for a scripted run, and the
2672
+ * catalog's own issuer is dropped when written, so an ordinary run produces
2673
+ * the document it would if none of this existed.
2674
+ */
2675
+ async function askConnectionEndpoints(options) {
2676
+ const { provider, developmentBuild, ask } = options;
2677
+ if (!developmentBuild) return;
2678
+ return { issuer: await ask({
2679
+ kind: "text",
2680
+ name: "issuer",
2681
+ message: "What is the issuer URL of the provider?",
2682
+ parse: parseIssuer,
2683
+ initialValue: idpProvider(provider).issuer
2684
+ }) };
2685
+ }
2686
+ //#endregion
2687
+ //#region src/lib/idp/credentials.ts
2688
+ /**
2689
+ * Publish a client secret to the project.
2690
+ *
2691
+ * Nothing is written to disk. The value is a credential, the project stores it
2692
+ * encrypted under the project's own key (ADR 029), and no runtime reads it
2693
+ * from the environment — the token exchange happens on the server. A copy in
2694
+ * `.env.local` would be a credential sitting in the working tree for the
2695
+ * convenience of nobody, and one `git add -A` away from being published.
2696
+ *
2697
+ * Passing no value publishes nothing and is not an error: the developer may
2698
+ * intend to publish it themselves.
2699
+ */
2700
+ async function storeClientSecret(options) {
2701
+ const { name, value, publish } = options;
2702
+ if (value === void 0) return {
2703
+ name,
2704
+ published: "deferred"
2705
+ };
2706
+ return {
2707
+ name,
2708
+ published: await publishSecret(publish, name, value)
2709
+ };
2710
+ }
2711
+ /**
2712
+ * Publish the connection's client id.
2713
+ *
2714
+ * An ordinary variable, not a secret: the id travels in the browser's
2715
+ * authorize URL, so it is public by construction and hiding it would only cost
2716
+ * the developer the ability to read back what was configured.
2717
+ */
2718
+ async function publishClientId(options) {
2719
+ return publishSecret(options.publish, options.name, options.value, false);
2720
+ }
2721
+ /**
2722
+ * Send the value to the project, reporting a refusal instead of raising it.
2723
+ *
2724
+ * By the time this runs the connection document is already on disk and, in
2725
+ * `setup`, the whole project is provisioned. Failing the command there would
2726
+ * leave the developer with a half-written Project to clean up over something
2727
+ * one later command fixes, so the caller warns and points at `variable set`.
2728
+ */
2729
+ async function publishSecret(publish, name, value, secret = true) {
2730
+ if (publish === void 0) return "deferred";
2731
+ try {
2732
+ await publish(name, value, { secret });
2733
+ return "stored";
2734
+ } catch (error) {
2735
+ consola.debug(`Publishing ${name} to the project failed`, error);
2736
+ return "failed";
2737
+ }
2738
+ }
2739
+ /**
2740
+ * The `variable set` arguments that publish one credential to the project.
2741
+ *
2742
+ * Without `--preview` the write takes the platform's default: a client id,
2743
+ * which is not secret, is stored for both kinds, and a client secret for the
2744
+ * primary kind only. The engine resolves a connection's `${{ NAME }}` from the
2745
+ * project's own variables.
2746
+ */
2747
+ function republishArgsFor(name, secret) {
2748
+ return [
2749
+ "variable",
2750
+ "set",
2751
+ name,
2752
+ ...secret ? ["--secret"] : []
2753
+ ];
2754
+ }
2755
+ /**
2756
+ * The command that puts a credential the project never received where the
2757
+ * connection looks for it.
2758
+ *
2759
+ * Shared so the warning printed to a terminal and the `next_commands` a JSON
2760
+ * run reports cannot drift apart — a JSON run prints no warnings at all, so
2761
+ * that list is the only place the recovery step appears.
2762
+ */
2763
+ function republishCommand(name, secret, cliVersion, selector) {
2764
+ return projectCommand(republishArgsFor(name, secret).join(" "), selector, cliVersion);
2765
+ }
2766
+ /**
2767
+ * The arguments that finish the job for every credential the project did not
2768
+ * receive — empty in the ordinary case, where both were stored.
2769
+ *
2770
+ * Argument lists rather than command strings, for a caller that also reports
2771
+ * them as `next_args`; {@link republishCommands} is built from this, so the
2772
+ * two forms list the same commands.
2773
+ *
2774
+ * @param credentials - Each credential of the run and what became of it.
2775
+ * @param extraArgs - Appended to every command, such as the `--cwd` the run
2776
+ * was given, so the recovery reaches the same Project.
2777
+ */
2778
+ function republishArgs(credentials, extraArgs = []) {
2779
+ return credentials.flatMap(({ name, secret, published }) => name !== void 0 && published !== void 0 && published !== "stored" ? [[...republishArgsFor(name, secret), ...extraArgs]] : []);
2780
+ }
2781
+ /**
2782
+ * {@link republishArgs} as commands to run.
2783
+ *
2784
+ * Built for a run that prints nothing: with `--json` consola is disabled, so
2785
+ * the warnings above never appear and this list is the only place the recovery
2786
+ * step is stated.
2787
+ */
2788
+ function republishCommands(credentials, cliVersion, selector) {
2789
+ return republishArgs(credentials).map((args) => projectCommand(args.join(" "), selector, cliVersion));
2790
+ }
2791
+ /**
2792
+ * Say whether the project received a credential, and how to retry when it did
2793
+ * not. Shared by both credentials, because the project is the destination that
2794
+ * decides whether sign-in works and the wording should not drift between them.
2795
+ *
2796
+ * `noValue` separates the two ways a publish can be deferred: nothing was
2797
+ * supplied, or there was no project to publish to. They read the same to the
2798
+ * code and call for different things from the developer.
2799
+ */
2800
+ function reportPublished(name, state, republish, noValue) {
2801
+ switch (state) {
2802
+ case "stored":
2803
+ consola.success(`Published ${name} to the project`);
2804
+ break;
2805
+ case "deferred":
2806
+ reportWarning(noValue ? `${name} has no value yet. Publish it with: ${republish}` : `${name} was not published to the project. Publish it with: ${republish}`);
2807
+ break;
2808
+ case "failed": reportWarning(`${name} could not be published. Sign-in fails until it is: ${republish}`);
2809
+ }
2810
+ }
2811
+ /** Tell the developer what became of the secret. */
2812
+ function reportSecretOutcome(outcome, cliVersion, noValue, selector) {
2813
+ reportPublished(outcome.name, outcome.published, republishCommand(outcome.name, true, cliVersion, selector), noValue);
2814
+ }
2815
+ /**
2816
+ * Tell the developer what became of the client id.
2817
+ *
2818
+ * A missing one is never "no value yet": the command refuses without a client
2819
+ * id, so the only way here is a project it could not reach.
2820
+ */
2821
+ function reportClientIdOutcome(name, state, cliVersion, selector) {
2822
+ reportPublished(name, state, republishCommand(name, false, cliVersion, selector), false);
2823
+ }
2824
+ //#endregion
2825
+ //#region src/lib/user-schema/index.ts
2826
+ /**
2827
+ * Public surface for the user-schema domain. Every caller outside this
2828
+ * module imports from here (not from individual files), the same
2829
+ * discipline as `lib/flows/`.
2830
+ *
2831
+ * **Source of truth.** The wire shape lives in
2832
+ * `@zitadel/api/generated/model` (orval-generated from the
2833
+ * OpenAPI spec). Callers that need the type import `CreateSchemaBody`
2834
+ * from there directly; callers that need the runtime validator import
2835
+ * the matching Zod schema from
2836
+ * `@zitadel/api/generated/endpoints/zitadelNextGen.zod`. The default
2837
+ * schema/flow bodies the CLI scaffolds are composed in
2838
+ * `@zitadel/config/defaults` (`getDefaultHumanUserSchema`) — the single
2839
+ * source of field defaults.
2840
+ *
2841
+ * **Dependency rule.** No upward imports (`commands/`, `sync/`, etc.)
2842
+ * and no filesystem I/O. Reading and writing local files is the
2843
+ * caller's responsibility, served by `apps/cli/src/lib/json-dir.ts`
2844
+ * plus this module's {@link SCHEMAS_DIR} constant.
2845
+ */
2846
+ /**
2847
+ * Relative directory (from the project root) where local user-schema
2848
+ * files live. Owned here so callers (`commands/*`, `sync/syncers.ts`)
2849
+ * and tests share a single source of truth for the path; the runtime
2850
+ * never depends on it directly because `lib/user-schema` does not touch
2851
+ * the filesystem. The counterpart of `lib/flows`' `FLOWS_DIR`.
2852
+ */
2853
+ const SCHEMAS_DIR = ".zitadel/schemas";
2854
+ //#endregion
2855
+ //#region src/lib/idp/schemas.ts
2856
+ /**
2857
+ * The authentication methods a schema document enables, sorted: every entry
2858
+ * under `x-auth-methods` whose `enabled` is `true`. What the login engine can
2859
+ * actually serve is narrower, and is the caller's to decide.
2860
+ */
2861
+ function methodsOf(body) {
2862
+ const methods = body["x-auth-methods"];
2863
+ if (!isObject$1(methods)) return [];
2864
+ return Object.entries(methods).filter(([, value]) => isObject$1(value) && value.enabled === true).map(([name]) => name).sort();
2865
+ }
2866
+ /**
2867
+ * Read the Project's user schemas.
2868
+ *
2869
+ * READMEs sit in the same directory, so only `.json` files count. A schema
2870
+ * that cannot be parsed stops the command: enabling a provider edits these
2871
+ * files, and editing one we could not read is how a working setup gets
2872
+ * corrupted.
2873
+ */
2874
+ async function readSchemaFiles(cwd) {
2875
+ const dir = join(cwd, SCHEMAS_DIR);
2876
+ let entries;
2877
+ try {
2878
+ entries = await readdir(dir);
2879
+ } catch (error) {
2880
+ if (isErrno$1(error, "ENOENT")) return [];
2881
+ throw error;
2882
+ }
2883
+ const files = [];
2884
+ for (const entry of entries.filter((e) => e.endsWith(".json")).sort()) {
2885
+ const path = `${SCHEMAS_DIR}/${entry}`;
2886
+ let body;
2887
+ try {
2888
+ body = JSON.parse(await readFile(join(dir, entry), "utf8"));
2889
+ } catch (error) {
2890
+ throw new ZitadelError("E_VALIDATION", `${path} is not valid JSON`, {
2891
+ hint: "Fix the file, then run the command again.",
2892
+ details: {
2893
+ file: path,
2894
+ parse_error: String(error)
2895
+ }
2896
+ });
2897
+ }
2898
+ if (!isObject$1(body)) throw new ZitadelError("E_VALIDATION", `${path} does not contain a JSON object`, { details: { file: path } });
2899
+ files.push({
2900
+ name: basename(entry, ".json"),
2901
+ path,
2902
+ properties: isObject$1(body.properties) ? Object.keys(body.properties) : [],
2903
+ methods: methodsOf(body),
2904
+ body
2905
+ });
2906
+ }
2907
+ return files;
2908
+ }
2909
+ /**
2910
+ * Pick the schema to change.
2911
+ *
2912
+ * One schema is the common case and is chosen without asking. Several is
2913
+ * ambiguous, and picking silently would edit the wrong user type, so the name
2914
+ * has to be given — the interactive picker is the caller's, because this
2915
+ * module does not prompt.
2916
+ */
2917
+ function selectSchema(files, requested) {
2918
+ if (files.length === 0) throw new ZitadelError("E_NOT_FOUND", `This Project has no user schema under ${SCHEMAS_DIR}`, { hint: "Run `zitadel setup` first." });
2919
+ if (requested !== void 0) {
2920
+ const wanted = requested.endsWith(".json") ? basename(requested, ".json") : requested;
2921
+ const match = files.find((file) => file.name === wanted);
2922
+ if (match === void 0) throw new ZitadelError("E_NOT_FOUND", `This Project has no user schema named ${wanted}`, {
2923
+ hint: `Known schemas: ${files.map((f) => f.name).join(", ")}`,
2924
+ details: {
2925
+ requested: wanted,
2926
+ known: files.map((f) => f.name)
2927
+ }
2928
+ });
2929
+ return match;
2930
+ }
2931
+ const [only, ...rest] = files;
2932
+ if (rest.length > 0 || only === void 0) throw new ZitadelError("E_VALIDATION", "This Project has more than one user schema", {
2933
+ hint: `Name the one to change with --schema. Known schemas: ${files.map((f) => f.name).join(", ")}`,
2934
+ details: { known: files.map((f) => f.name) }
2935
+ });
2936
+ return only;
2937
+ }
2938
+ /**
2939
+ * Read the Project's flow files.
2940
+ *
2941
+ * A flow names the schema it runs against, so the caller can pick the ones
2942
+ * belonging to the schema being changed rather than editing every flow.
2943
+ */
2944
+ async function readFlowFiles(cwd) {
2945
+ const dir = join(cwd, FLOWS_DIR);
2946
+ let entries;
2947
+ try {
2948
+ entries = await readdir(dir);
2949
+ } catch (error) {
2950
+ if (isErrno$1(error, "ENOENT")) return [];
2951
+ throw error;
2952
+ }
2953
+ const files = [];
2954
+ for (const entry of entries.filter((e) => e.endsWith(".json")).sort()) {
2955
+ const path = `${FLOWS_DIR}/${entry}`;
2956
+ let body;
2957
+ try {
2958
+ body = JSON.parse(await readFile(join(dir, entry), "utf8"));
2959
+ } catch (error) {
2960
+ throw new ZitadelError("E_VALIDATION", `${path} is not valid JSON`, {
2961
+ hint: "Fix the file, then run the command again.",
2962
+ details: {
2963
+ file: path,
2964
+ parse_error: String(error)
2965
+ }
2966
+ });
2967
+ }
2968
+ if (!isObject$1(body)) throw new ZitadelError("E_VALIDATION", `${path} does not contain a JSON object`, { details: { file: path } });
2969
+ files.push({
2970
+ name: basename(entry, ".json"),
2971
+ path,
2972
+ body
2973
+ });
2974
+ }
2975
+ return files;
2976
+ }
2977
+ /**
2978
+ * Read the enabled methods straight off a schema document.
2979
+ *
2980
+ * Setup composes its schema in memory and never reads it back from disk, so
2981
+ * it needs this before a {@link SchemaFile} exists.
2982
+ */
2983
+ function authMethods(body) {
2984
+ const enabled = methodsOf(body);
2985
+ return {
2986
+ password: enabled.includes("password"),
2987
+ passkey: enabled.includes("passkey")
2988
+ };
2989
+ }
2990
+ /** Whether a schema file enables a given authentication method. */
2991
+ function enabledMethods(schema) {
2992
+ return {
2993
+ password: schema.methods.includes("password"),
2994
+ passkey: schema.methods.includes("passkey")
2995
+ };
2996
+ }
2997
+ //#endregion
2998
+ //#region src/lib/oclif/groups.ts
2999
+ /**
3000
+ * The groups the root help lists commands under, declared in print order and
3001
+ * named for what a developer is trying to do: get going, work against a local
3002
+ * server, manage who can sign in and how, change the project's configuration,
3003
+ * or check on it. Every command declares its own group (`static group` on the
3004
+ * class) so nothing has to be registered centrally; oclif's own utilities
3005
+ * (`autocomplete`, `search`, …) declare none and are listed with the last group.
3006
+ */
3007
+ const CommandGroups = {
3008
+ gettingStarted: "Getting started",
3009
+ localDevelopment: "Local development",
3010
+ usersAndTeams: "Users and teams",
3011
+ authentication: "Authentication",
3012
+ configuration: "Configuration and releases",
3013
+ diagnostics: "Diagnostics and utilities"
3014
+ };
3015
+ const COMMAND_GROUPS = Object.values(CommandGroups);
3016
+ /**
3017
+ * The order within a group, which the root help and the docs' CLI reference
3018
+ * (`./reference`) share: `groupOrder` first, lowest first, then the command id.
3019
+ */
3020
+ function compareInGroup(a, b) {
3021
+ return (a.groupOrder ?? Number.MAX_SAFE_INTEGER) - (b.groupOrder ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id);
3022
+ }
3023
+ //#endregion
3024
+ //#region src/lib/local-files.ts
3025
+ /**
3026
+ * Local file references in `.zitadel/` resource files.
3027
+ *
3028
+ * Wherever the OpenAPI YAML marks a string field `x-local: file`, the
3029
+ * generated editor meta-schema accepts `{ "$file": "<path>" }` in its place,
3030
+ * so a long value such as a login template lives in its own file instead of a
3031
+ * JSON-escaped string. The API never sees a reference: before upload every
3032
+ * one is replaced by the referenced file's content.
3033
+ *
3034
+ * Resolution is generic. Nothing here knows which fields may carry a
3035
+ * reference; the Zod gate and the editor schema decide that.
3036
+ *
3037
+ * Paths are relative to the directory of the resource file that holds them
3038
+ * and must stay inside the project, because upload reads them. The check
3039
+ * applies to the real target: a symlink, or a symlinked directory on the way,
3040
+ * that leads outside the project is refused.
3041
+ */
3042
+ const FILE_REFERENCE_KEY = "$file";
3043
+ const omittedReference = Symbol("omittedReference");
3044
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3045
+ function isErrno(error, code) {
3046
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
3047
+ }
3048
+ /** A file's content, or undefined when it is absent. */
3049
+ function readIfPresent(path) {
3050
+ try {
3051
+ return readFileSync(path, "utf8");
3052
+ } catch (error) {
3053
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) return;
3054
+ throw error;
3055
+ }
3056
+ }
3057
+ function readFileReference(path, ref, options) {
3058
+ let content;
3059
+ try {
3060
+ content = readIfPresent(path);
3061
+ } catch {
3062
+ throw new ZitadelError("E_VALIDATION", `$file ${JSON.stringify(ref)} cannot be read`, { hint: "Create the referenced file or fix the path." });
3063
+ }
3064
+ if (content !== void 0) return content;
3065
+ if (options.onMissing === "omit") return omittedReference;
3066
+ throw new ZitadelError("E_VALIDATION", `$file ${JSON.stringify(ref)} cannot be read`, { hint: "Create the referenced file or fix the path." });
3067
+ }
3068
+ /** Whether `path` is `root` itself or lies beneath it. */
3069
+ function isWithin(root, path) {
3070
+ const rel = relative(root, path);
3071
+ return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
3072
+ }
3073
+ /** Whether `path` itself is a symlink, without following it. */
3074
+ function isSymlink(path) {
3075
+ try {
3076
+ return lstatSync(path).isSymbolicLink();
3077
+ } catch {
3078
+ return false;
3079
+ }
3080
+ }
3081
+ /**
3082
+ * `path` with every symlink resolved, symlinked parent directories included,
3083
+ * even when its last segments do not exist yet. Undefined when a segment is a
3084
+ * symlink that resolves nowhere, since what it names could be anywhere.
3085
+ */
3086
+ function realTarget(path) {
3087
+ const missing = [];
3088
+ let current = path;
3089
+ for (;;) try {
3090
+ return join(realpathSync(current), ...missing);
3091
+ } catch (error) {
3092
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTDIR")) throw error;
3093
+ if (isSymlink(current)) return;
3094
+ const parent = dirname(current);
3095
+ if (parent === current) return path;
3096
+ missing.unshift(basename(current));
3097
+ current = parent;
3098
+ }
3099
+ }
3100
+ /** A `{ "$file": "<path>" }` object and nothing else. */
3101
+ function isFileReference(value) {
3102
+ return isPlainObject(value) && Object.keys(value).length === 1 && typeof value["$file"] === "string";
3103
+ }
3104
+ /**
3105
+ * The absolute path a reference points at. E_VALIDATION when the path, or the
3106
+ * real target behind any symlink on the way, leaves the project.
3107
+ */
3108
+ function resolveFileReference(context, ref) {
3109
+ const absolute = resolve(context.cwd, context.baseDir, ref);
3110
+ const root = realTarget(resolve(context.cwd));
3111
+ const target = realTarget(absolute);
3112
+ if (!(isWithin(resolve(context.cwd), absolute) && root !== void 0 && target !== void 0 && isWithin(root, target))) throw new ZitadelError("E_VALIDATION", `$file ${JSON.stringify(ref)} points outside the project`, { hint: "Keep referenced files inside the project, next to the file that references them; a symlink on the way must resolve inside the project too." });
3113
+ return absolute;
3114
+ }
3115
+ /** Every reference in `document`, with the path that leads to it. */
3116
+ function findFileReferences(document) {
3117
+ const found = [];
3118
+ const walk = (node, path) => {
3119
+ if (isFileReference(node)) found.push({
3120
+ path,
3121
+ ref: node[FILE_REFERENCE_KEY]
3122
+ });
3123
+ else if (Array.isArray(node)) node.forEach((item, index) => {
3124
+ walk(item, [...path, index]);
3125
+ });
3126
+ else if (isPlainObject(node)) for (const [key, value] of Object.entries(node)) walk(value, [...path, key]);
3127
+ };
3128
+ walk(document, []);
3129
+ return found;
3130
+ }
3131
+ /**
3132
+ * Returns a copy of `document` with every reference replaced by the content
3133
+ * of the file it points at. With `onMissing: "throw"` an unreadable file is
3134
+ * E_VALIDATION, which is what plan's gate wants; with `"omit"` the value is
3135
+ * dropped instead (the key from an object, the element from an array), so
3136
+ * normalizing and hashing stay total. A path leaving the project is always
3137
+ * E_VALIDATION.
3138
+ */
3139
+ function inlineFileReferences(document, context, options) {
3140
+ const inline = (node) => {
3141
+ if (isFileReference(node)) {
3142
+ const ref = node[FILE_REFERENCE_KEY];
3143
+ return readFileReference(resolveFileReference(context, ref), ref, options);
3144
+ }
3145
+ if (Array.isArray(node)) return node.map(inline).filter((item) => item !== omittedReference);
3146
+ if (isPlainObject(node)) {
3147
+ const out = {};
3148
+ for (const [key, value] of Object.entries(node)) {
3149
+ const inlined = inline(value);
3150
+ if (inlined !== omittedReference) out[key] = inlined;
3151
+ }
3152
+ return out;
3153
+ }
3154
+ return node;
3155
+ };
3156
+ return inline(document);
3157
+ }
3158
+ //#endregion
3159
+ //#region src/lib/branding/index.ts
3160
+ /**
3161
+ * Relative directory (from the project root) where local branding files live:
3162
+ * the `branding.json` descriptor plus the sibling `.liquid` template it
3163
+ * references. Owned here so commands, syncers, and tests share one source of
3164
+ * truth; only `*.json` files are sync-discovered, the template rides along as
3165
+ * a `$file` reference in `liquid_template`.
3166
+ */
3167
+ const BRANDING_DIR = ".zitadel/branding";
3168
+ /**
3169
+ * The key descriptors used before `$file` references. It is still recognised
3170
+ * so plan can say how to migrate instead of reporting an unknown key.
3171
+ */
3172
+ const LEGACY_TEMPLATE_FILE_KEY = "liquid_template_file";
3173
+ /**
3174
+ * References in a descriptor resolve against its directory; all descriptors
3175
+ * live flat in {@link BRANDING_DIR}.
3176
+ */
3177
+ const referenceContext = (cwd) => ({
3178
+ cwd,
3179
+ baseDir: BRANDING_DIR
3180
+ });
3181
+ /** E_VALIDATION with a migration hint when a descriptor still carries `liquid_template_file`. */
3182
+ function assertNoLegacyTemplateKey(data) {
3183
+ const legacy = data[LEGACY_TEMPLATE_FILE_KEY];
3184
+ if (legacy === void 0) return;
3185
+ throw new ZitadelError("E_VALIDATION", `${LEGACY_TEMPLATE_FILE_KEY} is no longer supported`, { hint: `Replace it with "liquid_template": { "$file": ${JSON.stringify(typeof legacy === "string" ? legacy : "./login.liquid")} }.` });
3186
+ }
3187
+ /**
3188
+ * Returns the template string a descriptor carries, inline or behind a
3189
+ * `$file` reference. Undefined when it has none (a legal descriptor that only
3190
+ * sets layout or asset URLs); E_VALIDATION when a reference cannot be read.
3191
+ */
3192
+ function readDescriptorTemplate(cwd, data) {
3193
+ const { liquid_template } = inlineFileReferences(data, referenceContext(cwd), { onMissing: "throw" });
3194
+ return typeof liquid_template === "string" ? liquid_template : void 0;
3195
+ }
3196
+ /**
3197
+ * Converts a local descriptor to the wire body of `POST /branding`: strips the
3198
+ * editor `$schema` affordance and inlines every `$file` reference. Non-throwing
3199
+ * on an unreadable file (the field is left out) so normalizing and hashing stay
3200
+ * total; `validate` reports that case with a hint before any planning happens.
3201
+ */
3202
+ /**
3203
+ * The project-relative files a descriptor's `$file` references point at, for a
3204
+ * diff's pathspec: the bundle inlines them, so editing one changes what a
3205
+ * deploy publishes even though the file may sit outside `.zitadel/branding/`.
3206
+ */
3207
+ function brandingReferencePaths(cwd, data) {
3208
+ return findFileReferences(data).map(({ ref }) => relative(cwd, resolveFileReference(referenceContext(cwd), ref)));
3209
+ }
3210
+ function toBrandingWireBody(cwd, data) {
3211
+ const { $schema, ...rest } = data;
3212
+ return inlineFileReferences(rest, referenceContext(cwd), { onMissing: "omit" });
3213
+ }
3214
+ //#endregion
3215
+ //#region src/lib/json-dir.ts
3216
+ /**
3217
+ * Read every `*.json` file under `absDir` and return each body as a plain
3218
+ * object. Files are read in lexical filename order so the result is
3219
+ * deterministic for downstream hashing and diffs. Each file is parsed by
3220
+ * `JSON.parse`; this function does **not** apply any schema validation —
3221
+ * unknown keys, forward-compatible fields, and `${VAR}` placeholders survive
3222
+ * intact. Callers that need a typed shape pass the result through their
3223
+ * resource's Zod schema.
3224
+ *
3225
+ * Returns an empty array when the directory is missing. Throws `E_VALIDATION`
3226
+ * `ZitadelError` on invalid JSON or when a file's root value is not an object.
3227
+ *
3228
+ * @param absDir - Absolute path to the directory to scan.
3229
+ */
3230
+ async function readJsonDir(absDir) {
3231
+ return (await readJsonDirEntries(absDir)).map((entry) => entry.body);
3232
+ }
3233
+ /**
3234
+ * {@link readJsonDir}, keeping each body's file name — for callers that report
3235
+ * a problem against the file it came from. `skip` leaves out files by name
3236
+ * before they are read, so a file the caller does not own (a policy test file
3237
+ * beside the policies) can never fail it.
3238
+ */
3239
+ async function readJsonDirEntries(absDir, { skip } = {}) {
3240
+ let entries;
3241
+ try {
3242
+ entries = await readdir(absDir);
3243
+ } catch (error) {
3244
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return [];
3245
+ throw error;
3246
+ }
3247
+ const result = [];
3248
+ for (const entry of entries.filter((name) => name.endsWith(".json") && !skip?.(name)).sort()) {
3249
+ const abs = join(absDir, entry);
3250
+ let raw;
3251
+ try {
3252
+ raw = JSON.parse(await readFile(abs, "utf8"));
3253
+ } catch {
3254
+ throw new ZitadelError("E_VALIDATION", `${entry} is not valid JSON`);
3255
+ }
3256
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ZitadelError("E_VALIDATION", `${entry} must contain a JSON object at the root`);
3257
+ result.push({
3258
+ name: entry,
3259
+ body: raw
3260
+ });
3261
+ }
3262
+ return result;
3263
+ }
3264
+ //#endregion
3265
+ //#region src/lib/policies/index.ts
3266
+ /**
3267
+ * Operation policies (ADR 066): one `.zitadel/policies/<operation>.json` file
3268
+ * per guarded operation, which `deploy` carries in the release bundle. The
3269
+ * server publishes each as the operation's newest revision when the config
3270
+ * changed, and the release pins it.
3271
+ *
3272
+ * **Source of truth.** The file is the publish body plus `$schema`; its shape
3273
+ * is `policy.json`, the meta-schema generated from the OpenAPI spec and
3274
+ * shipped by `@zitadel/config`, and the CLI checks it with
3275
+ * `policyConfigSchema` from the same generated contract. The server's template
3276
+ * checks it again on the build, together with the rules that relate settings
3277
+ * to each other.
3278
+ *
3279
+ * **Dependency rule.** No upward imports (`commands/`, `release/`).
3280
+ */
3281
+ /** Project-relative directory the policy files live in. */
3282
+ const POLICIES_DIR = ".zitadel/policies";
3283
+ /**
3284
+ * The body `POST /policies` takes, and the form a stored policy compares in:
3285
+ * the file without its editor pointer.
3286
+ */
3287
+ function toPolicyWireBody(file) {
3288
+ return {
3289
+ operation: file.operation,
3290
+ config: file.config
3291
+ };
3292
+ }
3293
+ /**
3294
+ * The suffix of a policy test file (`<operation>.test.json`), which sits
3295
+ * beside the policies but is never deployed: `zitadel policies test` runs it.
3296
+ */
3297
+ const POLICY_TEST_SUFFIX = ".test.json";
3298
+ /** Whether a file in `.zitadel/policies/` is a test file rather than a policy. */
3299
+ function isPolicyTestFile(name) {
3300
+ return name.endsWith(POLICY_TEST_SUFFIX);
3301
+ }
3302
+ /** The name a policy file must have: its operation, so one file holds one operation's policy. */
3303
+ function policyFileName(operation) {
3304
+ return `${operation}.json`;
3305
+ }
3306
+ /**
3307
+ * What is wrong with a policy file, each as a line naming the file, or none.
3308
+ * The file must parse against the strict `policyConfigSchema`, built from the
3309
+ * generated publish body, and be named after its operation: one file per
3310
+ * operation, so two files can never carry the same policy. The rules that
3311
+ * relate settings to each other (a minimum above the maximum) are the server
3312
+ * template's, and fail the release build.
3313
+ */
3314
+ function policyFileIssues(data, file) {
3315
+ const result = policyConfigSchema.safeParse(data);
3316
+ if (!result.success) return result.error.issues.map((issue) => `${file}: ${issue.path.join(".") || "(root)"} ${issue.message}`);
3317
+ const expected = policyFileName(result.data.operation);
3318
+ if (file.split("/").at(-1) !== expected) return [`${file} holds the ${result.data.operation} policy, so it must be named ${expected}`];
3319
+ return [];
3320
+ }
3321
+ /** The operation the password policy guards, and the name of its file. */
3322
+ const PASSWORD_POLICY_OPERATION = "auth.factor.password.set";
3323
+ /** Where `setup` writes the password policy. */
3324
+ const PASSWORD_POLICY_PATH = `${POLICIES_DIR}/${policyFileName(PASSWORD_POLICY_OPERATION)}`;
3325
+ /**
3326
+ * The password policy file `setup` writes for a new project: the given
3327
+ * minimum, {@link RECOMMENDED_PASSWORD_MIN_LENGTH} when none was given, and an
3328
+ * empty `config`, which enforces nothing, for an explicit "none".
3329
+ */
3330
+ function passwordPolicyFile(minLength) {
3331
+ const value = minLength === void 0 ? RECOMMENDED_PASSWORD_MIN_LENGTH : minLength;
3332
+ return {
3333
+ $schema: POLICY_FILE_SCHEMA_REF,
3334
+ operation: PASSWORD_POLICY_OPERATION,
3335
+ config: value === null ? {} : { min_length: value }
3336
+ };
3337
+ }
3338
+ /**
3339
+ * Reads a minimum password length the way `setup` takes one, from its flag or
3340
+ * its prompt: empty or 0 means none (`null`), anything else a whole number the
3341
+ * template's bounds accept. The bounds come from the generated publish body,
3342
+ * so the CLI and the server cannot disagree on them.
3343
+ */
3344
+ function parsePasswordMinLength(value) {
3345
+ const text = value.trim();
3346
+ if (text === "" || /^0+$/.test(text)) return {
3347
+ ok: true,
3348
+ value: null
3349
+ };
3350
+ const result = PublishPolicyBody.shape.config.shape.min_length.safeParse(Number(text));
3351
+ if (!/^\d+$/.test(text) || !result.success || result.data === void 0) return {
3352
+ ok: false,
3353
+ problem: `Must be a whole number from 1 to ${publishPolicyBodyOneConfigMinLengthMax}, or 0 for none`
3354
+ };
3355
+ return {
3356
+ ok: true,
3357
+ value: result.data
3358
+ };
3359
+ }
3360
+ //#endregion
3361
+ //#region src/lib/release/bundle.ts
3362
+ /** The most resources a release may pin — the cap `POST /releases/build` enforces. */
3363
+ const MAX_BUNDLE_RESOURCES = 50;
3364
+ /** Drop the editor-only `$schema` pointer; the server never echoes it. */
3365
+ function stripSchema(resource) {
3366
+ const { $schema, ...rest } = resource;
3367
+ return rest;
3368
+ }
3369
+ /**
3370
+ * Read `.zitadel/` and assemble the bundle a deploy sends. Schemas and flows
3371
+ * are taken as authored with their `$schema` affordance removed; the branding
3372
+ * descriptor is converted to its wire body, which inlines the `$file` template
3373
+ * and strips `$schema`. An empty directory and a bundle over the cap both fail
3374
+ * before any server call, with the same messages the server would answer.
3375
+ */
3376
+ async function buildBundle(cwd) {
3377
+ const schemas = (await readJsonDir(join(cwd, SCHEMAS_DIR))).map(stripSchema);
3378
+ const flow_definitions = (await readJsonDir(join(cwd, FLOWS_DIR))).map(stripSchema);
3379
+ const idpEntries = await readJsonDirEntries(join(cwd, IDPS_DIR));
3380
+ const idps = idpEntries.map((entry) => stripSchema(entry.body));
3381
+ const policyEntries = await readJsonDirEntries(join(cwd, POLICIES_DIR), { skip: isPolicyTestFile });
3382
+ const policies = policyEntries.map((entry) => stripSchema(entry.body));
3383
+ const brandingSources = (await readJsonDir(join(cwd, BRANDING_DIR))).map((descriptor) => ({ ...descriptor }));
3384
+ if (brandingSources.length > 1) throw new ZitadelError("E_VALIDATION", `A release carries at most one branding, but .zitadel/branding/ holds ${brandingSources.length}`, { hint: "A project has a single branding; keep one descriptor under .zitadel/branding/." });
3385
+ const [descriptor] = brandingSources;
3386
+ const branding = descriptor === void 0 ? void 0 : toBrandingWireBody(cwd, descriptor);
3387
+ const resourceCount = schemas.length + flow_definitions.length + idps.length + policies.length + (branding === void 0 ? 0 : 1);
3388
+ if (resourceCount === 0) throw new ZitadelError("E_VALIDATION", "There is nothing in .zitadel/ to deploy", { hint: "Add a schema, flow, policy or branding under .zitadel/, or run `zitadel setup` to scaffold one." });
3389
+ if (resourceCount > MAX_BUNDLE_RESOURCES) throw new ZitadelError("E_VALIDATION", `A release pins at most ${MAX_BUNDLE_RESOURCES} resources, but .zitadel/ holds ${resourceCount}`);
3390
+ return {
3391
+ bundle: {
3392
+ schemas,
3393
+ flow_definitions,
3394
+ idps,
3395
+ ...policies.length === 0 ? {} : { policies },
3396
+ ...branding === void 0 ? {} : { branding }
3397
+ },
3398
+ resourceCount,
3399
+ brandingSources,
3400
+ idpFiles: idpEntries.map((entry) => entry.name),
3401
+ policyFiles: policyEntries.map((entry) => entry.name)
3402
+ };
3403
+ }
3404
+ //#endregion
3405
+ //#region src/lib/release/deployment.ts
3406
+ /**
3407
+ * Every live deployment of the project, newest first, drained across pages: one
3408
+ * live preview per open pull request can push the primary origins' deployment
3409
+ * off the first page, and a guard, a baseline or a rollback read from that page
3410
+ * alone would quietly miss it.
3411
+ */
3412
+ async function listLiveDeployments(client, projectId, options = {}) {
3413
+ const { items } = await collectPages((token) => client.listDeployments({
3414
+ project_id: projectId,
3415
+ live: true,
3416
+ ...options.origin === void 0 ? {} : { origin: options.origin },
3417
+ ...options.withRelease ? { expand: ["release"] } : {},
3418
+ ...token === void 0 ? {} : { page_token: token }
3419
+ }), {
3420
+ items: "deployments",
3421
+ all: true,
3422
+ nextPageToken: "next_page_token"
3423
+ });
3424
+ return items;
3425
+ }
3426
+ /**
3427
+ * Whether a deployment serves a primary origin. A preview deployment's targets
3428
+ * carry an expiry; it is never the baseline, the guard or the thing a bare
3429
+ * rollback undoes.
3430
+ */
3431
+ function servesPrimary(deployment) {
3432
+ return deployment.targets.some((target) => target.expires_at == null);
3433
+ }
3434
+ /**
3435
+ * Make a release live. The release is named by id or by content digest, never
3436
+ * both, and the targets are `primary` (every primary origin), one exact primary
3437
+ * origin, or the preview URLs a pull-request build registers. `ttl` sets how
3438
+ * long each preview stays live and is only valid with preview URLs. The server
3439
+ * answers 201 when it wrote a deployment and 200 when every target already
3440
+ * served the release.
3441
+ */
3442
+ async function createDeployment(client, projectId, input) {
3443
+ return await client.createDeployment({
3444
+ ...input.releaseId === void 0 ? {} : { release_id: input.releaseId },
3445
+ ...input.contentHash === void 0 ? {} : { content_hash: input.contentHash },
3446
+ targets: input.targets,
3447
+ ...input.message === void 0 ? {} : { message: input.message },
3448
+ ...input.ttl === void 0 ? {} : { ttl: input.ttl },
3449
+ ...input.expectedDeploymentId === void 0 ? {} : { expected_deployment_id: input.expectedDeploymentId }
3450
+ }, { project_id: projectId });
3451
+ }
3452
+ /**
3453
+ * Re-apply a deployment: a new deployment with its release and its targets,
3454
+ * recorded as a rollback. Returns 200 when the targets already serve it.
3455
+ */
3456
+ async function rollbackDeployment(client, projectId, deploymentId, options) {
3457
+ const body = {
3458
+ ...options?.message === void 0 ? {} : { message: options.message },
3459
+ ...options?.expectedDeploymentId === void 0 ? {} : { expected_deployment_id: options.expectedDeploymentId }
3460
+ };
3461
+ return client.rollbackDeployment(deploymentId, { project_id: projectId }, Object.keys(body).length === 0 ? void 0 : body);
3462
+ }
3463
+ //#endregion
3464
+ //#region src/lib/release/git.ts
3465
+ const run = promisify(execFile);
3466
+ /**
3467
+ * The directories a release is built from, the only paths a deploy diffs. Not
3468
+ * the whole of `.zitadel/`: `state.json` and `meta/` are not published, and
3469
+ * `.zitadel/secret` or `.zitadel/local/`, left untracked but not ignored, would
3470
+ * otherwise be marked for the diff and their contents printed.
3471
+ */
3472
+ const CONFIG_PATHS = [
3473
+ ".zitadel/schemas/",
3474
+ ".zitadel/flows/",
3475
+ ".zitadel/branding/",
3476
+ ".zitadel/idps/"
3477
+ ];
3478
+ /** Run git in `cwd`, returning its stdout, or null when git cannot answer. */
3479
+ async function git(cwd, args, env) {
3480
+ try {
3481
+ const { stdout } = await run("git", args, {
3482
+ cwd,
3483
+ maxBuffer: 16777216,
3484
+ ...env ? { env: {
3485
+ ...process.env,
3486
+ ...env
3487
+ } } : {}
3488
+ });
3489
+ return stdout;
3490
+ } catch {
3491
+ return null;
3492
+ }
3493
+ }
3494
+ /** The current commit, or null when there is no repo or no commit yet. */
3495
+ async function headSha(cwd) {
3496
+ const stdout = await git(cwd, ["rev-parse", "HEAD"]);
3497
+ return stdout === null ? null : stdout.trim() || null;
3498
+ }
3499
+ /**
3500
+ * Whether the repository's working tree has any uncommitted change, anywhere —
3501
+ * not only under `.zitadel/`. A release records the provenance of the whole
3502
+ * commit it was built from, so a change outside `.zitadel/` (a code change that
3503
+ * the config depends on, say) makes that provenance just as inexact.
3504
+ */
3505
+ async function worktreeIsDirty(cwd) {
3506
+ const stdout = await git(cwd, ["status", "--porcelain"]);
3507
+ return stdout !== null && stdout.trim() !== "";
3508
+ }
3509
+ /**
3510
+ * The `git diff` options the terminal's diff and the `--json` one share, so
3511
+ * the two never disagree: git's own unified diff, never an external diff
3512
+ * program's, with paths relative to the project, as the CLI reports them
3513
+ * everywhere else.
3514
+ */
3515
+ const DIFF_OPTIONS = ["--no-ext-diff", "--relative"];
3516
+ /**
3517
+ * The coloured diff of `.zitadel/` against `sha`, or null when no diff can be
3518
+ * shown: git is missing, this is not a repo, or the commit is not in local
3519
+ * history (a shallow clone, say). A dead commit is told apart from a clean tree
3520
+ * by first checking the commit resolves.
3521
+ */
3522
+ async function diffConfigAgainst(cwd, sha, color, referenced = []) {
3523
+ return diffConfig(cwd, sha, [color ? "--color=always" : "--no-color"], referenced);
3524
+ }
3525
+ /**
3526
+ * {@link diffConfigAgainst}, uncoloured and split by file: joined back
3527
+ * together, the patches are the diff the terminal shows. Null when no diff can
3528
+ * be shown, for the same reasons.
3529
+ */
3530
+ async function diffConfigFilesAgainst(cwd, sha, referenced = []) {
3531
+ const output = await diffConfig(cwd, sha, [
3532
+ "--no-color",
3533
+ "--raw",
3534
+ "-z",
3535
+ "--patch"
3536
+ ], referenced);
3537
+ return output === null ? null : splitByFile(output);
3538
+ }
3539
+ /**
3540
+ * Pair the `--raw -z` records with the patch's per-file sections. git writes
3541
+ * both from one list of files, in one order, so they line up one to one, except
3542
+ * for a file whose type changed (a file replaced by a symlink): git shows it as
3543
+ * a deletion and a creation, two sections under one header, joined here.
3544
+ */
3545
+ function splitByFile(output) {
3546
+ let at = 0;
3547
+ const field = () => {
3548
+ const end = output.indexOf("\0", at);
3549
+ const value = output.slice(at, end === -1 ? output.length : end);
3550
+ at = end === -1 ? output.length : end + 1;
3551
+ return value;
3552
+ };
3553
+ const records = [];
3554
+ while (output.startsWith(":", at)) {
3555
+ const meta = field();
3556
+ const letter = meta.charAt(meta.lastIndexOf(" ") + 1);
3557
+ const path = field();
3558
+ if (letter === "R" || letter === "C") {
3559
+ const status = letter === "R" ? "renamed" : "copied";
3560
+ records.push({
3561
+ path: field(),
3562
+ status,
3563
+ previous_path: path
3564
+ });
3565
+ } else {
3566
+ const status = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified";
3567
+ records.push({
3568
+ path,
3569
+ status
3570
+ });
3571
+ }
3572
+ }
3573
+ const sections = output.slice(at + 1).split(/^(?=diff --git )/m);
3574
+ let next = 0;
3575
+ return records.map((record) => {
3576
+ let patch = sections[next++] ?? "";
3577
+ while (next < sections.length && headerOf(sections[next]) === headerOf(patch)) patch += sections[next++];
3578
+ return {
3579
+ ...record,
3580
+ patch
3581
+ };
3582
+ });
3583
+ }
3584
+ /** A patch section's first line, its `diff --git` header. */
3585
+ function headerOf(section = "") {
3586
+ const end = section.indexOf("\n");
3587
+ return end === -1 ? section : section.slice(0, end);
3588
+ }
3589
+ /** `git diff` of the release's paths against `sha`, in `format`. */
3590
+ async function diffConfig(cwd, sha, format, referenced) {
3591
+ const paths = [...CONFIG_PATHS, ...referenced];
3592
+ if (await git(cwd, [
3593
+ "cat-file",
3594
+ "-e",
3595
+ `${sha}^{commit}`
3596
+ ]) === null) return null;
3597
+ const diffArgs = [
3598
+ "diff",
3599
+ ...DIFF_OPTIONS,
3600
+ ...format,
3601
+ sha,
3602
+ "--",
3603
+ ...paths
3604
+ ];
3605
+ const indexPath = (await git(cwd, [
3606
+ "rev-parse",
3607
+ "--git-path",
3608
+ "index"
3609
+ ]))?.trim();
3610
+ if (!indexPath) return git(cwd, diffArgs);
3611
+ let scratch;
3612
+ try {
3613
+ scratch = await mkdtemp(join(tmpdir(), "zitadel-diff-"));
3614
+ const tempIndex = join(scratch, "index");
3615
+ await copyFile(resolve(cwd, indexPath), tempIndex).catch(() => void 0);
3616
+ const env = { GIT_INDEX_FILE: tempIndex };
3617
+ const present = paths.filter((path) => existsSync(join(cwd, path)));
3618
+ if (present.length > 0) await git(cwd, [
3619
+ "add",
3620
+ "-N",
3621
+ "--",
3622
+ ...present
3623
+ ], env);
3624
+ return await git(cwd, diffArgs, env) ?? await git(cwd, diffArgs);
3625
+ } finally {
3626
+ if (scratch) await rm(scratch, {
3627
+ recursive: true,
3628
+ force: true
3629
+ });
3630
+ }
3631
+ }
3632
+ //#endregion
3633
+ //#region src/lib/release/steps/asset-probe.ts
3634
+ /**
3635
+ * Branding descriptor fields whose value is a URL the login page fetches as
3636
+ * an image. Both are optional and both fail the same invisible way when the
3637
+ * URL is well-formed but dead.
3638
+ */
3639
+ const ASSET_FIELDS = ["logo_url", "hero_url"];
3640
+ /**
3641
+ * Per-URL probe budget. Short on purpose: this runs on the critical path of
3642
+ * every `deploy` and `preview`, and a dead host that never answers must cost a
3643
+ * couple of seconds, not a hung command.
3644
+ */
3645
+ const DEFAULT_TIMEOUT_MS = 2500;
3646
+ /** Keep redirect chains bounded and re-validate every destination ourselves. */
3647
+ const MAX_REDIRECTS = 5;
3648
+ /**
3649
+ * Addresses an untrusted descriptor must never make the planning host contact.
3650
+ * The browser-side render gate still supports canonical loopback HTTP for local
3651
+ * development; the CLI probe deliberately leaves those URLs inconclusive
3652
+ * instead of turning `deploy` over repo content into a localhost/network scanner.
3653
+ */
3654
+ const UNSAFE_IPV4_ADDRESSES = new BlockList();
3655
+ for (const [network, prefix] of [
3656
+ ["0.0.0.0", 8],
3657
+ ["10.0.0.0", 8],
3658
+ ["100.64.0.0", 10],
3659
+ ["127.0.0.0", 8],
3660
+ ["169.254.0.0", 16],
3661
+ ["172.16.0.0", 12],
3662
+ ["192.0.0.0", 24],
3663
+ ["192.0.2.0", 24],
3664
+ ["192.88.99.0", 24],
3665
+ ["192.168.0.0", 16],
3666
+ ["198.18.0.0", 15],
3667
+ ["198.51.100.0", 24],
3668
+ ["203.0.113.0", 24],
3669
+ ["224.0.0.0", 4],
3670
+ ["240.0.0.0", 4]
3671
+ ]) UNSAFE_IPV4_ADDRESSES.addSubnet(network, prefix, "ipv4");
3672
+ const UNSAFE_IPV6_ADDRESSES = new BlockList();
3673
+ for (const [network, prefix] of [
3674
+ ["::", 3],
3675
+ ["4000::", 2],
3676
+ ["8000::", 1],
3677
+ ["2001::", 23],
3678
+ ["2001:db8::", 32],
3679
+ ["2002::", 16],
3680
+ ["3fff::", 20]
3681
+ ]) UNSAFE_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
3682
+ /** Content types that are images despite not being `image/*`. */
3683
+ const EXTRA_IMAGE_TYPES = /* @__PURE__ */ new Set(["application/octet-stream"]);
3684
+ /**
3685
+ * Probe every asset URL the bundle's branding descriptors carry and return one
3686
+ * warning line per URL that is well-formed but will not render: an unreachable
3687
+ * host, a non-2xx status, or a response that is not an image.
3688
+ *
3689
+ * Why the CLI probes at all: a bad `logo_url` clears every gate on the way in
3690
+ * (the CLI's Zod shape check, the server's save gate) and then fails silently
3691
+ * in the browser as a 0×0 `<img>` — no deploy output, no console error. The
3692
+ * probe is the only place in the pipeline that can see it.
3693
+ *
3694
+ * Deliberately non-fatal and best-effort. A developer on a plane, behind a
3695
+ * proxy, or pointing at a CDN that only resolves from production gets a
3696
+ * warning, never a failed deploy — the machine running `deploy` is not
3697
+ * necessarily the machine that renders the login page. Set
3698
+ * `ZITADEL_SKIP_ASSET_PROBE` to turn it off entirely, and
3699
+ * `ZITADEL_ASSET_PROBE_TIMEOUT_MS` to retune the per-URL budget. Only public
3700
+ * HTTPS destinations are contacted: loopback/private/internal targets and
3701
+ * redirects to them stay inconclusive so repo config cannot scan the running
3702
+ * host's network.
3703
+ */
3704
+ async function brandingAssetWarnings(brandings) {
3705
+ if (process.env.ZITADEL_SKIP_ASSET_PROBE) {
3706
+ consola.debug("Branding asset probe skipped (ZITADEL_SKIP_ASSET_PROBE is set)");
3707
+ return [];
3708
+ }
3709
+ const fields = brandings.flatMap((branding) => assetUrlsOf(branding));
3710
+ const urls = new Set(fields.map(({ url }) => url));
3711
+ if (urls.size === 0) return [];
3712
+ const timeoutMs = probeTimeoutMs();
3713
+ const verdicts = new Map(await Promise.all([...urls].map(async (url) => [url, await probeAsset(url, timeoutMs)])));
3714
+ const warnings = [];
3715
+ for (const { field, url } of fields) {
3716
+ const warning = describeVerdict(field, url, verdicts.get(url) ?? { kind: "inconclusive" });
3717
+ if (warning) warnings.push(warning);
3718
+ }
3719
+ return warnings;
3720
+ }
3721
+ /**
3722
+ * The probe itself: a HEAD request bounded by an abort timeout. HEAD keeps a
3723
+ * multi-megabyte hero image off the wire — we only need the status line and
3724
+ * the content type.
3725
+ */
3726
+ async function probeAsset(url, timeoutMs) {
3727
+ let current;
3728
+ try {
3729
+ current = new URL(url);
3730
+ } catch {
3731
+ return { kind: "inconclusive" };
3732
+ }
3733
+ const signal = AbortSignal.timeout(timeoutMs);
3734
+ try {
3735
+ for (let redirects = 0;; redirects += 1) {
3736
+ if (!isSafeProbeUrl(current)) {
3737
+ consola.debug(`asset probe ${url} skipped unsafe/private target: ${current.href}`);
3738
+ return { kind: "inconclusive" };
3739
+ }
3740
+ const response = await requestHead(current, signal);
3741
+ if (isRedirect(response.status)) {
3742
+ const location = response.location;
3743
+ if (!location) return {
3744
+ kind: "status",
3745
+ status: response.status
3746
+ };
3747
+ if (redirects >= MAX_REDIRECTS) return {
3748
+ kind: "unreachable",
3749
+ detail: `more than ${MAX_REDIRECTS} redirects`
3750
+ };
3751
+ current = new URL(location, current);
3752
+ continue;
3753
+ }
3754
+ if (response.status === 405 || response.status === 501) return { kind: "inconclusive" };
3755
+ if (response.status < 200 || response.status >= 300) return {
3756
+ kind: "status",
3757
+ status: response.status
3758
+ };
3759
+ if (response.status === 204 || response.status === 205) return {
3760
+ kind: "empty",
3761
+ status: response.status
3762
+ };
3763
+ const contentType = response.contentType?.split(";")[0]?.trim().toLowerCase();
3764
+ if (!contentType) return { kind: "ok" };
3765
+ if (contentType.startsWith("image/") || EXTRA_IMAGE_TYPES.has(contentType)) return { kind: "ok" };
3766
+ return {
3767
+ kind: "content-type",
3768
+ contentType
3769
+ };
3770
+ }
3771
+ } catch (error) {
3772
+ if (isUnsafeProbeTargetError(error)) {
3773
+ consola.debug(`asset probe ${url} skipped unsafe/private target`);
3774
+ return { kind: "inconclusive" };
3775
+ }
3776
+ consola.debug(`asset probe ${url} failed:`, error);
3777
+ return {
3778
+ kind: "unreachable",
3779
+ detail: describeProbeError(error, timeoutMs)
3780
+ };
3781
+ }
3782
+ }
3783
+ function isRedirect(status) {
3784
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
3785
+ }
3786
+ /**
3787
+ * Reject targets whose URL alone proves they are unsafe. Hostname resolution
3788
+ * happens inside the connector lookup below so validation and connection use
3789
+ * the same DNS answer, without a rebinding window.
3790
+ */
3791
+ function isSafeProbeUrl(url) {
3792
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "") return false;
3793
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
3794
+ if (hostname === "localhost" || !hostname.includes(".") || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".lan") || hostname.endsWith(".home.arpa")) return false;
3795
+ const literalFamily = isIP(hostname);
3796
+ return literalFamily === 0 || !isUnsafeAddress(hostname, literalFamily);
3797
+ }
3798
+ /** Resolve once and accept the answer only when every address is public. */
3799
+ async function resolvePublicAddresses(hostname) {
3800
+ const addresses = await lookup(hostname, {
3801
+ all: true,
3802
+ verbatim: true
3803
+ });
3804
+ return addresses.length > 0 && addresses.every(({ address, family }) => !isUnsafeAddress(address, family)) ? addresses : void 0;
3805
+ }
3806
+ function isUnsafeAddress(address, family) {
3807
+ return family === 6 ? UNSAFE_IPV6_ADDRESSES.check(address, "ipv6") : UNSAFE_IPV4_ADDRESSES.check(address, "ipv4");
3808
+ }
3809
+ const UNSAFE_PROBE_TARGET_CODE = "ERR_ZITADEL_UNSAFE_ASSET_TARGET";
3810
+ function unsafeProbeTargetError(hostname) {
3811
+ return Object.assign(/* @__PURE__ */ new Error(`asset target ${hostname} resolves to a non-public address`), { code: UNSAFE_PROBE_TARGET_CODE });
3812
+ }
3813
+ /**
3814
+ * Validate DNS in the connector's own lookup and return only approved
3815
+ * addresses. The socket cannot perform a second lookup, which closes the DNS
3816
+ * rebinding gap while preserving the hostname for TLS SNI/certificate checks.
3817
+ */
3818
+ async function requestHead(url, signal) {
3819
+ const safeLookup = (hostname, options, callback) => {
3820
+ resolvePublicAddresses(hostname).then((addresses) => {
3821
+ if (!addresses) {
3822
+ callback(unsafeProbeTargetError(hostname), "", 0);
3823
+ return;
3824
+ }
3825
+ const candidates = options.family === 4 || options.family === 6 ? addresses.filter(({ family }) => family === options.family) : addresses;
3826
+ const candidate = candidates[0];
3827
+ if (!candidate) {
3828
+ callback(unsafeProbeTargetError(hostname), "", 0);
3829
+ return;
3830
+ }
3831
+ if (options.all) {
3832
+ callback(null, candidates);
3833
+ return;
3834
+ }
3835
+ callback(null, candidate.address, candidate.family);
3836
+ }, (error) => callback(error, "", 0));
3837
+ };
3838
+ const dispatcher = new Agent({ connect: { lookup: safeLookup } }).compose(userAgentInterceptor);
3839
+ try {
3840
+ const response = await fetch(url, {
3841
+ method: "HEAD",
3842
+ redirect: "manual",
3843
+ signal,
3844
+ dispatcher
3845
+ });
3846
+ return {
3847
+ status: response.status,
3848
+ location: response.headers.get("location") ?? void 0,
3849
+ contentType: response.headers.get("content-type") ?? void 0
3850
+ };
3851
+ } finally {
3852
+ await dispatcher.destroy();
3853
+ }
3854
+ }
3855
+ function isUnsafeProbeTargetError(error) {
3856
+ let current = error;
3857
+ for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
3858
+ if (current.code === UNSAFE_PROBE_TARGET_CODE) return true;
3859
+ current = current.cause;
3860
+ }
3861
+ return false;
3862
+ }
3863
+ function describeVerdict(field, url, verdict) {
3864
+ switch (verdict.kind) {
3865
+ case "ok":
3866
+ case "inconclusive": return;
3867
+ case "status": return `${field} ${url} returned HTTP ${verdict.status} — the login page renders a well-formed URL that serves nothing as a 0×0 image, with no error anywhere.`;
3868
+ case "empty": return `${field} ${url} returned HTTP ${verdict.status} with no representation — the login page has no image bytes to render.`;
3869
+ case "unreachable": return `${field} ${url} could not be reached (${verdict.detail}) — the login page renders an unreachable URL as a 0×0 image. Ignore this if the host is only reachable from where the login page renders; set ZITADEL_SKIP_ASSET_PROBE to stop checking.`;
3870
+ case "content-type": return `${field} ${url} responded with content-type "${verdict.contentType}", not an image — the login page renders a non-image response as a 0×0 image.`;
3871
+ }
3872
+ }
3873
+ /** The `logo_url` / `hero_url` values a descriptor actually sets. */
3874
+ function assetUrlsOf(content) {
3875
+ const body = content;
3876
+ const out = [];
3877
+ for (const field of ASSET_FIELDS) {
3878
+ const value = body[field];
3879
+ if (typeof value === "string" && value !== "") out.push({
3880
+ field,
3881
+ url: value
3882
+ });
3883
+ }
3884
+ return out;
3885
+ }
3886
+ function probeTimeoutMs() {
3887
+ const raw = Number(process.env.ZITADEL_ASSET_PROBE_TIMEOUT_MS);
3888
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
3889
+ }
3890
+ function describeProbeError(error, timeoutMs) {
3891
+ if (error instanceof Error && (error.name === "TimeoutError" || error.cause instanceof Error && error.cause.name === "TimeoutError")) return `no response within ${timeoutMs}ms`;
3892
+ if (error instanceof Error) {
3893
+ const cause = error.cause;
3894
+ return (cause instanceof Error ? cause.message : error.message) || error.name;
3895
+ }
3896
+ return String(error);
3897
+ }
3898
+ //#endregion
3899
+ //#region src/lib/release/steps/idp-checks.ts
3900
+ /**
3901
+ * The checks a connection file gets before the release is built.
3902
+ *
3903
+ * The server creates the connections from the bundle, inside the release's own
3904
+ * transaction, and holds them to the same rules. These run first anyway so a
3905
+ * bad file is named locally — with the file it came from and the command that
3906
+ * fixes it — rather than coming back as one bulk `rel.invalid`. Two of them the
3907
+ * server cannot run for us: that a `client_secret` references a variable rather
3908
+ * than holding a value, which must fail before anything leaves the machine, and
3909
+ * that the variables a connection names are the kind the engine needs.
3910
+ *
3911
+ * A preview is checked the same way, except that its token may not read the
3912
+ * project's variables; that check then belongs to the deploy that merges it.
3913
+ */
3914
+ async function validateConnections(ctx) {
3915
+ const files = ctx.idpFiles;
3916
+ const named = ctx.bundle.idps.map((body, index) => ({
3917
+ body,
3918
+ file: `${IDPS_DIR}/${files[index] ?? "(unnamed)"}`
3919
+ }));
3920
+ for (const { body, file } of named) validateConnection(body, file, ctx.suggest);
3921
+ if (named.length === 0) return;
3922
+ const held = await heldVariables(ctx);
3923
+ if (held === void 0) return;
3924
+ for (const { body, file } of named) checkCredentialVariables(body, file, held, ctx.suggest, ctx.mode === "preview");
3925
+ }
3926
+ /**
3927
+ * The connection's shape, and the one mistake that would publish a credential:
3928
+ * a `client_secret` held as a value rather than a `${{ NAME }}` reference.
3929
+ */
3930
+ function validateConnection(data, file, suggest) {
3931
+ const result = idpConnectionConfigSchema.safeParse(data);
3932
+ if (!result.success) {
3933
+ const literalSecret = result.error.issues.some((issue) => {
3934
+ if (issue.path[issue.path.length - 1] !== "client_secret") return false;
3935
+ const stored = valueAt(data, issue.path);
3936
+ return typeof stored === "string" && !isVariableReference(stored);
3937
+ });
3938
+ throw new ZitadelError("E_VALIDATION", literalSecret ? "Connection file's client_secret must reference a variable, not hold a value" : "Connection file is not a valid identity provider connection", {
3939
+ hint: literalSecret ? `Use "client_secret": "\${{ NAME }}" and publish the value with \`${suggest("variable set NAME --secret")}\`.` : void 0,
3940
+ details: { issues: result.error.issues }
3941
+ });
3942
+ }
3943
+ const missing = missingEndpoints(data.oidc);
3944
+ if (missing.length > 0) throw new ZitadelError("E_VALIDATION", `${file}: oidc names some endpoints but not ${missing.join(", ")}`, {
3945
+ hint: "Name every endpoint (authorization_endpoint, jwks_uri, token_endpoint, and userinfo_endpoint unless id_token_mapping is true), or none to read them from the issuer's discovery document.",
3946
+ details: {
3947
+ file,
3948
+ missing
3949
+ }
3950
+ });
3951
+ refuseResolvedSecret(data, "the connection");
3952
+ }
3953
+ /** The value a Zod issue path points at, or undefined when it is absent. */
3954
+ function valueAt(data, path) {
3955
+ let current = data;
3956
+ for (const key of path) {
3957
+ if (typeof current !== "object" || current === null) return;
3958
+ current = current[key];
3959
+ }
3960
+ return current;
3961
+ }
3962
+ /**
3963
+ * The variables the deploy would freeze, by name, and whether each is a secret:
3964
+ * the primary kind's for a deploy and the preview kind's for a preview, with no
3965
+ * fallback from one to the other (ADR 069). A preview's token may not read them,
3966
+ * which leaves the check to the deploy that merges it.
3967
+ */
3968
+ async function heldVariables(ctx) {
3969
+ const kind = ctx.mode === "preview" ? "preview" : "primary";
3970
+ let held;
3971
+ try {
3972
+ held = await ctx.client.getVariables({
3973
+ project_id: ctx.projectId,
3974
+ kind
3975
+ });
3976
+ } catch (error) {
3977
+ if (ctx.mode === "preview" && error instanceof ApiError && error.status === 403) return;
3978
+ throw error;
3979
+ }
3980
+ return new Map(Object.entries(valuesOfKind(held, kind)).map(([name, value]) => [name, typeof value === "object"]));
3981
+ }
3982
+ /**
3983
+ * What the engine needs from the variables a connection names: they exist,
3984
+ * the client id is a plain one it can put in the authorize URL, the client
3985
+ * secret is a secret one, and they are not one and the same.
3986
+ */
3987
+ function checkCredentialVariables(connection, file, held, suggest, preview) {
3988
+ const setCommand = (name, secret) => suggest(`variable set ${name}${secret ? " --secret" : ""}${preview ? " --preview" : ""}`);
3989
+ const block = connection.oidc;
3990
+ const nameOf = (value) => typeof value === "string" ? referencedVariable(value) : void 0;
3991
+ const named = [{
3992
+ field: "client_id",
3993
+ secret: false,
3994
+ name: nameOf(block?.client_id)
3995
+ }, {
3996
+ field: "client_secret",
3997
+ secret: true,
3998
+ name: nameOf(block?.client_secret)
3999
+ }].flatMap((credential) => credential.name === void 0 ? [] : [{
4000
+ ...credential,
4001
+ name: credential.name
4002
+ }]);
4003
+ const [id, secret] = named;
4004
+ if (id !== void 0 && secret !== void 0 && id.name === secret.name) throw new ZitadelError("E_VALIDATION", `${file}: client_id and client_secret both name the variable ${id.name}`, {
4005
+ hint: "Give each its own variable: a plain one for the client id, a secret one for the client secret.",
4006
+ details: {
4007
+ variable: id.name,
4008
+ file
4009
+ }
4010
+ });
4011
+ for (const { field, secret: wantsSecret, name } of named) {
4012
+ const isSecret = held.get(name);
4013
+ if (isSecret === void 0) throw new ZitadelError("E_CREDENTIAL_MISSING", `${file}: ${field} names the variable ${name}, which the project does not have`, {
4014
+ hint: "Publish it with the command below, then deploy again.",
4015
+ nextCommands: [setCommand(name, wantsSecret)],
4016
+ details: {
4017
+ variable: name,
4018
+ field,
4019
+ file
4020
+ }
4021
+ });
4022
+ if (isSecret !== wantsSecret) throw new ZitadelError("E_VALIDATION", `${file}: ${field} names the variable ${name}, which is ${isSecret ? "a secret" : "not a secret"}`, {
4023
+ hint: wantsSecret ? "The client secret must be a secret variable: publish it with the command below." : "The client id must be a plain variable the engine can read back: publish it with the command below, without --secret.",
4024
+ nextCommands: [setCommand(name, wantsSecret)],
4025
+ details: {
4026
+ variable: name,
4027
+ field,
4028
+ file,
4029
+ secret: isSecret
4030
+ }
4031
+ });
4032
+ }
4033
+ }
4034
+ //#endregion
4035
+ //#region src/lib/release/steps/sso-consistency.ts
4036
+ /**
4037
+ * Cross-file checks on the SSO configuration, before anything is sent.
4038
+ *
4039
+ * A flow that offers a provider its schema does not list, or a schema that
4040
+ * lists a provider with no connection file, is only visible when the files are
4041
+ * read together, and the server would reveal it late or not at all. This reads
4042
+ * the whole of `.zitadel/` so the rules can see what each file points at, and
4043
+ * throws one `E_VALIDATION` naming every problem, so a deploy (or a dry run)
4044
+ * stops before any request.
4045
+ *
4046
+ * A deploy publishes everything under `.zitadel/`, so everything is judged;
4047
+ * there is no narrower set of files a deploy "touches".
4048
+ */
4049
+ async function validateSsoConsistency(cwd) {
4050
+ const [schemas, flows, connections] = await Promise.all([
4051
+ readJsonDirEntries(join(cwd, SCHEMAS_DIR)),
4052
+ readJsonDirEntries(join(cwd, FLOWS_DIR)),
4053
+ readJsonDirEntries(join(cwd, IDPS_DIR))
4054
+ ]);
4055
+ const keyed = (dir, entries) => new Map(entries.map((entry) => [`${dir}/${entry.name}`, entry.body]));
4056
+ const schemaFiles = keyed(SCHEMAS_DIR, schemas);
4057
+ const tree = {
4058
+ schemas: schemaFiles,
4059
+ flows: new Map(flows.map((flow) => {
4060
+ const path = `${FLOWS_DIR}/${flow.name}`;
4061
+ const schemaPath = pinnedSchemaPath(flow.body, schemaFiles);
4062
+ if (schemaPath === void 0 && offeredProviders(flow.body).length > 0) reportWarning(`${path}: could not tell which local schema this flow pins, so its provider rules were not checked against a schema`);
4063
+ return [path, {
4064
+ body: flow.body,
4065
+ schemaPath
4066
+ }];
4067
+ })),
4068
+ connections: keyed(IDPS_DIR, connections)
4069
+ };
4070
+ const issues = validateProject(tree);
4071
+ if (issues.length === 0) return;
4072
+ const noun = issues.length === 1 ? "issue" : "issues";
4073
+ throw new ZitadelError("E_VALIDATION", `SSO configuration is not consistent (${issues.length} ${noun}):\n` + issues.map((issue) => ` - ${issue.file}: ${issue.message}`).join("\n"), {
4074
+ hint: "These checks read the schemas, flows and connection files together. Fix the files named above and re-run; no changes were made. Only the CLI makes these checks.",
4075
+ details: { issues: issues.map(({ rule, file, steps, message }) => ({
4076
+ rule,
4077
+ path: file,
4078
+ ...steps === void 0 ? {} : { steps },
4079
+ message
4080
+ })) }
4081
+ });
4082
+ }
4083
+ /** The provider slugs the steps of a flow offer. */
4084
+ function offeredProviders(flow) {
4085
+ const steps = flow.steps;
4086
+ if (!Array.isArray(steps)) return [];
4087
+ return steps.flatMap((step) => {
4088
+ const providers = typeof step === "object" && step !== null ? step.sso_providers : void 0;
4089
+ return Array.isArray(providers) ? providers.filter((item) => typeof item === "string") : [];
4090
+ });
4091
+ }
4092
+ /**
4093
+ * The local schema a flow's `user_schema` pins, or `undefined` when it cannot
4094
+ * be told. A flow names its schema by `objectType`; a schema's `$id` is also
4095
+ * accepted for a flow written against the URL form.
4096
+ */
4097
+ function pinnedSchemaPath(flow, schemas) {
4098
+ const ref = flow.user_schema;
4099
+ if (typeof ref !== "string" || ref === "") return;
4100
+ for (const [path, schema] of schemas) {
4101
+ const { $id, objectType } = schema;
4102
+ if ($id === ref || objectType === ref) return path;
4103
+ }
4104
+ }
4105
+ //#endregion
4106
+ //#region src/lib/release/steps/validate.ts
4107
+ /**
4108
+ * Validate the bundle the way the server does before it builds the release, so
4109
+ * a misconfiguration fails here with the file named rather than as a bulk
4110
+ * `rel.invalid` from the server. Two checks the server cannot run live earn
4111
+ * their place: every `${VAR}` / `*_env` reference must resolve in the running
4112
+ * environment, and every branding asset URL must actually serve an image.
4113
+ *
4114
+ * Moved out of the sync engine, where it validated a diff; here it validates
4115
+ * the bundle a deploy (and, later, a preview) assembles.
4116
+ */
4117
+ const validateStep = {
4118
+ name: "validate",
4119
+ runsFor: ["deploy", "preview"],
4120
+ async run(ctx) {
4121
+ assertEnvRefsResolve(ctx);
4122
+ validateBundleShapes(ctx);
4123
+ validateFlows(ctx);
4124
+ await validateConnections(ctx);
4125
+ await validateSsoConsistency(ctx.cwd);
4126
+ for (const warning of await brandingAssetWarnings(brandingsOf(ctx))) reportWarning(warning);
4127
+ }
4128
+ };
4129
+ /**
4130
+ * Parse every schema, flow, policy and branding descriptor against the same Zod
4131
+ * the server enforces, before any writing step runs. Without this a malformed
4132
+ * schema, flow or branding only fails at `createRelease`, after the IdP-sync
4133
+ * step has already written — leaving a half-applied deploy. Errors across
4134
+ * every bundle kind aggregate into one `E_VALIDATION`. Branding is parsed from
4135
+ * the descriptor as authored (not the inlined wire body), so a `$file` that
4136
+ * cannot be read is reported here rather than silently dropped.
4137
+ */
4138
+ function validateBundleShapes(ctx) {
4139
+ const failures = [];
4140
+ ctx.bundle.schemas.forEach((schema, index) => {
4141
+ const result = schemaConfigSchema.safeParse(schema);
4142
+ if (!result.success) {
4143
+ const handle = typeof schema.objectType === "string" ? schema.objectType : `schema #${index + 1}`;
4144
+ for (const issue of result.error.issues) failures.push(`${handle}: ${issue.path.join(".") || "(root)"} ${issue.message}`);
4145
+ }
4146
+ });
4147
+ ctx.bundle.flow_definitions.forEach((flow, index) => {
4148
+ const result = flowConfigSchema.safeParse(flow);
4149
+ if (!result.success) {
4150
+ const handle = typeof flow.name === "string" ? flow.name : `flow #${index + 1}`;
4151
+ for (const issue of result.error.issues) failures.push(`${handle}: ${issue.path.join(".") || "(root)"} ${issue.message}`);
4152
+ }
4153
+ });
4154
+ (ctx.bundle.policies ?? []).forEach((policy, index) => {
4155
+ const file = `${POLICIES_DIR}/${ctx.policyFiles[index] ?? "(unnamed)"}`;
4156
+ failures.push(...policyFileIssues(policy, file));
4157
+ });
4158
+ ctx.brandingSources.forEach((descriptor, index) => {
4159
+ const handle = `branding #${index + 1}`;
4160
+ try {
4161
+ assertNoLegacyTemplateKey(descriptor);
4162
+ } catch (error) {
4163
+ const hint = error instanceof ZitadelError && error.hint ? ` ${error.hint}` : "";
4164
+ failures.push(`${handle}: ${error instanceof Error ? error.message : String(error)}.${hint}`);
4165
+ return;
4166
+ }
4167
+ const result = brandingConfigSchema.safeParse(descriptor);
4168
+ if (!result.success) for (const issue of result.error.issues) failures.push(`${handle}: ${issue.path.join(".") || "(root)"} ${issue.message}`);
4169
+ let template;
4170
+ try {
4171
+ template = readDescriptorTemplate(ctx.cwd, descriptor);
4172
+ } catch (error) {
4173
+ failures.push(`${handle}: ${error instanceof Error ? error.message : String(error)}`);
4174
+ }
4175
+ if (template !== void 0) {
4176
+ for (const issue of validateLoginTemplate(template)) if (issue.severity === "error") failures.push(`${handle}: template ${issue.rule}: ${issue.message}`);
4177
+ }
4178
+ });
4179
+ if (failures.length === 0) return;
4180
+ const noun = failures.length === 1 ? "issue" : "issues";
4181
+ throw new ZitadelError("E_VALIDATION", `Configuration validation failed (${failures.length} ${noun}):\n` + failures.map((failure) => ` - ${failure}`).join("\n"), {
4182
+ hint: "These are the same rules the server enforces. Fix the file(s) and re-run.",
4183
+ details: { issues: failures }
4184
+ });
4185
+ }
4186
+ /** The schema a flow's `user_schema` names, found by `objectType` in the bundle. */
4187
+ function schemaFor(ctx, flow) {
4188
+ const ref = flow.user_schema;
4189
+ if (typeof ref !== "string" || ref === "") return;
4190
+ return ctx.bundle.schemas.find((schema) => schema.objectType === ref);
4191
+ }
4192
+ /**
4193
+ * The semantic flow validation the server enforces when it builds the release:
4194
+ * errors aggregate into one `E_VALIDATION`, warnings are reported as they are
4195
+ * found. The schema a flow pins is resolved from the bundle so schema-aware
4196
+ * rules run.
4197
+ */
4198
+ function validateFlows(ctx) {
4199
+ const failures = [];
4200
+ for (const flow of ctx.bundle.flow_definitions) {
4201
+ const handle = typeof flow.name === "string" ? flow.name : "flow";
4202
+ for (const issue of validateFlowDefinition(flow, schemaFor(ctx, flow))) if (issue.severity === "error") failures.push({
4203
+ handle,
4204
+ issue
4205
+ });
4206
+ else reportWarning(`${handle}: ${issue.message}`);
4207
+ }
4208
+ if (failures.length === 0) return;
4209
+ const noun = failures.length === 1 ? "issue" : "issues";
4210
+ throw new ZitadelError("E_VALIDATION", `Flow validation failed (${failures.length} ${noun}):\n` + failures.map(({ handle, issue }) => ` - ${handle}: ${issue.message}`).join("\n"), {
4211
+ hint: "These are the same rules the server enforces. Fix the flow definition(s) and re-run.",
4212
+ details: { issues: failures.map(({ handle, issue }) => ({
4213
+ handle,
4214
+ rule: issue.rule,
4215
+ message: issue.message,
4216
+ ...issue.step === void 0 ? {} : { step: issue.step }
4217
+ })) }
4218
+ });
4219
+ }
4220
+ /**
4221
+ * Fail when a `${VAR}` or `*_env` reference names an environment variable the
4222
+ * running environment does not hold: the deploy would otherwise send the
4223
+ * placeholder literally, and the login page would never resolve it. The server
4224
+ * cannot check this — the references point at the deployer's environment.
4225
+ */
4226
+ /** The bundle's branding as a list, empty when it carries none. */
4227
+ function brandingsOf(ctx) {
4228
+ return ctx.bundle.branding === void 0 ? [] : [ctx.bundle.branding];
4229
+ }
4230
+ function assertEnvRefsResolve(ctx) {
4231
+ const missing = /* @__PURE__ */ new Set();
4232
+ for (const resource of [
4233
+ ...ctx.bundle.schemas,
4234
+ ...ctx.bundle.flow_definitions,
4235
+ ...brandingsOf(ctx)
4236
+ ]) for (const name of flowEnvRefs(resource)) if (!ctx.env[name]) missing.add(name);
4237
+ if (missing.size > 0) throw new ZitadelError("E_VALIDATION", `Missing environment variables: ${[...missing].sort().join(", ")}`);
4238
+ }
4239
+ //#endregion
4240
+ //#region src/lib/release/steps/diff.ts
4241
+ /**
4242
+ * Show what `.zitadel/` changed since the release the target serves now.
4243
+ *
4244
+ * It reads the live release's `git_sha` from its metadata and runs
4245
+ * `git diff <sha> -- .zitadel/` against the working tree. There is no diff
4246
+ * library and no server fallback: when git is missing, this is not a repo, the
4247
+ * commit is not in local history, or the live release carries no `git_sha`, it
4248
+ * prints one line saying no diff is available and carries on. Colour is
4249
+ * suppressed under `--no-color` and a non-terminal. Under `--json` nothing is
4250
+ * printed, so the envelope travels alone on stdout: the diff, split by file, or
4251
+ * the line saying why there is none, is reported as `diff` instead.
4252
+ */
4253
+ const diffStep = {
4254
+ name: "diff",
4255
+ runsFor: ["deploy", "preview"],
4256
+ async run(ctx) {
4257
+ const deployments = await listLiveDeployments(ctx.client, ctx.projectId, { withRelease: true });
4258
+ const live = deployments.find(servesPrimary)?.release?.metadata;
4259
+ const sha = live?.git_sha;
4260
+ if (!sha) return noDiff(ctx, null, "No diff available: nothing is deployed yet, or the live release has no commit.");
4261
+ if (live?.git_dirty) return noDiff(ctx, sha, `No reliable diff available: the live release was built from an uncommitted tree at ${sha.slice(0, 12)}.`);
4262
+ const releases = new Set(deployments.filter(servesPrimary).map((entry) => entry.release_id));
4263
+ const note = releases.size > 1 ? `The primary origins serve ${releases.size} different releases; the diff is against the newest, ${sha.slice(0, 12)}.` : void 0;
4264
+ const unknown = `No diff available: ${sha.slice(0, 12)} is not in this repository's history.`;
4265
+ const referenced = ctx.brandingSources.flatMap((descriptor) => brandingReferencePaths(ctx.cwd, descriptor));
4266
+ if (ctx.json) {
4267
+ const files = await diffConfigFilesAgainst(ctx.cwd, sha, referenced);
4268
+ if (files === null) return noDiff(ctx, sha, unknown);
4269
+ return { diff: {
4270
+ against: sha,
4271
+ files,
4272
+ ...note === void 0 ? {} : { note }
4273
+ } };
4274
+ }
4275
+ if (note !== void 0) process.stdout.write(`${note}\n`);
4276
+ const diff = await diffConfigAgainst(ctx.cwd, sha, ctx.color, referenced);
4277
+ if (diff === null) return noDiff(ctx, sha, unknown);
4278
+ const shown = diff.trim() === "" ? `No changes to .zitadel/ since ${sha.slice(0, 12)}.` : diff;
4279
+ process.stdout.write(shown.endsWith("\n") ? shown : `${shown}\n`);
4280
+ }
4281
+ };
4282
+ /** No diff to show: the line saying why, printed, or reported under `--json`. */
4283
+ function noDiff(ctx, against, note) {
4284
+ if (!ctx.json) {
4285
+ process.stdout.write(`${note}\n`);
4286
+ return;
4287
+ }
4288
+ return { diff: {
4289
+ against,
4290
+ files: null,
4291
+ note
4292
+ } };
4293
+ }
4294
+ //#endregion
4295
+ //#region src/lib/release/index.ts
4296
+ /**
4297
+ * The arguments of a suggested publish. `deploy` refuses to run without `-m` when
4298
+ * no one can be asked for a message, which is every agent and every `--json`
4299
+ * run, so a suggested deploy carries one for the reader to fill in.
4300
+ */
4301
+ const DEPLOY_ARGS = "deploy -m '<what changed>'";
4302
+ //#endregion
4303
+ //#region src/lib/box.ts
4304
+ /**
4305
+ * Frame overhead of a `consola.box` with `padding: 1`: two border columns,
4306
+ * the padding columns, and consola's left margin. Content wrapped to the
4307
+ * terminal width minus this stays inside the frame instead of soft-wrapping
4308
+ * under the right border.
4309
+ */
4310
+ const BOX_OVERHEAD = 8;
4311
+ const MIN_CONTENT_WIDTH = 20;
4312
+ const MAX_TERMINAL_WIDTH = 80;
4313
+ /** Bold cyan, for commands the user should run (URLs/paths stay plain cyan). */
4314
+ const command = (s) => pc.bold(pc.cyan(s));
4315
+ /**
4316
+ * Wraps box content to the terminal (capped at 80 columns) so consola's
4317
+ * frame, which sizes itself to the longest content line, never exceeds the
4318
+ * window. ANSI-aware, preserves hard line breaks and indentation, and never
4319
+ * splits a token: URLs and commands stay whole for clicking, copying, and the
4320
+ * journey e2e scrape (claim.spec.ts), even when such a token alone is wider
4321
+ * than a very narrow window.
4322
+ */
4323
+ function wrapForBox(message, columns = process.stdout.columns) {
4324
+ const width = Math.max(MIN_CONTENT_WIDTH, Math.min(columns ?? MAX_TERMINAL_WIDTH, MAX_TERMINAL_WIDTH) - BOX_OVERHEAD);
4325
+ return wrapAnsi(message, width, {
4326
+ hard: false,
4327
+ trim: false
4328
+ });
4329
+ }
4330
+ function renderBoxActions(actions) {
4331
+ return actions.map((action) => action.command ? `${action.text}\n\n ${command(action.command)}` : action.text).join("\n\n");
4332
+ }
4333
+ //#endregion
4334
+ //#region src/lib/journey-guidance.ts
4335
+ /**
4336
+ * Guidance copy that commands share, so the story doesn't fork; most of it is
4337
+ * the journey-staged copy of `setup` and `status`.
4338
+ *
4339
+ * The staging exists because of alpha tester feedback (Elina, alpha.15):
4340
+ * printing customize/publish steps before the user has logged in once reads
4341
+ * as noise, but dropping them entirely recreates the round-1 dead end where
4342
+ * nothing pointed to the next step. So the guidance tracks journey position —
4343
+ * verify first, customize/publish once login demonstrably works — and both
4344
+ * commands quote the same copy so the story doesn't fork.
4345
+ */
4346
+ /**
4347
+ * The verify mission: prove the scaffolded auth round-trips before touching
4348
+ * config. `openUrl` prefixes the browser destination for callers that have
4349
+ * not already narrated it (setup's box has a separate "Start your project"
4350
+ * line; status has only the issuer).
4351
+ */
4352
+ function verifyLoginAction(openUrl) {
4353
+ return `Verify auth in the browser: ${openUrl ? `open ${openUrl}, ` : ""}register a user, log out, log in again with the same user, and confirm /profile shows Signed in.`;
4354
+ }
4355
+ /**
4356
+ * Where to look when something fails against the local server, shared by
4357
+ * `start` and `doctor`. A sign-in step only says that a provider or a check
4358
+ * failed, because the login is public; the server logs why.
4359
+ */
4360
+ function serverLogAction(cliVersion) {
4361
+ return `When a sign-in or a request fails, the server's log says why: run ${publicCliCommand("logs --follow", cliVersion)} in another terminal to watch it.`;
4362
+ }
4363
+ /**
4364
+ * Where the list of every command is: the CLI run with no subcommand. Setup,
4365
+ * status and every command's help end with it, after their own next steps, so
4366
+ * the overview can be found without already knowing it exists. The command is on
4367
+ * a line of its own, with nothing around it, so it can be copied as written.
4368
+ */
4369
+ function exploreAllCommands(cliVersion) {
4370
+ return ["Explore all commands:", ` ${publicCliCommand("", cliVersion)}`].join("\n");
4371
+ }
4372
+ /**
4373
+ * The customize/publish pair, in Elina's tested wording. `selector` is the
4374
+ * run's environment selection, so the quoted commands publish to the project
4375
+ * the run was about.
4376
+ */
4377
+ function customizeAndPublishActions(cliVersion, selector) {
4378
+ return ["Customize what you ask for and how people sign in: edit .zitadel/schemas/ to change the information you collect, and .zitadel/flows/ to change the sign-up/sign-in experience. Each folder has a README with examples.", `See your changes before they go live: ${projectCommand("deploy --dry-run", selector, cliVersion)} to preview, then ${projectCommand(DEPLOY_ARGS, selector, cliVersion)} to publish. Nothing is released yet, so publishing is what pins this configuration rather than leaving the project on whatever its newest revisions happen to be.`];
4379
+ }
4380
+ /**
4381
+ * How to change the login's look after setup. Setup never ejects a template
4382
+ * or publishes a branding revision (#1039): the app embeds the maintained
4383
+ * `<zitadel-login>`, so its look is the app's own CSS (`--zl-*` tokens) and
4384
+ * layout around it. Taking ownership of the widget's structure stays the
4385
+ * opt-in `branding eject`, surfaced here so it remains discoverable.
4386
+ */
4387
+ function brandingGuidanceAction(cliVersion) {
4388
+ return `Change how the login looks: it is a component in your app, so theme it with \`--zl-*\` custom properties in your own stylesheet and build any page layout around it in your code. To change the widget's structure, ${publicCliCommand("branding eject", cliVersion)} scaffolds an editable template in .zitadel/branding/.`;
4389
+ }
4390
+ //#endregion
4391
+ //#region src/lib/skills-hint.ts
4392
+ const SHOWN_FILE = "skills-hint.json";
4393
+ const INSTALL_COMMAND = "npx skills add zitadel/nextgen --full-depth";
4394
+ /**
4395
+ * Tell a developer who has AI coding agents installed how to give them the
4396
+ * Zitadel skills, once per machine and after the command's own next steps.
4397
+ * The CLI never installs anything itself: it names the command and the
4398
+ * developer runs it. Modelled on Wrangler's skills suggestion, minus the prompt.
4399
+ *
4400
+ * The marker lives in the user's CLI config directory (`telemetryConfigDir`),
4401
+ * not in a project, so a second project on the same machine does not show it
4402
+ * again. It is written only once the note is about to be shown, and removed
4403
+ * again if showing it fails.
4404
+ *
4405
+ * Silent in CI, in non-interactive runs, when no agent is detected, once it has
4406
+ * been shown, and when `NO_SKILLS_PROMPT` is set. Fail-open: nothing here may
4407
+ * fail the command the user actually ran, and losing the race to create the marker
4408
+ * (`EEXIST`) just means another run is showing the note.
4409
+ */
4410
+ async function showSkillsHint({ env, interactive }) {
4411
+ try {
4412
+ if (!interactive || isCI(env) || isOptedOut(env)) return;
4413
+ const shownPath = join(telemetryConfigDir(env), SHOWN_FILE);
4414
+ if (existsSync(shownPath)) return;
4415
+ if (!(await rosie.agents()).some((agent) => agent.detected && agent.installPath !== null)) return;
4416
+ mkdirSync(dirname(shownPath), { recursive: true });
4417
+ writeFileSync(shownPath, `${JSON.stringify({ date: (/* @__PURE__ */ new Date()).toISOString() })}\n`, { flag: "wx" });
4418
+ try {
4419
+ consola$1.log([
4420
+ "",
4421
+ "Using an AI coding agent?",
4422
+ "Install Zitadel skills to help your agent work with Zitadel’s CLI and authentication integrations:",
4423
+ "",
4424
+ ` ${command(INSTALL_COMMAND)}`,
4425
+ "",
4426
+ "Optional. You can continue without installing skills."
4427
+ ].join("\n"));
4428
+ } catch (error) {
4429
+ unlinkSync(shownPath);
4430
+ throw error;
4431
+ }
4432
+ } catch {}
4433
+ }
4434
+ function isCI(env) {
4435
+ return Boolean(env.CI) && env.CI !== "0" && env.CI !== "false";
4436
+ }
4437
+ function isOptedOut(env) {
4438
+ const value = env.NO_SKILLS_PROMPT?.trim().toLowerCase();
4439
+ return value !== void 0 && value !== "" && value !== "0" && value !== "false";
4440
+ }
4441
+ //#endregion
4442
+ export { checkGivenCustomFlags as $, isPortableShellWord as $t, readJsonDirEntries as A, toVariableRow as At, selectSchema as B, installUserAgent as Bt, isPolicyTestFile as C, isErrno$1 as Cn, checkFlow as Ct, policyFileName as D, parseVariableValue as Dt, policyFileIssues as E, assertVariableName as Et, authMethods as F, variableReadScope as Ft, republishArgs as G, resolveConsent as Gt, publishClientId as H, hostAgent as Ht, enabledMethods as I, variableScope as It, askConnectionEndpoints as J, inShell as Jt, republishCommands as K, envEnabled as Kt, methodsOf as L, reportWarning as Lt, COMMAND_GROUPS as M, valuesOfKind as Mt, CommandGroups as N, variableCells as Nt, toPolicyWireBody as O, readStdin as Ot, compareInGroup as P, variableInventoryCells as Pt, SETUP_FLAG_NAMES as Q, unscopedCommand as Qt, readFlowFiles as R, takeReportedWarnings as Rt, POLICY_TEST_SUFFIX as S, ZitadelError as Sn, FLOWS_DIR as St, passwordPolicyFile as T, toZitadelError as Tn, VARIABLE_TYPES as Tt, reportClientIdOutcome as U, ciProvider as Ut, SCHEMAS_DIR as V, processUserAgentFacts as Vt, reportSecretOutcome as W, loadOrCreateIdentity as Wt, ENABLE_FLAG_NAMES as X, projectCommand as Xt, ENABLE_FLAG_KEYS as Y, portableSuggestions as Yt, SETUP_FLAG_KEYS as Z, shellCommand as Zt, servesPrimary as _, sanitizeRejection as _n, credentialVariablesOf as _t, serverLogAction as a, normalizePublicCliProse as an, loopbackIssuerWarning as at, PASSWORD_POLICY_PATH as b, listenForInterrupt as bn, refuseChangedIssuer as bt, wrapForBox as c, sdkVersionForCli as cn, applySsoToFlow as ct, validateStep as d, parseJsonObject as dn, removeSsoFromSchema as dt, isTrainVersion as en, customExample as et, headSha as f, setTopLevelJsonKey as fn, ssoEditRefusal as ft, rollbackDeployment as g, escapeControlCharacters as gn, checkCustomSlug as gt, listLiveDeployments as h, createZitadelClient$1 as hn, IDPS_DIR as ht, exploreAllCommands as i, normalizePublicCliJson as in, givenCustomFlags as it, BRANDING_DIR as j, toVariableRows as jt, readJsonDir as k, toInventoryRows as kt, DEPLOY_ARGS as l, collectPages as ln, applySsoToSchema as lt, createDeployment as m, REQUEST_TIMEOUT_MS as mn, CONNECTION_SCHEMA_REF as mt, brandingGuidanceAction as n, normalizePublicCliCommand as nn, customFlagValues as nt, verifyLoginAction as o, optionArgs as on, selectionFromFlags as ot, worktreeIsDirty as p, stableStringify as pn, ssoProvidersRefusal as pt, storeClientSecret as q, asEnvironmentSelector as qt, customizeAndPublishActions as r, normalizePublicCliCommands as rn, flagText as rt, renderBoxActions as s, publicCliCommand as sn, ssoEnableArgs as st, showSkillsHint as t, maskPublicCliVersion as tn, customFlagText as tt, diffStep as u, isObject$1 as un, removeSsoFromFlow as ut, buildBundle as v, sanitizeResponse as vn, planConnection as vt, parsePasswordMinLength as w, isMissingEndpoint as wn, offeredSignInMethods as wt, POLICIES_DIR as x, stopListeningForInterrupt as xn, storedCustomSettings as xt, PASSWORD_POLICY_OPERATION as y, interruptSignal as yn, readConnectionFiles as yt, readSchemaFiles as z, buildUserAgent as zt };
4443
+
4444
+ //# sourceMappingURL=skills-hint-N1H5-ny1.mjs.map