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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "allow": ["Bash(pnpm test *)", "Bash(pnpm tsc *)"]
4
+ }
5
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-08-07
4
+
5
+ ### Added
6
+
7
+ - Added local HTTP Web Server & SPA Dashboard via `pmgr serve` (`GET /`, REST API `/api/projects`, `/api/history`, `/api/stats`).
8
+ - Added Activity Feed date filtering with native Date Picker (defaulting to Today's local date).
9
+ - Added Activity Feed Grouping Mode toggle: Grouped by Project (Default) vs Flat List.
10
+ - Made `--status` parameter in `pmgr add` optional (defaults to `completed`, with `done` as a normalized alias).
11
+ - Added `countEntries` query for accurate database metrics.
12
+ - Fixed XSS vulnerability in Web UI event handling via HTML5 data attribute event delegation.
13
+ - Added descending project sorting by latest update timestamp.
14
+
3
15
  ## 0.1.7 - 2026-08-07
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -171,11 +171,12 @@ pnpm start at \
171
171
  `add` requires:
172
172
 
173
173
  - `--project`: Unique project name. It may contain English letters, numbers, `.`, `_`, and `-`, and must start with a letter or number.
174
- - `--status`: One of `idea`, `planned`, `active`, `blocked`, `paused`, `completed`, or `archived`.
175
174
  - `--summary`: A summary of this status.
176
175
 
177
176
  Optional arguments:
178
177
 
178
+ - `--status`: One of `idea`, `planned`, `active`, `blocked`, `paused`, `completed`, or `archived` (`done` is accepted as an alias for `completed`). Defaults to `completed` if omitted.
179
+
179
180
  - `--description`: Project introduction or description (updates project metadata).
180
181
  - `--notes`: Project notes/remarks (updates project metadata).
181
182
  - `--progress`: Work already completed.
@@ -236,6 +237,32 @@ pnpm start import --file statuses.jsonl --json
236
237
 
237
238
  Unknown fields, unknown CLI options, extra positional arguments, invalid statuses, and timestamps without a timezone are rejected.
238
239
 
240
+ ## Local Web Dashboard
241
+
242
+ Launch a local HTTP server to view project overviews and project activity history in your browser:
243
+
244
+ ```bash
245
+ pmgr serve
246
+ ```
247
+
248
+ By default, `pmgr serve` listens on `http://127.0.0.1:3000` and automatically opens the web dashboard in your default browser.
249
+
250
+ Options:
251
+
252
+ - `--port`: HTTP server port (default: `3000` or `PORT` environment variable).
253
+ - `--host`: Host interface to bind (default: `127.0.0.1`).
254
+ - `--no-open`: Do not automatically open the web browser.
255
+ - `--json`: Output server startup details in JSON format.
256
+
257
+ ### REST API Endpoints
258
+
259
+ The server also exposes JSON API endpoints:
260
+
261
+ - `GET /api/projects`: List project overviews (supports `?query=...`).
262
+ - `GET /api/projects/:slug`: Detailed metadata and latest status of a specific project.
263
+ - `GET /api/history`: Timeline activity log across all or specific projects (supports `?project=...`, `?since=...`, `?until=...`, `?limit=...`, `?offset=...`).
264
+ - `GET /api/stats`: Overview metrics (total projects count, status breakdowns, total activity entries count).
265
+
239
266
  ## Query and export
240
267
 
241
268
  | Command | Purpose | Key options |
@@ -246,8 +273,9 @@ Unknown fields, unknown CLI options, extra positional arguments, invalid statuse
246
273
  | `history` | History for all projects or one project, newest first | `--project`, `--since`, `--until`, `--limit`, `--offset`, `--json` |
247
274
  | `at` | The latest status at or before a specified time | `--project`, `--at`, `--json` |
248
275
  | `export` | Export all records or one project's records | `--project`, `--format json\|jsonl` |
276
+ | `serve` | Start local HTTP server & interactive web dashboard | `--port`, `--host`, `--open`, `--no-open`, `--json` |
249
277
 
250
- All actions can also use the compatible `status <action>` form, for example `pmgr status projects`. Human-readable multi-record results list projects and their latest status, or `No entries found.` / `No projects found.` when empty. `add`, `import`, `current`, `projects`, `list`, `set-project`, `history`, and `at` support `--json`; `export` uses `--format` to select its format.
278
+ All actions can also use the compatible `status <action>` form, for example `pmgr status projects`. Human-readable multi-record results list projects and their latest status, or `No entries found.` / `No projects found.` when empty. `add`, `import`, `current`, `projects`, `list`, `set-project`, `history`, `at`, and `serve` support `--json`; `export` uses `--format` to select its format.
251
279
 
252
280
  Exports omit the database-internal `id` and `recordedAt` fields and can be imported directly. Records are exported from oldest to newest so that restoring them preserves the insertion order of records with the same timestamp:
253
281
 
package/README_CN.md CHANGED
@@ -168,14 +168,15 @@ pnpm start at \
168
168
 
169
169
  ## 状态写入
170
170
 
171
- `add` 必须提供:
171
+ `add` 必需参数:
172
172
 
173
- - `--project`:项目唯一名称,只能使用英文字母、数字、`.`、`_`、`-`,并以字母或数字开头。
174
- - `--status`:`idea`、`planned`、`active`、`blocked`、`paused`、`completed` 或 `archived`。
175
- - `--summary`:本次状态摘要。
173
+ - `--project`:唯一的项目名称。可包含英文字母、数字、`.`、`_` 和 `-`,且必须以字母或数字开头。
174
+ - `--summary`:本次动态的摘要。
176
175
 
177
176
  可选参数:
178
177
 
178
+ - `--status`:`idea`、`planned`、`active`、`blocked`、`paused`、`completed`、`archived` 之一(`done` 可作为 `completed` 的别名)。若省略则默认值为 `completed`。
179
+
179
180
  - `--description`:项目简介/描述(更新项目元数据)。
180
181
  - `--notes`:项目备注(更新项目元数据)。
181
182
  - `--progress`:已经完成的工作。
@@ -236,18 +237,45 @@ pnpm start import --file statuses.jsonl --json
236
237
 
237
238
  未知字段、未知 CLI 参数、多余位置参数、非法状态和无时区时间都会被拒绝。
238
239
 
239
- ## 查询与导出
240
+ ## 本地 Web 画布 (Web Server)
241
+
242
+ 启动本地 HTTP 服务器,在浏览器中可视化查看项目概览与项目动态(Timeline 演进):
243
+
244
+ ```bash
245
+ pmgr serve
246
+ ```
247
+
248
+ 默认情况下,`pmgr serve` 监听 `http://127.0.0.1:3000` 并自动在默认浏览器中打开页面。
240
249
 
241
- | 命令 | 用途 | 主要选项 |
242
- | ------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
243
- | `projects` | 列出所有项目或按关键词模糊匹配搜索项目 | `--query`、`--json` |
244
- | `set-project` | 设置/更新/清空项目的显示名称、简介与备注 | `--project`、`--display-name`、`--description`、`--notes`、`--clear-description`、`--clear-notes`、`--json` |
245
- | `current` | 所有项目或单项目的当前状态 | `--project`、`--json` |
246
- | `history` | 全部或单个项目的历史,最新记录在前 | `--project`、`--since`、`--until`、`--limit`、`--offset`、`--json` |
247
- | `at` | 指定时间点及之前的最后状态 | `--project`、`--at`、`--json` |
248
- | `export` | 导出全部或单项目记录 | `--project`、`--format json\|jsonl` |
250
+ 可选项:
251
+
252
+ - `--port`:HTTP 服务器端口(默认 `3000` 或环境变量 `PORT`)。
253
+ - `--host`:绑定主机地址(默认 `127.0.0.1`)。
254
+ - `--no-open`:禁止自动打开浏览器。
255
+ - `--json`:以 JSON 格式输出服务器启动信息。
256
+
257
+ ### REST API 接口
258
+
259
+ 服务器同时暴露以下 JSON API 接口:
260
+
261
+ - `GET /api/projects`:列出项目概览(支持 `?query=...` 检索)。
262
+ - `GET /api/projects/:slug`:查看指定项目的元数据与最新状态。
263
+ - `GET /api/history`:查看全量或指定项目的状态动态演进(支持 `?project=...`, `?since=...`, `?until=...`, `?limit=...`, `?offset=...`)。
264
+ - `GET /api/stats`:查看整体统计指标(项目总数、不同状态分布、动态总条数)。
265
+
266
+ ## 查询与导出
249
267
 
250
- 全部 action 仍可使用 `status <action>` 的兼容形式,例如 `pmgr status projects`。人类可读的多条结果按项目及其最新状态输出;为空时输出 `No entries found.` 或 `No projects found.`。`add`、`import`、`current`、`projects`、`list`、`set-project`、`history`、`at` 支持 `--json`;`export` 由 `--format` 控制格式。
268
+ | 命令 | 用途 | 主要选项 |
269
+ | ------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
270
+ | `projects` | 列出所有项目或按关键词模糊匹配搜索项目 | `--query`、`--json` |
271
+ | `set-project` | 设置/更新/清空项目的显示名称、简介与备注 | `--project`、`--display-name`、`--description`、`--notes`、`--clear-description`、`--clear-notes`、`--json` |
272
+ | `current` | 所有项目或单项目的当前状态 | `--project`、`--json` |
273
+ | `history` | 全部或单个项目的历史,最新记录在前 | `--project`、`--since`、`--until`、`--limit`、`--offset`、`--json` |
274
+ | `at` | 指定时间点及之前的最后状态 | `--project`、`--at`、`--json` |
275
+ | `export` | 导出全部或单项目记录 | `--project`、`--format json\|jsonl` |
276
+ | `serve` | 启动本地 HTTP 服务器与 Web 动态可视化画布 | `--port`、`--host`、`--open`、`--no-open`、`--json` |
277
+
278
+ 全部 action 仍可使用 `status <action>` 的兼容形式,例如 `pmgr status projects`。人类可读的多条结果按项目及其最新状态输出;为空时输出 `No entries found.` 或 `No projects found.`。`add`、`import`、`current`、`projects`、`list`、`set-project`、`history`、`at`、`serve` 支持 `--json`;`export` 由 `--format` 控制格式。
251
279
 
252
280
  导出内容不包含数据库内部的 `id` 和 `recordedAt`,可以直接重新导入。记录按时间从旧到新导出,从而在恢复时保留相同时间戳记录的先后关系:
253
281
 
package/dist/cli.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  export declare function run(argv: string[], write?: {
3
3
  (buffer: Uint8Array | string, cb?: (err?: Error | null) => void): boolean;
4
4
  (str: Uint8Array | string, encoding?: BufferEncoding, cb?: (err?: Error | null) => void): boolean;
5
- }): void;
5
+ }): unknown;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { basename } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { addStatus, at, current, history, importStatuses, listProjects, openLedger, parseTime, setProjectMetadata, toPortableStatusEntry, ValidationError, } from "./index.js";
5
+ import { addStatus, at, current, history, importStatuses, listProjects, openLedger, parseTime, setProjectMetadata, startServer, toPortableStatusEntry, ValidationError, } from "./index.js";
6
6
  const repeats = new Set(["next", "blocker", "decision", "tag"]);
