changeledger 0.4.0 → 0.6.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/src/contract.mjs CHANGED
@@ -1,29 +1,49 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { writeFileAtomic } from './atomic-write.mjs';
4
- import { agentsTemplate } from './paths.mjs';
5
5
 
6
- // The tool's contract (templates/AGENTS.md) is a tool artifact, not a project
7
- // artifact. Each repo links to the installed copy via `.changeledger/AGENTS.md` (a
8
- // per-machine, gitignored symlink) and points its own contract files at that
9
- // link. We never copy the contract (it would drift) nor commit the symlink (it
10
- // would dangle on another machine). See change #20260614-151759.
11
-
12
- // Project-owned contract files that should reference the linked contract. The
13
- // root AGENTS.md is required by `init`; CLAUDE.md is referenced when present.
14
6
  const CONTRACT_FILES = ['AGENTS.md', 'CLAUDE.md'];
15
-
16
7
  const MARKER = '<!-- changeledger -->';
17
- const REFERENCE = `${MARKER}
8
+ const LEGACY_ENTRY = '.changeledger/AGENTS.md';
9
+ // Exact SHA-256 digests of every historical templates/AGENTS.md payload. A
10
+ // regular file is removed only when it is byte-identical to a contract version
11
+ // ChangeLedger actually shipped; headings or other heuristics are not enough.
12
+ const LEGACY_CONTRACT_HASHES = new Set([
13
+ '92a6d22c9985dce2d15724d20e2d5c0f1178d82dc56d28e339b72c7a27574c6f',
14
+ '4701b260d0cc39098f3b942ed2b1969f00a84e7b77a4e107070fedef0fda7d0d',
15
+ '30b3ce1768655ebee2be2b88328ba860293d8bbbe55e12a5aacc8177d7d02c9b',
16
+ 'd36d17f99505f2b47a7ceea8f078618f652807463d5d60a83ca95f940d5a9c35',
17
+ '1175cc4bcc884d792cdde2338b3c72b336bcb66b47555d6e5938d756acdea635',
18
+ 'f605adad9192286bc2cda45aebb5a12dab5b3c199924789398684b79b55811',
19
+ '05e06c8e9dc45ac41ff38b47075b22492c30f3f1a7019371293be10351aada77',
20
+ '9816e4d14f70dad268f9b89ad49094665997bf62f0b773a05fe8eed17eebc9de',
21
+ '1b5439055e52a1bb72f72b99cbd2842b2118776fb80e262175d584a335e07e5f',
22
+ '6fe7a03f699ffa875c1ef4bc2d59b451058d6ab4c3c8824cc220ae031d068480',
23
+ 'd56ec9ef5c1bd71464d9d9bc2a772c42cce103f336ebba61106855c8011bca6f',
24
+ '071ed83e79589ec0362e4e99ee80cb4f7a839f2a810cf59dbf056c46e342a09c',
25
+ '1148a1b69ce035a695c64f0df1aaaedcbeeed669b5b774c52f7b136e9f5fe4f9',
26
+ '25d4508b0b60aa939e81d1e83383f90e7a41feacb6cb2f12fab6548cebb77ca1',
27
+ '37e1ffe059c0d26b07566597607c41d42df3e9548830d42413dd8d2afd62aa86',
28
+ '60519091c785977299725e4f2f3207b83828bfc56d94a989adf13bd0fc9bb174',
29
+ '6e201bb60337c9b0869c6c769272c82abc466330f82c662bb023a74ad0b8fa92',
30
+ '80e2560e01cfae87455177b54f77845b657764338d3bf3507119e3e1ff289bcd',
31
+ '7a126595a0b5de0b58eec9db38ee6d1654caf0b42df29d181692c382abe213c6',
32
+ '2e839be3ad9458feebcfd8ca979f9173b1f63b514939903ea8f9f03517528f88',
33
+ 'f10407e809fc679d11e442d57c92a4b82d0647f06946bb42c7daccedf76873ec',
34
+ '99a42380f3835e1f706b986b86e58e33f12d68de4a69d94eb50845e790e05f71',
35
+ ]);
36
+
37
+ export const REFERENCE = `${MARKER}
18
38
  > [!IMPORTANT]
