sing-box-cli 0.1.1__tar.gz → 0.1.2__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 (46) hide show
  1. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/PKG-INFO +57 -7
  2. sing_box_cli-0.1.2/README.md +100 -0
  3. sing_box_cli-0.1.2/hatch_build.py +47 -0
  4. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/pyproject.toml +7 -1
  5. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/__init__.py +1 -1
  6. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/config/config.py +12 -0
  7. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/main.py +2 -2
  8. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/service/manager.py +54 -110
  9. sing_box_cli-0.1.2/windows-service/common.go +121 -0
  10. sing_box_cli-0.1.2/windows-service/common_test.go +66 -0
  11. sing_box_cli-0.1.2/windows-service/go.mod +5 -0
  12. sing_box_cli-0.1.2/windows-service/go.sum +2 -0
  13. sing_box_cli-0.1.2/windows-service/integration_windows_test.go +141 -0
  14. sing_box_cli-0.1.2/windows-service/main_other.go +13 -0
  15. sing_box_cli-0.1.2/windows-service/main_windows.go +385 -0
  16. sing_box_cli-0.1.2/windows-service/testdata/core.go +30 -0
  17. sing_box_cli-0.1.1/.github/dependabot.yml +0 -16
  18. sing_box_cli-0.1.1/.github/workflows/main.yml +0 -99
  19. sing_box_cli-0.1.1/.pre-commit-config.yaml +0 -50
  20. sing_box_cli-0.1.1/CHANGELOG.md +0 -551
  21. sing_box_cli-0.1.1/README.md +0 -50
  22. sing_box_cli-0.1.1/assets/image.png +0 -0
  23. sing_box_cli-0.1.1/scripts/bump.sh +0 -8
  24. sing_box_cli-0.1.1/scripts/format.sh +0 -5
  25. sing_box_cli-0.1.1/scripts/lint.sh +0 -8
  26. sing_box_cli-0.1.1/scripts/pre-commit.sh +0 -9
  27. sing_box_cli-0.1.1/scripts/test.sh +0 -8
  28. sing_box_cli-0.1.1/src/sing_box_cli/bin/nssm.exe +0 -0
  29. sing_box_cli-0.1.1/tests/config/test_utils.py +0 -177
  30. sing_box_cli-0.1.1/tests/conftest.py +0 -11
  31. sing_box_cli-0.1.1/tests/data/config.json +0 -243
  32. sing_box_cli-0.1.1/uv.lock +0 -1036
  33. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/.gitignore +0 -0
  34. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/LICENSE +0 -0
  35. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/__init__.py +0 -0
  36. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/client.py +0 -0
  37. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/connections.py +0 -0
  38. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/logs.py +0 -0
  39. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/monitor.py +0 -0
  40. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/policy.py +0 -0
  41. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/api/widget.py +0 -0
  42. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/common.py +0 -0
  43. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/config/__init__.py +0 -0
  44. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/config/utils.py +0 -0
  45. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/py.typed +0 -0
  46. {sing_box_cli-0.1.1 → sing_box_cli-0.1.2}/src/sing_box_cli/service/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sing-box-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: cross-platform sing-box manager.
5
5
  Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
6
6
  Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
@@ -69,21 +69,71 @@ Linux
69
69
  curl -LsSf https://astral.sh/uv/install.sh | sh
70
70
  ```
71
71
 
72
- ### sing-box
73
-
74
- Windows
72
+ ### sing-box-cli
75
73
 
74
+ Install
76
75
  ```powershell
76
+ # windows
77
77
  uv tool install sing-box-cli
78
78
  ```
79
79
 
80
- Linux
81
-
82
80
  ```bash
81
+ # linux
83
82
  uv tool install sing-box-cli
84
83
  sudo ln -sf $(which sing-box-cli) /usr/local/bin/
