prodboard 0.1.3 → 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,17 @@
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
+
9
+ ## 0.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#6](https://github.com/G4brym/prodboard/pull/6) [`030c913`](https://github.com/G4brym/prodboard/commit/030c9136dc344e93616e0ef6014bab25d45b50d5) Thanks [@G4brym](https://github.com/G4brym)! - Add OpenCode support, prodboard-managed worktrees, tmux session wrapping, configurable base path, and web UI
14
+
3
15
  ## 0.1.3
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,22 +1,27 @@
1
1
  # prodboard
2
2
 
3
- Give Claude Code a persistent task board and a cron scheduler so it can manage work across sessions.
3
+ Give AI coding agents a persistent task board and a cron scheduler so they can manage work across sessions.
4
4
 
5
- **The problem:** Claude Code loses context between sessions. It can't remember what tasks exist, what's in progress, or what to work on next. There's no way to schedule it to run recurring jobs like daily triage or nightly CI.
5
+ **The problem:** AI coding agents lose context between sessions. They can't remember what tasks exist, what's in progress, or what to work on next. There's no way to schedule them to run recurring jobs like daily triage or nightly CI.
6
6
 
7
- **The solution:** prodboard is a local issue tracker backed by SQLite that Claude Code can read and write through MCP tools. It also includes a cron daemon that spawns Claude Code on a schedule to work through tasks autonomously.
7
+ **The solution:** prodboard is a local issue tracker backed by SQLite that agents can read and write through MCP tools. It includes a cron daemon that spawns agents on a schedule, with optional tmux session wrapping and git worktree isolation.
8
8
 
9
9
  ```
10
10
  You (CLI) ──┐
11
- ├──▶ SQLite DB ◀── MCP Server ◀── Claude Code
12
- Cron Daemon ──┘
11
+ ├──▶ SQLite DB ◀── MCP Server ◀── Claude Code / OpenCode
12
+ Cron Daemon ──┘
13
+ Web UI ─────────────────┘
13
14
  ```
14
15
 
15
16
  ## What You Get
16
17
 
17
- - **An issue board Claude Code can use** — Claude reads, creates, updates, and completes issues via MCP tools during any session
18
- - **Scheduled Claude Code runs** — Define cron jobs that spawn Claude Code to triage issues, run maintenance, or work through the backlog
19
- - **A CLI you can use too** — Same board, human-friendly commands. Add issues, check status, review what Claude did
18
+ - **An issue board your agent can use** — The agent reads, creates, updates, and completes issues via MCP tools during any session
19
+ - **Scheduled agent runs** — Define cron jobs that spawn your agent to triage issues, run maintenance, or work through the backlog
20
+ - **Multiple agent support** — Works with Claude Code (default) and OpenCode
21
+ - **tmux sessions** — Running agents are wrapped in tmux sessions you can attach to and watch live
22
+ - **Git worktree isolation** — Each scheduled run gets its own worktree so concurrent runs don't conflict
23
+ - **Web UI** — Optional browser-based kanban board for managing issues, schedules, and runs
24
+ - **A CLI you can use too** — Same board, human-friendly commands. Add issues, check status, review what the agent did
20
25
  - **Everything local** — Single SQLite file at `~/.prodboard/db.sqlite`. No servers, no accounts, no cloud
21
26
 
22
27
  ## Quick Start
@@ -195,6 +200,30 @@ These are the tools Claude Code sees when connected to the board:
195
200
 
196
201
  MCP resources: `prodboard://issues` (board summary) and `prodboard://schedules` (active schedules).
197
202
 
