@zitadel/cli 0.1.0-alpha.17 → 0.1.0-alpha.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +210 -124
  2. package/SKILLS.md +159 -17
  3. package/dist/branding-cygSBPkV.mjs +79 -0
  4. package/dist/branding-cygSBPkV.mjs.map +1 -0
  5. package/dist/claim-state-DlfD6Res.mjs +66 -0
  6. package/dist/claim-state-DlfD6Res.mjs.map +1 -0
  7. package/dist/commands/apply.mjs +6 -5
  8. package/dist/commands/apply.mjs.map +1 -1
  9. package/dist/commands/branding/eject.mjs +104 -0
  10. package/dist/commands/branding/eject.mjs.map +1 -0
  11. package/dist/commands/claim.mjs +402 -0
  12. package/dist/commands/claim.mjs.map +1 -0
  13. package/dist/commands/doctor.mjs +500 -37
  14. package/dist/commands/doctor.mjs.map +1 -1
  15. package/dist/commands/eject.mjs +4 -4
  16. package/dist/commands/eject.mjs.map +1 -1
  17. package/dist/commands/logs.mjs +2 -2
  18. package/dist/commands/plan.mjs +6 -5
  19. package/dist/commands/plan.mjs.map +1 -1
  20. package/dist/commands/reset.mjs +2 -2
  21. package/dist/commands/schemas/list.mjs +11 -11
  22. package/dist/commands/schemas/list.mjs.map +1 -1
  23. package/dist/commands/setup.mjs +359 -132
  24. package/dist/commands/setup.mjs.map +1 -1
  25. package/dist/commands/start.mjs +3 -3
  26. package/dist/commands/status.mjs +21 -13
  27. package/dist/commands/status.mjs.map +1 -1
  28. package/dist/commands/stop.mjs +3 -3
  29. package/dist/commands/stop.mjs.map +1 -1
  30. package/dist/designs-Ckz18Dpo.mjs +38 -0
  31. package/dist/designs-Ckz18Dpo.mjs.map +1 -0
  32. package/dist/{docker-pGcTrDIY.mjs → docker-DcRGTOJa.mjs} +4 -3
  33. package/dist/docker-DcRGTOJa.mjs.map +1 -0
  34. package/dist/{environment-BQF7LeCz.mjs → environment-rjRVkJjW.mjs} +1 -1
  35. package/dist/{environment-BQF7LeCz.mjs.map → environment-rjRVkJjW.mjs.map} +1 -1
  36. package/dist/{journey-guidance-CYVpQszz.mjs → journey-guidance-BGrOX_gT.mjs} +14 -3
  37. package/dist/{journey-guidance-CYVpQszz.mjs.map → journey-guidance-BGrOX_gT.mjs.map} +1 -1
  38. package/dist/{oclif-BRdjVNne.mjs → oclif-CanO3zdt.mjs} +247 -43
  39. package/dist/oclif-CanO3zdt.mjs.map +1 -0
  40. package/dist/{orca-Dip6DPJ2.mjs → orca-Cu0iW1mv.mjs} +894 -196
  41. package/dist/orca-Cu0iW1mv.mjs.map +1 -0
  42. package/dist/package-manager-DFdLcDx1.mjs +194 -0
  43. package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
  44. package/dist/{ports-V8JfiNgT.mjs → ports-BM20XIZb.mjs} +2 -2
  45. package/dist/{ports-V8JfiNgT.mjs.map → ports-BM20XIZb.mjs.map} +1 -1
  46. package/dist/{processes-BVqYsxT8.mjs → processes-DKHPkU8O.mjs} +1 -1
  47. package/dist/{processes-BVqYsxT8.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
  48. package/dist/{project-BGgv7Bba.mjs → project-CHhot85s.mjs} +57 -4
  49. package/dist/project-CHhot85s.mjs.map +1 -0
  50. package/dist/{sync-Cd3z7Q1R.mjs → sync-Bd6vASgg.mjs} +549 -75
  51. package/dist/sync-Bd6vASgg.mjs.map +1 -0
  52. package/dist/user-schema-DTuOsdKE.mjs +91 -0
  53. package/dist/user-schema-DTuOsdKE.mjs.map +1 -0
  54. package/oclif.manifest.json +227 -5
  55. package/package.json +9 -6
  56. package/dist/docker-pGcTrDIY.mjs.map +0 -1
  57. package/dist/oclif-BRdjVNne.mjs.map +0 -1
  58. package/dist/orca-Dip6DPJ2.mjs.map +0 -1
  59. package/dist/project-BGgv7Bba.mjs.map +0 -1
  60. package/dist/sync-Cd3z7Q1R.mjs.map +0 -1
  61. package/dist/user-schema-DDz5-lX5.mjs +0 -13
  62. package/dist/user-schema-DDz5-lX5.mjs.map +0 -1
@@ -1,12 +1,18 @@
1
- import { D as ZitadelError, E as stableStringify, w as isObject } from "./oclif-BRdjVNne.mjs";
2
- import { t as SCHEMAS_DIR } from "./user-schema-DDz5-lX5.mjs";
1
+ import { E as ZitadelError, S as isObject, T as stableStringify } from "./oclif-CanO3zdt.mjs";
2
+ import { a as updateState, n as readState, r as removeFromState, t as SCHEMAS_DIR } from "./user-schema-DTuOsdKE.mjs";
3
+ import { a as toLocalBrandingBody, i as toBrandingWireBody, n as readDescriptorTemplate, r as resolveTemplatePath, t as BRANDING_DIR } from "./branding-cygSBPkV.mjs";
3
4
  import { readFile, readdir, writeFile } from "node:fs/promises";
4
- import { join } from "node:path";
5
+ import { basename, join } from "node:path";
5
6
  import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
6
7
  import { consola as consola$1 } from "consola";
7
8
  import { createHash } from "node:crypto";
9
+ import { writeFileSync } from "node:fs";
8
10
  import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
9
- import { flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
11
+ import { brandingConfigSchema, flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
12
+ import { validateLoginTemplate } from "@zitadel/config/template";
13
+ import { lookup } from "node:dns/promises";
14
+ import { BlockList, isIP } from "node:net";
15
+ import { Agent } from "undici";
10
16
  import { validateFlowDefinition } from "@zitadel/config/validate";
11
17
  //#region src/lib/flows/env-refs.ts
12
18
  /**
@@ -51,7 +57,11 @@ const FLOWS_DIR = ".zitadel/flows";
51
57
  * sync loop.
52
58
  */
53
59
  function makeSyncers(opts) {
54
- return [new SchemaSyncer(opts.client, opts.projectId, opts.env), new FlowDefinitionSyncer(opts.client, opts.projectId, opts.env)];
60
+ return [
61
+ new SchemaSyncer(opts.client, opts.projectId, opts.env),
62
+ new FlowDefinitionSyncer(opts.client, opts.projectId, opts.env),
63
+ new BrandingSyncer(opts.client, opts.projectId, opts.env, opts.cwd)
64
+ ];
55
65
  }
56
66
  /**
57
67
  * Assert that every env var a resource references — `${VAR}` placeholders and
@@ -117,7 +127,7 @@ var SchemaSyncer = class {
117
127
  throw new ZitadelError("E_NOT_IMPLEMENTED", `schema delete is not supported (${id})`);
118
128
  }
119
129
  async fetch(id) {
120
- return await this.client.getSchemaById(encodeURIComponent(id), { project_id: this.projectId });
130
+ return await this.client.getSchemaById(encodeURIComponent(id));
121
131
  }
122
132
  };
123
133
  var FlowDefinitionSyncer = class {
@@ -162,25 +172,392 @@ var FlowDefinitionSyncer = class {
162
172
  /**
163
173
  * PUT completely replaces the flow definition. The wire request wraps the
164
174
  * bare on-disk flow in the `{ flow_definition }` update envelope
165
- * (`api/openapi/components/flows/flow-definition-update-request.yaml`) and
166
- * carries `project_id` as a query parameter; the file on disk stays bare so
167
- * it is human-editable.
175
+ * (`api/openapi/components/flows/flow-definition-update-request.yaml`); the
176
+ * file on disk stays bare so it is human-editable. Flat-by-id: no
177
+ * `project_id` query — authz resolves the project from RSI.
168
178
  */
169
179
  async update(id, data) {
170
- return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data }, { project_id: this.projectId })).flow_definition };
180
+ return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data })).flow_definition };
171
181
  }
