prodboard 0.2.0 → 0.2.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # prodboard
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#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.
8
+
3
9
  ## 0.2.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodboard",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
- import { loadConfig, PRODBOARD_DIR } from "../config.ts";
5
+ import { loadConfig, loadConfigRaw, validateConfig, checkWebuiDependencies, 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,57 @@ 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
+ // Validate config
118
+ let config;
119
+ try {
120
+ const { config: cfg, rawParsed } = loadConfigRaw();
121
+ config = cfg;
122
+ const { errors, warnings } = validateConfig(rawParsed);
123
+ for (const e of errors) {
124
+ console.error(`✗ Config: ${e}`);
125
+ }
126
+ if (errors.length > 0) {
127
+ process.exit(1);
128
+ }
129
+ for (const w of warnings) {
130
+ console.warn(`⚠ Config: ${w}`);
131
+ }
132
+ } catch (err: any) {
133
+ console.error(`Config error: ${err.message}`);
134
+ process.exit(1);
135
+ }
136
+
137
+ // Check webui dependencies
138
+ if (config.webui.enabled) {
139
+ const depWarnings = await checkWebuiDependencies();
140
+ for (const w of depWarnings) {
141
+ console.warn(`⚠ ${w}`);
142
+ }
143
+ }
144
+
145
+ // Check systemd availability
146
+ if (!(await systemctlAvailable())) {
147
+ console.error("systemd is not available. daemon restart requires systemd.");
148
+ process.exit(1);
149
+ }
150
+
151
+ // Check service file exists
152
+ const servicePath = path.join(os.homedir(), ".config", "systemd", "user", "prodboard.service");
153
+ if (!fs.existsSync(servicePath)) {
154
+ console.error("prodboard is not installed as a systemd service. Run: prodboard install");
155
+ process.exit(1);
156
+ }
157
+
158
+ // Restart and show status
159
+ const restart = await runSystemctl("restart", "prodboard");
160
+ if (restart.exitCode !== 0) {
161
+ console.error("Failed to restart prodboard:", restart.stderr);
162
+ process.exit(1);
163
+ }
164
+
165
+ console.log("prodboard daemon restarted.");
166
+ const { stdout } = await runSystemctl("status", "prodboard");
167
+ console.log(stdout);
168
+ }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as os from "os";
4
+ import { loadConfigRaw, validateConfig, checkWebuiDependencies } from "../config.ts";
4
5
 
5
6
  const SERVICE_NAME = "prodboard";
6
7
  const SERVICE_DIR = path.join(os.homedir(), ".config", "systemd", "user");
@@ -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",
@@ -62,6 +63,24 @@ WantedBy=default.target
62
63
  export async function install(args: string[]): Promise<void> {
63
64
  const { flags } = parseArgs(args);
64
65
 
66
+ // Validate config before proceeding
67
+ try {
68
+ const { config, rawParsed } = loadConfigRaw();
69
+ const { warnings } = validateConfig(rawParsed);
70
+ for (const w of warnings) {
71
+ console.warn(`⚠ Config: ${w}`);
72
+ }
73
+ if (config.webui.enabled) {
74
+ const depWarnings = await checkWebuiDependencies();
75
+ for (const w of depWarnings) {
76
+ console.warn(`⚠ ${w}`);
77
+ }
78
+ }
79
+ } catch (err: any) {
80
+ console.error(`Config error: ${err.message}`);
81
+ process.exit(1);
82
+ }
83
+
65
84
  if (!(await systemctlAvailable())) {
66
85
  console.error("systemd is not available on this system.");
67
86
  console.error("The install command requires systemd (Linux).");
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,90 @@ 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("webui is enabled but 'hono' is not installed. Run: bun install");
244
+ }
245
+ try {
246
+ await import("hono/jsx/jsx-runtime");
247
+ } catch {
248
+ warnings.push(
249
+ "webui is enabled but the Hono JSX runtime could not be loaded. " +
250
+ "If prodboard is installed globally, you may need to install hono in the global package directory."
251
+ );
252
+ }
253
+ return warnings;
169
254
  }
package/src/index.ts CHANGED
@@ -137,6 +137,8 @@ export async function main(): Promise<void> {
137
137
  const daemonMod = await import("./commands/daemon.ts");
138
138
  if (sub === "status") {
139
139
  await daemonMod.daemonStatus(args.slice(2));
140
+ } else if (sub === "restart") {
141
+ await daemonMod.daemonRestart(args.slice(2));
140
142
  } else {
141
143
  await daemonMod.daemonStart(args.slice(1));
142
144
  }
@@ -201,6 +203,8 @@ Commands:
201
203
  comments <id> List comments for an issue
202
204
  schedule <sub> Manage scheduled tasks
203
205
  daemon Start the scheduler daemon
206
+ daemon restart Restart the daemon (systemd)
207
+ daemon status Show daemon status
204
208
  install Install systemd service
205
209
  uninstall Remove systemd service
206
210
  config Show configuration