7
7
  function parse(argv) {
8
8
  const command = [];
@@ -16,7 +16,9 @@ function parse(argv) {
16
16
  const key = token.slice(2);
17
17
  if (key === "json" ||
18
18
  key === "clear-description" ||
19
- key === "clear-notes") {
19
+ key === "clear-notes" ||
20
+ key === "open" ||
21
+ key === "no-open") {
20
22
  if (flags[key] !== undefined)
21
23
  throw new ValidationError(`--${key} may only be supplied once`);
22
24
  flags[key] = true;
@@ -76,13 +78,14 @@ const allowedFlags = new Map([
76
78
  ],
77
79
  ["at", new Set(["project", "at", "json"])],
78
80
  ["export", new Set(["project", "format"])],
81
+ ["serve", new Set(["port", "host", "open", "no-open", "json"])],
79
82
  ]);
80
83
  function validateInvocation(command, flags) {
81
84
  const statusCommand = command[0] === "status";
82
85
  const action = command[statusCommand ? 1 : 0];
83
86
  const allowed = typeof action === "string" ? allowedFlags.get(action) : undefined;
84
87
  if (typeof action !== "string" || !allowed)
85
- throw new ValidationError("usage: pmgr <add|import|current|projects|list|set-project|history|at|export> (or: pmgr status <action>)");
88
+ throw new ValidationError("usage: pmgr <add|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)");
86
89
  const positional = command.slice(statusCommand ? 2 : 1);
87
90
  if (action === "import") {
88
91
  if (positional.length > 1)
@@ -190,7 +193,7 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
190
193
  }
191
194
  output(addStatus(db, {
192
195
  project,
193
- status: stringFlag(flags, "status", true),
196
+ status: stringFlag(flags, "status"),
194
197
  summary: stringFlag(flags, "summary", true),
195
198
  progress: stringFlag(flags, "progress"),
196
199
  blockers: listFlag(flags, "blocker"),
@@ -273,17 +276,35 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
273
276
  throw new ValidationError("--format must be json or jsonl");
274
277
  break;
275
278
  }
279
+ case "serve": {
280
+ const port = flags.port !== undefined
281
+ ? numberFlag(flags, "port", 3000)
282
+ : undefined;
283
+ const host = stringFlag(flags, "host");
284
+ const open = flags["no-open"] === true ? false : true;
285
+ return startServer({ port, host, open, db }).then((instance) => {
286
+ if (json) {
287
+ write(`${JSON.stringify({ url: instance.url, port: instance.port, host: instance.host })}\n`);
288
+ }
289
+ else {
290
+ write(`Server running at ${instance.url}\n`);
291
+ }
292
+ return instance;
293
+ });
294
+ }
276
295
  }
277
296
  }
278
297
  finally {
279
- db.close();
298
+ if (action !== "serve") {
299
+ db.close();
300
+ }
280
301
  }
281
302
  }
282
303
  /* c8 ignore start -- process entry glue is covered by the CLI integration tests through run(). */
283
304
  if (process.argv[1] &&
284
305
  import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
285
306
  try {
286
- run(process.argv.slice(2));
307
+ await run(process.argv.slice(2));
287
308
  }
288
309
  catch (error) {
289
310
  console.error(error instanceof Error ? error.message : String(error));
package/dist/db.d.ts CHANGED
@@ -22,3 +22,4 @@ export declare function importStatuses(db: Ledger, values: unknown[]): InsertRes
22
22
  export declare function current(db: Ledger, project?: string): StatusEntry[];
23
23
  export declare function history(db: Ledger, project?: string, options?: HistoryOptions): StatusEntry[];
24
24
  export declare function at(db: Ledger, project: string, occurredAt: string): StatusEntry | undefined;
25
+ export declare function countEntries(db: Ledger, project?: string): number;
package/dist/db.js CHANGED
@@ -142,6 +142,14 @@ export function listProjects(db, options = {}) {
142
142
  ...(latestStatus ? { latestStatus } : {}),
143
143
  };
144
144
  });
145
+ overviews.sort((a, b) => {
146
+ const timeA = a.latestStatus?.occurredAt ?? a.createdAt;
147
+ const timeB = b.latestStatus?.occurredAt ?? b.createdAt;
148
+ if (timeA !== timeB) {
149
+ return timeB.localeCompare(timeA);
150
+ }
151
+ return a.project.localeCompare(b.project);
152
+ });
145
153
  if (!query)
146
154
  return overviews;
147
155
  return overviews.filter((overview) => {
@@ -154,13 +162,12 @@ export function listProjects(db, options = {}) {
154
162
  if (overview.notes?.toLowerCase().includes(query))
155
163
  return true;
156
164
  if (overview.latestStatus) {
157
- if (overview.latestStatus.status.toLowerCase().includes(query))
158
- return true;
159
- if (overview.latestStatus.summary.toLowerCase().includes(query))
160
- return true;
161
- if (overview.latestStatus.progress?.toLowerCase().includes(query))
162
- return true;
163
- if (overview.latestStatus.tags.some((t) => t.toLowerCase().includes(query)))
165
+ const st = overview.latestStatus;
166
+ const match = st.status.toLowerCase().includes(query) ||
167
+ st.summary.toLowerCase().includes(query) ||
168
+ (st.progress && st.progress.toLowerCase().includes(query)) ||
169
+ st.tags.some((t) => t.toLowerCase().includes(query));
170
+ if (match)
164
171
  return true;
165
172
  }
166
173
  return false;
@@ -229,3 +236,15 @@ export function history(db, project, options = {}) {
229
236
  export function at(db, project, occurredAt) {
230
237
  return one(db, `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`, project, occurredAt);
231
238
  }
239
+ export function countEntries(db, project) {
240
+ if (project) {
241
+ const row = db
242
+ .prepare("SELECT COUNT(*) AS count FROM status_entries e JOIN projects p ON p.id = e.project_id WHERE p.slug = ?")
243
+ .get(project);
244
+ return row.count;
245
+ }
246
+ const row = db
247
+ .prepare("SELECT COUNT(*) AS count FROM status_entries")
248
+ .get();
249
+ return row.count;
250
+ }
package/dist/domain.js CHANGED
@@ -103,7 +103,15 @@ export function validateStatusEntry(value, defaultSource = "human") {
103
103
  const project = text(input.project, "project", true);
104
104
  if (!projectName.test(project))
105
105
  throw new ValidationError("project must use letters, numbers, dots, underscores, or hyphens");
106
- 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))
107
115
  throw new ValidationError("status is invalid");
108
116
  const source = input.source === undefined ? defaultSource : input.source;
109
117
  if (!sources.includes(source))
@@ -113,7 +121,7 @@ export function validateStatusEntry(value, defaultSource = "human") {
113
121
  throw new ValidationError("externalRef must be at most 200 characters");
114
122
  return {
115
123
  project,
116
- status: input.status,
124
+ status: statusInput,
117
125
  summary: text(input.summary, "summary", true),
118
126
  progress: text(input.progress, "progress"),
119
127
  blockers: textList(input.blockers, "blockers"),
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;