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/.claude/settings.local.json +5 -0
- package/CHANGELOG.md +19 -0
- package/README.md +64 -9
- package/README_CN.md +67 -12
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +109 -8
- package/dist/db.d.ts +6 -1
- package/dist/db.js +122 -4
- package/dist/domain.d.ts +17 -0
- package/dist/domain.js +56 -7
- 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/docs/architecture.md +3 -3
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +14 -4
- package/src/cli.ts +135 -8
- package/src/db.ts +158 -6
- package/src/domain.ts +80 -7
- package/src/index.ts +3 -0
- package/src/server.ts +202 -0
- package/src/web/ui.ts +977 -0
- package/test/core.test.ts +274 -4
- package/test/server.test.ts +354 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.1.7 - 2026-08-07
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added project metadata management (`displayName`, `description`, `notes`) and fuzzy search via `projects` / `list --query`.
|
|
20
|
+
- Added explicit field clearing for project metadata via `--clear-description` and `--clear-notes` flags in `pmgr set-project`.
|
|
21
|
+
|
|
3
22
|
## 0.1.6 - 2026-08-03
|
|
4
23
|
|
|
5
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -171,11 +171,14 @@ 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
|
+
|
|
180
|
+
- `--description`: Project introduction or description (updates project metadata).
|
|
181
|
+
- `--notes`: Project notes/remarks (updates project metadata).
|
|
179
182
|
- `--progress`: Work already completed.
|
|
180
183
|
- `--blocker`, `--next`, `--decision`, and `--tag`: May be specified repeatedly.
|
|
181
184
|
- `--at`: When the status actually occurred. It must be an ISO 8601 timestamp with a timezone; defaults to the current time.
|
|
@@ -183,6 +186,29 @@ Optional arguments:
|
|
|
183
186
|
- `--external-ref`: A globally idempotent key. A duplicate write to the same project returns the existing record; use by another project is an error.
|
|
184
187
|
- `--json`: Emit stable JSON.
|
|
185
188
|
|
|
189
|
+
### Project Metadata & Fuzzy Search
|
|
190
|
+
|
|
191
|
+
Set or update a project's display name, description, and notes with `set-project`, or clear metadata using `--clear-description` / `--clear-notes`:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pnpm start set-project \
|
|
195
|
+
--project project-mgr \
|
|
196
|
+
--display-name "Project Manager" \
|
|
197
|
+
--description "Local-first append-only project status ledger" \
|
|
198
|
+
--notes "Core CLI and Agent Skill are published"
|
|
199
|
+
|
|
200
|
+
# Clear description or notes
|
|
201
|
+
pnpm start set-project --project project-mgr --clear-description --clear-notes
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
List projects or search projects using fuzzy matching across slug, display name, description, notes, latest status summary, and tags with `projects` (or `list`):
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pnpm start projects
|
|
208
|
+
pnpm start list --query "ledger"
|
|
209
|
+
pnpm start projects --query "mvp" --json
|
|
210
|
+
```
|
|
211
|
+
|
|
186
212
|
## Bulk import
|
|
187
213
|
|
|
188
214
|
`import` accepts a JSON object, a JSON array, or a JSONL file containing one object per line. Every record is validated before the whole batch is written in one SQLite transaction; any failure rolls back the entire batch.
|
|
@@ -211,16 +237,45 @@ pnpm start import --file statuses.jsonl --json
|
|
|
211
237
|
|
|
212
238
|
Unknown fields, unknown CLI options, extra positional arguments, invalid statuses, and timestamps without a timezone are rejected.
|
|
213
239
|
|
|
214
|
-
##
|
|
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.
|
|
215
249
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
|
|
266
|
+
## Query and export
|
|
222
267
|
|
|
223
|
-
|
|
268
|
+
| Command | Purpose | Key options |
|
|
269
|
+
| ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
270
|
+
| `projects` | List all projects or fuzzy search by query | `--query`, `--json` |
|
|
271
|
+
| `set-project` | Set/update project display name, description, & notes | `--project`, `--display-name`, `--description`, `--notes`, `--clear-description`, `--clear-notes`, `--json` |
|
|
272
|
+
| `current` | Current status for all projects or one project | `--project`, `--json` |
|
|
273
|
+
| `history` | History for all projects or one project, newest first | `--project`, `--since`, `--until`, `--limit`, `--offset`, `--json` |
|
|
274
|
+
| `at` | The latest status at or before a specified time | `--project`, `--at`, `--json` |
|
|
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` |
|
|
277
|
+
|
|
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.
|
|
224
279
|
|
|
225
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:
|
|
226
281
|
|
package/README_CN.md
CHANGED
|
@@ -168,14 +168,17 @@ 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
|
+
|
|
180
|
+
- `--description`:项目简介/描述(更新项目元数据)。
|
|
181
|
+
- `--notes`:项目备注(更新项目元数据)。
|
|
179
182
|
- `--progress`:已经完成的工作。
|
|
180
183
|
- `--blocker`、`--next`、`--decision`、`--tag`:可以重复传入。
|
|
181
184
|
- `--at`:状态实际发生时间,必须是带时区的 ISO 8601 时间;缺省为当前时间。
|
|
@@ -183,6 +186,29 @@ pnpm start at \
|
|
|
183
186
|
- `--external-ref`:全局幂等键。重复写入同一项目时返回原记录;若被其他项目占用则报错。
|
|
184
187
|
- `--json`:输出稳定 JSON。
|
|
185
188
|
|
|
189
|
+
## 项目元数据与模糊查询
|
|
190
|
+
|
|
191
|
+
可通过 `set-project` 设置或更新项目的名称、简介与备注,或使用 `--clear-description` / `--clear-notes` 清空:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pnpm start set-project \
|
|
195
|
+
--project project-mgr \
|
|
196
|
+
--display-name "Project Manager" \
|
|
197
|
+
--description "本地优先、只追加的项目状态账本" \
|
|
198
|
+
--notes "核心 CLI 与 Agent Skill 已上线"
|
|
199
|
+
|
|
200
|
+
# 清空简介或备注
|
|
201
|
+
pnpm start set-project --project project-mgr --clear-description --clear-notes
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
可通过 `projects`(或 `list`)列出所有项目或使用 `--query` 进行模糊匹配搜索(匹配项目名称 slug、显示名称、简介、备注、最新状态摘要及标签):
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pnpm start projects
|
|
208
|
+
pnpm start list --query "账本"
|
|
209
|
+
pnpm start projects --query "mvp" --json
|
|
210
|
+
```
|
|
211
|
+
|
|
186
212
|
## 批量导入
|
|
187
213
|
|
|
188
214
|
`import` 接受一个 JSON 对象、JSON 数组,或每行一个对象的 JSONL 文件。所有记录先校验,再在同一个 SQLite 事务中写入;任一记录失败时整批回滚。
|
|
@@ -211,16 +237,45 @@ pnpm start import --file statuses.jsonl --json
|
|
|
211
237
|
|
|
212
238
|
未知字段、未知 CLI 参数、多余位置参数、非法状态和无时区时间都会被拒绝。
|
|
213
239
|
|
|
214
|
-
##
|
|
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` 并自动在默认浏览器中打开页面。
|
|
215
249
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
## 查询与导出
|
|
222
267
|
|
|
223
|
-
|
|
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` 控制格式。
|
|
224
279
|
|
|
225
280
|
导出内容不包含数据库内部的 `id` 和 `recordedAt`,可以直接重新导入。记录按时间从旧到新导出,从而在恢复时保留相同时间戳记录的先后关系:
|
|
226
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, openLedger, parseTime, 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 = [];
|
|
@@ -14,7 +14,11 @@ function parse(argv) {
|
|
|
14
14
|
continue;
|
|
15
15
|
}
|
|
16
16
|
const key = token.slice(2);
|
|
17
|
-
if (key === "json"
|
|
17
|
+
if (key === "json" ||
|
|
18
|
+
key === "clear-description" ||
|
|
19
|
+
key === "clear-notes" ||
|
|
20
|
+
key === "open" ||
|
|
21
|
+
key === "no-open") {
|
|
18
22
|
if (flags[key] !== undefined)
|
|
19
23
|
throw new ValidationError(`--${key} may only be supplied once`);
|
|
20
24
|
flags[key] = true;
|
|
@@ -39,6 +43,8 @@ const allowedFlags = new Map([
|
|
|
39
43
|
"project",
|
|
40
44
|
"status",
|
|
41
45
|
"summary",
|
|
46
|
+
"description",
|
|
47
|
+
"notes",
|
|
42
48
|
"progress",
|
|
43
49
|
"blocker",
|
|
44
50
|
"next",
|
|
@@ -52,19 +58,34 @@ const allowedFlags = new Map([
|
|
|
52
58
|
],
|
|
53
59
|
["import", new Set(["file", "json"])],
|
|
54
60
|
["current", new Set(["project", "json"])],
|
|
61
|
+
["projects", new Set(["query", "json"])],
|
|
62
|
+
["list", new Set(["query", "json"])],
|
|
63
|
+
[
|
|
64
|
+
"set-project",
|
|
65
|
+
new Set([
|
|
66
|
+
"project",
|
|
67
|
+
"display-name",
|
|
68
|
+
"description",
|
|
69
|
+
"notes",
|
|
70
|
+
"clear-description",
|
|
71
|
+
"clear-notes",
|
|
72
|
+
"json",
|
|
73
|
+
]),
|
|
74
|
+
],
|
|
55
75
|
[
|
|
56
76
|
"history",
|
|
57
77
|
new Set(["project", "since", "until", "limit", "offset", "json"]),
|
|
58
78
|
],
|
|
59
79
|
["at", new Set(["project", "at", "json"])],
|
|
60
80
|
["export", new Set(["project", "format"])],
|
|
81
|
+
["serve", new Set(["port", "host", "open", "no-open", "json"])],
|
|
61
82
|
]);
|
|
62
83
|
function validateInvocation(command, flags) {
|
|
63
84
|
const statusCommand = command[0] === "status";
|
|
64
85
|
const action = command[statusCommand ? 1 : 0];
|
|
65
86
|
const allowed = typeof action === "string" ? allowedFlags.get(action) : undefined;
|
|
66
87
|
if (typeof action !== "string" || !allowed)
|
|
67
|
-
throw new ValidationError("usage: pmgr <add|import|current|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>)");
|
|
68
89
|
const positional = command.slice(statusCommand ? 2 : 1);
|
|
69
90
|
if (action === "import") {
|
|
70
91
|
if (positional.length > 1)
|
|
@@ -110,6 +131,28 @@ function output(value, json, write) {
|
|
|
110
131
|
else
|
|
111
132
|
write(`${JSON.stringify(value, null, 2)}\n`);
|
|
112
133
|
}
|
|
134
|
+
function formatProjectOverview(item) {
|
|
135
|
+
const lines = [`${item.project}\t${item.displayName}`];
|
|
136
|
+
if (item.description)
|
|
137
|
+
lines.push(` Description: ${item.description}`);
|
|
138
|
+
if (item.notes)
|
|
139
|
+
lines.push(` Notes: ${item.notes}`);
|
|
140
|
+
if (item.latestStatus) {
|
|
141
|
+
lines.push(` Latest Status: ${item.latestStatus.status}\t${item.latestStatus.occurredAt}\t${item.latestStatus.summary}`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
lines.push(` Latest Status: (none)`);
|
|
145
|
+
}
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
function outputProjects(items, json, write) {
|
|
149
|
+
if (json)
|
|
150
|
+
write(`${JSON.stringify(items)}\n`);
|
|
151
|
+
else if (items.length === 0)
|
|
152
|
+
write("No projects found.\n");
|
|
153
|
+
else
|
|
154
|
+
write(`${items.map(formatProjectOverview).join("\n")}\n`);
|
|
155
|
+
}
|
|
113
156
|
function imported(file) {
|
|
114
157
|
if (!file)
|
|
115
158
|
throw new ValidationError("import file is required");
|
|
@@ -141,10 +184,16 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
141
184
|
const json = flags.json === true;
|
|
142
185
|
try {
|
|
143
186
|
switch (action) {
|
|
144
|
-
case "add":
|
|
187
|
+
case "add": {
|
|
188
|
+
const project = stringFlag(flags, "project", true);
|
|
189
|
+
const description = stringFlag(flags, "description");
|
|
190
|
+
const notes = stringFlag(flags, "notes");
|
|
191
|
+
if (description !== undefined || notes !== undefined) {
|
|
192
|
+
setProjectMetadata(db, { project, description, notes });
|
|
193
|
+
}
|
|
145
194
|
output(addStatus(db, {
|
|
146
|
-
project
|
|
147
|
-
status: stringFlag(flags, "status"
|
|
195
|
+
project,
|
|
196
|
+
status: stringFlag(flags, "status"),
|
|
148
197
|
summary: stringFlag(flags, "summary", true),
|
|
149
198
|
progress: stringFlag(flags, "progress"),
|
|
150
199
|
blockers: listFlag(flags, "blocker"),
|
|
@@ -156,6 +205,7 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
156
205
|
externalRef: stringFlag(flags, "external-ref"),
|
|
157
206
|
}).entry, json, write);
|
|
158
207
|
break;
|
|
208
|
+
}
|
|
159
209
|
case "import":
|
|
160
210
|
output(importStatuses(db, imported(stringFlag(flags, "file") ??
|
|
161
211
|
command[command[0] === "status" ? 2 : 1] ??
|
|
@@ -164,6 +214,39 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
164
214
|
case "current":
|
|
165
215
|
output(current(db, stringFlag(flags, "project")), json, write);
|
|
166
216
|
break;
|
|
217
|
+
case "projects":
|
|
218
|
+
case "list":
|
|
219
|
+
outputProjects(listProjects(db, { query: stringFlag(flags, "query") }), json, write);
|
|
220
|
+
break;
|
|
221
|
+
case "set-project": {
|
|
222
|
+
const project = stringFlag(flags, "project", true);
|
|
223
|
+
const displayName = stringFlag(flags, "display-name");
|
|
224
|
+
let description = undefined;
|
|
225
|
+
if (flags["clear-description"] === true) {
|
|
226
|
+
if (flags.description !== undefined)
|
|
227
|
+
throw new ValidationError("cannot specify both --description and --clear-description");
|
|
228
|
+
description = null;
|
|
229
|
+
}
|
|
230
|
+
else if (flags.description !== undefined) {
|
|
231
|
+
description = stringFlag(flags, "description");
|
|
232
|
+
}
|
|
233
|
+
let notes = undefined;
|
|
234
|
+
if (flags["clear-notes"] === true) {
|
|
235
|
+
if (flags.notes !== undefined)
|
|
236
|
+
throw new ValidationError("cannot specify both --notes and --clear-notes");
|
|
237
|
+
notes = null;
|
|
238
|
+
}
|
|
239
|
+
else if (flags.notes !== undefined) {
|
|
240
|
+
notes = stringFlag(flags, "notes");
|
|
241
|
+
}
|
|
242
|
+
output(setProjectMetadata(db, {
|
|
243
|
+
project,
|
|
244
|
+
displayName,
|
|
245
|
+
description,
|
|
246
|
+
notes,
|
|
247
|
+
}), json, write);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
167
250
|
case "history":
|
|
168
251
|
output(history(db, stringFlag(flags, "project"), {
|
|
169
252
|
since: stringFlag(flags, "since") &&
|
|
@@ -193,17 +276,35 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
193
276
|
throw new ValidationError("--format must be json or jsonl");
|
|
194
277
|
break;
|
|
195
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
|
+
}
|
|
196
295
|
}
|
|
197
296
|
}
|
|
198
297
|
finally {
|
|
199
|
-
|
|
298
|
+
if (action !== "serve") {
|
|
299
|
+
db.close();
|
|
300
|
+
}
|
|
200
301
|
}
|
|
201
302
|
}
|
|
202
303
|
/* c8 ignore start -- process entry glue is covered by the CLI integration tests through run(). */
|
|
203
304
|
if (process.argv[1] &&
|
|
204
305
|
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
205
306
|
try {
|
|
206
|
-
run(process.argv.slice(2));
|
|
307
|
+
await run(process.argv.slice(2));
|
|
207
308
|
}
|
|
208
309
|
catch (error) {
|
|
209
310
|
console.error(error instanceof Error ? error.message : String(error));
|
package/dist/db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { type Source, type StatusEntry } from "./domain.js";
|
|
2
|
+
import { type ProjectMetadata, type ProjectOverview, type Source, type StatusEntry } from "./domain.js";
|
|
3
3
|
export type Ledger = Database.Database;
|
|
4
4
|
export type InsertResult = {
|
|
5
5
|
entry: StatusEntry;
|
|
@@ -13,8 +13,13 @@ export type HistoryOptions = {
|
|
|
13
13
|
};
|
|
14
14
|
export declare function databasePath(): string;
|
|
15
15
|
export declare function openLedger(path?: string): Ledger;
|
|
16
|
+
export declare function setProjectMetadata(db: Ledger, value: unknown): ProjectMetadata;
|
|
17
|
+
export declare function listProjects(db: Ledger, options?: {
|
|
18
|
+
query?: string;
|
|
19
|
+
}): ProjectOverview[];
|
|
16
20
|
export declare function addStatus(db: Ledger, value: unknown, defaultSource?: Source): InsertResult;
|
|
17
21
|
export declare function importStatuses(db: Ledger, values: unknown[]): InsertResult[];
|
|
18
22
|
export declare function current(db: Ledger, project?: string): StatusEntry[];
|
|
19
23
|
export declare function history(db: Ledger, project?: string, options?: HistoryOptions): StatusEntry[];
|
|
20
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
|
@@ -2,8 +2,8 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { validateStatusEntry, ValidationError, } from "./domain.js";
|
|
6
|
-
const
|
|
5
|
+
import { validateProjectMetadataInput, validateStatusEntry, ValidationError, } from "./domain.js";
|
|
6
|
+
const migration1 = `
|
|
7
7
|
CREATE TABLE IF NOT EXISTS projects (
|
|
8
8
|
id INTEGER PRIMARY KEY,
|
|
9
9
|
slug TEXT NOT NULL UNIQUE,
|
|
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS status_entries (
|
|
|
27
27
|
);
|
|
28
28
|
CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
|
|
29
29
|
`;
|
|
30
|
+
const migration2 = `
|
|
31
|
+
ALTER TABLE projects ADD COLUMN description TEXT;
|
|
32
|
+
ALTER TABLE projects ADD COLUMN notes TEXT;
|
|
33
|
+
`;
|
|
30
34
|
export function databasePath() {
|
|
31
35
|
return (process.env.PMGR_DB_PATH ||
|
|
32
36
|
resolve(homedir(), ".local", "share", "project-mgr", "project-mgr.sqlite"));
|
|
@@ -39,9 +43,13 @@ export function openLedger(path = databasePath()) {
|
|
|
39
43
|
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)");
|
|
40
44
|
db.transaction(() => {
|
|
41
45
|
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()) {
|
|
42
|
-
db.exec(
|
|
46
|
+
db.exec(migration1);
|
|
43
47
|
db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
|
|
44
48
|
}
|
|
49
|
+
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 2").get()) {
|
|
50
|
+
db.exec(migration2);
|
|
51
|
+
db.prepare("INSERT INTO schema_migrations(version) VALUES (2)").run();
|
|
52
|
+
}
|
|
45
53
|
}).immediate();
|
|
46
54
|
return db;
|
|
47
55
|
}
|
|
@@ -67,6 +75,104 @@ function one(db, sql, ...params) {
|
|
|
67
75
|
const row = db.prepare(sql).get(...params);
|
|
68
76
|
return row && entryFromRow(row);
|
|
69
77
|
}
|
|
78
|
+
function upsertProjectMetadata(db, input) {
|
|
79
|
+
const existing = db
|
|
80
|
+
.prepare("SELECT 1 FROM projects WHERE slug = ?")
|
|
81
|
+
.get(input.project);
|
|
82
|
+
if (!existing) {
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
db.prepare("INSERT INTO projects(slug, display_name, description, notes, created_at) VALUES (?, ?, ?, ?, ?)").run(input.project, input.displayName ?? input.project, input.description ?? null, input.notes ?? null, now);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const updates = [];
|
|
88
|
+
const params = [];
|
|
89
|
+
if (input.displayName !== undefined) {
|
|
90
|
+
updates.push("display_name = ?");
|
|
91
|
+
params.push(input.displayName);
|
|
92
|
+
}
|
|
93
|
+
if (input.description !== undefined) {
|
|
94
|
+
updates.push("description = ?");
|
|
95
|
+
params.push(input.description);
|
|
96
|
+
}
|
|
97
|
+
if (input.notes !== undefined) {
|
|
98
|
+
updates.push("notes = ?");
|
|
99
|
+
params.push(input.notes);
|
|
100
|
+
}
|
|
101
|
+
if (updates.length > 0) {
|
|
102
|
+
params.push(input.project);
|
|
103
|
+
db.prepare(`UPDATE projects SET ${updates.join(", ")} WHERE slug = ?`).run(...params);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function setProjectMetadata(db, value) {
|
|
108
|
+
const input = validateProjectMetadataInput(value);
|
|
109
|
+
return db.transaction(() => {
|
|
110
|
+
upsertProjectMetadata(db, input);
|
|
111
|
+
const row = db
|
|
112
|
+
.prepare("SELECT * FROM projects WHERE slug = ?")
|
|
113
|
+
.get(input.project);
|
|
114
|
+
return {
|
|
115
|
+
project: row.slug,
|
|
116
|
+
displayName: row.display_name,
|
|
117
|
+
...(row.description ? { description: row.description } : {}),
|
|
118
|
+
...(row.notes ? { notes: row.notes } : {}),
|
|
119
|
+
createdAt: row.created_at,
|
|
120
|
+
};
|
|
121
|
+
})();
|
|
122
|
+
}
|
|
123
|
+
export function listProjects(db, options = {}) {
|
|
124
|
+
const query = options.query?.trim().toLowerCase();
|
|
125
|
+
const projects = db
|
|
126
|
+
.prepare("SELECT * FROM projects ORDER BY slug ASC")
|
|
127
|
+
.all();
|
|
128
|
+
const latestStatuses = current(db);
|
|
129
|
+
const statusMap = new Map();
|
|
130
|
+
for (const entry of latestStatuses) {
|
|
131
|
+
statusMap.set(entry.project, entry);
|
|
132
|
+
}
|
|
133
|
+
const overviews = projects.map((row) => {
|
|
134
|
+
const slug = row.slug;
|
|
135
|
+
const latestStatus = statusMap.get(slug);
|
|
136
|
+
return {
|
|
137
|
+
project: slug,
|
|
138
|
+
displayName: row.display_name,
|
|
139
|
+
...(row.description ? { description: row.description } : {}),
|
|
140
|
+
...(row.notes ? { notes: row.notes } : {}),
|
|
141
|
+
createdAt: row.created_at,
|
|
142
|
+
...(latestStatus ? { latestStatus } : {}),
|
|
143
|
+
};
|
|
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
|
+
});
|
|
153
|
+
if (!query)
|
|
154
|
+
return overviews;
|
|
155
|
+
return overviews.filter((overview) => {
|
|
156
|
+
if (overview.project.toLowerCase().includes(query))
|
|
157
|
+
return true;
|
|
158
|
+
if (overview.displayName.toLowerCase().includes(query))
|
|
159
|
+
return true;
|
|
160
|
+
if (overview.description?.toLowerCase().includes(query))
|
|
161
|
+
return true;
|
|
162
|
+
if (overview.notes?.toLowerCase().includes(query))
|
|
163
|
+
return true;
|
|
164
|
+
if (overview.latestStatus) {
|
|
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)
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
70
176
|
export function addStatus(db, value, defaultSource = "human") {
|
|
71
177
|
const input = validateStatusEntry(value, defaultSource);
|
|
72
178
|
return db.transaction(() => addValidatedStatus(db, input))();
|
|
@@ -81,7 +187,7 @@ function addValidatedStatus(db, input) {
|
|
|
81
187
|
}
|
|
82
188
|
}
|
|
83
189
|
const now = new Date().toISOString();
|
|
84
|
-
db
|
|
190
|
+
upsertProjectMetadata(db, { project: input.project });
|
|
85
191
|
const project = db
|
|
86
192
|
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
87
193
|
.get(input.project);
|
|
@@ -130,3 +236,15 @@ export function history(db, project, options = {}) {
|
|
|
130
236
|
export function at(db, project, occurredAt) {
|
|
131
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);
|
|
132
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.d.ts
CHANGED
|
@@ -2,6 +2,22 @@ export declare const statuses: readonly ["idea", "planned", "active", "blocked",
|
|
|
2
2
|
export declare const sources: readonly ["human", "agent", "import"];
|
|
3
3
|
export type ProjectStatus = (typeof statuses)[number];
|
|
4
4
|
export type Source = (typeof sources)[number];
|
|
5
|
+
export type ProjectMetadata = {
|
|
6
|
+
project: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
notes?: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
};
|
|
12
|
+
export type ProjectOverview = ProjectMetadata & {
|
|
13
|
+
latestStatus?: StatusEntry;
|
|
14
|
+
};
|
|
15
|
+
export type ProjectMetadataInput = {
|
|
16
|
+
project: string;
|
|
17
|
+
displayName?: string;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
notes?: string | null;
|
|
20
|
+
};
|
|
5
21
|
export type StatusEntryInput = {
|
|
6
22
|
project: string;
|
|
7
23
|
status: ProjectStatus;
|
|
@@ -26,5 +42,6 @@ export declare class ValidationError extends Error {
|
|
|
26
42
|
constructor(message: string);
|
|
27
43
|
}
|
|
28
44
|
export declare function parseTime(value: unknown, field: string): string;
|
|
45
|
+
export declare function validateProjectMetadataInput(value: unknown): ProjectMetadataInput;
|
|
29
46
|
export declare function validateStatusEntry(value: unknown, defaultSource?: Source): StatusEntryInput & Required<Pick<StatusEntryInput, "occurredAt" | "source">>;
|
|
30
47
|
export declare function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry;
|