172
182
  async delete(id) {
173
- await this.client.deleteFlowDefinition(id, { project_id: this.projectId });
183
+ await this.client.deleteFlowDefinition(id);
174
184
  }
175
185
  /**
176
186
  * `GET /flow_definitions/:id` returns a detail envelope with metadata
177
187
  * (`id`, `project_id`, `created_at`, `updated_at`) plus `flow_definition`.
178
188
  * Return only `flow_definition` so diffs compare with the on-disk bare body.
189
+ * Flat-by-id: no `project_id` query — authz resolves the project from RSI.
179
190
  */
180
191
  async fetch(id) {
181
- return (await this.client.getFlowDefinition(id, { project_id: this.projectId })).flow_definition;
192
+ return (await this.client.getFlowDefinition(id)).flow_definition;
182
193
  }
183
194
  };
195
+ /**
196
+ * Branding revisions (ADR 040): schema-style immutable semantics — every
197
+ * edit publishes a new revision via `POST /branding`, no update or delete.
198
+ * Unlike schemas, nothing references branding revisions, so a revise never
199
+ * triggers re-pinning. The descriptor keeps the template in a sibling
200
+ * `.liquid` file (`liquid_template_file`); this syncer inlines it for
201
+ * hashing and upload and splits it back out on write-back.
202
+ */
203
+ var BrandingSyncer = class {
204
+ kind = "branding";
205
+ directory = BRANDING_DIR;
206
+ mutable = false;
207
+ revisioned = true;
208
+ /** One project, one branding descriptor — extra .json files fail the scan. */
209
+ singletonFile = "branding.json";
210
+ constructor(client, projectId, env, cwd) {
211
+ this.client = client;
212
+ this.projectId = projectId;
213
+ this.env = env;
214
+ this.cwd = cwd;
215
+ }
216
+ /**
217
+ * The comparison form is the wire body with the template inlined, so an
218
+ * edit to the referenced `.liquid` file changes the state hash and plans
219
+ * a `revise` even though the descriptor JSON is untouched.
220
+ */
221
+ normalize = (data) => toBrandingWireBody(this.cwd, data);
222
+ /**
223
+ * Zod shape + env refs, then the authoritative template validation from
224
+ * `@zitadel/config/template` — the LiquidJS-dialect check the Go server
225
+ * cannot run (its save gate is lexical; see ADR 040).
226
+ */
227
+ validate(data) {
228
+ const result = brandingConfigSchema.safeParse(data);
229
+ if (!result.success) throw new ZitadelError("E_VALIDATION", "Branding file is not a valid branding descriptor", { details: { issues: result.error.issues } });
230
+ assertEnvRefs(data, this.env);
231
+ const template = readDescriptorTemplate(this.cwd, data);
232
+ if (template === void 0) return;
233
+ const issues = validateLoginTemplate(template).filter((issue) => issue.severity === "error");
234
+ if (issues.length > 0) throw new ZitadelError("E_VALIDATION", "Login template failed validation", {
235
+ details: { issues },
236
+ hint: "Fix the template issues; the rules live in docs/design/flowengine/template-security.md."
237
+ });
238
+ }
239
+ /**
240
+ * `POST /branding` publishes a new immutable revision. The canonical body
241
+ * is converted back to descriptor form: the stored template is written to
242
+ * the referenced `.liquid` file (when it differs) and the JSON keeps the
243
+ * file reference, so `writeBackResource` stays pure-JSON.
244
+ */
245
+ async create(data) {
246
+ const wire = toBrandingWireBody(this.cwd, data);
247
+ const result = await this.client.createBranding(wire, { project_id: this.projectId });
248
+ return {
249
+ id: result.id,
250
+ canonical: this.canonicalToLocal(result.branding, data)
251
+ };
252
+ }
253
+ async update(_id, _data) {
254
+ throw new ZitadelError("E_NOT_IMPLEMENTED", "branding is revisioned — edit publishes a new revision, not an update");
255
+ }
256
+ async delete(id) {
257
+ throw new ZitadelError("E_NOT_IMPLEMENTED", `branding delete is not supported (${id})`);
258
+ }
259
+ /** Wire form (template inlined); diffs compare in the normalized form. Flat-by-id: no project_id query. */
260
+ async fetch(id) {
261
+ return (await this.client.getBrandingById(id)).branding;
262
+ }
263
+ canonicalToLocal(canonicalWire, localData) {
264
+ const local = localData;
265
+ const template = canonicalWire.liquid_template;
266
+ if (typeof local.liquid_template_file === "string" && typeof template === "string") {
267
+ const path = resolveTemplatePath(this.cwd, local.liquid_template_file);
268
+ if (readDescriptorTemplate(this.cwd, localData) !== template) {
269
+ writeFileSync(path, template);
270
+ consola$1.info(`Updated ${local.liquid_template_file} from the server's canonical response`);
271
+ }
272
+ }
273
+ return toLocalBrandingBody(canonicalWire, localData);
274
+ }
275
+ };
276
+ //#endregion
277
+ //#region src/lib/sync/asset-probe.ts
278
+ /**
279
+ * Branding descriptor fields whose value is a URL the login page fetches as
280
+ * an image. Both are optional and both fail the same invisible way when the
281
+ * URL is well-formed but dead.
282
+ */
283
+ const ASSET_FIELDS = ["logo_url", "hero_url"];
284
+ /**
285
+ * Per-URL probe budget. Short on purpose: this runs on the critical path of
286
+ * every `plan` and `apply`, and a dead host that never answers must cost a
287
+ * couple of seconds, not a hung command.
288
+ */
289
+ const DEFAULT_TIMEOUT_MS = 2500;
290
+ /** Keep redirect chains bounded and re-validate every destination ourselves. */
291
+ const MAX_REDIRECTS = 5;
292
+ /**
293
+ * Addresses an untrusted descriptor must never make the planning host contact.
294
+ * The browser-side render gate still supports canonical loopback HTTP for local
295
+ * development; the CLI probe deliberately leaves those URLs inconclusive
296
+ * instead of turning `plan` over repo content into a localhost/network scanner.
297
+ */
298
+ const UNSAFE_IPV4_ADDRESSES = new BlockList();
299
+ for (const [network, prefix] of [
300
+ ["0.0.0.0", 8],
301
+ ["10.0.0.0", 8],
302
+ ["100.64.0.0", 10],
303
+ ["127.0.0.0", 8],
304
+ ["169.254.0.0", 16],
305
+ ["172.16.0.0", 12],
306
+ ["192.0.0.0", 24],
307
+ ["192.0.2.0", 24],
308
+ ["192.88.99.0", 24],
309
+ ["192.168.0.0", 16],
310
+ ["198.18.0.0", 15],
311
+ ["198.51.100.0", 24],
312
+ ["203.0.113.0", 24],
313
+ ["224.0.0.0", 4],
314
+ ["240.0.0.0", 4]
315
+ ]) UNSAFE_IPV4_ADDRESSES.addSubnet(network, prefix, "ipv4");
316
+ const UNSAFE_IPV6_ADDRESSES = new BlockList();
317
+ for (const [network, prefix] of [
318
+ ["::", 3],
319
+ ["4000::", 2],
320
+ ["8000::", 1],
321
+ ["2001::", 23],
322
+ ["2001:db8::", 32],
323
+ ["2002::", 16],
324
+ ["3fff::", 20]
325
+ ]) UNSAFE_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
326
+ /** Content types that are images despite not being `image/*`. */
327
+ const EXTRA_IMAGE_TYPES = new Set(["application/octet-stream"]);
328
+ /**
329
+ * Annotate every branding action in a plan with warnings for asset URLs that
330
+ * are well-formed but will not render: an unreachable host, a non-2xx status,
331
+ * or a response that is not an image.
332
+ *
333
+ * Why the CLI probes at all: a bad `logo_url` clears every gate on the way in
334
+ * (the CLI's Zod shape check, the server's save gate) and then fails silently
335
+ * in the browser as a 0×0 `<img>` — no plan output, no apply output, no
336
+ * console error. The probe is the only place in the pipeline that can see it.
337
+ *
338
+ * Deliberately non-fatal and best-effort. A developer on a plane, behind a
339
+ * proxy, or pointing at a CDN that only resolves from production gets a
340
+ * warning, never a failed plan — the machine running `plan` is not
341
+ * necessarily the machine that renders the login page. Set
342
+ * `ZITADEL_SKIP_ASSET_PROBE` to turn it off entirely, and
343
+ * `ZITADEL_ASSET_PROBE_TIMEOUT_MS` to retune the per-URL budget. Only public
344
+ * HTTPS destinations are contacted: loopback/private/internal targets and
345
+ * redirects to them stay inconclusive so repo config cannot scan the planning
346
+ * host's network.
347
+ *
348
+ * Mutates `actions` in place, like {@link validatePlannedFlows} — the caller
349
+ * (`buildSyncPlan`) owns the array it just built.
350
+ */
351
+ async function annotateAssetWarnings(actions) {
352
+ if (process.env.ZITADEL_SKIP_ASSET_PROBE) {
353
+ consola$1.debug("Branding asset probe skipped (ZITADEL_SKIP_ASSET_PROBE is set)");
354
+ return;
355
+ }
356
+ const targets = actions.filter((action) => (action.kind === "create" || action.kind === "update" || action.kind === "revise") && action.syncer.kind === "branding");
357
+ if (targets.length === 0) return;
358
+ const urls = /* @__PURE__ */ new Set();
359
+ for (const action of targets) for (const { url } of assetUrlsOf(action.content)) urls.add(url);
360
+ if (urls.size === 0) return;
361
+ const timeoutMs = probeTimeoutMs();
362
+ const verdicts = new Map(await Promise.all([...urls].map(async (url) => [url, await probeAsset(url, timeoutMs)])));
363
+ for (const action of targets) {
364
+ const warnings = [];
365
+ for (const { field, url } of assetUrlsOf(action.content)) {
366
+ const warning = describeVerdict(field, url, verdicts.get(url) ?? { kind: "inconclusive" });
367
+ if (warning) warnings.push(warning);
368
+ }
369
+ if (warnings.length > 0) action.warnings = [...action.warnings ?? [], ...warnings];
370
+ }
371
+ }
372
+ /**
373
+ * The probe itself: a HEAD request bounded by an abort timeout. HEAD keeps a
374
+ * multi-megabyte hero image off the wire — we only need the status line and
375
+ * the content type.
376
+ */
377
+ async function probeAsset(url, timeoutMs) {
378
+ let current;
379
+ try {
380
+ current = new URL(url);
381
+ } catch {
382
+ return { kind: "inconclusive" };
383
+ }
384
+ const signal = AbortSignal.timeout(timeoutMs);
385
+ try {
386
+ for (let redirects = 0;; redirects += 1) {
387
+ if (!isSafeProbeUrl(current)) {
388
+ consola$1.debug(`asset probe ${url} skipped unsafe/private target: ${current.href}`);
389
+ return { kind: "inconclusive" };
390
+ }
391
+ const response = await requestHead(current, signal);
392
+ if (isRedirect(response.status)) {
393
+ const location = response.location;
394
+ if (!location) return {
395
+ kind: "status",
396
+ status: response.status
397
+ };
398
+ if (redirects >= MAX_REDIRECTS) return {
399
+ kind: "unreachable",
400
+ detail: `more than ${MAX_REDIRECTS} redirects`
401
+ };
402
+ current = new URL(location, current);
403
+ continue;
404
+ }
405
+ if (response.status === 405 || response.status === 501) return { kind: "inconclusive" };
406
+ if (response.status < 200 || response.status >= 300) return {
407
+ kind: "status",
408
+ status: response.status
409
+ };
410
+ if (response.status === 204 || response.status === 205) return {
411
+ kind: "empty",
412
+ status: response.status
413
+ };
414
+ const contentType = response.contentType?.split(";")[0]?.trim().toLowerCase();
415
+ if (!contentType) return { kind: "ok" };
416
+ if (contentType.startsWith("image/") || EXTRA_IMAGE_TYPES.has(contentType)) return { kind: "ok" };
417
+ return {
418
+ kind: "content-type",
419
+ contentType
420
+ };
421
+ }
422
+ } catch (error) {
423
+ if (isUnsafeProbeTargetError(error)) {
424
+ consola$1.debug(`asset probe ${url} skipped unsafe/private target`);
425
+ return { kind: "inconclusive" };
426
+ }
427
+ consola$1.debug(`asset probe ${url} failed:`, error);
428
+ return {
429
+ kind: "unreachable",
430
+ detail: describeProbeError(error, timeoutMs)
431
+ };
432
+ }
433
+ }
434
+ function isRedirect(status) {
435
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
436
+ }
437
+ /**
438
+ * Reject targets whose URL alone proves they are unsafe. Hostname resolution
439
+ * happens inside the connector lookup below so validation and connection use
440
+ * the same DNS answer, without a rebinding window.
441
+ */
442
+ function isSafeProbeUrl(url) {
443
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "") return false;
444
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
445
+ if (hostname === "localhost" || !hostname.includes(".") || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".lan") || hostname.endsWith(".home.arpa")) return false;
446
+ const literalFamily = isIP(hostname);
447
+ return literalFamily === 0 || !isUnsafeAddress(hostname, literalFamily);
448
+ }
449
+ /** Resolve once and accept the answer only when every address is public. */
450
+ async function resolvePublicAddresses(hostname) {
451
+ const addresses = await lookup(hostname, {
452
+ all: true,
453
+ verbatim: true
454
+ });
455
+ return addresses.length > 0 && addresses.every(({ address, family }) => !isUnsafeAddress(address, family)) ? addresses : void 0;
456
+ }
457
+ function isUnsafeAddress(address, family) {
458
+ return family === 6 ? UNSAFE_IPV6_ADDRESSES.check(address, "ipv6") : UNSAFE_IPV4_ADDRESSES.check(address, "ipv4");
459
+ }
460
+ const UNSAFE_PROBE_TARGET_CODE = "ERR_ZITADEL_UNSAFE_ASSET_TARGET";
461
+ function unsafeProbeTargetError(hostname) {
462
+ return Object.assign(/* @__PURE__ */ new Error(`asset target ${hostname} resolves to a non-public address`), { code: UNSAFE_PROBE_TARGET_CODE });
463
+ }
464
+ /**
465
+ * Validate DNS in the connector's own lookup and return only approved
466
+ * addresses. The socket cannot perform a second lookup, which closes the DNS
467
+ * rebinding gap while preserving the hostname for TLS SNI/certificate checks.
468
+ */
469
+ async function requestHead(url, signal) {
470
+ const safeLookup = (hostname, options, callback) => {
471
+ resolvePublicAddresses(hostname).then((addresses) => {
472
+ if (!addresses) {
473
+ callback(unsafeProbeTargetError(hostname), "", 0);
474
+ return;
475
+ }
476
+ const candidates = options.family === 4 || options.family === 6 ? addresses.filter(({ family }) => family === options.family) : addresses;
477
+ const candidate = candidates[0];
478
+ if (!candidate) {
479
+ callback(unsafeProbeTargetError(hostname), "", 0);
480
+ return;
481
+ }
482
+ if (options.all) {
483
+ callback(null, candidates);
484
+ return;
485
+ }
486
+ callback(null, candidate.address, candidate.family);
487
+ }, (error) => callback(error, "", 0));
488
+ };
489
+ const dispatcher = new Agent({ connect: { lookup: safeLookup } });
490
+ try {
491
+ const response = await fetch(url, {
492
+ method: "HEAD",
493
+ redirect: "manual",
494
+ signal,
495
+ dispatcher
496
+ });
497
+ return {
498
+ status: response.status,
499
+ location: response.headers.get("location") ?? void 0,
500
+ contentType: response.headers.get("content-type") ?? void 0
501
+ };
502
+ } finally {
503
+ await dispatcher.destroy();
504
+ }
505
+ }
506
+ function isUnsafeProbeTargetError(error) {
507
+ let current = error;
508
+ for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
509
+ if (current.code === UNSAFE_PROBE_TARGET_CODE) return true;
510
+ current = current.cause;
511
+ }
512
+ return false;
513
+ }
514
+ function describeVerdict(field, url, verdict) {
515
+ switch (verdict.kind) {
516
+ case "ok":
517
+ case "inconclusive": return;
518
+ case "status": return {
519
+ rule: "warn/asset-unreachable",
520
+ message: `${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.`
521
+ };
522
+ case "empty": return {
523
+ rule: "warn/asset-unreachable",
524
+ message: `${field} ${url} returned HTTP ${verdict.status} with no representation — the login page has no image bytes to render.`
525
+ };
526
+ case "unreachable": return {
527
+ rule: "warn/asset-unreachable",
528
+ message: `${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.`
529
+ };
530
+ case "content-type": return {
531
+ rule: "warn/asset-content-type",
532
+ message: `${field} ${url} responded with content-type "${verdict.contentType}", not an image — the login page renders a non-image response as a 0×0 image.`
533
+ };
534
+ }
535
+ }
536
+ /** The `logo_url` / `hero_url` values a descriptor actually sets. */
537
+ function assetUrlsOf(content) {
538
+ const body = content;
539
+ const out = [];
540
+ for (const field of ASSET_FIELDS) {
541
+ const value = body[field];
542
+ if (typeof value === "string" && value !== "") out.push({
543
+ field,
544
+ url: value
545
+ });
546
+ }
547
+ return out;
548
+ }
549
+ function probeTimeoutMs() {
550
+ const raw = Number(process.env.ZITADEL_ASSET_PROBE_TIMEOUT_MS);
551
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
552
+ }
553
+ function describeProbeError(error, timeoutMs) {
554
+ if (error instanceof Error && (error.name === "TimeoutError" || error.cause instanceof Error && error.cause.name === "TimeoutError")) return `no response within ${timeoutMs}ms`;
555
+ if (error instanceof Error) {
556
+ const cause = error.cause;
557
+ return (cause instanceof Error ? cause.message : error.message) || error.name;
558
+ }
559
+ return String(error);
560
+ }
184
561
  //#endregion
