Skip to content

Commit 1bf5c67

Browse files
committed
feat: init, validate and copy functionality
1 parent 65af537 commit 1bf5c67

5 files changed

Lines changed: 344 additions & 5 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ groups:
249249
| `Ctrl+u/d` | Half page up / down |
250250
| `e` | Export current service logs |
251251
| `E` (shift) | Export all logs |
252+
| `y` | Copy last log to clipboard |
253+
| `Y` (shift) | Copy all visible logs |
252254

253255
### Search
254256

@@ -282,6 +284,8 @@ Commands:
282284
down Stop all services
283285
restart Restart all services
284286
status Show service status
287+
init Create a new devproc.yaml config file
288+
validate Validate the config file without starting services
285289
286290
Options:
287291
-c, --config Path to config file (default: devproc.yaml)
@@ -290,6 +294,22 @@ Options:
290294
-v, --version Show version
291295
```
292296

297+
### Quick Start with `init`
298+
299+
```bash
300+
# Create a new config file in the current directory
301+
devproc init
302+
303+
# Validate your config before running
304+
devproc validate
305+
```
306+
307+
The `init` command will:
308+
309+
- Detect your project name from `package.json` if present
310+
- Suggest services based on npm scripts (dev, start, etc.)
311+
- Create a commented template with examples
312+
293313
### Config Reload
294314

295315
DevProc supports hot-reloading your configuration without restarting:

docs/ROADMAP.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@ Optional web UI for remote access.
215215
### Configuration
216216

217217
- [ ] JSON schema for IDE autocomplete
218-
- [ ] Config validation command: `devproc validate`
219-
- [ ] Init command: `devproc init` to generate starter config
218+
- [x] Config validation command: `devproc validate`
219+
- [x] Init command: `devproc init` to generate starter config
220220
- [ ] Import from docker-compose.yml
221221
- [ ] Import from Procfile
222222

@@ -228,7 +228,7 @@ Optional web UI for remote access.
228228
- [ ] Resize panes with keyboard
229229
- [ ] Multiple log panes (split view)
230230
- [ ] Bookmark log lines
231-
- [ ] Copy log lines to clipboard
231+
- [x] Copy log lines to clipboard (`y` / `Y`)
232232

233233
### Performance
234234

@@ -288,6 +288,9 @@ Want to help build these features? Here's how:
288288
- Real-time CPU % and memory usage displayed in service list
289289
- Resource graph view with sparkline visualization (toggle with `m`)
290290
- Color-coded CPU usage indicators
291+
- Added `devproc init` command to scaffold starter config
292+
- Added `devproc validate` command to check config for errors
293+
- Added clipboard support (`y` to copy last log, `Y` to copy all visible logs)
291294

292295
### v0.4.0
293296

src/app.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ProcessManager } from "./process/manager"
66
import { useServices, type DisplayItem } from "./ui/hooks/useServices"
77
import { useLogs, type SearchMatch } from "./ui/hooks/useLogs"
88
import { formatBytes, formatCpu, generateSparkline } from "./process/resources"
9+
import { copyToClipboard } from "./utils/clipboard"
910

1011
interface AppProps {
1112
manager: ProcessManager
@@ -275,6 +276,54 @@ export function App(props: AppProps) {
275276
}
276277
}
277278

279+
// Copy last log line to clipboard
280+
const handleCopyLog = async () => {
281+
const logs = visibleLogs()
282+
if (logs.length === 0) {
283+
setStatusMessage("No logs to copy")
284+
setTimeout(() => setStatusMessage(null), 2000)
285+
return
286+
}
287+
288+
// Get the last log line
289+
const lastLog = logs[logs.length - 1]!
290+
const time = formatLogTime(lastLog.timestamp)
291+
const logLine = `[${time}] ${lastLog.service} | ${lastLog.content}`
292+
293+
const success = await copyToClipboard(logLine)
294+
if (success) {
295+
setStatusMessage("Copied to clipboard")
296+
} else {
297+
setStatusMessage("Copy failed")
298+
}
299+
setTimeout(() => setStatusMessage(null), 2000)
300+
}
301+
302+
// Copy all visible logs to clipboard
303+
const handleCopyAllLogs = async () => {
304+
const logs = visibleLogs()
305+
if (logs.length === 0) {
306+
setStatusMessage("No logs to copy")
307+
setTimeout(() => setStatusMessage(null), 2000)
308+
return
309+
}
310+
311+
const content = logs
312+
.map((log) => {
313+
const time = formatLogTime(log.timestamp)
314+
return `[${time}] ${log.service} | ${log.content}`
315+
})
316+
.join("\n")
317+
318+
const success = await copyToClipboard(content)
319+
if (success) {
320+
setStatusMessage(`Copied ${logs.length} lines`)
321+
} else {
322+
setStatusMessage("Copy failed")
323+
}
324+
setTimeout(() => setStatusMessage(null), 2000)
325+
}
326+
278327
// Keyboard handling
279328
useKeyboard((event: { name: string; ctrl: boolean; shift: boolean; preventDefault: () => void }) => {
280329
// Handle search mode input
@@ -576,6 +625,18 @@ export function App(props: AppProps) {
576625
setShowResourceGraph((prev) => !prev)
577626
event.preventDefault()
578627
break
628+
629+
// Copy to clipboard
630+
case "y":
631+
if (event.shift) {
632+
// Y - copy all visible logs
633+
handleCopyAllLogs()
634+
} else {
635+
// y - copy last log line
636+
handleCopyLog()
637+
}
638+
event.preventDefault()
639+
break
579640
}
580641
})
581642

@@ -910,6 +971,9 @@ export function App(props: AppProps) {
910971
<text>/ Start search | n Next match | N Prev match</text>
911972
<text>Esc Clear search</text>
912973
<text> </text>
974+
<text fg="yellow">Clipboard</text>
975+
<text>y Copy last log | Y Copy all visible logs</text>
976+
<text> </text>
913977
<text fg="yellow">Config</text>
914978
<text>Ctrl+L Reload config from disk</text>
915979
<text> </text>

src/index.tsx

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bun
22
import { render } from "@opentui/solid"
33
import { watch } from "fs"
4-
import { loadConfig } from "./config/loader"
4+
import { loadConfig, findConfigFile } from "./config/loader"
55
import { ProcessManager } from "./process/manager"
66
import { App } from "./app"
77

@@ -19,6 +19,8 @@ Commands:
1919
down Stop all services
2020
restart Restart all services
2121
status Show service status (non-interactive)
22+
init Create a new devproc.yaml config file
23+
validate Validate the config file without starting services
2224
2325
Options:
2426
-c, --config <file> Path to config file (default: devproc.yaml)
@@ -29,11 +31,189 @@ Options:
2931
Examples:
3032
devproc Start all services with TUI
3133
devproc up Start all services with TUI
34+
devproc init Create a starter config file
35+
devproc validate Check config for errors
3236
devproc -c dev.yaml Use custom config file
3337
devproc -w Auto-reload on config changes
3438
`)
3539
}
3640

