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.
Files changed (98) hide show
  1. {devconfig_gen_singbox-2.1.4/src/devconfig_gen_singbox.egg-info → devconfig_gen_singbox-2.1.6}/PKG-INFO +2 -2
  2. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/README.md +1 -1
  3. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/pyproject.toml +1 -1
  4. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/__init__.py +1 -1
  5. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6/src/devconfig_gen_singbox.egg-info}/PKG-INFO +2 -2
  6. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/SOURCES.txt +2 -0
  7. devconfig_gen_singbox-2.1.6/src/singbox_ops/cli.py +391 -0
  8. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/orchestrator.py +23 -11
  9. devconfig_gen_singbox-2.1.6/src/singbox_ops/core/ui.py +68 -0
  10. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_cli.py +1 -1
  11. devconfig_gen_singbox-2.1.6/tests/test_singbox_ops_wizard.py +66 -0
  12. devconfig_gen_singbox-2.1.4/src/singbox_ops/cli.py +0 -233
  13. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/LICENSE +0 -0
  14. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/setup.cfg +0 -0
  15. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/cli.py +0 -0
  16. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/engine.py +0 -0
  17. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/formats.py +0 -0
  18. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/interactive.py +0 -0
  19. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/models.py +0 -0
  20. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/__init__.py +0 -0
  21. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/custom.py +0 -0
  22. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/env_provider.py +0 -0
  23. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/json_provider.py +0 -0
  24. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/__init__.py +0 -0
  25. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/data/rules.json +0 -0
  26. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/links.py +0 -0
  27. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/models.py +0 -0
  28. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/__init__.py +0 -0
  29. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/anytls.py +0 -0
  30. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/base.py +0 -0
  31. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/hysteria2.py +0 -0
  32. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/plugins/tuic.py +0 -0
  33. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/provider.py +0 -0
  34. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/route.py +0 -0
  35. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/providers/singbox/schema.py +0 -0
  36. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/py.typed +0 -0
  37. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/registry.py +0 -0
  38. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/validation.py +0 -0
  39. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen/web_ui.py +0 -0
  40. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/dependency_links.txt +0 -0
  41. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/entry_points.txt +0 -0
  42. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/requires.txt +0 -0
  43. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/devconfig_gen_singbox.egg-info/top_level.txt +0 -0
  44. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/__init__.py +0 -0
  45. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/__init__.py +0 -0
  46. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/__init__.py +0 -0
  47. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/base.py +0 -0
  48. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/cloudflare_dns01.py +0 -0
  49. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/acme/prune.py +0 -0
  50. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/base.py +0 -0
  51. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/dns/__init__.py +0 -0
  52. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/dns/base.py +0 -0
  53. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/dns/cloudflare.py +0 -0
  54. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/export/__init__.py +0 -0
  55. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/export/local_files.py +0 -0
  56. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/__init__.py +0 -0
  57. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/auto_update.py +0 -0
  58. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/nftables.py +0 -0
  59. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/packages.py +0 -0
  60. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/systemd.py +0 -0
  61. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/runtime/warp_watchdog.py +0 -0
  62. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/__init__.py +0 -0
  63. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/base.py +0 -0
  64. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/python_secrets.py +0 -0
  65. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/singbox_subprocess.py +0 -0
  66. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/secrets/static.py +0 -0
  67. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/state/__init__.py +0 -0
  68. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/adapters/state/local_json.py +0 -0
  69. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/__init__.py +0 -0
  70. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/command.py +0 -0
  71. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/context_builder.py +0 -0
  72. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/exceptions.py +0 -0
  73. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/network.py +0 -0
  74. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/plan.py +0 -0
  75. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/core/protocols.py +0 -0
  76. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/templates/singbox_auto_update.py.template +0 -0
  77. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/src/singbox_ops/templates/warp_watchdog.sh.template +0 -0
  78. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_api_parity.py +0 -0
  79. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_cli_e2e.py +0 -0
  80. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_custom_provider.py +0 -0
  81. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_devconfig_core.py +0 -0
  82. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_env_provider.py +0 -0
  83. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_formats.py +0 -0
  84. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_interactive.py +0 -0
  85. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_merge.py +0 -0
  86. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_provider_metadata.py +0 -0
  87. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_acme.py +0 -0
  88. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_context_builder.py +0 -0
  89. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_dns.py +0 -0
  90. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_orchestrator.py +0 -0
  91. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_plan.py +0 -0
  92. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_runtime.py +0 -0
  93. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_secrets.py +0 -0
  94. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_ops_state_export.py +0 -0
  95. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_singbox_provider.py +0 -0
  96. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_validation.py +0 -0
  97. {devconfig_gen_singbox-2.1.4 → devconfig_gen_singbox-2.1.6}/tests/test_web_ui.py +0 -0
  98. {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.4
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
- [![Version](https://img.shields.io/badge/version-2.1.4-blue.svg)](CHANGELOG.md)
31
+ [![Version](https://img.shields.io/badge/version-2.1.6-blue.svg)](CHANGELOG.md)
32
32
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
33
33
  ![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)
34
34
  ![Tests](https://img.shields.io/badge/tests-136%20passing-brightgreen.svg)
@@ -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
- [![Version](https://img.shields.io/badge/version-2.1.4-blue.svg)](CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/version-2.1.6-blue.svg)](CHANGELOG.md)
7
7
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
8
8
  ![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)
9
9
  ![Tests](https://img.shields.io/badge/tests-136%20passing-brightgreen.svg)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devconfig_gen_singbox"
7
- version = "2.1.4"
7
+ version = "2.1.6"
8
8
  authors = [
9
9
  { name = "henryliu443", email = "henryliu443@gmail.com" },
10
10
  ]
@@ -66,4 +66,4 @@ __all__ = [
66
66
  "loads",
67
67
  "validate_request",
68
68
  ]
69
- __version__ = "2.1.4"
69
+ __version__ = "2.1.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devconfig_gen_singbox
3
- Version: 2.1.4
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
- [![Version](https://img.shields.io/badge/version-2.1.4-blue.svg)](CHANGELOG.md)
31
+ [![Version](https://img.shields.io/badge/version-2.1.6-blue.svg)](CHANGELOG.md)
32
32
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
33
33
  ![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)
34
34
  ![Tests](https://img.shields.io/badge/tests-136%20passing-brightgreen.svg)
@@ -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())
@@ -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
- steps.append("secrets")
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
- steps.append("dns")
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
- steps.append("acme")
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
- steps.append("generate")
208
+ note("generate")
203
209
 
204
210
  written = dict(suite.export.write(result.artifacts, plan.outputs, dry_run=dry_run))
205
- steps.append("export")
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
- steps.append(key)
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
- steps.append("state")
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
- steps.append(key)
286
+ note(key)
275
287
 
276
288
  if record_ids:
277
289
  suite.dns.destroy(record_ids, dry_run=dry_run)
278
- steps.append("dns")
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"])