prodboard 0.0.0 → 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/index.ts ADDED
@@ -0,0 +1,214 @@
1
+ import { existsSync } from "fs";
2
+ import { PRODBOARD_DIR } from "./config.ts";
3
+
4
+ export class NotInitializedError extends Error {
5
+ constructor() {
6
+ super(`prodboard is not initialized. Run 'prodboard init' first.`);
7
+ this.name = "NotInitializedError";
8
+ }
9
+ }
10
+
11
+ export class DatabaseError extends Error {
12
+ constructor(message: string) {
13
+ super(message);
14
+ this.name = "DatabaseError";
15
+ }
16
+ }
17
+
18
+ function ensureInitialized(): void {
19
+ if (!existsSync(PRODBOARD_DIR)) {
20
+ throw new NotInitializedError();
21
+ }
22
+ }
23
+
24
+ export async function main(): Promise<void> {
25
+ const args = Bun.argv.slice(2);
26
+ const command = args[0];
27
+
28
+ if (command === "--version" || command === "version") {
29
+ const pkg = await import("../package.json");
30
+ console.log(pkg.version);
31
+ return;
32
+ }
33
+
34
+ if (command === "--help" || command === "help" || !command) {
35
+ printHelp();
36
+ return;
37
+ }
38
+
39
+ try {
40
+ switch (command) {
41
+ case "init": {
42
+ const { init } = await import("./commands/init.ts");
43
+ await init(args.slice(1));
44
+ break;
45
+ }
46
+ case "add": {
47
+ ensureInitialized();
48
+ const { add } = await import("./commands/issues.ts");
49
+ await add(args.slice(1));
50
+ break;
51
+ }
52
+ case "ls": {
53
+ ensureInitialized();
54
+ const { ls } = await import("./commands/issues.ts");
55
+ await ls(args.slice(1));
56
+ break;
57
+ }
58
+ case "show": {
59
+ ensureInitialized();
60
+ const { show } = await import("./commands/issues.ts");
61
+ await show(args.slice(1));
62
+ break;
63
+ }
64
+ case "edit": {
65
+ ensureInitialized();
66
+ const { edit } = await import("./commands/issues.ts");
67
+ await edit(args.slice(1));
68
+ break;
69
+ }
70
+ case "mv": {
71
+ ensureInitialized();
72
+ const { mv } = await import("./commands/issues.ts");
73
+ await mv(args.slice(1));
74
+ break;
75
+ }
76
+ case "rm": {
77
+ ensureInitialized();
78
+ const { rm } = await import("./commands/issues.ts");
79
+ await rm(args.slice(1));
80
+ break;
81
+ }
82
+ case "comment": {
83
+ ensureInitialized();
84
+ const { comment } = await import("./commands/comments.ts");
85
+ await comment(args.slice(1));
86
+ break;
87
+ }
88
+ case "comments": {
89
+ ensureInitialized();
90
+ const { comments } = await import("./commands/comments.ts");
91
+ await comments(args.slice(1));
92
+ break;
93
+ }
94
+ case "schedule": {
95
+ ensureInitialized();
96
+ const sub = args[1];
97
+ const subArgs = args.slice(2);
98
+ const schedMod = await import("./commands/schedules.ts");
99
+ switch (sub) {
100
+ case "add":
101
+ await schedMod.scheduleAdd(subArgs);
102
+ break;
103
+ case "ls":
104
+ await schedMod.scheduleLs(subArgs);
105
+ break;
106
+ case "edit":
107
+ await schedMod.scheduleEdit(subArgs);
108
+ break;
109
+ case "enable":
110
+ await schedMod.scheduleEnable(subArgs);
111
+ break;
112
+ case "disable":
113
+ await schedMod.scheduleDisable(subArgs);
114
+ break;
115
+ case "rm":
116
+ await schedMod.scheduleRm(subArgs);
117
+ break;
118
+ case "logs":
119
+ await schedMod.scheduleLogs(subArgs);
120
+ break;
121
+ case "run":
122
+ await schedMod.scheduleRun(subArgs);
123
+ break;
124
+ case "stats":
125
+ await schedMod.scheduleStats(subArgs);
126
+ break;
127
+ default:
128
+ console.error(`Unknown schedule subcommand: ${sub}`);
129
+ console.error("Available: add, ls, edit, enable, disable, rm, logs, run, stats");
130
+ process.exit(1);
131
+ }
132
+ break;
133
+ }
134
+ case "daemon": {
135
+ ensureInitialized();
136
+ const sub = args[1];
137
+ const daemonMod = await import("./commands/daemon.ts");
138
+ if (sub === "status") {
139
+ await daemonMod.daemonStatus(args.slice(2));
140
+ } else {
141
+ await daemonMod.daemonStart(args.slice(1));
142
+ }
143
+ break;
144
+ }
145
+ case "config": {
146
+ ensureInitialized();
147
+ const { loadConfig } = await import("./config.ts");
148
+ const config = loadConfig();
149
+ console.log(JSON.stringify(config, null, 2));
150
+ break;
151
+ }
152
+ case "mcp": {
153
+ ensureInitialized();
154
+ const { startMcpServer } = await import("./mcp.ts");
155
+ await startMcpServer();
156
+ break;
157
+ }
158
+ default:
159
+ console.error(`Unknown command: ${command}`);
160
+ printHelp();
161
+ process.exit(1);
162
+ }
163
+ } catch (err: any) {
164
+ if (err instanceof NotInitializedError) {
165
+ console.error(err.message);
166
+ process.exit(3);
167
+ }
168
+ if (err instanceof DatabaseError) {
169
+ console.error(`Database error: ${err.message}`);
170
+ process.exit(2);
171
+ }
172
+ console.error(err instanceof Error ? err.message : String(err));
173
+ process.exit(1);
174
+ }
175
+ }
176
+
177
+ function printHelp(): void {
178
+ console.log(`prodboard — CLI-first issue tracker for AI coding agents
179
+
180
+ Usage: prodboard <command> [options]
181
+
182
+ Commands:
183
+ init Initialize prodboard (~/.prodboard/)
184
+ add <title> Create a new issue
185
+ ls List issues
186
+ show <id> Show issue details
187
+ edit <id> Edit an issue
188
+ mv <id> <status> Change issue status
189
+ rm <id> Delete an issue
190
+ comment <id> Add a comment to an issue
191
+ comments <id> List comments for an issue
192
+ schedule <sub> Manage scheduled tasks
193
+ daemon Start the scheduler daemon
194
+ config Show configuration
195
+ mcp Start MCP server (stdio)
196
+ version Show version
197
+ help Show this help
198
+
199
+ Schedule subcommands:
200
+ schedule add Create a schedule
201
+ schedule ls List schedules
202
+ schedule edit Edit a schedule
203
+ schedule enable Enable a schedule
204
+ schedule disable Disable a schedule
205
+ schedule rm Delete a schedule
206
+ schedule logs Show run history
207
+ schedule run Run a schedule immediately
208
+ schedule stats Show schedule statistics
209
+
210
+ Options:
211
+ --json Output in JSON format
212
+ --help Show help for a command
213
+ --version Show version`);
214
+ }
@@ -0,0 +1,102 @@
1
+ import * as path from "path";
2
+ import type { Config, Schedule, Run, EnvironmentInfo } from "./types.ts";
3
+ import { PRODBOARD_DIR } from "./config.ts";
4
+ import { getLastSessionId } from "./queries/runs.ts";
5
+ import { Database } from "bun:sqlite";
6
+
7
+ export function detectEnvironment(workdir: string, config: Config): EnvironmentInfo {
8
+ let hasGit = false;
9
+ try {
10
+ const result = Bun.spawnSync(["git", "rev-parse", "--git-dir"], {
11
+ cwd: workdir,
12
+ stdout: "pipe",
13
+ stderr: "pipe",
14
+ });
15
+ hasGit = result.exitCode === 0;
16
+ } catch {}
17
+
18
+ let hasClaude = false;
19
+ try {
20
+ const result = Bun.spawnSync(["claude", "--version"], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ });
24
+ hasClaude = result.exitCode === 0;
25
+ } catch {}
26
+
27
+ const worktreeSupported = hasGit && config.daemon.useWorktrees !== "never";
28
+
29
+ return { hasGit, hasClaude, worktreeSupported };
30
+ }
31
+
32
+ export function buildInvocation(
33
+ schedule: Schedule,
34
+ run: Run,
35
+ config: Config,
36
+ env: EnvironmentInfo,
37
+ resolvedPrompt: string,
38
+ db?: Database
39
+ ): string[] {
40
+ const args: string[] = ["claude"];
41
+
42
+ // Prompt
43
+ args.push("-p", resolvedPrompt);
44
+
45
+ // Permissions
46
+ args.push("--dangerously-skip-permissions");
47
+
48
+ // Output format
49
+ args.push("--output-format", "stream-json");
50
+
51
+ // MCP config
52
+ const mcpConfigPath = path.join(PRODBOARD_DIR, "mcp.json");
53
+ args.push("--mcp-config", mcpConfigPath);
54
+
55
+ // System prompt
56
+ const systemPromptFile = env.hasGit
57
+ ? path.join(PRODBOARD_DIR, "system-prompt.md")
58
+ : path.join(PRODBOARD_DIR, "system-prompt-nogit.md");
59
+ args.push("--append-system-prompt-file", systemPromptFile);
60
+
61
+ // Max turns: min of schedule override, config default, and hard max
62
+ const scheduleTurns = schedule.max_turns ?? config.daemon.maxTurns;
63
+ const maxTurns = Math.min(scheduleTurns, config.daemon.hardMaxTurns);
64
+ args.push("--max-turns", String(maxTurns));
65
+
66
+ // Allowed tools
67
+ let tools: string[];
68
+ if (schedule.allowed_tools) {
69
+ try {
70
+ tools = JSON.parse(schedule.allowed_tools);
71
+ } catch {
72
+ tools = env.hasGit ? config.daemon.defaultAllowedTools : config.daemon.nonGitDefaultAllowedTools;
73
+ }
74
+ } else if (!env.hasGit) {
75
+ tools = config.daemon.nonGitDefaultAllowedTools;
76
+ } else {
77
+ tools = config.daemon.defaultAllowedTools;
78
+ }
79
+ for (const tool of tools) {
80
+ args.push("--allowedTools", tool);
81
+ }
82
+
83
+ // Worktree
84
+ if (env.worktreeSupported && schedule.use_worktree !== 0) {
85
+ args.push("--worktree");
86
+ }
87
+
88
+ // Session resume
89
+ if (schedule.persist_session && db) {
90
+ const lastSessionId = getLastSessionId(db, schedule.id);
91
+ if (lastSessionId) {
92
+ args.push("--resume", lastSessionId);
93
+ }
94
+ }
95
+
96
+ // Agents JSON
97
+ if (schedule.agents_json) {
98
+ args.push("--agents", schedule.agents_json);
99
+ }
100
+
101
+ return args;
102
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,84 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ export type LogLevel = "debug" | "info" | "warn" | "error";
5
+
6
+ const LEVEL_ORDER: Record<LogLevel, number> = {
7
+ debug: 0,
8
+ info: 1,
9
+ warn: 2,
10
+ error: 3,
11
+ };
12
+
13
+ export class Logger {
14
+ private logDir: string;
15
+ private level: LogLevel;
16
+ private maxSizeBytes: number;
17
+ private maxFiles: number;
18
+ private logFile: string;
19
+
20
+ constructor(options: { logDir: string; level: LogLevel; maxSizeMb: number; maxFiles: number }) {
21
+ this.logDir = options.logDir;
22
+ this.level = options.level;
23
+ this.maxSizeBytes = options.maxSizeMb * 1024 * 1024;
24
+ this.maxFiles = options.maxFiles;
25
+ this.logFile = path.join(this.logDir, "daemon.log");
26
+
27
+ if (!fs.existsSync(this.logDir)) {
28
+ fs.mkdirSync(this.logDir, { recursive: true });
29
+ }
30
+ }
31
+
32
+ debug(msg: string, data?: Record<string, unknown>): void {
33
+ this.log("debug", msg, data);
34
+ }
35
+
36
+ info(msg: string, data?: Record<string, unknown>): void {
37
+ this.log("info", msg, data);
38
+ }
39
+
40
+ warn(msg: string, data?: Record<string, unknown>): void {
41
+ this.log("warn", msg, data);
42
+ }
43
+
44
+ error(msg: string, data?: Record<string, unknown>): void {
45
+ this.log("error", msg, data);
46
+ }
47
+
48
+ private log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
49
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) return;
50
+
51
+ const timestamp = new Date().toISOString();
52
+ const dataStr = data ? " " + JSON.stringify(data) : "";
53
+ const line = `[${timestamp}] [${level.toUpperCase()}] ${msg}${dataStr}\n`;
54
+
55
+ this.rotate();
56
+ fs.appendFileSync(this.logFile, line);
57
+
58
+ // Also output to stderr
59
+ process.stderr.write(line);
60
+ }
61
+
62
+ private rotate(): void {
63
+ try {
64
+ if (!fs.existsSync(this.logFile)) return;
65
+ const stat = fs.statSync(this.logFile);
66
+ if (stat.size < this.maxSizeBytes) return;
67
+
68
+ // Shift existing rotated files
69
+ for (let i = this.maxFiles; i >= 1; i--) {
70
+ const src = path.join(this.logDir, `daemon.${i}.log`);
71
+ if (fs.existsSync(src)) {
72
+ if (i >= this.maxFiles) {
73
+ fs.unlinkSync(src);
74
+ } else {
75
+ fs.renameSync(src, path.join(this.logDir, `daemon.${i + 1}.log`));
76
+ }
77
+ }
78
+ }
79
+
80
+ // Move current to .1
81
+ fs.renameSync(this.logFile, path.join(this.logDir, "daemon.1.log"));
82
+ } catch {}
83
+ }
84
+ }