@zitadel/cli 0.1.0-alpha.14 → 0.1.0-alpha.16

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 (43) hide show
  1. package/README.md +2 -2
  2. package/SKILLS.md +15 -6
  3. package/dist/commands/apply.mjs +13 -7
  4. package/dist/commands/apply.mjs.map +1 -1
  5. package/dist/commands/doctor.mjs +7 -7
  6. package/dist/commands/eject.mjs +17 -5
  7. package/dist/commands/eject.mjs.map +1 -1
  8. package/dist/commands/logs.mjs +2 -2
  9. package/dist/commands/plan.mjs +9 -6
  10. package/dist/commands/plan.mjs.map +1 -1
  11. package/dist/commands/reset.mjs +2 -2
  12. package/dist/commands/schemas/list.mjs +3 -3
  13. package/dist/commands/setup.mjs +154 -40
  14. package/dist/commands/setup.mjs.map +1 -1
  15. package/dist/commands/start.mjs +4 -4
  16. package/dist/commands/status.mjs +40 -9
  17. package/dist/commands/status.mjs.map +1 -1
  18. package/dist/commands/stop.mjs +3 -3
  19. package/dist/{docker-D7BSD5C9.mjs → docker-BbX0yOpg.mjs} +2 -2
  20. package/dist/{docker-D7BSD5C9.mjs.map → docker-BbX0yOpg.mjs.map} +1 -1
  21. package/dist/{docker-guidance-mcTT3_0E.mjs → docker-guidance-3UZESTfb.mjs} +2 -2
  22. package/dist/{docker-guidance-mcTT3_0E.mjs.map → docker-guidance-3UZESTfb.mjs.map} +1 -1
  23. package/dist/{environment-BQF7LeCz.mjs → environment-ABj1IWZt.mjs} +1 -1
  24. package/dist/{environment-BQF7LeCz.mjs.map → environment-ABj1IWZt.mjs.map} +1 -1
  25. package/dist/journey-guidance-E_3_UwGt.mjs +29 -0
  26. package/dist/journey-guidance-E_3_UwGt.mjs.map +1 -0
  27. package/dist/{oclif-Bm-FkF6z.mjs → oclif-SjHLB_XK.mjs} +17 -35
  28. package/dist/oclif-SjHLB_XK.mjs.map +1 -0
  29. package/dist/{orca-CpXj_XsA.mjs → orca-_TmrJQMw.mjs} +209 -163
  30. package/dist/orca-_TmrJQMw.mjs.map +1 -0
  31. package/dist/{ports-CxBKS1ga.mjs → ports-XujDtA47.mjs} +1 -1
  32. package/dist/{ports-CxBKS1ga.mjs.map → ports-XujDtA47.mjs.map} +1 -1
  33. package/dist/{processes-BVqYsxT8.mjs → processes-p2PPbKGG.mjs} +1 -1
  34. package/dist/{processes-BVqYsxT8.mjs.map → processes-p2PPbKGG.mjs.map} +1 -1
  35. package/dist/{project-Dk7V0xka.mjs → project-DXc7q4wN.mjs} +2 -2
  36. package/dist/{project-Dk7V0xka.mjs.map → project-DXc7q4wN.mjs.map} +1 -1
  37. package/dist/{sync-nSLnVhKK.mjs → sync-Bu_iLIbb.mjs} +351 -29
  38. package/dist/sync-Bu_iLIbb.mjs.map +1 -0
  39. package/oclif.manifest.json +12 -1
  40. package/package.json +5 -5
  41. package/dist/oclif-Bm-FkF6z.mjs.map +0 -1
  42. package/dist/orca-CpXj_XsA.mjs.map +0 -1
  43. package/dist/sync-nSLnVhKK.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"project-Dk7V0xka.mjs","names":[],"sources":["../src/lib/project.ts"],"sourcesContent":["import { readFile, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { ZitadelError } from \"./errors\";\nimport { isObject, parseJsonObject } from \"./json\";\n\n/**\n * Reports whether `cwd` has already been initialized, i.e. a committed\n * `zitadel.json` exists. Used to decide whether setup should run or skip.\n */\nexport async function hasZitadelConfig(cwd: string): Promise<boolean> {\n return exists(join(cwd, \"zitadel.json\"));\n}\n\n/**\n * Reports whether local secret material (`.zitadel/secret`) is present. Gates\n * commands that need credentials, and signals that secrets were already pulled.\n */\nexport async function hasZitadelSecret(cwd: string): Promise<boolean> {\n return exists(join(cwd, \".zitadel/secret\"));\n}\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await stat(path);\n return true;\n } catch (error) {\n if (isNotFound(error)) {\n return false;\n }\n throw error;\n }\n}\n\n/**\n * Shape of the project secret persisted at `.zitadel/secret`. Holds the\n * project identity plus the credentials used to talk to the platform in\n * preview and production. Validated structurally by {@link readZitadelSecret}.\n */\nexport type ZitadelSecret = {\n project_id: string;\n project_secret: string;\n preview_secret: string;\n preview_origins: string[];\n created_at: string;\n};\n\n/**\n * Reads and parses `zitadel.json` into a plain object. Translates a missing\n * file into an actionable `E_VALIDATION` error pointing at `zitadel setup`;\n * other errors (e.g. malformed JSON) propagate unchanged.\n */\nexport async function readZitadelConfig(cwd: string): Promise<Record<string, unknown>> {\n try {\n return parseJsonObject(await readFile(join(cwd, \"zitadel.json\"), \"utf8\"), \"zitadel.json\");\n } catch (error) {\n if (isNotFound(error)) {\n throw new ZitadelError(\"E_VALIDATION\", \"zitadel.json was not found\", {\n hint: \"Run `zitadel setup` first.\",\n nextCommands: [\"zitadel setup\"],\n });\n }\n throw error;\n }\n}\n\n/**\n * Reads, parses, and structurally validates `.zitadel/secret`, returning it\n * as a {@link ZitadelSecret}. A missing file becomes an actionable\n * `E_VALIDATION` error pointing at `zitadel setup` / `zitadel doctor --fix`;\n * a present-but-incomplete file throws so callers never proceed with partial\n * credentials.\n */\nexport async function readZitadelSecret(cwd: string): Promise<ZitadelSecret> {\n try {\n const secret = parseJsonObject(\n await readFile(join(cwd, \".zitadel/secret\"), \"utf8\"),\n \".zitadel/secret\",\n );\n if (\n typeof secret.project_id !== \"string\" ||\n typeof secret.project_secret !== \"string\" ||\n typeof secret.preview_secret !== \"string\" ||\n !Array.isArray(secret.preview_origins)\n ) {\n throw new Error(\".zitadel/secret is missing required fields\");\n }\n return secret as ZitadelSecret;\n } catch (error) {\n if (isNotFound(error)) {\n throw new ZitadelError(\"E_VALIDATION\", \".zitadel/secret was not found\", {\n hint: \"Run `zitadel setup` first, or restore the project secret with `zitadel doctor --fix`.\",\n nextCommands: [\"zitadel setup\", \"zitadel doctor --fix\"],\n });\n }\n throw error;\n }\n}\n\n/**\n * Reads the configured renderer id from a parsed `zitadel.json`, normalising the\n * legacy `default` alias to `react` and falling back to `react` when unset. The\n * value is validated downstream by `getRenderer`, so callers need not re-check.\n */\nexport function readRendererId(config: Record<string, unknown>): string {\n const branding = isObject(config.branding) ? config.branding : undefined;\n const value = branding && typeof branding.renderer === \"string\" ? branding.renderer : \"react\";\n return value === \"default\" ? \"react\" : value;\n}\n\n/** Reads `environments.development.issuer` from a parsed `zitadel.json`, if present. */\nexport function readDevelopmentIssuer(config: Record<string, unknown>): string | undefined {\n if (isObject(config.environments) && isObject(config.environments.development)) {\n const issuer = config.environments.development.issuer;\n return typeof issuer === \"string\" ? issuer : undefined;\n }\n return undefined;\n}\n\nfunction isNotFound(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"code\" in error &&\n (error as { code?: string }).code === \"ENOENT\"\n );\n}\n"],"mappings":";;;;;;;;AAUA,eAAsB,iBAAiB,KAA+B;AACpE,QAAO,OAAO,KAAK,KAAK,eAAe,CAAC;;;;;;AAO1C,eAAsB,iBAAiB,KAA+B;AACpE,QAAO,OAAO,KAAK,KAAK,kBAAkB,CAAC;;AAG7C,eAAe,OAAO,MAAgC;AACpD,KAAI;AACF,QAAM,KAAK,KAAK;AAChB,SAAO;UACA,OAAO;AACd,MAAI,WAAW,MAAM,CACnB,QAAO;AAET,QAAM;;;;;;;;AAsBV,eAAsB,kBAAkB,KAA+C;AACrF,KAAI;AACF,SAAO,gBAAgB,MAAM,SAAS,KAAK,KAAK,eAAe,EAAE,OAAO,EAAE,eAAe;UAClF,OAAO;AACd,MAAI,WAAW,MAAM,CACnB,OAAM,IAAI,aAAa,gBAAgB,8BAA8B;GACnE,MAAM;GACN,cAAc,CAAC,gBAAgB;GAChC,CAAC;AAEJ,QAAM;;;;;;;;;;AAWV,eAAsB,kBAAkB,KAAqC;AAC3E,KAAI;EACF,MAAM,SAAS,gBACb,MAAM,SAAS,KAAK,KAAK,kBAAkB,EAAE,OAAO,EACpD,kBACD;AACD,MACE,OAAO,OAAO,eAAe,YAC7B,OAAO,OAAO,mBAAmB,YACjC,OAAO,OAAO,mBAAmB,YACjC,CAAC,MAAM,QAAQ,OAAO,gBAAgB,CAEtC,OAAM,IAAI,MAAM,6CAA6C;AAE/D,SAAO;UACA,OAAO;AACd,MAAI,WAAW,MAAM,CACnB,OAAM,IAAI,aAAa,gBAAgB,iCAAiC;GACtE,MAAM;GACN,cAAc,CAAC,iBAAiB,uBAAuB;GACxD,CAAC;AAEJ,QAAM;;;;;;;;AASV,SAAgB,eAAe,QAAyC;CACtE,MAAM,WAAW,SAAS,OAAO,SAAS,GAAG,OAAO,WAAW,KAAA;CAC/D,MAAM,QAAQ,YAAY,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AACtF,QAAO,UAAU,YAAY,UAAU;;;AAIzC,SAAgB,sBAAsB,QAAqD;AACzF,KAAI,SAAS,OAAO,aAAa,IAAI,SAAS,OAAO,aAAa,YAAY,EAAE;EAC9E,MAAM,SAAS,OAAO,aAAa,YAAY;AAC/C,SAAO,OAAO,WAAW,WAAW,SAAS,KAAA;;;AAKjD,SAAS,WAAW,OAAyB;AAC3C,QACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACT,MAA4B,SAAS"}
