@dennisrongo/dsh-todo 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -17
- package/lib/bin.js +504 -0
- package/lib/cli.js +513 -0
- package/lib/client.js +275 -45
- package/lib/index.js +197 -81
- package/lib/typert.host.js +7 -4
- package/package.json +12 -2
package/lib/bin.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve as resolve2 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/db.ts
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
// src/types.ts
|
|
12
|
+
var STATUSES = ["backlog", "todo", "in-progress", "blocked", "done"];
|
|
13
|
+
var DEFAULT_STATUS = "todo";
|
|
14
|
+
var PRIORITIES = ["p0", "p1", "p2", "p3"];
|
|
15
|
+
var DEFAULT_PRIORITY = "p2";
|
|
16
|
+
function toStatus(value) {
|
|
17
|
+
return typeof value === "string" && STATUSES.includes(value) ? value : DEFAULT_STATUS;
|
|
18
|
+
}
|
|
19
|
+
function toPriority(value) {
|
|
20
|
+
return typeof value === "string" && PRIORITIES.includes(value) ? value : DEFAULT_PRIORITY;
|
|
21
|
+
}
|
|
22
|
+
function normalizeLabel(raw) {
|
|
23
|
+
if (typeof raw !== "string") return void 0;
|
|
24
|
+
const text = raw.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL);
|
|
25
|
+
return text.length > 0 ? text : void 0;
|
|
26
|
+
}
|
|
27
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
|
+
function normalizeDueDate(raw) {
|
|
29
|
+
if (typeof raw !== "string") return void 0;
|
|
30
|
+
const text = raw.trim();
|
|
31
|
+
if (!DATE_RE.test(text)) return void 0;
|
|
32
|
+
const parsed = /* @__PURE__ */ new Date(`${text}T00:00:00Z`);
|
|
33
|
+
if (Number.isNaN(parsed.getTime())) return void 0;
|
|
34
|
+
return parsed.toISOString().slice(0, 10) === text ? text : void 0;
|
|
35
|
+
}
|
|
36
|
+
var MAX_TEXT = 500;
|
|
37
|
+
var MAX_DESC = 5e3;
|
|
38
|
+
var MAX_LABEL = 60;
|
|
39
|
+
|
|
40
|
+
// src/db.ts
|
|
41
|
+
var DOT_DSH = ".dsh";
|
|
42
|
+
var DB_FILE = "todo.db";
|
|
43
|
+
var BUSY_TIMEOUT_MS = 5e3;
|
|
44
|
+
function openDb(dir) {
|
|
45
|
+
const resolved = resolve(dir);
|
|
46
|
+
mkdirSync(join(resolved, DOT_DSH), { recursive: true });
|
|
47
|
+
const db = new DatabaseSync(join(resolved, DOT_DSH, DB_FILE));
|
|
48
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS todo (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
text TEXT NOT NULL,
|
|
53
|
+
done INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
created_at INTEGER NOT NULL,
|
|
55
|
+
completed_at INTEGER,
|
|
56
|
+
archived_at INTEGER,
|
|
57
|
+
position INTEGER NOT NULL
|
|
58
|
+
);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
60
|
+
`);
|
|
61
|
+
migrateSchema(db);
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
64
|
+
function migrateSchema(db) {
|
|
65
|
+
const columns = new Set(
|
|
66
|
+
db.prepare("PRAGMA table_info(todo)").all().map((c) => String(c.name))
|
|
67
|
+
);
|
|
68
|
+
const add = (name, ddl) => {
|
|
69
|
+
if (columns.has(name)) return false;
|
|
70
|
+
db.exec(`ALTER TABLE todo ADD COLUMN ${ddl}`);
|
|
71
|
+
columns.add(name);
|
|
72
|
+
return true;
|
|
73
|
+
};
|
|
74
|
+
const addedTitle = add("title", "title TEXT");
|
|
75
|
+
const addedStatus = add("status", "status TEXT");
|
|
76
|
+
add("description", "description TEXT");
|
|
77
|
+
add("priority", "priority TEXT");
|
|
78
|
+
add("release", "release TEXT");
|
|
79
|
+
add("sprint", "sprint TEXT");
|
|
80
|
+
add("due_date", "due_date TEXT");
|
|
81
|
+
if (addedTitle && columns.has("text")) {
|
|
82
|
+
db.exec("UPDATE todo SET title = text WHERE title IS NULL");
|
|
83
|
+
}
|
|
84
|
+
if (addedStatus && columns.has("done")) {
|
|
85
|
+
db.exec("UPDATE todo SET status = CASE WHEN done = 1 THEN 'done' ELSE 'todo' END WHERE status IS NULL");
|
|
86
|
+
}
|
|
87
|
+
db.exec("UPDATE todo SET status = 'todo' WHERE status IS NULL OR status = ''");
|
|
88
|
+
db.exec("UPDATE todo SET priority = 'p2' WHERE priority IS NULL OR priority = ''");
|
|
89
|
+
db.exec("UPDATE todo SET title = '' WHERE title IS NULL");
|
|
90
|
+
}
|
|
91
|
+
function readList(db) {
|
|
92
|
+
const revision = Number(db.prepare("SELECT value FROM meta WHERE key = 'revision'").get()?.value ?? 0);
|
|
93
|
+
const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
|
|
94
|
+
const rows = db.prepare(
|
|
95
|
+
`SELECT id, title, description, status, priority, release, sprint, due_date,
|
|
96
|
+
created_at, completed_at, archived_at
|
|
97
|
+
FROM todo ORDER BY position ASC`
|
|
98
|
+
).all();
|
|
99
|
+
const text = (v) => v === null || v === void 0 ? void 0 : String(v);
|
|
100
|
+
const items = [];
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
items.push({
|
|
103
|
+
id: String(row.id),
|
|
104
|
+
title: String(row.title ?? ""),
|
|
105
|
+
status: toStatus(row.status),
|
|
106
|
+
priority: toPriority(row.priority),
|
|
107
|
+
...text(row.description) !== void 0 ? { description: text(row.description) } : {},
|
|
108
|
+
...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
|
|
109
|
+
...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
|
|
110
|
+
...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
|
|
111
|
+
createdAt: Number(row.created_at),
|
|
112
|
+
...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
|
|
113
|
+
...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return { items, revision, updatedAt };
|
|
117
|
+
}
|
|
118
|
+
function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
119
|
+
db.exec("BEGIN IMMEDIATE");
|
|
120
|
+
try {
|
|
121
|
+
db.prepare("DELETE FROM todo").run();
|
|
122
|
+
const insert = db.prepare(
|
|
123
|
+
`INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
|
|
124
|
+
text, done, created_at, completed_at, archived_at, position)
|
|
125
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
126
|
+
);
|
|
127
|
+
items.forEach((item, index) => {
|
|
128
|
+
insert.run(
|
|
129
|
+
item.id,
|
|
130
|
+
item.title,
|
|
131
|
+
item.description ?? null,
|
|
132
|
+
item.status,
|
|
133
|
+
item.priority,
|
|
134
|
+
item.release ?? null,
|
|
135
|
+
item.sprint ?? null,
|
|
136
|
+
item.dueDate ?? null,
|
|
137
|
+
item.title,
|
|
138
|
+
item.status === "done" ? 1 : 0,
|
|
139
|
+
item.createdAt,
|
|
140
|
+
item.completedAt ?? null,
|
|
141
|
+
item.archivedAt ?? null,
|
|
142
|
+
index
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
db.prepare(`INSERT INTO meta (key, value) VALUES ('revision', ?)
|
|
146
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(revision));
|
|
147
|
+
db.prepare(`INSERT INTO meta (key, value) VALUES ('updatedAt', ?)
|
|
148
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(updatedAt));
|
|
149
|
+
db.exec("COMMIT");
|
|
150
|
+
} catch (err) {
|
|
151
|
+
db.exec("ROLLBACK");
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/cli.ts
|
|
157
|
+
var EXIT = {
|
|
158
|
+
ok: 0,
|
|
159
|
+
/** Bad flags, unknown command, malformed value. */
|
|
160
|
+
usage: 2,
|
|
161
|
+
/** A well-formed request that matched nothing (e.g. unknown task id). */
|
|
162
|
+
notFound: 3
|
|
163
|
+
};
|
|
164
|
+
function parseArgs(argv) {
|
|
165
|
+
const [command = "help", ...rest] = argv;
|
|
166
|
+
const positional = [];
|
|
167
|
+
const options = {};
|
|
168
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
169
|
+
const token = rest[i];
|
|
170
|
+
if (!token.startsWith("--")) {
|
|
171
|
+
positional.push(token);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const body = token.slice(2);
|
|
175
|
+
const eq = body.indexOf("=");
|
|
176
|
+
if (eq >= 0) {
|
|
177
|
+
options[body.slice(0, eq)] = body.slice(eq + 1);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const next = rest[i + 1];
|
|
181
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
182
|
+
options[body] = true;
|
|
183
|
+
} else {
|
|
184
|
+
options[body] = next;
|
|
185
|
+
i += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { command, positional, options };
|
|
189
|
+
}
|
|
190
|
+
var CliError = class extends Error {
|
|
191
|
+
/**
|
|
192
|
+
* @param message - human-readable reason.
|
|
193
|
+
* @param code - process exit code, from {@link EXIT}.
|
|
194
|
+
*/
|
|
195
|
+
constructor(message, code = EXIT.usage) {
|
|
196
|
+
super(message);
|
|
197
|
+
this.code = code;
|
|
198
|
+
this.name = "CliError";
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
function str(options, key) {
|
|
202
|
+
const value = options[key];
|
|
203
|
+
if (value === void 0) return void 0;
|
|
204
|
+
if (value === true) throw new CliError(`--${key} needs a value`);
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
function oneOf(options, key, allowed) {
|
|
208
|
+
const raw = str(options, key);
|
|
209
|
+
if (raw === void 0) return void 0;
|
|
210
|
+
if (!allowed.includes(raw)) {
|
|
211
|
+
throw new CliError(`--${key} must be one of: ${allowed.join(", ")} (got "${raw}")`);
|
|
212
|
+
}
|
|
213
|
+
return raw;
|
|
214
|
+
}
|
|
215
|
+
function resolveWorkspace(options, cwd) {
|
|
216
|
+
return resolve2(str(options, "workspace") ?? cwd);
|
|
217
|
+
}
|
|
218
|
+
function makeId(now, rand) {
|
|
219
|
+
return `t${now.toString(36)}${Math.floor(rand() * 1e6).toString(36)}`;
|
|
220
|
+
}
|
|
221
|
+
function isDone(item) {
|
|
222
|
+
return item.status === "done";
|
|
223
|
+
}
|
|
224
|
+
function isArchived(item) {
|
|
225
|
+
return typeof item.archivedAt === "number";
|
|
226
|
+
}
|
|
227
|
+
function findItem(items, ref) {
|
|
228
|
+
const exact = items.find((i) => i.id === ref);
|
|
229
|
+
if (exact) return exact;
|
|
230
|
+
const matches = items.filter((i) => i.id.startsWith(ref));
|
|
231
|
+
if (matches.length === 1) return matches[0];
|
|
232
|
+
if (matches.length === 0) throw new CliError(`no task matching "${ref}"`, EXIT.notFound);
|
|
233
|
+
throw new CliError(
|
|
234
|
+
`"${ref}" matches ${matches.length} tasks: ${matches.map((i) => i.id).join(", ")}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
function mutate(dir, fn) {
|
|
238
|
+
const db = openDb(dir);
|
|
239
|
+
try {
|
|
240
|
+
const current = readList(db);
|
|
241
|
+
const next = fn(current.items);
|
|
242
|
+
const revision = current.revision + 1;
|
|
243
|
+
writeList(db, next, revision);
|
|
244
|
+
return { items: next, revision };
|
|
245
|
+
} finally {
|
|
246
|
+
db.close();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function read(dir) {
|
|
250
|
+
const db = openDb(dir);
|
|
251
|
+
try {
|
|
252
|
+
const list = readList(db);
|
|
253
|
+
return { items: list.items, revision: list.revision };
|
|
254
|
+
} finally {
|
|
255
|
+
db.close();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function filterList(items, filter) {
|
|
259
|
+
return items.filter((item) => {
|
|
260
|
+
if (!filter.archived && isArchived(item)) return false;
|
|
261
|
+
if (filter.archived && !isArchived(item)) return false;
|
|
262
|
+
if (filter.open && isDone(item)) return false;
|
|
263
|
+
if (filter.status && item.status !== filter.status) return false;
|
|
264
|
+
if (filter.priority && item.priority !== filter.priority) return false;
|
|
265
|
+
if (filter.release && item.release !== filter.release) return false;
|
|
266
|
+
if (filter.sprint && item.sprint !== filter.sprint) return false;
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function formatItem(item) {
|
|
271
|
+
const box = isDone(item) ? "[x]" : "[ ]";
|
|
272
|
+
const bits = [box, item.id.padEnd(12), item.status.padEnd(11), item.priority, item.title];
|
|
273
|
+
const meta = [];
|
|
274
|
+
if (item.release) meta.push(`release=${item.release}`);
|
|
275
|
+
if (item.sprint) meta.push(`sprint=${item.sprint}`);
|
|
276
|
+
if (item.dueDate) meta.push(`due=${item.dueDate}`);
|
|
277
|
+
if (isArchived(item)) meta.push("archived");
|
|
278
|
+
return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
|
|
279
|
+
}
|
|
280
|
+
var HELP = `dsh-todo \u2014 manage a workspace's task list
|
|
281
|
+
|
|
282
|
+
Usage
|
|
283
|
+
dsh-todo <command> [options]
|
|
284
|
+
|
|
285
|
+
Commands
|
|
286
|
+
list Show tasks (active only by default)
|
|
287
|
+
add <title> Create a task
|
|
288
|
+
update <id> Change fields on a task
|
|
289
|
+
done <id> Mark a task done
|
|
290
|
+
reopen <id> Return a finished task to todo
|
|
291
|
+
rm <id> Delete a task outright
|
|
292
|
+
archive [<id>] Archive one task, or every completed task
|
|
293
|
+
show <id> Print one task in full
|
|
294
|
+
help This text
|
|
295
|
+
|
|
296
|
+
Options
|
|
297
|
+
--workspace <dir> Workspace directory (default: cwd)
|
|
298
|
+
--json Machine-readable output (use this from a script)
|
|
299
|
+
|
|
300
|
+
--status <s> ${STATUSES.join("|")}
|
|
301
|
+
--priority <p> ${PRIORITIES.join("|")}
|
|
302
|
+
--release <label> e.g. v1.2.0 (empty string clears)
|
|
303
|
+
--sprint <label> e.g. "Sprint 24" (empty string clears)
|
|
304
|
+
--due <YYYY-MM-DD> Calendar day (empty string clears)
|
|
305
|
+
--description <text> Body text (empty string clears)
|
|
306
|
+
--title <text> Rename (update only)
|
|
307
|
+
|
|
308
|
+
list filters: --status --priority --release --sprint --open --archived
|
|
309
|
+
|
|
310
|
+
Ids may be given as any unambiguous prefix.
|
|
311
|
+
|
|
312
|
+
Examples
|
|
313
|
+
dsh-todo list --open --json
|
|
314
|
+
dsh-todo add "Fix token refresh" --priority p0 --release v1.2.0 --due 2026-03-14
|
|
315
|
+
dsh-todo update t1a2 --status in-progress --sprint "Sprint 24"
|
|
316
|
+
dsh-todo done t1a2
|
|
317
|
+
`;
|
|
318
|
+
function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
319
|
+
const { command, positional, options } = parsed;
|
|
320
|
+
const dir = resolveWorkspace(options, cwd);
|
|
321
|
+
switch (command) {
|
|
322
|
+
case "help":
|
|
323
|
+
case "--help":
|
|
324
|
+
case "-h":
|
|
325
|
+
return { text: HELP, json: { help: HELP } };
|
|
326
|
+
case "list": {
|
|
327
|
+
const { items } = read(dir);
|
|
328
|
+
const filtered = filterList(items, {
|
|
329
|
+
status: oneOf(options, "status", STATUSES),
|
|
330
|
+
priority: oneOf(options, "priority", PRIORITIES),
|
|
331
|
+
release: str(options, "release"),
|
|
332
|
+
sprint: str(options, "sprint"),
|
|
333
|
+
open: options.open === true,
|
|
334
|
+
archived: options.archived === true
|
|
335
|
+
});
|
|
336
|
+
const text = filtered.length === 0 ? "No matching tasks." : filtered.map(formatItem).join("\n");
|
|
337
|
+
return { text, json: { count: filtered.length, items: filtered } };
|
|
338
|
+
}
|
|
339
|
+
case "show": {
|
|
340
|
+
const ref = positional[0];
|
|
341
|
+
if (!ref) throw new CliError("show needs a task id");
|
|
342
|
+
const { items } = read(dir);
|
|
343
|
+
const item = findItem(items, ref);
|
|
344
|
+
const lines = [
|
|
345
|
+
`id ${item.id}`,
|
|
346
|
+
`title ${item.title}`,
|
|
347
|
+
`status ${item.status}`,
|
|
348
|
+
`priority ${item.priority}`,
|
|
349
|
+
`release ${item.release ?? "-"}`,
|
|
350
|
+
`sprint ${item.sprint ?? "-"}`,
|
|
351
|
+
`due ${item.dueDate ?? "-"}`,
|
|
352
|
+
`created ${new Date(item.createdAt).toISOString()}`,
|
|
353
|
+
...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
|
|
354
|
+
...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
|
|
355
|
+
...item.description ? ["", item.description] : []
|
|
356
|
+
];
|
|
357
|
+
return { text: lines.join("\n"), json: item };
|
|
358
|
+
}
|
|
359
|
+
case "add": {
|
|
360
|
+
const title = positional.join(" ").trim();
|
|
361
|
+
if (!title) throw new CliError("add needs a title");
|
|
362
|
+
const description = str(options, "description");
|
|
363
|
+
const release = normalizeLabel(str(options, "release"));
|
|
364
|
+
const sprint = normalizeLabel(str(options, "sprint"));
|
|
365
|
+
const dueRaw = str(options, "due");
|
|
366
|
+
if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
|
|
367
|
+
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
|
|
368
|
+
}
|
|
369
|
+
const item = {
|
|
370
|
+
id: makeId(now(), rand),
|
|
371
|
+
title: title.slice(0, MAX_TEXT),
|
|
372
|
+
status: oneOf(options, "status", STATUSES) ?? "todo",
|
|
373
|
+
priority: oneOf(options, "priority", PRIORITIES) ?? "p2",
|
|
374
|
+
...description ? { description: description.slice(0, MAX_DESC) } : {},
|
|
375
|
+
...release !== void 0 ? { release } : {},
|
|
376
|
+
...sprint !== void 0 ? { sprint } : {},
|
|
377
|
+
...dueRaw ? { dueDate: normalizeDueDate(dueRaw) } : {},
|
|
378
|
+
createdAt: now()
|
|
379
|
+
};
|
|
380
|
+
const { revision } = mutate(dir, (items) => [...items, item]);
|
|
381
|
+
return { text: `added ${item.id} ${item.title}`, json: { item, revision } };
|
|
382
|
+
}
|
|
383
|
+
case "update": {
|
|
384
|
+
const ref = positional[0];
|
|
385
|
+
if (!ref) throw new CliError("update needs a task id");
|
|
386
|
+
const status = oneOf(options, "status", STATUSES);
|
|
387
|
+
const priority = oneOf(options, "priority", PRIORITIES);
|
|
388
|
+
const title = str(options, "title");
|
|
389
|
+
const description = str(options, "description");
|
|
390
|
+
const release = str(options, "release");
|
|
391
|
+
const sprint = str(options, "sprint");
|
|
392
|
+
const due = str(options, "due");
|
|
393
|
+
if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
|
|
394
|
+
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
|
|
395
|
+
}
|
|
396
|
+
if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0) {
|
|
397
|
+
throw new CliError("update needs at least one field to change");
|
|
398
|
+
}
|
|
399
|
+
let updated;
|
|
400
|
+
const { revision } = mutate(dir, (items) => {
|
|
401
|
+
const target = findItem(items, ref);
|
|
402
|
+
return items.map((item) => {
|
|
403
|
+
if (item.id !== target.id) return item;
|
|
404
|
+
const next = { ...item };
|
|
405
|
+
if (title !== void 0) next.title = title.slice(0, MAX_TEXT);
|
|
406
|
+
if (priority !== void 0) next.priority = toPriority(priority);
|
|
407
|
+
if (status !== void 0) {
|
|
408
|
+
next.status = toStatus(status);
|
|
409
|
+
if (next.status === "done") next.completedAt = now();
|
|
410
|
+
else delete next.completedAt;
|
|
411
|
+
}
|
|
412
|
+
if (description !== void 0) {
|
|
413
|
+
if (description) next.description = description.slice(0, MAX_DESC);
|
|
414
|
+
else delete next.description;
|
|
415
|
+
}
|
|
416
|
+
for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
|
|
417
|
+
if (raw === void 0) continue;
|
|
418
|
+
const label = normalizeLabel(raw);
|
|
419
|
+
if (label !== void 0) next[key] = label;
|
|
420
|
+
else delete next[key];
|
|
421
|
+
}
|
|
422
|
+
if (due !== void 0) {
|
|
423
|
+
const value = normalizeDueDate(due);
|
|
424
|
+
if (value !== void 0) next.dueDate = value;
|
|
425
|
+
else delete next.dueDate;
|
|
426
|
+
}
|
|
427
|
+
updated = next;
|
|
428
|
+
return next;
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
return { text: `updated ${updated?.id}`, json: { item: updated, revision } };
|
|
432
|
+
}
|
|
433
|
+
case "done":
|
|
434
|
+
case "reopen": {
|
|
435
|
+
const ref = positional[0];
|
|
436
|
+
if (!ref) throw new CliError(`${command} needs a task id`);
|
|
437
|
+
const target = command === "done" ? "done" : "todo";
|
|
438
|
+
let updated;
|
|
439
|
+
const { revision } = mutate(dir, (items) => {
|
|
440
|
+
const found = findItem(items, ref);
|
|
441
|
+
return items.map((item) => {
|
|
442
|
+
if (item.id !== found.id) return item;
|
|
443
|
+
const next = { ...item, status: target };
|
|
444
|
+
if (target === "done") next.completedAt = now();
|
|
445
|
+
else delete next.completedAt;
|
|
446
|
+
updated = next;
|
|
447
|
+
return next;
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
return { text: `${command} ${updated?.id} ${updated?.title}`, json: { item: updated, revision } };
|
|
451
|
+
}
|
|
452
|
+
case "rm": {
|
|
453
|
+
const ref = positional[0];
|
|
454
|
+
if (!ref) throw new CliError("rm needs a task id");
|
|
455
|
+
let removed;
|
|
456
|
+
const { revision } = mutate(dir, (items) => {
|
|
457
|
+
removed = findItem(items, ref);
|
|
458
|
+
return items.filter((item) => item.id !== removed?.id);
|
|
459
|
+
});
|
|
460
|
+
return { text: `removed ${removed?.id} ${removed?.title}`, json: { item: removed, revision } };
|
|
461
|
+
}
|
|
462
|
+
case "archive": {
|
|
463
|
+
const ref = positional[0];
|
|
464
|
+
const stamp = now();
|
|
465
|
+
let count = 0;
|
|
466
|
+
const { revision } = mutate(dir, (items) => {
|
|
467
|
+
if (ref) {
|
|
468
|
+
const found = findItem(items, ref);
|
|
469
|
+
return items.map((item) => {
|
|
470
|
+
if (item.id !== found.id || isArchived(item)) return item;
|
|
471
|
+
count += 1;
|
|
472
|
+
return { ...item, archivedAt: stamp };
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return items.map((item) => {
|
|
476
|
+
if (!isDone(item) || isArchived(item)) return item;
|
|
477
|
+
count += 1;
|
|
478
|
+
return { ...item, archivedAt: stamp };
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
return { text: `archived ${count} task(s)`, json: { archived: count, revision } };
|
|
482
|
+
}
|
|
483
|
+
default:
|
|
484
|
+
throw new CliError(`unknown command "${command}" \u2014 try: dsh-todo help`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function main(argv, cwd = process.cwd()) {
|
|
488
|
+
const parsed = parseArgs(argv);
|
|
489
|
+
const wantsJson = parsed.options.json === true;
|
|
490
|
+
try {
|
|
491
|
+
const outcome = run(parsed, cwd);
|
|
492
|
+
console.log(wantsJson ? JSON.stringify(outcome.json, null, 2) : outcome.text);
|
|
493
|
+
return EXIT.ok;
|
|
494
|
+
} catch (error) {
|
|
495
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
496
|
+
const code = error instanceof CliError ? error.code : 1;
|
|
497
|
+
if (wantsJson) console.log(JSON.stringify({ error: message, code }, null, 2));
|
|
498
|
+
else console.error(`dsh-todo: ${message}`);
|
|
499
|
+
return code;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/bin.ts
|
|
504
|
+
process.exitCode = main(process.argv.slice(2));
|