procd 1.0.0__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.0/LICENSE +21 -0
- procd-1.0.0/PKG-INFO +30 -0
- procd-1.0.0/README_PYPI.md +4 -0
- procd-1.0.0/pyproject.toml +106 -0
- procd-1.0.0/src/procd/__init__.py +3 -0
- procd-1.0.0/src/procd/_expand.py +20 -0
- procd-1.0.0/src/procd/_logger.py +41 -0
- procd-1.0.0/src/procd/builtin.py +415 -0
- procd-1.0.0/src/procd/cli.py +617 -0
- procd-1.0.0/src/procd/config.py +223 -0
- procd-1.0.0/src/procd/download.py +264 -0
- procd-1.0.0/src/procd/process.py +279 -0
- procd-1.0.0/src/procd/supervisor.py +153 -0
procd-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alpine
|
|
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.
|
procd-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: procd
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The Native Stack Runner. Orchestration Without Containers.
|
|
5
|
+
Keywords:
|
|
6
|
+
Author: alpine
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Requires-Dist: cryptography>=46.0.7
|
|
19
|
+
Requires-Dist: inquirerpy>=0.3.4
|
|
20
|
+
Requires-Dist: psutil>=7.2.2
|
|
21
|
+
Requires-Dist: pyshortcuts>=1.9.7
|
|
22
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
23
|
+
Requires-Dist: rich>=15.0.0
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Procd
|
|
28
|
+
The Native Stack Runner. Orchestration Without Containers.
|
|
29
|
+
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.15,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "procd"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "The Native Stack Runner. Orchestration Without Containers."
|
|
9
|
+
readme = "README_PYPI.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "alpine" },
|
|
14
|
+
]
|
|
15
|
+
keywords = []
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Database",
|
|
26
|
+
]
|
|
27
|
+
requires-python = ">=3.10"
|
|
28
|
+
dependencies = [
|
|
29
|
+
"cryptography>=46.0.7",
|
|
30
|
+
"inquirerpy>=0.3.4",
|
|
31
|
+
"psutil>=7.2.2",
|
|
32
|
+
"pyshortcuts>=1.9.7",
|
|
33
|
+
"python-dotenv>=1.2.2",
|
|
34
|
+
"rich>=15.0.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
procd = "procd.cli:main"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9.0.3",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
[tool.uv]
|
|
49
|
+
package = true
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
# https://docs.astral.sh/ruff/configuration/
|
|
54
|
+
src = ["src"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
# https://docs.astral.sh/ruff/rules/
|
|
58
|
+
select = [
|
|
59
|
+
# pycodestyle (E, W)
|
|
60
|
+
"E2",
|
|
61
|
+
"E4",
|
|
62
|
+
"E7",
|
|
63
|
+
"E9",
|
|
64
|
+
"W",
|
|
65
|
+
|
|
66
|
+
# Pyflakes (F)
|
|
67
|
+
"F",
|
|
68
|
+
|
|
69
|
+
# flake8-no-pep420 (INP)
|
|
70
|
+
"INP",
|
|
71
|
+
|
|
72
|
+
# pep8-naming (N)
|
|
73
|
+
#"N",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
"docker/*.py" = ["INP"]
|
|
78
|
+
"scripts/*.py" = ["INP"]
|
|
79
|
+
"tests/*.py" = ["INP"]
|
|
80
|
+
"var/*.py" = ["INP"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
[tool.pytest.ini_options]
|
|
84
|
+
cache_dir = "var/.pytest_cache"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
[tool.coverage.run]
|
|
88
|
+
source = ["src"]
|
|
89
|
+
data_file = "var/.coverage"
|
|
90
|
+
omit = [
|
|
91
|
+
"tests/*",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.coverage.report]
|
|
95
|
+
exclude_lines = [
|
|
96
|
+
"pragma: no cover",
|
|
97
|
+
"def __repr__",
|
|
98
|
+
"if self.debug:",
|
|
99
|
+
"if settings.DEBUG",
|
|
100
|
+
"raise AssertionError",
|
|
101
|
+
"raise NotImplementedError",
|
|
102
|
+
"if 0:",
|
|
103
|
+
"if __name__ == .__main__.:",
|
|
104
|
+
"class .*\\bProtocol\\):",
|
|
105
|
+
"@(abc\\.)?abstractmethod",
|
|
106
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""@{VAR} / @{VAR:-default} 展開の共通実装。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
_AT_BRACE_RE = re.compile(r"@\{([^}]+)\}")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_var(key: str, mapping: dict[str, str]) -> str:
|
|
10
|
+
"""@{VAR} または @{VAR:-default} を mapping で解決する。"""
|
|
11
|
+
if ":-" in key:
|
|
12
|
+
var, default = key.split(":-", 1)
|
|
13
|
+
val = mapping.get(var)
|
|
14
|
+
return val if val is not None and val != "" else default
|
|
15
|
+
return mapping.get(key, f"@{{{key}}}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def expand(s: str, mapping: dict[str, str]) -> str:
|
|
19
|
+
"""mapping で @{VAR} / @{VAR:-default} を展開する。未定義キーはそのまま残す。"""
|
|
20
|
+
return _AT_BRACE_RE.sub(lambda m: resolve_var(m.group(1), mapping), s)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
サービスの stdout をリアルタイムで JSONL に書き出すラッパープロセス。
|
|
3
|
+
|
|
4
|
+
使い方(procd 内部から呼ばれる):
|
|
5
|
+
<service> | python -m procd._logger <tag> <jsonl_path>
|
|
6
|
+
|
|
7
|
+
サービスが終了すると stdin が閉じ、このプロセスも自然に終了する。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(tag: str, output_file: str) -> None:
|
|
17
|
+
with open(output_file, mode="a", encoding="utf-8", buffering=1) as f:
|
|
18
|
+
for line in sys.stdin:
|
|
19
|
+
clean_line = line.rstrip("\n\r")
|
|
20
|
+
if not clean_line:
|
|
21
|
+
continue
|
|
22
|
+
entry = {
|
|
23
|
+
"t": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="milliseconds"),
|
|
24
|
+
"s": tag,
|
|
25
|
+
"m": clean_line,
|
|
26
|
+
}
|
|
27
|
+
try:
|
|
28
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
29
|
+
f.flush()
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"[procd._logger] {e}", file=sys.stderr)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
if len(sys.argv) < 3:
|
|
36
|
+
print("Usage: <command> | python -m procd._logger <tag> <jsonl_path>", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
try:
|
|
39
|
+
run(sys.argv[1], sys.argv[2])
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
pass
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Zeus 組み込みコマンド (@mkdir, @remove, @copy, @move, @extract, @get, @run, @read, @echo, @select, @set, @enc, @dec)
|
|
3
|
+
|
|
4
|
+
steps の行が @ で始まる場合にこのモジュールが処理する。
|
|
5
|
+
Python 標準ライブラリのみ使用するためクロスプラットフォームで動作する。
|
|
6
|
+
ただし @enc / @dec は cryptography パッケージを使用する(OpenSSL AES-256-CBC 互換、改ざん検知なし)。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import tarfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from . import download as dl_mod
|
|
20
|
+
|
|
21
|
+
_console = Console()
|
|
22
|
+
_err_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .config import Config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Cancelled(SystemExit):
|
|
29
|
+
"""ユーザーが対話入力をキャンセルしたときに送出する。終了コード 130(Ctrl+C 相当)。"""
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__(130)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(cmd: str, conf: "Config | None" = None, runtime_env: "dict[str, str] | None" = None, extra_env: "dict[str, str] | None" = None, step_runner: "Any | None" = None) -> None:
|
|
35
|
+
"""@ 始まりのコマンド文字列を実行する。"""
|
|
36
|
+
parts = shlex.split(cmd[1:]) # 先頭の @ を除いて分割
|
|
37
|
+
if not parts:
|
|
38
|
+
raise ValueError("Empty built-in command")
|
|
39
|
+
|
|
40
|
+
name, args = parts[0], parts[1:]
|
|
41
|
+
|
|
42
|
+
if name == "echo":
|
|
43
|
+
# "off" / "on" control step-echo mode via runtime_env
|
|
44
|
+
if len(args) == 1 and args[0].lower() in ("off", "on"):
|
|
45
|
+
if runtime_env is not None:
|
|
46
|
+
runtime_env["__echo__"] = args[0].lower()
|
|
47
|
+
return
|
|
48
|
+
# Extract raw text preserving internal whitespace
|
|
49
|
+
raw_parts = cmd[1:].split(None, 1)
|
|
50
|
+
print(raw_parts[1] if len(raw_parts) > 1 else "")
|
|
51
|
+
|
|
52
|
+
elif name == "mkdir":
|
|
53
|
+
for p in args:
|
|
54
|
+
Path(p).mkdir(parents=True, exist_ok=True)
|
|
55
|
+
_console.print(f"[dim]mkdir {p}[/dim]")
|
|
56
|
+
|
|
57
|
+
elif name == "remove":
|
|
58
|
+
for p in args:
|
|
59
|
+
path = Path(p)
|
|
60
|
+
if path.is_dir():
|
|
61
|
+
shutil.rmtree(path)
|
|
62
|
+
elif path.exists():
|
|
63
|
+
path.unlink()
|
|
64
|
+
_console.print(f"[dim]remove {p}[/dim]")
|
|
65
|
+
|
|
66
|
+
elif name == "copy":
|
|
67
|
+
if len(args) != 2:
|
|
68
|
+
raise ValueError("@copy <src> <dst>")
|
|
69
|
+
shutil.copy2(args[0], args[1])
|
|
70
|
+
_console.print(f"[dim]copy {args[0]} -> {args[1]}[/dim]")
|
|
71
|
+
|
|
72
|
+
elif name in ("move", "mv"):
|
|
73
|
+
if len(args) < 2:
|
|
74
|
+
raise ValueError("@move <src> [src ...] <dst>")
|
|
75
|
+
import glob as glob_mod
|
|
76
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
77
|
+
*src_patterns, dst_str = args
|
|
78
|
+
dst = Path(dst_str)
|
|
79
|
+
dst_is_dir = dst_str.endswith("/") or dst_str.endswith("\\")
|
|
80
|
+
if dst_is_dir and not dst.exists():
|
|
81
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
srcs: list[Path] = []
|
|
83
|
+
for pat in src_patterns:
|
|
84
|
+
if _GLOB_CHARS.intersection(pat):
|
|
85
|
+
matched = sorted(glob_mod.glob(pat, recursive=True))
|
|
86
|
+
srcs.extend(Path(m) for m in matched)
|
|
87
|
+
else:
|
|
88
|
+
srcs.append(Path(pat))
|
|
89
|
+
if len(srcs) > 1 and not dst.is_dir():
|
|
90
|
+
raise ValueError(f"@move: destination must be a directory when moving multiple files: {dst_str}")
|
|
91
|
+
for src in srcs:
|
|
92
|
+
shutil.move(str(src), str(dst))
|
|
93
|
+
_console.print(f"[dim]move {src} -> {dst_str}[/dim]")
|
|
94
|
+
|
|
95
|
+
elif name == "extract":
|
|
96
|
+
if len(args) != 2:
|
|
97
|
+
raise ValueError("@extract <file> <dest>")
|
|
98
|
+
src, dest_str = args[0], args[1]
|
|
99
|
+
dest = Path(dest_str)
|
|
100
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
src_lower = src.lower()
|
|
102
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
103
|
+
with Progress(
|
|
104
|
+
SpinnerColumn(),
|
|
105
|
+
TextColumn("[progress.description]{task.description}"),
|
|
106
|
+
console=_console,
|
|
107
|
+
transient=True,
|
|
108
|
+
) as progress:
|
|
109
|
+
task = progress.add_task(f"extracting {Path(src).name} ...")
|
|
110
|
+
if src_lower.endswith(".zip"):
|
|
111
|
+
with zipfile.ZipFile(src) as zf:
|
|
112
|
+
for member in zf.infolist():
|
|
113
|
+
progress.update(task, description=f"extracting {member.filename}")
|
|
114
|
+
zf.extract(member, dest)
|
|
115
|
+
elif any(src_lower.endswith(ext) for ext in (".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")):
|
|
116
|
+
with tarfile.open(src) as tf:
|
|
117
|
+
for member in tf.getmembers():
|
|
118
|
+
progress.update(task, description=f"extracting {member.name}")
|
|
119
|
+
tf.extract(member, dest)
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError(f"@extract: unsupported format: {src}")
|
|
122
|
+
_console.print(f"[dim]extract {src} -> {dest_str}[/dim]")
|
|
123
|
+
|
|
124
|
+
elif name == "read":
|
|
125
|
+
if len(args) < 1:
|
|
126
|
+
raise ValueError("@read <VAR> [prompt...]")
|
|
127
|
+
if runtime_env is None:
|
|
128
|
+
raise RuntimeError("@read requires a runtime context")
|
|
129
|
+
var = args[0]
|
|
130
|
+
# shlex.split strips trailing spaces, so extract the prompt from the raw string.
|
|
131
|
+
# cmd[1:] strips the leading '@', then split(None, 2) gives ["read", "VAR", "raw prompt"].
|
|
132
|
+
raw_parts = cmd[1:].split(None, 2)
|
|
133
|
+
prompt = raw_parts[2] if len(raw_parts) > 2 else ""
|
|
134
|
+
value = input(prompt)
|
|
135
|
+
runtime_env[var] = value
|
|
136
|
+
|
|
137
|
+
elif name == "get":
|
|
138
|
+
if len(args) < 1:
|
|
139
|
+
raise ValueError("@get <name> [name ...]")
|
|
140
|
+
if conf is None:
|
|
141
|
+
raise RuntimeError("@get requires config")
|
|
142
|
+
if len(args) == 1:
|
|
143
|
+
_do_get(args[0], conf)
|
|
144
|
+
else:
|
|
145
|
+
dl_mod.do_get_parallel(list(args), conf)
|
|
146
|
+
|
|
147
|
+
elif name == "run":
|
|
148
|
+
if len(args) != 1:
|
|
149
|
+
raise ValueError("@run <name>")
|
|
150
|
+
if conf is None:
|
|
151
|
+
raise RuntimeError("@run requires config")
|
|
152
|
+
_do_run(args[0], conf, runtime_env, parent_env=extra_env, step_runner=step_runner)
|
|
153
|
+
|
|
154
|
+
elif name == "up":
|
|
155
|
+
if len(args) < 1:
|
|
156
|
+
raise ValueError("@up <name> [name ...]")
|
|
157
|
+
if conf is None:
|
|
158
|
+
raise RuntimeError("@up requires config")
|
|
159
|
+
for svc_name in args:
|
|
160
|
+
_do_up(svc_name, conf)
|
|
161
|
+
|
|
162
|
+
elif name == "down":
|
|
163
|
+
if len(args) < 1:
|
|
164
|
+
raise ValueError("@down <name> [name ...]")
|
|
165
|
+
if conf is None:
|
|
166
|
+
raise RuntimeError("@down requires config")
|
|
167
|
+
for svc_name in args:
|
|
168
|
+
_do_down(svc_name, conf)
|
|
169
|
+
|
|
170
|
+
elif name == "select":
|
|
171
|
+
if len(args) < 2:
|
|
172
|
+
raise ValueError("@select <VAR> <token>...")
|
|
173
|
+
if runtime_env is None:
|
|
174
|
+
raise RuntimeError("@select requires a runtime context")
|
|
175
|
+
runtime_env[args[0]] = _do_select(args[0], args[1:], project_dir=conf.project_dir if conf else None)
|
|
176
|
+
|
|
177
|
+
elif name == "set":
|
|
178
|
+
# @set <VAR> <value> [s/<pattern>/<replacement>/[flags]]
|
|
179
|
+
if len(args) < 2 or len(args) > 3:
|
|
180
|
+
raise ValueError("@set <VAR> <value> [s/<pattern>/<replacement>/[flags]]")
|
|
181
|
+
if runtime_env is None:
|
|
182
|
+
raise RuntimeError("@set requires a runtime context")
|
|
183
|
+
value = args[1]
|
|
184
|
+
if len(args) == 3:
|
|
185
|
+
value = _apply_substitution(value, args[2])
|
|
186
|
+
runtime_env[args[0]] = value
|
|
187
|
+
|
|
188
|
+
elif name == "enc":
|
|
189
|
+
# @enc <passfile> <src> <dest>
|
|
190
|
+
if len(args) != 3:
|
|
191
|
+
raise ValueError("@enc <passfile> <src> <dest>")
|
|
192
|
+
_do_enc(pass_file=args[0], src=args[1], dest=args[2])
|
|
193
|
+
|
|
194
|
+
elif name == "dec":
|
|
195
|
+
# @dec <passfile> <src> <dest>
|
|
196
|
+
if len(args) != 3:
|
|
197
|
+
raise ValueError("@dec <passfile> <src> <dest>")
|
|
198
|
+
_do_dec(pass_file=args[0], src=args[1], dest=args[2])
|
|
199
|
+
|
|
200
|
+
else:
|
|
201
|
+
raise ValueError(f"Unknown built-in command: @{name}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# set (変数代入・正規表現置換)
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _apply_substitution(value: str, expr: str) -> str:
|
|
209
|
+
"""s/<pattern>/<replacement>/[flags] 形式の正規表現置換を value に適用する。
|
|
210
|
+
flags: i = 大小文字無視, g = 全置換(省略時は最初の1箇所のみ)。
|
|
211
|
+
パターン・置換文字列内の \\/ は / のエスケープとして扱う。
|
|
212
|
+
"""
|
|
213
|
+
import re
|
|
214
|
+
if not expr.startswith("s/"):
|
|
215
|
+
raise ValueError(f"@set: 置換式は s/<pattern>/<replacement>/[flags] の形式にしてください: {expr!r}")
|
|
216
|
+
|
|
217
|
+
# \/ をエスケープとして扱いながら / で最大2分割するパーサー
|
|
218
|
+
inner = expr[2:]
|
|
219
|
+
parts: list[str] = []
|
|
220
|
+
current: list[str] = []
|
|
221
|
+
i = 0
|
|
222
|
+
while i < len(inner):
|
|
223
|
+
if inner[i] == "\\" and i + 1 < len(inner) and inner[i + 1] == "/":
|
|
224
|
+
current.append("\\/")
|
|
225
|
+
i += 2
|
|
226
|
+
elif inner[i] == "/":
|
|
227
|
+
parts.append("".join(current))
|
|
228
|
+
current = []
|
|
229
|
+
i += 1
|
|
230
|
+
if len(parts) == 2:
|
|
231
|
+
parts.append(inner[i:]) # 残りはすべて flags
|
|
232
|
+
break
|
|
233
|
+
else:
|
|
234
|
+
current.append(inner[i])
|
|
235
|
+
i += 1
|
|
236
|
+
if len(parts) < 2:
|
|
237
|
+
parts.append("".join(current))
|
|
238
|
+
while len(parts) < 3:
|
|
239
|
+
parts.append("")
|
|
240
|
+
|
|
241
|
+
pattern, replacement, flags_str = parts[0], parts[1], parts[2]
|
|
242
|
+
re_flags, count = 0, 1
|
|
243
|
+
for f in flags_str:
|
|
244
|
+
if f == "i":
|
|
245
|
+
re_flags |= re.IGNORECASE
|
|
246
|
+
elif f == "g":
|
|
247
|
+
count = 0 # 0 = すべて置換
|
|
248
|
+
return re.sub(pattern, replacement, value, count=count, flags=re_flags)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# enc / dec (OpenSSL AES-256-CBC 互換、改ざん検知なし)
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
_OPENSSL_MAGIC = b"Salted__"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _evp_bytes_to_key(password: bytes, salt: bytes) -> tuple[bytes, bytes]:
|
|
259
|
+
"""EVP_BytesToKey (SHA-256, 1 iteration) → (key: 32 bytes, iv: 16 bytes)."""
|
|
260
|
+
import hashlib
|
|
261
|
+
d, d_i = b"", b""
|
|
262
|
+
while len(d) < 48: # 32 (AES-256 key) + 16 (IV)
|
|
263
|
+
d_i = hashlib.sha256(d_i + password + salt).digest()
|
|
264
|
+
d += d_i
|
|
265
|
+
return d[:32], d[32:48]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _do_enc(pass_file: str, src: str, dest: str) -> None:
|
|
269
|
+
"""AES-256-CBC で暗号化。OpenSSL `Salted__` フォーマット互換。"""
|
|
270
|
+
import os
|
|
271
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
272
|
+
from cryptography.hazmat.primitives import padding
|
|
273
|
+
|
|
274
|
+
password = Path(pass_file).read_bytes().splitlines()[0] # 1行目のみ(openssl互換)
|
|
275
|
+
salt = os.urandom(8)
|
|
276
|
+
key, iv = _evp_bytes_to_key(password, salt)
|
|
277
|
+
|
|
278
|
+
padder = padding.PKCS7(128).padder()
|
|
279
|
+
plaintext = padder.update(Path(src).read_bytes()) + padder.finalize()
|
|
280
|
+
|
|
281
|
+
encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
|
|
282
|
+
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
|
|
283
|
+
|
|
284
|
+
Path(dest).write_bytes(_OPENSSL_MAGIC + salt + ciphertext)
|
|
285
|
+
_console.print(f"[dim]enc {src} -> {dest}[/dim]")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _do_dec(pass_file: str, src: str, dest: str) -> None:
|
|
289
|
+
"""AES-256-CBC で復号。OpenSSL `Salted__` フォーマット互換。"""
|
|
290
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
291
|
+
from cryptography.hazmat.primitives import padding
|
|
292
|
+
|
|
293
|
+
raw = Path(src).read_bytes()
|
|
294
|
+
if not raw.startswith(_OPENSSL_MAGIC):
|
|
295
|
+
raise ValueError(f"@dec: OpenSSL Salted__ ヘッダが見つかりません: {src}")
|
|
296
|
+
|
|
297
|
+
salt = raw[8:16]
|
|
298
|
+
ciphertext = raw[16:]
|
|
299
|
+
password = Path(pass_file).read_bytes().splitlines()[0]
|
|
300
|
+
key, iv = _evp_bytes_to_key(password, salt)
|
|
301
|
+
|
|
302
|
+
decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
|
|
303
|
+
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
304
|
+
|
|
305
|
+
unpadder = padding.PKCS7(128).unpadder()
|
|
306
|
+
data = unpadder.update(padded) + unpadder.finalize()
|
|
307
|
+
|
|
308
|
+
Path(dest).write_bytes(data)
|
|
309
|
+
_console.print(f"[dim]dec {src} -> {dest}[/dim]")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _do_get(name: str, conf: "Config", force: bool = False) -> None:
|
|
315
|
+
dl_mod.do_get(name, conf, force=force)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _do_run(name: str, conf: "Config", runtime_env: "dict[str, str] | None" = None, parent_env: "dict[str, str] | None" = None, step_runner: "Any | None" = None) -> None:
|
|
319
|
+
"""@run の実装。別コマンドの steps を再帰実行する。"""
|
|
320
|
+
if name not in conf.commands:
|
|
321
|
+
raise ValueError(f"@run: unknown command: {name}")
|
|
322
|
+
cmd = conf.commands[name]
|
|
323
|
+
label = f"[dim]{name}[/dim]" + (f"[dim] {cmd.description}[/dim]" if cmd.description else "")
|
|
324
|
+
_console.rule(label, style="dim")
|
|
325
|
+
# 環境変数マージ: 親の env を下敷きにして、子の cmd.env で上書き
|
|
326
|
+
merged_env: dict[str, str] = {**(parent_env or {}), **(cmd.env or {})}
|
|
327
|
+
# @set / @select の変数スコープは子コマンド内で独立させる
|
|
328
|
+
child_runtime_env: dict[str, str] = {}
|
|
329
|
+
total = len(cmd.steps)
|
|
330
|
+
for i, step in enumerate(cmd.steps):
|
|
331
|
+
prefix = f" [cyan][{i + 1}/{total}][/cyan] "
|
|
332
|
+
if step_runner is not None:
|
|
333
|
+
code = step_runner(
|
|
334
|
+
step, conf,
|
|
335
|
+
extra_env=merged_env if merged_env else None,
|
|
336
|
+
shell=cmd.shell,
|
|
337
|
+
prefix=prefix,
|
|
338
|
+
interactive=cmd.interactive,
|
|
339
|
+
runtime_env=child_runtime_env,
|
|
340
|
+
)
|
|
341
|
+
if code != 0:
|
|
342
|
+
raise RuntimeError(f"Step failed (exit code {code}): {step}")
|
|
343
|
+
else:
|
|
344
|
+
# フォールバック: step_runner なしでの直接実行(@run が cli 外から呼ばれた場合)
|
|
345
|
+
if step.startswith("@"):
|
|
346
|
+
run(step, conf, child_runtime_env, extra_env=merged_env if merged_env else None)
|
|
347
|
+
else:
|
|
348
|
+
import subprocess as _sp
|
|
349
|
+
result = _sp.run(shlex.split(step))
|
|
350
|
+
if result.returncode != 0:
|
|
351
|
+
raise RuntimeError(f"Step failed (exit code {result.returncode}): {step}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _do_up(name: str, conf: "Config") -> None:
|
|
355
|
+
"""@up の実装。サービスをバックグラウンドで起動し ready まで待機する。"""
|
|
356
|
+
from . import process
|
|
357
|
+
|
|
358
|
+
if name not in conf.services:
|
|
359
|
+
raise ValueError(f"@up: unknown service: {name}")
|
|
360
|
+
if process.is_running(name):
|
|
361
|
+
_console.print(f"[dim][{name}] already running[/dim]")
|
|
362
|
+
return
|
|
363
|
+
svc = conf.services[name]
|
|
364
|
+
_console.print(f"[dim]starting {name}...[/dim]")
|
|
365
|
+
process.start(name, svc, detach=True, project_dir=conf.project_dir)
|
|
366
|
+
result = process.wait_ready(name, svc, project_dir=conf.project_dir)
|
|
367
|
+
if result is None:
|
|
368
|
+
raise RuntimeError(f"@up: {name} exited immediately")
|
|
369
|
+
if not result:
|
|
370
|
+
raise RuntimeError(f"@up: {name} timed out waiting for ready")
|
|
371
|
+
_console.print(f"[dim][{name}] ready (pid {process.read_pid(name)})[/dim]")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _do_down(name: str, conf: "Config") -> None:
|
|
375
|
+
"""@down の実装。サービスを停止する。"""
|
|
376
|
+
from . import process
|
|
377
|
+
|
|
378
|
+
if name not in conf.services:
|
|
379
|
+
raise ValueError(f"@down: unknown service: {name}")
|
|
380
|
+
if process.stop(name):
|
|
381
|
+
_console.print(f"[dim]stopped {name}[/dim]")
|
|
382
|
+
else:
|
|
383
|
+
_console.print(f"[dim][{name}] was not running[/dim]")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _do_select(var: str, tokens: list[str], project_dir: "Path | None" = None) -> str:
|
|
387
|
+
"""@select の実装。tokens を glob 展開またはリテラルとして候補を収集し InquirerPy fuzzy で選択させる。"""
|
|
388
|
+
import glob as glob_mod
|
|
389
|
+
|
|
390
|
+
from InquirerPy import inquirer
|
|
391
|
+
|
|
392
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
393
|
+
|
|
394
|
+
candidates: list[str] = []
|
|
395
|
+
for token in tokens:
|
|
396
|
+
if _GLOB_CHARS.intersection(token):
|
|
397
|
+
matches = sorted(glob_mod.glob(token, root_dir=project_dir, recursive=True))
|
|
398
|
+
candidates.extend(Path(m).as_posix() for m in matches)
|
|
399
|
+
else:
|
|
400
|
+
candidates.append(token)
|
|
401
|
+
|
|
402
|
+
if not candidates:
|
|
403
|
+
_err_console.print(f"[bold red]Error:[/bold red] @select: no candidates for {tokens!r}")
|
|
404
|
+
raise SystemExit(1)
|
|
405
|
+
|
|
406
|
+
result = inquirer.fuzzy(
|
|
407
|
+
message=f"Select {var}:",
|
|
408
|
+
choices=candidates,
|
|
409
|
+
max_height="40%",
|
|
410
|
+
).execute()
|
|
411
|
+
|
|
412
|
+
if result is None:
|
|
413
|
+
raise Cancelled()
|
|
414
|
+
|
|
415
|
+
return result
|