1
+ {"version":3,"file":"project-DXc7q4wN.mjs","names":[],"sources":["../src/lib/project.ts"],"sourcesContent":["import { readFile, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { ZitadelError } from \"./errors\";\nimport { isObject, parseJsonObject } from \"./json\";\n\n/**\n * Reports whether `cwd` has already been initialized, i.e. a committed\n * `zitadel.json` exists. Used to decide whether setup should run or skip.\n */\nexport async function hasZitadelConfig(cwd: string): Promise<boolean> {\n return exists(join(cwd, \"zitadel.json\"));\n}\n\n/**\n * Reports whether local secret material (`.zitadel/secret`) is present. Gates\n * commands that need credentials, and signals that secrets were already pulled.\n */\nexport async function hasZitadelSecret(cwd: string): Promise<boolean> {\n return exists(join(cwd, \".zitadel/secret\"));\n}\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await stat(path);\n return true;\n } catch (error) {\n if (isNotFound(error)) {\n return false;\n }\n throw error;\n }\n}\n\n/**\n * Shape of the project secret persisted at `.zitadel/secret`. Holds the\n * project identity plus the credentials used to talk to the platform in\n * preview and production. Validated structurally by {@link readZitadelSecret}.\n */\nexport type ZitadelSecret = {\n project_id: string;\n project_secret: string;\n preview_secret: string;\n preview_origins: string[];\n created_at: string;\n};\n\n/**\n * Reads and parses `zitadel.json` into a plain object. Translates a missing\n * file into an actionable `E_VALIDATION` error pointing at `zitadel setup`;\n * other errors (e.g. malformed JSON) propagate unchanged.\n */\nexport async function readZitadelConfig(cwd: string): Promise<Record<string, unknown>> {\n try {\n return parseJsonObject(await readFile(join(cwd, \"zitadel.json\"), \"utf8\"), \"zitadel.json\");\n } catch (error) {\n if (isNotFound(error)) {\n throw new ZitadelError(\"E_VALIDATION\", \"zitadel.json was not found\", {\n hint: \"Run `zitadel setup` first.\",\n nextCommands: [\"zitadel setup\"],\n });\n }\n throw error;\n }\n}\n\n/**\n * Reads, parses, and structurally validates `.zitadel/secret`, returning it\n * as a {@link ZitadelSecret}. A missing file becomes an actionable\n * `E_VALIDATION` error pointing at `zitadel setup` / `zitadel doctor --fix`;\n * a present-but-incomplete file throws so callers never proceed with partial\n * credentials.\n */\nexport async function readZitadelSecret(cwd: string): Promise<ZitadelSecret> {\n try {\n const secret = parseJsonObject(\n await readFile(join(cwd, \".zitadel/secret\"), \"utf8\"),\n \".zitadel/secret\",\n );\n if (\n typeof secret.project_id !== \"string\" ||\n typeof secret.project_secret !== \"string\" ||\n typeof secret.preview_secret !== \"string\" ||\n !Array.isArray(secret.preview_origins)\n ) {\n throw new Error(\".zitadel/secret is missing required fields\");\n }\n return secret as ZitadelSecret;\n } catch (error) {\n if (isNotFound(error)) {\n throw new ZitadelError(\"E_VALIDATION\", \".zitadel/secret was not found\", {\n hint: \"Run `zitadel setup` first, or restore the project secret with `zitadel doctor --fix`.\",\n nextCommands: [\"zitadel setup\", \"zitadel doctor --fix\"],\n });\n }\n throw error;\n }\n}\n\n/**\n * Reads the configured renderer id from a parsed `zitadel.json`, normalising the\n * legacy `default` alias to `react` and falling back to `react` when unset. The\n * value is validated downstream by `getRenderer`, so callers need not re-check.\n */\nexport function readRendererId(config: Record<string, unknown>): string {\n const branding = isObject(config.branding) ? config.branding : undefined;\n const value = branding && typeof branding.renderer === \"string\" ? branding.renderer : \"react\";\n return value === \"default\" ? \"react\" : value;\n}\n\n/** Reads `environments.development.issuer` from a parsed `zitadel.json`, if present. */\nexport function readDevelopmentIssuer(config: Record<string, unknown>): string | undefined {\n if (isObject(config.environments) && isObject(config.environments.development)) {\n const issuer = config.environments.development.issuer;\n return typeof issuer === \"string\" ? issuer : undefined;\n }\n return undefined;\n}\n\nfunction isNotFound(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"code\" in error &&\n (error as { code?: string }).code === \"ENOENT\"\n );\n}\n"],"mappings":";;;;;;;;AAUA,eAAsB,iBAAiB,KAA+B;AACpE,QAAO,OAAO,KAAK,KAAK,eAAe,CAAC;;;;;;AAO1C,eAAsB,iBAAiB,KAA+B;AACpE,QAAO,OAAO,KAAK,KAAK,kBAAkB,CAAC;;AAG7C,eAAe,OAAO,MAAgC;AACpD,KAAI;AACF,QAAM,KAAK,KAAK;AAChB,SAAO;UACA,OAAO;AACd,MAAI,WAAW,MAAM,CACnB,QAAO;AAET,QAAM;;;;;;;;AAsBV,eAAsB,kBAAkB,KAA+C;AACrF,KAAI;AACF,SAAO,gBAAgB,MAAM,SAAS,KAAK,KAAK,eAAe,EAAE,OAAO,EAAE,eAAe;UAClF,OAAO;AACd,MAAI,WAAW,MAAM,CACnB,OAAM,IAAI,aAAa,gBAAgB,8BAA8B;GACnE,MAAM;GACN,cAAc,CAAC,gBAAgB;GAChC,CAAC;AAEJ,QAAM;;;;;;;;;;AAWV,eAAsB,kBAAkB,KAAqC;AAC3E,KAAI;EACF,MAAM,SAAS,gBACb,MAAM,SAAS,KAAK,KAAK,kBAAkB,EAAE,OAAO,EACpD,kBACD;AACD,MACE,OAAO,OAAO,eAAe,YAC7B,OAAO,OAAO,mBAAmB,YACjC,OAAO,OAAO,mBAAmB,YACjC,CAAC,MAAM,QAAQ,OAAO,gBAAgB,CAEtC,OAAM,IAAI,MAAM,6CAA6C;AAE/D,SAAO;UACA,OAAO;AACd,MAAI,WAAW,MAAM,CACnB,OAAM,IAAI,aAAa,gBAAgB,iCAAiC;GACtE,MAAM;GACN,cAAc,CAAC,iBAAiB,uBAAuB;GACxD,CAAC;AAEJ,QAAM;;;;;;;;AASV,SAAgB,eAAe,QAAyC;CACtE,MAAM,WAAW,SAAS,OAAO,SAAS,GAAG,OAAO,WAAW,KAAA;CAC/D,MAAM,QAAQ,YAAY,OAAO,SAAS,aAAa,WAAW,SAAS,WAAW;AACtF,QAAO,UAAU,YAAY,UAAU;;;AAIzC,SAAgB,sBAAsB,QAAqD;AACzF,KAAI,SAAS,OAAO,aAAa,IAAI,SAAS,OAAO,aAAa,YAAY,EAAE;EAC9E,MAAM,SAAS,OAAO,aAAa,YAAY;AAC/C,SAAO,OAAO,WAAW,WAAW,SAAS,KAAA;;;AAKjD,SAAS,WAAW,OAAyB;AAC3C,QACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACT,MAA4B,SAAS"}
@@ -1,11 +1,13 @@
1
- import { C as isObject, E as ZitadelError } from "./oclif-Bm-FkF6z.mjs";
1
+ import { C as isObject, E as ZitadelError, T as stableStringify } from "./oclif-SjHLB_XK.mjs";
2
2
  import { t as SCHEMAS_DIR } from "./user-schema-DDz5-lX5.mjs";
