@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.
- package/README.md +137 -57
- package/SKILLS.md +26 -13
- package/dist/commands/apply.mjs +11 -6
- package/dist/commands/apply.mjs.map +1 -1
- package/dist/commands/doctor.mjs +89 -12
- package/dist/commands/doctor.mjs.map +1 -1
- package/dist/commands/eject.mjs +4 -4
- package/dist/commands/logs.mjs +2 -2
- package/dist/commands/plan.mjs +6 -4
- package/dist/commands/plan.mjs.map +1 -1
- package/dist/commands/reset.mjs +2 -2
- package/dist/commands/schemas/list.mjs +134 -0
- package/dist/commands/schemas/list.mjs.map +1 -0
- package/dist/commands/setup.mjs +158 -23
- package/dist/commands/setup.mjs.map +1 -1
- package/dist/commands/start.mjs +4 -4
- package/dist/commands/status.mjs +3 -3
- package/dist/commands/stop.mjs +3 -3
- package/dist/{docker-Djz6BLp9.mjs → docker-D7BSD5C9.mjs} +3 -3
- package/dist/{docker-Djz6BLp9.mjs.map → docker-D7BSD5C9.mjs.map} +1 -1
- package/dist/{docker-guidance-D-33g1uV.mjs → docker-guidance-mcTT3_0E.mjs} +2 -2
- package/dist/{docker-guidance-D-33g1uV.mjs.map → docker-guidance-mcTT3_0E.mjs.map} +1 -1
- package/dist/environment-BQF7LeCz.mjs +17 -0
- package/dist/environment-BQF7LeCz.mjs.map +1 -0
- package/dist/{oclif-BoUVygsZ.mjs → oclif-Bm-FkF6z.mjs} +66 -13
- package/dist/oclif-Bm-FkF6z.mjs.map +1 -0
- package/dist/{orca-DU8myWGm.mjs → orca-CpXj_XsA.mjs} +46 -94
- package/dist/orca-CpXj_XsA.mjs.map +1 -0
- package/dist/{ports-B09RjuHx.mjs → ports-CxBKS1ga.mjs} +1 -1
- package/dist/{ports-B09RjuHx.mjs.map → ports-CxBKS1ga.mjs.map} +1 -1
- package/dist/{processes-Cw8TO1SY.mjs → processes-BVqYsxT8.mjs} +1 -1
- package/dist/{processes-Cw8TO1SY.mjs.map → processes-BVqYsxT8.mjs.map} +1 -1
- package/dist/{project-B1qVQMKS.mjs → project-Dk7V0xka.mjs} +3 -3
- package/dist/{project-B1qVQMKS.mjs.map → project-Dk7V0xka.mjs.map} +1 -1
- package/dist/{sync-B8Z2it_E.mjs → sync-CYuHVQT5.mjs} +344 -116
- package/dist/sync-CYuHVQT5.mjs.map +1 -0
- package/dist/user-schema-DDz5-lX5.mjs +13 -0
- package/dist/user-schema-DDz5-lX5.mjs.map +1 -0
- package/oclif.manifest.json +106 -1
- package/package.json +6 -5
- package/dist/oclif-BoUVygsZ.mjs.map +0 -1
- package/dist/orca-DU8myWGm.mjs.map +0 -1
- package/dist/sync-B8Z2it_E.mjs.map +0 -1
|
@@ -1,64 +1,12 @@
|
|
|
1
|
-
import { C as isObject, E as ZitadelError } from "./oclif-
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
|
170
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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 =
|
|
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: "
|
|
313
|
+
reason: "no-change"
|
|
321
314
|
});
|
|
322
315
|
continue;
|
|
323
316
|
}
|
|
324
|
-
if (
|
|
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: "
|
|
321
|
+
kind: "revise",
|
|
327
322
|
path: relPath,
|
|
328
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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 "
|
|
376
|
-
await action.syncer.
|
|
377
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
893
|
+
const display = {
|
|
694
894
|
id: KNOWN_AFTER_APPLY,
|
|
695
895
|
...action.content
|
|
696
|
-
}
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
964
|
+
//# sourceMappingURL=sync-CYuHVQT5.mjs.map
|