@microck/canonfig 2.0.0 → 2.1.0
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 +1 -1
- package/dist/cli/cli.js +1 -1
- package/dist/harness-configuration/adapters/amp.js +87 -0
- package/dist/harness-configuration/adapters/antigravity.js +50 -0
- package/dist/harness-configuration/adapters/claude.js +43 -0
- package/dist/harness-configuration/adapters/codex.js +60 -0
- package/dist/harness-configuration/adapters/copilot.js +79 -0
- package/dist/harness-configuration/adapters/cursor.js +67 -0
- package/dist/harness-configuration/adapters/descriptor.js +10 -0
- package/dist/harness-configuration/adapters/devin.js +66 -0
- package/dist/harness-configuration/adapters/droid.js +32 -0
- package/dist/harness-configuration/adapters/grok.js +44 -0
- package/dist/harness-configuration/adapters/hermes.js +105 -0
- package/dist/harness-configuration/adapters/index.js +50 -0
- package/dist/harness-configuration/adapters/kilo.js +18 -0
- package/dist/harness-configuration/adapters/kimi.js +148 -0
- package/dist/harness-configuration/adapters/omp.js +64 -0
- package/dist/harness-configuration/adapters/open-code-family.js +94 -0
- package/dist/harness-configuration/adapters/opencode.js +18 -0
- package/dist/harness-configuration/adapters/pi.js +93 -0
- package/dist/harness-configuration/adapters/qwen.js +164 -0
- package/dist/harness-configuration/adapters/shared-common.js +66 -0
- package/dist/harness-configuration/adapters/shared-documents.js +117 -0
- package/dist/harness-configuration/adapters/shared-hooks.js +186 -0
- package/dist/harness-configuration/adapters/shared-mcp.js +214 -0
- package/dist/harness-configuration/adapters/shared.js +4 -0
- package/dist/harness-configuration/adapters/tools.js +24 -0
- package/dist/harness-configuration/cli-arguments.js +105 -0
- package/dist/harness-configuration/cli-output.js +77 -0
- package/dist/harness-configuration/cli.js +196 -0
- package/dist/harness-configuration/core/compiler.js +175 -0
- package/dist/harness-configuration/core/config.js +74 -0
- package/dist/harness-configuration/core/diff.js +60 -0
- package/dist/harness-configuration/core/doctor.js +40 -0
- package/dist/harness-configuration/core/errors.js +13 -0
- package/dist/harness-configuration/core/filesystem.js +172 -0
- package/dist/harness-configuration/core/frontmatter.js +49 -0
- package/dist/harness-configuration/core/hash.js +4 -0
- package/dist/harness-configuration/core/path.js +50 -0
- package/dist/harness-configuration/core/planner.js +255 -0
- package/dist/harness-configuration/core/render-cleanup.js +91 -0
- package/dist/harness-configuration/core/render-json.js +195 -0
- package/dist/harness-configuration/core/render-text.js +134 -0
- package/dist/harness-configuration/core/render-utils.js +202 -0
- package/dist/harness-configuration/core/render.js +54 -0
- package/dist/harness-configuration/core/scaffold.js +103 -0
- package/dist/harness-configuration/core/schema-components.js +167 -0
- package/dist/harness-configuration/core/schema-config.js +98 -0
- package/dist/harness-configuration/core/schema-runtime.js +143 -0
- package/dist/harness-configuration/core/schema-types.js +8 -0
- package/dist/harness-configuration/core/schema.js +13 -0
- package/dist/harness-configuration/core/state.js +42 -0
- package/dist/harness-configuration/core/types.js +5 -0
- package/dist/harness-configuration/core/validation.js +113 -0
- package/dist/harness-configuration/templates/runtime.js +212 -0
- package/dist/runtime/main.js +20 -14
- package/package.json +5 -5
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { assertRealPathInside, assertSafeRelativePath, resolveInside } from "./path.js";
|
|
5
|
+
import { assertNoSymlinkPathComponents, atomicWrite, ensureDirectoryNoFollow, readOptionalFile, removeFileAndEmptyParents, } from "./filesystem.js";
|
|
6
|
+
import { loadState, writeState, HARNESS_CONFIGURATION_VERSION } from "./state.js";
|
|
7
|
+
import { renderArtifacts } from "./render.js";
|
|
8
|
+
import { sha256 } from "./hash.js";
|
|
9
|
+
import { CanonfigError } from "./errors.js";
|
|
10
|
+
function bytesEqual(left, right) {
|
|
11
|
+
if (left === undefined || right === undefined)
|
|
12
|
+
return left === undefined && right === undefined;
|
|
13
|
+
const rightBytes = typeof right === "string" ? Buffer.from(right) : Buffer.from(right);
|
|
14
|
+
return Buffer.from(left).equals(rightBytes);
|
|
15
|
+
}
|
|
16
|
+
function isTextArtifacts(artifacts) {
|
|
17
|
+
return artifacts.every((artifact) => artifact.kind !== "replace" || typeof artifact.content === "string");
|
|
18
|
+
}
|
|
19
|
+
function selectedOwner(owner, targets) {
|
|
20
|
+
return owner === "common" || targets.includes(owner);
|
|
21
|
+
}
|
|
22
|
+
function ownerFor(artifacts, diagnostics, filePath) {
|
|
23
|
+
const owners = [...new Set(artifacts.map((artifact) => artifact.owner))];
|
|
24
|
+
if (owners.length > 1) {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
level: "error",
|
|
27
|
+
code: "ARTIFACT_OWNER_COLLISION",
|
|
28
|
+
message: `Multiple owners target ${filePath}: ${owners.join(", ")}`,
|
|
29
|
+
path: filePath,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return owners[0] ?? "common";
|
|
33
|
+
}
|
|
34
|
+
function modeFor(artifacts) {
|
|
35
|
+
const modes = artifacts.flatMap((artifact) => artifact.kind === "replace" && artifact.mode !== undefined ? [artifact.mode] : []);
|
|
36
|
+
return modes[0];
|
|
37
|
+
}
|
|
38
|
+
export async function createPlan(root, targets, artifacts, diagnostics = [], options = {}) {
|
|
39
|
+
const previousState = await loadState(root);
|
|
40
|
+
const nextArtifacts = {};
|
|
41
|
+
for (const [filePath, state] of Object.entries(previousState.artifacts)) {
|
|
42
|
+
if (!selectedOwner(state.owner, targets))
|
|
43
|
+
nextArtifacts[filePath] = state;
|
|
44
|
+
}
|
|
45
|
+
const groups = new Map();
|
|
46
|
+
for (const artifact of artifacts) {
|
|
47
|
+
const safePath = assertSafeRelativePath(artifact.path);
|
|
48
|
+
const list = groups.get(safePath) ?? [];
|
|
49
|
+
list.push({ ...artifact, path: safePath });
|
|
50
|
+
groups.set(safePath, list);
|
|
51
|
+
}
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const [filePath, group] of [...groups.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
54
|
+
const absolute = resolveInside(root, filePath);
|
|
55
|
+
await assertRealPathInside(root, absolute);
|
|
56
|
+
const currentBytes = await readOptionalFile(absolute);
|
|
57
|
+
const current = isTextArtifacts(group) && currentBytes !== undefined ? Buffer.from(currentBytes).toString("utf8") : currentBytes;
|
|
58
|
+
const previous = previousState.artifacts[filePath];
|
|
59
|
+
const rendered = renderArtifacts(group, current, previous, options.force ?? false);
|
|
60
|
+
const owner = ownerFor(group, diagnostics, filePath);
|
|
61
|
+
const mode = modeFor(group);
|
|
62
|
+
if (rendered.conflicts.length > 0) {
|
|
63
|
+
entries.push({
|
|
64
|
+
path: filePath,
|
|
65
|
+
owner,
|
|
66
|
+
action: "conflict",
|
|
67
|
+
reason: rendered.conflicts.join(" "),
|
|
68
|
+
before: typeof current === "string" ? current : undefined,
|
|
69
|
+
after: typeof rendered.content === "string" ? rendered.content : undefined,
|
|
70
|
+
content: rendered.content,
|
|
71
|
+
binary: rendered.content instanceof Uint8Array,
|
|
72
|
+
...(mode === undefined ? {} : { mode }),
|
|
73
|
+
});
|
|
74
|
+
if (previous)
|
|
75
|
+
nextArtifacts[filePath] = previous;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (rendered.content === undefined) {
|
|
79
|
+
entries.push({ path: filePath, owner, action: currentBytes === undefined ? "unchanged" : "delete" });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const contentHash = sha256(rendered.content);
|
|
83
|
+
const unmanagedIdenticalReplace = previous === undefined &&
|
|
84
|
+
currentBytes !== undefined &&
|
|
85
|
+
group.length === 1 &&
|
|
86
|
+
group[0]?.kind === "replace" &&
|
|
87
|
+
contentHash === sha256(currentBytes);
|
|
88
|
+
let nextState;
|
|
89
|
+
if (!unmanagedIdenticalReplace) {
|
|
90
|
+
nextState = {
|
|
91
|
+
owner,
|
|
92
|
+
hash: contentHash,
|
|
93
|
+
existedBefore: previous?.existedBefore ?? currentBytes !== undefined,
|
|
94
|
+
cleanup: rendered.cleanup,
|
|
95
|
+
...(mode === undefined ? {} : { mode }),
|
|
96
|
+
};
|
|
97
|
+
nextArtifacts[filePath] = nextState;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
diagnostics.push({
|
|
101
|
+
level: "info",
|
|
102
|
+
code: "UNMANAGED_IDENTICAL",
|
|
103
|
+
message: `${filePath} already matches generated output; Canonfig left ownership unchanged.`,
|
|
104
|
+
path: filePath,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const action = currentBytes === undefined ? "create" : bytesEqual(currentBytes, rendered.content) ? "unchanged" : "update";
|
|
108
|
+
entries.push({
|
|
109
|
+
path: filePath,
|
|
110
|
+
owner,
|
|
111
|
+
action,
|
|
112
|
+
before: typeof current === "string" ? current : undefined,
|
|
113
|
+
after: typeof rendered.content === "string" ? rendered.content : undefined,
|
|
114
|
+
content: rendered.content,
|
|
115
|
+
binary: rendered.content instanceof Uint8Array,
|
|
116
|
+
...(mode === undefined ? {} : { mode }),
|
|
117
|
+
...(nextState === undefined ? {} : { nextState }),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const desiredPaths = new Set(groups.keys());
|
|
121
|
+
for (const [filePath, previous] of Object.entries(previousState.artifacts)) {
|
|
122
|
+
if (desiredPaths.has(filePath) || !selectedOwner(previous.owner, targets))
|
|
123
|
+
continue;
|
|
124
|
+
const absolute = resolveInside(root, filePath);
|
|
125
|
+
await assertRealPathInside(root, absolute);
|
|
126
|
+
const currentBytes = await readOptionalFile(absolute);
|
|
127
|
+
const current = currentBytes === undefined ? undefined : Buffer.from(currentBytes).toString("utf8");
|
|
128
|
+
const rendered = renderArtifacts([], current, previous, options.force ?? false);
|
|
129
|
+
if (rendered.conflicts.length > 0) {
|
|
130
|
+
entries.push({
|
|
131
|
+
path: filePath,
|
|
132
|
+
owner: previous.owner,
|
|
133
|
+
action: "conflict",
|
|
134
|
+
reason: rendered.conflicts.join(" "),
|
|
135
|
+
before: current,
|
|
136
|
+
after: typeof rendered.content === "string" ? rendered.content : undefined,
|
|
137
|
+
});
|
|
138
|
+
nextArtifacts[filePath] = previous;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const cleaned = previous.existedBefore ? rendered.content : undefined;
|
|
142
|
+
if (cleaned === undefined) {
|
|
143
|
+
entries.push({ path: filePath, owner: previous.owner, action: currentBytes === undefined ? "unchanged" : "delete", before: current });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const action = bytesEqual(currentBytes, cleaned) ? "unchanged" : currentBytes === undefined ? "create" : "update";
|
|
147
|
+
entries.push({
|
|
148
|
+
path: filePath,
|
|
149
|
+
owner: previous.owner,
|
|
150
|
+
action,
|
|
151
|
+
before: current,
|
|
152
|
+
after: typeof cleaned === "string" ? cleaned : undefined,
|
|
153
|
+
content: cleaned,
|
|
154
|
+
binary: cleaned instanceof Uint8Array,
|
|
155
|
+
...(previous.mode === undefined ? {} : { mode: previous.mode }),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const nextState = {
|
|
160
|
+
version: 1,
|
|
161
|
+
generatedAt: new Date().toISOString(),
|
|
162
|
+
canonfigVersion: HARNESS_CONFIGURATION_VERSION,
|
|
163
|
+
artifacts: Object.fromEntries(Object.entries(nextArtifacts).sort(([left], [right]) => left.localeCompare(right))),
|
|
164
|
+
};
|
|
165
|
+
return { root, targets, entries: entries.sort((a, b) => a.path.localeCompare(b.path)), diagnostics, nextState };
|
|
166
|
+
}
|
|
167
|
+
async function snapshotFile(root, relativePath) {
|
|
168
|
+
const absolute = resolveInside(root, relativePath);
|
|
169
|
+
try {
|
|
170
|
+
const stats = await fs.lstat(absolute);
|
|
171
|
+
if (stats.isSymbolicLink()) {
|
|
172
|
+
return { path: relativePath, kind: "symlink", linkTarget: await fs.readlink(absolute) };
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
path: relativePath,
|
|
176
|
+
kind: "file",
|
|
177
|
+
content: await fs.readFile(absolute),
|
|
178
|
+
mode: stats.mode & 0o777,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code === "ENOENT")
|
|
183
|
+
return { path: relativePath, kind: "missing" };
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function restoreSnapshots(root, snapshots) {
|
|
188
|
+
const failures = [];
|
|
189
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
190
|
+
const absolute = resolveInside(root, snapshot.path);
|
|
191
|
+
try {
|
|
192
|
+
if (snapshot.kind === "missing") {
|
|
193
|
+
await removeFileAndEmptyParents(absolute, root);
|
|
194
|
+
}
|
|
195
|
+
else if (snapshot.kind === "symlink") {
|
|
196
|
+
const parent = path.dirname(absolute);
|
|
197
|
+
await ensureDirectoryNoFollow(root, parent);
|
|
198
|
+
await assertNoSymlinkPathComponents(root, parent);
|
|
199
|
+
await fs.rm(absolute, { force: true });
|
|
200
|
+
await assertNoSymlinkPathComponents(root, parent);
|
|
201
|
+
await fs.symlink(snapshot.linkTarget, absolute);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
await atomicWrite(absolute, snapshot.content, snapshot.mode, root);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
failures.push({ path: snapshot.path, error });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (failures.length > 0) {
|
|
212
|
+
throw new Error(`Failed to restore ${failures.length} snapshot(s): ${failures.map(({ path: filePath, error }) => `${filePath}: ${String(error)}`).join("; ")}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function applyPlan(plan) {
|
|
216
|
+
const conflicts = plan.entries.filter((entry) => entry.action === "conflict");
|
|
217
|
+
const errors = plan.diagnostics.filter((diagnostic) => diagnostic.level === "error");
|
|
218
|
+
if (conflicts.length > 0 || errors.length > 0) {
|
|
219
|
+
throw new CanonfigError("PLAN_CONFLICT", `Plan has ${conflicts.length} conflict(s) and ${errors.length} error diagnostic(s).`);
|
|
220
|
+
}
|
|
221
|
+
const mutableEntries = plan.entries.filter((entry) => entry.action === "create" || entry.action === "update" || entry.action === "delete");
|
|
222
|
+
const snapshots = [];
|
|
223
|
+
for (const entry of mutableEntries) {
|
|
224
|
+
const absolute = resolveInside(plan.root, entry.path);
|
|
225
|
+
await assertRealPathInside(plan.root, absolute);
|
|
226
|
+
snapshots.push(await snapshotFile(plan.root, entry.path));
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
for (const entry of mutableEntries) {
|
|
230
|
+
const absolute = resolveInside(plan.root, entry.path);
|
|
231
|
+
if (entry.action === "create" || entry.action === "update") {
|
|
232
|
+
if (entry.after === undefined && !entry.binary) {
|
|
233
|
+
throw new CanonfigError("PLAN_INVALID", `Missing output content for ${entry.path}`);
|
|
234
|
+
}
|
|
235
|
+
const content = entry.content ?? entry.after;
|
|
236
|
+
if (content === undefined)
|
|
237
|
+
throw new CanonfigError("PLAN_INVALID", `Missing output content for ${entry.path}`);
|
|
238
|
+
await atomicWrite(absolute, content, entry.mode, plan.root);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
await removeFileAndEmptyParents(absolute, plan.root);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await writeState(plan.root, plan.nextState);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
try {
|
|
248
|
+
await restoreSnapshots(plan.root, snapshots);
|
|
249
|
+
}
|
|
250
|
+
catch (rollbackError) {
|
|
251
|
+
throw new CanonfigError("APPLY_ROLLBACK_FAILED", `Harness apply failed and rollback also failed: ${String(rollbackError)}`, error);
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { sha256 } from "./hash.js";
|
|
2
|
+
import { restoreJsonCleanup } from "./render-json.js";
|
|
3
|
+
import { commentMarkers, locateBlock, parseJsonDocument, serializeJsonDocument, tomlBlockMarkers, } from "./render-utils.js";
|
|
4
|
+
function removeManagedText(text, cleanup, force, conflicts) {
|
|
5
|
+
const markers = commentMarkers(cleanup.marker, cleanup.comments);
|
|
6
|
+
const located = locateBlock(text, markers.begin, markers.end);
|
|
7
|
+
if (located === undefined) {
|
|
8
|
+
conflicts.push(`Managed block ${cleanup.marker} is missing.`);
|
|
9
|
+
return text;
|
|
10
|
+
}
|
|
11
|
+
if (sha256(located.block) !== cleanup.blockHash && !force) {
|
|
12
|
+
conflicts.push(`Managed block ${cleanup.marker} was edited outside Canonfig.`);
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
return `${text.slice(0, located.start)}${text.slice(located.end)}`
|
|
16
|
+
.replace(/^\s+$/u, "");
|
|
17
|
+
}
|
|
18
|
+
function removeTomlBlock(text, cleanup, force, conflicts) {
|
|
19
|
+
const markers = tomlBlockMarkers(cleanup.marker);
|
|
20
|
+
const located = locateBlock(text, markers.begin, markers.end);
|
|
21
|
+
if (located === undefined) {
|
|
22
|
+
conflicts.push(`Managed TOML block ${cleanup.marker} is missing.`);
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
if (sha256(located.block) !== cleanup.blockHash && !force) {
|
|
26
|
+
conflicts.push(`Managed TOML block ${cleanup.marker} was edited outside Canonfig.`);
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
return `${text.slice(0, located.start)}${text.slice(located.end)}`;
|
|
30
|
+
}
|
|
31
|
+
function removeTomlKey(text, cleanup, conflicts) {
|
|
32
|
+
const lines = text.split(/\r?\n/u);
|
|
33
|
+
const marker = `# canonfig:key ${cleanup.marker}`;
|
|
34
|
+
const index = lines.findIndex((line) => line.includes(marker));
|
|
35
|
+
if (index < 0) {
|
|
36
|
+
conflicts.push(`Managed TOML key ${cleanup.section}.${cleanup.key} is missing.`);
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
if (cleanup.originalLine !== undefined)
|
|
40
|
+
lines[index] = cleanup.originalLine;
|
|
41
|
+
else
|
|
42
|
+
lines.splice(index, 1);
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
export function unapplyPrevious(current, previous, force, conflicts) {
|
|
46
|
+
if (previous === undefined)
|
|
47
|
+
return current;
|
|
48
|
+
let output = current;
|
|
49
|
+
const jsonCleanups = previous.cleanup.filter((cleanup) => cleanup.kind.startsWith("json-"));
|
|
50
|
+
let jsonDocument;
|
|
51
|
+
if (jsonCleanups.length > 0 && typeof output === "string" && output.trim() !== "") {
|
|
52
|
+
jsonDocument = parseJsonDocument(output, conflicts);
|
|
53
|
+
}
|
|
54
|
+
for (const cleanup of previous.cleanup) {
|
|
55
|
+
if (cleanup.kind === "replace") {
|
|
56
|
+
if (output === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
if (sha256(output) !== previous.hash && !force) {
|
|
59
|
+
conflicts.push("Generated file was edited outside Canonfig.");
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
output = undefined;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (output instanceof Uint8Array) {
|
|
66
|
+
conflicts.push("Cannot merge text cleanup into a binary file.");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (cleanup.kind === "json-managed-map"
|
|
70
|
+
|| cleanup.kind === "json-managed-array"
|
|
71
|
+
|| cleanup.kind === "json-managed-hooks") {
|
|
72
|
+
if (jsonDocument !== undefined) {
|
|
73
|
+
restoreJsonCleanup(jsonDocument, cleanup, force, conflicts);
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const text = output ?? "";
|
|
78
|
+
if (cleanup.kind === "managed-text") {
|
|
79
|
+
output = removeManagedText(text, cleanup, force, conflicts);
|
|
80
|
+
}
|
|
81
|
+
else if (cleanup.kind === "toml-block") {
|
|
82
|
+
output = removeTomlBlock(text, cleanup, force, conflicts);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
output = removeTomlKey(text, cleanup, conflicts);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (jsonDocument !== undefined && typeof output === "string")
|
|
89
|
+
output = serializeJsonDocument(jsonDocument);
|
|
90
|
+
return output;
|
|
91
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { containsMarker, deepEqual, getAtPath, identityOf, isRecord, parseJsonDocument, serializeJsonDocument, setAtPath, } from "./render-utils.js";
|
|
2
|
+
export function restoreJsonCleanup(document, cleanup, force, conflicts) {
|
|
3
|
+
if (cleanup.kind === "json-managed-map") {
|
|
4
|
+
for (const [key, expected] of Object.entries(cleanup.entries)) {
|
|
5
|
+
const currentMap = getAtPath(document, cleanup.path);
|
|
6
|
+
const current = isRecord(currentMap) ? currentMap[key] : undefined;
|
|
7
|
+
if (!deepEqual(current, expected) && !force) {
|
|
8
|
+
conflicts.push(`Managed JSON entry ${[...cleanup.path, key].join(".")} was edited outside Canonfig.`);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const original = cleanup.originals[key];
|
|
12
|
+
setAtPath(document, [...cleanup.path, key], original?.existed ? original.value : undefined);
|
|
13
|
+
}
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (cleanup.kind === "json-managed-array") {
|
|
17
|
+
const current = getAtPath(document, cleanup.path);
|
|
18
|
+
if (!Array.isArray(current))
|
|
19
|
+
return;
|
|
20
|
+
const remaining = [...current];
|
|
21
|
+
for (const expected of cleanup.values) {
|
|
22
|
+
const expectedIdentity = identityOf(expected, cleanup.identity);
|
|
23
|
+
const index = remaining.findIndex((candidate) => cleanup.identity === undefined
|
|
24
|
+
? deepEqual(candidate, expected)
|
|
25
|
+
: deepEqual(identityOf(candidate, cleanup.identity), expectedIdentity));
|
|
26
|
+
if (index >= 0)
|
|
27
|
+
remaining.splice(index, 1);
|
|
28
|
+
}
|
|
29
|
+
setAtPath(document, cleanup.path, remaining);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const current = getAtPath(document, cleanup.path);
|
|
33
|
+
if (!isRecord(current))
|
|
34
|
+
return;
|
|
35
|
+
const next = {};
|
|
36
|
+
const managedEvents = cleanup.events === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: new Set(cleanup.events);
|
|
39
|
+
for (const [event, entries] of Object.entries(current)) {
|
|
40
|
+
if (managedEvents !== undefined && !managedEvents.has(event)) {
|
|
41
|
+
next[event] = entries;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const filtered = Array.isArray(entries)
|
|
45
|
+
? entries.filter((entry) => !containsMarker(entry, cleanup.marker))
|
|
46
|
+
: entries;
|
|
47
|
+
const original = cleanup.originals?.[event];
|
|
48
|
+
if (original?.existed === true && !Array.isArray(filtered))
|
|
49
|
+
next[event] = original.value;
|
|
50
|
+
else if (Array.isArray(filtered) && filtered.length > 0)
|
|
51
|
+
next[event] = filtered;
|
|
52
|
+
else if (original?.existed === true)
|
|
53
|
+
next[event] = original.value;
|
|
54
|
+
}
|
|
55
|
+
if (cleanup.pathExisted === false && Object.keys(next).length === 0) {
|
|
56
|
+
setAtPath(document, cleanup.path, undefined);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
setAtPath(document, cleanup.path, next);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function applyJsonArtifact(input, artifact, force, conflicts) {
|
|
63
|
+
const document = parseJsonDocument(input.trim() === "" ? "{}" : input, conflicts);
|
|
64
|
+
const cleanup = [];
|
|
65
|
+
const appliedRootDefaults = {};
|
|
66
|
+
const rootDefaultOriginals = {};
|
|
67
|
+
for (const [key, value] of Object.entries(artifact.rootDefaults ?? {})) {
|
|
68
|
+
if (document[key] === undefined) {
|
|
69
|
+
rootDefaultOriginals[key] = { existed: false };
|
|
70
|
+
document[key] = value;
|
|
71
|
+
appliedRootDefaults[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(appliedRootDefaults).length > 0) {
|
|
75
|
+
cleanup.push({
|
|
76
|
+
kind: "json-managed-map",
|
|
77
|
+
path: [],
|
|
78
|
+
entries: appliedRootDefaults,
|
|
79
|
+
originals: rootDefaultOriginals,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
for (const operation of artifact.operations) {
|
|
83
|
+
if (operation.kind === "defaults") {
|
|
84
|
+
for (const entry of operation.entries) {
|
|
85
|
+
if (getAtPath(document, entry.path) === undefined) {
|
|
86
|
+
setAtPath(document, entry.path, entry.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (operation.kind === "managed-map") {
|
|
92
|
+
const map = getAtPath(document, operation.path);
|
|
93
|
+
if (map !== undefined && !isRecord(map) && !force) {
|
|
94
|
+
conflicts.push(`JSON path ${operation.path.join(".")} is not an object and is not owned by Canonfig.`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const object = isRecord(map) ? map : {};
|
|
98
|
+
const originals = {};
|
|
99
|
+
const applied = {};
|
|
100
|
+
for (const [key, value] of Object.entries(operation.entries)) {
|
|
101
|
+
const existed = Object.prototype.hasOwnProperty.call(object, key);
|
|
102
|
+
const current = object[key];
|
|
103
|
+
originals[key] = existed
|
|
104
|
+
? { existed: true, value: current }
|
|
105
|
+
: { existed: false };
|
|
106
|
+
if (existed
|
|
107
|
+
&& !deepEqual(current, value)
|
|
108
|
+
&& operation.collision !== "replace"
|
|
109
|
+
&& !force) {
|
|
110
|
+
conflicts.push(`JSON entry ${[...operation.path, key].join(".")} already exists and is not owned by Canonfig.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
setAtPath(document, [...operation.path, key], value);
|
|
114
|
+
applied[key] = value;
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(applied).length > 0) {
|
|
117
|
+
cleanup.push({
|
|
118
|
+
kind: "json-managed-map",
|
|
119
|
+
path: [...operation.path],
|
|
120
|
+
entries: applied,
|
|
121
|
+
originals,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (operation.kind === "managed-array") {
|
|
127
|
+
const found = getAtPath(document, operation.path);
|
|
128
|
+
if (found !== undefined && !Array.isArray(found) && !force) {
|
|
129
|
+
conflicts.push(`JSON path ${operation.path.join(".")} is not an array and is not owned by Canonfig.`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const values = Array.isArray(found) ? [...found] : [];
|
|
133
|
+
const added = [];
|
|
134
|
+
for (const value of operation.values) {
|
|
135
|
+
const identity = identityOf(value, operation.identity);
|
|
136
|
+
const existingIndex = values.findIndex((candidate) => operation.identity === undefined
|
|
137
|
+
? deepEqual(candidate, value)
|
|
138
|
+
: deepEqual(identityOf(candidate, operation.identity), identity));
|
|
139
|
+
if (existingIndex < 0) {
|
|
140
|
+
values.push(value);
|
|
141
|
+
added.push(value);
|
|
142
|
+
}
|
|
143
|
+
else if (operation.identity !== undefined
|
|
144
|
+
&& !deepEqual(values[existingIndex], value)) {
|
|
145
|
+
if (!force) {
|
|
146
|
+
conflicts.push(`Array entry ${operation.path.join(".")} with ${operation.identity}=${String(identity)} already exists.`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
values[existingIndex] = value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
setAtPath(document, operation.path, values);
|
|
154
|
+
if (added.length > 0) {
|
|
155
|
+
cleanup.push({
|
|
156
|
+
kind: "json-managed-array",
|
|
157
|
+
path: [...operation.path],
|
|
158
|
+
values: added,
|
|
159
|
+
...(operation.identity === undefined ? {} : { identity: operation.identity }),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const existingValue = getAtPath(document, operation.path);
|
|
165
|
+
if (existingValue !== undefined && !isRecord(existingValue) && !force) {
|
|
166
|
+
conflicts.push(`JSON path ${operation.path.join(".")} is not an object and is not owned by Canonfig.`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const existing = isRecord(existingValue) ? existingValue : {};
|
|
170
|
+
const next = {};
|
|
171
|
+
for (const [event, entries] of Object.entries(existing)) {
|
|
172
|
+
next[event] = Array.isArray(entries)
|
|
173
|
+
? entries.filter((entry) => !containsMarker(entry, operation.marker))
|
|
174
|
+
: entries;
|
|
175
|
+
}
|
|
176
|
+
const originals = {};
|
|
177
|
+
for (const [event, entries] of Object.entries(operation.hooks)) {
|
|
178
|
+
originals[event] = Object.prototype.hasOwnProperty.call(existing, event)
|
|
179
|
+
? { existed: true, value: existing[event] }
|
|
180
|
+
: { existed: false };
|
|
181
|
+
const current = Array.isArray(next[event]) ? next[event] : [];
|
|
182
|
+
next[event] = [...current, ...entries];
|
|
183
|
+
}
|
|
184
|
+
setAtPath(document, operation.path, next);
|
|
185
|
+
cleanup.push({
|
|
186
|
+
kind: "json-managed-hooks",
|
|
187
|
+
path: [...operation.path],
|
|
188
|
+
marker: operation.marker,
|
|
189
|
+
events: Object.keys(operation.hooks),
|
|
190
|
+
originals,
|
|
191
|
+
pathExisted: existingValue !== undefined,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return { text: serializeJsonDocument(document), cleanup };
|
|
195
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { sha256 } from "./hash.js";
|
|
2
|
+
import { commentMarkers, findTomlSection, locateBlock, tomlBlockMarkers, } from "./render-utils.js";
|
|
3
|
+
export function appendManagedText(text, artifact, force, conflicts) {
|
|
4
|
+
const markers = commentMarkers(artifact.marker, artifact.comments);
|
|
5
|
+
const existing = locateBlock(text, markers.begin, markers.end);
|
|
6
|
+
if (existing !== undefined) {
|
|
7
|
+
if (!force) {
|
|
8
|
+
conflicts.push(`An unmanaged block already uses marker ${artifact.marker}.`);
|
|
9
|
+
}
|
|
10
|
+
text = `${text.slice(0, existing.start)}${text.slice(existing.end)}`;
|
|
11
|
+
}
|
|
12
|
+
const block = `${markers.begin}\n${artifact.content.trim()}\n${markers.end}\n`;
|
|
13
|
+
const separator = text.trim() === ""
|
|
14
|
+
? ""
|
|
15
|
+
: text.endsWith("\n\n")
|
|
16
|
+
? ""
|
|
17
|
+
: text.endsWith("\n")
|
|
18
|
+
? "\n"
|
|
19
|
+
: "\n\n";
|
|
20
|
+
return {
|
|
21
|
+
text: artifact.placement === "start"
|
|
22
|
+
? `${block}${separator}${text}`
|
|
23
|
+
: `${text}${separator}${block}`,
|
|
24
|
+
cleanup: {
|
|
25
|
+
kind: "managed-text",
|
|
26
|
+
marker: artifact.marker,
|
|
27
|
+
comments: artifact.comments,
|
|
28
|
+
blockHash: sha256(block),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function parseTomlLiteralLine(line, key) {
|
|
33
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
34
|
+
const match = line.match(new RegExp(`^\\s*${escaped}\\s*=\\s*(.*?)\\s*(?:#.*)?$`, "u"));
|
|
35
|
+
return match?.[1]?.trim();
|
|
36
|
+
}
|
|
37
|
+
function applyTomlEnsureKey(input, ensure, force, conflicts) {
|
|
38
|
+
const lines = input.replace(/\r\n/gu, "\n").split("\n");
|
|
39
|
+
let section = findTomlSection(lines, ensure.section);
|
|
40
|
+
if (section === undefined) {
|
|
41
|
+
if (lines.length > 0 && lines.at(-1) !== "")
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push(`[${ensure.section}]`);
|
|
44
|
+
section = { header: lines.length - 1, end: lines.length };
|
|
45
|
+
}
|
|
46
|
+
const escaped = ensure.key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
47
|
+
const keyPattern = new RegExp(`^\\s*${escaped}\\s*=`, "u");
|
|
48
|
+
let keyIndex = -1;
|
|
49
|
+
for (let index = section.header + 1; index < section.end; index += 1) {
|
|
50
|
+
if (keyPattern.test(lines[index] ?? "")) {
|
|
51
|
+
keyIndex = index;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const marker = `# canonfig:key ${ensure.marker}`;
|
|
56
|
+
const desiredLine = `${ensure.key} = ${ensure.value} ${marker}`;
|
|
57
|
+
if (keyIndex >= 0) {
|
|
58
|
+
const currentLine = lines[keyIndex] ?? "";
|
|
59
|
+
if (parseTomlLiteralLine(currentLine, ensure.key) === ensure.value) {
|
|
60
|
+
return { text: lines.join("\n") };
|
|
61
|
+
}
|
|
62
|
+
if (ensure.collision !== "replace" && !force) {
|
|
63
|
+
conflicts.push(`TOML key ${ensure.section}.${ensure.key} already exists and differs.`);
|
|
64
|
+
return { text: lines.join("\n") };
|
|
65
|
+
}
|
|
66
|
+
lines[keyIndex] = desiredLine;
|
|
67
|
+
return {
|
|
68
|
+
text: lines.join("\n"),
|
|
69
|
+
cleanup: {
|
|
70
|
+
kind: "toml-key",
|
|
71
|
+
section: ensure.section,
|
|
72
|
+
key: ensure.key,
|
|
73
|
+
marker: ensure.marker,
|
|
74
|
+
originalLine: currentLine,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
lines.splice(section.end, 0, desiredLine);
|
|
79
|
+
return {
|
|
80
|
+
text: lines.join("\n"),
|
|
81
|
+
cleanup: {
|
|
82
|
+
kind: "toml-key",
|
|
83
|
+
section: ensure.section,
|
|
84
|
+
key: ensure.key,
|
|
85
|
+
marker: ensure.marker,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function applyTomlArtifact(input, artifact, force, conflicts) {
|
|
90
|
+
let text = input.replace(/\r\n/gu, "\n");
|
|
91
|
+
const cleanup = [];
|
|
92
|
+
for (const ensure of artifact.ensureKeys ?? []) {
|
|
93
|
+
const result = applyTomlEnsureKey(text, ensure, force, conflicts);
|
|
94
|
+
text = result.text;
|
|
95
|
+
if (result.cleanup !== undefined)
|
|
96
|
+
cleanup.push(result.cleanup);
|
|
97
|
+
}
|
|
98
|
+
for (const managed of artifact.blocks ?? []) {
|
|
99
|
+
const markers = tomlBlockMarkers(managed.marker);
|
|
100
|
+
const existing = locateBlock(text, markers.begin, markers.end);
|
|
101
|
+
if (existing !== undefined) {
|
|
102
|
+
if (!force) {
|
|
103
|
+
conflicts.push(`An unmanaged TOML block already uses marker ${managed.marker}.`);
|
|
104
|
+
}
|
|
105
|
+
text = `${text.slice(0, existing.start)}${text.slice(existing.end)}`;
|
|
106
|
+
}
|
|
107
|
+
const sections = [...managed.content.matchAll(/^\s*\[([^\]]+)\]\s*$/gmu)]
|
|
108
|
+
.map((match) => match[1])
|
|
109
|
+
.filter((section) => section !== undefined);
|
|
110
|
+
for (const section of sections) {
|
|
111
|
+
if (findTomlSection(text.split("\n"), section) !== undefined) {
|
|
112
|
+
conflicts.push(`TOML section [${section}] already exists outside Canonfig's managed block.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const block = `${markers.begin}\n${managed.content.trim()}\n${markers.end}\n`;
|
|
116
|
+
const separator = text.trim() === ""
|
|
117
|
+
? ""
|
|
118
|
+
: text.endsWith("\n\n")
|
|
119
|
+
? ""
|
|
120
|
+
: text.endsWith("\n")
|
|
121
|
+
? "\n"
|
|
122
|
+
: "\n\n";
|
|
123
|
+
text = `${text}${separator}${block}`;
|
|
124
|
+
cleanup.push({
|
|
125
|
+
kind: "toml-block",
|
|
126
|
+
marker: managed.marker,
|
|
127
|
+
blockHash: sha256(block),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
text: text.endsWith("\n") ? text : `${text}\n`,
|
|
132
|
+
cleanup,
|
|
133
|
+
};
|
|
134
|
+
}
|