procd 1.0.2__tar.gz → 1.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {procd-1.0.2 → procd-1.0.4}/PKG-INFO +1 -1
- {procd-1.0.2 → procd-1.0.4}/pyproject.toml +1 -1
- {procd-1.0.2 → procd-1.0.4}/src/procd/cli.py +216 -78
- {procd-1.0.2 → procd-1.0.4}/src/procd/download.py +20 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/process.py +46 -13
- {procd-1.0.2 → procd-1.0.4}/LICENSE +0 -0
- {procd-1.0.2 → procd-1.0.4}/README_PYPI.md +0 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/__init__.py +0 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/_expand.py +0 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/_logger.py +0 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/builtin.py +0 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/config.py +0 -0
- {procd-1.0.2 → procd-1.0.4}/src/procd/supervisor.py +0 -0
|
@@ -50,10 +50,16 @@ def _resolve_services(names: list[str], conf: cfg.Config) -> list[str]:
|
|
|
50
50
|
return result
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def _default_services(conf: cfg.Config) -> list[str]:
|
|
53
|
+
def _default_services(conf: cfg.Config) -> list[str] | None:
|
|
54
|
+
"""default グループのサービス一覧。未定義なら None。"""
|
|
54
55
|
if "default" in conf.groups:
|
|
55
56
|
return conf.groups["default"].services
|
|
56
|
-
return
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _no_default_group(conf: cfg.Config) -> None:
|
|
61
|
+
_console.print("[yellow]No 'default' group defined. Specify NAME, or define \\[group.default].[/yellow]")
|
|
62
|
+
_print_list(conf)
|
|
57
63
|
|
|
58
64
|
|
|
59
65
|
# " < file" " > file" " >> file" を検出する(スペース区切りで誤検出を防ぐ)
|
|
@@ -178,7 +184,17 @@ def _run_step(step: str, conf: cfg.Config, extra_env: dict[str, str] | None = No
|
|
|
178
184
|
|
|
179
185
|
def cmd_up(args: argparse.Namespace) -> None:
|
|
180
186
|
conf = _load(args)
|
|
181
|
-
|
|
187
|
+
if args.names:
|
|
188
|
+
targets = _resolve_services(args.names, conf)
|
|
189
|
+
else:
|
|
190
|
+
targets = _default_services(conf)
|
|
191
|
+
if targets is None:
|
|
192
|
+
_no_default_group(conf)
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
if args.follow:
|
|
196
|
+
_up_follow(args, conf, targets)
|
|
197
|
+
return
|
|
182
198
|
|
|
183
199
|
if args.detach:
|
|
184
200
|
# detach: 従来通り _logger サブプロセスで JSONL に書き続ける
|
|
@@ -199,6 +215,7 @@ def cmd_up(args: argparse.Namespace) -> None:
|
|
|
199
215
|
color = process.COLORS[i % len(process.COLORS)]
|
|
200
216
|
result = process.wait_ready(name, svc, project_dir=conf.project_dir)
|
|
201
217
|
if result is None:
|
|
218
|
+
process.clear_pid(name)
|
|
202
219
|
print(f"{color}[{name}]{process.RESET} Process exited immediately. Last log:", file=sys.stderr)
|
|
203
220
|
tail = process.tail_log(name)
|
|
204
221
|
if tail:
|
|
@@ -277,9 +294,93 @@ def cmd_up(args: argparse.Namespace) -> None:
|
|
|
277
294
|
shutdown()
|
|
278
295
|
|
|
279
296
|
|
|
297
|
+
def _up_follow(args: argparse.Namespace, conf: cfg.Config, targets: list[str]) -> None:
|
|
298
|
+
"""detach 起動してから jsonl ログをフォローし、終了時にそのサービスも停止する。"""
|
|
299
|
+
jsonl = process.jsonl_path()
|
|
300
|
+
log_start_pos = jsonl.stat().st_size if jsonl.exists() else 0
|
|
301
|
+
|
|
302
|
+
started_names: list[str] = []
|
|
303
|
+
for i, name in enumerate(targets):
|
|
304
|
+
if process.is_running(name):
|
|
305
|
+
print(f"[{name}] already running (pid {process.read_pid(name)})")
|
|
306
|
+
continue
|
|
307
|
+
svc = conf.services[name]
|
|
308
|
+
color = process.COLORS[i % len(process.COLORS)]
|
|
309
|
+
print(f"{color}[{name}]{process.RESET} Starting...")
|
|
310
|
+
process.start(name, svc, detach=True, project_dir=conf.project_dir, verbose=args.verbose)
|
|
311
|
+
started_names.append(name)
|
|
312
|
+
for name in started_names:
|
|
313
|
+
svc = conf.services[name]
|
|
314
|
+
i = list(conf.services.keys()).index(name)
|
|
315
|
+
color = process.COLORS[i % len(process.COLORS)]
|
|
316
|
+
result = process.wait_ready(name, svc, project_dir=conf.project_dir)
|
|
317
|
+
if result is None:
|
|
318
|
+
process.clear_pid(name)
|
|
319
|
+
print(f"{color}[{name}]{process.RESET} Process exited immediately.", file=sys.stderr)
|
|
320
|
+
elif result:
|
|
321
|
+
print(f"{color}[{name}]{process.RESET} Ready (pid {process.read_pid(name)})")
|
|
322
|
+
else:
|
|
323
|
+
print(f"{color}[{name}]{process.RESET} Timed out waiting for ready", file=sys.stderr)
|
|
324
|
+
|
|
325
|
+
def _shutdown_started() -> None:
|
|
326
|
+
if not started_names:
|
|
327
|
+
return
|
|
328
|
+
print("\nStopping services...")
|
|
329
|
+
for name in reversed(started_names):
|
|
330
|
+
if process.stop(name):
|
|
331
|
+
print(f" Stopped {name}")
|
|
332
|
+
|
|
333
|
+
if not jsonl.exists():
|
|
334
|
+
if not started_names:
|
|
335
|
+
_err_console.print("[yellow]no logs found[/yellow]")
|
|
336
|
+
return
|
|
337
|
+
deadline = time.time() + 5
|
|
338
|
+
while not jsonl.exists() and time.time() < deadline:
|
|
339
|
+
time.sleep(0.1)
|
|
340
|
+
if not jsonl.exists():
|
|
341
|
+
process.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
jsonl.touch()
|
|
343
|
+
|
|
344
|
+
def _fmt(entry: dict) -> str:
|
|
345
|
+
s = entry.get("s", "?")
|
|
346
|
+
color = process.COLORS[list(conf.services).index(s) % len(process.COLORS)] if s in conf.services else ""
|
|
347
|
+
ts = f" {entry.get('t','')[:23]} " if args.timestamp else ""
|
|
348
|
+
return f"{color}[{s}]{process.RESET}{ts} {entry.get('m','')}"
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
with open(jsonl, encoding="utf-8") as f:
|
|
352
|
+
f.seek(log_start_pos)
|
|
353
|
+
while True:
|
|
354
|
+
line = f.readline()
|
|
355
|
+
if line:
|
|
356
|
+
try:
|
|
357
|
+
entry = json.loads(line)
|
|
358
|
+
except json.JSONDecodeError:
|
|
359
|
+
continue
|
|
360
|
+
print(_fmt(entry))
|
|
361
|
+
sys.stdout.flush()
|
|
362
|
+
else:
|
|
363
|
+
time.sleep(0.05)
|
|
364
|
+
except KeyboardInterrupt:
|
|
365
|
+
pass
|
|
366
|
+
finally:
|
|
367
|
+
_shutdown_started()
|
|
368
|
+
|
|
369
|
+
|
|
280
370
|
def cmd_down(args: argparse.Namespace) -> None:
|
|
281
371
|
conf = _load(args)
|
|
282
|
-
|
|
372
|
+
if args.all:
|
|
373
|
+
if args.names:
|
|
374
|
+
_err_console.print("[bold red]Error:[/bold red] NAME と --all は同時に指定できません")
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
targets = list(conf.services.keys())
|
|
377
|
+
elif args.names:
|
|
378
|
+
targets = _resolve_services(args.names, conf)
|
|
379
|
+
else:
|
|
380
|
+
targets = _default_services(conf)
|
|
381
|
+
if targets is None:
|
|
382
|
+
_no_default_group(conf)
|
|
383
|
+
return
|
|
283
384
|
for name in reversed(targets):
|
|
284
385
|
if process.stop(name):
|
|
285
386
|
print(f"Stopped {name}")
|
|
@@ -289,7 +390,13 @@ def cmd_down(args: argparse.Namespace) -> None:
|
|
|
289
390
|
|
|
290
391
|
def cmd_restart(args: argparse.Namespace) -> None:
|
|
291
392
|
conf = _load(args)
|
|
292
|
-
|
|
393
|
+
if args.names:
|
|
394
|
+
targets = _resolve_services(args.names, conf)
|
|
395
|
+
else:
|
|
396
|
+
targets = _default_services(conf)
|
|
397
|
+
if targets is None:
|
|
398
|
+
_no_default_group(conf)
|
|
399
|
+
return
|
|
293
400
|
for name in reversed(targets):
|
|
294
401
|
process.stop(name)
|
|
295
402
|
for i, name in enumerate(targets):
|
|
@@ -337,59 +444,13 @@ def cmd_logs(args: argparse.Namespace) -> None:
|
|
|
337
444
|
ts = f" {entry.get('t','')[:23]} " if args.timestamp else ""
|
|
338
445
|
return f"{color}[{s}]{process.RESET}{ts} {entry.get('m','')}"
|
|
339
446
|
|
|
340
|
-
# --up: サービスを起動してからログをフォローし、終了時にサービスも停止する
|
|
341
|
-
started_names: list[str] = []
|
|
342
|
-
# 起動前のログ末尾位置を記録して古いログを表示しない
|
|
343
|
-
log_start_pos: int = jsonl.stat().st_size if jsonl.exists() else 0
|
|
344
|
-
|
|
345
|
-
if getattr(args, "up", None) is not None:
|
|
346
|
-
targets = _resolve_services(args.up, conf) if args.up else _default_services(conf)
|
|
347
|
-
for i, name in enumerate(targets):
|
|
348
|
-
if process.is_running(name):
|
|
349
|
-
print(f"[{name}] already running (pid {process.read_pid(name)})")
|
|
350
|
-
continue
|
|
351
|
-
svc = conf.services[name]
|
|
352
|
-
color = process.COLORS[i % len(process.COLORS)]
|
|
353
|
-
print(f"{color}[{name}]{process.RESET} Starting...")
|
|
354
|
-
process.start(name, svc, detach=True, project_dir=conf.project_dir, verbose=args.verbose)
|
|
355
|
-
started_names.append(name)
|
|
356
|
-
for name in started_names:
|
|
357
|
-
svc = conf.services[name]
|
|
358
|
-
i = list(conf.services.keys()).index(name)
|
|
359
|
-
color = process.COLORS[i % len(process.COLORS)]
|
|
360
|
-
result = process.wait_ready(name, svc, project_dir=conf.project_dir)
|
|
361
|
-
if result is None:
|
|
362
|
-
print(f"{color}[{name}]{process.RESET} Process exited immediately.", file=sys.stderr)
|
|
363
|
-
elif result:
|
|
364
|
-
print(f"{color}[{name}]{process.RESET} Ready (pid {process.read_pid(name)})")
|
|
365
|
-
else:
|
|
366
|
-
print(f"{color}[{name}]{process.RESET} Timed out waiting for ready", file=sys.stderr)
|
|
367
|
-
args.follow = True
|
|
368
|
-
|
|
369
|
-
def _shutdown_started() -> None:
|
|
370
|
-
if not started_names:
|
|
371
|
-
return
|
|
372
|
-
print("\nStopping services...")
|
|
373
|
-
for name in reversed(started_names):
|
|
374
|
-
if process.stop(name):
|
|
375
|
-
print(f" Stopped {name}")
|
|
376
|
-
|
|
377
447
|
if args.follow:
|
|
378
|
-
# --up でサービスを起動した場合はログファイルが存在するまで少し待つ
|
|
379
448
|
if not jsonl.exists():
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return
|
|
383
|
-
deadline = time.time() + 5
|
|
384
|
-
while not jsonl.exists() and time.time() < deadline:
|
|
385
|
-
time.sleep(0.1)
|
|
386
|
-
if not jsonl.exists():
|
|
387
|
-
process.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
388
|
-
jsonl.touch()
|
|
449
|
+
_err_console.print("[yellow]no logs found — run 'procd up -d' first[/yellow]")
|
|
450
|
+
return
|
|
389
451
|
try:
|
|
390
452
|
with open(jsonl, encoding="utf-8") as f:
|
|
391
|
-
#
|
|
392
|
-
f.seek(log_start_pos if started_names else 0, 0 if started_names else 2)
|
|
453
|
+
f.seek(0, 2) # 末尾からフォロー
|
|
393
454
|
while True:
|
|
394
455
|
line = f.readline()
|
|
395
456
|
if line:
|
|
@@ -401,8 +462,6 @@ def cmd_logs(args: argparse.Namespace) -> None:
|
|
|
401
462
|
time.sleep(0.05)
|
|
402
463
|
except KeyboardInterrupt:
|
|
403
464
|
pass
|
|
404
|
-
finally:
|
|
405
|
-
_shutdown_started()
|
|
406
465
|
else:
|
|
407
466
|
if not jsonl.exists():
|
|
408
467
|
return
|
|
@@ -461,24 +520,42 @@ def cmd_run(args: argparse.Namespace) -> None:
|
|
|
461
520
|
|
|
462
521
|
def cmd_list(args: argparse.Namespace) -> None:
|
|
463
522
|
conf = _load(args)
|
|
523
|
+
_print_list(conf)
|
|
464
524
|
|
|
465
|
-
if conf.services:
|
|
466
|
-
_console.print("[bold]Services[/bold]")
|
|
467
|
-
for name in conf.services:
|
|
468
|
-
_console.print(f" [cyan]{name}[/cyan]")
|
|
469
|
-
_console.print()
|
|
470
525
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
526
|
+
def _print_list(conf: cfg.Config) -> None:
|
|
527
|
+
if not conf.services and not conf.groups:
|
|
528
|
+
return
|
|
529
|
+
from rich.table import Table
|
|
530
|
+
table = Table(show_header=True, header_style="bold")
|
|
531
|
+
table.add_column("NAME", style="cyan")
|
|
532
|
+
table.add_column("TYPE")
|
|
533
|
+
table.add_column("SERVICES")
|
|
534
|
+
for name in conf.services:
|
|
535
|
+
table.add_row(name, "service", "")
|
|
536
|
+
for name, grp in conf.groups.items():
|
|
537
|
+
table.add_row(name, "group", ", ".join(grp.services))
|
|
538
|
+
_console.print(table)
|
|
478
539
|
|
|
479
540
|
|
|
480
541
|
def cmd_get(args: argparse.Namespace) -> None:
|
|
481
542
|
conf = _load(args)
|
|
543
|
+
if not args.names:
|
|
544
|
+
if not conf.downloads:
|
|
545
|
+
_console.print("[yellow]No downloads defined in procd.toml.[/yellow]")
|
|
546
|
+
return
|
|
547
|
+
from rich.table import Table
|
|
548
|
+
table = Table(show_header=True, header_style="bold")
|
|
549
|
+
table.add_column("NAME", style="cyan")
|
|
550
|
+
table.add_column("STATUS")
|
|
551
|
+
for name in conf.downloads:
|
|
552
|
+
if dl_mod.is_installed(name, conf):
|
|
553
|
+
status = "[green]installed[/green]"
|
|
554
|
+
else:
|
|
555
|
+
status = "[red]not installed[/red]"
|
|
556
|
+
table.add_row(name, status)
|
|
557
|
+
_console.print(table)
|
|
558
|
+
return
|
|
482
559
|
unknown = [n for n in args.names if n not in conf.downloads]
|
|
483
560
|
if unknown:
|
|
484
561
|
for n in unknown:
|
|
@@ -490,6 +567,55 @@ def cmd_get(args: argparse.Namespace) -> None:
|
|
|
490
567
|
dl_mod.do_get_parallel(args.names, conf, force=args.force)
|
|
491
568
|
|
|
492
569
|
|
|
570
|
+
def _print_clean_preview(conf: cfg.Config) -> None:
|
|
571
|
+
stale = process.stale_pid_names()
|
|
572
|
+
if stale:
|
|
573
|
+
_console.print("[bold]Stale PID files[/bold] ([dim]--pids で削除[/dim])")
|
|
574
|
+
for name in stale:
|
|
575
|
+
_console.print(f" [cyan]{name}[/cyan]")
|
|
576
|
+
else:
|
|
577
|
+
_console.print("[dim]No stale PID files.[/dim]")
|
|
578
|
+
_console.print()
|
|
579
|
+
installed = [n for n in conf.downloads if dl_mod.is_installed(n, conf)]
|
|
580
|
+
if installed:
|
|
581
|
+
_console.print("[bold]Downloaded[/bold] ([dim]--downloads で削除[/dim])")
|
|
582
|
+
for name in installed:
|
|
583
|
+
_console.print(f" [cyan]{name}[/cyan]")
|
|
584
|
+
else:
|
|
585
|
+
_console.print("[dim]No downloaded files.[/dim]")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_clean(args: argparse.Namespace) -> None:
|
|
589
|
+
conf = _load(args)
|
|
590
|
+
do_pids = args.all or args.pids
|
|
591
|
+
do_downloads = args.all or args.downloads is not None
|
|
592
|
+
|
|
593
|
+
if not do_pids and not do_downloads:
|
|
594
|
+
_print_clean_preview(conf)
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
if do_pids:
|
|
598
|
+
stale = process.stale_pid_names()
|
|
599
|
+
if not stale:
|
|
600
|
+
print("No stale PID files.")
|
|
601
|
+
for name in stale:
|
|
602
|
+
process.clear_pid(name)
|
|
603
|
+
print(f"Removed stale pid file: {name}")
|
|
604
|
+
|
|
605
|
+
if do_downloads:
|
|
606
|
+
names = args.downloads if (not args.all and args.downloads) else list(conf.downloads.keys())
|
|
607
|
+
unknown = [n for n in names if n not in conf.downloads]
|
|
608
|
+
if unknown:
|
|
609
|
+
for n in unknown:
|
|
610
|
+
_err_console.print(f"[bold red]Error:[/bold red] unknown opt: {n}")
|
|
611
|
+
sys.exit(1)
|
|
612
|
+
for name in names:
|
|
613
|
+
if dl_mod.remove(name, conf):
|
|
614
|
+
print(f"Removed download: {name}")
|
|
615
|
+
else:
|
|
616
|
+
print(f"{name} was not downloaded")
|
|
617
|
+
|
|
618
|
+
|
|
493
619
|
_INIT_TEMPLATE = '''\
|
|
494
620
|
# ============================================================
|
|
495
621
|
# Procd ローカルサービス定義
|
|
@@ -589,10 +715,10 @@ def cmd_link(args: argparse.Namespace) -> None:
|
|
|
589
715
|
conf = _load(args)
|
|
590
716
|
project_dir = conf.project_dir
|
|
591
717
|
|
|
592
|
-
if args.
|
|
593
|
-
names = " ".join(args.
|
|
594
|
-
procd_sub = f"
|
|
595
|
-
shortcut_name = f"procd
|
|
718
|
+
if args.up_follow is not None:
|
|
719
|
+
names = " ".join(args.up_follow)
|
|
720
|
+
procd_sub = f"up --follow{' ' + names if names else ''}"
|
|
721
|
+
shortcut_name = f"procd up --follow{' ' + names if names else ''}"
|
|
596
722
|
elif args.up is not None:
|
|
597
723
|
names = " ".join(args.up)
|
|
598
724
|
procd_sub = f"up{' ' + names if names else ''}"
|
|
@@ -632,12 +758,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
632
758
|
# up
|
|
633
759
|
p = sub.add_parser("up", help="サービスを起動する")
|
|
634
760
|
p.add_argument("names", nargs="*", metavar="NAME")
|
|
635
|
-
p.
|
|
761
|
+
group = p.add_mutually_exclusive_group()
|
|
762
|
+
group.add_argument("-d", "--detach", action="store_true", help="バックグラウンド起動")
|
|
763
|
+
group.add_argument("-f", "--follow", action="store_true",
|
|
764
|
+
help="バックグラウンド起動してログをフォローし、終了時にサービスも停止する")
|
|
765
|
+
p.add_argument("-t", "--timestamp", action="store_true", help="--follow 時にタイムスタンプを表示する")
|
|
636
766
|
p.set_defaults(func=cmd_up)
|
|
637
767
|
|
|
638
768
|
# down
|
|
639
769
|
p = sub.add_parser("down", help="サービスを停止する")
|
|
640
770
|
p.add_argument("names", nargs="*", metavar="NAME")
|
|
771
|
+
p.add_argument("-a", "--all", action="store_true", help="default グループの定義に関わらず全サービスを停止する")
|
|
641
772
|
p.set_defaults(func=cmd_down)
|
|
642
773
|
|
|
643
774
|
# restart
|
|
@@ -655,8 +786,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
655
786
|
p.add_argument("-f", "--follow", action="store_true", help="tail -f スタイルで追跡")
|
|
656
787
|
p.add_argument("-n", "--lines", type=int, default=200, metavar="N", help="表示する行数(デフォルト: 200)")
|
|
657
788
|
p.add_argument("-t", "--timestamp", action="store_true", help="タイムスタンプを表示する")
|
|
658
|
-
p.add_argument("--up", nargs="*", metavar="NAME",
|
|
659
|
-
help="サービスを起動してからログをフォローし、終了時にサービスも停止する")
|
|
660
789
|
p.set_defaults(func=cmd_logs)
|
|
661
790
|
|
|
662
791
|
# run
|
|
@@ -675,17 +804,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
675
804
|
|
|
676
805
|
# get
|
|
677
806
|
p = sub.add_parser("get", help="[opt.*] をダウンロード・展開する")
|
|
678
|
-
p.add_argument("names", nargs="
|
|
807
|
+
p.add_argument("names", nargs="*", metavar="NAME",
|
|
808
|
+
help="ダウンロードする opt 名(省略時は一覧を表示する)")
|
|
679
809
|
p.add_argument("--force", action="store_true", help="既存でも強制的に再ダウンロードする")
|
|
680
810
|
p.set_defaults(func=cmd_get)
|
|
681
811
|
|
|
812
|
+
# clean
|
|
813
|
+
p = sub.add_parser("clean", help="stale な pid ファイルやダウンロード済みファイルを削除する")
|
|
814
|
+
p.add_argument("--pids", action="store_true", help="動いていないプロセスの pid ファイルを削除する")
|
|
815
|
+
p.add_argument("--downloads", nargs="*", metavar="NAME",
|
|
816
|
+
help="ダウンロード済みファイルを削除する(NAME 省略で全件)")
|
|
817
|
+
p.add_argument("--all", action="store_true", help="--pids と --downloads の両方を実行する")
|
|
818
|
+
p.set_defaults(func=cmd_clean)
|
|
819
|
+
|
|
682
820
|
# link
|
|
683
821
|
p = sub.add_parser("link", help="デスクトップショートカットを作成する")
|
|
684
822
|
group = p.add_mutually_exclusive_group(required=True)
|
|
685
823
|
group.add_argument("--up", nargs="*", metavar="NAME",
|
|
686
824
|
help="procd up のショートカット(NAME 省略で default グループ)")
|
|
687
|
-
group.add_argument("--
|
|
688
|
-
help="procd
|
|
825
|
+
group.add_argument("--up-follow", nargs="*", metavar="NAME", dest="up_follow",
|
|
826
|
+
help="procd up --follow のショートカット(NAME 省略で default グループ)")
|
|
689
827
|
group.add_argument("--run", metavar="NAME",
|
|
690
828
|
help="procd run <NAME> のショートカット")
|
|
691
829
|
p.add_argument("--icon", metavar="FILE", help="アイコンファイル (.ico / .icns)")
|
|
@@ -5,6 +5,7 @@ cli.cmd_get と builtin._do_get の両方から呼ばれる。
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
import hashlib
|
|
8
|
+
import shutil
|
|
8
9
|
import stat
|
|
9
10
|
import sys
|
|
10
11
|
import tarfile
|
|
@@ -43,6 +44,25 @@ def _already_exists(dl: "DownloadConfig", dest: Path, url_lower: str) -> bool:
|
|
|
43
44
|
return (dest / Path(dl.url.split("?")[0]).name).exists()
|
|
44
45
|
|
|
45
46
|
|
|
47
|
+
def is_installed(name: str, conf: "Config") -> bool:
|
|
48
|
+
"""opt.<name> が展開済み(ダウンロード済み)かどうかを返す。"""
|
|
49
|
+
dl = conf.downloads[name]
|
|
50
|
+
dest = Path(dl.dest)
|
|
51
|
+
if not dest.exists():
|
|
52
|
+
return False
|
|
53
|
+
url_lower = dl.url.lower().split("?")[0]
|
|
54
|
+
return _already_exists(dl, dest, url_lower)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def remove(name: str, conf: "Config") -> bool:
|
|
58
|
+
"""opt.<name> の展開先ディレクトリを削除する。存在しなければ False。"""
|
|
59
|
+
dest = Path(conf.downloads[name].dest)
|
|
60
|
+
if not dest.exists():
|
|
61
|
+
return False
|
|
62
|
+
shutil.rmtree(dest)
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
|
|
46
66
|
def _finalize(name: str, dl: "DownloadConfig", tmp: Path, dest: Path, url_lower: str) -> None:
|
|
47
67
|
"""verify → extract/rename → 後処理。do_get / do_get_parallel の共通後半処理。"""
|
|
48
68
|
_verify_checksum(tmp, dl.checksum, name)
|
|
@@ -60,30 +60,63 @@ def _append_jsonl(name: str, message: str) -> None:
|
|
|
60
60
|
|
|
61
61
|
def write_pid(name: str, pid: int) -> None:
|
|
62
62
|
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
-
|
|
63
|
+
try:
|
|
64
|
+
create_time = psutil.Process(pid).create_time()
|
|
65
|
+
except psutil.NoSuchProcess:
|
|
66
|
+
create_time = 0.0
|
|
67
|
+
pid_path(name).write_text(f"{pid}:{create_time}")
|
|
64
68
|
|
|
65
69
|
|
|
66
|
-
def
|
|
70
|
+
def _read_pid_file(name: str) -> tuple[int, float | None] | None:
|
|
71
|
+
"""pid ファイルを (pid, create_time) で読む。旧形式(PID のみ)なら create_time は None。"""
|
|
67
72
|
p = pid_path(name)
|
|
68
73
|
if not p.exists():
|
|
69
74
|
return None
|
|
75
|
+
text = p.read_text().strip()
|
|
70
76
|
try:
|
|
71
|
-
|
|
77
|
+
if ":" in text:
|
|
78
|
+
pid_str, ct_str = text.split(":", 1)
|
|
79
|
+
return int(pid_str), float(ct_str)
|
|
80
|
+
return int(text), None
|
|
72
81
|
except ValueError:
|
|
73
82
|
return None
|
|
74
83
|
|
|
75
84
|
|
|
85
|
+
def read_pid(name: str) -> int | None:
|
|
86
|
+
parsed = _read_pid_file(name)
|
|
87
|
+
return parsed[0] if parsed else None
|
|
88
|
+
|
|
89
|
+
|
|
76
90
|
def clear_pid(name: str) -> None:
|
|
77
91
|
p = pid_path(name)
|
|
78
92
|
if p.exists():
|
|
79
93
|
p.unlink()
|
|
80
94
|
|
|
81
95
|
|
|
96
|
+
def stale_pid_names() -> list[str]:
|
|
97
|
+
"""var/run/*.pid のうち、対応するプロセスが実際には動いていない名前一覧。"""
|
|
98
|
+
if not RUN_DIR.exists():
|
|
99
|
+
return []
|
|
100
|
+
return [p.stem for p in sorted(RUN_DIR.glob("*.pid")) if not is_running(p.stem)]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_same_process(pid: int, create_time: float | None) -> bool:
|
|
104
|
+
"""PID が存在し、記録した起動時刻と一致するか(PID 再利用検出)。"""
|
|
105
|
+
if not psutil.pid_exists(pid):
|
|
106
|
+
return False
|
|
107
|
+
if create_time is None:
|
|
108
|
+
return True # 旧形式の pid ファイル: create_time 比較をスキップ
|
|
109
|
+
try:
|
|
110
|
+
return abs(psutil.Process(pid).create_time() - create_time) < 1
|
|
111
|
+
except psutil.NoSuchProcess:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
82
115
|
def is_running(name: str) -> bool:
|
|
83
|
-
|
|
84
|
-
if
|
|
116
|
+
parsed = _read_pid_file(name)
|
|
117
|
+
if parsed is None:
|
|
85
118
|
return False
|
|
86
|
-
return
|
|
119
|
+
return _is_same_process(*parsed)
|
|
87
120
|
|
|
88
121
|
|
|
89
122
|
# ---------- プロセス起動・停止 ----------
|
|
@@ -205,11 +238,12 @@ def _kill_tree(pid: int) -> None:
|
|
|
205
238
|
|
|
206
239
|
|
|
207
240
|
def stop(name: str) -> bool:
|
|
208
|
-
"""PID
|
|
209
|
-
|
|
210
|
-
if
|
|
241
|
+
"""PID ファイルを元にプロセスを停止する。既に停止済み、または PID が別プロセスに再利用されている場合は False を返す。"""
|
|
242
|
+
parsed = _read_pid_file(name)
|
|
243
|
+
if parsed is None:
|
|
211
244
|
return False
|
|
212
|
-
|
|
245
|
+
pid, create_time = parsed
|
|
246
|
+
if not _is_same_process(pid, create_time):
|
|
213
247
|
clear_pid(name)
|
|
214
248
|
return False
|
|
215
249
|
_kill_tree(pid)
|
|
@@ -246,9 +280,8 @@ def wait_ready(name: str, svc: "ServiceConfig", timeout: int = 30, project_dir:
|
|
|
246
280
|
port = int(port_str)
|
|
247
281
|
deadline = time.time() + timeout
|
|
248
282
|
while time.time() < deadline:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return None # 起動直後にクラッシュ
|
|
283
|
+
if not is_running(name):
|
|
284
|
+
return None # 起動直後にクラッシュ(または PID が別プロセスに再利用された)
|
|
252
285
|
try:
|
|
253
286
|
with socket.create_connection((host, port), timeout=1):
|
|
254
287
|
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|