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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +302 -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/install.ts +127 -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 +227 -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
|
@@ -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
|
+
}
|