project-mgr 0.2.1 → 0.3.1
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/CHANGELOG.md +27 -0
- package/README.md +21 -0
- package/dist/cli.js +37 -32
- package/dist/db.d.ts +33 -1
- package/dist/db.js +259 -10
- package/dist/domain.d.ts +12 -1
- package/dist/domain.js +9 -0
- package/dist/server.js +19 -2
- package/dist/web/i18n.d.ts +8 -0
- package/dist/web/i18n.js +77 -0
- package/dist/web/locales/en.d.ts +2 -0
- package/dist/web/locales/en.js +52 -0
- package/dist/web/locales/zh.d.ts +54 -0
- package/dist/web/locales/zh.js +52 -0
- package/dist/web/script.d.ts +2 -0
- package/dist/web/script.js +356 -0
- package/dist/web/styles.d.ts +1 -0
- package/dist/web/styles.js +636 -0
- package/dist/web/ui.d.ts +3 -1
- package/dist/web/ui.js +33 -924
- package/package.json +8 -2
- package/skills/project-mgr/SKILL.md +36 -17
- package/.claude/settings.local.json +0 -5
- package/.env_sample +0 -10
- package/.prettierignore +0 -4
- package/AGENTS.md +0 -5
- package/docs/architecture.md +0 -95
- package/ecosystem.config.cjs +0 -21
- package/eslint.config.js +0 -12
- package/pnpm-workspace.yaml +0 -3
- package/src/cli.ts +0 -399
- package/src/db.ts +0 -373
- package/src/domain.ts +0 -226
- package/src/index.ts +0 -5
- package/src/server.ts +0 -202
- package/src/web/ui.ts +0 -977
- package/test/core.test.ts +0 -739
- package/test/server.test.ts +0 -354
- package/tsconfig.json +0 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-mgr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Local-first append-only project status ledger",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,5 +34,11 @@
|
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22"
|
|
36
36
|
},
|
|
37
|
-
"packageManager": "pnpm@10.30.2"
|
|
37
|
+
"packageManager": "pnpm@10.30.2",
|
|
38
|
+
"files": [
|
|
39
|
+
"dist/",
|
|
40
|
+
"skills/",
|
|
41
|
+
"README_CN.md",
|
|
42
|
+
"CHANGELOG.md"
|
|
43
|
+
]
|
|
38
44
|
}
|
|
@@ -5,15 +5,24 @@ description: Manage project status with the local-first project-mgr CLI. Use whe
|
|
|
5
5
|
|
|
6
6
|
# project-mgr
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## CLI selection
|
|
9
|
+
|
|
10
|
+
Prefer an installed `pmgr`: locate it with `command -v pmgr` and check the installed package metadata/documentation for the required version and capabilities. The current CLI does not implement `--help` or `--version`; do not use those flags as health checks. In a source checkout, use `pnpm pmgr` only when the local dependencies are already installed and the package manager is available without downloading it.
|
|
11
|
+
|
|
12
|
+
Do not automatically install or upgrade dependencies, or fall back to `npx` / `npm exec`. If no usable CLI is available, report the missing executable, dependency, or capability and the setup needed. Perform installation or upgrades only when the user authorizes that setup.
|
|
13
|
+
|
|
14
|
+
Completion links and version-2 exports require project-mgr 0.3.0 or later. This skill is updated through Git and has no independent release version.
|
|
9
15
|
|
|
10
16
|
## Database safety
|
|
11
17
|
|
|
12
18
|
- Read `PMGR_DB_PATH` before operating. If unset, the CLI uses `~/.local/share/project-mgr/project-mgr.sqlite`.
|
|
13
19
|
- For exploratory or test work, set `PMGR_DB_PATH` to a dedicated absolute SQLite path for every command. Do not use `:memory:` when more than one CLI invocation must share data.
|
|
14
|
-
-
|
|
20
|
+
- The database file and its parent directory must be accessible with the permissions SQLite needs for journal files and writes. Even query commands open the ledger and may initialize or migrate it; do not assume they are filesystem-read-only.
|
|
21
|
+
- On a permission or read-only database error, report the command, resolved database path, and error without exposing credentials. Use the environment's approved permission mechanism when available; otherwise report the blocked operation. Do not reinstall the CLI, change permissions, or silently switch to another database to work around the error. Change the ledger location only when the user requests it.
|
|
22
|
+
- Query before writing. `add` appends a snapshot; it does not edit the previous one. Read back the affected state after writing and report success only when verified. If a write's outcome is uncertain, query before retrying to avoid duplicates.
|
|
15
23
|
- Set `--source agent` for agent-authored updates. Use `--external-ref` only when a stable idempotency key is available.
|
|
16
|
-
-
|
|
24
|
+
- Explicitly pass `--status` on every `add`, and include `status` in every agent-authored import entry. The CLI defaults to `completed`; do not rely on that default. Choose the status from the evidence and inspect the current snapshot when unclear. Completing one activity does not necessarily complete the project; use `active` when project work continues. If the status remains uncertain, clarify before writing.
|
|
25
|
+
- If the project is not yet in the ledger database, initialize its metadata with `set-project`. Backfill only milestones supported by verifiable Git commits, dated documents, or historical records, and include the evidence reference in `--progress`. Use historical `--at` timestamps only when supported by that evidence; a commit timestamp proves a commit, not a deployment. There is no required number of entries. Never invent milestones or dates; when evidence is unavailable, skip backfill and append only the current known status.
|
|
17
26
|
|
|
18
27
|
## Metadata vs. status entries
|
|
19
28
|
|
|
@@ -30,29 +39,39 @@ Prefer `--json` for data that must be interpreted or reported programmatically.
|
|
|
30
39
|
|
|
31
40
|
```bash
|
|
32
41
|
# Set, update, or clear project metadata (description, notes, display name).
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
pmgr set-project --project my-project --description "Project summary" --notes "Internal notes" --json
|
|
43
|
+
pmgr set-project --project my-project --clear-description --clear-notes --json
|
|
35
44
|
|
|
36
45
|
# List all projects or fuzzy search across project names, descriptions, notes, status summaries, and tags.
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
pmgr projects --json
|
|
47
|
+
pmgr list --query "keyword" --json
|
|
39
48
|
|
|
40
49
|
# Start the local Web Dashboard & REST API server.
|
|
41
|
-
|
|
50
|
+
pmgr serve --port 3000 --no-open --json
|
|
42
51
|
|
|
43
|
-
#
|
|
44
|
-
|
|
52
|
+
# Record a completed milestone while the project remains active.
|
|
53
|
+
pmgr add --project my-project --status active --summary "Implemented the CLI" \
|
|
45
54
|
--progress "Tests pass" --next "Publish the package" --source agent --json
|
|
46
55
|
|
|
47
56
|
# Query current state, history, or state at a time.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
pmgr current --project my-project --json
|
|
58
|
+
pmgr history --project my-project --limit 20 --json
|
|
59
|
+
pmgr history --limit 20 --json
|
|
60
|
+
pmgr at --project my-project --at 2026-08-03T12:00:00+08:00 --json
|
|
52
61
|
|
|
53
62
|
# Import validated JSON/JSONL, or export portable records.
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
pmgr import statuses.jsonl --json
|
|
64
|
+
pmgr export --project my-project --format jsonl > statuses.jsonl
|
|
56
65
|
```
|
|
57
66
|
|
|
58
|
-
Use `PMGR_DB_PATH=/absolute/path/test.sqlite
|
|
67
|
+
Use `PMGR_DB_PATH=/absolute/path/test.sqlite pmgr ...` to isolate a manual test database. Import validates and writes the entire batch within one transaction; export output can be imported directly.
|
|
68
|
+
|
|
69
|
+
## Complete predecessor activities
|
|
70
|
+
|
|
71
|
+
Before recording follow-up work, inspect `history --project <name> --json`, including the original `status`, `effectiveStatus`, and `completedBy` evidence. Do not interpret a missing completion link as unfinished work. Inspect older pages with `--offset` when needed.
|
|
72
|
+
|
|
73
|
+
When this update resolves all commitments of earlier entries (including blockers and next steps), pass repeatable `--completes <id-or-uid>` to `add`. One follow-up may complete several entries. For partial progress, describe the remaining work and do not link. A commit/deployment is sufficient only when it resolves all the targeted entry's commitments.
|
|
74
|
+
|
|
75
|
+
Use `complete --entry <id-or-uid> --by <id-or-uid> --source agent` for an existing follow-up. Use `revoke-completion --entry <id-or-uid> --by <id-or-uid> --reason "..." --source agent` to correct a mistaken link. Links require the same project and strictly increasing occurrence times. Query the affected history afterward to verify effective completion. Do not bulk-close old entries without evidence.
|
|
76
|
+
|
|
77
|
+
Export produces version-2 bundles (one bundle per line for JSONL), preserving entry/event identities and audit history; legacy imports remain supported. `at` has no completion annotations. Project current state still comes from the latest snapshot.
|
package/.env_sample
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Optional SQLite database override. When unset, the database is stored at
|
|
2
|
-
# ~/.local/share/project-mgr/project-mgr.sqlite.
|
|
3
|
-
# Use a literal absolute path here; .env files do not expand ~ or $HOME.
|
|
4
|
-
# PMGR_DB_PATH=/absolute/path/to/project-mgr.sqlite
|
|
5
|
-
|
|
6
|
-
# For isolated manual testing, use a separate database instead:
|
|
7
|
-
# PMGR_DB_PATH=/tmp/project-mgr-manual-test.sqlite
|
|
8
|
-
|
|
9
|
-
# Local Web server port.
|
|
10
|
-
PORT=3000
|
package/.prettierignore
DELETED
package/AGENTS.md
DELETED
package/docs/architecture.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# Architecture
|
|
2
|
-
|
|
3
|
-
## Design goals
|
|
4
|
-
|
|
5
|
-
`project-mgr` is a local, single-machine, append-only project status ledger. The first release follows four principles:
|
|
6
|
-
|
|
7
|
-
- History is never overwritten: every write appends a status snapshot.
|
|
8
|
-
- One source of truth: the CLI and any future interfaces must reuse the same domain and database logic.
|
|
9
|
-
- Clear time semantics: distinguish when a status occurred from when the system recorded it.
|
|
10
|
-
- Portable: exports can be imported into a new database to restore the status timeline.
|
|
11
|
-
|
|
12
|
-
The first release has no work that needs to run continuously, so it provides neither an HTTP API nor PM2 configuration. Consider a service layer only when other local processes need a stable interface or background jobs.
|
|
13
|
-
|
|
14
|
-
## Module boundaries
|
|
15
|
-
|
|
16
|
-
```text
|
|
17
|
-
User / script
|
|
18
|
-
│
|
|
19
|
-
▼
|
|
20
|
-
src/cli.ts Argument parsing, file input, output formatting, command-level constraints
|
|
21
|
-
│
|
|
22
|
-
├──────────────► src/domain.ts
|
|
23
|
-
│ Types, field validation, time normalization, portable record conversion
|
|
24
|
-
▼
|
|
25
|
-
src/db.ts SQLite connection, migrations, transactions, idempotent writes, and queries
|
|
26
|
-
│
|
|
27
|
-
▼
|
|
28
|
-
SQLite file
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
`src/index.ts` exports only public types and functions. Business validation does not depend on the CLI, so bulk imports and direct database API calls use the same rules.
|
|
32
|
-
|
|
33
|
-
The CLI accepts `pmgr <action>` for `add`, `import`, `current`, `history`, `at`, and `export`; `pmgr status <action>` remains a compatible form. Both forms share the same validation and execution path.
|
|
34
|
-
|
|
35
|
-
## Data model
|
|
36
|
-
|
|
37
|
-
The database contains three structures:
|
|
38
|
-
|
|
39
|
-
- `schema_migrations`: Applied migration versions (1: base tables, 2: project description & notes columns).
|
|
40
|
-
- `projects`: Project slug, display name, description, notes, and creation time.
|
|
41
|
-
- `status_entries`: Immutable status snapshots and their project foreign keys.
|
|
42
|
-
|
|
43
|
-
Key `status_entries` fields:
|
|
44
|
-
|
|
45
|
-
| Field | Meaning |
|
|
46
|
-
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
47
|
-
| `status`, `summary`, `progress` | Status enum, summary, and completed work |
|
|
48
|
-
| `blockers_json`, `next_steps_json`, `decisions_json`, `tags_json` | JSON-encoded string arrays |
|
|
49
|
-
| `occurred_at` | When the status actually occurred, supplied by the caller or generated during validation |
|
|
50
|
-
| `recorded_at` | When the database actually wrote the entry |
|
|
51
|
-
| `source` | `human`, `agent`, or `import` |
|
|
52
|
-
| `external_ref` | Optional globally unique idempotency key |
|
|
53
|
-
|
|
54
|
-
The `project_id, occurred_at DESC, id DESC` index supports current-status and history queries.
|
|
55
|
-
|
|
56
|
-
## Initialization and migrations
|
|
57
|
-
|
|
58
|
-
After opening the database, `openLedger()` enables foreign keys, creates the migration table, and checks for and applies migrations 1 and 2 within a `BEGIN IMMEDIATE` transaction. The immediate transaction prevents migration checks and writes from interleaving when several local processes open the database for the first time at once.
|
|
59
|
-
|
|
60
|
-
The database path is read from `PMGR_DB_PATH` first; otherwise, it uses the absolute path `~/.local/share/project-mgr/project-mgr.sqlite`, resolved from the current user's home directory. Non-memory databases create their parent directory automatically.
|
|
61
|
-
|
|
62
|
-
## Write flow
|
|
63
|
-
|
|
64
|
-
A single write proceeds as follows:
|
|
65
|
-
|
|
66
|
-
1. Reject unknown fields and validate the project name, status, source, string arrays, and time.
|
|
67
|
-
2. Normalize all timestamps to UTC ISO 8601 strings.
|
|
68
|
-
3. In a transaction, check `externalRef`: repeated calls for the same project return the existing entry; cross-project conflicts fail.
|
|
69
|
-
4. Create the project when needed, then append a status snapshot.
|
|
70
|
-
5. Read and return the final entry from the database.
|
|
71
|
-
|
|
72
|
-
Bulk import validates all input first, then executes the same write logic for each entry in one transaction. A database constraint or any failed write rolls back the entire batch.
|
|
73
|
-
|
|
74
|
-
## Query semantics
|
|
75
|
-
|
|
76
|
-
- Current status: take the first entry for each project ordered by `occurred_at DESC, id DESC`.
|
|
77
|
-
- History: use the same occurrence-time and ID descending order, with inclusive `since` / `until` bounds and `limit` / `offset`.
|
|
78
|
-
- Status at a time: take the first entry with `occurred_at <=` the specified time.
|
|
79
|
-
- Same occurrence time: the later-written, larger `id` wins, keeping results deterministic.
|
|
80
|
-
|
|
81
|
-
All ISO 8601 timestamps are converted to UTC on write, so SQLite string ordering matches time ordering.
|
|
82
|
-
|
|
83
|
-
## Import and export
|
|
84
|
-
|
|
85
|
-
Complete records returned by database queries include the internal `id` and `recordedAt`. The export layer removes these read-only fields while preserving business fields, original source, and the idempotency key, so JSON and JSONL can pass strict input validation when imported again.
|
|
86
|
-
|
|
87
|
-
Exports have no record limit and reverse descending history results into oldest-to-newest order. When restoring to an empty database, entries with the same `occurredAt` receive IDs in their original append order, so the current status remains the same.
|
|
88
|
-
|
|
89
|
-
## Consistency and limits
|
|
90
|
-
|
|
91
|
-
- SQLite transactions guarantee atomic single writes and bulk imports.
|
|
92
|
-
- `externalRef` is unique across the entire database; cross-project reuse is explicitly rejected.
|
|
93
|
-
- Current status is derived from history; no separate cache table can drift.
|
|
94
|
-
- Version 1 is for trusted local users and includes no authentication, authorization, multi-user synchronization, or encryption.
|
|
95
|
-
- JSON arrays are stored as text. Evaluate a normalized schema only when cross-field full-text search or complex tag statistics are needed.
|
package/ecosystem.config.cjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module.exports = {
|
|
2
|
-
apps: [
|
|
3
|
-
{
|
|
4
|
-
name: "project-mgr-web",
|
|
5
|
-
cwd: __dirname,
|
|
6
|
-
script: process.execPath,
|
|
7
|
-
interpreter: "none",
|
|
8
|
-
args: [
|
|
9
|
-
"--env-file-if-exists=.env",
|
|
10
|
-
"dist/cli.js",
|
|
11
|
-
"serve",
|
|
12
|
-
"--host",
|
|
13
|
-
"127.0.0.1",
|
|
14
|
-
"--no-open",
|
|
15
|
-
],
|
|
16
|
-
env: {
|
|
17
|
-
PMGR_DB_PATH: `${process.env.HOME}/.local/share/project-mgr/project-mgr.sqlite`,
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
],
|
|
21
|
-
};
|
package/eslint.config.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import plugin from "@typescript-eslint/eslint-plugin";
|
|
2
|
-
import parser from "@typescript-eslint/parser";
|
|
3
|
-
|
|
4
|
-
export default [
|
|
5
|
-
{
|
|
6
|
-
files: ["src/**/*.ts", "test/**/*.ts"],
|
|
7
|
-
ignores: ["dist/**", "coverage/**", "node_modules/**"],
|
|
8
|
-
languageOptions: { parser },
|
|
9
|
-
plugins: { "@typescript-eslint": plugin },
|
|
10
|
-
rules: { "@typescript-eslint/no-unused-vars": "error" },
|
|
11
|
-
},
|
|
12
|
-
];
|
package/pnpm-workspace.yaml
DELETED
package/src/cli.ts
DELETED
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
-
import { basename } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import {
|
|
6
|
-
addStatus,
|
|
7
|
-
at,
|
|
8
|
-
current,
|
|
9
|
-
history,
|
|
10
|
-
importStatuses,
|
|
11
|
-
listProjects,
|
|
12
|
-
openLedger,
|
|
13
|
-
parseTime,
|
|
14
|
-
type ProjectOverview,
|
|
15
|
-
type ProjectStatus,
|
|
16
|
-
setProjectMetadata,
|
|
17
|
-
startServer,
|
|
18
|
-
toPortableStatusEntry,
|
|
19
|
-
ValidationError,
|
|
20
|
-
} from "./index.js";
|
|
21
|
-
|
|
22
|
-
type Flags = Record<string, string | string[] | boolean>;
|
|
23
|
-
const repeats = new Set(["next", "blocker", "decision", "tag"]);
|
|
24
|
-
|
|
25
|
-
function parse(argv: string[]): { command: string[]; flags: Flags } {
|
|
26
|
-
const command: string[] = [];
|
|
27
|
-
const flags: Flags = Object.create(null) as Flags;
|
|
28
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
29
|
-
const token = argv[index];
|
|
30
|
-
if (!token.startsWith("--")) {
|
|
31
|
-
command.push(token);
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const key = token.slice(2);
|
|
35
|
-
if (
|
|
36
|
-
key === "json" ||
|
|
37
|
-
key === "clear-description" ||
|
|
38
|
-
key === "clear-notes" ||
|
|
39
|
-
key === "open" ||
|
|
40
|
-
key === "no-open"
|
|
41
|
-
) {
|
|
42
|
-
if (flags[key] !== undefined)
|
|
43
|
-
throw new ValidationError(`--${key} may only be supplied once`);
|
|
44
|
-
flags[key] = true;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
const value = argv[++index];
|
|
48
|
-
if (!value || value.startsWith("--"))
|
|
49
|
-
throw new ValidationError(`--${key} requires a value`);
|
|
50
|
-
if (repeats.has(key))
|
|
51
|
-
flags[key] = [...((flags[key] as string[] | undefined) ?? []), value];
|
|
52
|
-
else if (flags[key] !== undefined)
|
|
53
|
-
throw new ValidationError(`--${key} may only be supplied once`);
|
|
54
|
-
else flags[key] = value;
|
|
55
|
-
}
|
|
56
|
-
return { command, flags };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const allowedFlags = new Map<string, ReadonlySet<string>>([
|
|
60
|
-
[
|
|
61
|
-
"add",
|
|
62
|
-
new Set([
|
|
63
|
-
"project",
|
|
64
|
-
"status",
|
|
65
|
-
"summary",
|
|
66
|
-
"description",
|
|
67
|
-
"notes",
|
|
68
|
-
"progress",
|
|
69
|
-
"blocker",
|
|
70
|
-
"next",
|
|
71
|
-
"decision",
|
|
72
|
-
"tag",
|
|
73
|
-
"at",
|
|
74
|
-
"source",
|
|
75
|
-
"external-ref",
|
|
76
|
-
"json",
|
|
77
|
-
]),
|
|
78
|
-
],
|
|
79
|
-
["import", new Set(["file", "json"])],
|
|
80
|
-
["current", new Set(["project", "json"])],
|
|
81
|
-
["projects", new Set(["query", "json"])],
|
|
82
|
-
["list", new Set(["query", "json"])],
|
|
83
|
-
[
|
|
84
|
-
"set-project",
|
|
85
|
-
new Set([
|
|
86
|
-
"project",
|
|
87
|
-
"display-name",
|
|
88
|
-
"description",
|
|
89
|
-
"notes",
|
|
90
|
-
"clear-description",
|
|
91
|
-
"clear-notes",
|
|
92
|
-
"json",
|
|
93
|
-
]),
|
|
94
|
-
],
|
|
95
|
-
[
|
|
96
|
-
"history",
|
|
97
|
-
new Set(["project", "since", "until", "limit", "offset", "json"]),
|
|
98
|
-
],
|
|
99
|
-
["at", new Set(["project", "at", "json"])],
|
|
100
|
-
["export", new Set(["project", "format"])],
|
|
101
|
-
["serve", new Set(["port", "host", "open", "no-open", "json"])],
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
function validateInvocation(command: string[], flags: Flags): string {
|
|
105
|
-
const statusCommand = command[0] === "status";
|
|
106
|
-
const action = command[statusCommand ? 1 : 0];
|
|
107
|
-
const allowed =
|
|
108
|
-
typeof action === "string" ? allowedFlags.get(action) : undefined;
|
|
109
|
-
if (typeof action !== "string" || !allowed)
|
|
110
|
-
throw new ValidationError(
|
|
111
|
-
"usage: pmgr <add|import|current|projects|list|set-project|history|at|export|serve> (or: pmgr status <action>)",
|
|
112
|
-
);
|
|
113
|
-
const positional = command.slice(statusCommand ? 2 : 1);
|
|
114
|
-
if (action === "import") {
|
|
115
|
-
if (positional.length > 1)
|
|
116
|
-
throw new ValidationError("import accepts at most one file argument");
|
|
117
|
-
if (positional.length === 1 && flags.file !== undefined)
|
|
118
|
-
throw new ValidationError(
|
|
119
|
-
"import file must be positional or --file, not both",
|
|
120
|
-
);
|
|
121
|
-
} else if (positional.length) {
|
|
122
|
-
throw new ValidationError(`${action} does not accept positional arguments`);
|
|
123
|
-
}
|
|
124
|
-
for (const key of Object.keys(flags))
|
|
125
|
-
if (!allowed.has(key))
|
|
126
|
-
throw new ValidationError(`unknown flag for ${action}: --${key}`);
|
|
127
|
-
return action;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function stringFlag(
|
|
131
|
-
flags: Flags,
|
|
132
|
-
name: string,
|
|
133
|
-
required = false,
|
|
134
|
-
): string | undefined {
|
|
135
|
-
const value = flags[name];
|
|
136
|
-
if (required && typeof value !== "string")
|
|
137
|
-
throw new ValidationError(`--${name} is required`);
|
|
138
|
-
return typeof value === "string" ? value : undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function listFlag(flags: Flags, name: string): string[] {
|
|
142
|
-
return (flags[name] as string[] | undefined) ?? [];
|
|
143
|
-
}
|
|
144
|
-
function numberFlag(flags: Flags, name: string, fallback: number): number {
|
|
145
|
-
const value = stringFlag(flags, name);
|
|
146
|
-
if (!value) return fallback;
|
|
147
|
-
const number = Number(value);
|
|
148
|
-
if (!Number.isInteger(number) || number < 0)
|
|
149
|
-
throw new ValidationError(`--${name} must be a non-negative integer`);
|
|
150
|
-
return number;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function output(
|
|
154
|
-
value: unknown,
|
|
155
|
-
json: boolean,
|
|
156
|
-
write: (text: string) => void,
|
|
157
|
-
): void {
|
|
158
|
-
if (json) write(`${JSON.stringify(value)}\n`);
|
|
159
|
-
else if (Array.isArray(value)) {
|
|
160
|
-
const rows = value
|
|
161
|
-
.map(
|
|
162
|
-
(entry) =>
|
|
163
|
-
`${entry.project}\t${entry.status}\t${entry.occurredAt}\t${entry.summary}`,
|
|
164
|
-
)
|
|
165
|
-
.join("\n");
|
|
166
|
-
write(`${rows || "No entries found."}\n`);
|
|
167
|
-
} else write(`${JSON.stringify(value, null, 2)}\n`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function formatProjectOverview(item: ProjectOverview): string {
|
|
171
|
-
const lines = [`${item.project}\t${item.displayName}`];
|
|
172
|
-
if (item.description) lines.push(` Description: ${item.description}`);
|
|
173
|
-
if (item.notes) lines.push(` Notes: ${item.notes}`);
|
|
174
|
-
if (item.latestStatus) {
|
|
175
|
-
lines.push(
|
|
176
|
-
` Latest Status: ${item.latestStatus.status}\t${item.latestStatus.occurredAt}\t${item.latestStatus.summary}`,
|
|
177
|
-
);
|
|
178
|
-
} else {
|
|
179
|
-
lines.push(` Latest Status: (none)`);
|
|
180
|
-
}
|
|
181
|
-
return lines.join("\n");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function outputProjects(
|
|
185
|
-
items: ProjectOverview[],
|
|
186
|
-
json: boolean,
|
|
187
|
-
write: (text: string) => void,
|
|
188
|
-
): void {
|
|
189
|
-
if (json) write(`${JSON.stringify(items)}\n`);
|
|
190
|
-
else if (items.length === 0) write("No projects found.\n");
|
|
191
|
-
else write(`${items.map(formatProjectOverview).join("\n")}\n`);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function imported(file: string): unknown[] {
|
|
195
|
-
if (!file) throw new ValidationError("import file is required");
|
|
196
|
-
const content = readFileSync(file, "utf8").trim();
|
|
197
|
-
if (!content) throw new ValidationError(`${basename(file)} is empty`);
|
|
198
|
-
if (file.endsWith(".jsonl"))
|
|
199
|
-
return content.split(/\r?\n/).map((line, index) => {
|
|
200
|
-
try {
|
|
201
|
-
return JSON.parse(line) as unknown;
|
|
202
|
-
} catch {
|
|
203
|
-
throw new ValidationError(
|
|
204
|
-
`${basename(file)} line ${index + 1} is not JSON`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
let parsed: unknown;
|
|
209
|
-
try {
|
|
210
|
-
parsed = JSON.parse(content) as unknown;
|
|
211
|
-
} catch {
|
|
212
|
-
throw new ValidationError(`${basename(file)} is not JSON`);
|
|
213
|
-
}
|
|
214
|
-
return Array.isArray(parsed) ? parsed : [parsed];
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function run(
|
|
218
|
-
argv: string[],
|
|
219
|
-
write = process.stdout.write.bind(process.stdout),
|
|
220
|
-
): unknown {
|
|
221
|
-
const { command, flags } = parse(argv);
|
|
222
|
-
const action = validateInvocation(command, flags);
|
|
223
|
-
const db = openLedger();
|
|
224
|
-
const json = flags.json === true;
|
|
225
|
-
try {
|
|
226
|
-
switch (action) {
|
|
227
|
-
case "add": {
|
|
228
|
-
const project = stringFlag(flags, "project", true) as string;
|
|
229
|
-
const description = stringFlag(flags, "description");
|
|
230
|
-
const notes = stringFlag(flags, "notes");
|
|
231
|
-
if (description !== undefined || notes !== undefined) {
|
|
232
|
-
setProjectMetadata(db, { project, description, notes });
|
|
233
|
-
}
|
|
234
|
-
output(
|
|
235
|
-
addStatus(db, {
|
|
236
|
-
project,
|
|
237
|
-
status: stringFlag(flags, "status") as unknown as ProjectStatus,
|
|
238
|
-
summary: stringFlag(flags, "summary", true),
|
|
239
|
-
progress: stringFlag(flags, "progress"),
|
|
240
|
-
blockers: listFlag(flags, "blocker"),
|
|
241
|
-
nextSteps: listFlag(flags, "next"),
|
|
242
|
-
decisions: listFlag(flags, "decision"),
|
|
243
|
-
tags: listFlag(flags, "tag"),
|
|
244
|
-
occurredAt: stringFlag(flags, "at"),
|
|
245
|
-
source: stringFlag(flags, "source"),
|
|
246
|
-
externalRef: stringFlag(flags, "external-ref"),
|
|
247
|
-
}).entry,
|
|
248
|
-
json,
|
|
249
|
-
write,
|
|
250
|
-
);
|
|
251
|
-
break;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
case "import":
|
|
255
|
-
output(
|
|
256
|
-
importStatuses(
|
|
257
|
-
db,
|
|
258
|
-
imported(
|
|
259
|
-
stringFlag(flags, "file") ??
|
|
260
|
-
command[command[0] === "status" ? 2 : 1] ??
|
|
261
|
-
"",
|
|
262
|
-
),
|
|
263
|
-
).map((result) => result.entry),
|
|
264
|
-
json,
|
|
265
|
-
write,
|
|
266
|
-
);
|
|
267
|
-
break;
|
|
268
|
-
case "current":
|
|
269
|
-
output(current(db, stringFlag(flags, "project")), json, write);
|
|
270
|
-
break;
|
|
271
|
-
case "projects":
|
|
272
|
-
case "list":
|
|
273
|
-
outputProjects(
|
|
274
|
-
listProjects(db, { query: stringFlag(flags, "query") }),
|
|
275
|
-
json,
|
|
276
|
-
write,
|
|
277
|
-
);
|
|
278
|
-
break;
|
|
279
|
-
case "set-project": {
|
|
280
|
-
const project = stringFlag(flags, "project", true) as string;
|
|
281
|
-
const displayName = stringFlag(flags, "display-name");
|
|
282
|
-
let description: string | null | undefined = undefined;
|
|
283
|
-
if (flags["clear-description"] === true) {
|
|
284
|
-
if (flags.description !== undefined)
|
|
285
|
-
throw new ValidationError(
|
|
286
|
-
"cannot specify both --description and --clear-description",
|
|
287
|
-
);
|
|
288
|
-
description = null;
|
|
289
|
-
} else if (flags.description !== undefined) {
|
|
290
|
-
description = stringFlag(flags, "description");
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
let notes: string | null | undefined = undefined;
|
|
294
|
-
if (flags["clear-notes"] === true) {
|
|
295
|
-
if (flags.notes !== undefined)
|
|
296
|
-
throw new ValidationError(
|
|
297
|
-
"cannot specify both --notes and --clear-notes",
|
|
298
|
-
);
|
|
299
|
-
notes = null;
|
|
300
|
-
} else if (flags.notes !== undefined) {
|
|
301
|
-
notes = stringFlag(flags, "notes");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
output(
|
|
305
|
-
setProjectMetadata(db, {
|
|
306
|
-
project,
|
|
307
|
-
displayName,
|
|
308
|
-
description,
|
|
309
|
-
notes,
|
|
310
|
-
}),
|
|
311
|
-
json,
|
|
312
|
-
write,
|
|
313
|
-
);
|
|
314
|
-
break;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
case "history":
|
|
318
|
-
output(
|
|
319
|
-
history(db, stringFlag(flags, "project"), {
|
|
320
|
-
since:
|
|
321
|
-
stringFlag(flags, "since") &&
|
|
322
|
-
parseTime(stringFlag(flags, "since"), "since"),
|
|
323
|
-
until:
|
|
324
|
-
stringFlag(flags, "until") &&
|
|
325
|
-
parseTime(stringFlag(flags, "until"), "until"),
|
|
326
|
-
limit: numberFlag(flags, "limit", 100),
|
|
327
|
-
offset: numberFlag(flags, "offset", 0),
|
|
328
|
-
}),
|
|
329
|
-
json,
|
|
330
|
-
write,
|
|
331
|
-
);
|
|
332
|
-
break;
|
|
333
|
-
case "at":
|
|
334
|
-
output(
|
|
335
|
-
at(
|
|
336
|
-
db,
|
|
337
|
-
stringFlag(flags, "project", true) as string,
|
|
338
|
-
parseTime(stringFlag(flags, "at", true), "at"),
|
|
339
|
-
) ?? null,
|
|
340
|
-
json,
|
|
341
|
-
write,
|
|
342
|
-
);
|
|
343
|
-
break;
|
|
344
|
-
case "export": {
|
|
345
|
-
const entries = history(db, stringFlag(flags, "project"), {
|
|
346
|
-
limit: -1,
|
|
347
|
-
})
|
|
348
|
-
.reverse()
|
|
349
|
-
.map(toPortableStatusEntry);
|
|
350
|
-
const format = stringFlag(flags, "format") ?? "json";
|
|
351
|
-
if (format === "jsonl")
|
|
352
|
-
write(
|
|
353
|
-
entries.map((entry) => JSON.stringify(entry)).join("\n") +
|
|
354
|
-
(entries.length ? "\n" : ""),
|
|
355
|
-
);
|
|
356
|
-
else if (format === "json") output(entries, true, write);
|
|
357
|
-
else throw new ValidationError("--format must be json or jsonl");
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
case "serve": {
|
|
362
|
-
const port =
|
|
363
|
-
flags.port !== undefined
|
|
364
|
-
? numberFlag(flags, "port", 3000)
|
|
365
|
-
: undefined;
|
|
366
|
-
const host = stringFlag(flags, "host");
|
|
367
|
-
const open = flags["no-open"] === true ? false : true;
|
|
368
|
-
return startServer({ port, host, open, db }).then((instance) => {
|
|
369
|
-
if (json) {
|
|
370
|
-
write(
|
|
371
|
-
`${JSON.stringify({ url: instance.url, port: instance.port, host: instance.host })}\n`,
|
|
372
|
-
);
|
|
373
|
-
} else {
|
|
374
|
-
write(`Server running at ${instance.url}\n`);
|
|
375
|
-
}
|
|
376
|
-
return instance;
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
} finally {
|
|
381
|
-
if (action !== "serve") {
|
|
382
|
-
db.close();
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/* c8 ignore start -- process entry glue is covered by the CLI integration tests through run(). */
|
|
388
|
-
if (
|
|
389
|
-
process.argv[1] &&
|
|
390
|
-
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
391
|
-
) {
|
|
392
|
-
try {
|
|
393
|
-
await run(process.argv.slice(2));
|
|
394
|
-
} catch (error) {
|
|
395
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
396
|
-
process.exitCode = 1;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
/* c8 ignore stop */
|