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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/bin/prodboard.ts +4 -0
- package/config.schema.json +100 -0
- package/package.json +47 -6
- package/src/commands/comments.ts +86 -0
- package/src/commands/daemon.ts +112 -0
- package/src/commands/init.ts +83 -0
- package/src/commands/issues.ts +268 -0
- package/src/commands/schedules.ts +276 -0
- package/src/config.ts +155 -0
- package/src/confirm.ts +14 -0
- package/src/cron.ts +121 -0
- package/src/db.ts +157 -0
- package/src/format.ts +99 -0
- package/src/ids.ts +5 -0
- package/src/index.ts +214 -0
- package/src/invocation.ts +102 -0
- package/src/logger.ts +84 -0
- package/src/mcp.ts +543 -0
- package/src/queries/comments.ts +31 -0
- package/src/queries/issues.ts +155 -0
- package/src/queries/runs.ts +159 -0
- package/src/queries/schedules.ts +115 -0
- package/src/scheduler.ts +411 -0
- package/src/templates.ts +43 -0
- package/src/types.ts +82 -0
- package/templates/CLAUDE.md +12 -0
- package/templates/config.jsonc +38 -0
- package/templates/mcp.json +8 -0
- package/templates/system-prompt-nogit.md +33 -0
- package/templates/system-prompt.md +31 -0
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
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import { PRODBOARD_DIR } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
export function getDbPath(): string {
|
|
7
|
+
return path.join(PRODBOARD_DIR, "db.sqlite");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getDb(dbPath?: string): Database {
|
|
11
|
+
const p = dbPath ?? getDbPath();
|
|
12
|
+
if (p !== ":memory:") {
|
|
13
|
+
const dir = path.dirname(p);
|
|
14
|
+
if (!fs.existsSync(dir)) {
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const db = new Database(p);
|
|
19
|
+
if (p !== ":memory:") {
|
|
20
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
21
|
+
}
|
|
22
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ensureDb(): Database {
|
|
27
|
+
return getDb(getDbPath());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Migration {
|
|
31
|
+
version: number;
|
|
32
|
+
sql: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MIGRATIONS: Migration[] = [
|
|
36
|
+
{
|
|
37
|
+
version: 1,
|
|
38
|
+
sql: `
|
|
39
|
+
CREATE TABLE IF NOT EXISTS issues (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
title TEXT NOT NULL,
|
|
42
|
+
description TEXT NOT NULL DEFAULT '',
|
|
43
|
+
status TEXT NOT NULL DEFAULT 'todo',
|
|
44
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
45
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
issue_id TEXT NOT NULL,
|
|
51
|
+
body TEXT NOT NULL,
|
|
52
|
+
author TEXT NOT NULL DEFAULT 'user',
|
|
53
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
54
|
+
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
name TEXT NOT NULL,
|
|
60
|
+
cron TEXT NOT NULL,
|
|
61
|
+
prompt TEXT NOT NULL,
|
|
62
|
+
workdir TEXT NOT NULL DEFAULT '.',
|
|
63
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
64
|
+
max_turns INTEGER,
|
|
65
|
+
allowed_tools TEXT,
|
|
66
|
+
use_worktree INTEGER NOT NULL DEFAULT 1,
|
|
67
|
+
inject_context INTEGER NOT NULL DEFAULT 1,
|
|
68
|
+
persist_session INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
agents_json TEXT,
|
|
70
|
+
source TEXT NOT NULL DEFAULT 'cli',
|
|
71
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
72
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
schedule_id TEXT NOT NULL,
|
|
78
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
79
|
+
prompt_used TEXT NOT NULL,
|
|
80
|
+
pid INTEGER,
|
|
81
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
82
|
+
finished_at TEXT,
|
|
83
|
+
exit_code INTEGER,
|
|
84
|
+
stdout_tail TEXT,
|
|
85
|
+
stderr_tail TEXT,
|
|
86
|
+
session_id TEXT,
|
|
87
|
+
worktree_path TEXT,
|
|
88
|
+
tokens_in INTEGER,
|
|
89
|
+
tokens_out INTEGER,
|
|
90
|
+
cost_usd REAL,
|
|
91
|
+
tools_used TEXT,
|
|
92
|
+
issues_touched TEXT,
|
|
93
|
+
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_issues_updated ON issues(updated_at);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_comments_issue ON comments(issue_id);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_runs_schedule ON runs(schedule_id);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at);
|
|
102
|
+
`,
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
export { MIGRATIONS };
|
|
107
|
+
|
|
108
|
+
function splitStatements(sql: string): string[] {
|
|
109
|
+
const results: string[] = [];
|
|
110
|
+
let current = "";
|
|
111
|
+
let depth = 0;
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < sql.length; i++) {
|
|
114
|
+
const ch = sql[i];
|
|
115
|
+
if (ch === "(") depth++;
|
|
116
|
+
else if (ch === ")") depth--;
|
|
117
|
+
else if (ch === ";" && depth === 0) {
|
|
118
|
+
const trimmed = current.trim();
|
|
119
|
+
if (trimmed.length > 0) results.push(trimmed);
|
|
120
|
+
current = "";
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
current += ch;
|
|
124
|
+
}
|
|
125
|
+
const trimmed = current.trim();
|
|
126
|
+
if (trimmed.length > 0) results.push(trimmed);
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function execStatements(db: Database, sql: string): void {
|
|
131
|
+
for (const stmt of splitStatements(sql)) {
|
|
132
|
+
db.exec(stmt);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runMigrations(db: Database): void {
|
|
137
|
+
db.exec(
|
|
138
|
+
"CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const appliedRows = db.query("SELECT version FROM _migrations ORDER BY version").all() as { version: number }[];
|
|
142
|
+
const applied = new Set(appliedRows.map((r) => r.version));
|
|
143
|
+
|
|
144
|
+
for (const migration of MIGRATIONS) {
|
|
145
|
+
if (applied.has(migration.version)) continue;
|
|
146
|
+
|
|
147
|
+
db.exec("BEGIN TRANSACTION");
|
|
148
|
+
try {
|
|
149
|
+
execStatements(db, migration.sql);
|
|
150
|
+
db.exec(`INSERT INTO _migrations (version) VALUES (${migration.version})`);
|
|
151
|
+
db.exec("COMMIT");
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
try { db.exec("ROLLBACK"); } catch {}
|
|
154
|
+
throw new Error(`Migration v${migration.version} failed: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const NO_COLOR = !!process.env.NO_COLOR;
|
|
2
|
+
|
|
3
|
+
export function color(text: string, code: number): string {
|
|
4
|
+
if (NO_COLOR) return text;
|
|
5
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function dim(text: string): string {
|
|
9
|
+
return color(text, 2);
|
|
10
|
+
}
|
|
11
|
+
export function bold(text: string): string {
|
|
12
|
+
return color(text, 1);
|
|
13
|
+
}
|
|
14
|
+
export function green(text: string): string {
|
|
15
|
+
return color(text, 32);
|
|
16
|
+
}
|
|
17
|
+
export function red(text: string): string {
|
|
18
|
+
return color(text, 31);
|
|
19
|
+
}
|
|
20
|
+
export function yellow(text: string): string {
|
|
21
|
+
return color(text, 33);
|
|
22
|
+
}
|
|
23
|
+
export function cyan(text: string): string {
|
|
24
|
+
return color(text, 36);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderTable(
|
|
28
|
+
headers: string[],
|
|
29
|
+
rows: string[][],
|
|
30
|
+
options?: { maxWidths?: number[] }
|
|
31
|
+
): string {
|
|
32
|
+
const maxWidths = options?.maxWidths;
|
|
33
|
+
|
|
34
|
+
// Calculate column widths
|
|
35
|
+
const colWidths = headers.map((h, i) => {
|
|
36
|
+
let max = h.length;
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
if (row[i] && row[i].length > max) max = row[i].length;
|
|
39
|
+
}
|
|
40
|
+
if (maxWidths && maxWidths[i] && max > maxWidths[i]) {
|
|
41
|
+
max = maxWidths[i];
|
|
42
|
+
}
|
|
43
|
+
return max;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function truncate(text: string, width: number): string {
|
|
47
|
+
if (text.length <= width) return text;
|
|
48
|
+
return text.slice(0, width - 1) + "…";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pad(text: string, width: number): string {
|
|
52
|
+
const t = truncate(text, width);
|
|
53
|
+
return t + " ".repeat(Math.max(0, width - t.length));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const top =
|
|
57
|
+
"┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
|
|
58
|
+
const mid =
|
|
59
|
+
"├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
|
|
60
|
+
const bot =
|
|
61
|
+
"└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
|
|
62
|
+
|
|
63
|
+
const headerRow =
|
|
64
|
+
"│" +
|
|
65
|
+
headers.map((h, i) => " " + pad(h, colWidths[i]) + " ").join("│") +
|
|
66
|
+
"│";
|
|
67
|
+
|
|
68
|
+
const dataRows = rows.map(
|
|
69
|
+
(row) =>
|
|
70
|
+
"│" +
|
|
71
|
+
row
|
|
72
|
+
.map((cell, i) => " " + pad(cell ?? "", colWidths[i]) + " ")
|
|
73
|
+
.join("│") +
|
|
74
|
+
"│"
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const lines = [top, headerRow];
|
|
78
|
+
if (rows.length > 0) {
|
|
79
|
+
lines.push(mid);
|
|
80
|
+
lines.push(...dataRows);
|
|
81
|
+
}
|
|
82
|
+
lines.push(bot);
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatDate(iso: string): string {
|
|
87
|
+
if (!iso) return "";
|
|
88
|
+
const d = new Date(iso + (iso.includes("Z") || iso.includes("+") ? "" : "Z"));
|
|
89
|
+
const year = d.getUTCFullYear();
|
|
90
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
91
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
92
|
+
const hour = String(d.getUTCHours()).padStart(2, "0");
|
|
93
|
+
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
|
94
|
+
return `${year}-${month}-${day} ${hour}:${min}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function jsonOutput(data: unknown): string {
|
|
98
|
+
return JSON.stringify(data);
|
|
99
|
+
}
|