@smbcloud / proses / commits / 37612da

v0.1.0

Seto committed Apr 5, 2026 at 22:45 UTC 37612da223a42f427f30c957895f4da336364474
18 files changed +3471 -3
Cargo.lock
+1040
@@ -2,6 +2,1046 @@
2 # It is not intended for manual editing.
3 version = 4
4
5 +[[package]]
6 +name = "allocator-api2"
7 +version = "0.2.21"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10 +
11 +[[package]]
12 +name = "android_system_properties"
13 +version = "0.1.5"
14 +source = "registry+https://github.com/rust-lang/crates.io-index"
15 +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
16 +dependencies = [
17 + "libc",
18 +]
19 +
20 +[[package]]
21 +name = "anstream"
22 +version = "1.0.0"
23 +source = "registry+https://github.com/rust-lang/crates.io-index"
24 +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
25 +dependencies = [
26 + "anstyle",
27 + "anstyle-parse",
28 + "anstyle-query",
29 + "anstyle-wincon",
30 + "colorchoice",
31 + "is_terminal_polyfill",
32 + "utf8parse",
33 +]
34 +
35 +[[package]]
36 +name = "anstyle"
37 +version = "1.0.14"
38 +source = "registry+https://github.com/rust-lang/crates.io-index"
39 +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
40 +
41 +[[package]]
42 +name = "anstyle-parse"
43 +version = "1.0.0"
44 +source = "registry+https://github.com/rust-lang/crates.io-index"
45 +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
46 +dependencies = [
47 + "utf8parse",
48 +]
49 +
50 +[[package]]
51 +name = "anstyle-query"
52 +version = "1.1.5"
53 +source = "registry+https://github.com/rust-lang/crates.io-index"
54 +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
55 +dependencies = [
56 + "windows-sys 0.61.2",
57 +]
58 +
59 +[[package]]
60 +name = "anstyle-wincon"
61 +version = "3.0.11"
62 +source = "registry+https://github.com/rust-lang/crates.io-index"
63 +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
64 +dependencies = [
65 + "anstyle",
66 + "once_cell_polyfill",
67 + "windows-sys 0.61.2",
68 +]
69 +
70 +[[package]]
71 +name = "anyhow"
72 +version = "1.0.102"
73 +source = "registry+https://github.com/rust-lang/crates.io-index"
74 +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
75 +
76 +[[package]]
77 +name = "autocfg"
78 +version = "1.5.0"
79 +source = "registry+https://github.com/rust-lang/crates.io-index"
80 +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
81 +
82 +[[package]]
83 +name = "bitflags"
84 +version = "2.11.0"
85 +source = "registry+https://github.com/rust-lang/crates.io-index"
86 +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
87 +
88 +[[package]]
89 +name = "bumpalo"
90 +version = "3.20.2"
91 +source = "registry+https://github.com/rust-lang/crates.io-index"
92 +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
93 +
94 +[[package]]
95 +name = "bytes"
96 +version = "1.11.1"
97 +source = "registry+https://github.com/rust-lang/crates.io-index"
98 +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
99 +
100 +[[package]]
101 +name = "cassowary"
102 +version = "0.3.0"
103 +source = "registry+https://github.com/rust-lang/crates.io-index"
104 +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
105 +
106 +[[package]]
107 +name = "castaway"
108 +version = "0.2.4"
109 +source = "registry+https://github.com/rust-lang/crates.io-index"
110 +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
111 +dependencies = [
112 + "rustversion",
113 +]
114 +
115 +[[package]]
116 +name = "cc"
117 +version = "1.2.59"
118 +source = "registry+https://github.com/rust-lang/crates.io-index"
119 +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
120 +dependencies = [
121 + "find-msvc-tools",
122 + "shlex",
123 +]
124 +
125 +[[package]]
126 +name = "cfg-if"
127 +version = "1.0.4"
128 +source = "registry+https://github.com/rust-lang/crates.io-index"
129 +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
130 +
131 +[[package]]
132 +name = "chrono"
133 +version = "0.4.44"
134 +source = "registry+https://github.com/rust-lang/crates.io-index"
135 +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
136 +dependencies = [
137 + "iana-time-zone",
138 + "js-sys",
139 + "num-traits",
140 + "serde",
141 + "wasm-bindgen",
142 + "windows-link",
143 +]
144 +
145 +[[package]]
146 +name = "clap"
147 +version = "4.6.0"
148 +source = "registry+https://github.com/rust-lang/crates.io-index"
149 +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
150 +dependencies = [
151 + "clap_builder",
152 + "clap_derive",
153 +]
154 +
155 +[[package]]
156 +name = "clap_builder"
157 +version = "4.6.0"
158 +source = "registry+https://github.com/rust-lang/crates.io-index"
159 +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
160 +dependencies = [
161 + "anstream",
162 + "anstyle",
163 + "clap_lex",
164 + "strsim",
165 +]
166 +
167 +[[package]]
168 +name = "clap_derive"
169 +version = "4.6.0"
170 +source = "registry+https://github.com/rust-lang/crates.io-index"
171 +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
172 +dependencies = [
173 + "heck",
174 + "proc-macro2",
175 + "quote",
176 + "syn",
177 +]
178 +
179 +[[package]]
180 +name = "clap_lex"
181 +version = "1.1.0"
182 +source = "registry+https://github.com/rust-lang/crates.io-index"
183 +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
184 +
185 +[[package]]
186 +name = "colorchoice"
187 +version = "1.0.5"
188 +source = "registry+https://github.com/rust-lang/crates.io-index"
189 +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
190 +
191 +[[package]]
192 +name = "compact_str"
193 +version = "0.8.1"
194 +source = "registry+https://github.com/rust-lang/crates.io-index"
195 +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
196 +dependencies = [
197 + "castaway",
198 + "cfg-if",
199 + "itoa",
200 + "rustversion",
201 + "ryu",
202 + "static_assertions",
203 +]
204 +
205 +[[package]]
206 +name = "core-foundation-sys"
207 +version = "0.8.7"
208 +source = "registry+https://github.com/rust-lang/crates.io-index"
209 +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
210 +
211 +[[package]]
212 +name = "crossterm"
213 +version = "0.28.1"
214 +source = "registry+https://github.com/rust-lang/crates.io-index"
215 +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
216 +dependencies = [
217 + "bitflags",
218 + "crossterm_winapi",
219 + "mio",
220 + "parking_lot",
221 + "rustix",
222 + "signal-hook",
223 + "signal-hook-mio",
224 + "winapi",
225 +]
226 +
227 +[[package]]
228 +name = "crossterm_winapi"
229 +version = "0.9.1"
230 +source = "registry+https://github.com/rust-lang/crates.io-index"
231 +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
232 +dependencies = [
233 + "winapi",
234 +]
235 +
236 +[[package]]
237 +name = "darling"
238 +version = "0.23.0"
239 +source = "registry+https://github.com/rust-lang/crates.io-index"
240 +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
241 +dependencies = [
242 + "darling_core",
243 + "darling_macro",
244 +]
245 +
246 +[[package]]
247 +name = "darling_core"
248 +version = "0.23.0"
249 +source = "registry+https://github.com/rust-lang/crates.io-index"
250 +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
251 +dependencies = [
252 + "ident_case",
253 + "proc-macro2",
254 + "quote",
255 + "strsim",
256 + "syn",
257 +]
258 +
259 +[[package]]
260 +name = "darling_macro"
261 +version = "0.23.0"
262 +source = "registry+https://github.com/rust-lang/crates.io-index"
263 +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
264 +dependencies = [
265 + "darling_core",
266 + "quote",
267 + "syn",
268 +]
269 +
270 +[[package]]
271 +name = "either"
272 +version = "1.15.0"
273 +source = "registry+https://github.com/rust-lang/crates.io-index"
274 +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
275 +
276 +[[package]]
277 +name = "equivalent"
278 +version = "1.0.2"
279 +source = "registry+https://github.com/rust-lang/crates.io-index"
280 +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
281 +
282 +[[package]]
283 +name = "errno"
284 +version = "0.3.14"
285 +source = "registry+https://github.com/rust-lang/crates.io-index"
286 +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
287 +dependencies = [
288 + "libc",
289 + "windows-sys 0.61.2",
290 +]
291 +
292 +[[package]]
293 +name = "find-msvc-tools"
294 +version = "0.1.9"
295 +source = "registry+https://github.com/rust-lang/crates.io-index"
296 +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
297 +
298 +[[package]]
299 +name = "foldhash"
300 +version = "0.1.5"
301 +source = "registry+https://github.com/rust-lang/crates.io-index"
302 +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
303 +
304 +[[package]]
305 +name = "hashbrown"
306 +version = "0.15.5"
307 +source = "registry+https://github.com/rust-lang/crates.io-index"
308 +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
309 +dependencies = [
310 + "allocator-api2",
311 + "equivalent",
312 + "foldhash",
313 +]
314 +
315 +[[package]]
316 +name = "heck"
317 +version = "0.5.0"
318 +source = "registry+https://github.com/rust-lang/crates.io-index"
319 +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
320 +
321 +[[package]]
322 +name = "home"
323 +version = "0.5.12"
324 +source = "registry+https://github.com/rust-lang/crates.io-index"
325 +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
326 +dependencies = [
327 + "windows-sys 0.61.2",
328 +]
329 +
330 +[[package]]
331 +name = "iana-time-zone"
332 +version = "0.1.65"
333 +source = "registry+https://github.com/rust-lang/crates.io-index"
334 +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
335 +dependencies = [
336 + "android_system_properties",
337 + "core-foundation-sys",
338 + "iana-time-zone-haiku",
339 + "js-sys",
340 + "log",
341 + "wasm-bindgen",
342 + "windows-core",
343 +]
344 +
345 +[[package]]
346 +name = "iana-time-zone-haiku"
347 +version = "0.1.2"
348 +source = "registry+https://github.com/rust-lang/crates.io-index"
349 +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
350 +dependencies = [
351 + "cc",
352 +]
353 +
354 +[[package]]
355 +name = "ident_case"
356 +version = "1.0.1"
357 +source = "registry+https://github.com/rust-lang/crates.io-index"
358 +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
359 +
360 +[[package]]
361 +name = "indoc"
362 +version = "2.0.7"
363 +source = "registry+https://github.com/rust-lang/crates.io-index"
364 +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
365 +dependencies = [
366 + "rustversion",
367 +]
368 +
369 +[[package]]
370 +name = "instability"
371 +version = "0.3.12"
372 +source = "registry+https://github.com/rust-lang/crates.io-index"
373 +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
374 +dependencies = [
375 + "darling",
376 + "indoc",
377 + "proc-macro2",
378 + "quote",
379 + "syn",
380 +]
381 +
382 +[[package]]
383 +name = "is_terminal_polyfill"
384 +version = "1.70.2"
385 +source = "registry+https://github.com/rust-lang/crates.io-index"
386 +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
387 +
388 +[[package]]
389 +name = "itertools"
390 +version = "0.13.0"
391 +source = "registry+https://github.com/rust-lang/crates.io-index"
392 +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
393 +dependencies = [
394 + "either",
395 +]
396 +
397 +[[package]]
398 +name = "itoa"
399 +version = "1.0.18"
400 +source = "registry+https://github.com/rust-lang/crates.io-index"
401 +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
402 +
403 +[[package]]
404 +name = "js-sys"
405 +version = "0.3.94"
406 +source = "registry+https://github.com/rust-lang/crates.io-index"
407 +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
408 +dependencies = [
409 + "once_cell",
410 + "wasm-bindgen",
411 +]
412 +
413 +[[package]]
414 +name = "libc"
415 +version = "0.2.184"
416 +source = "registry+https://github.com/rust-lang/crates.io-index"
417 +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
418 +
419 +[[package]]
420 +name = "linux-raw-sys"
421 +version = "0.4.15"
422 +source = "registry+https://github.com/rust-lang/crates.io-index"
423 +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
424 +
425 +[[package]]
426 +name = "lock_api"
427 +version = "0.4.14"
428 +source = "registry+https://github.com/rust-lang/crates.io-index"
429 +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
430 +dependencies = [
431 + "scopeguard",
432 +]
433 +
434 +[[package]]
435 +name = "log"
436 +version = "0.4.29"
437 +source = "registry+https://github.com/rust-lang/crates.io-index"
438 +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
439 +
440 +[[package]]
441 +name = "lru"
442 +version = "0.12.5"
443 +source = "registry+https://github.com/rust-lang/crates.io-index"
444 +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
445 +dependencies = [
446 + "hashbrown",
447 +]
448 +
449 +[[package]]
450 +name = "memchr"
451 +version = "2.8.0"
452 +source = "registry+https://github.com/rust-lang/crates.io-index"
453 +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
454 +
455 +[[package]]
456 +name = "mio"
457 +version = "1.2.0"
458 +source = "registry+https://github.com/rust-lang/crates.io-index"
459 +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
460 +dependencies = [
461 + "libc",
462 + "log",
463 + "wasi",
464 + "windows-sys 0.61.2",
465 +]
466 +
467 +[[package]]
468 +name = "num-traits"
469 +version = "0.2.19"
470 +source = "registry+https://github.com/rust-lang/crates.io-index"
471 +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
472 +dependencies = [
473 + "autocfg",
474 +]
475 +
476 +[[package]]
477 +name = "once_cell"
478 +version = "1.21.4"
479 +source = "registry+https://github.com/rust-lang/crates.io-index"
480 +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
481 +
482 +[[package]]
483 +name = "once_cell_polyfill"
484 +version = "1.70.2"
485 +source = "registry+https://github.com/rust-lang/crates.io-index"
486 +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
487 +
488 +[[package]]
489 +name = "parking_lot"
490 +version = "0.12.5"
491 +source = "registry+https://github.com/rust-lang/crates.io-index"
492 +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
493 +dependencies = [
494 + "lock_api",
495 + "parking_lot_core",
496 +]
497 +
498 +[[package]]
499 +name = "parking_lot_core"
500 +version = "0.9.12"
501 +source = "registry+https://github.com/rust-lang/crates.io-index"
502 +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
503 +dependencies = [
504 + "cfg-if",
505 + "libc",
506 + "redox_syscall",
507 + "smallvec",
508 + "windows-link",
509 +]
510 +
511 +[[package]]
512 +name = "paste"
513 +version = "1.0.15"
514 +source = "registry+https://github.com/rust-lang/crates.io-index"
515 +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
516 +
517 +[[package]]
518 +name = "pin-project-lite"
519 +version = "0.2.17"
520 +source = "registry+https://github.com/rust-lang/crates.io-index"
521 +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
522 +
523 +[[package]]
524 +name = "proc-macro2"
525 +version = "1.0.106"
526 +source = "registry+https://github.com/rust-lang/crates.io-index"
527 +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
528 +dependencies = [
529 + "unicode-ident",
530 +]
531 +
532 [[package]]
533 name = "proses"
534 version = "0.1.0"
535 +dependencies = [
536 + "anyhow",
537 + "chrono",
538 + "clap",
539 + "crossterm",
540 + "home",
541 + "libc",
542 + "once_cell",
543 + "ratatui",
544 + "serde",
545 + "serde_json",
546 + "tokio",
547 +]
548 +
549 +[[package]]
550 +name = "quote"
551 +version = "1.0.45"
552 +source = "registry+https://github.com/rust-lang/crates.io-index"
553 +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
554 +dependencies = [
555 + "proc-macro2",
556 +]
557 +
558 +[[package]]
559 +name = "ratatui"
560 +version = "0.29.0"
561 +source = "registry+https://github.com/rust-lang/crates.io-index"
562 +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
563 +dependencies = [
564 + "bitflags",
565 + "cassowary",
566 + "compact_str",
567 + "crossterm",
568 + "indoc",
569 + "instability",
570 + "itertools",
571 + "lru",
572 + "paste",
573 + "strum",
574 + "unicode-segmentation",
575 + "unicode-truncate",
576 + "unicode-width 0.2.0",
577 +]
578 +
579 +[[package]]
580 +name = "redox_syscall"
581 +version = "0.5.18"
582 +source = "registry+https://github.com/rust-lang/crates.io-index"
583 +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
584 +dependencies = [
585 + "bitflags",
586 +]
587 +
588 +[[package]]
589 +name = "rustix"
590 +version = "0.38.44"
591 +source = "registry+https://github.com/rust-lang/crates.io-index"
592 +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
593 +dependencies = [
594 + "bitflags",
595 + "errno",
596 + "libc",
597 + "linux-raw-sys",
598 + "windows-sys 0.59.0",
599 +]
600 +
601 +[[package]]
602 +name = "rustversion"
603 +version = "1.0.22"
604 +source = "registry+https://github.com/rust-lang/crates.io-index"
605 +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
606 +
607 +[[package]]
608 +name = "ryu"
609 +version = "1.0.23"
610 +source = "registry+https://github.com/rust-lang/crates.io-index"
611 +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
612 +
613 +[[package]]
614 +name = "scopeguard"
615 +version = "1.2.0"
616 +source = "registry+https://github.com/rust-lang/crates.io-index"
617 +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
618 +
619 +[[package]]
620 +name = "serde"
621 +version = "1.0.228"
622 +source = "registry+https://github.com/rust-lang/crates.io-index"
623 +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
624 +dependencies = [
625 + "serde_core",
626 + "serde_derive",
627 +]
628 +
629 +[[package]]
630 +name = "serde_core"
631 +version = "1.0.228"
632 +source = "registry+https://github.com/rust-lang/crates.io-index"
633 +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
634 +dependencies = [
635 + "serde_derive",
636 +]
637 +
638 +[[package]]
639 +name = "serde_derive"
640 +version = "1.0.228"
641 +source = "registry+https://github.com/rust-lang/crates.io-index"
642 +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
643 +dependencies = [
644 + "proc-macro2",
645 + "quote",
646 + "syn",
647 +]
648 +
649 +[[package]]
650 +name = "serde_json"
651 +version = "1.0.149"
652 +source = "registry+https://github.com/rust-lang/crates.io-index"
653 +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
654 +dependencies = [
655 + "itoa",
656 + "memchr",
657 + "serde",
658 + "serde_core",
659 + "zmij",
660 +]
661 +
662 +[[package]]
663 +name = "shlex"
664 +version = "1.3.0"
665 +source = "registry+https://github.com/rust-lang/crates.io-index"
666 +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
667 +
668 +[[package]]
669 +name = "signal-hook"
670 +version = "0.3.18"
671 +source = "registry+https://github.com/rust-lang/crates.io-index"
672 +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
673 +dependencies = [
674 + "libc",
675 + "signal-hook-registry",
676 +]
677 +
678 +[[package]]
679 +name = "signal-hook-mio"
680 +version = "0.2.5"
681 +source = "registry+https://github.com/rust-lang/crates.io-index"
682 +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
683 +dependencies = [
684 + "libc",
685 + "mio",
686 + "signal-hook",
687 +]
688 +
689 +[[package]]
690 +name = "signal-hook-registry"
691 +version = "1.4.8"
692 +source = "registry+https://github.com/rust-lang/crates.io-index"
693 +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
694 +dependencies = [
695 + "errno",
696 + "libc",
697 +]
698 +
699 +[[package]]
700 +name = "smallvec"
701 +version = "1.15.1"
702 +source = "registry+https://github.com/rust-lang/crates.io-index"
703 +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
704 +
705 +[[package]]
706 +name = "socket2"
707 +version = "0.6.3"
708 +source = "registry+https://github.com/rust-lang/crates.io-index"
709 +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
710 +dependencies = [
711 + "libc",
712 + "windows-sys 0.61.2",
713 +]
714 +
715 +[[package]]
716 +name = "static_assertions"
717 +version = "1.1.0"
718 +source = "registry+https://github.com/rust-lang/crates.io-index"
719 +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
720 +
721 +[[package]]
722 +name = "strsim"
723 +version = "0.11.1"
724 +source = "registry+https://github.com/rust-lang/crates.io-index"
725 +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
726 +
727 +[[package]]
728 +name = "strum"
729 +version = "0.26.3"
730 +source = "registry+https://github.com/rust-lang/crates.io-index"
731 +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
732 +dependencies = [
733 + "strum_macros",
734 +]
735 +
736 +[[package]]
737 +name = "strum_macros"
738 +version = "0.26.4"
739 +source = "registry+https://github.com/rust-lang/crates.io-index"
740 +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
741 +dependencies = [
742 + "heck",
743 + "proc-macro2",
744 + "quote",
745 + "rustversion",
746 + "syn",
747 +]
748 +
749 +[[package]]
750 +name = "syn"
751 +version = "2.0.117"
752 +source = "registry+https://github.com/rust-lang/crates.io-index"
753 +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
754 +dependencies = [
755 + "proc-macro2",
756 + "quote",
757 + "unicode-ident",
758 +]
759 +
760 +[[package]]
761 +name = "tokio"
762 +version = "1.51.0"
763 +source = "registry+https://github.com/rust-lang/crates.io-index"
764 +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
765 +dependencies = [
766 + "bytes",
767 + "libc",
768 + "mio",
769 + "parking_lot",
770 + "pin-project-lite",
771 + "signal-hook-registry",
772 + "socket2",
773 + "tokio-macros",
774 + "windows-sys 0.61.2",
775 +]
776 +
777 +[[package]]
778 +name = "tokio-macros"
779 +version = "2.7.0"
780 +source = "registry+https://github.com/rust-lang/crates.io-index"
781 +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
782 +dependencies = [
783 + "proc-macro2",
784 + "quote",
785 + "syn",
786 +]
787 +
788 +[[package]]
789 +name = "unicode-ident"
790 +version = "1.0.24"
791 +source = "registry+https://github.com/rust-lang/crates.io-index"
792 +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
793 +
794 +[[package]]
795 +name = "unicode-segmentation"
796 +version = "1.13.2"
797 +source = "registry+https://github.com/rust-lang/crates.io-index"
798 +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
799 +
800 +[[package]]
801 +name = "unicode-truncate"
802 +version = "1.1.0"
803 +source = "registry+https://github.com/rust-lang/crates.io-index"
804 +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
805 +dependencies = [
806 + "itertools",
807 + "unicode-segmentation",
808 + "unicode-width 0.1.14",
809 +]
810 +
811 +[[package]]
812 +name = "unicode-width"
813 +version = "0.1.14"
814 +source = "registry+https://github.com/rust-lang/crates.io-index"
815 +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
816 +
817 +[[package]]
818 +name = "unicode-width"
819 +version = "0.2.0"
820 +source = "registry+https://github.com/rust-lang/crates.io-index"
821 +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
822 +
823 +[[package]]
824 +name = "utf8parse"
825 +version = "0.2.2"
826 +source = "registry+https://github.com/rust-lang/crates.io-index"
827 +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
828 +
829 +[[package]]
830 +name = "wasi"
831 +version = "0.11.1+wasi-snapshot-preview1"
832 +source = "registry+https://github.com/rust-lang/crates.io-index"
833 +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
834 +
835 +[[package]]
836 +name = "wasm-bindgen"
837 +version = "0.2.117"
838 +source = "registry+https://github.com/rust-lang/crates.io-index"
839 +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
840 +dependencies = [
841 + "cfg-if",
842 + "once_cell",
843 + "rustversion",
844 + "wasm-bindgen-macro",
845 + "wasm-bindgen-shared",
846 +]
847 +
848 +[[package]]
849 +name = "wasm-bindgen-macro"
850 +version = "0.2.117"
851 +source = "registry+https://github.com/rust-lang/crates.io-index"
852 +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
853 +dependencies = [
854 + "quote",
855 + "wasm-bindgen-macro-support",
856 +]
857 +
858 +[[package]]
859 +name = "wasm-bindgen-macro-support"
860 +version = "0.2.117"
861 +source = "registry+https://github.com/rust-lang/crates.io-index"
862 +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
863 +dependencies = [
864 + "bumpalo",
865 + "proc-macro2",
866 + "quote",
867 + "syn",
868 + "wasm-bindgen-shared",
869 +]
870 +
871 +[[package]]
872 +name = "wasm-bindgen-shared"
873 +version = "0.2.117"
874 +source = "registry+https://github.com/rust-lang/crates.io-index"
875 +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
876 +dependencies = [
877 + "unicode-ident",
878 +]
879 +
880 +[[package]]
881 +name = "winapi"
882 +version = "0.3.9"
883 +source = "registry+https://github.com/rust-lang/crates.io-index"
884 +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
885 +dependencies = [
886 + "winapi-i686-pc-windows-gnu",
887 + "winapi-x86_64-pc-windows-gnu",
888 +]
889 +
890 +[[package]]
891 +name = "winapi-i686-pc-windows-gnu"
892 +version = "0.4.0"
893 +source = "registry+https://github.com/rust-lang/crates.io-index"
894 +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
895 +
896 +[[package]]
897 +name = "winapi-x86_64-pc-windows-gnu"
898 +version = "0.4.0"
899 +source = "registry+https://github.com/rust-lang/crates.io-index"
900 +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
901 +
902 +[[package]]
903 +name = "windows-core"
904 +version = "0.62.2"
905 +source = "registry+https://github.com/rust-lang/crates.io-index"
906 +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
907 +dependencies = [
908 + "windows-implement",
909 + "windows-interface",
910 + "windows-link",
911 + "windows-result",
912 + "windows-strings",
913 +]
914 +
915 +[[package]]
916 +name = "windows-implement"
917 +version = "0.60.2"
918 +source = "registry+https://github.com/rust-lang/crates.io-index"
919 +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
920 +dependencies = [
921 + "proc-macro2",
922 + "quote",
923 + "syn",
924 +]
925 +
926 +[[package]]
927 +name = "windows-interface"
928 +version = "0.59.3"
929 +source = "registry+https://github.com/rust-lang/crates.io-index"
930 +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
931 +dependencies = [
932 + "proc-macro2",
933 + "quote",
934 + "syn",
935 +]
936 +
937 +[[package]]
938 +name = "windows-link"
939 +version = "0.2.1"
940 +source = "registry+https://github.com/rust-lang/crates.io-index"
941 +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
942 +
943 +[[package]]
944 +name = "windows-result"
945 +version = "0.4.1"
946 +source = "registry+https://github.com/rust-lang/crates.io-index"
947 +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
948 +dependencies = [
949 + "windows-link",
950 +]
951 +
952 +[[package]]
953 +name = "windows-strings"
954 +version = "0.5.1"
955 +source = "registry+https://github.com/rust-lang/crates.io-index"
956 +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
957 +dependencies = [
958 + "windows-link",
959 +]
960 +
961 +[[package]]
962 +name = "windows-sys"
963 +version = "0.59.0"
964 +source = "registry+https://github.com/rust-lang/crates.io-index"
965 +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
966 +dependencies = [
967 + "windows-targets",
968 +]
969 +
970 +[[package]]
971 +name = "windows-sys"
972 +version = "0.61.2"
973 +source = "registry+https://github.com/rust-lang/crates.io-index"
974 +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
975 +dependencies = [
976 + "windows-link",
977 +]
978 +
979 +[[package]]
980 +name = "windows-targets"
981 +version = "0.52.6"
982 +source = "registry+https://github.com/rust-lang/crates.io-index"
983 +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
984 +dependencies = [
985 + "windows_aarch64_gnullvm",
986 + "windows_aarch64_msvc",
987 + "windows_i686_gnu",
988 + "windows_i686_gnullvm",
989 + "windows_i686_msvc",
990 + "windows_x86_64_gnu",
991 + "windows_x86_64_gnullvm",
992 + "windows_x86_64_msvc",
993 +]
994 +
995 +[[package]]
996 +name = "windows_aarch64_gnullvm"
997 +version = "0.52.6"
998 +source = "registry+https://github.com/rust-lang/crates.io-index"
999 +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1000 +
1001 +[[package]]
1002 +name = "windows_aarch64_msvc"
1003 +version = "0.52.6"
1004 +source = "registry+https://github.com/rust-lang/crates.io-index"
1005 +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1006 +
1007 +[[package]]
1008 +name = "windows_i686_gnu"
1009 +version = "0.52.6"
1010 +source = "registry+https://github.com/rust-lang/crates.io-index"
1011 +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1012 +
1013 +[[package]]
1014 +name = "windows_i686_gnullvm"
1015 +version = "0.52.6"
1016 +source = "registry+https://github.com/rust-lang/crates.io-index"
1017 +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1018 +
1019 +[[package]]
1020 +name = "windows_i686_msvc"
1021 +version = "0.52.6"
1022 +source = "registry+https://github.com/rust-lang/crates.io-index"
1023 +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1024 +
1025 +[[package]]
1026 +name = "windows_x86_64_gnu"
1027 +version = "0.52.6"
1028 +source = "registry+https://github.com/rust-lang/crates.io-index"
1029 +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1030 +
1031 +[[package]]
1032 +name = "windows_x86_64_gnullvm"
1033 +version = "0.52.6"
1034 +source = "registry+https://github.com/rust-lang/crates.io-index"
1035 +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1036 +
1037 +[[package]]
1038 +name = "windows_x86_64_msvc"
1039 +version = "0.52.6"
1040 +source = "registry+https://github.com/rust-lang/crates.io-index"
1041 +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1042 +
1043 +[[package]]
1044 +name = "zmij"
1045 +version = "1.0.21"
1046 +source = "registry+https://github.com/rust-lang/crates.io-index"
1047 +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml
+51 -1
@@ -1,6 +1,56 @@
1 [package]
2 name = "proses"
3 version = "0.1.0"
4 -edition = "2024"
4 +edition = "2021"
5 +description = "Proses – Professional Secure Execution System"
6 +license = "MIT"
7
8 +[[bin]]
9 +name = "proses"
10 +path = "src/main.rs"
11 +
12 +# ── Runtime ────────────────────────────────────────────────────────────────────
13 [dependencies]
14 +
15 +# CLI argument parsing
16 +clap = { version = "4", features = ["derive"] }
17 +
18 +# Async runtime – used exclusively inside the background daemon process.
19 +# The CLI itself is fully synchronous.
20 +tokio = { version = "1", features = ["full"] }
21 +
22 +# ── Serialisation ──────────────────────────────────────────────────────────────
23 +
24 +# IPC protocol (newline-delimited JSON over a Unix domain socket) +
25 +# persistent state store (~/.proses/store.json)
26 +serde = { version = "1", features = ["derive"] }
27 +serde_json = "1"
28 +
29 +# Timestamps and uptime calculations
30 +chrono = { version = "0.4", features = ["serde"] }
31 +
32 +# ── Terminal UI ────────────────────────────────────────────────────────────────
33 +
34 +# Primary TUI framework – live dashboard, log viewer, process table
35 +ratatui = "0.29"
36 +
37 +# Event handling (keyboard, resize) – must match the version ratatui 0.29 uses
38 +crossterm = "0.28"
39 +
40 +# ── System / Unix ──────────────────────────────────────────────────────────────
41 +
42 +# Low-level Unix syscalls: fork(2), setsid(2), dup2(2), kill(2), waitpid(2)
43 +# All signal operations are done via raw libc for maximum portability and to
44 +# avoid any version-skew issues with higher-level wrappers.
45 +libc = "0.2"
46 +
47 +# ── Utilities ──────────────────────────────────────────────────────────────────
48 +
49 +# Ergonomic error handling throughout
50 +anyhow = "1"
51 +
52 +# Home-directory resolution for ~/.proses/
53 +home = "0.5"
54 +
55 +# One-time global initialisation of the Config singleton
56 +once_cell = "1"
LICENSE.md new
+21
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2025 smbCloud
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
README.md
+51 -1
@@ -1 +1,51 @@
1 -# Proses: Professional Secure Execution System
1 +<!-- LOGO -->
2 +<h1>
3 +<p align="center">
4 + <img src="./assets/proses-logo.png" alt="Proses Logo" width="128">
5 + <br>Proses
6 +</h1>
7 + <p align="center">
8 + <br />
9 + <a href="https://smbcloud.xyz">Website</a>
10 + ·
11 + <a href="https://github.com/smbcloudXYZ/proses">GitHub</a>
12 + ·
13 + <a href="https://docs.smbcloud.xyz">Documentation</a>
14 + </p>
15 +</p>
16 +
17 +## About
18 +
19 +Proses is a fast, secure process manager built in Rust. One command starts your Next.js app, supervises it, restarts it on crash, and captures its logs — no config files, no Node runtime, no dependency hell. A single binary. A live terminal dashboard. That's it.
20 +
21 +## Installation
22 +
23 +To install `proses` via Homebrew, run:
24 +
25 +```bash
26 +brew tap smbcloudXYZ/tap
27 +brew install proses
28 +```
29 +
30 +Once installed, you can verify it's working:
31 +
32 +```bash
33 +proses --version
34 +proses --help
35 +```
36 +
37 +## Upgrading
38 +
39 +When a new version is released, upgrade easily:
40 +
41 +```bash
42 +brew upgrade proses
43 +```
44 +
45 +## Contributing & Issues
46 +
47 +If you encounter an issue, please report it on the [proses repository](https://github.com/smbcloudXYZ/proses/issues).
48 +
49 +## License
50 +
51 +MIT — Copyright © 2026 [smbCloud](https://smbcloud.xyz)
assets/proses-logo.png
Binary files /dev/null and b/assets/proses-logo.png differ
src/cli.rs new
+229
@@ -0,0 +1,229 @@
1 +use std::collections::HashMap;
2 +
3 +use crate::daemon::ipc::{self, Request};
4 +
5 +// ── Output helpers ────────────────────────────────────────────────────────────
6 +
7 +fn ok(msg: &str) {
8 + println!(" ✓ {msg}");
9 +}
10 +
11 +fn fail(msg: &str) -> ! {
12 + eprintln!(" ✗ {msg}");
13 + std::process::exit(1);
14 +}
15 +
16 +fn send_or_fail(req: &Request) -> crate::daemon::ipc::Response {
17 + match ipc::send(req) {
18 + Ok(resp) => resp,
19 + Err(e) => fail(&e.to_string()),
20 + }
21 +}
22 +
23 +// ── Commands ──────────────────────────────────────────────────────────────────
24 +
25 +/// `proses start <command> [--name NAME] [--cwd DIR] [--env K=V]`
26 +///
27 +/// If a process with the same name already exists in the daemon the daemon
28 +/// will stop the old instance and re-launch it with the new parameters,
29 +/// effectively acting as a restart-with-new-config.
30 +pub fn start(
31 + command: String,
32 + name: Option<String>,
33 + cwd: Option<String>,
34 + env_pairs: Vec<String>,
35 + max_restarts: u32,
36 +) {
37 + // Default name: first token of the command string.
38 + let name = name.unwrap_or_else(|| {
39 + command
40 + .split_whitespace()
41 + .next()
42 + .unwrap_or("process")
43 + .to_string()
44 + });
45 +
46 + // Default cwd: current working directory.
47 + let cwd = cwd.unwrap_or_else(|| {
48 + std::env::current_dir()
49 + .map(|p| p.to_string_lossy().into_owned())
50 + .unwrap_or_else(|_| "/".to_string())
51 + });
52 +
53 + // Parse KEY=VALUE pairs.
54 + let env: HashMap<String, String> = env_pairs
55 + .into_iter()
56 + .filter_map(|pair| {
57 + let (k, v) = pair.split_once('=')?;
58 + Some((k.to_string(), v.to_string()))
59 + })
60 + .collect();
61 +
62 + let resp = send_or_fail(&Request::Start {
63 + name,
64 + command,
65 + cwd,
66 + env,
67 + max_restarts,
68 + });
69 +
70 + if resp.success {
71 + ok(&resp.message);
72 + } else {
73 + fail(&resp.message);
74 + }
75 +}
76 +
77 +/// `proses stop <name|id>`
78 +///
79 +/// Sends `SIGTERM` to the process and marks it `Stopped` so the daemon
80 +/// will not attempt to restart it automatically.
81 +pub fn stop(name_or_id: String) {
82 + let resp = send_or_fail(&Request::Stop {
83 + name_or_id: name_or_id.clone(),
84 + });
85 + if resp.success {
86 + ok(&resp.message);
87 + } else {
88 + fail(&resp.message);
89 + }
90 +}
91 +
92 +/// `proses restart <name|id>`
93 +///
94 +/// Stops the current instance (SIGTERM) and immediately re-spawns it.
95 +pub fn restart(name_or_id: String) {
96 + let resp = send_or_fail(&Request::Restart {
97 + name_or_id: name_or_id.clone(),
98 + });
99 + if resp.success {
100 + ok(&resp.message);
101 + } else {
102 + fail(&resp.message);
103 + }
104 +}
105 +
106 +/// `proses delete <name|id>`
107 +///
108 +/// Stops the process (if running) and removes it from the daemon's store.
109 +pub fn delete(name_or_id: String) {
110 + let resp = send_or_fail(&Request::Delete {
111 + name_or_id: name_or_id.clone(),
112 + });
113 + if resp.success {
114 + ok(&resp.message);
115 + } else {
116 + fail(&resp.message);
117 + }
118 +}
119 +
120 +/// `proses save`
121 +///
122 +/// Flushes the current in-memory process list to `~/.proses/store.json`
123 +/// so it survives a daemon restart.
124 +pub fn save() {
125 + let resp = send_or_fail(&Request::Save);
126 + if resp.success {
127 + ok(&resp.message);
128 + } else {
129 + fail(&resp.message);
130 + }
131 +}
132 +
133 +/// `proses resurrect`
134 +///
135 +/// Re-launches all processes that were in the `Running` state the last
136 +/// time `save` was called (or the daemon was stopped cleanly).
137 +pub fn resurrect() {
138 + let resp = send_or_fail(&Request::Resurrect);
139 + if resp.success {
140 + ok(&resp.message);
141 + } else {
142 + fail(&resp.message);
143 + }
144 +}
145 +
146 +/// `proses show <name|id>`
147 +///
148 +/// Prints detailed information about a single process.
149 +pub fn show(name_or_id: String) {
150 + let resp = send_or_fail(&Request::Show {
151 + name_or_id: name_or_id.clone(),
152 + });
153 +
154 + if !resp.success {
155 + fail(&resp.message);
156 + }
157 +
158 + let Some(p) = resp.process else {
159 + fail("daemon returned no process data");
160 + };
161 +
162 + let status_str = format!("{}", p.status);
163 + let uptime = if p.pid > 0 {
164 + let secs = (chrono::Utc::now() - p.started_at).num_seconds().max(0) as u64;
165 + format_uptime(secs)
166 + } else {
167 + "–".to_string()
168 + };
169 +
170 + println!();
171 + println!(" {:<16} {}", "id:", p.id);
172 + println!(" {:<16} {}", "name:", p.name);
173 + println!(" {:<16} {status_str}", "status:");
174 + println!(
175 + " {:<16} {}",
176 + "pid:",
177 + if p.pid > 0 {
178 + p.pid.to_string()
179 + } else {
180 + "–".to_string()
181 + }
182 + );
183 + println!(" {:<16} {}", "restarts:", p.restarts);
184 + println!(" {:<16} {}", "max restarts:", p.max_restarts);
185 + println!(" {:<16} {uptime}", "uptime:");
186 + println!(" {:<16} {}", "command:", p.command);
187 + println!(" {:<16} {}", "cwd:", p.cwd.display());
188 + println!(" {:<16} {}", "stdout log:", p.log_out.display());
189 + println!(" {:<16} {}", "stderr log:", p.log_err.display());
190 + if !p.env.is_empty() {
191 + println!(" {:<16}", "env:");
192 + let mut keys: Vec<&String> = p.env.keys().collect();
193 + keys.sort();
194 + for k in keys {
195 + println!(" {}={}", k, p.env[k]);
196 + }
197 + }
198 + println!();
199 +}
200 +
201 +/// `proses logs <name|id> [-n LINES]`
202 +///
203 +/// Dumps the last `lines` lines of the process's stdout log to stdout.
204 +/// For a live follow view, use `proses list` (the TUI dashboard).
205 +pub fn logs(name_or_id: String, lines: usize) {
206 + let resp = send_or_fail(&Request::Logs { name_or_id, lines });
207 +
208 + if !resp.success {
209 + fail(&resp.message);
210 + }
211 +
212 + if let Some(content) = resp.logs {
213 + print!("{content}");
214 + }
215 +}
216 +
217 +// ── Utilities ─────────────────────────────────────────────────────────────────
218 +
219 +fn format_uptime(secs: u64) -> String {
220 + if secs < 60 {
221 + format!("{secs}s")
222 + } else if secs < 3600 {
223 + format!("{}m {}s", secs / 60, secs % 60)
224 + } else if secs < 86_400 {
225 + format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
226 + } else {
227 + format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3600)
228 + }
229 +}
src/config.rs new
+61
@@ -0,0 +1,61 @@
1 +use home::home_dir;
2 +use once_cell::sync::OnceCell;
3 +use std::{fs, path::PathBuf};
4 +
5 +/// Global runtime configuration for the Proses daemon and CLI.
6 +///
7 +/// All paths are rooted under `~/.proses/`.
8 +pub struct Config {
9 + /// Persistent process store: `~/.proses/store.json`
10 + pub store_path: PathBuf,
11 + /// Unix domain socket used for IPC: `~/.proses/proses.sock`
12 + pub sock_path: PathBuf,
13 + /// Daemon PID file: `~/.proses/daemon.pid`
14 + pub pid_path: PathBuf,
15 + /// Directory for per-process stdout/stderr logs: `~/.proses/logs/`
16 + pub log_dir: PathBuf,
17 +}
18 +
19 +static CONFIG: OnceCell<Config> = OnceCell::new();
20 +
21 +/// Initialise the global [`Config`] singleton and create the necessary
22 +/// directories on disk.
23 +///
24 +/// Safe to call multiple times — subsequent calls are no-ops.
25 +pub fn init() {
26 + // If already initialised, nothing to do.
27 + if CONFIG.get().is_some() {
28 + return;
29 + }
30 +
31 + let base_dir = home_dir()
32 + .expect("Cannot determine the home directory")
33 + .join(".proses");
34 +
35 + let log_dir = base_dir.join("logs");
36 +
37 + fs::create_dir_all(&base_dir).expect("Failed to create ~/.proses directory");
38 + fs::create_dir_all(&log_dir).expect("Failed to create ~/.proses/logs directory");
39 +
40 + let config = Config {
41 + store_path: base_dir.join("store.json"),
42 + sock_path: base_dir.join("proses.sock"),
43 + pid_path: base_dir.join("daemon.pid"),
44 + log_dir,
45 + };
46 +
47 + // A concurrent caller may have beaten us to the set; that is fine —
48 + // the value that wins is valid for our lifetime.
49 + CONFIG.set(config).ok();
50 +}
51 +
52 +/// Return a reference to the global [`Config`].
53 +///
54 +/// # Panics
55 +/// Panics if [`init`] has not been called yet.
56 +#[inline]
57 +pub fn get() -> &'static Config {
58 + CONFIG
59 + .get()
60 + .expect("Config not initialised — call config::init() first")
61 +}
src/daemon/fork.rs new
+119
@@ -0,0 +1,119 @@
1 +//! Classic Unix double-fork daemonization.
2 +//!
3 +//! Only `libc` is used for all syscalls so that this module has zero
4 +//! dependency on any async runtime and can safely be called before Tokio
5 +//! is ever created.
6 +
7 +use std::io;
8 +
9 +/// Double-fork daemonization.
10 +///
11 +/// ## Process tree after the call returns
12 +///
13 +/// ```text
14 +/// original caller
15 +/// ├── [fork #1] first child
16 +/// │ ├── setsid() — new session, no controlling terminal
17 +/// │ └── [fork #2] grandchild ← the actual daemon
18 +/// │ ├── dup2 stdin/stdout/stderr → /dev/null
19 +/// │ └── daemon_fn()
20 +/// └── waitpid(first child) → returns normally to caller
21 +/// ```
22 +///
23 +/// * **Original process** — waits for the first child (so it does not
24 +/// become a zombie) then **returns normally**. The `daemon_fn` closure
25 +/// is dropped (not called) here.
26 +///
27 +/// * **First child** — calls `setsid(2)` to detach from the controlling
28 +/// terminal, forks a second time, then exits immediately via `_exit(0)`
29 +/// so that the grandchild is re-parented to `init` / `systemd`.
30 +///
31 +/// * **Grandchild** — the daemon proper: its standard file descriptors are
32 +/// redirected to `/dev/null` so that stray reads/writes cannot interact
33 +/// with a terminal, then `daemon_fn()` is invoked.
34 +///
35 +/// # Panics
36 +///
37 +/// Panics in the original process if `fork()` fails.
38 +///
39 +/// # Safety
40 +///
41 +/// This function **must** be called before any multi-threaded runtime
42 +/// (Tokio, Rayon, …) is started. Forking a multi-threaded process is
43 +/// undefined behaviour.
44 +pub fn spawn_daemon<F: FnOnce()>(daemon_fn: F) {
45 + // ── Fork #1 ───────────────────────────────────────────────────────────
46 + let pid1 = unsafe { libc::fork() };
47 +
48 + if pid1 < 0 {
49 + panic!("fork() #1 failed: {}", io::Error::last_os_error());
50 + }
51 +
52 + if pid1 > 0 {
53 + // ── Original process (parent) ─────────────────────────────────────
54 + // Reap the first child so it does not linger as a zombie.
55 + unsafe {
56 + let mut status: libc::c_int = 0;
57 + libc::waitpid(pid1, &mut status, 0);
58 + }
59 + // `daemon_fn` is intentionally *dropped* here — it must only run in
60 + // the grandchild.
61 + return;
62 + }
63 +
64 + // ── First child ───────────────────────────────────────────────────────
65 + // Become the leader of a brand-new session. This severs the connection
66 + // to any controlling terminal inherited from the parent.
67 + if unsafe { libc::setsid() } < 0 {
68 + // Cannot recover; exit without running Rust destructors so we do not
69 + // flush/corrupt stdio buffers shared with the parent.
70 + unsafe { libc::_exit(1) };
71 + }
72 +
73 + // ── Fork #2 ───────────────────────────────────────────────────────────
74 + let pid2 = unsafe { libc::fork() };
75 +
76 + if pid2 < 0 {
77 + unsafe { libc::_exit(1) };
78 + }
79 +
80 + if pid2 > 0 {
81 + // First child exits immediately. The grandchild is now an orphan
82 + // and will be re-parented to PID 1 (init / systemd), ensuring it
83 + // can never accidentally acquire a controlling terminal.
84 + // `_exit` is used (rather than `exit`) to avoid double-flushing
85 + // any stdio buffers inherited from the original process.
86 + unsafe { libc::_exit(0) };
87 + }
88 +
89 + // ── Grandchild — the actual daemon ────────────────────────────────────
90 +
91 + // Redirect the standard file descriptors to /dev/null so that any
92 + // accidental reads or writes do not interact with a terminal or pipe.
93 + unsafe {
94 + let dev_null = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_RDWR);
95 +
96 + if dev_null >= 0 {
97 + // It is fine if any of these dup2 calls fail — we are already
98 + // detached, and the daemon will soon open its own log files.
99 + libc::dup2(dev_null, libc::STDIN_FILENO);
100 + libc::dup2(dev_null, libc::STDOUT_FILENO);
101 + libc::dup2(dev_null, libc::STDERR_FILENO);
102 +
103 + // Close the temporary fd only if it is not one of the three
104 + // standard slots (which would have been installed above).
105 + if dev_null > libc::STDERR_FILENO {
106 + libc::close(dev_null);
107 + }
108 + }
109 + }
110 +
111 + // Hand control to the daemon entry point.
112 + //
113 + // In normal usage `daemon_fn` is `|| server::run()`, which is `-> !`
114 + // and never returns. The `_exit` below is a safety net for any
115 + // `daemon_fn` that does return.
116 + daemon_fn();
117 +
118 + unsafe { libc::_exit(0) };
119 +}
src/daemon/ipc.rs new
+142
@@ -0,0 +1,142 @@
1 +use crate::process::Process;
2 +use anyhow::{Context, Result};
3 +use serde::{Deserialize, Serialize};
4 +use std::collections::HashMap;
5 +use std::io::{BufRead, BufReader, Write};
6 +use std::os::unix::net::UnixStream;
7 +
8 +/// Every command the CLI can send to the daemon over the Unix socket.
9 +///
10 +/// Serialised as newline-delimited JSON with an internal `"cmd"` tag, e.g.:
11 +/// `{"cmd":"start","name":"myapp","command":"node index.js","cwd":"/app","env":{},"max_restarts":10}`
12 +#[derive(Debug, Serialize, Deserialize)]
13 +#[serde(tag = "cmd", rename_all = "snake_case")]
14 +pub enum Request {
15 + /// Launch a new managed process.
16 + Start {
17 + name: String,
18 + command: String,
19 + cwd: String,
20 + env: HashMap<String, String>,
21 + max_restarts: u32,
22 + },
23 + /// Gracefully stop a running process and mark it `Stopped`
24 + /// (no automatic restart).
25 + Stop { name_or_id: String },
26 + /// Stop then re-spawn a process, resetting its PID and start timestamp.
27 + Restart { name_or_id: String },
28 + /// Stop (if running) and permanently remove a process from the store.
29 + Delete { name_or_id: String },
30 + /// Return the full list of managed processes.
31 + List,
32 + /// Tail the last `lines` lines of a process's stdout log.
33 + Logs { name_or_id: String, lines: usize },
34 + /// Return detailed information about a single process.
35 + Show { name_or_id: String },
36 + /// Flush the in-memory store to `~/.proses/store.json`.
37 + Save,
38 + /// Re-spawn all processes that were in the `Running` state at last save.
39 + Resurrect,
40 + /// Ping the daemon; returns `{"success":true,"message":"ok"}`.
41 + Health,
42 + /// Gracefully stop all processes and exit the daemon.
43 + Shutdown,
44 +}
45 +
46 +/// Envelope returned by the daemon for every [`Request`].
47 +#[derive(Debug, Serialize, Deserialize)]
48 +pub struct Response {
49 + pub success: bool,
50 + pub message: String,
51 + #[serde(skip_serializing_if = "Option::is_none")]
52 + pub processes: Option<Vec<Process>>,
53 + #[serde(skip_serializing_if = "Option::is_none")]
54 + pub process: Option<Process>,
55 + #[serde(skip_serializing_if = "Option::is_none")]
56 + pub logs: Option<String>,
57 +}
58 +
59 +impl Response {
60 + /// Construct a successful response with `message` and no payload.
61 + pub fn ok(message: impl Into<String>) -> Self {
62 + Self {
63 + success: true,
64 + message: message.into(),
65 + processes: None,
66 + process: None,
67 + logs: None,
68 + }
69 + }
70 +
71 + /// Construct a failure response with `message`.
72 + pub fn err(message: impl Into<String>) -> Self {
73 + Self {
74 + success: false,
75 + message: message.into(),
76 + processes: None,
77 + process: None,
78 + logs: None,
79 + }
80 + }
81 +
82 + /// Attach a process list payload (used by `List`).
83 + pub fn with_processes(mut self, procs: Vec<Process>) -> Self {
84 + self.processes = Some(procs);
85 + self
86 + }
87 +
88 + /// Attach a single-process payload (used by `Start`, `Show`, etc.).
89 + pub fn with_process(mut self, proc: Process) -> Self {
90 + self.process = Some(proc);
91 + self
92 + }
93 +
94 + /// Attach a log-content payload (used by `Logs`).
95 + pub fn with_logs(mut self, logs: String) -> Self {
96 + self.logs = Some(logs);
97 + self
98 + }
99 +}
100 +
101 +/// Blocking IPC client.
102 +///
103 +/// Connects to the daemon's Unix domain socket, writes the JSON-encoded
104 +/// `request` followed by a newline, then reads and deserialises the
105 +/// single-line JSON response.
106 +///
107 +/// This function uses only `std` I/O so it can be called from the
108 +/// synchronous CLI without a Tokio runtime.
109 +pub fn send(request: &Request) -> Result<Response> {
110 + let sock_path = crate::config::get().sock_path.clone();
111 +
112 + let mut stream = UnixStream::connect(&sock_path).map_err(|e| {
113 + anyhow::anyhow!(
114 + "Cannot connect to daemon socket at {:?}: {}\n\
115 + Hint: run `proses daemon start` first.",
116 + sock_path,
117 + e
118 + )
119 + })?;
120 +
121 + // ── Write request ──────────────────────────────────────────────────────
122 + let mut payload = serde_json::to_string(request)?;
123 + payload.push('\n');
124 + stream
125 + .write_all(payload.as_bytes())
126 + .context("Failed to write request to daemon socket")?;
127 + stream
128 + .flush()
129 + .context("Failed to flush request to daemon socket")?;
130 +
131 + // ── Read response ──────────────────────────────────────────────────────
132 + // The daemon always writes exactly one JSON line per connection.
133 + let mut line = String::new();
134 + BufReader::new(&stream)
135 + .read_line(&mut line)
136 + .context("Failed to read response from daemon socket")?;
137 +
138 + let response: Response =
139 + serde_json::from_str(line.trim()).context("Failed to deserialise daemon response")?;
140 +
141 + Ok(response)
142 +}
src/daemon/mod.rs new
+173
@@ -0,0 +1,173 @@
1 +//! CLI-facing daemon lifecycle management.
2 +//!
3 +//! All functions here use only `std` (no Tokio) and `libc` for signal
4 +//! operations, so they can be called from the synchronous CLI binary.
5 +
6 +pub mod fork;
7 +pub mod ipc;
8 +pub mod server;
9 +
10 +use crate::config;
11 +use std::{fs, thread, time::Duration};
12 +
13 +// ─── State helpers ────────────────────────────────────────────────────────────
14 +
15 +/// Returns `true` if the daemon's PID file exists **and** the recorded PID
16 +/// corresponds to a live OS process (`kill(pid, 0) == 0`).
17 +pub fn is_running() -> bool {
18 + let cfg = config::get();
19 +
20 + let pid_str = match fs::read_to_string(&cfg.pid_path) {
21 + Ok(s) => s,
22 + Err(_) => return false,
23 + };
24 +
25 + let pid: libc::pid_t = match pid_str.trim().parse() {
26 + Ok(p) => p,
27 + Err(_) => return false,
28 + };
29 +
30 + if pid <= 0 {
31 + return false;
32 + }
33 +
34 + // kill(pid, 0) succeeds (returns 0) when the process exists.
35 + unsafe { libc::kill(pid, 0) == 0 }
36 +}
37 +
38 +// ─── Lifecycle commands ───────────────────────────────────────────────────────
39 +
40 +/// Auto-start the daemon if it is not already running.
41 +///
42 +/// After forking, waits up to 3 s (30 × 100 ms) for the Unix socket to
43 +/// appear, which indicates that the daemon is ready to accept connections.
44 +/// Exits the process with an error message if the daemon never comes up.
45 +pub fn ensure_running() {
46 + if is_running() {
47 + return;
48 + }
49 +
50 + fork::spawn_daemon(|| server::run());
51 +
52 + let cfg = config::get();
53 + for _ in 0..30 {
54 + thread::sleep(Duration::from_millis(100));
55 + if cfg.sock_path.exists() {
56 + return;
57 + }
58 + }
59 +
60 + eprintln!(
61 + "error: daemon did not start within 3 seconds \
62 + (socket {:?} never appeared)",
63 + cfg.sock_path
64 + );
65 + std::process::exit(1);
66 +}
67 +
68 +/// Handler for `proses daemon start`.
69 +///
70 +/// Starts the daemon if not already running, then waits for the socket to
71 +/// appear. Prints a status message and exits cleanly.
72 +pub fn start_cmd() {
73 + config::init();
74 +
75 + if is_running() {
76 + let pid = read_pid().unwrap_or(0);
77 + println!("Daemon is already running (pid {}).", pid);
78 + return;
79 + }
80 +
81 + println!("Starting daemon…");
82 + fork::spawn_daemon(|| server::run());
83 +
84 + let cfg = config::get();
85 + for _ in 0..30 {
86 + thread::sleep(Duration::from_millis(100));
87 + if cfg.sock_path.exists() {
88 + let pid = read_pid().unwrap_or(0);
89 + println!("Daemon started (pid {}).", pid);
90 + return;
91 + }
92 + }
93 +
94 + eprintln!(
95 + "error: daemon did not start within 3 seconds \
96 + (socket {:?} never appeared)",
97 + cfg.sock_path
98 + );
99 + std::process::exit(1);
100 +}
101 +
102 +/// Handler for `proses daemon stop`.
103 +///
104 +/// Reads the PID file, sends `SIGTERM`, waits up to 2 s for the process to
105 +/// exit, then cleans up the PID and socket files.
106 +pub fn stop_cmd() {
107 + config::init();
108 +
109 + let pid: libc::pid_t = match read_pid() {
110 + Some(p) => p,
111 + None => {
112 + println!("Daemon is not running (no PID file).");
113 + return;
114 + }
115 + };
116 +
117 + // Check the process is actually alive before trying to kill it.
118 + if unsafe { libc::kill(pid, 0) } != 0 {
119 + println!("Daemon is not running (pid {} not found).", pid);
120 + cleanup_files();
121 + return;
122 + }
123 +
124 + println!("Stopping daemon (pid {})…", pid);
125 + unsafe { libc::kill(pid, libc::SIGTERM) };
126 +
127 + // Wait up to 2 s for the process to exit.
128 + for _ in 0..20 {
129 + thread::sleep(Duration::from_millis(100));
130 + if unsafe { libc::kill(pid, 0) } != 0 {
131 + break;
132 + }
133 + }
134 +
135 + cleanup_files();
136 + println!("Daemon stopped.");
137 +}
138 +
139 +/// Handler for `proses daemon status`.
140 +///
141 +/// Prints whether the daemon is running and, if so, its PID.
142 +pub fn status_cmd() {
143 + config::init();
144 +
145 + match read_pid() {
146 + None => println!("Daemon: stopped (no PID file)"),
147 + Some(pid) => {
148 + if unsafe { libc::kill(pid, 0) } == 0 {
149 + println!("Daemon: running (pid {})", pid);
150 + } else {
151 + println!("Daemon: stopped (pid {} not found)", pid);
152 + cleanup_files();
153 + }
154 + }
155 + }
156 +}
157 +
158 +// ─── Private helpers ──────────────────────────────────────────────────────────
159 +
160 +/// Read and parse the daemon PID from the PID file.
161 +/// Returns `None` if the file is missing or contains an invalid integer.
162 +fn read_pid() -> Option<libc::pid_t> {
163 + let cfg = config::get();
164 + let content = fs::read_to_string(&cfg.pid_path).ok()?;
165 + content.trim().parse::<libc::pid_t>().ok()
166 +}
167 +
168 +/// Remove the PID and socket files, ignoring errors (they may already be gone).
169 +fn cleanup_files() {
170 + let cfg = config::get();
171 + let _ = fs::remove_file(&cfg.pid_path);
172 + let _ = fs::remove_file(&cfg.sock_path);
173 +}
src/daemon/server.rs new
+495
@@ -0,0 +1,495 @@
1 +//! Async daemon internals.
2 +//!
3 +//! `run()` is called from the daemonized grandchild process created by
4 +//! `daemon::fork::spawn_daemon`. It boots a multi-threaded Tokio runtime,
5 +//! writes the PID file, binds the Unix socket, and then services IPC
6 +//! connections while a background task monitors managed processes.
7 +
8 +use std::{fs, sync::Arc};
9 +
10 +use anyhow::Result;
11 +use chrono::Utc;
12 +use tokio::{
13 + io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
14 + net::{UnixListener, UnixStream},
15 + sync::Mutex,
16 + time::{sleep, Duration},
17 +};
18 +
19 +use crate::{
20 + config,
21 + daemon::ipc::{Request, Response},
22 + process::{runner, store::Store, Process, ProcessStatus},
23 +};
24 +
25 +// ─── Public entry point ───────────────────────────────────────────────────────
26 +
27 +/// Entry point called from `daemon/mod.rs` after the double-fork.
28 +///
29 +/// Creates a multi-threaded Tokio runtime and drives `async_main` to
30 +/// completion. Never returns — the process terminates via
31 +/// `std::process::exit`.
32 +pub fn run() -> ! {
33 + // Initialise config inside the daemon process (safe to call multiple
34 + // times; subsequent calls are no-ops via OnceCell).
35 + config::init();
36 +
37 + let rt = tokio::runtime::Builder::new_multi_thread()
38 + .enable_all()
39 + .build()
40 + .expect("Failed to build Tokio runtime");
41 +
42 + rt.block_on(async {
43 + if let Err(e) = async_main().await {
44 + eprintln!("[proses-daemon] fatal: {:#}", e);
45 + std::process::exit(1);
46 + }
47 + });
48 +
49 + std::process::exit(0);
50 +}
51 +
52 +// ─── Async entry point ────────────────────────────────────────────────────────
53 +
54 +async fn async_main() -> Result<()> {
55 + let cfg = config::get();
56 +
57 + // Write PID file so the CLI can find us.
58 + let pid = std::process::id();
59 + fs::write(&cfg.pid_path, pid.to_string())?;
60 +
61 + // Remove any stale socket left by a previous crash.
62 + if cfg.sock_path.exists() {
63 + let _ = fs::remove_file(&cfg.sock_path);
64 + }
65 +
66 + // Load (or create) the persistent process store.
67 + let store: Arc<Mutex<Store>> = Arc::new(Mutex::new(Store::load(&cfg.store_path)));
68 +
69 + // Background task: periodically check for dead processes and restart them.
70 + let monitor_store = store.clone();
71 + tokio::spawn(async move {
72 + monitor_loop(monitor_store).await;
73 + });
74 +
75 + // Bind the Unix domain socket.
76 + let listener = UnixListener::bind(&cfg.sock_path)?;
77 +
78 + loop {
79 + match listener.accept().await {
80 + Ok((stream, _addr)) => {
81 + let store = store.clone();
82 + tokio::spawn(async move {
83 + handle_connection(stream, store).await;
84 + });
85 + }
86 + Err(e) => {
87 + eprintln!("[proses-daemon] accept error: {}", e);
88 + }
89 + }
90 + }
91 +}
92 +
93 +// ─── Monitor loop ─────────────────────────────────────────────────────────────
94 +
95 +/// Wakes every 2 seconds and restarts any `Running` process whose OS PID has
96 +/// died. Processes explicitly stopped by the user (`Stopped` status) are
97 +/// never restarted here.
98 +async fn monitor_loop(store: Arc<Mutex<Store>>) {
99 + loop {
100 + sleep(Duration::from_secs(2)).await;
101 +
102 + let cfg = config::get();
103 +
104 + // Snapshot the IDs of currently Running processes (short lock hold).
105 + let running_ids: Vec<u32> = {
106 + let s = store.lock().await;
107 + s.processes
108 + .values()
109 + .filter(|p| p.status == ProcessStatus::Running)
110 + .map(|p| p.id)
111 + .collect()
112 + };
113 +
114 + for id in running_ids {
115 + // Re-check with a fresh lock so we pick up Stop commands that
116 + // arrived since we collected the IDs.
117 + let (pid, restarts, max_restarts) = {
118 + let s = store.lock().await;
119 + match s.processes.get(&id) {
120 + Some(p) if p.status == ProcessStatus::Running => {
121 + (p.pid, p.restarts, p.max_restarts)
122 + }
123 + _ => continue,
124 + }
125 + };
126 +
127 + // Fast path: the process is still alive.
128 + if runner::is_running(pid) {
129 + continue;
130 + }
131 +
132 + // Process is dead — decide whether to restart or mark Errored.
133 + let mut s = store.lock().await;
134 + let proc = match s.processes.get_mut(&id) {
135 + Some(p) if p.status == ProcessStatus::Running => p,
136 + _ => continue,
137 + };
138 +
139 + // max_restarts == 0 means unlimited.
140 + if max_restarts == 0 || restarts < max_restarts {
141 + let snapshot = proc.clone();
142 + match runner::spawn(&snapshot) {
143 + Ok(new_pid) => {
144 + proc.pid = new_pid;
145 + proc.restarts += 1;
146 + proc.started_at = Utc::now();
147 + proc.status = ProcessStatus::Running;
148 + }
149 + Err(e) => {
150 + eprintln!("[proses-daemon] failed to restart '{}': {}", proc.name, e);
151 + proc.status = ProcessStatus::Errored;
152 + }
153 + }
154 + } else {
155 + eprintln!(
156 + "[proses-daemon] '{}' exceeded max_restarts ({}), marking errored",
157 + proc.name, max_restarts
158 + );
159 + proc.status = ProcessStatus::Errored;
160 + }
161 +
162 + let _ = s.save(&cfg.store_path);
163 + }
164 + }
165 +}
166 +
167 +// ─── Connection handler ───────────────────────────────────────────────────────
168 +
169 +/// Read one JSON line from `stream`, dispatch to `handle_request`, and
170 +/// write back a single JSON response line.
171 +async fn handle_connection(stream: UnixStream, store: Arc<Mutex<Store>>) {
172 + let (reader_half, mut writer_half) = stream.into_split();
173 + let mut reader = BufReader::new(reader_half);
174 + let mut line = String::new();
175 +
176 + match reader.read_line(&mut line).await {
177 + Ok(0) => return, // EOF — client disconnected without sending anything.
178 + Err(e) => {
179 + eprintln!("[proses-daemon] read error: {}", e);
180 + return;
181 + }
182 + Ok(_) => {}
183 + }
184 +
185 + let response = match serde_json::from_str::<Request>(line.trim()) {
186 + Ok(req) => handle_request(req, store).await,
187 + Err(e) => Response::err(format!("Malformed request: {}", e)),
188 + };
189 +
190 + let mut json = match serde_json::to_string(&response) {
191 + Ok(s) => s,
192 + Err(_) => r#"{"success":false,"message":"response serialisation error"}"#.to_string(),
193 + };
194 + json.push('\n');
195 +
196 + let _ = writer_half.write_all(json.as_bytes()).await;
197 +}
198 +
199 +// ─── Request dispatcher ───────────────────────────────────────────────────────
200 +
201 +async fn handle_request(req: Request, store: Arc<Mutex<Store>>) -> Response {
202 + match req {
203 + // ── Start ──────────────────────────────────────────────────────────
204 + Request::Start {
205 + name,
206 + command,
207 + cwd,
208 + env,
209 + max_restarts,
210 + } => {
211 + let cfg = config::get();
212 + let mut s = store.lock().await;
213 +
214 + if s.find(&name).is_some() {
215 + return Response::err(format!(
216 + "A process named '{}' already exists. \
217 + Use `restart` or `delete` it first.",
218 + name
219 + ));
220 + }
221 +
222 + let id = s.alloc_id();
223 + let log_out = cfg.log_dir.join(format!("{}-{}.out", name, id));
224 + let log_err = cfg.log_dir.join(format!("{}-{}.err", name, id));
225 +
226 + let mut proc = Process {
227 + id,
228 + name: name.clone(),
229 + pid: 0,
230 + command,
231 + cwd: std::path::PathBuf::from(cwd),
232 + env,
233 + status: ProcessStatus::Stopped,
234 + restarts: 0,
235 + max_restarts,
236 + started_at: Utc::now(),
237 + log_out,
238 + log_err,
239 + };
240 +
241 + match runner::spawn(&proc) {
242 + Ok(pid) => {
243 + proc.pid = pid;
244 + proc.status = ProcessStatus::Running;
245 + proc.started_at = Utc::now();
246 + let resp = Response::ok(format!("Process '{}' started (pid {})", name, pid))
247 + .with_process(proc.clone());
248 + s.processes.insert(id, proc);
249 + let _ = s.save(&cfg.store_path);
250 + resp
251 + }
252 + Err(e) => {
253 + proc.status = ProcessStatus::Errored;
254 + s.processes.insert(id, proc);
255 + let _ = s.save(&cfg.store_path);
256 + Response::err(format!("Failed to start '{}': {}", name, e))
257 + }
258 + }
259 + }
260 +
261 + // ── Stop ───────────────────────────────────────────────────────────
262 + Request::Stop { name_or_id } => {
263 + let cfg = config::get();
264 + let mut s = store.lock().await;
265 +
266 + let id = match s.find_id(&name_or_id) {
267 + Some(id) => id,
268 + None => return Response::err(format!("Process '{}' not found", name_or_id)),
269 + };
270 +
271 + let pid = s.processes[&id].pid;
272 + let _ = runner::stop(pid);
273 +
274 + if let Some(proc) = s.processes.get_mut(&id) {
275 + proc.status = ProcessStatus::Stopped;
276 + proc.pid = 0;
277 + }
278 + let _ = s.save(&cfg.store_path);
279 + Response::ok(format!("Process '{}' stopped", name_or_id))
280 + }
281 +
282 + // ── Restart ────────────────────────────────────────────────────────
283 + //
284 + // Per spec: release the lock, stop the old process, sleep 300 ms,
285 + // respawn, re-acquire the lock to update pid / started_at.
286 + Request::Restart { name_or_id } => {
287 + let cfg = config::get();
288 +
289 + // Step 1 — snapshot what we need, then release the lock.
290 + let (old_pid, snapshot) = {
291 + let s = store.lock().await;
292 + match s.find(&name_or_id) {
293 + None => return Response::err(format!("Process '{}' not found", name_or_id)),
294 + Some(p) => (p.pid, p.clone()),
295 + }
296 + };
297 +
298 + // Step 2 — stop old process (lock free).
299 + let _ = runner::stop(old_pid);
300 +
301 + // Step 3 — give the OS a moment to reap the child.
302 + sleep(Duration::from_millis(300)).await;
303 +
304 + // Step 4 — spawn the new instance (lock free).
305 + let new_pid = match runner::spawn(&snapshot) {
306 + Ok(pid) => pid,
307 + Err(e) => {
308 + return Response::err(format!("Failed to restart '{}': {}", name_or_id, e))
309 + }
310 + };
311 +
312 + // Step 5 — re-acquire lock and update the store record.
313 + let mut s = store.lock().await;
314 + if let Some(id) = s.find_id(&name_or_id) {
315 + if let Some(proc) = s.processes.get_mut(&id) {
316 + proc.pid = new_pid;
317 + proc.started_at = Utc::now();
318 + proc.status = ProcessStatus::Running;
319 + }
320 + let _ = s.save(&cfg.store_path);
321 + Response::ok(format!(
322 + "Process '{}' restarted (pid {})",
323 + name_or_id, new_pid
324 + ))
325 + } else {
326 + Response::err(format!("Process '{}' vanished during restart", name_or_id))
327 + }
328 + }
329 +
330 + // ── Delete ─────────────────────────────────────────────────────────
331 + Request::Delete { name_or_id } => {
332 + let cfg = config::get();
333 + let mut s = store.lock().await;
334 +
335 + let id = match s.find_id(&name_or_id) {
336 + Some(id) => id,
337 + None => return Response::err(format!("Process '{}' not found", name_or_id)),
338 + };
339 +
340 + // Stop if running before removing.
341 + let pid_to_stop = s
342 + .processes
343 + .get(&id)
344 + .filter(|p| p.status == ProcessStatus::Running)
345 + .map(|p| p.pid);
346 +
347 + if let Some(pid) = pid_to_stop {
348 + let _ = runner::stop(pid);
349 + }
350 +
351 + s.processes.remove(&id);
352 + let _ = s.save(&cfg.store_path);
353 + Response::ok(format!("Process '{}' deleted", name_or_id))
354 + }
355 +
356 + // ── List ───────────────────────────────────────────────────────────
357 + Request::List => {
358 + let s = store.lock().await;
359 + let procs: Vec<Process> = s.processes.values().cloned().collect();
360 + let count = procs.len();
361 + Response::ok(format!("{} process(es)", count)).with_processes(procs)
362 + }
363 +
364 + // ── Logs ───────────────────────────────────────────────────────────
365 + Request::Logs { name_or_id, lines } => {
366 + let log_path = {
367 + let s = store.lock().await;
368 + match s.find(&name_or_id) {
369 + None => return Response::err(format!("Process '{}' not found", name_or_id)),
370 + Some(p) => p.log_out.clone(),
371 + }
372 + };
373 +
374 + match fs::read_to_string(&log_path) {
375 + Err(e) => Response::err(format!("Cannot read log {:?}: {}", log_path, e)),
376 + Ok(content) => {
377 + let all_lines: Vec<&str> = content.lines().collect();
378 + let start = all_lines.len().saturating_sub(lines);
379 + let tail = all_lines[start..].join("\n");
380 + Response::ok(format!("Last {} line(s) of stdout log", lines)).with_logs(tail)
381 + }
382 + }
383 + }
384 +
385 + // ── Show ───────────────────────────────────────────────────────────
386 + Request::Show { name_or_id } => {
387 + let s = store.lock().await;
388 + match s.find(&name_or_id) {
389 + None => Response::err(format!("Process '{}' not found", name_or_id)),
390 + Some(p) => {
391 + Response::ok(format!("Process '{}'", name_or_id)).with_process(p.clone())
392 + }
393 + }
394 + }
395 +
396 + // ── Save ───────────────────────────────────────────────────────────
397 + Request::Save => {
398 + let cfg = config::get();
399 + let s = store.lock().await;
400 + match s.save(&cfg.store_path) {
401 + Ok(_) => Response::ok("Store saved to disk"),
402 + Err(e) => Response::err(format!("Save failed: {}", e)),
403 + }
404 + }
405 +
406 + // ── Resurrect ──────────────────────────────────────────────────────
407 + //
408 + // Re-spawn all processes whose persisted status is Running but whose
409 + // PID is no longer alive. Useful after a daemon crash/restart.
410 + Request::Resurrect => {
411 + let cfg = config::get();
412 + let mut s = store.lock().await;
413 +
414 + let dead_running_ids: Vec<u32> = s
415 + .processes
416 + .values()
417 + .filter(|p| p.status == ProcessStatus::Running && !runner::is_running(p.pid))
418 + .map(|p| p.id)
419 + .collect();
420 +
421 + let mut spawned = 0u32;
422 + let mut failed = 0u32;
423 +
424 + for id in dead_running_ids {
425 + if let Some(proc) = s.processes.get_mut(&id) {
426 + let snapshot = proc.clone();
427 + match runner::spawn(&snapshot) {
428 + Ok(new_pid) => {
429 + proc.pid = new_pid;
430 + proc.started_at = Utc::now();
431 + proc.status = ProcessStatus::Running;
432 + spawned += 1;
433 + }
434 + Err(e) => {
435 + eprintln!(
436 + "[proses-daemon] resurrect failed for '{}': {}",
437 + proc.name, e
438 + );
439 + proc.status = ProcessStatus::Errored;
440 + failed += 1;
441 + }
442 + }
443 + }
444 + }
445 +
446 + let _ = s.save(&cfg.store_path);
447 +
448 + if failed == 0 {
449 + Response::ok(format!("Resurrected {} process(es)", spawned))
450 + } else {
451 + Response::err(format!(
452 + "Resurrected {}, failed to spawn {}",
453 + spawned, failed
454 + ))
455 + }
456 + }
457 +
458 + // ── Health ─────────────────────────────────────────────────────────
459 + Request::Health => Response::ok("daemon is healthy"),
460 +
461 + // ── Shutdown ───────────────────────────────────────────────────────
462 + Request::Shutdown => {
463 + let cfg = config::get();
464 +
465 + // Stop every running process and persist the final state.
466 + {
467 + let mut s = store.lock().await;
468 + let ids: Vec<u32> = s.processes.keys().cloned().collect();
469 + for id in ids {
470 + if let Some(proc) = s.processes.get_mut(&id) {
471 + if proc.status == ProcessStatus::Running {
472 + let _ = runner::stop(proc.pid);
473 + proc.status = ProcessStatus::Stopped;
474 + proc.pid = 0;
475 + }
476 + }
477 + }
478 + let _ = s.save(&cfg.store_path);
479 + }
480 +
481 + // Schedule the actual exit slightly after this response is flushed
482 + // so the client always receives the response before the socket
483 + // disappears.
484 + tokio::spawn(async {
485 + sleep(Duration::from_millis(150)).await;
486 + let cfg = config::get();
487 + let _ = fs::remove_file(&cfg.pid_path);
488 + let _ = fs::remove_file(&cfg.sock_path);
489 + std::process::exit(0);
490 + });
491 +
492 + Response::ok("Daemon is shutting down")
493 + }
494 + }
495 +}
src/main.rs
+152 -1
@@ -1,3 +1,154 @@
1 +mod cli;
2 +mod config;
3 +mod daemon;
4 +mod process;
5 +mod tui;
6 +
7 +use clap::{Parser, Subcommand};
8 +
9 +#[derive(Parser)]
10 +#[command(
11 + name = "proses",
12 + version = "0.1.0",
13 + about = "Proses – Professional Secure Execution System",
14 + long_about = None
15 +)]
16 +struct Cli {
17 + #[command(subcommand)]
18 + command: Commands,
19 +}
20 +
21 +#[derive(Subcommand)]
22 +enum Commands {
23 + /// Start a new process (or replace an existing one with the same name)
24 + #[command(visible_alias = "run")]
25 + Start {
26 + /// Shell command to execute (passed verbatim to sh -c)
27 + command: String,
28 + /// Human-readable name (defaults to the first token of the command)
29 + #[arg(short, long)]
30 + name: Option<String>,
31 + /// Working directory (defaults to the current directory)
32 + #[arg(long)]
33 + cwd: Option<String>,
34 + /// Extra environment variables in KEY=VALUE format
35 + #[arg(long = "env", value_name = "KEY=VALUE")]
36 + env: Vec<String>,
37 + /// Max automatic restarts before marking errored (0 = unlimited)
38 + #[arg(long, default_value_t = 10)]
39 + max_restarts: u32,
40 + },
41 +
42 + /// Gracefully stop a process (it will not be auto-restarted)
43 + Stop {
44 + /// Process name or numeric ID
45 + name_or_id: String,
46 + },
47 +
48 + /// Stop then immediately re-launch a process
49 + Restart {
50 + /// Process name or numeric ID
51 + name_or_id: String,
52 + },
53 +
54 + /// Stop and permanently remove a process from the list
55 + #[command(visible_alias = "rm", visible_alias = "del")]
56 + Delete {
57 + /// Process name or numeric ID
58 + name_or_id: String,
59 + },
60 +
61 + /// Open the live process dashboard (interactive Ratatui TUI)
62 + #[command(visible_alias = "ls", visible_alias = "ps", visible_alias = "status")]
63 + List,
64 +
65 + /// Dump the last N lines of a process's log to stdout
66 + Logs {
67 + /// Process name or numeric ID
68 + name_or_id: String,
69 + /// Number of log lines to show
70 + #[arg(short = 'n', long, default_value_t = 50)]
71 + lines: usize,
72 + },
73 +
74 + /// Flush the in-memory store to ~/.proses/store.json
75 + Save,
76 +
77 + /// Re-launch all processes that were running when last saved
78 + Resurrect,
79 +
80 + /// Print detailed information about a process
81 + #[command(visible_alias = "info")]
82 + Show {
83 + /// Process name or numeric ID
84 + name_or_id: String,
85 + },
86 +
87 + /// Manage the background daemon
88 + Daemon {
89 + #[command(subcommand)]
90 + command: DaemonCommands,
91 + },
92 +}
93 +
94 +#[derive(Subcommand)]
95 +enum DaemonCommands {
96 + /// Start the background daemon
97 + Start,
98 + /// Stop the background daemon
99 + Stop,
100 + /// Show whether the daemon is running and its PID
101 + Status,
102 +}
103 +
104 fn main() {
2 - println!("Hello, Proses!");
105 + let cli = Cli::parse();
106 +
107 + // Initialise ~/.proses/ paths before anything else.
108 + config::init();
109 +
110 + match cli.command {
111 + // Daemon sub-commands manage the background process directly — no
112 + // need to auto-start the daemon first.
113 + Commands::Daemon { command } => match command {
114 + DaemonCommands::Start => daemon::start_cmd(),
115 + DaemonCommands::Stop => daemon::stop_cmd(),
116 + DaemonCommands::Status => daemon::status_cmd(),
117 + },
118 +
119 + // All other commands talk to the daemon over IPC, so we must ensure
120 + // it is running before proceeding.
121 + other => {
122 + daemon::ensure_running();
123 + dispatch(other);
124 + }
125 + }
126 +}
127 +
128 +fn dispatch(command: Commands) {
129 + match command {
130 + Commands::Start {
131 + command,
132 + name,
133 + cwd,
134 + env,
135 + max_restarts,
136 + } => {
137 + cli::start(command, name, cwd, env, max_restarts);
138 + }
139 + Commands::Stop { name_or_id } => cli::stop(name_or_id),
140 + Commands::Restart { name_or_id } => cli::restart(name_or_id),
141 + Commands::Delete { name_or_id } => cli::delete(name_or_id),
142 + Commands::Save => cli::save(),
143 + Commands::Resurrect => cli::resurrect(),
144 + Commands::Show { name_or_id } => cli::show(name_or_id),
145 + Commands::Logs { name_or_id, lines } => cli::logs(name_or_id, lines),
146 + Commands::List => {
147 + tui::run_dashboard().unwrap_or_else(|e| {
148 + eprintln!(" ✗ TUI error: {e}");
149 + std::process::exit(1);
150 + });
151 + }
152 + Commands::Daemon { .. } => unreachable!(),
153 + }
154 }
src/process/mod.rs new
+69
@@ -0,0 +1,69 @@
1 +pub mod runner;
2 +pub mod store;
3 +
4 +use chrono::{DateTime, Utc};
5 +use serde::{Deserialize, Serialize};
6 +use std::{collections::HashMap, path::PathBuf};
7 +
8 +/// Lifecycle state of a managed process.
9 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10 +#[serde(rename_all = "lowercase")]
11 +pub enum ProcessStatus {
12 + /// Process is alive and being monitored.
13 + Running,
14 + /// Process was deliberately stopped by the user.
15 + Stopped,
16 + /// Process crashed and exhausted its restart budget.
17 + Errored,
18 +}
19 +
20 +impl std::fmt::Display for ProcessStatus {
21 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 + match self {
23 + Self::Running => write!(f, "online"),
24 + Self::Stopped => write!(f, "stopped"),
25 + Self::Errored => write!(f, "errored"),
26 + }
27 + }
28 +}
29 +
30 +/// A single managed process entry.
31 +#[derive(Debug, Clone, Serialize, Deserialize)]
32 +pub struct Process {
33 + /// Monotonically increasing ID assigned by the daemon.
34 + pub id: u32,
35 +
36 + /// Human-readable name (must be unique).
37 + pub name: String,
38 +
39 + /// OS PID of the currently running process instance.
40 + /// 0 when the process is stopped or not yet started.
41 + pub pid: u32,
42 +
43 + /// Shell command passed verbatim to `sh -c`.
44 + pub command: String,
45 +
46 + /// Working directory for the process.
47 + pub cwd: PathBuf,
48 +
49 + /// Extra environment variables merged on top of the daemon's env.
50 + pub env: HashMap<String, String>,
51 +
52 + /// Current lifecycle status.
53 + pub status: ProcessStatus,
54 +
55 + /// Total number of automatic restarts since the process was created.
56 + pub restarts: u32,
57 +
58 + /// Maximum automatic restarts before marking as errored (0 = unlimited).
59 + pub max_restarts: u32,
60 +
61 + /// Timestamp of the most recent spawn.
62 + pub started_at: DateTime<Utc>,
63 +
64 + /// Absolute path to the stdout log file.
65 + pub log_out: PathBuf,
66 +
67 + /// Absolute path to the stderr log file.
68 + pub log_err: PathBuf,
69 +}
src/process/runner.rs new
+85
@@ -0,0 +1,85 @@
1 +use crate::process::Process;
2 +use anyhow::{Context, Result};
3 +use std::fs::OpenOptions;
4 +use std::process::Command;
5 +
6 +/// Spawn `sh -c <process.command>` in `process.cwd`.
7 +///
8 +/// stdout is redirected to `process.log_out` (append mode).
9 +/// stderr is redirected to `process.log_err` (append mode).
10 +/// stdin is connected to `/dev/null`.
11 +///
12 +/// Returns the OS PID of the spawned child.
13 +pub fn spawn(process: &Process) -> Result<u32> {
14 + // Ensure parent log directory exists.
15 + if let Some(parent) = process.log_out.parent() {
16 + std::fs::create_dir_all(parent)
17 + .with_context(|| format!("Failed to create log directory {:?}", parent))?;
18 + }
19 +
20 + let log_out = OpenOptions::new()
21 + .create(true)
22 + .append(true)
23 + .open(&process.log_out)
24 + .with_context(|| format!("Failed to open stdout log {:?}", process.log_out))?;
25 +
26 + let log_err = OpenOptions::new()
27 + .create(true)
28 + .append(true)
29 + .open(&process.log_err)
30 + .with_context(|| format!("Failed to open stderr log {:?}", process.log_err))?;
31 +
32 + let child = Command::new("sh")
33 + .arg("-c")
34 + .arg(&process.command)
35 + .current_dir(&process.cwd)
36 + .envs(&process.env)
37 + .stdout(log_out)
38 + .stderr(log_err)
39 + .stdin(std::process::Stdio::null())
40 + .spawn()
41 + .with_context(|| {
42 + format!(
43 + "Failed to spawn process '{}' with command: {}",
44 + process.name, process.command
45 + )
46 + })?;
47 +
48 + Ok(child.id())
49 +}
50 +
51 +/// Send `SIGTERM` to the process identified by `pid`.
52 +///
53 +/// Returns `Ok(())` even if the process no longer exists (`ESRCH`), because
54 +/// the goal — stopping the process — has already been achieved in that case.
55 +pub fn stop(pid: u32) -> Result<()> {
56 + if pid == 0 {
57 + return Ok(());
58 + }
59 +
60 + let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
61 +
62 + if ret != 0 {
63 + let err = std::io::Error::last_os_error();
64 + // ESRCH: no such process — it is already gone, which is fine.
65 + if err.raw_os_error() == Some(libc::ESRCH) {
66 + return Ok(());
67 + }
68 + return Err(anyhow::anyhow!("kill({}, SIGTERM) failed: {}", pid, err));
69 + }
70 +
71 + Ok(())
72 +}
73 +
74 +/// Returns `true` if a process with the given `pid` exists in the OS process
75 +/// table (`kill(pid, 0)` succeeds).
76 +///
77 +/// Always returns `false` for `pid == 0`.
78 +pub fn is_running(pid: u32) -> bool {
79 + if pid == 0 {
80 + return false;
81 + }
82 + // kill(pid, 0) returns 0 when the process exists (regardless of whether
83 + // we can actually send it a signal), and -1 otherwise.
84 + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
85 +}
src/process/store.rs new
+92
@@ -0,0 +1,92 @@
1 +use crate::process::Process;
2 +use anyhow::Result;
3 +use serde::{Deserialize, Serialize};
4 +use std::{collections::BTreeMap, fs, path::PathBuf};
5 +
6 +/// Persistent state for all managed processes.
7 +///
8 +/// Serialised as pretty-printed JSON to `~/.proses/store.json`.
9 +/// A `BTreeMap` is used so that processes are always iterated in
10 +/// insertion (ID) order — important for stable `list` output.
11 +#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12 +pub struct Store {
13 + /// Counter used to assign the next unique process ID.
14 + pub next_id: u32,
15 +
16 + /// All known processes, keyed by their unique ID.
17 + pub processes: BTreeMap<u32, Process>,
18 +}
19 +
20 +impl Store {
21 + // ---------------------------------------------------------------
22 + // Persistence
23 + // ---------------------------------------------------------------
24 +
25 + /// Load the store from disk. Returns an empty `Store` on any error
26 + /// (missing file, corrupt JSON, etc.) so the daemon can always
27 + /// start cleanly.
28 + pub fn load(path: &PathBuf) -> Self {
29 + if path.exists() {
30 + if let Ok(content) = fs::read_to_string(path) {
31 + if let Ok(store) = serde_json::from_str::<Self>(&content) {
32 + return store;
33 + }
34 + }
35 + }
36 + Self::default()
37 + }
38 +
39 + /// Persist the store to disk atomically (write to a temp file, then
40 + /// rename) so a crash during the write never leaves a corrupt state.
41 + pub fn save(&self, path: &PathBuf) -> Result<()> {
42 + if let Some(parent) = path.parent() {
43 + fs::create_dir_all(parent)?;
44 + }
45 +
46 + let tmp_path = path.with_extension("json.tmp");
47 + let content = serde_json::to_string_pretty(self)?;
48 + fs::write(&tmp_path, &content)?;
49 + fs::rename(&tmp_path, path)?;
50 + Ok(())
51 + }
52 +
53 + // ---------------------------------------------------------------
54 + // ID management
55 + // ---------------------------------------------------------------
56 +
57 + /// Return the next available ID and advance the counter.
58 + pub fn alloc_id(&mut self) -> u32 {
59 + let id = self.next_id;
60 + self.next_id += 1;
61 + id
62 + }
63 +
64 + // ---------------------------------------------------------------
65 + // Lookup helpers
66 + // ---------------------------------------------------------------
67 +
68 + /// Resolve a "name or numeric ID" string to a process ID.
69 + ///
70 + /// Numeric strings are tried as an ID first; if that entry does not
71 + /// exist the string is also tried as a name, so a process literally
72 + /// named `"0"` is still reachable by name.
73 + pub fn find_id(&self, name_or_id: &str) -> Option<u32> {
74 + // Fast-path: numeric look-up by ID.
75 + if let Ok(id) = name_or_id.parse::<u32>() {
76 + if self.processes.contains_key(&id) {
77 + return Some(id);
78 + }
79 + }
80 + // Fall back to name search.
81 + self.processes
82 + .values()
83 + .find(|p| p.name == name_or_id)
84 + .map(|p| p.id)
85 + }
86 +
87 + /// Immutable look-up by name or numeric ID.
88 + pub fn find(&self, name_or_id: &str) -> Option<&Process> {
89 + self.find_id(name_or_id)
90 + .and_then(|id| self.processes.get(&id))
91 + }
92 +}
src/tui/app.rs new
+279
@@ -0,0 +1,279 @@
1 +use std::time::{Duration, Instant};
2 +
3 +use ratatui::widgets::TableState;
4 +
5 +use crate::{
6 + daemon::ipc::{self, Request},
7 + process::{Process, ProcessStatus},
8 +};
9 +
10 +/// Which log stream is currently visible in the log pane.
11 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 +pub enum LogSource {
13 + Stdout,
14 + Stderr,
15 +}
16 +
17 +/// A transient notification shown in the help / status bar.
18 +struct StatusMsg {
19 + text: String,
20 + is_error: bool,
21 + at: Instant,
22 +}
23 +
24 +/// Top-level application state passed into every render frame.
25 +pub struct App {
26 + /// Current snapshot of all managed processes (refreshed every 2 s).
27 + pub processes: Vec<Process>,
28 +
29 + /// Ratatui stateful-widget state – tracks the highlighted row index.
30 + pub table_state: TableState,
31 +
32 + /// Lines loaded from the selected process's log file (~last 200 lines).
33 + pub log_lines: Vec<String>,
34 +
35 + /// Whether the log pane shows stdout or stderr.
36 + pub log_source: LogSource,
37 +
38 + /// Set to `true` when the user presses `q` / `Esc` to exit.
39 + pub should_quit: bool,
40 +
41 + /// Waiting for the second `d` keypress to confirm deletion.
42 + pub confirm_delete: bool,
43 +
44 + /// When the last full refresh from the daemon happened.
45 + pub last_refresh: Instant,
46 +
47 + /// Optional transient message displayed in the status bar.
48 + status: Option<StatusMsg>,
49 +}
50 +
51 +impl App {
52 + /// Create a fresh, empty `App`.
53 + /// Call [`App::refresh`] right away to populate it with live data.
54 + pub fn new() -> Self {
55 + Self {
56 + processes: Vec::new(),
57 + table_state: TableState::default(),
58 + log_lines: Vec::new(),
59 + log_source: LogSource::Stdout,
60 + should_quit: false,
61 + confirm_delete: false,
62 + last_refresh: Instant::now(),
63 + status: None,
64 + }
65 + }
66 +
67 + // ── Daemon communication ─────────────────────────────────────────────────
68 +
69 + /// Pull a fresh process list from the daemon and reload the log pane.
70 + pub fn refresh(&mut self) {
71 + match ipc::send(&Request::List) {
72 + Ok(resp) if resp.success => {
73 + if let Some(procs) = resp.processes {
74 + self.processes = procs;
75 + self.clamp_selection();
76 + }
77 + }
78 + Ok(resp) => self.set_error(resp.message),
79 + Err(e) => self.set_error(format!("Daemon unreachable: {e}")),
80 + }
81 + self.reload_logs();
82 + self.last_refresh = Instant::now();
83 + }
84 +
85 + /// Restart the currently selected process.
86 + pub fn restart_selected(&mut self) {
87 + let Some(id) = self.selected_id_str() else {
88 + return;
89 + };
90 + match ipc::send(&Request::Restart {
91 + name_or_id: id.clone(),
92 + }) {
93 + Ok(resp) if resp.success => self.set_ok(format!("↺ Restarted '{id}'")),
94 + Ok(resp) => self.set_error(resp.message),
95 + Err(e) => self.set_error(format!("IPC error: {e}")),
96 + }
97 + self.refresh();
98 + }
99 +
100 + /// Toggle the selected process:
101 + /// - Running → Stop (will not auto-restart)
102 + /// - Stopped/Errored → Restart (re-launch)
103 + pub fn toggle_stop(&mut self) {
104 + let Some(id) = self.selected_id_str() else {
105 + return;
106 + };
107 + let is_running = self
108 + .selected_process()
109 + .map(|p| p.status == ProcessStatus::Running)
110 + .unwrap_or(false);
111 +
112 + let req = if is_running {
113 + Request::Stop {
114 + name_or_id: id.clone(),
115 + }
116 + } else {
117 + Request::Restart {
118 + name_or_id: id.clone(),
119 + }
120 + };
121 +
122 + match ipc::send(&req) {
123 + Ok(resp) if resp.success => self.set_ok(resp.message),
124 + Ok(resp) => self.set_error(resp.message),
125 + Err(e) => self.set_error(format!("IPC error: {e}")),
126 + }
127 + self.refresh();
128 + }
129 +
130 + /// Arm or execute a deletion.
131 + /// First `d` → sets `confirm_delete = true` (UI shows a warning).
132 + /// Second `d` → sends the Delete request to the daemon.
133 + pub fn delete_selected(&mut self) {
134 + if !self.confirm_delete {
135 + self.confirm_delete = true;
136 + return;
137 + }
138 + self.confirm_delete = false;
139 + let Some(id) = self.selected_id_str() else {
140 + return;
141 + };
142 + match ipc::send(&Request::Delete {
143 + name_or_id: id.clone(),
144 + }) {
145 + Ok(resp) if resp.success => self.set_ok(format!("✕ Deleted '{id}'")),
146 + Ok(resp) => self.set_error(resp.message),
147 + Err(e) => self.set_error(format!("IPC error: {e}")),
148 + }
149 + self.refresh();
150 + }
151 +
152 + /// Flip between stdout and stderr in the log pane.
153 + pub fn toggle_log_source(&mut self) {
154 + self.log_source = match self.log_source {
155 + LogSource::Stdout => LogSource::Stderr,
156 + LogSource::Stderr => LogSource::Stdout,
157 + };
158 + self.reload_logs();
159 + }
160 +
161 + // ── Navigation ───────────────────────────────────────────────────────────
162 +
163 + pub fn select_next(&mut self) {
164 + if self.processes.is_empty() {
165 + return;
166 + }
167 + let next = match self.table_state.selected() {
168 + Some(i) if i + 1 < self.processes.len() => i + 1,
169 + _ => 0,
170 + };
171 + self.table_state.select(Some(next));
172 + self.reload_logs();
173 + }
174 +
175 + pub fn select_prev(&mut self) {
176 + if self.processes.is_empty() {
177 + return;
178 + }
179 + let prev = match self.table_state.selected() {
180 + Some(0) | None => self.processes.len() - 1,
181 + Some(i) => i - 1,
182 + };
183 + self.table_state.select(Some(prev));
184 + self.reload_logs();
185 + }
186 +
187 + // ── Accessors ────────────────────────────────────────────────────────────
188 +
189 + /// Return an immutable reference to the currently selected process, if any.
190 + pub fn selected_process(&self) -> Option<&Process> {
191 + self.table_state
192 + .selected()
193 + .and_then(|i| self.processes.get(i))
194 + }
195 +
196 + /// Return the numeric ID of the selected process as a string (used for IPC).
197 + fn selected_id_str(&self) -> Option<String> {
198 + self.selected_process().map(|p| p.id.to_string())
199 + }
200 +
201 + // ── Log loading ──────────────────────────────────────────────────────────
202 +
203 + /// Read the appropriate log file for the selected process and populate
204 + /// `self.log_lines` with its last ~200 lines.
205 + pub fn reload_logs(&mut self) {
206 + self.log_lines.clear();
207 +
208 + let Some(proc) = self.selected_process() else {
209 + return;
210 + };
211 +
212 + let path = match self.log_source {
213 + LogSource::Stdout => proc.log_out.clone(),
214 + LogSource::Stderr => proc.log_err.clone(),
215 + };
216 +
217 + if let Ok(content) = std::fs::read_to_string(&path) {
218 + // Take the last 200 lines so the buffer stays small.
219 + let lines: Vec<String> = content
220 + .lines()
221 + .rev()
222 + .take(200)
223 + .map(String::from)
224 + .collect::<Vec<_>>()
225 + .into_iter()
226 + .rev()
227 + .collect();
228 + self.log_lines = lines;
229 + }
230 + }
231 +
232 + // ── Selection clamping ───────────────────────────────────────────────────
233 +
234 + /// Ensure the selection index is valid after a process list update.
235 + /// Selects index 0 if nothing was selected before.
236 + fn clamp_selection(&mut self) {
237 + if self.processes.is_empty() {
238 + self.table_state.select(None);
239 + return;
240 + }
241 + match self.table_state.selected() {
242 + None => self.table_state.select(Some(0)),
243 + Some(i) if i >= self.processes.len() => {
244 + self.table_state.select(Some(self.processes.len() - 1));
245 + }
246 + _ => {}
247 + }
248 + }
249 +
250 + // ── Status messages ──────────────────────────────────────────────────────
251 +
252 + fn set_ok(&mut self, msg: String) {
253 + self.status = Some(StatusMsg {
254 + text: msg,
255 + is_error: false,
256 + at: Instant::now(),
257 + });
258 + }
259 +
260 + fn set_error(&mut self, msg: String) {
261 + self.status = Some(StatusMsg {
262 + text: msg,
263 + is_error: true,
264 + at: Instant::now(),
265 + });
266 + }
267 +
268 + /// Return the current status message if it is still within the 4-second
269 + /// display window. Returns `(text, is_error)`.
270 + pub fn status_text(&self) -> Option<(&str, bool)> {
271 + self.status.as_ref().and_then(|s| {
272 + if s.at.elapsed() < Duration::from_secs(4) {
273 + Some((s.text.as_str(), s.is_error))
274 + } else {
275 + None
276 + }
277 + })
278 + }
279 +}
src/tui/mod.rs new
+118
@@ -0,0 +1,118 @@
1 +mod app;
2 +mod ui;
3 +
4 +use std::time::Duration;
5 +
6 +use anyhow::Result;
7 +use crossterm::event::{self, Event, KeyCode, KeyEventKind};
8 +
9 +pub use app::App;
10 +
11 +/// Launch the live process dashboard.
12 +///
13 +/// Initialises the Ratatui terminal, runs the event loop, and restores
14 +/// the terminal on exit (including on panic, via Ratatui's built-in hook).
15 +pub fn run_dashboard() -> Result<()> {
16 + let mut terminal = ratatui::init();
17 + let result = event_loop(&mut terminal);
18 + ratatui::restore();
19 + result
20 +}
21 +
22 +// ── Main event loop ───────────────────────────────────────────────────────────
23 +
24 +fn event_loop(terminal: &mut ratatui::DefaultTerminal) -> Result<()> {
25 + let mut app = App::new();
26 +
27 + // Populate with live data before the first draw.
28 + app.refresh();
29 +
30 + loop {
31 + // Draw a frame.
32 + terminal.draw(|frame| ui::render(frame, &mut app))?;
33 +
34 + // Poll for keyboard / resize events with a short timeout so the
35 + // 2-second refresh timer can fire promptly.
36 + if event::poll(Duration::from_millis(100))? {
37 + match event::read()? {
38 + Event::Key(key) if key.kind == KeyEventKind::Press => {
39 + handle_key(&mut app, key.code);
40 + }
41 + Event::Resize(_, _) => {
42 + // Terminal resize is handled automatically by ratatui on
43 + // the next draw; we just need to trigger a redraw.
44 + }
45 + _ => {}
46 + }
47 + }
48 +
49 + // Periodic refresh: pull a fresh snapshot from the daemon every 2 s.
50 + if app.last_refresh.elapsed() >= Duration::from_secs(2) {
51 + app.refresh();
52 + }
53 +
54 + if app.should_quit {
55 + break;
56 + }
57 + }
58 +
59 + Ok(())
60 +}
61 +
62 +// ── Keyboard handler ──────────────────────────────────────────────────────────
63 +
64 +fn handle_key(app: &mut App, code: KeyCode) {
65 + // Esc always cancels any pending confirmation and, if nothing is pending,
66 + // quits the dashboard.
67 + if code == KeyCode::Esc {
68 + if app.confirm_delete {
69 + app.confirm_delete = false;
70 + } else {
71 + app.should_quit = true;
72 + }
73 + return;
74 + }
75 +
76 + match code {
77 + // ── Navigation ────────────────────────────────────────────────────
78 + KeyCode::Down | KeyCode::Char('j') => {
79 + app.confirm_delete = false;
80 + app.select_next();
81 + }
82 + KeyCode::Up | KeyCode::Char('k') => {
83 + app.confirm_delete = false;
84 + app.select_prev();
85 + }
86 +
87 + // ── Process actions ───────────────────────────────────────────────
88 + KeyCode::Char('r') => {
89 + app.confirm_delete = false;
90 + app.restart_selected();
91 + }
92 + KeyCode::Char('s') => {
93 + app.confirm_delete = false;
94 + app.toggle_stop();
95 + }
96 + KeyCode::Char('d') => {
97 + // First press → arms confirmation; second press → executes.
98 + app.delete_selected();
99 + }
100 +
101 + // ── Log pane ──────────────────────────────────────────────────────
102 + KeyCode::Char('e') => {
103 + app.confirm_delete = false;
104 + app.toggle_log_source();
105 + }
106 +
107 + // ── Quit ──────────────────────────────────────────────────────────
108 + KeyCode::Char('q') => {
109 + if app.confirm_delete {
110 + app.confirm_delete = false;
111 + } else {
112 + app.should_quit = true;
113 + }
114 + }
115 +
116 + _ => {}
117 + }
118 +}
src/tui/ui.rs new
+294
@@ -0,0 +1,294 @@
1 +use chrono::Utc;
2 +use ratatui::{
3 + layout::{Alignment, Constraint, Layout, Rect},
4 + style::{Color, Modifier, Style, Stylize},
5 + text::{Line, Span},
6 + widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
7 + Frame,
8 +};
9 +
10 +use crate::process::ProcessStatus;
11 +use crate::tui::app::{App, LogSource};
12 +
13 +// ── Entry point ───────────────────────────────────────────────────────────────
14 +
15 +/// Render one full frame: title bar → process table + log pane → status bar.
16 +pub fn render(frame: &mut Frame, app: &mut App) {
17 + let area = frame.area();
18 +
19 + let chunks = Layout::vertical([
20 + Constraint::Length(3), // title / header
21 + Constraint::Fill(1), // process table + log pane
22 + Constraint::Length(1), // status / help bar
23 + ])
24 + .split(area);
25 +
26 + render_title(frame, chunks[0], app);
27 + render_body(frame, chunks[1], app);
28 + render_statusbar(frame, chunks[2], app);
29 +}
30 +
31 +// ── Title bar ─────────────────────────────────────────────────────────────────
32 +
33 +fn render_title(frame: &mut Frame, area: Rect, app: &App) {
34 + let online = app
35 + .processes
36 + .iter()
37 + .filter(|p| p.status == ProcessStatus::Running)
38 + .count();
39 + let stopped = app
40 + .processes
41 + .iter()
42 + .filter(|p| p.status == ProcessStatus::Stopped)
43 + .count();
44 + let errored = app
45 + .processes
46 + .iter()
47 + .filter(|p| p.status == ProcessStatus::Errored)
48 + .count();
49 +
50 + let title_line = Line::from(vec![
51 + Span::styled(" PROSES ", Style::default().bold().fg(Color::White)),
52 + Span::styled(" ", Style::default()),
53 + Span::styled(
54 + format!("● {online} online"),
55 + Style::default().fg(Color::Green).bold(),
56 + ),
57 + Span::styled(" · ", Style::default().fg(Color::DarkGray)),
58 + Span::styled(
59 + format!("○ {stopped} stopped"),
60 + if stopped > 0 {
61 + Style::default().fg(Color::Gray)
62 + } else {
63 + Style::default().fg(Color::DarkGray)
64 + },
65 + ),
66 + Span::styled(" · ", Style::default().fg(Color::DarkGray)),
67 + Span::styled(
68 + format!("✕ {errored} errored"),
69 + if errored > 0 {
70 + Style::default().fg(Color::Red).bold()
71 + } else {
72 + Style::default().fg(Color::DarkGray)
73 + },
74 + ),
75 + ]);
76 +
77 + let block = Block::default()
78 + .borders(Borders::ALL)
79 + .border_style(Style::default().fg(Color::DarkGray))
80 + .title(title_line)
81 + .title_alignment(Alignment::Left)
82 + .title_bottom(
83 + Line::from(Span::styled(
84 + " proses v0.1.0 ",
85 + Style::default().fg(Color::DarkGray),
86 + ))
87 + .right_aligned(),
88 + );
89 +
90 + frame.render_widget(block, area);
91 +}
92 +
93 +// ── Body: process table + log pane ───────────────────────────────────────────
94 +
95 +fn render_body(frame: &mut Frame, area: Rect, app: &mut App) {
96 + let chunks = Layout::vertical([Constraint::Percentage(50), Constraint::Fill(1)]).split(area);
97 +
98 + render_process_table(frame, chunks[0], app);
99 + render_log_pane(frame, chunks[1], app);
100 +}
101 +
102 +fn render_process_table(frame: &mut Frame, area: Rect, app: &mut App) {
103 + let header_style = Style::default().bold().fg(Color::Cyan);
104 + let selected_style = Style::default().add_modifier(Modifier::REVERSED);
105 +
106 + let header = Row::new(vec![
107 + Cell::from(" ID"),
108 + Cell::from("Name"),
109 + Cell::from("Status"),
110 + Cell::from("PID"),
111 + Cell::from("↺"),
112 + Cell::from("Uptime"),
113 + Cell::from("Command"),
114 + ])
115 + .style(header_style)
116 + .bottom_margin(0);
117 +
118 + let rows: Vec<Row> = app
119 + .processes
120 + .iter()
121 + .map(|p| {
122 + let (symbol, status_style) = match p.status {
123 + ProcessStatus::Running => ("● online ", Style::default().fg(Color::Green).bold()),
124 + ProcessStatus::Stopped => ("○ stopped", Style::default().fg(Color::DarkGray)),
125 + ProcessStatus::Errored => ("✕ errored", Style::default().fg(Color::Red).bold()),
126 + };
127 +
128 + let uptime = if p.status == ProcessStatus::Running && p.pid > 0 {
129 + format_uptime(p.started_at)
130 + } else {
131 + "–".to_string()
132 + };
133 +
134 + let pid_str = if p.pid > 0 {
135 + p.pid.to_string()
136 + } else {
137 + "–".to_string()
138 + };
139 +
140 + Row::new(vec![
141 + Cell::from(format!(" {}", p.id)),
142 + Cell::from(p.name.clone()),
143 + Cell::from(Span::styled(symbol, status_style)),
144 + Cell::from(pid_str),
145 + Cell::from(p.restarts.to_string()),
146 + Cell::from(uptime),
147 + Cell::from(truncate(&p.command, 48)),
148 + ])
149 + })
150 + .collect();
151 +
152 + let widths = [
153 + Constraint::Length(5), // ID
154 + Constraint::Length(18), // Name
155 + Constraint::Length(11), // Status
156 + Constraint::Length(8), // PID
157 + Constraint::Length(4), // ↺
158 + Constraint::Length(10), // Uptime
159 + Constraint::Fill(1), // Command
160 + ];
161 +
162 + let table = Table::new(rows, widths)
163 + .header(header)
164 + .block(
165 + Block::default()
166 + .borders(Borders::ALL)
167 + .border_style(Style::default().fg(Color::DarkGray))
168 + .title(Span::styled(
169 + " Processes ",
170 + Style::default().fg(Color::White).bold(),
171 + )),
172 + )
173 + .row_highlight_style(selected_style)
174 + .highlight_symbol("▶ ");
175 +
176 + frame.render_stateful_widget(table, area, &mut app.table_state);
177 +}
178 +
179 +fn render_log_pane(frame: &mut Frame, area: Rect, app: &App) {
180 + let proc_name = app
181 + .selected_process()
182 + .map(|p| p.name.as_str())
183 + .unwrap_or("none");
184 +
185 + let src_label = match app.log_source {
186 + LogSource::Stdout => "stdout",
187 + LogSource::Stderr => "stderr",
188 + };
189 +
190 + let title = format!(" Logs: {proc_name} [{src_label}] ");
191 +
192 + let content: Vec<Line> = if app.log_lines.is_empty() {
193 + vec![Line::from(Span::styled(
194 + " (no output yet)",
195 + Style::default().fg(Color::DarkGray),
196 + ))]
197 + } else {
198 + app.log_lines
199 + .iter()
200 + .map(|l| Line::from(Span::raw(format!(" {l}"))))
201 + .collect()
202 + };
203 +
204 + // Auto-scroll: always show the most recent lines at the bottom.
205 + let total_lines = content.len() as u16;
206 + let inner_height = area.height.saturating_sub(2); // subtract borders
207 + let scroll_offset = total_lines.saturating_sub(inner_height);
208 +
209 + let para = Paragraph::new(content)
210 + .block(
211 + Block::default()
212 + .borders(Borders::ALL)
213 + .border_style(Style::default().fg(Color::DarkGray))
214 + .title(Span::styled(
215 + title,
216 + Style::default().fg(Color::White).bold(),
217 + )),
218 + )
219 + .wrap(Wrap { trim: false })
220 + .scroll((scroll_offset, 0));
221 +
222 + frame.render_widget(para, area);
223 +}
224 +
225 +// ── Status / help bar ─────────────────────────────────────────────────────────
226 +
227 +fn render_statusbar(frame: &mut Frame, area: Rect, app: &App) {
228 + let line = if app.confirm_delete {
229 + Line::from(vec![Span::styled(
230 + " ⚠ Press d again to confirm deletion · Esc to cancel",
231 + Style::default().fg(Color::Yellow).bold(),
232 + )])
233 + } else if let Some((msg, is_error)) = app.status_text() {
234 + let color = if is_error { Color::Red } else { Color::Green };
235 + Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color)))
236 + } else {
237 + help_line()
238 + };
239 +
240 + frame.render_widget(Paragraph::new(line), area);
241 +}
242 +
243 +fn help_line<'a>() -> Line<'a> {
244 + let dim = Style::default().fg(Color::DarkGray);
245 + let key = Style::default().fg(Color::White);
246 +
247 + Line::from(vec![
248 + Span::styled(" ", dim),
249 + Span::styled("↑↓", key),
250 + Span::styled(" navigate", dim),
251 + Span::styled(" r", key),
252 + Span::styled(" restart", dim),
253 + Span::styled(" s", key),
254 + Span::styled(" stop/start", dim),
255 + Span::styled(" d", key),
256 + Span::styled(" delete", dim),
257 + Span::styled(" e", key),
258 + Span::styled(" stderr/stdout", dim),
259 + Span::styled(" q", key),
260 + Span::styled(" quit", dim),
261 + ])
262 +}
263 +
264 +// ── Utilities ─────────────────────────────────────────────────────────────────
265 +
266 +fn truncate(s: &str, max: usize) -> String {
267 + let chars: Vec<char> = s.chars().collect();
268 + if chars.len() <= max {
269 + s.to_string()
270 + } else {
271 + let truncated: String = chars[..max - 1].iter().collect();
272 + format!("{truncated}…")
273 + }
274 +}
275 +
276 +fn format_uptime(started_at: chrono::DateTime<Utc>) -> String {
277 + let secs = (Utc::now() - started_at).num_seconds().max(0) as u64;
278 +
279 + if secs < 60 {
280 + format!("{secs}s")
281 + } else if secs < 3600 {
282 + let m = secs / 60;
283 + let s = secs % 60;
284 + format!("{m}m {s}s")
285 + } else if secs < 86_400 {
286 + let h = secs / 3600;
287 + let m = (secs % 3600) / 60;
288 + format!("{h}h {m}m")
289 + } else {
290 + let d = secs / 86_400;
291 + let h = (secs % 86_400) / 3600;
292 + format!("{d}d {h}h")
293 + }
294 +}