3
3
  import { readFile, readdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
5
6
  import { consola as consola$1 } from "consola";
6
7
  import { createHash } from "node:crypto";
7
- import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
8
+ import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
8
9
  import { flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
10
+ import { validateFlowDefinition } from "@zitadel/config/validate";
9
11
  //#region src/lib/flows/env-refs.ts
10
12
  /**
11
13
  * Collects the environment variables a flows document depends on, sorted and
@@ -66,6 +68,7 @@ var SchemaSyncer = class {
66
68
  directory = SCHEMAS_DIR;
67
69
  mutable = false;
68
70
  revisioned = true;
71
+ normalize = normalizeSchemaBody;
69
72
  constructor(client, projectId, env) {
70
73
  this.client = client;
71
74
  this.projectId = projectId;
@@ -85,9 +88,21 @@ var SchemaSyncer = class {
85
88
  /**
86
89
  * `POST /schemas` mints a new immutable row. The server allocates the
87
90
  * opaque id; the CLI records it in state and re-pins flows against it.
91
+ * The create response carries only the id, so the canonical stored body
92
+ * comes from a follow-up fetch; a fetch failure degrades to no
93
+ * write-back rather than failing the create.
88
94
  */
89
95
  async create(data) {
90
- return (await this.client.createSchema(data, { project_id: this.projectId })).id;
96
+ const result = await this.client.createSchema(data, { project_id: this.projectId });
97
+ try {
98
+ return {
99
+ id: result.id,
100
+ canonical: await this.fetch(result.id)
101
+ };
102
+ } catch (err) {
103
+ consola$1.debug(`fetch created schema ${result.id} failed:`, err);
104
+ return { id: result.id };
105
+ }
91
106
  }
92
107
  /**
93
108
  * Not called by the sync loop: schemas are `revisioned`, so a hash change
@@ -110,6 +125,8 @@ var FlowDefinitionSyncer = class {
110
125
  directory = FLOWS_DIR;
111
126
  mutable = true;
112
127
  revisioned = false;
128
+ normalize = normalizeFlowBody;
129
+ normalizeWrite = normalizeFlowBody;
113
130
  constructor(client, projectId, env) {
114
131
  this.client = client;
115
132
  this.projectId = projectId;
@@ -132,11 +149,15 @@ var FlowDefinitionSyncer = class {
132
149
  * envelope.
133
150
  */
134
151
  async create(data) {
135
- return (await this.client.createFlowDefinition({
152
+ const result = await this.client.createFlowDefinition({
136
153
  project_id: this.projectId,
137
154
  schema_uri: DEFAULT_FLOW_SCHEMA_URI,
138
155
  flow_definition: data
139
- })).id;
156
+ });
157
+ return {
158
+ id: result.id,
159
+ canonical: result.flow_definition
160
+ };
140
161
  }
141
162
  /**
142
163
  * PUT completely replaces the flow definition. The wire request wraps the
@@ -146,7 +167,7 @@ var FlowDefinitionSyncer = class {
146
167
  * it is human-editable.
147
168
  */
148
169
  async update(id, data) {
149
- await this.client.updateFlowDefinition(id, { flow_definition: data }, { project_id: this.projectId });
170
+ return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data }, { project_id: this.projectId })).flow_definition };
150
171
  }