203
+ ## Supported Agents
204
+
205
+ prodboard works with multiple AI coding agents. Set `daemon.agent` in your config:
206
+
207
+ | Agent | Config value | Notes |
208
+ |-------|-------------|-------|
209
+ | **Claude Code** | `"claude"` (default) | Uses `claude` CLI with `--dangerously-skip-permissions` |
210
+ | **OpenCode** | `"opencode"` | Uses `opencode run` with JSON output. Prodboard auto-starts `opencode serve` if needed |
211
+
212
+ OpenCode-specific settings:
213
+
214
+ ```jsonc
215
+ {
216
+ "daemon": {
217
+ "agent": "opencode",
218
+ "opencode": {
219
+ "serverUrl": null, // auto-detect or override (e.g., "http://localhost:4096")
220
+ "model": null, // e.g., "anthropic/claude-sonnet-4-20250514"
221
+ "agent": null // opencode agent name
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
198
227
  ## Configuration
199
228
 
200
229
  Config file: `~/.prodboard/config.jsonc`
@@ -207,17 +236,94 @@ Config file: `~/.prodboard/config.jsonc`
207
236
  "idPrefix": ""
208
237
  },
209
238
  "daemon": {
239
+ "agent": "claude", // "claude" or "opencode"
240
+ "basePath": null, // base path for worktrees and runs (null = use schedule workdir)
241
+ "useTmux": true, // wrap agent runs in tmux sessions
210
242
  "maxConcurrentRuns": 2,
211
243
  "maxTurns": 50,
212
244
  "hardMaxTurns": 200,
213
245
  "runTimeoutSeconds": 1800,
214
246
  "runRetentionDays": 30,
215
247
  "logLevel": "info",
248
+ "logMaxSizeMb": 10, // max size per log file in MB
249
+ "logMaxFiles": 5, // max number of rotated log files
250
+ "defaultAllowedTools": [...], // tools allowed for git-repo runs
251
+ "nonGitDefaultAllowedTools": [...], // tools allowed for non-git runs
252
+ "useWorktrees": "auto" // "auto", "always", or "never"
253
+ },
254
+ "webui": {
255
+ "enabled": false, // enable the web UI
256
+ "port": 3838,
257
+ "hostname": "127.0.0.1",
258
+ "password": null // set a password to require login
259
+ }
260
+ }
261
+ ```
262
+
263
+ ## Web UI
264
+
265
+ prodboard includes an optional browser-based interface for managing issues, schedules, and runs.
266
+
267
+ To enable it, set `webui.enabled` in your config:
268
+
269
+ ```jsonc
270
+ {
271
+ "webui": {
272
+ "enabled": true,
273
+ "port": 3838,
274
+ "password": "your-secret" // optional — null for no auth
275
+ }
276
+ }
277
+ ```
278
+
279
+ Start the daemon and open `http://127.0.0.1:3838`. The web UI provides:
280
+
281
+ - Kanban board with drag-and-drop issue management
282
+ - Schedule creation and editing
283
+ - Run monitoring with status, cost, and token usage
284
+ - Password protection when `password` is set
285
+
286
+ ## tmux Sessions
287
+
288
+ When `daemon.useTmux` is `true` (the default) and tmux is installed, each agent run is wrapped in a detached tmux session. This lets you attach and watch the agent work in real time:
289
+
290
+ ```bash
291
+ # List active prodboard sessions
292
+ tmux list-sessions | grep prodboard
293
+
294
+ # Attach to a running agent
295
+ tmux attach -t prodboard-<run-id-prefix>
296
+ ```
297
+
298
+ The session name is `prodboard-` followed by the first 8 characters of the run ID (visible in `prodboard schedule logs`).
299
+
300
+ If tmux is not installed, runs fall back to direct process spawning with piped stdout. A warning is logged at daemon startup.
301
+
302
+ ## Git Worktrees
303
+
304
+ prodboard creates isolated git worktrees for each scheduled run, so concurrent runs in the same repository don't conflict.
305
+
306
+ **Requirement:** You must set `daemon.basePath` in your config for worktrees to work. This tells prodboard where to create the `.worktrees/` directory. If `basePath` is `null` (the default), worktrees are disabled regardless of the `useWorktrees` setting.
307
+
308
+ ```jsonc
309
+ {
310
+ "daemon": {
311
+ "basePath": "/home/you/my-project", // required for worktrees
216
312
  "useWorktrees": "auto"
217
313
  }
218
314
  }
219
315
  ```
220
316
 
317
+ | `useWorktrees` | Behavior |
318
+ |---------------|----------|
319
+ | `"auto"` (default) | Create worktrees when `basePath` is set, the directory is a git repo, and the schedule allows it |
320
+ | `"always"` | Always create worktrees (fails if directory is not a git repo) |
321
+ | `"never"` | Never create worktrees |
322
+
323
+ Worktrees are created under `<basePath>/.worktrees/<run-id>` on a branch named `prodboard/<run-id>`, and automatically cleaned up (directory + branch deleted) after the run completes.
324
+
325
+ Per-schedule control: set `use_worktree` to `false` on a schedule to skip worktree creation for that specific job.
326
+
221
327
  ## Scheduler Details
