@zitadel/cli 0.1.0-alpha.13 → 0.1.0-alpha.15

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 +137 -57
  2. package/SKILLS.md +26 -13
  3. package/dist/commands/apply.mjs +11 -6
  4. package/dist/commands/apply.mjs.map +1 -1
  5. package/dist/commands/doctor.mjs +89 -12
  6. package/dist/commands/doctor.mjs.map +1 -1
  7. package/dist/commands/eject.mjs +4 -4
  8. package/dist/commands/logs.mjs +2 -2
  9. package/dist/commands/plan.mjs +6 -4
  10. package/dist/commands/plan.mjs.map +1 -1
  11. package/dist/commands/reset.mjs +2 -2
  12. package/dist/commands/schemas/list.mjs +134 -0
  13. package/dist/commands/schemas/list.mjs.map +1 -0
  14. package/dist/commands/setup.mjs +158 -23
  15. package/dist/commands/setup.mjs.map +1 -1
  16. package/dist/commands/start.mjs +4 -4
  17. package/dist/commands/status.mjs +3 -3
  18. package/dist/commands/stop.mjs +3 -3
  19. package/dist/{docker-Djz6BLp9.mjs → docker-D7BSD5C9.mjs} +3 -3
  20. package/dist/{docker-Djz6BLp9.mjs.map → docker-D7BSD5C9.mjs.map} +1 -1
  21. package/dist/{docker-guidance-D-33g1uV.mjs → docker-guidance-mcTT3_0E.mjs} +2 -2
  22. package/dist/{docker-guidance-D-33g1uV.mjs.map → docker-guidance-mcTT3_0E.mjs.map} +1 -1
  23. package/dist/environment-BQF7LeCz.mjs +17 -0
  24. package/dist/environment-BQF7LeCz.mjs.map +1 -0
  25. package/dist/{oclif-BoUVygsZ.mjs → oclif-Bm-FkF6z.mjs} +66 -13
  26. package/dist/oclif-Bm-FkF6z.mjs.map +1 -0
  27. package/dist/{orca-DU8myWGm.mjs → orca-CpXj_XsA.mjs} +46 -94
  28. package/dist/orca-CpXj_XsA.mjs.map +1 -0
  29. package/dist/{ports-B09RjuHx.mjs → ports-CxBKS1ga.mjs} +1 -1
  30. package/dist/{ports-B09RjuHx.mjs.map → ports-CxBKS1ga.mjs.map} +1 -1
  31. package/dist/{processes-Cw8TO1SY.mjs → processes-BVqYsxT8.mjs} +1 -1
  32. package/dist/{processes-Cw8TO1SY.mjs.map → processes-BVqYsxT8.mjs.map} +1 -1
  33. package/dist/{project-B1qVQMKS.mjs → project-Dk7V0xka.mjs} +3 -3
  34. package/dist/{project-B1qVQMKS.mjs.map → project-Dk7V0xka.mjs.map} +1 -1
  35. package/dist/{sync-B8Z2it_E.mjs → sync-CYuHVQT5.mjs} +344 -116
  36. package/dist/sync-CYuHVQT5.mjs.map +1 -0
  37. package/dist/user-schema-DDz5-lX5.mjs +13 -0
  38. package/dist/user-schema-DDz5-lX5.mjs.map +1 -0
  39. package/oclif.manifest.json +106 -1
  40. package/package.json +6 -5
  41. package/dist/oclif-BoUVygsZ.mjs.map +0 -1
  42. package/dist/orca-DU8myWGm.mjs.map +0 -1
  43. package/dist/sync-B8Z2it_E.mjs.map +0 -1
