@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/lib/index.js CHANGED
@@ -48,22 +48,181 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
48
48
  var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
49
49
 
50
50
  // src/index.ts
51
- import { mkdirSync, readFileSync, renameSync, existsSync } from "node:fs";
52
- import { DatabaseSync } from "node:sqlite";
53
- import { isAbsolute, join, resolve } from "node:path";
51
+ import { readFileSync, renameSync, existsSync } from "node:fs";
52
+ import { isAbsolute, join as join2, resolve as resolve2 } from "node:path";
54
53
  import { Service } from "@deepseek-ai/cordis";
55
54
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
56
55
  import { z } from "zod";
57
56
 
57
+ // src/db.ts
58
+ import { mkdirSync } from "node:fs";
59
+ import { DatabaseSync } from "node:sqlite";
60
+ import { join, resolve } from "node:path";
61
+
58
62
  // src/types.ts
63
+ var STATUSES = ["backlog", "todo", "in-progress", "blocked", "done"];
64
+ var DEFAULT_STATUS = "todo";
65
+ var PRIORITIES = ["p0", "p1", "p2", "p3"];
66
+ var DEFAULT_PRIORITY = "p2";
67
+ function toStatus(value) {
68
+ return typeof value === "string" && STATUSES.includes(value) ? value : DEFAULT_STATUS;
69
+ }
70
+ __name(toStatus, "toStatus");
71
+ function toPriority(value) {
72
+ return typeof value === "string" && PRIORITIES.includes(value) ? value : DEFAULT_PRIORITY;
73
+ }
74
+ __name(toPriority, "toPriority");
75
+ function normalizeLabel(raw) {
76
+ if (typeof raw !== "string") return void 0;
77
+ const text = raw.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL);
78
+ return text.length > 0 ? text : void 0;
79
+ }
80
+ __name(normalizeLabel, "normalizeLabel");
81
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
82
+ function normalizeDueDate(raw) {
83
+ if (typeof raw !== "string") return void 0;
84
+ const text = raw.trim();
85
+ if (!DATE_RE.test(text)) return void 0;
86
+ const parsed = /* @__PURE__ */ new Date(`${text}T00:00:00Z`);
87
+ if (Number.isNaN(parsed.getTime())) return void 0;
88
+ return parsed.toISOString().slice(0, 10) === text ? text : void 0;
89
+ }
90
+ __name(normalizeDueDate, "normalizeDueDate");
59
91
  var MAX_TEXT = 500;
92
+ var MAX_DESC = 5e3;
93
+ var MAX_LABEL = 60;
60
94
  var MAX_ITEMS = 1e3;
61
95
 
