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