cohorte 3.0.0-dev.0 → 3.0.0-dev.2
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/assets/manifest.json +4 -4
- package/assets/schemas/reconcile-plan.schema.json +1 -2
- package/dist/agent-host.mjs +2142 -5
- package/dist/agent-host.mjs.map +1 -1
- package/dist/bundle-manifest.json +38 -36
- package/dist/chunks/{auth-D8rCVY33.mjs → auth-ybswxUPK.mjs} +54 -2
- package/dist/chunks/auth-ybswxUPK.mjs.map +1 -0
- package/dist/chunks/{brainstorm-CU10T1tm.mjs → brainstorm-BwKA6yZP.mjs} +2 -2
- package/dist/chunks/{brainstorm-CU10T1tm.mjs.map → brainstorm-BwKA6yZP.mjs.map} +1 -1
- package/dist/chunks/{catalogue-BvwSrM_j.mjs → catalogue-B5uFqfzJ.mjs} +2 -2
- package/dist/chunks/{catalogue-BvwSrM_j.mjs.map → catalogue-B5uFqfzJ.mjs.map} +1 -1
- package/dist/chunks/{compose-Cr0hP0qm.mjs → compose-C0rEh5wB.mjs} +3354 -3343
- package/dist/chunks/compose-C0rEh5wB.mjs.map +1 -0
- package/dist/chunks/{contract-B0ra2-bm.mjs → contract-BkMjU1kR.mjs} +2 -2
- package/dist/chunks/{contract-B0ra2-bm.mjs.map → contract-BkMjU1kR.mjs.map} +1 -1
- package/dist/chunks/{contract-vYbwYr0Y.mjs → contract-bQ9FJl4N.mjs} +2 -2
- package/dist/chunks/contract-bQ9FJl4N.mjs.map +1 -0
- package/dist/chunks/{diff-Qw0II0sj.mjs → diff-D_0hBwHl.mjs} +2 -2
- package/dist/chunks/{diff-Qw0II0sj.mjs.map → diff-D_0hBwHl.mjs.map} +1 -1
- package/dist/chunks/{discover-30Ciya_0.mjs → discover-eS01-me1.mjs} +3 -2
- package/dist/chunks/{discover-30Ciya_0.mjs.map → discover-eS01-me1.mjs.map} +1 -1
- package/dist/chunks/{doctor-DfCrKpfT.mjs → doctor-CginW8y8.mjs} +5 -5
- package/dist/chunks/{doctor-DfCrKpfT.mjs.map → doctor-CginW8y8.mjs.map} +1 -1
- package/dist/chunks/init-BUnzIkeE.mjs +63 -0
- package/dist/chunks/init-BUnzIkeE.mjs.map +1 -0
- package/dist/chunks/migrate-DybihKzZ.mjs +33 -0
- package/dist/chunks/migrate-DybihKzZ.mjs.map +1 -0
- package/dist/chunks/{policy-DUIgzF3W.mjs → policy-Clypo20s.mjs} +3 -3
- package/dist/chunks/{policy-DUIgzF3W.mjs.map → policy-Clypo20s.mjs.map} +1 -1
- package/dist/chunks/reconcile-Bhy3IIYo.mjs +50 -0
- package/dist/chunks/reconcile-Bhy3IIYo.mjs.map +1 -0
- package/dist/chunks/scan-8Gy_B6kI.mjs +122 -0
- package/dist/chunks/scan-8Gy_B6kI.mjs.map +1 -0
- package/dist/chunks/src-CmFjh4M0.mjs +928 -0
- package/dist/chunks/src-CmFjh4M0.mjs.map +1 -0
- package/dist/chunks/{src-CfAOde7C.mjs → src-DZazJCp1.mjs} +2 -2
- package/dist/chunks/{src-CfAOde7C.mjs.map → src-DZazJCp1.mjs.map} +1 -1
- package/dist/chunks/{src-CId6kMsY.mjs → src-r0ECB1UJ.mjs} +2 -2
- package/dist/chunks/{src-CId6kMsY.mjs.map → src-r0ECB1UJ.mjs.map} +1 -1
- package/dist/cli.mjs +12 -12
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/chunks/auth-D8rCVY33.mjs.map +0 -1
- package/dist/chunks/compose-Cr0hP0qm.mjs.map +0 -1
- package/dist/chunks/contract-vYbwYr0Y.mjs.map +0 -1
- package/dist/chunks/init-Okz4mlhW.mjs +0 -30
- package/dist/chunks/init-Okz4mlhW.mjs.map +0 -1
- package/dist/chunks/migrate-DRoFUg53.mjs +0 -19
- package/dist/chunks/migrate-DRoFUg53.mjs.map +0 -1
- package/dist/chunks/reconcile-BfD785gd.mjs +0 -26
- package/dist/chunks/reconcile-BfD785gd.mjs.map +0 -1
- package/dist/chunks/src-JxawBQBn.mjs +0 -446
- package/dist/chunks/src-JxawBQBn.mjs.map +0 -1
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import { n as sha256Hex, t as canonicalJson } from "./canonical-C8UxeEmB.mjs";
|
|
2
|
+
import "./src-Cu3TxNob.mjs";
|
|
3
|
+
import "./contract-bQ9FJl4N.mjs";
|
|
4
|
+
import { r as specContentSha256, s as DEFAULT_CONFIG } from "./schema-C9QbUW4H.mjs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { appendFile, copyFile, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { parse, stringify } from "yaml";
|
|
9
|
+
//#region ../../packages/project-model/src/desired/index.ts
|
|
10
|
+
function deriveDesiredState(input) {
|
|
11
|
+
const modelYaml = stringify(input.model);
|
|
12
|
+
const generatedArtifacts = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const path of input.model.generatedArtifacts.value) {
|
|
14
|
+
const relative = path.replace(/^\.cohorte\//u, "");
|
|
15
|
+
if (relative === "manifest.yaml" || relative === "project.yaml") continue;
|
|
16
|
+
generatedArtifacts.set(relative, canonicalJson({
|
|
17
|
+
path: relative,
|
|
18
|
+
model: input.model,
|
|
19
|
+
config: input.config,
|
|
20
|
+
skills: input.skills
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
const manifest = {
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
cohorteVersion: input.cohorteVersion,
|
|
26
|
+
createdWith: input.cohorteVersion,
|
|
27
|
+
protocol: {
|
|
28
|
+
min: "3.0",
|
|
29
|
+
max: "3.x"
|
|
30
|
+
},
|
|
31
|
+
stateSchemaVersion: 1,
|
|
32
|
+
generated: [
|
|
33
|
+
{
|
|
34
|
+
path: "project.yaml",
|
|
35
|
+
templateId: "project-model/v1",
|
|
36
|
+
templateSha256: sha256Hex(modelYaml),
|
|
37
|
+
renderedSha256: sha256Hex(modelYaml)
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: ".gitignore",
|
|
41
|
+
templateId: "cohorte/gitignore/v1",
|
|
42
|
+
templateSha256: sha256Hex("state/\nruns/\n"),
|
|
43
|
+
renderedSha256: sha256Hex("state/\nruns/\n")
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
path: "generated/.gitkeep",
|
|
47
|
+
templateId: "cohorte/generated-dir/v1",
|
|
48
|
+
templateSha256: sha256Hex(""),
|
|
49
|
+
renderedSha256: sha256Hex("")
|
|
50
|
+
},
|
|
51
|
+
...[...generatedArtifacts.keys()].map((path) => ({
|
|
52
|
+
path,
|
|
53
|
+
templateId: path,
|
|
54
|
+
templateSha256: sha256Hex(generatedArtifacts.get(path)),
|
|
55
|
+
renderedSha256: sha256Hex(generatedArtifacts.get(path))
|
|
56
|
+
}))
|
|
57
|
+
]
|
|
58
|
+
};
|
|
59
|
+
const builtin = {
|
|
60
|
+
"manifest.yaml": stringify(manifest),
|
|
61
|
+
"project.yaml": modelYaml,
|
|
62
|
+
"config.yaml": "schemaVersion: 1\n",
|
|
63
|
+
"ownership.yaml": "surfaces: {}\n",
|
|
64
|
+
".gitignore": "state/\nruns/\n",
|
|
65
|
+
"generated/.gitkeep": ""
|
|
66
|
+
};
|
|
67
|
+
const stateFiles = [.../* @__PURE__ */ new Set([...Object.keys(builtin), ...generatedArtifacts.keys()])].sort().map((path) => ({
|
|
68
|
+
path,
|
|
69
|
+
class: path === "config.yaml" || path === "ownership.yaml" ? "human" : "generated",
|
|
70
|
+
sha256: sha256Hex(builtin[path] ?? generatedArtifacts.get(path) ?? ""),
|
|
71
|
+
content: builtin[path] ?? generatedArtifacts.get(path) ?? "",
|
|
72
|
+
...input.skills[path] === void 0 ? {} : { templateId: path }
|
|
73
|
+
}));
|
|
74
|
+
return {
|
|
75
|
+
cohorteVersion: input.cohorteVersion,
|
|
76
|
+
files: stateFiles
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region ../../packages/project-model/src/drift/index.ts
|
|
81
|
+
async function readActualState(root) {
|
|
82
|
+
const stateRoot = join(root, ".cohorte");
|
|
83
|
+
let manifest = null;
|
|
84
|
+
try {
|
|
85
|
+
manifest = parse(await readFile(join(stateRoot, "manifest.yaml"), "utf8"));
|
|
86
|
+
} catch {
|
|
87
|
+
return {
|
|
88
|
+
manifest: null,
|
|
89
|
+
files: []
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const files = [];
|
|
93
|
+
const visit = async (dir) => {
|
|
94
|
+
try {
|
|
95
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
96
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
97
|
+
if (entry.name === "state" || entry.name === "runs") continue;
|
|
98
|
+
const path = join(dir, entry.name);
|
|
99
|
+
if (entry.isDirectory()) await visit(path);
|
|
100
|
+
else if (entry.isFile()) {
|
|
101
|
+
const bytes = await readFile(path);
|
|
102
|
+
const relativePath = relative(stateRoot, path).split("\\").join("/");
|
|
103
|
+
const generated = relativePath === "manifest.yaml" ? {
|
|
104
|
+
path: relativePath,
|
|
105
|
+
templateId: "cohorte/manifest/v1",
|
|
106
|
+
renderedSha256: sha256Hex(bytes)
|
|
107
|
+
} : manifest?.generated.find((item) => item.path === relativePath);
|
|
108
|
+
files.push({
|
|
109
|
+
path: relativePath,
|
|
110
|
+
class: generated ? "generated" : "human",
|
|
111
|
+
sha256: sha256Hex(bytes),
|
|
112
|
+
...generated?.templateId ? { templateId: generated.templateId } : {}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await visit(stateRoot);
|
|
121
|
+
return {
|
|
122
|
+
manifest,
|
|
123
|
+
files
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function diffStates(desired, actual, clock) {
|
|
127
|
+
const desiredByPath = new Map(desired.files.map((file) => [file.path, file]));
|
|
128
|
+
const actualByPath = new Map(actual.files.map((file) => [file.path, file]));
|
|
129
|
+
const recorded = new Map((actual.manifest?.generated ?? []).map((file) => [file.path, file.renderedSha256]));
|
|
130
|
+
const entries = [.../* @__PURE__ */ new Set([...desiredByPath.keys(), ...actualByPath.keys()])].sort().flatMap((path) => {
|
|
131
|
+
const expected = desiredByPath.get(path);
|
|
132
|
+
const found = actualByPath.get(path);
|
|
133
|
+
const recordedSha256 = recorded.get(path);
|
|
134
|
+
if (!found && expected) return [{
|
|
135
|
+
target: path,
|
|
136
|
+
class: expected.class,
|
|
137
|
+
diff: "absent",
|
|
138
|
+
desiredSha256: expected.sha256,
|
|
139
|
+
...recordedSha256 ? { recordedSha256 } : {},
|
|
140
|
+
detail: "desired file is absent"
|
|
141
|
+
}];
|
|
142
|
+
if (found && !expected) return [{
|
|
143
|
+
target: path,
|
|
144
|
+
class: found.class,
|
|
145
|
+
diff: found.class === "generated" ? "potential-deletion" : "unknown",
|
|
146
|
+
actualSha256: found.sha256,
|
|
147
|
+
...recordedSha256 ? { recordedSha256 } : {},
|
|
148
|
+
detail: "file exists but is not desired"
|
|
149
|
+
}];
|
|
150
|
+
if (!found || !expected) throw new Error("unreachable drift entry");
|
|
151
|
+
if (found.sha256 === expected.sha256) return [];
|
|
152
|
+
if (found.class === "human") return [{
|
|
153
|
+
target: path,
|
|
154
|
+
class: found.class,
|
|
155
|
+
diff: "human-change",
|
|
156
|
+
desiredSha256: expected.sha256,
|
|
157
|
+
actualSha256: found.sha256,
|
|
158
|
+
...recordedSha256 ? { recordedSha256 } : {},
|
|
159
|
+
detail: "content changed in a human-owned file"
|
|
160
|
+
}];
|
|
161
|
+
if (recordedSha256 === void 0 || found.sha256 === recordedSha256) return [{
|
|
162
|
+
target: path,
|
|
163
|
+
class: expected.class,
|
|
164
|
+
diff: "expected-change",
|
|
165
|
+
desiredSha256: expected.sha256,
|
|
166
|
+
actualSha256: found.sha256,
|
|
167
|
+
...recordedSha256 ? { recordedSha256 } : {},
|
|
168
|
+
detail: "generated content differs from the previous render"
|
|
169
|
+
}];
|
|
170
|
+
return [{
|
|
171
|
+
target: path,
|
|
172
|
+
class: found.class,
|
|
173
|
+
diff: "conflict",
|
|
174
|
+
desiredSha256: expected.sha256,
|
|
175
|
+
actualSha256: found.sha256,
|
|
176
|
+
recordedSha256,
|
|
177
|
+
detail: "content changed since the recorded render"
|
|
178
|
+
}];
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
generatedAt: clock.now(),
|
|
183
|
+
entries
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region ../../packages/project-model/src/import-v2/index.ts
|
|
188
|
+
const FORMAT = "cohorte-v2-export";
|
|
189
|
+
const MAX_FILE_BYTES = 10485760;
|
|
190
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
191
|
+
".git",
|
|
192
|
+
"node_modules",
|
|
193
|
+
".build",
|
|
194
|
+
"dist",
|
|
195
|
+
"coverage",
|
|
196
|
+
".cohorte"
|
|
197
|
+
]);
|
|
198
|
+
const SECRET = /(^|[/\\])(?:\.env(?:\..*)?|credentials?(?:\..*)?|secrets?(?:\..*)?|.*token.*|.*password.*|.*api[-_]?key.*)$/iu;
|
|
199
|
+
function normalisePath(value) {
|
|
200
|
+
const path = value.replaceAll("\\", "/");
|
|
201
|
+
if (!path || path.startsWith("/") || path.split("/").some((part) => part === ".." || part === "")) throw new Error(`configuration/import-invalid: unsafe relative path ${JSON.stringify(value)}`);
|
|
202
|
+
return path;
|
|
203
|
+
}
|
|
204
|
+
async function walk(root, current = root) {
|
|
205
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
206
|
+
const result = [];
|
|
207
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
208
|
+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
|
|
209
|
+
const absolute = join(current, entry.name);
|
|
210
|
+
const rel = relative(root, absolute).split(sep).join("/");
|
|
211
|
+
if (entry.isSymbolicLink()) throw new Error(`configuration/import-symlink: refusing symbolic link ${rel}`);
|
|
212
|
+
if (entry.isDirectory()) result.push(...await walk(root, absolute));
|
|
213
|
+
else if (entry.isFile()) result.push(rel);
|
|
214
|
+
}
|
|
215
|
+
return result.sort();
|
|
216
|
+
}
|
|
217
|
+
function fileKind(path) {
|
|
218
|
+
if (path === "PIPELINE.md" || path === "cohorte.config.yaml" || path.startsWith("profile/")) return "config";
|
|
219
|
+
if (path.startsWith("specs/")) return path.startsWith("specs/reports/") ? "history" : "spec";
|
|
220
|
+
return "marker";
|
|
221
|
+
}
|
|
222
|
+
function warning(code, message, path, blocking = false) {
|
|
223
|
+
return {
|
|
224
|
+
code,
|
|
225
|
+
message,
|
|
226
|
+
...path === void 0 ? {} : { path },
|
|
227
|
+
blocking
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
async function digestFiles(root, files) {
|
|
231
|
+
const hash = createHash("sha256");
|
|
232
|
+
for (const path of files) {
|
|
233
|
+
hash.update(path);
|
|
234
|
+
hash.update(await readFile(join(root, path)));
|
|
235
|
+
}
|
|
236
|
+
return hash.digest("hex");
|
|
237
|
+
}
|
|
238
|
+
function sourceVersion(pipeline) {
|
|
239
|
+
return (pipeline?.match(/(?:version|core_version)\s*[:=]\s*["']?([0-9]+\.[0-9]+\.[0-9]+)/iu))?.[1] ?? null;
|
|
240
|
+
}
|
|
241
|
+
async function exportV2(options) {
|
|
242
|
+
const root = resolve(options.root);
|
|
243
|
+
const destination = resolve(options.destination);
|
|
244
|
+
const all = await walk(root);
|
|
245
|
+
const files = [];
|
|
246
|
+
const excluded = [];
|
|
247
|
+
const warnings = [];
|
|
248
|
+
for (const path of all) {
|
|
249
|
+
if (SECRET.test(path)) {
|
|
250
|
+
excluded.push({
|
|
251
|
+
path,
|
|
252
|
+
reason: "sensitive-path"
|
|
253
|
+
});
|
|
254
|
+
warnings.push(warning("sensitive-excluded", "sensitive file excluded from export", path));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if ((await stat(join(root, path))).size > MAX_FILE_BYTES) {
|
|
258
|
+
excluded.push({
|
|
259
|
+
path,
|
|
260
|
+
reason: "file-too-large"
|
|
261
|
+
});
|
|
262
|
+
warnings.push(warning("file-too-large", `file exceeds ${MAX_FILE_BYTES} bytes`, path, true));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const content = await readFile(join(root, path));
|
|
266
|
+
files.push({
|
|
267
|
+
path,
|
|
268
|
+
sha256: sha256Hex(content),
|
|
269
|
+
bytes: content.byteLength,
|
|
270
|
+
kind: fileKind(path)
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const selected = files.map((file) => file.path);
|
|
274
|
+
const pipeline = all.includes("PIPELINE.md") ? await readFile(join(root, "PIPELINE.md"), "utf8") : void 0;
|
|
275
|
+
const manifest = {
|
|
276
|
+
format: FORMAT,
|
|
277
|
+
version: 1,
|
|
278
|
+
sourceVersion: sourceVersion(pipeline),
|
|
279
|
+
createdAt: options.now ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
280
|
+
projectRootDigest: await digestFiles(root, selected),
|
|
281
|
+
files,
|
|
282
|
+
excluded,
|
|
283
|
+
warnings
|
|
284
|
+
};
|
|
285
|
+
await rm(destination, {
|
|
286
|
+
recursive: true,
|
|
287
|
+
force: true
|
|
288
|
+
});
|
|
289
|
+
await mkdir(join(destination, "files"), {
|
|
290
|
+
recursive: true,
|
|
291
|
+
mode: 448
|
|
292
|
+
});
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
const target = join(destination, "files", file.path);
|
|
295
|
+
await mkdir(dirname(target), {
|
|
296
|
+
recursive: true,
|
|
297
|
+
mode: 448
|
|
298
|
+
});
|
|
299
|
+
await writeFile(target, await readFile(join(root, file.path)), { mode: 384 });
|
|
300
|
+
}
|
|
301
|
+
await writeFile(join(destination, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 384 });
|
|
302
|
+
const checksums = files.map((file) => `${file.sha256} files/${file.path}`).concat(`${sha256Hex(canonicalJson(manifest))} manifest.json`).join("\n");
|
|
303
|
+
const checksumsPath = join(destination, "checksums.sha256");
|
|
304
|
+
await writeFile(checksumsPath, `${checksums}\n`, { mode: 384 });
|
|
305
|
+
return {
|
|
306
|
+
bundleRoot: destination,
|
|
307
|
+
manifest,
|
|
308
|
+
checksumsPath
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async function readBundle(bundleRoot) {
|
|
312
|
+
const root = resolve(bundleRoot);
|
|
313
|
+
const manifestPath = join(root, "manifest.json");
|
|
314
|
+
const manifestInfo = await lstat(manifestPath);
|
|
315
|
+
if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink()) throw new Error("security/import-invalid: manifest must be a regular file");
|
|
316
|
+
const manifestText = await readFile(manifestPath, "utf8");
|
|
317
|
+
const manifest = JSON.parse(manifestText);
|
|
318
|
+
if (manifest.format !== FORMAT || manifest.version !== 1) throw new Error("configuration/import-invalid: unsupported V2 export format");
|
|
319
|
+
const files = /* @__PURE__ */ new Map();
|
|
320
|
+
const expectedChecksums = /* @__PURE__ */ new Map();
|
|
321
|
+
const checksums = await readFile(join(root, "checksums.sha256"), "utf8");
|
|
322
|
+
for (const line of checksums.split("\n").map((item) => item.trim()).filter(Boolean)) {
|
|
323
|
+
const match = line.match(/^([0-9a-f]{64}) {2}(.+)$/u);
|
|
324
|
+
if (!match?.[1] || !match[2]) throw new Error("security/import-invalid: malformed checksums file");
|
|
325
|
+
expectedChecksums.set(match[2], match[1]);
|
|
326
|
+
}
|
|
327
|
+
if (expectedChecksums.get("manifest.json") !== sha256Hex(canonicalJson(manifest))) throw new Error("security/import-checksum-mismatch: manifest.json");
|
|
328
|
+
for (const entry of manifest.files) {
|
|
329
|
+
const path = normalisePath(entry.path);
|
|
330
|
+
if (files.has(path)) throw new Error(`configuration/import-invalid: duplicate path ${path}`);
|
|
331
|
+
const filePath = join(root, "files", path);
|
|
332
|
+
const info = await lstat(filePath);
|
|
333
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`security/import-invalid: symlink or non-file ${path}`);
|
|
334
|
+
const content = await readFile(filePath, "utf8");
|
|
335
|
+
if (sha256Hex(content) !== entry.sha256) throw new Error(`security/import-checksum-mismatch: ${path}`);
|
|
336
|
+
if (expectedChecksums.get(`files/${path}`) !== entry.sha256) throw new Error(`security/import-checksum-mismatch: files/${path}`);
|
|
337
|
+
files.set(path, content);
|
|
338
|
+
}
|
|
339
|
+
const sourceDigest = await digestFiles(join(root, "files"), [...files.keys()]);
|
|
340
|
+
if (sourceDigest !== manifest.projectRootDigest) throw new Error("security/import-checksum-mismatch: project root digest does not match manifest");
|
|
341
|
+
return {
|
|
342
|
+
manifest,
|
|
343
|
+
files,
|
|
344
|
+
sourceDigest
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function pipelineBlock(input) {
|
|
348
|
+
const match = input?.match(/```ya?ml\s+pipeline-profile\s*\n([\s\S]*?)\n```/iu);
|
|
349
|
+
if (!match?.[1]) return {};
|
|
350
|
+
try {
|
|
351
|
+
const parsed = parse(match[1]);
|
|
352
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
353
|
+
} catch {
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function rule(id, program, decision) {
|
|
358
|
+
return {
|
|
359
|
+
id,
|
|
360
|
+
program,
|
|
361
|
+
decision,
|
|
362
|
+
replay: "idempotent",
|
|
363
|
+
network: false,
|
|
364
|
+
origin: "project-config"
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function mapConfig(input, pipeline, model) {
|
|
368
|
+
const warnings = [];
|
|
369
|
+
let document = {};
|
|
370
|
+
if (input !== void 0) try {
|
|
371
|
+
const parsed = parse(input);
|
|
372
|
+
if (parsed && typeof parsed === "object") document = parsed;
|
|
373
|
+
} catch {
|
|
374
|
+
warnings.push(warning("config-invalid-yaml", "V2 configuration is not valid YAML", "cohorte.config.yaml", true));
|
|
375
|
+
}
|
|
376
|
+
const profile = pipelineBlock(pipeline);
|
|
377
|
+
if (profile.name === void 0) warnings.push(warning("config-project-id-missing", "project id needs human confirmation"));
|
|
378
|
+
if (document.kanban !== void 0) warnings.push(warning("kanban-not-imported", "Kanban links remain external and are not synchronized"));
|
|
379
|
+
const id = typeof profile.name === "string" ? profile.name : "imported-project";
|
|
380
|
+
const vcs = profile.vcs ?? {};
|
|
381
|
+
const commands = profile.commands ?? {};
|
|
382
|
+
const gate = profile.gate ?? {};
|
|
383
|
+
const defaultBranch = typeof vcs.default_branch === "string" ? vcs.default_branch : DEFAULT_CONFIG.project.defaultBranch;
|
|
384
|
+
const branchPrefix = typeof vcs.feature_branch_prefix === "string" ? vcs.feature_branch_prefix : DEFAULT_CONFIG.git.branchPrefix;
|
|
385
|
+
const migrated = structuredClone(DEFAULT_CONFIG);
|
|
386
|
+
migrated.project = {
|
|
387
|
+
...migrated.project,
|
|
388
|
+
id,
|
|
389
|
+
defaultBranch,
|
|
390
|
+
protectedBranches: [defaultBranch]
|
|
391
|
+
};
|
|
392
|
+
migrated.git = {
|
|
393
|
+
...migrated.git,
|
|
394
|
+
branchPrefix
|
|
395
|
+
};
|
|
396
|
+
migrated.checks = {
|
|
397
|
+
...migrated.checks,
|
|
398
|
+
...typeof commands.test === "string" ? { test: commands.test.split(/\s+/u) } : {},
|
|
399
|
+
...typeof commands.lint === "string" ? { lint: commands.lint.split(/\s+/u) } : {},
|
|
400
|
+
...typeof commands.typecheck === "string" ? { typecheck: commands.typecheck.split(/\s+/u) } : {}
|
|
401
|
+
};
|
|
402
|
+
const deny = Array.isArray(gate.deny) ? gate.deny.filter((v) => typeof v === "string") : [];
|
|
403
|
+
const ask = Array.isArray(gate.ask) ? gate.ask.filter((v) => typeof v === "string") : [];
|
|
404
|
+
migrated.policy.commands = {
|
|
405
|
+
allow: [],
|
|
406
|
+
ask: ask.map((value, index) => rule(`v2-ask-${index}`, value.split(/\s+/u)[0] ?? value, "ask")),
|
|
407
|
+
deny: deny.map((value, index) => rule(`v2-deny-${index}`, value.split(/\s+/u)[0] ?? value, "deny"))
|
|
408
|
+
};
|
|
409
|
+
warnings.push(warning("runtime-review-required", "V2 runtime adapters are not migrated; review the current runtime selection"));
|
|
410
|
+
const surfaces = Object.fromEntries(Object.entries(model?.surfaces ?? {}).map(([key, value]) => [key, {
|
|
411
|
+
paths: value.paths.value,
|
|
412
|
+
owners: ["implementer"],
|
|
413
|
+
reviewers: ["reviewer"]
|
|
414
|
+
}]));
|
|
415
|
+
return {
|
|
416
|
+
config: stringify(migrated),
|
|
417
|
+
ownership: stringify({ surfaces }),
|
|
418
|
+
warnings
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function section(markdown, number, fallback) {
|
|
422
|
+
return markdown.match(new RegExp(`^##\\s+${number}\\.?.*?\\n([\\s\\S]*?)(?=^##\\s+|$)`, "imu"))?.[1]?.trim() ?? fallback;
|
|
423
|
+
}
|
|
424
|
+
function bullets(text) {
|
|
425
|
+
return text.split("\n").map((line) => line.match(/^\s*(?:[-*]|\d+[.)])\s+(.*)$/u)?.[1]?.trim()).filter((value) => Boolean(value));
|
|
426
|
+
}
|
|
427
|
+
function importedSpec(path, markdown) {
|
|
428
|
+
const front = markdown.match(/^---\s*\n([\s\S]*?)\n---/u);
|
|
429
|
+
const metadata = front?.[1] ? parse(front[1]) : {};
|
|
430
|
+
const filename = path.split("/").pop()?.replace(/\.md$/iu, "") ?? "imported-spec";
|
|
431
|
+
const id = String(metadata.feature_id ?? filename).toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 48) || "imported-spec";
|
|
432
|
+
const heading = markdown.match(/^#\s+(.+)$/mu)?.[1]?.trim();
|
|
433
|
+
const title = String(metadata.title ?? heading ?? id);
|
|
434
|
+
const rawStatus = String(metadata.status ?? "draft");
|
|
435
|
+
const status = rawStatus === "draft" ? "draft" : "frozen";
|
|
436
|
+
const acceptance = bullets(section(markdown, "9", ""));
|
|
437
|
+
const openQuestions = bullets(section(markdown, "10", ""));
|
|
438
|
+
const surfaces = {};
|
|
439
|
+
const surfaceSection = section(markdown, "6", "");
|
|
440
|
+
for (const match of surfaceSection.matchAll(/^###\s+([^\n]+)\n([\s\S]*?)(?=^###\s+|$)/gmu)) {
|
|
441
|
+
const key = match[1]?.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "shared";
|
|
442
|
+
surfaces[key] = { tasks: bullets(match[2] ?? "") };
|
|
443
|
+
}
|
|
444
|
+
if (Object.keys(surfaces).length === 0) surfaces.shared = { tasks: ["Review imported V2 specification and assign implementation tasks."] };
|
|
445
|
+
const body = {
|
|
446
|
+
id,
|
|
447
|
+
kind: "feature",
|
|
448
|
+
status,
|
|
449
|
+
title,
|
|
450
|
+
acceptance: acceptance.length ? acceptance : [`Review imported V2 specification: ${title}`],
|
|
451
|
+
surfaces,
|
|
452
|
+
openQuestions: [...openQuestions, ...rawStatus === "draft" ? [] : [`Original V2 status: ${rawStatus}`]]
|
|
453
|
+
};
|
|
454
|
+
if (status === "frozen") return {
|
|
455
|
+
id,
|
|
456
|
+
content: stringify({
|
|
457
|
+
...body,
|
|
458
|
+
sha256: specContentSha256(body)
|
|
459
|
+
})
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
id,
|
|
463
|
+
content: stringify(body),
|
|
464
|
+
...rawStatus === "draft" ? {} : { warning: warning("spec-status-normalized", `V2 status ${rawStatus} was normalized to frozen`, path) }
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async function planV2Import(bundleRoot, projectRoot, options = {}) {
|
|
468
|
+
const bundle = await readBundle(bundleRoot);
|
|
469
|
+
const modelSource = bundle.files.get("PIPELINE.md");
|
|
470
|
+
const mapped = mapConfig(bundle.files.get("cohorte.config.yaml"), modelSource, options.model);
|
|
471
|
+
const projectContent = options.model === void 0 ? void 0 : stringify(options.model);
|
|
472
|
+
const generated = projectContent === void 0 ? [] : [{
|
|
473
|
+
path: "project.yaml",
|
|
474
|
+
templateId: "project-model/v1",
|
|
475
|
+
templateSha256: sha256Hex(projectContent),
|
|
476
|
+
renderedSha256: sha256Hex(projectContent)
|
|
477
|
+
}];
|
|
478
|
+
const manifestContent = stringify({
|
|
479
|
+
schemaVersion: 1,
|
|
480
|
+
cohorteVersion: "3.0.0",
|
|
481
|
+
createdWith: "v2-import",
|
|
482
|
+
protocol: {
|
|
483
|
+
min: "3.0",
|
|
484
|
+
max: "3.x"
|
|
485
|
+
},
|
|
486
|
+
stateSchemaVersion: 1,
|
|
487
|
+
generated
|
|
488
|
+
});
|
|
489
|
+
const convertedSpecs = bundle.manifest.files.filter((entry) => entry.kind === "spec").map((entry) => ({
|
|
490
|
+
entry,
|
|
491
|
+
converted: importedSpec(entry.path, bundle.files.get(entry.path) ?? "")
|
|
492
|
+
}));
|
|
493
|
+
const specWarnings = convertedSpecs.flatMap(({ converted }) => converted.warning ? [converted.warning] : []);
|
|
494
|
+
const files = [
|
|
495
|
+
{
|
|
496
|
+
path: ".cohorte/manifest.yaml",
|
|
497
|
+
content: manifestContent,
|
|
498
|
+
action: "create",
|
|
499
|
+
reason: "V3 migration marker"
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
path: ".cohorte/.gitignore",
|
|
503
|
+
content: "state/\nruns/\n",
|
|
504
|
+
action: "create",
|
|
505
|
+
reason: "protect V3 local state"
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
path: ".cohorte/config.yaml",
|
|
509
|
+
content: mapped.config,
|
|
510
|
+
action: "create",
|
|
511
|
+
reason: "mapped V2 configuration"
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
path: ".cohorte/ownership.yaml",
|
|
515
|
+
content: mapped.ownership,
|
|
516
|
+
action: "create",
|
|
517
|
+
reason: "new V3 ownership document"
|
|
518
|
+
},
|
|
519
|
+
...options.model === void 0 ? [] : [{
|
|
520
|
+
path: ".cohorte/project.yaml",
|
|
521
|
+
content: projectContent ?? "",
|
|
522
|
+
action: "create",
|
|
523
|
+
reason: "deterministic V3 project model generated during import"
|
|
524
|
+
}],
|
|
525
|
+
...modelSource === void 0 ? [] : [{
|
|
526
|
+
path: ".cohorte/import-source/PIPELINE.md",
|
|
527
|
+
content: modelSource,
|
|
528
|
+
action: "create",
|
|
529
|
+
reason: "preserve V2 source"
|
|
530
|
+
}],
|
|
531
|
+
...convertedSpecs.map(({ entry, converted }) => ({
|
|
532
|
+
path: `.cohorte/specs/${converted.id}.yaml`,
|
|
533
|
+
content: converted.content,
|
|
534
|
+
action: "create",
|
|
535
|
+
reason: `convert V2 Markdown specification ${entry.path} to native schema`
|
|
536
|
+
})),
|
|
537
|
+
...bundle.manifest.files.filter((entry) => entry.kind === "history").map((entry) => ({
|
|
538
|
+
path: `.cohorte/artifacts/v2-history/${normalisePath(entry.path).replace(/^specs\/reports\//u, "")}`,
|
|
539
|
+
content: bundle.files.get(entry.path) ?? "",
|
|
540
|
+
action: "create",
|
|
541
|
+
reason: "import V2 historical artifact"
|
|
542
|
+
}))
|
|
543
|
+
];
|
|
544
|
+
const currentDigest = await digestProject(projectRoot);
|
|
545
|
+
const conflicts = [];
|
|
546
|
+
for (const file of files) try {
|
|
547
|
+
const currentPath = join(projectRoot, file.path);
|
|
548
|
+
if ((await lstat(currentPath)).isSymbolicLink()) {
|
|
549
|
+
file.action = "conflict";
|
|
550
|
+
file.reason = "target is a symbolic link";
|
|
551
|
+
conflicts.push(file.path);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (await readFile(currentPath, "utf8") !== file.content) {
|
|
555
|
+
file.action = "conflict";
|
|
556
|
+
file.reason = "target exists with different content";
|
|
557
|
+
conflicts.push(file.path);
|
|
558
|
+
} else file.action = "keep";
|
|
559
|
+
} catch {}
|
|
560
|
+
return {
|
|
561
|
+
bundleRoot: resolve(bundleRoot),
|
|
562
|
+
projectRoot: resolve(projectRoot),
|
|
563
|
+
sourceVersion: bundle.manifest.sourceVersion,
|
|
564
|
+
files,
|
|
565
|
+
warnings: [
|
|
566
|
+
...bundle.manifest.warnings,
|
|
567
|
+
...mapped.warnings,
|
|
568
|
+
...specWarnings
|
|
569
|
+
],
|
|
570
|
+
conflicts,
|
|
571
|
+
sourceDigest: bundle.sourceDigest,
|
|
572
|
+
targetDigest: currentDigest
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
async function digestProject(root) {
|
|
576
|
+
return digestFiles(root, (await walk(root)).filter((path) => !path.startsWith(".cohorte/import-preview/")));
|
|
577
|
+
}
|
|
578
|
+
async function applyV2Import(plan, options) {
|
|
579
|
+
if (plan.conflicts.length) throw new Error(`configuration/import-conflict: ${plan.conflicts.join(", ")}`);
|
|
580
|
+
if (plan.warnings.some((item) => item.blocking)) throw new Error("configuration/import-blocked: resolve blocking warnings first");
|
|
581
|
+
if (!options.confirm) throw new Error("configuration/import-confirmation-required: rerun with explicit confirmation");
|
|
582
|
+
if (await digestProject(plan.projectRoot) !== plan.targetDigest) throw new Error("configuration/import-project-changed: project changed since preview");
|
|
583
|
+
const id = sha256Hex(`${plan.sourceDigest}:${plan.targetDigest}:${options.now ?? (/* @__PURE__ */ new Date()).toISOString()}`).slice(0, 16);
|
|
584
|
+
const backupRoot = resolve(options.backupRoot, `cohorte-v2-${id}`);
|
|
585
|
+
const stage = join(backupRoot, ".stage");
|
|
586
|
+
const reportPath = join(plan.projectRoot, ".cohorte", "import-reports", `${id}.json`);
|
|
587
|
+
const existing = [];
|
|
588
|
+
let mutated = false;
|
|
589
|
+
const restore = async () => {
|
|
590
|
+
await rm(stage, {
|
|
591
|
+
recursive: true,
|
|
592
|
+
force: true
|
|
593
|
+
});
|
|
594
|
+
await rm(reportPath, { force: true });
|
|
595
|
+
if (!mutated) return;
|
|
596
|
+
for (const file of plan.files) {
|
|
597
|
+
if (file.action === "keep") continue;
|
|
598
|
+
const target = join(plan.projectRoot, file.path);
|
|
599
|
+
const backup = join(backupRoot, file.path);
|
|
600
|
+
try {
|
|
601
|
+
await stat(backup);
|
|
602
|
+
await mkdir(dirname(target), {
|
|
603
|
+
recursive: true,
|
|
604
|
+
mode: 448
|
|
605
|
+
});
|
|
606
|
+
await cp(backup, target, {
|
|
607
|
+
recursive: true,
|
|
608
|
+
verbatimSymlinks: true
|
|
609
|
+
});
|
|
610
|
+
} catch {
|
|
611
|
+
await rm(target, {
|
|
612
|
+
recursive: true,
|
|
613
|
+
force: true
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
try {
|
|
619
|
+
options.fault?.("before-backup");
|
|
620
|
+
await mkdir(backupRoot, {
|
|
621
|
+
recursive: true,
|
|
622
|
+
mode: 448
|
|
623
|
+
});
|
|
624
|
+
for (const file of plan.files) try {
|
|
625
|
+
await lstat(join(plan.projectRoot, file.path));
|
|
626
|
+
existing.push(file.path);
|
|
627
|
+
await mkdir(dirname(join(backupRoot, file.path)), {
|
|
628
|
+
recursive: true,
|
|
629
|
+
mode: 448
|
|
630
|
+
});
|
|
631
|
+
await cp(join(plan.projectRoot, file.path), join(backupRoot, file.path), {
|
|
632
|
+
recursive: true,
|
|
633
|
+
verbatimSymlinks: true
|
|
634
|
+
});
|
|
635
|
+
} catch {}
|
|
636
|
+
options.fault?.("after-backup");
|
|
637
|
+
await mkdir(stage, {
|
|
638
|
+
recursive: true,
|
|
639
|
+
mode: 448
|
|
640
|
+
});
|
|
641
|
+
for (const file of plan.files) {
|
|
642
|
+
if (file.action === "keep") continue;
|
|
643
|
+
const target = join(stage, file.path);
|
|
644
|
+
await mkdir(dirname(target), {
|
|
645
|
+
recursive: true,
|
|
646
|
+
mode: 448
|
|
647
|
+
});
|
|
648
|
+
await writeFile(target, file.content, { mode: 384 });
|
|
649
|
+
}
|
|
650
|
+
options.fault?.("after-stage");
|
|
651
|
+
options.fault?.("before-rename");
|
|
652
|
+
for (const file of plan.files) {
|
|
653
|
+
if (file.action === "keep") continue;
|
|
654
|
+
const target = join(plan.projectRoot, file.path);
|
|
655
|
+
await mkdir(dirname(target), {
|
|
656
|
+
recursive: true,
|
|
657
|
+
mode: 448
|
|
658
|
+
});
|
|
659
|
+
mutated = true;
|
|
660
|
+
await rename(join(stage, file.path), target);
|
|
661
|
+
}
|
|
662
|
+
options.fault?.("after-rename");
|
|
663
|
+
await rm(stage, {
|
|
664
|
+
recursive: true,
|
|
665
|
+
force: true
|
|
666
|
+
});
|
|
667
|
+
} catch (error) {
|
|
668
|
+
await restore();
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
const report = {
|
|
672
|
+
reportId: id,
|
|
673
|
+
status: "applied",
|
|
674
|
+
bundleRoot: plan.bundleRoot,
|
|
675
|
+
projectRoot: plan.projectRoot,
|
|
676
|
+
backupRoot,
|
|
677
|
+
sourceDigest: plan.sourceDigest,
|
|
678
|
+
targetDigest: plan.targetDigest,
|
|
679
|
+
files: plan.files.filter((file) => file.action !== "keep").map((file) => file.path),
|
|
680
|
+
warnings: plan.warnings,
|
|
681
|
+
createdAt: options.now ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
682
|
+
};
|
|
683
|
+
try {
|
|
684
|
+
options.fault?.("before-report");
|
|
685
|
+
await mkdir(dirname(reportPath), {
|
|
686
|
+
recursive: true,
|
|
687
|
+
mode: 448
|
|
688
|
+
});
|
|
689
|
+
await writeFile(reportPath, `${JSON.stringify({
|
|
690
|
+
...report,
|
|
691
|
+
existing
|
|
692
|
+
}, null, 2)}\n`, { mode: 384 });
|
|
693
|
+
} catch (error) {
|
|
694
|
+
await restore();
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
return report;
|
|
698
|
+
}
|
|
699
|
+
async function rollbackV2Import(report) {
|
|
700
|
+
for (const path of report.files) {
|
|
701
|
+
const backup = join(report.backupRoot, path);
|
|
702
|
+
try {
|
|
703
|
+
await stat(backup);
|
|
704
|
+
await mkdir(dirname(join(report.projectRoot, path)), {
|
|
705
|
+
recursive: true,
|
|
706
|
+
mode: 448
|
|
707
|
+
});
|
|
708
|
+
await cp(backup, join(report.projectRoot, path), {
|
|
709
|
+
recursive: true,
|
|
710
|
+
verbatimSymlinks: true
|
|
711
|
+
});
|
|
712
|
+
} catch {
|
|
713
|
+
await rm(join(report.projectRoot, path), {
|
|
714
|
+
recursive: true,
|
|
715
|
+
force: true
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
...report,
|
|
721
|
+
status: "rolled-back"
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region ../../packages/project-model/src/init/index.ts
|
|
726
|
+
async function planInit(options) {
|
|
727
|
+
if (options.semantic) throw new Error("configuration/phase-not-available: semantic init is not available in V3.0");
|
|
728
|
+
const root = options.root;
|
|
729
|
+
const files = [
|
|
730
|
+
{
|
|
731
|
+
path: "project.yaml",
|
|
732
|
+
class: "generated",
|
|
733
|
+
content: stringify(options.model),
|
|
734
|
+
templateId: "project-model/v1"
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
path: "config.yaml",
|
|
738
|
+
class: "human",
|
|
739
|
+
content: "schemaVersion: 1\n"
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
path: "ownership.yaml",
|
|
743
|
+
class: "human",
|
|
744
|
+
content: "surfaces: {}\n"
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
path: ".gitignore",
|
|
748
|
+
class: "generated",
|
|
749
|
+
content: "state/\nruns/\n",
|
|
750
|
+
templateId: "cohorte/gitignore/v1"
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
path: "generated/.gitkeep",
|
|
754
|
+
class: "generated",
|
|
755
|
+
content: "",
|
|
756
|
+
templateId: "cohorte/generated-dir/v1"
|
|
757
|
+
}
|
|
758
|
+
];
|
|
759
|
+
const initFiles = [];
|
|
760
|
+
for (const file of files) {
|
|
761
|
+
let exists = false;
|
|
762
|
+
try {
|
|
763
|
+
await readFile(join(root, ".cohorte", file.path));
|
|
764
|
+
exists = true;
|
|
765
|
+
} catch {}
|
|
766
|
+
const renderedSha256 = sha256Hex(file.content);
|
|
767
|
+
initFiles.push({
|
|
768
|
+
path: file.path,
|
|
769
|
+
action: exists ? "keep-existing" : "create",
|
|
770
|
+
class: file.class,
|
|
771
|
+
content: file.content,
|
|
772
|
+
...file.templateId === void 0 ? {} : { templateId: file.templateId },
|
|
773
|
+
...file.class === "generated" ? {
|
|
774
|
+
templateSha256: renderedSha256,
|
|
775
|
+
renderedSha256
|
|
776
|
+
} : {}
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const generated = initFiles.filter((file) => file.class === "generated").map((file) => ({
|
|
780
|
+
path: file.path,
|
|
781
|
+
templateId: file.templateId ?? "builtin",
|
|
782
|
+
templateSha256: file.templateSha256 ?? sha256Hex(file.content),
|
|
783
|
+
renderedSha256: file.renderedSha256 ?? sha256Hex(file.content)
|
|
784
|
+
}));
|
|
785
|
+
return {
|
|
786
|
+
projectRoot: root,
|
|
787
|
+
model: options.model,
|
|
788
|
+
files: initFiles,
|
|
789
|
+
manifest: {
|
|
790
|
+
schemaVersion: 1,
|
|
791
|
+
cohorteVersion: options.cohorteVersion,
|
|
792
|
+
createdWith: options.cohorteVersion,
|
|
793
|
+
protocol: {
|
|
794
|
+
min: "3.0",
|
|
795
|
+
max: "3.x"
|
|
796
|
+
},
|
|
797
|
+
stateSchemaVersion: 1,
|
|
798
|
+
generated
|
|
799
|
+
},
|
|
800
|
+
warnings: []
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
async function applyInit(plan) {
|
|
804
|
+
await mkdir(join(plan.projectRoot, ".cohorte"), {
|
|
805
|
+
recursive: true,
|
|
806
|
+
mode: 448
|
|
807
|
+
});
|
|
808
|
+
const written = [];
|
|
809
|
+
const kept = [];
|
|
810
|
+
for (const file of plan.files) {
|
|
811
|
+
const target = join(plan.projectRoot, ".cohorte", file.path);
|
|
812
|
+
if (file.action === "keep-existing") {
|
|
813
|
+
kept.push(file.path);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
await mkdir(join(target, ".."), {
|
|
817
|
+
recursive: true,
|
|
818
|
+
mode: 448
|
|
819
|
+
});
|
|
820
|
+
await writeFile(target, file.content, { mode: 384 });
|
|
821
|
+
written.push(file.path);
|
|
822
|
+
}
|
|
823
|
+
const manifestPath = join(plan.projectRoot, ".cohorte", "manifest.yaml");
|
|
824
|
+
try {
|
|
825
|
+
await readFile(manifestPath);
|
|
826
|
+
kept.push("manifest.yaml");
|
|
827
|
+
} catch {
|
|
828
|
+
await writeFile(manifestPath, stringify(plan.manifest), { mode: 384 });
|
|
829
|
+
written.push("manifest.yaml");
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
written,
|
|
833
|
+
kept
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region ../../packages/project-model/src/reconcile/index.ts
|
|
838
|
+
/** READ-ONLY. */
|
|
839
|
+
async function planReconcile(options) {
|
|
840
|
+
const model = await options.scan(options.root);
|
|
841
|
+
let stableModel = model;
|
|
842
|
+
try {
|
|
843
|
+
const generatedAt = parse(await readFile(join(options.root, ".cohorte", "project.yaml"), "utf8")).provenance?.generatedAt;
|
|
844
|
+
const stableGeneratedAt = generatedAt instanceof Date ? generatedAt.toISOString() : typeof generatedAt === "string" ? generatedAt : void 0;
|
|
845
|
+
if (stableGeneratedAt !== void 0) stableModel = {
|
|
846
|
+
...model,
|
|
847
|
+
provenance: {
|
|
848
|
+
...model.provenance,
|
|
849
|
+
generatedAt: stableGeneratedAt
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
} catch {}
|
|
853
|
+
const drift = diffStates(deriveDesiredState({
|
|
854
|
+
model: stableModel,
|
|
855
|
+
config: DEFAULT_CONFIG,
|
|
856
|
+
cohorteVersion: options.cohorteVersion,
|
|
857
|
+
skills: {}
|
|
858
|
+
}), await readActualState(options.root), options.clock);
|
|
859
|
+
const operations = drift.entries.map((entry) => {
|
|
860
|
+
return {
|
|
861
|
+
op: entry.diff === "absent" ? "create" : entry.diff === "expected-change" ? "replace" : entry.diff === "potential-deletion" ? "delete" : entry.diff === "unknown" ? "keep" : "ask",
|
|
862
|
+
target: entry.target,
|
|
863
|
+
diff: entry.diff,
|
|
864
|
+
reason: entry.detail
|
|
865
|
+
};
|
|
866
|
+
});
|
|
867
|
+
return {
|
|
868
|
+
schemaVersion: 1,
|
|
869
|
+
cohorteVersion: options.cohorteVersion,
|
|
870
|
+
generatedAt: drift.generatedAt,
|
|
871
|
+
drift,
|
|
872
|
+
operations,
|
|
873
|
+
conflicts: drift.entries.filter((entry) => entry.diff === "conflict").map((entry) => entry.target),
|
|
874
|
+
applyAvailable: true
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/** Apply only create/replace/delete operations authorized by a conflict-free plan. */
|
|
878
|
+
async function applyReconcile(options) {
|
|
879
|
+
if (options.plan.conflicts.length > 0) throw new Error(`conflict/reconcile-human-edit: ${options.plan.conflicts.join(", ")}`);
|
|
880
|
+
const desiredByPath = new Map(options.desired.files.map((file) => [file.path, file]));
|
|
881
|
+
const backupDir = options.backup ? join(options.root, ".cohorte", "reconcile-backups", options.clock.now().replaceAll(":", "")) : void 0;
|
|
882
|
+
const journal = join(options.root, ".cohorte", "reconcile.log");
|
|
883
|
+
await mkdir(join(options.root, ".cohorte"), {
|
|
884
|
+
recursive: true,
|
|
885
|
+
mode: 448
|
|
886
|
+
});
|
|
887
|
+
const applied = [];
|
|
888
|
+
const skipped = [];
|
|
889
|
+
for (const operation of options.plan.operations) {
|
|
890
|
+
if (operation.op !== "create" && operation.op !== "replace" && operation.op !== "delete") {
|
|
891
|
+
skipped.push(operation.target);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
const target = join(options.root, ".cohorte", operation.target);
|
|
895
|
+
if (!target.startsWith(`${join(options.root, ".cohorte")}/`)) throw new Error("security/path-outside-grant");
|
|
896
|
+
const existing = await readFile(target).catch(() => void 0);
|
|
897
|
+
const drift = options.plan.drift.entries.find((entry) => entry.target === operation.target);
|
|
898
|
+
if ((existing === void 0 ? void 0 : sha256Hex(existing)) !== drift?.actualSha256) throw new Error(`conflict/reconcile-race: ${operation.target} changed after the plan was generated`);
|
|
899
|
+
if (existing !== void 0 && backupDir) {
|
|
900
|
+
const backupPath = join(backupDir, operation.target);
|
|
901
|
+
await mkdir(dirname(backupPath), { recursive: true });
|
|
902
|
+
await copyFile(target, backupPath);
|
|
903
|
+
}
|
|
904
|
+
if (operation.op === "delete") await unlink(target).catch(() => void 0);
|
|
905
|
+
else {
|
|
906
|
+
const file = desiredByPath.get(operation.target);
|
|
907
|
+
if (file?.content === void 0) throw new Error(`configuration/reconcile-content-missing: ${operation.target}`);
|
|
908
|
+
await mkdir(dirname(target), { recursive: true });
|
|
909
|
+
await writeFile(target, file.content, "utf8");
|
|
910
|
+
}
|
|
911
|
+
applied.push(operation.target);
|
|
912
|
+
await appendFile(journal, `${JSON.stringify({
|
|
913
|
+
at: options.clock.now(),
|
|
914
|
+
op: operation.op,
|
|
915
|
+
target: operation.target
|
|
916
|
+
})}\n`);
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
applied,
|
|
920
|
+
skipped,
|
|
921
|
+
...backupDir ? { backupDir } : {},
|
|
922
|
+
journal
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
export { applyV2Import as a, rollbackV2Import as c, planInit as i, deriveDesiredState as l, planReconcile as n, exportV2 as o, applyInit as r, planV2Import as s, applyReconcile as t };
|
|
927
|
+
|
|
928
|
+
//# sourceMappingURL=src-CmFjh4M0.mjs.map
|