recursive-board 0.1.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/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/wi/wi.js +1551 -0
- package/package.json +38 -0
package/dist/wi/wi.js
ADDED
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/wi.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { resolve as resolve2 } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// src/cli/vault.ts
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join, resolve as resolvePath, dirname, basename, sep } from "node:path";
|
|
12
|
+
|
|
13
|
+
// src/shared/frontmatter.ts
|
|
14
|
+
var KEY_LINE = /^([A-Za-z_][\w.-]*)\s*:(?:[ \t]+(.*))?$/;
|
|
15
|
+
function splitBlock(text) {
|
|
16
|
+
if (!text.startsWith("---")) return null;
|
|
17
|
+
const first = /^---[ \t]*\r?\n/.exec(text);
|
|
18
|
+
if (!first) return null;
|
|
19
|
+
const lines = [];
|
|
20
|
+
const ends = [];
|
|
21
|
+
const contentStart = first[0].length;
|
|
22
|
+
let cursor = contentStart;
|
|
23
|
+
while (cursor < text.length) {
|
|
24
|
+
const nl = text.indexOf("\n", cursor);
|
|
25
|
+
const hasNewline = nl !== -1;
|
|
26
|
+
const lineEnd = hasNewline ? nl + 1 : text.length;
|
|
27
|
+
const raw = text.slice(cursor, lineEnd);
|
|
28
|
+
const line2 = raw.replace(/\r?\n$/, "");
|
|
29
|
+
const end = raw.slice(line2.length);
|
|
30
|
+
if (/^---[ \t]*$/.test(line2)) {
|
|
31
|
+
return { lines, ends, contentStart, contentEnd: cursor };
|
|
32
|
+
}
|
|
33
|
+
lines.push(line2);
|
|
34
|
+
ends.push(end);
|
|
35
|
+
cursor = lineEnd;
|
|
36
|
+
if (!hasNewline) break;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function readEntries(lines) {
|
|
41
|
+
const entries = [];
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const match = KEY_LINE.exec(lines[i]);
|
|
44
|
+
if (!match) continue;
|
|
45
|
+
const [, key, rest] = match;
|
|
46
|
+
const value = rest === void 0 || rest.trim() === "" ? void 0 : parseScalar(rest);
|
|
47
|
+
let end = i + 1;
|
|
48
|
+
if (value === void 0) {
|
|
49
|
+
while (end < lines.length && (/^[ \t]/.test(lines[end]) || lines[end].trim() === "")) end++;
|
|
50
|
+
}
|
|
51
|
+
entries.push({ key, start: i, end, value });
|
|
52
|
+
i = end - 1;
|
|
53
|
+
}
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
function parseScalar(raw) {
|
|
57
|
+
const text = raw.trim();
|
|
58
|
+
if (text === "") return void 0;
|
|
59
|
+
if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
|
|
60
|
+
return text.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
61
|
+
}
|
|
62
|
+
if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) {
|
|
63
|
+
return text.slice(1, -1).replace(/''/g, "'");
|
|
64
|
+
}
|
|
65
|
+
if (text === "true") return true;
|
|
66
|
+
if (text === "false") return false;
|
|
67
|
+
if (/^-?\d+$/.test(text)) return Number(text);
|
|
68
|
+
if (/^-?\d*\.\d+$/.test(text)) return Number(text);
|
|
69
|
+
if (text.startsWith("[") || text.startsWith("{")) return void 0;
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
function needsQuotes(text) {
|
|
73
|
+
if (text === "") return true;
|
|
74
|
+
if (text !== text.trim()) return true;
|
|
75
|
+
if (/^[[\]{}#&*!|>'"%@`,?-]/.test(text)) return true;
|
|
76
|
+
if (text.includes(": ") || text.includes(" #") || /[\r\n]/.test(text)) return true;
|
|
77
|
+
if (text.endsWith(":")) return true;
|
|
78
|
+
if (/["'\\]/.test(text)) return true;
|
|
79
|
+
if (/^(true|false|null|~|yes|no|on|off)$/i.test(text)) return true;
|
|
80
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
function formatScalar(value) {
|
|
84
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
85
|
+
if (typeof value === "number") return String(value);
|
|
86
|
+
if (!needsQuotes(value)) return value;
|
|
87
|
+
return `"${value.replace(/([\\"])/g, "\\$1")}"`;
|
|
88
|
+
}
|
|
89
|
+
function parseFrontmatter(text) {
|
|
90
|
+
const block = splitBlock(text);
|
|
91
|
+
if (!block) return null;
|
|
92
|
+
const entries = readEntries(block.lines);
|
|
93
|
+
const byKey = new Map(entries.map((e) => [e.key, e]));
|
|
94
|
+
return {
|
|
95
|
+
entries,
|
|
96
|
+
keys: () => entries.map((e) => e.key),
|
|
97
|
+
has: (key) => byKey.has(key),
|
|
98
|
+
get: (key) => byKey.get(key)?.value,
|
|
99
|
+
entry: (key) => byKey.get(key)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function rewrite(text, block, lines, ends) {
|
|
103
|
+
const head = text.slice(0, block.contentStart);
|
|
104
|
+
const tail = text.slice(block.contentEnd);
|
|
105
|
+
const middle = lines.map((line2, i) => line2 + (ends[i] ?? "\n")).join("");
|
|
106
|
+
return head + middle + tail;
|
|
107
|
+
}
|
|
108
|
+
function setKey(text, key, value) {
|
|
109
|
+
const block = splitBlock(text);
|
|
110
|
+
if (!block) throw new Error(`cannot set "${key}": the file has no frontmatter`);
|
|
111
|
+
const entry = readEntries(block.lines).find((e) => e.key === key);
|
|
112
|
+
const line2 = `${key}: ${formatScalar(value)}`;
|
|
113
|
+
const lines = [...block.lines];
|
|
114
|
+
const ends = [...block.ends];
|
|
115
|
+
if (entry) {
|
|
116
|
+
lines.splice(entry.start, entry.end - entry.start, line2);
|
|
117
|
+
ends.splice(entry.start, entry.end - entry.start, ends[entry.start] ?? "\n");
|
|
118
|
+
} else {
|
|
119
|
+
lines.push(line2);
|
|
120
|
+
ends.push(ends[ends.length - 1] ?? defaultEnd(text));
|
|
121
|
+
}
|
|
122
|
+
return rewrite(text, block, lines, ends);
|
|
123
|
+
}
|
|
124
|
+
function removeKey(text, key) {
|
|
125
|
+
const block = splitBlock(text);
|
|
126
|
+
if (!block) return text;
|
|
127
|
+
const entry = readEntries(block.lines).find((e) => e.key === key);
|
|
128
|
+
if (!entry) return text;
|
|
129
|
+
const lines = [...block.lines];
|
|
130
|
+
const ends = [...block.ends];
|
|
131
|
+
lines.splice(entry.start, entry.end - entry.start);
|
|
132
|
+
ends.splice(entry.start, entry.end - entry.start);
|
|
133
|
+
return rewrite(text, block, lines, ends);
|
|
134
|
+
}
|
|
135
|
+
function defaultEnd(text) {
|
|
136
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/shared/schema.ts
|
|
140
|
+
var STATUSES = ["backlog", "options", "doing", "done"];
|
|
141
|
+
var CORE_FIELDS = [
|
|
142
|
+
"type",
|
|
143
|
+
"id",
|
|
144
|
+
"title",
|
|
145
|
+
"status",
|
|
146
|
+
"parent",
|
|
147
|
+
"created",
|
|
148
|
+
"updated",
|
|
149
|
+
"board",
|
|
150
|
+
"prev_status"
|
|
151
|
+
];
|
|
152
|
+
var OPTIONAL_FIELDS = [
|
|
153
|
+
"owner",
|
|
154
|
+
"agent",
|
|
155
|
+
"priority",
|
|
156
|
+
"due",
|
|
157
|
+
"blocked",
|
|
158
|
+
"depends_on",
|
|
159
|
+
"tags",
|
|
160
|
+
"archived"
|
|
161
|
+
// docs/adr/0007-archive-is-a-frontmatter-flag.md: a flag, not a status.
|
|
162
|
+
];
|
|
163
|
+
var INHERITED_FIELDS = ["owner", "agent"];
|
|
164
|
+
var WORK_ITEM_TYPE = "work-item";
|
|
165
|
+
var FOLDERS = ["Boards", "Templates"];
|
|
166
|
+
var BOARDS = "Boards";
|
|
167
|
+
function isStatus(value) {
|
|
168
|
+
return typeof value === "string" && STATUSES.includes(value);
|
|
169
|
+
}
|
|
170
|
+
function parseWikilink(value) {
|
|
171
|
+
if (typeof value !== "string") return null;
|
|
172
|
+
const match = /^\s*\[\[([^\]]+)\]\]\s*$/.exec(value);
|
|
173
|
+
if (!match) return null;
|
|
174
|
+
const target = match[1].split("|")[0].split("#")[0].trim();
|
|
175
|
+
return target === "" ? null : target;
|
|
176
|
+
}
|
|
177
|
+
function formatWikilink(target) {
|
|
178
|
+
return `[[${target}]]`;
|
|
179
|
+
}
|
|
180
|
+
var UNSAFE_IN_FILENAME = /[\\/:*?"<>|[\]#^]/g;
|
|
181
|
+
function fileNameStem(title) {
|
|
182
|
+
const stem = title.replace(UNSAFE_IN_FILENAME, "-").replace(/\s+/g, " ").replace(/^[\s.]+|[\s.]+$/g, "");
|
|
183
|
+
return stem === "" ? "Untitled" : stem;
|
|
184
|
+
}
|
|
185
|
+
function idSuffix(id) {
|
|
186
|
+
return id.startsWith("wi-") ? id.slice(3) : id;
|
|
187
|
+
}
|
|
188
|
+
function fileNameFor(title, id, taken) {
|
|
189
|
+
const stem = fileNameStem(title);
|
|
190
|
+
if (!taken.has(stem.toLowerCase())) return stem;
|
|
191
|
+
return `${stem}--${idSuffix(id)}`;
|
|
192
|
+
}
|
|
193
|
+
var ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
194
|
+
function newId(taken, random = Math.random) {
|
|
195
|
+
for (let length = 4; length <= 12; length++) {
|
|
196
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
197
|
+
let short = "";
|
|
198
|
+
for (let i = 0; i < length; i++) {
|
|
199
|
+
short += ID_ALPHABET[Math.floor(random() * ID_ALPHABET.length)];
|
|
200
|
+
}
|
|
201
|
+
const id = `wi-${short}`;
|
|
202
|
+
if (!taken.has(id)) return id;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error("could not mint an unused work item id");
|
|
206
|
+
}
|
|
207
|
+
function today(now = /* @__PURE__ */ new Date()) {
|
|
208
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
209
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/shared/vault-config.ts
|
|
213
|
+
var WI_CONFIG_FILE = ".wi.json";
|
|
214
|
+
var DEFAULT_VAULT_CONFIG = {
|
|
215
|
+
workItemFolder: "Boards",
|
|
216
|
+
defaultRoot: null,
|
|
217
|
+
extraSections: []
|
|
218
|
+
};
|
|
219
|
+
function parseVaultConfig(text) {
|
|
220
|
+
if (text === null) return { ...DEFAULT_VAULT_CONFIG, extraSections: [] };
|
|
221
|
+
let value;
|
|
222
|
+
try {
|
|
223
|
+
value = JSON.parse(text);
|
|
224
|
+
} catch {
|
|
225
|
+
throw new Error(`${WI_CONFIG_FILE} must contain valid JSON.`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
228
|
+
throw new Error(`${WI_CONFIG_FILE} must contain a JSON object.`);
|
|
229
|
+
}
|
|
230
|
+
const folderValue = "workItemFolder" in value ? value.workItemFolder : void 0;
|
|
231
|
+
const rootValue = "defaultRoot" in value ? value.defaultRoot : void 0;
|
|
232
|
+
const sectionsValue = "extraSections" in value ? value.extraSections : void 0;
|
|
233
|
+
let workItemFolder = DEFAULT_VAULT_CONFIG.workItemFolder;
|
|
234
|
+
if (folderValue !== void 0) {
|
|
235
|
+
if (typeof folderValue !== "string") {
|
|
236
|
+
throw new Error(`${WI_CONFIG_FILE}: workItemFolder must be a vault-relative folder path.`);
|
|
237
|
+
}
|
|
238
|
+
workItemFolder = folderValue.replace(/\/$/, "");
|
|
239
|
+
if (workItemFolder === "" || workItemFolder.startsWith("/") || workItemFolder.split("/").some((part) => part === "" || part === "." || part === "..") || /[\\:\0]/.test(workItemFolder)) {
|
|
240
|
+
throw new Error(`${WI_CONFIG_FILE}: workItemFolder must be a vault-relative folder path.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let defaultRoot = null;
|
|
244
|
+
if (rootValue !== void 0 && rootValue !== null) {
|
|
245
|
+
if (typeof rootValue !== "string" || rootValue === "" || fileNameStem(rootValue) !== rootValue) {
|
|
246
|
+
throw new Error(`${WI_CONFIG_FILE}: defaultRoot must be a work-item filename stem.`);
|
|
247
|
+
}
|
|
248
|
+
defaultRoot = rootValue;
|
|
249
|
+
}
|
|
250
|
+
const extraSections = [];
|
|
251
|
+
if (sectionsValue !== void 0) {
|
|
252
|
+
if (!Array.isArray(sectionsValue)) {
|
|
253
|
+
throw new Error(`${WI_CONFIG_FILE}: extraSections must be an array of non-empty, single-line headings.`);
|
|
254
|
+
}
|
|
255
|
+
const headings = sectionsValue;
|
|
256
|
+
for (const heading of headings) {
|
|
257
|
+
if (typeof heading !== "string" || heading.trim() === "" || /[\r\n]/.test(heading)) {
|
|
258
|
+
throw new Error(`${WI_CONFIG_FILE}: extraSections must be an array of non-empty, single-line headings.`);
|
|
259
|
+
}
|
|
260
|
+
extraSections.push(heading);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { workItemFolder, defaultRoot, extraSections };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/shared/archive.ts
|
|
267
|
+
function archiveEdits(archived, toArchived) {
|
|
268
|
+
if (archived === toArchived) return null;
|
|
269
|
+
return toArchived ? [{ op: "set", key: "archived", value: true }] : [{ op: "remove", key: "archived" }];
|
|
270
|
+
}
|
|
271
|
+
function archiveOwner(item, parentOf, flagged) {
|
|
272
|
+
const seen = /* @__PURE__ */ new Set();
|
|
273
|
+
for (let current = item; current !== null; current = parentOf(current)) {
|
|
274
|
+
if (seen.has(current)) break;
|
|
275
|
+
seen.add(current);
|
|
276
|
+
if (flagged(current)) return current;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
function activeDescendant(item, childrenOf, statusOf) {
|
|
281
|
+
const seen = /* @__PURE__ */ new Set([item]);
|
|
282
|
+
const queue = [...childrenOf(item)];
|
|
283
|
+
for (let i = 0; i < queue.length; i++) {
|
|
284
|
+
const child = queue[i];
|
|
285
|
+
if (seen.has(child)) continue;
|
|
286
|
+
seen.add(child);
|
|
287
|
+
if (statusOf(child) === "doing") return child;
|
|
288
|
+
queue.push(...childrenOf(child));
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/cli/vault.ts
|
|
294
|
+
var MARKDOWN = /\.md$/i;
|
|
295
|
+
var IGNORED_HIDDEN = /* @__PURE__ */ new Set([".DS_Store", ".localized", ".gitkeep"]);
|
|
296
|
+
function isUnaccounted(name) {
|
|
297
|
+
if (!name.startsWith(".")) return false;
|
|
298
|
+
if (MARKDOWN.test(name)) return false;
|
|
299
|
+
return !IGNORED_HIDDEN.has(name);
|
|
300
|
+
}
|
|
301
|
+
function findVaultRoot(start) {
|
|
302
|
+
let dir = resolvePath(start);
|
|
303
|
+
for (; ; ) {
|
|
304
|
+
if (existsSync(join(dir, WI_CONFIG_FILE)) || existsSync(join(dir, BOARDS))) return dir;
|
|
305
|
+
const parent = dirname(dir);
|
|
306
|
+
if (parent === dir) return null;
|
|
307
|
+
dir = parent;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function scan(root, workItemFolder) {
|
|
311
|
+
const markdown = [];
|
|
312
|
+
const unaccounted = [];
|
|
313
|
+
const misplaced = [];
|
|
314
|
+
for (const folder of [workItemFolder, ...FOLDERS.filter((name) => name !== BOARDS && name !== workItemFolder && !workItemFolder.startsWith(`${name}/`))]) {
|
|
315
|
+
const dir = join(root, folder);
|
|
316
|
+
let entries;
|
|
317
|
+
try {
|
|
318
|
+
entries = await readdir(dir, { withFileTypes: true, recursive: true });
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
const parent = (entry.parentPath ?? dir).slice(root.length + 1).split(sep).join("/");
|
|
324
|
+
const rel = `${parent}/${entry.name}`;
|
|
325
|
+
if (entry.isDirectory()) continue;
|
|
326
|
+
if (folder === BOARDS && isUnaccounted(entry.name)) {
|
|
327
|
+
unaccounted.push(rel);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!MARKDOWN.test(entry.name)) continue;
|
|
331
|
+
if (parent !== folder) {
|
|
332
|
+
misplaced.push(rel);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
markdown.push(rel);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { markdown, unaccounted, misplaced };
|
|
339
|
+
}
|
|
340
|
+
function toWorkItem(root, relPath, text) {
|
|
341
|
+
const frontmatter = parseFrontmatter(text);
|
|
342
|
+
if (!frontmatter) return null;
|
|
343
|
+
if (frontmatter.get("type") !== WORK_ITEM_TYPE) return null;
|
|
344
|
+
const id = frontmatter.get("id");
|
|
345
|
+
const title = frontmatter.get("title");
|
|
346
|
+
const status = frontmatter.get("status");
|
|
347
|
+
const parentRaw = frontmatter.get("parent");
|
|
348
|
+
return {
|
|
349
|
+
path: join(root, ...relPath.split("/")),
|
|
350
|
+
relPath,
|
|
351
|
+
stem: basename(relPath).replace(MARKDOWN, ""),
|
|
352
|
+
id: typeof id === "string" ? id : void 0,
|
|
353
|
+
title: typeof title === "string" && title !== "" ? title : void 0,
|
|
354
|
+
status: isStatus(status) ? status : void 0,
|
|
355
|
+
parent: parseWikilink(parentRaw),
|
|
356
|
+
parentRaw: typeof parentRaw === "string" ? parentRaw : void 0,
|
|
357
|
+
board: frontmatter.get("board") === true,
|
|
358
|
+
archived: frontmatter.get("archived") === true,
|
|
359
|
+
frontmatter,
|
|
360
|
+
text
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function compareSiblings(a, b) {
|
|
364
|
+
const priority = (i) => {
|
|
365
|
+
const value = i.frontmatter.get("priority");
|
|
366
|
+
return typeof value === "number" ? value : Number.POSITIVE_INFINITY;
|
|
367
|
+
};
|
|
368
|
+
const updated = (i) => {
|
|
369
|
+
const value = i.frontmatter.get("updated");
|
|
370
|
+
return typeof value === "string" ? value : "";
|
|
371
|
+
};
|
|
372
|
+
return priority(a) - priority(b) || updated(b).localeCompare(updated(a)) || a.stem.localeCompare(b.stem);
|
|
373
|
+
}
|
|
374
|
+
function linkKey(target) {
|
|
375
|
+
const last = target.split("/").pop() ?? target;
|
|
376
|
+
return last.replace(MARKDOWN, "").trim().toLowerCase();
|
|
377
|
+
}
|
|
378
|
+
function requireAccountedTree(vault, what) {
|
|
379
|
+
const files = vault.unaccounted;
|
|
380
|
+
if (files.length === 0) return;
|
|
381
|
+
const names = files.slice(0, 3).join(", ");
|
|
382
|
+
const more = files.length > 3 ? `, and ${files.length - 3} more` : "";
|
|
383
|
+
const one = files.length === 1;
|
|
384
|
+
throw new Error(
|
|
385
|
+
`cannot ${what}: ${names}${more} ${one ? "is" : "are"} not accounted for, so the tree is not fully visible. Let the sync client download ` + (one ? "it" : "them") + " if it is a work item, or delete it if it is a stray file, then retry."
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
async function loadVault(root) {
|
|
389
|
+
let configText = null;
|
|
390
|
+
try {
|
|
391
|
+
configText = await readFile(join(root, WI_CONFIG_FILE), "utf8");
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!isMissingFile(error)) throw error;
|
|
394
|
+
}
|
|
395
|
+
const config = parseVaultConfig(configText);
|
|
396
|
+
const { markdown, unaccounted, misplaced } = await scan(root, config.workItemFolder);
|
|
397
|
+
const items = [];
|
|
398
|
+
const nonItems = [];
|
|
399
|
+
for (const relPath of markdown) {
|
|
400
|
+
if (!relPath.startsWith(`${config.workItemFolder}/`)) continue;
|
|
401
|
+
const text = await readFile(join(root, ...relPath.split("/")), "utf8");
|
|
402
|
+
const item = toWorkItem(root, relPath, text);
|
|
403
|
+
if (item) items.push(item);
|
|
404
|
+
else nonItems.push(relPath);
|
|
405
|
+
}
|
|
406
|
+
nonItems.sort();
|
|
407
|
+
items.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
408
|
+
const byId = /* @__PURE__ */ new Map();
|
|
409
|
+
const duplicateIds = [];
|
|
410
|
+
for (const item of items) {
|
|
411
|
+
if (item.id === void 0) continue;
|
|
412
|
+
if (byId.has(item.id)) duplicateIds.push(item.id);
|
|
413
|
+
else byId.set(item.id, item);
|
|
414
|
+
}
|
|
415
|
+
const byStem = /* @__PURE__ */ new Map();
|
|
416
|
+
for (const item of items) {
|
|
417
|
+
const key = item.stem.toLowerCase();
|
|
418
|
+
if (!byStem.has(key)) byStem.set(key, item);
|
|
419
|
+
}
|
|
420
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
421
|
+
for (const item of items) {
|
|
422
|
+
if (item.title === void 0) continue;
|
|
423
|
+
const key = item.title.toLowerCase();
|
|
424
|
+
byTitle.set(key, [...byTitle.get(key) ?? [], item]);
|
|
425
|
+
}
|
|
426
|
+
const children = /* @__PURE__ */ new Map();
|
|
427
|
+
for (const item of items) {
|
|
428
|
+
if (item.parent === null) continue;
|
|
429
|
+
const key = linkKey(item.parent);
|
|
430
|
+
if (!byStem.has(key)) continue;
|
|
431
|
+
children.set(key, [...children.get(key) ?? [], item]);
|
|
432
|
+
}
|
|
433
|
+
for (const siblings of children.values()) siblings.sort(compareSiblings);
|
|
434
|
+
const resolveLink = (target) => target === null ? void 0 : byStem.get(linkKey(target));
|
|
435
|
+
const resolve3 = (ref) => {
|
|
436
|
+
const trimmed = ref.trim();
|
|
437
|
+
const byIdHit = byId.get(trimmed);
|
|
438
|
+
if (byIdHit) return byIdHit;
|
|
439
|
+
const byStemHit = byStem.get(linkKey(trimmed));
|
|
440
|
+
if (byStemHit) return byStemHit;
|
|
441
|
+
const byTitleHits = byTitle.get(trimmed.toLowerCase()) ?? [];
|
|
442
|
+
if (byTitleHits.length === 1) return byTitleHits[0];
|
|
443
|
+
if (byTitleHits.length > 1) {
|
|
444
|
+
const options = byTitleHits.map((i) => `${i.id ?? "?"} (${i.relPath})`).join(", ");
|
|
445
|
+
throw new Error(`"${ref}" matches ${byTitleHits.length} work items: ${options}. Use an id.`);
|
|
446
|
+
}
|
|
447
|
+
throw new Error(`no work item matches "${ref}". Try an id, a filename or a title.`);
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
root,
|
|
451
|
+
config,
|
|
452
|
+
items,
|
|
453
|
+
byId,
|
|
454
|
+
duplicateIds,
|
|
455
|
+
unaccounted,
|
|
456
|
+
misplaced,
|
|
457
|
+
nonItems,
|
|
458
|
+
takenIds: new Set(byId.keys()),
|
|
459
|
+
takenStems: new Set(byStem.keys()),
|
|
460
|
+
resolveLink,
|
|
461
|
+
resolve: resolve3,
|
|
462
|
+
childrenOf: (item) => children.get(item.stem.toLowerCase()) ?? [],
|
|
463
|
+
isArchived: (item) => archiveOwner(item, (current) => resolveLink(current.parent) ?? null, (current) => current.archived) !== null
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function isMissingFile(error) {
|
|
467
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/cli/commands/new.ts
|
|
471
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
472
|
+
import { join as join3 } from "node:path";
|
|
473
|
+
|
|
474
|
+
// src/cli/write.ts
|
|
475
|
+
import { writeFile, rename } from "node:fs/promises";
|
|
476
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
477
|
+
|
|
478
|
+
// src/shared/edits.ts
|
|
479
|
+
function applyEdits(text, edits) {
|
|
480
|
+
let out = text;
|
|
481
|
+
for (const edit of edits) {
|
|
482
|
+
out = edit.op === "set" ? setKey(out, edit.key, edit.value) : removeKey(out, edit.key);
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
function withStamp(edits, stamp = today()) {
|
|
487
|
+
if (edits.some((e) => e.key === "updated")) return [...edits];
|
|
488
|
+
return [...edits, { op: "set", key: "updated", value: stamp }];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/cli/write.ts
|
|
492
|
+
async function writeAtomic(path, text) {
|
|
493
|
+
const temp = join2(dirname2(path), `.wi-${process.pid}-${Date.now()}.tmp`);
|
|
494
|
+
await writeFile(temp, text, "utf8");
|
|
495
|
+
await rename(temp, path);
|
|
496
|
+
}
|
|
497
|
+
async function editItem(item, edits) {
|
|
498
|
+
const text = applyEdits(item.text, withStamp(edits));
|
|
499
|
+
if (text !== item.text) await writeAtomic(item.path, text);
|
|
500
|
+
return text;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/shared/templates.ts
|
|
504
|
+
var TEMPLATES = [
|
|
505
|
+
{
|
|
506
|
+
name: "work-item",
|
|
507
|
+
description: "The default. What every work item starts as.",
|
|
508
|
+
sections: [
|
|
509
|
+
{ heading: "Objective" },
|
|
510
|
+
{ heading: "Context" },
|
|
511
|
+
{ heading: "Acceptance Criteria", starter: "- " },
|
|
512
|
+
{ heading: "Notes" }
|
|
513
|
+
]
|
|
514
|
+
}
|
|
515
|
+
];
|
|
516
|
+
var DEFAULT_TEMPLATE = "work-item";
|
|
517
|
+
function templateNames() {
|
|
518
|
+
return TEMPLATES.map((t) => t.name);
|
|
519
|
+
}
|
|
520
|
+
function findTemplate(name) {
|
|
521
|
+
return TEMPLATES.find((t) => t.name === name);
|
|
522
|
+
}
|
|
523
|
+
function requireTemplate(name = DEFAULT_TEMPLATE) {
|
|
524
|
+
const template = findTemplate(name);
|
|
525
|
+
if (template) return template;
|
|
526
|
+
throw new Error(`no template called "${name}". There is: ${templateNames().join(", ")}.`);
|
|
527
|
+
}
|
|
528
|
+
function renderBody(template, extraSections = []) {
|
|
529
|
+
const parts = [];
|
|
530
|
+
const sections = [
|
|
531
|
+
...template.sections,
|
|
532
|
+
...extraSections.map((heading) => ({ heading, starter: "- " }))
|
|
533
|
+
];
|
|
534
|
+
for (const section of sections) {
|
|
535
|
+
parts.push(`## ${section.heading}`, "");
|
|
536
|
+
if (section.starter !== void 0) parts.push(section.starter, "");
|
|
537
|
+
}
|
|
538
|
+
return parts.join("\n");
|
|
539
|
+
}
|
|
540
|
+
function renderVaultTemplate(template, defaultRoot = null, extraSections = []) {
|
|
541
|
+
const placeholders = {
|
|
542
|
+
type: formatScalar(WORK_ITEM_TYPE),
|
|
543
|
+
id: "wi-XXXX",
|
|
544
|
+
title: "",
|
|
545
|
+
status: "backlog",
|
|
546
|
+
parent: defaultRoot === null ? "" : formatScalar(formatWikilink(defaultRoot)),
|
|
547
|
+
created: "",
|
|
548
|
+
updated: ""
|
|
549
|
+
};
|
|
550
|
+
const lines = CORE_FIELDS.filter((field) => field in placeholders).map(
|
|
551
|
+
(field) => `${field}: ${placeholders[field]}`.trimEnd()
|
|
552
|
+
);
|
|
553
|
+
return `---
|
|
554
|
+
${lines.join("\n")}
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
${renderBody(template, extraSections)}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/shared/work-item.ts
|
|
561
|
+
function renderWorkItem(item, extraSections = []) {
|
|
562
|
+
const fields = [
|
|
563
|
+
["type", WORK_ITEM_TYPE],
|
|
564
|
+
["id", item.id],
|
|
565
|
+
["title", item.title],
|
|
566
|
+
["status", item.status],
|
|
567
|
+
["parent", formatWikilink(item.parentStem)]
|
|
568
|
+
];
|
|
569
|
+
if (item.owner !== void 0 && item.owner !== "") fields.push(["owner", item.owner]);
|
|
570
|
+
if (item.agent !== void 0 && item.agent !== "") fields.push(["agent", item.agent]);
|
|
571
|
+
if (item.priority !== void 0) fields.push(["priority", item.priority]);
|
|
572
|
+
fields.push(["created", item.created], ["updated", item.updated]);
|
|
573
|
+
const frontmatter = fields.map(([key, value]) => `${key}: ${formatScalar(value)}`).join("\n");
|
|
574
|
+
return `---
|
|
575
|
+
${frontmatter}
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
${renderBody(requireTemplate(item.template), extraSections)}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/cli/commands/new.ts
|
|
582
|
+
async function createItem(vault, options) {
|
|
583
|
+
const title = options.title.trim();
|
|
584
|
+
if (title === "") throw new Error("a work item needs a title");
|
|
585
|
+
const parentRef = options.parent ?? vault.config.defaultRoot ?? "";
|
|
586
|
+
if (parentRef.trim() === "") {
|
|
587
|
+
throw new Error("a work item needs a --parent. Only the root has none.");
|
|
588
|
+
}
|
|
589
|
+
requireTemplate(options.template);
|
|
590
|
+
const status = options.status ?? "backlog";
|
|
591
|
+
if (!isStatus(status)) {
|
|
592
|
+
throw new Error(`"${status}" is not a status. Use backlog, options, doing or done.`);
|
|
593
|
+
}
|
|
594
|
+
const parent = vault.resolve(parentRef);
|
|
595
|
+
const id = newId(vault.takenIds);
|
|
596
|
+
const stem = fileNameFor(title, id, vault.takenStems);
|
|
597
|
+
const relPath = `${vault.config.workItemFolder}/${stem}.md`;
|
|
598
|
+
const path = join3(vault.root, ...vault.config.workItemFolder.split("/"), `${stem}.md`);
|
|
599
|
+
if (existsSync2(path)) {
|
|
600
|
+
throw new Error(`${relPath} already exists. Refusing to overwrite it.`);
|
|
601
|
+
}
|
|
602
|
+
const stamp = today();
|
|
603
|
+
const fields = {
|
|
604
|
+
id,
|
|
605
|
+
title,
|
|
606
|
+
status,
|
|
607
|
+
parentStem: parent.stem,
|
|
608
|
+
created: stamp,
|
|
609
|
+
updated: stamp,
|
|
610
|
+
template: options.template
|
|
611
|
+
};
|
|
612
|
+
for (const field of INHERITED_FIELDS) {
|
|
613
|
+
const value = options[field] ?? parent.frontmatter.get(field);
|
|
614
|
+
if (value !== void 0 && value !== "") fields[field] = String(value);
|
|
615
|
+
}
|
|
616
|
+
if (options.priority !== void 0) fields.priority = options.priority;
|
|
617
|
+
await writeAtomic(path, renderWorkItem(fields, vault.config.extraSections));
|
|
618
|
+
return { id, stem, relPath, path, parentStem: parent.stem };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/shared/transitions.ts
|
|
622
|
+
function statusEdits(from, to, hasPrevStatus) {
|
|
623
|
+
if (from === to) return null;
|
|
624
|
+
const edits = [{ op: "set", key: "status", value: to }];
|
|
625
|
+
if (to === "done") {
|
|
626
|
+
if (from !== void 0) edits.push({ op: "set", key: "prev_status", value: from });
|
|
627
|
+
} else if (hasPrevStatus) {
|
|
628
|
+
edits.push({ op: "remove", key: "prev_status" });
|
|
629
|
+
}
|
|
630
|
+
return edits;
|
|
631
|
+
}
|
|
632
|
+
function moveEdits(currentParentStem, targetStem) {
|
|
633
|
+
if (currentParentStem !== null && currentParentStem.toLowerCase() === targetStem.toLowerCase()) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
return [{ op: "set", key: "parent", value: formatWikilink(targetStem) }];
|
|
637
|
+
}
|
|
638
|
+
function moveRefusal(check) {
|
|
639
|
+
const { item, target, isRoot, parentOf } = check;
|
|
640
|
+
if (isRoot) {
|
|
641
|
+
return "it is a root, and a root has no parent to change.";
|
|
642
|
+
}
|
|
643
|
+
if (item === target) return "an item cannot be moved under itself.";
|
|
644
|
+
const seen = /* @__PURE__ */ new Set();
|
|
645
|
+
for (let key = target; key !== null; key = parentOf(key)) {
|
|
646
|
+
if (key === item) return "the target is under its own subtree, so the move would make a loop.";
|
|
647
|
+
if (seen.has(key)) break;
|
|
648
|
+
seen.add(key);
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/cli/commands/status.ts
|
|
654
|
+
async function setStatus(vault, ref, status) {
|
|
655
|
+
if (!isStatus(status)) {
|
|
656
|
+
throw new Error(`"${status}" is not a status. Use one of: ${STATUSES.join(", ")}.`);
|
|
657
|
+
}
|
|
658
|
+
const item = vault.resolve(ref);
|
|
659
|
+
if (item.parent === null) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`${item.relPath} is a root, and a root is not a card in anyone's column. It takes no status.`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
const from = item.status;
|
|
665
|
+
const edits = statusEdits(from, status, item.frontmatter.has("prev_status"));
|
|
666
|
+
if (edits === null) {
|
|
667
|
+
return { item, from, to: status, recorded: void 0, changed: false };
|
|
668
|
+
}
|
|
669
|
+
const recorded = status === "done" ? from : void 0;
|
|
670
|
+
await editItem(item, edits);
|
|
671
|
+
return { item, from, to: status, recorded, changed: true };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/cli/commands/children.ts
|
|
675
|
+
function listChildren(vault, ref, options = {}) {
|
|
676
|
+
const { status, recursive = false, archived = false } = options;
|
|
677
|
+
if (status !== void 0 && !isStatus(status)) {
|
|
678
|
+
throw new Error(`"${status}" is not a status. Use one of: ${STATUSES.join(", ")}.`);
|
|
679
|
+
}
|
|
680
|
+
const parent = vault.resolve(ref);
|
|
681
|
+
const rows = [];
|
|
682
|
+
const seen = /* @__PURE__ */ new Set([parent.relPath]);
|
|
683
|
+
let cycle = false;
|
|
684
|
+
const walk = (item, depth) => {
|
|
685
|
+
for (const child of vault.childrenOf(item)) {
|
|
686
|
+
if (seen.has(child.relPath)) {
|
|
687
|
+
cycle = true;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
seen.add(child.relPath);
|
|
691
|
+
const effectiveArchived = vault.isArchived(child);
|
|
692
|
+
if (!effectiveArchived || archived) {
|
|
693
|
+
rows.push({ item: child, childCount: vault.childrenOf(child).filter((kid) => !vault.isArchived(kid) || archived).length, depth, archived: effectiveArchived });
|
|
694
|
+
}
|
|
695
|
+
if (recursive) walk(child, depth + 1);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
walk(parent, 0);
|
|
699
|
+
const filtered = status === void 0 ? rows : rows.filter((r) => r.item.status === status);
|
|
700
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
701
|
+
for (const value of STATUSES) {
|
|
702
|
+
byStatus.set(value, filtered.filter((r) => r.item.status === value));
|
|
703
|
+
}
|
|
704
|
+
return { parent, children: filtered, byStatus, cycle };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/cli/commands/validate.ts
|
|
708
|
+
var ID_SHAPE = /^wi-[a-z0-9]+$/;
|
|
709
|
+
var DATE_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
|
|
710
|
+
var KNOWN_FIELDS = /* @__PURE__ */ new Set([...CORE_FIELDS, ...OPTIONAL_FIELDS]);
|
|
711
|
+
var DATE_FIELDS = ["created", "updated"];
|
|
712
|
+
async function validate(vault) {
|
|
713
|
+
const problems = [];
|
|
714
|
+
const report = (rule, severity, relPath, id, message) => problems.push({ rule, severity, relPath, id, message });
|
|
715
|
+
checkLayout(vault, report);
|
|
716
|
+
checkIdentity(vault, report);
|
|
717
|
+
for (const item of vault.items) {
|
|
718
|
+
checkItem(item, vault, report);
|
|
719
|
+
}
|
|
720
|
+
checkDefaultRoot(vault, report);
|
|
721
|
+
checkRoots(vault, report);
|
|
722
|
+
checkCycles(vault, report);
|
|
723
|
+
problems.sort((a, b) => a.relPath.localeCompare(b.relPath) || a.rule.localeCompare(b.rule));
|
|
724
|
+
const errorCount = problems.filter((p) => p.severity === "error").length;
|
|
725
|
+
return {
|
|
726
|
+
problems,
|
|
727
|
+
errorCount,
|
|
728
|
+
warningCount: problems.length - errorCount,
|
|
729
|
+
ok: errorCount === 0,
|
|
730
|
+
itemCount: vault.items.length
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function checkDefaultRoot(vault, report) {
|
|
734
|
+
const configured = vault.config.defaultRoot;
|
|
735
|
+
if (configured === null) return;
|
|
736
|
+
const target = vault.items.find((item) => item.stem.toLowerCase() === configured.toLowerCase());
|
|
737
|
+
if (target !== void 0 && target.parent === null) return;
|
|
738
|
+
report(
|
|
739
|
+
"default-root-unresolved",
|
|
740
|
+
"warning",
|
|
741
|
+
".wi.json",
|
|
742
|
+
target?.id,
|
|
743
|
+
`sets defaultRoot to "${configured}", which does not name a root work item.`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
function checkLayout(vault, report) {
|
|
747
|
+
for (const relPath of vault.misplaced) {
|
|
748
|
+
report(
|
|
749
|
+
"folder-nested",
|
|
750
|
+
"error",
|
|
751
|
+
relPath,
|
|
752
|
+
void 0,
|
|
753
|
+
"sits below one of the product folders. Hierarchy lives in the parent wikilink, never in a folder."
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
for (const relPath of vault.nonItems) {
|
|
757
|
+
report(
|
|
758
|
+
"not-a-work-item",
|
|
759
|
+
"warning",
|
|
760
|
+
relPath,
|
|
761
|
+
void 0,
|
|
762
|
+
`sits in ${vault.config.workItemFolder}/ but has no \`type: work-item\`. The board will never show it.`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
for (const relPath of vault.unaccounted) {
|
|
766
|
+
report(
|
|
767
|
+
"unaccounted-file",
|
|
768
|
+
"warning",
|
|
769
|
+
relPath,
|
|
770
|
+
void 0,
|
|
771
|
+
"is a hidden file that is not Markdown, so its contents were not read. Let the sync client download it if it is a work item, or delete it if it is a stray file, then validate again."
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function checkIdentity(vault, report) {
|
|
776
|
+
const byId = /* @__PURE__ */ new Map();
|
|
777
|
+
for (const item of vault.items) {
|
|
778
|
+
if (item.id === void 0) continue;
|
|
779
|
+
byId.set(item.id, [...byId.get(item.id) ?? [], item]);
|
|
780
|
+
}
|
|
781
|
+
for (const [id, items] of byId) {
|
|
782
|
+
if (items.length < 2) continue;
|
|
783
|
+
const files = items.map((i) => i.relPath).join(", ");
|
|
784
|
+
for (const item of items) {
|
|
785
|
+
report(
|
|
786
|
+
"id-duplicate",
|
|
787
|
+
"error",
|
|
788
|
+
item.relPath,
|
|
789
|
+
id,
|
|
790
|
+
`shares id ${id} with another work item. Both files: ${files}. The id is what recovers the hierarchy, so it must be unique.`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function checkItem(item, vault, report) {
|
|
796
|
+
const say = (rule, severity, message) => report(rule, severity, item.relPath, item.id, message);
|
|
797
|
+
const rawId = item.frontmatter.get("id");
|
|
798
|
+
if (rawId === void 0 || rawId === "") say("id-missing", "error", "has no id.");
|
|
799
|
+
else if (!ID_SHAPE.test(String(rawId))) {
|
|
800
|
+
say("id-malformed", "error", `has id "${rawId}". An id looks like wi-a7f3.`);
|
|
801
|
+
}
|
|
802
|
+
if (item.title === void 0) say("title-missing", "error", "has no title.");
|
|
803
|
+
const rawStatus = item.frontmatter.get("status");
|
|
804
|
+
const isRoot = !item.frontmatter.has("parent");
|
|
805
|
+
if (isRoot) {
|
|
806
|
+
if (rawStatus !== void 0) {
|
|
807
|
+
say(
|
|
808
|
+
"status-on-root",
|
|
809
|
+
"error",
|
|
810
|
+
`is a root and carries status "${rawStatus}". A root is not a card in anyone's column, so it takes no status.`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
} else if (rawStatus === void 0) {
|
|
814
|
+
say("status-missing", "error", `has no status. Use one of: ${STATUSES.join(", ")}.`);
|
|
815
|
+
} else if (!isStatus(rawStatus)) {
|
|
816
|
+
say(
|
|
817
|
+
"status-invalid",
|
|
818
|
+
"error",
|
|
819
|
+
`has status "${rawStatus}". The four values are ${STATUSES.join(", ")}.`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
if (!isRoot) {
|
|
823
|
+
const raw = item.frontmatter.get("parent");
|
|
824
|
+
if (parseWikilink(raw) === null) {
|
|
825
|
+
say(
|
|
826
|
+
"parent-malformed",
|
|
827
|
+
"error",
|
|
828
|
+
`has parent ${JSON.stringify(raw)}, which is not a wikilink. Write parent: "[[Title]]".`
|
|
829
|
+
);
|
|
830
|
+
} else if (vault.resolveLink(item.parent) === void 0) {
|
|
831
|
+
say(
|
|
832
|
+
"parent-unresolved",
|
|
833
|
+
"error",
|
|
834
|
+
`points at [[${item.parent}]], which no file matches. This item appears on no board. Fix the link or recreate the parent; do not delete the child.`
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const board = item.frontmatter.get("board");
|
|
839
|
+
if (board === false) {
|
|
840
|
+
say(
|
|
841
|
+
"board-false",
|
|
842
|
+
"error",
|
|
843
|
+
"carries board: false. Demotion deletes the key; absence means not a board."
|
|
844
|
+
);
|
|
845
|
+
} else if (board !== void 0 && board !== true) {
|
|
846
|
+
say("board-invalid", "error", `carries board: ${board}. The only value is true.`);
|
|
847
|
+
}
|
|
848
|
+
const archived = item.frontmatter.get("archived");
|
|
849
|
+
if (archived === false) {
|
|
850
|
+
say(
|
|
851
|
+
"archived-false",
|
|
852
|
+
"error",
|
|
853
|
+
"carries archived: false. Unarchiving deletes the key; absence means visible."
|
|
854
|
+
);
|
|
855
|
+
} else if (archived !== void 0 && archived !== true) {
|
|
856
|
+
say("archived-invalid", "error", `carries archived: ${archived}. The only value is true.`);
|
|
857
|
+
}
|
|
858
|
+
const prev = item.frontmatter.get("prev_status");
|
|
859
|
+
if (prev !== void 0) {
|
|
860
|
+
if (!isStatus(prev)) {
|
|
861
|
+
say(
|
|
862
|
+
"prev-status-invalid",
|
|
863
|
+
"error",
|
|
864
|
+
`has prev_status "${prev}". It holds what the item was before it was ticked done.`
|
|
865
|
+
);
|
|
866
|
+
} else if (item.status !== "done") {
|
|
867
|
+
say(
|
|
868
|
+
"prev-status-stale",
|
|
869
|
+
"warning",
|
|
870
|
+
`has prev_status while its status is "${item.status}". Leaving done should clear it.`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const field of DATE_FIELDS) {
|
|
875
|
+
const value = item.frontmatter.get(field);
|
|
876
|
+
if (value === void 0) {
|
|
877
|
+
say(
|
|
878
|
+
"date-missing",
|
|
879
|
+
"error",
|
|
880
|
+
`has no ${field}. The sibling sort and the Done window both read it.`
|
|
881
|
+
);
|
|
882
|
+
} else if (!DATE_SHAPE.test(String(value))) {
|
|
883
|
+
say("date-invalid", "error", `has ${field}: ${value}. Dates are YYYY-MM-DD.`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
for (const key of item.frontmatter.keys()) {
|
|
887
|
+
if (KNOWN_FIELDS.has(key)) continue;
|
|
888
|
+
say(
|
|
889
|
+
"unknown-key",
|
|
890
|
+
"warning",
|
|
891
|
+
`carries "${key}", which is outside the schema. It is preserved, not stripped. Use a supported field or remove it.`
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function checkRoots(vault, report) {
|
|
896
|
+
const roots = vault.items.filter((i) => !i.frontmatter.has("parent"));
|
|
897
|
+
if (vault.items.length === 0 && roots.length === 0) {
|
|
898
|
+
report(
|
|
899
|
+
"root-missing",
|
|
900
|
+
"warning",
|
|
901
|
+
vault.config.workItemFolder,
|
|
902
|
+
void 0,
|
|
903
|
+
"holds no work items yet, so the vault has no root."
|
|
904
|
+
);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (roots.length === 0) {
|
|
908
|
+
report(
|
|
909
|
+
"root-missing",
|
|
910
|
+
"warning",
|
|
911
|
+
vault.config.workItemFolder,
|
|
912
|
+
void 0,
|
|
913
|
+
"holds no root. A root is a work item with no parent, and every board hangs off one."
|
|
914
|
+
);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (roots.length > 1) {
|
|
918
|
+
const files = roots.map((r) => r.relPath).join(", ");
|
|
919
|
+
for (const root of roots) {
|
|
920
|
+
report(
|
|
921
|
+
"root-multiple",
|
|
922
|
+
"warning",
|
|
923
|
+
root.relPath,
|
|
924
|
+
root.id,
|
|
925
|
+
`is one of ${roots.length} roots: ${files}. Nothing forbids this, but a defaultRoot can name only one.`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function checkCycles(vault, report) {
|
|
931
|
+
for (const item of vault.items) {
|
|
932
|
+
const seen = /* @__PURE__ */ new Set([item.relPath]);
|
|
933
|
+
let current = item;
|
|
934
|
+
for (; ; ) {
|
|
935
|
+
current = vault.resolveLink(current.parent);
|
|
936
|
+
if (current === void 0) break;
|
|
937
|
+
if (seen.has(current.relPath)) {
|
|
938
|
+
report(
|
|
939
|
+
"cycle",
|
|
940
|
+
"error",
|
|
941
|
+
item.relPath,
|
|
942
|
+
item.id,
|
|
943
|
+
`is its own ancestor. The parent chain loops back through ${current.relPath}.`
|
|
944
|
+
);
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
seen.add(current.relPath);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/cli/commands/template.ts
|
|
953
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
954
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
955
|
+
import { mkdir } from "node:fs/promises";
|
|
956
|
+
import { join as join4 } from "node:path";
|
|
957
|
+
var TEMPLATES_FOLDER = "Templates";
|
|
958
|
+
function listTemplates() {
|
|
959
|
+
return TEMPLATES;
|
|
960
|
+
}
|
|
961
|
+
async function writeTemplates(vault) {
|
|
962
|
+
const folder = join4(vault.root, TEMPLATES_FOLDER);
|
|
963
|
+
await mkdir(folder, { recursive: true });
|
|
964
|
+
const written = [];
|
|
965
|
+
for (const template of TEMPLATES) {
|
|
966
|
+
const path = join4(folder, `${template.name}.md`);
|
|
967
|
+
const relPath = `${TEMPLATES_FOLDER}/${template.name}.md`;
|
|
968
|
+
const next = renderVaultTemplate(template, vault.config.defaultRoot, vault.config.extraSections);
|
|
969
|
+
if (!existsSync3(path)) {
|
|
970
|
+
await writeAtomic(path, next);
|
|
971
|
+
written.push({ name: template.name, relPath, outcome: "created" });
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const current = await readFile2(path, "utf8");
|
|
975
|
+
if (current === next) {
|
|
976
|
+
written.push({ name: template.name, relPath, outcome: "unchanged" });
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
await writeAtomic(path, next);
|
|
980
|
+
written.push({ name: template.name, relPath, outcome: "updated" });
|
|
981
|
+
}
|
|
982
|
+
return written;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/cli/commands/remove.ts
|
|
986
|
+
import { mkdir as mkdir2, rename as rename2 } from "node:fs/promises";
|
|
987
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
988
|
+
import { join as join5 } from "node:path";
|
|
989
|
+
var TRASH = ".trash";
|
|
990
|
+
function subtree(vault, root) {
|
|
991
|
+
const ordered = [];
|
|
992
|
+
const seen = /* @__PURE__ */ new Set([root.relPath]);
|
|
993
|
+
const walk = (item) => {
|
|
994
|
+
for (const child of vault.childrenOf(item)) {
|
|
995
|
+
if (seen.has(child.relPath)) continue;
|
|
996
|
+
seen.add(child.relPath);
|
|
997
|
+
walk(child);
|
|
998
|
+
}
|
|
999
|
+
ordered.push(item);
|
|
1000
|
+
};
|
|
1001
|
+
walk(root);
|
|
1002
|
+
return ordered;
|
|
1003
|
+
}
|
|
1004
|
+
function trashPath(vault, item) {
|
|
1005
|
+
const stem = item.stem;
|
|
1006
|
+
for (let n = 0; n < 1e3; n++) {
|
|
1007
|
+
const name = n === 0 ? `${stem}.md` : `${stem} ${n}.md`;
|
|
1008
|
+
if (!existsSync4(join5(vault.root, TRASH, name))) return `${TRASH}/${name}`;
|
|
1009
|
+
}
|
|
1010
|
+
return `${TRASH}/${stem} ${Date.now()}.md`;
|
|
1011
|
+
}
|
|
1012
|
+
async function removeItem(vault, ref, options = {}) {
|
|
1013
|
+
const { recursive = false, dryRun = false } = options;
|
|
1014
|
+
const item = vault.resolve(ref);
|
|
1015
|
+
requireAccountedTree(vault, `remove ${item.title ?? item.stem}`);
|
|
1016
|
+
if (item.parent === null) {
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
`${item.relPath} is a root. Every board hangs off it, so it is not a card anyone can discard.`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
const children = vault.childrenOf(item);
|
|
1022
|
+
if (children.length > 0 && !recursive) {
|
|
1023
|
+
const names = children.map((c) => `${c.id ?? "?"} ${c.title ?? c.stem}`).join(", ");
|
|
1024
|
+
throw new Error(
|
|
1025
|
+
`${item.title ?? item.stem} has ${children.length} children: ${names}. Removing it would leave them on no board. Move them first, or pass --recursive.`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const doomed = recursive ? subtree(vault, item) : [item];
|
|
1029
|
+
const removed = [];
|
|
1030
|
+
for (const target of doomed) {
|
|
1031
|
+
const trashedTo = trashPath(vault, target);
|
|
1032
|
+
if (!dryRun) {
|
|
1033
|
+
await mkdir2(join5(vault.root, TRASH), { recursive: true });
|
|
1034
|
+
await rename2(target.path, join5(vault.root, ...trashedTo.split("/")));
|
|
1035
|
+
}
|
|
1036
|
+
removed.push({ item: target, trashedTo });
|
|
1037
|
+
}
|
|
1038
|
+
return { removed, dryRun };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// src/cli/commands/move.ts
|
|
1042
|
+
async function moveItem(vault, ref, targetRef) {
|
|
1043
|
+
const item = vault.resolve(ref);
|
|
1044
|
+
const target = vault.resolve(targetRef);
|
|
1045
|
+
requireAccountedTree(vault, `move ${item.title ?? item.stem}`);
|
|
1046
|
+
const key = (w) => w.stem.toLowerCase();
|
|
1047
|
+
const byKey = new Map(vault.items.map((w) => [key(w), w]));
|
|
1048
|
+
const refusal = moveRefusal({
|
|
1049
|
+
item: key(item),
|
|
1050
|
+
target: key(target),
|
|
1051
|
+
isRoot: item.parent === null,
|
|
1052
|
+
parentOf: (k) => {
|
|
1053
|
+
const parent = vault.resolveLink(byKey.get(k)?.parent ?? null);
|
|
1054
|
+
return parent ? key(parent) : null;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
if (refusal !== null) {
|
|
1058
|
+
throw new Error(`cannot move ${item.title ?? item.stem} under ${target.title ?? target.stem}: ${refusal}`);
|
|
1059
|
+
}
|
|
1060
|
+
const edits = moveEdits(item.parent, target.stem);
|
|
1061
|
+
if (edits === null) return { item, from: item.parent, to: target, changed: false };
|
|
1062
|
+
await editItem(item, edits);
|
|
1063
|
+
return { item, from: item.parent, to: target, changed: true };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// src/cli/commands/archive.ts
|
|
1067
|
+
async function archiveItem(vault, ref, undo) {
|
|
1068
|
+
const item = vault.resolve(ref);
|
|
1069
|
+
const archived = !undo;
|
|
1070
|
+
requireAccountedTree(vault, `${archived ? "archive" : "unarchive"} ${item.title ?? item.stem}`);
|
|
1071
|
+
const edits = archiveEdits(item.archived, archived);
|
|
1072
|
+
if (edits === null) return { item, archived, changed: false };
|
|
1073
|
+
if (archived) {
|
|
1074
|
+
const active = activeDescendant(item, vault.childrenOf, (child) => child.status);
|
|
1075
|
+
if (active) {
|
|
1076
|
+
throw new Error(`cannot archive ${item.title ?? item.stem}: descendant ${active.title ?? active.stem} (${active.id ?? active.relPath}) is doing.`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
await editItem(item, edits);
|
|
1080
|
+
return { item, archived, changed: true };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/cli/commands/hook.ts
|
|
1084
|
+
import { execFile } from "node:child_process";
|
|
1085
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1086
|
+
import { chmod, mkdir as mkdir3, readFile as readFile3, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
1087
|
+
import { dirname as dirname3, resolve } from "node:path";
|
|
1088
|
+
import { promisify } from "node:util";
|
|
1089
|
+
var run = promisify(execFile);
|
|
1090
|
+
var HOOK_MARK = "# installed by scripts/vault-git.mjs";
|
|
1091
|
+
async function gitHook(vault) {
|
|
1092
|
+
try {
|
|
1093
|
+
const top = (await run("git", ["-C", vault, "rev-parse", "--show-toplevel"])).stdout.trim();
|
|
1094
|
+
const path = (await run("git", ["-C", vault, "rev-parse", "--git-path", "hooks/pre-commit"])).stdout.trim();
|
|
1095
|
+
return { top, path: resolve(vault, path) };
|
|
1096
|
+
} catch {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
function isOurHook(body) {
|
|
1101
|
+
return body.split("\n").some((line2) => line2 === HOOK_MARK || line2 === "# installed by wi hook");
|
|
1102
|
+
}
|
|
1103
|
+
function shellQuote(value) {
|
|
1104
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1105
|
+
}
|
|
1106
|
+
function hookBody(vault, entry) {
|
|
1107
|
+
return `#!/bin/sh
|
|
1108
|
+
${HOOK_MARK}
|
|
1109
|
+
# Edits outside the plugin can leave a vault invalid. Validate before committing.
|
|
1110
|
+
# To bypass validation: git commit --no-verify
|
|
1111
|
+
|
|
1112
|
+
exec ${shellQuote(process.execPath)} ${shellQuote(entry)} validate --vault ${shellQuote(vault)}
|
|
1113
|
+
`;
|
|
1114
|
+
}
|
|
1115
|
+
async function hookStatus(vault) {
|
|
1116
|
+
const git = await gitHook(vault);
|
|
1117
|
+
if (!git) return { vault, gitRepo: null, preCommit: "not installed" };
|
|
1118
|
+
const present = existsSync5(git.path);
|
|
1119
|
+
const ours = present && isOurHook(await readFile3(git.path, "utf8"));
|
|
1120
|
+
return {
|
|
1121
|
+
vault,
|
|
1122
|
+
gitRepo: git.top,
|
|
1123
|
+
preCommit: ours ? "installed" : present ? "present, not ours" : "not installed"
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
async function installHook(vault, entry, force) {
|
|
1127
|
+
const git = await gitHook(vault);
|
|
1128
|
+
if (!git) throw new Error(`${vault} is not inside a Git repository. Run: git -C ${shellQuote(vault)} init`);
|
|
1129
|
+
if (existsSync5(git.path)) {
|
|
1130
|
+
const current = await readFile3(git.path, "utf8");
|
|
1131
|
+
if (!isOurHook(current) && !force) {
|
|
1132
|
+
throw new Error(`${git.path} already exists and is not ours. Read it, then pass --force to replace it.`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
await mkdir3(dirname3(git.path), { recursive: true });
|
|
1136
|
+
await writeFile2(git.path, hookBody(vault, entry), "utf8");
|
|
1137
|
+
await chmod(git.path, 493);
|
|
1138
|
+
return git.path;
|
|
1139
|
+
}
|
|
1140
|
+
async function uninstallHook(vault) {
|
|
1141
|
+
const git = await gitHook(vault);
|
|
1142
|
+
if (!git || !existsSync5(git.path) || !isOurHook(await readFile3(git.path, "utf8"))) return null;
|
|
1143
|
+
await rm(git.path);
|
|
1144
|
+
return git.path;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/cli/wi.ts
|
|
1148
|
+
var HELP = `wi \u2014 the Recursive Board CLI
|
|
1149
|
+
|
|
1150
|
+
Usage
|
|
1151
|
+
wi new <title> [--parent <ref>] [--status <s>] [--template <t>] [--owner <o>] [--agent <a>]
|
|
1152
|
+
[--priority <n>]
|
|
1153
|
+
wi status <ref> <status>
|
|
1154
|
+
wi move <ref> --to <ref>
|
|
1155
|
+
wi archive <ref> [--undo]
|
|
1156
|
+
wi rm <ref> [--recursive] [--dry-run]
|
|
1157
|
+
wi children <ref> [--status <s>] [--tree] [--archived]
|
|
1158
|
+
wi validate
|
|
1159
|
+
wi template [list|write]
|
|
1160
|
+
wi hook <install|uninstall|status> [--force]
|
|
1161
|
+
|
|
1162
|
+
A <ref> is a work item id, a filename or a title. An id always wins.
|
|
1163
|
+
A <status> is one of: ${STATUSES.join(", ")}.
|
|
1164
|
+
A <template> is one of: ${templateNames().join(", ")}.
|
|
1165
|
+
|
|
1166
|
+
Options
|
|
1167
|
+
--vault <path> The vault root. Defaults to $WI_VAULT, then the nearest configured vault or Boards/.
|
|
1168
|
+
--json Machine-readable output.
|
|
1169
|
+
--force Replace another pre-commit hook with wi hook install.
|
|
1170
|
+
-h, --help This text.
|
|
1171
|
+
-V, --version Print the version.
|
|
1172
|
+
|
|
1173
|
+
Notes
|
|
1174
|
+
Unticking a done item is \`wi status <ref> <its prev_status>\`, which also clears the record.
|
|
1175
|
+
\`wi validate\` exits 1 when the vault has errors, so it works as a pre-commit hook.
|
|
1176
|
+
\`wi template write\` regenerates Templates/ from the code, which is authoritative.
|
|
1177
|
+
\`wi rm\` moves a file to the vault's .trash. It refuses an item that has children
|
|
1178
|
+
unless you pass --recursive, because removing a parent leaves its children on no board.
|
|
1179
|
+
\`wi move\` changes only the item's parent. Its status stays, and its children follow it.
|
|
1180
|
+
\`wi rm\`, \`wi move\` and \`wi archive\` refuse while a hidden non-Markdown file sits in the work-item
|
|
1181
|
+
folder, because the index cannot read it and may be missing a work item. Let the sync
|
|
1182
|
+
client download the file, or delete the stray file, then retry. There is no --force.
|
|
1183
|
+
\`wi archive\` changes one flag. Descendants disappear with their parent at read time.
|
|
1184
|
+
\`wi new\` warns when such a file exists because a new id or filename may clash with it.
|
|
1185
|
+
`;
|
|
1186
|
+
var VERSION = "0.1.0";
|
|
1187
|
+
var UsageError = class extends Error {
|
|
1188
|
+
};
|
|
1189
|
+
async function main(argv) {
|
|
1190
|
+
const { values, positionals } = parseArgs({
|
|
1191
|
+
args: argv,
|
|
1192
|
+
allowPositionals: true,
|
|
1193
|
+
strict: true,
|
|
1194
|
+
options: {
|
|
1195
|
+
parent: { type: "string" },
|
|
1196
|
+
to: { type: "string" },
|
|
1197
|
+
status: { type: "string" },
|
|
1198
|
+
owner: { type: "string" },
|
|
1199
|
+
agent: { type: "string" },
|
|
1200
|
+
priority: { type: "string" },
|
|
1201
|
+
template: { type: "string" },
|
|
1202
|
+
vault: { type: "string" },
|
|
1203
|
+
tree: { type: "boolean", default: false },
|
|
1204
|
+
archived: { type: "boolean", default: false },
|
|
1205
|
+
undo: { type: "boolean", default: false },
|
|
1206
|
+
recursive: { type: "boolean", short: "r", default: false },
|
|
1207
|
+
"dry-run": { type: "boolean", default: false },
|
|
1208
|
+
json: { type: "boolean", default: false },
|
|
1209
|
+
force: { type: "boolean", default: false },
|
|
1210
|
+
help: { type: "boolean", short: "h", default: false },
|
|
1211
|
+
version: { type: "boolean", short: "V", default: false }
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
if (values.version) {
|
|
1215
|
+
process.stdout.write(`${VERSION}
|
|
1216
|
+
`);
|
|
1217
|
+
return 0;
|
|
1218
|
+
}
|
|
1219
|
+
const [command, ...rest] = positionals;
|
|
1220
|
+
if (values.help || command === void 0 || command === "help") {
|
|
1221
|
+
process.stdout.write(HELP);
|
|
1222
|
+
return command === void 0 && !values.help ? 2 : 0;
|
|
1223
|
+
}
|
|
1224
|
+
if (values.force && (command !== "hook" || rest[0] !== "install")) {
|
|
1225
|
+
throw new UsageError("--force applies only to wi hook install.");
|
|
1226
|
+
}
|
|
1227
|
+
const vault = await openVault(values.vault);
|
|
1228
|
+
const json = values.json;
|
|
1229
|
+
switch (command) {
|
|
1230
|
+
case "new":
|
|
1231
|
+
return runNew(vault, rest, values, json);
|
|
1232
|
+
case "status":
|
|
1233
|
+
return runStatus(vault, rest, json);
|
|
1234
|
+
case "move":
|
|
1235
|
+
return runMove(vault, rest, values, json);
|
|
1236
|
+
case "archive":
|
|
1237
|
+
return runArchive(vault, rest, values, json);
|
|
1238
|
+
case "rm":
|
|
1239
|
+
return runRemove(vault, rest, values, json);
|
|
1240
|
+
case "children":
|
|
1241
|
+
return runChildren(vault, rest, values, json);
|
|
1242
|
+
case "validate":
|
|
1243
|
+
return runValidate(vault, json);
|
|
1244
|
+
case "template":
|
|
1245
|
+
return runTemplate(vault, rest, json);
|
|
1246
|
+
case "hook":
|
|
1247
|
+
return runHook(vault, rest, values, json);
|
|
1248
|
+
default:
|
|
1249
|
+
throw new UsageError(`unknown command "${command}". Run wi --help.`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
async function openVault(flag) {
|
|
1253
|
+
const hint = flag ?? process.env["WI_VAULT"];
|
|
1254
|
+
const root = hint ? resolve2(hint) : findVaultRoot(process.cwd());
|
|
1255
|
+
if (root === null) {
|
|
1256
|
+
throw new UsageError(
|
|
1257
|
+
"no vault found. Run wi inside a vault, pass --vault <path>, or set WI_VAULT."
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
if (findVaultRoot(root) !== root) {
|
|
1261
|
+
throw new UsageError(`${root} is not a vault: it has no Boards/ folder or .wi.json.`);
|
|
1262
|
+
}
|
|
1263
|
+
return loadVault(root);
|
|
1264
|
+
}
|
|
1265
|
+
async function runNew(vault, rest, values, json) {
|
|
1266
|
+
const title = rest.join(" ").trim();
|
|
1267
|
+
if (title === "") throw new UsageError('wi new needs a title. Try: wi new "Build server" --parent Main');
|
|
1268
|
+
const parent = typeof values["parent"] === "string" ? values["parent"] : vault.config.defaultRoot;
|
|
1269
|
+
if (!parent) throw new UsageError("wi new needs --parent <ref> or defaultRoot in .wi.json.");
|
|
1270
|
+
const priority = typeof values["priority"] === "string" ? Number(values["priority"]) : void 0;
|
|
1271
|
+
if (priority !== void 0 && !Number.isFinite(priority)) {
|
|
1272
|
+
throw new UsageError(`--priority must be a number, not "${values["priority"]}".`);
|
|
1273
|
+
}
|
|
1274
|
+
const created = await createItem(vault, {
|
|
1275
|
+
title,
|
|
1276
|
+
parent,
|
|
1277
|
+
...typeof values["status"] === "string" ? { status: values["status"] } : {},
|
|
1278
|
+
...typeof values["owner"] === "string" ? { owner: values["owner"] } : {},
|
|
1279
|
+
...typeof values["agent"] === "string" ? { agent: values["agent"] } : {},
|
|
1280
|
+
...typeof values["template"] === "string" ? { template: values["template"] } : {},
|
|
1281
|
+
...priority !== void 0 ? { priority } : {}
|
|
1282
|
+
});
|
|
1283
|
+
if (vault.unaccounted.length > 0) {
|
|
1284
|
+
process.stderr.write(
|
|
1285
|
+
`wi: warning: a new id or filename may clash with an unread file. Unread files: ${vault.unaccounted.join(", ")}.
|
|
1286
|
+
`
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
if (json) print({ id: created.id, path: created.relPath, parent: created.parentStem });
|
|
1290
|
+
else process.stdout.write(`${created.id} ${created.relPath} (child of ${created.parentStem})
|
|
1291
|
+
`);
|
|
1292
|
+
return 0;
|
|
1293
|
+
}
|
|
1294
|
+
async function runStatus(vault, rest, json) {
|
|
1295
|
+
const [ref, status] = rest;
|
|
1296
|
+
if (ref === void 0 || status === void 0) {
|
|
1297
|
+
throw new UsageError(`wi status needs a <ref> and a <status>. One of: ${STATUSES.join(", ")}.`);
|
|
1298
|
+
}
|
|
1299
|
+
const change = await setStatus(vault, ref, status);
|
|
1300
|
+
if (json) {
|
|
1301
|
+
print({
|
|
1302
|
+
id: change.item.id,
|
|
1303
|
+
path: change.item.relPath,
|
|
1304
|
+
from: change.from ?? null,
|
|
1305
|
+
to: change.to,
|
|
1306
|
+
prev_status: change.recorded ?? null,
|
|
1307
|
+
changed: change.changed
|
|
1308
|
+
});
|
|
1309
|
+
} else if (!change.changed) {
|
|
1310
|
+
process.stdout.write(`${label(change.item)} is already ${change.to}. Nothing written.
|
|
1311
|
+
`);
|
|
1312
|
+
} else {
|
|
1313
|
+
const recorded = change.recorded ? ` (prev_status: ${change.recorded})` : "";
|
|
1314
|
+
process.stdout.write(`${label(change.item)} ${change.from ?? "\u2014"} \u2192 ${change.to}${recorded}
|
|
1315
|
+
`);
|
|
1316
|
+
}
|
|
1317
|
+
return 0;
|
|
1318
|
+
}
|
|
1319
|
+
async function runMove(vault, rest, values, json) {
|
|
1320
|
+
const ref = rest.join(" ").trim();
|
|
1321
|
+
const to = typeof values["to"] === "string" ? values["to"] : "";
|
|
1322
|
+
if (ref === "" || to === "") {
|
|
1323
|
+
throw new UsageError("wi move needs a <ref> and --to <ref>. Try: wi move wi-a7f3 --to Main");
|
|
1324
|
+
}
|
|
1325
|
+
const result = await moveItem(vault, ref, to);
|
|
1326
|
+
if (json) {
|
|
1327
|
+
print({
|
|
1328
|
+
id: result.item.id,
|
|
1329
|
+
path: result.item.relPath,
|
|
1330
|
+
from: result.from,
|
|
1331
|
+
to: result.to.stem,
|
|
1332
|
+
changed: result.changed
|
|
1333
|
+
});
|
|
1334
|
+
} else if (!result.changed) {
|
|
1335
|
+
process.stdout.write(`${label(result.item)} is already under ${result.to.stem}. Nothing written.
|
|
1336
|
+
`);
|
|
1337
|
+
} else {
|
|
1338
|
+
process.stdout.write(`${label(result.item)} ${result.from ?? "\u2014"} \u2192 ${result.to.stem}
|
|
1339
|
+
`);
|
|
1340
|
+
}
|
|
1341
|
+
return 0;
|
|
1342
|
+
}
|
|
1343
|
+
async function runArchive(vault, rest, values, json) {
|
|
1344
|
+
const ref = rest.join(" ").trim();
|
|
1345
|
+
if (ref === "") throw new UsageError("wi archive needs a <ref>.");
|
|
1346
|
+
const change = await archiveItem(vault, ref, values["undo"] === true);
|
|
1347
|
+
if (json) {
|
|
1348
|
+
print({ id: change.item.id, path: change.item.relPath, archived: change.archived, changed: change.changed });
|
|
1349
|
+
} else {
|
|
1350
|
+
const verb = change.archived ? "archived" : "unarchived";
|
|
1351
|
+
process.stdout.write(`${label(change.item)} ${verb}${change.changed ? "" : " (already so; nothing written)"}
|
|
1352
|
+
`);
|
|
1353
|
+
}
|
|
1354
|
+
return 0;
|
|
1355
|
+
}
|
|
1356
|
+
async function runRemove(vault, rest, values, json) {
|
|
1357
|
+
const ref = rest.join(" ").trim();
|
|
1358
|
+
if (ref === "") throw new UsageError("wi rm needs a <ref>.");
|
|
1359
|
+
const result = await removeItem(vault, ref, {
|
|
1360
|
+
recursive: values["recursive"] === true,
|
|
1361
|
+
dryRun: values["dry-run"] === true
|
|
1362
|
+
});
|
|
1363
|
+
if (json) {
|
|
1364
|
+
print({
|
|
1365
|
+
dryRun: result.dryRun,
|
|
1366
|
+
removed: result.removed.map((r) => ({
|
|
1367
|
+
id: r.item.id,
|
|
1368
|
+
title: r.item.title,
|
|
1369
|
+
from: r.item.relPath,
|
|
1370
|
+
to: r.trashedTo
|
|
1371
|
+
}))
|
|
1372
|
+
});
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
const verb = result.dryRun ? "would remove" : "removed";
|
|
1376
|
+
for (const entry of result.removed) {
|
|
1377
|
+
const id = entry.item.id ?? "(no id)";
|
|
1378
|
+
process.stdout.write(`${verb} ${id} ${entry.item.relPath} -> ${entry.trashedTo}
|
|
1379
|
+
`);
|
|
1380
|
+
}
|
|
1381
|
+
const count = result.removed.length;
|
|
1382
|
+
process.stdout.write(`${count} work item${count === 1 ? "" : "s"}${result.dryRun ? ", nothing written" : ""}
|
|
1383
|
+
`);
|
|
1384
|
+
return 0;
|
|
1385
|
+
}
|
|
1386
|
+
function runChildren(vault, rest, values, json) {
|
|
1387
|
+
const ref = rest.join(" ").trim();
|
|
1388
|
+
if (ref === "") throw new UsageError("wi children needs a <ref>.");
|
|
1389
|
+
const listing = listChildren(vault, ref, {
|
|
1390
|
+
...typeof values["status"] === "string" ? { status: values["status"] } : {},
|
|
1391
|
+
recursive: values["tree"] === true,
|
|
1392
|
+
archived: values["archived"] === true
|
|
1393
|
+
});
|
|
1394
|
+
if (json) {
|
|
1395
|
+
print({
|
|
1396
|
+
parent: { id: listing.parent.id, path: listing.parent.relPath, board: listing.parent.board },
|
|
1397
|
+
cycle: listing.cycle,
|
|
1398
|
+
children: listing.children.map((row) => ({
|
|
1399
|
+
id: row.item.id,
|
|
1400
|
+
title: row.item.title,
|
|
1401
|
+
path: row.item.relPath,
|
|
1402
|
+
status: row.item.status ?? null,
|
|
1403
|
+
children: row.childCount,
|
|
1404
|
+
depth: row.depth,
|
|
1405
|
+
archived: row.archived
|
|
1406
|
+
}))
|
|
1407
|
+
});
|
|
1408
|
+
return 0;
|
|
1409
|
+
}
|
|
1410
|
+
const out = [
|
|
1411
|
+
`${label(listing.parent)}${listing.parent.board ? " [board]" : ""}`
|
|
1412
|
+
];
|
|
1413
|
+
if (listing.children.length === 0) {
|
|
1414
|
+
out.push(" no children");
|
|
1415
|
+
} else if (values["tree"] === true || typeof values["status"] === "string") {
|
|
1416
|
+
for (const row of listing.children) out.push(` ${" ".repeat(row.depth)}${row3(row)}`);
|
|
1417
|
+
} else {
|
|
1418
|
+
for (const [status, rows] of listing.byStatus) {
|
|
1419
|
+
out.push(` ${status} (${rows.length})`);
|
|
1420
|
+
for (const row of rows) out.push(` ${row3(row)}`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (listing.cycle) out.push(" ! the parent chain loops. Run wi validate.");
|
|
1424
|
+
process.stdout.write(`${out.join("\n")}
|
|
1425
|
+
`);
|
|
1426
|
+
return 0;
|
|
1427
|
+
}
|
|
1428
|
+
async function runValidate(vault, json) {
|
|
1429
|
+
const report = await validate(vault);
|
|
1430
|
+
if (json) {
|
|
1431
|
+
print({
|
|
1432
|
+
ok: report.ok,
|
|
1433
|
+
items: report.itemCount,
|
|
1434
|
+
errors: report.errorCount,
|
|
1435
|
+
warnings: report.warningCount,
|
|
1436
|
+
problems: report.problems
|
|
1437
|
+
});
|
|
1438
|
+
return report.ok ? 0 : 1;
|
|
1439
|
+
}
|
|
1440
|
+
for (const problem of report.problems) process.stdout.write(`${line(problem)}
|
|
1441
|
+
`);
|
|
1442
|
+
const counts = `${report.itemCount} work items, ${report.errorCount} errors, ${report.warningCount} warnings`;
|
|
1443
|
+
process.stdout.write(report.ok ? `ok: ${counts}
|
|
1444
|
+
` : `FAILED: ${counts}
|
|
1445
|
+
`);
|
|
1446
|
+
return report.ok ? 0 : 1;
|
|
1447
|
+
}
|
|
1448
|
+
async function runTemplate(vault, rest, json) {
|
|
1449
|
+
const action = rest[0] ?? "list";
|
|
1450
|
+
if (action === "list") {
|
|
1451
|
+
const templates = listTemplates();
|
|
1452
|
+
if (json) {
|
|
1453
|
+
print(templates.map((t) => ({
|
|
1454
|
+
name: t.name,
|
|
1455
|
+
description: t.description,
|
|
1456
|
+
sections: t.sections.map((s) => s.heading)
|
|
1457
|
+
})));
|
|
1458
|
+
return 0;
|
|
1459
|
+
}
|
|
1460
|
+
for (const template of templates) {
|
|
1461
|
+
process.stdout.write(`${template.name.padEnd(14)} ${template.description}
|
|
1462
|
+
`);
|
|
1463
|
+
process.stdout.write(`${" ".repeat(16)}${template.sections.map((s) => s.heading).join(", ")}
|
|
1464
|
+
`);
|
|
1465
|
+
}
|
|
1466
|
+
return 0;
|
|
1467
|
+
}
|
|
1468
|
+
if (action === "write") {
|
|
1469
|
+
const written = await writeTemplates(vault);
|
|
1470
|
+
if (json) {
|
|
1471
|
+
print(written);
|
|
1472
|
+
return 0;
|
|
1473
|
+
}
|
|
1474
|
+
for (const entry of written) {
|
|
1475
|
+
process.stdout.write(`${entry.outcome.padEnd(10)}${entry.relPath}
|
|
1476
|
+
`);
|
|
1477
|
+
}
|
|
1478
|
+
return 0;
|
|
1479
|
+
}
|
|
1480
|
+
throw new UsageError(`wi template takes "list" or "write", not "${action}".`);
|
|
1481
|
+
}
|
|
1482
|
+
async function runHook(vault, rest, values, json) {
|
|
1483
|
+
const [action, ...extra] = rest;
|
|
1484
|
+
if (extra.length > 0 || action !== "install" && action !== "uninstall" && action !== "status") {
|
|
1485
|
+
throw new UsageError("wi hook needs install, uninstall, or status. Run wi --help.");
|
|
1486
|
+
}
|
|
1487
|
+
if (values["force"] === true && action !== "install") {
|
|
1488
|
+
throw new UsageError("--force applies only to wi hook install.");
|
|
1489
|
+
}
|
|
1490
|
+
if (action === "status") {
|
|
1491
|
+
const status = await hookStatus(vault.root);
|
|
1492
|
+
if (json) print(status);
|
|
1493
|
+
else {
|
|
1494
|
+
process.stdout.write(`vault ${status.vault}
|
|
1495
|
+
`);
|
|
1496
|
+
process.stdout.write(`git repo ${status.gitRepo ?? "none \u2014 run git init in the vault"}
|
|
1497
|
+
`);
|
|
1498
|
+
if (status.gitRepo) process.stdout.write(`pre-commit ${status.preCommit}
|
|
1499
|
+
`);
|
|
1500
|
+
}
|
|
1501
|
+
return 0;
|
|
1502
|
+
}
|
|
1503
|
+
if (action === "uninstall") {
|
|
1504
|
+
const removed = await uninstallHook(vault.root);
|
|
1505
|
+
if (json) print({ removed });
|
|
1506
|
+
else process.stdout.write(removed ? `removed ${removed}
|
|
1507
|
+
` : "done\n");
|
|
1508
|
+
return 0;
|
|
1509
|
+
}
|
|
1510
|
+
const path = await installHook(vault.root, fileURLToPath(import.meta.url), values["force"] === true);
|
|
1511
|
+
if (json) print({ installed: path });
|
|
1512
|
+
else {
|
|
1513
|
+
process.stdout.write(`installed ${path}
|
|
1514
|
+
`);
|
|
1515
|
+
process.stdout.write("a commit that would record an invalid vault is now refused\n");
|
|
1516
|
+
process.stdout.write("bypass with: git commit --no-verify\n");
|
|
1517
|
+
}
|
|
1518
|
+
const report = await validate(vault);
|
|
1519
|
+
if (!report.ok && !json) {
|
|
1520
|
+
process.stdout.write("\nheads up: the vault does not validate right now, so the next commit will be refused:\n");
|
|
1521
|
+
for (const problem of report.problems) process.stdout.write(`${line(problem)}
|
|
1522
|
+
`);
|
|
1523
|
+
}
|
|
1524
|
+
return 0;
|
|
1525
|
+
}
|
|
1526
|
+
function line(problem) {
|
|
1527
|
+
const mark = problem.severity === "error" ? "error" : "warn ";
|
|
1528
|
+
return `${mark} ${problem.relPath} [${problem.rule}] ${problem.message}`;
|
|
1529
|
+
}
|
|
1530
|
+
function label(item) {
|
|
1531
|
+
return `${item.id ?? "(no id)"} ${item.title ?? item.stem}`;
|
|
1532
|
+
}
|
|
1533
|
+
function row3(row) {
|
|
1534
|
+
const status = row.item.status ?? "\u2014";
|
|
1535
|
+
const kids = row.childCount > 0 ? ` (${row.childCount})` : "";
|
|
1536
|
+
const board = row.item.board ? " [board]" : "";
|
|
1537
|
+
const archived = row.archived ? " [archived]" : "";
|
|
1538
|
+
return `${row.item.id ?? "(no id)"} ${status.padEnd(7)} ${row.item.title ?? row.item.stem}${kids}${board}${archived}`;
|
|
1539
|
+
}
|
|
1540
|
+
function print(value) {
|
|
1541
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
1542
|
+
`);
|
|
1543
|
+
}
|
|
1544
|
+
try {
|
|
1545
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1548
|
+
process.stderr.write(`wi: ${message}
|
|
1549
|
+
`);
|
|
1550
|
+
process.exitCode = 2;
|
|
1551
|
+
}
|