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