prodboard 0.0.0 → 0.1.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.
@@ -0,0 +1,112 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { ensureDb } from "../db.ts";
4
+ import { loadConfig, PRODBOARD_DIR } from "../config.ts";
5
+ import { listSchedules } from "../queries/schedules.ts";
6
+ import { getNextFire } from "../cron.ts";
7
+ import { formatDate } from "../format.ts";
8
+ import { Daemon } from "../scheduler.ts";
9
+
10
+ function parseArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
11
+ const flags: Record<string, string | boolean> = {};
12
+ const positional: string[] = [];
13
+
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (arg.startsWith("--")) {
17
+ const key = arg.slice(2);
18
+ if (key === "dry-run" || key === "foreground") {
19
+ flags[key] = true;
20
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
21
+ flags[key] = args[++i];
22
+ } else {
23
+ flags[key] = true;
24
+ }
25
+ } else if (arg.startsWith("-") && arg.length === 2) {
26
+ const key = arg.slice(1);
27
+ if (key === "f") {
28
+ flags.foreground = true;
29
+ } else {
30
+ flags[key] = true;
31
+ }
32
+ } else {
33
+ positional.push(arg);
34
+ }
35
+ }
36
+ return { flags, positional };
37
+ }
38
+
39
+ export async function daemonStart(args: string[]): Promise<void> {
40
+ const { flags } = parseArgs(args);
41
+ const db = ensureDb();
42
+ const config = loadConfig();
43
+
44
+ if (flags["dry-run"]) {
45
+ const schedules = listSchedules(db);
46
+ if (schedules.length === 0) {
47
+ console.log("No active schedules.");
48
+ return;
49
+ }
50
+
51
+ console.log("Active schedules (dry run):\n");
52
+ for (const s of schedules) {
53
+ let nextFire = "N/A";
54
+ try {
55
+ const next = getNextFire(s.cron, new Date());
56
+ nextFire = formatDate(next.toISOString());
57
+ } catch {}
58
+ console.log(` ${s.id} ${s.name}`);
59
+ console.log(` Cron: ${s.cron}`);
60
+ console.log(` Next: ${nextFire}`);
61
+ console.log();
62
+ }
63
+ return;
64
+ }
65
+
66
+ const daemon = new Daemon(db, config);
67
+ await daemon.start();
68
+
69
+ // Keep process alive
70
+ await new Promise(() => {});
71
+ }
72
+
73
+ export async function daemonStatus(args: string[]): Promise<void> {
74
+ const pidFile = path.join(PRODBOARD_DIR, "daemon.pid");
75
+
76
+ if (!fs.existsSync(pidFile)) {
77
+ console.log("Daemon is not running (no PID file).");
78
+ return;
79
+ }
80
+
81
+ const pidStr = fs.readFileSync(pidFile, "utf-8").trim();
82
+ const pid = parseInt(pidStr, 10);
83
+
84
+ let running = false;
85
+ try {
86
+ process.kill(pid, 0);
87
+ running = true;
88
+ } catch {}
89
+
90
+ if (running) {
91
+ console.log(`Daemon is running (PID ${pid}).`);
92
+
93
+ // Show next scheduled runs
94
+ try {
95
+ const db = ensureDb();
96
+ const schedules = listSchedules(db);
97
+ if (schedules.length > 0) {
98
+ console.log("\nUpcoming runs:");
99
+ for (const s of schedules) {
100
+ try {
101
+ const next = getNextFire(s.cron, new Date());
102
+ console.log(` ${s.name}: ${formatDate(next.toISOString())}`);
103
+ } catch {}
104
+ }
105
+ }
106
+ } catch {}
107
+ } else {
108
+ console.log(`Daemon is not running (stale PID file: ${pid}).`);
109
+ // Clean up stale PID file
110
+ try { fs.unlinkSync(pidFile); } catch {}
111
+ }
112
+ }
@@ -0,0 +1,83 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { PRODBOARD_DIR } from "../config.ts";
4
+ import { getDb, runMigrations } from "../db.ts";
5
+
6
+ function resolveTemplatePath(name: string): string {
7
+ return path.resolve(import.meta.dir, "../../templates", name);
8
+ }
9
+
10
+ function resolveSchemaPath(): string {
11
+ return path.resolve(import.meta.dir, "../../config.schema.json");
12
+ }
13
+
14
+ export async function init(args: string[], dirOverride?: string): Promise<void> {
15
+ const prodboardDir = dirOverride ?? PRODBOARD_DIR;
16
+ const logsDir = path.join(prodboardDir, "logs");
17
+ const claudeMdFlag = args.includes("--claude-md");
18
+
19
+ // Create directories
20
+ if (!fs.existsSync(prodboardDir)) {
21
+ fs.mkdirSync(prodboardDir, { recursive: true });
22
+ console.log(`Created ${prodboardDir}/`);
23
+ }
24
+ if (!fs.existsSync(logsDir)) {
25
+ fs.mkdirSync(logsDir, { recursive: true });
26
+ console.log(`Created ${logsDir}/`);
27
+ }
28
+
29
+ // Initialize database
30
+ const dbPath = path.join(prodboardDir, "db.sqlite");
31
+ const db = getDb(dbPath);
32
+ runMigrations(db);
33
+ db.close();
34
+ try { fs.chmodSync(dbPath, 0o600); } catch {}
35
+ try { fs.chmodSync(logsDir, 0o700); } catch {}
36
+ console.log("Database initialized.");
37
+
38
+ // Config file — only write if not exists (user may have edited)
39
+ const configDest = path.join(prodboardDir, "config.jsonc");
40
+ if (!fs.existsSync(configDest)) {
41
+ const configSrc = resolveTemplatePath("config.jsonc");
42
+ fs.copyFileSync(configSrc, configDest);
43
+ console.log("Created config.jsonc");
44
+ } else {
45
+ console.log("config.jsonc already exists, skipping.");
46
+ }
47
+
48
+ // Schema — always overwrite (package-managed)
49
+ const schemaDest = path.join(prodboardDir, "config.schema.json");
50
+ const schemaSrc = resolveSchemaPath();
51
+ fs.copyFileSync(schemaSrc, schemaDest);
52
+ console.log("Updated config.schema.json");
53
+
54
+ // MCP config — always overwrite
55
+ const mcpDest = path.join(prodboardDir, "mcp.json");
56
+ const mcpSrc = resolveTemplatePath("mcp.json");
57
+ fs.copyFileSync(mcpSrc, mcpDest);
58
+ console.log("Updated mcp.json");
59
+
60
+ // System prompts — always overwrite
61
+ const spDest = path.join(prodboardDir, "system-prompt.md");
62
+ const spSrc = resolveTemplatePath("system-prompt.md");
63
+ fs.copyFileSync(spSrc, spDest);
64
+
65
+ const spNogitDest = path.join(prodboardDir, "system-prompt-nogit.md");
66
+ const spNogitSrc = resolveTemplatePath("system-prompt-nogit.md");
67
+ fs.copyFileSync(spNogitSrc, spNogitDest);
68
+ console.log("Updated system prompts.");
69
+
70
+ // CLAUDE.md — only if --claude-md flag and not exists
71
+ if (claudeMdFlag) {
72
+ const claudeMdDest = path.join(process.cwd(), "CLAUDE.md");
73
+ if (!fs.existsSync(claudeMdDest)) {
74
+ const claudeMdSrc = resolveTemplatePath("CLAUDE.md");
75
+ fs.copyFileSync(claudeMdSrc, claudeMdDest);
76
+ console.log("Created CLAUDE.md in current directory.");
77
+ } else {
78
+ console.log("CLAUDE.md already exists, skipping.");
79
+ }
80
+ }
81
+
82
+ console.log("\nprodboard initialized successfully!");
83
+ }
@@ -0,0 +1,127 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+
5
+ const SERVICE_NAME = "prodboard";
6
+ const SERVICE_DIR = path.join(os.homedir(), ".config", "systemd", "user");
7
+ const SERVICE_PATH = path.join(SERVICE_DIR, `${SERVICE_NAME}.service`);
8
+
9
+ function parseArgs(args: string[]): { flags: Record<string, boolean> } {
10
+ const flags: Record<string, boolean> = {};
11
+ for (const arg of args) {
12
+ if (arg === "--force" || arg === "-f") {
13
+ flags.force = true;
14
+ }
15
+ }
16
+ return { flags };
17
+ }
18
+
19
+ async function systemctlAvailable(): Promise<boolean> {
20
+ try {
21
+ const proc = Bun.spawn(["systemctl", "--version"], {
22
+ stdout: "ignore",
23
+ stderr: "ignore",
24
+ });
25
+ const code = await proc.exited;
26
+ return code === 0;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ async function runSystemctl(...args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
33
+ const proc = Bun.spawn(["systemctl", "--user", ...args], {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+ const [stdout, stderr, exitCode] = await Promise.all([
38
+ new Response(proc.stdout).text(),
39
+ new Response(proc.stderr).text(),
40
+ proc.exited,
41
+ ]);
42
+ return { exitCode, stdout, stderr };
43
+ }
44
+
45
+ export function generateServiceFile(bunPath: string, prodboardPath: string, home: string): string {
46
+ return `[Unit]
47
+ Description=prodboard scheduler daemon
48
+ After=network.target
49
+
50
+ [Service]
51
+ Type=simple
52
+ ExecStart=${bunPath} run ${prodboardPath} daemon
53
+ Restart=on-failure
54
+ RestartSec=10
55
+ Environment="HOME=${home}"
56
+
57
+ [Install]
58
+ WantedBy=default.target
59
+ `;
60
+ }
61
+
62
+ export async function install(args: string[]): Promise<void> {
63
+ const { flags } = parseArgs(args);
64
+
65
+ if (!(await systemctlAvailable())) {
66
+ console.error("systemd is not available on this system.");
67
+ console.error("The install command requires systemd (Linux).");
68
+ process.exit(1);
69
+ }
70
+
71
+ const alreadyInstalled = fs.existsSync(SERVICE_PATH);
72
+
73
+ if (alreadyInstalled && !flags.force) {
74
+ console.log("prodboard is already installed as a systemd service.");
75
+ const { stdout } = await runSystemctl("status", SERVICE_NAME);
76
+ console.log(stdout);
77
+ return;
78
+ }
79
+
80
+ const bunPath = Bun.which("bun") ?? process.execPath;
81
+ const prodboardPath = Bun.which("prodboard") ?? `${bunPath} x prodboard`;
82
+ const home = os.homedir();
83
+
84
+ const serviceContent = generateServiceFile(bunPath, prodboardPath, home);
85
+
86
+ fs.mkdirSync(SERVICE_DIR, { recursive: true });
87
+ fs.writeFileSync(SERVICE_PATH, serviceContent);
88
+ console.log(`Service file written to ${SERVICE_PATH}`);
89
+
90
+ const reload = await runSystemctl("daemon-reload");
91
+ if (reload.exitCode !== 0) {
92
+ console.error("Failed to reload systemd:", reload.stderr);
93
+ process.exit(1);
94
+ }
95
+
96
+ const enable = await runSystemctl("enable", SERVICE_NAME);
97
+ if (enable.exitCode !== 0) {
98
+ console.error("Failed to enable service:", enable.stderr);
99
+ process.exit(1);
100
+ }
101
+
102
+ const start = await runSystemctl("start", SERVICE_NAME);
103
+ if (start.exitCode !== 0) {
104
+ console.error("Failed to start service:", start.stderr);
105
+ process.exit(1);
106
+ }
107
+
108
+ console.log("prodboard service installed, enabled, and started.");
109
+ const { stdout } = await runSystemctl("status", SERVICE_NAME);
110
+ console.log(stdout);
111
+ }
112
+
113
+ export async function uninstall(_args: string[]): Promise<void> {
114
+ if (!fs.existsSync(SERVICE_PATH)) {
115
+ console.log("prodboard is not installed as a systemd service.");
116
+ return;
117
+ }
118
+
119
+ await runSystemctl("stop", SERVICE_NAME);
120
+ await runSystemctl("disable", SERVICE_NAME);
121
+
122
+ fs.unlinkSync(SERVICE_PATH);
123
+ console.log(`Removed ${SERVICE_PATH}`);
124
+
125
+ await runSystemctl("daemon-reload");
126
+ console.log("prodboard service uninstalled.");
127
+ }
@@ -0,0 +1,268 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { ensureDb } from "../db.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import {
5
+ createIssue, getIssueByPrefix, listIssues, updateIssue,
6
+ deleteIssue, validateStatus
7
+ } from "../queries/issues.ts";
8
+ import { listComments } from "../queries/comments.ts";
9
+ import { renderTable, formatDate, jsonOutput, bold, dim, cyan } from "../format.ts";
10
+ import { confirm } from "../confirm.ts";
11
+
12
+ function parseArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
13
+ const flags: Record<string, string | boolean> = {};
14
+ const positional: string[] = [];
15
+
16
+ for (let i = 0; i < args.length; i++) {
17
+ const arg = args[i];
18
+ if (arg === "--") {
19
+ positional.push(...args.slice(i + 1));
20
+ break;
21
+ }
22
+ if (arg.startsWith("--")) {
23
+ const key = arg.slice(2);
24
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
25
+ flags[key] = args[++i];
26
+ } else {
27
+ flags[key] = true;
28
+ }
29
+ } else if (arg.startsWith("-") && arg.length === 2) {
30
+ const key = arg.slice(1);
31
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
32
+ flags[key] = args[++i];
33
+ } else {
34
+ flags[key] = true;
35
+ }
36
+ } else {
37
+ positional.push(arg);
38
+ }
39
+ }
40
+ return { flags, positional };
41
+ }
42
+
43
+ function getFlag(flags: Record<string, string | boolean>, ...keys: string[]): string | undefined {
44
+ for (const k of keys) {
45
+ const val = flags[k];
46
+ if (val !== undefined && val !== true) return val as string;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function hasFlag(flags: Record<string, string | boolean>, ...keys: string[]): boolean {
52
+ return keys.some((k) => flags[k] !== undefined);
53
+ }
54
+
55
+ // Collect repeatable flags like --status todo --status done
56
+ function parseArgsMulti(args: string[]): { flags: Record<string, (string | boolean)[]>; positional: string[] } {
57
+ const flags: Record<string, (string | boolean)[]> = {};
58
+ const positional: string[] = [];
59
+
60
+ for (let i = 0; i < args.length; i++) {
61
+ const arg = args[i];
62
+ if (arg === "--") {
63
+ positional.push(...args.slice(i + 1));
64
+ break;
65
+ }
66
+ if (arg.startsWith("--")) {
67
+ const key = arg.slice(2);
68
+ if (!flags[key]) flags[key] = [];
69
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
70
+ flags[key].push(args[++i]);
71
+ } else {
72
+ flags[key].push(true);
73
+ }
74
+ } else if (arg.startsWith("-") && arg.length === 2) {
75
+ const key = arg.slice(1);
76
+ if (!flags[key]) flags[key] = [];
77
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
78
+ flags[key].push(args[++i]);
79
+ } else {
80
+ flags[key].push(true);
81
+ }
82
+ } else {
83
+ positional.push(arg);
84
+ }
85
+ }
86
+ return { flags, positional };
87
+ }
88
+
89
+ export async function add(args: string[], dbOverride?: Database): Promise<void> {
90
+ const { flags, positional } = parseArgs(args);
91
+ const title = positional.join(" ");
92
+ if (!title) {
93
+ console.error("Usage: prodboard add <title> [-d description] [-s status]");
94
+ throw new Error("Invalid arguments");
95
+ }
96
+
97
+ const db = dbOverride ?? ensureDb();
98
+ const config = loadConfig();
99
+
100
+ const status = getFlag(flags, "status", "s");
101
+ if (status) validateStatus(status, config);
102
+
103
+ const issue = createIssue(db, {
104
+ title,
105
+ description: getFlag(flags, "description", "d"),
106
+ status,
107
+ });
108
+
109
+ console.log(`Created issue ${issue.id}: ${issue.title} [${issue.status}]`);
110
+ }
111
+
112
+ export async function ls(args: string[], dbOverride?: Database): Promise<void> {
113
+ const { flags } = parseArgsMulti(args);
114
+ const db = dbOverride ?? ensureDb();
115
+
116
+ const statusFilters = [
117
+ ...(flags.status?.filter((v): v is string => typeof v === "string") ?? []),
118
+ ...(flags.s?.filter((v): v is string => typeof v === "string") ?? []),
119
+ ];
120
+
121
+ const search = (flags.search?.[0] ?? flags.q?.[0]) as string | undefined;
122
+ const all = flags.all !== undefined || flags.a !== undefined;
123
+ const sortFlag = flags.sort?.[0] as string | undefined;
124
+ const asc = flags.asc !== undefined;
125
+ const limitStr = (flags.limit?.[0] ?? flags.n?.[0]) as string | undefined;
126
+ const isJson = flags.json !== undefined;
127
+
128
+ const { issues, total } = listIssues(db, {
129
+ status: statusFilters.length > 0 ? statusFilters : undefined,
130
+ search: typeof search === "string" ? search : undefined,
131
+ includeArchived: all,
132
+ sort: typeof sortFlag === "string" ? sortFlag : undefined,
133
+ order: asc ? "ASC" : undefined,
134
+ limit: limitStr ? parseInt(limitStr, 10) : undefined,
135
+ });
136
+
137
+ if (isJson) {
138
+ console.log(jsonOutput(issues));
139
+ return;
140
+ }
141
+
142
+ if (issues.length === 0) {
143
+ console.log("No issues found.");
144
+ return;
145
+ }
146
+
147
+ const table = renderTable(
148
+ ["ID", "Title", "Status", "Updated"],
149
+ issues.map((i) => [i.id, i.title, i.status, formatDate(i.updated_at)]),
150
+ { maxWidths: [10, 40, 15, 18] }
151
+ );
152
+ console.log(table);
153
+ console.log(`${total} issue${total === 1 ? "" : "s"}`);
154
+ }
155
+
156
+ export async function show(args: string[], dbOverride?: Database): Promise<void> {
157
+ const { flags, positional } = parseArgs(args);
158
+ const idOrPrefix = positional[0];
159
+ if (!idOrPrefix) {
160
+ console.error("Usage: prodboard show <id>");
161
+ throw new Error("Invalid arguments");
162
+ }
163
+
164
+ const db = dbOverride ?? ensureDb();
165
+ const isJson = hasFlag(flags, "json");
166
+
167
+ const issue = getIssueByPrefix(db, idOrPrefix);
168
+ const comments = listComments(db, issue.id);
169
+
170
+ if (isJson) {
171
+ console.log(jsonOutput({ ...issue, comments }));
172
+ return;
173
+ }
174
+
175
+ console.log(`Issue ${issue.id}`);
176
+ console.log(`Title: ${issue.title}`);
177
+ console.log(`Status: ${issue.status}`);
178
+ console.log(`Created: ${formatDate(issue.created_at)}`);
179
+ console.log(`Updated: ${formatDate(issue.updated_at)}`);
180
+
181
+ if (issue.description) {
182
+ console.log(`\nDescription:\n${issue.description}`);
183
+ }
184
+
185
+ if (comments.length > 0) {
186
+ console.log(`\nComments (${comments.length}):`);
187
+ for (const c of comments) {
188
+ console.log(` [${c.author}] ${formatDate(c.created_at)}`);
189
+ console.log(` ${c.body}`);
190
+ console.log();
191
+ }
192
+ }
193
+ }
194
+
195
+ export async function edit(args: string[], dbOverride?: Database): Promise<void> {
196
+ const { flags, positional } = parseArgs(args);
197
+ const idOrPrefix = positional[0];
198
+ if (!idOrPrefix) {
199
+ console.error("Usage: prodboard edit <id> [--title/-t title] [--description/-d desc] [--status/-s status]");
200
+ throw new Error("Invalid arguments");
201
+ }
202
+
203
+ const db = dbOverride ?? ensureDb();
204
+ const config = loadConfig();
205
+ const issue = getIssueByPrefix(db, idOrPrefix);
206
+
207
+ const fields: { title?: string; description?: string; status?: string } = {};
208
+ const title = getFlag(flags, "title", "t");
209
+ const description = getFlag(flags, "description", "d");
210
+ const status = getFlag(flags, "status", "s");
211
+
212
+ if (title) fields.title = title;
213
+ if (description) fields.description = description;
214
+ if (status) {
215
+ validateStatus(status, config);
216
+ fields.status = status;
217
+ }
218
+
219
+ if (Object.keys(fields).length === 0) {
220
+ console.error("No fields to update. Use --title, --description, or --status.");
221
+ throw new Error("Invalid arguments");
222
+ }
223
+
224
+ const updated = updateIssue(db, issue.id, fields);
225
+ console.log(`Updated issue ${updated.id}: ${updated.title} [${updated.status}]`);
226
+ }
227
+
228
+ export async function mv(args: string[], dbOverride?: Database): Promise<void> {
229
+ const { positional } = parseArgs(args);
230
+ const idOrPrefix = positional[0];
231
+ const newStatus = positional[1];
232
+
233
+ if (!idOrPrefix || !newStatus) {
234
+ console.error("Usage: prodboard mv <id> <status>");
235
+ throw new Error("Invalid arguments");
236
+ }
237
+
238
+ const db = dbOverride ?? ensureDb();
239
+ const config = loadConfig();
240
+ validateStatus(newStatus, config);
241
+
242
+ const issue = getIssueByPrefix(db, idOrPrefix);
243
+ const updated = updateIssue(db, issue.id, { status: newStatus });
244
+ console.log(`Moved issue ${updated.id} to ${updated.status}`);
245
+ }
246
+
247
+ export async function rm(args: string[], dbOverride?: Database): Promise<void> {
248
+ const { flags, positional } = parseArgs(args);
249
+ const idOrPrefix = positional[0];
250
+ if (!idOrPrefix) {
251
+ console.error("Usage: prodboard rm <id> [--force/-f]");
252
+ throw new Error("Invalid arguments");
253
+ }
254
+
255
+ const db = dbOverride ?? ensureDb();
256
+ const issue = getIssueByPrefix(db, idOrPrefix);
257
+
258
+ if (!hasFlag(flags, "force", "f")) {
259
+ const ok = await confirm(`Delete issue ${issue.id}: ${issue.title}?`);
260
+ if (!ok) {
261
+ console.log("Cancelled.");
262
+ return;
263
+ }
264
+ }
265
+
266
+ deleteIssue(db, issue.id);
267
+ console.log(`Deleted issue ${issue.id}`);
268
+ }