project-mgr 0.1.6 → 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/dist/domain.js CHANGED
@@ -21,6 +21,12 @@ const keys = new Set([
21
21
  "source",
22
22
  "externalRef",
23
23
  ]);
24
+ const projectMetadataKeys = new Set([
25
+ "project",
26
+ "displayName",
27
+ "description",
28
+ "notes",
29
+ ]);
24
30
  const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
25
31
  export class ValidationError extends Error {
26
32
  constructor(message) {
@@ -38,6 +44,20 @@ function text(value, field, required = false) {
38
44
  throw new ValidationError(`${field} must be at most 2000 characters`);
39
45
  return normalized;
40
46
  }
47
+ function nullableText(value, field) {
48
+ if (value === undefined)
49
+ return undefined;
50
+ if (value === null)
51
+ return null;
52
+ if (typeof value !== "string")
53
+ throw new ValidationError(`${field} must be a string or null`);
54
+ const normalized = value.trim();
55
+ if (normalized === "")
56
+ return null;
57
+ if (normalized.length > 2_000)
58
+ throw new ValidationError(`${field} must be at most 2000 characters`);
59
+ return normalized;
60
+ }
41
61
  function textList(value, field) {
42
62
  if (value === undefined)
43
63
  return [];
@@ -53,6 +73,26 @@ export function parseTime(value, field) {
53
73
  }
54
74
  return new Date(value).toISOString();
55
75
  }
76
+ export function validateProjectMetadataInput(value) {
77
+ if (!value || typeof value !== "object" || Array.isArray(value))
78
+ throw new ValidationError("project metadata must be an object");
79
+ const input = value;
80
+ for (const key of Object.keys(input))
81
+ if (!projectMetadataKeys.has(key))
82
+ throw new ValidationError(`unknown field: ${key}`);
83
+ const project = text(input.project, "project", true);
84
+ if (!projectName.test(project))
85
+ throw new ValidationError("project must use letters, numbers, dots, underscores, or hyphens");
86
+ const displayName = text(input.displayName, "displayName");
87
+ const description = nullableText(input.description, "description");
88
+ const notes = nullableText(input.notes, "notes");
89
+ return {
90
+ project,
91
+ ...(displayName !== undefined ? { displayName } : {}),
92
+ ...(description !== undefined ? { description } : {}),
93
+ ...(notes !== undefined ? { notes } : {}),
94
+ };
95
+ }
56
96
  export function validateStatusEntry(value, defaultSource = "human") {
57
97
  if (!value || typeof value !== "object" || Array.isArray(value))
58
98
  throw new ValidationError("entry must be an object");
@@ -63,7 +103,15 @@ export function validateStatusEntry(value, defaultSource = "human") {
63
103
  const project = text(input.project, "project", true);
64
104
  if (!projectName.test(project))
65
105
  throw new ValidationError("project must use letters, numbers, dots, underscores, or hyphens");
66
- if (!statuses.includes(input.status))
106
+ let statusInput = input.status;
107
+ if (statusInput === undefined || statusInput === null) {
108
+ statusInput = "completed";
109
+ }
110
+ else if (typeof statusInput === "string" &&
111
+ statusInput.trim().toLowerCase() === "done") {
112
+ statusInput = "completed";
113
+ }
114
+ if (!statuses.includes(statusInput))
67
115
  throw new ValidationError("status is invalid");
68
116
  const source = input.source === undefined ? defaultSource : input.source;
69
117
  if (!sources.includes(source))
@@ -73,7 +121,7 @@ export function validateStatusEntry(value, defaultSource = "human") {
73
121
  throw new ValidationError("externalRef must be at most 200 characters");
74
122
  return {
75
123
  project,
76
- status: input.status,
124
+ status: statusInput,
77
125
  summary: text(input.summary, "summary", true),
78
126
  progress: text(input.progress, "progress"),
79
127
  blockers: textList(input.blockers, "blockers"),
@@ -88,19 +136,20 @@ export function validateStatusEntry(value, defaultSource = "human") {
88
136
  };
89
137
  }
90
138
  export function toPortableStatusEntry(entry) {
91
- return {
139
+ const result = {
92
140
  project: entry.project,
93
141
  status: entry.status,
94
142
  summary: entry.summary,
95
- ...(entry.progress === undefined ? {} : { progress: entry.progress }),
96
143
  blockers: entry.blockers,
97
144
  nextSteps: entry.nextSteps,
98
145
  decisions: entry.decisions,
99
146
  tags: entry.tags,
100
147
  occurredAt: entry.occurredAt,
101
148
  source: entry.source,
102
- ...(entry.externalRef === undefined
103
- ? {}
104
- : { externalRef: entry.externalRef }),
105
149
  };
150
+ if (entry.progress !== undefined)
151
+ result.progress = entry.progress;
152
+ if (entry.externalRef !== undefined)
153
+ result.externalRef = entry.externalRef;
154
+ return result;
106
155
  }
package/dist/index.d.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/dist/index.js 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";
@@ -0,0 +1,18 @@
1
+ import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import { type Ledger } from "./db.js";
3
+ export type ServerOptions = {
4
+ port?: number;
5
+ host?: string;
6
+ open?: boolean;
7
+ db?: Ledger;
8
+ };
9
+ export type ServerInstance = {
10
+ server: Server;
11
+ url: string;
12
+ port: number;
13
+ host: string;
14
+ close: () => Promise<void>;
15
+ };
16
+ export declare function openBrowser(url: string): void;
17
+ export declare function handleRequest(req: IncomingMessage, res: ServerResponse, db: Ledger): void;
18
+ export declare function startServer(options?: ServerOptions): Promise<ServerInstance>;
package/dist/server.js ADDED
@@ -0,0 +1,147 @@
1
+ import { exec } from "node:child_process";
2
+ import { createServer, } from "node:http";
3
+ import { URL } from "node:url";
4
+ import { countEntries, history, listProjects, openLedger, } from "./db.js";
5
+ import { getWebUIHTML } from "./web/ui.js";
6
+ export function openBrowser(url) {
7
+ const platform = process.platform;
8
+ let command = "";
9
+ if (platform === "darwin") {
10
+ command = `open "${url}"`;
11
+ }
12
+ else if (platform === "win32") {
13
+ command = `start "" "${url}"`;
14
+ }
15
+ else {
16
+ command = `xdg-open "${url}"`;
17
+ }
18
+ exec(command, () => {
19
+ /* ignore background exec errors */
20
+ });
21
+ }
22
+ function parseQueryParams(url) {
23
+ const params = {};
24
+ for (const [key, value] of url.searchParams.entries()) {
25
+ params[key] = value;
26
+ }
27
+ return params;
28
+ }
29
+ function sendJson(res, statusCode, data) {
30
+ res.writeHead(statusCode, {
31
+ "Content-Type": "application/json; charset=utf-8",
32
+ "Cache-Control": "no-cache",
33
+ });
34
+ res.end(JSON.stringify(data));
35
+ }
36
+ export function handleRequest(req, res, db) {
37
+ if (req.method !== "GET" && req.method !== "HEAD") {
38
+ sendJson(res, 405, { error: "Method not allowed" });
39
+ return;
40
+ }
41
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
42
+ const pathname = reqUrl.pathname;
43
+ const query = parseQueryParams(reqUrl);
44
+ if (pathname === "/" || pathname === "/index.html") {
45
+ res.writeHead(200, {
46
+ "Content-Type": "text/html; charset=utf-8",
47
+ "Cache-Control": "no-cache",
48
+ });
49
+ res.end(getWebUIHTML());
50
+ return;
51
+ }
52
+ if (pathname === "/api/projects") {
53
+ const projects = listProjects(db, { query: query.query });
54
+ sendJson(res, 200, projects);
55
+ return;
56
+ }
57
+ if (pathname.startsWith("/api/projects/")) {
58
+ const slug = decodeURIComponent(pathname.slice("/api/projects/".length));
59
+ const projects = listProjects(db);
60
+ const target = projects.find((p) => p.project === slug);
61
+ if (!target) {
62
+ sendJson(res, 404, { error: "Project not found" });
63
+ }
64
+ else {
65
+ sendJson(res, 200, target);
66
+ }
67
+ return;
68
+ }
69
+ if (pathname === "/api/history") {
70
+ const project = query.project;
71
+ const since = query.since;
72
+ const until = query.until;
73
+ const rawLimit = Number(query.limit ?? 100);
74
+ const rawOffset = Number(query.offset ?? 0);
75
+ const entries = history(db, project, {
76
+ since,
77
+ until,
78
+ limit: Number.isInteger(rawLimit) && rawLimit >= 0 ? rawLimit : 100,
79
+ offset: Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0,
80
+ });
81
+ sendJson(res, 200, entries);
82
+ return;
83
+ }
84
+ if (pathname === "/api/stats") {
85
+ const projects = listProjects(db);
86
+ const totalEntries = countEntries(db);
87
+ const statusCounts = {};
88
+ for (const proj of projects) {
89
+ const st = proj.latestStatus?.status || "none";
90
+ statusCounts[st] = (statusCounts[st] || 0) + 1;
91
+ }
92
+ sendJson(res, 200, {
93
+ totalProjects: projects.length,
94
+ totalEntries,
95
+ statusCounts,
96
+ });
97
+ return;
98
+ }
99
+ sendJson(res, 404, { error: "Not found" });
100
+ }
101
+ export function startServer(options = {}) {
102
+ const host = options.host ?? "127.0.0.1";
103
+ const port = options.port ??
104
+ (process.env.PORT ? Number(process.env.PORT) : /* c8 ignore next */ 3000);
105
+ const db = options.db || openLedger();
106
+ const shouldOpen = options.open ?? true;
107
+ const server = createServer((req, res) => {
108
+ try {
109
+ handleRequest(req, res, db);
110
+ }
111
+ catch (err) {
112
+ sendJson(res, 500, {
113
+ error: err instanceof Error ? err.message : String(err),
114
+ });
115
+ }
116
+ });
117
+ return new Promise((resolve, reject) => {
118
+ server.once("error", reject);
119
+ server.listen(port, host, () => {
120
+ /* c8 ignore start -- server.address() ternary branch is Node network adapter glue */
121
+ const address = server.address();
122
+ const actualPort = typeof address === "object" && address !== null ? address.port : port;
123
+ /* c8 ignore stop */
124
+ const url = `http://${host}:${actualPort}`;
125
+ if (shouldOpen) {
126
+ openBrowser(url);
127
+ }
128
+ resolve({
129
+ server,
130
+ url,
131
+ port: actualPort,
132
+ host,
133
+ close: () => new Promise((resClose, rejClose) => {
134
+ server.close((err) => {
135
+ if (!options.db) {
136
+ db.close();
137
+ }
138
+ if (err)
139
+ rejClose(err);
140
+ else
141
+ resClose();
142
+ });
143
+ }),
144
+ });
145
+ });
146
+ });
147
+ }
@@ -0,0 +1 @@
1
+ export declare function getWebUIHTML(): string;