151
172
  async delete(id) {
152
173
  await this.client.deleteFlowDefinition(id, { project_id: this.projectId });
@@ -161,6 +182,109 @@ var FlowDefinitionSyncer = class {
161
182
  }
162
183
  };
163
184
  //#endregion
185
+ //#region src/lib/sync/flow-validation.ts
186
+ /**
187
+ * Pre-flight semantic validation for every flow this plan uploads,
188
+ * mirroring the server-side `ValidateFlowDefinition` (see
189
+ * `@zitadel/config/validate`). Runs inside `buildSyncPlan`, so `plan`
190
+ * reports the violations the server would only reveal on apply — and
191
+ * `apply` fails before any platform mutation instead of half-applied
192
+ * (a schema revision would otherwise publish before the flow update
193
+ * fails).
194
+ *
195
+ * Only actions that upload a flow (create/update, including repin-forced
196
+ * updates) are validated: an unchanged flow is never re-judged, so a
197
+ * validator behavior change cannot break an already-applied project.
198
+ *
199
+ * Warning-severity issues ANNOTATE the actions in place (`action.warnings`)
200
+ * for plan rendering — `actions` is deliberately typed mutable because the
201
+ * caller (`buildSyncPlan`, which owns the array it just built) expects the
202
+ * annotations. Error-severity issues aggregate into one E_VALIDATION
203
+ * carrying every violation.
204
+ *
205
+ * Escape hatch: setting `ZITADEL_SKIP_FLOW_VALIDATION` skips the check —
206
+ * insurance against a port bug rejecting something the server accepts.
207
+ */
208
+ function validatePlannedFlows(opts) {
209
+ if (process.env.ZITADEL_SKIP_FLOW_VALIDATION) {
210
+ consola$1.warn("Flow validation skipped (ZITADEL_SKIP_FLOW_VALIDATION is set)");
211
+ return;
212
+ }
213
+ const failures = [];
214
+ let anyRepin = false;
215
+ const flowCreatePaths = opts.actions.filter((a) => a.kind === "create" && a.syncer.kind === "flow").map((a) => a.path);
216
+ const trackedFlowPaths = Object.keys(opts.stateResources).filter((path) => path.startsWith(`${FLOWS_DIR}/`));
217
+ for (const action of opts.actions) {
218
+ if (action.kind !== "create" && action.kind !== "update") continue;
219
+ if (action.syncer.kind !== "flow") continue;
220
+ const issues = validateFlowDefinition(action.content, resolveSchemaContent(action, opts.scannedContents, opts.stateResources));
221
+ const warnings = issues.filter((issue) => issue.severity === "warning");
222
+ if (warnings.length > 0) action.warnings = warnings.map(({ rule, message }) => ({
223
+ rule,
224
+ message
225
+ }));
226
+ for (const issue of issues) if (issue.severity === "error") {
227
+ failures.push({
228
+ path: action.path,
229
+ issue
230
+ });
231
+ anyRepin ||= action.repin !== void 0;
232
+ }
233
+ const swapWarning = defaultSwapWarning(action, flowCreatePaths, trackedFlowPaths);
234
+ if (swapWarning !== void 0) action.warnings = [...action.warnings ?? [], swapWarning];
235
+ }
236
+ if (failures.length === 0) return;
237
+ const noun = failures.length === 1 ? "issue" : "issues";
238
+ throw new ZitadelError("E_VALIDATION", `Flow validation failed (${failures.length} ${noun}):\n` + failures.map(({ path, issue }) => ` - ${path}: ${issue.message}`).join("\n"), {
239
+ 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)." : ""),
240
+ details: { issues: failures.map(({ path, issue }) => ({
241
+ path,
242
+ rule: issue.rule,
243
+ message: issue.message,
244
+ ...issue.step === void 0 ? {} : { step: issue.step }
245
+ })) }
246
+ });
247
+ }
248
+ /**
249
+ * Warn when applying a NEW active, unscoped flow into a project that
250
+ * already has flows: the engine's default selection is newest-unscoped-
251
+ * wins (ties broken by id), so this flow silently becomes what
252
+ * `<zitadel-login>` renders for its purposes unless the widget pins a
253
+ * `flow-name`. Deliberately engine-level rather than a validator rule:
254
+ * the verdict depends on the action kind and the rest of the plan/state,
255
+ * which `validateFlowDefinition` never sees. A project's first flow is
256
+ * exempt — becoming the default is the point of creating it.
257
+ */
258
+ function defaultSwapWarning(action, flowCreatePaths, trackedFlowPaths) {
259
+ if (action.kind !== "create") return;
260
+ const body = action.content;
261
+ if (body.status !== "active") return;
262
+ if ((body.audience?.team_ids?.length ?? 0) > 0 || (body.audience?.app_ids?.length ?? 0) > 0) return;
263
+ const purposes = Object.keys(body.purposes ?? {});
264
+ if (purposes.length === 0) return;
265
+ if (trackedFlowPaths.filter((path) => path !== action.path).length + flowCreatePaths.filter((path) => path !== action.path).length === 0) return;
266
+ return {
267
+ rule: "warn/default-flow-swap",
268
+ message: `applying this new active flow makes it the newest unscoped definition — clients that do not pin a flow-name will get it as the default for ${purposes.join(", ")}. Scope it via "audience" or set flow-name in the widget if that is not intended.`
269
+ };
270
+ }
271
+ /**
272
+ * Locate the local content of the schema a flow's `user_schema` pins.
273
+ * A repin action already names the schema file (the pin is adopting that
274
+ * file's next/new revision); otherwise the pinned id is looked up in
275
+ * state and its file body taken from this scan. Unresolvable refs (e.g.
276
+ * an un-substituted `${USER_SCHEMA_URL}` placeholder or a server-side
277
+ * schema not tracked locally) return undefined — flow-local rules still
278
+ * run, schema-dependent rules are skipped.
279
+ */
280
+ function resolveSchemaContent(action, scannedContents, stateResources) {
281
+ if (action.repin) return scannedContents.get(action.repin.schemaPath);
282
+ const ref = action.content.user_schema;
283
+ if (typeof ref !== "string" || ref === "") return;
284
+ for (const [path, entry] of Object.entries(stateResources)) if (entry.id === ref) return scannedContents.get(path);
285
+ consola$1.debug(`flow validation: no local schema for user_schema ${ref}; flow-local rules only`);
286
+ }
287
+ //#endregion
164
288
  //#region src/lib/sync/state.ts
