project-mgr 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.
@@ -0,0 +1,439 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { run } from "../src/cli.js";
7
+ import {
8
+ addStatus,
9
+ at,
10
+ current,
11
+ databasePath,
12
+ history,
13
+ importStatuses,
14
+ openLedger,
15
+ parseTime,
16
+ toPortableStatusEntry,
17
+ validateStatusEntry,
18
+ ValidationError,
19
+ } from "../src/index.js";
20
+
21
+ const timestamp = "2026-08-03T00:00:00Z";
22
+ const entry = (overrides: Record<string, unknown> = {}) => ({
23
+ project: "project-mgr",
24
+ status: "active",
25
+ summary: "working",
26
+ occurredAt: timestamp,
27
+ ...overrides,
28
+ });
29
+
30
+ test("domain normalizes valid values and rejects invalid input", () => {
31
+ assert.deepEqual(
32
+ validateStatusEntry(
33
+ entry({
34
+ blockers: [" wait "],
35
+ nextSteps: [" next "],
36
+ decisions: [" decide "],
37
+ tags: [" mvp "],
38
+ progress: " done ",
39
+ externalRef: " ref ",
40
+ }),
41
+ ),
42
+ {
43
+ project: "project-mgr",
44
+ status: "active",
45
+ summary: "working",
46
+ progress: "done",
47
+ blockers: ["wait"],
48
+ nextSteps: ["next"],
49
+ decisions: ["decide"],
50
+ tags: ["mvp"],
51
+ occurredAt: "2026-08-03T00:00:00.000Z",
52
+ source: "human",
53
+ externalRef: "ref",
54
+ },
55
+ );
56
+ for (const invalid of [
57
+ null,
58
+ [],
59
+ entry({ unknown: true }),
60
+ entry({ project: "bad space" }),
61
+ entry({ status: "nope" }),
62
+ entry({ summary: "" }),
63
+ entry({ summary: "x".repeat(2_001) }),
64
+ entry({ blockers: "no" }),
65
+ entry({ source: "bot" }),
66
+ entry({ occurredAt: "2026-08-03" }),
67
+ entry({ occurredAt: "2026-08-03T00:00:00" }),
68
+ entry({ externalRef: "x".repeat(201) }),
69
+ ]) {
70
+ assert.throws(() => validateStatusEntry(invalid), ValidationError);
71
+ }
72
+ assert.throws(() => parseTime(1, "time"), /time must be a valid ISO/);
73
+ const portable = toPortableStatusEntry({
74
+ id: 1,
75
+ recordedAt: "2026-08-03T01:00:00.000Z",
76
+ ...validateStatusEntry(entry()),
77
+ });
78
+ assert.equal("id" in portable, false);
79
+ assert.equal("recordedAt" in portable, false);
80
+ });
81
+
82
+ test("SQLite ledger migrates, appends, is idempotent, and queries by timeline", () => {
83
+ const db = openLedger(":memory:");
84
+ const first = addStatus(db, entry({ externalRef: "one", progress: "done" }));
85
+ assert.equal(first.inserted, true);
86
+ assert.equal(
87
+ addStatus(db, entry({ summary: "ignored", externalRef: "one" })).inserted,
88
+ false,
89
+ );
90
+ assert.throws(
91
+ () =>
92
+ addStatus(db, entry({ project: "wrong-project", externalRef: "one" })),
93
+ /externalRef is already used by project project-mgr/,
94
+ );
95
+ addStatus(
96
+ db,
97
+ entry({
98
+ project: "other",
99
+ status: "planned",
100
+ summary: "other",
101
+ occurredAt: "2026-08-04T00:00:00Z",
102
+ }),
103
+ );
104
+ addStatus(db, entry({ summary: "same-time winner", occurredAt: timestamp }));
105
+ addStatus(
106
+ db,
107
+ entry({ summary: "later", occurredAt: "2026-08-05T00:00:00Z" }),
108
+ );
109
+ assert.deepEqual(
110
+ current(db).map(({ project, summary }) => [project, summary]),
111
+ [
112
+ ["project-mgr", "later"],
113
+ ["other", "other"],
114
+ ],
115
+ );
116
+ assert.equal(current(db, "project-mgr")[0]?.progress, undefined);
117
+ assert.deepEqual(
118
+ history(db, "project-mgr", {
119
+ since: "2026-08-04T00:00:00.000Z",
120
+ until: "2026-08-06T00:00:00.000Z",
121
+ limit: 1,
122
+ offset: 0,
123
+ }).map((item) => item.summary),
124
+ ["later"],
125
+ );
126
+ assert.equal(history(db, undefined, { offset: 1 }).length, 3);
127
+ assert.equal(
128
+ at(db, "project-mgr", "2026-08-04T00:00:00.000Z")?.summary,
129
+ "same-time winner",
130
+ );
131
+ assert.equal(at(db, "missing", "2026-08-04T00:00:00.000Z"), undefined);
132
+ assert.equal(
133
+ (
134
+ db.prepare("SELECT count(*) AS count FROM schema_migrations").get() as {
135
+ count: number;
136
+ }
137
+ ).count,
138
+ 1,
139
+ );
140
+ db.close();
141
+ });
142
+
143
+ test("import validates before transaction and default source is import", () => {
144
+ const db = openLedger(":memory:");
145
+ assert.throws(
146
+ () =>
147
+ importStatuses(db, [
148
+ entry({ summary: "would write" }),
149
+ entry({ status: "wrong" }),
150
+ ]),
151
+ ValidationError,
152
+ );
153
+ assert.equal(history(db).length, 0);
154
+ const results = importStatuses(db, [
155
+ entry({ externalRef: "batch" }),
156
+ entry({ project: "second", summary: "second" }),
157
+ ]);
158
+ assert.equal(results[0]?.entry.source, "import");
159
+ assert.equal(results.length, 2);
160
+
161
+ db.exec(`CREATE TRIGGER reject_explosion BEFORE INSERT ON status_entries
162
+ WHEN NEW.summary = 'explode'
163
+ BEGIN SELECT RAISE(ABORT, 'exploded'); END`);
164
+ assert.throws(() =>
165
+ importStatuses(db, [
166
+ entry({ project: "transaction-first", summary: "valid" }),
167
+ entry({ project: "transaction-second", summary: "explode" }),
168
+ ]),
169
+ );
170
+ assert.equal(current(db, "transaction-first").length, 0);
171
+ db.close();
172
+ });
173
+
174
+ test("database path uses environment override and file migration creates parent directory", () => {
175
+ const directory = mkdtempSync(join(tmpdir(), "pmgr-"));
176
+ const previous = process.env.PMGR_DB_PATH;
177
+ process.env.PMGR_DB_PATH = join(directory, "nested", "ledger.sqlite");
178
+ assert.equal(databasePath(), process.env.PMGR_DB_PATH);
179
+ const db = openLedger();
180
+ db.close();
181
+ if (previous === undefined) delete process.env.PMGR_DB_PATH;
182
+ else process.env.PMGR_DB_PATH = previous;
183
+ assert.match(databasePath(), /data\/project-mgr\.sqlite$/);
184
+ rmSync(directory, { recursive: true, force: true });
185
+ });
186
+
187
+ test("CLI supports add, import, reads, errors, and export formats", () => {
188
+ const directory = mkdtempSync(join(tmpdir(), "pmgr-cli-"));
189
+ const file = join(directory, "ledger.sqlite");
190
+ const importFile = join(directory, "entries.jsonl");
191
+ const jsonFile = join(directory, "entry.json");
192
+ const arrayFile = join(directory, "entries.json");
193
+ const badFile = join(directory, "bad.jsonl");
194
+ const badJsonFile = join(directory, "bad.json");
195
+ const emptyFile = join(directory, "empty.json");
196
+ const portableFile = join(directory, "portable.jsonl");
197
+ const restoredFile = join(directory, "restored.sqlite");
198
+ writeFileSync(
199
+ importFile,
200
+ `${JSON.stringify(entry({ project: "imported", summary: "from file" }))}\n`,
201
+ );
202
+ writeFileSync(
203
+ jsonFile,
204
+ JSON.stringify(entry({ project: "json", summary: "one JSON object" })),
205
+ );
206
+ writeFileSync(
207
+ arrayFile,
208
+ JSON.stringify([entry({ project: "array", summary: "JSON array" })]),
209
+ );
210
+ writeFileSync(badFile, "not-json\n");
211
+ writeFileSync(badJsonFile, "not-json");
212
+ writeFileSync(emptyFile, "\n");
213
+ const previous = process.env.PMGR_DB_PATH;
214
+ process.env.PMGR_DB_PATH = file;
215
+ const execute = (args: string[]) => {
216
+ let result = "";
217
+ run(args, (text) => {
218
+ result += text;
219
+ });
220
+ return result;
221
+ };
222
+ const added = JSON.parse(
223
+ execute([
224
+ "add",
225
+ "--project",
226
+ "cli",
227
+ "--status",
228
+ "active",
229
+ "--summary",
230
+ "added",
231
+ "--progress",
232
+ "covered",
233
+ "--next",
234
+ "test",
235
+ "--next",
236
+ "repeat",
237
+ "--tag",
238
+ "cli",
239
+ "--external-ref",
240
+ "cli-add",
241
+ "--json",
242
+ ]),
243
+ );
244
+ assert.equal(added.project, "cli");
245
+ assert.match(
246
+ execute([
247
+ "status",
248
+ "add",
249
+ "--project",
250
+ "human",
251
+ "--status",
252
+ "active",
253
+ "--summary",
254
+ "human output",
255
+ ]),
256
+ /human output/,
257
+ );
258
+ assert.match(execute(["import", importFile, "--json"]), /from file/);
259
+ assert.match(
260
+ execute(["status", "import", "--file", jsonFile, "--json"]),
261
+ /one JSON object/,
262
+ );
263
+ assert.match(
264
+ execute(["status", "import", arrayFile, "--json"]),
265
+ /JSON array/,
266
+ );
267
+ assert.match(execute(["current"]), /cli\tactive/);
268
+ assert.match(execute(["status", "current"]), /cli\tactive/);
269
+ assert.equal(
270
+ JSON.parse(
271
+ execute([
272
+ "history",
273
+ "--project",
274
+ "cli",
275
+ "--since",
276
+ "2026-08-01T00:00:00Z",
277
+ "--until",
278
+ "2026-08-05T00:00:00Z",
279
+ "--limit",
280
+ "1",
281
+ "--offset",
282
+ "0",
283
+ "--json",
284
+ ]),
285
+ )[0].summary,
286
+ "added",
287
+ );
288
+ assert.match(
289
+ execute(["status", "history", "--project", "cli", "--json"]),
290
+ /added/,
291
+ );
292
+ assert.equal(
293
+ JSON.parse(
294
+ execute([
295
+ "at",
296
+ "--project",
297
+ "cli",
298
+ "--at",
299
+ "2026-08-04T00:00:00Z",
300
+ "--json",
301
+ ]),
302
+ ).project,
303
+ "cli",
304
+ );
305
+ assert.equal(
306
+ JSON.parse(
307
+ execute([
308
+ "status",
309
+ "at",
310
+ "--project",
311
+ "cli",
312
+ "--at",
313
+ "2026-08-01T00:00:00Z",
314
+ "--json",
315
+ ]),
316
+ ),
317
+ null,
318
+ );
319
+ assert.match(
320
+ execute(["export", "--project", "cli", "--format", "jsonl"]),
321
+ /"project":"cli"/,
322
+ );
323
+ const exported = execute(["status", "export", "--format", "json"]);
324
+ assert.match(exported, /imported/);
325
+ assert.doesNotMatch(exported, /"recordedAt"|"id"/);
326
+ assert.match(execute(["status", "export"]), /imported/);
327
+ assert.equal(
328
+ execute(["export", "--project", "missing", "--format", "jsonl"]),
329
+ "",
330
+ );
331
+ assert.equal(
332
+ execute(["history", "--project", "missing"]),
333
+ "No entries found.\n",
334
+ );
335
+ assert.equal(execute(["history", "--project", "missing", "--json"]), "[]\n");
336
+ assert.throws(
337
+ () => execute(["status", "history", "--project", "cli", "--limit", "-1"]),
338
+ ValidationError,
339
+ );
340
+ assert.throws(
341
+ () => execute(["status", "history", "--project", "cli", "--limit", "1.5"]),
342
+ ValidationError,
343
+ );
344
+ assert.throws(() => execute(["status", "history"]), ValidationError);
345
+ assert.throws(() => execute(["status", "add", "--project"]), ValidationError);
346
+ assert.throws(
347
+ () =>
348
+ execute(["status", "current", "--project", "cli", "--project", "again"]),
349
+ ValidationError,
350
+ );
351
+ assert.throws(() => execute(["status", "import", badFile]), ValidationError);
352
+ assert.throws(
353
+ () => execute(["status", "import", badJsonFile]),
354
+ ValidationError,
355
+ );
356
+ assert.throws(
357
+ () => execute(["status", "import", emptyFile]),
358
+ ValidationError,
359
+ );
360
+ assert.throws(() => execute(["status", "import"]), ValidationError);
361
+ assert.throws(() => execute(["wat"]), ValidationError);
362
+ assert.throws(() => execute([]), ValidationError);
363
+ assert.throws(() => execute(["status"]), ValidationError);
364
+ assert.throws(() => execute(["status", "wat"]), ValidationError);
365
+ assert.throws(() => execute(["status", "toString"]), ValidationError);
366
+ assert.throws(
367
+ () => execute(["status", "current", "unexpected"]),
368
+ /does not accept positional arguments/,
369
+ );
370
+ assert.throws(
371
+ () => execute(["status", "current", "--prject", "cli"]),
372
+ /unknown flag/,
373
+ );
374
+ assert.throws(
375
+ () => execute(["status", "current", "--__proto__", "value"]),
376
+ /unknown flag/,
377
+ );
378
+ assert.throws(
379
+ () => execute(["status", "import", importFile, "extra.json"]),
380
+ /at most one file argument/,
381
+ );
382
+ assert.throws(
383
+ () => execute(["status", "import", importFile, "--file", jsonFile]),
384
+ /not both/,
385
+ );
386
+ assert.throws(() => execute(["status", "export", "--json"]), /unknown flag/);
387
+ assert.throws(
388
+ () => execute(["status", "current", "--json", "--json"]),
389
+ /only be supplied once/,
390
+ );
391
+ assert.throws(
392
+ () => execute(["status", "export", "--format", "csv"]),
393
+ ValidationError,
394
+ );
395
+
396
+ execute([
397
+ "status",
398
+ "add",
399
+ "--project",
400
+ "tie",
401
+ "--status",
402
+ "active",
403
+ "--summary",
404
+ "tie first",
405
+ "--at",
406
+ timestamp,
407
+ ]);
408
+ execute([
409
+ "status",
410
+ "add",
411
+ "--project",
412
+ "tie",
413
+ "--status",
414
+ "active",
415
+ "--summary",
416
+ "tie second",
417
+ "--at",
418
+ timestamp,
419
+ ]);
420
+ writeFileSync(
421
+ portableFile,
422
+ execute(["status", "export", "--format", "jsonl"]),
423
+ );
424
+ process.env.PMGR_DB_PATH = restoredFile;
425
+ assert.doesNotThrow(() => execute(["status", "import", portableFile]));
426
+ assert.equal(
427
+ JSON.parse(execute(["status", "current", "--project", "cli", "--json"]))[0]
428
+ .summary,
429
+ "added",
430
+ );
431
+ assert.equal(
432
+ JSON.parse(execute(["status", "current", "--project", "tie", "--json"]))[0]
433
+ .summary,
434
+ "tie second",
435
+ );
436
+ if (previous === undefined) delete process.env.PMGR_DB_PATH;
437
+ else process.env.PMGR_DB_PATH = previous;
438
+ rmSync(directory, { recursive: true, force: true });
439
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*.ts"]
14
+ }