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/.claude/settings.local.json +5 -0
- package/CHANGELOG.md +12 -0
- package/README.md +30 -2
- package/README_CN.md +42 -14
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +27 -6
- package/dist/db.d.ts +1 -0
- package/dist/db.js +26 -7
- package/dist/domain.js +10 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +147 -0
- package/dist/web/ui.d.ts +1 -0
- package/dist/web/ui.js +977 -0
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +3 -2
- package/src/cli.ts +32 -6
- package/src/db.ts +31 -10
- package/src/domain.ts +11 -2
- package/src/index.ts +3 -0
- package/src/server.ts +202 -0
- package/src/web/ui.ts +977 -0
- package/test/core.test.ts +46 -7
- package/test/server.test.ts +354 -0
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 `
|
|
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
|
-
- `--
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
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"
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/dist/index.js
CHANGED
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/web/ui.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getWebUIHTML(): string;
|