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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodboard",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Self-hosted, CLI-first issue tracker and cron scheduler for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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 loadConfig(configDir?: string): Config {
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