222
328
 
223
329
  ### Cron Syntax
@@ -282,10 +388,14 @@ prodboard install --force
282
388
 
283
389
  **MCP not connecting** — Verify `claude mcp add prodboard -- bunx prodboard mcp` was run, or check `~/.prodboard/mcp.json`.
284
390
 
285
- **Daemon not starting** — Check `~/.prodboard/logs/daemon.log`. Make sure `claude` CLI is installed and `ANTHROPIC_API_KEY` is set.
391
+ **Daemon not starting** — Check `~/.prodboard/logs/daemon.log`. Make sure your agent CLI is installed (`claude` or `opencode`) and `ANTHROPIC_API_KEY` is set.
286
392
 
287
393
  **Stale PID file** — The daemon crashed. Run `prodboard daemon` to restart (auto-cleans stale PIDs).
288
394
 
395
+ **tmux not working** — Install tmux (`apt install tmux` / `brew install tmux`). The daemon falls back to direct spawning without it.
396
+
397
+ **Worktree errors** — Ensure the working directory is a git repo with at least one commit. Set `useWorktrees: "never"` to disable.
398
+
289
399
  ## Development
290
400
 
291
401
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodboard",
3
- "version": "0.1.3",
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",
@@ -33,7 +33,8 @@
33
33
  "release": "bun run test && bun run typecheck && npm publish"
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.12.1"
36
+ "@modelcontextprotocol/sdk": "^1.12.1",
37
+ "hono": "^4.12.5"
37
38
  },
38
39
  "engines": {
39
40
  "bun": ">=1.0.0"
@@ -46,6 +47,7 @@
46
47
  "CHANGELOG.md"
47
48
  ],
48
49
  "devDependencies": {
50
+ "@changesets/changelog-github": "^0.5.2",
49
51
  "@changesets/cli": "^2.29.8",
50
52
  "@types/bun": "^1.3.9"
51
53
  }
