@zitadel/cli 0.1.0-alpha.18 → 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 (56) hide show
  1. package/README.md +81 -33
  2. package/SKILLS.md +74 -8
  3. package/dist/{branding-BXj028-W.mjs → branding-cygSBPkV.mjs} +2 -2
  4. package/dist/{branding-BXj028-W.mjs.map → branding-cygSBPkV.mjs.map} +1 -1
  5. package/dist/{claim-state-BTGInAaN.mjs → claim-state-DlfD6Res.mjs} +2 -2
  6. package/dist/{claim-state-BTGInAaN.mjs.map → claim-state-DlfD6Res.mjs.map} +1 -1
  7. package/dist/commands/apply.mjs +4 -4
  8. package/dist/commands/branding/eject.mjs +11 -32
  9. package/dist/commands/branding/eject.mjs.map +1 -1
  10. package/dist/commands/claim.mjs +3 -3
  11. package/dist/commands/doctor.mjs +41 -18
  12. package/dist/commands/doctor.mjs.map +1 -1
  13. package/dist/commands/eject.mjs +4 -4
  14. package/dist/commands/eject.mjs.map +1 -1
  15. package/dist/commands/logs.mjs +2 -2
  16. package/dist/commands/plan.mjs +4 -4
  17. package/dist/commands/reset.mjs +2 -2
  18. package/dist/commands/schemas/list.mjs +6 -6
  19. package/dist/commands/schemas/list.mjs.map +1 -1
  20. package/dist/commands/setup.mjs +101 -143
  21. package/dist/commands/setup.mjs.map +1 -1
  22. package/dist/commands/start.mjs +3 -3
  23. package/dist/commands/status.mjs +5 -5
  24. package/dist/commands/stop.mjs +3 -3
  25. package/dist/designs-Ckz18Dpo.mjs +38 -0
  26. package/dist/designs-Ckz18Dpo.mjs.map +1 -0
  27. package/dist/{docker-Ciz9N6uI.mjs → docker-DcRGTOJa.mjs} +4 -3
  28. package/dist/docker-DcRGTOJa.mjs.map +1 -0
  29. package/dist/{environment-CuHKxtIO.mjs → environment-rjRVkJjW.mjs} +1 -1
  30. package/dist/{environment-CuHKxtIO.mjs.map → environment-rjRVkJjW.mjs.map} +1 -1
  31. package/dist/{journey-guidance-CtQUfWar.mjs → journey-guidance-BGrOX_gT.mjs} +2 -2
  32. package/dist/{journey-guidance-CtQUfWar.mjs.map → journey-guidance-BGrOX_gT.mjs.map} +1 -1
  33. package/dist/{oclif-CL6-M13m.mjs → oclif-CanO3zdt.mjs} +48 -2
  34. package/dist/oclif-CanO3zdt.mjs.map +1 -0
  35. package/dist/{orca-BWB4zOdw.mjs → orca-Cu0iW1mv.mjs} +167 -26
  36. package/dist/orca-Cu0iW1mv.mjs.map +1 -0
  37. package/dist/package-manager-DFdLcDx1.mjs +194 -0
  38. package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
  39. package/dist/{ports-Cu2Y7aXQ.mjs → ports-BM20XIZb.mjs} +2 -2
  40. package/dist/{ports-Cu2Y7aXQ.mjs.map → ports-BM20XIZb.mjs.map} +1 -1
  41. package/dist/{processes-Bn2WngOi.mjs → processes-DKHPkU8O.mjs} +1 -1
  42. package/dist/{processes-Bn2WngOi.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
  43. package/dist/{project-yejJSfdJ.mjs → project-CHhot85s.mjs} +2 -2
  44. package/dist/{project-yejJSfdJ.mjs.map → project-CHhot85s.mjs.map} +1 -1
  45. package/dist/{sync-Cx2_4rXW.mjs → sync-Bd6vASgg.mjs} +456 -31
  46. package/dist/sync-Bd6vASgg.mjs.map +1 -0
  47. package/dist/{user-schema-W1OsXdpc.mjs → user-schema-DTuOsdKE.mjs} +1 -1
  48. package/dist/{user-schema-W1OsXdpc.mjs.map → user-schema-DTuOsdKE.mjs.map} +1 -1
  49. package/oclif.manifest.json +2 -2
  50. package/package.json +6 -5
  51. package/dist/docker-Ciz9N6uI.mjs.map +0 -1
  52. package/dist/oclif-CL6-M13m.mjs.map +0 -1
  53. package/dist/orca-BWB4zOdw.mjs.map +0 -1
  54. package/dist/scaffold-manifest-CKNFLX9T.mjs +0 -81
  55. package/dist/scaffold-manifest-CKNFLX9T.mjs.map +0 -1
  56. package/dist/sync-Cx2_4rXW.mjs.map +0 -1
@@ -1,6 +1,6 @@
1
- import { E as ZitadelError, S as isObject, T as stableStringify } from "./oclif-CL6-M13m.mjs";
2
- import { a as updateState, n as readState, r as removeFromState, t as SCHEMAS_DIR } from "./user-schema-W1OsXdpc.mjs";
3
- import { a as toLocalBrandingBody, i as toBrandingWireBody, n as readDescriptorTemplate, r as resolveTemplatePath, t as BRANDING_DIR } from "./branding-BXj028-W.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";
4
4
  import { readFile, readdir, writeFile } from "node:fs/promises";
5
5
  import { basename, join } from "node:path";
6
6
  import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
@@ -10,6 +10,9 @@ import { writeFileSync } from "node:fs";
10
10
  import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
11
11
  import { brandingConfigSchema, flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
12
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";
13
16
  import { validateFlowDefinition } from "@zitadel/config/validate";
14
17
  //#region src/lib/flows/env-refs.ts
15
18
  /**
@@ -124,7 +127,7 @@ var SchemaSyncer = class {
124
127
  throw new ZitadelError("E_NOT_IMPLEMENTED", `schema delete is not supported (${id})`);
125
128
  }
126
129
  async fetch(id) {
127
- return await this.client.getSchemaById(encodeURIComponent(id), { project_id: this.projectId });
130
+ return await this.client.getSchemaById(encodeURIComponent(id));
128
131
  }
129
132
  };
130
133
  var FlowDefinitionSyncer = class {
@@ -169,23 +172,24 @@ var FlowDefinitionSyncer = class {
169
172
  /**
170
173
  * PUT completely replaces the flow definition. The wire request wraps the
171
174
  * bare on-disk flow in the `{ flow_definition }` update envelope
172
- * (`api/openapi/components/flows/flow-definition-update-request.yaml`) and
173
- * carries `project_id` as a query parameter; the file on disk stays bare so
174
- * 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.
175
178
  */
176
179
  async update(id, data) {
177
- 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 };
178
181
  }
179
182
  async delete(id) {
180
- await this.client.deleteFlowDefinition(id, { project_id: this.projectId });
183
+ await this.client.deleteFlowDefinition(id);
181
184
  }
182
185
  /**
183
186
  * `GET /flow_definitions/:id` returns a detail envelope with metadata
184
187
  * (`id`, `project_id`, `created_at`, `updated_at`) plus `flow_definition`.
185
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.
186
190
  */
187
191
  async fetch(id) {
188
- return (await this.client.getFlowDefinition(id, { project_id: this.projectId })).flow_definition;
192
+ return (await this.client.getFlowDefinition(id)).flow_definition;
189
193
  }
190
194
  };
