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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +250 -62
  2. package/SKILLS.md +221 -37
  3. package/dist/branding-cygSBPkV.mjs +79 -0
  4. package/dist/branding-cygSBPkV.mjs.map +1 -0
  5. package/dist/claim-state-DlfD6Res.mjs +66 -0
  6. package/dist/claim-state-DlfD6Res.mjs.map +1 -0
  7. package/dist/commands/apply.mjs +27 -8
  8. package/dist/commands/apply.mjs.map +1 -1
  9. package/dist/commands/branding/eject.mjs +104 -0
  10. package/dist/commands/branding/eject.mjs.map +1 -0
  11. package/dist/commands/claim.mjs +402 -0
  12. package/dist/commands/claim.mjs.map +1 -0
  13. package/dist/commands/doctor.mjs +594 -41
  14. package/dist/commands/doctor.mjs.map +1 -1
  15. package/dist/commands/eject.mjs +18 -6
  16. package/dist/commands/eject.mjs.map +1 -1
  17. package/dist/commands/logs.mjs +2 -2
  18. package/dist/commands/plan.mjs +19 -6
  19. package/dist/commands/plan.mjs.map +1 -1
  20. package/dist/commands/reset.mjs +2 -2
  21. package/dist/commands/schemas/list.mjs +146 -0
  22. package/dist/commands/schemas/list.mjs.map +1 -0
  23. package/dist/commands/setup.mjs +629 -214
  24. package/dist/commands/setup.mjs.map +1 -1
  25. package/dist/commands/start.mjs +4 -4
  26. package/dist/commands/start.mjs.map +1 -1
  27. package/dist/commands/status.mjs +52 -13
  28. package/dist/commands/status.mjs.map +1 -1
  29. package/dist/commands/stop.mjs +3 -3
  30. package/dist/commands/stop.mjs.map +1 -1
  31. package/dist/designs-Ckz18Dpo.mjs +38 -0
  32. package/dist/designs-Ckz18Dpo.mjs.map +1 -0
  33. package/dist/{docker-CnGQK3ZK.mjs → docker-DcRGTOJa.mjs} +5 -4
  34. package/dist/docker-DcRGTOJa.mjs.map +1 -0
  35. package/dist/environment-rjRVkJjW.mjs +17 -0
  36. package/dist/environment-rjRVkJjW.mjs.map +1 -0
  37. package/dist/journey-guidance-BGrOX_gT.mjs +40 -0
  38. package/dist/journey-guidance-BGrOX_gT.mjs.map +1 -0
  39. package/dist/oclif-CanO3zdt.mjs +1769 -0
  40. package/dist/oclif-CanO3zdt.mjs.map +1 -0
  41. package/dist/{orca-U142Wrau.mjs → orca-BzQIQqzt.mjs} +1663 -303
  42. package/dist/orca-BzQIQqzt.mjs.map +1 -0
  43. package/dist/package-manager-DFdLcDx1.mjs +194 -0
  44. package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
  45. package/dist/{ports-B09RjuHx.mjs → ports-BM20XIZb.mjs} +25 -20
  46. package/dist/ports-BM20XIZb.mjs.map +1 -0
  47. package/dist/{processes-Cw8TO1SY.mjs → processes-DKHPkU8O.mjs} +1 -1
  48. package/dist/{processes-Cw8TO1SY.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
  49. package/dist/{project-Cd0L3PtM.mjs → project-CHhot85s.mjs} +57 -4
  50. package/dist/project-CHhot85s.mjs.map +1 -0
  51. package/dist/sync-BBlACKzT.mjs +1633 -0
  52. package/dist/sync-BBlACKzT.mjs.map +1 -0
  53. package/dist/user-schema-DTuOsdKE.mjs +91 -0
  54. package/dist/user-schema-DTuOsdKE.mjs.map +1 -0
  55. package/oclif.manifest.json +406 -5
  56. package/package.json +10 -5
  57. package/dist/docker-CnGQK3ZK.mjs.map +0 -1
  58. package/dist/docker-guidance-ypN3IM3o.mjs +0 -21
  59. package/dist/docker-guidance-ypN3IM3o.mjs.map +0 -1
  60. package/dist/oclif-B7lBzh3R.mjs +0 -808
  61. package/dist/oclif-B7lBzh3R.mjs.map +0 -1
  62. package/dist/orca-U142Wrau.mjs.map +0 -1
  63. package/dist/ports-B09RjuHx.mjs.map +0 -1
  64. package/dist/project-Cd0L3PtM.mjs.map +0 -1
  65. package/dist/sync-BojoQm2P.mjs +0 -733
  66. package/dist/sync-BojoQm2P.mjs.map +0 -1
@@ -0,0 +1,1633 @@
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
+ import { readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { basename, join } from "node:path";
6
+ import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
7
+ import { consola as consola$1 } from "consola";
8
+ import { createHash } from "node:crypto";
9
+ import { writeFileSync } from "node:fs";
10
+ import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
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";
16
+ import { validateFlowDefinition } from "@zitadel/config/validate";
17
+ //#region src/lib/flows/env-refs.ts
18
+ /**
19
+ * Collects the environment variables a flows document depends on, sorted and
20
+ * de-duplicated. Recognises two reference styles: inline `${VAR}` interpolations
21
+ * inside string values, and keys ending in `_env` whose value names a single
22
+ * variable. `apply`/`plan` use this to fail before contacting the platform when
23
+ * a required variable is absent.
24
+ */
25
+ function flowEnvRefs(value) {
26
+ const refs = /* @__PURE__ */ new Set();
27
+ const visit = (node) => {
28
+ if (typeof node === "string") for (const match of node.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
29
+ const ref = match[1];
30
+ if (ref) refs.add(ref);
31
+ }
32
+ else if (Array.isArray(node)) node.forEach(visit);
33
+ else if (isObject(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);
34
+ else visit(child);
35
+ };
36
+ visit(value);
37
+ return [...refs].sort();
38
+ }
39
+ //#endregion
40
+ //#region src/lib/flows/index.ts
41
+ /**
42
+ * Relative directory (from the project root) where local flow files
43
+ * live. Owned here so callers (`commands/*`, `sync/syncers.ts`) and
44
+ * tests share a single source of truth for the path; the runtime
45
+ * never depends on it directly because `lib/flows` does not touch
46
+ * the filesystem.
47
+ */
48
+ const FLOWS_DIR = ".zitadel/flows";
49
+ //#endregion
50
+ //#region src/lib/sync/syncers.ts
51
+ /**
52
+ * Build the syncer list with the context every syncer needs: the
53
+ * `project_id` flow creates carry, and the runtime `env` against which
54
+ * each file's `${VAR}` / `*_env` references are checked. Callers (apply /
55
+ * plan / setup) read `project_id` from `.zitadel/secret` and pass the
56
+ * process environment. The returned array is treated as read-only by the
57
+ * sync loop.
58
+ */
59
+ function makeSyncers(opts) {
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
+ ];
65
+ }
66
+ /**
67
+ * Assert that every env var a resource references — `${VAR}` placeholders and
68
+ * the `*_env` convention — is present in `env`, throwing `E_VALIDATION` listing
69
+ * the missing names. Shared by every syncer so the check is identical for
70
+ * schemas and flows, and runs in the sync engine before any platform call.
71
+ */
72
+ function assertEnvRefs(data, env) {
73
+ const missing = flowEnvRefs(data).filter((name) => !env[name]);
74
+ if (missing.length > 0) throw new ZitadelError("E_VALIDATION", `Missing environment variables: ${missing.join(", ")}`);
75
+ }
76
+ var SchemaSyncer = class {
77
+ kind = "schema";
78
+ directory = SCHEMAS_DIR;
79
+ mutable = false;
80
+ revisioned = true;
81
+ normalize = normalizeSchemaBody;
82
+ constructor(client, projectId, env) {
83
+ this.client = client;
84
+ this.projectId = projectId;
85
+ this.env = env;
86
+ }
87
+ /**
88
+ * Parse against the generated `CreateSchemaBody` Zod (the orval-emitted
89
+ * equivalent of `api/openapi/endpoints/schemas/user-schema.yaml`). The
90
+ * generated schema is a union of `user-schema` and `schema-url`
91
+ * discriminated on `kind`; both are valid on-disk bodies.
92
+ */
93
+ validate(data) {
94
+ const result = schemaConfigSchema.safeParse(data);
95
+ if (!result.success) throw new ZitadelError("E_VALIDATION", "Schema file is not a valid Zitadel schema body", { details: { issues: result.error.issues } });
96
+ assertEnvRefs(data, this.env);
97
+ }
98
+ /**
99
+ * `POST /schemas` mints a new immutable row. The server allocates the
100
+ * opaque id; the CLI records it in state and re-pins flows against it.
101
+ * The create response carries only the id, so the canonical stored body
102
+ * comes from a follow-up fetch; a fetch failure degrades to no
103
+ * write-back rather than failing the create.
104
+ */
105
+ async create(data) {
106
+ const result = await this.client.createSchema(data, { project_id: this.projectId });
107
+ try {
108
+ return {
109
+ id: result.id,
110
+ canonical: await this.fetch(result.id)
111
+ };
112
+ } catch (err) {
113
+ consola$1.debug(`fetch created schema ${result.id} failed:`, err);
114
+ return { id: result.id };
115
+ }
116
+ }
117
+ /**
118
+ * Not called by the sync loop: schemas are `revisioned`, so a hash change
119
+ * publishes a new immutable revision through {@link create} rather than
120
+ * mutating an existing row. Kept as a required interface member; throws
121
+ * loudly if a caller reaches it.
122
+ */
123
+ async update(_id, _data) {
124
+ throw new ZitadelError("E_NOT_IMPLEMENTED", "schemas are revisioned — edit publishes a new revision, not an update");
125
+ }
126
+ async delete(id) {
127
+ throw new ZitadelError("E_NOT_IMPLEMENTED", `schema delete is not supported (${id})`);
128
+ }
129
+ async fetch(id) {
130
+ return (await this.client.getSchemaById(encodeURIComponent(id))).schema;
131
+ }
132
+ };
133
+ var FlowDefinitionSyncer = class {
134
+ kind = "flow";
135
+ directory = FLOWS_DIR;
136
+ mutable = true;
137
+ revisioned = false;
138
+ normalize = normalizeFlowBody;
139
+ normalizeWrite = normalizeFlowBody;
140
+ constructor(client, projectId, env) {
141
+ this.client = client;
142
+ this.projectId = projectId;
143
+ this.env = env;
144
+ }
145
+ /**
146
+ * Validates one flow file against the canonical `flowConfigSchema` (the
147
+ * same Zod `validateFlows` and doctor use), then checks env references.
148
+ */
149
+ validate(data) {
150
+ const result = flowConfigSchema.safeParse(data);
151
+ if (!result.success) throw new ZitadelError("E_VALIDATION", "Flow file is not a valid Zitadel flow body", { details: { issues: result.error.issues } });
152
+ assertEnvRefs(data, this.env);
153
+ }
154
+ /**
155
+ * Wraps the bare on-disk flow body in the spec's create-envelope
156
+ * (`api/openapi/components/flows/flow-definition-create-request.yaml`)
157
+ * before sending. The file on disk stays bare so it is human-editable;
158
+ * only the wire request carries `project_id` and the surrounding
159
+ * envelope.
160
+ */
161
+ async create(data) {
162
+ const result = await this.client.createFlowDefinition({
163
+ project_id: this.projectId,
164
+ schema_uri: DEFAULT_FLOW_SCHEMA_URI,
165
+ flow_definition: data
166
+ });
167
+ return {
168
+ id: result.id,
169
+ canonical: result.flow_definition
170
+ };
171
+ }
172
+ /**
173
+ * PUT completely replaces the flow definition. The wire request wraps the
174
+ * bare on-disk flow in the `{ flow_definition }` update envelope
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.
178
+ */
179
+ async update(id, data) {
180
+ return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data })).flow_definition };
181
+ }
182
+ async delete(id) {
183
+ await this.client.deleteFlowDefinition(id);
184
+ }
185
+ /**
186
+ * `GET /flow_definitions/:id` returns a response envelope with metadata
187
+ * (`id`, `project_id`, `created_at`, `updated_at`) plus `flow_definition`.
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.
190
+ */
191
+ async fetch(id) {
192
+ return (await this.client.getFlowDefinition(id)).flow_definition;
193
+ }
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
+ }
561
+ //#endregion
562
+ //#region src/lib/sync/flow-validation.ts
563
+ /**
564
+ * Pre-flight semantic validation for every flow this plan uploads,
565
+ * mirroring the server-side `ValidateFlowDefinition` (see
566
+ * `@zitadel/config/validate`). Runs inside `buildSyncPlan`, so `plan`
567
+ * reports the violations the server would only reveal on apply — and
568
+ * `apply` fails before any platform mutation instead of half-applied
569
+ * (a schema revision would otherwise publish before the flow update
570
+ * fails).
571
+ *
572
+ * Only actions that upload a flow (create/update, including repin-forced
573
+ * updates) are validated: an unchanged flow is never re-judged, so a
574
+ * validator behavior change cannot break an already-applied project.
575
+ *
576
+ * Warning-severity issues ANNOTATE the actions in place (`action.warnings`)
577
+ * for plan rendering — `actions` is deliberately typed mutable because the
578
+ * caller (`buildSyncPlan`, which owns the array it just built) expects the
579
+ * annotations. Error-severity issues aggregate into one E_VALIDATION
580
+ * carrying every violation.
581
+ *
582
+ * Escape hatch: setting `ZITADEL_SKIP_FLOW_VALIDATION` skips the check —
583
+ * insurance against a port bug rejecting something the server accepts.
584
+ */
585
+ function validatePlannedFlows(opts) {
586
+ if (process.env.ZITADEL_SKIP_FLOW_VALIDATION) {
587
+ consola$1.warn("Flow validation skipped (ZITADEL_SKIP_FLOW_VALIDATION is set)");
588
+ return;
589
+ }
590
+ const failures = [];
591
+ let anyRepin = false;
592
+ const flowCreatePaths = opts.actions.filter((a) => a.kind === "create" && a.syncer.kind === "flow").map((a) => a.path);
593
+ const trackedFlowPaths = Object.keys(opts.stateResources).filter((path) => path.startsWith(`${FLOWS_DIR}/`));
594
+ for (const action of opts.actions) {
595
+ if (action.kind !== "create" && action.kind !== "update") continue;
596
+ if (action.syncer.kind !== "flow") continue;
597
+ const issues = validateFlowDefinition(action.content, resolveSchemaContent(action, opts.scannedContents, opts.stateResources));
598
+ const warnings = issues.filter((issue) => issue.severity === "warning");
599
+ if (warnings.length > 0) action.warnings = warnings.map(({ rule, message }) => ({
600
+ rule,
601
+ message
602
+ }));
603
+ for (const issue of issues) if (issue.severity === "error") {
604
+ failures.push({
605
+ path: action.path,
606
+ issue
607
+ });
608
+ anyRepin ||= action.repin !== void 0;
609
+ }
610
+ const swapWarning = defaultSwapWarning(action, flowCreatePaths, trackedFlowPaths);
611
+ if (swapWarning !== void 0) action.warnings = [...action.warnings ?? [], swapWarning];
612
+ }
613
+ if (failures.length === 0) return;
614
+ const noun = failures.length === 1 ? "issue" : "issues";
615
+ throw new ZitadelError("E_VALIDATION", `Flow validation failed (${failures.length} ${noun}):\n` + failures.map(({ path, issue }) => ` - ${path}: ${issue.message}`).join("\n"), {
616
+ hint: "These are the same rules the server enforces on apply. Fix the flow definition(s) and re-run plan." + (anyRepin ? " A failing flow is adopting an edited schema revision — update its steps[].fields to match the edited schema (or restore the removed/renamed properties)." : ""),
617
+ details: { issues: failures.map(({ path, issue }) => ({
618
+ path,
619
+ rule: issue.rule,
620
+ message: issue.message,
621
+ ...issue.step === void 0 ? {} : { step: issue.step }
622
+ })) }
623
+ });
624
+ }
625
+ /**
626
+ * Warn when applying a NEW active, unscoped flow into a project that
627
+ * already has flows: the engine's default selection is newest-unscoped-
628
+ * wins (ties broken by id), so this flow silently becomes what
629
+ * `<zitadel-login>` renders for its purposes unless the widget pins a
630
+ * `flow-name`. Deliberately engine-level rather than a validator rule:
631
+ * the verdict depends on the action kind and the rest of the plan/state,
632
+ * which `validateFlowDefinition` never sees. A project's first flow is
633
+ * exempt — becoming the default is the point of creating it.
634
+ */
635
+ function defaultSwapWarning(action, flowCreatePaths, trackedFlowPaths) {
636
+ if (action.kind !== "create") return;
637
+ const body = action.content;
638
+ if (body.status !== "active") return;
639
+ if ((body.audience?.team_ids?.length ?? 0) > 0 || (body.audience?.app_ids?.length ?? 0) > 0) return;
640
+ const purposes = Object.keys(body.purposes ?? {});
641
+ if (purposes.length === 0) return;
642
+ if (trackedFlowPaths.filter((path) => path !== action.path).length + flowCreatePaths.filter((path) => path !== action.path).length === 0) return;
643
+ return {
644
+ rule: "warn/default-flow-swap",
645
+ message: `once applied, this flow becomes the new default for ${humanList(purposes)}: pages that do not explicitly set flow-name on <zitadel-login> will start rendering it. If that is not intended, scope it with "audience" or set flow-name on the pages that should use it.`
646
+ };
647
+ }
648
+ /** ["login", "register"] → "login and register" (Oxford-free prose list). */
649
+ function humanList(items) {
650
+ if (items.length <= 1) return items[0] ?? "";
651
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
652
+ }
653
+ /**
654
+ * Locate the local content of the schema a flow's `user_schema` pins.
655
+ * A repin action already names the schema file (the pin is adopting that
656
+ * file's next/new revision); otherwise the pinned id is looked up in
657
+ * state and its file body taken from this scan. Unresolvable refs (e.g.
658
+ * an un-substituted `${USER_SCHEMA_URL}` placeholder or a server-side
659
+ * schema not tracked locally) return undefined — flow-local rules still
660
+ * run, schema-dependent rules are skipped.
661
+ */
662
+ function resolveSchemaContent(action, scannedContents, stateResources) {
663
+ if (action.repin) return scannedContents.get(action.repin.schemaPath);
664
+ const ref = action.content.user_schema;
665
+ if (typeof ref !== "string" || ref === "") return;
666
+ for (const [path, entry] of Object.entries(stateResources)) if (entry.id === ref) return scannedContents.get(path);
667
+ consola$1.debug(`flow validation: no local schema for user_schema ${ref}; flow-local rules only`);
668
+ }
669
+ //#endregion
670
+ //#region src/lib/sync/loop.ts
671
+ /**
672
+ * Compute the sync plan for `cwd` against the state file and (when
673
+ * `fetchOld` is true) the platform API. The plan is read-only: it
674
+ * decides what create/update/revise/delete operations need to happen but
675
+ * performs none of them. Pass it to {@link runSyncLoop} to execute.
676
+ *
677
+ * Validates every on-disk file (via `syncer.validate`) before planning any
678
+ * work — a single malformed schema or flow aborts the whole run with
679
+ * `E_VALIDATION` before any platform mutation. Both `plan` and `apply`
680
+ * reach this code path.
681
+ *
682
+ * Bearer auth + base URL live in the api package's runtime registries
683
+ * (`runtime/{auth,base-url}`). Callers set them once at command boot;
684
+ * the sync engine doesn't carry a client.
685
+ *
686
+ * @param cwd - Project root.
687
+ * @param syncers - Per-resource adapters. Order is preserved in the output.
688
+ * @param fetchOld - When true, the planner fetches each delete/update target
689
+ * from the platform to populate `oldContent` for diff rendering.
690
+ */
691
+ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
692
+ const state = await readState(cwd);
693
+ const actions = [];
694
+ const localFlows = await readLocalFlowUserSchemas(cwd);
695
+ const pendingRevisions = /* @__PURE__ */ new Map();
696
+ const recoveredRevisions = /* @__PURE__ */ new Map();
697
+ for (const [schemaPath, entry] of Object.entries(state.resources)) if (entry.previousId && entry.id && entry.previousId !== entry.id) recoveredRevisions.set(entry.previousId, {
698
+ schemaPath,
699
+ newId: entry.id
700
+ });
701
+ const scannedContents = /* @__PURE__ */ new Map();
702
+ for (const syncer of syncers) {
703
+ const dirPath = join(cwd, syncer.directory);
704
+ consola$1.debug(`scanning ${syncer.directory}`);
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
+ }
710
+ for (const content of onDisk.values()) syncer.validate(content);
711
+ for (const [filePath, entry] of Object.entries(state.resources)) {
712
+ if (!filePath.startsWith(syncer.directory)) continue;
713
+ if (onDisk.has(join(cwd, filePath)) || !entry.id) continue;
714
+ let oldContent = null;
715
+ if (fetchOld && syncer.fetch) try {
716
+ oldContent = await syncer.fetch(entry.id);
717
+ } catch (err) {
718
+ consola$1.debug(`fetch ${syncer.kind} ${entry.id} failed:`, err);
719
+ }
720
+ actions.push({
721
+ kind: "delete",
722
+ path: filePath,
723
+ syncer,
724
+ id: entry.id,
725
+ oldContent
726
+ });
727
+ }
728
+ for (const [absPath, content] of onDisk.entries()) {
729
+ const relPath = absPath.slice(cwd.length + 1);
730
+ scannedContents.set(relPath, content);
731
+ const entry = state.resources[relPath];
732
+ const hash = hashForState(syncer, content);
733
+ const flowRef = localFlows.get(relPath);
734
+ const pending = flowRef ? pendingRevisions.get(flowRef) : void 0;
735
+ const recovered = flowRef ? recoveredRevisions.get(flowRef) : void 0;
736
+ const repin = pending ? {
737
+ previousId: flowRef,
738
+ schemaPath: pending.schemaPath
739
+ } : recovered ? {
740
+ previousId: flowRef,
741
+ schemaPath: recovered.schemaPath,
742
+ newId: recovered.newId
743
+ } : void 0;
744
+ if (!entry?.id) {
745
+ actions.push({
746
+ kind: "create",
747
+ path: relPath,
748
+ syncer,
749
+ content,
750
+ hash,
751
+ ...repin ? { repin } : {}
752
+ });
753
+ continue;
754
+ }
755
+ if ((entry.hash === hash || entry.hash === hashResourceContent(content) || entry.hash === hashResourceContent(JSON.parse(stableStringify(content)))) && !(repin && syncer.mutable)) {
756
+ actions.push({
757
+ kind: "skip",
758
+ path: relPath,
759
+ reason: "no-change"
760
+ });
761
+ continue;
762
+ }
763
+ if (syncer.revisioned) {
764
+ const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
765
+ pendingRevisions.set(entry.id, { schemaPath: relPath });
766
+ actions.push({
767
+ kind: "revise",
768
+ path: relPath,
769
+ syncer,
770
+ content,
771
+ hash,
772
+ previousId: entry.id,
773
+ oldContent,
774
+ affectedPaths: findFlowsPinnedTo(entry.id, localFlows)
775
+ });
776
+ continue;
777
+ }
778
+ if (!syncer.mutable) {
779
+ actions.push({
780
+ kind: "skip",
781
+ path: relPath,
782
+ reason: "immutable"
783
+ });
784
+ continue;
785
+ }
786
+ const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
787
+ actions.push({
788
+ kind: "update",
789
+ path: relPath,
790
+ syncer,
791
+ id: entry.id,
792
+ content,
793
+ hash,
794
+ oldContent,
795
+ ...repin ? { repin } : {}
796
+ });
797
+ }
798
+ }
799
+ validatePlannedFlows({
800
+ actions,
801
+ scannedContents,
802
+ stateResources: state.resources
803
+ });
804
+ await annotateAssetWarnings(actions);
805
+ return actions;
806
+ }
807
+ /**
808
+ * Execute every action returned by {@link buildSyncPlan} against the
809
+ * platform. Updates the local state file (`.zitadel/state.json`) as
810
+ * each action completes so an interrupted run can resume. After each
811
+ * mutation, the server's canonical body is written back to the local
812
+ * file (when it differs in normalized form), so repo config matches
813
+ * live state by construction and the next `plan` is empty.
814
+ *
815
+ * The platform target (base URL + bearer auth) lives in the api
816
+ * package's runtime registries; callers set them before invoking this.
817
+ *
818
+ * @param cwd - Project root.
819
+ * @param syncers - Per-resource adapters; same list passed to
820
+ * `buildSyncPlan`.
821
+ */
822
+ async function runSyncLoop(cwd, syncers) {
823
+ const actions = await buildSyncPlan(cwd, syncers);
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}`);
825
+ const filesUpdated = [];
826
+ const applied = [];
827
+ const repinned = /* @__PURE__ */ new Map();
828
+ const writeBack = async (action, canonical, fallbackHash) => {
829
+ if (!canonical) return fallbackHash;
830
+ const { hash, changed } = await writeBackResource(cwd, action.path, action.syncer, canonical);
831
+ if (changed) {
832
+ filesUpdated.push(action.path);
833
+ consola$1.info(`Updated ${action.path} from the server's canonical response`);
834
+ }
835
+ return hash;
836
+ };
837
+ for (const action of actions) switch (action.kind) {
838
+ case "create": {
839
+ let content = action.content;
840
+ const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
841
+ if (newId) content = {
842
+ ...content,
843
+ user_schema: newId
844
+ };
845
+ const { id, canonical } = await action.syncer.create(content);
846
+ const entry = {
847
+ id,
848
+ hash: await writeBack(action, canonical, newId ? hashForState(action.syncer, content) : action.hash)
849
+ };
850
+ await updateState(cwd, action.path, entry);
851
+ consola$1.info(`Created a new ${action.syncer.kind} on Zitadel from ${action.path} (id ${id})`);
852
+ applied.push({
853
+ kind: action.syncer.kind,
854
+ action: "create",
855
+ file: action.path,
856
+ id
857
+ });
858
+ break;
859
+ }
860
+ case "revise": {
861
+ const { id, canonical } = await action.syncer.create(action.content);
862
+ const entry = {
863
+ id,
864
+ hash: await writeBack(action, canonical, action.hash),
865
+ previousId: action.previousId
866
+ };
867
+ await updateState(cwd, action.path, entry);
868
+ repinned.set(action.previousId, id);
869
+ consola$1.info(`Published a new ${action.syncer.kind} revision on Zitadel from ${action.path} (id ${id})`);
870
+ for (const flowPath of action.affectedPaths) if (await repinFlowFile(cwd, flowPath, action.previousId, id)) {
871
+ filesUpdated.push(flowPath);
872
+ consola$1.info(`Re-pinned user_schema in ${flowPath} to ${id}`);
873
+ }
874
+ applied.push({
875
+ kind: action.syncer.kind,
876
+ action: "revision",
877
+ file: action.path,
878
+ id,
879
+ previous_id: action.previousId
880
+ });
881
+ break;
882
+ }
883
+ case "update": {
884
+ let content = action.content;
885
+ const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
886
+ if (newId && action.repin) {
887
+ content = {
888
+ ...content,
889
+ user_schema: newId
890
+ };
891
+ if (await repinFlowFile(cwd, action.path, action.repin.previousId, newId)) {
892
+ filesUpdated.push(action.path);
893
+ consola$1.info(`Re-pinned user_schema in ${action.path} to ${newId}`);
894
+ }
895
+ }
896
+ const { canonical } = await action.syncer.update(action.id, content);
897
+ const fallbackHash = newId ? hashForState(action.syncer, content) : action.hash;
898
+ await updateState(cwd, action.path, { hash: await writeBack(action, canonical, fallbackHash) });
899
+ consola$1.info(`Updated the ${action.syncer.kind} on Zitadel from ${action.path}`);
900
+ applied.push({
901
+ kind: action.syncer.kind,
902
+ action: "update",
903
+ file: action.path,
904
+ id: action.id
905
+ });
906
+ break;
907
+ }
908
+ case "delete":
909
+ await action.syncer.delete(action.id);
910
+ await removeFromState(cwd, action.path);
911
+ consola$1.info(`Deleted the ${action.syncer.kind} on Zitadel because ${action.path} was removed locally`);
912
+ applied.push({
913
+ kind: action.syncer.kind,
914
+ action: "delete",
915
+ file: action.path,
916
+ id: action.id
917
+ });
918
+ break;
919
+ case "skip":
920
+ consola$1.debug(`Skipped ${action.path} (${action.reason})`);
921
+ break;
922
+ }
923
+ const remainingPins = new Set((await readLocalFlowUserSchemas(cwd)).values());
924
+ const finalState = await readState(cwd);
925
+ for (const [path, entry] of Object.entries(finalState.resources)) if (entry.previousId && !remainingPins.has(entry.previousId)) await updateState(cwd, path, { previousId: void 0 });
926
+ return {
927
+ filesUpdated: [...new Set(filesUpdated)],
928
+ applied
929
+ };
930
+ }
931
+ /**
932
+ * Rewrite a flow file's `user_schema` pin from `previousId` to `newId`,
933
+ * lockfile-style. Prefers a targeted text replacement so the author's
934
+ * formatting survives a one-string change; falls back to parse +
935
+ * `stableStringify` when the raw text doesn't contain exactly one pin.
936
+ * Returns false when the file doesn't pin `previousId` (already re-pinned
937
+ * or hand-edited) — never throws for an unreadable file.
938
+ */
939
+ async function repinFlowFile(cwd, relPath, previousId, newId) {
940
+ const absPath = join(cwd, relPath);
941
+ let raw;
942
+ try {
943
+ raw = await readFile(absPath, "utf8");
944
+ } catch (err) {
945
+ consola$1.debug(`read ${relPath} for re-pin failed:`, err);
946
+ return false;
947
+ }
948
+ const pinPattern = new RegExp(`("user_schema"\\s*:\\s*)${escapeRegExp(JSON.stringify(previousId))}`, "g");
949
+ if (raw.match(pinPattern)?.length === 1) {
950
+ await writeFile(absPath, raw.replace(pinPattern, (_match, prefix) => `${prefix}${JSON.stringify(newId)}`));
951
+ return true;
952
+ }
953
+ try {
954
+ const doc = JSON.parse(raw);
955
+ if (doc.user_schema !== previousId) return false;
956
+ doc.user_schema = newId;
957
+ await writeFile(absPath, `${stableStringify(doc)}\n`);
958
+ return true;
959
+ } catch (err) {
960
+ consola$1.debug(`re-pin ${relPath} failed:`, err);
961
+ return false;
962
+ }
963
+ }
964
+ function escapeRegExp(value) {
965
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
966
+ }
967
+ /**
968
+ * Reconcile a local file with the server's canonical body. What gets
969
+ * written is the `normalizeWrite` form (strips pure transport noise like
970
+ * the empty `audience` echo; for schemas the canonical body verbatim —
971
+ * spelled-out x-* defaults must survive, or the next apply would publish
972
+ * a revision without them). Equality is judged in the `normalize`
973
+ * comparison form, so the file is rewritten only when it materially
974
+ * differs from live state; hand-formatted files stay untouched otherwise.
975
+ * Returns the state hash of the written form.
976
+ */
977
+ async function writeBackResource(cwd, relPath, syncer, canonical) {
978
+ let writeBody = syncer.normalizeWrite?.(canonical) ?? canonical;
979
+ const compare = (body) => stableStringify(syncer.normalize?.(body) ?? body);
980
+ const absPath = join(cwd, relPath);
981
+ let changed = true;
982
+ try {
983
+ const onDisk = JSON.parse(await readFile(absPath, "utf8"));
984
+ changed = compare(writeBody) !== compare(onDisk);
985
+ if (typeof onDisk.$schema === "string" && !("$schema" in writeBody)) writeBody = {
986
+ ...writeBody,
987
+ $schema: onDisk.$schema
988
+ };
989
+ } catch (err) {
990
+ consola$1.debug(`read ${relPath} for write-back failed:`, err);
991
+ }
992
+ if (changed) await writeFile(absPath, `${stableStringify(writeBody)}\n`);
993
+ return {
994
+ hash: hashForState(syncer, writeBody),
995
+ changed
996
+ };
997
+ }
998
+ async function fetchOldIfAsked(syncer, id, fetchOld) {
999
+ if (!fetchOld || !syncer.fetch) return null;
1000
+ try {
1001
+ return await syncer.fetch(id);
1002
+ } catch (err) {
1003
+ consola$1.debug(`fetch ${syncer.kind} ${id} failed:`, err);
1004
+ return null;
1005
+ }
1006
+ }
1007
+ async function readJsonDir(dirPath) {
1008
+ const result = /* @__PURE__ */ new Map();
1009
+ let entries;
1010
+ try {
1011
+ entries = await readdir(dirPath);
1012
+ } catch (err) {
1013
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") return result;
1014
+ throw err;
1015
+ }
1016
+ for (const entry of entries.filter((e) => e.endsWith(".json"))) {
1017
+ const filePath = join(dirPath, entry);
1018
+ const raw = await readFile(filePath, "utf8");
1019
+ result.set(filePath, JSON.parse(raw));
1020
+ }
1021
+ return result;
1022
+ }
1023
+ /**
1024
+ * Walk `.zitadel/flows/*.json` once and return each flow's `user_schema` value
1025
+ * keyed by project-root-relative path. A flow file without a `user_schema` (or
1026
+ * with a non-string one) is skipped: it does not pin a schema revision, so it
1027
+ * cannot be affected by one.
1028
+ */
1029
+ async function readLocalFlowUserSchemas(cwd) {
1030
+ const result = /* @__PURE__ */ new Map();
1031
+ const flows = await readJsonDir(join(cwd, FLOWS_DIR));
1032
+ for (const [absPath, content] of flows.entries()) {
1033
+ const relPath = absPath.slice(cwd.length + 1);
1034
+ if (typeof content === "object" && content !== null && "user_schema" in content && typeof content.user_schema === "string") result.set(relPath, content.user_schema);
1035
+ }
1036
+ return result;
1037
+ }
1038
+ function findFlowsPinnedTo(previousId, localFlows) {
1039
+ const affected = [];
1040
+ for (const [relPath, ref] of localFlows.entries()) if (ref === previousId) affected.push(relPath);
1041
+ return affected;
1042
+ }
1043
+ /**
1044
+ * Legacy content hash: order-sensitive and normalization-blind. Kept only
1045
+ * so state entries written by older CLI versions still match; new hashes
1046
+ * come from {@link hashForState}.
1047
+ */
1048
+ function hashResourceContent(data) {
1049
+ return createHash("sha256").update(JSON.stringify(data)).digest("hex");
1050
+ }
1051
+ /**
1052
+ * The content hash stored in `.zitadel/state.json`: key-order-insensitive
1053
+ * (via `stableStringify`) and computed on the syncer's normalized form, so
1054
+ * reordering keys or spelling out a meta-schema default does not read as an
1055
+ * edit.
1056
+ */
1057
+ function hashForState(syncer, data) {
1058
+ const normalized = syncer.normalize?.(data) ?? data;
1059
+ return createHash("sha256").update(stableStringify(normalized)).digest("hex");
1060
+ }
1061
+ //#endregion
1062
+ //#region src/lib/sync/plan-renderer.ts
1063
+ /**
1064
+ * Count the non-`skip` actions in a {@link buildSyncPlan} result. Pure; the
1065
+ * single source of truth for the plan counts shared by the `plan` /
1066
+ * `apply --dry-run` JSON payload and {@link renderPlan}'s summary line.
1067
+ */
1068
+ function summarizePlan(actions) {
1069
+ const active = actions.filter((a) => a.kind !== "skip");
1070
+ return {
1071
+ creates: active.filter((a) => a.kind === "create").length,
1072
+ updates: active.filter((a) => a.kind === "update").length,
1073
+ revisions: active.filter((a) => a.kind === "revise").length,
1074
+ deletes: active.filter((a) => a.kind === "delete").length,
1075
+ total: active.length
1076
+ };
1077
+ }
1078
+ /**
1079
+ * Collect the plan-time validation warnings across all actions, tagged
1080
+ * with the file they belong to. Feeds the `plan` / `apply --dry-run`
1081
+ * `--json` payload so agents can read warnings structurally.
1082
+ */
1083
+ function collectPlanWarnings(actions) {
1084
+ const out = [];
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
+ });
1090
+ return out;
1091
+ }
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
+ /**
1100
+ * Enumerate the non-`skip` actions of a {@link buildSyncPlan} result as
1101
+ * envelope-ready {@link PlanResourceChange} rows. Pure; the structural
1102
+ * counterpart of {@link summarizePlan} — counts and rows always agree.
1103
+ */
1104
+ function enumeratePlanResources(actions) {
1105
+ const out = [];
1106
+ for (const action of actions) switch (action.kind) {
1107
+ case "create":
1108
+ out.push({
1109
+ kind: action.syncer.kind,
1110
+ action: "create",
1111
+ file: action.path
1112
+ });
1113
+ break;
1114
+ case "update":
1115
+ out.push({
1116
+ kind: action.syncer.kind,
1117
+ action: "update",
1118
+ file: action.path,
1119
+ id: action.id
1120
+ });
1121
+ break;
1122
+ case "revise":
1123
+ out.push({
1124
+ kind: action.syncer.kind,
1125
+ action: "revision",
1126
+ file: action.path,
1127
+ previous_id: action.previousId
1128
+ });
1129
+ break;
1130
+ case "delete":
1131
+ out.push({
1132
+ kind: action.syncer.kind,
1133
+ action: "delete",
1134
+ file: action.path,
1135
+ id: action.id
1136
+ });
1137
+ break;
1138
+ case "skip": break;
1139
+ }
1140
+ return out;
1141
+ }
1142
+ /**
1143
+ * Render a {@link buildSyncPlan} result as a human-readable Terraform-style
1144
+ * plan. TTY-aware: colors and bold are emitted only when `tty` is true.
1145
+ * Returns the empty-state message when every action is `skip`.
1146
+ *
1147
+ * @param actions - The action list produced by `buildSyncPlan`. Read-only;
1148
+ * the function never mutates the input.
1149
+ * @param tty - True when stdout is a TTY; controls ANSI emission.
1150
+ */
1151
+ function renderPlan(actions, tty) {
1152
+ const active = actions.filter((a) => a.kind !== "skip");
1153
+ if (active.length === 0) return paint("No changes. Your Zitadel configuration matches the current state.", A.bold, tty);
1154
+ const out = [];
1155
+ out.push(paint("Zitadel will perform the following actions:", A.bold, tty));
1156
+ for (const action of active) {
1157
+ out.push("");
1158
+ out.push(...renderBlock(action, tty));
1159
+ }
1160
+ out.push("");
1161
+ const { creates, updates, revisions, deletes } = summarizePlan(actions);
1162
+ const parts = [];
1163
+ if (creates > 0) parts.push(`${creates} to add`);
1164
+ if (updates > 0) parts.push(`${updates} to change`);
1165
+ if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
1166
+ if (deletes > 0) parts.push(`${deletes} to destroy`);
1167
+ out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
1168
+ const warningCount = active.reduce((count, action) => count + warningsOf(action).length, 0);
1169
+ if (warningCount > 0) out.push(paint(`Warnings: ${warningCount} (non-blocking — see the # warning lines above).`, A.yellow, tty));
1170
+ return out.join("\n");
1171
+ }
1172
+ const A = {
1173
+ reset: "\x1B[0m",
1174
+ bold: "\x1B[1m",
1175
+ green: "\x1B[32m",
1176
+ red: "\x1B[31m",
1177
+ yellow: "\x1B[33m"
1178
+ };
1179
+ function paint(text, code, tty) {
1180
+ return tty ? `${code}${text}${A.reset}` : text;
1181
+ }
1182
+ function isPrimitive(v) {
1183
+ return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
1184
+ }
1185
+ function isPlainObject(v) {
1186
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1187
+ }
1188
+ const KNOWN_AFTER_APPLY = "(known after apply)";
1189
+ function escapeString(s) {
1190
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
1191
+ }
1192
+ function fmtPrimitive(v) {
1193
+ if (v === null) return "null";
1194
+ if (typeof v === "string" && v === KNOWN_AFTER_APPLY) return KNOWN_AFTER_APPLY;
1195
+ if (typeof v === "string") return `"${escapeString(v)}"`;
1196
+ return String(v);
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
+ }
1229
+ function prefixAnsi(p) {
1230
+ if (p === "+") return A.green;
1231
+ if (p === "-") return A.red;
1232
+ if (p === "~") return A.yellow;
1233
+ return "";
1234
+ }
1235
+ function renderFields(obj, prefix, prefixCol, ctx, lines) {
1236
+ const pad = " ".repeat(prefixCol);
1237
+ const ansi = prefixAnsi(prefix);
1238
+ const col = (s) => paint(s, ansi, ctx.tty);
1239
+ const keys = Object.keys(obj).sort();
1240
+ const maxLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
1241
+ for (const key of keys) {
1242
+ const val = obj[key];
1243
+ const pk = key.padEnd(maxLen);
1244
+ if (isPrimitive(val)) {
1245
+ const formatted = fmtScalar(val);
1246
+ const suffix = ctx.deleteMode ? " -> null" : "";
1247
+ lines.push(col(`${pad}${prefix} ${pk} = ${formatted}${suffix}`));
1248
+ } else if (Array.isArray(val)) if (val.length === 0) lines.push(col(`${pad}${prefix} ${pk} = []`));
1249
+ else {
1250
+ lines.push(col(`${pad}${prefix} ${pk} = [`));
1251
+ renderArrayItems(val, prefix, prefixCol + 4, ctx, lines);
1252
+ lines.push(col(`${" ".repeat(prefixCol + 2)}]`));
1253
+ }
1254
+ else if (isPlainObject(val)) if (Object.keys(val).length === 0) lines.push(col(`${pad}${prefix} ${pk} = {}`));
1255
+ else {
1256
+ lines.push(col(`${pad}${prefix} ${pk} = {`));
1257
+ renderFields(val, prefix, prefixCol + 4, ctx, lines);
1258
+ lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
1259
+ }
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Renders the items of an array. Unlike {@link renderFields}, primitive
1264
+ * elements never get a trailing ` -> null` suffix even under `deleteMode` —
1265
+ * Terraform only annotates scalar object-field removals that way, not array
1266
+ * items.
1267
+ */
1268
+ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
1269
+ const pad = " ".repeat(prefixCol);
1270
+ const ansi = prefixAnsi(prefix);
1271
+ const col = (s) => paint(s, ansi, ctx.tty);
1272
+ for (const item of arr) if (isPrimitive(item)) {
1273
+ const formatted = fmtScalar(item);
1274
+ lines.push(col(`${pad}${prefix} ${formatted},`));
1275
+ } else if (Array.isArray(item)) if (item.length === 0) lines.push(col(`${pad}${prefix} [],`));
1276
+ else {
1277
+ lines.push(col(`${pad}${prefix} [`));
1278
+ renderArrayItems(item, prefix, prefixCol + 4, ctx, lines);
1279
+ lines.push(col(`${" ".repeat(prefixCol + 2)}],`));
1280
+ }
1281
+ else if (isPlainObject(item)) if (Object.keys(item).length === 0) lines.push(col(`${pad}${prefix} {},`));
1282
+ else {
1283
+ lines.push(col(`${pad}${prefix} {`));
1284
+ renderFields(item, prefix, prefixCol + 4, ctx, lines);
1285
+ lines.push(col(`${" ".repeat(prefixCol + 2)}},`));
1286
+ }
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
+ }
1384
+ /**
1385
+ * Walks both old and new objects, emitting Terraform-style change lines.
1386
+ * Returns true if any actual change line (+ / - / ~) was emitted.
1387
+ *
1388
+ * Edge cases:
1389
+ * - Changed arrays render as a full remove + full add (no LCS diff).
1390
+ * - Nested objects recurse, and the outer key is only marked `~` if a child
1391
+ * actually changed; unchanged children render with the neutral prefix.
1392
+ * - A value whose type changed (e.g. string → object) also renders as a
1393
+ * remove + add pair.
1394
+ */
1395
+ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
1396
+ const allKeys = [...new Set([...Object.keys(oldObj), ...Object.keys(newObj)])].sort();
1397
+ const maxLen = allKeys.reduce((m, k) => Math.max(m, k.length), 0);
1398
+ const pad = " ".repeat(prefixCol);
1399
+ let hasChanges = false;
1400
+ for (const key of allKeys) {
1401
+ const pk = key.padEnd(maxLen);
1402
+ const hasOld = Object.prototype.hasOwnProperty.call(oldObj, key);
1403
+ const hasNew = Object.prototype.hasOwnProperty.call(newObj, key);
1404
+ const oldVal = oldObj[key];
1405
+ const newVal = newObj[key];
1406
+ if (!hasOld) {
1407
+ hasChanges = true;
1408
+ const col = (s) => paint(s, A.green, tty);
1409
+ if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
1410
+ else if (Array.isArray(newVal)) {
1411
+ lines.push(col(`${pad}+ ${pk} = [`));
1412
+ renderArrayItems(newVal, "+", prefixCol + 4, {
1413
+ tty,
1414
+ deleteMode: false
1415
+ }, lines);
1416
+ lines.push(col(`${" ".repeat(prefixCol + 2)}]`));
1417
+ } else if (isPlainObject(newVal)) {
1418
+ lines.push(col(`${pad}+ ${pk} = {`));
1419
+ renderFields(newVal, "+", prefixCol + 4, {
1420
+ tty,
1421
+ deleteMode: false
1422
+ }, lines);
1423
+ lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
1424
+ }
1425
+ } else if (!hasNew) {
1426
+ hasChanges = true;
1427
+ const col = (s) => paint(s, A.red, tty);
1428
+ if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
1429
+ else if (Array.isArray(oldVal)) {
1430
+ lines.push(col(`${pad}- ${pk} = [`));
1431
+ renderArrayItems(oldVal, "-", prefixCol + 4, {
1432
+ tty,
1433
+ deleteMode: false
1434
+ }, lines);
1435
+ lines.push(col(`${" ".repeat(prefixCol + 2)}]`));
1436
+ } else if (isPlainObject(oldVal)) {
1437
+ lines.push(col(`${pad}- ${pk} = {`));
1438
+ renderFields(oldVal, "-", prefixCol + 4, {
1439
+ tty,
1440
+ deleteMode: true
1441
+ }, lines);
1442
+ lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
1443
+ }
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 {
1449
+ hasChanges = true;
1450
+ const col = (s) => paint(s, A.yellow, tty);
1451
+ lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
1452
+ }
1453
+ else if (Array.isArray(oldVal) && Array.isArray(newVal)) if (stableStringify(oldVal) === stableStringify(newVal)) if (newVal.length === 0) lines.push(`${pad} ${pk} = []`);
1454
+ else {
1455
+ lines.push(`${pad} ${pk} = [`);
1456
+ renderArrayItems(newVal, " ", prefixCol + 4, {
1457
+ tty,
1458
+ deleteMode: false
1459
+ }, lines);
1460
+ lines.push(`${" ".repeat(prefixCol + 2)}]`);
1461
+ }
1462
+ else {
1463
+ hasChanges = true;
1464
+ const colR = (s) => paint(s, A.red, tty);
1465
+ const colA = (s) => paint(s, A.green, tty);
1466
+ if (oldVal.length === 0) lines.push(colR(`${pad}- ${pk} = []`));
1467
+ else {
1468
+ lines.push(colR(`${pad}- ${pk} = [`));
1469
+ renderArrayItems(oldVal, "-", prefixCol + 4, {
1470
+ tty,
1471
+ deleteMode: false
1472
+ }, lines);
1473
+ lines.push(colR(`${" ".repeat(prefixCol + 2)}]`));
1474
+ }
1475
+ if (newVal.length === 0) lines.push(colA(`${pad}+ ${pk} = []`));
1476
+ else {
1477
+ lines.push(colA(`${pad}+ ${pk} = [`));
1478
+ renderArrayItems(newVal, "+", prefixCol + 4, {
1479
+ tty,
1480
+ deleteMode: false
1481
+ }, lines);
1482
+ lines.push(colA(`${" ".repeat(prefixCol + 2)}]`));
1483
+ }
1484
+ }
1485
+ else if (isPlainObject(oldVal) && isPlainObject(newVal)) {
1486
+ const childLines = [];
1487
+ if (renderDiff(oldVal, newVal, prefixCol + 4, tty, childLines)) {
1488
+ hasChanges = true;
1489
+ const col = (s) => paint(s, A.yellow, tty);
1490
+ lines.push(col(`${pad}~ ${pk} = {`));
1491
+ lines.push(...childLines);
1492
+ lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
1493
+ } else if (childLines.length > 0) {
1494
+ lines.push(`${pad} ${pk} = {`);
1495
+ lines.push(...childLines);
1496
+ lines.push(`${" ".repeat(prefixCol + 2)}}`);
1497
+ } else lines.push(`${pad} ${pk} = {}`);
1498
+ } else {
1499
+ hasChanges = true;
1500
+ const colR = (s) => paint(s, A.red, tty);
1501
+ const colA = (s) => paint(s, A.green, tty);
1502
+ if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
1503
+ if (isPrimitive(newVal)) lines.push(colA(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
1504
+ }
1505
+ }
1506
+ return hasChanges;
1507
+ }
1508
+ /**
1509
+ * Column layout (matches Terraform's per-block format):
1510
+ * BLOCK_COL = 2 — where the +/-/~ sits on the resource opening line
1511
+ * FIELD_COL = 6 — where the +/-/~ sits on first-level field lines
1512
+ * closing } — at BLOCK_COL + 2 = 4, no prefix
1513
+ */
1514
+ const BLOCK_COL = 2;
1515
+ const FIELD_COL = 6;
1516
+ function resourceName(path) {
1517
+ return path.split("/").pop() ?? path;
1518
+ }
1519
+ /**
1520
+ * Diff both sides in the syncer's canonical form so server-echoed noise
1521
+ * (empty `audience`, spelled-out meta-schema defaults) never renders as a
1522
+ * change the author didn't make. Rendering only — upload payloads stay raw.
1523
+ */
1524
+ function normalized(syncer, content) {
1525
+ return syncer.normalize?.(content) ?? content;
1526
+ }
1527
+ /**
1528
+ * Renders one Terraform-style resource block for a single `SyncAction`.
1529
+ *
1530
+ * Per-case notes:
1531
+ * - **create**: a synthetic `id = (known after apply)` is injected into the
1532
+ * rendered fields so it sorts alphabetically alongside the real keys.
1533
+ * - **delete**: when `oldContent` is null (the fetch failed), the body
1534
+ * collapses to a single `- id = "<id>" -> null` line.
1535
+ * - **update**: when `oldContent` is null (no read endpoint for this
1536
+ * resource kind), the field diff is replaced with a placeholder
1537
+ * "field diff unavailable" line.
1538
+ * - **skip**: omitted from the output entirely, matching Terraform's
1539
+ * default of not showing no-change resources.
1540
+ */
1541
+ function renderBlock(action, tty) {
1542
+ const lines = [];
1543
+ const blkPad = " ".repeat(BLOCK_COL);
1544
+ const closePad = " ".repeat(BLOCK_COL + 2);
1545
+ switch (action.kind) {
1546
+ case "create": {
1547
+ const header = `${blkPad}# ${action.path} will be created`;
1548
+ const opening = `${blkPad}+ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
1549
+ lines.push(paint(header, A.bold, tty));
1550
+ lines.push(paint(opening, A.green, tty));
1551
+ const display = {
1552
+ id: KNOWN_AFTER_APPLY,
1553
+ ...action.content
1554
+ };
1555
+ if (action.repin) display.user_schema = action.repin.newId ?? KNOWN_AFTER_APPLY;
1556
+ renderFields(display, "+", FIELD_COL, {
1557
+ tty,
1558
+ deleteMode: false
1559
+ }, lines);
1560
+ lines.push(`${closePad}}`);
1561
+ renderWarnings(action.warnings, blkPad, tty, lines);
1562
+ break;
1563
+ }
1564
+ case "delete": {
1565
+ const header = `${blkPad}# ${action.path} will be destroyed`;
1566
+ const opening = `${blkPad}- resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
1567
+ lines.push(paint(header, A.bold, tty));
1568
+ lines.push(paint(opening, A.red, tty));
1569
+ if (action.oldContent) renderFields({
1570
+ id: action.id,
1571
+ ...action.oldContent
1572
+ }, "-", FIELD_COL, {
1573
+ tty,
1574
+ deleteMode: true
1575
+ }, lines);
1576
+ else lines.push(paint(`${" ".repeat(FIELD_COL)}- id = "${action.id}" -> null`, A.red, tty));
1577
+ lines.push(`${closePad}}`);
1578
+ break;
1579
+ }
1580
+ case "update": {
1581
+ const headerSuffix = action.repin ? " (re-pin user_schema)" : "";
1582
+ const header = `${blkPad}# ${action.path} will be updated in-place${headerSuffix}`;
1583
+ const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
1584
+ lines.push(paint(header, A.bold, tty));
1585
+ lines.push(paint(opening, A.yellow, tty));
1586
+ const newContent = action.repin ? {
1587
+ ...normalized(action.syncer, action.content),
1588
+ user_schema: action.repin.newId ?? KNOWN_AFTER_APPLY
1589
+ } : normalized(action.syncer, action.content);
1590
+ if (action.oldContent) renderDiff(normalized(action.syncer, action.oldContent), newContent, FIELD_COL, tty, lines);
1591
+ else if (action.repin) lines.push(paint(`${" ".repeat(FIELD_COL)}~ user_schema = "${action.repin.previousId}" -> ${action.repin.newId ? `"${action.repin.newId}"` : KNOWN_AFTER_APPLY}`, A.yellow, tty));
1592
+ else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
1593
+ lines.push(`${closePad}}`);
1594
+ renderWarnings(action.warnings, blkPad, tty, lines);
1595
+ break;
1596
+ }
1597
+ case "revise": {
1598
+ const header = `${blkPad}# ${action.path} will publish a new revision`;
1599
+ const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
1600
+ lines.push(paint(header, A.bold, tty));
1601
+ lines.push(paint(opening, A.yellow, tty));
1602
+ if (action.oldContent) renderDiff({
1603
+ id: action.previousId,
1604
+ ...normalized(action.syncer, action.oldContent)
1605
+ }, {
1606
+ id: KNOWN_AFTER_APPLY,
1607
+ ...normalized(action.syncer, action.content)
1608
+ }, FIELD_COL, tty, lines);
1609
+ else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
1610
+ lines.push(`${closePad}}`);
1611
+ renderWarnings(action.warnings, blkPad, tty, lines);
1612
+ if (action.affectedPaths.length > 0) {
1613
+ lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
1614
+ for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
1615
+ }
1616
+ break;
1617
+ }
1618
+ case "skip": break;
1619
+ }
1620
+ return lines;
1621
+ }
1622
+ /**
1623
+ * Emit one yellow `# warning:` comment line per plan-time validation
1624
+ * warning, below the action's closing brace (same channel as the revise
1625
+ * re-pin announcement). Warnings never block the plan.
1626
+ */
1627
+ function renderWarnings(warnings, blkPad, tty, lines) {
1628
+ for (const warning of warnings ?? []) lines.push(paint(`${blkPad}# warning: ${warning.message}`, A.yellow, tty));
1629
+ }
1630
+ //#endregion
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 };
1632
+
1633
+ //# sourceMappingURL=sync-BBlACKzT.mjs.map