devconfig-gen-singbox 2.1.4__tar.gz → 2.1.6__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.
- {devconfig_gen_singbox-2.1.4/src/devconfig_gen_singbox.egg-info → devconfig_gen_singbox-2.1.6}/PKG-INFO +2 -2
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/README.md +1 -1
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/pyproject.toml +1 -1
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/__init__.py +1 -1
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6/src/devconfig_gen_singbox.egg-info}/PKG-INFO +2 -2
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/SOURCES.txt +2 -0
- devconfig_gen_singbox-2.1.6/src/singbox_ops/cli.py +391 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/orchestrator.py +23 -11
- devconfig_gen_singbox-2.1.6/src/singbox_ops/core/ui.py +68 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_cli.py +1 -1
- devconfig_gen_singbox-2.1.6/tests/test_singbox_ops_wizard.py +66 -0
- devconfig_gen_singbox-2.1.4/src/singbox_ops/cli.py +0 -233
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/LICENSE +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/setup.cfg +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/cli.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/engine.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/formats.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/interactive.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/models.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/custom.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/env_provider.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/json_provider.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/data/rules.json +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/links.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/models.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/anytls.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/base.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/hysteria2.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/tuic.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/provider.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/route.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/schema.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/py.typed +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/registry.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/validation.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/web_ui.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/dependency_links.txt +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/entry_points.txt +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/requires.txt +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/top_level.txt +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/base.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/cloudflare_dns01.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/prune.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/base.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/dns/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/dns/base.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/dns/cloudflare.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/export/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/export/local_files.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/auto_update.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/nftables.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/packages.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/systemd.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/warp_watchdog.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/base.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/python_secrets.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/singbox_subprocess.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/static.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/state/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/state/local_json.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/command.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/context_builder.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/exceptions.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/network.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/plan.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/protocols.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/templates/singbox_auto_update.py.template +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/templates/warp_watchdog.sh.template +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_api_parity.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_cli_e2e.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_custom_provider.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_devconfig_core.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_env_provider.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_formats.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_interactive.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_merge.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_provider_metadata.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_acme.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_context_builder.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_dns.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_orchestrator.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_plan.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_runtime.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_secrets.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_state_export.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_provider.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_validation.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_web_ui.py +0 -0
- {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_web_ui_widgets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devconfig_gen_singbox
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.6
|
|
4
4
|
Summary: Provider-based developer configuration generation engine and CLI
|
|
5
5
|
Author-email: henryliu443 <henryliu443@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,7 +28,7 @@ Dynamic: license-file
|
|
|
28
28
|
> **A sing-box domain implementation built on top of DevConfig-Gen.**
|
|
29
29
|
> **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
|
|
30
30
|
|
|
31
|
-
[](CHANGELOG.md)
|
|
32
32
|
[](LICENSE)
|
|
33
33
|

|
|
34
34
|

|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **A sing-box domain implementation built on top of DevConfig-Gen.**
|
|
4
4
|
> **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
|
|
5
5
|
|
|
6
|
-
[](CHANGELOG.md)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|

|
|
9
9
|

|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devconfig_gen_singbox
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.6
|
|
4
4
|
Summary: Provider-based developer configuration generation engine and CLI
|
|
5
5
|
Author-email: henryliu443 <henryliu443@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,7 +28,7 @@ Dynamic: license-file
|
|
|
28
28
|
> **A sing-box domain implementation built on top of DevConfig-Gen.**
|
|
29
29
|
> **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
|
|
30
30
|
|
|
31
|
-
[](CHANGELOG.md)
|
|
32
32
|
[](LICENSE)
|
|
33
33
|

|
|
34
34
|

|
|
@@ -67,6 +67,7 @@ src/singbox_ops/core/network.py
|
|
|
67
67
|
src/singbox_ops/core/orchestrator.py
|
|
68
68
|
src/singbox_ops/core/plan.py
|
|
69
69
|
src/singbox_ops/core/protocols.py
|
|
70
|
+
src/singbox_ops/core/ui.py
|
|
70
71
|
src/singbox_ops/templates/singbox_auto_update.py.template
|
|
71
72
|
src/singbox_ops/templates/warp_watchdog.sh.template
|
|
72
73
|
tests/test_api_parity.py
|
|
@@ -87,6 +88,7 @@ tests/test_singbox_ops_plan.py
|
|
|
87
88
|
tests/test_singbox_ops_runtime.py
|
|
88
89
|
tests/test_singbox_ops_secrets.py
|
|
89
90
|
tests/test_singbox_ops_state_export.py
|
|
91
|
+
tests/test_singbox_ops_wizard.py
|
|
90
92
|
tests/test_singbox_provider.py
|
|
91
93
|
tests/test_validation.py
|
|
92
94
|
tests/test_web_ui.py
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Operations subcommands, registered into the single ``devconfig_gen_singbox`` CLI.
|
|
2
|
+
|
|
3
|
+
There is exactly **one** command name in this project: the PyPI name
|
|
4
|
+
``devconfig_gen_singbox``. ``add_subparsers()`` is what ``devconfig_gen.cli``
|
|
5
|
+
calls to graft these subcommands onto that single command, so the ops layer
|
|
6
|
+
never introduces a second CLI name.
|
|
7
|
+
|
|
8
|
+
A-repo style: running ``deploy`` without ``--plan`` starts an interactive
|
|
9
|
+
wizard (with colour, per-field help, credential capture and live progress).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import getpass
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from devconfig_gen import formats
|
|
23
|
+
|
|
24
|
+
from .core.context_builder import build_context
|
|
25
|
+
from .core.exceptions import OpsError
|
|
26
|
+
from .core.plan import DeployPlan, load_plan
|
|
27
|
+
from .core.orchestrator import DRY_RUN_IP, build_suite, deploy, destroy, make_runner
|
|
28
|
+
from .core.protocols import PROTOCOLS
|
|
29
|
+
from .core.ui import UI
|
|
30
|
+
|
|
31
|
+
PROG = "devconfig_gen_singbox"
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
DEFAULT_SERVER_OUT = "/etc/sing-box/config.json"
|
|
35
|
+
DEFAULT_CLIENT_OUT = "/root/singbox-client.json"
|
|
36
|
+
DEFAULT_LINKS_OUT = "/root/singbox-links.txt"
|
|
37
|
+
|
|
38
|
+
STEP_LABELS = {
|
|
39
|
+
"secrets": "生成凭据 / 子域前缀",
|
|
40
|
+
"dns": "同步 Cloudflare DNS A 记录",
|
|
41
|
+
"acme": "签发 TLS 证书 (acme.sh DNS-01)",
|
|
42
|
+
"generate": "用引擎生成 server / client / links",
|
|
43
|
+
"export": "写出配置产物",
|
|
44
|
+
"packages": "安装依赖 (sing-box / WARP)",
|
|
45
|
+
"systemd": "写入 systemd 单元并重启服务",
|
|
46
|
+
"firewall": "应用 nftables 防火墙",
|
|
47
|
+
"watchdog": "部署 / 取消 WARP watchdog",
|
|
48
|
+
"auto_update": "安装每日自动升级任务",
|
|
49
|
+
"state": "保存部署状态",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
SUBCOMMANDS = (
|
|
53
|
+
("context", "Assemble and print the sing-box context (no side effects)"),
|
|
54
|
+
("plan", "Interactive wizard: write a plan.yaml, do not deploy"),
|
|
55
|
+
("deploy", "Deploy sing-box (interactive wizard when --plan is omitted)"),
|
|
56
|
+
("redeploy", "Regenerate credentials and redeploy"),
|
|
57
|
+
("destroy", "Tear down what a previous deploy created"),
|
|
58
|
+
("certs", "Prune stale acme.sh certificate dirs (dry-run unless --apply)"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def add_subparsers(sub) -> None:
|
|
63
|
+
"""Graft the operations subcommands onto the shared root parser."""
|
|
64
|
+
|
|
65
|
+
for name, help_text in SUBCOMMANDS:
|
|
66
|
+
command = sub.add_parser(name, help=help_text)
|
|
67
|
+
command.add_argument("--plan", help="Path to a JSON/YAML plan (omit for the wizard)")
|
|
68
|
+
command.add_argument("--domain", help="Domain root (skips that prompt)")
|
|
69
|
+
command.add_argument("--protocols", help="Comma-separated protocols (skips that prompt)")
|
|
70
|
+
if name == "plan":
|
|
71
|
+
command.add_argument("--output", default="plan.yaml", help="Where to write the plan")
|
|
72
|
+
if name == "certs":
|
|
73
|
+
command.add_argument("--keep", required=True, help="Comma-separated hosts to keep")
|
|
74
|
+
command.add_argument("--acme-home", default="/root/.acme.sh", help="acme.sh home dir")
|
|
75
|
+
command.add_argument("--apply", action="store_true", help="Actually delete (default: dry run)")
|
|
76
|
+
if name == "context":
|
|
77
|
+
command.add_argument("--format", choices=("json", "yaml"), help="Output format")
|
|
78
|
+
if name in ("deploy", "redeploy", "destroy"):
|
|
79
|
+
command.add_argument("--dry-run", action="store_true",
|
|
80
|
+
help="Print intended actions without touching the system")
|
|
81
|
+
command.add_argument("--yes", action="store_true",
|
|
82
|
+
help="Skip the interactive confirmation before a real run")
|
|
83
|
+
command.add_argument("--json", action="store_true",
|
|
84
|
+
help="Emit the machine-readable report as JSON (implies --yes for real runs)")
|
|
85
|
+
command.set_defaults(handler=_run)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------
|
|
89
|
+
# interactive wizard
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
def _ask(ui: UI, prompt, desc=None, required=False, default=None, reader=None, writer=None):
|
|
92
|
+
reader = reader or sys.stdin
|
|
93
|
+
writer = writer or sys.stdout
|
|
94
|
+
ui.field(prompt, desc, required=required)
|
|
95
|
+
suffix = f" [{default}]" if default else ""
|
|
96
|
+
writer.write(f" ? {prompt}{suffix}: ")
|
|
97
|
+
writer.flush()
|
|
98
|
+
line = reader.readline()
|
|
99
|
+
if line == "":
|
|
100
|
+
return default
|
|
101
|
+
line = line.strip()
|
|
102
|
+
return line if line else default
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ask_yes_no(ui: UI, prompt, desc=None, default=True, reader=None, writer=None):
|
|
106
|
+
hint = "[Y/n]" if default else "[y/N]"
|
|
107
|
+
ui.field(prompt + f" {hint}", desc)
|
|
108
|
+
writer = writer or sys.stdout
|
|
109
|
+
reader = reader or sys.stdin
|
|
110
|
+
writer.write(" ? ")
|
|
111
|
+
writer.flush()
|
|
112
|
+
line = reader.readline()
|
|
113
|
+
if line == "":
|
|
114
|
+
return default
|
|
115
|
+
line = line.strip().lower()
|
|
116
|
+
if not line:
|
|
117
|
+
return default
|
|
118
|
+
return line in ("y", "yes", "true", "1")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ask_secret(ui: UI, prompt, desc=None, reader=None, writer=None) -> str:
|
|
122
|
+
ui.field(prompt, desc)
|
|
123
|
+
reader = reader or sys.stdin
|
|
124
|
+
writer = writer or sys.stdout
|
|
125
|
+
if reader is not sys.stdin or not sys.stdin.isatty():
|
|
126
|
+
# Piped input / tests: read a plain line so nothing echoes weirdly.
|
|
127
|
+
line = reader.readline()
|
|
128
|
+
return line.strip() if line else ""
|
|
129
|
+
writer.write(" ? ")
|
|
130
|
+
writer.flush()
|
|
131
|
+
try:
|
|
132
|
+
return getpass.getpass("")
|
|
133
|
+
except Exception:
|
|
134
|
+
line = sys.stdin.readline()
|
|
135
|
+
return line.strip() if line else ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def interactive_plan(domain=None, protocols=None, reader=None, writer=None, ui=None):
|
|
139
|
+
"""Prompt for a full deployment spec.
|
|
140
|
+
|
|
141
|
+
Returns ``(plan_mapping, env)`` where ``env`` carries any credentials to be
|
|
142
|
+
placed into the process environment (never written to disk).
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
ui = ui or UI(writer)
|
|
146
|
+
ui.banner(f"{PROG} · 交互式部署向导", "逐项回答;回车使用默认值。Ctrl-C 可随时退出。")
|
|
147
|
+
|
|
148
|
+
ui.section("1/4 基础")
|
|
149
|
+
while not domain:
|
|
150
|
+
domain = _ask(ui, "主域名", "例如 example.com;协议主机名 = <前缀>.<主域名>",
|
|
151
|
+
required=True, reader=reader, writer=writer)
|
|
152
|
+
if not domain:
|
|
153
|
+
ui.error("域名必填")
|
|
154
|
+
protocols = protocols or _ask(
|
|
155
|
+
ui, "启用协议", "可选 anytls / tuic / hysteria2,逗号分隔",
|
|
156
|
+
default=",".join(PROTOCOLS), reader=reader, writer=writer,
|
|
157
|
+
)
|
|
158
|
+
tunnel_mode = _ask(
|
|
159
|
+
ui, "出站模式", "none=全部直连;proxy=走本机 WARP socks(127.0.0.1:40000);tun=WARP 系统隧道",
|
|
160
|
+
default="proxy", reader=reader, writer=writer,
|
|
161
|
+
)
|
|
162
|
+
server_ip = _ask(ui, "服务器公网 IP", "留空=自动探测", reader=reader, writer=writer)
|
|
163
|
+
|
|
164
|
+
ui.section("2/4 外部资源")
|
|
165
|
+
use_dns = _ask_yes_no(
|
|
166
|
+
ui, "用 Cloudflare 管理 DNS A 记录?",
|
|
167
|
+
"Y:自动创建/更新 <前缀>.<域名> 的 A 记录(需要 CF 凭据)",
|
|
168
|
+
default=True, reader=reader, writer=writer,
|
|
169
|
+
)
|
|
170
|
+
use_acme = _ask_yes_no(
|
|
171
|
+
ui, "用 acme.sh 自动签发 TLS 证书?",
|
|
172
|
+
"TUIC / Hysteria2 需要证书;Y 会走 Cloudflare DNS-01 签发",
|
|
173
|
+
default=True, reader=reader, writer=writer,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
env = {}
|
|
177
|
+
if use_dns or use_acme:
|
|
178
|
+
ui.section("3/4 Cloudflare 凭据(仅本次内存使用,不写盘)")
|
|
179
|
+
env["CF_Token"] = _ask_secret(
|
|
180
|
+
ui, "Cloudflare API Token", "Zone.DNS 编辑权限",
|
|
181
|
+
reader=reader, writer=writer,
|
|
182
|
+
)
|
|
183
|
+
env["CF_Zone_ID"] = _ask(
|
|
184
|
+
ui, "Cloudflare Zone ID", "域名的 Zone ID",
|
|
185
|
+
reader=reader, writer=writer,
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
ui.section("3/4 Cloudflare 凭据")
|
|
189
|
+
ui.warn("已跳过 DNS 与证书:你需要自己解析域名并准备证书文件")
|
|
190
|
+
|
|
191
|
+
use_runtime = _ask_yes_no(
|
|
192
|
+
ui, "启用运行时适配器?",
|
|
193
|
+
"systemd / nftables / watchdog / 每日自动升级(生产建议 Y)",
|
|
194
|
+
default=True, reader=reader, writer=writer,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
ui.section("4/4 产物路径")
|
|
198
|
+
server_out = _ask(ui, "服务端配置", "sing-box 主配置", default=DEFAULT_SERVER_OUT, reader=reader, writer=writer)
|
|
199
|
+
client_out = _ask(ui, "客户端配置", "可导入 GUI 的完整客户端配置", default=DEFAULT_CLIENT_OUT, reader=reader, writer=writer)
|
|
200
|
+
links_out = _ask(ui, "分享链接", "anytls:// tuic:// hy2:// 文本", default=DEFAULT_LINKS_OUT, reader=reader, writer=writer)
|
|
201
|
+
|
|
202
|
+
plan = {
|
|
203
|
+
"domain_root": domain,
|
|
204
|
+
"protocols": [item.strip() for item in str(protocols).split(",") if item.strip()],
|
|
205
|
+
"tunnel_mode": tunnel_mode,
|
|
206
|
+
"server_ip": server_ip or "auto",
|
|
207
|
+
"adapters": {
|
|
208
|
+
"secrets": "singbox-subprocess",
|
|
209
|
+
"dns": "cloudflare" if use_dns else None,
|
|
210
|
+
"acme": "cloudflare-dns01" if use_acme else None,
|
|
211
|
+
"state": "local-json" if use_runtime else None,
|
|
212
|
+
"runtime": {
|
|
213
|
+
"packages": "debian" if use_runtime else None,
|
|
214
|
+
"systemd": "systemd" if use_runtime else None,
|
|
215
|
+
"firewall": "nftables-basic" if use_runtime else None,
|
|
216
|
+
"watchdog": "warp" if use_runtime else None,
|
|
217
|
+
"auto_update": "auto-update" if use_runtime else None,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
"outputs": {
|
|
221
|
+
"server_config": server_out,
|
|
222
|
+
"client_config": client_out,
|
|
223
|
+
"links": links_out,
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
return plan, env
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _confirm(ui: UI, plan, dry_run: bool, reader=None, writer=None) -> bool:
|
|
230
|
+
adapters = plan.adapters
|
|
231
|
+
runtime = adapters.get("runtime") or {}
|
|
232
|
+
ui.section("即将执行")
|
|
233
|
+
ui.kv("域名", plan.domain_root)
|
|
234
|
+
ui.kv("协议", ", ".join(plan.protocols))
|
|
235
|
+
ui.kv("出站模式", plan.tunnel_mode)
|
|
236
|
+
ui.kv("服务器 IP", plan.server_ip or "自动探测")
|
|
237
|
+
ui.kv("DNS", adapters.get("dns") or "跳过(自己管理)")
|
|
238
|
+
ui.kv("证书", adapters.get("acme") or "跳过(自己管理)")
|
|
239
|
+
ui.kv("运行时", ", ".join(f"{k}={v}" for k, v in runtime.items() if v) or "关闭")
|
|
240
|
+
ui.kv("服务端配置", plan.outputs.get("server_config", "?"))
|
|
241
|
+
if dry_run:
|
|
242
|
+
ui.warn("DRY-RUN:只打印将执行的步骤,不改动系统")
|
|
243
|
+
return True
|
|
244
|
+
prefix = ui.red("这会覆盖 /etc/sing-box 并重启 sing-box")
|
|
245
|
+
ui.warn(prefix)
|
|
246
|
+
reader = reader or sys.stdin
|
|
247
|
+
writer = writer or sys.stdout
|
|
248
|
+
writer.write(" 确认执行?[y/N] ")
|
|
249
|
+
writer.flush()
|
|
250
|
+
line = reader.readline()
|
|
251
|
+
return line.strip().lower() in ("y", "yes")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _load_or_prompt(args, ui):
|
|
255
|
+
"""Return ``(plan, env)`` from ``--plan`` or the interactive wizard."""
|
|
256
|
+
|
|
257
|
+
if getattr(args, "plan", None):
|
|
258
|
+
return load_plan(args.plan), {}
|
|
259
|
+
plan_mapping, env = interactive_plan(
|
|
260
|
+
domain=getattr(args, "domain", None),
|
|
261
|
+
protocols=getattr(args, "protocols", None),
|
|
262
|
+
ui=ui,
|
|
263
|
+
)
|
|
264
|
+
return DeployPlan.from_mapping(plan_mapping), env
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _detect_or_placeholder(plan) -> str:
|
|
268
|
+
try:
|
|
269
|
+
from .core.network import detect_public_ipv4
|
|
270
|
+
|
|
271
|
+
return detect_public_ipv4()
|
|
272
|
+
except OpsError:
|
|
273
|
+
return DRY_RUN_IP
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _run(args) -> int:
|
|
277
|
+
json_mode = bool(getattr(args, "json", False))
|
|
278
|
+
ui = UI(io.StringIO()) if json_mode else UI()
|
|
279
|
+
try:
|
|
280
|
+
if getattr(args, "command", None) == "certs":
|
|
281
|
+
from .adapters.acme.prune import DEFAULT_ACME_HOME, prune_certs
|
|
282
|
+
from .core.command import LocalCommandRunner
|
|
283
|
+
|
|
284
|
+
keep = [item for item in (getattr(args, "keep", "") or "").split(",") if item.strip()]
|
|
285
|
+
dry_run = not bool(getattr(args, "apply", False))
|
|
286
|
+
runner = LocalCommandRunner()
|
|
287
|
+
result = prune_certs(
|
|
288
|
+
runner,
|
|
289
|
+
acme_home=getattr(args, "acme_home", DEFAULT_ACME_HOME),
|
|
290
|
+
keep_hosts=keep,
|
|
291
|
+
dry_run=dry_run,
|
|
292
|
+
)
|
|
293
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
if getattr(args, "command", None) == "destroy" and not getattr(args, "plan", None):
|
|
297
|
+
raise OpsError("destroy requires --plan")
|
|
298
|
+
|
|
299
|
+
if args.command == "plan":
|
|
300
|
+
plan_mapping, _ = interactive_plan(
|
|
301
|
+
domain=getattr(args, "domain", None),
|
|
302
|
+
protocols=getattr(args, "protocols", None),
|
|
303
|
+
ui=ui,
|
|
304
|
+
)
|
|
305
|
+
output = getattr(args, "output", "plan.yaml") or "plan.yaml"
|
|
306
|
+
with open(output, "w", encoding="utf-8") as handle:
|
|
307
|
+
handle.write(formats.dumps(plan_mapping, "yaml"))
|
|
308
|
+
ui.success(f"已写出 {output}(凭据不写盘;部署时请 export CF_Token/CF_Zone_ID)")
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
plan, env = _load_or_prompt(args, ui)
|
|
312
|
+
for key, value in (env or {}).items():
|
|
313
|
+
if value:
|
|
314
|
+
os.environ[key] = value
|
|
315
|
+
|
|
316
|
+
dry_run = True if args.command == "context" else bool(getattr(args, "dry_run", False))
|
|
317
|
+
runner = make_runner(dry_run)
|
|
318
|
+
suite = build_suite(plan, runner)
|
|
319
|
+
|
|
320
|
+
if args.command == "context":
|
|
321
|
+
generated = suite.secrets.generate(plan, dry_run=True)
|
|
322
|
+
server_ip = plan.server_ip or _detect_or_placeholder(plan)
|
|
323
|
+
context = build_context(
|
|
324
|
+
plan, generated.credentials, server_ip,
|
|
325
|
+
subdomain_prefixes=generated.subdomain_prefixes,
|
|
326
|
+
)
|
|
327
|
+
print(formats.dumps(context, getattr(args, "format", None) or plan.output_format))
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
if json_mode and not dry_run and not getattr(args, "yes", False):
|
|
331
|
+
raise OpsError("--json with a real run requires --yes")
|
|
332
|
+
if not json_mode and not getattr(args, "yes", False) and not _confirm(ui, plan, dry_run):
|
|
333
|
+
ui.warn("已取消")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
def progress(step):
|
|
337
|
+
ui.step(STEP_LABELS.get(step, step))
|
|
338
|
+
|
|
339
|
+
if args.command in ("deploy", "redeploy"):
|
|
340
|
+
ui.banner(f"{PROG} · 开始部署" + ("(dry-run)" if dry_run else ""))
|
|
341
|
+
report = deploy(plan, suite, dry_run=dry_run, progress=progress)
|
|
342
|
+
else:
|
|
343
|
+
ui.banner(f"{PROG} · 开始卸载")
|
|
344
|
+
report = destroy(plan, suite, dry_run=dry_run, progress=progress)
|
|
345
|
+
except OpsError as exc:
|
|
346
|
+
ui.error(str(exc))
|
|
347
|
+
if json_mode:
|
|
348
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
349
|
+
if "Cloudflare credentials" in str(exc):
|
|
350
|
+
ui.warn("向导里填 CF 凭据,或先 export CF_Token / CF_Zone_ID")
|
|
351
|
+
return 1
|
|
352
|
+
except (OSError, formats.FormatError, ValueError) as exc:
|
|
353
|
+
ui.error(str(exc))
|
|
354
|
+
if json_mode:
|
|
355
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
356
|
+
return 2
|
|
357
|
+
|
|
358
|
+
if json_mode:
|
|
359
|
+
print(json.dumps(report.as_dict(), indent=2, ensure_ascii=False))
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
ui.section("结果")
|
|
363
|
+
ui.kv("action", report.action)
|
|
364
|
+
ui.kv("server_ip", report.server_ip or "-")
|
|
365
|
+
ui.kv("steps", ", ".join(report.steps))
|
|
366
|
+
if report.outputs:
|
|
367
|
+
for key, path in report.outputs.items():
|
|
368
|
+
ui.success(f"{key} -> {path}")
|
|
369
|
+
if report.dry_run:
|
|
370
|
+
ui.warn("dry-run 结束,未改动系统")
|
|
371
|
+
else:
|
|
372
|
+
ui.success("部署完成")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
377
|
+
"""Standalone parser (used by ``python -m singbox_ops.cli``)."""
|
|
378
|
+
|
|
379
|
+
parser = argparse.ArgumentParser(prog=PROG, description="sing-box operations.")
|
|
380
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
381
|
+
add_subparsers(parser.add_subparsers(dest="command", required=True))
|
|
382
|
+
return parser
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def main(argv: Optional[list] = None) -> int:
|
|
386
|
+
args = build_parser().parse_args(argv)
|
|
387
|
+
return args.handler(args)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
raise SystemExit(main())
|
{devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/orchestrator.py
RENAMED
|
@@ -169,28 +169,34 @@ def deploy(
|
|
|
169
169
|
*,
|
|
170
170
|
dry_run: bool = False,
|
|
171
171
|
generate: Optional[Callable[..., Any]] = None,
|
|
172
|
+
progress: Optional[Callable[[str], None]] = None,
|
|
172
173
|
) -> DeployReport:
|
|
173
174
|
"""Run the full deployment sequence."""
|
|
174
175
|
|
|
175
176
|
generate = generate or _default_generate
|
|
176
|
-
steps = []
|
|
177
|
+
steps: list = []
|
|
178
|
+
|
|
179
|
+
def note(step: str) -> None:
|
|
180
|
+
steps.append(step)
|
|
181
|
+
if progress is not None:
|
|
182
|
+
progress(step)
|
|
177
183
|
|
|
178
184
|
generated = suite.secrets.generate(plan, dry_run=dry_run)
|
|
179
185
|
prefixes = dict(generated.subdomain_prefixes)
|
|
180
186
|
hosts = build_protocol_hosts(plan.domain_root, prefixes)
|
|
181
|
-
|
|
187
|
+
note("secrets")
|
|
182
188
|
|
|
183
189
|
server_ip = _resolve_server_ip(plan, dry_run)
|
|
184
190
|
|
|
185
191
|
record_ids = suite.dns.apply(hosts, server_ip, dry_run=dry_run)
|
|
186
|
-
|
|
192
|
+
note("dns")
|
|
187
193
|
|
|
188
194
|
certificates: dict = {}
|
|
189
195
|
if suite.acme is not None and plan.tls_protocols():
|
|
190
196
|
certificates = dict(
|
|
191
197
|
suite.acme.apply(hosts, plan.tls_protocols(), dry_run=dry_run)
|
|
192
198
|
)
|
|
193
|
-
|
|
199
|
+
note("acme")
|
|
194
200
|
|
|
195
201
|
context = build_context(
|
|
196
202
|
plan,
|
|
@@ -199,10 +205,10 @@ def deploy(
|
|
|
199
205
|
subdomain_prefixes=prefixes,
|
|
200
206
|
)
|
|
201
207
|
result = generate(plan, context)
|
|
202
|
-
|
|
208
|
+
note("generate")
|
|
203
209
|
|
|
204
210
|
written = dict(suite.export.write(result.artifacts, plan.outputs, dry_run=dry_run))
|
|
205
|
-
|
|
211
|
+
note("export")
|
|
206
212
|
|
|
207
213
|
for key, adapter in (
|
|
208
214
|
("packages", suite.packages),
|
|
@@ -214,7 +220,7 @@ def deploy(
|
|
|
214
220
|
if adapter is None:
|
|
215
221
|
continue
|
|
216
222
|
adapter.apply(plan, hosts, dry_run=dry_run)
|
|
217
|
-
|
|
223
|
+
note(key)
|
|
218
224
|
|
|
219
225
|
if suite.state is not None:
|
|
220
226
|
suite.state.save(
|
|
@@ -231,7 +237,7 @@ def deploy(
|
|
|
231
237
|
},
|
|
232
238
|
dry_run=dry_run,
|
|
233
239
|
)
|
|
234
|
-
|
|
240
|
+
note("state")
|
|
235
241
|
|
|
236
242
|
return DeployReport(
|
|
237
243
|
action="deploy",
|
|
@@ -251,10 +257,16 @@ def destroy(
|
|
|
251
257
|
suite: AdapterSuite,
|
|
252
258
|
*,
|
|
253
259
|
dry_run: bool = False,
|
|
260
|
+
progress: Optional[Callable[[str], None]] = None,
|
|
254
261
|
) -> DeployReport:
|
|
255
262
|
"""Tear down what :func:`deploy` created, in reverse order."""
|
|
256
263
|
|
|
257
|
-
steps = []
|
|
264
|
+
steps: list = []
|
|
265
|
+
|
|
266
|
+
def note(step: str) -> None:
|
|
267
|
+
steps.append(step)
|
|
268
|
+
if progress is not None:
|
|
269
|
+
progress(step)
|
|
258
270
|
state = suite.state.load() if suite.state is not None else None
|
|
259
271
|
state = state or {}
|
|
260
272
|
record_ids = dict(state.get("dns_record_ids") or {})
|
|
@@ -271,11 +283,11 @@ def destroy(
|
|
|
271
283
|
if adapter is None:
|
|
272
284
|
continue
|
|
273
285
|
adapter.destroy(plan, hosts, dry_run=dry_run)
|
|
274
|
-
|
|
286
|
+
note(key)
|
|
275
287
|
|
|
276
288
|
if record_ids:
|
|
277
289
|
suite.dns.destroy(record_ids, dry_run=dry_run)
|
|
278
|
-
|
|
290
|
+
note("dns")
|
|
279
291
|
|
|
280
292
|
return DeployReport(
|
|
281
293
|
action="destroy",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tiny ANSI UI helpers for the interactive CLI.
|
|
2
|
+
|
|
3
|
+
Colours are disabled when the stream is not a TTY, when ``NO_COLOR`` is set, or
|
|
4
|
+
when ``TERM=dumb``, so logs and CI output stay clean.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
RESET = "\033[0m"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _supports_color(stream) -> bool:
|
|
16
|
+
if os.environ.get("NO_COLOR"):
|
|
17
|
+
return False
|
|
18
|
+
if os.environ.get("TERM", "") == "dumb":
|
|
19
|
+
return False
|
|
20
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UI:
|
|
24
|
+
def __init__(self, stream=None):
|
|
25
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
26
|
+
self.color = _supports_color(self.stream)
|
|
27
|
+
|
|
28
|
+
def _c(self, text: str, code: str) -> str:
|
|
29
|
+
return f"\033[{code}m{text}{RESET}" if self.color else text
|
|
30
|
+
|
|
31
|
+
def bold(self, text): return self._c(text, "1")
|
|
32
|
+
def dim(self, text): return self._c(text, "2")
|
|
33
|
+
def green(self, text): return self._c(text, "32")
|
|
34
|
+
def yellow(self, text): return self._c(text, "33")
|
|
35
|
+
def red(self, text): return self._c(text, "31")
|
|
36
|
+
def cyan(self, text): return self._c(text, "36")
|
|
37
|
+
|
|
38
|
+
def banner(self, title: str, subtitle: str = "") -> None:
|
|
39
|
+
line = "═" * 60
|
|
40
|
+
self.stream.write(self.cyan(line) + "\n")
|
|
41
|
+
self.stream.write(" " + self.bold(title) + "\n")
|
|
42
|
+
if subtitle:
|
|
43
|
+
self.stream.write(" " + self.dim(subtitle) + "\n")
|
|
44
|
+
self.stream.write(self.cyan(line) + "\n")
|
|
45
|
+
|
|
46
|
+
def section(self, title: str) -> None:
|
|
47
|
+
self.stream.write("\n" + self.cyan("▶ ") + self.bold(title) + "\n")
|
|
48
|
+
|
|
49
|
+
def field(self, name: str, desc: str = "", required: bool = False) -> None:
|
|
50
|
+
tag = self.red(" *必填") if required else ""
|
|
51
|
+
self.stream.write(f" {self.bold(name)}{tag}\n")
|
|
52
|
+
if desc:
|
|
53
|
+
self.stream.write(" " + self.dim(desc) + "\n")
|
|
54
|
+
|
|
55
|
+
def step(self, text: str) -> None:
|
|
56
|
+
self.stream.write(" " + self.cyan("→ ") + text + "\n")
|
|
57
|
+
|
|
58
|
+
def success(self, text: str) -> None:
|
|
59
|
+
self.stream.write(" " + self.green("✓ ") + text + "\n")
|
|
60
|
+
|
|
61
|
+
def warn(self, text: str) -> None:
|
|
62
|
+
self.stream.write(" " + self.yellow("! ") + text + "\n")
|
|
63
|
+
|
|
64
|
+
def error(self, text: str) -> None:
|
|
65
|
+
self.stream.write(" " + self.red("✗ ") + text + "\n")
|
|
66
|
+
|
|
67
|
+
def kv(self, key: str, value: str) -> None:
|
|
68
|
+
self.stream.write(" " + self.dim(f"{key}:") + " " + str(value) + "\n")
|
|
@@ -28,7 +28,7 @@ class OpsCliTests(unittest.TestCase):
|
|
|
28
28
|
self.assertEqual(len(context["network"]["protocols"]), 3)
|
|
29
29
|
|
|
30
30
|
def test_deploy_dry_run_reports_steps(self):
|
|
31
|
-
result = run_ops_cli("deploy", "--plan", PLAN, "--dry-run")
|
|
31
|
+
result = run_ops_cli("deploy", "--plan", PLAN, "--dry-run", "--json")
|
|
32
32
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
33
33
|
report = json.loads(result.stdout)
|
|
34
34
|
self.assertTrue(report["dry_run"])
|