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
+}