96
+ // src/db.ts
97
+ var DOT_DSH = ".dsh";
98
+ var DB_FILE = "todo.db";
99
+ var BUSY_TIMEOUT_MS = 5e3;
100
+ function openDb(dir) {
101
+ const resolved = resolve(dir);
102
+ mkdirSync(join(resolved, DOT_DSH), { recursive: true });
103
+ const db = new DatabaseSync(join(resolved, DOT_DSH, DB_FILE));
104
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
105
+ db.exec(`
106
+ CREATE TABLE IF NOT EXISTS todo (
107
+ id TEXT PRIMARY KEY,
108
+ text TEXT NOT NULL,
109
+ done INTEGER NOT NULL DEFAULT 0,
110
+ created_at INTEGER NOT NULL,
111
+ completed_at INTEGER,
112
+ archived_at INTEGER,
113
+ position INTEGER NOT NULL
114
+ );
115
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
116
+ `);
117
+ migrateSchema(db);
118
+ return db;
119
+ }
120
+ __name(openDb, "openDb");
121
+ function migrateSchema(db) {
122
+ const columns = new Set(
123
+ db.prepare("PRAGMA table_info(todo)").all().map((c) => String(c.name))
124
+ );
125
+ const add = /* @__PURE__ */ __name((name, ddl) => {
126
+ if (columns.has(name)) return false;
127
+ db.exec(`ALTER TABLE todo ADD COLUMN ${ddl}`);
128
+ columns.add(name);
129
+ return true;
130
+ }, "add");
131
+ const addedTitle = add("title", "title TEXT");
132
+ const addedStatus = add("status", "status TEXT");
133
+ add("description", "description TEXT");
134
+ add("priority", "priority TEXT");
135
+ add("release", "release TEXT");
136
+ add("sprint", "sprint TEXT");
137
+ add("due_date", "due_date TEXT");
138
+ if (addedTitle && columns.has("text")) {
139
+ db.exec("UPDATE todo SET title = text WHERE title IS NULL");
140
+ }
141
+ if (addedStatus && columns.has("done")) {
142
+ db.exec("UPDATE todo SET status = CASE WHEN done = 1 THEN 'done' ELSE 'todo' END WHERE status IS NULL");
143
+ }
144
+ db.exec("UPDATE todo SET status = 'todo' WHERE status IS NULL OR status = ''");
145
+ db.exec("UPDATE todo SET priority = 'p2' WHERE priority IS NULL OR priority = ''");
146
+ db.exec("UPDATE todo SET title = '' WHERE title IS NULL");
147
+ }
148
+ __name(migrateSchema, "migrateSchema");
149
+ function readList(db) {
150
+ const revision = Number(db.prepare("SELECT value FROM meta WHERE key = 'revision'").get()?.value ?? 0);
151
+ const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
152
+ const rows = db.prepare(
153
+ `SELECT id, title, description, status, priority, release, sprint, due_date,
154
+ created_at, completed_at, archived_at
155
+ FROM todo ORDER BY position ASC`
156
+ ).all();
157
+ const text = /* @__PURE__ */ __name((v) => v === null || v === void 0 ? void 0 : String(v), "text");
158
+ const items = [];
159
+ for (const row of rows) {
160
+ items.push({
161
+ id: String(row.id),
162
+ title: String(row.title ?? ""),
163
+ status: toStatus(row.status),
164
+ priority: toPriority(row.priority),
165
+ ...text(row.description) !== void 0 ? { description: text(row.description) } : {},
166
+ ...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
167
+ ...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
168
+ ...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
169
+ createdAt: Number(row.created_at),
170
+ ...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
171
+ ...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
172
+ });
173
+ }
174
+ return { items, revision, updatedAt };
175
+ }
176
+ __name(readList, "readList");
177
+ function writeList(db, items, revision, updatedAt = Date.now()) {
178
+ db.exec("BEGIN IMMEDIATE");
179
+ try {
180
+ db.prepare("DELETE FROM todo").run();
181
+ const insert = db.prepare(
182
+ `INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
183
+ text, done, created_at, completed_at, archived_at, position)
184
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
185
+ );
186
+ items.forEach((item, index) => {
187
+ insert.run(
188
+ item.id,
189
+ item.title,
190
+ item.description ?? null,
191
+ item.status,
192
+ item.priority,
193
+ item.release ?? null,
194
+ item.sprint ?? null,
195
+ item.dueDate ?? null,
196
+ item.title,
197
+ item.status === "done" ? 1 : 0,
198
+ item.createdAt,
199
+ item.completedAt ?? null,
200
+ item.archivedAt ?? null,
201
+ index
202
+ );
203
+ });
204
+ db.prepare(`INSERT INTO meta (key, value) VALUES ('revision', ?)
205
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(revision));
206
+ db.prepare(`INSERT INTO meta (key, value) VALUES ('updatedAt', ?)
207
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(updatedAt));
208
+ db.exec("COMMIT");
209
+ } catch (err) {
210
+ db.exec("ROLLBACK");
211
+ throw err;
212
+ }
213
+ }
214
+ __name(writeList, "writeList");
215
+
62
216
  // src/index.ts
63
217
  var todoItemSchema = z.object({
64
218
  id: z.string().min(1),
65
- text: z.string().max(MAX_TEXT),
66
- done: z.boolean(),
219
+ title: z.string().max(MAX_TEXT),
220
+ description: z.string().max(MAX_DESC).optional(),
221
+ status: z.enum(["backlog", "todo", "in-progress", "blocked", "done"]),
222
+ priority: z.enum(["p0", "p1", "p2", "p3"]),
223
+ release: z.string().max(MAX_LABEL).optional(),
224
+ sprint: z.string().max(MAX_LABEL).optional(),
225
+ dueDate: z.string().optional(),
67
226
  createdAt: z.number(),
68
227
  completedAt: z.number().optional(),
69
228
  archivedAt: z.number().optional()
@@ -77,8 +236,6 @@ var todoDomainSpec = {
77
236
  name: "dsh_todo",
78
237
  version: 2
79
238
  };
80
- var DOT_DSH = ".dsh";
81
- var DB_FILE = "todo.db";
82
239
  var _replace_dec, _list_dec, _a, _init, _b;
83
240
  var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
84
241
  /**
@@ -110,8 +267,8 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
110
267
  this.ctx.effect(() => () => this.close(), "dsh-todo: close workspace databases");
111
268
  const home = process.env.DSH_HOME;
112
269
  if (!home) return;
113
- const legacyPath = join(home, "storages", "dsh_todo.json");
114
- const registryPath = join(home, "storages", "workspace.json");
270
+ const legacyPath = join2(home, "storages", "dsh_todo.json");
271
+ const registryPath = join2(home, "storages", "workspace.json");
115
272
  try {
116
273
  if (!existsSync(legacyPath)) return;
117
274
  const legacy = JSON.parse(readFileSync(legacyPath, "utf8"));
@@ -160,7 +317,7 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
160
317
  const registry = this.ctx.workspaceRegistry;
161
318
  const workspace = registry.list().find((w) => String(w.id) === workspaceId);
162
319
  if (workspace === void 0) throw new Error(`dsh-todo: unknown workspace ${workspaceId}`);
163
- const dir = resolve(workspace.path);
320
+ const dir = resolve2(workspace.path);
164
321
  this.dirs.set(workspaceId, dir);
165
322
  return dir;
166
323
  }
@@ -171,23 +328,10 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
171
328
  * cloned since.
172
329
  */
173
330
  openDb(dir) {
174
- const resolved = resolve(dir);
331
+ const resolved = resolve2(dir);
175
332
  const cached = this.dbs.get(resolved);
176
333
  if (cached) return cached;
177
- mkdirSync(join(resolved, DOT_DSH), { recursive: true });
178
- const db = new DatabaseSync(join(resolved, DOT_DSH, DB_FILE));
179
- db.exec(`
180
- CREATE TABLE IF NOT EXISTS todo (
181
- id TEXT PRIMARY KEY,
182
- text TEXT NOT NULL,
183
- done INTEGER NOT NULL DEFAULT 0,
184
- created_at INTEGER NOT NULL,
185
- completed_at INTEGER,
186
- archived_at INTEGER,
187
- position INTEGER NOT NULL
188
- );
189
- CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
190
- `);
334
+ const db = openDb(resolved);
191
335
  this.dbs.set(resolved, db);
192
336
  return db;
193
337
  }
@@ -249,61 +393,22 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
249
393
  }
250
394
  this.dbs.clear();
251
395
  }
252
- /** Read the whole list from the database, ordered by `position`. */
396
+ /**
397
+ * Read the whole list, then re-validate it at this service's own boundary.
398
+ *
399
+ * `db.readList` already coerces rows, but the host keeps the zod check: it is
400
+ * the durable read boundary for the WIRE, and a shape the schema rejects must
401
+ * not reach the browser even if the storage layer was willing to build it.
402
+ */
253
403
  readList(db) {
254
- const revision = Number(db.prepare(`SELECT value FROM meta WHERE key = 'revision'`).get()?.value ?? 0);
255
- const updatedAt = Number(db.prepare(`SELECT value FROM meta WHERE key = 'updatedAt'`).get()?.value ?? 0);
256
- const rows = db.prepare(
257
- `SELECT id, text, done, created_at, completed_at, archived_at
258
- FROM todo ORDER BY position ASC`
259
- ).all();
260
- const items = [];
261
- for (const row of rows) {
262
- const candidate = {
263
- id: String(row.id),
264
- text: String(row.text),
265
- done: Number(row.done) === 1,
266
- createdAt: Number(row.created_at),
267
- ...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
268
- ...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
269
- };
270
- const parsed = todoItemSchema.safeParse(candidate);
271
- if (parsed.success) items.push(parsed.data);
272
- }
273
- const list = { items, revision, updatedAt };
274
- const check = todoListSchema.safeParse(list);
275
- return check.success ? list : { items: [], revision, updatedAt };
404
+ const list = readList(db);
405
+ const items = list.items.filter((item) => todoItemSchema.safeParse(item).success);
406
+ const checked = { items, revision: list.revision, updatedAt: list.updatedAt };
407
+ return todoListSchema.safeParse(checked).success ? checked : { items: [], revision: list.revision, updatedAt: list.updatedAt };
276
408
  }
277
409
  /** Replace every row inside one transaction and stamp the meta tokens. */
278
410
  writeList(db, items, revision, updatedAt = Date.now()) {
279
- const now = updatedAt;
280
- db.exec("BEGIN");
281
- try {
282
- db.prepare("DELETE FROM todo").run();
283
- const insert = db.prepare(
284
- `INSERT INTO todo (id, text, done, created_at, completed_at, archived_at, position)
285
- VALUES (?, ?, ?, ?, ?, ?, ?)`
286
- );
287
- items.forEach((item, index) => {
288
- insert.run(
289
- item.id,
290
- item.text,
291
- item.done ? 1 : 0,
292
- item.createdAt,
293
- item.completedAt ?? null,
294
- item.archivedAt ?? null,
295
- index
296
- );
297
- });
298
- db.prepare(`INSERT INTO meta (key, value) VALUES ('revision', ?)
299
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(revision));
300
- db.prepare(`INSERT INTO meta (key, value) VALUES ('updatedAt', ?)
301
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(now));
302
- db.exec("COMMIT");
303
- } catch (err) {
304
- db.exec("ROLLBACK");
305
- throw err;
306
- }
411
+ writeList(db, items, revision, updatedAt);
307
412
  }
308
413
  };
309
414
  _init = __decoratorStart(_b);
@@ -324,18 +429,29 @@ function sanitizeItems(value) {
324
429
  if (!entry || typeof entry !== "object") continue;
325
430
  const e = entry;
326
431
  if (typeof e.id !== "string" || e.id.length === 0) continue;
327
- if (typeof e.text !== "string") continue;
432
+ const rawTitle = typeof e.title === "string" ? e.title : e.text;
433
+ if (typeof rawTitle !== "string") continue;
328
434
  if (seen.has(e.id)) continue;
329
435
  seen.add(e.id);
330
- const done = e.done === true;
436
+ const status = e.status === void 0 && e.done === true ? "done" : toStatus(e.status);
437
+ const done = status === "done";
438
+ const description = typeof e.description === "string" && e.description.length > 0 ? e.description.slice(0, MAX_DESC) : void 0;
439
+ const release = normalizeLabel(e.release);
440
+ const sprint = normalizeLabel(e.sprint);
441
+ const dueDate = normalizeDueDate(e.dueDate);
331
442
  const completedAt = typeof e.completedAt === "number" ? e.completedAt : void 0;
332
443
  const archivedAt = typeof e.archivedAt === "number" ? e.archivedAt : void 0;
333
444
  out.push({
334
445
  id: e.id,
335
- text: e.text.slice(0, MAX_TEXT),
336
- done,
446
+ title: rawTitle.slice(0, MAX_TEXT),
447
+ status,
448
+ priority: toPriority(e.priority),
449
+ ...description !== void 0 ? { description } : {},
450
+ ...release !== void 0 ? { release } : {},
451
+ ...sprint !== void 0 ? { sprint } : {},
452
+ ...dueDate !== void 0 ? { dueDate } : {},
337
453
  createdAt: typeof e.createdAt === "number" ? e.createdAt : 0,
338
- // completedAt is meaningless on an open item; drop it rather than store a lie.
454
+ // completedAt is meaningless on an unfinished item; drop it rather than store a lie.
339
455
  ...done && completedAt !== void 0 ? { completedAt } : {},
340
456
  // archivedAt is the archived flag itself, so a non-numeric value must not
341
457
  // survive as a truthy marker.
@@ -5,12 +5,15 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
5
5
  import { z } from "zod";
6
6
  var todoItemSchema = z.object({
7
7
  id: z.string(),
8
- text: z.string(),
9
- done: z.boolean(),
8
+ title: z.string(),
9
+ description: z.string().optional(),
10
+ status: z.enum(["backlog", "todo", "in-progress", "blocked", "done"]),
11
+ priority: z.enum(["p0", "p1", "p2", "p3"]),
12
+ release: z.string().optional(),
13
+ sprint: z.string().optional(),
14
+ dueDate: z.string().optional(),
10
15
  createdAt: z.number(),
11
16
  completedAt: z.number().optional(),
12
- // Must be carried explicitly: these are strict codecs, so a field the schema
13
- // does not name would be stripped off the wire and archiving would not persist.
14
17
  archivedAt: z.number().optional()
15
18
  });
16
19
  var todoListSchema = z.object({
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@dennisrongo/dsh-todo",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Todo list for DeepSeek Harness (dsh) — a per-workspace task list persisted on disk by the host",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Dennis Rongo",
8
8
  "main": "lib/index.js",
9
+ "bin": {
10
+ "dsh-todo": "lib/bin.js"
11
+ },
9
12
  "exports": {
10
13
  ".": {
11
14
  "default": "./lib/index.js"
@@ -16,6 +19,9 @@
16
19
  "./typert": {
17
20
  "default": "./lib/typert.host.js"
18
21
  },
22
+ "./cli": {
23
+ "default": "./lib/cli.js"
24
+ },
19
25
  "./package.json": "./package.json"
20
26
  },
21
27
  "files": [
@@ -28,7 +34,10 @@
28
34
  "scripts": {
29
35
  "build": "node build/build.mjs",
30
36
  "typecheck": "tsc --noEmit",
31
- "test": "node test/smoke.mjs"
37
+ "test": "node test/smoke.mjs && node test/cli.test.mjs",
38
+ "test:icons": "node test/icon-probe.mjs",
39
+ "test:modal": "node test/modal-probe.mjs",
40
+ "test:cli": "node test/cli.test.mjs"
32
41
  },
33
42
  "dsh": {
34
43
  "bundle": {
@@ -58,6 +67,7 @@
58
67
  "devDependencies": {
59
68
  "@types/node": "^22.20.1",
60
69
  "@types/react": "^18.3.0",
70
+ "@types/react-dom": "^18.3.0",
61
71
  "esbuild": "^0.24.0",
62
72
  "react": "^18.3.0",
63
73
  "react-dom": "^18.3.0",