185
562
  //#region src/lib/sync/flow-validation.ts
186
563
  /**
@@ -290,49 +667,6 @@ function resolveSchemaContent(action, scannedContents, stateResources) {
290
667
  consola$1.debug(`flow validation: no local schema for user_schema ${ref}; flow-local rules only`);
291
668
  }
292
669
  //#endregion
293
- //#region src/lib/sync/state.ts
294
- /**
295
- * Read and parse `.zitadel/state.json`. Throws if the file is
296
- * missing or malformed; callers run `zitadel setup` first to bring
297
- * the file into existence.
298
- */
299
- async function readState(cwd) {
300
- const raw = await readFile(join(cwd, ".zitadel/state.json"), "utf8");
301
- return JSON.parse(raw);
302
- }
303
- /**
304
- * Merge an entry into the state file under `key`, preserving any
305
- * fields the caller did not override. Reads the file, writes it back
306
- * with sorted keys disabled (state is engine-managed, not human-
307
- * authored, so deterministic ordering isn't required here).
308
- */
309
- async function updateState(cwd, key, entry) {
310
- const current = await readState(cwd);
311
- const updated = {
312
- ...current,
313
- resources: {
314
- ...current.resources,
315
- [key]: {
316
- ...current.resources[key],
317
- ...entry
318
- }
319
- }
320
- };
321
- await writeFile(join(cwd, ".zitadel/state.json"), JSON.stringify(updated, null, 2));
322
- }
323
- /**
324
- * Remove an entry from the state file. No-op if the key is absent.
325
- */
326
- async function removeFromState(cwd, key) {
327
- const current = await readState(cwd);
328
- const { [key]: _removed, ...rest } = current.resources;
329
- const updated = {
330
- ...current,
331
- resources: rest
332
- };
333
- await writeFile(join(cwd, ".zitadel/state.json"), JSON.stringify(updated, null, 2));
334
- }
335
- //#endregion
336
670
  //#region src/lib/sync/loop.ts