84
+ sudo ln -sf $(which sbc) /usr/local/bin/
85
+ ```
86
+
87
+ Install with specific sing-box version
88
+
89
+ ```bash
90
+ uv tool install sing-box-cli --with sing-box-bin==x.y.z
85
91
  ```
86
92
 
93
+ Upgrade
94
+ ```bash
95
+ uv tool upgrade sing-box-cli
96
+ ```
97
+
98
+ After upgrading on Windows, run the following in an administrator PowerShell:
99
+
100
+ ```powershell
101
+ sbc service enable
102
+ sbc service status
103
+ ```
104
+
105
+ This refreshes the installed service paths and migrates existing NSSM installations
106
+ to the native Go service host, keeping the same `sing-box-service` name and account.
107
+ An existing running service is stopped and restarted when its executable paths change.
108
+ If that update fails, the old service target is restored and the error is reported.
109
+
110
+ The native host and core are copied into versioned directories under
111
+ `%ProgramFiles%/sing-box-cli/sing-box-service`. The running service does not depend
112
+ on uv's environment, so future uv upgrades do not overwrite its running executables.
113
+ Configuration files stay in their existing location. Windows services run at boot;
114
+ closing the terminal does not stop them. Linux continues to use the existing systemd service.
115
+
116
+ No Go installation is required when installing a published wheel. Go is needed
117
+ only to build a wheel from source; the required version is in `windows-service/go.mod`.
118
+ Architecture support follows `sing-box-bin`: Windows distributes the amd64 core
119
+ and service helper; Linux distributes amd64 and arm64 cores. No additional CPU
120
+ architectures are introduced by the service helper.
121
+
122
+ ### Logs
123
+
124
+ The service uses the user's sing-box configuration without changing its log level.
125
+ Set `log.level` to `info` or `warn` in that configuration to reduce generated logs.
126
+ `sbc logs --log-level debug` selects the logs to view; it cannot recover debug entries
127
+ that the core did not generate. For diagnostics, explicitly change the core's log level
128
+ and restart, then restore it when finished.
129
+
130
+ The native Windows host writes only warnings/errors to the Windows Application event
131
+ log (source `sing-box-service-native`). Core stdout/stderr is kept as a bounded diagnostic
132
+ tail for failures, without an additional debug log file. To persist core logs, use
133
+ sing-box's `log.output` configuration; streaming `sbc logs` continues to use the core API.
134
+ Old versioned executables are retained for rollback. After disabling the service,
135
+ its directory under Program Files may be removed manually; user configuration is separate.
136
+
87
137
  ## Run
88
138
 
89
139
  Windows in Admin powershell
@@ -0,0 +1,100 @@
1
+ # Sing-Box CLI
2
+
3
+ 🎤 Cross-platform sing-box service manager.
4
+
5
+ ![help](assets/image.png)
6
+
7
+ ## Install
8
+
9
+ ### uv
10
+
11
+ Windows
12
+
13
+ ```powershell
14
+ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
15
+ ```
16
+
17
+ Linux
18
+
19
+ ```bash
20
+ curl -LsSf https://astral.sh/uv/install.sh | sh
21
+ ```
22
+
23
+ ### sing-box-cli
24
+
25
+ Install
26
+ ```powershell
27
+ # windows
28
+ uv tool install sing-box-cli
29
+ ```
30
+
31
+ ```bash
32
+ # linux
33
+ uv tool install sing-box-cli
34
+ sudo ln -sf $(which sing-box-cli) /usr/local/bin/
35
+ sudo ln -sf $(which sbc) /usr/local/bin/
36
+ ```
37
+
38
+ Install with specific sing-box version
39
+
40
+ ```bash
41
+ uv tool install sing-box-cli --with sing-box-bin==x.y.z
42
+ ```
43
+
44
+ Upgrade
45
+ ```bash
46
+ uv tool upgrade sing-box-cli
47
+ ```
48
+
49
+ After upgrading on Windows, run the following in an administrator PowerShell:
50
+
51
+ ```powershell
52
+ sbc service enable
53
+ sbc service status
54
+ ```
55
+
56
+ This refreshes the installed service paths and migrates existing NSSM installations
57
+ to the native Go service host, keeping the same `sing-box-service` name and account.
58
+ An existing running service is stopped and restarted when its executable paths change.
59
+ If that update fails, the old service target is restored and the error is reported.
60
+
61
+ The native host and core are copied into versioned directories under
62
+ `%ProgramFiles%/sing-box-cli/sing-box-service`. The running service does not depend
63
+ on uv's environment, so future uv upgrades do not overwrite its running executables.
64
+ Configuration files stay in their existing location. Windows services run at boot;
65
+ closing the terminal does not stop them. Linux continues to use the existing systemd service.
66
+
67
+ No Go installation is required when installing a published wheel. Go is needed
68
+ only to build a wheel from source; the required version is in `windows-service/go.mod`.
69
+ Architecture support follows `sing-box-bin`: Windows distributes the amd64 core
70
+ and service helper; Linux distributes amd64 and arm64 cores. No additional CPU
71
+ architectures are introduced by the service helper.
72
+
73
+ ### Logs
74
+
75
+ The service uses the user's sing-box configuration without changing its log level.
76
+ Set `log.level` to `info` or `warn` in that configuration to reduce generated logs.
77
+ `sbc logs --log-level debug` selects the logs to view; it cannot recover debug entries
78
+ that the core did not generate. For diagnostics, explicitly change the core's log level
79
+ and restart, then restore it when finished.
80
+
81
+ The native Windows host writes only warnings/errors to the Windows Application event
82
+ log (source `sing-box-service-native`). Core stdout/stderr is kept as a bounded diagnostic
83
+ tail for failures, without an additional debug log file. To persist core logs, use
84
+ sing-box's `log.output` configuration; streaming `sbc logs` continues to use the core API.
85
+ Old versioned executables are retained for rollback. After disabling the service,
86
+ its directory under Program Files may be removed manually; user configuration is separate.
87
+
88
+ ## Run
89
+
90
+ Windows in Admin powershell
91
+
92
+ ```powershell
93
+ sbc --help
94
+ ```
95
+
96
+ Linux
97
+
98
+ ```bash
99
+ sudo sbc --help
100
+ ```
@@ -0,0 +1,47 @@
1
+ """Bundle the small Windows SCM helper; the CLI remains Python."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
10
+
11
+
12
+ class CustomBuildHook(BuildHookInterface):
13
+ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
14
+ if self.target_name != "wheel" or version == "editable":
15
+ return
16
+ go = shutil.which("go")
17
+ if go is None:
18
+ raise RuntimeError(
19
+ "Building a wheel requires Go (see windows-service/go.mod). Installing a published wheel does not require Go."
20
+ )
21
+ root = Path(self.root)
22
+ output = root / "build" / "windows-service"
23
+ output.mkdir(parents=True, exist_ok=True)
24
+ # Match sing-box-bin's single Windows amd64 resource.
25
+ filename = "sbc-service-windows-amd64.exe"
26
+ destination = output / filename
27
+ subprocess.run(
28
+ [
29
+ go,
30
+ "build",
31
+ "-mod=readonly",
32
+ "-trimpath",
33
+ "-ldflags=-s -w",
34
+ "-o",
35
+ str(destination),
36
+ ".",
37
+ ],
38
+ cwd=root / "windows-service",
39
+ env={
40
+ **os.environ,
41
+ "GOOS": "windows",
42
+ "GOARCH": "amd64",
43
+ "CGO_ENABLED": "0",
44
+ },
45
+ check=True,
46
+ )
47
+ build_data["force_include"][str(destination)] = f"sing_box_cli/bin/{filename}"
@@ -71,6 +71,12 @@ path = "src/sing_box_cli/__init__.py"
71
71
  packages = ["src/sing_box_cli"]
72
72
  # FROM: https://hatch.pypa.io/latest/build/
73
73
 
74
+ [tool.hatch.build.hooks.custom]
75
+ path = "hatch_build.py"
76
+
77
+ [tool.hatch.build.targets.sdist]
78
+ include = ["/src", "/windows-service", "/hatch_build.py", "/pyproject.toml", "/README.md", "/LICENSE"]
79
+
74
80
  ## Test
75
81
  [tool.mypy]
76
82
  strict = true
@@ -209,7 +215,7 @@ topo_order = false
209
215
  sort_commits = "oldest"
210
216
 
211
217
  [tool.bumpversion]
212
- current_version = "0.1.1"
218
+ current_version = "0.1.2"
213
219
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
214
220
  serialize = ["{major}.{minor}.{patch}"]
215
221
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.1.1"
5
+ __version__ = "0.1.2"
@@ -315,5 +315,17 @@ def get_config() -> ConfigHandler:
315
315
  return config
316
316
 
317
317
 
318
+ def run_args(config: ConfigHandler) -> list[str]:
319
+ """Build subprocess arguments without splitting paths containing whitespace."""
320
+ return [
321
+ str(config.bin_path),
322
+ "run",
323
+ "-c",
324
+ str(config.config_file),
325
+ "-D",
326
+ str(config.config_dir),
327
+ ]
328
+
329
+
318
330
  def run_cmd(config: ConfigHandler) -> str:
319
331
  return f"{config.bin_path} run -c {config.config_file} -D {config.config_dir}"
@@ -6,7 +6,7 @@ from rich import print
6
6
  from sing_box_cli.api import api as api_app
7
7
  from sing_box_cli.common import ClearCacheOption, UpdateConfigOption, ensure_root
8
8
  from sing_box_cli.config import config as config_app
9
- from sing_box_cli.config.config import get_config, run_cmd
9
+ from sing_box_cli.config.config import get_config, run_args
10
10
  from sing_box_cli.service import SharedContext, get_context_obj, service as service_app
11
11
  from sing_box_cli.service.manager import create_service
12
12
 
@@ -47,7 +47,7 @@ def run(
47
47
  raise typer.Exit(1)
48
48
 
49
49
  # run
50
- cmd = run_cmd(cfg).split()
50
+ cmd = run_args(cfg)
51
51
  subprocess.run(cmd)
52
52
 
53
53
 
@@ -1,8 +1,7 @@
1
- import shutil
2
1
  import subprocess
3
2
  from pathlib import Path
4
3
 
5
- from ..config.config import ConfigHandler, run_cmd
4
+ from ..config.config import ConfigHandler, run_args, run_cmd
6
5
 
7
6
 
8
7
  class ServiceManager:
@@ -35,151 +34,96 @@ class ServiceManager:
35
34
 
36
35
 
37
36
  class WindowsServiceManager(ServiceManager):
38
- """
39
- commands: https://nssm.cc/commands
40
- binary: https://nssm.cc/builds
41
- """
37
+ """Windows SCM service managed by the bundled native Go helper."""
42
38
 
43
39
  def __init__(self, config: ConfigHandler) -> None:
44
40
  super().__init__(config)
45
41
  self.service_name = "sing-box-service"
46
- self.status_list = ["SERVICE_RUNNING", "SERVICE_STOPPED", "SERVICE_PAUSED"]
47
42
 
48
43
  @property
49
- def nssm_bin(self) -> str:
50
- """Get the NSSM executable path"""
51
- nssm_exe: Path | str | None = shutil.which("nssm")
52
- if nssm_exe is None:
53
- bin_dir = Path(__file__).parents[1] / "bin"
54
- nssm_exe = bin_dir / "nssm.exe"
55
- return str(nssm_exe)
56
-
57
- def create_service(self) -> None:
58
- """Create a Windows service using NSSM"""
59
- # Install the service
60
- try:
61
- subprocess.run(
62
- [
63
- self.nssm_bin,
64
- "install",
65
- self.service_name,
66
- *run_cmd(self.config).split(),
67
- ],
68
- check=True,
69
- stdout=subprocess.DEVNULL,
44
+ def helper_bin(self) -> str:
45
+ # Match sing-box-bin: Windows always ships the amd64 executable.
46
+ binary = Path(__file__).parents[1] / "bin" / "sbc-service-windows-amd64.exe"
47
+ if not binary.is_file():
48
+ raise FileNotFoundError(
49
+ f"Windows service helper not found: {binary}. Reinstall sing-box-cli from a built wheel."
70
50
  )
71
- except subprocess.CalledProcessError:
72
- # Service might already exist, which is fine for a create
73
- pass
51
+ return str(binary)
74
52
 
75
- # Automatic startup at boot.
53
+ def create_service(self) -> None:
54
+ self.check_service() # Fail on SCM access errors before attempting installation.
76
55
  subprocess.run(
77
- [self.nssm_bin, "set", self.service_name, "Start", "SERVICE_AUTO_START"],
56
+ [
57
+ self.helper_bin,
58
+ "install",
59
+ self.service_name,
60
+ "--work-dir",
61
+ str(self.config.config_dir),
62
+ "--",
63
+ *run_args(self.config),
64
+ ],
78
65
  check=True,
79
66
  stdout=subprocess.DEVNULL,
80
67
  )
81
68
 
82
- # Configure service recovery options
83
- subprocess.run(
84
- [self.nssm_bin, "set", self.service_name, "AppExit", "Default", "Restart"],
85
- check=True,
86
- stdout=subprocess.DEVNULL,
69
+ def check_service(self) -> bool:
70
+ result = subprocess.run(
71
+ [self.helper_bin, "exists", self.service_name],
72
+ capture_output=True,
73
+ text=True,
87
74
  )
75
+ if result.returncode == 1060: # ERROR_SERVICE_DOES_NOT_EXIST
76
+ return False
77
+ result.check_returncode()
78
+ return True
88
79
 
89
- subprocess.run(
90
- [self.nssm_bin, "set", self.service_name, "AppRestartDelay", "2000"],
80
+ def _service_state(self) -> str:
81
+ result = subprocess.run(
82
+ [self.helper_bin, "status", self.service_name],
83
+ capture_output=True,
84
+ text=True,
91
85
  check=True,
92
- stdout=subprocess.DEVNULL,
93
86
  )
87
+ return result.stdout.strip()
94
88
 
95
- # Process priority and CPU affinity
89
+ def start(self) -> None:
90
+ if self.check_service() and self._service_state() == "SERVICE_RUNNING":
91
+ return
96
92
  subprocess.run(
97
- [
98
- self.nssm_bin,
99
- "set",
100
- self.service_name,
101
- "AppPriority",
102
- "HIGH_PRIORITY_CLASS",
103
- ],
93
+ [self.helper_bin, "start", self.service_name],
104
94
  check=True,
105
95
  stdout=subprocess.DEVNULL,
106
96
  )
107
97
 
108
- # A standalone service. This is the default.
98
+ def stop(self) -> None:
99
+ if not self.check_service() or self._service_state() == "SERVICE_STOPPED":
100
+ return
109
101
  subprocess.run(
110
- [
111
- self.nssm_bin,
112
- "set",
113
- self.service_name,
114
- "Type",
115
- "SERVICE_WIN32_OWN_PROCESS",
116
- ],
102
+ [self.helper_bin, "stop", self.service_name],
117
103
  check=True,
118
104
  stdout=subprocess.DEVNULL,
119
105
  )
120
106
 
121
- def check_service(self) -> bool:
122
- """Check if the service exists"""
123
- result = subprocess.run(
124
- [self.nssm_bin, "status", self.service_name], capture_output=True, text=True
125
- )
126
- return result.stdout.strip() in self.status_list
127
-
128
- def start(self) -> None:
129
- """Start the service"""
130
- try:
131
- subprocess.run(
132
- [self.nssm_bin, "start", self.service_name],
133
- check=True,
134
- stdout=subprocess.DEVNULL,
135
- )
136
- except subprocess.CalledProcessError:
137
- pass
138
-
139
- def stop(self) -> None:
140
- """Stop the service"""
141
- try:
142
- subprocess.run(
143
- [self.nssm_bin, "stop", self.service_name],
144
- check=True,
145
- stdout=subprocess.DEVNULL,
146
- stderr=subprocess.DEVNULL,
147
- )
148
- except subprocess.CalledProcessError:
149
- # Service might not be running, which is fine for a stop
150
- pass
151
-
152
107
  def restart(self) -> None:
153
- """Restart the service"""
154
108
  subprocess.run(
155
- [self.nssm_bin, "restart", self.service_name],
109
+ [self.helper_bin, "restart", self.service_name],
156
110
  check=True,
157
111
  stdout=subprocess.DEVNULL,
158
- stderr=subprocess.DEVNULL,
159
112
  )
160
113
 
161
114
  def status(self) -> str:
162
- """Get the service status"""
163
- result = subprocess.run(
164
- [self.nssm_bin, "status", self.service_name], capture_output=True, text=True
165
- )
166
- return (
167
- result.stdout.replace("_", " ").title().strip()
168
- if result.stdout
169
- else "Service not installed"
170
- )
115
+ if not self.check_service():
116
+ return "Service not installed"
117
+ return self._service_state().replace("_", " ").title()
171
118
 
172
119
  def disable(self) -> None:
173
- """Remove the service"""
174
- try:
175
- subprocess.run(
176
- [self.nssm_bin, "remove", self.service_name, "confirm"],
177
- check=True,
178
- stdout=subprocess.DEVNULL,
179
- )
180
- except subprocess.CalledProcessError:
181
- # Service might not exist, which is fine for a disable
182
- pass
120
+ if not self.check_service():
121
+ return
122
+ subprocess.run(
123
+ [self.helper_bin, "remove", self.service_name],
124
+ check=True,
125
+ stdout=subprocess.DEVNULL,
126
+ )
183
127
 
184
128
  def version(self) -> str:
185
129
  result = subprocess.run([self.config.bin_path, "version"], capture_output=True)
@@ -0,0 +1,121 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "errors"
7
+ "flag"
8
+ "fmt"
9
+ "io"
10
+ "os"
11
+ "path/filepath"
12
+ "regexp"
13
+ "sync"
14
+ )
15
+
16
+ type invocation struct {
17
+ command, name, directory string
18
+ args []string
19
+ }
20
+
21
+ func parseInvocation(args []string) (invocation, error) {
22
+ var in invocation
23
+ if len(args) < 2 {
24
+ return in, errors.New("usage: sbc-service <install|exists|status|start|stop|restart|remove|serve> <service-name> [--work-dir DIR -- PROGRAM ARGS...]")
25
+ }
26
+ in.command, in.name = args[0], args[1]
27
+ if !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(in.name) {
28
+ return in, errors.New("invalid service name")
29
+ }
30
+ switch in.command {
31
+ case "install", "serve":
32
+ flags := flag.NewFlagSet(in.command, flag.ContinueOnError)
33
+ flags.SetOutput(io.Discard)
34
+ flags.StringVar(&in.directory, "work-dir", "", "working directory")
35
+ if err := flags.Parse(args[2:]); err != nil {
36
+ return in, err
37
+ }
38
+ in.args = flags.Args()
39
+ if !filepath.IsAbs(in.directory) || len(in.args) == 0 || !filepath.IsAbs(in.args[0]) {
40
+ return in, errors.New("absolute working directory and executable paths are required")
41
+ }
42
+ case "exists", "status", "start", "stop", "restart", "remove":
43
+ if len(args) != 2 {
44
+ return in, errors.New("unexpected command arguments")
45
+ }
46
+ default:
47
+ return in, fmt.Errorf("unknown command: %s", in.command)
48
+ }
49
+ return in, nil
50
+ }
51
+
52
+ // Never overwrite an executable that another service or uv environment is using.
53
+ func stageBinary(source, root, filename string) (string, error) {
54
+ f, err := os.Open(source)
55
+ if err != nil {
56
+ return "", err
57
+ }
58
+ defer f.Close()
59
+ hash := sha256.New()
60
+ if _, err := io.Copy(hash, f); err != nil {
61
+ return "", err
62
+ }
63
+ directory := filepath.Join(root, hex.EncodeToString(hash.Sum(nil)))
64
+ if err := os.MkdirAll(directory, 0755); err != nil {
65
+ return "", err
66
+ }
67
+ destination := filepath.Join(directory, filename)
68
+ // Verify cached contents too; a partial copy must never become the service target.
69
+ if data, err := os.ReadFile(destination); err == nil {
70
+ sum := sha256.Sum256(data)
71
+ if hex.EncodeToString(sum[:]) == filepath.Base(directory) {
72
+ return destination, nil
73
+ }
74
+ }
75
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
76
+ return "", err
77
+ }
78
+ tmp, err := os.CreateTemp(directory, "copy-*.exe")
79
+ if err != nil {
80
+ return "", err
81
+ }
82
+ defer os.Remove(tmp.Name())
83
+ _, copyErr := io.Copy(tmp, f)
84
+ syncErr := tmp.Sync()
85
+ closeErr := tmp.Close()
86
+ if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
87
+ return "", err
88
+ }
89
+ if err := os.Rename(tmp.Name(), destination); err != nil {
90
+ return "", err
91
+ }
92
+ return destination, nil
93
+ }
94
+
95
+ // Only keep a bounded diagnostic tail, never an unbounded service debug log.
96
+ type diagnosticTail struct {
97
+ mu sync.Mutex
98
+ data []byte
99
+ }
100
+
101
+ func (t *diagnosticTail) Write(p []byte) (int, error) {
102
+ t.mu.Lock()
103
+ defer t.mu.Unlock()
104
+ const limit = 8192
105
+ n := len(p)
106
+ if len(p) >= limit {
107
+ t.data = append(t.data[:0], p[len(p)-limit:]...)
108
+ } else {
109
+ t.data = append(t.data, p...)
110
+ if len(t.data) > limit {
111
+ t.data = t.data[len(t.data)-limit:]
112
+ }
113
+ }
114
+ return n, nil
115
+ }
116
+
117
+ func (t *diagnosticTail) String() string {
118
+ t.mu.Lock()
119
+ defer t.mu.Unlock()
120
+ return string(t.data)
121
+ }