changeledger 0.5.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/README.md +15 -2
- package/bin/changeledger.mjs +29 -2
- package/package.json +1 -1
- package/src/commands/check.mjs +10 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/view.mjs +4 -0
- package/src/config-migration.mjs +213 -0
- package/src/viewer/domain.mjs +280 -0
- package/src/viewer/public/api.js +28 -0
- package/src/viewer/public/app.js +529 -29
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +283 -0
- package/src/viewer/server/router.mjs +40 -14
- package/templates/config.yml +4 -0
- package/templates/contract/core.md +7 -0
- package/templates/contract/release.md +10 -0
package/README.md
CHANGED
|
@@ -139,8 +139,21 @@ changeledger context review # explicit task mode
|
|
|
139
139
|
|
|
140
140
|
`init` places a small fail-closed bootstrap in the project-owned `AGENTS.md`;
|
|
141
141
|
there is no linked or copied contract under `.changeledger/`. Run
|
|
142
|
-
`changeledger register` after upgrading to refresh that bootstrap
|
|
143
|
-
|
|
142
|
+
`changeledger register` after upgrading to refresh that bootstrap.
|
|
143
|
+
|
|
144
|
+
### Upgrading an existing repo's configuration
|
|
145
|
+
|
|
146
|
+
Repos created before ChangeLedger 0.6 may have an older configuration schema.
|
|
147
|
+
Run this to inspect and apply available migrations:
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
changeledger config migrate --dry-run # preview changes without writing
|
|
151
|
+
changeledger config migrate # apply atomically
|
|
152
|
+
changeledger check # confirm the repo is valid
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Migrations are safe to run more than once — if the config is already current,
|
|
156
|
+
the command reports so and exits without modifying any file.
|
|
144
157
|
|
|
145
158
|
## Compatibility and security
|
|
146
159
|
|
package/bin/changeledger.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { Command } from 'commander';
|
|
3
4
|
import {
|
|
4
5
|
archive,
|
|
@@ -20,8 +21,12 @@ import { newChange } from '../src/commands/new.mjs';
|
|
|
20
21
|
import { registerRepo } from '../src/commands/register.mjs';
|
|
21
22
|
import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
|
|
22
23
|
import { view } from '../src/commands/view.mjs';
|
|
24
|
+
import { findChangeledgerDir } from '../src/config.mjs';
|
|
25
|
+
import { applyMigration, SUPPORTED_SCHEMA_VERSION } from '../src/config-migration.mjs';
|
|
23
26
|
import { nowUtc } from '../src/paths.mjs';
|
|
24
27
|
|
|
28
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
29
|
+
|
|
25
30
|
const USAGE = `ChangeLedger (changeledger)
|
|
26
31
|
|
|
27
32
|
changeledger init set up .changeledger/ in the current repo (+ register it)
|
|
@@ -45,6 +50,7 @@ const USAGE = `ChangeLedger (changeledger)
|
|
|
45
50
|
changeledger graduate <change-id> <spec-slug> --into graduate into an existing spec
|
|
46
51
|
changeledger graduate <change-id> --skip [reason] mark graduation reviewed, no spec
|
|
47
52
|
changeledger graduate --pending list done changes not yet reviewed
|
|
53
|
+
changeledger config migrate [--dry-run] migrate .changeledger/config.yml to schema ${SUPPORTED_SCHEMA_VERSION}
|
|
48
54
|
changeledger release init <version> initialize release history at X.Y.Z
|
|
49
55
|
changeledger release plan [--json] calculate the next portable SemVer release
|
|
50
56
|
changeledger release record <version> record the calculated release manifest`;
|
|
@@ -65,6 +71,7 @@ function action(fn) {
|
|
|
65
71
|
program
|
|
66
72
|
.name('changeledger')
|
|
67
73
|
.description('ChangeLedger (changeledger)')
|
|
74
|
+
.version(version, '-v, --version', 'output the installed version (-V also accepted)')
|
|
68
75
|
.helpOption('-h, --help', 'display help for command')
|
|
69
76
|
.addHelpText('after', `\n${USAGE}`);
|
|
70
77
|
|
|
@@ -326,6 +333,24 @@ program
|
|
|
326
333
|
}),
|
|
327
334
|
);
|
|
328
335
|
|
|
336
|
+
const configCommand = program
|
|
337
|
+
.command('config')
|
|
338
|
+
.description('inspect and manage the repo configuration');
|
|
339
|
+
|
|
340
|
+
configCommand
|
|
341
|
+
.command('migrate')
|
|
342
|
+
.description('migrate .changeledger/config.yml to the current schema')
|
|
343
|
+
.option('--dry-run', 'show the migration plan and candidate YAML without writing')
|
|
344
|
+
.action(
|
|
345
|
+
action((options) => {
|
|
346
|
+
const changeledgerDir = findChangeledgerDir();
|
|
347
|
+
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo.');
|
|
348
|
+
const configFile = `${changeledgerDir}/config.yml`;
|
|
349
|
+
const result = applyMigration(configFile, { dryRun: options.dryRun ?? false });
|
|
350
|
+
console.log(result);
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
329
354
|
const releaseCommand = program
|
|
330
355
|
.command('release')
|
|
331
356
|
.description('plan and record portable SemVer releases');
|
|
@@ -376,8 +401,10 @@ releaseCommand
|
|
|
376
401
|
}),
|
|
377
402
|
);
|
|
378
403
|
|
|
379
|
-
|
|
404
|
+
// Normalize -V to --version so both short aliases work identically.
|
|
405
|
+
const argv = process.argv.map((a) => (a === '-V' ? '--version' : a));
|
|
406
|
+
if (argv.length <= 2) {
|
|
380
407
|
console.log(USAGE);
|
|
381
408
|
} else {
|
|
382
|
-
program.parse();
|
|
409
|
+
program.parse(argv);
|
|
383
410
|
}
|
package/package.json
CHANGED
package/src/commands/check.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkRepo } from '../check.mjs';
|
|
2
|
+
import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
|
|
2
3
|
import { checkContract } from '../contract.mjs';
|
|
3
4
|
import { loadRepo } from '../repo.mjs';
|
|
4
5
|
|
|
@@ -22,6 +23,15 @@ export function check(args = [], cwd = process.cwd(), output = console) {
|
|
|
22
23
|
|
|
23
24
|
const { errors, warnings } = checkRepo(repo, { id });
|
|
24
25
|
|
|
26
|
+
// Schema version detection — warn without mutating.
|
|
27
|
+
const schemaVersion = getSchemaVersion(repo.config);
|
|
28
|
+
if (!id && schemaVersion < SUPPORTED_SCHEMA_VERSION) {
|
|
29
|
+
warnings.push({
|
|
30
|
+
file: '.changeledger/config.yml',
|
|
31
|
+
message: `config schema ${schemaVersion} is outdated; run \`changeledger config migrate --dry-run\``,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
// Discovery validation needs the filesystem (root contract bootstrap), so it
|
|
26
36
|
// lives here, not in the pure validator. Repo-wide only.
|
|
27
37
|
if (!id) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
4
|
+
import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
|
|
4
5
|
import {
|
|
5
6
|
ensureReference,
|
|
6
7
|
removeLegacyContract,
|
|
@@ -11,7 +12,7 @@ import { register } from '../registry.mjs';
|
|
|
11
12
|
|
|
12
13
|
// Refreshes the repo bootstrap and registry path. Also migrates the per-machine
|
|
13
14
|
// contract artifact left by legacy versions.
|
|
14
|
-
export function registerRepo(cwd = process.cwd()) {
|
|
15
|
+
export function registerRepo(cwd = process.cwd(), output = console) {
|
|
15
16
|
const changeledgerDir = findChangeledgerDir(cwd);
|
|
16
17
|
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
17
18
|
|
|
@@ -20,6 +21,13 @@ export function registerRepo(cwd = process.cwd()) {
|
|
|
20
21
|
throw new Error('config.yml has no project_id. Run `changeledger init` to create one.');
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
const schemaVersion = getSchemaVersion(config);
|
|
25
|
+
if (schemaVersion < SUPPORTED_SCHEMA_VERSION) {
|
|
26
|
+
output.warn(
|
|
27
|
+
`warn .changeledger/config.yml: config schema ${schemaVersion} is outdated; run \`changeledger config migrate --dry-run\``,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
const repoRoot = path.dirname(changeledgerDir);
|
|
24
32
|
const name = config.project_name || path.basename(repoRoot);
|
|
25
33
|
|
package/src/commands/view.mjs
CHANGED
|
@@ -6,8 +6,12 @@ import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
|
|
|
6
6
|
import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
|
+
applyConfigMigration,
|
|
9
10
|
changeStatus,
|
|
11
|
+
patchProjectConfig,
|
|
12
|
+
previewConfigMigration,
|
|
10
13
|
readProjectConfig,
|
|
14
|
+
readProjectConfigStructured,
|
|
11
15
|
repairProjectPath,
|
|
12
16
|
resolveProjects,
|
|
13
17
|
saveProjectConfig,
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseDocument } from 'yaml';
|
|
4
|
+
import { writeFileAtomic } from './atomic-write.mjs';
|
|
5
|
+
import { templatesDir } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
export const SUPPORTED_SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
const CANONICAL_STATUSES = [
|
|
10
|
+
'draft',
|
|
11
|
+
'approved',
|
|
12
|
+
'in-progress',
|
|
13
|
+
'in-review',
|
|
14
|
+
'in-validation',
|
|
15
|
+
'blocked',
|
|
16
|
+
'done',
|
|
17
|
+
'discarded',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const BUILTIN_TYPES = ['feature', 'bug', 'refactor'];
|
|
21
|
+
|
|
22
|
+
const BUILTIN_IMPACTS = {
|
|
23
|
+
feature: 'minor',
|
|
24
|
+
bug: 'patch',
|
|
25
|
+
audit: 'none',
|
|
26
|
+
refactor: 'none',
|
|
27
|
+
chore: 'none',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function getSchemaVersion(config) {
|
|
31
|
+
const v = config.schema_version;
|
|
32
|
+
return typeof v === 'number' ? v : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Returns null when no migration needed; throws on invalid/future schema.
|
|
36
|
+
// Otherwise returns { yaml: string, changes: string[] }.
|
|
37
|
+
export function buildMigration(originalText) {
|
|
38
|
+
let doc;
|
|
39
|
+
try {
|
|
40
|
+
doc = parseDocument(originalText, { merge: false });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new Error(`Invalid YAML: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
if (doc.errors.length) {
|
|
45
|
+
throw new Error(`Invalid YAML: ${doc.errors[0].message}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config = doc.toJS() ?? {};
|
|
49
|
+
const current = getSchemaVersion(config);
|
|
50
|
+
|
|
51
|
+
if (current > SUPPORTED_SCHEMA_VERSION) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`config schema ${current} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (current === SUPPORTED_SCHEMA_VERSION) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const changes = [];
|
|
61
|
+
|
|
62
|
+
// schema_version: 1 — remove any existing value, then prepend to appear first
|
|
63
|
+
if (Object.hasOwn(config, 'schema_version')) {
|
|
64
|
+
doc.delete('schema_version');
|
|
65
|
+
}
|
|
66
|
+
doc.contents.items.unshift(doc.createPair('schema_version', 1));
|
|
67
|
+
changes.push('added schema_version: 1');
|
|
68
|
+
|
|
69
|
+
// tdd: true if absent
|
|
70
|
+
if (!Object.hasOwn(config, 'tdd')) {
|
|
71
|
+
doc.set('tdd', true);
|
|
72
|
+
changes.push('added tdd: true');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// specs_dir if absent
|
|
76
|
+
if (!Object.hasOwn(config, 'specs_dir')) {
|
|
77
|
+
doc.set('specs_dir', '.changeledger/specs');
|
|
78
|
+
changes.push('added specs_dir: .changeledger/specs');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Canonical statuses — insert missing ones in canonical order
|
|
82
|
+
const statusesNode = doc.get('statuses', true);
|
|
83
|
+
if (statusesNode) {
|
|
84
|
+
const current_statuses = statusesNode.items.map((n) => String(n.value));
|
|
85
|
+
for (const status of CANONICAL_STATUSES) {
|
|
86
|
+
if (current_statuses.includes(status)) continue;
|
|
87
|
+
const insertBefore = findInsertBefore(current_statuses, CANONICAL_STATUSES, status);
|
|
88
|
+
const newNode = doc.createNode(status);
|
|
89
|
+
if (insertBefore === -1) {
|
|
90
|
+
statusesNode.items.push(newNode);
|
|
91
|
+
current_statuses.push(status);
|
|
92
|
+
} else {
|
|
93
|
+
statusesNode.items.splice(insertBefore, 0, newNode);
|
|
94
|
+
current_statuses.splice(insertBefore, 0, status);
|
|
95
|
+
}
|
|
96
|
+
changes.push(`added status: ${status}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// review_required: true for built-in types where key is absent
|
|
101
|
+
const configTypes = config.types ?? {};
|
|
102
|
+
for (const typeName of BUILTIN_TYPES) {
|
|
103
|
+
if (!Object.hasOwn(configTypes, typeName)) continue;
|
|
104
|
+
if (Object.hasOwn(configTypes[typeName], 'review_required')) continue;
|
|
105
|
+
doc.setIn(['types', typeName, 'review_required'], true);
|
|
106
|
+
changes.push(`added types.${typeName}.review_required: true`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// release.impacts defaults for built-in types that exist in config.types
|
|
110
|
+
const currentImpacts = config.release?.impacts ?? {};
|
|
111
|
+
for (const [type, impact] of Object.entries(BUILTIN_IMPACTS)) {
|
|
112
|
+
if (!Object.hasOwn(configTypes, type)) continue;
|
|
113
|
+
if (Object.hasOwn(currentImpacts, type)) continue;
|
|
114
|
+
doc.setIn(['release', 'impacts', type], impact);
|
|
115
|
+
changes.push(`added release.impacts.${type}: ${impact}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Remove legacy id_digits
|
|
119
|
+
if (Object.hasOwn(config, 'id_digits')) {
|
|
120
|
+
doc.delete('id_digits');
|
|
121
|
+
changes.push('removed legacy id_digits');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Refresh managed comments from the current template, preserving custom comments.
|
|
125
|
+
const templateComments = loadTemplateComments();
|
|
126
|
+
const commentChanges = refreshManagedComments(doc, templateComments);
|
|
127
|
+
changes.push(...commentChanges);
|
|
128
|
+
|
|
129
|
+
return { yaml: doc.toString(), changes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Apply migration to a file (or dry-run). Returns summary string.
|
|
133
|
+
export function applyMigration(configFile, { dryRun = false } = {}) {
|
|
134
|
+
let original;
|
|
135
|
+
try {
|
|
136
|
+
original = fs.readFileSync(configFile, 'utf8');
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new Error(`Cannot read config: ${e.message}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = buildMigration(original);
|
|
142
|
+
|
|
143
|
+
if (!result) {
|
|
144
|
+
return `Config is already at schema ${SUPPORTED_SCHEMA_VERSION}. No changes needed.`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const header = dryRun
|
|
148
|
+
? `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
|
|
149
|
+
: `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION}`;
|
|
150
|
+
|
|
151
|
+
const summary = [header, ...result.changes.map((c) => ` - ${c}`)].join('\n');
|
|
152
|
+
|
|
153
|
+
if (!dryRun) {
|
|
154
|
+
writeFileAtomic(configFile, result.yaml);
|
|
155
|
+
return summary;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return `${summary}\n\n--- candidate YAML ---\n${result.yaml}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Find the index in currentList where `status` should be inserted, based on
|
|
162
|
+
// canonical order. Returns -1 to append at end.
|
|
163
|
+
function findInsertBefore(currentList, canonicalOrder, status) {
|
|
164
|
+
const canonIdx = canonicalOrder.indexOf(status);
|
|
165
|
+
for (let i = canonIdx + 1; i < canonicalOrder.length; i++) {
|
|
166
|
+
const pos = currentList.indexOf(canonicalOrder[i]);
|
|
167
|
+
if (pos !== -1) return pos;
|
|
168
|
+
}
|
|
169
|
+
return -1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Reads the current template and returns a map of top-level key → commentBefore string.
|
|
173
|
+
// Returns an empty map if the template is unreadable (comment refresh is optional).
|
|
174
|
+
function loadTemplateComments() {
|
|
175
|
+
try {
|
|
176
|
+
const templateText = fs.readFileSync(path.join(templatesDir, 'config.yml'), 'utf8');
|
|
177
|
+
const templateDoc = parseDocument(templateText, { merge: false });
|
|
178
|
+
const comments = new Map();
|
|
179
|
+
for (const pair of templateDoc.contents.items) {
|
|
180
|
+
const key = pair.key?.value;
|
|
181
|
+
const comment = pair.key?.commentBefore;
|
|
182
|
+
if (key && comment !== undefined) {
|
|
183
|
+
comments.set(key, comment);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return comments;
|
|
187
|
+
} catch {
|
|
188
|
+
return new Map();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Replace managed comments (those defined in the template) on existing keys,
|
|
193
|
+
// preserving comments on custom/unknown keys.
|
|
194
|
+
// doc.set() creates keys as plain strings (not Scalar nodes), so commentBefore
|
|
195
|
+
// cannot be set on them directly. Convert string keys to Scalar nodes first.
|
|
196
|
+
function refreshManagedComments(doc, templateComments) {
|
|
197
|
+
const changes = [];
|
|
198
|
+
for (const pair of doc.contents.items) {
|
|
199
|
+
// Normalise string-primitive keys to Scalar nodes so commentBefore is writable.
|
|
200
|
+
if (typeof pair.key === 'string') {
|
|
201
|
+
pair.key = doc.createNode(pair.key);
|
|
202
|
+
}
|
|
203
|
+
const key = pair.key?.value;
|
|
204
|
+
if (!key) continue;
|
|
205
|
+
if (!templateComments.has(key)) continue; // custom key — preserve its comment
|
|
206
|
+
const templateComment = templateComments.get(key);
|
|
207
|
+
const currentComment = pair.key?.commentBefore;
|
|
208
|
+
if (currentComment === templateComment) continue; // already matches
|
|
209
|
+
pair.key.commentBefore = templateComment;
|
|
210
|
+
changes.push(`refreshed comment for ${key}`);
|
|
211
|
+
}
|
|
212
|
+
return changes;
|
|
213
|
+
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -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
|
+
}
|