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
package/src/statuses.mjs
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// `dotmd statuses` — manage per-project status taxonomy without hand-editing
|
|
2
|
+
// the rich-form object in dotmd.config.mjs. Subcommands:
|
|
3
|
+
//
|
|
4
|
+
// list table view of every status × type
|
|
5
|
+
// add <name> add a new status (use --like <existing> to clone)
|
|
6
|
+
// set <name> edit flags on an existing status
|
|
7
|
+
// remove <name> delete a status (refuses if any docs use it)
|
|
8
|
+
// migrate <type> convert array-form statuses to rich-form
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
13
|
+
import { collectDocFiles } from './index.mjs';
|
|
14
|
+
import { asString, toRepoPath, die } from './util.mjs';
|
|
15
|
+
import { bold, dim, green, yellow } from './color.mjs';
|
|
16
|
+
import { isInteractive, promptText } from './prompt.mjs';
|
|
17
|
+
import {
|
|
18
|
+
parseStatusesBlock,
|
|
19
|
+
renderEntryLine,
|
|
20
|
+
spliceEntry,
|
|
21
|
+
replaceEntry,
|
|
22
|
+
deleteEntry,
|
|
23
|
+
inferIndent,
|
|
24
|
+
hasExplicitLifecycle,
|
|
25
|
+
validateStatusName,
|
|
26
|
+
writeConfigAtomic,
|
|
27
|
+
ConfigEditError,
|
|
28
|
+
} from './config-edit.mjs';
|
|
29
|
+
|
|
30
|
+
const FLAG_PROPS = ['context', 'staleDays', 'requiresModule', 'terminal', 'archive', 'skipStale', 'skipWarnings', 'quiet'];
|
|
31
|
+
const BOOLEAN_FLAGS = ['requiresModule', 'terminal', 'archive', 'skipStale', 'skipWarnings', 'quiet'];
|
|
32
|
+
|
|
33
|
+
export async function runStatuses(argv, config, opts = {}) {
|
|
34
|
+
const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'list';
|
|
35
|
+
const rest = sub === argv[0] ? argv.slice(1) : argv;
|
|
36
|
+
// The dispatcher strips global `--type`; surface it back so subcommands see it.
|
|
37
|
+
if (opts.type && !rest.includes('--type')) {
|
|
38
|
+
rest.unshift('--type', opts.type);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
switch (sub) {
|
|
42
|
+
case 'list': return runListStatuses(rest, config);
|
|
43
|
+
case 'add': return await runAddStatus(rest, config, opts);
|
|
44
|
+
case 'set': return await runSetStatus(rest, config, opts);
|
|
45
|
+
case 'remove': return await runRemoveStatus(rest, config, opts);
|
|
46
|
+
case 'migrate': return await runMigrateType(rest, config, opts);
|
|
47
|
+
default:
|
|
48
|
+
die(`Unknown statuses subcommand: '${sub}'\nUse: list, add, set, remove, migrate`);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof ConfigEditError) die(err.message);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── list ────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function runListStatuses(args, config) {
|
|
59
|
+
const flags = parseFlags(args, { allowProps: false });
|
|
60
|
+
const types = flags.type ? flags.type.split(',').map(t => t.trim()).filter(Boolean) : [...config.validTypes];
|
|
61
|
+
|
|
62
|
+
// For each type, derive a flag table from config.raw.
|
|
63
|
+
const out = { types: {} };
|
|
64
|
+
for (const t of types) {
|
|
65
|
+
if (!config.validTypes.has(t)) {
|
|
66
|
+
die(`Unknown type: '${t}'. Known: ${[...config.validTypes].join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
out.types[t] = describeTypeStatuses(t, config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (flags.json) {
|
|
72
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [t, statuses] of Object.entries(out.types)) {
|
|
77
|
+
process.stdout.write(`${bold(`type: ${t}`)}\n`);
|
|
78
|
+
if (Object.keys(statuses).length === 0) {
|
|
79
|
+
process.stdout.write(` (no statuses defined)\n\n`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const headers = ['status', ...FLAG_PROPS];
|
|
83
|
+
const rows = [headers];
|
|
84
|
+
for (const [name, props] of Object.entries(statuses)) {
|
|
85
|
+
rows.push([name, ...FLAG_PROPS.map(p => formatPropDisplay(props[p]))]);
|
|
86
|
+
}
|
|
87
|
+
const widths = headers.map((_, c) => Math.max(...rows.map(r => String(r[c] ?? '').length)));
|
|
88
|
+
for (let r = 0; r < rows.length; r++) {
|
|
89
|
+
const cells = rows[r].map((v, c) => String(v ?? '').padEnd(widths[c]));
|
|
90
|
+
const line = ' ' + cells.join(' ');
|
|
91
|
+
process.stdout.write((r === 0 ? dim(line) : line) + '\n');
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write('\n');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function describeTypeStatuses(typeName, config) {
|
|
98
|
+
const typeDef = config.raw?.types?.[typeName];
|
|
99
|
+
if (!typeDef) return {};
|
|
100
|
+
const result = {};
|
|
101
|
+
// After resolveConfig, rich-form types have been normalized into an array.
|
|
102
|
+
// Reconstruct each status's effective flags from derived sets.
|
|
103
|
+
const statusList = Array.isArray(typeDef.statuses) ? typeDef.statuses : Object.keys(typeDef.statuses ?? {});
|
|
104
|
+
const ctx = typeDef.context ?? {};
|
|
105
|
+
const ctxByStatus = {};
|
|
106
|
+
for (const [bucket, names] of Object.entries(ctx)) {
|
|
107
|
+
for (const n of names) ctxByStatus[n] = bucket;
|
|
108
|
+
}
|
|
109
|
+
const stale = typeDef.staleDays ?? {};
|
|
110
|
+
const lc = config.lifecycle;
|
|
111
|
+
const moduleReq = config.moduleRequiredStatuses;
|
|
112
|
+
|
|
113
|
+
for (const name of statusList) {
|
|
114
|
+
const skipStale = lc.skipStaleFor.has(name);
|
|
115
|
+
const skipWarnings = lc.skipWarningsFor.has(name);
|
|
116
|
+
const quiet = skipStale && skipWarnings;
|
|
117
|
+
result[name] = {
|
|
118
|
+
context: ctxByStatus[name] ?? null,
|
|
119
|
+
staleDays: stale[name] ?? null,
|
|
120
|
+
requiresModule: moduleReq.has(name),
|
|
121
|
+
terminal: lc.terminalStatuses.has(name),
|
|
122
|
+
archive: lc.archiveStatuses.has(name),
|
|
123
|
+
skipStale,
|
|
124
|
+
skipWarnings,
|
|
125
|
+
quiet,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatPropDisplay(v) {
|
|
132
|
+
if (v === null || v === undefined) return '—';
|
|
133
|
+
if (v === true) return 'true';
|
|
134
|
+
if (v === false) return 'false';
|
|
135
|
+
return String(v);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── add ─────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
async function runAddStatus(args, config, opts) {
|
|
141
|
+
const flags = parseFlags(args, { allowProps: true });
|
|
142
|
+
const name = flags.positional[0];
|
|
143
|
+
if (!name) die('Usage: dotmd statuses add <name> --type <type> [--like <existing>] [flags]');
|
|
144
|
+
if (!flags.type) die('--type is required for `dotmd statuses add`.');
|
|
145
|
+
|
|
146
|
+
const validationErr = validateStatusName(name);
|
|
147
|
+
if (validationErr) die(validationErr);
|
|
148
|
+
|
|
149
|
+
requireConfigPath(config);
|
|
150
|
+
const content = readFileSync(config.configPath, 'utf8');
|
|
151
|
+
const parsed = parseStatusesBlock(content, flags.type);
|
|
152
|
+
if (parsed.form === 'array') {
|
|
153
|
+
die(`Type '${flags.type}' uses array-form statuses. Run \`dotmd statuses migrate ${flags.type}\` first to convert to rich form.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (parsed.entries.some(e => e.name === name)) {
|
|
157
|
+
die(`Status '${name}' already exists in type '${flags.type}'. Use \`dotmd statuses set\` to edit it.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Resolve --like base flags
|
|
161
|
+
let baseProps = {};
|
|
162
|
+
let likeName = null;
|
|
163
|
+
if (flags.like) {
|
|
164
|
+
likeName = flags.like;
|
|
165
|
+
const likeEntry = parsed.entries.find(e => e.name === likeName);
|
|
166
|
+
if (!likeEntry) {
|
|
167
|
+
die(`--like target '${likeName}' is not defined in type '${flags.type}'.`);
|
|
168
|
+
}
|
|
169
|
+
if (likeEntry.multiLine) {
|
|
170
|
+
die(`--like target '${likeName}' spans multiple lines in dotmd.config.mjs; this CLI only edits single-line entries.`);
|
|
171
|
+
}
|
|
172
|
+
baseProps = parseEntryProps(likeEntry.raw);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build final props by overlaying user flags on base.
|
|
176
|
+
const finalProps = { ...baseProps };
|
|
177
|
+
for (const [k, v] of Object.entries(flags.props)) finalProps[k] = v;
|
|
178
|
+
if (Object.keys(finalProps).length === 0) {
|
|
179
|
+
die(`Provide at least one flag (e.g. --context listed) or --like <existing>.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const overrideErr = checkLifecycleOverride(content, flags.ignoreLifecycle);
|
|
183
|
+
if (overrideErr) die(overrideErr);
|
|
184
|
+
|
|
185
|
+
// Determine insertion position: before first entry with terminal:true or archive:true.
|
|
186
|
+
let beforeName = null;
|
|
187
|
+
for (const e of parsed.entries) {
|
|
188
|
+
const p = parseEntryProps(e.raw);
|
|
189
|
+
if (p.terminal === true || p.archive === true) { beforeName = e.name; break; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const indent = inferIndent(content, parsed);
|
|
193
|
+
const newLine = renderEntryLine(name, finalProps, indent);
|
|
194
|
+
|
|
195
|
+
printAddDiff(name, flags.type, likeName, baseProps, finalProps, flags.props);
|
|
196
|
+
|
|
197
|
+
if (opts.dryRun) {
|
|
198
|
+
process.stdout.write(`${dim('[dry-run]')} would write to ${path.relative(process.cwd(), config.configPath)}\n`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!flags.yes && !await confirm()) {
|
|
203
|
+
process.stdout.write('Aborted.\n');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const updated = spliceEntry(content, parsed, newLine, beforeName);
|
|
208
|
+
await writeConfigAtomic(config.configPath, updated, config.configDir);
|
|
209
|
+
process.stdout.write(`${green('Added')} '${name}' to types.${flags.type}.statuses\n`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function printAddDiff(name, typeName, likeName, baseProps, finalProps, userProps) {
|
|
213
|
+
process.stdout.write(`${bold(`Adding '${name}' to types.${typeName}.statuses`)}\n`);
|
|
214
|
+
if (likeName) {
|
|
215
|
+
process.stdout.write(`Cloned from '${likeName}' (--like ${likeName}):\n`);
|
|
216
|
+
}
|
|
217
|
+
const allKeys = [...new Set([...Object.keys(baseProps), ...Object.keys(finalProps)])];
|
|
218
|
+
const keyOrder = FLAG_PROPS.filter(p => allKeys.includes(p)).concat(allKeys.filter(k => !FLAG_PROPS.includes(k)));
|
|
219
|
+
const labelW = Math.max(...keyOrder.map(k => k.length)) + 1;
|
|
220
|
+
for (const key of keyOrder) {
|
|
221
|
+
const before = baseProps[key];
|
|
222
|
+
const after = finalProps[key];
|
|
223
|
+
const beforeStr = before === undefined ? '—' : formatPropDisplay(before);
|
|
224
|
+
const afterStr = after === undefined ? '—' : formatPropDisplay(after);
|
|
225
|
+
let suffix;
|
|
226
|
+
if (beforeStr === afterStr) suffix = dim('(same)');
|
|
227
|
+
else if (before === undefined) suffix = dim(`(set by ${labelOrigin(key, userProps)})`);
|
|
228
|
+
else suffix = dim(`(${labelOrigin(key, userProps)})`);
|
|
229
|
+
const arrow = beforeStr === afterStr ? beforeStr : `${beforeStr} → ${afterStr}`;
|
|
230
|
+
process.stdout.write(` ${(key + ':').padEnd(labelW)} ${arrow.padEnd(28)} ${suffix}\n`);
|
|
231
|
+
}
|
|
232
|
+
process.stdout.write('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function labelOrigin(key, userProps) {
|
|
236
|
+
if (key in userProps) {
|
|
237
|
+
if (userProps.quiet === true && (key === 'skipStale' || key === 'skipWarnings') && !(key in userProps)) {
|
|
238
|
+
return 'added by --quiet';
|
|
239
|
+
}
|
|
240
|
+
return `--${key}`;
|
|
241
|
+
}
|
|
242
|
+
if (userProps.quiet === true && (key === 'skipStale' || key === 'skipWarnings')) {
|
|
243
|
+
return 'added by --quiet';
|
|
244
|
+
}
|
|
245
|
+
return 'inherited';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── set ─────────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
async function runSetStatus(args, config, opts) {
|
|
251
|
+
const flags = parseFlags(args, { allowProps: true });
|
|
252
|
+
const name = flags.positional[0];
|
|
253
|
+
if (!name) die('Usage: dotmd statuses set <name> --type <type> [flags]');
|
|
254
|
+
if (!flags.type) die('--type is required for `dotmd statuses set`.');
|
|
255
|
+
if (Object.keys(flags.props).length === 0) {
|
|
256
|
+
die('At least one flag is required (e.g. --quiet, --staleDays 30).');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
requireConfigPath(config);
|
|
260
|
+
const content = readFileSync(config.configPath, 'utf8');
|
|
261
|
+
const parsed = parseStatusesBlock(content, flags.type);
|
|
262
|
+
if (parsed.form === 'array') {
|
|
263
|
+
die(`Type '${flags.type}' uses array-form statuses. Run \`dotmd statuses migrate ${flags.type}\` first.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const existing = parsed.entries.find(e => e.name === name);
|
|
267
|
+
if (!existing) {
|
|
268
|
+
die(`Status '${name}' is not defined in type '${flags.type}'. Use \`dotmd statuses add\` to create it.`);
|
|
269
|
+
}
|
|
270
|
+
if (existing.multiLine) {
|
|
271
|
+
die(`Status '${name}' spans multiple lines in dotmd.config.mjs; edit it by hand.`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const oldProps = parseEntryProps(existing.raw);
|
|
275
|
+
const newProps = { ...oldProps };
|
|
276
|
+
for (const [k, v] of Object.entries(flags.props)) newProps[k] = v;
|
|
277
|
+
|
|
278
|
+
const overrideErr = checkLifecycleOverride(content, flags.ignoreLifecycle);
|
|
279
|
+
if (overrideErr) die(overrideErr);
|
|
280
|
+
|
|
281
|
+
const indent = (existing.raw.match(/^(\s*)/) ?? [''])[1] || ' ';
|
|
282
|
+
const newLine = renderEntryLine(name, newProps, indent);
|
|
283
|
+
|
|
284
|
+
printSetDiff(name, flags.type, oldProps, newProps, flags.props);
|
|
285
|
+
|
|
286
|
+
if (opts.dryRun) {
|
|
287
|
+
process.stdout.write(`${dim('[dry-run]')} would write to ${path.relative(process.cwd(), config.configPath)}\n`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!flags.yes && !await confirm()) {
|
|
291
|
+
process.stdout.write('Aborted.\n');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const updated = replaceEntry(content, parsed, name, newLine);
|
|
296
|
+
await writeConfigAtomic(config.configPath, updated, config.configDir);
|
|
297
|
+
process.stdout.write(`${green('Updated')} '${name}' in types.${flags.type}.statuses\n`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function printSetDiff(name, typeName, oldProps, newProps, userProps) {
|
|
301
|
+
process.stdout.write(`${bold(`Updating '${name}' in types.${typeName}.statuses`)}\n`);
|
|
302
|
+
const allKeys = [...new Set([...Object.keys(oldProps), ...Object.keys(newProps)])];
|
|
303
|
+
const keyOrder = FLAG_PROPS.filter(p => allKeys.includes(p)).concat(allKeys.filter(k => !FLAG_PROPS.includes(k)));
|
|
304
|
+
const labelW = Math.max(...keyOrder.map(k => k.length)) + 1;
|
|
305
|
+
for (const key of keyOrder) {
|
|
306
|
+
const before = oldProps[key];
|
|
307
|
+
const after = newProps[key];
|
|
308
|
+
const beforeStr = before === undefined ? '—' : formatPropDisplay(before);
|
|
309
|
+
const afterStr = after === undefined ? '—' : formatPropDisplay(after);
|
|
310
|
+
const changed = beforeStr !== afterStr;
|
|
311
|
+
const arrow = changed ? `${beforeStr} → ${afterStr}` : beforeStr;
|
|
312
|
+
const origin = key in userProps ? `--${key}` : 'unchanged';
|
|
313
|
+
const suffix = changed ? dim(`(${origin})`) : dim('(same)');
|
|
314
|
+
process.stdout.write(` ${(key + ':').padEnd(labelW)} ${arrow.padEnd(28)} ${suffix}\n`);
|
|
315
|
+
}
|
|
316
|
+
process.stdout.write('\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── remove ──────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
async function runRemoveStatus(args, config, opts) {
|
|
322
|
+
const flags = parseFlags(args, { allowProps: false });
|
|
323
|
+
const name = flags.positional[0];
|
|
324
|
+
if (!name) die('Usage: dotmd statuses remove <name> --type <type>');
|
|
325
|
+
if (!flags.type) die('--type is required for `dotmd statuses remove`.');
|
|
326
|
+
|
|
327
|
+
requireConfigPath(config);
|
|
328
|
+
const content = readFileSync(config.configPath, 'utf8');
|
|
329
|
+
const parsed = parseStatusesBlock(content, flags.type);
|
|
330
|
+
if (parsed.form === 'array') {
|
|
331
|
+
die(`Type '${flags.type}' uses array-form statuses. Run \`dotmd statuses migrate ${flags.type}\` first.`);
|
|
332
|
+
}
|
|
333
|
+
if (!parsed.entries.find(e => e.name === name)) {
|
|
334
|
+
die(`Status '${name}' is not defined in type '${flags.type}'.`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check for docs using this status
|
|
338
|
+
const offenders = findDocsByStatus(config, name);
|
|
339
|
+
if (offenders.length > 0) {
|
|
340
|
+
const list = offenders.slice(0, 10).map(p => ` - ${p}`).join('\n');
|
|
341
|
+
const more = offenders.length > 10 ? `\n ... and ${offenders.length - 10} more` : '';
|
|
342
|
+
die(`${offenders.length} doc(s) currently use status '${name}':\n${list}${more}\n\nMigrate them first: \`dotmd migrate status ${name} <other> [files...]\``);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Warn (don't refuse) if explicit lifecycle references the name.
|
|
346
|
+
const lifeRef = scanLifecycleReferences(content, name);
|
|
347
|
+
if (lifeRef.length > 0) {
|
|
348
|
+
process.stderr.write(yellow(`Warning: explicit lifecycle export references '${name}' in: ${lifeRef.join(', ')}. Update those manually.\n`));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const overrideErr = checkLifecycleOverride(content, flags.ignoreLifecycle);
|
|
352
|
+
if (overrideErr) die(overrideErr);
|
|
353
|
+
|
|
354
|
+
process.stdout.write(`${bold(`Removing '${name}' from types.${flags.type}.statuses`)}\n`);
|
|
355
|
+
if (opts.dryRun) {
|
|
356
|
+
process.stdout.write(`${dim('[dry-run]')} would write to ${path.relative(process.cwd(), config.configPath)}\n`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (!flags.yes && !await confirm()) {
|
|
360
|
+
process.stdout.write('Aborted.\n');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const updated = deleteEntry(content, parsed, name);
|
|
365
|
+
await writeConfigAtomic(config.configPath, updated, config.configDir);
|
|
366
|
+
process.stdout.write(`${green('Removed')} '${name}' from types.${flags.type}.statuses\n`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function findDocsByStatus(config, statusName) {
|
|
370
|
+
const offenders = [];
|
|
371
|
+
for (const filePath of collectDocFiles(config)) {
|
|
372
|
+
let raw;
|
|
373
|
+
try { raw = readFileSync(filePath, 'utf8'); }
|
|
374
|
+
catch { continue; }
|
|
375
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
376
|
+
if (!frontmatter) continue;
|
|
377
|
+
const fm = parseSimpleFrontmatter(frontmatter);
|
|
378
|
+
if (asString(fm.status) === statusName) {
|
|
379
|
+
offenders.push(toRepoPath(filePath, config.repoRoot));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return offenders;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function scanLifecycleReferences(content, name) {
|
|
386
|
+
// Simple text scan inside the lifecycle block for the name.
|
|
387
|
+
const m = content.match(/export\s+const\s+lifecycle\s*=\s*\{/);
|
|
388
|
+
if (!m) return [];
|
|
389
|
+
const start = m.index + m[0].length;
|
|
390
|
+
const end = findMatchingBrace(content, start - 1);
|
|
391
|
+
if (end === -1) return [];
|
|
392
|
+
const block = content.slice(start, end);
|
|
393
|
+
const buckets = ['archiveStatuses', 'skipStaleFor', 'skipWarningsFor', 'terminalStatuses'];
|
|
394
|
+
const found = [];
|
|
395
|
+
for (const b of buckets) {
|
|
396
|
+
const re = new RegExp(`${b}\\s*:[^\\]]*\\b${name}\\b`);
|
|
397
|
+
if (re.test(block)) found.push(b);
|
|
398
|
+
}
|
|
399
|
+
return found;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function findMatchingBrace(content, openPos) {
|
|
403
|
+
if (content[openPos] !== '{') return -1;
|
|
404
|
+
let depth = 1;
|
|
405
|
+
let i = openPos + 1;
|
|
406
|
+
while (i < content.length && depth > 0) {
|
|
407
|
+
const c = content[i];
|
|
408
|
+
if (c === '\'' || c === '"') {
|
|
409
|
+
const q = c;
|
|
410
|
+
i++;
|
|
411
|
+
while (i < content.length && content[i] !== q) {
|
|
412
|
+
if (content[i] === '\\') i++;
|
|
413
|
+
i++;
|
|
414
|
+
}
|
|
415
|
+
i++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (c === '{') depth++;
|
|
419
|
+
else if (c === '}') { depth--; if (depth === 0) return i; }
|
|
420
|
+
i++;
|
|
421
|
+
}
|
|
422
|
+
return -1;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─── migrate (array → rich) ──────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
async function runMigrateType(args, config, opts) {
|
|
428
|
+
const flags = parseFlags(args, { allowProps: false });
|
|
429
|
+
const typeName = flags.positional[0];
|
|
430
|
+
if (!typeName) die('Usage: dotmd statuses migrate <type>');
|
|
431
|
+
|
|
432
|
+
requireConfigPath(config);
|
|
433
|
+
const content = readFileSync(config.configPath, 'utf8');
|
|
434
|
+
const parsed = parseStatusesBlock(content, typeName);
|
|
435
|
+
|
|
436
|
+
if (parsed.form === 'object') {
|
|
437
|
+
process.stdout.write(`Type '${typeName}' is already in rich (object) form. Nothing to migrate.\n`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const typeDef = config.raw?.types?.[typeName];
|
|
442
|
+
if (!typeDef) die(`Type '${typeName}' not present in resolved config.`);
|
|
443
|
+
|
|
444
|
+
const statusList = parsed.entries.map(e => e.name);
|
|
445
|
+
const ctxByStatus = {};
|
|
446
|
+
for (const [bucket, names] of Object.entries(typeDef.context ?? {})) {
|
|
447
|
+
for (const n of names) ctxByStatus[n] = bucket;
|
|
448
|
+
}
|
|
449
|
+
const staleByStatus = typeDef.staleDays ?? {};
|
|
450
|
+
const moduleRequired = new Set(config.raw?.taxonomy?.moduleRequiredFor ?? []);
|
|
451
|
+
const lc = config.lifecycle;
|
|
452
|
+
|
|
453
|
+
// Determine indent
|
|
454
|
+
const openBracketPos = parsed.blockStart - 1;
|
|
455
|
+
const lineStart = lastNewlineIndexBefore(content, openBracketPos) + 1;
|
|
456
|
+
const lineLeading = content.slice(lineStart, openBracketPos).match(/^\s*/)[0];
|
|
457
|
+
const itemIndent = lineLeading + ' ';
|
|
458
|
+
|
|
459
|
+
const lines = ['{\n'];
|
|
460
|
+
for (const name of statusList) {
|
|
461
|
+
const props = {};
|
|
462
|
+
if (ctxByStatus[name]) props.context = ctxByStatus[name];
|
|
463
|
+
if (staleByStatus[name] != null) props.staleDays = staleByStatus[name];
|
|
464
|
+
if (moduleRequired.has(name)) props.requiresModule = true;
|
|
465
|
+
if (lc.archiveStatuses.has(name)) props.archive = true;
|
|
466
|
+
if (lc.terminalStatuses.has(name)) props.terminal = true;
|
|
467
|
+
// Apply quiet sugar when both skipStale and skipWarnings hold; otherwise emit the individual flag.
|
|
468
|
+
const skipStale = lc.skipStaleFor.has(name);
|
|
469
|
+
const skipWarnings = lc.skipWarningsFor.has(name);
|
|
470
|
+
if (skipStale && skipWarnings) props.quiet = true;
|
|
471
|
+
else {
|
|
472
|
+
if (skipStale) props.skipStale = true;
|
|
473
|
+
if (skipWarnings) props.skipWarnings = true;
|
|
474
|
+
}
|
|
475
|
+
lines.push(renderEntryLine(name, props, itemIndent));
|
|
476
|
+
}
|
|
477
|
+
lines.push(lineLeading + '}');
|
|
478
|
+
|
|
479
|
+
const newBlock = lines.join('');
|
|
480
|
+
let updatedContent = content.slice(0, openBracketPos) + newBlock + content.slice(parsed.blockEnd + 1);
|
|
481
|
+
|
|
482
|
+
// The peer `context` and `staleDays` blocks inside types.<typeName> shadow the
|
|
483
|
+
// rich-form flags at runtime (config.mjs preserves explicit user values over
|
|
484
|
+
// derived ones). Removing them is part of the conversion: they were the
|
|
485
|
+
// array-form's source of truth and are now structurally redundant.
|
|
486
|
+
const cleanup = removePeerBlocks(updatedContent, typeName);
|
|
487
|
+
updatedContent = cleanup.content;
|
|
488
|
+
|
|
489
|
+
process.stdout.write(`${bold(`Migrating types.${typeName}.statuses to rich form`)}\n`);
|
|
490
|
+
process.stdout.write(` ${statusList.length} status(es): ${statusList.join(', ')}\n`);
|
|
491
|
+
if (cleanup.removed.length > 0) {
|
|
492
|
+
process.stdout.write(dim(` removing redundant peer block(s): ${cleanup.removed.join(', ')}\n`));
|
|
493
|
+
}
|
|
494
|
+
process.stdout.write('\n');
|
|
495
|
+
|
|
496
|
+
const overrideErr = checkLifecycleOverride(content, flags.ignoreLifecycle);
|
|
497
|
+
if (overrideErr) die(overrideErr);
|
|
498
|
+
|
|
499
|
+
if (opts.dryRun) {
|
|
500
|
+
process.stdout.write(`${dim('[dry-run]')} would write to ${path.relative(process.cwd(), config.configPath)}\n`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (!flags.yes && !await confirm()) {
|
|
504
|
+
process.stdout.write('Aborted.\n');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await writeConfigAtomic(config.configPath, updatedContent, config.configDir);
|
|
509
|
+
process.stdout.write(`${green('Migrated')} types.${typeName}.statuses to rich form.\n`);
|
|
510
|
+
if (config.raw?.taxonomy?.moduleRequiredFor) {
|
|
511
|
+
process.stdout.write(dim(`Note: \`taxonomy.moduleRequiredFor\` is now also derived from per-status flags. You can remove it from your config (the rich form contains the same information).\n`));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Locate and remove `context: {...}` and `staleDays: {...}` peer blocks
|
|
516
|
+
// inside types.<typeName>. Returns { content, removed: [<keys>] }.
|
|
517
|
+
function removePeerBlocks(content, typeName) {
|
|
518
|
+
const removed = [];
|
|
519
|
+
let working = content;
|
|
520
|
+
for (const key of ['context', 'staleDays']) {
|
|
521
|
+
const r = removeOnePeerBlock(working, typeName, key);
|
|
522
|
+
if (r) { working = r; removed.push(key); }
|
|
523
|
+
}
|
|
524
|
+
return { content: working, removed };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function removeOnePeerBlock(content, typeName, key) {
|
|
528
|
+
// Find types.<typeName>.<key> and delete its property line(s) including
|
|
529
|
+
// trailing comma/newline. Use a string scan with a small state machine.
|
|
530
|
+
const typesIdx = content.search(/(^|[^A-Za-z0-9_$])types\s*[:=]\s*\{/);
|
|
531
|
+
if (typesIdx < 0) return null;
|
|
532
|
+
// Walk to find <typeName>: { (similar to parseStatusesBlock helpers, but
|
|
533
|
+
// simplified — we only need rough boundaries).
|
|
534
|
+
const typeRe = new RegExp(`(['"]?)${escapeForRegex(typeName)}\\1\\s*:\\s*\\{`);
|
|
535
|
+
const tm = typeRe.exec(content);
|
|
536
|
+
if (!tm) return null;
|
|
537
|
+
const typeStart = tm.index + tm[0].length - 1; // points at `{`
|
|
538
|
+
const typeEnd = matchBraceClose(content, typeStart);
|
|
539
|
+
if (typeEnd < 0) return null;
|
|
540
|
+
// Find the property
|
|
541
|
+
const propRe = new RegExp(`\\n([ \\t]*)${escapeForRegex(key)}\\s*:\\s*\\{`);
|
|
542
|
+
propRe.lastIndex = typeStart;
|
|
543
|
+
const pm = propRe.exec(content.slice(typeStart, typeEnd));
|
|
544
|
+
if (!pm) return null;
|
|
545
|
+
const propStartRel = pm.index + 1; // after the leading newline
|
|
546
|
+
const propStart = typeStart + propStartRel;
|
|
547
|
+
// Find the matching `{` … `}` for the value
|
|
548
|
+
const valOpen = content.indexOf('{', propStart);
|
|
549
|
+
const valClose = matchBraceClose(content, valOpen);
|
|
550
|
+
if (valClose < 0) return null;
|
|
551
|
+
// Eat trailing whitespace, optional comma, optional inline comment, and the
|
|
552
|
+
// line's terminating newline so we don't leave a blank line.
|
|
553
|
+
let after = valClose + 1;
|
|
554
|
+
while (content[after] === ' ' || content[after] === '\t') after++;
|
|
555
|
+
if (content[after] === ',') after++;
|
|
556
|
+
while (content[after] === ' ' || content[after] === '\t') after++;
|
|
557
|
+
if (content[after] === '\n') after++;
|
|
558
|
+
// Eat the leading whitespace at propStart so we delete the full line.
|
|
559
|
+
let before = propStart;
|
|
560
|
+
while (content[before - 1] === ' ' || content[before - 1] === '\t') before--;
|
|
561
|
+
return content.slice(0, before) + content.slice(after);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function matchBraceClose(content, openPos) {
|
|
565
|
+
if (content[openPos] !== '{') return -1;
|
|
566
|
+
let depth = 1;
|
|
567
|
+
let i = openPos + 1;
|
|
568
|
+
while (i < content.length && depth > 0) {
|
|
569
|
+
const c = content[i];
|
|
570
|
+
if (c === '\'' || c === '"' || c === '`') {
|
|
571
|
+
const q = c;
|
|
572
|
+
i++;
|
|
573
|
+
while (i < content.length && content[i] !== q) {
|
|
574
|
+
if (content[i] === '\\') i++;
|
|
575
|
+
i++;
|
|
576
|
+
}
|
|
577
|
+
i++;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (c === '{' || c === '[') depth++;
|
|
581
|
+
else if (c === '}' || c === ']') { depth--; if (depth === 0) return i; }
|
|
582
|
+
i++;
|
|
583
|
+
}
|
|
584
|
+
return -1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function escapeForRegex(s) {
|
|
588
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function lastNewlineIndexBefore(content, pos) {
|
|
592
|
+
return content.lastIndexOf('\n', pos - 1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ─── shared helpers ──────────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
function requireConfigPath(config) {
|
|
598
|
+
if (!config.configPath || !existsSync(config.configPath)) {
|
|
599
|
+
die(`No dotmd.config.mjs found in ${process.cwd()} — run \`dotmd init\` first.`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function checkLifecycleOverride(content, ignoreFlag) {
|
|
604
|
+
if (!hasExplicitLifecycle(content)) return null;
|
|
605
|
+
if (ignoreFlag) return null;
|
|
606
|
+
return [
|
|
607
|
+
'Your config has an explicit `lifecycle` block, which overrides the per-status flags this CLI edits.',
|
|
608
|
+
'The new flags will be written but won\'t take effect at runtime until you either:',
|
|
609
|
+
' (a) remove the explicit `lifecycle` block (recommended with rich-form types), or',
|
|
610
|
+
' (b) update lifecycle.<bucket> manually to include the new status.',
|
|
611
|
+
'',
|
|
612
|
+
'Re-run with --ignore-lifecycle-override to write anyway.',
|
|
613
|
+
].join('\n');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function confirm() {
|
|
617
|
+
if (!isInteractive()) return false;
|
|
618
|
+
const ans = await promptText('Apply? [y/N] ');
|
|
619
|
+
return ans.toLowerCase() === 'y' || ans.toLowerCase() === 'yes';
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Parse the inner `{...}` of an entry line into a flag object.
|
|
623
|
+
function parseEntryProps(line) {
|
|
624
|
+
const open = line.indexOf('{');
|
|
625
|
+
const close = line.lastIndexOf('}');
|
|
626
|
+
if (open === -1 || close === -1 || close < open) return {};
|
|
627
|
+
const inner = line.slice(open + 1, close);
|
|
628
|
+
const props = {};
|
|
629
|
+
for (const part of splitTopLevelCommas(inner)) {
|
|
630
|
+
const colon = findUnquotedColon(part);
|
|
631
|
+
if (colon === -1) continue;
|
|
632
|
+
let key = part.slice(0, colon).trim();
|
|
633
|
+
if ((key.startsWith('\'') && key.endsWith('\'')) || (key.startsWith('"') && key.endsWith('"'))) {
|
|
634
|
+
key = key.slice(1, -1);
|
|
635
|
+
}
|
|
636
|
+
const valRaw = part.slice(colon + 1).trim();
|
|
637
|
+
props[key] = parseScalar(valRaw);
|
|
638
|
+
}
|
|
639
|
+
return props;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function findUnquotedColon(s) {
|
|
643
|
+
let i = 0;
|
|
644
|
+
while (i < s.length) {
|
|
645
|
+
const c = s[i];
|
|
646
|
+
if (c === '\'' || c === '"' || c === '`') {
|
|
647
|
+
i++;
|
|
648
|
+
while (i < s.length && s[i] !== c) { if (s[i] === '\\') i++; i++; }
|
|
649
|
+
i++;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (c === ':') return i;
|
|
653
|
+
i++;
|
|
654
|
+
}
|
|
655
|
+
return -1;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function splitTopLevelCommas(s) {
|
|
659
|
+
const out = [];
|
|
660
|
+
let depth = 0;
|
|
661
|
+
let start = 0;
|
|
662
|
+
let i = 0;
|
|
663
|
+
while (i < s.length) {
|
|
664
|
+
const c = s[i];
|
|
665
|
+
if (c === '\'' || c === '"' || c === '`') {
|
|
666
|
+
i++;
|
|
667
|
+
while (i < s.length && s[i] !== c) { if (s[i] === '\\') i++; i++; }
|
|
668
|
+
i++;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (c === '{' || c === '[' || c === '(') { depth++; i++; continue; }
|
|
672
|
+
if (c === '}' || c === ']' || c === ')') { depth--; i++; continue; }
|
|
673
|
+
if (c === ',' && depth === 0) { out.push(s.slice(start, i)); start = i + 1; }
|
|
674
|
+
i++;
|
|
675
|
+
}
|
|
676
|
+
if (start < s.length) {
|
|
677
|
+
const tail = s.slice(start).trim();
|
|
678
|
+
if (tail) out.push(s.slice(start));
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function parseScalar(s) {
|
|
684
|
+
if (s === 'true') return true;
|
|
685
|
+
if (s === 'false') return false;
|
|
686
|
+
if (s === 'null') return null;
|
|
687
|
+
if (s === 'undefined') return undefined;
|
|
688
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
689
|
+
if (s.startsWith('\'') && s.endsWith('\'')) return s.slice(1, -1);
|
|
690
|
+
if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1);
|
|
691
|
+
if (s.startsWith('`') && s.endsWith('`')) return s.slice(1, -1);
|
|
692
|
+
return s;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ─── flag parser ─────────────────────────────────────────────────────────────
|
|
696
|
+
|
|
697
|
+
function parseFlags(args, { allowProps }) {
|
|
698
|
+
const out = { positional: [], props: {} };
|
|
699
|
+
for (let i = 0; i < args.length; i++) {
|
|
700
|
+
const a = args[i];
|
|
701
|
+
if (a === '--type') { out.type = args[++i]; continue; }
|
|
702
|
+
if (a === '--like') { out.like = args[++i]; continue; }
|
|
703
|
+
if (a === '--yes' || a === '-y') { out.yes = true; continue; }
|
|
704
|
+
if (a === '--ignore-lifecycle-override') { out.ignoreLifecycle = true; continue; }
|
|
705
|
+
if (a === '--json') { out.json = true; continue; }
|
|
706
|
+
|
|
707
|
+
if (allowProps) {
|
|
708
|
+
if (a === '--context') {
|
|
709
|
+
const v = args[++i];
|
|
710
|
+
if (!['expanded', 'listed', 'counted'].includes(v)) {
|
|
711
|
+
die(`--context must be one of: expanded, listed, counted (got '${v}')`);
|
|
712
|
+
}
|
|
713
|
+
out.props.context = v;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (a === '--staleDays') {
|
|
717
|
+
const v = args[++i];
|
|
718
|
+
if (v === 'null') { out.props.staleDays = null; continue; }
|
|
719
|
+
const n = Number(v);
|
|
720
|
+
if (!Number.isFinite(n)) die(`--staleDays must be a number or 'null' (got '${v}')`);
|
|
721
|
+
out.props.staleDays = n;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
let matched = false;
|
|
725
|
+
for (const f of BOOLEAN_FLAGS) {
|
|
726
|
+
if (a === '--' + f) { out.props[f] = true; matched = true; break; }
|
|
727
|
+
if (a === '--no-' + f) { out.props[f] = false; matched = true; break; }
|
|
728
|
+
}
|
|
729
|
+
if (matched) continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (a.startsWith('-')) die(`Unknown flag: ${a}`);
|
|
733
|
+
out.positional.push(a);
|
|
734
|
+
}
|
|
735
|
+
return out;
|
|
736
|
+
}
|