devconfig-gen-singbox 2.1.3__tar.gz → 2.1.4__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 (96) hide show
  1. {devconfig_gen_singbox-2.1.3/src/devconfig_gen_singbox.egg-info → devconfig_gen_singbox-2.1.4}/PKG-INFO +2 -2
  2. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/README.md +1 -1
  3. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/pyproject.toml +1 -1
  4. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/__init__.py +1 -1
  5. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4/src/devconfig_gen_singbox.egg-info}/PKG-INFO +2 -2
  6. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen_singbox.egg-info/SOURCES.txt +1 -0
  7. devconfig_gen_singbox-2.1.4/src/singbox_ops/adapters/acme/__init__.py +8 -0
  8. devconfig_gen_singbox-2.1.4/src/singbox_ops/adapters/acme/prune.py +56 -0
  9. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/cli.py +25 -0
  10. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/command.py +11 -0
  11. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_acme.py +30 -0
  12. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_cli.py +1 -1
  13. devconfig_gen_singbox-2.1.3/src/singbox_ops/adapters/acme/__init__.py +0 -7
  14. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/LICENSE +0 -0
  15. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/setup.cfg +0 -0
  16. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/cli.py +0 -0
  17. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/engine.py +0 -0
  18. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/formats.py +0 -0
  19. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/interactive.py +0 -0
  20. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/models.py +0 -0
  21. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/__init__.py +0 -0
  22. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/custom.py +0 -0
  23. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/env_provider.py +0 -0
  24. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/json_provider.py +0 -0
  25. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/__init__.py +0 -0
  26. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/data/rules.json +0 -0
  27. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/links.py +0 -0
  28. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/models.py +0 -0
  29. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/plugins/__init__.py +0 -0
  30. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/plugins/anytls.py +0 -0
  31. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/plugins/base.py +0 -0
  32. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/plugins/hysteria2.py +0 -0
  33. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/plugins/tuic.py +0 -0
  34. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/provider.py +0 -0
  35. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/route.py +0 -0
  36. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/providers/singbox/schema.py +0 -0
  37. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/py.typed +0 -0
  38. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/registry.py +0 -0
  39. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/validation.py +0 -0
  40. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen/web_ui.py +0 -0
  41. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen_singbox.egg-info/dependency_links.txt +0 -0
  42. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen_singbox.egg-info/entry_points.txt +0 -0
  43. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen_singbox.egg-info/requires.txt +0 -0
  44. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/devconfig_gen_singbox.egg-info/top_level.txt +0 -0
  45. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/__init__.py +0 -0
  46. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/__init__.py +0 -0
  47. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/acme/base.py +0 -0
  48. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/acme/cloudflare_dns01.py +0 -0
  49. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/base.py +0 -0
  50. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/dns/__init__.py +0 -0
  51. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/dns/base.py +0 -0
  52. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/dns/cloudflare.py +0 -0
  53. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/export/__init__.py +0 -0
  54. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/export/local_files.py +0 -0
  55. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/runtime/__init__.py +0 -0
  56. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/runtime/auto_update.py +0 -0
  57. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/runtime/nftables.py +0 -0
  58. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/runtime/packages.py +0 -0
  59. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/runtime/systemd.py +0 -0
  60. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/runtime/warp_watchdog.py +0 -0
  61. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/secrets/__init__.py +0 -0
  62. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/secrets/base.py +0 -0
  63. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/secrets/python_secrets.py +0 -0
  64. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/secrets/singbox_subprocess.py +0 -0
  65. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/secrets/static.py +0 -0
  66. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/state/__init__.py +0 -0
  67. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/adapters/state/local_json.py +0 -0
  68. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/__init__.py +0 -0
  69. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/context_builder.py +0 -0
  70. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/exceptions.py +0 -0
  71. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/network.py +0 -0
  72. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/orchestrator.py +0 -0
  73. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/plan.py +0 -0
  74. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/core/protocols.py +0 -0
  75. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/templates/singbox_auto_update.py.template +0 -0
  76. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/src/singbox_ops/templates/warp_watchdog.sh.template +0 -0
  77. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_api_parity.py +0 -0
  78. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_cli_e2e.py +0 -0
  79. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_custom_provider.py +0 -0
  80. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_devconfig_core.py +0 -0
  81. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_env_provider.py +0 -0
  82. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_formats.py +0 -0
  83. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_interactive.py +0 -0
  84. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_merge.py +0 -0
  85. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_provider_metadata.py +0 -0
  86. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_context_builder.py +0 -0
  87. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_dns.py +0 -0
  88. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_orchestrator.py +0 -0
  89. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_plan.py +0 -0
  90. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_runtime.py +0 -0
  91. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_secrets.py +0 -0
  92. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_ops_state_export.py +0 -0
  93. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_singbox_provider.py +0 -0
  94. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_validation.py +0 -0
  95. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/tests/test_web_ui.py +0 -0
  96. {devconfig_gen_singbox-2.1.3 → devconfig_gen_singbox-2.1.4}/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
