project-mgr 0.1.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/.env_sample +5 -0
- package/.prettierignore +4 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/README_CN.md +184 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +213 -0
- package/dist/db.d.ts +20 -0
- package/dist/db.js +131 -0
- package/dist/domain.d.ts +30 -0
- package/dist/domain.js +106 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/docs/architecture.md +95 -0
- package/eslint.config.js +12 -0
- package/package.json +38 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/cli.ts +272 -0
- package/src/db.ts +220 -0
- package/src/domain.ts +153 -0
- package/src/index.ts +2 -0
- package/test/core.test.ts +439 -0
- package/tsconfig.json +14 -0
package/.env_sample
ADDED
package/.prettierignore
ADDED
package/AGENTS.md
ADDED
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Approved the required `better-sqlite3` and `esbuild` build scripts for reproducible frozen pnpm installs.
|
|
8
|
+
- Made empty human-readable list queries explicit while preserving `[]` JSON output.
|
|
9
|
+
- Added top-level CLI action shorthand while keeping `status <action>` compatibility.
|
|
10
|
+
|
|
11
|
+
## 0.1.0 - 2026-08-03
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- A local TypeScript CLI and SQLite append-only project status ledger.
|
|
16
|
+
- Single-record, JSON, and JSONL atomic writes; current, historical, and point-in-time queries; and restorable exports.
|
|
17
|
+
- Strict input validation, `externalRef` idempotency, automatic migrations, and deterministic time ordering.
|
|
18
|
+
- `.env_sample` and automatic loading of local `.env` database configuration by the run scripts.
|
|
19
|
+
- English usage documentation and a standalone architecture document.
|
|
20
|
+
- 100% coverage thresholds for statements, branches, functions, and lines.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dean
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Project Manager
|
|
2
|
+
|
|
3
|
+
[Chinese](README_CN.md)
|
|
4
|
+
|
|
5
|
+
`project-mgr` is a local-first, append-only ledger for project status. It stores progress, blockers, decisions, and next steps as timestamped status snapshots, so people and AI agents can quickly inspect each project's current state, complete history, or state at a particular time.
|
|
6
|
+
|
|
7
|
+
The first release provides only a local CLI and SQLite. It has no HTTP API, long-running service, or agent skills. Data stays on the local machine and requires no account or cloud service.
|
|
8
|
+
|
|
9
|
+
## Install and run
|
|
10
|
+
|
|
11
|
+
Requires Node.js 22+ and pnpm 10.30.2.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm install --frozen-lockfile
|
|
15
|
+
cp .env_sample .env
|
|
16
|
+
pnpm build
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`.env_sample` provides examples for all environment settings. The copied `.env` file is ignored by Git. `pnpm start` and `pnpm pmgr` automatically load the root `.env`; existing shell environment variables take precedence.
|
|
20
|
+
|
|
21
|
+
After building, run:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm start current
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
During development, run the TypeScript CLI directly:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm pmgr current
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The default configuration in `.env_sample` is:
|
|
34
|
+
|
|
35
|
+
```dotenv
|
|
36
|
+
PMGR_DB_PATH=./data/project-mgr.sqlite
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Set `PMGR_DB_PATH` in `.env` to use another location, or override it for one command:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
PMGR_DB_PATH=/absolute/path/projects.sqlite pnpm start current
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Relative paths are resolved from the working directory of the command. When `pnpm start ...` runs from this project's root, the default database is `./data/project-mgr.sqlite`. It is created automatically when the first valid command runs.
|
|
46
|
+
|
|
47
|
+
### Use an isolated test database
|
|
48
|
+
|
|
49
|
+
For manual testing, set a persistent path in `.env` that is separate from production data:
|
|
50
|
+
|
|
51
|
+
```dotenv
|
|
52
|
+
PMGR_DB_PATH=./data/manual-test.sqlite
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then run commands normally:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pnpm start add \
|
|
59
|
+
--project test-project \
|
|
60
|
+
--status active \
|
|
61
|
+
--summary "Testing the isolated database"
|
|
62
|
+
|
|
63
|
+
pnpm start history --project test-project
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Project keys must match exactly: a record added to `test-project` is not part of `project-mgr` history. Empty human-readable list queries print `No entries found.`; with `--json`, they print `[]`.
|
|
67
|
+
|
|
68
|
+
`data/` is ignored by Git, so test data is not committed. Delete `data/manual-test.sqlite` when it is no longer needed.
|
|
69
|
+
|
|
70
|
+
Automated tests use an in-memory database or SQLite files in the system temporary directory; they do not read or write the default database. `PMGR_DB_PATH=:memory:` also works for one command, but each CLI command runs in a separate process and its data disappears when the command exits, so it is not suitable for consecutive manual tests.
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
Append a project status:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pnpm start add \
|
|
78
|
+
--project project-mgr \
|
|
79
|
+
--status active \
|
|
80
|
+
--summary "The local status ledger is available" \
|
|
81
|
+
--progress "Completed SQLite and CLI support" \
|
|
82
|
+
--next "Connect a real project" \
|
|
83
|
+
--tag mvp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Query the current status of all projects or one project:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pnpm start current
|
|
90
|
+
pnpm start current --project project-mgr --json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Query history and the status at a specified time:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pnpm start history \
|
|
97
|
+
--project project-mgr \
|
|
98
|
+
--since 2026-08-01T00:00:00Z \
|
|
99
|
+
--limit 20 \
|
|
100
|
+
--offset 0
|
|
101
|
+
|
|
102
|
+
pnpm start at \
|
|
103
|
+
--project project-mgr \
|
|
104
|
+
--at 2026-08-03T12:00:00+08:00 \
|
|
105
|
+
--json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Write status
|
|
109
|
+
|
|
110
|
+
`add` requires:
|
|
111
|
+
|
|
112
|
+
- `--project`: Unique project name. It may contain English letters, numbers, `.`, `_`, and `-`, and must start with a letter or number.
|
|
113
|
+
- `--status`: One of `idea`, `planned`, `active`, `blocked`, `paused`, `completed`, or `archived`.
|
|
114
|
+
- `--summary`: A summary of this status.
|
|
115
|
+
|
|
116
|
+
Optional arguments:
|
|
117
|
+
|
|
118
|
+
- `--progress`: Work already completed.
|
|
119
|
+
- `--blocker`, `--next`, `--decision`, and `--tag`: May be specified repeatedly.
|
|
120
|
+
- `--at`: When the status actually occurred. It must be an ISO 8601 timestamp with a timezone; defaults to the current time.
|
|
121
|
+
- `--source`: `human`, `agent`, or `import`; defaults to `human`.
|
|
122
|
+
- `--external-ref`: A globally idempotent key. A duplicate write to the same project returns the existing record; use by another project is an error.
|
|
123
|
+
- `--json`: Emit stable JSON.
|
|
124
|
+
|
|
125
|
+
## Bulk import
|
|
126
|
+
|
|
127
|
+
`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.
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"project": "project-mgr",
|
|
132
|
+
"status": "active",
|
|
133
|
+
"summary": "Core functionality is complete",
|
|
134
|
+
"progress": "CLI and SQLite have passed tests",
|
|
135
|
+
"blockers": [],
|
|
136
|
+
"nextSteps": ["Connect a real project"],
|
|
137
|
+
"decisions": ["The first release provides only a local CLI"],
|
|
138
|
+
"tags": ["mvp"],
|
|
139
|
+
"occurredAt": "2026-08-03T12:00:00+08:00",
|
|
140
|
+
"source": "human",
|
|
141
|
+
"externalRef": "project-mgr-2026-08-03-core"
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pnpm start import statuses.jsonl
|
|
147
|
+
# Equivalent form
|
|
148
|
+
pnpm start import --file statuses.jsonl --json
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Unknown fields, unknown CLI options, extra positional arguments, invalid statuses, and timestamps without a timezone are rejected.
|
|
152
|
+
|
|
153
|
+
## Query and export
|
|
154
|
+
|
|
155
|
+
| Command | Purpose | Key options |
|
|
156
|
+
| --------- | ----------------------------------------------- | ------------------------------------------------------------------ |
|
|
157
|
+
| `current` | Current status for all projects or one project | `--project`, `--json` |
|
|
158
|
+
| `history` | One project's history, newest first | `--project`, `--since`, `--until`, `--limit`, `--offset`, `--json` |
|
|
159
|
+
| `at` | The latest status at or before a specified time | `--project`, `--at`, `--json` |
|
|
160
|
+
| `export` | Export all records or one project's records | `--project`, `--format json\|jsonl` |
|
|
161
|
+
|
|
162
|
+
All six actions can also use the compatible `status <action>` form, for example `pmgr status export`. Human-readable multi-record results list project, status, occurrence time, and summary, or `No entries found.` when empty. `add`, `import`, `current`, `history`, and `at` support `--json`; `export` uses `--format` to select its format.
|
|
163
|
+
|
|
164
|
+
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:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm start export --format jsonl > project-statuses.jsonl
|
|
168
|
+
|
|
169
|
+
PMGR_DB_PATH=./data/restored.sqlite \
|
|
170
|
+
pnpm start import project-statuses.jsonl
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Development and verification
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pnpm typecheck
|
|
177
|
+
pnpm lint
|
|
178
|
+
pnpm format-check
|
|
179
|
+
pnpm test
|
|
180
|
+
pnpm coverage
|
|
181
|
+
pnpm build
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Coverage thresholds for statements, branches, functions, and lines are all 100%. See the [architecture document](docs/architecture.md) for the detailed design.
|
package/README_CN.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Project Manager
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
`project-mgr` 是一个本地优先、只追加的项目状态账本。它把项目进展、阻塞、决策和下一步保存为带时间的状态快照,让个人或 AI Agent 能快速查询每个项目的当前状态、完整历史以及某个时间点的状态。
|
|
6
|
+
|
|
7
|
+
首版只提供本地 CLI 和 SQLite,不包含 HTTP API、常驻服务或 Agent Skills。数据默认保存在本机,无需账号或云服务。
|
|
8
|
+
|
|
9
|
+
## 安装与运行
|
|
10
|
+
|
|
11
|
+
需要 Node.js 22+ 和 pnpm 10.30.2。
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm install --frozen-lockfile
|
|
15
|
+
cp .env_sample .env
|
|
16
|
+
pnpm build
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`.env_sample` 提供全部环境配置示例;复制得到的 `.env` 已被 Git 忽略。`pnpm start` 和 `pnpm pmgr` 会自动读取项目根目录下的 `.env`,已有的 shell 环境变量优先级更高。
|
|
20
|
+
|
|
21
|
+
构建后通过以下方式运行:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm start current
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
开发时也可以直接运行 TypeScript:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm pmgr current
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
默认配置位于 `.env_sample`:
|
|
34
|
+
|
|
35
|
+
```dotenv
|
|
36
|
+
PMGR_DB_PATH=./data/project-mgr.sqlite
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
通过 `.env` 中的 `PMGR_DB_PATH` 指定其他位置,也可以为单条命令临时覆盖:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
PMGR_DB_PATH=/absolute/path/projects.sqlite pnpm start current
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
路径基于启动命令的当前工作目录计算。从本项目根目录执行 `pnpm start ...` 时,默认位置就是 `./data/project-mgr.sqlite`。数据库会在第一次执行有效命令时自动创建。
|
|
46
|
+
|
|
47
|
+
### 使用独立测试数据库
|
|
48
|
+
|
|
49
|
+
手工测试时,建议在 `.env` 中固定设置一个与正式数据分离的路径:
|
|
50
|
+
|
|
51
|
+
```dotenv
|
|
52
|
+
PMGR_DB_PATH=./data/manual-test.sqlite
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
随后正常执行命令:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pnpm start add \
|
|
59
|
+
--project test-project \
|
|
60
|
+
--status active \
|
|
61
|
+
--summary "测试独立数据库"
|
|
62
|
+
|
|
63
|
+
pnpm start history --project test-project
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
项目键必须完全一致:写入 `test-project` 的记录不会出现在 `project-mgr` 的历史中。人类可读的空列表查询会输出 `No entries found.`;使用 `--json` 时则输出 `[]`。
|
|
67
|
+
|
|
68
|
+
`data/` 已加入 Git ignore,测试数据不会进入版本库。不需要保留时,可以删除 `data/manual-test.sqlite`。
|
|
69
|
+
|
|
70
|
+
自动化测试会使用内存数据库或系统临时目录中的 SQLite 文件,不会读写默认数据库。`PMGR_DB_PATH=:memory:` 也可用于单条命令,但每个 CLI 命令都是独立进程,命令结束后数据就会消失,不适合连续手工测试。
|
|
71
|
+
|
|
72
|
+
## 快速开始
|
|
73
|
+
|
|
74
|
+
追加一条项目状态:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pnpm start add \
|
|
78
|
+
--project project-mgr \
|
|
79
|
+
--status active \
|
|
80
|
+
--summary "本地状态账本已可用" \
|
|
81
|
+
--progress "完成 SQLite 与 CLI" \
|
|
82
|
+
--next "接入真实项目" \
|
|
83
|
+
--tag mvp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
查询所有项目或单个项目的当前状态:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pnpm start current
|
|
90
|
+
pnpm start current --project project-mgr --json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
查询历史与指定时间点状态:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pnpm start history \
|
|
97
|
+
--project project-mgr \
|
|
98
|
+
--since 2026-08-01T00:00:00Z \
|
|
99
|
+
--limit 20 \
|
|
100
|
+
--offset 0
|
|
101
|
+
|
|
102
|
+
pnpm start at \
|
|
103
|
+
--project project-mgr \
|
|
104
|
+
--at 2026-08-03T12:00:00+08:00 \
|
|
105
|
+
--json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 状态写入
|
|
109
|
+
|
|
110
|
+
`add` 必须提供:
|
|
111
|
+
|
|
112
|
+
- `--project`:项目唯一名称,只能使用英文字母、数字、`.`、`_`、`-`,并以字母或数字开头。
|
|
113
|
+
- `--status`:`idea`、`planned`、`active`、`blocked`、`paused`、`completed` 或 `archived`。
|
|
114
|
+
- `--summary`:本次状态摘要。
|
|
115
|
+
|
|
116
|
+
可选参数:
|
|
117
|
+
|
|
118
|
+
- `--progress`:已经完成的工作。
|
|
119
|
+
- `--blocker`、`--next`、`--decision`、`--tag`:可以重复传入。
|
|
120
|
+
- `--at`:状态实际发生时间,必须是带时区的 ISO 8601 时间;缺省为当前时间。
|
|
121
|
+
- `--source`:`human`、`agent` 或 `import`;缺省为 `human`。
|
|
122
|
+
- `--external-ref`:全局幂等键。重复写入同一项目时返回原记录;若被其他项目占用则报错。
|
|
123
|
+
- `--json`:输出稳定 JSON。
|
|
124
|
+
|
|
125
|
+
## 批量导入
|
|
126
|
+
|
|
127
|
+
`import` 接受一个 JSON 对象、JSON 数组,或每行一个对象的 JSONL 文件。所有记录先校验,再在同一个 SQLite 事务中写入;任一记录失败时整批回滚。
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"project": "project-mgr",
|
|
132
|
+
"status": "active",
|
|
133
|
+
"summary": "核心功能实现完成",
|
|
134
|
+
"progress": "CLI 与 SQLite 已通过测试",
|
|
135
|
+
"blockers": [],
|
|
136
|
+
"nextSteps": ["接入真实项目"],
|
|
137
|
+
"decisions": ["首版只做本地 CLI"],
|
|
138
|
+
"tags": ["mvp"],
|
|
139
|
+
"occurredAt": "2026-08-03T12:00:00+08:00",
|
|
140
|
+
"source": "human",
|
|
141
|
+
"externalRef": "project-mgr-2026-08-03-core"
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pnpm start import statuses.jsonl
|
|
147
|
+
# 等价写法
|
|
148
|
+
pnpm start import --file statuses.jsonl --json
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
未知字段、未知 CLI 参数、多余位置参数、非法状态和无时区时间都会被拒绝。
|
|
152
|
+
|
|
153
|
+
## 查询与导出
|
|
154
|
+
|
|
155
|
+
| 命令 | 用途 | 主要选项 |
|
|
156
|
+
| --------- | -------------------------- | ------------------------------------------------------------------ |
|
|
157
|
+
| `current` | 所有项目或单项目的当前状态 | `--project`、`--json` |
|
|
158
|
+
| `history` | 单项目历史,最新记录在前 | `--project`、`--since`、`--until`、`--limit`、`--offset`、`--json` |
|
|
159
|
+
| `at` | 指定时间点及之前的最后状态 | `--project`、`--at`、`--json` |
|
|
160
|
+
| `export` | 导出全部或单项目记录 | `--project`、`--format json\|jsonl` |
|
|
161
|
+
|
|
162
|
+
全部六个 action 仍可使用 `status <action>` 的兼容形式,例如 `pmgr status export`。人类可读的多条结果按“项目、状态、发生时间、摘要”输出;为空时输出 `No entries found.`。`add`、`import`、`current`、`history`、`at` 支持 `--json`;`export` 由 `--format` 控制格式。
|
|
163
|
+
|
|
164
|
+
导出内容不包含数据库内部的 `id` 和 `recordedAt`,可以直接重新导入。记录按时间从旧到新导出,从而在恢复时保留相同时间戳记录的先后关系:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm start export --format jsonl > project-statuses.jsonl
|
|
168
|
+
|
|
169
|
+
PMGR_DB_PATH=./data/restored.sqlite \
|
|
170
|
+
pnpm start import project-statuses.jsonl
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## 开发与验证
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pnpm typecheck
|
|
177
|
+
pnpm lint
|
|
178
|
+
pnpm format-check
|
|
179
|
+
pnpm test
|
|
180
|
+
pnpm coverage
|
|
181
|
+
pnpm build
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Statements、branches、functions、lines 的覆盖率门槛均为 100%。详细设计见[架构文档](docs/architecture.md)。
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { addStatus, at, current, history, importStatuses, openLedger, parseTime, toPortableStatusEntry, ValidationError, } from "./index.js";
|
|
6
|
+
const repeats = new Set(["next", "blocker", "decision", "tag"]);
|
|
7
|
+
function parse(argv) {
|
|
8
|
+
const command = [];
|
|
9
|
+
const flags = Object.create(null);
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const token = argv[index];
|
|
12
|
+
if (!token.startsWith("--")) {
|
|
13
|
+
command.push(token);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const key = token.slice(2);
|
|
17
|
+
if (key === "json") {
|
|
18
|
+
if (flags[key] !== undefined)
|
|
19
|
+
throw new ValidationError(`--${key} may only be supplied once`);
|
|
20
|
+
flags[key] = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const value = argv[++index];
|
|
24
|
+
if (!value || value.startsWith("--"))
|
|
25
|
+
throw new ValidationError(`--${key} requires a value`);
|
|
26
|
+
if (repeats.has(key))
|
|
27
|
+
flags[key] = [...(flags[key] ?? []), value];
|
|
28
|
+
else if (flags[key] !== undefined)
|
|
29
|
+
throw new ValidationError(`--${key} may only be supplied once`);
|
|
30
|
+
else
|
|
31
|
+
flags[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return { command, flags };
|
|
34
|
+
}
|
|
35
|
+
const allowedFlags = new Map([
|
|
36
|
+
[
|
|
37
|
+
"add",
|
|
38
|
+
new Set([
|
|
39
|
+
"project",
|
|
40
|
+
"status",
|
|
41
|
+
"summary",
|
|
42
|
+
"progress",
|
|
43
|
+
"blocker",
|
|
44
|
+
"next",
|
|
45
|
+
"decision",
|
|
46
|
+
"tag",
|
|
47
|
+
"at",
|
|
48
|
+
"source",
|
|
49
|
+
"external-ref",
|
|
50
|
+
"json",
|
|
51
|
+
]),
|
|
52
|
+
],
|
|
53
|
+
["import", new Set(["file", "json"])],
|
|
54
|
+
["current", new Set(["project", "json"])],
|
|
55
|
+
[
|
|
56
|
+
"history",
|
|
57
|
+
new Set(["project", "since", "until", "limit", "offset", "json"]),
|
|
58
|
+
],
|
|
59
|
+
["at", new Set(["project", "at", "json"])],
|
|
60
|
+
["export", new Set(["project", "format"])],
|
|
61
|
+
]);
|
|
62
|
+
function validateInvocation(command, flags) {
|
|
63
|
+
const statusCommand = command[0] === "status";
|
|
64
|
+
const action = command[statusCommand ? 1 : 0];
|
|
65
|
+
const allowed = typeof action === "string" ? allowedFlags.get(action) : undefined;
|
|
66
|
+
if (typeof action !== "string" || !allowed)
|
|
67
|
+
throw new ValidationError("usage: pmgr <add|import|current|history|at|export> (or: pmgr status <action>)");
|
|
68
|
+
const positional = command.slice(statusCommand ? 2 : 1);
|
|
69
|
+
if (action === "import") {
|
|
70
|
+
if (positional.length > 1)
|
|
71
|
+
throw new ValidationError("import accepts at most one file argument");
|
|
72
|
+
if (positional.length === 1 && flags.file !== undefined)
|
|
73
|
+
throw new ValidationError("import file must be positional or --file, not both");
|
|
74
|
+
}
|
|
75
|
+
else if (positional.length) {
|
|
76
|
+
throw new ValidationError(`${action} does not accept positional arguments`);
|
|
77
|
+
}
|
|
78
|
+
for (const key of Object.keys(flags))
|
|
79
|
+
if (!allowed.has(key))
|
|
80
|
+
throw new ValidationError(`unknown flag for ${action}: --${key}`);
|
|
81
|
+
return action;
|
|
82
|
+
}
|
|
83
|
+
function stringFlag(flags, name, required = false) {
|
|
84
|
+
const value = flags[name];
|
|
85
|
+
if (required && typeof value !== "string")
|
|
86
|
+
throw new ValidationError(`--${name} is required`);
|
|
87
|
+
return typeof value === "string" ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
function listFlag(flags, name) {
|
|
90
|
+
return flags[name] ?? [];
|
|
91
|
+
}
|
|
92
|
+
function numberFlag(flags, name, fallback) {
|
|
93
|
+
const value = stringFlag(flags, name);
|
|
94
|
+
if (!value)
|
|
95
|
+
return fallback;
|
|
96
|
+
const number = Number(value);
|
|
97
|
+
if (!Number.isInteger(number) || number < 0)
|
|
98
|
+
throw new ValidationError(`--${name} must be a non-negative integer`);
|
|
99
|
+
return number;
|
|
100
|
+
}
|
|
101
|
+
function output(value, json, write) {
|
|
102
|
+
if (json)
|
|
103
|
+
write(`${JSON.stringify(value)}\n`);
|
|
104
|
+
else if (Array.isArray(value)) {
|
|
105
|
+
const rows = value
|
|
106
|
+
.map((entry) => `${entry.project}\t${entry.status}\t${entry.occurredAt}\t${entry.summary}`)
|
|
107
|
+
.join("\n");
|
|
108
|
+
write(`${rows || "No entries found."}\n`);
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
write(`${JSON.stringify(value, null, 2)}\n`);
|
|
112
|
+
}
|
|
113
|
+
function imported(file) {
|
|
114
|
+
if (!file)
|
|
115
|
+
throw new ValidationError("import file is required");
|
|
116
|
+
const content = readFileSync(file, "utf8").trim();
|
|
117
|
+
if (!content)
|
|
118
|
+
throw new ValidationError(`${basename(file)} is empty`);
|
|
119
|
+
if (file.endsWith(".jsonl"))
|
|
120
|
+
return content.split(/\r?\n/).map((line, index) => {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(line);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new ValidationError(`${basename(file)} line ${index + 1} is not JSON`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(content);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new ValidationError(`${basename(file)} is not JSON`);
|
|
134
|
+
}
|
|
135
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
136
|
+
}
|
|
137
|
+
export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
138
|
+
const { command, flags } = parse(argv);
|
|
139
|
+
const action = validateInvocation(command, flags);
|
|
140
|
+
const db = openLedger();
|
|
141
|
+
const json = flags.json === true;
|
|
142
|
+
try {
|
|
143
|
+
switch (action) {
|
|
144
|
+
case "add":
|
|
145
|
+
output(addStatus(db, {
|
|
146
|
+
project: stringFlag(flags, "project", true),
|
|
147
|
+
status: stringFlag(flags, "status", true),
|
|
148
|
+
summary: stringFlag(flags, "summary", true),
|
|
149
|
+
progress: stringFlag(flags, "progress"),
|
|
150
|
+
blockers: listFlag(flags, "blocker"),
|
|
151
|
+
nextSteps: listFlag(flags, "next"),
|
|
152
|
+
decisions: listFlag(flags, "decision"),
|
|
153
|
+
tags: listFlag(flags, "tag"),
|
|
154
|
+
occurredAt: stringFlag(flags, "at"),
|
|
155
|
+
source: stringFlag(flags, "source"),
|
|
156
|
+
externalRef: stringFlag(flags, "external-ref"),
|
|
157
|
+
}).entry, json, write);
|
|
158
|
+
break;
|
|
159
|
+
case "import":
|
|
160
|
+
output(importStatuses(db, imported(stringFlag(flags, "file") ??
|
|
161
|
+
command[command[0] === "status" ? 2 : 1] ??
|
|
162
|
+
"")).map((result) => result.entry), json, write);
|
|
163
|
+
break;
|
|
164
|
+
case "current":
|
|
165
|
+
output(current(db, stringFlag(flags, "project")), json, write);
|
|
166
|
+
break;
|
|
167
|
+
case "history":
|
|
168
|
+
output(history(db, stringFlag(flags, "project", true), {
|
|
169
|
+
since: stringFlag(flags, "since") &&
|
|
170
|
+
parseTime(stringFlag(flags, "since"), "since"),
|
|
171
|
+
until: stringFlag(flags, "until") &&
|
|
172
|
+
parseTime(stringFlag(flags, "until"), "until"),
|
|
173
|
+
limit: numberFlag(flags, "limit", 100),
|
|
174
|
+
offset: numberFlag(flags, "offset", 0),
|
|
175
|
+
}), json, write);
|
|
176
|
+
break;
|
|
177
|
+
case "at":
|
|
178
|
+
output(at(db, stringFlag(flags, "project", true), parseTime(stringFlag(flags, "at", true), "at")) ?? null, json, write);
|
|
179
|
+
break;
|
|
180
|
+
case "export": {
|
|
181
|
+
const entries = history(db, stringFlag(flags, "project"), {
|
|
182
|
+
limit: -1,
|
|
183
|
+
})
|
|
184
|
+
.reverse()
|
|
185
|
+
.map(toPortableStatusEntry);
|
|
186
|
+
const format = stringFlag(flags, "format") ?? "json";
|
|
187
|
+
if (format === "jsonl")
|
|
188
|
+
write(entries.map((entry) => JSON.stringify(entry)).join("\n") +
|
|
189
|
+
(entries.length ? "\n" : ""));
|
|
190
|
+
else if (format === "json")
|
|
191
|
+
output(entries, true, write);
|
|
192
|
+
else
|
|
193
|
+
throw new ValidationError("--format must be json or jsonl");
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
db.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/* c8 ignore start -- process entry glue is covered by the CLI integration tests through run(). */
|
|
203
|
+
if (process.argv[1] &&
|
|
204
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
205
|
+
try {
|
|
206
|
+
run(process.argv.slice(2));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/* c8 ignore stop */
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { type Source, type StatusEntry } from "./domain.js";
|
|
3
|
+
export type Ledger = Database.Database;
|
|
4
|
+
export type InsertResult = {
|
|
5
|
+
entry: StatusEntry;
|
|
6
|
+
inserted: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type HistoryOptions = {
|
|
9
|
+
since?: string;
|
|
10
|
+
until?: string;
|
|
11
|
+
limit?: number;
|
|
12
|
+
offset?: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function databasePath(): string;
|
|
15
|
+
export declare function openLedger(path?: string): Ledger;
|
|
16
|
+
export declare function addStatus(db: Ledger, value: unknown, defaultSource?: Source): InsertResult;
|
|
17
|
+
export declare function importStatuses(db: Ledger, values: unknown[]): InsertResult[];
|
|
18
|
+
export declare function current(db: Ledger, project?: string): StatusEntry[];
|
|
19
|
+
export declare function history(db: Ledger, project?: string, options?: HistoryOptions): StatusEntry[];
|
|
20
|
+
export declare function at(db: Ledger, project: string, occurredAt: string): StatusEntry | undefined;
|