191
195
  /**
@@ -252,9 +256,9 @@ var BrandingSyncer = class {
252
256
  async delete(id) {
253
257
  throw new ZitadelError("E_NOT_IMPLEMENTED", `branding delete is not supported (${id})`);
254
258
  }
255
- /** Wire form (template inlined); diffs compare in the normalized form. */
259
+ /** Wire form (template inlined); diffs compare in the normalized form. Flat-by-id: no project_id query. */
256
260
  async fetch(id) {
257
- return (await this.client.getBrandingById(id, { project_id: this.projectId })).branding;
261
+ return (await this.client.getBrandingById(id)).branding;
258
262
  }
259
263
  canonicalToLocal(canonicalWire, localData) {
260
264
  const local = localData;
@@ -270,6 +274,291 @@ var BrandingSyncer = class {
270
274
  }
271
275
  };
272
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
+ }
561
+ //#endregion
273
562
  //#region src/lib/sync/flow-validation.ts
274
563
  /**
275
564
  * Pre-flight semantic validation for every flow this plan uploads,
@@ -512,6 +801,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
512
801
  scannedContents,
513
802
  stateResources: state.resources
514
803
  });
804
+ await annotateAssetWarnings(actions);
515
805
  return actions;
516
806
  }
517
807
  /**
@@ -531,7 +821,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
531
821
  */
532
822
  async function runSyncLoop(cwd, syncers) {
533
823
  const actions = await buildSyncPlan(cwd, syncers);
534
- 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}`);
535
825
  const filesUpdated = [];
536
826
  const applied = [];
537
827
  const repinned = /* @__PURE__ */ new Map();
@@ -792,17 +1082,21 @@ function summarizePlan(actions) {
792
1082
  */
793
1083
  function collectPlanWarnings(actions) {
794
1084
  const out = [];
795
- for (const action of actions) {
796
- if (action.kind !== "create" && action.kind !== "update") continue;
797
- for (const warning of action.warnings ?? []) out.push({
798
- path: action.path,
799
- rule: warning.rule,
800
- message: warning.message
801
- });
802
- }
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
+ });
803
1090
  return out;
804
1091
  }
805
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
+ /**
806
1100
  * Enumerate the non-`skip` actions of a {@link buildSyncPlan} result as
807
1101
  * envelope-ready {@link PlanResourceChange} rows. Pure; the structural
808
1102
  * counterpart of {@link summarizePlan} — counts and rows always agree.
@@ -871,7 +1165,7 @@ function renderPlan(actions, tty) {
871
1165
  if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
872
1166
  if (deletes > 0) parts.push(`${deletes} to destroy`);
873
1167
  out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
874
- 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);
875
1169
  if (warningCount > 0) out.push(paint(`Warnings: ${warningCount} (non-blocking — see the # warning lines above).`, A.yellow, tty));
876
1170
  return out.join("\n");
877
1171
  }
