project-mgr 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/CHANGELOG.md +17 -0
- package/README.md +22 -1
- package/README_CN.md +1 -1
- 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 +39 -16
- package/.claude/settings.local.json +0 -5
- package/.env_sample +0 -7
- package/.prettierignore +0 -4
- package/AGENTS.md +0 -5
- package/docs/architecture.md +0 -95
- 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/src/db.ts
DELETED
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
import Database from "better-sqlite3";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
type ProjectMetadata,
|
|
7
|
-
type ProjectOverview,
|
|
8
|
-
type Source,
|
|
9
|
-
type StatusEntry,
|
|
10
|
-
type StatusEntryInput,
|
|
11
|
-
validateProjectMetadataInput,
|
|
12
|
-
validateStatusEntry,
|
|
13
|
-
ValidationError,
|
|
14
|
-
} from "./domain.js";
|
|
15
|
-
|
|
16
|
-
export type Ledger = Database.Database;
|
|
17
|
-
export type InsertResult = { entry: StatusEntry; inserted: boolean };
|
|
18
|
-
export type HistoryOptions = {
|
|
19
|
-
since?: string;
|
|
20
|
-
until?: string;
|
|
21
|
-
limit?: number;
|
|
22
|
-
offset?: number;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const migration1 = `
|
|
26
|
-
CREATE TABLE IF NOT EXISTS projects (
|
|
27
|
-
id INTEGER PRIMARY KEY,
|
|
28
|
-
slug TEXT NOT NULL UNIQUE,
|
|
29
|
-
display_name TEXT NOT NULL,
|
|
30
|
-
created_at TEXT NOT NULL
|
|
31
|
-
);
|
|
32
|
-
CREATE TABLE IF NOT EXISTS status_entries (
|
|
33
|
-
id INTEGER PRIMARY KEY,
|
|
34
|
-
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
35
|
-
status TEXT NOT NULL,
|
|
36
|
-
summary TEXT NOT NULL,
|
|
37
|
-
progress TEXT,
|
|
38
|
-
blockers_json TEXT NOT NULL DEFAULT '[]',
|
|
39
|
-
next_steps_json TEXT NOT NULL DEFAULT '[]',
|
|
40
|
-
decisions_json TEXT NOT NULL DEFAULT '[]',
|
|
41
|
-
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
42
|
-
occurred_at TEXT NOT NULL,
|
|
43
|
-
recorded_at TEXT NOT NULL,
|
|
44
|
-
source TEXT NOT NULL,
|
|
45
|
-
external_ref TEXT UNIQUE
|
|
46
|
-
);
|
|
47
|
-
CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
|
|
48
|
-
`;
|
|
49
|
-
|
|
50
|
-
const migration2 = `
|
|
51
|
-
ALTER TABLE projects ADD COLUMN description TEXT;
|
|
52
|
-
ALTER TABLE projects ADD COLUMN notes TEXT;
|
|
53
|
-
`;
|
|
54
|
-
|
|
55
|
-
export function databasePath(): string {
|
|
56
|
-
return (
|
|
57
|
-
process.env.PMGR_DB_PATH ||
|
|
58
|
-
resolve(homedir(), ".local", "share", "project-mgr", "project-mgr.sqlite")
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function openLedger(path = databasePath()): Ledger {
|
|
63
|
-
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
64
|
-
const db = new Database(path);
|
|
65
|
-
db.pragma("foreign_keys = ON");
|
|
66
|
-
db.exec(
|
|
67
|
-
"CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)",
|
|
68
|
-
);
|
|
69
|
-
db.transaction(() => {
|
|
70
|
-
if (
|
|
71
|
-
!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()
|
|
72
|
-
) {
|
|
73
|
-
db.exec(migration1);
|
|
74
|
-
db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
|
|
75
|
-
}
|
|
76
|
-
if (
|
|
77
|
-
!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 2").get()
|
|
78
|
-
) {
|
|
79
|
-
db.exec(migration2);
|
|
80
|
-
db.prepare("INSERT INTO schema_migrations(version) VALUES (2)").run();
|
|
81
|
-
}
|
|
82
|
-
}).immediate();
|
|
83
|
-
return db;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function entryFromRow(row: Record<string, unknown>): StatusEntry {
|
|
87
|
-
return {
|
|
88
|
-
id: row.id as number,
|
|
89
|
-
project: row.project as string,
|
|
90
|
-
status: row.status as StatusEntry["status"],
|
|
91
|
-
summary: row.summary as string,
|
|
92
|
-
...(row.progress ? { progress: row.progress as string } : {}),
|
|
93
|
-
blockers: JSON.parse(row.blockers_json as string) as string[],
|
|
94
|
-
nextSteps: JSON.parse(row.next_steps_json as string) as string[],
|
|
95
|
-
decisions: JSON.parse(row.decisions_json as string) as string[],
|
|
96
|
-
tags: JSON.parse(row.tags_json as string) as string[],
|
|
97
|
-
occurredAt: row.occurred_at as string,
|
|
98
|
-
recordedAt: row.recorded_at as string,
|
|
99
|
-
source: row.source as Source,
|
|
100
|
-
...(row.external_ref ? { externalRef: row.external_ref as string } : {}),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const select = `SELECT e.*, p.slug AS project FROM status_entries e JOIN projects p ON p.id = e.project_id`;
|
|
105
|
-
function one(
|
|
106
|
-
db: Ledger,
|
|
107
|
-
sql: string,
|
|
108
|
-
...params: unknown[]
|
|
109
|
-
): StatusEntry | undefined {
|
|
110
|
-
const row = db.prepare(sql).get(...params) as
|
|
111
|
-
Record<string, unknown> | undefined;
|
|
112
|
-
return row && entryFromRow(row);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function upsertProjectMetadata(
|
|
116
|
-
db: Ledger,
|
|
117
|
-
input: {
|
|
118
|
-
project: string;
|
|
119
|
-
displayName?: string;
|
|
120
|
-
description?: string | null;
|
|
121
|
-
notes?: string | null;
|
|
122
|
-
},
|
|
123
|
-
): void {
|
|
124
|
-
const existing = db
|
|
125
|
-
.prepare("SELECT 1 FROM projects WHERE slug = ?")
|
|
126
|
-
.get(input.project);
|
|
127
|
-
if (!existing) {
|
|
128
|
-
const now = new Date().toISOString();
|
|
129
|
-
db.prepare(
|
|
130
|
-
"INSERT INTO projects(slug, display_name, description, notes, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
131
|
-
).run(
|
|
132
|
-
input.project,
|
|
133
|
-
input.displayName ?? input.project,
|
|
134
|
-
input.description ?? null,
|
|
135
|
-
input.notes ?? null,
|
|
136
|
-
now,
|
|
137
|
-
);
|
|
138
|
-
} else {
|
|
139
|
-
const updates: string[] = [];
|
|
140
|
-
const params: unknown[] = [];
|
|
141
|
-
if (input.displayName !== undefined) {
|
|
142
|
-
updates.push("display_name = ?");
|
|
143
|
-
params.push(input.displayName);
|
|
144
|
-
}
|
|
145
|
-
if (input.description !== undefined) {
|
|
146
|
-
updates.push("description = ?");
|
|
147
|
-
params.push(input.description);
|
|
148
|
-
}
|
|
149
|
-
if (input.notes !== undefined) {
|
|
150
|
-
updates.push("notes = ?");
|
|
151
|
-
params.push(input.notes);
|
|
152
|
-
}
|
|
153
|
-
if (updates.length > 0) {
|
|
154
|
-
params.push(input.project);
|
|
155
|
-
db.prepare(
|
|
156
|
-
`UPDATE projects SET ${updates.join(", ")} WHERE slug = ?`,
|
|
157
|
-
).run(...params);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function setProjectMetadata(
|
|
163
|
-
db: Ledger,
|
|
164
|
-
value: unknown,
|
|
165
|
-
): ProjectMetadata {
|
|
166
|
-
const input = validateProjectMetadataInput(value);
|
|
167
|
-
return db.transaction(() => {
|
|
168
|
-
upsertProjectMetadata(db, input);
|
|
169
|
-
const row = db
|
|
170
|
-
.prepare("SELECT * FROM projects WHERE slug = ?")
|
|
171
|
-
.get(input.project) as Record<string, unknown>;
|
|
172
|
-
return {
|
|
173
|
-
project: row.slug as string,
|
|
174
|
-
displayName: row.display_name as string,
|
|
175
|
-
...(row.description ? { description: row.description as string } : {}),
|
|
176
|
-
...(row.notes ? { notes: row.notes as string } : {}),
|
|
177
|
-
createdAt: row.created_at as string,
|
|
178
|
-
};
|
|
179
|
-
})();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function listProjects(
|
|
183
|
-
db: Ledger,
|
|
184
|
-
options: { query?: string } = {},
|
|
185
|
-
): ProjectOverview[] {
|
|
186
|
-
const query = options.query?.trim().toLowerCase();
|
|
187
|
-
const projects = db
|
|
188
|
-
.prepare("SELECT * FROM projects ORDER BY slug ASC")
|
|
189
|
-
.all() as Record<string, unknown>[];
|
|
190
|
-
|
|
191
|
-
const latestStatuses = current(db);
|
|
192
|
-
const statusMap = new Map<string, StatusEntry>();
|
|
193
|
-
for (const entry of latestStatuses) {
|
|
194
|
-
statusMap.set(entry.project, entry);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const overviews: ProjectOverview[] = projects.map((row) => {
|
|
198
|
-
const slug = row.slug as string;
|
|
199
|
-
const latestStatus = statusMap.get(slug);
|
|
200
|
-
return {
|
|
201
|
-
project: slug,
|
|
202
|
-
displayName: row.display_name as string,
|
|
203
|
-
...(row.description ? { description: row.description as string } : {}),
|
|
204
|
-
...(row.notes ? { notes: row.notes as string } : {}),
|
|
205
|
-
createdAt: row.created_at as string,
|
|
206
|
-
...(latestStatus ? { latestStatus } : {}),
|
|
207
|
-
};
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
overviews.sort((a, b) => {
|
|
211
|
-
const timeA = a.latestStatus?.occurredAt ?? a.createdAt;
|
|
212
|
-
const timeB = b.latestStatus?.occurredAt ?? b.createdAt;
|
|
213
|
-
if (timeA !== timeB) {
|
|
214
|
-
return timeB.localeCompare(timeA);
|
|
215
|
-
}
|
|
216
|
-
return a.project.localeCompare(b.project);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
if (!query) return overviews;
|
|
220
|
-
|
|
221
|
-
return overviews.filter((overview) => {
|
|
222
|
-
if (overview.project.toLowerCase().includes(query)) return true;
|
|
223
|
-
if (overview.displayName.toLowerCase().includes(query)) return true;
|
|
224
|
-
if (overview.description?.toLowerCase().includes(query)) return true;
|
|
225
|
-
if (overview.notes?.toLowerCase().includes(query)) return true;
|
|
226
|
-
if (overview.latestStatus) {
|
|
227
|
-
const st = overview.latestStatus;
|
|
228
|
-
const match =
|
|
229
|
-
st.status.toLowerCase().includes(query) ||
|
|
230
|
-
st.summary.toLowerCase().includes(query) ||
|
|
231
|
-
(st.progress && st.progress.toLowerCase().includes(query)) ||
|
|
232
|
-
st.tags.some((t) => t.toLowerCase().includes(query));
|
|
233
|
-
if (match) return true;
|
|
234
|
-
}
|
|
235
|
-
return false;
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function addStatus(
|
|
240
|
-
db: Ledger,
|
|
241
|
-
value: unknown,
|
|
242
|
-
defaultSource: Source = "human",
|
|
243
|
-
): InsertResult {
|
|
244
|
-
const input = validateStatusEntry(value, defaultSource);
|
|
245
|
-
return db.transaction(() => addValidatedStatus(db, input))();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function addValidatedStatus(
|
|
249
|
-
db: Ledger,
|
|
250
|
-
input: StatusEntryInput & { occurredAt: string; source: Source },
|
|
251
|
-
): InsertResult {
|
|
252
|
-
if (input.externalRef) {
|
|
253
|
-
const existing = one(
|
|
254
|
-
db,
|
|
255
|
-
`${select} WHERE e.external_ref = ?`,
|
|
256
|
-
input.externalRef,
|
|
257
|
-
);
|
|
258
|
-
if (existing) {
|
|
259
|
-
if (existing.project !== input.project)
|
|
260
|
-
throw new ValidationError(
|
|
261
|
-
`externalRef is already used by project ${existing.project}`,
|
|
262
|
-
);
|
|
263
|
-
return { entry: existing, inserted: false };
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
const now = new Date().toISOString();
|
|
267
|
-
upsertProjectMetadata(db, { project: input.project });
|
|
268
|
-
|
|
269
|
-
const project = db
|
|
270
|
-
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
271
|
-
.get(input.project) as { id: number };
|
|
272
|
-
const result = db
|
|
273
|
-
.prepare(
|
|
274
|
-
`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref)
|
|
275
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
276
|
-
)
|
|
277
|
-
.run(
|
|
278
|
-
project.id,
|
|
279
|
-
input.status,
|
|
280
|
-
input.summary,
|
|
281
|
-
input.progress ?? null,
|
|
282
|
-
JSON.stringify(input.blockers),
|
|
283
|
-
JSON.stringify(input.nextSteps),
|
|
284
|
-
JSON.stringify(input.decisions),
|
|
285
|
-
JSON.stringify(input.tags),
|
|
286
|
-
input.occurredAt,
|
|
287
|
-
now,
|
|
288
|
-
input.source,
|
|
289
|
-
input.externalRef ?? null,
|
|
290
|
-
);
|
|
291
|
-
return {
|
|
292
|
-
entry: one(
|
|
293
|
-
db,
|
|
294
|
-
`${select} WHERE e.id = ?`,
|
|
295
|
-
result.lastInsertRowid,
|
|
296
|
-
) as StatusEntry,
|
|
297
|
-
inserted: true,
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function importStatuses(db: Ledger, values: unknown[]): InsertResult[] {
|
|
302
|
-
const inputs = values.map((value) => validateStatusEntry(value, "import"));
|
|
303
|
-
return db.transaction(() =>
|
|
304
|
-
inputs.map((input) => addValidatedStatus(db, input)),
|
|
305
|
-
)();
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function current(db: Ledger, project?: string): StatusEntry[] {
|
|
309
|
-
const where = project ? " AND p.slug = ?" : "";
|
|
310
|
-
const rows = db
|
|
311
|
-
.prepare(
|
|
312
|
-
`${select} WHERE e.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY occurred_at DESC, id DESC) AS position FROM status_entries) WHERE position = 1)${where} ORDER BY e.occurred_at DESC, e.id DESC`,
|
|
313
|
-
)
|
|
314
|
-
.all(...(project ? [project] : [])) as Record<string, unknown>[];
|
|
315
|
-
return rows.map(entryFromRow);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export function history(
|
|
319
|
-
db: Ledger,
|
|
320
|
-
project?: string,
|
|
321
|
-
options: HistoryOptions = {},
|
|
322
|
-
): StatusEntry[] {
|
|
323
|
-
const clauses: string[] = [];
|
|
324
|
-
const params: unknown[] = [];
|
|
325
|
-
if (project) {
|
|
326
|
-
clauses.push("p.slug = ?");
|
|
327
|
-
params.push(project);
|
|
328
|
-
}
|
|
329
|
-
if (options.since) {
|
|
330
|
-
clauses.push("e.occurred_at >= ?");
|
|
331
|
-
params.push(options.since);
|
|
332
|
-
}
|
|
333
|
-
if (options.until) {
|
|
334
|
-
clauses.push("e.occurred_at <= ?");
|
|
335
|
-
params.push(options.until);
|
|
336
|
-
}
|
|
337
|
-
const limit = options.limit ?? 100;
|
|
338
|
-
const offset = options.offset ?? 0;
|
|
339
|
-
const rows = db
|
|
340
|
-
.prepare(
|
|
341
|
-
`${select}${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY e.occurred_at DESC, e.id DESC LIMIT ? OFFSET ?`,
|
|
342
|
-
)
|
|
343
|
-
.all(...params, limit, offset) as Record<string, unknown>[];
|
|
344
|
-
return rows.map(entryFromRow);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export function at(
|
|
348
|
-
db: Ledger,
|
|
349
|
-
project: string,
|
|
350
|
-
occurredAt: string,
|
|
351
|
-
): StatusEntry | undefined {
|
|
352
|
-
return one(
|
|
353
|
-
db,
|
|
354
|
-
`${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`,
|
|
355
|
-
project,
|
|
356
|
-
occurredAt,
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
export function countEntries(db: Ledger, project?: string): number {
|
|
361
|
-
if (project) {
|
|
362
|
-
const row = db
|
|
363
|
-
.prepare(
|
|
364
|
-
"SELECT COUNT(*) AS count FROM status_entries e JOIN projects p ON p.id = e.project_id WHERE p.slug = ?",
|
|
365
|
-
)
|
|
366
|
-
.get(project) as { count: number };
|
|
367
|
-
return row.count;
|
|
368
|
-
}
|
|
369
|
-
const row = db
|
|
370
|
-
.prepare("SELECT COUNT(*) AS count FROM status_entries")
|
|
371
|
-
.get() as { count: number };
|
|
372
|
-
return row.count;
|
|
373
|
-
}
|
package/src/domain.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
export const statuses = [
|
|
2
|
-
"idea",
|
|
3
|
-
"planned",
|
|
4
|
-
"active",
|
|
5
|
-
"blocked",
|
|
6
|
-
"paused",
|
|
7
|
-
"completed",
|
|
8
|
-
"archived",
|
|
9
|
-
] as const;
|
|
10
|
-
export const sources = ["human", "agent", "import"] as const;
|
|
11
|
-
|
|
12
|
-
export type ProjectStatus = (typeof statuses)[number];
|
|
13
|
-
export type Source = (typeof sources)[number];
|
|
14
|
-
|
|
15
|
-
export type ProjectMetadata = {
|
|
16
|
-
project: string;
|
|
17
|
-
displayName: string;
|
|
18
|
-
description?: string;
|
|
19
|
-
notes?: string;
|
|
20
|
-
createdAt: string;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export type ProjectOverview = ProjectMetadata & {
|
|
24
|
-
latestStatus?: StatusEntry;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type ProjectMetadataInput = {
|
|
28
|
-
project: string;
|
|
29
|
-
displayName?: string;
|
|
30
|
-
description?: string | null;
|
|
31
|
-
notes?: string | null;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export type StatusEntryInput = {
|
|
35
|
-
project: string;
|
|
36
|
-
status: ProjectStatus;
|
|
37
|
-
summary: string;
|
|
38
|
-
progress?: string;
|
|
39
|
-
blockers?: string[];
|
|
40
|
-
nextSteps?: string[];
|
|
41
|
-
decisions?: string[];
|
|
42
|
-
tags?: string[];
|
|
43
|
-
occurredAt?: string;
|
|
44
|
-
source?: Source;
|
|
45
|
-
externalRef?: string;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export type StatusEntry = Required<
|
|
49
|
-
Omit<StatusEntryInput, "progress" | "externalRef">
|
|
50
|
-
> & {
|
|
51
|
-
id: number;
|
|
52
|
-
progress?: string;
|
|
53
|
-
externalRef?: string;
|
|
54
|
-
recordedAt: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt">;
|
|
58
|
-
|
|
59
|
-
const keys = new Set([
|
|
60
|
-
"project",
|
|
61
|
-
"status",
|
|
62
|
-
"summary",
|
|
63
|
-
"progress",
|
|
64
|
-
"blockers",
|
|
65
|
-
"nextSteps",
|
|
66
|
-
"decisions",
|
|
67
|
-
"tags",
|
|
68
|
-
"occurredAt",
|
|
69
|
-
"source",
|
|
70
|
-
"externalRef",
|
|
71
|
-
]);
|
|
72
|
-
const projectMetadataKeys = new Set([
|
|
73
|
-
"project",
|
|
74
|
-
"displayName",
|
|
75
|
-
"description",
|
|
76
|
-
"notes",
|
|
77
|
-
]);
|
|
78
|
-
const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
79
|
-
|
|
80
|
-
export class ValidationError extends Error {
|
|
81
|
-
constructor(message: string) {
|
|
82
|
-
super(message);
|
|
83
|
-
this.name = "ValidationError";
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function text(
|
|
88
|
-
value: unknown,
|
|
89
|
-
field: string,
|
|
90
|
-
required = false,
|
|
91
|
-
): string | undefined {
|
|
92
|
-
if (value === undefined && !required) return undefined;
|
|
93
|
-
if (typeof value !== "string" || !value.trim())
|
|
94
|
-
throw new ValidationError(`${field} must be a non-empty string`);
|
|
95
|
-
const normalized = value.trim();
|
|
96
|
-
if (normalized.length > 2_000)
|
|
97
|
-
throw new ValidationError(`${field} must be at most 2000 characters`);
|
|
98
|
-
return normalized;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function nullableText(
|
|
102
|
-
value: unknown,
|
|
103
|
-
field: string,
|
|
104
|
-
): string | null | undefined {
|
|
105
|
-
if (value === undefined) return undefined;
|
|
106
|
-
if (value === null) return null;
|
|
107
|
-
if (typeof value !== "string")
|
|
108
|
-
throw new ValidationError(`${field} must be a string or null`);
|
|
109
|
-
const normalized = value.trim();
|
|
110
|
-
if (normalized === "") return null;
|
|
111
|
-
if (normalized.length > 2_000)
|
|
112
|
-
throw new ValidationError(`${field} must be at most 2000 characters`);
|
|
113
|
-
return normalized;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function textList(value: unknown, field: string): string[] {
|
|
117
|
-
if (value === undefined) return [];
|
|
118
|
-
if (!Array.isArray(value) || value.length > 100)
|
|
119
|
-
throw new ValidationError(
|
|
120
|
-
`${field} must be an array of at most 100 strings`,
|
|
121
|
-
);
|
|
122
|
-
return value.map((item) => text(item, field, true) as string);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function parseTime(value: unknown, field: string): string {
|
|
126
|
-
if (
|
|
127
|
-
typeof value !== "string" ||
|
|
128
|
-
!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
|
|
129
|
-
Number.isNaN(Date.parse(value))
|
|
130
|
-
) {
|
|
131
|
-
throw new ValidationError(`${field} must be a valid ISO 8601 timestamp`);
|
|
132
|
-
}
|
|
133
|
-
return new Date(value).toISOString();
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function validateProjectMetadataInput(
|
|
137
|
-
value: unknown,
|
|
138
|
-
): ProjectMetadataInput {
|
|
139
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
140
|
-
throw new ValidationError("project metadata must be an object");
|
|
141
|
-
const input = value as Record<string, unknown>;
|
|
142
|
-
for (const key of Object.keys(input))
|
|
143
|
-
if (!projectMetadataKeys.has(key))
|
|
144
|
-
throw new ValidationError(`unknown field: ${key}`);
|
|
145
|
-
const project = text(input.project, "project", true) as string;
|
|
146
|
-
if (!projectName.test(project))
|
|
147
|
-
throw new ValidationError(
|
|
148
|
-
"project must use letters, numbers, dots, underscores, or hyphens",
|
|
149
|
-
);
|
|
150
|
-
const displayName = text(input.displayName, "displayName");
|
|
151
|
-
const description = nullableText(input.description, "description");
|
|
152
|
-
const notes = nullableText(input.notes, "notes");
|
|
153
|
-
return {
|
|
154
|
-
project,
|
|
155
|
-
...(displayName !== undefined ? { displayName } : {}),
|
|
156
|
-
...(description !== undefined ? { description } : {}),
|
|
157
|
-
...(notes !== undefined ? { notes } : {}),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function validateStatusEntry(
|
|
162
|
-
value: unknown,
|
|
163
|
-
defaultSource: Source = "human",
|
|
164
|
-
): StatusEntryInput &
|
|
165
|
-
Required<Pick<StatusEntryInput, "occurredAt" | "source">> {
|
|
166
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
167
|
-
throw new ValidationError("entry must be an object");
|
|
168
|
-
const input = value as Record<string, unknown>;
|
|
169
|
-
for (const key of Object.keys(input))
|
|
170
|
-
if (!keys.has(key)) throw new ValidationError(`unknown field: ${key}`);
|
|
171
|
-
const project = text(input.project, "project", true) as string;
|
|
172
|
-
if (!projectName.test(project))
|
|
173
|
-
throw new ValidationError(
|
|
174
|
-
"project must use letters, numbers, dots, underscores, or hyphens",
|
|
175
|
-
);
|
|
176
|
-
let statusInput = input.status;
|
|
177
|
-
if (statusInput === undefined || statusInput === null) {
|
|
178
|
-
statusInput = "completed";
|
|
179
|
-
} else if (
|
|
180
|
-
typeof statusInput === "string" &&
|
|
181
|
-
statusInput.trim().toLowerCase() === "done"
|
|
182
|
-
) {
|
|
183
|
-
statusInput = "completed";
|
|
184
|
-
}
|
|
185
|
-
if (!statuses.includes(statusInput as ProjectStatus))
|
|
186
|
-
throw new ValidationError("status is invalid");
|
|
187
|
-
const source = input.source === undefined ? defaultSource : input.source;
|
|
188
|
-
if (!sources.includes(source as Source))
|
|
189
|
-
throw new ValidationError("source is invalid");
|
|
190
|
-
const externalRef = text(input.externalRef, "externalRef");
|
|
191
|
-
if (externalRef && externalRef.length > 200)
|
|
192
|
-
throw new ValidationError("externalRef must be at most 200 characters");
|
|
193
|
-
return {
|
|
194
|
-
project,
|
|
195
|
-
status: statusInput as ProjectStatus,
|
|
196
|
-
summary: text(input.summary, "summary", true) as string,
|
|
197
|
-
progress: text(input.progress, "progress"),
|
|
198
|
-
blockers: textList(input.blockers, "blockers"),
|
|
199
|
-
nextSteps: textList(input.nextSteps, "nextSteps"),
|
|
200
|
-
decisions: textList(input.decisions, "decisions"),
|
|
201
|
-
tags: textList(input.tags, "tags"),
|
|
202
|
-
occurredAt:
|
|
203
|
-
input.occurredAt === undefined
|
|
204
|
-
? new Date().toISOString()
|
|
205
|
-
: parseTime(input.occurredAt, "occurredAt"),
|
|
206
|
-
source: source as Source,
|
|
207
|
-
externalRef,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry {
|
|
212
|
-
const result: PortableStatusEntry = {
|
|
213
|
-
project: entry.project,
|
|
214
|
-
status: entry.status,
|
|
215
|
-
summary: entry.summary,
|
|
216
|
-
blockers: entry.blockers,
|
|
217
|
-
nextSteps: entry.nextSteps,
|
|
218
|
-
decisions: entry.decisions,
|
|
219
|
-
tags: entry.tags,
|
|
220
|
-
occurredAt: entry.occurredAt,
|
|
221
|
-
source: entry.source,
|
|
222
|
-
};
|
|
223
|
-
if (entry.progress !== undefined) result.progress = entry.progress;
|
|
224
|
-
if (entry.externalRef !== undefined) result.externalRef = entry.externalRef;
|
|
225
|
-
return result;
|
|
226
|
-
}
|