@@ -0,0 +1,107 @@
1
+ import * as path from "path";
2
+ import { PRODBOARD_DIR } from "../config.ts";
3
+ import { getLastSessionId } from "../queries/runs.ts";
4
+ import type { AgentDriver, AgentRunContext, AgentResult, StreamEvent } from "./types.ts";
5
+
6
+ export class ClaudeDriver implements AgentDriver {
7
+ readonly name = "claude";
8
+
9
+ buildCommand(ctx: AgentRunContext): string[] {
10
+ const { schedule, config, env, resolvedPrompt, db } = ctx;
11
+ const args: string[] = ["claude"];
12
+
13
+ args.push("-p", resolvedPrompt);
14
+ args.push("--dangerously-skip-permissions");
15
+ args.push("--verbose", "--output-format", "stream-json");
16
+
17
+ const mcpConfigPath = path.join(PRODBOARD_DIR, "mcp.json");
18
+ args.push("--mcp-config", mcpConfigPath);
19
+
20
+ const systemPromptFile = env.hasGit
21
+ ? path.join(PRODBOARD_DIR, "system-prompt.md")
22
+ : path.join(PRODBOARD_DIR, "system-prompt-nogit.md");
23
+ args.push("--append-system-prompt-file", systemPromptFile);
24
+
25
+ const scheduleTurns = schedule.max_turns ?? config.daemon.maxTurns;
26
+ const maxTurns = Math.min(scheduleTurns, config.daemon.hardMaxTurns);
27
+ args.push("--max-turns", String(maxTurns));
28
+
29
+ let tools: string[];
30
+ if (schedule.allowed_tools) {
31
+ try {
32
+ tools = JSON.parse(schedule.allowed_tools);
33
+ } catch {
34
+ tools = env.hasGit ? config.daemon.defaultAllowedTools : config.daemon.nonGitDefaultAllowedTools;
35
+ }
36
+ } else if (!env.hasGit) {
37
+ tools = config.daemon.nonGitDefaultAllowedTools;
38
+ } else {
39
+ tools = config.daemon.defaultAllowedTools;
40
+ }
41
+ for (const tool of tools) {
42
+ args.push("--allowedTools", tool);
43
+ }
44
+
45
+ if (schedule.persist_session && db) {
46
+ const lastSessionId = getLastSessionId(db, schedule.id);
47
+ if (lastSessionId) {
48
+ args.push("--resume", lastSessionId);
49
+ }
50
+ }
51
+
52
+ if (schedule.agents_json) {
53
+ args.push("--agents", schedule.agents_json);
54
+ }
55
+
56
+ return args;
57
+ }
58
+
59
+ parseEvent(line: string): StreamEvent | null {
60
+ try {
61
+ return JSON.parse(line.trim());
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ extractResult(events: StreamEvent[]): AgentResult {
68
+ let tokens_in = 0;
69
+ let tokens_out = 0;
70
+ let cost_usd = 0;
71
+ let session_id: string | null = null;
72
+ const tools_used = new Set<string>();
73
+ const issues_touched = new Set<string>();
74
+
75
+ for (const event of events) {
76
+ if (event.type === "init" && event.session_id) {
77
+ session_id = event.session_id;
78
+ }
79
+ if (event.type === "tool_use" && event.tool) {
80
+ tools_used.add(event.tool);
81
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.id) {
82
+ issues_touched.add(event.tool_input.id);
83
+ }
84
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.issue_id) {
85
+ issues_touched.add(event.tool_input.issue_id);
86
+ }
87
+ }
88
+ if (event.type === "result") {
89
+ if (event.result?.tokens_in) tokens_in = event.result.tokens_in;
90
+ if (event.result?.tokens_out) tokens_out = event.result.tokens_out;
91
+ if (event.result?.cost_usd) cost_usd = event.result.cost_usd;
92
+ }
93
+ if (event.tokens_in) tokens_in = event.tokens_in;
94
+ if (event.tokens_out) tokens_out = event.tokens_out;
95
+ if (event.cost_usd) cost_usd = event.cost_usd;
96
+ }
97
+
98
+ return {
99
+ tokens_in,
100
+ tokens_out,
101
+ cost_usd,
102
+ session_id,
103
+ tools_used: [...tools_used],
104
+ issues_touched: [...issues_touched],
105
+ };
106
+ }
107
+ }
@@ -0,0 +1,18 @@
1
+ import type { Config } from "../types.ts";
2
+ import type { AgentDriver } from "./types.ts";
3
+ import { ClaudeDriver } from "./claude.ts";
4
+ import { OpenCodeDriver } from "./opencode.ts";
5
+
6
+ export function createAgentDriver(config: Config): AgentDriver {
7
+ switch (config.daemon.agent) {
8
+ case "opencode":
9
+ return new OpenCodeDriver();
10
+ case "claude":
11
+ default:
12
+ return new ClaudeDriver();
13
+ }
14
+ }
15
+
16
+ export type { AgentDriver, AgentRunContext, AgentResult, StreamEvent } from "./types.ts";
17
+ export { ClaudeDriver } from "./claude.ts";
18
+ export { OpenCodeDriver } from "./opencode.ts";
@@ -0,0 +1,108 @@
1
+ import type { AgentDriver, AgentRunContext, AgentResult, StreamEvent } from "./types.ts";
2
+ import { getLastSessionId } from "../queries/runs.ts";
3
+
4
+ export class OpenCodeDriver implements AgentDriver {
5
+ readonly name = "opencode";
6
+
7
+ buildCommand(ctx: AgentRunContext): string[] {
8
+ const { schedule, config, resolvedPrompt, workdir, db } = ctx;
9
+ const args: string[] = ["opencode", "run", resolvedPrompt];
10
+
11
+ args.push("--format", "json");
12
+ args.push("--dir", workdir);
13
+
14
+ const opencode = config.daemon.opencode;
15
+
16
+ if (opencode.serverUrl) {
17
+ args.push("--attach", opencode.serverUrl);
18
+ }
19
+
20
+ if (opencode.model) {
21
+ args.push("--model", opencode.model);
22
+ }
23
+
24
+ if (opencode.agent) {
25
+ args.push("--agent", opencode.agent);
26
+ }
27
+
28
+ if (schedule.persist_session && db) {
29
+ const lastSessionId = getLastSessionId(db, schedule.id);
30
+ if (lastSessionId) {
31
+ args.push("--session", lastSessionId, "--continue");
32
+ }
33
+ }
34
+
35
+ return args;
36
+ }
37
+
38
+ parseEvent(line: string): StreamEvent | null {
39
+ let text = line.trim();
40
+ if (!text) return null;
41
+
42
+ // Strip SSE data: prefix
43
+ if (text.startsWith("data: ")) {
44
+ text = text.slice(6);
45
+ }
46
+
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ extractResult(events: StreamEvent[]): AgentResult {
55
+ let tokens_in = 0;
56
+ let tokens_out = 0;
57
+ let cost_usd = 0;
58
+ let session_id: string | null = null;
59
+ const tools_used = new Set<string>();
60
+ const issues_touched = new Set<string>();
61
+
62
+ for (const event of events) {
63
+ // OpenCode session.updated events
64
+ if (event.type === "session.updated" && event.session?.id) {
65
+ session_id = event.session.id;
66
+ }
67
+ if (event.type === "session.updated" && event.session?.usage) {
68
+ const usage = event.session.usage;
69
+ if (usage.input_tokens) tokens_in = usage.input_tokens;
70
+ if (usage.output_tokens) tokens_out = usage.output_tokens;
71
+ if (usage.cost_usd) cost_usd = usage.cost_usd;
72
+ }
73
+
74
+ // OpenCode tool events
75
+ if (event.type === "tool_use" && event.tool) {
76
+ tools_used.add(event.tool);
77
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.id) {
78
+ issues_touched.add(event.tool_input.id);
79
+ }
80
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.issue_id) {
81
+ issues_touched.add(event.tool_input.issue_id);
82
+ }
83
+ }
84
+
85
+ // Also handle message.part.updated events with tool info
86
+ if (event.type === "message.part.updated" && event.part?.type === "tool-invocation") {
87
+ if (event.part.toolName) {
88
+ tools_used.add(event.part.toolName);
89
+ }
90
+ }
91
+
92
+ // Fallback top-level fields
93
+ if (event.session_id) session_id = event.session_id;
94
+ if (event.tokens_in) tokens_in = event.tokens_in;
95
+ if (event.tokens_out) tokens_out = event.tokens_out;
96
+ if (event.cost_usd) cost_usd = event.cost_usd;
97
+ }
98
+
99
+ return {
100
+ tokens_in,
101
+ tokens_out,
102
+ cost_usd,
103
+ session_id,
104
+ tools_used: [...tools_used],
105
+ issues_touched: [...issues_touched],
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,41 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { Config, Schedule, Run, EnvironmentInfo } from "../types.ts";
3
+
4
+ export interface AgentResult {
5
+ tokens_in: number;
6
+ tokens_out: number;
7
+ cost_usd: number;
8
+ session_id: string | null;
9
+ tools_used: string[];
10
+ issues_touched: string[];
11
+ }
12
+
13
+ export interface AgentRunContext {
14
+ schedule: Schedule;
15
+ run: Run;
16
+ config: Config;
17
+ env: EnvironmentInfo;
18
+ resolvedPrompt: string;
19
+ workdir: string;
20
+ db: Database;
21
+ }
22
+
23
+ export interface StreamEvent {
24
+ type: string;
25
+ session_id?: string;
26
+ tool?: string;
27
+ tool_input?: any;
28
+ result?: {
29
+ tokens_in?: number;
30
+ tokens_out?: number;
31
+ cost_usd?: number;
32
+ };
33
+ [key: string]: any;
34
+ }
35
+
36
+ export interface AgentDriver {
37
+ readonly name: string;
38
+ buildCommand(ctx: AgentRunContext): string[];
39
+ parseEvent(line: string): StreamEvent | null;
40
+ extractResult(events: StreamEvent[]): AgentResult;
41
+ }
@@ -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).");
@@ -251,6 +251,7 @@ export async function scheduleRun(args: string[], dbOverride?: Database): Promis
251
251
  prompt_used: schedule.prompt,
252
252
  });
253
253
 
254
+ // CLI schedule run is a lightweight path without tmux/worktree support
254
255
  const em = new ExecutionManager(db, config);
255
256
  await em.executeRun(schedule, run);
256
257