@zitadel/cli 0.1.0-alpha.9 → 1.0.0-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +250 -62
- package/SKILLS.md +221 -37
- package/dist/branding-cygSBPkV.mjs +79 -0
- package/dist/branding-cygSBPkV.mjs.map +1 -0
- package/dist/claim-state-DlfD6Res.mjs +66 -0
- package/dist/claim-state-DlfD6Res.mjs.map +1 -0
- package/dist/commands/apply.mjs +27 -8
- package/dist/commands/apply.mjs.map +1 -1
- package/dist/commands/branding/eject.mjs +104 -0
- package/dist/commands/branding/eject.mjs.map +1 -0
- package/dist/commands/claim.mjs +402 -0
- package/dist/commands/claim.mjs.map +1 -0
- package/dist/commands/doctor.mjs +594 -41
- package/dist/commands/doctor.mjs.map +1 -1
- package/dist/commands/eject.mjs +18 -6
- package/dist/commands/eject.mjs.map +1 -1
- package/dist/commands/logs.mjs +2 -2
- package/dist/commands/plan.mjs +19 -6
- package/dist/commands/plan.mjs.map +1 -1
- package/dist/commands/reset.mjs +2 -2
- package/dist/commands/schemas/list.mjs +146 -0
- package/dist/commands/schemas/list.mjs.map +1 -0
- package/dist/commands/setup.mjs +629 -214
- package/dist/commands/setup.mjs.map +1 -1
- package/dist/commands/start.mjs +4 -4
- package/dist/commands/start.mjs.map +1 -1
- package/dist/commands/status.mjs +52 -13
- package/dist/commands/status.mjs.map +1 -1
- package/dist/commands/stop.mjs +3 -3
- package/dist/commands/stop.mjs.map +1 -1
- package/dist/designs-Ckz18Dpo.mjs +38 -0
- package/dist/designs-Ckz18Dpo.mjs.map +1 -0
- package/dist/{docker-CnGQK3ZK.mjs → docker-DcRGTOJa.mjs} +5 -4
- package/dist/docker-DcRGTOJa.mjs.map +1 -0
- package/dist/environment-rjRVkJjW.mjs +17 -0
- package/dist/environment-rjRVkJjW.mjs.map +1 -0
- package/dist/journey-guidance-BGrOX_gT.mjs +40 -0
- package/dist/journey-guidance-BGrOX_gT.mjs.map +1 -0
- package/dist/oclif-CanO3zdt.mjs +1769 -0
- package/dist/oclif-CanO3zdt.mjs.map +1 -0
- package/dist/{orca-U142Wrau.mjs → orca-BzQIQqzt.mjs} +1663 -303
- package/dist/orca-BzQIQqzt.mjs.map +1 -0
- package/dist/package-manager-DFdLcDx1.mjs +194 -0
- package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
- package/dist/{ports-B09RjuHx.mjs → ports-BM20XIZb.mjs} +25 -20
- package/dist/ports-BM20XIZb.mjs.map +1 -0
- package/dist/{processes-Cw8TO1SY.mjs → processes-DKHPkU8O.mjs} +1 -1
- package/dist/{processes-Cw8TO1SY.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
- package/dist/{project-Cd0L3PtM.mjs → project-CHhot85s.mjs} +57 -4
- package/dist/project-CHhot85s.mjs.map +1 -0
- package/dist/sync-BBlACKzT.mjs +1633 -0
- package/dist/sync-BBlACKzT.mjs.map +1 -0
- package/dist/user-schema-DTuOsdKE.mjs +91 -0
- package/dist/user-schema-DTuOsdKE.mjs.map +1 -0
- package/oclif.manifest.json +406 -5
- package/package.json +10 -5
- package/dist/docker-CnGQK3ZK.mjs.map +0 -1
- package/dist/docker-guidance-ypN3IM3o.mjs +0 -21
- package/dist/docker-guidance-ypN3IM3o.mjs.map +0 -1
- package/dist/oclif-B7lBzh3R.mjs +0 -808
- package/dist/oclif-B7lBzh3R.mjs.map +0 -1
- package/dist/orca-U142Wrau.mjs.map +0 -1
- package/dist/ports-B09RjuHx.mjs.map +0 -1
- package/dist/project-Cd0L3PtM.mjs.map +0 -1
- package/dist/sync-BojoQm2P.mjs +0 -733
- package/dist/sync-BojoQm2P.mjs.map +0 -1
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
import { E as ZitadelError, S as isObject, T as stableStringify } from "./oclif-CanO3zdt.mjs";
|
|
2
|
+
import { a as updateState, n as readState, r as removeFromState, t as SCHEMAS_DIR } from "./user-schema-DTuOsdKE.mjs";
|
|
3
|
+
import { a as toLocalBrandingBody, i as toBrandingWireBody, n as readDescriptorTemplate, r as resolveTemplatePath, t as BRANDING_DIR } from "./branding-cygSBPkV.mjs";
|
|
4
|
+
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
|
|
7
|
+
import { consola as consola$1 } from "consola";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
|
|
11
|
+
import { brandingConfigSchema, flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
|
|
12
|
+
import { validateLoginTemplate } from "@zitadel/config/template";
|
|
13
|
+
import { lookup } from "node:dns/promises";
|
|
14
|
+
import { BlockList, isIP } from "node:net";
|
|
15
|
+
import { Agent } from "undici";
|
|
16
|
+
import { validateFlowDefinition } from "@zitadel/config/validate";
|
|
17
|
+
//#region src/lib/flows/env-refs.ts
|
|
18
|
+
/**
|
|
19
|
+
* Collects the environment variables a flows document depends on, sorted and
|
|
20
|
+
* de-duplicated. Recognises two reference styles: inline `${VAR}` interpolations
|
|
21
|
+
* inside string values, and keys ending in `_env` whose value names a single
|
|
22
|
+
* variable. `apply`/`plan` use this to fail before contacting the platform when
|
|
23
|
+
* a required variable is absent.
|
|
24
|
+
*/
|
|
25
|
+
function flowEnvRefs(value) {
|
|
26
|
+
const refs = /* @__PURE__ */ new Set();
|
|
27
|
+
const visit = (node) => {
|
|
28
|
+
if (typeof node === "string") for (const match of node.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
29
|
+
const ref = match[1];
|
|
30
|
+
if (ref) refs.add(ref);
|
|
31
|
+
}
|
|
32
|
+
else if (Array.isArray(node)) node.forEach(visit);
|
|
33
|
+
else if (isObject(node)) for (const [key, child] of Object.entries(node)) if (key.endsWith("_env") && typeof child === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(child)) refs.add(child);
|
|
34
|
+
else visit(child);
|
|
35
|
+
};
|
|
36
|
+
visit(value);
|
|
37
|
+
return [...refs].sort();
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/lib/flows/index.ts
|
|
41
|
+
/**
|
|
42
|
+
* Relative directory (from the project root) where local flow files
|
|
43
|
+
* live. Owned here so callers (`commands/*`, `sync/syncers.ts`) and
|
|
44
|
+
* tests share a single source of truth for the path; the runtime
|
|
45
|
+
* never depends on it directly because `lib/flows` does not touch
|
|
46
|
+
* the filesystem.
|
|
47
|
+
*/
|
|
48
|
+
const FLOWS_DIR = ".zitadel/flows";
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/lib/sync/syncers.ts
|
|
51
|
+
/**
|
|
52
|
+
* Build the syncer list with the context every syncer needs: the
|
|
53
|
+
* `project_id` flow creates carry, and the runtime `env` against which
|
|
54
|
+
* each file's `${VAR}` / `*_env` references are checked. Callers (apply /
|
|
55
|
+
* plan / setup) read `project_id` from `.zitadel/secret` and pass the
|
|
56
|
+
* process environment. The returned array is treated as read-only by the
|
|
57
|
+
* sync loop.
|
|
58
|
+
*/
|
|
59
|
+
function makeSyncers(opts) {
|
|
60
|
+
return [
|
|
61
|
+
new SchemaSyncer(opts.client, opts.projectId, opts.env),
|
|
62
|
+
new FlowDefinitionSyncer(opts.client, opts.projectId, opts.env),
|
|
63
|
+
new BrandingSyncer(opts.client, opts.projectId, opts.env, opts.cwd)
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Assert that every env var a resource references — `${VAR}` placeholders and
|
|
68
|
+
* the `*_env` convention — is present in `env`, throwing `E_VALIDATION` listing
|
|
69
|
+
* the missing names. Shared by every syncer so the check is identical for
|
|
70
|
+
* schemas and flows, and runs in the sync engine before any platform call.
|
|
71
|
+
*/
|
|
72
|
+
function assertEnvRefs(data, env) {
|
|
73
|
+
const missing = flowEnvRefs(data).filter((name) => !env[name]);
|
|
74
|
+
if (missing.length > 0) throw new ZitadelError("E_VALIDATION", `Missing environment variables: ${missing.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
var SchemaSyncer = class {
|
|
77
|
+
kind = "schema";
|
|
78
|
+
directory = SCHEMAS_DIR;
|
|
79
|
+
mutable = false;
|
|
80
|
+
revisioned = true;
|
|
81
|
+
normalize = normalizeSchemaBody;
|
|
82
|
+
constructor(client, projectId, env) {
|
|
83
|
+
this.client = client;
|
|
84
|
+
this.projectId = projectId;
|
|
85
|
+
this.env = env;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parse against the generated `CreateSchemaBody` Zod (the orval-emitted
|
|
89
|
+
* equivalent of `api/openapi/endpoints/schemas/user-schema.yaml`). The
|
|
90
|
+
* generated schema is a union of `user-schema` and `schema-url`
|
|
91
|
+
* discriminated on `kind`; both are valid on-disk bodies.
|
|
92
|
+
*/
|
|
93
|
+
validate(data) {
|
|
94
|
+
const result = schemaConfigSchema.safeParse(data);
|
|
95
|
+
if (!result.success) throw new ZitadelError("E_VALIDATION", "Schema file is not a valid Zitadel schema body", { details: { issues: result.error.issues } });
|
|
96
|
+
assertEnvRefs(data, this.env);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `POST /schemas` mints a new immutable row. The server allocates the
|
|
100
|
+
* opaque id; the CLI records it in state and re-pins flows against it.
|
|
101
|
+
* The create response carries only the id, so the canonical stored body
|
|
102
|
+
* comes from a follow-up fetch; a fetch failure degrades to no
|
|
103
|
+
* write-back rather than failing the create.
|
|
104
|
+
*/
|
|
105
|
+
async create(data) {
|
|
106
|
+
const result = await this.client.createSchema(data, { project_id: this.projectId });
|
|
107
|
+
try {
|
|
108
|
+
return {
|
|
109
|
+
id: result.id,
|
|
110
|
+
canonical: await this.fetch(result.id)
|
|
111
|
+
};
|
|
112
|
+
} catch (err) {
|
|
113
|
+
consola$1.debug(`fetch created schema ${result.id} failed:`, err);
|
|
114
|
+
return { id: result.id };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Not called by the sync loop: schemas are `revisioned`, so a hash change
|
|
119
|
+
* publishes a new immutable revision through {@link create} rather than
|
|
120
|
+
* mutating an existing row. Kept as a required interface member; throws
|
|
121
|
+
* loudly if a caller reaches it.
|
|
122
|
+
*/
|
|
123
|
+
async update(_id, _data) {
|
|
124
|
+
throw new ZitadelError("E_NOT_IMPLEMENTED", "schemas are revisioned — edit publishes a new revision, not an update");
|
|
125
|
+
}
|
|
126
|
+
async delete(id) {
|
|
127
|
+
throw new ZitadelError("E_NOT_IMPLEMENTED", `schema delete is not supported (${id})`);
|
|
128
|
+
}
|
|
129
|
+
async fetch(id) {
|
|
130
|
+
return (await this.client.getSchemaById(encodeURIComponent(id))).schema;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var FlowDefinitionSyncer = class {
|
|
134
|
+
kind = "flow";
|
|
135
|
+
directory = FLOWS_DIR;
|
|
136
|
+
mutable = true;
|
|
137
|
+
revisioned = false;
|
|
138
|
+
normalize = normalizeFlowBody;
|
|
139
|
+
normalizeWrite = normalizeFlowBody;
|
|
140
|
+
constructor(client, projectId, env) {
|
|
141
|
+
this.client = client;
|
|
142
|
+
this.projectId = projectId;
|
|
143
|
+
this.env = env;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Validates one flow file against the canonical `flowConfigSchema` (the
|
|
147
|
+
* same Zod `validateFlows` and doctor use), then checks env references.
|
|
148
|
+
*/
|
|
149
|
+
validate(data) {
|
|
150
|
+
const result = flowConfigSchema.safeParse(data);
|
|
151
|
+
if (!result.success) throw new ZitadelError("E_VALIDATION", "Flow file is not a valid Zitadel flow body", { details: { issues: result.error.issues } });
|
|
152
|
+
assertEnvRefs(data, this.env);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Wraps the bare on-disk flow body in the spec's create-envelope
|
|
156
|
+
* (`api/openapi/components/flows/flow-definition-create-request.yaml`)
|
|
157
|
+
* before sending. The file on disk stays bare so it is human-editable;
|
|
158
|
+
* only the wire request carries `project_id` and the surrounding
|
|
159
|
+
* envelope.
|
|
160
|
+
*/
|
|
161
|
+
async create(data) {
|
|
162
|
+
const result = await this.client.createFlowDefinition({
|
|
163
|
+
project_id: this.projectId,
|
|
164
|
+
schema_uri: DEFAULT_FLOW_SCHEMA_URI,
|
|
165
|
+
flow_definition: data
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
id: result.id,
|
|
169
|
+
canonical: result.flow_definition
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* PUT completely replaces the flow definition. The wire request wraps the
|
|
174
|
+
* bare on-disk flow in the `{ flow_definition }` update envelope
|
|
175
|
+
* (`api/openapi/components/flows/flow-definition-update-request.yaml`); the
|
|
176
|
+
* file on disk stays bare so it is human-editable. Flat-by-id: no
|
|
177
|
+
* `project_id` query — authz resolves the project from RSI.
|
|
178
|
+
*/
|
|
179
|
+
async update(id, data) {
|
|
180
|
+
return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data })).flow_definition };
|
|
181
|
+
}
|
|
182
|
+
async delete(id) {
|
|
183
|
+
await this.client.deleteFlowDefinition(id);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* `GET /flow_definitions/:id` returns a response envelope with metadata
|
|
187
|
+
* (`id`, `project_id`, `created_at`, `updated_at`) plus `flow_definition`.
|
|
188
|
+
* Return only `flow_definition` so diffs compare with the on-disk bare body.
|
|
189
|
+
* Flat-by-id: no `project_id` query — authz resolves the project from RSI.
|
|
190
|
+
*/
|
|
191
|
+
async fetch(id) {
|
|
192
|
+
return (await this.client.getFlowDefinition(id)).flow_definition;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Branding revisions (ADR 040): schema-style immutable semantics — every
|
|
197
|
+
* edit publishes a new revision via `POST /branding`, no update or delete.
|
|
198
|
+
* Unlike schemas, nothing references branding revisions, so a revise never
|
|
199
|
+
* triggers re-pinning. The descriptor keeps the template in a sibling
|
|
200
|
+
* `.liquid` file (`liquid_template_file`); this syncer inlines it for
|
|
201
|
+
* hashing and upload and splits it back out on write-back.
|
|
202
|
+
*/
|
|
203
|
+
var BrandingSyncer = class {
|
|
204
|
+
kind = "branding";
|
|
205
|
+
directory = BRANDING_DIR;
|
|
206
|
+
mutable = false;
|
|
207
|
+
revisioned = true;
|
|
208
|
+
/** One project, one branding descriptor — extra .json files fail the scan. */
|
|
209
|
+
singletonFile = "branding.json";
|
|
210
|
+
constructor(client, projectId, env, cwd) {
|
|
211
|
+
this.client = client;
|
|
212
|
+
this.projectId = projectId;
|
|
213
|
+
this.env = env;
|
|
214
|
+
this.cwd = cwd;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The comparison form is the wire body with the template inlined, so an
|
|
218
|
+
* edit to the referenced `.liquid` file changes the state hash and plans
|
|
219
|
+
* a `revise` even though the descriptor JSON is untouched.
|
|
220
|
+
*/
|
|
221
|
+
normalize = (data) => toBrandingWireBody(this.cwd, data);
|
|
222
|
+
/**
|
|
223
|
+
* Zod shape + env refs, then the authoritative template validation from
|
|
224
|
+
* `@zitadel/config/template` — the LiquidJS-dialect check the Go server
|
|
225
|
+
* cannot run (its save gate is lexical; see ADR 040).
|
|
226
|
+
*/
|
|
227
|
+
validate(data) {
|
|
228
|
+
const result = brandingConfigSchema.safeParse(data);
|
|
229
|
+
if (!result.success) throw new ZitadelError("E_VALIDATION", "Branding file is not a valid branding descriptor", { details: { issues: result.error.issues } });
|
|
230
|
+
assertEnvRefs(data, this.env);
|
|
231
|
+
const template = readDescriptorTemplate(this.cwd, data);
|
|
232
|
+
if (template === void 0) return;
|
|
233
|
+
const issues = validateLoginTemplate(template).filter((issue) => issue.severity === "error");
|
|
234
|
+
if (issues.length > 0) throw new ZitadelError("E_VALIDATION", "Login template failed validation", {
|
|
235
|
+
details: { issues },
|
|
236
|
+
hint: "Fix the template issues; the rules live in docs/design/flowengine/template-security.md."
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* `POST /branding` publishes a new immutable revision. The canonical body
|
|
241
|
+
* is converted back to descriptor form: the stored template is written to
|
|
242
|
+
* the referenced `.liquid` file (when it differs) and the JSON keeps the
|
|
243
|
+
* file reference, so `writeBackResource` stays pure-JSON.
|
|
244
|
+
*/
|
|
245
|
+
async create(data) {
|
|
246
|
+
const wire = toBrandingWireBody(this.cwd, data);
|
|
247
|
+
const result = await this.client.createBranding(wire, { project_id: this.projectId });
|
|
248
|
+
return {
|
|
249
|
+
id: result.id,
|
|
250
|
+
canonical: this.canonicalToLocal(result.branding, data)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async update(_id, _data) {
|
|
254
|
+
throw new ZitadelError("E_NOT_IMPLEMENTED", "branding is revisioned — edit publishes a new revision, not an update");
|
|
255
|
+
}
|
|
256
|
+
async delete(id) {
|
|
257
|
+
throw new ZitadelError("E_NOT_IMPLEMENTED", `branding delete is not supported (${id})`);
|
|
258
|
+
}
|
|
259
|
+
/** Wire form (template inlined); diffs compare in the normalized form. Flat-by-id: no project_id query. */
|
|
260
|
+
async fetch(id) {
|
|
261
|
+
return (await this.client.getBrandingById(id)).branding;
|
|
262
|
+
}
|
|
263
|
+
canonicalToLocal(canonicalWire, localData) {
|
|
264
|
+
const local = localData;
|
|
265
|
+
const template = canonicalWire.liquid_template;
|
|
266
|
+
if (typeof local.liquid_template_file === "string" && typeof template === "string") {
|
|
267
|
+
const path = resolveTemplatePath(this.cwd, local.liquid_template_file);
|
|
268
|
+
if (readDescriptorTemplate(this.cwd, localData) !== template) {
|
|
269
|
+
writeFileSync(path, template);
|
|
270
|
+
consola$1.info(`Updated ${local.liquid_template_file} from the server's canonical response`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return toLocalBrandingBody(canonicalWire, localData);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/lib/sync/asset-probe.ts
|
|
278
|
+
/**
|
|
279
|
+
* Branding descriptor fields whose value is a URL the login page fetches as
|
|
280
|
+
* an image. Both are optional and both fail the same invisible way when the
|
|
281
|
+
* URL is well-formed but dead.
|
|
282
|
+
*/
|
|
283
|
+
const ASSET_FIELDS = ["logo_url", "hero_url"];
|
|
284
|
+
/**
|
|
285
|
+
* Per-URL probe budget. Short on purpose: this runs on the critical path of
|
|
286
|
+
* every `plan` and `apply`, and a dead host that never answers must cost a
|
|
287
|
+
* couple of seconds, not a hung command.
|
|
288
|
+
*/
|
|
289
|
+
const DEFAULT_TIMEOUT_MS = 2500;
|
|
290
|
+
/** Keep redirect chains bounded and re-validate every destination ourselves. */
|
|
291
|
+
const MAX_REDIRECTS = 5;
|
|
292
|
+
/**
|
|
293
|
+
* Addresses an untrusted descriptor must never make the planning host contact.
|
|
294
|
+
* The browser-side render gate still supports canonical loopback HTTP for local
|
|
295
|
+
* development; the CLI probe deliberately leaves those URLs inconclusive
|
|
296
|
+
* instead of turning `plan` over repo content into a localhost/network scanner.
|
|
297
|
+
*/
|
|
298
|
+
const UNSAFE_IPV4_ADDRESSES = new BlockList();
|
|
299
|
+
for (const [network, prefix] of [
|
|
300
|
+
["0.0.0.0", 8],
|
|
301
|
+
["10.0.0.0", 8],
|
|
302
|
+
["100.64.0.0", 10],
|
|
303
|
+
["127.0.0.0", 8],
|
|
304
|
+
["169.254.0.0", 16],
|
|
305
|
+
["172.16.0.0", 12],
|
|
306
|
+
["192.0.0.0", 24],
|
|
307
|
+
["192.0.2.0", 24],
|
|
308
|
+
["192.88.99.0", 24],
|
|
309
|
+
["192.168.0.0", 16],
|
|
310
|
+
["198.18.0.0", 15],
|
|
311
|
+
["198.51.100.0", 24],
|
|
312
|
+
["203.0.113.0", 24],
|
|
313
|
+
["224.0.0.0", 4],
|
|
314
|
+
["240.0.0.0", 4]
|
|
315
|
+
]) UNSAFE_IPV4_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
316
|
+
const UNSAFE_IPV6_ADDRESSES = new BlockList();
|
|
317
|
+
for (const [network, prefix] of [
|
|
318
|
+
["::", 3],
|
|
319
|
+
["4000::", 2],
|
|
320
|
+
["8000::", 1],
|
|
321
|
+
["2001::", 23],
|
|
322
|
+
["2001:db8::", 32],
|
|
323
|
+
["2002::", 16],
|
|
324
|
+
["3fff::", 20]
|
|
325
|
+
]) UNSAFE_IPV6_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
326
|
+
/** Content types that are images despite not being `image/*`. */
|
|
327
|
+
const EXTRA_IMAGE_TYPES = new Set(["application/octet-stream"]);
|
|
328
|
+
/**
|
|
329
|
+
* Annotate every branding action in a plan with warnings for asset URLs that
|
|
330
|
+
* are well-formed but will not render: an unreachable host, a non-2xx status,
|
|
331
|
+
* or a response that is not an image.
|
|
332
|
+
*
|
|
333
|
+
* Why the CLI probes at all: a bad `logo_url` clears every gate on the way in
|
|
334
|
+
* (the CLI's Zod shape check, the server's save gate) and then fails silently
|
|
335
|
+
* in the browser as a 0×0 `<img>` — no plan output, no apply output, no
|
|
336
|
+
* console error. The probe is the only place in the pipeline that can see it.
|
|
337
|
+
*
|
|
338
|
+
* Deliberately non-fatal and best-effort. A developer on a plane, behind a
|
|
339
|
+
* proxy, or pointing at a CDN that only resolves from production gets a
|
|
340
|
+
* warning, never a failed plan — the machine running `plan` is not
|
|
341
|
+
* necessarily the machine that renders the login page. Set
|
|
342
|
+
* `ZITADEL_SKIP_ASSET_PROBE` to turn it off entirely, and
|
|
343
|
+
* `ZITADEL_ASSET_PROBE_TIMEOUT_MS` to retune the per-URL budget. Only public
|
|
344
|
+
* HTTPS destinations are contacted: loopback/private/internal targets and
|
|
345
|
+
* redirects to them stay inconclusive so repo config cannot scan the planning
|
|
346
|
+
* host's network.
|
|
347
|
+
*
|
|
348
|
+
* Mutates `actions` in place, like {@link validatePlannedFlows} — the caller
|
|
349
|
+
* (`buildSyncPlan`) owns the array it just built.
|
|
350
|
+
*/
|
|
351
|
+
async function annotateAssetWarnings(actions) {
|
|
352
|
+
if (process.env.ZITADEL_SKIP_ASSET_PROBE) {
|
|
353
|
+
consola$1.debug("Branding asset probe skipped (ZITADEL_SKIP_ASSET_PROBE is set)");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const targets = actions.filter((action) => (action.kind === "create" || action.kind === "update" || action.kind === "revise") && action.syncer.kind === "branding");
|
|
357
|
+
if (targets.length === 0) return;
|
|
358
|
+
const urls = /* @__PURE__ */ new Set();
|
|
359
|
+
for (const action of targets) for (const { url } of assetUrlsOf(action.content)) urls.add(url);
|
|
360
|
+
if (urls.size === 0) return;
|
|
361
|
+
const timeoutMs = probeTimeoutMs();
|
|
362
|
+
const verdicts = new Map(await Promise.all([...urls].map(async (url) => [url, await probeAsset(url, timeoutMs)])));
|
|
363
|
+
for (const action of targets) {
|
|
364
|
+
const warnings = [];
|
|
365
|
+
for (const { field, url } of assetUrlsOf(action.content)) {
|
|
366
|
+
const warning = describeVerdict(field, url, verdicts.get(url) ?? { kind: "inconclusive" });
|
|
367
|
+
if (warning) warnings.push(warning);
|
|
368
|
+
}
|
|
369
|
+
if (warnings.length > 0) action.warnings = [...action.warnings ?? [], ...warnings];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The probe itself: a HEAD request bounded by an abort timeout. HEAD keeps a
|
|
374
|
+
* multi-megabyte hero image off the wire — we only need the status line and
|
|
375
|
+
* the content type.
|
|
376
|
+
*/
|
|
377
|
+
async function probeAsset(url, timeoutMs) {
|
|
378
|
+
let current;
|
|
379
|
+
try {
|
|
380
|
+
current = new URL(url);
|
|
381
|
+
} catch {
|
|
382
|
+
return { kind: "inconclusive" };
|
|
383
|
+
}
|
|
384
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
385
|
+
try {
|
|
386
|
+
for (let redirects = 0;; redirects += 1) {
|
|
387
|
+
if (!isSafeProbeUrl(current)) {
|
|
388
|
+
consola$1.debug(`asset probe ${url} skipped unsafe/private target: ${current.href}`);
|
|
389
|
+
return { kind: "inconclusive" };
|
|
390
|
+
}
|
|
391
|
+
const response = await requestHead(current, signal);
|
|
392
|
+
if (isRedirect(response.status)) {
|
|
393
|
+
const location = response.location;
|
|
394
|
+
if (!location) return {
|
|
395
|
+
kind: "status",
|
|
396
|
+
status: response.status
|
|
397
|
+
};
|
|
398
|
+
if (redirects >= MAX_REDIRECTS) return {
|
|
399
|
+
kind: "unreachable",
|
|
400
|
+
detail: `more than ${MAX_REDIRECTS} redirects`
|
|
401
|
+
};
|
|
402
|
+
current = new URL(location, current);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (response.status === 405 || response.status === 501) return { kind: "inconclusive" };
|
|
406
|
+
if (response.status < 200 || response.status >= 300) return {
|
|
407
|
+
kind: "status",
|
|
408
|
+
status: response.status
|
|
409
|
+
};
|
|
410
|
+
if (response.status === 204 || response.status === 205) return {
|
|
411
|
+
kind: "empty",
|
|
412
|
+
status: response.status
|
|
413
|
+
};
|
|
414
|
+
const contentType = response.contentType?.split(";")[0]?.trim().toLowerCase();
|
|
415
|
+
if (!contentType) return { kind: "ok" };
|
|
416
|
+
if (contentType.startsWith("image/") || EXTRA_IMAGE_TYPES.has(contentType)) return { kind: "ok" };
|
|
417
|
+
return {
|
|
418
|
+
kind: "content-type",
|
|
419
|
+
contentType
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (isUnsafeProbeTargetError(error)) {
|
|
424
|
+
consola$1.debug(`asset probe ${url} skipped unsafe/private target`);
|
|
425
|
+
return { kind: "inconclusive" };
|
|
426
|
+
}
|
|
427
|
+
consola$1.debug(`asset probe ${url} failed:`, error);
|
|
428
|
+
return {
|
|
429
|
+
kind: "unreachable",
|
|
430
|
+
detail: describeProbeError(error, timeoutMs)
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function isRedirect(status) {
|
|
435
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Reject targets whose URL alone proves they are unsafe. Hostname resolution
|
|
439
|
+
* happens inside the connector lookup below so validation and connection use
|
|
440
|
+
* the same DNS answer, without a rebinding window.
|
|
441
|
+
*/
|
|
442
|
+
function isSafeProbeUrl(url) {
|
|
443
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "") return false;
|
|
444
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
|
445
|
+
if (hostname === "localhost" || !hostname.includes(".") || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".lan") || hostname.endsWith(".home.arpa")) return false;
|
|
446
|
+
const literalFamily = isIP(hostname);
|
|
447
|
+
return literalFamily === 0 || !isUnsafeAddress(hostname, literalFamily);
|
|
448
|
+
}
|
|
449
|
+
/** Resolve once and accept the answer only when every address is public. */
|
|
450
|
+
async function resolvePublicAddresses(hostname) {
|
|
451
|
+
const addresses = await lookup(hostname, {
|
|
452
|
+
all: true,
|
|
453
|
+
verbatim: true
|
|
454
|
+
});
|
|
455
|
+
return addresses.length > 0 && addresses.every(({ address, family }) => !isUnsafeAddress(address, family)) ? addresses : void 0;
|
|
456
|
+
}
|
|
457
|
+
function isUnsafeAddress(address, family) {
|
|
458
|
+
return family === 6 ? UNSAFE_IPV6_ADDRESSES.check(address, "ipv6") : UNSAFE_IPV4_ADDRESSES.check(address, "ipv4");
|
|
459
|
+
}
|
|
460
|
+
const UNSAFE_PROBE_TARGET_CODE = "ERR_ZITADEL_UNSAFE_ASSET_TARGET";
|
|
461
|
+
function unsafeProbeTargetError(hostname) {
|
|
462
|
+
return Object.assign(/* @__PURE__ */ new Error(`asset target ${hostname} resolves to a non-public address`), { code: UNSAFE_PROBE_TARGET_CODE });
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Validate DNS in the connector's own lookup and return only approved
|
|
466
|
+
* addresses. The socket cannot perform a second lookup, which closes the DNS
|
|
467
|
+
* rebinding gap while preserving the hostname for TLS SNI/certificate checks.
|
|
468
|
+
*/
|
|
469
|
+
async function requestHead(url, signal) {
|
|
470
|
+
const safeLookup = (hostname, options, callback) => {
|
|
471
|
+
resolvePublicAddresses(hostname).then((addresses) => {
|
|
472
|
+
if (!addresses) {
|
|
473
|
+
callback(unsafeProbeTargetError(hostname), "", 0);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const candidates = options.family === 4 || options.family === 6 ? addresses.filter(({ family }) => family === options.family) : addresses;
|
|
477
|
+
const candidate = candidates[0];
|
|
478
|
+
if (!candidate) {
|
|
479
|
+
callback(unsafeProbeTargetError(hostname), "", 0);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (options.all) {
|
|
483
|
+
callback(null, candidates);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
callback(null, candidate.address, candidate.family);
|
|
487
|
+
}, (error) => callback(error, "", 0));
|
|
488
|
+
};
|
|
489
|
+
const dispatcher = new Agent({ connect: { lookup: safeLookup } });
|
|
490
|
+
try {
|
|
491
|
+
const response = await fetch(url, {
|
|
492
|
+
method: "HEAD",
|
|
493
|
+
redirect: "manual",
|
|
494
|
+
signal,
|
|
495
|
+
dispatcher
|
|
496
|
+
});
|
|
497
|
+
return {
|
|
498
|
+
status: response.status,
|
|
499
|
+
location: response.headers.get("location") ?? void 0,
|
|
500
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
501
|
+
};
|
|
502
|
+
} finally {
|
|
503
|
+
await dispatcher.destroy();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function isUnsafeProbeTargetError(error) {
|
|
507
|
+
let current = error;
|
|
508
|
+
for (let depth = 0; depth < 4 && current instanceof Error; depth += 1) {
|
|
509
|
+
if (current.code === UNSAFE_PROBE_TARGET_CODE) return true;
|
|
510
|
+
current = current.cause;
|
|
511
|
+
}
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
function describeVerdict(field, url, verdict) {
|
|
515
|
+
switch (verdict.kind) {
|
|
516
|
+
case "ok":
|
|
517
|
+
case "inconclusive": return;
|
|
518
|
+
case "status": return {
|
|
519
|
+
rule: "warn/asset-unreachable",
|
|
520
|
+
message: `${field} ${url} returned HTTP ${verdict.status} — the login page renders a well-formed URL that serves nothing as a 0×0 image, with no error anywhere.`
|
|
521
|
+
};
|
|
522
|
+
case "empty": return {
|
|
523
|
+
rule: "warn/asset-unreachable",
|
|
524
|
+
message: `${field} ${url} returned HTTP ${verdict.status} with no representation — the login page has no image bytes to render.`
|
|
525
|
+
};
|
|
526
|
+
case "unreachable": return {
|
|
527
|
+
rule: "warn/asset-unreachable",
|
|
528
|
+
message: `${field} ${url} could not be reached (${verdict.detail}) — the login page renders an unreachable URL as a 0×0 image. Ignore this if the host is only reachable from where the login page renders; set ZITADEL_SKIP_ASSET_PROBE to stop checking.`
|
|
529
|
+
};
|
|
530
|
+
case "content-type": return {
|
|
531
|
+
rule: "warn/asset-content-type",
|
|
532
|
+
message: `${field} ${url} responded with content-type "${verdict.contentType}", not an image — the login page renders a non-image response as a 0×0 image.`
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** The `logo_url` / `hero_url` values a descriptor actually sets. */
|
|
537
|
+
function assetUrlsOf(content) {
|
|
538
|
+
const body = content;
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const field of ASSET_FIELDS) {
|
|
541
|
+
const value = body[field];
|
|
542
|
+
if (typeof value === "string" && value !== "") out.push({
|
|
543
|
+
field,
|
|
544
|
+
url: value
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
function probeTimeoutMs() {
|
|
550
|
+
const raw = Number(process.env.ZITADEL_ASSET_PROBE_TIMEOUT_MS);
|
|
551
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
|
|
552
|
+
}
|
|
553
|
+
function describeProbeError(error, timeoutMs) {
|
|
554
|
+
if (error instanceof Error && (error.name === "TimeoutError" || error.cause instanceof Error && error.cause.name === "TimeoutError")) return `no response within ${timeoutMs}ms`;
|
|
555
|
+
if (error instanceof Error) {
|
|
556
|
+
const cause = error.cause;
|
|
557
|
+
return (cause instanceof Error ? cause.message : error.message) || error.name;
|
|
558
|
+
}
|
|
559
|
+
return String(error);
|
|
560
|
+
}
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/lib/sync/flow-validation.ts
|
|
563
|
+
/**
|
|
564
|
+
* Pre-flight semantic validation for every flow this plan uploads,
|
|
565
|
+
* mirroring the server-side `ValidateFlowDefinition` (see
|
|
566
|
+
* `@zitadel/config/validate`). Runs inside `buildSyncPlan`, so `plan`
|
|
567
|
+
* reports the violations the server would only reveal on apply — and
|
|
568
|
+
* `apply` fails before any platform mutation instead of half-applied
|
|
569
|
+
* (a schema revision would otherwise publish before the flow update
|
|
570
|
+
* fails).
|
|
571
|
+
*
|
|
572
|
+
* Only actions that upload a flow (create/update, including repin-forced
|
|
573
|
+
* updates) are validated: an unchanged flow is never re-judged, so a
|
|
574
|
+
* validator behavior change cannot break an already-applied project.
|
|
575
|
+
*
|
|
576
|
+
* Warning-severity issues ANNOTATE the actions in place (`action.warnings`)
|
|
577
|
+
* for plan rendering — `actions` is deliberately typed mutable because the
|
|
578
|
+
* caller (`buildSyncPlan`, which owns the array it just built) expects the
|
|
579
|
+
* annotations. Error-severity issues aggregate into one E_VALIDATION
|
|
580
|
+
* carrying every violation.
|
|
581
|
+
*
|
|
582
|
+
* Escape hatch: setting `ZITADEL_SKIP_FLOW_VALIDATION` skips the check —
|
|
583
|
+
* insurance against a port bug rejecting something the server accepts.
|
|
584
|
+
*/
|
|
585
|
+
function validatePlannedFlows(opts) {
|
|
586
|
+
if (process.env.ZITADEL_SKIP_FLOW_VALIDATION) {
|
|
587
|
+
consola$1.warn("Flow validation skipped (ZITADEL_SKIP_FLOW_VALIDATION is set)");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const failures = [];
|
|
591
|
+
let anyRepin = false;
|
|
592
|
+
const flowCreatePaths = opts.actions.filter((a) => a.kind === "create" && a.syncer.kind === "flow").map((a) => a.path);
|
|
593
|
+
const trackedFlowPaths = Object.keys(opts.stateResources).filter((path) => path.startsWith(`${FLOWS_DIR}/`));
|
|
594
|
+
for (const action of opts.actions) {
|
|
595
|
+
if (action.kind !== "create" && action.kind !== "update") continue;
|
|
596
|
+
if (action.syncer.kind !== "flow") continue;
|
|
597
|
+
const issues = validateFlowDefinition(action.content, resolveSchemaContent(action, opts.scannedContents, opts.stateResources));
|
|
598
|
+
const warnings = issues.filter((issue) => issue.severity === "warning");
|
|
599
|
+
if (warnings.length > 0) action.warnings = warnings.map(({ rule, message }) => ({
|
|
600
|
+
rule,
|
|
601
|
+
message
|
|
602
|
+
}));
|
|
603
|
+
for (const issue of issues) if (issue.severity === "error") {
|
|
604
|
+
failures.push({
|
|
605
|
+
path: action.path,
|
|
606
|
+
issue
|
|
607
|
+
});
|
|
608
|
+
anyRepin ||= action.repin !== void 0;
|
|
609
|
+
}
|
|
610
|
+
const swapWarning = defaultSwapWarning(action, flowCreatePaths, trackedFlowPaths);
|
|
611
|
+
if (swapWarning !== void 0) action.warnings = [...action.warnings ?? [], swapWarning];
|
|
612
|
+
}
|
|
613
|
+
if (failures.length === 0) return;
|
|
614
|
+
const noun = failures.length === 1 ? "issue" : "issues";
|
|
615
|
+
throw new ZitadelError("E_VALIDATION", `Flow validation failed (${failures.length} ${noun}):\n` + failures.map(({ path, issue }) => ` - ${path}: ${issue.message}`).join("\n"), {
|
|
616
|
+
hint: "These are the same rules the server enforces on apply. Fix the flow definition(s) and re-run plan." + (anyRepin ? " A failing flow is adopting an edited schema revision — update its steps[].fields to match the edited schema (or restore the removed/renamed properties)." : ""),
|
|
617
|
+
details: { issues: failures.map(({ path, issue }) => ({
|
|
618
|
+
path,
|
|
619
|
+
rule: issue.rule,
|
|
620
|
+
message: issue.message,
|
|
621
|
+
...issue.step === void 0 ? {} : { step: issue.step }
|
|
622
|
+
})) }
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Warn when applying a NEW active, unscoped flow into a project that
|
|
627
|
+
* already has flows: the engine's default selection is newest-unscoped-
|
|
628
|
+
* wins (ties broken by id), so this flow silently becomes what
|
|
629
|
+
* `<zitadel-login>` renders for its purposes unless the widget pins a
|
|
630
|
+
* `flow-name`. Deliberately engine-level rather than a validator rule:
|
|
631
|
+
* the verdict depends on the action kind and the rest of the plan/state,
|
|
632
|
+
* which `validateFlowDefinition` never sees. A project's first flow is
|
|
633
|
+
* exempt — becoming the default is the point of creating it.
|
|
634
|
+
*/
|
|
635
|
+
function defaultSwapWarning(action, flowCreatePaths, trackedFlowPaths) {
|
|
636
|
+
if (action.kind !== "create") return;
|
|
637
|
+
const body = action.content;
|
|
638
|
+
if (body.status !== "active") return;
|
|
639
|
+
if ((body.audience?.team_ids?.length ?? 0) > 0 || (body.audience?.app_ids?.length ?? 0) > 0) return;
|
|
640
|
+
const purposes = Object.keys(body.purposes ?? {});
|
|
641
|
+
if (purposes.length === 0) return;
|
|
642
|
+
if (trackedFlowPaths.filter((path) => path !== action.path).length + flowCreatePaths.filter((path) => path !== action.path).length === 0) return;
|
|
643
|
+
return {
|
|
644
|
+
rule: "warn/default-flow-swap",
|
|
645
|
+
message: `once applied, this flow becomes the new default for ${humanList(purposes)}: pages that do not explicitly set flow-name on <zitadel-login> will start rendering it. If that is not intended, scope it with "audience" or set flow-name on the pages that should use it.`
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** ["login", "register"] → "login and register" (Oxford-free prose list). */
|
|
649
|
+
function humanList(items) {
|
|
650
|
+
if (items.length <= 1) return items[0] ?? "";
|
|
651
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Locate the local content of the schema a flow's `user_schema` pins.
|
|
655
|
+
* A repin action already names the schema file (the pin is adopting that
|
|
656
|
+
* file's next/new revision); otherwise the pinned id is looked up in
|
|
657
|
+
* state and its file body taken from this scan. Unresolvable refs (e.g.
|
|
658
|
+
* an un-substituted `${USER_SCHEMA_URL}` placeholder or a server-side
|
|
659
|
+
* schema not tracked locally) return undefined — flow-local rules still
|
|
660
|
+
* run, schema-dependent rules are skipped.
|
|
661
|
+
*/
|
|
662
|
+
function resolveSchemaContent(action, scannedContents, stateResources) {
|
|
663
|
+
if (action.repin) return scannedContents.get(action.repin.schemaPath);
|
|
664
|
+
const ref = action.content.user_schema;
|
|
665
|
+
if (typeof ref !== "string" || ref === "") return;
|
|
666
|
+
for (const [path, entry] of Object.entries(stateResources)) if (entry.id === ref) return scannedContents.get(path);
|
|
667
|
+
consola$1.debug(`flow validation: no local schema for user_schema ${ref}; flow-local rules only`);
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/lib/sync/loop.ts
|
|
671
|
+
/**
|
|
672
|
+
* Compute the sync plan for `cwd` against the state file and (when
|
|
673
|
+
* `fetchOld` is true) the platform API. The plan is read-only: it
|
|
674
|
+
* decides what create/update/revise/delete operations need to happen but
|
|
675
|
+
* performs none of them. Pass it to {@link runSyncLoop} to execute.
|
|
676
|
+
*
|
|
677
|
+
* Validates every on-disk file (via `syncer.validate`) before planning any
|
|
678
|
+
* work — a single malformed schema or flow aborts the whole run with
|
|
679
|
+
* `E_VALIDATION` before any platform mutation. Both `plan` and `apply`
|
|
680
|
+
* reach this code path.
|
|
681
|
+
*
|
|
682
|
+
* Bearer auth + base URL live in the api package's runtime registries
|
|
683
|
+
* (`runtime/{auth,base-url}`). Callers set them once at command boot;
|
|
684
|
+
* the sync engine doesn't carry a client.
|
|
685
|
+
*
|
|
686
|
+
* @param cwd - Project root.
|
|
687
|
+
* @param syncers - Per-resource adapters. Order is preserved in the output.
|
|
688
|
+
* @param fetchOld - When true, the planner fetches each delete/update target
|
|
689
|
+
* from the platform to populate `oldContent` for diff rendering.
|
|
690
|
+
*/
|
|
691
|
+
async function buildSyncPlan(cwd, syncers, fetchOld = false) {
|
|
692
|
+
const state = await readState(cwd);
|
|
693
|
+
const actions = [];
|
|
694
|
+
const localFlows = await readLocalFlowUserSchemas(cwd);
|
|
695
|
+
const pendingRevisions = /* @__PURE__ */ new Map();
|
|
696
|
+
const recoveredRevisions = /* @__PURE__ */ new Map();
|
|
697
|
+
for (const [schemaPath, entry] of Object.entries(state.resources)) if (entry.previousId && entry.id && entry.previousId !== entry.id) recoveredRevisions.set(entry.previousId, {
|
|
698
|
+
schemaPath,
|
|
699
|
+
newId: entry.id
|
|
700
|
+
});
|
|
701
|
+
const scannedContents = /* @__PURE__ */ new Map();
|
|
702
|
+
for (const syncer of syncers) {
|
|
703
|
+
const dirPath = join(cwd, syncer.directory);
|
|
704
|
+
consola$1.debug(`scanning ${syncer.directory}`);
|
|
705
|
+
const onDisk = await readJsonDir(dirPath);
|
|
706
|
+
if (syncer.singletonFile) for (const absPath of onDisk.keys()) {
|
|
707
|
+
const name = basename(absPath);
|
|
708
|
+
if (name !== syncer.singletonFile) throw new ZitadelError("E_VALIDATION", `unexpected file in ${syncer.directory}: ${name}`, { hint: `${syncer.kind} is a singleton resource — keep exactly one descriptor at ${syncer.directory}/${syncer.singletonFile} and move other .json files out of the directory (each scanned descriptor would publish as its own revision).` });
|
|
709
|
+
}
|
|
710
|
+
for (const content of onDisk.values()) syncer.validate(content);
|
|
711
|
+
for (const [filePath, entry] of Object.entries(state.resources)) {
|
|
712
|
+
if (!filePath.startsWith(syncer.directory)) continue;
|
|
713
|
+
if (onDisk.has(join(cwd, filePath)) || !entry.id) continue;
|
|
714
|
+
let oldContent = null;
|
|
715
|
+
if (fetchOld && syncer.fetch) try {
|
|
716
|
+
oldContent = await syncer.fetch(entry.id);
|
|
717
|
+
} catch (err) {
|
|
718
|
+
consola$1.debug(`fetch ${syncer.kind} ${entry.id} failed:`, err);
|
|
719
|
+
}
|
|
720
|
+
actions.push({
|
|
721
|
+
kind: "delete",
|
|
722
|
+
path: filePath,
|
|
723
|
+
syncer,
|
|
724
|
+
id: entry.id,
|
|
725
|
+
oldContent
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
for (const [absPath, content] of onDisk.entries()) {
|
|
729
|
+
const relPath = absPath.slice(cwd.length + 1);
|
|
730
|
+
scannedContents.set(relPath, content);
|
|
731
|
+
const entry = state.resources[relPath];
|
|
732
|
+
const hash = hashForState(syncer, content);
|
|
733
|
+
const flowRef = localFlows.get(relPath);
|
|
734
|
+
const pending = flowRef ? pendingRevisions.get(flowRef) : void 0;
|
|
735
|
+
const recovered = flowRef ? recoveredRevisions.get(flowRef) : void 0;
|
|
736
|
+
const repin = pending ? {
|
|
737
|
+
previousId: flowRef,
|
|
738
|
+
schemaPath: pending.schemaPath
|
|
739
|
+
} : recovered ? {
|
|
740
|
+
previousId: flowRef,
|
|
741
|
+
schemaPath: recovered.schemaPath,
|
|
742
|
+
newId: recovered.newId
|
|
743
|
+
} : void 0;
|
|
744
|
+
if (!entry?.id) {
|
|
745
|
+
actions.push({
|
|
746
|
+
kind: "create",
|
|
747
|
+
path: relPath,
|
|
748
|
+
syncer,
|
|
749
|
+
content,
|
|
750
|
+
hash,
|
|
751
|
+
...repin ? { repin } : {}
|
|
752
|
+
});
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if ((entry.hash === hash || entry.hash === hashResourceContent(content) || entry.hash === hashResourceContent(JSON.parse(stableStringify(content)))) && !(repin && syncer.mutable)) {
|
|
756
|
+
actions.push({
|
|
757
|
+
kind: "skip",
|
|
758
|
+
path: relPath,
|
|
759
|
+
reason: "no-change"
|
|
760
|
+
});
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (syncer.revisioned) {
|
|
764
|
+
const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
|
|
765
|
+
pendingRevisions.set(entry.id, { schemaPath: relPath });
|
|
766
|
+
actions.push({
|
|
767
|
+
kind: "revise",
|
|
768
|
+
path: relPath,
|
|
769
|
+
syncer,
|
|
770
|
+
content,
|
|
771
|
+
hash,
|
|
772
|
+
previousId: entry.id,
|
|
773
|
+
oldContent,
|
|
774
|
+
affectedPaths: findFlowsPinnedTo(entry.id, localFlows)
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (!syncer.mutable) {
|
|
779
|
+
actions.push({
|
|
780
|
+
kind: "skip",
|
|
781
|
+
path: relPath,
|
|
782
|
+
reason: "immutable"
|
|
783
|
+
});
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const oldContent = await fetchOldIfAsked(syncer, entry.id, fetchOld);
|
|
787
|
+
actions.push({
|
|
788
|
+
kind: "update",
|
|
789
|
+
path: relPath,
|
|
790
|
+
syncer,
|
|
791
|
+
id: entry.id,
|
|
792
|
+
content,
|
|
793
|
+
hash,
|
|
794
|
+
oldContent,
|
|
795
|
+
...repin ? { repin } : {}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
validatePlannedFlows({
|
|
800
|
+
actions,
|
|
801
|
+
scannedContents,
|
|
802
|
+
stateResources: state.resources
|
|
803
|
+
});
|
|
804
|
+
await annotateAssetWarnings(actions);
|
|
805
|
+
return actions;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Execute every action returned by {@link buildSyncPlan} against the
|
|
809
|
+
* platform. Updates the local state file (`.zitadel/state.json`) as
|
|
810
|
+
* each action completes so an interrupted run can resume. After each
|
|
811
|
+
* mutation, the server's canonical body is written back to the local
|
|
812
|
+
* file (when it differs in normalized form), so repo config matches
|
|
813
|
+
* live state by construction and the next `plan` is empty.
|
|
814
|
+
*
|
|
815
|
+
* The platform target (base URL + bearer auth) lives in the api
|
|
816
|
+
* package's runtime registries; callers set them before invoking this.
|
|
817
|
+
*
|
|
818
|
+
* @param cwd - Project root.
|
|
819
|
+
* @param syncers - Per-resource adapters; same list passed to
|
|
820
|
+
* `buildSyncPlan`.
|
|
821
|
+
*/
|
|
822
|
+
async function runSyncLoop(cwd, syncers) {
|
|
823
|
+
const actions = await buildSyncPlan(cwd, syncers);
|
|
824
|
+
for (const action of actions) if (action.kind === "create" || action.kind === "update" || action.kind === "revise") for (const warning of action.warnings ?? []) consola$1.warn(`${action.path}: ${warning.message}`);
|
|
825
|
+
const filesUpdated = [];
|
|
826
|
+
const applied = [];
|
|
827
|
+
const repinned = /* @__PURE__ */ new Map();
|
|
828
|
+
const writeBack = async (action, canonical, fallbackHash) => {
|
|
829
|
+
if (!canonical) return fallbackHash;
|
|
830
|
+
const { hash, changed } = await writeBackResource(cwd, action.path, action.syncer, canonical);
|
|
831
|
+
if (changed) {
|
|
832
|
+
filesUpdated.push(action.path);
|
|
833
|
+
consola$1.info(`Updated ${action.path} from the server's canonical response`);
|
|
834
|
+
}
|
|
835
|
+
return hash;
|
|
836
|
+
};
|
|
837
|
+
for (const action of actions) switch (action.kind) {
|
|
838
|
+
case "create": {
|
|
839
|
+
let content = action.content;
|
|
840
|
+
const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
|
|
841
|
+
if (newId) content = {
|
|
842
|
+
...content,
|
|
843
|
+
user_schema: newId
|
|
844
|
+
};
|
|
845
|
+
const { id, canonical } = await action.syncer.create(content);
|
|
846
|
+
const entry = {
|
|
847
|
+
id,
|
|
848
|
+
hash: await writeBack(action, canonical, newId ? hashForState(action.syncer, content) : action.hash)
|
|
849
|
+
};
|
|
850
|
+
await updateState(cwd, action.path, entry);
|
|
851
|
+
consola$1.info(`Created a new ${action.syncer.kind} on Zitadel from ${action.path} (id ${id})`);
|
|
852
|
+
applied.push({
|
|
853
|
+
kind: action.syncer.kind,
|
|
854
|
+
action: "create",
|
|
855
|
+
file: action.path,
|
|
856
|
+
id
|
|
857
|
+
});
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case "revise": {
|
|
861
|
+
const { id, canonical } = await action.syncer.create(action.content);
|
|
862
|
+
const entry = {
|
|
863
|
+
id,
|
|
864
|
+
hash: await writeBack(action, canonical, action.hash),
|
|
865
|
+
previousId: action.previousId
|
|
866
|
+
};
|
|
867
|
+
await updateState(cwd, action.path, entry);
|
|
868
|
+
repinned.set(action.previousId, id);
|
|
869
|
+
consola$1.info(`Published a new ${action.syncer.kind} revision on Zitadel from ${action.path} (id ${id})`);
|
|
870
|
+
for (const flowPath of action.affectedPaths) if (await repinFlowFile(cwd, flowPath, action.previousId, id)) {
|
|
871
|
+
filesUpdated.push(flowPath);
|
|
872
|
+
consola$1.info(`Re-pinned user_schema in ${flowPath} to ${id}`);
|
|
873
|
+
}
|
|
874
|
+
applied.push({
|
|
875
|
+
kind: action.syncer.kind,
|
|
876
|
+
action: "revision",
|
|
877
|
+
file: action.path,
|
|
878
|
+
id,
|
|
879
|
+
previous_id: action.previousId
|
|
880
|
+
});
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
case "update": {
|
|
884
|
+
let content = action.content;
|
|
885
|
+
const newId = action.repin ? repinned.get(action.repin.previousId) ?? action.repin.newId : void 0;
|
|
886
|
+
if (newId && action.repin) {
|
|
887
|
+
content = {
|
|
888
|
+
...content,
|
|
889
|
+
user_schema: newId
|
|
890
|
+
};
|
|
891
|
+
if (await repinFlowFile(cwd, action.path, action.repin.previousId, newId)) {
|
|
892
|
+
filesUpdated.push(action.path);
|
|
893
|
+
consola$1.info(`Re-pinned user_schema in ${action.path} to ${newId}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const { canonical } = await action.syncer.update(action.id, content);
|
|
897
|
+
const fallbackHash = newId ? hashForState(action.syncer, content) : action.hash;
|
|
898
|
+
await updateState(cwd, action.path, { hash: await writeBack(action, canonical, fallbackHash) });
|
|
899
|
+
consola$1.info(`Updated the ${action.syncer.kind} on Zitadel from ${action.path}`);
|
|
900
|
+
applied.push({
|
|
901
|
+
kind: action.syncer.kind,
|
|
902
|
+
action: "update",
|
|
903
|
+
file: action.path,
|
|
904
|
+
id: action.id
|
|
905
|
+
});
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
case "delete":
|
|
909
|
+
await action.syncer.delete(action.id);
|
|
910
|
+
await removeFromState(cwd, action.path);
|
|
911
|
+
consola$1.info(`Deleted the ${action.syncer.kind} on Zitadel because ${action.path} was removed locally`);
|
|
912
|
+
applied.push({
|
|
913
|
+
kind: action.syncer.kind,
|
|
914
|
+
action: "delete",
|
|
915
|
+
file: action.path,
|
|
916
|
+
id: action.id
|
|
917
|
+
});
|
|
918
|
+
break;
|
|
919
|
+
case "skip":
|
|
920
|
+
consola$1.debug(`Skipped ${action.path} (${action.reason})`);
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
const remainingPins = new Set((await readLocalFlowUserSchemas(cwd)).values());
|
|
924
|
+
const finalState = await readState(cwd);
|
|
925
|
+
for (const [path, entry] of Object.entries(finalState.resources)) if (entry.previousId && !remainingPins.has(entry.previousId)) await updateState(cwd, path, { previousId: void 0 });
|
|
926
|
+
return {
|
|
927
|
+
filesUpdated: [...new Set(filesUpdated)],
|
|
928
|
+
applied
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Rewrite a flow file's `user_schema` pin from `previousId` to `newId`,
|
|
933
|
+
* lockfile-style. Prefers a targeted text replacement so the author's
|
|
934
|
+
* formatting survives a one-string change; falls back to parse +
|
|
935
|
+
* `stableStringify` when the raw text doesn't contain exactly one pin.
|
|
936
|
+
* Returns false when the file doesn't pin `previousId` (already re-pinned
|
|
937
|
+
* or hand-edited) — never throws for an unreadable file.
|
|
938
|
+
*/
|
|
939
|
+
async function repinFlowFile(cwd, relPath, previousId, newId) {
|
|
940
|
+
const absPath = join(cwd, relPath);
|
|
941
|
+
let raw;
|
|
942
|
+
try {
|
|
943
|
+
raw = await readFile(absPath, "utf8");
|
|
944
|
+
} catch (err) {
|
|
945
|
+
consola$1.debug(`read ${relPath} for re-pin failed:`, err);
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
const pinPattern = new RegExp(`("user_schema"\\s*:\\s*)${escapeRegExp(JSON.stringify(previousId))}`, "g");
|
|
949
|
+
if (raw.match(pinPattern)?.length === 1) {
|
|
950
|
+
await writeFile(absPath, raw.replace(pinPattern, (_match, prefix) => `${prefix}${JSON.stringify(newId)}`));
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
try {
|
|
954
|
+
const doc = JSON.parse(raw);
|
|
955
|
+
if (doc.user_schema !== previousId) return false;
|
|
956
|
+
doc.user_schema = newId;
|
|
957
|
+
await writeFile(absPath, `${stableStringify(doc)}\n`);
|
|
958
|
+
return true;
|
|
959
|
+
} catch (err) {
|
|
960
|
+
consola$1.debug(`re-pin ${relPath} failed:`, err);
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function escapeRegExp(value) {
|
|
965
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Reconcile a local file with the server's canonical body. What gets
|
|
969
|
+
* written is the `normalizeWrite` form (strips pure transport noise like
|
|
970
|
+
* the empty `audience` echo; for schemas the canonical body verbatim —
|
|
971
|
+
* spelled-out x-* defaults must survive, or the next apply would publish
|
|
972
|
+
* a revision without them). Equality is judged in the `normalize`
|
|
973
|
+
* comparison form, so the file is rewritten only when it materially
|
|
974
|
+
* differs from live state; hand-formatted files stay untouched otherwise.
|
|
975
|
+
* Returns the state hash of the written form.
|
|
976
|
+
*/
|
|
977
|
+
async function writeBackResource(cwd, relPath, syncer, canonical) {
|
|
978
|
+
let writeBody = syncer.normalizeWrite?.(canonical) ?? canonical;
|
|
979
|
+
const compare = (body) => stableStringify(syncer.normalize?.(body) ?? body);
|
|
980
|
+
const absPath = join(cwd, relPath);
|
|
981
|
+
let changed = true;
|
|
982
|
+
try {
|
|
983
|
+
const onDisk = JSON.parse(await readFile(absPath, "utf8"));
|
|
984
|
+
changed = compare(writeBody) !== compare(onDisk);
|
|
985
|
+
if (typeof onDisk.$schema === "string" && !("$schema" in writeBody)) writeBody = {
|
|
986
|
+
...writeBody,
|
|
987
|
+
$schema: onDisk.$schema
|
|
988
|
+
};
|
|
989
|
+
} catch (err) {
|
|
990
|
+
consola$1.debug(`read ${relPath} for write-back failed:`, err);
|
|
991
|
+
}
|
|
992
|
+
if (changed) await writeFile(absPath, `${stableStringify(writeBody)}\n`);
|
|
993
|
+
return {
|
|
994
|
+
hash: hashForState(syncer, writeBody),
|
|
995
|
+
changed
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
async function fetchOldIfAsked(syncer, id, fetchOld) {
|
|
999
|
+
if (!fetchOld || !syncer.fetch) return null;
|
|
1000
|
+
try {
|
|
1001
|
+
return await syncer.fetch(id);
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
consola$1.debug(`fetch ${syncer.kind} ${id} failed:`, err);
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async function readJsonDir(dirPath) {
|
|
1008
|
+
const result = /* @__PURE__ */ new Map();
|
|
1009
|
+
let entries;
|
|
1010
|
+
try {
|
|
1011
|
+
entries = await readdir(dirPath);
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") return result;
|
|
1014
|
+
throw err;
|
|
1015
|
+
}
|
|
1016
|
+
for (const entry of entries.filter((e) => e.endsWith(".json"))) {
|
|
1017
|
+
const filePath = join(dirPath, entry);
|
|
1018
|
+
const raw = await readFile(filePath, "utf8");
|
|
1019
|
+
result.set(filePath, JSON.parse(raw));
|
|
1020
|
+
}
|
|
1021
|
+
return result;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Walk `.zitadel/flows/*.json` once and return each flow's `user_schema` value
|
|
1025
|
+
* keyed by project-root-relative path. A flow file without a `user_schema` (or
|
|
1026
|
+
* with a non-string one) is skipped: it does not pin a schema revision, so it
|
|
1027
|
+
* cannot be affected by one.
|
|
1028
|
+
*/
|
|
1029
|
+
async function readLocalFlowUserSchemas(cwd) {
|
|
1030
|
+
const result = /* @__PURE__ */ new Map();
|
|
1031
|
+
const flows = await readJsonDir(join(cwd, FLOWS_DIR));
|
|
1032
|
+
for (const [absPath, content] of flows.entries()) {
|
|
1033
|
+
const relPath = absPath.slice(cwd.length + 1);
|
|
1034
|
+
if (typeof content === "object" && content !== null && "user_schema" in content && typeof content.user_schema === "string") result.set(relPath, content.user_schema);
|
|
1035
|
+
}
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
1038
|
+
function findFlowsPinnedTo(previousId, localFlows) {
|
|
1039
|
+
const affected = [];
|
|
1040
|
+
for (const [relPath, ref] of localFlows.entries()) if (ref === previousId) affected.push(relPath);
|
|
1041
|
+
return affected;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Legacy content hash: order-sensitive and normalization-blind. Kept only
|
|
1045
|
+
* so state entries written by older CLI versions still match; new hashes
|
|
1046
|
+
* come from {@link hashForState}.
|
|
1047
|
+
*/
|
|
1048
|
+
function hashResourceContent(data) {
|
|
1049
|
+
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* The content hash stored in `.zitadel/state.json`: key-order-insensitive
|
|
1053
|
+
* (via `stableStringify`) and computed on the syncer's normalized form, so
|
|
1054
|
+
* reordering keys or spelling out a meta-schema default does not read as an
|
|
1055
|
+
* edit.
|
|
1056
|
+
*/
|
|
1057
|
+
function hashForState(syncer, data) {
|
|
1058
|
+
const normalized = syncer.normalize?.(data) ?? data;
|
|
1059
|
+
return createHash("sha256").update(stableStringify(normalized)).digest("hex");
|
|
1060
|
+
}
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region src/lib/sync/plan-renderer.ts
|
|
1063
|
+
/**
|
|
1064
|
+
* Count the non-`skip` actions in a {@link buildSyncPlan} result. Pure; the
|
|
1065
|
+
* single source of truth for the plan counts shared by the `plan` /
|
|
1066
|
+
* `apply --dry-run` JSON payload and {@link renderPlan}'s summary line.
|
|
1067
|
+
*/
|
|
1068
|
+
function summarizePlan(actions) {
|
|
1069
|
+
const active = actions.filter((a) => a.kind !== "skip");
|
|
1070
|
+
return {
|
|
1071
|
+
creates: active.filter((a) => a.kind === "create").length,
|
|
1072
|
+
updates: active.filter((a) => a.kind === "update").length,
|
|
1073
|
+
revisions: active.filter((a) => a.kind === "revise").length,
|
|
1074
|
+
deletes: active.filter((a) => a.kind === "delete").length,
|
|
1075
|
+
total: active.length
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Collect the plan-time validation warnings across all actions, tagged
|
|
1080
|
+
* with the file they belong to. Feeds the `plan` / `apply --dry-run`
|
|
1081
|
+
* `--json` payload so agents can read warnings structurally.
|
|
1082
|
+
*/
|
|
1083
|
+
function collectPlanWarnings(actions) {
|
|
1084
|
+
const out = [];
|
|
1085
|
+
for (const action of actions) for (const warning of warningsOf(action)) out.push({
|
|
1086
|
+
path: action.path,
|
|
1087
|
+
rule: warning.rule,
|
|
1088
|
+
message: warning.message
|
|
1089
|
+
});
|
|
1090
|
+
return out;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* The warnings an action carries. Only the three kinds that upload a body can
|
|
1094
|
+
* have any: a `delete` has no content to judge, and a `skip` was never judged.
|
|
1095
|
+
*/
|
|
1096
|
+
function warningsOf(action) {
|
|
1097
|
+
return action.kind === "create" || action.kind === "update" || action.kind === "revise" ? action.warnings ?? [] : [];
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Enumerate the non-`skip` actions of a {@link buildSyncPlan} result as
|
|
1101
|
+
* envelope-ready {@link PlanResourceChange} rows. Pure; the structural
|
|
1102
|
+
* counterpart of {@link summarizePlan} — counts and rows always agree.
|
|
1103
|
+
*/
|
|
1104
|
+
function enumeratePlanResources(actions) {
|
|
1105
|
+
const out = [];
|
|
1106
|
+
for (const action of actions) switch (action.kind) {
|
|
1107
|
+
case "create":
|
|
1108
|
+
out.push({
|
|
1109
|
+
kind: action.syncer.kind,
|
|
1110
|
+
action: "create",
|
|
1111
|
+
file: action.path
|
|
1112
|
+
});
|
|
1113
|
+
break;
|
|
1114
|
+
case "update":
|
|
1115
|
+
out.push({
|
|
1116
|
+
kind: action.syncer.kind,
|
|
1117
|
+
action: "update",
|
|
1118
|
+
file: action.path,
|
|
1119
|
+
id: action.id
|
|
1120
|
+
});
|
|
1121
|
+
break;
|
|
1122
|
+
case "revise":
|
|
1123
|
+
out.push({
|
|
1124
|
+
kind: action.syncer.kind,
|
|
1125
|
+
action: "revision",
|
|
1126
|
+
file: action.path,
|
|
1127
|
+
previous_id: action.previousId
|
|
1128
|
+
});
|
|
1129
|
+
break;
|
|
1130
|
+
case "delete":
|
|
1131
|
+
out.push({
|
|
1132
|
+
kind: action.syncer.kind,
|
|
1133
|
+
action: "delete",
|
|
1134
|
+
file: action.path,
|
|
1135
|
+
id: action.id
|
|
1136
|
+
});
|
|
1137
|
+
break;
|
|
1138
|
+
case "skip": break;
|
|
1139
|
+
}
|
|
1140
|
+
return out;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Render a {@link buildSyncPlan} result as a human-readable Terraform-style
|
|
1144
|
+
* plan. TTY-aware: colors and bold are emitted only when `tty` is true.
|
|
1145
|
+
* Returns the empty-state message when every action is `skip`.
|
|
1146
|
+
*
|
|
1147
|
+
* @param actions - The action list produced by `buildSyncPlan`. Read-only;
|
|
1148
|
+
* the function never mutates the input.
|
|
1149
|
+
* @param tty - True when stdout is a TTY; controls ANSI emission.
|
|
1150
|
+
*/
|
|
1151
|
+
function renderPlan(actions, tty) {
|
|
1152
|
+
const active = actions.filter((a) => a.kind !== "skip");
|
|
1153
|
+
if (active.length === 0) return paint("No changes. Your Zitadel configuration matches the current state.", A.bold, tty);
|
|
1154
|
+
const out = [];
|
|
1155
|
+
out.push(paint("Zitadel will perform the following actions:", A.bold, tty));
|
|
1156
|
+
for (const action of active) {
|
|
1157
|
+
out.push("");
|
|
1158
|
+
out.push(...renderBlock(action, tty));
|
|
1159
|
+
}
|
|
1160
|
+
out.push("");
|
|
1161
|
+
const { creates, updates, revisions, deletes } = summarizePlan(actions);
|
|
1162
|
+
const parts = [];
|
|
1163
|
+
if (creates > 0) parts.push(`${creates} to add`);
|
|
1164
|
+
if (updates > 0) parts.push(`${updates} to change`);
|
|
1165
|
+
if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
|
|
1166
|
+
if (deletes > 0) parts.push(`${deletes} to destroy`);
|
|
1167
|
+
out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
|
|
1168
|
+
const warningCount = active.reduce((count, action) => count + warningsOf(action).length, 0);
|
|
1169
|
+
if (warningCount > 0) out.push(paint(`Warnings: ${warningCount} (non-blocking — see the # warning lines above).`, A.yellow, tty));
|
|
1170
|
+
return out.join("\n");
|
|
1171
|
+
}
|
|
1172
|
+
const A = {
|
|
1173
|
+
reset: "\x1B[0m",
|
|
1174
|
+
bold: "\x1B[1m",
|
|
1175
|
+
green: "\x1B[32m",
|
|
1176
|
+
red: "\x1B[31m",
|
|
1177
|
+
yellow: "\x1B[33m"
|
|
1178
|
+
};
|
|
1179
|
+
function paint(text, code, tty) {
|
|
1180
|
+
return tty ? `${code}${text}${A.reset}` : text;
|
|
1181
|
+
}
|
|
1182
|
+
function isPrimitive(v) {
|
|
1183
|
+
return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
1184
|
+
}
|
|
1185
|
+
function isPlainObject(v) {
|
|
1186
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1187
|
+
}
|
|
1188
|
+
const KNOWN_AFTER_APPLY = "(known after apply)";
|
|
1189
|
+
function escapeString(s) {
|
|
1190
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
1191
|
+
}
|
|
1192
|
+
function fmtPrimitive(v) {
|
|
1193
|
+
if (v === null) return "null";
|
|
1194
|
+
if (typeof v === "string" && v === KNOWN_AFTER_APPLY) return KNOWN_AFTER_APPLY;
|
|
1195
|
+
if (typeof v === "string") return `"${escapeString(v)}"`;
|
|
1196
|
+
return String(v);
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* A multi-line string is a document, not a scalar: branding inlines a whole
|
|
1200
|
+
* `login.liquid` into `liquid_template`, and escaping 200 lines onto one
|
|
1201
|
+
* `"…\n…\n…"` line buries every real change in the same block. Such values
|
|
1202
|
+
* render as a summary (and, when they changed, as a line diff) instead.
|
|
1203
|
+
*/
|
|
1204
|
+
function isBlockString(v) {
|
|
1205
|
+
return typeof v === "string" && v !== KNOWN_AFTER_APPLY && v.includes("\n");
|
|
1206
|
+
}
|
|
1207
|
+
/** Trailing-newline-insensitive line count: `"a\nb\n"` is two lines, not three. */
|
|
1208
|
+
function lineCount(value) {
|
|
1209
|
+
const lines = value.split("\n");
|
|
1210
|
+
return lines.length > 1 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
1211
|
+
}
|
|
1212
|
+
/** Short content fingerprint, so two summarised blocks can be told apart. */
|
|
1213
|
+
function shortHash(value) {
|
|
1214
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
1215
|
+
}
|
|
1216
|
+
function plural(count, noun) {
|
|
1217
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
1218
|
+
}
|
|
1219
|
+
function blockSummary(value) {
|
|
1220
|
+
return `(${plural(lineCount(value), "line")}, sha256:${shortHash(value)})`;
|
|
1221
|
+
}
|
|
1222
|
+
function unchangedBlockSummary(value) {
|
|
1223
|
+
return `(unchanged, ${plural(lineCount(value), "line")}, sha256:${shortHash(value)})`;
|
|
1224
|
+
}
|
|
1225
|
+
/** {@link fmtPrimitive}, with multi-line strings summarised. */
|
|
1226
|
+
function fmtScalar(v) {
|
|
1227
|
+
return isBlockString(v) ? blockSummary(v) : fmtPrimitive(v);
|
|
1228
|
+
}
|
|
1229
|
+
function prefixAnsi(p) {
|
|
1230
|
+
if (p === "+") return A.green;
|
|
1231
|
+
if (p === "-") return A.red;
|
|
1232
|
+
if (p === "~") return A.yellow;
|
|
1233
|
+
return "";
|
|
1234
|
+
}
|
|
1235
|
+
function renderFields(obj, prefix, prefixCol, ctx, lines) {
|
|
1236
|
+
const pad = " ".repeat(prefixCol);
|
|
1237
|
+
const ansi = prefixAnsi(prefix);
|
|
1238
|
+
const col = (s) => paint(s, ansi, ctx.tty);
|
|
1239
|
+
const keys = Object.keys(obj).sort();
|
|
1240
|
+
const maxLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
1241
|
+
for (const key of keys) {
|
|
1242
|
+
const val = obj[key];
|
|
1243
|
+
const pk = key.padEnd(maxLen);
|
|
1244
|
+
if (isPrimitive(val)) {
|
|
1245
|
+
const formatted = fmtScalar(val);
|
|
1246
|
+
const suffix = ctx.deleteMode ? " -> null" : "";
|
|
1247
|
+
lines.push(col(`${pad}${prefix} ${pk} = ${formatted}${suffix}`));
|
|
1248
|
+
} else if (Array.isArray(val)) if (val.length === 0) lines.push(col(`${pad}${prefix} ${pk} = []`));
|
|
1249
|
+
else {
|
|
1250
|
+
lines.push(col(`${pad}${prefix} ${pk} = [`));
|
|
1251
|
+
renderArrayItems(val, prefix, prefixCol + 4, ctx, lines);
|
|
1252
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}]`));
|
|
1253
|
+
}
|
|
1254
|
+
else if (isPlainObject(val)) if (Object.keys(val).length === 0) lines.push(col(`${pad}${prefix} ${pk} = {}`));
|
|
1255
|
+
else {
|
|
1256
|
+
lines.push(col(`${pad}${prefix} ${pk} = {`));
|
|
1257
|
+
renderFields(val, prefix, prefixCol + 4, ctx, lines);
|
|
1258
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Renders the items of an array. Unlike {@link renderFields}, primitive
|
|
1264
|
+
* elements never get a trailing ` -> null` suffix even under `deleteMode` —
|
|
1265
|
+
* Terraform only annotates scalar object-field removals that way, not array
|
|
1266
|
+
* items.
|
|
1267
|
+
*/
|
|
1268
|
+
function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
|
|
1269
|
+
const pad = " ".repeat(prefixCol);
|
|
1270
|
+
const ansi = prefixAnsi(prefix);
|
|
1271
|
+
const col = (s) => paint(s, ansi, ctx.tty);
|
|
1272
|
+
for (const item of arr) if (isPrimitive(item)) {
|
|
1273
|
+
const formatted = fmtScalar(item);
|
|
1274
|
+
lines.push(col(`${pad}${prefix} ${formatted},`));
|
|
1275
|
+
} else if (Array.isArray(item)) if (item.length === 0) lines.push(col(`${pad}${prefix} [],`));
|
|
1276
|
+
else {
|
|
1277
|
+
lines.push(col(`${pad}${prefix} [`));
|
|
1278
|
+
renderArrayItems(item, prefix, prefixCol + 4, ctx, lines);
|
|
1279
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}],`));
|
|
1280
|
+
}
|
|
1281
|
+
else if (isPlainObject(item)) if (Object.keys(item).length === 0) lines.push(col(`${pad}${prefix} {},`));
|
|
1282
|
+
else {
|
|
1283
|
+
lines.push(col(`${pad}${prefix} {`));
|
|
1284
|
+
renderFields(item, prefix, prefixCol + 4, ctx, lines);
|
|
1285
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}},`));
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/** Changed-line budget for one block-string diff, before the "more" trailer. */
|
|
1289
|
+
const MAX_BLOCK_DIFF_LINES = 20;
|
|
1290
|
+
/**
|
|
1291
|
+
* LCS cell budget (500 × 500 changed lines). Above it the middle section
|
|
1292
|
+
* renders as one remove/add block instead — quadratic DP over a pathological
|
|
1293
|
+
* template is not worth the memory, and a whole-block replace is still an
|
|
1294
|
+
* honest rendering. The shipped designs are under 200 lines *before* the
|
|
1295
|
+
* prefix/suffix trim, so real templates never come close.
|
|
1296
|
+
*/
|
|
1297
|
+
const MAX_LCS_CELLS = 25e4;
|
|
1298
|
+
function asLines(v) {
|
|
1299
|
+
return typeof v === "string" ? v.split("\n") : [fmtPrimitive(v)];
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Line-level diff of two block strings. Common prefix and suffix are trimmed
|
|
1303
|
+
* first — a template edit touches one region, so this alone usually reduces
|
|
1304
|
+
* the problem to a handful of lines — and the remaining middle goes through
|
|
1305
|
+
* an LCS pass (or, past {@link MAX_LCS_CELLS}, renders as a wholesale
|
|
1306
|
+
* replacement).
|
|
1307
|
+
*/
|
|
1308
|
+
function diffLines(oldLines, newLines) {
|
|
1309
|
+
let start = 0;
|
|
1310
|
+
while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) start += 1;
|
|
1311
|
+
let endOld = oldLines.length;
|
|
1312
|
+
let endNew = newLines.length;
|
|
1313
|
+
while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) {
|
|
1314
|
+
endOld -= 1;
|
|
1315
|
+
endNew -= 1;
|
|
1316
|
+
}
|
|
1317
|
+
const midOld = oldLines.slice(start, endOld);
|
|
1318
|
+
const midNew = newLines.slice(start, endNew);
|
|
1319
|
+
if (midOld.length * midNew.length > MAX_LCS_CELLS) return [...midOld.map((line) => ({
|
|
1320
|
+
kind: "del",
|
|
1321
|
+
line
|
|
1322
|
+
})), ...midNew.map((line) => ({
|
|
1323
|
+
kind: "add",
|
|
1324
|
+
line
|
|
1325
|
+
}))];
|
|
1326
|
+
return lcsDiff(midOld, midNew);
|
|
1327
|
+
}
|
|
1328
|
+
/** Classic LCS-length DP, walked back into an op list. */
|
|
1329
|
+
function lcsDiff(a, b) {
|
|
1330
|
+
const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
1331
|
+
for (let i = a.length - 1; i >= 0; i -= 1) for (let j = b.length - 1; j >= 0; j -= 1) table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
1332
|
+
const ops = [];
|
|
1333
|
+
let i = 0;
|
|
1334
|
+
let j = 0;
|
|
1335
|
+
while (i < a.length && j < b.length) if (a[i] === b[j]) {
|
|
1336
|
+
ops.push({
|
|
1337
|
+
kind: "same",
|
|
1338
|
+
line: a[i]
|
|
1339
|
+
});
|
|
1340
|
+
i += 1;
|
|
1341
|
+
j += 1;
|
|
1342
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
1343
|
+
ops.push({
|
|
1344
|
+
kind: "del",
|
|
1345
|
+
line: a[i]
|
|
1346
|
+
});
|
|
1347
|
+
i += 1;
|
|
1348
|
+
} else {
|
|
1349
|
+
ops.push({
|
|
1350
|
+
kind: "add",
|
|
1351
|
+
line: b[j]
|
|
1352
|
+
});
|
|
1353
|
+
j += 1;
|
|
1354
|
+
}
|
|
1355
|
+
for (; i < a.length; i += 1) ops.push({
|
|
1356
|
+
kind: "del",
|
|
1357
|
+
line: a[i]
|
|
1358
|
+
});
|
|
1359
|
+
for (; j < b.length; j += 1) ops.push({
|
|
1360
|
+
kind: "add",
|
|
1361
|
+
line: b[j]
|
|
1362
|
+
});
|
|
1363
|
+
return ops;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Render a changed block string as a header plus its changed lines. Context
|
|
1367
|
+
* lines are deliberately omitted: the file is on disk, and the plan's job is
|
|
1368
|
+
* to name what moved, not to reproduce the template.
|
|
1369
|
+
*/
|
|
1370
|
+
function renderBlockStringDiff(paddedKey, oldVal, newVal, prefixCol, tty, lines) {
|
|
1371
|
+
const pad = " ".repeat(prefixCol);
|
|
1372
|
+
const bodyPad = " ".repeat(prefixCol + 4);
|
|
1373
|
+
const changed = diffLines(asLines(oldVal), asLines(newVal)).filter((op) => op.kind !== "same");
|
|
1374
|
+
const total = typeof newVal === "string" ? lineCount(newVal) : 1;
|
|
1375
|
+
const fingerprints = typeof oldVal === "string" && typeof newVal === "string" ? `, sha256:${shortHash(oldVal)} -> sha256:${shortHash(newVal)}` : "";
|
|
1376
|
+
lines.push(paint(`${pad}~ ${paddedKey} = (${plural(changed.length, "line")} changed of ${total}${fingerprints})`, A.yellow, tty));
|
|
1377
|
+
for (const op of changed.slice(0, MAX_BLOCK_DIFF_LINES)) {
|
|
1378
|
+
const del = op.kind === "del";
|
|
1379
|
+
lines.push(paint(`${bodyPad}${del ? "-" : "+"} ${op.line}`, del ? A.red : A.green, tty));
|
|
1380
|
+
}
|
|
1381
|
+
const omitted = changed.length - MAX_BLOCK_DIFF_LINES;
|
|
1382
|
+
if (omitted > 0) lines.push(`${bodyPad} # (${plural(omitted, "more changed line")} not shown)`);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Walks both old and new objects, emitting Terraform-style change lines.
|
|
1386
|
+
* Returns true if any actual change line (+ / - / ~) was emitted.
|
|
1387
|
+
*
|
|
1388
|
+
* Edge cases:
|
|
1389
|
+
* - Changed arrays render as a full remove + full add (no LCS diff).
|
|
1390
|
+
* - Nested objects recurse, and the outer key is only marked `~` if a child
|
|
1391
|
+
* actually changed; unchanged children render with the neutral prefix.
|
|
1392
|
+
* - A value whose type changed (e.g. string → object) also renders as a
|
|
1393
|
+
* remove + add pair.
|
|
1394
|
+
*/
|
|
1395
|
+
function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
|
|
1396
|
+
const allKeys = [...new Set([...Object.keys(oldObj), ...Object.keys(newObj)])].sort();
|
|
1397
|
+
const maxLen = allKeys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
1398
|
+
const pad = " ".repeat(prefixCol);
|
|
1399
|
+
let hasChanges = false;
|
|
1400
|
+
for (const key of allKeys) {
|
|
1401
|
+
const pk = key.padEnd(maxLen);
|
|
1402
|
+
const hasOld = Object.prototype.hasOwnProperty.call(oldObj, key);
|
|
1403
|
+
const hasNew = Object.prototype.hasOwnProperty.call(newObj, key);
|
|
1404
|
+
const oldVal = oldObj[key];
|
|
1405
|
+
const newVal = newObj[key];
|
|
1406
|
+
if (!hasOld) {
|
|
1407
|
+
hasChanges = true;
|
|
1408
|
+
const col = (s) => paint(s, A.green, tty);
|
|
1409
|
+
if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
|
|
1410
|
+
else if (Array.isArray(newVal)) {
|
|
1411
|
+
lines.push(col(`${pad}+ ${pk} = [`));
|
|
1412
|
+
renderArrayItems(newVal, "+", prefixCol + 4, {
|
|
1413
|
+
tty,
|
|
1414
|
+
deleteMode: false
|
|
1415
|
+
}, lines);
|
|
1416
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}]`));
|
|
1417
|
+
} else if (isPlainObject(newVal)) {
|
|
1418
|
+
lines.push(col(`${pad}+ ${pk} = {`));
|
|
1419
|
+
renderFields(newVal, "+", prefixCol + 4, {
|
|
1420
|
+
tty,
|
|
1421
|
+
deleteMode: false
|
|
1422
|
+
}, lines);
|
|
1423
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
|
|
1424
|
+
}
|
|
1425
|
+
} else if (!hasNew) {
|
|
1426
|
+
hasChanges = true;
|
|
1427
|
+
const col = (s) => paint(s, A.red, tty);
|
|
1428
|
+
if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
|
|
1429
|
+
else if (Array.isArray(oldVal)) {
|
|
1430
|
+
lines.push(col(`${pad}- ${pk} = [`));
|
|
1431
|
+
renderArrayItems(oldVal, "-", prefixCol + 4, {
|
|
1432
|
+
tty,
|
|
1433
|
+
deleteMode: false
|
|
1434
|
+
}, lines);
|
|
1435
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}]`));
|
|
1436
|
+
} else if (isPlainObject(oldVal)) {
|
|
1437
|
+
lines.push(col(`${pad}- ${pk} = {`));
|
|
1438
|
+
renderFields(oldVal, "-", prefixCol + 4, {
|
|
1439
|
+
tty,
|
|
1440
|
+
deleteMode: true
|
|
1441
|
+
}, lines);
|
|
1442
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
|
|
1443
|
+
}
|
|
1444
|
+
} else if (isPrimitive(oldVal) && isPrimitive(newVal)) if (oldVal === newVal) lines.push(isBlockString(newVal) ? `${pad} ${pk} = ${unchangedBlockSummary(newVal)}` : `${pad} ${pk} = ${fmtPrimitive(newVal)}`);
|
|
1445
|
+
else if (isBlockString(oldVal) || isBlockString(newVal)) {
|
|
1446
|
+
hasChanges = true;
|
|
1447
|
+
renderBlockStringDiff(pk, oldVal, newVal, prefixCol, tty, lines);
|
|
1448
|
+
} else {
|
|
1449
|
+
hasChanges = true;
|
|
1450
|
+
const col = (s) => paint(s, A.yellow, tty);
|
|
1451
|
+
lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
|
|
1452
|
+
}
|
|
1453
|
+
else if (Array.isArray(oldVal) && Array.isArray(newVal)) if (stableStringify(oldVal) === stableStringify(newVal)) if (newVal.length === 0) lines.push(`${pad} ${pk} = []`);
|
|
1454
|
+
else {
|
|
1455
|
+
lines.push(`${pad} ${pk} = [`);
|
|
1456
|
+
renderArrayItems(newVal, " ", prefixCol + 4, {
|
|
1457
|
+
tty,
|
|
1458
|
+
deleteMode: false
|
|
1459
|
+
}, lines);
|
|
1460
|
+
lines.push(`${" ".repeat(prefixCol + 2)}]`);
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
hasChanges = true;
|
|
1464
|
+
const colR = (s) => paint(s, A.red, tty);
|
|
1465
|
+
const colA = (s) => paint(s, A.green, tty);
|
|
1466
|
+
if (oldVal.length === 0) lines.push(colR(`${pad}- ${pk} = []`));
|
|
1467
|
+
else {
|
|
1468
|
+
lines.push(colR(`${pad}- ${pk} = [`));
|
|
1469
|
+
renderArrayItems(oldVal, "-", prefixCol + 4, {
|
|
1470
|
+
tty,
|
|
1471
|
+
deleteMode: false
|
|
1472
|
+
}, lines);
|
|
1473
|
+
lines.push(colR(`${" ".repeat(prefixCol + 2)}]`));
|
|
1474
|
+
}
|
|
1475
|
+
if (newVal.length === 0) lines.push(colA(`${pad}+ ${pk} = []`));
|
|
1476
|
+
else {
|
|
1477
|
+
lines.push(colA(`${pad}+ ${pk} = [`));
|
|
1478
|
+
renderArrayItems(newVal, "+", prefixCol + 4, {
|
|
1479
|
+
tty,
|
|
1480
|
+
deleteMode: false
|
|
1481
|
+
}, lines);
|
|
1482
|
+
lines.push(colA(`${" ".repeat(prefixCol + 2)}]`));
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
else if (isPlainObject(oldVal) && isPlainObject(newVal)) {
|
|
1486
|
+
const childLines = [];
|
|
1487
|
+
if (renderDiff(oldVal, newVal, prefixCol + 4, tty, childLines)) {
|
|
1488
|
+
hasChanges = true;
|
|
1489
|
+
const col = (s) => paint(s, A.yellow, tty);
|
|
1490
|
+
lines.push(col(`${pad}~ ${pk} = {`));
|
|
1491
|
+
lines.push(...childLines);
|
|
1492
|
+
lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
|
|
1493
|
+
} else if (childLines.length > 0) {
|
|
1494
|
+
lines.push(`${pad} ${pk} = {`);
|
|
1495
|
+
lines.push(...childLines);
|
|
1496
|
+
lines.push(`${" ".repeat(prefixCol + 2)}}`);
|
|
1497
|
+
} else lines.push(`${pad} ${pk} = {}`);
|
|
1498
|
+
} else {
|
|
1499
|
+
hasChanges = true;
|
|
1500
|
+
const colR = (s) => paint(s, A.red, tty);
|
|
1501
|
+
const colA = (s) => paint(s, A.green, tty);
|
|
1502
|
+
if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
|
|
1503
|
+
if (isPrimitive(newVal)) lines.push(colA(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return hasChanges;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Column layout (matches Terraform's per-block format):
|
|
1510
|
+
* BLOCK_COL = 2 — where the +/-/~ sits on the resource opening line
|
|
1511
|
+
* FIELD_COL = 6 — where the +/-/~ sits on first-level field lines
|
|
1512
|
+
* closing } — at BLOCK_COL + 2 = 4, no prefix
|
|
1513
|
+
*/
|
|
1514
|
+
const BLOCK_COL = 2;
|
|
1515
|
+
const FIELD_COL = 6;
|
|
1516
|
+
function resourceName(path) {
|
|
1517
|
+
return path.split("/").pop() ?? path;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Diff both sides in the syncer's canonical form so server-echoed noise
|
|
1521
|
+
* (empty `audience`, spelled-out meta-schema defaults) never renders as a
|
|
1522
|
+
* change the author didn't make. Rendering only — upload payloads stay raw.
|
|
1523
|
+
*/
|
|
1524
|
+
function normalized(syncer, content) {
|
|
1525
|
+
return syncer.normalize?.(content) ?? content;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Renders one Terraform-style resource block for a single `SyncAction`.
|
|
1529
|
+
*
|
|
1530
|
+
* Per-case notes:
|
|
1531
|
+
* - **create**: a synthetic `id = (known after apply)` is injected into the
|
|
1532
|
+
* rendered fields so it sorts alphabetically alongside the real keys.
|
|
1533
|
+
* - **delete**: when `oldContent` is null (the fetch failed), the body
|
|
1534
|
+
* collapses to a single `- id = "<id>" -> null` line.
|
|
1535
|
+
* - **update**: when `oldContent` is null (no read endpoint for this
|
|
1536
|
+
* resource kind), the field diff is replaced with a placeholder
|
|
1537
|
+
* "field diff unavailable" line.
|
|
1538
|
+
* - **skip**: omitted from the output entirely, matching Terraform's
|
|
1539
|
+
* default of not showing no-change resources.
|
|
1540
|
+
*/
|
|
1541
|
+
function renderBlock(action, tty) {
|
|
1542
|
+
const lines = [];
|
|
1543
|
+
const blkPad = " ".repeat(BLOCK_COL);
|
|
1544
|
+
const closePad = " ".repeat(BLOCK_COL + 2);
|
|
1545
|
+
switch (action.kind) {
|
|
1546
|
+
case "create": {
|
|
1547
|
+
const header = `${blkPad}# ${action.path} will be created`;
|
|
1548
|
+
const opening = `${blkPad}+ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
|
|
1549
|
+
lines.push(paint(header, A.bold, tty));
|
|
1550
|
+
lines.push(paint(opening, A.green, tty));
|
|
1551
|
+
const display = {
|
|
1552
|
+
id: KNOWN_AFTER_APPLY,
|
|
1553
|
+
...action.content
|
|
1554
|
+
};
|
|
1555
|
+
if (action.repin) display.user_schema = action.repin.newId ?? KNOWN_AFTER_APPLY;
|
|
1556
|
+
renderFields(display, "+", FIELD_COL, {
|
|
1557
|
+
tty,
|
|
1558
|
+
deleteMode: false
|
|
1559
|
+
}, lines);
|
|
1560
|
+
lines.push(`${closePad}}`);
|
|
1561
|
+
renderWarnings(action.warnings, blkPad, tty, lines);
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
case "delete": {
|
|
1565
|
+
const header = `${blkPad}# ${action.path} will be destroyed`;
|
|
1566
|
+
const opening = `${blkPad}- resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
|
|
1567
|
+
lines.push(paint(header, A.bold, tty));
|
|
1568
|
+
lines.push(paint(opening, A.red, tty));
|
|
1569
|
+
if (action.oldContent) renderFields({
|
|
1570
|
+
id: action.id,
|
|
1571
|
+
...action.oldContent
|
|
1572
|
+
}, "-", FIELD_COL, {
|
|
1573
|
+
tty,
|
|
1574
|
+
deleteMode: true
|
|
1575
|
+
}, lines);
|
|
1576
|
+
else lines.push(paint(`${" ".repeat(FIELD_COL)}- id = "${action.id}" -> null`, A.red, tty));
|
|
1577
|
+
lines.push(`${closePad}}`);
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case "update": {
|
|
1581
|
+
const headerSuffix = action.repin ? " (re-pin user_schema)" : "";
|
|
1582
|
+
const header = `${blkPad}# ${action.path} will be updated in-place${headerSuffix}`;
|
|
1583
|
+
const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
|
|
1584
|
+
lines.push(paint(header, A.bold, tty));
|
|
1585
|
+
lines.push(paint(opening, A.yellow, tty));
|
|
1586
|
+
const newContent = action.repin ? {
|
|
1587
|
+
...normalized(action.syncer, action.content),
|
|
1588
|
+
user_schema: action.repin.newId ?? KNOWN_AFTER_APPLY
|
|
1589
|
+
} : normalized(action.syncer, action.content);
|
|
1590
|
+
if (action.oldContent) renderDiff(normalized(action.syncer, action.oldContent), newContent, FIELD_COL, tty, lines);
|
|
1591
|
+
else if (action.repin) lines.push(paint(`${" ".repeat(FIELD_COL)}~ user_schema = "${action.repin.previousId}" -> ${action.repin.newId ? `"${action.repin.newId}"` : KNOWN_AFTER_APPLY}`, A.yellow, tty));
|
|
1592
|
+
else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
|
|
1593
|
+
lines.push(`${closePad}}`);
|
|
1594
|
+
renderWarnings(action.warnings, blkPad, tty, lines);
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
case "revise": {
|
|
1598
|
+
const header = `${blkPad}# ${action.path} will publish a new revision`;
|
|
1599
|
+
const opening = `${blkPad}~ resource "${action.syncer.kind}" "${resourceName(action.path)}" {`;
|
|
1600
|
+
lines.push(paint(header, A.bold, tty));
|
|
1601
|
+
lines.push(paint(opening, A.yellow, tty));
|
|
1602
|
+
if (action.oldContent) renderDiff({
|
|
1603
|
+
id: action.previousId,
|
|
1604
|
+
...normalized(action.syncer, action.oldContent)
|
|
1605
|
+
}, {
|
|
1606
|
+
id: KNOWN_AFTER_APPLY,
|
|
1607
|
+
...normalized(action.syncer, action.content)
|
|
1608
|
+
}, FIELD_COL, tty, lines);
|
|
1609
|
+
else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
|
|
1610
|
+
lines.push(`${closePad}}`);
|
|
1611
|
+
renderWarnings(action.warnings, blkPad, tty, lines);
|
|
1612
|
+
if (action.affectedPaths.length > 0) {
|
|
1613
|
+
lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
|
|
1614
|
+
for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
|
|
1615
|
+
}
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
case "skip": break;
|
|
1619
|
+
}
|
|
1620
|
+
return lines;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Emit one yellow `# warning:` comment line per plan-time validation
|
|
1624
|
+
* warning, below the action's closing brace (same channel as the revise
|
|
1625
|
+
* re-pin announcement). Warnings never block the plan.
|
|
1626
|
+
*/
|
|
1627
|
+
function renderWarnings(warnings, blkPad, tty, lines) {
|
|
1628
|
+
for (const warning of warnings ?? []) lines.push(paint(`${blkPad}# warning: ${warning.message}`, A.yellow, tty));
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
export { buildSyncPlan as a, writeBackResource as c, summarizePlan as i, makeSyncers as l, enumeratePlanResources as n, hashForState as o, renderPlan as r, runSyncLoop as s, collectPlanWarnings as t, FLOWS_DIR as u };
|
|
1632
|
+
|
|
1633
|
+
//# sourceMappingURL=sync-BBlACKzT.mjs.map
|