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,276 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { ensureDb } from "../db.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import { validateCron, getNextFire } from "../cron.ts";
5
+ import {
6
+ createSchedule, getScheduleByPrefix, listSchedules,
7
+ updateSchedule, deleteSchedule, enableSchedule, disableSchedule,
8
+ } from "../queries/schedules.ts";
9
+ import { listRuns, getScheduleStats } from "../queries/runs.ts";
10
+ import { renderTable, formatDate, jsonOutput } from "../format.ts";
11
+ import { confirm } from "../confirm.ts";
12
+ import { ExecutionManager } from "../scheduler.ts";
13
+ import { createRun } from "../queries/runs.ts";
14
+
15
+ function safeParseInt(val: string | boolean | undefined): number | undefined {
16
+ if (typeof val !== "string") return undefined;
17
+ const n = parseInt(val, 10);
18
+ return isNaN(n) ? undefined : n;
19
+ }
20
+
21
+ function parseArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
22
+ const flags: Record<string, string | boolean> = {};
23
+ const positional: string[] = [];
24
+
25
+ for (let i = 0; i < args.length; i++) {
26
+ const arg = args[i];
27
+ if (arg.startsWith("--")) {
28
+ const key = arg.slice(2);
29
+ if (key === "no-worktree" || key === "no-context" || key === "persist-session" || key === "force" || key === "all" || key === "json" || key === "asc") {
30
+ flags[key] = true;
31
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
32
+ flags[key] = args[++i];
33
+ } else {
34
+ flags[key] = true;
35
+ }
36
+ } else if (arg.startsWith("-") && arg.length === 2) {
37
+ const key = arg.slice(1);
38
+ if (key === "f" || key === "a") {
39
+ flags[key] = true;
40
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
41
+ flags[key] = args[++i];
42
+ } else {
43
+ flags[key] = true;
44
+ }
45
+ } else {
46
+ positional.push(arg);
47
+ }
48
+ }
49
+ return { flags, positional };
50
+ }
51
+
52
+ export async function scheduleAdd(args: string[], dbOverride?: Database): Promise<void> {
53
+ const { flags } = parseArgs(args);
54
+ const db = dbOverride ?? ensureDb();
55
+
56
+ const name = (flags.name ?? flags.n) as string;
57
+ const cron = (flags.cron ?? flags.c) as string;
58
+ const prompt = (flags.prompt ?? flags.p) as string;
59
+
60
+ if (!name || !cron || !prompt) {
61
+ console.error("Usage: prodboard schedule add --name <name> --cron <expr> --prompt <prompt>");
62
+ throw new Error("Invalid arguments");
63
+ }
64
+
65
+ const validation = validateCron(cron);
66
+ if (!validation.valid) {
67
+ console.error(`Invalid cron expression: ${validation.error}`);
68
+ throw new Error("Invalid arguments");
69
+ }
70
+
71
+ const schedule = createSchedule(db, {
72
+ name,
73
+ cron,
74
+ prompt,
75
+ workdir: (flags.workdir ?? flags.w) as string | undefined,
76
+ max_turns: flags["max-turns"] ? parseInt(flags["max-turns"] as string, 10) : undefined,
77
+ use_worktree: !flags["no-worktree"],
78
+ inject_context: !flags["no-context"],
79
+ persist_session: !!flags["persist-session"],
80
+ });
81
+
82
+ console.log(`Created schedule ${schedule.id}: ${schedule.name} [${schedule.cron}]`);
83
+ }
84
+
85
+ export async function scheduleLs(args: string[], dbOverride?: Database): Promise<void> {
86
+ const { flags } = parseArgs(args);
87
+ const db = dbOverride ?? ensureDb();
88
+ const isJson = !!flags.json;
89
+ const all = !!(flags.all || flags.a);
90
+
91
+ const schedules = listSchedules(db, { includeDisabled: all });
92
+
93
+ if (isJson) {
94
+ console.log(jsonOutput(schedules));
95
+ return;
96
+ }
97
+
98
+ if (schedules.length === 0) {
99
+ console.log("No schedules.");
100
+ return;
101
+ }
102
+
103
+ const table = renderTable(
104
+ ["ID", "Name", "Cron", "Enabled", "Next Fire"],
105
+ schedules.map((s) => {
106
+ let nextFire = "";
107
+ try {
108
+ const next = getNextFire(s.cron, new Date());
109
+ nextFire = formatDate(next.toISOString());
110
+ } catch {}
111
+ return [s.id, s.name, s.cron, s.enabled ? "yes" : "no", nextFire];
112
+ }),
113
+ { maxWidths: [10, 30, 20, 8, 18] }
114
+ );
115
+ console.log(table);
116
+ console.log(`${schedules.length} schedule${schedules.length === 1 ? "" : "s"}`);
117
+ }
118
+
119
+ export async function scheduleEdit(args: string[], dbOverride?: Database): Promise<void> {
120
+ const { flags, positional } = parseArgs(args);
121
+ const idOrPrefix = positional[0];
122
+ if (!idOrPrefix) {
123
+ console.error("Usage: prodboard schedule edit <id> [--name name] [--cron expr] [--prompt prompt]");
124
+ throw new Error("Invalid arguments");
125
+ }
126
+
127
+ const db = dbOverride ?? ensureDb();
128
+ const schedule = getScheduleByPrefix(db, idOrPrefix);
129
+
130
+ const fields: any = {};
131
+ if (flags.name || flags.n) fields.name = flags.name ?? flags.n;
132
+ if (flags.cron || flags.c) {
133
+ const newCron = (flags.cron ?? flags.c) as string;
134
+ const validation = validateCron(newCron);
135
+ if (!validation.valid) {
136
+ console.error(`Invalid cron expression: ${validation.error}`);
137
+ throw new Error("Invalid arguments");
138
+ }
139
+ fields.cron = newCron;
140
+ }
141
+ if (flags.prompt || flags.p) fields.prompt = flags.prompt ?? flags.p;
142
+ if (flags["max-turns"]) fields.max_turns = parseInt(flags["max-turns"] as string, 10);
143
+
144
+ const updated = updateSchedule(db, schedule.id, fields);
145
+ console.log(`Updated schedule ${updated.id}: ${updated.name}`);
146
+ }
147
+
148
+ export async function scheduleEnable(args: string[], dbOverride?: Database): Promise<void> {
149
+ const { positional } = parseArgs(args);
150
+ const idOrPrefix = positional[0];
151
+ if (!idOrPrefix) {
152
+ console.error("Usage: prodboard schedule enable <id>");
153
+ throw new Error("Invalid arguments");
154
+ }
155
+
156
+ const db = dbOverride ?? ensureDb();
157
+ const schedule = getScheduleByPrefix(db, idOrPrefix);
158
+ enableSchedule(db, schedule.id);
159
+ console.log(`Enabled schedule ${schedule.id}: ${schedule.name}`);
160
+ }
161
+
162
+ export async function scheduleDisable(args: string[], dbOverride?: Database): Promise<void> {
163
+ const { positional } = parseArgs(args);
164
+ const idOrPrefix = positional[0];
165
+ if (!idOrPrefix) {
166
+ console.error("Usage: prodboard schedule disable <id>");
167
+ throw new Error("Invalid arguments");
168
+ }
169
+
170
+ const db = dbOverride ?? ensureDb();
171
+ const schedule = getScheduleByPrefix(db, idOrPrefix);
172
+ disableSchedule(db, schedule.id);
173
+ console.log(`Disabled schedule ${schedule.id}: ${schedule.name}`);
174
+ }
175
+
176
+ export async function scheduleRm(args: string[], dbOverride?: Database): Promise<void> {
177
+ const { flags, positional } = parseArgs(args);
178
+ const idOrPrefix = positional[0];
179
+ if (!idOrPrefix) {
180
+ console.error("Usage: prodboard schedule rm <id> [--force/-f]");
181
+ throw new Error("Invalid arguments");
182
+ }
183
+
184
+ const db = dbOverride ?? ensureDb();
185
+ const schedule = getScheduleByPrefix(db, idOrPrefix);
186
+
187
+ if (!flags.force && !flags.f) {
188
+ const ok = await confirm(`Delete schedule ${schedule.id}: ${schedule.name}?`);
189
+ if (!ok) {
190
+ console.log("Cancelled.");
191
+ return;
192
+ }
193
+ }
194
+
195
+ deleteSchedule(db, schedule.id);
196
+ console.log(`Deleted schedule ${schedule.id}`);
197
+ }
198
+
199
+ export async function scheduleLogs(args: string[], dbOverride?: Database): Promise<void> {
200
+ const { flags } = parseArgs(args);
201
+ const db = dbOverride ?? ensureDb();
202
+ const isJson = !!flags.json;
203
+
204
+ const runs = listRuns(db, {
205
+ schedule_id: (flags.schedule ?? flags.s) as string | undefined,
206
+ status: flags.status as string | undefined,
207
+ limit: safeParseInt(flags.limit ?? flags.n),
208
+ });
209
+
210
+ if (isJson) {
211
+ console.log(jsonOutput(runs));
212
+ return;
213
+ }
214
+
215
+ if (runs.length === 0) {
216
+ console.log("No runs found.");
217
+ return;
218
+ }
219
+
220
+ const table = renderTable(
221
+ ["ID", "Schedule", "Status", "Started", "Exit", "Tokens"],
222
+ runs.map((r) => [
223
+ r.id,
224
+ r.schedule_name ?? r.schedule_id,
225
+ r.status,
226
+ formatDate(r.started_at),
227
+ r.exit_code !== null ? String(r.exit_code) : "-",
228
+ r.tokens_in ? `${r.tokens_in}/${r.tokens_out}` : "-",
229
+ ]),
230
+ { maxWidths: [10, 20, 10, 18, 5, 12] }
231
+ );
232
+ console.log(table);
233
+ }
234
+
235
+ export async function scheduleRun(args: string[], dbOverride?: Database): Promise<void> {
236
+ const { positional } = parseArgs(args);
237
+ const idOrPrefix = positional[0];
238
+ if (!idOrPrefix) {
239
+ console.error("Usage: prodboard schedule run <id>");
240
+ throw new Error("Invalid arguments");
241
+ }
242
+
243
+ const db = dbOverride ?? ensureDb();
244
+ const config = loadConfig();
245
+ const schedule = getScheduleByPrefix(db, idOrPrefix);
246
+
247
+ console.log(`Running schedule ${schedule.id}: ${schedule.name}...`);
248
+
249
+ const run = createRun(db, {
250
+ schedule_id: schedule.id,
251
+ prompt_used: schedule.prompt,
252
+ });
253
+
254
+ const em = new ExecutionManager(db, config);
255
+ await em.executeRun(schedule, run);
256
+
257
+ const updatedRun = db.query("SELECT * FROM runs WHERE id = ?").get(run.id) as any;
258
+ console.log(`Run completed: ${updatedRun.status} (exit code: ${updatedRun.exit_code ?? "N/A"})`);
259
+ }
260
+
261
+ export async function scheduleStats(args: string[], dbOverride?: Database): Promise<void> {
262
+ const { flags } = parseArgs(args);
263
+ const db = dbOverride ?? ensureDb();
264
+
265
+ const scheduleId = (flags.schedule ?? flags.s) as string | undefined;
266
+ const days = safeParseInt(flags.days ?? flags.d);
267
+
268
+ const stats = getScheduleStats(db, scheduleId, days);
269
+
270
+ console.log(`Total runs: ${stats.total}`);
271
+ console.log(`Success: ${stats.success} (${(stats.success_rate * 100).toFixed(1)}%)`);
272
+ console.log(`Failed: ${stats.failed}`);
273
+ console.log(`Avg tokens in: ${Math.round(stats.avg_tokens_in)}`);
274
+ console.log(`Avg tokens out: ${Math.round(stats.avg_tokens_out)}`);
275
+ console.log(`Total cost: $${stats.total_cost.toFixed(4)}`);
276
+ }
package/src/config.ts ADDED
@@ -0,0 +1,155 @@
1
+ import * as path from "path";
2
+ import * as os from "os";
3
+ import * as fs from "fs";
4
+ import type { Config } from "./types.ts";
5
+
6
+ export const PRODBOARD_DIR = path.join(os.homedir(), ".prodboard");
7
+
8
+ export function stripJsoncComments(text: string): string {
9
+ let result = "";
10
+ let i = 0;
11
+ let inString = false;
12
+ let stringChar = "";
13
+
14
+ while (i < text.length) {
15
+ // Handle string literals
16
+ if (inString) {
17
+ if (text[i] === "\\" && i + 1 < text.length) {
18
+ result += text[i] + text[i + 1];
19
+ i += 2;
20
+ continue;
21
+ }
22
+ if (text[i] === stringChar) {
23
+ inString = false;
24
+ }
25
+ result += text[i];
26
+ i++;
27
+ continue;
28
+ }
29
+
30
+ // Check for string start
31
+ if (text[i] === '"') {
32
+ inString = true;
33
+ stringChar = text[i];
34
+ result += text[i];
35
+ i++;
36
+ continue;
37
+ }
38
+
39
+ // Check for line comment
40
+ if (text[i] === "/" && i + 1 < text.length && text[i + 1] === "/") {
41
+ // Skip until end of line
42
+ while (i < text.length && text[i] !== "\n") {
43
+ i++;
44
+ }
45
+ continue;
46
+ }
47
+
48
+ // Check for block comment
49
+ if (text[i] === "/" && i + 1 < text.length && text[i + 1] === "*") {
50
+ i += 2;
51
+ while (i < text.length && !(text[i] === "*" && i + 1 < text.length && text[i + 1] === "/")) {
52
+ i++;
53
+ }
54
+ if (i < text.length) {
55
+ i += 2; // skip */
56
+ }
57
+ continue;
58
+ }
59
+
60
+ result += text[i];
61
+ i++;
62
+ }
63
+
64
+ return result;
65
+ }
66
+
67
+ export function getDefaults(): Config {
68
+ return {
69
+ general: {
70
+ statuses: ["todo", "in-progress", "review", "done", "archived"],
71
+ defaultStatus: "todo",
72
+ idPrefix: "",
73
+ },
74
+ daemon: {
75
+ maxConcurrentRuns: 2,
76
+ maxTurns: 50,
77
+ hardMaxTurns: 200,
78
+ runTimeoutSeconds: 1800,
79
+ runRetentionDays: 30,
80
+ logLevel: "info",
81
+ logMaxSizeMb: 10,
82
+ logMaxFiles: 5,
83
+ defaultAllowedTools: [
84
+ "Read", "Edit", "Write", "Glob", "Grep", "Bash",
85
+ "mcp__prodboard__list_issues",
86
+ "mcp__prodboard__get_issue",
87
+ "mcp__prodboard__create_issue",
88
+ "mcp__prodboard__update_issue",
89
+ "mcp__prodboard__add_comment",
90
+ "mcp__prodboard__board_summary",
91
+ "mcp__prodboard__pick_next_issue",
92
+ "mcp__prodboard__complete_issue",
93
+ ],
94
+ nonGitDefaultAllowedTools: [
95
+ "Read", "Edit", "Write", "Glob", "Grep", "Bash",
96
+ "mcp__prodboard__list_issues",
97
+ "mcp__prodboard__get_issue",
98
+ "mcp__prodboard__create_issue",
99
+ "mcp__prodboard__update_issue",
100
+ "mcp__prodboard__add_comment",
101
+ "mcp__prodboard__board_summary",
102
+ "mcp__prodboard__pick_next_issue",
103
+ "mcp__prodboard__complete_issue",
104
+ ],
105
+ useWorktrees: "auto",
106
+ },
107
+ };
108
+ }
109
+
110
+ export function deepMerge(defaults: any, user: any): any {
111
+ const result = { ...defaults };
112
+ for (const key of Object.keys(user)) {
113
+ if (
114
+ user[key] !== null &&
115
+ typeof user[key] === "object" &&
116
+ !Array.isArray(user[key]) &&
117
+ defaults[key] !== undefined &&
118
+ typeof defaults[key] === "object" &&
119
+ !Array.isArray(defaults[key])
120
+ ) {
121
+ result[key] = deepMerge(defaults[key], user[key]);
122
+ } else {
123
+ result[key] = user[key];
124
+ }
125
+ }
126
+ return result;
127
+ }
128
+
129
+ export function loadConfig(configDir?: string): Config {
130
+ const dir = configDir ?? PRODBOARD_DIR;
131
+ const configPath = path.join(dir, "config.jsonc");
132
+ const defaults = getDefaults();
133
+
134
+ if (!fs.existsSync(configPath)) {
135
+ return defaults;
136
+ }
137
+
138
+ let text: string;
139
+ try {
140
+ text = fs.readFileSync(configPath, "utf-8");
141
+ } catch (err: any) {
142
+ throw new Error(`Failed to read config file: ${err.message}`);
143
+ }
144
+
145
+ const stripped = stripJsoncComments(text);
146
+
147
+ let parsed: any;
148
+ try {
149
+ parsed = JSON.parse(stripped);
150
+ } catch (err: any) {
151
+ throw new Error(`Invalid JSON in config file ${configPath}: ${err.message}`);
152
+ }
153
+
154
+ return deepMerge(defaults, parsed);
155
+ }
package/src/confirm.ts ADDED
@@ -0,0 +1,14 @@
1
+ export async function confirm(message: string): Promise<boolean> {
2
+ process.stdout.write(`${message} [y/N] `);
3
+ const response = await new Promise<string>((resolve) => {
4
+ let data = "";
5
+ process.stdin.setEncoding("utf-8");
6
+ process.stdin.once("data", (chunk: string) => {
7
+ process.stdin.pause();
8
+ data += chunk;
9
+ resolve(data.trim().toLowerCase());
10
+ });
11
+ process.stdin.resume();
12
+ });
13
+ return response === "y" || response === "yes";
14
+ }
package/src/cron.ts ADDED
@@ -0,0 +1,121 @@
1
+ export interface CronFields {
2
+ minute: Set<number>;
3
+ hour: Set<number>;
4
+ dayOfMonth: Set<number>;
5
+ month: Set<number>;
6
+ dayOfWeek: Set<number>;
7
+ }
8
+
9
+ export function parseCronField(field: string, min: number, max: number): Set<number> {
10
+ const result = new Set<number>();
11
+ const parts = field.split(",");
12
+
13
+ for (const part of parts) {
14
+ if (part === "*") {
15
+ for (let i = min; i <= max; i++) result.add(i);
16
+ } else if (part.includes("/")) {
17
+ const [range, stepStr] = part.split("/");
18
+ const step = parseInt(stepStr, 10);
19
+ if (isNaN(step) || step <= 0) throw new Error(`Invalid step value: ${stepStr}`);
20
+
21
+ let start = min;
22
+ let end = max;
23
+
24
+ if (range !== "*") {
25
+ if (range.includes("-")) {
26
+ const [s, e] = range.split("-").map(Number);
27
+ start = s;
28
+ end = e;
29
+ } else {
30
+ start = parseInt(range, 10);
31
+ }
32
+ }
33
+
34
+ if (isNaN(start) || isNaN(end)) throw new Error(`Invalid range: ${range}`);
35
+ if (start < min || end > max) throw new Error(`Range ${start}-${end} out of bounds (${min}-${max})`);
36
+
37
+ for (let i = start; i <= end; i += step) {
38
+ result.add(i);
39
+ }
40
+ } else if (part.includes("-")) {
41
+ const [startStr, endStr] = part.split("-");
42
+ const start = parseInt(startStr, 10);
43
+ const end = parseInt(endStr, 10);
44
+ if (isNaN(start) || isNaN(end)) throw new Error(`Invalid range: ${part}`);
45
+ if (start < min || end > max) throw new Error(`Range ${part} out of bounds (${min}-${max})`);
46
+
47
+ for (let i = start; i <= end; i++) {
48
+ result.add(i);
49
+ }
50
+ } else {
51
+ const val = parseInt(part, 10);
52
+ if (isNaN(val)) throw new Error(`Invalid value: ${part}`);
53
+ if (val < min || val > max) throw new Error(`Value ${val} out of range (${min}-${max})`);
54
+ result.add(val);
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ export function parseCronExpression(expr: string): CronFields {
62
+ const parts = expr.trim().split(/\s+/);
63
+ if (parts.length !== 5) {
64
+ throw new Error(`Cron expression must have 5 fields, got ${parts.length}: "${expr}"`);
65
+ }
66
+
67
+ return {
68
+ minute: parseCronField(parts[0], 0, 59),
69
+ hour: parseCronField(parts[1], 0, 23),
70
+ dayOfMonth: parseCronField(parts[2], 1, 31),
71
+ month: parseCronField(parts[3], 1, 12),
72
+ dayOfWeek: parseCronField(parts[4], 0, 6),
73
+ };
74
+ }
75
+
76
+ /** Cron expressions are evaluated in server-local time, matching standard crontab behavior. */
77
+ export function shouldFire(expr: string, date: Date): boolean {
78
+ const fields = parseCronExpression(expr);
79
+ return (
80
+ fields.minute.has(date.getMinutes()) &&
81
+ fields.hour.has(date.getHours()) &&
82
+ fields.dayOfMonth.has(date.getDate()) &&
83
+ fields.month.has(date.getMonth() + 1) &&
84
+ fields.dayOfWeek.has(date.getDay())
85
+ );
86
+ }
87
+
88
+ export function validateCron(expr: string): { valid: boolean; error?: string } {
89
+ try {
90
+ parseCronExpression(expr);
91
+ return { valid: true };
92
+ } catch (err: any) {
93
+ return { valid: false, error: err.message };
94
+ }
95
+ }
96
+
97
+ export function getNextFire(expr: string, after: Date): Date {
98
+ const fields = parseCronExpression(expr);
99
+ const candidate = new Date(after);
100
+ candidate.setSeconds(0, 0);
101
+ candidate.setMinutes(candidate.getMinutes() + 1);
102
+
103
+ // Search up to 2 years ahead
104
+ const limit = new Date(after);
105
+ limit.setFullYear(limit.getFullYear() + 2);
106
+
107
+ while (candidate < limit) {
108
+ if (
109
+ fields.minute.has(candidate.getMinutes()) &&
110
+ fields.hour.has(candidate.getHours()) &&
111
+ fields.dayOfMonth.has(candidate.getDate()) &&
112
+ fields.month.has(candidate.getMonth() + 1) &&
113
+ fields.dayOfWeek.has(candidate.getDay())
114
+ ) {
115
+ return candidate;
116
+ }
117
+ candidate.setMinutes(candidate.getMinutes() + 1);
118
+ }
119
+
120
+ throw new Error(`No next fire time found within 2 years for: ${expr}`);
121
+ }