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 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
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
@@ -0,0 +1,4 @@
1
+ # Procd
2
+ The Native Stack Runner. Orchestration Without Containers.
3
+
4
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](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,3 @@
1
+ from .supervisor import Supervisor
2
+
3
+ __all__ = ["Supervisor"]
@@ -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