3
+ Version: 2.1.4
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.3-blue.svg)](CHANGELOG.md)
31
+ [![Version](https://img.shields.io/badge/version-2.1.4-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.3-blue.svg)](CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/version-2.1.4-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.3"
7
+ version = "2.1.4"
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.3"
69
+ __version__ = "2.1.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devconfig_gen_singbox
3
- Version: 2.1.3
3
+ Version: 2.1.4
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.3-blue.svg)](CHANGELOG.md)
31
+ [![Version](https://img.shields.io/badge/version-2.1.4-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)
@@ -40,6 +40,7 @@ src/singbox_ops/adapters/base.py
40
40
  src/singbox_ops/adapters/acme/__init__.py
41
41
  src/singbox_ops/adapters/acme/base.py
42
42
  src/singbox_ops/adapters/acme/cloudflare_dns01.py
43
+ src/singbox_ops/adapters/acme/prune.py
43
44
  src/singbox_ops/adapters/dns/__init__.py
44
45
  src/singbox_ops/adapters/dns/base.py
45
46
  src/singbox_ops/adapters/dns/cloudflare.py
@@ -0,0 +1,8 @@
1
+ """ACME certificate adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cloudflare_dns01 import CloudflareDNS01ACME
6
+ from .prune import DEFAULT_ACME_HOME, plan_prune, prune_certs
7
+
8
+ __all__ = ["CloudflareDNS01ACME", "DEFAULT_ACME_HOME", "plan_prune", "prune_certs"]
@@ -0,0 +1,56 @@
1
+ """Prune stale acme.sh certificate directories.
2
+
3
+ Only ``<acme_home>/<host>_ecc`` directories are ever considered. Anything whose
4
+ host is not in the keep set is removed. The operation refuses to run with an
5
+ empty keep set, and defaults to a dry run, so a slip can never wipe every
6
+ certificate (the failure mode of hand-rolled shell).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Mapping, Sequence
13
+
14
+ from ...core.command import CommandRunner, LocalCommandRunner
15
+ from ...core.exceptions import AdapterError
16
+
17
+ DEFAULT_ACME_HOME = "/root/.acme.sh"
18
+ SUFFIX = "_ecc"
19
+
20
+
21
+ def plan_prune(
22
+ runner: CommandRunner,
23
+ acme_home: str,
24
+ keep_hosts: Sequence[str],
25
+ ) -> "tuple[list, list]":
26
+ keep_set = {str(item).strip() for item in keep_hosts if str(item).strip()}
27
+ if not keep_set:
28
+ raise AdapterError("refusing to prune: no hosts to keep")
29
+ try:
30
+ entries = runner.listdir(acme_home)
31
+ except OSError as exc:
32
+ raise AdapterError(f"cannot list {acme_home}: {exc}") from exc
33
+
34
+ keep: list = []
35
+ remove: list = []
36
+ for entry in entries:
37
+ if not entry.endswith(SUFFIX):
38
+ continue
39
+ domain = entry[: -len(SUFFIX)]
40
+ (keep if domain in keep_set else remove).append(entry)
41
+ return keep, remove
42
+
43
+
44
+ def prune_certs(
45
+ runner: CommandRunner,
46
+ acme_home: str = DEFAULT_ACME_HOME,
47
+ keep_hosts: Sequence[str] = (),
48
+ dry_run: bool = True,
49
+ ) -> Mapping[str, object]:
50
+ keep, remove = plan_prune(runner, acme_home, keep_hosts)
51
+ result = {"acme_home": acme_home, "keep": sorted(keep), "remove": sorted(remove), "dry_run": dry_run}
52
+ if dry_run:
53
+ return result
54
+ for entry in remove:
55
+ runner.run(["rm", "-rf", os.path.join(acme_home, entry)], check=False)
56
+ return result
@@ -33,6 +33,7 @@ SUBCOMMANDS = (
33
33
  ("deploy", "Deploy sing-box (interactive wizard when --plan is omitted)"),
34
34
  ("redeploy", "Regenerate credentials and redeploy"),
35
35
  ("destroy", "Tear down what a previous deploy created"),
36
+ ("certs", "Prune stale acme.sh certificate dirs (dry-run unless --apply)"),
36
37
  )
37
38
 
38
39
 
@@ -46,6 +47,14 @@ def add_subparsers(sub) -> None:
46
47
  command.add_argument("--protocols", help="Comma-separated protocols (skips that prompt)")
47
48
  if name == "plan":
48
49
  command.add_argument("--output", default="plan.yaml", help="Where to write the plan")
50
+ if name == "certs":
51
+ command.add_argument("--keep", required=True, help="Comma-separated hosts to keep")
52
+ command.add_argument("--acme-home", default="/root/.acme.sh", help="acme.sh home dir")
53
+ command.add_argument(
54
+ "--apply",
55
+ action="store_true",
56
+ help="Actually delete (default is a dry run)",
57
+ )
49
58
  if name == "context":
50
59
  command.add_argument("--format", choices=("json", "yaml"), help="Output format")
51
60
  if name in ("deploy", "redeploy", "destroy"):
@@ -142,6 +151,22 @@ def _detect_or_placeholder(plan) -> str:
142
151
 
143
152
  def _run(args) -> int:
144
153
  try:
154
+ if getattr(args, "command", None) == "certs":
155
+ from .adapters.acme.prune import DEFAULT_ACME_HOME, prune_certs
156
+ from .core.command import LocalCommandRunner, RecordingRunner
157
+
158
+ keep = [item for item in (getattr(args, "keep", "") or "").split(",") if item.strip()]
159
+ dry_run = not bool(getattr(args, "apply", False))
160
+ runner = RecordingRunner() if dry_run else LocalCommandRunner()
161
+ result = prune_certs(
162
+ runner,
163
+ acme_home=getattr(args, "acme_home", DEFAULT_ACME_HOME),
164
+ keep_hosts=keep,
165
+ dry_run=dry_run,
166
+ )
167
+ print(json.dumps(result, indent=2, ensure_ascii=False))
168
+ return 0
169
+
145
170
  if getattr(args, "plan", None):
146
171
  plan = load_plan(args.plan)
147
172
  elif args.command == "destroy":
@@ -55,6 +55,9 @@ class CommandRunner:
55
55
  def islink(self, path) -> bool:
56
56
  raise NotImplementedError
57
57
 
58
+ def listdir(self, path) -> List[str]:
59
+ raise NotImplementedError
60
+
58
61
  def mkdir(self, path, *, mode: Optional[int] = None) -> None:
59
62
  raise NotImplementedError
60
63
 
@@ -113,6 +116,9 @@ class LocalCommandRunner(CommandRunner):
113
116
  def islink(self, path) -> bool:
114
117
  return Path(path).is_symlink()
115
118
 
119
+ def listdir(self, path) -> List[str]:
120
+ return sorted(os.listdir(str(path)))
121
+
116
122
  def mkdir(self, path, *, mode: Optional[int] = None) -> None:
117
123
  target = Path(path)
118
124
  target.mkdir(parents=True, exist_ok=True)
@@ -149,6 +155,7 @@ class RecordingRunner(CommandRunner):
149
155
  self.links: List[Tuple[str, str]] = []
150
156
  self._existing = set(str(item) for item in (existing or ()))
151
157
  self._files = {}
158
+ self._dirs = {}
152
159
 
153
160
  def run(self, args: Sequence[str], *, check: bool = True, env=None) -> CommandResult:
154
161
  self.commands.append(("run", tuple(str(item) for item in args), dict(env or {})))
@@ -173,9 +180,13 @@ class RecordingRunner(CommandRunner):
173
180
  def islink(self, path) -> bool:
174
181
  return False
175
182
 
183
+ def listdir(self, path) -> List[str]:
184
+ return sorted(self._dirs.get(str(path), set()))
185
+
176
186
  def mkdir(self, path, *, mode: Optional[int] = None) -> None:
177
187
  self.directories.append((str(path), mode))
178
188
  self._existing.add(str(path))
189
+ self._dirs.setdefault(str(path), set())
179
190
 
180
191
  def unlink(self, path, *, missing_ok: bool = True) -> None:
181
192
  self.removed.append(str(path))
@@ -1,7 +1,9 @@
1
1
  import unittest
2
2
 
3
3
  from singbox_ops.adapters.acme.cloudflare_dns01 import CloudflareDNS01ACME
4
+ from singbox_ops.adapters.acme.prune import plan_prune, prune_certs
4
5
  from singbox_ops.core.command import CommandResult
6
+ from singbox_ops.core.exceptions import AdapterError
5
7
  from tests._ops_support import FakeRunner
6
8
 
7
9
  HOSTS = {"tuic": "c3d4.example.com", "hysteria2": "e5f6.example.com"}
@@ -62,5 +64,33 @@ class ACMETests(unittest.TestCase):
62
64
  self.assertEqual(runner.commands, [])
63
65
 
64
66
 
67
+ class PruneTests(unittest.TestCase):
68
+ def _runner(self, entries):
69
+ return FakeRunner(dirs={"/root/.acme.sh": entries})
70
+
71
+ def test_plan_keep_and_remove(self):
72
+ runner = self._runner(["a.example.com_ecc", "b.example.com_ecc", "ca", "account.conf"])
73
+ keep, remove = plan_prune(runner, "/root/.acme.sh", ["a.example.com"])
74
+ self.assertEqual(keep, ["a.example.com_ecc"])
75
+ self.assertEqual(remove, ["b.example.com_ecc"])
76
+
77
+ def test_refuses_empty_keep(self):
78
+ runner = self._runner(["a.example.com_ecc"])
79
+ with self.assertRaises(AdapterError):
80
+ plan_prune(runner, "/root/.acme.sh", [])
81
+
82
+ def test_dry_run_does_not_delete(self):
83
+ runner = self._runner(["a.example.com_ecc", "b.example.com_ecc"])
84
+ result = prune_certs(runner, "/root/.acme.sh", ["a.example.com"], dry_run=True)
85
+ self.assertEqual(result["remove"], ["b.example.com_ecc"])
86
+ self.assertEqual(runner.commands, [])
87
+
88
+ def test_apply_deletes_only_stale(self):
89
+ runner = self._runner(["a.example.com_ecc", "b.example.com_ecc"])
90
+ prune_certs(runner, "/root/.acme.sh", ["a.example.com"], dry_run=False)
91
+ runs = [cmd[1] for cmd in runner.commands if cmd[0] == "run"]
92
+ self.assertEqual(runs, [("rm", "-rf", "/root/.acme.sh/b.example.com_ecc")])
93
+
94
+
65
95
  if __name__ == "__main__":
66
96
  unittest.main()
@@ -14,7 +14,7 @@ class SingleCliNameTests(unittest.TestCase):
14
14
  result = run_cli("--help")
15
15
  self.assertEqual(result.returncode, 0, result.stderr)
16
16
  self.assertIn("devconfig_gen_singbox", result.stdout)
17
- for name in ("generate", "init", "ui", "context", "plan", "deploy", "redeploy", "destroy"):
17
+ for name in ("generate", "init", "ui", "context", "plan", "deploy", "redeploy", "destroy", "certs"):
18
18
  self.assertIn(name, result.stdout)
19
19
  self.assertNotIn("singbox-ops", result.stdout)
20
20
 
@@ -1,7 +0,0 @@
1
- """ACME certificate adapters."""
2
-
3
- from __future__ import annotations
4
-
5
- from .cloudflare_dns01 import CloudflareDNS01ACME
6
-
7
- __all__ = ["CloudflareDNS01ACME"]