orzmc-app 2.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.
@@ -0,0 +1,177 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110
+ .pdm.toml
111
+ .pdm-python
112
+ .pdm-build/
113
+
114
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115
+ __pypackages__/
116
+
117
+ # Celery stuff
118
+ celerybeat-schedule
119
+ celerybeat.pid
120
+
121
+ # SageMath parsed files
122
+ *.sage.py
123
+
124
+ # Environments
125
+ .env
126
+ .venv
127
+ env/
128
+ venv/
129
+ ENV/
130
+ env.bak/
131
+ venv.bak/
132
+
133
+ # Spyder project settings
134
+ .spyderproject
135
+ .spyproject
136
+
137
+ # Rope project settings
138
+ .ropeproject
139
+
140
+ # mkdocs documentation
141
+ /site
142
+
143
+ # mypy
144
+ .mypy_cache/
145
+ .dmypy.json
146
+ dmypy.json
147
+
148
+ # Pyre type checker
149
+ .pyre/
150
+
151
+ # pytype static type analyzer
152
+ .pytype/
153
+
154
+ # Cython debug symbols
155
+ cython_debug/
156
+
157
+ # PyCharm
158
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
159
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
160
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
161
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
162
+ #.idea/
163
+
164
+ .DS_Store
165
+ .vscode/
166
+
167
+ # uv
168
+ .venv/
169
+
170
+ # pytest
171
+ .pytest_cache/
172
+
173
+ # PyInstaller build dir (scripts/build.py)
174
+ .pybuild/
175
+
176
+ # runtime artifacts (managed under game root, not repo)
177
+ java/
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: orzmc-app
3
+ Version: 2.0.0
4
+ Summary: OrzMC: CLI application for Minecraft client/server bootstrap
5
+ Author: OrzMC
6
+ License: MIT
7
+ Keywords: cli,java,launcher,minecraft,server
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Games/Entertainment
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: orzmc>=2.0.0
16
+ Requires-Dist: prompt-toolkit>=3.0
17
+ Requires-Dist: typer>=0.12
18
+ Description-Content-Type: text/markdown
19
+
20
+ # orzmc-app
21
+
22
+ OrzMC application: typer CLI built on the `orzmc` library.
23
+
24
+ - `orzmc` (no args) prints help
25
+ - `orzmc client` / `orzmc server` bootstrap game instances
26
+ - `orzmc remove` / `orzmc list` manage installed versions
27
+
28
+ See the [project README](../README.md) for full documentation.
@@ -0,0 +1,9 @@
1
+ # orzmc-app
2
+
3
+ OrzMC application: typer CLI built on the `orzmc` library.
4
+
5
+ - `orzmc` (no args) prints help
6
+ - `orzmc client` / `orzmc server` bootstrap game instances
7
+ - `orzmc remove` / `orzmc list` manage installed versions
8
+
9
+ See the [project README](../README.md) for full documentation.
@@ -0,0 +1,10 @@
1
+ """OrzMC application: typer CLI, built on the orzmc library.
2
+
3
+ The app layer only calls the library's public API (``orzmc/__init__.py``).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __version__ = "2.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,32 @@
1
+ """Typer CLI entry point for orzmc-app.
2
+
3
+ The console script ``orzmc`` maps to :func:`main`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ import typer
11
+
12
+ from orzmc_app.cli.app import app
13
+
14
+
15
+ def main() -> int:
16
+ try:
17
+ app()
18
+ return 0
19
+ except typer.Exit as exc:
20
+ return exc.exit_code or 0
21
+ except KeyboardInterrupt:
22
+ print("已取消", file=sys.stderr)
23
+ return 130
24
+ except Exception: # pragma: no cover - unexpected bug, keep a traceback
25
+ import traceback
26
+
27
+ traceback.print_exc()
28
+ return 1
29
+
30
+
31
+ if __name__ == "__main__":
32
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from orzmc_app.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,256 @@
1
+ """Typer CLI: subcommands that call only the orzmc library public API.
2
+
3
+ ``orzmc`` with no subcommand prints help; every subcommand works as a plain
4
+ command line, with lightweight rich prompts when run interactively (version
5
+ defaults to Mojang latest when neither ``-v`` nor a TTY prompt is available).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Annotated, NoReturn
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.prompt import Confirm
16
+ from rich.table import Table
17
+
18
+ from orzmc import (
19
+ GameType,
20
+ RuntimeOptions,
21
+ backup_world,
22
+ deploy_server,
23
+ launch_client,
24
+ list_versions,
25
+ remove_version,
26
+ )
27
+ from orzmc import (
28
+ __version__ as LIB_VERSION,
29
+ )
30
+ from orzmc.infra.log import RichReporter
31
+ from orzmc.infra.progress import RichProgress
32
+ from orzmc_app import __version__ as APP_VERSION
33
+ from orzmc_app.cli.options import JvmOpts, MaxMem, MinMem, RootDir, Username, Verbose, Version, Yes
34
+ from orzmc_app.cli.prompts import confirm_eula, confirm_java, is_interactive, resolve_version
35
+
36
+ _console = Console(highlight=False)
37
+ app = typer.Typer(add_completion=False, no_args_is_help=False, invoke_without_command=True)
38
+
39
+ _MEM_RE = re.compile(r"^\d+[kKmMgGtT]$")
40
+
41
+
42
+ def _fail(message: str) -> NoReturn:
43
+ _console.print(f"[error]错误:[/error] {message}")
44
+ raise typer.Exit(1)
45
+
46
+
47
+ def _check_mem(name: str, value: str) -> None:
48
+ if not _MEM_RE.match(value):
49
+ _fail(f"无效内存: {name}={value} (例如 512M、2G)")
50
+
51
+
52
+ def _parse_type(value: str, *, client: bool) -> GameType:
53
+ try:
54
+ game_type = GameType.parse(value)
55
+ except ValueError:
56
+ _fail(f"未知类型: {value}")
57
+ ok = game_type.is_client_capable if client else game_type.is_server_capable
58
+ if not ok:
59
+ _fail(f"类型 {value} 不能用于{'客户端' if client else '服务端'}")
60
+ return game_type
61
+
62
+
63
+ @app.callback(invoke_without_command=True)
64
+ def root(ctx: typer.Context, verbose: Verbose = False) -> None:
65
+ """OrzMC — Minecraft 客户端启动 / 服务端部署工具。
66
+
67
+ 不带子命令时打印帮助;直接使用子命令(如 ``orzmc client -v 1.20.4``)。
68
+ """
69
+ ctx.obj = {"verbose": verbose}
70
+ if ctx.invoked_subcommand is None:
71
+ _console.print(ctx.get_help(), markup=False)
72
+ raise typer.Exit(0)
73
+
74
+
75
+ @app.command()
76
+ def client(
77
+ ctx: typer.Context,
78
+ version: Version = None,
79
+ username: Username = "guest",
80
+ game_type: Annotated[str, typer.Option("--type", "-t", help="类型: vanilla|fabric|forge")] = "vanilla",
81
+ min_mem: MinMem = "512M",
82
+ max_mem: MaxMem = "2G",
83
+ extract_music: Annotated[bool, typer.Option("--extract-music", help="提取客户端音乐后退出")] = False,
84
+ jvm_opts: JvmOpts = None,
85
+ root_dir: RootDir = None,
86
+ ) -> None:
87
+ """运行 Minecraft 客户端(缺失文件自动下载即安装)。"""
88
+ game_type_obj = _parse_type(game_type, client=True)
89
+ _check_mem("min", min_mem)
90
+ _check_mem("max", max_mem)
91
+ resolved = resolve_version(version, root_dir)
92
+ options = RuntimeOptions(
93
+ is_client=True,
94
+ version=resolved,
95
+ username=username,
96
+ game_type=game_type_obj.value,
97
+ min_mem=min_mem,
98
+ max_mem=max_mem,
99
+ extract_music=extract_music,
100
+ jvm_opts=jvm_opts,
101
+ root_dir=root_dir,
102
+ )
103
+ reporter = RichReporter(verbose=bool(ctx.obj.get("verbose")))
104
+ sink = RichProgress()
105
+ try:
106
+ launch_client(options, reporter=reporter, sink=sink, confirm_java=confirm_java if is_interactive() else None)
107
+ except Exception as exc:
108
+ _fail(str(exc))
109
+ finally:
110
+ sink.close()
111
+
112
+
113
+ @app.command()
114
+ def server(
115
+ ctx: typer.Context,
116
+ version: Version = None,
117
+ game_type: Annotated[str, typer.Option("--type", "-t", help="类型: vanilla|paper|fabric|forge")] = "vanilla",
118
+ min_mem: MinMem = "512M",
119
+ max_mem: MaxMem = "2G",
120
+ force_upgrade: Annotated[bool, typer.Option("--force-upgrade", help="服务端启动加 --forceUpgrade")] = False,
121
+ symlink: Annotated[bool, typer.Option("--symlink", help="软链接到备份世界")] = False,
122
+ force_download: Annotated[bool, typer.Option("--force-download", help="强制重新下载核心")] = False,
123
+ yes: Yes = False,
124
+ jvm_opts: JvmOpts = None,
125
+ server_args: Annotated[str | None, typer.Option("--server-args", help="服务端程序参数(如 '--port 25565')")] = None,
126
+ nogui: Annotated[
127
+ bool, typer.Option("--nogui", help="无窗口模式启动(不弹服务端 GUI);终端输入 stop 或 Ctrl-C 关闭")
128
+ ] = False,
129
+ root_dir: RootDir = None,
130
+ ) -> None:
131
+ """部署并运行 Minecraft 服务端(缺失文件自动下载即安装)。"""
132
+ game_type_obj = _parse_type(game_type, client=False)
133
+ _check_mem("min", min_mem)
134
+ _check_mem("max", max_mem)
135
+ resolved = resolve_version(version, root_dir)
136
+ options = RuntimeOptions(
137
+ is_client=False,
138
+ version=resolved,
139
+ game_type=game_type_obj.value,
140
+ min_mem=min_mem,
141
+ max_mem=max_mem,
142
+ force_upgrade=force_upgrade,
143
+ symlink=symlink,
144
+ force_download=force_download,
145
+ yes=yes,
146
+ jvm_opts=jvm_opts,
147
+ server_args=server_args,
148
+ nogui=nogui,
149
+ root_dir=root_dir,
150
+ )
151
+ reporter = RichReporter(verbose=bool(ctx.obj.get("verbose")))
152
+ sink = RichProgress()
153
+ try:
154
+ deploy_server(
155
+ options,
156
+ reporter=reporter,
157
+ sink=sink,
158
+ confirm_java=confirm_java if is_interactive() else None,
159
+ confirm_eula=confirm_eula if is_interactive() else None,
160
+ )
161
+ except Exception as exc:
162
+ _fail(str(exc))
163
+ finally:
164
+ sink.close()
165
+
166
+
167
+ @app.command()
168
+ def remove(
169
+ version: Annotated[str, typer.Option("--version", "-v", help="要移除的 Minecraft 版本")],
170
+ server: Annotated[bool, typer.Option("--server", help="移除服务端(默认移除客户端)")] = False,
171
+ game_type: Annotated[str | None, typer.Option("--type", "-t", help="服务端类型(移除服务端时必填)")] = None,
172
+ yes: Yes = False,
173
+ root_dir: RootDir = None,
174
+ ) -> None:
175
+ """移除已安装的版本(客户端或某类型的服务端)。"""
176
+ if server and not game_type:
177
+ _fail("移除服务端需要指定类型 (-t)")
178
+ try:
179
+ removed = remove_version(
180
+ version,
181
+ is_client=not server,
182
+ game_type=game_type,
183
+ root_dir=root_dir,
184
+ yes=yes,
185
+ # 非交互时无法确认,视为取消(不删除);交互时弹 rich 确认。
186
+ confirm=(
187
+ (lambda desc: bool(Confirm.ask(f"{desc}\n确定移除吗?", default=False)))
188
+ if is_interactive()
189
+ else (lambda desc: False)
190
+ ),
191
+ )
192
+ except Exception as exc:
193
+ _fail(str(exc))
194
+ if removed:
195
+ _console.print(f"[success]已移除 {version}[/success]")
196
+ else:
197
+ _console.print("[yellow]已取消移除[/yellow]")
198
+
199
+
200
+ @app.command()
201
+ def list(root_dir: RootDir = None) -> None:
202
+ """列出已安装的版本(客户端 / 服务端类型)。"""
203
+ items = list_versions(root_dir=root_dir)
204
+ if not items:
205
+ _console.print("[muted]尚未安装任何版本[/muted]")
206
+ return
207
+ table = Table(title="已安装版本", header_style="bold")
208
+ table.add_column("版本")
209
+ table.add_column("客户端", justify="center")
210
+ table.add_column("服务端")
211
+ for item in items:
212
+ table.add_row(item.version, "✓" if item.has_client else "", ", ".join(item.server_types))
213
+ _console.print(table)
214
+
215
+
216
+ @app.command()
217
+ def backup(
218
+ version: Annotated[str | None, typer.Option("--version", "-v", help="要备份的版本(缺省自动推断)")] = None,
219
+ game_type: Annotated[str, typer.Option("--type", "-t", help="服务端类型")] = "vanilla",
220
+ root_dir: RootDir = None,
221
+ ) -> None:
222
+ """备份服务端世界到 ``backup/worlds/``。"""
223
+ resolved = _infer_backup_version(version, root_dir)
224
+ try:
225
+ dest = backup_world(resolved, game_type=game_type, root_dir=root_dir)
226
+ except Exception as exc:
227
+ _fail(str(exc))
228
+ _console.print(f"[success]备份完成:[/success] {dest}")
229
+
230
+
231
+ @app.command()
232
+ def version() -> None:
233
+ """打印工具版本。"""
234
+ _console.print(f"orzmc {APP_VERSION} (库 {LIB_VERSION})")
235
+
236
+
237
+ def _infer_backup_version(version: str | None, root_dir: str | None) -> str:
238
+ if version:
239
+ return version
240
+ installed = list_versions(root_dir=root_dir)
241
+ candidates = [item.version for item in installed if item.server_types]
242
+ if not candidates:
243
+ _fail("没有已安装的服务端,请用 -v 指定版本")
244
+ if len(candidates) == 1:
245
+ return candidates[0]
246
+ if is_interactive():
247
+ _console.print("[info]可备份的服务端版本:[/info]")
248
+ for i, item in enumerate(candidates, 1):
249
+ _console.print(f" [muted]{i:>2}.[/muted] {item}")
250
+ from rich.prompt import Prompt
251
+
252
+ picked = Prompt.ask("选择版本", default="")
253
+ if picked.isdigit() and 1 <= int(picked) <= len(candidates):
254
+ return candidates[int(picked) - 1]
255
+ return picked or candidates[0]
256
+ _fail("有多个服务端版本,请用 -v 指定")
@@ -0,0 +1,29 @@
1
+ """Shared typer option declarations reused across CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ # ── generic ──────────────────────────────────────────────────────────────────
10
+
11
+
12
+ def _root_dir_help() -> str:
13
+ return "游戏根目录(默认 ~/minecraft)"
14
+
15
+
16
+ def _version_help() -> str:
17
+ return "Minecraft 版本(缺省:TTY 交互选择,可搜索/切换正式版·测试版;非 TTY 用 Mojang 最新 release)"
18
+
19
+
20
+ # ── option type aliases ──────────────────────────────────────────────────────
21
+
22
+ RootDir = Annotated[str | None, typer.Option("--root-dir", help=_root_dir_help())]
23
+ Version = Annotated[str | None, typer.Option("--version", "-v", help=_version_help())]
24
+ Username = Annotated[str, typer.Option("--username", "-u", help="游戏用户名(默认 guest)")]
25
+ MinMem = Annotated[str, typer.Option("--minmem", "-m", help="最小内存(如 512M)")]
26
+ MaxMem = Annotated[str, typer.Option("--maxmem", "-x", help="最大内存(如 2G)")]
27
+ JvmOpts = Annotated[str | None, typer.Option("--jvm-opts", help="附加 JVM 旗标(空格分隔,如 '-XX:+UseZGC')")]
28
+ Verbose = Annotated[bool, typer.Option("--verbose", help="输出调试日志")]
29
+ Yes = Annotated[bool, typer.Option("--yes", help="跳过交互确认(自动接受 EULA 等)")]
@@ -0,0 +1,493 @@
1
+ """Keyboard-navigable full-screen picker over the Mojang version catalog.
2
+
3
+ The pure selection state machine (:class:`PickerState`), the responsive layout
4
+ (:func:`_layout`) and the fragment renderers (``*_fragments``) have no I/O and
5
+ are unit tested; :func:`run_picker` wires them to a prompt_toolkit full-screen
6
+ app whose ``input``/``output`` are injectable so tests can drive it with a pipe
7
+ and a dummy output.
8
+
9
+ The layout adapts to the terminal: a status title, the item list, an up/down
10
+ ``还有 N 个`` scroll indicator pair, a search box and a multi-line help block
11
+ are stacked, and slots that have no content collapse (``dont_extend_height``).
12
+ The viewport row count and the number of help lines are recomputed on every
13
+ render/keypress, so a mid-session resize is followed automatically
14
+ (prompt_toolkit re-renders on SIGWINCH).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Sequence
20
+ from dataclasses import dataclass
21
+
22
+ from prompt_toolkit.application import Application, get_app
23
+ from prompt_toolkit.buffer import Buffer
24
+ from prompt_toolkit.data_structures import Size
25
+ from prompt_toolkit.filters import Condition
26
+ from prompt_toolkit.formatted_text import StyleAndTextTuples
27
+ from prompt_toolkit.input.base import Input
28
+ from prompt_toolkit.key_binding import KeyBindings, KeyBindingsBase, merge_key_bindings
29
+ from prompt_toolkit.key_binding.defaults import load_key_bindings
30
+ from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
31
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
32
+ from prompt_toolkit.output.base import Output
33
+ from prompt_toolkit.styles import Style
34
+ from prompt_toolkit.utils import get_cwidth
35
+
36
+ from orzmc import VersionEntry
37
+
38
+ _DEFAULT_VIEW_ROWS = 10
39
+ _FALLBACK_TERM_ROWS = 24
40
+ _FALLBACK_TERM_COLUMNS = 80
41
+
42
+ _FIXED_ROWS = 4 # title + up indicator + down indicator + search box
43
+ _MIN_VIEW_ROWS = 3 # the list keeps at least this many rows when help is shown
44
+
45
+ # Bottom help. Each line is short enough for narrow terminals, ordered by
46
+ # non-decreasing display width (so a leading slice always fits the column
47
+ # budget), and explains what the keys DO rather than just listing them.
48
+ _HELP_LINES = (
49
+ "↑↓ 选择版本 · ←→ 前后翻页", # 25
50
+ "输入过滤 · t 切正式版/测试版 · x 清空", # 37
51
+ "Enter 选中 · Esc 用最新 · Ctrl-C 取消", # 37
52
+ )
53
+ _MAX_HELP_LINES = len(_HELP_LINES)
54
+
55
+ _STYLES = {
56
+ "picker.title": "bold fg:ansicyan",
57
+ "picker.selected": "reverse bold",
58
+ "picker.match": "fg:ansiyellow bold",
59
+ "picker.muted": "fg:ansibrightblack",
60
+ "picker.hint": "fg:ansibrightblack",
61
+ "picker.scroll": "fg:ansibrightblack",
62
+ "picker.empty": "fg:ansiyellow",
63
+ }
64
+
65
+
66
+ def _channel_label(channel: str) -> str:
67
+ return "正式版" if channel == "release" else "测试版"
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class LayoutSpec:
72
+ """Responsive layout derived from the terminal size."""
73
+
74
+ view_rows: int # item rows the list region can hold
75
+ help_lines: int # how many of the bottom help lines fit
76
+
77
+
78
+ def _layout(term_rows: int, term_columns: int) -> LayoutSpec:
79
+ """Pick a layout for the given terminal size.
80
+
81
+ Help lines (see :data:`_HELP_LINES`) are shown from the top until either
82
+ the column or the row budget runs out. Lines are ordered by non-decreasing
83
+ display width, so the leading slice ``_HELP_LINES[:help_lines]`` always
84
+ fits the column budget. The row budget stops adding help once the list
85
+ viewport would drop below :data:`_MIN_VIEW_ROWS`. The title, the two scroll
86
+ indicator slots and the search box are statically reserved
87
+ (:data:`_FIXED_ROWS`), so the scroll math never shifts as indicators
88
+ appear/disappear.
89
+ """
90
+ col_help = sum(1 for line in _HELP_LINES if get_cwidth(line) <= term_columns)
91
+ row_help = 0
92
+ for n in range(_MAX_HELP_LINES, 0, -1):
93
+ if term_rows - _FIXED_ROWS - n >= _MIN_VIEW_ROWS:
94
+ row_help = n
95
+ break
96
+ help_lines = min(col_help, row_help)
97
+ view_rows = max(1, min(_DEFAULT_VIEW_ROWS, term_rows - _FIXED_ROWS - help_lines))
98
+ return LayoutSpec(view_rows=view_rows, help_lines=help_lines)
99
+
100
+
101
+ def _fit(text: str, width: int) -> str:
102
+ """Truncate ``text`` to display ``width`` columns, appending ``…`` when cut."""
103
+ if get_cwidth(text) <= width:
104
+ return text
105
+ used = 0
106
+ kept: list[str] = []
107
+ for ch in text:
108
+ w = max(get_cwidth(ch), 1)
109
+ if used + w > width - 1: # reserve one column for the ellipsis
110
+ break
111
+ kept.append(ch)
112
+ used += w
113
+ return "".join(kept) + "…"
114
+
115
+
116
+ @dataclass
117
+ class PickerState:
118
+ """Pure selection state for the version picker (no I/O, unit-testable)."""
119
+
120
+ catalog: tuple[VersionEntry, ...]
121
+ channel: str = "release"
122
+ query: str = ""
123
+ index: int = 0 # absolute index of the highlighted item
124
+ start: int = 0 # index of the first visible row (scroll window)
125
+ rows: int = _DEFAULT_VIEW_ROWS
126
+
127
+ @property
128
+ def items(self) -> list[VersionEntry]:
129
+ """Filtered list: the current channel, narrowed by an active query.
130
+
131
+ Search is scoped to the list the user is currently looking at: a query
132
+ never reaches across channels, so switch channels (``t``) first to find
133
+ snapshot ids.
134
+ """
135
+ channel_items = [e for e in self.catalog if e.channel == self.channel]
136
+ if not self.query:
137
+ return channel_items
138
+ q = self.query.lower()
139
+ return [e for e in channel_items if q in e.id.lower()]
140
+
141
+ @property
142
+ def selected(self) -> str | None:
143
+ """Id of the highlighted item, or ``None`` for an empty list."""
144
+ items = self.items
145
+ return items[self.index].id if items else None
146
+
147
+ def move(self, delta: int) -> None:
148
+ """Move the highlight by ``delta``, clamped, keeping the cursor on screen."""
149
+ items = self.items
150
+ if not items:
151
+ self.index = 0
152
+ self.start = 0
153
+ return
154
+ self.index = min(max(self.index + delta, 0), len(items) - 1)
155
+ self._ensure_cursor_visible()
156
+
157
+ def jump(self, index: int) -> None:
158
+ """Jump to an absolute item index, clamped, keeping the cursor on screen."""
159
+ items = self.items
160
+ if not items:
161
+ self.index = 0
162
+ self.start = 0
163
+ return
164
+ self.index = min(max(index, 0), len(items) - 1)
165
+ self._ensure_cursor_visible()
166
+
167
+ def toggle(self) -> None:
168
+ """Switch release/snapshot, clearing the query and resetting the cursor."""
169
+ self.channel = "snapshot" if self.channel == "release" else "release"
170
+ self.query = ""
171
+ self.index = 0
172
+ self.start = 0
173
+
174
+ def set_query(self, query: str) -> None:
175
+ """Update the filter; reset the cursor so the first hit is highlighted."""
176
+ self.query = query
177
+ self.index = 0
178
+ self.start = 0
179
+
180
+ def clear_query(self) -> None:
181
+ """Drop the filter (keeps the channel)."""
182
+ self.set_query("")
183
+
184
+ def set_rows(self, rows: int) -> None:
185
+ """Resize the viewport, clamping cursor and scroll window to fit."""
186
+ self.rows = max(1, rows)
187
+ items = self.items
188
+ if not items:
189
+ self.index = 0
190
+ self.start = 0
191
+ return
192
+ self.index = min(self.index, len(items) - 1)
193
+ self._ensure_cursor_visible()
194
+
195
+ def visible_items(self) -> list[VersionEntry]:
196
+ """The slice of items currently on screen (the scroll window)."""
197
+ return self.items[self.start : self.start + self.rows]
198
+
199
+ def _ensure_cursor_visible(self) -> None:
200
+ if self.index < self.start:
201
+ self.start = self.index
202
+ elif self.index >= self.start + self.rows:
203
+ self.start = self.index - self.rows + 1
204
+
205
+
206
+ def title_fragments(state: PickerState, columns: int) -> StyleAndTextTuples:
207
+ """One-line status title: channel/count, or the active in-channel search."""
208
+ if state.query:
209
+ title = f"搜索 “{state.query}” · {_channel_label(state.channel)} · {len(state.items)} 个"
210
+ else:
211
+ title = f"{_channel_label(state.channel)} · {len(state.items)} 个"
212
+ return [("class:picker.title", _fit(title, columns))]
213
+
214
+
215
+ def list_fragments(state: PickerState, columns: int) -> StyleAndTextTuples:
216
+ """Item rows only (the up/down scroll indicators live in their own windows)."""
217
+ items = state.items
218
+ if not items:
219
+ msg = "无匹配版本 — 按 x 清空过滤" if state.query else "无匹配版本"
220
+ return [("class:picker.empty", msg)]
221
+ fragments: StyleAndTextTuples = []
222
+ for offset, entry in enumerate(state.visible_items()):
223
+ if fragments:
224
+ fragments.append(("", "\n"))
225
+ fragments.extend(_row_fragments(state, entry, state.start + offset == state.index, columns))
226
+ return fragments
227
+
228
+
229
+ def up_fragments(state: PickerState) -> StyleAndTextTuples:
230
+ """'↑ 还有 N 个' shown when content is hidden above the viewport."""
231
+ if state.start == 0 or not state.items:
232
+ return []
233
+ return [("class:picker.scroll", f"↑ 还有 {state.start} 个")]
234
+
235
+
236
+ def down_fragments(state: PickerState) -> StyleAndTextTuples:
237
+ """'↓ 还有 N 个' shown when content is hidden below the viewport."""
238
+ below = len(state.items) - (state.start + state.rows)
239
+ if below <= 0:
240
+ return []
241
+ return [("class:picker.scroll", f"↓ 还有 {below} 个")]
242
+
243
+
244
+ def help_fragments(spec: LayoutSpec) -> StyleAndTextTuples:
245
+ """The keybinding help block: up to ``spec.help_lines`` rows, one per line."""
246
+ lines = _HELP_LINES[: spec.help_lines]
247
+ if not lines:
248
+ return []
249
+ fragments: StyleAndTextTuples = []
250
+ for i, line in enumerate(lines):
251
+ if i:
252
+ fragments.append(("", "\n"))
253
+ fragments.append(("class:picker.hint", line))
254
+ return fragments
255
+
256
+
257
+ def _row_base(selected: bool) -> str:
258
+ return "picker.selected" if selected else "picker.item"
259
+
260
+
261
+ def _highlight(text: str, query: str, base: str) -> StyleAndTextTuples:
262
+ """Split ``text`` and tag the case-insensitive matches with ``picker.match``."""
263
+ if not query:
264
+ return [(f"class:{base}", text)]
265
+ q = query.lower()
266
+ lower = text.lower()
267
+ fragments: StyleAndTextTuples = []
268
+ pos = 0
269
+ while True:
270
+ hit = lower.find(q, pos)
271
+ if hit < 0:
272
+ if pos < len(text):
273
+ fragments.append((f"class:{base}", text[pos:]))
274
+ break
275
+ if hit > pos:
276
+ fragments.append((f"class:{base}", text[pos:hit]))
277
+ fragments.append((f"class:{base} class:picker.match", text[hit : hit + len(q)]))
278
+ pos = hit + len(q)
279
+ return fragments
280
+
281
+
282
+ def _row_fragments(state: PickerState, entry: VersionEntry, selected: bool, columns: int) -> StyleAndTextTuples:
283
+ """One list row: id (match-highlighted), muted type suffix; selection is
284
+ conveyed purely by the row's reverse-video style."""
285
+ base = _row_base(selected)
286
+ suffix = f" ({entry.type})" if not entry.is_release else ""
287
+ if get_cwidth(entry.id) + get_cwidth(suffix) > columns:
288
+ # Ultra-narrow fallback: drop the highlight, truncate a plain row.
289
+ return [(f"class:{base}", _fit(entry.id + suffix, columns))]
290
+ fragments = _highlight(entry.id, state.query, base)
291
+ if suffix:
292
+ fragments.append(("class:picker.muted", suffix))
293
+ return fragments
294
+
295
+
296
+ def run_picker(
297
+ catalog: Sequence[VersionEntry],
298
+ *,
299
+ input: Input | None = None,
300
+ output: Output | None = None,
301
+ ) -> str | None:
302
+ """Full-screen keyboard picker over ``catalog``.
303
+
304
+ ↑↓ move the highlight one version at a time, ←→/PgUp/PgDn page through the
305
+ list, and Home/End jump to the ends (the default viewport shows the most
306
+ recent releases; scrolling reveals more, with ``↑/↓ 还有 N 个``
307
+ indicators). Like ``t``, the ←/→ page bindings only apply while the search
308
+ box is empty, so the arrows still move the text cursor inside a query.
309
+ Typing filters within the current channel's list and highlights the
310
+ matches, ``t`` toggles release/snapshot while the search box is empty,
311
+ ``x`` clears the filter, Enter returns the highlighted version, Escape
312
+ returns ``None`` so the caller falls back to the latest release, and
313
+ Ctrl-C raises ``KeyboardInterrupt``. The layout adapts to the terminal
314
+ size on every render and on resize.
315
+ """
316
+ initial = output.get_size() if output is not None else Size(_FALLBACK_TERM_ROWS, _FALLBACK_TERM_COLUMNS)
317
+ spec = _layout(initial.rows, initial.columns)
318
+ state = PickerState(catalog=tuple(catalog), rows=spec.view_rows)
319
+
320
+ def _sync_layout_with_columns() -> tuple[LayoutSpec, int]:
321
+ """Recompute the layout from the live terminal size and apply it."""
322
+ size = get_app().output.get_size()
323
+ spec = _layout(size.rows, size.columns)
324
+ state.set_rows(spec.view_rows)
325
+ return spec, size.columns
326
+
327
+ def _sync_layout() -> LayoutSpec:
328
+ return _sync_layout_with_columns()[0]
329
+
330
+ def _header_fragments() -> StyleAndTextTuples:
331
+ _, columns = _sync_layout_with_columns()
332
+ return title_fragments(state, columns)
333
+
334
+ def _up_fragments() -> StyleAndTextTuples:
335
+ _sync_layout()
336
+ return up_fragments(state)
337
+
338
+ def _list_fragments() -> StyleAndTextTuples:
339
+ _, columns = _sync_layout_with_columns()
340
+ return list_fragments(state, columns)
341
+
342
+ def _down_fragments() -> StyleAndTextTuples:
343
+ _sync_layout()
344
+ return down_fragments(state)
345
+
346
+ def _help_fragments() -> StyleAndTextTuples:
347
+ return help_fragments(_sync_layout())
348
+
349
+ search_buffer = Buffer(
350
+ multiline=False,
351
+ on_text_changed=lambda buf: state.set_query(buf.text),
352
+ accept_handler=lambda _buf: _accept(state),
353
+ )
354
+ bindings = _build_bindings(state, search_buffer, _sync_layout)
355
+
356
+ root = HSplit(
357
+ [
358
+ Window(
359
+ FormattedTextControl(_header_fragments),
360
+ height=1,
361
+ wrap_lines=False,
362
+ always_hide_cursor=True,
363
+ ),
364
+ Window(
365
+ FormattedTextControl(_up_fragments),
366
+ wrap_lines=False,
367
+ dont_extend_height=True,
368
+ always_hide_cursor=True,
369
+ ),
370
+ Window(
371
+ FormattedTextControl(_list_fragments),
372
+ wrap_lines=False,
373
+ dont_extend_height=True,
374
+ always_hide_cursor=True,
375
+ ),
376
+ Window(
377
+ FormattedTextControl(_down_fragments),
378
+ wrap_lines=False,
379
+ dont_extend_height=True,
380
+ always_hide_cursor=True,
381
+ ),
382
+ VSplit(
383
+ [
384
+ Window(
385
+ FormattedTextControl("搜索: "),
386
+ height=1,
387
+ dont_extend_width=True,
388
+ always_hide_cursor=True,
389
+ ),
390
+ Window(BufferControl(search_buffer), height=1),
391
+ ]
392
+ ),
393
+ Window(
394
+ FormattedTextControl(_help_fragments),
395
+ wrap_lines=False,
396
+ dont_extend_height=True,
397
+ always_hide_cursor=True,
398
+ ),
399
+ ]
400
+ )
401
+
402
+ app: Application[str | None] = Application(
403
+ layout=Layout(root),
404
+ key_bindings=bindings,
405
+ style=Style.from_dict(_STYLES),
406
+ full_screen=True,
407
+ enable_page_navigation_bindings=False,
408
+ input=input,
409
+ output=output,
410
+ )
411
+ return app.run()
412
+
413
+
414
+ def _accept(state: PickerState) -> bool:
415
+ """Buffer accept handler: return the highlighted version and quit."""
416
+ get_app().exit(result=state.selected)
417
+ return True
418
+
419
+
420
+ def _build_bindings(
421
+ state: PickerState,
422
+ search_buffer: Buffer,
423
+ sync_layout: Callable[[], LayoutSpec],
424
+ ) -> KeyBindingsBase:
425
+ """Key bindings for the picker; merged with the defaults for buffer editing."""
426
+ kb = KeyBindings()
427
+
428
+ @kb.add("up", eager=True)
429
+ def _up(_event) -> None:
430
+ sync_layout()
431
+ state.move(-1)
432
+
433
+ @kb.add("down", eager=True)
434
+ def _down(_event) -> None:
435
+ sync_layout()
436
+ state.move(1)
437
+
438
+ @kb.add("pageup", eager=True)
439
+ def _page_up(_event) -> None:
440
+ sync_layout()
441
+ state.move(-state.rows)
442
+
443
+ @kb.add("pagedown", eager=True)
444
+ def _page_down(_event) -> None:
445
+ sync_layout()
446
+ state.move(state.rows)
447
+
448
+ # ←/→ page the list too, but only while the search box is empty: with a
449
+ # query the arrows stay in the search box for cursor movement (fixing
450
+ # typos), same gating as the `t` toggle below.
451
+ @kb.add("left", eager=True, filter=Condition(lambda: search_buffer.text == ""))
452
+ def _page_left(_event) -> None:
453
+ sync_layout()
454
+ state.move(-state.rows)
455
+
456
+ @kb.add("right", eager=True, filter=Condition(lambda: search_buffer.text == ""))
457
+ def _page_right(_event) -> None:
458
+ sync_layout()
459
+ state.move(state.rows)
460
+
461
+ @kb.add("home", eager=True)
462
+ def _home(_event) -> None:
463
+ sync_layout()
464
+ state.jump(0)
465
+
466
+ @kb.add("end", eager=True)
467
+ def _end(_event) -> None:
468
+ sync_layout()
469
+ state.jump(len(state.items) - 1)
470
+
471
+ @kb.add("escape", eager=True)
472
+ def _escape(event) -> None:
473
+ event.app.exit(result=None)
474
+
475
+ # Only toggle while the search box is empty, otherwise snapshot ids like
476
+ # "24w14potato" (which contain 't') could never be typed as a query.
477
+ @kb.add("t", eager=True, filter=Condition(lambda: search_buffer.text == ""))
478
+ def _toggle(_event) -> None:
479
+ search_buffer.text = ""
480
+ state.toggle()
481
+
482
+ # No Mojang version id contains 'x', so it is safe to reserve it for clearing.
483
+ @kb.add("x", eager=True)
484
+ def _clear(_event) -> None:
485
+ search_buffer.text = ""
486
+ state.clear_query()
487
+
488
+ # eager so it beats the merged defaults' no-op `c-c` (`_ignore`).
489
+ @kb.add("c-c", eager=True)
490
+ def _abort(_event) -> None:
491
+ raise KeyboardInterrupt
492
+
493
+ return merge_key_bindings([kb, load_key_bindings()])
@@ -0,0 +1,57 @@
1
+ """Interactive TTY prompts for the CLI (only used when stdin is a TTY)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from rich.console import Console
8
+ from rich.prompt import Confirm
9
+
10
+ from orzmc import remote_version_catalog
11
+
12
+ from .picker import run_picker
13
+
14
+ _console = Console(highlight=False)
15
+
16
+
17
+ def is_interactive() -> bool:
18
+ """True when stdin+stdout are real terminals (safe for rich prompts)."""
19
+ return sys.stdin.isatty() and sys.stdout.isatty()
20
+
21
+
22
+ def resolve_version(version: str | None, root_dir: str | None) -> str | None:
23
+ """Resolve the Minecraft version for a command.
24
+
25
+ Explicit value → used as-is. TTY without value → interactive keyboard
26
+ picker over the full Mojang catalog (scroll, filter, release/snapshot
27
+ toggle, Escape → latest). Non-TTY without value → ``None``, letting the
28
+ library fall back to the latest release.
29
+ """
30
+ if version:
31
+ return version
32
+ if not is_interactive():
33
+ return None
34
+ try:
35
+ catalog = remote_version_catalog(root_dir=root_dir)
36
+ except Exception as exc:
37
+ _console.print(f"[yellow]无法获取版本列表({exc}),回车使用最新[/yellow]")
38
+ return None
39
+ if not catalog:
40
+ _console.print("[yellow]版本清单为空,回车使用最新[/yellow]")
41
+ return None
42
+ try:
43
+ return run_picker(catalog)
44
+ except Exception as exc: # never crash the CLI on TUI trouble
45
+ _console.print(f"[yellow]版本选择器异常({exc}),回车使用最新[/yellow]")
46
+ return None
47
+
48
+
49
+ def confirm_java(major: int, need_jdk: bool) -> bool:
50
+ """Ask before installing a sandboxed Java runtime into the app dir."""
51
+ kind = "JDK" if need_jdk else "JRE"
52
+ return Confirm.ask(f"需要安装 Java {major}({kind}) 到应用目录,继续?", default=True)
53
+
54
+
55
+ def confirm_eula() -> bool:
56
+ """Ask the user to accept the Minecraft EULA before starting a server."""
57
+ return Confirm.ask("需要接受 Minecraft EULA 才能启动服务端,是否同意?", default=True)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "orzmc-app"
7
+ version = "2.0.0"
8
+ description = "OrzMC: CLI application for Minecraft client/server bootstrap"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "OrzMC" }]
13
+ keywords = ["minecraft", "launcher", "server", "java", "cli"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Environment :: Console",
20
+ "Topic :: Games/Entertainment",
21
+ ]
22
+ dependencies = [
23
+ "orzmc>=2.0.0",
24
+ "typer>=0.12",
25
+ "prompt_toolkit>=3.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ orzmc = "orzmc_app.cli:main"
30
+
31
+ [tool.uv.sources]
32
+ orzmc = { workspace = true }
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["orzmc_app"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["orzmc_app"]