337
671
  /**
338
672
  * Compute the sync plan for `cwd` against the state file and (when
@@ -369,6 +703,10 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
369
703
  const dirPath = join(cwd, syncer.directory);
370
704
  consola$1.debug(`scanning ${syncer.directory}`);
371
705
  const onDisk = await readJsonDir(dirPath);
706
+ if (syncer.singletonFile) for (const absPath of onDisk.keys()) {
707
+ const name = basename(absPath);
708
+ if (name !== syncer.singletonFile) throw new ZitadelError("E_VALIDATION", `unexpected file in ${syncer.directory}: ${name}`, { hint: `${syncer.kind} is a singleton resource — keep exactly one descriptor at ${syncer.directory}/${syncer.singletonFile} and move other .json files out of the directory (each scanned descriptor would publish as its own revision).` });
709
+ }
372
710
  for (const content of onDisk.values()) syncer.validate(content);
373
711
  for (const [filePath, entry] of Object.entries(state.resources)) {
374
712
  if (!filePath.startsWith(syncer.directory)) continue;
@@ -463,6 +801,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
463
801
  scannedContents,
464
802
  stateResources: state.resources
465
803
  });
804
+ await annotateAssetWarnings(actions);
466
805
  return actions;
467
806
  }
468
807
  /**
@@ -482,7 +821,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
482
821
  */