@@ -1,64 +1,12 @@
1
- import { C as isObject, E as ZitadelError } from "./oclif-BoUVygsZ.mjs";
1
+ import { C as isObject, E as ZitadelError, T as stableStringify } from "./oclif-Bm-FkF6z.mjs";
2
+ import { t as SCHEMAS_DIR } from "./user-schema-DDz5-lX5.mjs";
3
+ import { readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
2
5
  import { consola as consola$1 } from "consola";
3
6
  import { createHash } from "node:crypto";
4
- import { join } from "node:path";
5
- import { readFile, readdir, writeFile } from "node:fs/promises";
6
- import { z } from "zod";
7
- import { CreateFlowDefinitionBody, CreateSchemaBody } from "@zitadel/api/generated/endpoints/zitadelNextGen.zod";
8
- //#region src/lib/environment.ts
9
- /**
10
- * CLI-side deployment environment. Not an API model — it gates which
11
- * `zitadel.json` environment block and server the commands target.
12
- * Project request/response shapes live in `@zitadel/api`
13
- * (generated from the OpenAPI spec).
14
- */
15
- const environmentSchema = z.enum([
16
- "development",
17
- "preview",
18
- "production"
19
- ]);
20
- //#endregion
21
- //#region src/lib/flows/validate.ts
22
- /**
23
- * The generated `CreateFlowDefinitionBody` Zod schema describes the
24
- * full envelope (`{project_id, flow_definition, schema_uri?}`); the
25
- * on-disk flow body is just the inner `flow_definition` shape. Pull
26
- * that out via `.shape` so on-disk validation runs against exactly the
27
- * same schema the wire request validates against.
28
- */
29
- const flowDefinitionBodySchema = CreateFlowDefinitionBody.shape.flow_definition;
30
- /**
31
- * Validate raw JSON bodies against the generated flow-definition Zod
32
- * schema (the orval-emitted equivalent of
33
- * `api/openapi/components/flows/flow-definition.yaml`). Errors from
34
- * every input are collected and rethrown as a single `E_VALIDATION`
35
- * `ZitadelError` so callers see the full picture at once rather than
36
- * failing on the first malformed entry.
37
- *
38
- * Pure: does not touch the filesystem or network. The input array
39
- * is read-only; the returned array is freshly allocated.
40
- *
41
- * @param flows - Raw values to validate. Unknown-typed so callers
42
- * can pass freshly-parsed JSON without first asserting a shape.
43
- */
44
- function validateFlows(flows) {
45
- const issues = [];
46
- const parsed = [];
47
- for (let i = 0; i < flows.length; i += 1) {
48
- const result = flowDefinitionBodySchema.safeParse(flows[i]);
49
- if (!result.success) {
50
- issues.push({
51
- index: i,
52
- issues: result.error.issues
53
- });
54
- continue;
55
- }
56
- parsed.push(result.data);
57
- }
58
- if (issues.length > 0) throw new ZitadelError("E_VALIDATION", "One or more flow definitions are invalid", { details: { issues } });
59
- return parsed;
60
- }
61
- //#endregion
7
+ import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
8
+ import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
9
+ import { flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
62
10
  //#region src/lib/flows/env-refs.ts
63
11
  /**
64
12
  * Collects the environment variables a flows document depends on, sorted and
@@ -92,24 +40,14 @@ function flowEnvRefs(value) {
92
40
  */
93
41
  const FLOWS_DIR = ".zitadel/flows";
94
42
  //#endregion
95
- //#region src/lib/user-schema/index.ts
96
- /**
97
- * Relative directory (from the project root) where local user-schema
98
- * files live. Owned here so callers (`commands/*`, `sync/syncers.ts`)
99
- * and tests share a single source of truth for the path; the runtime
100
- * never depends on it directly because `lib/user-schema` does not touch
101
- * the filesystem. The counterpart of `lib/flows`' `FLOWS_DIR`.
102
- */
103
- const SCHEMAS_DIR = ".zitadel/schemas";
104
- //#endregion
105
43
  //#region src/lib/sync/syncers.ts
106
44
  /**
107
45
  * Build the syncer list with the context every syncer needs: the
108
46
  * `project_id` flow creates carry, and the runtime `env` against which
109
- * each file's `${VAR}` / `*_env` references are checked. Callers
110
- * (apply / plan / setup) read `project_id` from `.zitadel/secret` and
111
- * pass the process environment. The returned array is treated as
112
- * read-only by the sync loop.
47
+ * each file's `${VAR}` / `*_env` references are checked. Callers (apply /
48
+ * plan / setup) read `project_id` from `.zitadel/secret` and pass the
49
+ * process environment. The returned array is treated as read-only by the
50
+ * sync loop.
113
51
  */
114
52
  function makeSyncers(opts) {
115
53
  return [new SchemaSyncer(opts.client, opts.projectId, opts.env), new FlowDefinitionSyncer(opts.client, opts.projectId, opts.env)];
@@ -128,6 +66,8 @@ var SchemaSyncer = class {
128
66
  kind = "schema";
129
67
  directory = SCHEMAS_DIR;
130
68
  mutable = false;
69
+ revisioned = true;
70
+ normalize = normalizeSchemaBody;
131
71
  constructor(client, projectId, env) {
132
72
  this.client = client;
133
73
  this.projectId = projectId;
@@ -140,38 +80,64 @@ var SchemaSyncer = class {
140
80
  * discriminated on `kind`; both are valid on-disk bodies.
141
81
  */
142
82
  validate(data) {
143
- const result = CreateSchemaBody.safeParse(data);
83
+ const result = schemaConfigSchema.safeParse(data);
144
84
  if (!result.success) throw new ZitadelError("E_VALIDATION", "Schema file is not a valid Zitadel schema body", { details: { issues: result.error.issues } });
145
85
  assertEnvRefs(data, this.env);
146
86
  }
87
+ /**
88
+ * `POST /schemas` mints a new immutable row. The server allocates the
89
+ * opaque id; the CLI records it in state and re-pins flows against it.
90
+ * The create response carries only the id, so the canonical stored body
91
+ * comes from a follow-up fetch; a fetch failure degrades to no
92
+ * write-back rather than failing the create.
93
+ */
147
94
  async create(data) {
148
- return (await this.client.createSchema(data, { project_id: this.projectId })).id;
95
+ const result = await this.client.createSchema(data, { project_id: this.projectId });
96
+ try {
97
+ return {
98
+ id: result.id,
99
+ canonical: await this.fetch(result.id)
100
+ };
101
+ } catch (err) {
102
+ consola$1.debug(`fetch created schema ${result.id} failed:`, err);
103
+ return { id: result.id };
104
+ }
105
+ }
106
+ /**
107
+ * Not called by the sync loop: schemas are `revisioned`, so a hash change
108
+ * publishes a new immutable revision through {@link create} rather than
109
+ * mutating an existing row. Kept as a required interface member; throws
110
+ * loudly if a caller reaches it.
111
+ */
112
+ async update(_id, _data) {
113
+ throw new ZitadelError("E_NOT_IMPLEMENTED", "schemas are revisioned — edit publishes a new revision, not an update");
149
114
  }
150
- /** Never called — schemas are immutable on the platform, so `mutable = false`. */
151
- async update(_id, _data) {}
152
115
  async delete(id) {
153
116
  throw new ZitadelError("E_NOT_IMPLEMENTED", `schema delete is not supported (${id})`);
154
117
  }
155
118
  async fetch(id) {
156
- return await this.client.getSchemaById(id, { project_id: this.projectId });
119
+ return await this.client.getSchemaById(encodeURIComponent(id), { project_id: this.projectId });
157
120
  }
158
121
  };
159
122
  var FlowDefinitionSyncer = class {
160
123
  kind = "flow";
161
124
  directory = FLOWS_DIR;
162
125
  mutable = true;
126
+ revisioned = false;
127
+ normalize = normalizeFlowBody;
128
+ normalizeWrite = normalizeFlowBody;
163
129
  constructor(client, projectId, env) {
164
130
  this.client = client;
165
131
  this.projectId = projectId;
166
132
  this.env = env;
167
133
  }
168
134
  /**
169
- * Validates one flow file. `validateFlows` takes a batch and throws
170
- * `E_VALIDATION` on the first invalid entry; passing a single-element array
171
- * lets us reuse the batch validator for one file.
135
+ * Validates one flow file against the canonical `flowConfigSchema` (the
136
+ * same Zod `validateFlows` and doctor use), then checks env references.
172
137
  */
173
138
  validate(data) {
174
- validateFlows([data]);
139
+ const result = flowConfigSchema.safeParse(data);
140
+ if (!result.success) throw new ZitadelError("E_VALIDATION", "Flow file is not a valid Zitadel flow body", { details: { issues: result.error.issues } });
175
141
  assertEnvRefs(data, this.env);
176
142
  }
177
143
  /**
@@ -182,10 +148,15 @@ var FlowDefinitionSyncer = class {
182
148
  * envelope.
183
149
  */
184
150
  async create(data) {
185
- return (await this.client.createFlowDefinition({
151
+ const result = await this.client.createFlowDefinition({
186
152
  project_id: this.projectId,
153
+ schema_uri: DEFAULT_FLOW_SCHEMA_URI,
187
154
  flow_definition: data
188
- })).id;
155
+ });
156
+ return {
157
+ id: result.id,
158
+ canonical: result.flow_definition
159
+ };
189
160
  }
190
161
  /**
191
162
  * PUT completely replaces the flow definition. The wire request wraps the
@@ -195,7 +166,7 @@ var FlowDefinitionSyncer = class {
195
166
  * it is human-editable.
196
167
  */
197
168
  async update(id, data) {
198
- await this.client.updateFlowDefinition(id, { flow_definition: data }, { project_id: this.projectId });
169
+ return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data }, { project_id: this.projectId })).flow_definition };
199
170
  }
200
171
  async delete(id) {
201
172
  await this.client.deleteFlowDefinition(id, { project_id: this.projectId });
@@ -257,7 +228,7 @@ async function removeFromState(cwd, key) {
257
228
  /**
258
229
  * Compute the sync plan for `cwd` against the state file and (when
259
230
  * `fetchOld` is true) the platform API. The plan is read-only: it
260
- * decides what create/update/delete operations need to happen but
231
+ * decides what create/update/revise/delete operations need to happen but
261
232
  * performs none of them. Pass it to {@link runSyncLoop} to execute.
262
233
  *
263
234
  * Validates every on-disk file (via `syncer.validate`) before planning any
@@ -277,6 +248,14 @@ async function removeFromState(cwd, key) {
277
248
  async function buildSyncPlan(cwd, syncers, fetchOld = false) {
278
249
  const state = await readState(cwd);
279
250
  const actions = [];
251
+ const localFlows = await readLocalFlowUserSchemas(cwd);
252
+ const pendingRevisions = /* @__PURE__ */ new Map();
253
+ const recoveredRevisions = /* @__PURE__ */ new Map();
254
+ for (const [schemaPath, entry] of Object.entries(state.resources)) if (entry.previousId && entry.id && entry.previousId !== entry.id) recoveredRevisions.set(entry.previousId, {
255
+ schemaPath,
256
+ newId: entry.id
257
+ });
258
+ const scannedContents = /* @__PURE__ */ new Map();
280
259
  for (const syncer of syncers) {
281
260
  const dirPath = join(cwd, syncer.directory);
282
261
  consola$1.debug(`scanning ${syncer.directory}`);
@@ -301,40 +280,64 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
301
280
  }
302
281
  for (const [absPath, content] of onDisk.entries()) {
303
282
  const relPath = absPath.slice(cwd.length + 1);
283
+ scannedContents.set(relPath, content);
304
284
  const entry = state.resources[relPath];
305
- const hash = sha256(content);
285
+ const hash = hashForState(syncer, content);
286
+ const flowRef = localFlows.get(relPath);
287
+ const pending = flowRef ? pendingRevisions.get(flowRef) : void 0;
288
+ const recovered = flowRef ? recoveredRevisions.get(flowRef) : void 0;
289
+ const repin = pending ? {
290
+ previousId: flowRef,
291
+ schemaPath: pending.schemaPath
292
+ } : recovered ? {
293
+ previousId: flowRef,
294
+ schemaPath: recovered.schemaPath,
295
+ newId: recovered.newId
296
+ } : void 0;
297
+ if (repin) assertRepinnedFlowFields(relPath, content, repin.schemaPath, scannedContents.get(repin.schemaPath));
306
298
  if (!entry?.id) {
307
299
  actions.push({
308
300
  kind: "create",
309
301
  path: relPath,
310
302
  syncer,
311
303
  content,
312
- hash
304
+ hash,
305
+ ...repin ? { repin } : {}
313
306
  });
314
307
  continue;
315
308
  }
316
- if (!syncer.mutable) {
309
+ if ((entry.hash === hash || entry.hash === hashResourceContent(content) || entry.hash === hashResourceContent(JSON.parse(stableStringify(content)))) && !(repin && syncer.mutable)) {
317
310
  actions.push({
318
311
  kind: "skip",
319
312
  path: relPath,
320
- reason: "immutable"
313
+ reason: "no-change"
321
314
  });
322
315
  continue;
323
316
  }
324
- if (entry.hash === hash) {
317
+ if (syncer.revisioned) {
318
+ const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
319
+ pendingRevisions.set(entry.id, { schemaPath: relPath });
325
320
  actions.push({
326
- kind: "skip",
321
+ kind: "revise",
327
322
  path: relPath,
328
- reason: "no-change"
323
+ syncer,
324
+ content,
325
+ hash,
326
+ previousId: entry.id,
327
+ oldContent,
328
+ affectedPaths: findFlowsPinnedTo(entry.id, localFlows)
329
329
  });
330
330
  continue;
331
331
  }
332
- let oldContent = null;
333
- if (fetchOld && syncer.fetch) try {
334
- oldContent = await syncer.fetch(entry.id);
335
- } catch (err) {
336
- consola$1.debug(`fetch ${syncer.kind} ${entry.id} failed:`, err);
332
+ if (!syncer.mutable) {
333
+ actions.push({
334
+ kind: "skip",
335
+ path: relPath,
336
+ reason: "immutable"
337
+ });
338
+ continue;
337
339
  }
340
+ const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
338
341
  actions.push({
339
342
  kind: "update",
340
343
  path: relPath,
@@ -342,7 +345,8 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
342
345
  id: entry.id,
343
346
  content,
344
347
  hash,
345
- oldContent
348
+ oldContent,
349
+ ...repin ? { repin } : {}
346
350
  });
347
351
  }
348
352
  }
@@ -351,7 +355,10 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
351
355
  /**
352
356
  * Execute every action returned by {@link buildSyncPlan} against the
353
357
  * platform. Updates the local state file (`.zitadel/state.json`) as
354
- * each action completes so an interrupted run can resume.
358
+ * each action completes so an interrupted run can resume. After each
359
+ * mutation, the server's canonical body is written back to the local
360
+ * file (when it differs in normalized form), so repo config matches
361
+ * live state by construction and the next `plan` is empty.
355
362
  *
356
363
  * The platform target (base URL + bearer auth) lives in the api
357
364
  * package's runtime registries; callers set them before invoking this.
@@ -362,21 +369,69 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
362
369
  */
363
370
  async function runSyncLoop(cwd, syncers) {
364
371
  const actions = await buildSyncPlan(cwd, syncers);
372
+ const filesUpdated = [];
373
+ const repinned = /* @__PURE__ */ new Map();
374
+ const writeBack = async (action, canonical, fallbackHash) => {
375
+ if (!canonical) return fallbackHash;
376
+ const { hash, changed } = await writeBackResource(cwd, action.path, action.syncer, canonical);
377
+ if (changed) {
378
+ filesUpdated.push(action.path);
379
+ consola$1.info(`Updated ${action.path} from the server's canonical response`);
380
+ }
381
+ return hash;
382
+ };
365
383
  for (const action of actions) switch (action.kind) {
366
384
  case "create": {
367
- const id = await action.syncer.create(action.content);
368
- await updateState(cwd, action.path, {
385
+ let content = action.content;
386
+ const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
387
+ if (newId) content = {
388
+ ...content,
389
+ user_schema: newId
390
+ };
391
+ const { id, canonical } = await action.syncer.create(content);
392
+ const entry = {
369
393
  id,
370
- hash: action.hash
371
- });
394
+ hash: await writeBack(action, canonical, newId ? hashForState(action.syncer, content) : action.hash)
395
+ };
396
+ await updateState(cwd, action.path, entry);
372
397
  consola$1.info(`Created a new ${action.syncer.kind} on Zitadel from ${action.path} (id ${id})`);
373
398
  break;
374
399
  }
375
- case "update":
376
- await action.syncer.update(action.id, action.content);
377
- await updateState(cwd, action.path, { hash: action.hash });
400
+ case "revise": {
401
+ const { id, canonical } = await action.syncer.create(action.content);
402
+ const entry = {
403
+ id,
404
+ hash: await writeBack(action, canonical, action.hash),
405
+ previousId: action.previousId
406
+ };
407
+ await updateState(cwd, action.path, entry);
408
+ repinned.set(action.previousId, id);
409
+ consola$1.info(`Published a new ${action.syncer.kind} revision on Zitadel from ${action.path} (id ${id})`);
410
+ for (const flowPath of action.affectedPaths) if (await repinFlowFile(cwd, flowPath, action.previousId, id)) {
411
+ filesUpdated.push(flowPath);
412
+ consola$1.info(`Re-pinned user_schema in ${flowPath} to ${id}`);
413
+ }
414
+ break;
415
+ }
416
+ case "update": {
417
+ let content = action.content;
418
+ const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
419
+ if (newId && action.repin) {
420
+ content = {
421
+ ...content,
422
+ user_schema: newId
423
+ };
424
+ if (await repinFlowFile(cwd, action.path, action.repin.previousId, newId)) {
425
+ filesUpdated.push(action.path);
426
+ consola$1.info(`Re-pinned user_schema in ${action.path} to ${newId}`);
427
+ }
428
+ }
429
+ const { canonical } = await action.syncer.update(action.id, content);
430
+ const fallbackHash = newId ? hashForState(action.syncer, content) : action.hash;
431
+ await updateState(cwd, action.path, { hash: await writeBack(action, canonical, fallbackHash) });
378
432
  consola$1.info(`Updated the ${action.syncer.kind} on Zitadel from ${action.path}`);
379
433
  break;
434
+ }
380
435
  case "delete":
381
436
  await action.syncer.delete(action.id);
382
437
  await removeFromState(cwd, action.path);
@@ -386,6 +441,82 @@ async function runSyncLoop(cwd, syncers) {
386
441
  consola$1.debug(`Skipped ${action.path} (${action.reason})`);
387
442
  break;
388
443
  }
444
+ const remainingPins = new Set((await readLocalFlowUserSchemas(cwd)).values());
445
+ const finalState = await readState(cwd);
446
+ for (const [path, entry] of Object.entries(finalState.resources)) if (entry.previousId && !remainingPins.has(entry.previousId)) await updateState(cwd, path, { previousId: void 0 });
447
+ return { filesUpdated: [...new Set(filesUpdated)] };
448
+ }
449
+ /**
450
+ * Rewrite a flow file's `user_schema` pin from `previousId` to `newId`,
451
+ * lockfile-style. Prefers a targeted text replacement so the author's
452
+ * formatting survives a one-string change; falls back to parse +
453
+ * `stableStringify` when the raw text doesn't contain exactly one pin.
454
+ * Returns false when the file doesn't pin `previousId` (already re-pinned
455
+ * or hand-edited) — never throws for an unreadable file.
456
+ */
457
+ async function repinFlowFile(cwd, relPath, previousId, newId) {
458
+ const absPath = join(cwd, relPath);
459
+ let raw;
460
+ try {
461
+ raw = await readFile(absPath, "utf8");
462
+ } catch (err) {
463
+ consola$1.debug(`read ${relPath} for re-pin failed:`, err);
464
+ return false;
465
+ }
466
+ const pinPattern = new RegExp(`("user_schema"\\s*:\\s*)${escapeRegExp(JSON.stringify(previousId))}`, "g");
467
+ if (raw.match(pinPattern)?.length === 1) {
468
+ await writeFile(absPath, raw.replace(pinPattern, (_match, prefix) => `${prefix}${JSON.stringify(newId)}`));
469
+ return true;
470
+ }
471
+ try {
472
+ const doc = JSON.parse(raw);
473
+ if (doc.user_schema !== previousId) return false;
474
+ doc.user_schema = newId;
475
+ await writeFile(absPath, `${stableStringify(doc)}\n`);
476
+ return true;
477
+ } catch (err) {
478
+ consola$1.debug(`re-pin ${relPath} failed:`, err);
479
+ return false;
480
+ }
481
+ }
482
+ function escapeRegExp(value) {
483
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
484
+ }
485
+ /**
486
+ * Reconcile a local file with the server's canonical body. What gets
487
+ * written is the `normalizeWrite` form (strips pure transport noise like
488
+ * the empty `audience` echo; for schemas the canonical body verbatim —
489
+ * spelled-out x-* defaults must survive, or the next apply would publish
490
+ * a revision without them). Equality is judged in the `normalize`
491
+ * comparison form, so the file is rewritten only when it materially
492
+ * differs from live state; hand-formatted files stay untouched otherwise.
493
+ * Returns the state hash of the written form.
494
+ */
495
+ async function writeBackResource(cwd, relPath, syncer, canonical) {
496
+ const writeBody = syncer.normalizeWrite?.(canonical) ?? canonical;
497
+ const compare = (body) => stableStringify(syncer.normalize?.(body) ?? body);
498
+ const absPath = join(cwd, relPath);
499
+ let changed = true;
500
+ try {
501
+ const onDisk = JSON.parse(await readFile(absPath, "utf8"));
502
+ changed = compare(writeBody) !== compare(onDisk);
503
+ } catch (err) {
504
+ consola$1.debug(`read ${relPath} for write-back failed:`, err);
505
+ }
506
+ if (changed) await writeFile(absPath, `${stableStringify(writeBody)}\n`);
507
+ return {
508
+ hash: hashForState(syncer, writeBody),
509
+ changed
510
+ };
511
+ }
512
+ async function fetchOldIfAsked(syncer, id, fetchOld) {
513
+ if (!fetchOld || !syncer.fetch) return null;
514
+ try {
515
+ return await syncer.fetch(id);
516
+ } catch (err) {
517
+ consola$1.debug(`fetch ${syncer.kind} ${id} failed:`, err);
518
+ return null;
519
+ }
389
520
  }
390
521
  async function readJsonDir(dirPath) {
391
522
  const result = /* @__PURE__ */ new Map();
@@ -403,9 +534,68 @@ async function readJsonDir(dirPath) {
403
534
  }
404
535
  return result;
405
536
  }
406
- function sha256(data) {
537
+ /**
538
+ * Walk `.zitadel/flows/*.json` once and return each flow's `user_schema` value
539
+ * keyed by project-root-relative path. A flow file without a `user_schema` (or
540
+ * with a non-string one) is skipped: it does not pin a schema revision, so it
541
+ * cannot be affected by one.
542
+ */
543
+ async function readLocalFlowUserSchemas(cwd) {
544
+ const result = /* @__PURE__ */ new Map();
545
+ const flows = await readJsonDir(join(cwd, FLOWS_DIR));
546
+ for (const [absPath, content] of flows.entries()) {
547
+ const relPath = absPath.slice(cwd.length + 1);
548
+ if (typeof content === "object" && content !== null && "user_schema" in content && typeof content.user_schema === "string") result.set(relPath, content.user_schema);
549
+ }
550
+ return result;
551
+ }
552
+ /**
553
+ * Fail fast when a flow that is about to adopt a new schema revision
554
+ * references properties the new revision no longer has (the server would
555
+ * reject the flow update with `flow field: not a property in the user
556
+ * schema`). Runs at plan time, before any platform mutation — otherwise
557
+ * the revise would publish first and the run would die half-applied.
558
+ * Only plain property fields are checked; reserved credential tokens
559
+ * (`x-auth-methods#…`) resolve outside the schema.
560
+ */
561
+ function assertRepinnedFlowFields(flowPath, flowContent, schemaPath, schemaContent) {
562
+ if (!schemaContent) return;
563
+ const properties = schemaContent.properties;
564
+ if (typeof properties !== "object" || properties === null) return;
565
+ const steps = flowContent.steps;
566
+ const missing = [];
567
+ for (const step of Array.isArray(steps) ? steps : []) {
568
+ const fields = Array.isArray(step.fields) ? step.fields : [];
569
+ for (const field of fields) {
570
+ if (typeof field !== "string" || field.includes("#")) continue;
571
+ if (!Object.prototype.hasOwnProperty.call(properties, field)) missing.push(`step ${JSON.stringify(step.name ?? "?")}: ${JSON.stringify(field)}`);
572
+ }
573
+ }
574
+ if (missing.length > 0) throw new ZitadelError("E_VALIDATION", `${flowPath} cannot adopt the new revision of ${schemaPath}: flow fields missing from the edited schema — ${missing.join(", ")}`, { hint: "Update the flow's steps[].fields to match the edited schema (or restore the removed/renamed properties), then re-run plan/apply." });
575
+ }
576
+ function findFlowsPinnedTo(previousId, localFlows) {
577
+ const affected = [];
578
+ for (const [relPath, ref] of localFlows.entries()) if (ref === previousId) affected.push(relPath);
579
+ return affected;
580
+ }
581
+ /**
582
+ * Legacy content hash: order-sensitive and normalization-blind. Kept only
583
+ * so state entries written by older CLI versions still match; new hashes
584
+ * come from {@link hashForState}.
585
+ */
586
+ function hashResourceContent(data) {
407
587
  return createHash("sha256").update(JSON.stringify(data)).digest("hex");
408
588
  }
589
+ /**
590
+ * The content hash stored in `.zitadel/state.json`: key-order-insensitive
591
+ * (via `stableStringify`) and computed on the syncer's normalized form, so
592
+ * reordering keys or spelling out a meta-schema default does not read as an
593
+ * edit.
594
+ */
595
+ function hashForState(syncer, data) {
596
+ const normalized = syncer.normalize?.(data) ?? data;
597
+ return createHash("sha256").update(stableStringify(normalized)).digest("hex");
598
+ }
409
599
  //#endregion
410
600
  //#region src/lib/sync/plan-renderer.ts
411
601
  /**
@@ -418,6 +608,7 @@ function summarizePlan(actions) {
418
608
  return {
419
609
  creates: active.filter((a) => a.kind === "create").length,
420
610
  updates: active.filter((a) => a.kind === "update").length,
611
+ revisions: active.filter((a) => a.kind === "revise").length,
421
612
  deletes: active.filter((a) => a.kind === "delete").length,
422
613
  total: active.length
423
614
  };
@@ -441,10 +632,11 @@ function renderPlan(actions, tty) {
441
632
  out.push(...renderBlock(action, tty));
442
633
  }
443
634
  out.push("");
444
- const { creates, updates, deletes } = summarizePlan(actions);
635
+ const { creates, updates, revisions, deletes } = summarizePlan(actions);
445
636
  const parts = [];
446
637
  if (creates > 0) parts.push(`${creates} to add`);
447
638
  if (updates > 0) parts.push(`${updates} to change`);
639
+ if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
448
640
  if (deletes > 0) parts.push(`${deletes} to destroy`);
449
641
  out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
450
642
  return out.join("\n");
@@ -600,7 +792,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
600
792
  const col = (s) => paint(s, A.yellow, tty);
601
793
  lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
602
794
  }
603
- else if (Array.isArray(oldVal) && Array.isArray(newVal)) if (JSON.stringify(oldVal) === JSON.stringify(newVal)) if (newVal.length === 0) lines.push(`${pad} ${pk} = []`);
795
+ else if (Array.isArray(oldVal) && Array.isArray(newVal)) if (stableStringify(oldVal) === stableStringify(newVal)) if (newVal.length === 0) lines.push(`${pad} ${pk} = []`);
604
796
  else {
605
797
  lines.push(`${pad} ${pk} = [`);
606
798
  renderArrayItems(newVal, " ", prefixCol + 4, {
@@ -667,6 +859,14 @@ function resourceName(path) {
667
859
  return path.split("/").pop() ?? path;
668
860
  }
669
861
  /**
862
+ * Diff both sides in the syncer's canonical form so server-echoed noise
863
+ * (empty `audience`, spelled-out meta-schema defaults) never renders as a
864
+ * change the author didn't make. Rendering only — upload payloads stay raw.
865
+ */
866
+ function normalized(syncer, content) {
867
+ return syncer.normalize?.(content) ?? content;
868
+ }
869
+ /**
670
870
  * Renders one Terraform-style resource block for a single `SyncAction`.
671
871
  *
672
872
  * Per-case notes:
@@ -690,10 +890,12 @@ function renderBlock(action, tty) {
690
890
  const opening = `${blkPad}+ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
691
891
  lines.push(paint(header, A.bold, tty));
692
892
  lines.push(paint(opening, A.green, tty));
693
- renderFields({
893
+ const display = {
694
894
  id: KNOWN_AFTER_APPLY,
695
895
  ...action.content
696
- }, "+", FIELD_COL, {
896
+ };
897
+ if (action.repin) display.user_schema = action.repin.newId ?? KNOWN_AFTER_APPLY;
898
+ renderFields(display, "+", FIELD_COL, {
697
899
  tty,
698
900
  deleteMode: false
699
901
  }, lines);
@@ -717,20 +919,46 @@ function renderBlock(action, tty) {
717
919
  break;
718
920
  }
719
921
  case "update": {
720
- const header = `${blkPad}# ${action.path} will be updated in-place`;
922
+ const headerSuffix = action.repin ? " (re-pin user_schema)" : "";
923
+ const header = `${blkPad}# ${action.path} will be updated in-place${headerSuffix}`;
721
924
  const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
722
925
  lines.push(paint(header, A.bold, tty));
723
926
  lines.push(paint(opening, A.yellow, tty));
724
- if (action.oldContent) renderDiff(action.oldContent, action.content, FIELD_COL, tty, lines);
927
+ const newContent = action.repin ? {
928
+ ...normalized(action.syncer, action.content),
929
+ user_schema: action.repin.newId ?? KNOWN_AFTER_APPLY
930
+ } : normalized(action.syncer, action.content);
931
+ if (action.oldContent) renderDiff(normalized(action.syncer, action.oldContent), newContent, FIELD_COL, tty, lines);
932
+ 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));
725
933
  else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
726
934
  lines.push(`${closePad}}`);
727
935
  break;
728
936
  }
937
+ case "revise": {
938
+ const header = `${blkPad}# ${action.path} will publish a new revision`;
939
+ const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
940
+ lines.push(paint(header, A.bold, tty));
941
+ lines.push(paint(opening, A.yellow, tty));
942
+ if (action.oldContent) renderDiff({
943
+ id: action.previousId,
944
+ ...normalized(action.syncer, action.oldContent)
945
+ }, {
946
+ id: KNOWN_AFTER_APPLY,
947
+ ...normalized(action.syncer, action.content)
948
+ }, FIELD_COL, tty, lines);
949
+ else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
950
+ lines.push(`${closePad}}`);
951
+ if (action.affectedPaths.length > 0) {
952
+ lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
953
+ for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
954
+ }
955
+ break;
956
+ }
729
957
  case "skip": break;
730
958
  }
731
959
  return lines;
732
960
  }
733
961
  //#endregion
734
- export { makeSyncers as a, runSyncLoop as i, summarizePlan as n, environmentSchema as o, buildSyncPlan as r, renderPlan as t };
962
+ export { runSyncLoop as a, makeSyncers as c, hashForState as i, FLOWS_DIR as l, summarizePlan as n, writeBackResource as o, buildSyncPlan as r, updateState as s, renderPlan as t };
735
963
 
736
- //# sourceMappingURL=sync-B8Z2it_E.mjs.map
964
+ //# sourceMappingURL=sync-CYuHVQT5.mjs.map