dotmd-cli 0.15.0 → 0.16.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 +62 -0
- package/bin/dotmd.mjs +71 -4
- package/dotmd.config.example.mjs +14 -7
- package/package.json +1 -1
- package/src/config-edit.mjs +603 -0
- package/src/doctor.mjs +148 -0
- package/src/migrate.mjs +32 -3
- package/src/statuses.mjs +736 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// Line-based, brace-aware editor for the `types.<typename>.statuses` block in
|
|
2
|
+
// dotmd.config.mjs. Edits are scoped to single-line status entries; we refuse
|
|
3
|
+
// (with an actionable error) on multi-line entries, array form, or anything
|
|
4
|
+
// outside our supported shape. Atomic write contract:
|
|
5
|
+
//
|
|
6
|
+
// 1. compute new content in memory
|
|
7
|
+
// 2. fs.writeFileSync(<path>.tmp, new)
|
|
8
|
+
// 3. import the tmp via file:// + cache-bust query — must parse
|
|
9
|
+
// 4. resolveConfig(tmp) — must not surface new warnings
|
|
10
|
+
// 5. fs.renameSync(tmp, real) only on full success
|
|
11
|
+
// 6. on any failure: unlink tmp, surface error, real file untouched
|
|
12
|
+
//
|
|
13
|
+
// Pulling in @babel/parser would force a heavy dep on a project with two
|
|
14
|
+
// runtime deps and would round-trip-mangle function-heavy configs (Beyond's
|
|
15
|
+
// `templates` section is 264 lines of arrow functions with multi-line
|
|
16
|
+
// template literals). Line surgery is the conservative choice.
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
import { resolveConfig } from './config.mjs';
|
|
21
|
+
|
|
22
|
+
const STATUS_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
23
|
+
const RESERVED_NAMES = new Set([
|
|
24
|
+
'terminal', 'archive', 'skipStale', 'skipWarnings', 'quiet',
|
|
25
|
+
'requiresModule', 'staleDays', 'context',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export class ConfigEditError extends Error {
|
|
29
|
+
constructor(msg) { super(msg); this.name = 'ConfigEditError'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateStatusName(name) {
|
|
33
|
+
if (typeof name !== 'string' || !name) return 'Status name is required.';
|
|
34
|
+
if (!STATUS_NAME_RE.test(name)) {
|
|
35
|
+
return `Invalid status name '${name}': must be lowercase letters/digits with single dashes between segments (e.g. 'in-session').`;
|
|
36
|
+
}
|
|
37
|
+
if (RESERVED_NAMES.has(name)) {
|
|
38
|
+
return `Status name '${name}' collides with a flag keyword and would be confusing.`;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Tokenizer helpers ───────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function skipString(content, start) {
|
|
46
|
+
const quote = content[start];
|
|
47
|
+
let i = start + 1;
|
|
48
|
+
while (i < content.length) {
|
|
49
|
+
const c = content[i];
|
|
50
|
+
if (c === '\\') { i += 2; continue; }
|
|
51
|
+
if (c === quote) return i + 1;
|
|
52
|
+
if (quote === '`' && c === '$' && content[i + 1] === '{') {
|
|
53
|
+
i = skipBalanced(content, i + 1) + 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return content.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function skipComment(content, start) {
|
|
62
|
+
if (content[start] === '/' && content[start + 1] === '/') {
|
|
63
|
+
const end = content.indexOf('\n', start);
|
|
64
|
+
return end === -1 ? content.length : end + 1;
|
|
65
|
+
}
|
|
66
|
+
if (content[start] === '/' && content[start + 1] === '*') {
|
|
67
|
+
const end = content.indexOf('*/', start + 2);
|
|
68
|
+
return end === -1 ? content.length : end + 2;
|
|
69
|
+
}
|
|
70
|
+
return start;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Skip a balanced bracket group starting at `start` (which must point at
|
|
74
|
+
// `{`, `[`, or `(`). Returns the position OF the matching close.
|
|
75
|
+
function skipBalanced(content, start) {
|
|
76
|
+
const open = content[start];
|
|
77
|
+
const close = open === '{' ? '}' : open === '[' ? ']' : ')';
|
|
78
|
+
const stack = [close];
|
|
79
|
+
let i = start + 1;
|
|
80
|
+
while (i < content.length && stack.length > 0) {
|
|
81
|
+
const c = content[i];
|
|
82
|
+
if (c === '\'' || c === '"' || c === '`') { i = skipString(content, i); continue; }
|
|
83
|
+
if (c === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) { i = skipComment(content, i); continue; }
|
|
84
|
+
if (c === '{') { stack.push('}'); i++; continue; }
|
|
85
|
+
if (c === '[') { stack.push(']'); i++; continue; }
|
|
86
|
+
if (c === '(') { stack.push(')'); i++; continue; }
|
|
87
|
+
if (c === '}' || c === ']' || c === ')') {
|
|
88
|
+
const expected = stack.pop();
|
|
89
|
+
if (c !== expected) {
|
|
90
|
+
throw new ConfigEditError(`Unexpected '${c}' at offset ${i}; expected '${expected}'.`);
|
|
91
|
+
}
|
|
92
|
+
if (stack.length === 0) return i;
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Locating the types.<typename>.statuses block ───────────────────────────
|
|
102
|
+
|
|
103
|
+
// Find the `types` declaration anywhere at the top level of the file. Matches
|
|
104
|
+
// `export const types = {`, `types: {` (inside default-export), or any other
|
|
105
|
+
// occurrence of `types <ws>(=|:) <ws>{`. Returns { start, end } pointing
|
|
106
|
+
// immediately after the `{` and at the matching `}`.
|
|
107
|
+
function locateTypesBlock(content) {
|
|
108
|
+
let i = 0;
|
|
109
|
+
while (i < content.length) {
|
|
110
|
+
const c = content[i];
|
|
111
|
+
if (c === '\'' || c === '"' || c === '`') { i = skipString(content, i); continue; }
|
|
112
|
+
if (c === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) { i = skipComment(content, i); continue; }
|
|
113
|
+
|
|
114
|
+
const prevIsIdent = i > 0 && /[A-Za-z0-9_$]/.test(content[i - 1]);
|
|
115
|
+
if (!prevIsIdent && content.startsWith('types', i)) {
|
|
116
|
+
const after = content[i + 5] ?? '';
|
|
117
|
+
if (!/[A-Za-z0-9_$]/.test(after)) {
|
|
118
|
+
let j = i + 5;
|
|
119
|
+
while (j < content.length && /\s/.test(content[j])) j++;
|
|
120
|
+
if (content[j] === '=' || content[j] === ':') {
|
|
121
|
+
j++;
|
|
122
|
+
while (j < content.length) {
|
|
123
|
+
const cc = content[j];
|
|
124
|
+
if (cc === ' ' || cc === '\t' || cc === '\n' || cc === '\r') { j++; continue; }
|
|
125
|
+
if (cc === '/' && (content[j + 1] === '/' || content[j + 1] === '*')) { j = skipComment(content, j); continue; }
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (content[j] === '{') {
|
|
129
|
+
const close = skipBalanced(content, j);
|
|
130
|
+
if (close !== -1) return { start: j + 1, end: close };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Within the region `[regionStart, regionEnd)`, find the immediate-level
|
|
141
|
+
// property `<key>: <value>` where <value> is a `{...}` object or `[...]`
|
|
142
|
+
// array. Returns { keyPos, openPos, closePos, openChar } or null.
|
|
143
|
+
function findChildProperty(content, regionStart, regionEnd, key) {
|
|
144
|
+
let i = regionStart;
|
|
145
|
+
while (i < regionEnd) {
|
|
146
|
+
const c = content[i];
|
|
147
|
+
if (c === '\'' || c === '"' || c === '`') { i = skipString(content, i); continue; }
|
|
148
|
+
if (c === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) { i = skipComment(content, i); continue; }
|
|
149
|
+
if (/[\s,]/.test(c)) { i++; continue; }
|
|
150
|
+
if (c === '{' || c === '[' || c === '(') {
|
|
151
|
+
const close = skipBalanced(content, i);
|
|
152
|
+
if (close === -1) return null;
|
|
153
|
+
i = close + 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const keyMatch = matchPropertyKey(content, i, regionEnd);
|
|
158
|
+
if (!keyMatch) { i++; continue; }
|
|
159
|
+
|
|
160
|
+
if (keyMatch.name === key) {
|
|
161
|
+
let j = keyMatch.afterColon;
|
|
162
|
+
while (j < regionEnd) {
|
|
163
|
+
const cc = content[j];
|
|
164
|
+
if (cc === ' ' || cc === '\t' || cc === '\n' || cc === '\r') { j++; continue; }
|
|
165
|
+
if (cc === '/' && (content[j + 1] === '/' || content[j + 1] === '*')) { j = skipComment(content, j); continue; }
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const openChar = content[j];
|
|
169
|
+
if (openChar !== '{' && openChar !== '[') {
|
|
170
|
+
return { keyPos: i, openPos: -1, closePos: -1, openChar };
|
|
171
|
+
}
|
|
172
|
+
const close = skipBalanced(content, j);
|
|
173
|
+
return { keyPos: i, openPos: j, closePos: close, openChar };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Skip past this property's value to the next comma at our depth.
|
|
177
|
+
i = skipToNextProperty(content, keyMatch.afterColon, regionEnd);
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function matchPropertyKey(content, start, end) {
|
|
183
|
+
let i = start;
|
|
184
|
+
let name;
|
|
185
|
+
if (content[i] === '\'' || content[i] === '"') {
|
|
186
|
+
const quote = content[i];
|
|
187
|
+
const close = content.indexOf(quote, i + 1);
|
|
188
|
+
if (close === -1 || close >= end) return null;
|
|
189
|
+
name = content.slice(i + 1, close);
|
|
190
|
+
i = close + 1;
|
|
191
|
+
} else {
|
|
192
|
+
const slice = content.slice(i, end);
|
|
193
|
+
const m = slice.match(/^[A-Za-z_$][A-Za-z0-9_$-]*/);
|
|
194
|
+
if (!m) return null;
|
|
195
|
+
name = m[0];
|
|
196
|
+
i += name.length;
|
|
197
|
+
}
|
|
198
|
+
while (i < end && /[ \t]/.test(content[i])) i++;
|
|
199
|
+
if (content[i] !== ':') return null;
|
|
200
|
+
return { name, afterColon: i + 1 };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function skipToNextProperty(content, start, end) {
|
|
204
|
+
let i = start;
|
|
205
|
+
while (i < end) {
|
|
206
|
+
const c = content[i];
|
|
207
|
+
if (c === '\'' || c === '"' || c === '`') { i = skipString(content, i); continue; }
|
|
208
|
+
if (c === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) { i = skipComment(content, i); continue; }
|
|
209
|
+
if (c === '{' || c === '[' || c === '(') {
|
|
210
|
+
const close = skipBalanced(content, i);
|
|
211
|
+
if (close === -1) return end;
|
|
212
|
+
i = close + 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (c === ',') return i + 1;
|
|
216
|
+
i++;
|
|
217
|
+
}
|
|
218
|
+
return end;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Parsing the statuses block ──────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
// Parse the statuses block, returning a structural view we can edit:
|
|
224
|
+
// { form: 'object'|'array', blockStart, blockEnd, entries, openLineEnd, closeLineStart }
|
|
225
|
+
// `entries` items have shape { name, lineStart, lineEnd, multiLine, raw }.
|
|
226
|
+
// `lineStart` / `lineEnd` are absolute file offsets. `lineEnd` is exclusive
|
|
227
|
+
// and includes the trailing newline (one past `\n`); `lineStart` is the
|
|
228
|
+
// position of the first character of the entry's line.
|
|
229
|
+
export function parseStatusesBlock(content, typeName) {
|
|
230
|
+
const types = locateTypesBlock(content);
|
|
231
|
+
if (!types) {
|
|
232
|
+
throw new ConfigEditError('Your dotmd.config.mjs does not define a `types` block — there is nothing for `dotmd statuses` to edit. Add a `types: {...}` export to opt in to per-project status taxonomy. See dotmd.config.example.mjs for the rich-form template.');
|
|
233
|
+
}
|
|
234
|
+
const typeProp = findChildProperty(content, types.start, types.end, typeName);
|
|
235
|
+
if (!typeProp) {
|
|
236
|
+
throw new ConfigEditError(`Type '${typeName}' is not defined in this config's \`types\` block.`);
|
|
237
|
+
}
|
|
238
|
+
if (typeProp.openChar !== '{') {
|
|
239
|
+
throw new ConfigEditError(`Type '${typeName}' must be an object (found ${typeProp.openChar === '[' ? 'array' : 'a non-object value'}).`);
|
|
240
|
+
}
|
|
241
|
+
const statuses = findChildProperty(content, typeProp.openPos + 1, typeProp.closePos, 'statuses');
|
|
242
|
+
if (!statuses || statuses.openPos === -1) {
|
|
243
|
+
throw new ConfigEditError(`Type '${typeName}' has no \`statuses\` property.`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const blockStart = statuses.openPos + 1;
|
|
247
|
+
const blockEnd = statuses.closePos;
|
|
248
|
+
const form = statuses.openChar === '{' ? 'object' : 'array';
|
|
249
|
+
|
|
250
|
+
if (form === 'array') {
|
|
251
|
+
// For array form we replace the whole literal during migrate; per-line
|
|
252
|
+
// invariants don't apply.
|
|
253
|
+
return {
|
|
254
|
+
form, blockStart, blockEnd,
|
|
255
|
+
entries: parseArrayEntries(content, blockStart, blockEnd),
|
|
256
|
+
openLineEnd: -1, closeLineStart: -1,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Object form needs the open/close brace on their own lines for line-based
|
|
261
|
+
// editing.
|
|
262
|
+
const openLineEnd = nextNewline(content, statuses.openPos) + 1;
|
|
263
|
+
const closeLineStart = lineStartOf(content, blockEnd);
|
|
264
|
+
|
|
265
|
+
if (openLineEnd === 0 || openLineEnd > blockEnd) {
|
|
266
|
+
throw new ConfigEditError(`statuses block for type '${typeName}' is not in the expected multi-line form.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { form, blockStart, blockEnd, entries: parseObjectEntries(content, openLineEnd, closeLineStart), openLineEnd, closeLineStart };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function nextNewline(content, from) {
|
|
273
|
+
return content.indexOf('\n', from);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function lineStartOf(content, pos) {
|
|
277
|
+
const prev = content.lastIndexOf('\n', pos - 1);
|
|
278
|
+
return prev === -1 ? 0 : prev + 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseObjectEntries(content, regionStart, regionEnd) {
|
|
282
|
+
const entries = [];
|
|
283
|
+
let i = regionStart;
|
|
284
|
+
while (i < regionEnd) {
|
|
285
|
+
const lineEndIdx = content.indexOf('\n', i);
|
|
286
|
+
const lineEnd = lineEndIdx === -1 || lineEndIdx > regionEnd ? regionEnd : lineEndIdx;
|
|
287
|
+
const lineEndExclusive = lineEnd === regionEnd ? regionEnd : lineEnd + 1;
|
|
288
|
+
const lineRaw = content.slice(i, lineEnd);
|
|
289
|
+
|
|
290
|
+
const stripped = stripCommentsAndTrim(lineRaw);
|
|
291
|
+
if (stripped === '') {
|
|
292
|
+
i = lineEndExclusive;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const entry = parseSingleLineEntry(content, i, lineEnd);
|
|
297
|
+
if (entry) {
|
|
298
|
+
entries.push({
|
|
299
|
+
name: entry.name,
|
|
300
|
+
lineStart: i,
|
|
301
|
+
lineEnd: lineEndExclusive,
|
|
302
|
+
multiLine: false,
|
|
303
|
+
raw: content.slice(i, lineEndExclusive),
|
|
304
|
+
});
|
|
305
|
+
i = lineEndExclusive;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Possibly multi-line: detect if it starts a property with an opening `{`
|
|
310
|
+
// that doesn't close on the same line.
|
|
311
|
+
const multi = parseMultiLineEntryStart(content, i, regionEnd);
|
|
312
|
+
if (multi) {
|
|
313
|
+
const multiLineEnd = content.indexOf('\n', multi.endPos);
|
|
314
|
+
const ml = multiLineEnd === -1 || multiLineEnd > regionEnd ? regionEnd : multiLineEnd + 1;
|
|
315
|
+
entries.push({
|
|
316
|
+
name: multi.name,
|
|
317
|
+
lineStart: i,
|
|
318
|
+
lineEnd: ml,
|
|
319
|
+
multiLine: true,
|
|
320
|
+
raw: content.slice(i, ml),
|
|
321
|
+
});
|
|
322
|
+
i = ml;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Unknown line shape (could be a stray construct) — skip it.
|
|
327
|
+
i = lineEndExclusive;
|
|
328
|
+
}
|
|
329
|
+
return entries;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function stripCommentsAndTrim(line) {
|
|
333
|
+
// Strip `//` and `/* ... */` (single-line) and trim. Coarse but enough to
|
|
334
|
+
// detect whether a line is meaningful.
|
|
335
|
+
let s = line;
|
|
336
|
+
// Remove /* ... */ entirely (single-line cases)
|
|
337
|
+
s = s.replace(/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g, '');
|
|
338
|
+
const slashIdx = findUnquotedSlashSlash(s);
|
|
339
|
+
if (slashIdx !== -1) s = s.slice(0, slashIdx);
|
|
340
|
+
return s.trim();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function findUnquotedSlashSlash(s) {
|
|
344
|
+
let i = 0;
|
|
345
|
+
while (i < s.length) {
|
|
346
|
+
const c = s[i];
|
|
347
|
+
if (c === '\'' || c === '"' || c === '`') {
|
|
348
|
+
i = skipString(s, i);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (c === '/' && s[i + 1] === '/') return i;
|
|
352
|
+
i++;
|
|
353
|
+
}
|
|
354
|
+
return -1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseSingleLineEntry(content, lineStart, lineEnd) {
|
|
358
|
+
let i = lineStart;
|
|
359
|
+
while (i < lineEnd && /[ \t]/.test(content[i])) i++;
|
|
360
|
+
if (i >= lineEnd) return null;
|
|
361
|
+
|
|
362
|
+
let name;
|
|
363
|
+
if (content[i] === '\'' || content[i] === '"') {
|
|
364
|
+
const quote = content[i];
|
|
365
|
+
const close = content.indexOf(quote, i + 1);
|
|
366
|
+
if (close === -1 || close >= lineEnd) return null;
|
|
367
|
+
name = content.slice(i + 1, close);
|
|
368
|
+
i = close + 1;
|
|
369
|
+
} else if (/[A-Za-z_$]/.test(content[i])) {
|
|
370
|
+
const slice = content.slice(i, lineEnd);
|
|
371
|
+
const m = slice.match(/^[A-Za-z_$][A-Za-z0-9_$-]*/);
|
|
372
|
+
if (!m) return null;
|
|
373
|
+
name = m[0];
|
|
374
|
+
i += name.length;
|
|
375
|
+
} else {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
while (i < lineEnd && /[ \t]/.test(content[i])) i++;
|
|
380
|
+
if (content[i] !== ':') return null;
|
|
381
|
+
i++;
|
|
382
|
+
while (i < lineEnd && /[ \t]/.test(content[i])) i++;
|
|
383
|
+
if (content[i] !== '{') return null;
|
|
384
|
+
const close = skipBalanced(content, i);
|
|
385
|
+
if (close === -1 || close >= lineEnd) return null;
|
|
386
|
+
|
|
387
|
+
// Allow trailing whitespace, optional comma, optional inline comment.
|
|
388
|
+
let j = close + 1;
|
|
389
|
+
while (j < lineEnd && /[ \t]/.test(content[j])) j++;
|
|
390
|
+
if (content[j] === ',') j++;
|
|
391
|
+
while (j < lineEnd && /[ \t]/.test(content[j])) j++;
|
|
392
|
+
// Allow trailing line comment
|
|
393
|
+
if (j < lineEnd) {
|
|
394
|
+
if (content[j] === '/' && (content[j + 1] === '/' || content[j + 1] === '*')) {
|
|
395
|
+
// OK — rest of line is a comment
|
|
396
|
+
} else {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { name };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseMultiLineEntryStart(content, lineStart, regionEnd) {
|
|
404
|
+
let i = lineStart;
|
|
405
|
+
while (i < regionEnd && /[ \t]/.test(content[i])) i++;
|
|
406
|
+
if (i >= regionEnd) return null;
|
|
407
|
+
let name;
|
|
408
|
+
if (content[i] === '\'' || content[i] === '"') {
|
|
409
|
+
const quote = content[i];
|
|
410
|
+
const close = content.indexOf(quote, i + 1);
|
|
411
|
+
if (close === -1) return null;
|
|
412
|
+
name = content.slice(i + 1, close);
|
|
413
|
+
i = close + 1;
|
|
414
|
+
} else if (/[A-Za-z_$]/.test(content[i])) {
|
|
415
|
+
const slice = content.slice(i, regionEnd);
|
|
416
|
+
const m = slice.match(/^[A-Za-z_$][A-Za-z0-9_$-]*/);
|
|
417
|
+
if (!m) return null;
|
|
418
|
+
name = m[0];
|
|
419
|
+
i += name.length;
|
|
420
|
+
} else {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
while (i < regionEnd && /\s/.test(content[i])) i++;
|
|
424
|
+
if (content[i] !== ':') return null;
|
|
425
|
+
i++;
|
|
426
|
+
while (i < regionEnd && /\s/.test(content[i])) i++;
|
|
427
|
+
if (content[i] !== '{') return null;
|
|
428
|
+
const close = skipBalanced(content, i);
|
|
429
|
+
if (close === -1 || close >= regionEnd) return null;
|
|
430
|
+
return { name, endPos: close + 1 };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseArrayEntries(content, regionStart, regionEnd) {
|
|
434
|
+
const entries = [];
|
|
435
|
+
let i = regionStart;
|
|
436
|
+
while (i < regionEnd) {
|
|
437
|
+
const c = content[i];
|
|
438
|
+
if (c === '\'' || c === '"') {
|
|
439
|
+
const quote = c;
|
|
440
|
+
const close = content.indexOf(quote, i + 1);
|
|
441
|
+
if (close === -1 || close >= regionEnd) break;
|
|
442
|
+
entries.push({ name: content.slice(i + 1, close) });
|
|
443
|
+
i = close + 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (c === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) {
|
|
447
|
+
i = skipComment(content, i);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
i++;
|
|
451
|
+
}
|
|
452
|
+
return entries;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Detecting an explicit `lifecycle` export ────────────────────────────────
|
|
456
|
+
|
|
457
|
+
export function hasExplicitLifecycle(content) {
|
|
458
|
+
// Walk the file, skipping strings/comments, looking for
|
|
459
|
+
// `export <ws> const <ws> lifecycle <ws> =` at depth 0.
|
|
460
|
+
let i = 0;
|
|
461
|
+
let depth = 0;
|
|
462
|
+
while (i < content.length) {
|
|
463
|
+
const c = content[i];
|
|
464
|
+
if (c === '\'' || c === '"' || c === '`') { i = skipString(content, i); continue; }
|
|
465
|
+
if (c === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) { i = skipComment(content, i); continue; }
|
|
466
|
+
if (c === '{' || c === '[' || c === '(') { depth++; i++; continue; }
|
|
467
|
+
if (c === '}' || c === ']' || c === ')') { depth--; i++; continue; }
|
|
468
|
+
if (depth === 0) {
|
|
469
|
+
if ((i === 0 || !/[A-Za-z0-9_$]/.test(content[i - 1])) && content.startsWith('export', i)) {
|
|
470
|
+
let j = i + 6;
|
|
471
|
+
while (j < content.length && /\s/.test(content[j])) j++;
|
|
472
|
+
if (content.startsWith('const', j) || content.startsWith('let', j) || content.startsWith('var', j)) {
|
|
473
|
+
j += content[j] === 'c' ? 5 : 3;
|
|
474
|
+
while (j < content.length && /\s/.test(content[j])) j++;
|
|
475
|
+
if (content.startsWith('lifecycle', j) && !/[A-Za-z0-9_$]/.test(content[j + 9] ?? '')) {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
i++;
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Edit operations ─────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
// Build the literal text of a `'name': { flags... },` line.
|
|
489
|
+
export function renderEntryLine(name, props, indent = ' ') {
|
|
490
|
+
const quotedName = `'${name}'`;
|
|
491
|
+
const pairs = [];
|
|
492
|
+
// Stable ordering matches the example config.
|
|
493
|
+
const order = ['context', 'staleDays', 'requiresModule', 'archive', 'terminal', 'skipStale', 'skipWarnings', 'quiet'];
|
|
494
|
+
for (const key of order) {
|
|
495
|
+
if (key in props) pairs.push(`${key}: ${formatPropValue(props[key])}`);
|
|
496
|
+
}
|
|
497
|
+
for (const [key, val] of Object.entries(props)) {
|
|
498
|
+
if (!order.includes(key)) pairs.push(`${key}: ${formatPropValue(val)}`);
|
|
499
|
+
}
|
|
500
|
+
return `${indent}${quotedName}: { ${pairs.join(', ')} },\n`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function formatPropValue(v) {
|
|
504
|
+
if (typeof v === 'string') return `'${v.replace(/'/g, "\\'")}'`;
|
|
505
|
+
return String(v);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Insert a new status entry. Position rule: before the first entry whose
|
|
509
|
+
// flags include `terminal: true` or `archive: true`. If none exist, append at
|
|
510
|
+
// the end (right before the closing brace's line).
|
|
511
|
+
export function spliceEntry(content, parsed, line, beforeName) {
|
|
512
|
+
let insertPos;
|
|
513
|
+
if (beforeName) {
|
|
514
|
+
const target = parsed.entries.find(e => e.name === beforeName);
|
|
515
|
+
if (!target) {
|
|
516
|
+
throw new ConfigEditError(`Internal: insertion target '${beforeName}' not found.`);
|
|
517
|
+
}
|
|
518
|
+
insertPos = target.lineStart;
|
|
519
|
+
} else {
|
|
520
|
+
insertPos = parsed.closeLineStart;
|
|
521
|
+
}
|
|
522
|
+
return content.slice(0, insertPos) + line + content.slice(insertPos);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Replace the entry line for `name` with `newLine`. Refuses on multi-line.
|
|
526
|
+
export function replaceEntry(content, parsed, name, newLine) {
|
|
527
|
+
const target = parsed.entries.find(e => e.name === name);
|
|
528
|
+
if (!target) {
|
|
529
|
+
throw new ConfigEditError(`Status '${name}' is not defined for this type.`);
|
|
530
|
+
}
|
|
531
|
+
if (target.multiLine) {
|
|
532
|
+
throw new ConfigEditError(`Status '${name}' spans multiple lines; this CLI only edits single-line entries. Edit dotmd.config.mjs by hand.`);
|
|
533
|
+
}
|
|
534
|
+
return content.slice(0, target.lineStart) + newLine + content.slice(target.lineEnd);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Delete the entry line for `name`, including its trailing newline.
|
|
538
|
+
export function deleteEntry(content, parsed, name) {
|
|
539
|
+
const target = parsed.entries.find(e => e.name === name);
|
|
540
|
+
if (!target) {
|
|
541
|
+
throw new ConfigEditError(`Status '${name}' is not defined for this type.`);
|
|
542
|
+
}
|
|
543
|
+
if (target.multiLine) {
|
|
544
|
+
throw new ConfigEditError(`Status '${name}' spans multiple lines; delete it by hand in dotmd.config.mjs.`);
|
|
545
|
+
}
|
|
546
|
+
return content.slice(0, target.lineStart) + content.slice(target.lineEnd);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Inferred indent of inner entries — used when the block has none yet.
|
|
550
|
+
export function inferIndent(content, parsed) {
|
|
551
|
+
if (parsed.entries.length === 0) {
|
|
552
|
+
// Fall back to 4 spaces past the open-line's leading whitespace.
|
|
553
|
+
const openLineStart = lineStartOf(content, parsed.openLineEnd - 1);
|
|
554
|
+
const openLine = content.slice(openLineStart, parsed.openLineEnd - 1);
|
|
555
|
+
const leading = openLine.match(/^\s*/)[0];
|
|
556
|
+
return leading + ' ';
|
|
557
|
+
}
|
|
558
|
+
const firstLine = parsed.entries[0].raw;
|
|
559
|
+
const m = firstLine.match(/^(\s*)/);
|
|
560
|
+
return m ? m[1] : ' ';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ─── Atomic write ────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
export async function writeConfigAtomic(configPath, newContent, cwd) {
|
|
566
|
+
// Node only imports .mjs/.js/.cjs, so the temp must keep a JS extension.
|
|
567
|
+
// Sibling file in the same dir → atomic renameSync within one filesystem.
|
|
568
|
+
const tmpPath = configPath.replace(/(\.[^.]+)$/, `.dotmd-edit-${process.pid}-${Date.now()}$1`);
|
|
569
|
+
writeFileSync(tmpPath, newContent, 'utf8');
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
// Step 1: must parse.
|
|
573
|
+
const url = pathToFileURL(tmpPath).href + '?bust=' + Date.now();
|
|
574
|
+
try {
|
|
575
|
+
await import(url);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
throw new ConfigEditError(`Generated config does not parse: ${err.message}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Step 2: must resolve cleanly. Existing warnings are tolerated; new ones
|
|
581
|
+
// produced by the rewrite are not.
|
|
582
|
+
let baseline = [];
|
|
583
|
+
if (existsSync(configPath)) {
|
|
584
|
+
try {
|
|
585
|
+
const cfg = await resolveConfig(cwd, configPath);
|
|
586
|
+
baseline = cfg.configWarnings ?? [];
|
|
587
|
+
} catch {
|
|
588
|
+
// Couldn't resolve original — skip the diff check.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const updated = await resolveConfig(cwd, tmpPath);
|
|
592
|
+
const baselineSet = new Set(baseline);
|
|
593
|
+
const novel = (updated.configWarnings ?? []).filter(w => !baselineSet.has(w));
|
|
594
|
+
if (novel.length > 0) {
|
|
595
|
+
throw new ConfigEditError(`Generated config surfaces new warnings:\n - ${novel.join('\n - ')}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
renameSync(tmpPath, configPath);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
601
|
+
throw err;
|
|
602
|
+
}
|
|
603
|
+
}
|