project-mgr 0.2.1 → 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/CHANGELOG.md +17 -0
- package/README.md +21 -0
- package/dist/cli.js +37 -32
- package/dist/db.d.ts +33 -1
- package/dist/db.js +259 -10
- package/dist/domain.d.ts +12 -1
- package/dist/domain.js +9 -0
- package/dist/server.js +19 -2
- package/dist/web/i18n.d.ts +8 -0
- package/dist/web/i18n.js +77 -0
- package/dist/web/locales/en.d.ts +2 -0
- package/dist/web/locales/en.js +52 -0
- package/dist/web/locales/zh.d.ts +54 -0
- package/dist/web/locales/zh.js +52 -0
- package/dist/web/script.d.ts +2 -0
- package/dist/web/script.js +356 -0
- package/dist/web/styles.d.ts +1 -0
- package/dist/web/styles.js +636 -0
- package/dist/web/ui.d.ts +3 -1
- package/dist/web/ui.js +33 -924
- package/package.json +8 -2
- package/skills/project-mgr/SKILL.md +26 -14
- package/.claude/settings.local.json +0 -5
- package/.env_sample +0 -10
- package/.prettierignore +0 -4
- package/AGENTS.md +0 -5
- package/docs/architecture.md +0 -95
- package/ecosystem.config.cjs +0 -21
- package/eslint.config.js +0 -12
- package/pnpm-workspace.yaml +0 -3
- package/src/cli.ts +0 -399
- package/src/db.ts +0 -373
- package/src/domain.ts +0 -226
- package/src/index.ts +0 -5
- package/src/server.ts +0 -202
- package/src/web/ui.ts +0 -977
- package/test/core.test.ts +0 -739
- package/test/server.test.ts +0 -354
- package/tsconfig.json +0 -14
package/test/core.test.ts
DELETED
|
@@ -1,739 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { homedir, tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import test from "node:test";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { run } from "../src/cli.js";
|
|
9
|
-
import {
|
|
10
|
-
addStatus,
|
|
11
|
-
at,
|
|
12
|
-
countEntries,
|
|
13
|
-
current,
|
|
14
|
-
databasePath,
|
|
15
|
-
history,
|
|
16
|
-
importStatuses,
|
|
17
|
-
listProjects,
|
|
18
|
-
openLedger,
|
|
19
|
-
parseTime,
|
|
20
|
-
setProjectMetadata,
|
|
21
|
-
toPortableStatusEntry,
|
|
22
|
-
validateProjectMetadataInput,
|
|
23
|
-
validateStatusEntry,
|
|
24
|
-
ValidationError,
|
|
25
|
-
} from "../src/index.js";
|
|
26
|
-
|
|
27
|
-
const timestamp = "2026-08-03T00:00:00Z";
|
|
28
|
-
const entry = (overrides: Record<string, unknown> = {}) => ({
|
|
29
|
-
project: "project-mgr",
|
|
30
|
-
status: "active",
|
|
31
|
-
summary: "working",
|
|
32
|
-
occurredAt: timestamp,
|
|
33
|
-
...overrides,
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("domain normalizes valid values and rejects invalid input", () => {
|
|
37
|
-
assert.deepEqual(
|
|
38
|
-
validateStatusEntry(
|
|
39
|
-
entry({
|
|
40
|
-
blockers: [" wait "],
|
|
41
|
-
nextSteps: [" next "],
|
|
42
|
-
decisions: [" decide "],
|
|
43
|
-
tags: [" mvp "],
|
|
44
|
-
progress: " done ",
|
|
45
|
-
externalRef: " ref ",
|
|
46
|
-
}),
|
|
47
|
-
),
|
|
48
|
-
{
|
|
49
|
-
project: "project-mgr",
|
|
50
|
-
status: "active",
|
|
51
|
-
summary: "working",
|
|
52
|
-
progress: "done",
|
|
53
|
-
blockers: ["wait"],
|
|
54
|
-
nextSteps: ["next"],
|
|
55
|
-
decisions: ["decide"],
|
|
56
|
-
tags: ["mvp"],
|
|
57
|
-
occurredAt: "2026-08-03T00:00:00.000Z",
|
|
58
|
-
source: "human",
|
|
59
|
-
externalRef: "ref",
|
|
60
|
-
},
|
|
61
|
-
);
|
|
62
|
-
assert.equal(
|
|
63
|
-
validateStatusEntry({
|
|
64
|
-
project: "default-status",
|
|
65
|
-
summary: "sum",
|
|
66
|
-
}).status,
|
|
67
|
-
"completed",
|
|
68
|
-
);
|
|
69
|
-
assert.equal(
|
|
70
|
-
validateStatusEntry({
|
|
71
|
-
project: "done-status",
|
|
72
|
-
status: "done",
|
|
73
|
-
summary: "sum",
|
|
74
|
-
}).status,
|
|
75
|
-
"completed",
|
|
76
|
-
);
|
|
77
|
-
assert.ok(
|
|
78
|
-
validateStatusEntry({
|
|
79
|
-
project: "default-time",
|
|
80
|
-
status: "active",
|
|
81
|
-
summary: "sum",
|
|
82
|
-
}).occurredAt,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
for (const invalid of [
|
|
86
|
-
null,
|
|
87
|
-
[],
|
|
88
|
-
entry({ unknown: true }),
|
|
89
|
-
entry({ project: "bad space" }),
|
|
90
|
-
entry({ status: "nope" }),
|
|
91
|
-
entry({ summary: "" }),
|
|
92
|
-
entry({ summary: "x".repeat(2_001) }),
|
|
93
|
-
entry({ blockers: "no" }),
|
|
94
|
-
entry({ source: "bot" }),
|
|
95
|
-
entry({ occurredAt: "2026-08-03" }),
|
|
96
|
-
entry({ occurredAt: "2026-08-03T00:00:00" }),
|
|
97
|
-
entry({ externalRef: "x".repeat(201) }),
|
|
98
|
-
]) {
|
|
99
|
-
assert.throws(() => validateStatusEntry(invalid), ValidationError);
|
|
100
|
-
}
|
|
101
|
-
assert.throws(() => parseTime(1, "time"), /time must be a valid ISO/);
|
|
102
|
-
const portable = toPortableStatusEntry({
|
|
103
|
-
id: 1,
|
|
104
|
-
recordedAt: "2026-08-03T01:00:00.000Z",
|
|
105
|
-
...validateStatusEntry(entry()),
|
|
106
|
-
});
|
|
107
|
-
assert.equal("id" in portable, false);
|
|
108
|
-
assert.equal("recordedAt" in portable, false);
|
|
109
|
-
|
|
110
|
-
assert.deepEqual(
|
|
111
|
-
validateProjectMetadataInput({
|
|
112
|
-
project: " demo ",
|
|
113
|
-
displayName: " Demo Project ",
|
|
114
|
-
description: " Desc ",
|
|
115
|
-
notes: " Notes ",
|
|
116
|
-
}),
|
|
117
|
-
{
|
|
118
|
-
project: "demo",
|
|
119
|
-
displayName: "Demo Project",
|
|
120
|
-
description: "Desc",
|
|
121
|
-
notes: "Notes",
|
|
122
|
-
},
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
assert.deepEqual(
|
|
126
|
-
validateProjectMetadataInput({
|
|
127
|
-
project: "demo",
|
|
128
|
-
description: null,
|
|
129
|
-
notes: "",
|
|
130
|
-
}),
|
|
131
|
-
{
|
|
132
|
-
project: "demo",
|
|
133
|
-
description: null,
|
|
134
|
-
notes: null,
|
|
135
|
-
},
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
for (const invalid of [
|
|
139
|
-
null,
|
|
140
|
-
[],
|
|
141
|
-
{ unknownKey: true },
|
|
142
|
-
{ project: "bad space" },
|
|
143
|
-
{ project: "demo", description: 123 },
|
|
144
|
-
{ project: "demo", notes: "x".repeat(2_001) },
|
|
145
|
-
]) {
|
|
146
|
-
assert.throws(() => validateProjectMetadataInput(invalid), ValidationError);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("SQLite ledger migrates, appends, is idempotent, and queries by timeline", () => {
|
|
151
|
-
const db = openLedger(":memory:");
|
|
152
|
-
const first = addStatus(
|
|
153
|
-
db,
|
|
154
|
-
entry({
|
|
155
|
-
externalRef: "one",
|
|
156
|
-
progress: "done",
|
|
157
|
-
}),
|
|
158
|
-
);
|
|
159
|
-
assert.equal(first.inserted, true);
|
|
160
|
-
assert.equal(first.entry.progress, "done");
|
|
161
|
-
|
|
162
|
-
assert.equal(
|
|
163
|
-
addStatus(db, entry({ summary: "ignored", externalRef: "one" })).inserted,
|
|
164
|
-
false,
|
|
165
|
-
);
|
|
166
|
-
assert.throws(
|
|
167
|
-
() =>
|
|
168
|
-
addStatus(db, entry({ project: "wrong-project", externalRef: "one" })),
|
|
169
|
-
/externalRef is already used by project project-mgr/,
|
|
170
|
-
);
|
|
171
|
-
addStatus(
|
|
172
|
-
db,
|
|
173
|
-
entry({
|
|
174
|
-
project: "other",
|
|
175
|
-
status: "planned",
|
|
176
|
-
summary: "other",
|
|
177
|
-
occurredAt: "2026-08-04T00:00:00Z",
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
addStatus(db, entry({ summary: "same-time winner", occurredAt: timestamp }));
|
|
181
|
-
addStatus(
|
|
182
|
-
db,
|
|
183
|
-
entry({ summary: "later", occurredAt: "2026-08-05T00:00:00Z" }),
|
|
184
|
-
);
|
|
185
|
-
assert.deepEqual(
|
|
186
|
-
current(db).map(({ project, summary }) => [project, summary]),
|
|
187
|
-
[
|
|
188
|
-
["project-mgr", "later"],
|
|
189
|
-
["other", "other"],
|
|
190
|
-
],
|
|
191
|
-
);
|
|
192
|
-
assert.equal(current(db, "project-mgr")[0]?.progress, undefined);
|
|
193
|
-
assert.deepEqual(
|
|
194
|
-
history(db, "project-mgr", {
|
|
195
|
-
since: "2026-08-04T00:00:00.000Z",
|
|
196
|
-
until: "2026-08-06T00:00:00.000Z",
|
|
197
|
-
limit: 1,
|
|
198
|
-
offset: 0,
|
|
199
|
-
}).map((item) => item.summary),
|
|
200
|
-
["later"],
|
|
201
|
-
);
|
|
202
|
-
assert.equal(history(db, undefined, { offset: 1 }).length, 3);
|
|
203
|
-
assert.equal(
|
|
204
|
-
at(db, "project-mgr", "2026-08-04T00:00:00.000Z")?.summary,
|
|
205
|
-
"same-time winner",
|
|
206
|
-
);
|
|
207
|
-
assert.equal(at(db, "missing", "2026-08-04T00:00:00.000Z"), undefined);
|
|
208
|
-
assert.equal(
|
|
209
|
-
(
|
|
210
|
-
db.prepare("SELECT count(*) AS count FROM schema_migrations").get() as {
|
|
211
|
-
count: number;
|
|
212
|
-
}
|
|
213
|
-
).count,
|
|
214
|
-
2,
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// Test setProjectMetadata & listProjects
|
|
218
|
-
const meta = setProjectMetadata(db, {
|
|
219
|
-
project: "project-mgr",
|
|
220
|
-
displayName: "Project Manager",
|
|
221
|
-
description: "Updated Description",
|
|
222
|
-
notes: "Updated Notes",
|
|
223
|
-
});
|
|
224
|
-
assert.equal(meta.displayName, "Project Manager");
|
|
225
|
-
assert.equal(meta.description, "Updated Description");
|
|
226
|
-
assert.equal(meta.notes, "Updated Notes");
|
|
227
|
-
|
|
228
|
-
// Create project with notes only (no description)
|
|
229
|
-
|
|
230
|
-
const notesOnlyMeta = setProjectMetadata(db, {
|
|
231
|
-
project: "notes-only-meta",
|
|
232
|
-
notes: "Only Note",
|
|
233
|
-
});
|
|
234
|
-
assert.equal(notesOnlyMeta.description, undefined);
|
|
235
|
-
assert.equal(notesOnlyMeta.notes, "Only Note");
|
|
236
|
-
|
|
237
|
-
// Create project without status entries
|
|
238
|
-
setProjectMetadata(db, {
|
|
239
|
-
project: "standalone",
|
|
240
|
-
description: "Standalone App",
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// Create project with tag for search
|
|
244
|
-
addStatus(
|
|
245
|
-
db,
|
|
246
|
-
entry({ project: "tag-proj", tags: ["nomatch", "my-special-tag"] }),
|
|
247
|
-
);
|
|
248
|
-
// Add projects with identical timestamps to exercise fallback tie-breaker
|
|
249
|
-
addStatus(
|
|
250
|
-
db,
|
|
251
|
-
entry({ project: "twin-a", occurredAt: "2026-05-01T00:00:00.000Z" }),
|
|
252
|
-
);
|
|
253
|
-
addStatus(
|
|
254
|
-
db,
|
|
255
|
-
entry({ project: "twin-b", occurredAt: "2026-05-01T00:00:00.000Z" }),
|
|
256
|
-
);
|
|
257
|
-
// Add a project with an even newer status entry timestamp to test descending sort
|
|
258
|
-
addStatus(
|
|
259
|
-
db,
|
|
260
|
-
entry({ project: "newest-proj", occurredAt: "2026-12-31T00:00:00.000Z" }),
|
|
261
|
-
);
|
|
262
|
-
// Add project with undefined progress for query filtering test
|
|
263
|
-
addStatus(db, {
|
|
264
|
-
project: "no-progress-proj",
|
|
265
|
-
summary: "no progress summary",
|
|
266
|
-
});
|
|
267
|
-
const matchedNoProg = listProjects(db, { query: "no progress summary" });
|
|
268
|
-
assert.equal(matchedNoProg.length, 1);
|
|
269
|
-
const noMatch = listProjects(db, { query: "non-existent-tag-query" });
|
|
270
|
-
assert.equal(noMatch.length, 0);
|
|
271
|
-
|
|
272
|
-
const matchedNotes = listProjects(db, { query: "Updated Notes" });
|
|
273
|
-
assert.equal(matchedNotes.length, 1);
|
|
274
|
-
assert.equal(matchedNotes[0].project, "project-mgr");
|
|
275
|
-
|
|
276
|
-
const matchedStandalone = listProjects(db, { query: "Standalone App" });
|
|
277
|
-
assert.equal(matchedStandalone.length, 1);
|
|
278
|
-
assert.equal(matchedStandalone[0].project, "standalone");
|
|
279
|
-
|
|
280
|
-
const matchedNone = listProjects(db, { query: "nonexistent" });
|
|
281
|
-
assert.equal(matchedNone.length, 0);
|
|
282
|
-
|
|
283
|
-
assert.equal(countEntries(db) > 0, true);
|
|
284
|
-
assert.equal(countEntries(db, "tag-proj"), 1);
|
|
285
|
-
|
|
286
|
-
// Test clearing description and notes with null or empty string
|
|
287
|
-
const clearedMeta = setProjectMetadata(db, {
|
|
288
|
-
project: "project-mgr",
|
|
289
|
-
description: null,
|
|
290
|
-
notes: "",
|
|
291
|
-
});
|
|
292
|
-
assert.equal(clearedMeta.description, undefined);
|
|
293
|
-
assert.equal(clearedMeta.notes, undefined);
|
|
294
|
-
|
|
295
|
-
db.close();
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test("import validates before transaction and default source is import", () => {
|
|
299
|
-
const db = openLedger(":memory:");
|
|
300
|
-
assert.throws(
|
|
301
|
-
() =>
|
|
302
|
-
importStatuses(db, [
|
|
303
|
-
entry({ summary: "would write" }),
|
|
304
|
-
entry({ status: "wrong" }),
|
|
305
|
-
]),
|
|
306
|
-
ValidationError,
|
|
307
|
-
);
|
|
308
|
-
assert.equal(history(db).length, 0);
|
|
309
|
-
const results = importStatuses(db, [
|
|
310
|
-
entry({ externalRef: "batch" }),
|
|
311
|
-
entry({ project: "second", summary: "second" }),
|
|
312
|
-
]);
|
|
313
|
-
assert.equal(results[0]?.entry.source, "import");
|
|
314
|
-
assert.equal(results.length, 2);
|
|
315
|
-
|
|
316
|
-
db.exec(`CREATE TRIGGER reject_explosion BEFORE INSERT ON status_entries
|
|
317
|
-
WHEN NEW.summary = 'explode'
|
|
318
|
-
BEGIN SELECT RAISE(ABORT, 'exploded'); END`);
|
|
319
|
-
assert.throws(() =>
|
|
320
|
-
importStatuses(db, [
|
|
321
|
-
entry({ project: "transaction-first", summary: "valid" }),
|
|
322
|
-
entry({ project: "transaction-second", summary: "explode" }),
|
|
323
|
-
]),
|
|
324
|
-
);
|
|
325
|
-
assert.equal(current(db, "transaction-first").length, 0);
|
|
326
|
-
db.close();
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("database path has a stable default and environment override creates its parent directory", () => {
|
|
330
|
-
const directory = mkdtempSync(join(tmpdir(), "pmgr-"));
|
|
331
|
-
const previous = process.env.PMGR_DB_PATH;
|
|
332
|
-
process.env.PMGR_DB_PATH = join(directory, "nested", "ledger.sqlite");
|
|
333
|
-
assert.equal(databasePath(), process.env.PMGR_DB_PATH);
|
|
334
|
-
const db = openLedger();
|
|
335
|
-
db.close();
|
|
336
|
-
if (previous === undefined) delete process.env.PMGR_DB_PATH;
|
|
337
|
-
else process.env.PMGR_DB_PATH = previous;
|
|
338
|
-
assert.equal(
|
|
339
|
-
databasePath(),
|
|
340
|
-
join(homedir(), ".local", "share", "project-mgr", "project-mgr.sqlite"),
|
|
341
|
-
);
|
|
342
|
-
rmSync(directory, { recursive: true, force: true });
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
test("CLI runs through a package-style symlink", () => {
|
|
346
|
-
const directory = mkdtempSync(join(tmpdir(), "pmgr-bin-"));
|
|
347
|
-
const executable = join(directory, "pmgr");
|
|
348
|
-
symlinkSync(
|
|
349
|
-
fileURLToPath(new URL("../src/cli.ts", import.meta.url)),
|
|
350
|
-
executable,
|
|
351
|
-
);
|
|
352
|
-
const result = spawnSync(
|
|
353
|
-
process.execPath,
|
|
354
|
-
["--import", "tsx", executable, "current", "--json"],
|
|
355
|
-
{
|
|
356
|
-
encoding: "utf8",
|
|
357
|
-
env: { ...process.env, PMGR_DB_PATH: ":memory:" },
|
|
358
|
-
},
|
|
359
|
-
);
|
|
360
|
-
assert.equal(result.status, 0, result.stderr);
|
|
361
|
-
assert.equal(result.stdout, "[]\n");
|
|
362
|
-
rmSync(directory, { recursive: true, force: true });
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
test("CLI supports add, import, reads, errors, project metadata, and export formats", () => {
|
|
366
|
-
const directory = mkdtempSync(join(tmpdir(), "pmgr-cli-"));
|
|
367
|
-
const file = join(directory, "ledger.sqlite");
|
|
368
|
-
const importFile = join(directory, "entries.jsonl");
|
|
369
|
-
const jsonFile = join(directory, "entry.json");
|
|
370
|
-
const arrayFile = join(directory, "entries.json");
|
|
371
|
-
const badFile = join(directory, "bad.jsonl");
|
|
372
|
-
const badJsonFile = join(directory, "bad.json");
|
|
373
|
-
const emptyFile = join(directory, "empty.json");
|
|
374
|
-
const portableFile = join(directory, "portable.jsonl");
|
|
375
|
-
const restoredFile = join(directory, "restored.sqlite");
|
|
376
|
-
writeFileSync(
|
|
377
|
-
importFile,
|
|
378
|
-
`${JSON.stringify(entry({ project: "imported", summary: "from file" }))}\n`,
|
|
379
|
-
);
|
|
380
|
-
writeFileSync(
|
|
381
|
-
jsonFile,
|
|
382
|
-
JSON.stringify(entry({ project: "json", summary: "one JSON object" })),
|
|
383
|
-
);
|
|
384
|
-
writeFileSync(
|
|
385
|
-
arrayFile,
|
|
386
|
-
JSON.stringify([entry({ project: "array", summary: "JSON array" })]),
|
|
387
|
-
);
|
|
388
|
-
writeFileSync(badFile, "not-json\n");
|
|
389
|
-
writeFileSync(badJsonFile, "not-json");
|
|
390
|
-
writeFileSync(emptyFile, "\n");
|
|
391
|
-
const previous = process.env.PMGR_DB_PATH;
|
|
392
|
-
process.env.PMGR_DB_PATH = file;
|
|
393
|
-
const execute = (args: string[]) => {
|
|
394
|
-
let result = "";
|
|
395
|
-
run(args, (text) => {
|
|
396
|
-
result += text;
|
|
397
|
-
});
|
|
398
|
-
return result;
|
|
399
|
-
};
|
|
400
|
-
const added = JSON.parse(
|
|
401
|
-
execute([
|
|
402
|
-
"add",
|
|
403
|
-
"--project",
|
|
404
|
-
"cli",
|
|
405
|
-
"--status",
|
|
406
|
-
"active",
|
|
407
|
-
"--summary",
|
|
408
|
-
"added",
|
|
409
|
-
"--description",
|
|
410
|
-
"CLI project description",
|
|
411
|
-
"--notes",
|
|
412
|
-
"CLI project notes",
|
|
413
|
-
"--progress",
|
|
414
|
-
"covered",
|
|
415
|
-
"--next",
|
|
416
|
-
"test",
|
|
417
|
-
"--next",
|
|
418
|
-
"repeat",
|
|
419
|
-
"--tag",
|
|
420
|
-
"cli",
|
|
421
|
-
"--external-ref",
|
|
422
|
-
"cli-add",
|
|
423
|
-
"--at",
|
|
424
|
-
timestamp,
|
|
425
|
-
"--json",
|
|
426
|
-
]),
|
|
427
|
-
);
|
|
428
|
-
assert.equal(added.project, "cli");
|
|
429
|
-
|
|
430
|
-
// CLI set-project, projects, and list commands
|
|
431
|
-
assert.match(
|
|
432
|
-
execute([
|
|
433
|
-
"set-project",
|
|
434
|
-
"--project",
|
|
435
|
-
"custom-proj",
|
|
436
|
-
"--display-name",
|
|
437
|
-
"Custom Proj",
|
|
438
|
-
"--description",
|
|
439
|
-
"Custom description",
|
|
440
|
-
"--notes",
|
|
441
|
-
"Custom note",
|
|
442
|
-
"--json",
|
|
443
|
-
]),
|
|
444
|
-
/Custom Proj/,
|
|
445
|
-
);
|
|
446
|
-
|
|
447
|
-
// Test set-project updates existing project with partial fields
|
|
448
|
-
assert.match(
|
|
449
|
-
execute([
|
|
450
|
-
"set-project",
|
|
451
|
-
"--project",
|
|
452
|
-
"custom-proj",
|
|
453
|
-
"--display-name",
|
|
454
|
-
"Custom Proj Renamed",
|
|
455
|
-
]),
|
|
456
|
-
/Custom Proj Renamed/,
|
|
457
|
-
);
|
|
458
|
-
execute([
|
|
459
|
-
"set-project",
|
|
460
|
-
"--project",
|
|
461
|
-
"custom-proj",
|
|
462
|
-
"--description",
|
|
463
|
-
"Updated desc only",
|
|
464
|
-
]);
|
|
465
|
-
execute([
|
|
466
|
-
"set-project",
|
|
467
|
-
"--project",
|
|
468
|
-
"custom-proj",
|
|
469
|
-
"--notes",
|
|
470
|
-
"Updated note only",
|
|
471
|
-
]);
|
|
472
|
-
execute(["set-project", "--project", "custom-proj"]);
|
|
473
|
-
|
|
474
|
-
// Test list / projects fuzzy search across all branches and human formatting
|
|
475
|
-
assert.match(execute(["projects"]), /cli\tcli/);
|
|
476
|
-
assert.match(execute(["projects"]), /Description: CLI project description/);
|
|
477
|
-
assert.match(execute(["projects"]), /Notes: CLI project notes/);
|
|
478
|
-
assert.match(execute(["projects"]), /Latest Status: \(none\)/);
|
|
479
|
-
|
|
480
|
-
assert.match(
|
|
481
|
-
execute(["list", "--query", "Custom Proj Renamed"]),
|
|
482
|
-
/Custom Proj Renamed/,
|
|
483
|
-
);
|
|
484
|
-
assert.match(
|
|
485
|
-
execute(["list", "--query", "Updated desc only"]),
|
|
486
|
-
/Updated desc only/,
|
|
487
|
-
);
|
|
488
|
-
assert.match(
|
|
489
|
-
execute(["list", "--query", "Updated note only"]),
|
|
490
|
-
/Updated note only/,
|
|
491
|
-
);
|
|
492
|
-
assert.match(execute(["projects", "--query", "active"]), /cli/);
|
|
493
|
-
assert.match(execute(["projects", "--query", "added"]), /cli/);
|
|
494
|
-
assert.match(execute(["projects", "--query", "covered"]), /cli/);
|
|
495
|
-
assert.match(execute(["projects", "--query", "cli"]), /cli/);
|
|
496
|
-
assert.equal(
|
|
497
|
-
execute(["projects", "--query", "no-match"]),
|
|
498
|
-
"No projects found.\n",
|
|
499
|
-
);
|
|
500
|
-
assert.equal(execute(["projects", "--query", "no-match", "--json"]), "[]\n");
|
|
501
|
-
|
|
502
|
-
// Test clearing description and notes via CLI flags
|
|
503
|
-
assert.throws(
|
|
504
|
-
() =>
|
|
505
|
-
execute([
|
|
506
|
-
"set-project",
|
|
507
|
-
"--project",
|
|
508
|
-
"custom-proj",
|
|
509
|
-
"--description",
|
|
510
|
-
"desc",
|
|
511
|
-
"--clear-description",
|
|
512
|
-
]),
|
|
513
|
-
/cannot specify both --description and --clear-description/,
|
|
514
|
-
);
|
|
515
|
-
assert.throws(
|
|
516
|
-
() =>
|
|
517
|
-
execute([
|
|
518
|
-
"set-project",
|
|
519
|
-
"--project",
|
|
520
|
-
"custom-proj",
|
|
521
|
-
"--notes",
|
|
522
|
-
"note",
|
|
523
|
-
"--clear-notes",
|
|
524
|
-
]),
|
|
525
|
-
/cannot specify both --notes and --clear-notes/,
|
|
526
|
-
);
|
|
527
|
-
execute([
|
|
528
|
-
"set-project",
|
|
529
|
-
"--project",
|
|
530
|
-
"custom-proj",
|
|
531
|
-
"--clear-description",
|
|
532
|
-
"--clear-notes",
|
|
533
|
-
]);
|
|
534
|
-
|
|
535
|
-
assert.match(
|
|
536
|
-
execute([
|
|
537
|
-
"status",
|
|
538
|
-
"add",
|
|
539
|
-
"--project",
|
|
540
|
-
"human",
|
|
541
|
-
"--status",
|
|
542
|
-
"active",
|
|
543
|
-
"--summary",
|
|
544
|
-
"human output",
|
|
545
|
-
"--at",
|
|
546
|
-
timestamp,
|
|
547
|
-
]),
|
|
548
|
-
/human output/,
|
|
549
|
-
);
|
|
550
|
-
assert.match(execute(["import", importFile, "--json"]), /from file/);
|
|
551
|
-
assert.match(
|
|
552
|
-
execute(["status", "import", "--file", jsonFile, "--json"]),
|
|
553
|
-
/one JSON object/,
|
|
554
|
-
);
|
|
555
|
-
assert.match(
|
|
556
|
-
execute(["status", "import", arrayFile, "--json"]),
|
|
557
|
-
/JSON array/,
|
|
558
|
-
);
|
|
559
|
-
assert.match(execute(["current"]), /cli\tactive/);
|
|
560
|
-
assert.match(execute(["status", "current"]), /cli\tactive/);
|
|
561
|
-
assert.equal(
|
|
562
|
-
JSON.parse(
|
|
563
|
-
execute([
|
|
564
|
-
"history",
|
|
565
|
-
"--project",
|
|
566
|
-
"cli",
|
|
567
|
-
"--since",
|
|
568
|
-
"2026-08-01T00:00:00Z",
|
|
569
|
-
"--until",
|
|
570
|
-
"2026-08-05T00:00:00Z",
|
|
571
|
-
"--limit",
|
|
572
|
-
"1",
|
|
573
|
-
"--offset",
|
|
574
|
-
"0",
|
|
575
|
-
"--json",
|
|
576
|
-
]),
|
|
577
|
-
)[0].summary,
|
|
578
|
-
"added",
|
|
579
|
-
);
|
|
580
|
-
assert.match(
|
|
581
|
-
execute(["status", "history", "--project", "cli", "--json"]),
|
|
582
|
-
/added/,
|
|
583
|
-
);
|
|
584
|
-
assert.deepEqual(
|
|
585
|
-
JSON.parse(execute(["history", "--limit", "2", "--json"])).map(
|
|
586
|
-
(item: { project: string }) => item.project,
|
|
587
|
-
),
|
|
588
|
-
["array", "json"],
|
|
589
|
-
);
|
|
590
|
-
|
|
591
|
-
assert.equal(
|
|
592
|
-
JSON.parse(
|
|
593
|
-
execute([
|
|
594
|
-
"at",
|
|
595
|
-
"--project",
|
|
596
|
-
"cli",
|
|
597
|
-
"--at",
|
|
598
|
-
"2026-08-04T00:00:00Z",
|
|
599
|
-
"--json",
|
|
600
|
-
]),
|
|
601
|
-
).project,
|
|
602
|
-
"cli",
|
|
603
|
-
);
|
|
604
|
-
assert.equal(
|
|
605
|
-
JSON.parse(
|
|
606
|
-
execute([
|
|
607
|
-
"status",
|
|
608
|
-
"at",
|
|
609
|
-
"--project",
|
|
610
|
-
"cli",
|
|
611
|
-
"--at",
|
|
612
|
-
"2026-08-01T00:00:00Z",
|
|
613
|
-
"--json",
|
|
614
|
-
]),
|
|
615
|
-
),
|
|
616
|
-
null,
|
|
617
|
-
);
|
|
618
|
-
assert.match(
|
|
619
|
-
execute(["export", "--project", "cli", "--format", "jsonl"]),
|
|
620
|
-
/"project":"cli"/,
|
|
621
|
-
);
|
|
622
|
-
const exported = execute(["status", "export", "--format", "json"]);
|
|
623
|
-
assert.match(exported, /imported/);
|
|
624
|
-
assert.doesNotMatch(exported, /"recordedAt"|"id"/);
|
|
625
|
-
assert.match(execute(["status", "export"]), /imported/);
|
|
626
|
-
assert.equal(
|
|
627
|
-
execute(["export", "--project", "missing", "--format", "jsonl"]),
|
|
628
|
-
"",
|
|
629
|
-
);
|
|
630
|
-
assert.equal(
|
|
631
|
-
execute(["history", "--project", "missing"]),
|
|
632
|
-
"No entries found.\n",
|
|
633
|
-
);
|
|
634
|
-
assert.equal(execute(["history", "--project", "missing", "--json"]), "[]\n");
|
|
635
|
-
assert.throws(
|
|
636
|
-
() => execute(["status", "history", "--project", "cli", "--limit", "-1"]),
|
|
637
|
-
ValidationError,
|
|
638
|
-
);
|
|
639
|
-
assert.throws(
|
|
640
|
-
() => execute(["status", "history", "--project", "cli", "--limit", "1.5"]),
|
|
641
|
-
ValidationError,
|
|
642
|
-
);
|
|
643
|
-
assert.throws(() => execute(["set-project"]), ValidationError);
|
|
644
|
-
assert.throws(() => execute(["status", "add", "--project"]), ValidationError);
|
|
645
|
-
|
|
646
|
-
assert.throws(
|
|
647
|
-
() =>
|
|
648
|
-
execute(["status", "current", "--project", "cli", "--project", "again"]),
|
|
649
|
-
ValidationError,
|
|
650
|
-
);
|
|
651
|
-
assert.throws(() => execute(["status", "import", badFile]), ValidationError);
|
|
652
|
-
assert.throws(
|
|
653
|
-
() => execute(["status", "import", badJsonFile]),
|
|
654
|
-
ValidationError,
|
|
655
|
-
);
|
|
656
|
-
assert.throws(
|
|
657
|
-
() => execute(["status", "import", emptyFile]),
|
|
658
|
-
ValidationError,
|
|
659
|
-
);
|
|
660
|
-
assert.throws(() => execute(["status", "import"]), ValidationError);
|
|
661
|
-
assert.throws(() => execute(["wat"]), ValidationError);
|
|
662
|
-
assert.throws(() => execute([]), ValidationError);
|
|
663
|
-
assert.throws(() => execute(["status"]), ValidationError);
|
|
664
|
-
assert.throws(() => execute(["status", "wat"]), ValidationError);
|
|
665
|
-
assert.throws(() => execute(["status", "toString"]), ValidationError);
|
|
666
|
-
assert.throws(
|
|
667
|
-
() => execute(["status", "current", "unexpected"]),
|
|
668
|
-
/does not accept positional arguments/,
|
|
669
|
-
);
|
|
670
|
-
assert.throws(
|
|
671
|
-
() => execute(["status", "current", "--prject", "cli"]),
|
|
672
|
-
/unknown flag/,
|
|
673
|
-
);
|
|
674
|
-
assert.throws(
|
|
675
|
-
() => execute(["status", "current", "--__proto__", "value"]),
|
|
676
|
-
/unknown flag/,
|
|
677
|
-
);
|
|
678
|
-
assert.throws(
|
|
679
|
-
() => execute(["status", "import", importFile, "extra.json"]),
|
|
680
|
-
/at most one file argument/,
|
|
681
|
-
);
|
|
682
|
-
assert.throws(
|
|
683
|
-
() => execute(["status", "import", importFile, "--file", jsonFile]),
|
|
684
|
-
/not both/,
|
|
685
|
-
);
|
|
686
|
-
assert.throws(() => execute(["status", "export", "--json"]), /unknown flag/);
|
|
687
|
-
assert.throws(
|
|
688
|
-
() => execute(["status", "current", "--json", "--json"]),
|
|
689
|
-
/only be supplied once/,
|
|
690
|
-
);
|
|
691
|
-
assert.throws(
|
|
692
|
-
() => execute(["status", "export", "--format", "csv"]),
|
|
693
|
-
ValidationError,
|
|
694
|
-
);
|
|
695
|
-
|
|
696
|
-
execute([
|
|
697
|
-
"status",
|
|
698
|
-
"add",
|
|
699
|
-
"--project",
|
|
700
|
-
"tie",
|
|
701
|
-
"--status",
|
|
702
|
-
"active",
|
|
703
|
-
"--summary",
|
|
704
|
-
"tie first",
|
|
705
|
-
"--at",
|
|
706
|
-
timestamp,
|
|
707
|
-
]);
|
|
708
|
-
execute([
|
|
709
|
-
"status",
|
|
710
|
-
"add",
|
|
711
|
-
"--project",
|
|
712
|
-
"tie",
|
|
713
|
-
"--status",
|
|
714
|
-
"active",
|
|
715
|
-
"--summary",
|
|
716
|
-
"tie second",
|
|
717
|
-
"--at",
|
|
718
|
-
timestamp,
|
|
719
|
-
]);
|
|
720
|
-
writeFileSync(
|
|
721
|
-
portableFile,
|
|
722
|
-
execute(["status", "export", "--format", "jsonl"]),
|
|
723
|
-
);
|
|
724
|
-
process.env.PMGR_DB_PATH = restoredFile;
|
|
725
|
-
assert.doesNotThrow(() => execute(["status", "import", portableFile]));
|
|
726
|
-
assert.equal(
|
|
727
|
-
JSON.parse(execute(["status", "current", "--project", "cli", "--json"]))[0]
|
|
728
|
-
.summary,
|
|
729
|
-
"added",
|
|
730
|
-
);
|
|
731
|
-
assert.equal(
|
|
732
|
-
JSON.parse(execute(["status", "current", "--project", "tie", "--json"]))[0]
|
|
733
|
-
.summary,
|
|
734
|
-
"tie second",
|
|
735
|
-
);
|
|
736
|
-
if (previous === undefined) delete process.env.PMGR_DB_PATH;
|
|
737
|
-
else process.env.PMGR_DB_PATH = previous;
|
|
738
|
-
rmSync(directory, { recursive: true, force: true });
|
|
739
|
-
});
|