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.
- orzmc_app-2.0.0/.gitignore +177 -0
- orzmc_app-2.0.0/PKG-INFO +28 -0
- orzmc_app-2.0.0/README.md +9 -0
- orzmc_app-2.0.0/orzmc_app/__init__.py +10 -0
- orzmc_app-2.0.0/orzmc_app/cli/__init__.py +32 -0
- orzmc_app-2.0.0/orzmc_app/cli/__main__.py +6 -0
- orzmc_app-2.0.0/orzmc_app/cli/app.py +256 -0
- orzmc_app-2.0.0/orzmc_app/cli/options.py +29 -0
- orzmc_app-2.0.0/orzmc_app/cli/picker.py +493 -0
- orzmc_app-2.0.0/orzmc_app/cli/prompts.py +57 -0
- orzmc_app-2.0.0/pyproject.toml +38 -0
|
@@ -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/
|
orzmc_app-2.0.0/PKG-INFO
ADDED
|
@@ -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,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,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"]
|