165
289
  /**
166
290
  * Read and parse `.zitadel/state.json`. Throws if the file is
@@ -229,6 +353,13 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
229
353
  const state = await readState(cwd);
230
354
  const actions = [];
231
355
  const localFlows = await readLocalFlowUserSchemas(cwd);
356
+ const pendingRevisions = /* @__PURE__ */ new Map();
357
+ const recoveredRevisions = /* @__PURE__ */ new Map();
358
+ for (const [schemaPath, entry] of Object.entries(state.resources)) if (entry.previousId && entry.id && entry.previousId !== entry.id) recoveredRevisions.set(entry.previousId, {
359
+ schemaPath,
360
+ newId: entry.id
361
+ });
362
+ const scannedContents = /* @__PURE__ */ new Map();
232
363
  for (const syncer of syncers) {
233
364
  const dirPath = join(cwd, syncer.directory);
234
365
  consola$1.debug(`scanning ${syncer.directory}`);
@@ -253,19 +384,32 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
253
384
  }
254
385
  for (const [absPath, content] of onDisk.entries()) {
255
386
  const relPath = absPath.slice(cwd.length + 1);
387
+ scannedContents.set(relPath, content);
256
388
  const entry = state.resources[relPath];
257
- const hash = hashResourceContent(content);
389
+ const hash = hashForState(syncer, content);
390
+ const flowRef = localFlows.get(relPath);
391
+ const pending = flowRef ? pendingRevisions.get(flowRef) : void 0;
392
+ const recovered = flowRef ? recoveredRevisions.get(flowRef) : void 0;
393
+ const repin = pending ? {
394
+ previousId: flowRef,
395
+ schemaPath: pending.schemaPath
396
+ } : recovered ? {
397
+ previousId: flowRef,
398
+ schemaPath: recovered.schemaPath,
399
+ newId: recovered.newId
400
+ } : void 0;
258
401
  if (!entry?.id) {
259
402
  actions.push({
260
403
  kind: "create",
261
404
  path: relPath,
262
405
  syncer,
263
406
  content,
264
- hash
407
+ hash,
408
+ ...repin ? { repin } : {}
265
409
  });
266
410
  continue;
267
411
  }
268
- if (entry.hash === hash) {
412
+ if ((entry.hash === hash || entry.hash === hashResourceContent(content) || entry.hash === hashResourceContent(JSON.parse(stableStringify(content)))) && !(repin && syncer.mutable)) {
269
413
  actions.push({
270
414
  kind: "skip",
271
415
  path: relPath,
@@ -275,6 +419,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
275
419
  }
276
420
  if (syncer.revisioned) {
277
421
  const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
422
+ pendingRevisions.set(entry.id, { schemaPath: relPath });
278
423
  actions.push({
279
424
  kind: "revise",
280
425
  path: relPath,
@@ -303,16 +448,25 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
303
448
  id: entry.id,
304
449
  content,
305
450
  hash,
306
- oldContent
451
+ oldContent,
452
+ ...repin ? { repin } : {}
307
453
  });
308
454
  }
309
455
  }
456
+ validatePlannedFlows({
457
+ actions,
458
+ scannedContents,
459
+ stateResources: state.resources
460
+ });
310
461
  return actions;
311
462
  }
312
463
  /**
313
464
  * Execute every action returned by {@link buildSyncPlan} against the
314
465
  * platform. Updates the local state file (`.zitadel/state.json`) as
315
- * each action completes so an interrupted run can resume.
466
+ * each action completes so an interrupted run can resume. After each
467
+ * mutation, the server's canonical body is written back to the local
468
+ * file (when it differs in normalized form), so repo config matches
469
+ * live state by construction and the next `plan` is empty.
316
470
  *
317
471
  * The platform target (base URL + bearer auth) lives in the api
318
472
  * package's runtime registries; callers set them before invoking this.
@@ -323,33 +477,70 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
323
477
  */
324
478
  async function runSyncLoop(cwd, syncers) {
325
479
  const actions = await buildSyncPlan(cwd, syncers);
480
+ 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}`);
481
+ const filesUpdated = [];
482
+ const repinned = /* @__PURE__ */ new Map();
483
+ const writeBack = async (action, canonical, fallbackHash) => {
484
+ if (!canonical) return fallbackHash;
485
+ const { hash, changed } = await writeBackResource(cwd, action.path, action.syncer, canonical);
486
+ if (changed) {
487
+ filesUpdated.push(action.path);
488
+ consola$1.info(`Updated ${action.path} from the server's canonical response`);
489
+ }
490
+ return hash;
491
+ };
326
492
  for (const action of actions) switch (action.kind) {
327
493
  case "create": {
328
- const id = await action.syncer.create(action.content);
494
+ let content = action.content;
495
+ const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
496
+ if (newId) content = {
497
+ ...content,
498
+ user_schema: newId
499
+ };
500
+ const { id, canonical } = await action.syncer.create(content);
329
501
  const entry = {
330
502
  id,
331
- hash: action.hash
503
+ hash: await writeBack(action, canonical, newId ? hashForState(action.syncer, content) : action.hash)
332
504
  };
333
505
  await updateState(cwd, action.path, entry);
334
506
  consola$1.info(`Created a new ${action.syncer.kind} on Zitadel from ${action.path} (id ${id})`);
335
507
  break;
336
508
  }
337
509
  case "revise": {
338
- const id = await action.syncer.create(action.content);
510
+ const { id, canonical } = await action.syncer.create(action.content);
339
511
  const entry = {
340
512
  id,
341
- hash: action.hash
513
+ hash: await writeBack(action, canonical, action.hash),
514
+ previousId: action.previousId
342
515
  };
343
516
  await updateState(cwd, action.path, entry);
517
+ repinned.set(action.previousId, id);
344
518
  consola$1.info(`Published a new ${action.syncer.kind} revision on Zitadel from ${action.path} (id ${id})`);
345
- if (action.affectedPaths.length > 0) consola$1.warn(`New ${action.syncer.kind} revision ${id}. Update user_schema in these flow definitions to adopt it:\n` + action.affectedPaths.map((path) => ` - ${path}`).join("\n"));
519
+ for (const flowPath of action.affectedPaths) if (await repinFlowFile(cwd, flowPath, action.previousId, id)) {
520
+ filesUpdated.push(flowPath);
521
+ consola$1.info(`Re-pinned user_schema in ${flowPath} to ${id}`);
522
+ }
346
523
  break;
347
524
  }
348
- case "update":
349
- await action.syncer.update(action.id, action.content);
350
- await updateState(cwd, action.path, { hash: action.hash });
525
+ case "update": {
526
+ let content = action.content;
527
+ const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
528
+ if (newId && action.repin) {
529
+ content = {
530
+ ...content,
531
+ user_schema: newId
532
+ };
533
+ if (await repinFlowFile(cwd, action.path, action.repin.previousId, newId)) {
534
+ filesUpdated.push(action.path);
535
+ consola$1.info(`Re-pinned user_schema in ${action.path} to ${newId}`);
536
+ }
537
+ }
538
+ const { canonical } = await action.syncer.update(action.id, content);
539
+ const fallbackHash = newId ? hashForState(action.syncer, content) : action.hash;
540
+ await updateState(cwd, action.path, { hash: await writeBack(action, canonical, fallbackHash) });
351
541
  consola$1.info(`Updated the ${action.syncer.kind} on Zitadel from ${action.path}`);
352
542
  break;
543
+ }
353
544
  case "delete":
354
545
  await action.syncer.delete(action.id);
355
546
  await removeFromState(cwd, action.path);
@@ -359,6 +550,77 @@ async function runSyncLoop(cwd, syncers) {
359
550
  consola$1.debug(`Skipped ${action.path} (${action.reason})`);
360
551
  break;
361
552
  }
553
+ const remainingPins = new Set((await readLocalFlowUserSchemas(cwd)).values());
554
+ const finalState = await readState(cwd);
555
+ for (const [path, entry] of Object.entries(finalState.resources)) if (entry.previousId && !remainingPins.has(entry.previousId)) await updateState(cwd, path, { previousId: void 0 });
556
+ return { filesUpdated: [...new Set(filesUpdated)] };
557
+ }
558
+ /**
559
+ * Rewrite a flow file's `user_schema` pin from `previousId` to `newId`,
560
+ * lockfile-style. Prefers a targeted text replacement so the author's
561
+ * formatting survives a one-string change; falls back to parse +
562
+ * `stableStringify` when the raw text doesn't contain exactly one pin.
563
+ * Returns false when the file doesn't pin `previousId` (already re-pinned
564
+ * or hand-edited) — never throws for an unreadable file.
565
+ */
566
+ async function repinFlowFile(cwd, relPath, previousId, newId) {
567
+ const absPath = join(cwd, relPath);
568
+ let raw;
569
+ try {
570
+ raw = await readFile(absPath, "utf8");
571
+ } catch (err) {
572
+ consola$1.debug(`read ${relPath} for re-pin failed:`, err);
573
+ return false;
574
+ }
575
+ const pinPattern = new RegExp(`("user_schema"\\s*:\\s*)${escapeRegExp(JSON.stringify(previousId))}`, "g");
576
+ if (raw.match(pinPattern)?.length === 1) {
577
+ await writeFile(absPath, raw.replace(pinPattern, (_match, prefix) => `${prefix}${JSON.stringify(newId)}`));
578
+ return true;
579
+ }
580
+ try {
581
+ const doc = JSON.parse(raw);
582
+ if (doc.user_schema !== previousId) return false;
583
+ doc.user_schema = newId;
584
+ await writeFile(absPath, `${stableStringify(doc)}\n`);
585
+ return true;
586
+ } catch (err) {
587
+ consola$1.debug(`re-pin ${relPath} failed:`, err);
588
+ return false;
589
+ }
590
+ }
591
+ function escapeRegExp(value) {
592
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
593
+ }
594
+ /**
595
+ * Reconcile a local file with the server's canonical body. What gets
596
+ * written is the `normalizeWrite` form (strips pure transport noise like
597
+ * the empty `audience` echo; for schemas the canonical body verbatim —
598
+ * spelled-out x-* defaults must survive, or the next apply would publish
599
+ * a revision without them). Equality is judged in the `normalize`
600
+ * comparison form, so the file is rewritten only when it materially
601
+ * differs from live state; hand-formatted files stay untouched otherwise.
602
+ * Returns the state hash of the written form.
603
+ */
604
+ async function writeBackResource(cwd, relPath, syncer, canonical) {
605
+ let writeBody = syncer.normalizeWrite?.(canonical) ?? canonical;
606
+ const compare = (body) => stableStringify(syncer.normalize?.(body) ?? body);
607
+ const absPath = join(cwd, relPath);
608
+ let changed = true;
609
+ try {
610
+ const onDisk = JSON.parse(await readFile(absPath, "utf8"));
611
+ changed = compare(writeBody) !== compare(onDisk);
612
+ if (typeof onDisk.$schema === "string" && !("$schema" in writeBody)) writeBody = {
613
+ ...writeBody,
614
+ $schema: onDisk.$schema
615
+ };
616
+ } catch (err) {
617
+ consola$1.debug(`read ${relPath} for write-back failed:`, err);
618
+ }
619
+ if (changed) await writeFile(absPath, `${stableStringify(writeBody)}\n`);
620
+ return {
621
+ hash: hashForState(syncer, writeBody),
622
+ changed
623
+ };
362
624
  }
