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/src/cli.ts ADDED
@@ -0,0 +1,272 @@
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 {
6
+ addStatus,
7
+ at,
8
+ current,
9
+ history,
10
+ importStatuses,
11
+ openLedger,
12
+ parseTime,
13
+ toPortableStatusEntry,
14
+ ValidationError,
15
+ } from "./index.js";
16
+
17
+ type Flags = Record<string, string | string[] | boolean>;
18
+ const repeats = new Set(["next", "blocker", "decision", "tag"]);
19
+
20
+ function parse(argv: string[]): { command: string[]; flags: Flags } {
21
+ const command: string[] = [];
22
+ const flags: Flags = Object.create(null) as Flags;
23
+ for (let index = 0; index < argv.length; index += 1) {
24
+ const token = argv[index];
25
+ if (!token.startsWith("--")) {
26
+ command.push(token);
27
+ continue;
28
+ }
29
+ const key = token.slice(2);
30
+ if (key === "json") {
31
+ if (flags[key] !== undefined)
32
+ throw new ValidationError(`--${key} may only be supplied once`);
33
+ flags[key] = true;
34
+ continue;
35
+ }
36
+ const value = argv[++index];
37
+ if (!value || value.startsWith("--"))
38
+ throw new ValidationError(`--${key} requires a value`);
39
+ if (repeats.has(key))
40
+ flags[key] = [...((flags[key] as string[] | undefined) ?? []), value];
41
+ else if (flags[key] !== undefined)
42
+ throw new ValidationError(`--${key} may only be supplied once`);
43
+ else flags[key] = value;
44
+ }
45
+ return { command, flags };
46
+ }
47
+
48
+ const allowedFlags = new Map<string, ReadonlySet<string>>([
49
+ [
50
+ "add",
51
+ new Set([
52
+ "project",
53
+ "status",
54
+ "summary",
55
+ "progress",
56
+ "blocker",
57
+ "next",
58
+ "decision",
59
+ "tag",
60
+ "at",
61
+ "source",
62
+ "external-ref",
63
+ "json",
64
+ ]),
65
+ ],
66
+ ["import", new Set(["file", "json"])],
67
+ ["current", new Set(["project", "json"])],
68
+ [
69
+ "history",
70
+ new Set(["project", "since", "until", "limit", "offset", "json"]),
71
+ ],
72
+ ["at", new Set(["project", "at", "json"])],
73
+ ["export", new Set(["project", "format"])],
74
+ ]);
75
+
76
+ function validateInvocation(command: string[], flags: Flags): string {
77
+ const statusCommand = command[0] === "status";
78
+ const action = command[statusCommand ? 1 : 0];
79
+ const allowed =
80
+ typeof action === "string" ? allowedFlags.get(action) : undefined;
81
+ if (typeof action !== "string" || !allowed)
82
+ throw new ValidationError(
83
+ "usage: pmgr <add|import|current|history|at|export> (or: pmgr status <action>)",
84
+ );
85
+ const positional = command.slice(statusCommand ? 2 : 1);
86
+ if (action === "import") {
87
+ if (positional.length > 1)
88
+ throw new ValidationError("import accepts at most one file argument");
89
+ if (positional.length === 1 && flags.file !== undefined)
90
+ throw new ValidationError(
91
+ "import file must be positional or --file, not both",
92
+ );
93
+ } else if (positional.length) {
94
+ throw new ValidationError(`${action} does not accept positional arguments`);
95
+ }
96
+ for (const key of Object.keys(flags))
97
+ if (!allowed.has(key))
98
+ throw new ValidationError(`unknown flag for ${action}: --${key}`);
99
+ return action;
100
+ }
101
+
102
+ function stringFlag(
103
+ flags: Flags,
104
+ name: string,
105
+ required = false,
106
+ ): string | undefined {
107
+ const value = flags[name];
108
+ if (required && typeof value !== "string")
109
+ throw new ValidationError(`--${name} is required`);
110
+ return typeof value === "string" ? value : undefined;
111
+ }
112
+
113
+ function listFlag(flags: Flags, name: string): string[] {
114
+ return (flags[name] as string[] | undefined) ?? [];
115
+ }
116
+ function numberFlag(flags: Flags, name: string, fallback: number): number {
117
+ const value = stringFlag(flags, name);
118
+ if (!value) return fallback;
119
+ const number = Number(value);
120
+ if (!Number.isInteger(number) || number < 0)
121
+ throw new ValidationError(`--${name} must be a non-negative integer`);
122
+ return number;
123
+ }
124
+
125
+ function output(
126
+ value: unknown,
127
+ json: boolean,
128
+ write: (text: string) => void,
129
+ ): void {
130
+ if (json) write(`${JSON.stringify(value)}\n`);
131
+ else if (Array.isArray(value)) {
132
+ const rows = value
133
+ .map(
134
+ (entry) =>
135
+ `${entry.project}\t${entry.status}\t${entry.occurredAt}\t${entry.summary}`,
136
+ )
137
+ .join("\n");
138
+ write(`${rows || "No entries found."}\n`);
139
+ } else write(`${JSON.stringify(value, null, 2)}\n`);
140
+ }
141
+
142
+ function imported(file: string): unknown[] {
143
+ if (!file) throw new ValidationError("import file is required");
144
+ const content = readFileSync(file, "utf8").trim();
145
+ if (!content) throw new ValidationError(`${basename(file)} is empty`);
146
+ if (file.endsWith(".jsonl"))
147
+ return content.split(/\r?\n/).map((line, index) => {
148
+ try {
149
+ return JSON.parse(line) as unknown;
150
+ } catch {
151
+ throw new ValidationError(
152
+ `${basename(file)} line ${index + 1} is not JSON`,
153
+ );
154
+ }
155
+ });
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = JSON.parse(content) as unknown;
159
+ } catch {
160
+ throw new ValidationError(`${basename(file)} is not JSON`);
161
+ }
162
+ return Array.isArray(parsed) ? parsed : [parsed];
163
+ }
164
+
165
+ export function run(
166
+ argv: string[],
167
+ write = process.stdout.write.bind(process.stdout),
168
+ ): void {
169
+ const { command, flags } = parse(argv);
170
+ const action = validateInvocation(command, flags);
171
+ const db = openLedger();
172
+ const json = flags.json === true;
173
+ try {
174
+ switch (action) {
175
+ case "add":
176
+ output(
177
+ addStatus(db, {
178
+ project: stringFlag(flags, "project", true),
179
+ status: stringFlag(flags, "status", true),
180
+ summary: stringFlag(flags, "summary", true),
181
+ progress: stringFlag(flags, "progress"),
182
+ blockers: listFlag(flags, "blocker"),
183
+ nextSteps: listFlag(flags, "next"),
184
+ decisions: listFlag(flags, "decision"),
185
+ tags: listFlag(flags, "tag"),
186
+ occurredAt: stringFlag(flags, "at"),
187
+ source: stringFlag(flags, "source"),
188
+ externalRef: stringFlag(flags, "external-ref"),
189
+ }).entry,
190
+ json,
191
+ write,
192
+ );
193
+ break;
194
+ case "import":
195
+ output(
196
+ importStatuses(
197
+ db,
198
+ imported(
199
+ stringFlag(flags, "file") ??
200
+ command[command[0] === "status" ? 2 : 1] ??
201
+ "",
202
+ ),
203
+ ).map((result) => result.entry),
204
+ json,
205
+ write,
206
+ );
207
+ break;
208
+ case "current":
209
+ output(current(db, stringFlag(flags, "project")), json, write);
210
+ break;
211
+ case "history":
212
+ output(
213
+ history(db, stringFlag(flags, "project", true), {
214
+ since:
215
+ stringFlag(flags, "since") &&
216
+ parseTime(stringFlag(flags, "since"), "since"),
217
+ until:
218
+ stringFlag(flags, "until") &&
219
+ parseTime(stringFlag(flags, "until"), "until"),
220
+ limit: numberFlag(flags, "limit", 100),
221
+ offset: numberFlag(flags, "offset", 0),
222
+ }),
223
+ json,
224
+ write,
225
+ );
226
+ break;
227
+ case "at":
228
+ output(
229
+ at(
230
+ db,
231
+ stringFlag(flags, "project", true) as string,
232
+ parseTime(stringFlag(flags, "at", true), "at"),
233
+ ) ?? null,
234
+ json,
235
+ write,
236
+ );
237
+ break;
238
+ case "export": {
239
+ const entries = history(db, stringFlag(flags, "project"), {
240
+ limit: -1,
241
+ })
242
+ .reverse()
243
+ .map(toPortableStatusEntry);
244
+ const format = stringFlag(flags, "format") ?? "json";
245
+ if (format === "jsonl")
246
+ write(
247
+ entries.map((entry) => JSON.stringify(entry)).join("\n") +
248
+ (entries.length ? "\n" : ""),
249
+ );
250
+ else if (format === "json") output(entries, true, write);
251
+ else throw new ValidationError("--format must be json or jsonl");
252
+ break;
253
+ }
254
+ }
255
+ } finally {
256
+ db.close();
257
+ }
258
+ }
259
+
260
+ /* c8 ignore start -- process entry glue is covered by the CLI integration tests through run(). */
261
+ if (
262
+ process.argv[1] &&
263
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
264
+ ) {
265
+ try {
266
+ run(process.argv.slice(2));
267
+ } catch (error) {
268
+ console.error(error instanceof Error ? error.message : String(error));
269
+ process.exitCode = 1;
270
+ }
271
+ }
272
+ /* c8 ignore stop */
package/src/db.ts ADDED
@@ -0,0 +1,220 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import {
5
+ type Source,
6
+ type StatusEntry,
7
+ type StatusEntryInput,
8
+ validateStatusEntry,
9
+ ValidationError,
10
+ } from "./domain.js";
11
+
12
+ export type Ledger = Database.Database;
13
+ export type InsertResult = { entry: StatusEntry; inserted: boolean };
14
+ export type HistoryOptions = {
15
+ since?: string;
16
+ until?: string;
17
+ limit?: number;
18
+ offset?: number;
19
+ };
20
+
21
+ const migration = `
22
+ CREATE TABLE IF NOT EXISTS projects (
23
+ id INTEGER PRIMARY KEY,
24
+ slug TEXT NOT NULL UNIQUE,
25
+ display_name TEXT NOT NULL,
26
+ created_at TEXT NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS status_entries (
29
+ id INTEGER PRIMARY KEY,
30
+ project_id INTEGER NOT NULL REFERENCES projects(id),
31
+ status TEXT NOT NULL,
32
+ summary TEXT NOT NULL,
33
+ progress TEXT,
34
+ blockers_json TEXT NOT NULL DEFAULT '[]',
35
+ next_steps_json TEXT NOT NULL DEFAULT '[]',
36
+ decisions_json TEXT NOT NULL DEFAULT '[]',
37
+ tags_json TEXT NOT NULL DEFAULT '[]',
38
+ occurred_at TEXT NOT NULL,
39
+ recorded_at TEXT NOT NULL,
40
+ source TEXT NOT NULL,
41
+ external_ref TEXT UNIQUE
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_status_entries_project_occurred ON status_entries(project_id, occurred_at DESC, id DESC);
44
+ `;
45
+
46
+ export function databasePath(): string {
47
+ return (
48
+ process.env.PMGR_DB_PATH ||
49
+ resolve(process.cwd(), "data", "project-mgr.sqlite")
50
+ );
51
+ }
52
+
53
+ export function openLedger(path = databasePath()): Ledger {
54
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
55
+ const db = new Database(path);
56
+ db.pragma("foreign_keys = ON");
57
+ db.exec(
58
+ "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)",
59
+ );
60
+ db.transaction(() => {
61
+ if (
62
+ !db.prepare("SELECT 1 FROM schema_migrations WHERE version = 1").get()
63
+ ) {
64
+ db.exec(migration);
65
+ db.prepare("INSERT INTO schema_migrations(version) VALUES (1)").run();
66
+ }
67
+ }).immediate();
68
+ return db;
69
+ }
70
+
71
+ function entryFromRow(row: Record<string, unknown>): StatusEntry {
72
+ return {
73
+ id: row.id as number,
74
+ project: row.project as string,
75
+ status: row.status as StatusEntry["status"],
76
+ summary: row.summary as string,
77
+ ...(row.progress ? { progress: row.progress as string } : {}),
78
+ blockers: JSON.parse(row.blockers_json as string) as string[],
79
+ nextSteps: JSON.parse(row.next_steps_json as string) as string[],
80
+ decisions: JSON.parse(row.decisions_json as string) as string[],
81
+ tags: JSON.parse(row.tags_json as string) as string[],
82
+ occurredAt: row.occurred_at as string,
83
+ recordedAt: row.recorded_at as string,
84
+ source: row.source as StatusEntry["source"],
85
+ ...(row.external_ref ? { externalRef: row.external_ref as string } : {}),
86
+ };
87
+ }
88
+
89
+ const select = `SELECT e.*, p.slug AS project FROM status_entries e JOIN projects p ON p.id = e.project_id`;
90
+ function one(
91
+ db: Ledger,
92
+ sql: string,
93
+ ...params: unknown[]
94
+ ): StatusEntry | undefined {
95
+ const row = db.prepare(sql).get(...params) as
96
+ Record<string, unknown> | undefined;
97
+ return row && entryFromRow(row);
98
+ }
99
+
100
+ export function addStatus(
101
+ db: Ledger,
102
+ value: unknown,
103
+ defaultSource: Source = "human",
104
+ ): InsertResult {
105
+ const input = validateStatusEntry(value, defaultSource);
106
+ return db.transaction(() => addValidatedStatus(db, input))();
107
+ }
108
+
109
+ function addValidatedStatus(
110
+ db: Ledger,
111
+ input: StatusEntryInput & { occurredAt: string; source: Source },
112
+ ): InsertResult {
113
+ if (input.externalRef) {
114
+ const existing = one(
115
+ db,
116
+ `${select} WHERE e.external_ref = ?`,
117
+ input.externalRef,
118
+ );
119
+ if (existing) {
120
+ if (existing.project !== input.project)
121
+ throw new ValidationError(
122
+ `externalRef is already used by project ${existing.project}`,
123
+ );
124
+ return { entry: existing, inserted: false };
125
+ }
126
+ }
127
+ const now = new Date().toISOString();
128
+ db.prepare(
129
+ "INSERT INTO projects(slug, display_name, created_at) VALUES (?, ?, ?) ON CONFLICT(slug) DO NOTHING",
130
+ ).run(input.project, input.project, now);
131
+ const project = db
132
+ .prepare("SELECT id FROM projects WHERE slug = ?")
133
+ .get(input.project) as { id: number };
134
+ const result = db
135
+ .prepare(
136
+ `INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref)
137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
138
+ )
139
+ .run(
140
+ project.id,
141
+ input.status,
142
+ input.summary,
143
+ input.progress ?? null,
144
+ JSON.stringify(input.blockers),
145
+ JSON.stringify(input.nextSteps),
146
+ JSON.stringify(input.decisions),
147
+ JSON.stringify(input.tags),
148
+ input.occurredAt,
149
+ now,
150
+ input.source,
151
+ input.externalRef ?? null,
152
+ );
153
+ return {
154
+ entry: one(
155
+ db,
156
+ `${select} WHERE e.id = ?`,
157
+ result.lastInsertRowid,
158
+ ) as StatusEntry,
159
+ inserted: true,
160
+ };
161
+ }
162
+
163
+ export function importStatuses(db: Ledger, values: unknown[]): InsertResult[] {
164
+ const inputs = values.map((value) => validateStatusEntry(value, "import"));
165
+ return db.transaction(() =>
166
+ inputs.map((input) => addValidatedStatus(db, input)),
167
+ )();
168
+ }
169
+
170
+ export function current(db: Ledger, project?: string): StatusEntry[] {
171
+ const where = project ? " AND p.slug = ?" : "";
172
+ const rows = db
173
+ .prepare(
174
+ `${select} WHERE e.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY occurred_at DESC, id DESC) AS position FROM status_entries) WHERE position = 1)${where} ORDER BY e.occurred_at DESC, e.id DESC`,
175
+ )
176
+ .all(...(project ? [project] : [])) as Record<string, unknown>[];
177
+ return rows.map(entryFromRow);
178
+ }
179
+
180
+ export function history(
181
+ db: Ledger,
182
+ project?: string,
183
+ options: HistoryOptions = {},
184
+ ): StatusEntry[] {
185
+ const clauses: string[] = [];
186
+ const params: unknown[] = [];
187
+ if (project) {
188
+ clauses.push("p.slug = ?");
189
+ params.push(project);
190
+ }
191
+ if (options.since) {
192
+ clauses.push("e.occurred_at >= ?");
193
+ params.push(options.since);
194
+ }
195
+ if (options.until) {
196
+ clauses.push("e.occurred_at <= ?");
197
+ params.push(options.until);
198
+ }
199
+ const limit = options.limit ?? 100;
200
+ const offset = options.offset ?? 0;
201
+ const rows = db
202
+ .prepare(
203
+ `${select}${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY e.occurred_at DESC, e.id DESC LIMIT ? OFFSET ?`,
204
+ )
205
+ .all(...params, limit, offset) as Record<string, unknown>[];
206
+ return rows.map(entryFromRow);
207
+ }
208
+
209
+ export function at(
210
+ db: Ledger,
211
+ project: string,
212
+ occurredAt: string,
213
+ ): StatusEntry | undefined {
214
+ return one(
215
+ db,
216
+ `${select} WHERE p.slug = ? AND e.occurred_at <= ? ORDER BY e.occurred_at DESC, e.id DESC LIMIT 1`,
217
+ project,
218
+ occurredAt,
219
+ );
220
+ }
package/src/domain.ts ADDED
@@ -0,0 +1,153 @@
1
+ export const statuses = [
2
+ "idea",
3
+ "planned",
4
+ "active",
5
+ "blocked",
6
+ "paused",
7
+ "completed",
8
+ "archived",
9
+ ] as const;
10
+ export const sources = ["human", "agent", "import"] as const;
11
+
12
+ export type ProjectStatus = (typeof statuses)[number];
13
+ export type Source = (typeof sources)[number];
14
+
15
+ export type StatusEntryInput = {
16
+ project: string;
17
+ status: ProjectStatus;
18
+ summary: string;
19
+ progress?: string;
20
+ blockers?: string[];
21
+ nextSteps?: string[];
22
+ decisions?: string[];
23
+ tags?: string[];
24
+ occurredAt?: string;
25
+ source?: Source;
26
+ externalRef?: string;
27
+ };
28
+
29
+ export type StatusEntry = Required<
30
+ Omit<StatusEntryInput, "progress" | "externalRef">
31
+ > & {
32
+ id: number;
33
+ progress?: string;
34
+ externalRef?: string;
35
+ recordedAt: string;
36
+ };
37
+
38
+ export type PortableStatusEntry = Omit<StatusEntry, "id" | "recordedAt">;
39
+
40
+ const keys = new Set([
41
+ "project",
42
+ "status",
43
+ "summary",
44
+ "progress",
45
+ "blockers",
46
+ "nextSteps",
47
+ "decisions",
48
+ "tags",
49
+ "occurredAt",
50
+ "source",
51
+ "externalRef",
52
+ ]);
53
+ const projectName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
54
+
55
+ export class ValidationError extends Error {
56
+ constructor(message: string) {
57
+ super(message);
58
+ this.name = "ValidationError";
59
+ }
60
+ }
61
+
62
+ function text(
63
+ value: unknown,
64
+ field: string,
65
+ required = false,
66
+ ): string | undefined {
67
+ if (value === undefined && !required) return undefined;
68
+ if (typeof value !== "string" || !value.trim())
69
+ throw new ValidationError(`${field} must be a non-empty string`);
70
+ const normalized = value.trim();
71
+ if (normalized.length > 2_000)
72
+ throw new ValidationError(`${field} must be at most 2000 characters`);
73
+ return normalized;
74
+ }
75
+
76
+ function textList(value: unknown, field: string): string[] {
77
+ if (value === undefined) return [];
78
+ if (!Array.isArray(value) || value.length > 100)
79
+ throw new ValidationError(
80
+ `${field} must be an array of at most 100 strings`,
81
+ );
82
+ return value.map((item) => text(item, field, true) as string);
83
+ }
84
+
85
+ export function parseTime(value: unknown, field: string): string {
86
+ if (
87
+ typeof value !== "string" ||
88
+ !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
89
+ Number.isNaN(Date.parse(value))
90
+ ) {
91
+ throw new ValidationError(`${field} must be a valid ISO 8601 timestamp`);
92
+ }
93
+ return new Date(value).toISOString();
94
+ }
95
+
96
+ export function validateStatusEntry(
97
+ value: unknown,
98
+ defaultSource: Source = "human",
99
+ ): StatusEntryInput &
100
+ Required<Pick<StatusEntryInput, "occurredAt" | "source">> {
101
+ if (!value || typeof value !== "object" || Array.isArray(value))
102
+ throw new ValidationError("entry must be an object");
103
+ const input = value as Record<string, unknown>;
104
+ for (const key of Object.keys(input))
105
+ if (!keys.has(key)) throw new ValidationError(`unknown field: ${key}`);
106
+ const project = text(input.project, "project", true) as string;
107
+ if (!projectName.test(project))
108
+ throw new ValidationError(
109
+ "project must use letters, numbers, dots, underscores, or hyphens",
110
+ );
111
+ if (!statuses.includes(input.status as ProjectStatus))
112
+ throw new ValidationError("status is invalid");
113
+ const source = input.source === undefined ? defaultSource : input.source;
114
+ if (!sources.includes(source as Source))
115
+ throw new ValidationError("source is invalid");
116
+ const externalRef = text(input.externalRef, "externalRef");
117
+ if (externalRef && externalRef.length > 200)
118
+ throw new ValidationError("externalRef must be at most 200 characters");
119
+ return {
120
+ project,
121
+ status: input.status as ProjectStatus,
122
+ summary: text(input.summary, "summary", true) as string,
123
+ progress: text(input.progress, "progress"),
124
+ blockers: textList(input.blockers, "blockers"),
125
+ nextSteps: textList(input.nextSteps, "nextSteps"),
126
+ decisions: textList(input.decisions, "decisions"),
127
+ tags: textList(input.tags, "tags"),
128
+ occurredAt:
129
+ input.occurredAt === undefined
130
+ ? new Date().toISOString()
131
+ : parseTime(input.occurredAt, "occurredAt"),
132
+ source: source as Source,
133
+ externalRef,
134
+ };
135
+ }
136
+
137
+ export function toPortableStatusEntry(entry: StatusEntry): PortableStatusEntry {
138
+ return {
139
+ project: entry.project,
140
+ status: entry.status,
141
+ summary: entry.summary,
142
+ ...(entry.progress === undefined ? {} : { progress: entry.progress }),
143
+ blockers: entry.blockers,
144
+ nextSteps: entry.nextSteps,
145
+ decisions: entry.decisions,
146
+ tags: entry.tags,
147
+ occurredAt: entry.occurredAt,
148
+ source: entry.source,
149
+ ...(entry.externalRef === undefined
150
+ ? {}
151
+ : { externalRef: entry.externalRef }),
152
+ };
153
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./db.js";
2
+ export * from "./domain.js";