project-mgr 0.1.7 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-mgr",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first append-only project status ledger",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@ Use `npx --yes --package project-mgr pmgr`. In a source checkout, use `pnpm pmgr
13
13
  - For exploratory or test work, set `PMGR_DB_PATH` to a dedicated absolute SQLite path for every command. Do not use `:memory:` when more than one CLI invocation must share data.
14
14
  - Query before writing. `add` appends a snapshot; it does not edit the previous one.
15
15
  - Set `--source agent` for agent-authored updates. Use `--external-ref` only when a stable idempotency key is available.
16
+ - If the project is not yet in the ledger database, initialize its metadata with `set-project`, backfill 3–5 historical major changes/milestones using `add` (setting appropriate historical `--at` timestamps), and then append the latest status snapshot.
16
17
 
17
18
  ## Operations
18
19
 
@@ -28,8 +29,8 @@ npx --yes --package project-mgr pmgr set-project --project my-project --clear-de
28
29
  npx --yes --package project-mgr pmgr projects --json
29
30
  npx --yes --package project-mgr pmgr list --query "keyword" --json
30
31
 
31
- # Add an agent-authored status snapshot (optionally with project description or notes).
32
- npx --yes --package project-mgr pmgr add --project my-project --status active --summary "Implemented the CLI" \
32
+ # Add an agent-authored status snapshot (status defaults to completed if omitted).
33
+ npx --yes --package project-mgr pmgr add --project my-project --summary "Implemented the CLI" \
33
34
  --description "Project description" --progress "Tests pass" --next "Publish the package" --source agent --json
34
35
 
35
36
  # Query current state, history, or state at a time.
package/src/cli.ts CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  openLedger,
13
13
  parseTime,
14
14
  type ProjectOverview,
15
+ type ProjectStatus,
15
16
  setProjectMetadata,
17
+ startServer,
16
18
  toPortableStatusEntry,
17
19
  ValidationError,
18
20
  } from "./index.js";
@@ -33,7 +35,9 @@ function parse(argv: string[]): { command: string[]; flags: Flags } {
33
35
  if (
34
36
  key === "json" ||
35
37
  key === "clear-description" ||
36
- key === "clear-notes"
38
+ key === "clear-notes" ||
39
+ key === "open" ||
40
+ key === "no-open"
37
41
  ) {
38
42
  if (flags[key] !== undefined)
39
43
  throw new ValidationError(`--${key} may only be supplied once`);
@@ -94,6 +98,7 @@ const allowedFlags = new Map<string, ReadonlySet<string>>([
94
98
  ],
95
99
  ["at", new Set(["project", "at", "json"])],
96
100
  ["export", new Set(["project", "format"])],
101
+ ["serve", new Set(["port", "host", "open", "no-open", "json"])],
97
102
  ]);
98
103
 
99
104
  function validateInvocation(command: string[], flags: Flags): string {
@@ -103,7 +108,7 @@ function validateInvocation(command: string[], flags: Flags): string {
103
108
  typeof action === "string" ? allowedFlags.get(action) : undefined;
104
109
  if (typeof action !== "string" || !allowed)
105
110
  throw new ValidationError(
106
- "usage: pmgr <add|import|current|projects|list|set-project|history|at|export> (or: pmgr status <action>)",
111
+ "usage: pmgr <add|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)",
107
112
  );
108
113
  const positional = command.slice(statusCommand ? 2 : 1);
109
114
  if (action === "import") {
@@ -212,7 +217,7 @@ function imported(file: string): unknown[] {
212
217
  export function run(
213
218
  argv: string[],
214
219
  write = process.stdout.write.bind(process.stdout),
215
- ): void {
220
+ ): unknown {
216
221
  const { command, flags } = parse(argv);
217
222
  const action = validateInvocation(command, flags);
218
223
  const db = openLedger();
@@ -229,7 +234,7 @@ export function run(
229
234
  output(
230
235
  addStatus(db, {
231
236
  project,
232
- status: stringFlag(flags, "status", true),
237
+ status: stringFlag(flags, "status") as unknown as ProjectStatus,
233
238
  summary: stringFlag(flags, "summary", true),
234
239
  progress: stringFlag(flags, "progress"),
235
240
  blockers: listFlag(flags, "blocker"),
@@ -352,9 +357,30 @@ export function run(
352
357
  else throw new ValidationError("--format must be json or jsonl");
353
358
  break;
354
359
  }
360
+
361
+ case "serve": {
362
+ const port =
363
+ flags.port !== undefined
364
+ ? numberFlag(flags, "port", 3000)
365
+ : undefined;
366
+ const host = stringFlag(flags, "host");
367
+ const open = flags["no-open"] === true ? false : true;
368
+ return startServer({ port, host, open, db }).then((instance) => {
369
+ if (json) {
370
+ write(
371
+ `${JSON.stringify({ url: instance.url, port: instance.port, host: instance.host })}\n`,
372
+ );
373
+ } else {
374
+ write(`Server running at ${instance.url}\n`);
375
+ }
376
+ return instance;
377
+ });
378
+ }
355
379
  }
356
380
  } finally {
357
- db.close();
381
+ if (action !== "serve") {
382
+ db.close();
383
+ }
358
384
  }
359
385
  }
360
386
 
@@ -364,7 +390,7 @@ if (
364
390
  import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
365
391
  ) {
366
392
  try {
367
- run(process.argv.slice(2));
393
+ await run(process.argv.slice(2));
368
394
  } catch (error) {
369
395
  console.error(error instanceof Error ? error.message : String(error));
370
396
  process.exitCode = 1;
package/src/db.ts CHANGED
@@ -207,6 +207,15 @@ export function listProjects(
207
207
  };
208
208
  });
209
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
+
210
219
  if (!query) return overviews;
211
220
 
212
221
  return overviews.filter((overview) => {
@@ -215,16 +224,13 @@ export function listProjects(
215
224
  if (overview.description?.toLowerCase().includes(query)) return true;
216
225
  if (overview.notes?.toLowerCase().includes(query)) return true;
217
226
  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;
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;
228
234
  }
229
235
  return false;
230
236
  });
@@ -350,3 +356,18 @@ export function at(
350
356
  occurredAt,
351
357
  );
352
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 CHANGED
@@ -173,7 +173,16 @@ export function validateStatusEntry(
173
173
  throw new ValidationError(
174
174
  "project must use letters, numbers, dots, underscores, or hyphens",
175
175
  );
176
- if (!statuses.includes(input.status as ProjectStatus))
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))
177
186
  throw new ValidationError("status is invalid");
178
187
  const source = input.source === undefined ? defaultSource : input.source;
179
188
  if (!sources.includes(source as Source))
@@ -183,7 +192,7 @@ export function validateStatusEntry(
183
192
  throw new ValidationError("externalRef must be at most 200 characters");
184
193
  return {
185
194
  project,
186
- status: input.status as ProjectStatus,
195
+ status: statusInput as ProjectStatus,
187
196
  summary: text(input.summary, "summary", true) as string,
188
197
  progress: text(input.progress, "progress"),
189
198
  blockers: textList(input.blockers, "blockers"),
package/src/index.ts CHANGED
@@ -1,2 +1,5 @@
1
+ export * from "./cli.js";
1
2
  export * from "./db.js";
2
3
  export * from "./domain.js";
4
+ export * from "./server.js";
5
+ export * from "./web/ui.js";
package/src/server.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { exec } from "node:child_process";
2
+ import {
3
+ createServer,
4
+ type IncomingMessage,
5
+ type Server,
6
+ type ServerResponse,
7
+ } from "node:http";
8
+ import { URL } from "node:url";
9
+ import {
10
+ countEntries,
11
+ history,
12
+ listProjects,
13
+ openLedger,
14
+ type Ledger,
15
+ } from "./db.js";
16
+ import { getWebUIHTML } from "./web/ui.js";
17
+
18
+ export type ServerOptions = {
19
+ port?: number;
20
+ host?: string;
21
+ open?: boolean;
22
+ db?: Ledger;
23
+ };
24
+
25
+ export type ServerInstance = {
26
+ server: Server;
27
+ url: string;
28
+ port: number;
29
+ host: string;
30
+ close: () => Promise<void>;
31
+ };
32
+
33
+ export function openBrowser(url: string): void {
34
+ const platform = process.platform;
35
+ let command = "";
36
+ if (platform === "darwin") {
37
+ command = `open "${url}"`;
38
+ } else if (platform === "win32") {
39
+ command = `start "" "${url}"`;
40
+ } else {
41
+ command = `xdg-open "${url}"`;
42
+ }
43
+ exec(command, () => {
44
+ /* ignore background exec errors */
45
+ });
46
+ }
47
+
48
+ function parseQueryParams(url: URL): Record<string, string> {
49
+ const params: Record<string, string> = {};
50
+ for (const [key, value] of url.searchParams.entries()) {
51
+ params[key] = value;
52
+ }
53
+ return params;
54
+ }
55
+
56
+ function sendJson(
57
+ res: ServerResponse,
58
+ statusCode: number,
59
+ data: unknown,
60
+ ): void {
61
+ res.writeHead(statusCode, {
62
+ "Content-Type": "application/json; charset=utf-8",
63
+ "Cache-Control": "no-cache",
64
+ });
65
+ res.end(JSON.stringify(data));
66
+ }
67
+
68
+ export function handleRequest(
69
+ req: IncomingMessage,
70
+ res: ServerResponse,
71
+ db: Ledger,
72
+ ): void {
73
+ if (req.method !== "GET" && req.method !== "HEAD") {
74
+ sendJson(res, 405, { error: "Method not allowed" });
75
+ return;
76
+ }
77
+
78
+ const reqUrl = new URL(
79
+ req.url || "/",
80
+ `http://${req.headers.host || "localhost"}`,
81
+ );
82
+ const pathname = reqUrl.pathname;
83
+ const query = parseQueryParams(reqUrl);
84
+
85
+ if (pathname === "/" || pathname === "/index.html") {
86
+ res.writeHead(200, {
87
+ "Content-Type": "text/html; charset=utf-8",
88
+ "Cache-Control": "no-cache",
89
+ });
90
+ res.end(getWebUIHTML());
91
+ return;
92
+ }
93
+
94
+ if (pathname === "/api/projects") {
95
+ const projects = listProjects(db, { query: query.query });
96
+ sendJson(res, 200, projects);
97
+ return;
98
+ }
99
+
100
+ if (pathname.startsWith("/api/projects/")) {
101
+ const slug = decodeURIComponent(pathname.slice("/api/projects/".length));
102
+ const projects = listProjects(db);
103
+ const target = projects.find((p) => p.project === slug);
104
+ if (!target) {
105
+ sendJson(res, 404, { error: "Project not found" });
106
+ } else {
107
+ sendJson(res, 200, target);
108
+ }
109
+ return;
110
+ }
111
+
112
+ if (pathname === "/api/history") {
113
+ const project = query.project;
114
+ const since = query.since;
115
+ const until = query.until;
116
+ const rawLimit = Number(query.limit ?? 100);
117
+ const rawOffset = Number(query.offset ?? 0);
118
+
119
+ const entries = history(db, project, {
120
+ since,
121
+ until,
122
+ limit: Number.isInteger(rawLimit) && rawLimit >= 0 ? rawLimit : 100,
123
+ offset: Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0,
124
+ });
125
+ sendJson(res, 200, entries);
126
+ return;
127
+ }
128
+
129
+ if (pathname === "/api/stats") {
130
+ const projects = listProjects(db);
131
+ const totalEntries = countEntries(db);
132
+ const statusCounts: Record<string, number> = {};
133
+
134
+ for (const proj of projects) {
135
+ const st = proj.latestStatus?.status || "none";
136
+ statusCounts[st] = (statusCounts[st] || 0) + 1;
137
+ }
138
+
139
+ sendJson(res, 200, {
140
+ totalProjects: projects.length,
141
+ totalEntries,
142
+ statusCounts,
143
+ });
144
+ return;
145
+ }
146
+
147
+ sendJson(res, 404, { error: "Not found" });
148
+ }
149
+
150
+ export function startServer(
151
+ options: ServerOptions = {},
152
+ ): Promise<ServerInstance> {
153
+ const host = options.host ?? "127.0.0.1";
154
+ const port =
155
+ options.port ??
156
+ (process.env.PORT ? Number(process.env.PORT) : /* c8 ignore next */ 3000);
157
+ const db = options.db || openLedger();
158
+ const shouldOpen = options.open ?? true;
159
+
160
+ const server = createServer((req, res) => {
161
+ try {
162
+ handleRequest(req, res, db);
163
+ } catch (err) {
164
+ sendJson(res, 500, {
165
+ error: err instanceof Error ? err.message : String(err),
166
+ });
167
+ }
168
+ });
169
+
170
+ return new Promise((resolve, reject) => {
171
+ server.once("error", reject);
172
+ server.listen(port, host, () => {
173
+ /* c8 ignore start -- server.address() ternary branch is Node network adapter glue */
174
+ const address = server.address();
175
+ const actualPort =
176
+ typeof address === "object" && address !== null ? address.port : port;
177
+ /* c8 ignore stop */
178
+ const url = `http://${host}:${actualPort}`;
179
+
180
+ if (shouldOpen) {
181
+ openBrowser(url);
182
+ }
183
+
184
+ resolve({
185
+ server,
186
+ url,
187
+ port: actualPort,
188
+ host,
189
+ close: () =>
190
+ new Promise<void>((resClose, rejClose) => {
191
+ server.close((err) => {
192
+ if (!options.db) {
193
+ db.close();
194
+ }
195
+ if (err) rejClose(err);
196
+ else resClose();
197
+ });
198
+ }),
199
+ });
200
+ });
201
+ });
202
+ }