363
625
  async function fetchOldIfAsked(syncer, id, fetchOld) {
364
626
  if (!fetchOld || !syncer.fetch) return null;
@@ -405,9 +667,24 @@ function findFlowsPinnedTo(previousId, localFlows) {
405
667
  for (const [relPath, ref] of localFlows.entries()) if (ref === previousId) affected.push(relPath);
406
668
  return affected;
407
669
  }
670
+ /**
671
+ * Legacy content hash: order-sensitive and normalization-blind. Kept only
672
+ * so state entries written by older CLI versions still match; new hashes
673
+ * come from {@link hashForState}.
674
+ */
408
675
  function hashResourceContent(data) {
409
676
  return createHash("sha256").update(JSON.stringify(data)).digest("hex");
410
677
  }
678
+ /**
679
+ * The content hash stored in `.zitadel/state.json`: key-order-insensitive
680
+ * (via `stableStringify`) and computed on the syncer's normalized form, so
681
+ * reordering keys or spelling out a meta-schema default does not read as an
682
+ * edit.
683
+ */
684
+ function hashForState(syncer, data) {
685
+ const normalized = syncer.normalize?.(data) ?? data;
686
+ return createHash("sha256").update(stableStringify(normalized)).digest("hex");
687
+ }
411
688
  //#endregion
412
689
  //#region src/lib/sync/plan-renderer.ts
413
690
  /**
@@ -426,6 +703,23 @@ function summarizePlan(actions) {
426
703
  };
427
704
  }
428
705
  /**
706
+ * Collect the plan-time validation warnings across all actions, tagged
707
+ * with the file they belong to. Feeds the `plan` / `apply --dry-run`
708
+ * `--json` payload so agents can read warnings structurally.
709
+ */
710
+ function collectPlanWarnings(actions) {
711
+ const out = [];
712
+ for (const action of actions) {
713
+ if (action.kind !== "create" && action.kind !== "update") continue;
714
+ for (const warning of action.warnings ?? []) out.push({
715
+ path: action.path,
716
+ rule: warning.rule,
717
+ message: warning.message
718
+ });
719
+ }
720
+ return out;
721
+ }
722
+ /**
429
723
  * Render a {@link buildSyncPlan} result as a human-readable Terraform-style
430
724
  * plan. TTY-aware: colors and bold are emitted only when `tty` is true.
431
725
  * Returns the empty-state message when every action is `skip`.
@@ -451,6 +745,8 @@ function renderPlan(actions, tty) {
451
745
  if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
452
746
  if (deletes > 0) parts.push(`${deletes} to destroy`);
453
747
  out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
748
+ const warningCount = active.reduce((count, action) => count + ((action.kind === "create" || action.kind === "update") && action.warnings ? action.warnings.length : 0), 0);
749
+ if (warningCount > 0) out.push(paint(`Warnings: ${warningCount} (non-blocking — see the # warning lines above).`, A.yellow, tty));
454
750
  return out.join("\n");
455
751
  }
456
752
  const A = {
@@ -604,7 +900,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
604
900
  const col = (s) => paint(s, A.yellow, tty);
605
901
  lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
606
902
  }
607
- else if (Array.isArray(oldVal) && Array.isArray(newVal)) if (JSON.stringify(oldVal) === JSON.stringify(newVal)) if (newVal.length === 0) lines.push(`${pad} ${pk} = []`);
903
+ else if (Array.isArray(oldVal) && Array.isArray(newVal)) if (stableStringify(oldVal) === stableStringify(newVal)) if (newVal.length === 0) lines.push(`${pad} ${pk} = []`);
608
904
  else {
609
905
  lines.push(`${pad} ${pk} = [`);
610
906
  renderArrayItems(newVal, " ", prefixCol + 4, {
@@ -671,6 +967,14 @@ function resourceName(path) {
671
967
  return path.split("/").pop() ?? path;
672
968
  }
673
969
  /**
970
+ * Diff both sides in the syncer's canonical form so server-echoed noise
971
+ * (empty `audience`, spelled-out meta-schema defaults) never renders as a
972
+ * change the author didn't make. Rendering only — upload payloads stay raw.
973
+ */
974
+ function normalized(syncer, content) {
975
+ return syncer.normalize?.(content) ?? content;
976
+ }
977
+ /**
674
978
  * Renders one Terraform-style resource block for a single `SyncAction`.
675
979
  *
676
980
  * Per-case notes:
@@ -694,14 +998,17 @@ function renderBlock(action, tty) {
694
998
  const opening = `${blkPad}+ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
695
999
  lines.push(paint(header, A.bold, tty));
696
1000
  lines.push(paint(opening, A.green, tty));
697
- renderFields({
1001
+ const display = {
698
1002
  id: KNOWN_AFTER_APPLY,
699
1003
  ...action.content
700
- }, "+", FIELD_COL, {
1004
+ };
1005
+ if (action.repin) display.user_schema = action.repin.newId ?? KNOWN_AFTER_APPLY;
1006
+ renderFields(display, "+", FIELD_COL, {
701
1007
  tty,
702
1008
  deleteMode: false
703
1009
  }, lines);
704
1010
  lines.push(`${closePad}}`);
1011
+ renderWarnings(action.warnings, blkPad, tty, lines);
705
1012
  break;
706
1013
  }
707
1014
  case "delete": {
@@ -721,13 +1028,20 @@ function renderBlock(action, tty) {
721
1028
  break;
722
1029
  }
723
1030
  case "update": {
724
- const header = `${blkPad}# ${action.path} will be updated in-place`;
1031
+ const headerSuffix = action.repin ? " (re-pin user_schema)" : "";
1032
+ const header = `${blkPad}# ${action.path} will be updated in-place${headerSuffix}`;
725
1033
  const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
726
1034
  lines.push(paint(header, A.bold, tty));
727
1035
  lines.push(paint(opening, A.yellow, tty));
728
- if (action.oldContent) renderDiff(action.oldContent, action.content, FIELD_COL, tty, lines);
1036
+ const newContent = action.repin ? {
1037
+ ...normalized(action.syncer, action.content),
1038
+ user_schema: action.repin.newId ?? KNOWN_AFTER_APPLY
1039
+ } : normalized(action.syncer, action.content);
1040
+ if (action.oldContent) renderDiff(normalized(action.syncer, action.oldContent), newContent, FIELD_COL, tty, lines);
1041
+ 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));
729
1042
  else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
730
1043
  lines.push(`${closePad}}`);
1044
+ renderWarnings(action.warnings, blkPad, tty, lines);
731
1045
  break;
732
1046
  }
733
1047
  case "revise": {
@@ -737,15 +1051,15 @@ function renderBlock(action, tty) {
737
1051
  lines.push(paint(opening, A.yellow, tty));
738
1052
  if (action.oldContent) renderDiff({
739
1053
  id: action.previousId,
740
- ...action.oldContent
1054
+ ...normalized(action.syncer, action.oldContent)
741
1055
  }, {
742
1056
  id: KNOWN_AFTER_APPLY,
743
- ...action.content
1057
+ ...normalized(action.syncer, action.content)
744
1058
  }, FIELD_COL, tty, lines);
745
1059
  else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
746
1060
  lines.push(`${closePad}}`);
747
1061
  if (action.affectedPaths.length > 0) {
748
- lines.push(paint(`${blkPad}# after apply, update user_schema in these flow definitions:`, A.yellow, tty));
1062
+ lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
749
1063
  for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
750
1064
  }
751
1065
  break;
@@ -754,7 +1068,15 @@ function renderBlock(action, tty) {
754
1068
  }
755
1069
  return lines;
756
1070
  }
1071
+ /**
1072
+ * Emit one yellow `# warning:` comment line per plan-time validation
1073
+ * warning, below the action's closing brace (same channel as the revise
1074
+ * re-pin announcement). Warnings never block the plan.
1075
+ */
1076
+ function renderWarnings(warnings, blkPad, tty, lines) {
1077
+ for (const warning of warnings ?? []) lines.push(paint(`${blkPad}# warning: ${warning.message}`, A.yellow, tty));
1078
+ }
757
1079
  //#endregion
758
- export { runSyncLoop as a, FLOWS_DIR as c, hashResourceContent as i, summarizePlan as n, updateState as o, buildSyncPlan as r, makeSyncers as s, renderPlan as t };
1080
+ export { hashForState as a, updateState as c, buildSyncPlan as i, makeSyncers as l, renderPlan as n, runSyncLoop as o, summarizePlan as r, writeBackResource as s, collectPlanWarnings as t, FLOWS_DIR as u };
759
1081
 
760
- //# sourceMappingURL=sync-nSLnVhKK.mjs.map
1082
+ //# sourceMappingURL=sync-Bu_iLIbb.mjs.map