diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md index fa5a9d4..4bbee31 100644 --- a/.trellis/spec/backend/error-handling.md +++ b/.trellis/spec/backend/error-handling.md @@ -37,8 +37,8 @@ subprocess.run(f"nssm.exe start {service_name}", shell=True) ## 服务状态错误 -- `sing start ` 在服务已运行时失败,并提示使用 `sing restart `。 -- `sing restart ` 停止失败时不得继续启动。 +- `sing start ` 在服务已运行时失败,并提示使用 `sing restart`。 +- `sing restart` 停止失败时不得继续启动。 - `sing stop` 在服务停止失败时返回失败。 - `sing uninstall` 删除服务失败时返回失败。 - 第一版不做隐式重试,不做自动修复服务状态。 @@ -46,7 +46,8 @@ subprocess.run(f"nssm.exe start {service_name}", shell=True) ## Profile 状态错误 - profile 名不符合命名规则时失败。 -- `sing start `、`sing restart `、`sing update ` 找不到 profile 名时失败。 +- `sing start `、`sing update ` 找不到 profile 名时失败。 +- `sing restart` 没有 active profile 或 active profile 不存在时失败。 - `sing remove ` 删除 active profile 时失败。 - 下载 profile URL 失败、HTTP 状态失败、写入本地文件失败时命令失败。 - `sing add` 和 `sing update` 不主动调用 `sing-box check`;profile 有效性由 `sing start ` 启动时的 `sing-box` 行为暴露。 diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index bc39297..d537ebd 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -40,7 +40,7 @@ sing install [--bin ] sing uninstall sing start sing stop -sing restart +sing restart sing add sing remove sing update @@ -49,8 +49,8 @@ sing list - `sing install` 通过 NSSM 注册 `sing-box` Windows 服务并开启自启;默认使用 `PATH` 中的 `sing-box.exe`,`--bin` 可指定自定义路径。 - `sing install` 不下载、不升级 `sing-box.exe` 或 `nssm.exe`,也不指定或写入业务 profile。 -- `sing start ` 使用 `` 对应的本地 profile 启动服务;服务已运行时失败并提示使用 `sing restart `。 -- `sing restart ` 停止当前服务后,用 `` 对应 profile 重新启动。 +- `sing start ` 使用 `` 对应的本地 profile 启动服务;服务已运行时失败并提示使用 `sing restart`。 +- `sing restart` 停止当前服务后,用 active profile 重新启动。 - `sing stop` 停止服务,不需要 profile 名。 - `sing add ` 添加命名 profile;URL 返回完整 `sing-box` JSON profile。 - `sing update ` 从已保存 URL 重新下载 profile。 @@ -59,13 +59,13 @@ sing list ## 服务命令行 -`sing start ` 和 `sing restart ` 启动前必须先更新 NSSM 服务参数: +`sing start ` 和 `sing restart` 启动前必须先更新 NSSM 服务参数: ```text nssm.exe set sing-box Application nssm.exe set sing-box AppParameters "run -c \"\"" ``` -随后再启动 `sing-box` 服务。Windows 自启时沿用最后一次 `sing start ` 或 `sing restart ` 写入的 profile。 +随后再启动 `sing-box` 服务。Windows 自启时沿用最后一次 `sing start ` 或 `sing restart` 写入的 profile。 调用 `nssm.exe` 必须使用参数列表,不使用 shell 字符串拼接。 diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index b677f89..6311807 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -24,7 +24,7 @@ sing install [--bin ] sing uninstall sing start sing stop -sing restart +sing restart sing add sing remove sing update @@ -33,7 +33,7 @@ sing list - `sing install` 通过 NSSM 注册服务并开启自启;不处理业务配置。 - `sing start ` 负责把 NSSM 服务参数更新到 `` 对应配置,再启动服务。 -- `sing restart ` 先停止服务,再更新 NSSM 服务参数并启动服务。 +- `sing restart` 先停止服务,再用 active 配置更新 NSSM 服务参数并启动服务。 - `sing stop` 不需要配置名。 - `sing list` 必须标出 active 配置。 @@ -60,7 +60,7 @@ nssm.exe remove sing-box confirm - `sing install [--bin ]` 解析 `sing-box.exe` 后调用 `nssm.exe install`,再设置 `Start` 为 `SERVICE_AUTO_START`。 - `sing start ` 启动前设置 `Application` 为已安装的 `sing-box.exe` 路径,设置 `AppParameters` 为 `run -c ""`。 -- `sing restart ` 必须先停止服务,停止成功后再写入 NSSM 参数并启动服务。 +- `sing restart` 必须先停止服务,停止成功后再用 active 配置写入 NSSM 参数并启动服务。 - `nssm.exe` 必须从 `PATH` 解析;项目不下载、不内置 NSSM。 - 外部命令必须用参数列表调用,不通过 shell 字符串执行。 - 捕获外部命令输出时必须显式使用 `encoding="utf-8"` 和 `errors="replace"`,避免 Windows locale 默认编码导致 reader thread 抛出 `UnicodeDecodeError`。 @@ -73,7 +73,9 @@ nssm.exe remove sing-box confirm | `nssm.exe` 返回非零退出码 | 命令失败并暴露 stderr 或 stdout 摘要 | | `nssm.exe` 输出包含当前系统编码无法解码的字节 | 命令不因解码崩溃,输出中的非法字节以替换字符呈现 | | `nssm.exe status sing-box` 输出 `SERVICE_RUNNING` | `service_is_running()` 返回 `True` | -| `sing start ` 发现服务已运行 | 命令失败并提示使用 `sing restart ` | +| `sing start ` 发现服务已运行 | 命令失败并提示使用 `sing restart` | +| `sing restart` 没有 active 配置 | 命令失败并提示先运行 `sing start ` | +| `sing restart` 的 active 配置不存在 | 命令失败并提示 profile 不存在 | ### 5. Good/Base/Bad Cases @@ -505,7 +507,10 @@ updates: - `sing install` 的 PATH 解析、`--bin` 覆盖、找不到二进制失败。 - `nssm.exe` 参数列表构造,不通过 shell 字符串执行。 - `sing start ` 服务已运行时失败。 -- `sing restart ` 停止失败时不继续启动。 +- `sing restart` 停止失败时不继续启动。 +- `sing restart` 不接受 profile 名参数。 +- `sing restart` 使用 active 配置重启。 +- `sing restart` 没有 active 配置或 active 配置不存在时失败。 - `state.json` 读写、active 更新和删除 active 配置失败。 - 配置名称校验。 - HTTP 下载失败、写入失败和更新成功路径。 diff --git a/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/check.jsonl b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/check.jsonl new file mode 100644 index 0000000..e4911a1 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Verify backend command contract and docs remain consistent."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify restart errors remain explicit and user-facing failures are not swallowed."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify test coverage and required quality commands."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify CLI output remains concise and separated by success/error path."} diff --git a/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/implement.jsonl b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/implement.jsonl new file mode 100644 index 0000000..fc152bf --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/implement.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend command contract and project facts for CLI behavior changes."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "CLI error behavior for service and profile state failures."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Required tests and quality commands for Python CLI changes."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "stdout/stderr output expectations for CLI commands."} diff --git a/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/prd.md b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/prd.md new file mode 100644 index 0000000..397cff6 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/prd.md @@ -0,0 +1,58 @@ +# Fix restart active config behavior + +## Goal + +`sing restart` must restart the Windows service with the currently active profile and must not accept a profile name argument. + +## What I already know + +- User requirement: `sing restart` does not need parameters. +- User requirement: restart should use the current active configuration. +- Current implementation defines `restart(name: str)` and reconfigures the service with the provided profile. +- `README.md` and backend SPEC currently document `sing restart `. +- Current tests cover service and state helpers but do not cover the Typer command-level restart argument contract. + +## Requirements + +- Change the CLI command contract from `sing restart ` to `sing restart`. +- `sing restart` must load the current state and require `state.active` to be set. +- `sing restart` must require the active profile to still exist. +- `sing restart` must require an installed `sing-box.exe` path. +- `sing restart` must stop the service, configure NSSM with the active profile path, start the service, and keep `state.active` unchanged. +- `sing start ` service-running error must point users to `sing restart`. +- Update tests for the no-argument restart behavior. +- Update README and SPEC command descriptions to match the corrected contract. + +## Acceptance Criteria + +- [x] `sing restart` succeeds with the current active profile. +- [x] `sing restart ` is rejected by Typer as an unexpected argument. +- [x] `sing restart` fails clearly when no active profile is recorded. +- [x] `sing restart` fails clearly when the active profile is missing. +- [x] Python lint, type-check, and tests pass. + +## Definition of Done + +- Tests added or updated for the command behavior. +- `uv run ruff check src` passes. +- `uv run ty check src` passes. +- `uv run pytest` passes. +- Docs and SPEC reflect the finished behavior without extra notes or process commentary. + +## Out of Scope + +- Backward compatibility for `sing restart `. +- Selecting or switching profiles during restart. +- Changing `sing start`, `sing stop`, `sing update`, `sing remove`, or profile storage beyond the required help/error text updates. + +## Technical Notes + +- Relevant files inspected: + - `src/sing_cli/cli.py` + - `tests/test_service.py` + - `tests/test_state.py` + - `README.md` + - `.trellis/spec/backend/index.md` + - `.trellis/spec/backend/error-handling.md` + - `.trellis/spec/backend/quality-guidelines.md` + - `.trellis/spec/backend/logging-guidelines.md` diff --git a/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/task.json b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/task.json new file mode 100644 index 0000000..1d98715 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-fix-restart-active-config/task.json @@ -0,0 +1,26 @@ +{ + "id": "fix-restart-active-config", + "name": "fix-restart-active-config", + "title": "Fix restart active config behavior", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "pixelcola", + "assignee": "pixelcola", + "createdAt": "2026-05-12", + "completedAt": "2026-05-12", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workspace/pixelcola/index.md b/.trellis/workspace/pixelcola/index.md index 6ac7de0..4108048 100644 --- a/.trellis/workspace/pixelcola/index.md +++ b/.trellis/workspace/pixelcola/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 4 +- **Total Sessions**: 5 - **Last Active**: 2026-05-12 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~139 | Active | +| `journal-1.md` | ~172 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 5 | 2026-05-12 | Fix restart active profile | `010e59e` | `fix/restart-active-config` | | 4 | 2026-05-12 | Fix Windows subprocess output decoding | `e90a676` | `fix/windows-subprocess-output-decoding` | | 3 | 2026-05-12 | Use NSSM for Windows service | `72c1b0d` | `fix/use-nssm-service` | | 2 | 2026-05-12 | Windows sing-box CLI | `2f18462` | `feature/windows-sing-box-cli` | diff --git a/.trellis/workspace/pixelcola/journal-1.md b/.trellis/workspace/pixelcola/journal-1.md index 6265149..c232414 100644 --- a/.trellis/workspace/pixelcola/journal-1.md +++ b/.trellis/workspace/pixelcola/journal-1.md @@ -137,3 +137,36 @@ Created PR #4 for the Windows subprocess decoding fix. Updated NSSM subprocess o ### Next Steps - None - task complete + + +## Session 5: Fix restart active profile + +**Date**: 2026-05-12 +**Task**: Fix restart active profile +**Branch**: `fix/restart-active-config` + +### Summary + +Changed sing restart to use the active profile without accepting a profile argument, updated docs/specs, added CLI regression tests, and opened PR #5. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `010e59e` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/README.md b/README.md index 01d9510..bd9c6f0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Windows CLI for installing and controlling the `sing-box` Windows service. | `sing uninstall` | Delete the `sing-box` Windows service. | | `sing start ` | Start the service with a saved profile. | | `sing stop` | Stop the service. | -| `sing restart ` | Stop, reconfigure, and start the service with a saved profile. | +| `sing restart` | Stop, reconfigure, and start the service with the active profile. | | `sing add ` | Download a complete `sing-box` JSON profile and save it under a name. | | `sing remove ` | Remove a saved non-active profile. | | `sing update ` | Redownload a saved profile from its URL. | diff --git a/src/sing_cli/cli.py b/src/sing_cli/cli.py index 545b9fa..abcfe6c 100644 --- a/src/sing_cli/cli.py +++ b/src/sing_cli/cli.py @@ -90,7 +90,7 @@ def start(name: str) -> None: entry = require_profile(state, name) bin_path = require_installed_bin(state) if service_is_running(): - raise SingCliError(f"sing-box service is already running. Use 'sing restart {name}'.") + raise SingCliError("sing-box service is already running. Use 'sing restart'.") configure_service(bin_path, entry.path) start_service() state.active = name @@ -110,19 +110,19 @@ def stop() -> None: @app.command() -def restart(name: str) -> None: +def restart() -> None: try: state = load_cli_state() - entry = require_profile(state, name) + if state.active is None: + raise SingCliError("No active profile. Run 'sing start ' first.") + entry = require_profile(state, state.active) bin_path = require_installed_bin(state) stop_service() configure_service(bin_path, entry.path) start_service() - state.active = name - save_cli_state(state) except SingCliError as error: fail(error) - typer.echo(f"Restarted {name}") + typer.echo(f"Restarted {state.active}") @app.command() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c9bdb54 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,97 @@ +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from sing_cli import cli +from sing_cli.state import ProfileEntry, State, load_state, save_state + + +def write_state(tmp_path: Path, state: State) -> None: + save_state(tmp_path / "state.json", state) + + +def test_restart_uses_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + runner = CliRunner() + calls: list[tuple[str, str | None, str | None]] = [] + state = State( + bin="C:/tools/sing-box.exe", + active="home", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ) + write_state(tmp_path, state) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "stop_service", lambda: calls.append(("stop", None, None))) + monkeypatch.setattr( + cli, + "configure_service", + lambda bin_path, profile_path: calls.append(("configure", bin_path, profile_path)), + ) + monkeypatch.setattr(cli, "start_service", lambda: calls.append(("start", None, None))) + + result = runner.invoke(cli.app, ["restart"]) + + assert result.exit_code == 0 + assert result.stdout == "Restarted home\n" + assert calls == [ + ("stop", None, None), + ("configure", "C:/tools/sing-box.exe", "C:/profiles/home.json"), + ("start", None, None), + ] + assert load_state(tmp_path / "state.json") == state + + +def test_restart_rejects_profile_argument() -> None: + result = CliRunner().invoke(cli.app, ["restart", "home"]) + + assert result.exit_code != 0 + assert "Got unexpected extra argument" in result.output + + +def test_restart_requires_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state(tmp_path, State(bin="C:/tools/sing-box.exe")) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["restart"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: No active profile. Run 'sing start ' first.\n" + + +def test_restart_requires_existing_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state(tmp_path, State(bin="C:/tools/sing-box.exe", active="missing")) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["restart"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: Profile does not exist: missing\n" + + +def test_start_running_service_mentions_restart_without_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state( + tmp_path, + State( + bin="C:/tools/sing-box.exe", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "service_is_running", lambda: True) + + result = CliRunner().invoke(cli.app, ["start", "home"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: sing-box service is already running. Use 'sing restart'.\n"