prodboard 0.2.0 → 0.2.2
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 +12 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/commands/daemon.ts +28 -0
- package/src/commands/install.ts +3 -2
- package/src/config.ts +137 -3
- package/src/index.ts +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# prodboard
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`62ef4b0`](https://github.com/G4brym/prodboard/commit/62ef4b0a1f6969e3d06f549635ebfd337b84403c) Thanks [@G4brym](https://github.com/G4brym)! - Show config warnings (tmux availability, webui dependencies) on every CLI command. Improved webui dependency messages with actionable install commands.
|
|
8
|
+
|
|
9
|
+
## 0.2.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#9](https://github.com/G4brym/prodboard/pull/9) [`d418fc4`](https://github.com/G4brym/prodboard/commit/d418fc41b52e26789dd3b5be7f2dcdf9429ef287) Thanks [@G4brym](https://github.com/G4brym)! - Add `daemon restart` command with config validation and webui dependency checks. The `install` command now also validates config before proceeding. Invalid config values produce clear warnings with fix tips.
|
|
14
|
+
|
|
3
15
|
## 0.2.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -166,6 +166,7 @@ prodboard schedule stats --days 7 # Statistics
|
|
|
166
166
|
prodboard daemon # Start (foreground)
|
|
167
167
|
prodboard daemon --dry-run # Preview schedules
|
|
168
168
|
prodboard daemon status # Check if running
|
|
169
|
+
prodboard daemon restart # Restart via systemd
|
|
169
170
|
```
|
|
170
171
|
|
|
171
172
|
### Other
|
package/package.json
CHANGED
package/src/commands/daemon.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
3
4
|
import { ensureDb } from "../db.ts";
|
|
4
5
|
import { loadConfig, PRODBOARD_DIR } from "../config.ts";
|
|
5
6
|
import { listSchedules } from "../queries/schedules.ts";
|
|
6
7
|
import { getNextFire } from "../cron.ts";
|
|
7
8
|
import { formatDate } from "../format.ts";
|
|
8
9
|
import { Daemon } from "../scheduler.ts";
|
|
10
|
+
import { systemctlAvailable, runSystemctl } from "./install.ts";
|
|
9
11
|
|
|
10
12
|
function parseArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
|
|
11
13
|
const flags: Record<string, string | boolean> = {};
|
|
@@ -110,3 +112,29 @@ export async function daemonStatus(args: string[]): Promise<void> {
|
|
|
110
112
|
try { fs.unlinkSync(pidFile); } catch {}
|
|
111
113
|
}
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
export async function daemonRestart(_args: string[]): Promise<void> {
|
|
117
|
+
// Check systemd availability
|
|
118
|
+
if (!(await systemctlAvailable())) {
|
|
119
|
+
console.error("systemd is not available. daemon restart requires systemd.");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check service file exists
|
|
124
|
+
const servicePath = path.join(os.homedir(), ".config", "systemd", "user", "prodboard.service");
|
|
125
|
+
if (!fs.existsSync(servicePath)) {
|
|
126
|
+
console.error("prodboard is not installed as a systemd service. Run: prodboard install");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Restart and show status
|
|
131
|
+
const restart = await runSystemctl("restart", "prodboard");
|
|
132
|
+
if (restart.exitCode !== 0) {
|
|
133
|
+
console.error("Failed to restart prodboard:", restart.stderr);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log("prodboard daemon restarted.");
|
|
138
|
+
const { stdout } = await runSystemctl("status", "prodboard");
|
|
139
|
+
console.log(stdout);
|
|
140
|
+
}
|
package/src/commands/install.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as os from "os";
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
const SERVICE_NAME = "prodboard";
|
|
6
7
|
const SERVICE_DIR = path.join(os.homedir(), ".config", "systemd", "user");
|
|
7
8
|
const SERVICE_PATH = path.join(SERVICE_DIR, `${SERVICE_NAME}.service`);
|
|
@@ -16,7 +17,7 @@ function parseArgs(args: string[]): { flags: Record<string, boolean> } {
|
|
|
16
17
|
return { flags };
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async function systemctlAvailable(): Promise<boolean> {
|
|
20
|
+
export async function systemctlAvailable(): Promise<boolean> {
|
|
20
21
|
try {
|
|
21
22
|
const proc = Bun.spawn(["systemctl", "--version"], {
|
|
22
23
|
stdout: "ignore",
|
|
@@ -29,7 +30,7 @@ async function systemctlAvailable(): Promise<boolean> {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
async function runSystemctl(...args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
33
|
+
export async function runSystemctl(...args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
33
34
|
const proc = Bun.spawn(["systemctl", "--user", ...args], {
|
|
34
35
|
stdout: "pipe",
|
|
35
36
|
stderr: "pipe",
|
package/src/config.ts
CHANGED
|
@@ -140,13 +140,13 @@ export function deepMerge(defaults: any, user: any): any {
|
|
|
140
140
|
return result;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
export function
|
|
143
|
+
export function loadConfigRaw(configDir?: string): { config: Config; rawParsed: any } {
|
|
144
144
|
const dir = configDir ?? PRODBOARD_DIR;
|
|
145
145
|
const configPath = path.join(dir, "config.jsonc");
|
|
146
146
|
const defaults = getDefaults();
|
|
147
147
|
|
|
148
148
|
if (!fs.existsSync(configPath)) {
|
|
149
|
-
return defaults;
|
|
149
|
+
return { config: defaults, rawParsed: {} };
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
let text: string;
|
|
@@ -165,5 +165,139 @@ export function loadConfig(configDir?: string): Config {
|
|
|
165
165
|
throw new Error(`Invalid JSON in config file ${configPath}: ${err.message}`);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
return deepMerge(defaults, parsed);
|
|
168
|
+
return { config: deepMerge(defaults, parsed), rawParsed: parsed };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function loadConfig(configDir?: string): Config {
|
|
172
|
+
return loadConfigRaw(configDir).config;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateConfig(rawParsed: any): { errors: string[]; warnings: string[] } {
|
|
176
|
+
const errors: string[] = [];
|
|
177
|
+
const warnings: string[] = [];
|
|
178
|
+
|
|
179
|
+
if (typeof rawParsed !== "object" || rawParsed === null) {
|
|
180
|
+
errors.push("Config must be a JSON object.");
|
|
181
|
+
return { errors, warnings };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const knownTopLevel = ["general", "daemon", "webui"];
|
|
185
|
+
for (const key of Object.keys(rawParsed)) {
|
|
186
|
+
if (!knownTopLevel.includes(key)) {
|
|
187
|
+
warnings.push(`Unknown top-level key "${key}". Known keys: ${knownTopLevel.join(", ")}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const g = rawParsed.general;
|
|
192
|
+
if (g !== undefined) {
|
|
193
|
+
if (g.statuses !== undefined && (!Array.isArray(g.statuses) || !g.statuses.every((s: any) => typeof s === "string"))) {
|
|
194
|
+
warnings.push("general.statuses must be an array of strings.");
|
|
195
|
+
}
|
|
196
|
+
if (g.defaultStatus !== undefined && typeof g.defaultStatus !== "string") {
|
|
197
|
+
warnings.push(`general.defaultStatus must be a string, got ${typeof g.defaultStatus}.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const d = rawParsed.daemon;
|
|
202
|
+
if (d !== undefined) {
|
|
203
|
+
if (d.agent !== undefined && d.agent !== "claude" && d.agent !== "opencode") {
|
|
204
|
+
warnings.push(`daemon.agent must be "claude" or "opencode", got "${d.agent}".`);
|
|
205
|
+
}
|
|
206
|
+
if (d.useWorktrees !== undefined && !["auto", "always", "never"].includes(d.useWorktrees)) {
|
|
207
|
+
warnings.push(`daemon.useWorktrees must be "auto", "always", or "never", got "${d.useWorktrees}".`);
|
|
208
|
+
}
|
|
209
|
+
if (d.useTmux !== undefined && typeof d.useTmux !== "boolean") {
|
|
210
|
+
warnings.push(`daemon.useTmux must be a boolean, got ${typeof d.useTmux}.`);
|
|
211
|
+
}
|
|
212
|
+
for (const numField of ["maxConcurrentRuns", "maxTurns", "hardMaxTurns", "runTimeoutSeconds", "runRetentionDays"]) {
|
|
213
|
+
if (d[numField] !== undefined && typeof d[numField] !== "number") {
|
|
214
|
+
warnings.push(`daemon.${numField} must be a number, got ${typeof d[numField]}.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const w = rawParsed.webui;
|
|
220
|
+
if (w !== undefined) {
|
|
221
|
+
if (w.enabled !== undefined && typeof w.enabled !== "boolean") {
|
|
222
|
+
warnings.push(`webui.enabled must be a boolean, got ${typeof w.enabled}.`);
|
|
223
|
+
}
|
|
224
|
+
if (w.port !== undefined && (typeof w.port !== "number" || w.port < 1 || w.port > 65535)) {
|
|
225
|
+
warnings.push(`webui.port must be a number between 1 and 65535, got ${JSON.stringify(w.port)}.`);
|
|
226
|
+
}
|
|
227
|
+
if (w.hostname !== undefined && typeof w.hostname !== "string") {
|
|
228
|
+
warnings.push(`webui.hostname must be a string, got ${typeof w.hostname}.`);
|
|
229
|
+
}
|
|
230
|
+
if (w.password !== undefined && w.password !== null && typeof w.password !== "string") {
|
|
231
|
+
warnings.push(`webui.password must be a string or null, got ${typeof w.password}.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { errors, warnings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function checkWebuiDependencies(): Promise<string[]> {
|
|
239
|
+
const warnings: string[] = [];
|
|
240
|
+
try {
|
|
241
|
+
await import("hono");
|
|
242
|
+
} catch {
|
|
243
|
+
warnings.push(
|
|
244
|
+
"webui is enabled but 'hono' is not installed. " +
|
|
245
|
+
"Run: bun install hono (or bun install -g hono if prodboard is installed globally)"
|
|
246
|
+
);
|
|
247
|
+
return warnings; // skip JSX check if hono itself is missing
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await import("hono/jsx/jsx-runtime");
|
|
251
|
+
} catch {
|
|
252
|
+
warnings.push(
|
|
253
|
+
"webui is enabled but the Hono JSX runtime could not be loaded. " +
|
|
254
|
+
"Run: bun install hono (or bun install -g hono if prodboard is installed globally)"
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return warnings;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function checkTmuxAvailable(): Promise<string | null> {
|
|
261
|
+
try {
|
|
262
|
+
const proc = Bun.spawn(["tmux", "-V"], { stdout: "ignore", stderr: "ignore" });
|
|
263
|
+
const code = await proc.exited;
|
|
264
|
+
if (code !== 0) {
|
|
265
|
+
return "daemon.useTmux is enabled but tmux is not installed. Install it (e.g. apt install tmux) or set useTmux to false.";
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
} catch {
|
|
269
|
+
return "daemon.useTmux is enabled but tmux is not installed. Install it (e.g. apt install tmux) or set useTmux to false.";
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function printConfigWarnings(): Promise<void> {
|
|
274
|
+
let config: Config;
|
|
275
|
+
let rawParsed: any;
|
|
276
|
+
try {
|
|
277
|
+
const result = loadConfigRaw();
|
|
278
|
+
config = result.config;
|
|
279
|
+
rawParsed = result.rawParsed;
|
|
280
|
+
} catch (err: any) {
|
|
281
|
+
console.warn(`⚠ Config: ${err.message}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { warnings } = validateConfig(rawParsed);
|
|
286
|
+
for (const w of warnings) {
|
|
287
|
+
console.warn(`⚠ Config: ${w}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (config.daemon.useTmux) {
|
|
291
|
+
const tmuxWarning = await checkTmuxAvailable();
|
|
292
|
+
if (tmuxWarning) {
|
|
293
|
+
console.warn(`⚠ ${tmuxWarning}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (config.webui.enabled) {
|
|
298
|
+
const depWarnings = await checkWebuiDependencies();
|
|
299
|
+
for (const w of depWarnings) {
|
|
300
|
+
console.warn(`⚠ ${w}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
169
303
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import { PRODBOARD_DIR } from "./config.ts";
|
|
2
|
+
import { PRODBOARD_DIR, printConfigWarnings } from "./config.ts";
|
|
3
3
|
|
|
4
4
|
export class NotInitializedError extends Error {
|
|
5
5
|
constructor() {
|
|
@@ -36,6 +36,17 @@ export async function main(): Promise<void> {
|
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Show config warnings for commands that use the config
|
|
40
|
+
const skipWarnings = ["init", "mcp", "version", "--version", "help", "--help", "uninstall"];
|
|
41
|
+
if (!skipWarnings.includes(command)) {
|
|
42
|
+
try {
|
|
43
|
+
ensureInitialized();
|
|
44
|
+
await printConfigWarnings();
|
|
45
|
+
} catch {
|
|
46
|
+
// ensureInitialized will throw again inside the switch if needed
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
try {
|
|
40
51
|
switch (command) {
|
|
41
52
|
case "init": {
|
|
@@ -137,6 +148,8 @@ export async function main(): Promise<void> {
|
|
|
137
148
|
const daemonMod = await import("./commands/daemon.ts");
|
|
138
149
|
if (sub === "status") {
|
|
139
150
|
await daemonMod.daemonStatus(args.slice(2));
|
|
151
|
+
} else if (sub === "restart") {
|
|
152
|
+
await daemonMod.daemonRestart(args.slice(2));
|
|
140
153
|
} else {
|
|
141
154
|
await daemonMod.daemonStart(args.slice(1));
|
|
142
155
|
}
|
|
@@ -201,6 +214,8 @@ Commands:
|
|
|
201
214
|
comments <id> List comments for an issue
|
|
202
215
|
schedule <sub> Manage scheduled tasks
|
|
203
216
|
daemon Start the scheduler daemon
|
|
217
|
+
daemon restart Restart the daemon (systemd)
|
|
218
|
+
daemon status Show daemon status
|
|
204
219
|
install Install systemd service
|
|
205
220
|
uninstall Remove systemd service
|
|
206
221
|
config Show configuration
|