project-mgr 0.1.6 → 0.1.7

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/src/cli.ts CHANGED
@@ -8,8 +8,11 @@ import {
8
8
  current,
9
9
  history,
10
10
  importStatuses,
11
+ listProjects,
11
12
  openLedger,
12
13
  parseTime,
14
+ type ProjectOverview,
15
+ setProjectMetadata,
13
16
  toPortableStatusEntry,
14
17
  ValidationError,
15
18
  } from "./index.js";
@@ -27,7 +30,11 @@ function parse(argv: string[]): { command: string[]; flags: Flags } {
27
30
  continue;
28
31
  }
29
32
  const key = token.slice(2);
30
- if (key === "json") {
33
+ if (
34
+ key === "json" ||
35
+ key === "clear-description" ||
36
+ key === "clear-notes"
37
+ ) {
31
38
  if (flags[key] !== undefined)
32
39
  throw new ValidationError(`--${key} may only be supplied once`);
33
40
  flags[key] = true;
@@ -52,6 +59,8 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
52
59
  "project",
53
60
  "status",
54
61
  "summary",
62
+ "description",
63
+ "notes",
55
64
  "progress",
56
65
  "blocker",
57
66
  "next",
@@ -65,6 +74,20 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
65
74
  ],
66
75
  ["import", new Set(["file", "json"])],
67
76
  ["current", new Set(["project", "json"])],
77
+ ["projects", new Set(["query", "json"])],
78
+ ["list", new Set(["query", "json"])],
79
+ [
80
+ "set-project",
81
+ new Set([
82
+ "project",
83
+ "display-name",
84
+ "description",
85
+ "notes",
86
+ "clear-description",
87
+ "clear-notes",
88
+ "json",
89
+ ]),
90
+ ],
68
91
  [
69
92
  "history",
70
93
  new Set(["project", "since", "until", "limit", "offset", "json"]),
@@ -80,7 +103,7 @@ function validateInvocation(command: string[], flags: Flags): string {
80
103
  typeof action === "string" ? allowedFlags.get(action) : undefined;
81
104
  if (typeof action !== "string" || !allowed)
82
105
  throw new ValidationError(
83
- "usage: pmgr <add|import|current|history|at|export> (or: pmgr status <action>)",
106
+ "usage: pmgr <add|import|current|projects|list|set-project|history|at|export> (or: pmgr status <action>)",
84
107
  );
85
108
  const positional = command.slice(statusCommand ? 2 : 1);
86
109
  if (action === "import") {
@@ -139,6 +162,30 @@ function output(
139
162
  } else write(`${JSON.stringify(value, null, 2)}\n`);
140
163
  }
141
164
 
165
+ function formatProjectOverview(item: ProjectOverview): string {
166
+ const lines = [`${item.project}\t${item.displayName}`];
167
+ if (item.description) lines.push(` Description: ${item.description}`);
168
+ if (item.notes) lines.push(` Notes: ${item.notes}`);
169
+ if (item.latestStatus) {
170
+ lines.push(
171
+ ` Latest Status: ${item.latestStatus.status}\t${item.latestStatus.occurredAt}\t${item.latestStatus.summary}`,
172
+ );
173
+ } else {
174
+ lines.push(` Latest Status: (none)`);
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+
179
+ function outputProjects(
180
+ items: ProjectOverview[],
181
+ json: boolean,
182
+ write: (text: string) => void,
183
+ ): void {
184
+ if (json) write(`${JSON.stringify(items)}\n`);
185
+ else if (items.length === 0) write("No projects found.\n");
186
+ else write(`${items.map(formatProjectOverview).join("\n")}\n`);
187
+ }
188
+
142
189
  function imported(file: string): unknown[] {
143
190
  if (!file) throw new ValidationError("import file is required");
144
191
  const content = readFileSync(file, "utf8").trim();
@@ -172,10 +219,16 @@ export function run(
172
219
  const json = flags.json === true;
173
220
  try {
174
221
  switch (action) {
175
- case "add":
222
+ case "add": {
223
+ const project = stringFlag(flags, "project", true) as string;
224
+ const description = stringFlag(flags, "description");
225
+ const notes = stringFlag(flags, "notes");
226
+ if (description !== undefined || notes !== undefined) {
227
+ setProjectMetadata(db, { project, description, notes });
228
+ }
176
229
  output(
177
230
  addStatus(db, {
178
- project: stringFlag(flags, "project", true),
231
+ project,
179
232
  status: stringFlag(flags, "status", true),
180
233
  summary: stringFlag(flags, "summary", true),
181
234
  progress: stringFlag(flags, "progress"),
@@ -191,6 +244,8 @@ export function run(
191
244
  write,
192
245
  );
193
246
  break;
247
+ }
248
+
194
249
  case "import":
195
250
  output(
196
251
  importStatuses(
@@ -208,6 +263,52 @@ export function run(
208
263
  case "current":
209
264
  output(current(db, stringFlag(flags, "project")), json, write);
210
265
  break;
266
+ case "projects":
267
+ case "list":
268
+ outputProjects(
269
+ listProjects(db, { query: stringFlag(flags, "query") }),
270
+ json,
271
+ write,
272
+ );
273
+ break;
274
+ case "set-project": {
275
+ const project = stringFlag(flags, "project", true) as string;
276
+ const displayName = stringFlag(flags, "display-name");
277
+ let description: string | null | undefined = undefined;
278
+ if (flags["clear-description"] === true) {
279
+ if (flags.description !== undefined)
280
+ throw new ValidationError(
281
+ "cannot specify both --description and --clear-description",
282
+ );
283
+ description = null;
284
+ } else if (flags.description !== undefined) {
285
+ description = stringFlag(flags, "description");
286
+ }
287
+
288
+ let notes: string | null | undefined = undefined;
289
+ if (flags["clear-notes"] === true) {
290
+ if (flags.notes !== undefined)
291
+ throw new ValidationError(
292
+ "cannot specify both --notes and --clear-notes",
293
+ );
294
+ notes = null;
295
+ } else if (flags.notes !== undefined) {
296
+ notes = stringFlag(flags, "notes");
297
+ }
298
+
299
+ output(
300
+ setProjectMetadata(db, {
301
+ project,
302
+ displayName,
303
+ description,
304
+ notes,
305
+ }),
306
+ json,
307
+ write,
308
+ );
309
+ break;
310
+ }
311
+
211
312
  case "history":
212
313
  output(
213
314
  history(db, stringFlag(flags, "project"), {
package/src/db.ts CHANGED
@@ -3,9 +3,12 @@ import { mkdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import {
6
+ type ProjectMetadata,
7
+ type ProjectOverview,
6
8
  type Source,
7
9
  type StatusEntry,
8
10
  type StatusEntryInput,
11
+ validateProjectMetadataInput,
9
12
  validateStatusEntry,
10
13
  ValidationError,
11
14
  } from "./domain.js";
@@ -19,7 +22,7 @@ export type HistoryOptions = {
19
22
  offset?: number;
20
23
  };
21
24
 
22
- const migration = `
25
+ const migration1 = `
23
26
  CREATE TABLE IF NOT EXISTS projects (
24
27
  id INTEGER PRIMARY KEY,
25
28
  slug TEXT NOT NULL UNIQUE,
@@ -44,6 +47,11 @@ CREATE TABLE IF NOT EXISTS status_entries (
44
47
  CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
45
48
  `;
46
49
 
50
+ const migration2 = `
51
+ ALTER TABLE projects ADD COLUMN description TEXT;
52
+ ALTER TABLE projects ADD COLUMN notes TEXT;
53
+ `;
54
+
47
55
  export function databasePath(): string {
48
56
  return (
49
57
  process.env.PMGR_DB_PATH ||
@@ -62,9 +70,15 @@ export function openLedger(path = databasePath()): Ledger {
62
70
  if (
63
71
  !db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()
64
72
  ) {
65
- db.exec(migration);
73
+ db.exec(migration1);
66
74
  db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
67
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
+ }
68
82
  }).immediate();
69
83
  return db;
70
84
  }
@@ -82,7 +96,7 @@ function entryFromRow(row: Record<string, unknown>): StatusEntry {
82
96
  tags: JSON.parse(row.tags_json as string) as string[],
83
97
  occurredAt: row.occurred_at as string,
84
98
  recordedAt: row.recorded_at as string,
85
- source: row.source as StatusEntry["source"],
99
+ source: row.source as Source,
86
100
  ...(row.external_ref ? { externalRef: row.external_ref as string } : {}),
87
101
  };
88
102
  }
@@ -98,6 +112,124 @@ function one(
98
112
  return row && entryFromRow(row);
99
113
  }
100
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
+ if (!query) return overviews;
211
+
212
+ return overviews.filter((overview) => {
213
+ if (overview.project.toLowerCase().includes(query)) return true;
214
+ if (overview.displayName.toLowerCase().includes(query)) return true;
215
+ if (overview.description?.toLowerCase().includes(query)) return true;
216
+ if (overview.notes?.toLowerCase().includes(query)) return true;
217
+ if (overview.latestStatus) {
218
+ if (overview.latestStatus.status.toLowerCase().includes(query))
219
+ return true;
220
+ if (overview.latestStatus.summary.toLowerCase().includes(query))
221
+ return true;
222
+ if (overview.latestStatus.progress?.toLowerCase().includes(query))
223
+ return true;
224
+ if (
225
+ overview.latestStatus.tags.some((t) => t.toLowerCase().includes(query))
226
+ )
227
+ return true;
228
+ }
229
+ return false;
230
+ });
231
+ }
232
+
101
233
  export function addStatus(
102
234
  db: Ledger,
103
235
  value: unknown,
@@ -126,9 +258,8 @@ function addValidatedStatus(
126
258
  }
127
259
  }
128
260
  const now = new Date().toISOString();
129
- db.prepare(
130
- "INSERT INTO projects(slug, display_name, created_at) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING",
131
- ).run(input.project, input.project, now);
261
+ upsertProjectMetadata(db, { project: input.project });
262
+
132
263
  const project = db
133
264
  .prepare("SELECT id FROM projects WHERE slug = ?")
134
265
  .get(input.project) as { id: number };
package/src/domain.ts CHANGED
@@ -12,6 +12,25 @@ export const sources = ["human", "agent", "import"] as const;
12
12
  export type ProjectStatus = (typeof statuses)[number];
13
13
  export type Source = (typeof sources)[number];
14
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
+
15
34
  export type StatusEntryInput = {
16
35
  project: string;
17
36
  status: ProjectStatus;
@@ -50,6 +69,12 @@ const keys = new Set([
50
69
  "source",
51
70
  "externalRef",
52
71
  ]);
72
+ const projectMetadataKeys = new Set([
73
+ "project",
74
+ "displayName",
75
+ "description",
76
+ "notes",
77
+ ]);
53
78
  const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
54
79
 
55
80
  export class ValidationError extends Error {
@@ -73,6 +98,21 @@ function text(
73
98
  return normalized;
74
99
  }
75
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
+
76
116
  function textList(value: unknown, field: string): string[] {
77
117
  if (value === undefined) return [];
78
118
  if (!Array.isArray(value) || value.length > 100)
@@ -93,6 +133,31 @@ export function parseTime(value: unknown, field: string): string {
93
133
  return new Date(value).toISOString();
94
134
  }
95
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
+
96
161
  export function validateStatusEntry(
97
162
  value: unknown,
98
163
  defaultSource: Source = "human",
@@ -135,19 +200,18 @@ export function validateStatusEntry(
135
200
  }
136
201
 
137
202
  export function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry {
138
- return {
203
+ const result: PortableStatusEntry = {
139
204
  project: entry.project,
140
205
  status: entry.status,
141
206
  summary: entry.summary,
142
- ...(entry.progress === undefined ? {} : { progress: entry.progress }),
143
207
  blockers: entry.blockers,
144
208
  nextSteps: entry.nextSteps,
145
209
  decisions: entry.decisions,
146
210
  tags: entry.tags,
147
211
  occurredAt: entry.occurredAt,
148
212
  source: entry.source,
149
- ...(entry.externalRef === undefined
150
- ? {}
151
- : { externalRef: entry.externalRef }),
152
213
  };
214
+ if (entry.progress !== undefined) result.progress = entry.progress;
215
+ if (entry.externalRef !== undefined) result.externalRef = entry.externalRef;
216
+ return result;
153
217
  }