483
822
  async function runSyncLoop(cwd, syncers) {
484
823
  const actions = await buildSyncPlan(cwd, syncers);
485
- for (const action of actions) if (action.kind === "create" || action.kind === "update") for (const warning of action.warnings ?? []) consola$1.warn(`${action.path}: ${warning.message}`);
824
+ for (const action of actions) if (action.kind === "create" || action.kind === "update" || action.kind === "revise") for (const warning of action.warnings ?? []) consola$1.warn(`${action.path}: ${warning.message}`);
486
825
  const filesUpdated = [];
487
826
  const applied = [];
488
827
  const repinned = /* @__PURE__ */ new Map();
@@ -743,17 +1082,21 @@ function summarizePlan(actions) {
743
1082
  */
744
1083
  function collectPlanWarnings(actions) {
745
1084
  const out = [];
746
- for (const action of actions) {
747
- if (action.kind !== "create" && action.kind !== "update") continue;
748
- for (const warning of action.warnings ?? []) out.push({
749
- path: action.path,
750
- rule: warning.rule,
751
- message: warning.message
752
- });
753
- }
1085
+ for (const action of actions) for (const warning of warningsOf(action)) out.push({
1086
+ path: action.path,
1087
+ rule: warning.rule,
1088
+ message: warning.message
1089
+ });
754
1090
  return out;
755
1091
  }
756
1092
  /**
1093
+ * The warnings an action carries. Only the three kinds that upload a body can
1094
+ * have any: a `delete` has no content to judge, and a `skip` was never judged.
1095
+ */
1096
+ function warningsOf(action) {
1097
+ return action.kind === "create" || action.kind === "update" || action.kind === "revise" ? action.warnings ?? [] : [];
1098
+ }
1099
+ /**
757
1100
  * Enumerate the non-`skip` actions of a {@link buildSyncPlan} result as
758
1101
  * envelope-ready {@link PlanResourceChange} rows. Pure; the structural
759
1102
  * counterpart of {@link summarizePlan} — counts and rows always agree.
@@ -822,7 +1165,7 @@ function renderPlan(actions, tty) {
822
1165
  if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
823
1166
  if (deletes > 0) parts.push(`${deletes} to destroy`);
824
1167
  out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
825
- const warningCount = active.reduce((count, action) => count + ((action.kind === "create" || action.kind === "update") && action.warnings ? action.warnings.length : 0), 0);
1168
+ const warningCount = active.reduce((count, action) => count + warningsOf(action).length, 0);
826
1169
  if (warningCount > 0) out.push(paint(`Warnings: ${warningCount} (non-blocking — see the # warning lines above).`, A.yellow, tty));
827
1170
  return out.join("\n");
828
1171
  }
@@ -852,6 +1195,37 @@ function fmtPrimitive(v) {
852
1195
  if (typeof v === "string") return `"${escapeString(v)}"`;
853
1196
  return String(v);
854
1197
  }
1198
+ /**
1199
+ * A multi-line string is a document, not a scalar: branding inlines a whole
1200
+ * `login.liquid` into `liquid_template`, and escaping 200 lines onto one
1201
+ * `"…\n…\n…"` line buries every real change in the same block. Such values
1202
+ * render as a summary (and, when they changed, as a line diff) instead.
1203
+ */
1204
+ function isBlockString(v) {
1205
+ return typeof v === "string" && v !== KNOWN_AFTER_APPLY && v.includes("\n");
1206
+ }
1207
+ /** Trailing-newline-insensitive line count: `"a\nb\n"` is two lines, not three. */
1208
+ function lineCount(value) {
1209
+ const lines = value.split("\n");
1210
+ return lines.length > 1 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
1211
+ }
1212
+ /** Short content fingerprint, so two summarised blocks can be told apart. */
1213
+ function shortHash(value) {
1214
+ return createHash("sha256").update(value).digest("hex").slice(0, 8);
1215
+ }
1216
+ function plural(count, noun) {
1217
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
1218
+ }
1219
+ function blockSummary(value) {
1220
+ return `(${plural(lineCount(value), "line")}, sha256:${shortHash(value)})`;
1221
+ }
1222
+ function unchangedBlockSummary(value) {
1223
+ return `(unchanged, ${plural(lineCount(value), "line")}, sha256:${shortHash(value)})`;
1224
+ }
1225
+ /** {@link fmtPrimitive}, with multi-line strings summarised. */
1226
+ function fmtScalar(v) {
1227
+ return isBlockString(v) ? blockSummary(v) : fmtPrimitive(v);
1228
+ }
855
1229
  function prefixAnsi(p) {
856
1230
  if (p === "+") return A.green;
857
1231
  if (p === "-") return A.red;
@@ -868,7 +1242,7 @@ function renderFields(obj, prefix, prefixCol, ctx, lines) {
868
1242
  const val = obj[key];
869
1243
  const pk = key.padEnd(maxLen);
870
1244
  if (isPrimitive(val)) {
871
- const formatted = fmtPrimitive(val);
1245
+ const formatted = fmtScalar(val);
872
1246
  const suffix = ctx.deleteMode ? " -> null" : "";
873
1247
  lines.push(col(`${pad}${prefix} ${pk} = ${formatted}${suffix}`));
874
1248
  } else if (Array.isArray(val)) if (val.length === 0) lines.push(col(`${pad}${prefix} ${pk} = []`));
@@ -896,7 +1270,7 @@ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
896
1270
  const ansi = prefixAnsi(prefix);
897
1271
  const col = (s) => paint(s, ansi, ctx.tty);
898
1272
  for (const item of arr) if (isPrimitive(item)) {
899
- const formatted = fmtPrimitive(item);
1273
+ const formatted = fmtScalar(item);
900
1274
  lines.push(col(`${pad}${prefix} ${formatted},`));
901
1275
  } else if (Array.isArray(item)) if (item.length === 0) lines.push(col(`${pad}${prefix} [],`));
902
1276
  else {
@@ -911,6 +1285,102 @@ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
911
1285
  lines.push(col(`${" ".repeat(prefixCol + 2)}},`));
912
1286
  }
913
1287
  }
1288
+ /** Changed-line budget for one block-string diff, before the "more" trailer. */
1289
+ const MAX_BLOCK_DIFF_LINES = 20;
1290
+ /**
1291
+ * LCS cell budget (500 × 500 changed lines). Above it the middle section
1292
+ * renders as one remove/add block instead — quadratic DP over a pathological
1293
+ * template is not worth the memory, and a whole-block replace is still an
1294
+ * honest rendering. The shipped designs are under 200 lines *before* the
1295
+ * prefix/suffix trim, so real templates never come close.
1296
+ */
1297
+ const MAX_LCS_CELLS = 25e4;
1298
+ function asLines(v) {
1299
+ return typeof v === "string" ? v.split("\n") : [fmtPrimitive(v)];
1300
+ }
1301
+ /**
1302
+ * Line-level diff of two block strings. Common prefix and suffix are trimmed
1303
+ * first — a template edit touches one region, so this alone usually reduces
1304
+ * the problem to a handful of lines — and the remaining middle goes through
1305
+ * an LCS pass (or, past {@link MAX_LCS_CELLS}, renders as a wholesale
1306
+ * replacement).
1307
+ */
1308
+ function diffLines(oldLines, newLines) {
1309
+ let start = 0;
1310
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) start += 1;
1311
+ let endOld = oldLines.length;
1312
+ let endNew = newLines.length;
1313
+ while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) {
1314
+ endOld -= 1;
1315
+ endNew -= 1;
1316
+ }
1317
+ const midOld = oldLines.slice(start, endOld);
1318
+ const midNew = newLines.slice(start, endNew);
1319
+ if (midOld.length * midNew.length > MAX_LCS_CELLS) return [...midOld.map((line) => ({
1320
+ kind: "del",
1321
+ line
1322
+ })), ...midNew.map((line) => ({
1323
+ kind: "add",
1324
+ line
1325
+ }))];
1326
+ return lcsDiff(midOld, midNew);
1327
+ }
1328
+ /** Classic LCS-length DP, walked back into an op list. */
1329
+ function lcsDiff(a, b) {
1330
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
1331
+ for (let i = a.length - 1; i >= 0; i -= 1) for (let j = b.length - 1; j >= 0; j -= 1) table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
1332
+ const ops = [];
1333
+ let i = 0;
1334
+ let j = 0;
1335
+ while (i < a.length && j < b.length) if (a[i] === b[j]) {
1336
+ ops.push({
1337
+ kind: "same",
1338
+ line: a[i]
1339
+ });
1340
+ i += 1;
1341
+ j += 1;
1342
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
1343
+ ops.push({
1344
+ kind: "del",
1345
+ line: a[i]
1346
+ });
1347
+ i += 1;
1348
+ } else {
1349
+ ops.push({
1350
+ kind: "add",
1351
+ line: b[j]
1352
+ });
1353
+ j += 1;
1354
+ }
1355
+ for (; i < a.length; i += 1) ops.push({
1356
+ kind: "del",
1357
+ line: a[i]
1358
+ });
1359
+ for (; j < b.length; j += 1) ops.push({
1360
+ kind: "add",
1361
+ line: b[j]
1362
+ });
1363
+ return ops;
1364
+ }
1365
+ /**
1366
+ * Render a changed block string as a header plus its changed lines. Context
1367
+ * lines are deliberately omitted: the file is on disk, and the plan's job is
1368
+ * to name what moved, not to reproduce the template.
1369
+ */
1370
+ function renderBlockStringDiff(paddedKey, oldVal, newVal, prefixCol, tty, lines) {
1371
+ const pad = " ".repeat(prefixCol);
1372
+ const bodyPad = " ".repeat(prefixCol + 4);
1373
+ const changed = diffLines(asLines(oldVal), asLines(newVal)).filter((op) => op.kind !== "same");
1374
+ const total = typeof newVal === "string" ? lineCount(newVal) : 1;
1375
+ const fingerprints = typeof oldVal === "string" && typeof newVal === "string" ? `, sha256:${shortHash(oldVal)} -> sha256:${shortHash(newVal)}` : "";
1376
+ lines.push(paint(`${pad}~ ${paddedKey} = (${plural(changed.length, "line")} changed of ${total}${fingerprints})`, A.yellow, tty));
1377
+ for (const op of changed.slice(0, MAX_BLOCK_DIFF_LINES)) {
1378
+ const del = op.kind === "del";
1379
+ lines.push(paint(`${bodyPad}${del ? "-" : "+"} ${op.line}`, del ? A.red : A.green, tty));
1380
+ }
1381
+ const omitted = changed.length - MAX_BLOCK_DIFF_LINES;
1382
+ if (omitted > 0) lines.push(`${bodyPad} # (${plural(omitted, "more changed line")} not shown)`);
1383
+ }
914
1384
  /**
915
1385
  * Walks both old and new objects, emitting Terraform-style change lines.
916
1386
  * Returns true if any actual change line (+ / - / ~) was emitted.
@@ -936,7 +1406,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
936
1406
  if (!hasOld) {
937
1407
  hasChanges = true;
938
1408
  const col = (s) => paint(s, A.green, tty);
939
- if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtPrimitive(newVal)}`));
1409
+ if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
940
1410
  else if (Array.isArray(newVal)) {
941
1411
  lines.push(col(`${pad}+ ${pk} = [`));
942
1412
  renderArrayItems(newVal, "+", prefixCol + 4, {
@@ -955,7 +1425,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
955
1425
  } else if (!hasNew) {
956
1426
  hasChanges = true;
957
1427
  const col = (s) => paint(s, A.red, tty);
958
- if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtPrimitive(oldVal)} -> null`));
1428
+ if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
959
1429
  else if (Array.isArray(oldVal)) {
960
1430
  lines.push(col(`${pad}- ${pk} = [`));
961
1431
  renderArrayItems(oldVal, "-", prefixCol + 4, {
@@ -971,8 +1441,11 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
971
1441
  }, lines);
972
1442
  lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
973
1443
  }
974
- } else if (isPrimitive(oldVal) && isPrimitive(newVal)) if (oldVal === newVal) lines.push(`${pad} ${pk} = ${fmtPrimitive(newVal)}`);
975
- else {
1444
+ } else if (isPrimitive(oldVal) && isPrimitive(newVal)) if (oldVal === newVal) lines.push(isBlockString(newVal) ? `${pad} ${pk} = ${unchangedBlockSummary(newVal)}` : `${pad} ${pk} = ${fmtPrimitive(newVal)}`);
1445
+ else if (isBlockString(oldVal) || isBlockString(newVal)) {
1446
+ hasChanges = true;
1447
+ renderBlockStringDiff(pk, oldVal, newVal, prefixCol, tty, lines);
1448
+ } else {
976
1449
  hasChanges = true;
977
1450
  const col = (s) => paint(s, A.yellow, tty);
978
1451
  lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
@@ -1026,8 +1499,8 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
1026
1499
  hasChanges = true;
1027
1500
  const colR = (s) => paint(s, A.red, tty);
1028
1501
  const colA = (s) => paint(s, A.green, tty);
1029
- if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${fmtPrimitive(oldVal)} -> null`));
1030
- if (isPrimitive(newVal)) lines.push(colA(`${pad}+ ${pk} = ${fmtPrimitive(newVal)}`));
1502
+ if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
1503
+ if (isPrimitive(newVal)) lines.push(colA(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
1031
1504
  }
1032
1505
  }
1033
1506
  return hasChanges;
@@ -1135,6 +1608,7 @@ function renderBlock(action, tty) {
1135
1608
  }, FIELD_COL, tty, lines);
1136
1609
  else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
1137
1610
  lines.push(`${closePad}}`);
1611
+ renderWarnings(action.warnings, blkPad, tty, lines);
1138
1612
  if (action.affectedPaths.length > 0) {
1139
1613
  lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
1140
1614
  for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
@@ -1154,6 +1628,6 @@ function renderWarnings(warnings, blkPad, tty, lines) {
1154
1628
  for (const warning of warnings ?? []) lines.push(paint(`${blkPad}# warning: ${warning.message}`, A.yellow, tty));
1155
1629
  }
1156
1630
  //#endregion
1157
- export { buildSyncPlan as a, writeBackResource as c, FLOWS_DIR as d, summarizePlan as i, updateState as l, enumeratePlanResources as n, hashForState as o, renderPlan as r, runSyncLoop as s, collectPlanWarnings as t, makeSyncers as u };
1631
+ export { buildSyncPlan as a, writeBackResource as c, summarizePlan as i, makeSyncers as l, enumeratePlanResources as n, hashForState as o, renderPlan as r, runSyncLoop as s, collectPlanWarnings as t, FLOWS_DIR as u };
1158
1632
 
1159
- //# sourceMappingURL=sync-Cd3z7Q1R.mjs.map
1633
+ //# sourceMappingURL=sync-Bd6vASgg.mjs.map