19
- > This repo uses **ChangeLedger**. Read and follow \`.changeledger/AGENTS.md\` (the change
20
- > contract). If it is missing, run \`changeledger register\`.
39
+ > This repo uses **ChangeLedger**. Before creating or modifying files, run
40
+ > \`changeledger context\` (or \`changeledger context <change-id>\`) and follow its output.
41
+ > If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
21
42
  `;
22
43
 
23
44
  export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
24
45
  export const rootContract = (repoRoot) => path.join(repoRoot, 'AGENTS.md');
25
46
 
26
- // A real, non-symlink file we may safely append to.
27
47
  function isPlainFile(file) {
28
48
  try {
29
49
  return fs.lstatSync(file).isFile();
@@ -32,69 +52,85 @@ function isPlainFile(file) {
32
52
  }
33
53
  }
34
54
 
35
- // Create (or refresh) the `.changeledger/AGENTS.md` symlink to the installed contract.
36
- // Idempotent and tolerant of a pre-existing/dangling link.
37
- export function linkContract(changeledgerDir) {
38
- const link = contractLink(changeledgerDir);
39
- try {
40
- fs.lstatSync(link);
41
- fs.unlinkSync(link);
42
- } catch {
43
- // no existing link nothing to remove
44
- }
45
- try {
46
- fs.symlinkSync(agentsTemplate, link);
47
- } catch {
48
- // Windows without Developer Mode/admin cannot create symlinks. Fall back to
49
- // a copy so the contract is still present; `changeledger register` refreshes it if the
50
- // installed contract changes.
51
- writeFileAtomic(link, fs.readFileSync(agentsTemplate, 'utf8'));
52
- }
53
- return link;
55
+ function replaceReference(text) {
56
+ const start = text.indexOf(MARKER);
57
+ if (start === -1) return `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`;
58
+ const tail = text.slice(start).split('\n');
59
+ let consumed = 1;
60
+ while (consumed < tail.length && tail[consumed].startsWith('>')) consumed += 1;
61
+ const before = text.slice(0, start);
62
+ const after = tail.slice(consumed).join('\n').replace(/^\n+/, '');
63
+ return `${before}${REFERENCE}${after ? `\n${after}` : ''}`;
54
64
  }
55
65
 
56
- // Append the reference block to each present, non-symlink contract file unless
57
- // already there. Symlinks are skipped (appending would write into their target).
66
+ // Add or replace the managed bootstrap block in project-owned agent files.
58
67
  export function ensureReference(repoRoot) {
59
68
  const touched = [];
60
69
  for (const name of CONTRACT_FILES) {
61
70
  const file = path.join(repoRoot, name);
62
71
  if (!isPlainFile(file)) continue;
63
72
  const text = fs.readFileSync(file, 'utf8');
64
- if (text.includes(MARKER)) continue;
65
- writeFileAtomic(file, `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`);
73
+ const updated = replaceReference(text);
74
+ if (updated === text) continue;
75
+ writeFileAtomic(file, updated);
66
76
  touched.push(name);
67
77
  }
68
78
  return touched;
69
79
  }
70
80
 
71
- // Ensure `.changeledger/AGENTS.md` is gitignored (it is a per-machine artifact).
72
- export function ensureGitignore(repoRoot) {
81
+ // Remove only artifacts known to be managed by legacy ChangeLedger versions.
82
+ // Unknown regular files fail closed instead of being deleted.
83
+ export function removeLegacyContract(changeledgerDir, knownHashes = LEGACY_CONTRACT_HASHES) {
84
+ const file = contractLink(changeledgerDir);
85
+ let stat;
86
+ try {
87
+ stat = fs.lstatSync(file);
88
+ } catch (error) {
89
+ if (error.code === 'ENOENT') return false;
90
+ throw error;
91
+ }
92
+ if (stat.isSymbolicLink()) {
93
+ fs.unlinkSync(file);
94
+ return true;
95
+ }
96
+ if (stat.isFile()) {
97
+ const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
98
+ if (knownHashes.has(digest)) {
99
+ fs.unlinkSync(file);
100
+ return true;
101
+ }
102
+ }
103
+ throw new Error(
104
+ '`.changeledger/AGENTS.md` is not a recognized legacy ChangeLedger contract; move or remove it manually, then run `changeledger register` again',
105
+ );
106
+ }
107
+
108
+ export function removeLegacyGitignore(repoRoot) {
73
109
  const file = path.join(repoRoot, '.gitignore');
74
- const entry = '.changeledger/AGENTS.md';
75
- const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
76
- if (text.split('\n').some((l) => l.trim() === entry)) return false;
77
- writeFileAtomic(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${entry}\n`);
110
+ if (!fs.existsSync(file)) return false;
111
+ const text = fs.readFileSync(file, 'utf8');
112
+ const lines = text.split('\n');
113
+ const kept = lines.filter((line) => line !== LEGACY_ENTRY);
114
+ if (kept.length === lines.length) return false;
115
+ writeFileAtomic(file, kept.join('\n'));
78
116
  return true;
79
117
  }
80
118
 
81
- // IO-level discovery validation (repo-wide). Returns a list of error messages.
82
- export function checkContract(repoRoot, changeledgerDir) {
119
+ export function checkContract(repoRoot) {
83
120
  const errors = [];
84
121
  const root = rootContract(repoRoot);
85
122
  if (!fs.existsSync(root)) {
86
123
  errors.push('missing AGENTS.md at the repo root (ChangeLedger contract reference lives here)');
87
124
  }
88
- // Every present, non-symlink contract file must carry the reference.
89
125
  for (const name of CONTRACT_FILES) {
90
126
  const file = path.join(repoRoot, name);
91
127
  if (!isPlainFile(file)) continue;
92
- if (!fs.readFileSync(file, 'utf8').includes(MARKER)) {
128
+ const text = fs.readFileSync(file, 'utf8');
129
+ if (!text.includes(MARKER)) {
93
130
  errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
131
+ } else if (!text.includes(REFERENCE.trim())) {
132
+ errors.push(`${name} has an outdated ChangeLedger reference — run \`changeledger register\``);
94
133
  }
95
134
  }
96
- if (!fs.existsSync(contractLink(changeledgerDir))) {
97
- errors.push('`.changeledger/AGENTS.md` is missing or dangling — run `changeledger register`');
98
- }
99
135
  return errors;
100
136
  }
package/src/paths.mjs CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
3
3
 
4
4
  export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
5
5
  export const templatesDir = path.join(packageRoot, 'templates');
6
- export const agentsTemplate = path.join(templatesDir, 'AGENTS.md');
6
+ export const contractTemplatesDir = path.join(templatesDir, 'contract');
7
7
  export const publicDir = path.join(packageRoot, 'src', 'viewer', 'public');
8
8
 
9
9
  // ISO 8601 UTC at second precision, matching the change `created`/task convention.
@@ -1,10 +1,16 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { parseDocument } from 'yaml';
4
5
  import { mutateFileAtomic } from '../atomic-write.mjs';
5
6
  import { checkRepo } from '../check.mjs';
6
7
  import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
7
8
  import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
9
+ import {
10
+ buildMigration,
11
+ getSchemaVersion,
12
+ SUPPORTED_SCHEMA_VERSION,
13
+ } from '../config-migration.mjs';
8
14
  import { computeMetrics } from '../metrics.mjs';
9
15
  import { nowUtc } from '../paths.mjs';
10
16
  import { listProjects, remove, update } from '../registry.mjs';
@@ -209,12 +215,21 @@ export function saveProjectConfig(projects, payload, { mutateConfig = mutateFile
209
215
  if (revision(before) !== payload.revision) {
210
216
  throw new Error('configuration changed on disk; reload before saving');
211
217
  }
218
+ const currentSchema = getSchemaVersion(parseYaml(before));
219
+ if (currentSchema > SUPPORTED_SCHEMA_VERSION) {
220
+ throw new Error(
221
+ `config schema ${currentSchema} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
222
+ );
223
+ }
212
224
  return payload.content;
213
225
  });
214
226
  } catch (error) {
215
227
  if (error.message === 'configuration changed on disk; reload before saving') {
216
228
  return { code: 409, body: { error: error.message } };
217
229
  }
230
+ if (/^config schema \d+ is newer than supported schema \d+$/.test(error.message)) {
231
+ return { code: 400, body: { error: error.message } };
232
+ }
218
233
  return { code: 400, body: { error: 'unable to save project configuration' } };
219
234
  }
220
235
  return {
@@ -264,3 +279,268 @@ export function unregisterProject(projects, payload, { localOnly = false } = {})
264
279
  }
265
280
  return { code: 200, body: { ok: true } };
266
281
  }
282
+
283
+ // Returns config content + schema metadata without mutating anything.
284
+ export function readProjectConfigStructured(projects, id) {
285
+ const found = projectFor(projects, id);
286
+ if (!found.project) return found;
287
+ const file = path.join(found.project.path, '.changeledger', 'config.yml');
288
+ const content = fs.readFileSync(file, 'utf8');
289
+ const config = parseYaml(content);
290
+ const schemaVersion = getSchemaVersion(config);
291
+ return {
292
+ code: 200,
293
+ body: {
294
+ content,
295
+ revision: revision(content),
296
+ schemaVersion,
297
+ supported: SUPPORTED_SCHEMA_VERSION,
298
+ config,
299
+ },
300
+ };
301
+ }
302
+
303
+ // Applies a semantic patch (allowlisted fields only) to the YAML AST, preserving
304
+ // comments, unknown keys and fields the form does not represent.
305
+ export function patchProjectConfig(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
306
+ const found = projectFor(projects, payload.project);
307
+ if (!found.project) return found;
308
+ if (!payload.patch || typeof payload.patch !== 'object' || Array.isArray(payload.patch)) {
309
+ return { code: 400, body: { error: 'patch must be an object' } };
310
+ }
311
+ if (typeof payload.revision !== 'string') {
312
+ return { code: 400, body: { error: 'revision is required' } };
313
+ }
314
+ // Explicitly reject attempts to change identity fields via patch.
315
+ if ('project_id' in payload.patch) {
316
+ return { code: 400, body: { error: 'project_id cannot be changed from the viewer' } };
317
+ }
318
+ if ('schema_version' in payload.patch) {
319
+ return { code: 400, body: { error: 'schema_version cannot be changed via patch' } };
320
+ }
321
+
322
+ const file = path.join(found.project.path, '.changeledger', 'config.yml');
323
+
324
+ let result;
325
+ try {
326
+ mutateConfig(file, (before) => {
327
+ if (revision(before) !== payload.revision) {
328
+ throw new Error('configuration changed on disk; reload before saving');
329
+ }
330
+
331
+ const doc = parseDocument(before, { merge: false });
332
+ const config = doc.toJS() ?? {};
333
+
334
+ // Fail closed for future schema
335
+ const schemaVersion = getSchemaVersion(config);
336
+ if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
337
+ throw new Error(
338
+ `config schema ${schemaVersion} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
339
+ );
340
+ }
341
+
342
+ applyPatch(doc, payload.patch, config);
343
+
344
+ const patched = doc.toString();
345
+ const candidate = parseYaml(patched);
346
+
347
+ // Identity guard
348
+ if (String(candidate.project_id ?? '') !== String(found.project.id)) {
349
+ throw new Error('project_id cannot be changed from the viewer');
350
+ }
351
+
352
+ // Structural validation
353
+ const repo = loadRepo(found.project.path);
354
+ resolveRepoPath(repo.repoRoot, candidate.changes_dir, 'changes_dir');
355
+ resolveSpecsDir(repo.repoRoot, candidate);
356
+ const candidateRepo = loadRepoWithConfig(repo.repoRoot, repo.changeledgerDir, candidate);
357
+ const { errors } = checkRepo(candidateRepo);
358
+ if (errors.length) throw new Error(errors[0].message);
359
+
360
+ result = { content: patched, rev: revision(patched) };
361
+ return patched;
362
+ });
363
+ } catch (error) {
364
+ if (error.message === 'configuration changed on disk; reload before saving') {
365
+ return { code: 409, body: { error: error.message } };
366
+ }
367
+ return { code: 400, body: { error: error.message } };
368
+ }
369
+
370
+ return { code: 200, body: { ok: true, revision: result.rev } };
371
+ }
372
+
373
+ // Preview the migration without writing. Returns summary + candidate YAML.
374
+ export function previewConfigMigration(projects, id, rev) {
375
+ const found = projectFor(projects, id);
376
+ if (!found.project) return found;
377
+ const file = path.join(found.project.path, '.changeledger', 'config.yml');
378
+ const content = fs.readFileSync(file, 'utf8');
379
+ if (rev && revision(content) !== rev) {
380
+ return { code: 409, body: { error: 'configuration changed on disk; reload before saving' } };
381
+ }
382
+ let migrationResult;
383
+ try {
384
+ migrationResult = buildMigration(content);
385
+ } catch (e) {
386
+ return { code: 400, body: { error: e.message } };
387
+ }
388
+ if (!migrationResult) {
389
+ return {
390
+ code: 200,
391
+ body: {
392
+ already_current: true,
393
+ message: `Config is already at schema ${SUPPORTED_SCHEMA_VERSION}`,
394
+ },
395
+ };
396
+ }
397
+ return {
398
+ code: 200,
399
+ body: {
400
+ summary: `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
401
+ changes: migrationResult.changes,
402
+ yaml: migrationResult.yaml,
403
+ },
404
+ };
405
+ }
406
+
407
+ // Apply the migration atomically. Uses the same engine as `changeledger config migrate`.
408
+ // Revision check and write are inside mutateFileAtomic to avoid TOCTOU races.
409
+ export function applyConfigMigration(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
410
+ const found = projectFor(projects, payload.project);
411
+ if (!found.project) return found;
412
+ if (typeof payload.revision !== 'string') {
413
+ return { code: 400, body: { error: 'revision is required' } };
414
+ }
415
+ const file = path.join(found.project.path, '.changeledger', 'config.yml');
416
+
417
+ let result;
418
+ try {
419
+ mutateConfig(file, (before) => {
420
+ if (revision(before) !== payload.revision) {
421
+ throw new Error('configuration changed on disk; reload before saving');
422
+ }
423
+ let migrationResult;
424
+ try {
425
+ migrationResult = buildMigration(before);
426
+ } catch (e) {
427
+ throw new Error(e.message);
428
+ }
429
+ if (!migrationResult) {
430
+ result = { already_current: true, rev: payload.revision };
431
+ return undefined; // no write needed
432
+ }
433
+ result = { ok: true, rev: revision(migrationResult.yaml) };
434
+ return migrationResult.yaml;
435
+ });
436
+ } catch (error) {
437
+ if (error.message === 'configuration changed on disk; reload before saving') {
438
+ return { code: 409, body: { error: error.message } };
439
+ }
440
+ return { code: 400, body: { error: error.message } };
441
+ }
442
+
443
+ if (result.already_current) {
444
+ return { code: 200, body: { already_current: true, revision: result.rev } };
445
+ }
446
+ return { code: 200, body: { ok: true, revision: result.rev } };
447
+ }
448
+
449
+ // Allowlisted fields the form patch may update.
450
+ const PATCH_ALLOWED = new Set([
451
+ 'project_name',
452
+ 'language',
453
+ 'tdd',
454
+ 'changes_dir',
455
+ 'specs_dir',
456
+ 'statuses',
457
+ 'stages',
458
+ 'readiness',
459
+ 'types',
460
+ 'release',
461
+ ]);
462
+
463
+ const CANONICAL_STATUSES_REQUIRED = new Set([
464
+ 'draft',
465
+ 'approved',
466
+ 'in-progress',
467
+ 'in-review',
468
+ 'in-validation',
469
+ 'blocked',
470
+ 'done',
471
+ 'discarded',
472
+ ]);
473
+
474
+ const CANONICAL_STAGES_REQUIRED = new Set([
475
+ 'request',
476
+ 'investigation',
477
+ 'proposal',
478
+ 'specification',
479
+ 'plan',
480
+ 'log',
481
+ ]);
482
+
483
+ function applyPatch(doc, patch, currentConfig) {
484
+ for (const [key, value] of Object.entries(patch)) {
485
+ if (!PATCH_ALLOWED.has(key)) continue;
486
+
487
+ if (key === 'types') {
488
+ applyTypesPatch(doc, value, currentConfig.types ?? {});
489
+ } else if (key === 'release') {
490
+ applyReleasePatch(doc, value);
491
+ } else if (key === 'readiness') {
492
+ applyReadinessPatch(doc, value);
493
+ } else if (key === 'statuses') {
494
+ applyRequiredListPatch(doc, 'statuses', value, CANONICAL_STATUSES_REQUIRED);
495
+ } else if (key === 'stages') {
496
+ applyRequiredListPatch(doc, 'stages', value, CANONICAL_STAGES_REQUIRED);
497
+ } else {
498
+ doc.set(key, value);
499
+ }
500
+ }
501
+ }
502
+
503
+ function applyRequiredListPatch(doc, key, proposed, required) {
504
+ if (!Array.isArray(proposed) || proposed.some((value) => typeof value !== 'string')) {
505
+ throw new Error(`${key} must be a list of strings`);
506
+ }
507
+ const duplicate = proposed.find((value, index) => proposed.indexOf(value) !== index);
508
+ if (duplicate) throw new Error(`${key} contains duplicate value "${duplicate}"`);
509
+ const missing = [...required].find((value) => !proposed.includes(value));
510
+ if (missing) throw new Error(`${key} cannot remove required value "${missing}"`);
511
+ doc.set(key, proposed);
512
+ }
513
+
514
+ function applyTypesPatch(doc, typesPatch, currentTypes) {
515
+ for (const [typeName, typeDef] of Object.entries(typesPatch)) {
516
+ if (!Object.hasOwn(currentTypes, typeName)) continue; // don't add new types
517
+ if (!typeDef || typeof typeDef !== 'object') continue;
518
+ if (Array.isArray(typeDef.stages)) {
519
+ doc.setIn(['types', typeName, 'stages'], typeDef.stages);
520
+ }
521
+ if (typeof typeDef.review_required === 'boolean') {
522
+ doc.setIn(['types', typeName, 'review_required'], typeDef.review_required);
523
+ } else if (typeDef.review_required === null) {
524
+ doc.deleteIn(['types', typeName, 'review_required']);
525
+ }
526
+ }
527
+ }
528
+
529
+ function applyReleasePatch(doc, releasePatch) {
530
+ if (releasePatch.impacts && typeof releasePatch.impacts === 'object') {
531
+ for (const [type, impact] of Object.entries(releasePatch.impacts)) {
532
+ if (impact === null) doc.deleteIn(['release', 'impacts', type]);
533
+ else doc.setIn(['release', 'impacts', type], impact);
534
+ }
535
+ }
536
+ }
537
+
538
+ function applyReadinessPatch(doc, readinessPatch) {
539
+ if (!readinessPatch || typeof readinessPatch !== 'object') return;
540
+ if (Array.isArray(readinessPatch.target_patterns)) {
541
+ doc.setIn(['readiness', 'target_patterns'], readinessPatch.target_patterns);
542
+ }
543
+ if (Array.isArray(readinessPatch.verification_patterns)) {
544
+ doc.setIn(['readiness', 'verification_patterns'], readinessPatch.verification_patterns);
545
+ }
546
+ }
@@ -41,9 +41,37 @@ const postProject = (route, body) =>
41
41
  body: JSON.stringify(body),
42
42
  });
43
43
 
44
+ const jsonOrThrow = async (response) => {
45
+ const body = await response.json();
46
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
47
+ return body;
48
+ };
49
+
44
50
  export const postProjectConfig = (project, content, revision) =>
45
51
  postProject('/api/project-config', { project, content, revision });
46
52
 
53
+ export const getProjectConfigStructured = (project) =>
54
+ fetch(`/api/project-config-structured?project=${encodeURIComponent(project)}`).then(async (r) => {
55
+ const body = await r.json();
56
+ if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
57
+ return body;
58
+ });
59
+
60
+ export const patchProjectConfigApi = (project, patch, revision) =>
61
+ postProject('/api/project-config-patch', { project, patch, revision });
62
+
63
+ export const getConfigMigrationPreview = (project, revision) =>
64
+ fetch(
65
+ `/api/project-config-migrate-preview?project=${encodeURIComponent(project)}&revision=${encodeURIComponent(revision ?? '')}`,
66
+ ).then(async (r) => {
67
+ const body = await r.json();
68
+ if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
69
+ return body;
70
+ });
71
+
72
+ export const postConfigMigrationApply = (project, revision) =>
73
+ postProject('/api/project-config-migrate-apply', { project, revision }).then(jsonOrThrow);
74
+
47
75
  export const postProjectPath = (project, path) =>
48
76
  postProject('/api/project-path', { project, path });
49
77