@@ -901,6 +1195,37 @@ function fmtPrimitive(v) {
901
1195
  if (typeof v === "string") return `"${escapeString(v)}"`;
902
1196
  return String(v);
903
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
+ }
904
1229
  function prefixAnsi(p) {
905
1230
  if (p === "+") return A.green;
906
1231
  if (p === "-") return A.red;
@@ -917,7 +1242,7 @@ function renderFields(obj, prefix, prefixCol, ctx, lines) {
917
1242
  const val = obj[key];
918
1243
  const pk = key.padEnd(maxLen);
919
1244
  if (isPrimitive(val)) {
920
- const formatted = fmtPrimitive(val);
1245
+ const formatted = fmtScalar(val);
921
1246
  const suffix = ctx.deleteMode ? " -> null" : "";
922
1247
  lines.push(col(`${pad}${prefix} ${pk} = ${formatted}${suffix}`));
923
1248
  } else if (Array.isArray(val)) if (val.length === 0) lines.push(col(`${pad}${prefix} ${pk} = []`));
@@ -945,7 +1270,7 @@ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
945
1270
  const ansi = prefixAnsi(prefix);
946
1271
  const col = (s) => paint(s, ansi, ctx.tty);
947
1272
  for (const item of arr) if (isPrimitive(item)) {
948
- const formatted = fmtPrimitive(item);
1273
+ const formatted = fmtScalar(item);
949
1274
  lines.push(col(`${pad}${prefix} ${formatted},`));
950
1275
  } else if (Array.isArray(item)) if (item.length === 0) lines.push(col(`${pad}${prefix} [],`));
951
1276
  else {
@@ -960,6 +1285,102 @@ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
960
1285
  lines.push(col(`${" ".repeat(prefixCol + 2)}},`));
961
1286
  }
962
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
+ }
963
1384
  /**
964
1385
  * Walks both old and new objects, emitting Terraform-style change lines.
965
1386
  * Returns true if any actual change line (+ / - / ~) was emitted.
@@ -985,7 +1406,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
985
1406
  if (!hasOld) {
986
1407
  hasChanges = true;
987
1408
  const col = (s) => paint(s, A.green, tty);
988
- if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtPrimitive(newVal)}`));
1409
+ if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
989
1410
  else if (Array.isArray(newVal)) {
990
1411
  lines.push(col(`${pad}+ ${pk} = [`));
991
1412
  renderArrayItems(newVal, "+", prefixCol + 4, {
@@ -1004,7 +1425,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
1004
1425
  } else if (!hasNew) {
1005
1426
  hasChanges = true;
1006
1427
  const col = (s) => paint(s, A.red, tty);
1007
- if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtPrimitive(oldVal)} -> null`));
1428
+ if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
1008
1429
  else if (Array.isArray(oldVal)) {
1009
1430
  lines.push(col(`${pad}- ${pk} = [`));
1010
1431
  renderArrayItems(oldVal, "-", prefixCol + 4, {
@@ -1020,8 +1441,11 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
1020
1441
  }, lines);
1021
1442
  lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
1022
1443
  }
1023
- } else if (isPrimitive(oldVal) && isPrimitive(newVal)) if (oldVal === newVal) lines.push(`${pad} ${pk} = ${fmtPrimitive(newVal)}`);
1024
- 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 {
1025
1449
  hasChanges = true;
1026
1450
  const col = (s) => paint(s, A.yellow, tty);
1027
1451
  lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
@@ -1075,8 +1499,8 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
1075
1499
  hasChanges = true;
1076
1500
  const colR = (s) => paint(s, A.red, tty);
1077
1501
  const colA = (s) => paint(s, A.green, tty);
1078
- if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${fmtPrimitive(oldVal)} -> null`));
1079
- 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)}`));
1080
1504
  }
1081
1505
  }
1082
1506
  return hasChanges;
@@ -1184,6 +1608,7 @@ function renderBlock(action, tty) {
1184
1608
  }, FIELD_COL, tty, lines);
1185
1609
  else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
1186
1610
  lines.push(`${closePad}}`);
1611
+ renderWarnings(action.warnings, blkPad, tty, lines);
1187
1612
  if (action.affectedPaths.length > 0) {
1188
1613
  lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
1189
1614
  for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
@@ -1205,4 +1630,4 @@ function renderWarnings(warnings, blkPad, tty, lines) {
1205
1630
  //#endregion
1206
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 };
1207
1632
 
1208
- //# sourceMappingURL=sync-Cx2_4rXW.mjs.map
1633
+ //# sourceMappingURL=sync-Bd6vASgg.mjs.map