@zitadel/cli 0.1.0-alpha.17 → 0.1.0-alpha.19
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 +210 -124
- package/SKILLS.md +159 -17
- 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 +6 -5
- 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 +500 -37
- package/dist/commands/doctor.mjs.map +1 -1
- package/dist/commands/eject.mjs +4 -4
- package/dist/commands/eject.mjs.map +1 -1
- package/dist/commands/logs.mjs +2 -2
- package/dist/commands/plan.mjs +6 -5
- package/dist/commands/plan.mjs.map +1 -1
- package/dist/commands/reset.mjs +2 -2
- package/dist/commands/schemas/list.mjs +11 -11
- package/dist/commands/schemas/list.mjs.map +1 -1
- package/dist/commands/setup.mjs +359 -132
- package/dist/commands/setup.mjs.map +1 -1
- package/dist/commands/start.mjs +3 -3
- package/dist/commands/status.mjs +21 -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-pGcTrDIY.mjs → docker-DcRGTOJa.mjs} +4 -3
- package/dist/docker-DcRGTOJa.mjs.map +1 -0
- package/dist/{environment-BQF7LeCz.mjs → environment-rjRVkJjW.mjs} +1 -1
- package/dist/{environment-BQF7LeCz.mjs.map → environment-rjRVkJjW.mjs.map} +1 -1
- package/dist/{journey-guidance-CYVpQszz.mjs → journey-guidance-BGrOX_gT.mjs} +14 -3
- package/dist/{journey-guidance-CYVpQszz.mjs.map → journey-guidance-BGrOX_gT.mjs.map} +1 -1
- package/dist/{oclif-BRdjVNne.mjs → oclif-CanO3zdt.mjs} +247 -43
- package/dist/oclif-CanO3zdt.mjs.map +1 -0
- package/dist/{orca-Dip6DPJ2.mjs → orca-Cu0iW1mv.mjs} +894 -196
- package/dist/orca-Cu0iW1mv.mjs.map +1 -0
- package/dist/package-manager-DFdLcDx1.mjs +194 -0
- package/dist/package-manager-DFdLcDx1.mjs.map +1 -0
- package/dist/{ports-V8JfiNgT.mjs → ports-BM20XIZb.mjs} +2 -2
- package/dist/{ports-V8JfiNgT.mjs.map → ports-BM20XIZb.mjs.map} +1 -1
- package/dist/{processes-BVqYsxT8.mjs → processes-DKHPkU8O.mjs} +1 -1
- package/dist/{processes-BVqYsxT8.mjs.map → processes-DKHPkU8O.mjs.map} +1 -1
- package/dist/{project-BGgv7Bba.mjs → project-CHhot85s.mjs} +57 -4
- package/dist/project-CHhot85s.mjs.map +1 -0
- package/dist/{sync-Cd3z7Q1R.mjs → sync-Bd6vASgg.mjs} +549 -75
- package/dist/sync-Bd6vASgg.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 +227 -5
- package/package.json +9 -6
- package/dist/docker-pGcTrDIY.mjs.map +0 -1
- package/dist/oclif-BRdjVNne.mjs.map +0 -1
- package/dist/orca-Dip6DPJ2.mjs.map +0 -1
- package/dist/project-BGgv7Bba.mjs.map +0 -1
- package/dist/sync-Cd3z7Q1R.mjs.map +0 -1
- package/dist/user-schema-DDz5-lX5.mjs +0 -13
- package/dist/user-schema-DDz5-lX5.mjs.map +0 -1
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as SCHEMAS_DIR } from "./user-schema-
|
|
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";
|
|
3
4
|
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
5
6
|
import { DEFAULT_FLOW_SCHEMA_URI } from "@zitadel/config/defaults";
|
|
6
7
|
import { consola as consola$1 } from "consola";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
8
10
|
import { normalizeFlowBody, normalizeSchemaBody } from "@zitadel/config/normalize";
|
|
9
|
-
import { flowConfigSchema, schemaConfigSchema } from "@zitadel/config/schemas";
|
|
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";
|
|
10
16
|
import { validateFlowDefinition } from "@zitadel/config/validate";
|
|
11
17
|
//#region src/lib/flows/env-refs.ts
|
|
12
18
|
/**
|
|
@@ -51,7 +57,11 @@ const FLOWS_DIR = ".zitadel/flows";
|
|
|
51
57
|
* sync loop.
|
|
52
58
|
*/
|
|
53
59
|
function makeSyncers(opts) {
|
|
54
|
-
return [
|
|
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
|
+
];
|
|
55
65
|
}
|
|
56
66
|
/**
|
|
57
67
|
* Assert that every env var a resource references — `${VAR}` placeholders and
|
|
@@ -117,7 +127,7 @@ var SchemaSyncer = class {
|
|
|
117
127
|
throw new ZitadelError("E_NOT_IMPLEMENTED", `schema delete is not supported (${id})`);
|
|
118
128
|
}
|
|
119
129
|
async fetch(id) {
|
|
120
|
-
return await this.client.getSchemaById(encodeURIComponent(id)
|
|
130
|
+
return await this.client.getSchemaById(encodeURIComponent(id));
|
|
121
131
|
}
|
|
122
132
|
};
|
|
123
133
|
var FlowDefinitionSyncer = class {
|
|
@@ -162,25 +172,392 @@ var FlowDefinitionSyncer = class {
|
|
|
162
172
|
/**
|
|
163
173
|
* PUT completely replaces the flow definition. The wire request wraps the
|
|
164
174
|
* bare on-disk flow in the `{ flow_definition }` update envelope
|
|
165
|
-
* (`api/openapi/components/flows/flow-definition-update-request.yaml`)
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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.
|
|
168
178
|
*/
|
|
169
179
|
async update(id, data) {
|
|
170
|
-
return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data }
|
|
180
|
+
return { canonical: (await this.client.updateFlowDefinition(id, { flow_definition: data })).flow_definition };
|
|
171
181
|
}
|
|
172
182
|
async delete(id) {
|
|
173
|
-
await this.client.deleteFlowDefinition(id
|
|
183
|
+
await this.client.deleteFlowDefinition(id);
|
|
174
184
|
}
|
|
175
185
|
/**
|
|
176
186
|
* `GET /flow_definitions/:id` returns a detail envelope with metadata
|
|
177
187
|
* (`id`, `project_id`, `created_at`, `updated_at`) plus `flow_definition`.
|
|
178
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.
|
|
179
190
|
*/
|
|
180
191
|
async fetch(id) {
|
|
181
|
-
return (await this.client.getFlowDefinition(id
|
|
192
|
+
return (await this.client.getFlowDefinition(id)).flow_definition;
|
|
182
193
|
}
|
|
183
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
|
+
}
|
|
184
561
|
//#endregion
|
|
185
562
|
//#region src/lib/sync/flow-validation.ts
|
|
186
563
|
/**
|
|
@@ -290,49 +667,6 @@ function resolveSchemaContent(action, scannedContents, stateResources) {
|
|
|
290
667
|
consola$1.debug(`flow validation: no local schema for user_schema ${ref}; flow-local rules only`);
|
|
291
668
|
}
|
|
292
669
|
//#endregion
|
|
293
|
-
//#region src/lib/sync/state.ts
|
|
294
|
-
/**
|
|
295
|
-
* Read and parse `.zitadel/state.json`. Throws if the file is
|
|
296
|
-
* missing or malformed; callers run `zitadel setup` first to bring
|
|
297
|
-
* the file into existence.
|
|
298
|
-
*/
|
|
299
|
-
async function readState(cwd) {
|
|
300
|
-
const raw = await readFile(join(cwd, ".zitadel/state.json"), "utf8");
|
|
301
|
-
return JSON.parse(raw);
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Merge an entry into the state file under `key`, preserving any
|
|
305
|
-
* fields the caller did not override. Reads the file, writes it back
|
|
306
|
-
* with sorted keys disabled (state is engine-managed, not human-
|
|
307
|
-
* authored, so deterministic ordering isn't required here).
|
|
308
|
-
*/
|
|
309
|
-
async function updateState(cwd, key, entry) {
|
|
310
|
-
const current = await readState(cwd);
|
|
311
|
-
const updated = {
|
|
312
|
-
...current,
|
|
313
|
-
resources: {
|
|
314
|
-
...current.resources,
|
|
315
|
-
[key]: {
|
|
316
|
-
...current.resources[key],
|
|
317
|
-
...entry
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
await writeFile(join(cwd, ".zitadel/state.json"), JSON.stringify(updated, null, 2));
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Remove an entry from the state file. No-op if the key is absent.
|
|
325
|
-
*/
|
|
326
|
-
async function removeFromState(cwd, key) {
|
|
327
|
-
const current = await readState(cwd);
|
|
328
|
-
const { [key]: _removed, ...rest } = current.resources;
|
|
329
|
-
const updated = {
|
|
330
|
-
...current,
|
|
331
|
-
resources: rest
|
|
332
|
-
};
|
|
333
|
-
await writeFile(join(cwd, ".zitadel/state.json"), JSON.stringify(updated, null, 2));
|
|
334
|
-
}
|
|
335
|
-
//#endregion
|
|
336
670
|
//#region src/lib/sync/loop.ts
|
|
337
671
|
/**
|
|
338
672
|
* Compute the sync plan for `cwd` against the state file and (when
|
|
@@ -369,6 +703,10 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
|
|
|
369
703
|
const dirPath = join(cwd, syncer.directory);
|
|
370
704
|
consola$1.debug(`scanning ${syncer.directory}`);
|
|
371
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
|
+
}
|
|
372
710
|
for (const content of onDisk.values()) syncer.validate(content);
|
|
373
711
|
for (const [filePath, entry] of Object.entries(state.resources)) {
|
|
374
712
|
if (!filePath.startsWith(syncer.directory)) continue;
|
|
@@ -463,6 +801,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
|
|
|
463
801
|
scannedContents,
|
|
464
802
|
stateResources: state.resources
|
|
465
803
|
});
|
|
804
|
+
await annotateAssetWarnings(actions);
|
|
466
805
|
return actions;
|
|
467
806
|
}
|
|
468
807
|
/**
|
|
@@ -482,7 +821,7 @@ async function buildSyncPlan(cwd, syncers, fetchOld = false) {
|
|
|
482
821
|
*/
|
|
483
822
|
async function runSyncLoop(cwd, syncers) {
|
|
484
823
|
const actions = await buildSyncPlan(cwd, syncers);
|
|
485
|
-
for (const action of actions) if (action.kind === "create" || action.kind === "update") for (const warning of action.warnings ?? []) consola$1.warn(`${action.path}: ${warning.message}`);
|
|
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}`);
|
|
486
825
|
const filesUpdated = [];
|
|
487
826
|
const applied = [];
|
|
488
827
|
const repinned = /* @__PURE__ */ new Map();
|
|
@@ -743,17 +1082,21 @@ function summarizePlan(actions) {
|
|
|
743
1082
|
*/
|
|
744
1083
|
function collectPlanWarnings(actions) {
|
|
745
1084
|
const out = [];
|
|
746
|
-
for (const action of actions) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
message: warning.message
|
|
752
|
-
});
|
|
753
|
-
}
|
|
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
|
+
});
|
|
754
1090
|
return out;
|
|
755
1091
|
}
|
|
756
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
|
+
/**
|
|
757
1100
|
* Enumerate the non-`skip` actions of a {@link buildSyncPlan} result as
|
|
758
1101
|
* envelope-ready {@link PlanResourceChange} rows. Pure; the structural
|
|
759
1102
|
* counterpart of {@link summarizePlan} — counts and rows always agree.
|
|
@@ -822,7 +1165,7 @@ function renderPlan(actions, tty) {
|
|
|
822
1165
|
if (revisions > 0) parts.push(`${revisions} new revision${revisions === 1 ? "" : "s"}`);
|
|
823
1166
|
if (deletes > 0) parts.push(`${deletes} to destroy`);
|
|
824
1167
|
out.push(paint(`Plan: ${parts.join(", ")}.`, A.bold, tty));
|
|
825
|
-
const warningCount = active.reduce((count, action) => count + (
|
|
1168
|
+
const warningCount = active.reduce((count, action) => count + warningsOf(action).length, 0);
|
|
826
1169
|
if (warningCount > 0) out.push(paint(`Warnings: ${warningCount} (non-blocking — see the # warning lines above).`, A.yellow, tty));
|
|
827
1170
|
return out.join("\n");
|
|
828
1171
|
}
|
|
@@ -852,6 +1195,37 @@ function fmtPrimitive(v) {
|
|
|
852
1195
|
if (typeof v === "string") return `"${escapeString(v)}"`;
|
|
853
1196
|
return String(v);
|
|
854
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
|
+
}
|
|
855
1229
|
function prefixAnsi(p) {
|
|
856
1230
|
if (p === "+") return A.green;
|
|
857
1231
|
if (p === "-") return A.red;
|
|
@@ -868,7 +1242,7 @@ function renderFields(obj, prefix, prefixCol, ctx, lines) {
|
|
|
868
1242
|
const val = obj[key];
|
|
869
1243
|
const pk = key.padEnd(maxLen);
|
|
870
1244
|
if (isPrimitive(val)) {
|
|
871
|
-
const formatted =
|
|
1245
|
+
const formatted = fmtScalar(val);
|
|
872
1246
|
const suffix = ctx.deleteMode ? " -> null" : "";
|
|
873
1247
|
lines.push(col(`${pad}${prefix} ${pk} = ${formatted}${suffix}`));
|
|
874
1248
|
} else if (Array.isArray(val)) if (val.length === 0) lines.push(col(`${pad}${prefix} ${pk} = []`));
|
|
@@ -896,7 +1270,7 @@ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
|
|
|
896
1270
|
const ansi = prefixAnsi(prefix);
|
|
897
1271
|
const col = (s) => paint(s, ansi, ctx.tty);
|
|
898
1272
|
for (const item of arr) if (isPrimitive(item)) {
|
|
899
|
-
const formatted =
|
|
1273
|
+
const formatted = fmtScalar(item);
|
|
900
1274
|
lines.push(col(`${pad}${prefix} ${formatted},`));
|
|
901
1275
|
} else if (Array.isArray(item)) if (item.length === 0) lines.push(col(`${pad}${prefix} [],`));
|
|
902
1276
|
else {
|
|
@@ -911,6 +1285,102 @@ function renderArrayItems(arr, prefix, prefixCol, ctx, lines) {
|
|
|
911
1285
|
lines.push(col(`${" ".repeat(prefixCol + 2)}},`));
|
|
912
1286
|
}
|
|
913
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
|
+
}
|
|
914
1384
|
/**
|
|
915
1385
|
* Walks both old and new objects, emitting Terraform-style change lines.
|
|
916
1386
|
* Returns true if any actual change line (+ / - / ~) was emitted.
|
|
@@ -936,7 +1406,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
|
|
|
936
1406
|
if (!hasOld) {
|
|
937
1407
|
hasChanges = true;
|
|
938
1408
|
const col = (s) => paint(s, A.green, tty);
|
|
939
|
-
if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${
|
|
1409
|
+
if (isPrimitive(newVal)) lines.push(col(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
|
|
940
1410
|
else if (Array.isArray(newVal)) {
|
|
941
1411
|
lines.push(col(`${pad}+ ${pk} = [`));
|
|
942
1412
|
renderArrayItems(newVal, "+", prefixCol + 4, {
|
|
@@ -955,7 +1425,7 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
|
|
|
955
1425
|
} else if (!hasNew) {
|
|
956
1426
|
hasChanges = true;
|
|
957
1427
|
const col = (s) => paint(s, A.red, tty);
|
|
958
|
-
if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${
|
|
1428
|
+
if (isPrimitive(oldVal)) lines.push(col(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
|
|
959
1429
|
else if (Array.isArray(oldVal)) {
|
|
960
1430
|
lines.push(col(`${pad}- ${pk} = [`));
|
|
961
1431
|
renderArrayItems(oldVal, "-", prefixCol + 4, {
|
|
@@ -971,8 +1441,11 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
|
|
|
971
1441
|
}, lines);
|
|
972
1442
|
lines.push(col(`${" ".repeat(prefixCol + 2)}}`));
|
|
973
1443
|
}
|
|
974
|
-
} else if (isPrimitive(oldVal) && isPrimitive(newVal)) if (oldVal === newVal) lines.push(`${pad} ${pk} = ${fmtPrimitive(newVal)}`);
|
|
975
|
-
else {
|
|
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 {
|
|
976
1449
|
hasChanges = true;
|
|
977
1450
|
const col = (s) => paint(s, A.yellow, tty);
|
|
978
1451
|
lines.push(col(`${pad}~ ${pk} = ${fmtPrimitive(oldVal)} -> ${fmtPrimitive(newVal)}`));
|
|
@@ -1026,8 +1499,8 @@ function renderDiff(oldObj, newObj, prefixCol, tty, lines) {
|
|
|
1026
1499
|
hasChanges = true;
|
|
1027
1500
|
const colR = (s) => paint(s, A.red, tty);
|
|
1028
1501
|
const colA = (s) => paint(s, A.green, tty);
|
|
1029
|
-
if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${
|
|
1030
|
-
if (isPrimitive(newVal)) lines.push(colA(`${pad}+ ${pk} = ${
|
|
1502
|
+
if (isPrimitive(oldVal)) lines.push(colR(`${pad}- ${pk} = ${fmtScalar(oldVal)} -> null`));
|
|
1503
|
+
if (isPrimitive(newVal)) lines.push(colA(`${pad}+ ${pk} = ${fmtScalar(newVal)}`));
|
|
1031
1504
|
}
|
|
1032
1505
|
}
|
|
1033
1506
|
return hasChanges;
|
|
@@ -1135,6 +1608,7 @@ function renderBlock(action, tty) {
|
|
|
1135
1608
|
}, FIELD_COL, tty, lines);
|
|
1136
1609
|
else lines.push(`${" ".repeat(FIELD_COL)} # (field diff unavailable — no read endpoint for ${action.syncer.kind})`);
|
|
1137
1610
|
lines.push(`${closePad}}`);
|
|
1611
|
+
renderWarnings(action.warnings, blkPad, tty, lines);
|
|
1138
1612
|
if (action.affectedPaths.length > 0) {
|
|
1139
1613
|
lines.push(paint(`${blkPad}# user_schema will be re-pinned to the new revision ${KNOWN_AFTER_APPLY} in:`, A.yellow, tty));
|
|
1140
1614
|
for (const path of action.affectedPaths) lines.push(paint(`${blkPad}# - ${path}`, A.yellow, tty));
|
|
@@ -1154,6 +1628,6 @@ function renderWarnings(warnings, blkPad, tty, lines) {
|
|
|
1154
1628
|
for (const warning of warnings ?? []) lines.push(paint(`${blkPad}# warning: ${warning.message}`, A.yellow, tty));
|
|
1155
1629
|
}
|
|
1156
1630
|
//#endregion
|
|
1157
|
-
export { buildSyncPlan as a, writeBackResource as c,
|
|
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 };
|
|
1158
1632
|
|
|
1159
|
-
//# sourceMappingURL=sync-
|
|
1633
|
+
//# sourceMappingURL=sync-Bd6vASgg.mjs.map
|