41+
/**
42+
* Generate a starter devproc.yaml config file
43+
*/
44+
async function initConfig(): Promise<void> {
45+
const configPath = "devproc.yaml"
46+
const file = Bun.file(configPath)
47+
48+
if (await file.exists()) {
49+
console.error(`Error: ${configPath} already exists`)
50+
console.log("Use a different directory or remove the existing file.")
51+
process.exit(1)
52+
}
53+
54+
// Try to detect project type from package.json
55+
let projectName = "my-project"
56+
let suggestedServices = ""
57+
58+
const pkgFile = Bun.file("package.json")
59+
if (await pkgFile.exists()) {
60+
try {
61+
const pkg = await pkgFile.json()
62+
projectName = pkg.name || projectName
63+
64+
// Suggest services based on scripts
65+
const scripts = pkg.scripts || {}
66+
const suggestions: string[] = []
67+
68+
if (scripts.dev) {
69+
suggestions.push(` # Frontend dev server
70+
web:
71+
cmd: ${pkg.packageManager?.startsWith("bun") ? "bun" : "npm"} run dev
72+
color: cyan`)
73+
}
74+
75+
if (scripts.start) {
76+
suggestions.push(` # Main application
77+
app:
78+
cmd: ${pkg.packageManager?.startsWith("bun") ? "bun" : "npm"} run start
79+
color: green`)
80+
}
81+
82+
if (scripts["start:dev"] || scripts["dev:server"]) {
83+
const script = scripts["start:dev"] ? "start:dev" : "dev:server"
84+
suggestions.push(` # Dev server
85+
server:
86+
cmd: ${pkg.packageManager?.startsWith("bun") ? "bun" : "npm"} run ${script}
87+
color: green`)
88+
}
89+
90+
if (suggestions.length > 0) {
91+
suggestedServices = suggestions.join("\n\n")
92+
}
93+
} catch {
94+
// Ignore JSON parse errors
95+
}
96+
}
97+
98+
// Default services if we couldn't detect any
99+
if (!suggestedServices) {
100+
suggestedServices = ` # Example: Web server
101+
# web:
102+
# cmd: npm run dev
103+
# color: cyan
104+
105+
# Example: API server
106+
# api:
107+
# cmd: go run ./cmd/api
108+
# healthcheck:
109+
# cmd: curl -f http://localhost:8080/health
110+
# interval: 2s
111+
# retries: 30
112+
# color: green
113+
114+
# Example: Worker process
115+
# worker:
116+
# cmd: npm run worker
117+
# depends_on:
118+
# - api
119+
# restart: on-failure
120+
121+
# Example: Docker database
122+
# postgres:
123+
# cmd: docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16
124+
# healthcheck: pg_isready -h localhost -p 5432
125+
126+
# Placeholder service (remove this)
127+
echo:
128+
cmd: "bash -c 'while true; do echo Hello from devproc; sleep 5; done'"
129+
color: cyan`
130+
}
131+
132+
const configContent = `# DevProc Configuration
133+
# Documentation: https://github.com/captjt/devproc
134+
135+
name: ${projectName}
136+
137+
# Global environment variables (applied to all services)
138+
# env:
139+
# NODE_ENV: development
140+
141+
# Load environment from .env file
142+
# dotenv: .env
143+
144+
# Organize services into groups (optional)
145+
# groups:
146+
# backend:
147+
# - api
148+
# - worker
149+
# frontend:
150+
# - web
151+
152+
services:
153+
${suggestedServices}
154+
`
155+
156+
await Bun.write(configPath, configContent)
157+
console.log(`Created ${configPath}`)
158+
console.log("")
159+
console.log("Next steps:")
160+
console.log(" 1. Edit devproc.yaml to configure your services")
161+
console.log(" 2. Run 'devproc' to start all services")
162+
console.log(" 3. Press '?' for keyboard shortcuts")
163+
}
164+
165+
/**
166+
* Validate the config file and report any errors
167+
*/
168+
async function validateConfig(configPath?: string): Promise<void> {
169+
console.log("Validating configuration...")
170+
console.log("")
171+
172+
try {
173+
const config = await loadConfig(configPath)
174+
175+
console.log(`✓ Config file: ${config.configPath}`)
176+
console.log(`✓ Project name: ${config.name}`)
177+
console.log(`✓ Services: ${config.services.size}`)
178+
179+
// List services with their dependencies
180+
console.log("")
181+
console.log("Services:")
182+
for (const [name, service] of config.services) {
183+
const deps = Array.from(service.dependsOn.keys())
184+
const depStr = deps.length > 0 ? ` (depends on: ${deps.join(", ")})` : ""
185+
const healthStr = service.healthcheck ? " [healthcheck]" : ""
186+
const groupStr = service.group ? ` [group: ${service.group}]` : ""
187+
console.log(` • ${name}${depStr}${healthStr}${groupStr}`)
188+
}
189+
190+
// List groups
191+
if (config.groups.size > 0) {
192+
console.log("")
193+
console.log("Groups:")
194+
for (const [name, group] of config.groups) {
195+
console.log(` • ${name}: ${group.services.join(", ")}`)
196+
}
197+
}
198+
199+
console.log("")
200+
console.log("✓ Configuration is valid!")
201+
} catch (error) {
202+
console.error("✗ Configuration error:")
203+
console.error("")
204+
if (error instanceof Error) {
205+
// Format the error message nicely
206+
const lines = error.message.split("\n")
207+
for (const line of lines) {
208+
console.error(` ${line}`)
209+
}
210+
} else {
211+
console.error(` ${error}`)
212+
}
213+
process.exit(1)
214+
}
215+
}
216+
37217
async function main() {
38218
const args = process.argv.slice(2)
39219
let configPath: string | undefined
@@ -65,7 +245,7 @@ async function main() {
65245
}
66246

67247
// Commands
68-
if (["up", "down", "restart", "status"].includes(arg!)) {
248+
if (["up", "down", "restart", "status", "init", "validate"].includes(arg!)) {
69249
command = arg!
70250
continue
71251
}
@@ -75,6 +255,18 @@ async function main() {
75255
process.exit(1)
76256
}
77257

258+
// Handle init command (doesn't need existing config)
259+
if (command === "init") {
260+
await initConfig()
261+
process.exit(0)
262+
}
263+
264+
// Handle validate command
265+
if (command === "validate") {
266+
await validateConfig(configPath)
267+
process.exit(0)
268+
}
269+
78270
try {
79271
// Load configuration
80272
const config = await loadConfig(configPath)

0 commit comments

Comments
 (0)