├── rustfmt.toml ├── .gitignore ├── .editorconfig ├── .cargo └── config.toml ├── .pre-commit-config.yaml ├── Cargo.toml ├── LICENSE ├── CONTRIBUTING.md ├── .github └── workflows │ └── main.yaml ├── src ├── process_job.rs ├── bin │ └── shawl-child.rs ├── main.rs ├── control.rs ├── service.rs └── cli.rs ├── tasks.py ├── CHANGELOG.md ├── README.md ├── docs └── cli.md ├── tests └── integration.rs └── Cargo.lock /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | dist/ 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{json,yaml,yml}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | RUST_TEST_THREADS = "1" 3 | 4 | [target.x86_64-pc-windows-msvc] 5 | rustflags = ["-Ctarget-feature=+crt-static"] 6 | 7 | [target.i686-pc-windows-msvc] 8 | rustflags = ["-Ctarget-feature=+crt-static"] 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - repo: https://github.com/Lucas-C/pre-commit-hooks 8 | rev: v1.1.7 9 | hooks: 10 | - id: forbid-tabs 11 | - repo: https://github.com/mtkennerly/pre-commit-hooks 12 | rev: v0.2.0 13 | hooks: 14 | - id: cargo-fmt 15 | - id: cargo-clippy 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shawl" 3 | version = "1.8.0" 4 | authors = ["mtkennerly "] 5 | edition = "2021" 6 | description = "Windows service wrapper for arbitrary commands" 7 | repository = "https://github.com/mtkennerly/shawl" 8 | readme = "README.md" 9 | license = "MIT" 10 | default-run = "shawl" 11 | 12 | [dependencies] 13 | clap = { version = "4.5.20", features = ["derive", "wrap_help"] } 14 | ctrlc = "3.4.5" 15 | dunce = "1.0.5" 16 | flexi_logger = "0.29.3" 17 | log = "0.4.22" 18 | windows = { version = "0.58.0", features = [ 19 | "Win32_System_Console", 20 | "Win32_System_Threading", 21 | "Win32_System_JobObjects", 22 | "Win32_Foundation", 23 | "Win32_Security", 24 | ] } 25 | windows-service = "0.7.0" 26 | 27 | [dev-dependencies] 28 | regex = "1.11.0" 29 | speculate = "0.1.2" 30 | sysinfo = "0.31" 31 | 32 | [build-dependencies] 33 | winres = "0.1.12" 34 | 35 | [profile.release] 36 | lto = "thin" 37 | strip = true 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthew T. Kennerly (mtkennerly) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | Use the latest version of Rust. 3 | 4 | * Run tests: 5 | * `cargo test` 6 | * Activate pre-commit hooks (requires Python) to handle formatting/linting: 7 | ``` 8 | pip install --user pre-commit 9 | pre-commit install 10 | ``` 11 | * Generate docs (requires Python): 12 | ``` 13 | pip install --user invoke tomli 14 | invoke docs 15 | ``` 16 | 17 | ## Release 18 | Commands assume you are using [Git Bash](https://git-scm.com) on Windows: 19 | 20 | ### Dependencies (one-time) 21 | ```bash 22 | pip install invoke 23 | cargo install cargo-lichking 24 | ``` 25 | 26 | ### Process 27 | * Update version in `CHANGELOG.md` 28 | * Update version in `Cargo.toml` 29 | * Run `invoke prerelease` 30 | * Run `git add` for all relevant changes 31 | * Run `invoke release` 32 | * This will create a new commit/tag and push them. 33 | * Manually create a release on GitHub and attach the workflow build artifacts 34 | (plus `dist/*-legal.zip`). 35 | * Run `cargo publish` 36 | * Run `invoke release-winget` 37 | * When the script opens VSCode and pauses, 38 | manually edit `manifests/m/mtkennerly/shawl/${VERSION}/mtkennerly.shawl.locale.en-US.yaml` 39 | to add the `ReleaseNotes` and `ReleaseNotesUrl` fields: 40 | 41 | ```yaml 42 | ReleaseNotes: |- 43 | 44 | ReleaseNotesUrl: https://github.com/mtkennerly/shawl/releases/tag/v${VERSION} 45 | ``` 46 | 47 | Close the file, and the script will continue. 48 | * This will automatically push a branch to a fork of https://github.com/microsoft/winget-pkgs . 49 | * Manually open a pull request for that branch. 50 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | name: Main 6 | 7 | env: 8 | RUST_BACKTRACE: '1' 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | include: 15 | - rust-target: x86_64-pc-windows-msvc 16 | artifact-name: win64 17 | - rust-target: i686-pc-windows-msvc 18 | artifact-name: win32 19 | runs-on: windows-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.7' 27 | - uses: mtkennerly/dunamai-action@v1 28 | with: 29 | env-var: DUNAMAI_VERSION 30 | args: --style semver 31 | - uses: dtolnay/rust-toolchain@master 32 | with: 33 | toolchain: stable-${{ matrix.rust-target }} 34 | - run: cargo build --release 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: shawl-v${{ env.DUNAMAI_VERSION }}-${{ matrix.artifact-name }} 38 | path: target/release/shawl.exe 39 | 40 | test: 41 | runs-on: windows-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: dtolnay/rust-toolchain@stable 45 | - run: cargo test -- --test-threads 1 46 | 47 | lint: 48 | runs-on: windows-latest 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: actions/setup-python@v5 52 | with: 53 | python-version: '3.7' 54 | - uses: dtolnay/rust-toolchain@stable 55 | with: 56 | components: rustfmt, clippy 57 | - run: | 58 | pip install pre-commit 59 | pre-commit run --all-files --show-diff-on-failure 60 | -------------------------------------------------------------------------------- /src/process_job.rs: -------------------------------------------------------------------------------- 1 | use windows::Win32::{ 2 | Foundation::{CloseHandle, HANDLE}, 3 | System::JobObjects::{ 4 | AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject, 5 | JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 6 | }, 7 | }; 8 | 9 | use std::os::windows::io::AsRawHandle; 10 | 11 | pub struct ProcessJob { 12 | handle: HANDLE, 13 | } 14 | 15 | impl ProcessJob { 16 | /// Create a process job that kills all child processes when closed 17 | pub fn create_kill_on_close() -> Result { 18 | unsafe { 19 | let job = CreateJobObjectW(None, None)?; 20 | let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); 21 | limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; 22 | 23 | SetInformationJobObject( 24 | job, 25 | JobObjectExtendedLimitInformation, 26 | &limits as *const _ as *const _, 27 | std::mem::size_of::() as u32, 28 | )?; 29 | 30 | Ok(Self { handle: job }) 31 | } 32 | } 33 | 34 | /// Assign a child process to this job 35 | pub fn assign(&self, child: &std::process::Child) -> Result<(), windows::core::Error> { 36 | unsafe { 37 | let process_handle = HANDLE(child.as_raw_handle()); 38 | AssignProcessToJobObject(self.handle, process_handle)?; 39 | } 40 | Ok(()) 41 | } 42 | } 43 | 44 | impl Drop for ProcessJob { 45 | fn drop(&mut self) { 46 | unsafe { 47 | // Closing the job handle terminates all child processes 48 | let _ = CloseHandle(self.handle); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/bin/shawl-child.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use clap::Parser; 4 | 5 | #[derive(clap::Parser, Debug)] 6 | #[clap(name = "shawl-child", about = "Dummy program to test wrapping with Shawl")] 7 | struct Cli { 8 | /// Run forever unless forcibly killed 9 | #[clap(long)] 10 | infinite: bool, 11 | 12 | /// Exit immediately with this code 13 | #[clap(long)] 14 | exit: Option, 15 | 16 | /// Test option, prints an extra line to stdout if received 17 | #[clap(long)] 18 | test: bool, 19 | 20 | /// Spawn a grandchild process for testing process job killing 21 | #[clap(long)] 22 | spawn_grandchild: bool, 23 | } 24 | 25 | fn prepare_logging() -> Result<(), Box> { 26 | let mut exe_dir = std::env::current_exe()?; 27 | exe_dir.pop(); 28 | 29 | flexi_logger::Logger::try_with_env_or_str("debug")? 30 | .log_to_file( 31 | flexi_logger::FileSpec::default() 32 | .directory(exe_dir) 33 | .suppress_timestamp(), 34 | ) 35 | .append() 36 | .duplicate_to_stderr(flexi_logger::Duplicate::Info) 37 | .format_for_files(|w, now, record| { 38 | write!( 39 | w, 40 | "{} [{}] {}", 41 | now.now().format("%Y-%m-%d %H:%M:%S"), 42 | record.level(), 43 | &record.args() 44 | ) 45 | }) 46 | .format_for_stderr(|w, _now, record| write!(w, "[{}] {}", record.level(), &record.args())) 47 | .start()?; 48 | 49 | Ok(()) 50 | } 51 | 52 | fn main() -> Result<(), Box> { 53 | prepare_logging()?; 54 | info!("********** LAUNCH **********"); 55 | let cli = Cli::parse(); 56 | info!("{:?}", cli); 57 | info!("PATH: {}", std::env::var("PATH").unwrap()); 58 | info!("env.SHAWL_FROM_CLI: {:?}", std::env::var("SHAWL_FROM_CLI")); 59 | 60 | println!("shawl-child message on stdout"); 61 | eprintln!("shawl-child message on stderr"); 62 | 63 | if cli.spawn_grandchild { 64 | info!("Spawning grandchild process..."); 65 | 66 | let exe = std::env::current_exe()?; 67 | 68 | std::process::Command::new(&exe).arg("--infinite").spawn()?; 69 | 70 | info!("Grandchild spawned"); 71 | } 72 | if cli.test { 73 | println!("shawl-child test option received"); 74 | } 75 | 76 | if let Some(code) = cli.exit { 77 | std::process::exit(code); 78 | } 79 | 80 | let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); 81 | let running2 = running.clone(); 82 | 83 | ctrlc::set_handler(move || { 84 | if cli.infinite { 85 | info!("Ignoring ctrl-C"); 86 | } else { 87 | running2.store(false, std::sync::atomic::Ordering::SeqCst); 88 | } 89 | })?; 90 | 91 | while running.load(std::sync::atomic::Ordering::SeqCst) { 92 | std::thread::sleep(std::time::Duration::from_millis(500)); 93 | info!("Looping!"); 94 | } 95 | 96 | info!("End"); 97 | Ok(()) 98 | } 99 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | import zipfile 4 | from pathlib import Path 5 | 6 | import tomli 7 | from invoke import task 8 | 9 | ROOT = Path(__file__).parent 10 | DIST = ROOT / "dist" 11 | 12 | 13 | def get_version() -> str: 14 | manifest = ROOT / "Cargo.toml" 15 | return tomli.loads(manifest.read_bytes().decode("utf-8"))["package"]["version"] 16 | 17 | 18 | @task 19 | def clean(ctx): 20 | if DIST.exists(): 21 | shutil.rmtree(DIST, ignore_errors=True) 22 | DIST.mkdir() 23 | 24 | 25 | @task 26 | def legal(ctx): 27 | version = get_version() 28 | txt_name = f"shawl-v{version}-legal.txt" 29 | txt_path = DIST / txt_name 30 | try: 31 | ctx.run(f'cargo lichking bundle --file "{txt_path}"', hide=True) 32 | except Exception: 33 | pass 34 | raw = txt_path.read_text("utf8") 35 | normalized = re.sub(r"C:\\Users\\[^\\]+", "~", raw) 36 | txt_path.write_text(normalized, "utf8") 37 | 38 | zip_path = DIST / f"shawl-v{version}-legal.zip" 39 | with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip: 40 | zip.write(txt_path, txt_name) 41 | 42 | 43 | @task 44 | def docs(ctx): 45 | docs = Path(__file__).parent / "docs" 46 | cli = docs / "cli.md" 47 | 48 | commands = [ 49 | "--help", 50 | "add --help", 51 | "run --help", 52 | ] 53 | 54 | lines = [ 55 | "This is the raw help text for the command line interface.", 56 | ] 57 | for command in commands: 58 | output = ctx.run(f"cargo run -- {command}") 59 | lines.append("") 60 | lines.append(f"## `{command}`") 61 | lines.append("```") 62 | for line in output.stdout.splitlines(): 63 | lines.append(line.rstrip()) 64 | lines.append("```") 65 | 66 | if not docs.exists(): 67 | docs.mkdir() 68 | cli.unlink() 69 | with cli.open("a") as f: 70 | for line in lines: 71 | f.write(line + "\n") 72 | 73 | 74 | @task 75 | def prerelease(ctx): 76 | # Make sure that the lock file has the new version 77 | ctx.run("cargo build") 78 | 79 | clean(ctx) 80 | legal(ctx) 81 | docs(ctx) 82 | 83 | 84 | @task 85 | def release(ctx): 86 | version = get_version() 87 | ctx.run(f'git commit -m "Release v{version}"') 88 | ctx.run(f'git tag v{version} -m "Release"') 89 | ctx.run("git push") 90 | ctx.run("git push --tags") 91 | 92 | 93 | @task 94 | def release_winget(ctx, target="/git/_forks/winget-pkgs"): 95 | target = Path(target) 96 | version = get_version() 97 | 98 | with ctx.cd(target): 99 | ctx.run("git checkout master") 100 | ctx.run("git pull upstream master") 101 | ctx.run(f"git checkout -b mtkennerly.shawl-{version}") 102 | ctx.run(f"wingetcreate update mtkennerly.shawl --version {version} --urls https://github.com/mtkennerly/shawl/releases/download/v{version}/shawl-v{version}-win64.zip https://github.com/mtkennerly/shawl/releases/download/v{version}/shawl-v{version}-win32.zip") 103 | ctx.run(f"code --wait manifests/m/mtkennerly/shawl/{version}/mtkennerly.shawl.locale.en-US.yaml") 104 | ctx.run(f"winget validate --manifest manifests/m/mtkennerly/shawl/{version}") 105 | ctx.run("git add .") 106 | ctx.run(f'git commit -m "mtkennerly.shawl version {version}"') 107 | ctx.run("git push origin HEAD") 108 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.8.0 (2025-12-08) 2 | 3 | * Added: `--kill-process-tree` option to ensure child processes are also terminated. 4 | (Contributed by [nitaysol](https://github.com/mtkennerly/shawl/pull/71)) 5 | 6 | ## v1.7.0 (2025-01-16) 7 | 8 | * Added: `--restart-delay` option. 9 | 10 | ## v1.6.0 (2024-11-16) 11 | 12 | * Added: `--path-prepend` option. 13 | 14 | ## v1.5.1 (2024-10-14) 15 | 16 | * Fixed: Old log files were not deleted when stored on a Windows network share. 17 | 18 | ## v1.5.0 (2024-03-02) 19 | 20 | * Fixed: Local UNC paths were only simplified for the C drive. 21 | * Added: `shawl --version` to display the program version. 22 | * Changed: Help text is now styled a bit differently. 23 | 24 | ## v1.4.0 (2023-12-04) 25 | 26 | * Added: `--log-rotate` option to control how often the log file rotates. 27 | * Added: `--log-retain` option to control how many old log files are retained. 28 | * Added: `--log-as` option to change the base name of the main log file. 29 | * Added: `--log-cmd-as` option to log the wrapped command's stdout/stderr in a separate file. 30 | 31 | ## v1.3.0 (2023-10-01) 32 | 33 | * Fixed: The path to the Shawl executable was not quoted when it contained spaces. 34 | * Added: `--priority` option to set the process priority. 35 | * Added: `--dependencies` option for `add` command to specify services as dependencies. 36 | 37 | ## v1.2.1 (2023-08-10) 38 | 39 | * Fixed: Possible case in which old log files would not be deleted. 40 | (Contributed by [Luokun2016](https://github.com/mtkennerly/shawl/pull/33)) 41 | * Added: Some guidance in the README related to security. 42 | (Contributed by [kenvix](https://github.com/mtkennerly/shawl/pull/32)) 43 | 44 | ## v1.2.0 (2023-05-19) 45 | 46 | * Fixed: When both `--cwd` and `--path` were specified, 47 | they would both try to update the command's `PATH` environment variable, 48 | but the changes from `--cwd` would override the changes from `--path`. 49 | * Changed: When using `--cwd` and `--path`, Shawl now simplifies local UNC paths. 50 | For example, `\\?\C:\tmp` becomes `C:\tmp`. 51 | Some programs, notably Command Prompt, don't like UNC paths, so this is intended to broaden compatibility. 52 | * Changed: The CLI output now uses a prettier format, including color. 53 | 54 | ## v1.1.1 (2022-09-16) 55 | 56 | * Fixed `--pass`, `--restart-if`, and `--restart-if-not` not allowing a leading negative number. 57 | * Fixed `--pass`, `--restart-if`, and `--restart-if-not` not requiring a value. 58 | * Fixed `--no-restart`, `--restart-if`, and `--restart-if-not` not being marked as mutually exclusive. 59 | They had only been marked as exclusive with `--restart`. 60 | 61 | ## v1.1.0 (2022-01-18) 62 | 63 | * Added version to executable properties. 64 | * Added `--log-dir`. 65 | (Contributed by [oscarbailey-tc](https://github.com/mtkennerly/shawl/pull/19)) 66 | * Added `--env`. 67 | * Added `--path`. 68 | * When a custom `--cwd` is set, it is now automatically added to the command's 69 | PATH to make it easier to write some commands. Specifically, assuming there 70 | is a `C:\foo\bar\baz.exe`, then `--cwd C:\foo\bar -- baz.exe` will work now, 71 | but `--cwd C:\foo -- bar\baz.exe` still will not work, because the PATH only 72 | helps to resolve executable names, not subfolder names. 73 | 74 | ## v1.0.0 (2021-05-20) 75 | 76 | * Shawl now handles computer shutdown/restart, allowing the wrapped program 77 | to exit gracefully. 78 | 79 | ## v0.6.2 (2021-03-09) 80 | 81 | * Fixed an issue introduced in v0.6.1 where the 32-bit executable was not 82 | usable on 32-bit systems. 83 | * Changed build process to avoid potential "VCRUNTIME140_1.dll was not found" 84 | error when using the program. 85 | 86 | ## v0.6.1 (2020-12-22) 87 | 88 | * Updated `windows-service` dependency to avoid a build failure where 89 | `err-derive` would use a private symbol from `quote`. 90 | 91 | ## v0.6.0 (2020-03-22) 92 | 93 | * Added `--pass-start-args`. 94 | (Contributed by [Enet4](https://github.com/mtkennerly/shawl/pull/6)) 95 | * Added log rotation and service-specific log files. 96 | 97 | ## v0.5.0 (2020-03-03) 98 | 99 | * Added logging of stdout and stderr from commands. 100 | * Added `--no-log` and `--no-log-cmd` options to configure logging. 101 | 102 | ## v0.4.0 (2019-10-05) 103 | 104 | * Added `--cwd` for setting the command's working directory. 105 | * Set default help text width to 80 characters. 106 | * Fixed issue where Shawl would not report an error if it was unable to 107 | launch the command (e.g., file not found). 108 | * Fixed missing quotes when adding a service if the name or any part of 109 | the command contained inner spaces. 110 | * Fixed `--pass` and `--stop-timeout` being added to the service command 111 | configured by `shawl add` even when not explicitly set. 112 | 113 | ## v0.3.0 (2019-09-30) 114 | 115 | * Added `shawl add` for quickly creating a Shawl-wrapped service. 116 | * Moved existing CLI functionality under `shawl run`. 117 | * Generalized `--restart-ok` and `--no-restart-err` into 118 | `--(no-)restart` and `--restart-if(-not)`. 119 | * Added `--pass` to customize which exit codes are considered successful. 120 | 121 | ## v0.2.0 (2019-09-22) 122 | 123 | * Send ctrl-C to child process first instead of always forcibly killing it. 124 | * Report command failure as a service-specific error to Windows. 125 | * Added `--stop-timeout` option. 126 | 127 | ## v0.1.0 (2019-09-22) 128 | 129 | * Initial release. 130 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod control; 3 | #[cfg(windows)] 4 | mod process_job; 5 | #[cfg(windows)] 6 | mod service; 7 | 8 | use crate::cli::{evaluate_cli, Subcommand}; 9 | use log::{debug, error}; 10 | 11 | /// Simplify local UNC paths since some programs (notably cmd.exe) don't like them. 12 | pub fn simplify_path(path: &str) -> String { 13 | dunce::simplified(std::path::Path::new(path)) 14 | .to_string_lossy() 15 | .to_string() 16 | } 17 | 18 | fn prepare_logging( 19 | name: &str, 20 | log_dir: Option<&String>, 21 | console: bool, 22 | rotation: cli::LogRotation, 23 | retention: usize, 24 | log_as: Option<&String>, 25 | log_cmd_as: Option<&String>, 26 | ) -> Result<(), Box> { 27 | let mut exe_dir = std::env::current_exe()?; 28 | exe_dir.pop(); 29 | 30 | let log_dir = simplify_path(&match log_dir { 31 | Some(log_dir) => log_dir.to_string(), 32 | None => exe_dir.to_string_lossy().to_string(), 33 | }); 34 | 35 | let rotation = match rotation { 36 | cli::LogRotation::Bytes(bytes) => flexi_logger::Criterion::Size(bytes), 37 | cli::LogRotation::Daily => flexi_logger::Criterion::Age(flexi_logger::Age::Day), 38 | cli::LogRotation::Hourly => flexi_logger::Criterion::Age(flexi_logger::Age::Hour), 39 | }; 40 | 41 | let mut logger = flexi_logger::Logger::try_with_env_or_str("debug")? 42 | .log_to_file({ 43 | let spec = flexi_logger::FileSpec::default().directory(log_dir.clone()); 44 | 45 | if let Some(log_as) = log_as { 46 | spec.basename(log_as) 47 | } else { 48 | spec.discriminant(format!("for_{}", name)) 49 | } 50 | }) 51 | .append() 52 | .rotate( 53 | rotation, 54 | flexi_logger::Naming::Timestamps, 55 | flexi_logger::Cleanup::KeepLogFiles(retention), 56 | ) 57 | .format_for_files(|w, now, record| { 58 | write!( 59 | w, 60 | "{} [{}] {}", 61 | now.now().format("%Y-%m-%d %H:%M:%S"), 62 | record.level(), 63 | &record.args() 64 | ) 65 | }) 66 | .format_for_stderr(|w, _now, record| write!(w, "[{}] {}", record.level(), &record.args())); 67 | 68 | if console { 69 | logger = logger.duplicate_to_stderr(flexi_logger::Duplicate::Info); 70 | } 71 | 72 | if let Some(log_cmd_as) = log_cmd_as { 73 | logger = logger.add_writer( 74 | "shawl-cmd", 75 | Box::new( 76 | flexi_logger::writers::FileLogWriter::builder( 77 | flexi_logger::FileSpec::default() 78 | .directory(log_dir) 79 | .basename(log_cmd_as), 80 | ) 81 | .append() 82 | .rotate( 83 | rotation, 84 | flexi_logger::Naming::Timestamps, 85 | flexi_logger::Cleanup::KeepLogFiles(retention), 86 | ) 87 | .format(|w, _now, record| write!(w, "{}", &record.args())) 88 | .try_build()?, 89 | ), 90 | ); 91 | } 92 | 93 | logger.start()?; 94 | Ok(()) 95 | } 96 | 97 | #[cfg(windows)] 98 | fn main() -> Result<(), Box> { 99 | let cli = evaluate_cli(); 100 | let console = !matches!(cli.sub, Subcommand::Run { .. }); 101 | 102 | let should_log = match cli.clone().sub { 103 | Subcommand::Add { common: opts, .. } => !opts.no_log, 104 | Subcommand::Run { common: opts, .. } => !opts.no_log, 105 | }; 106 | if should_log { 107 | let (name, common) = match &cli.sub { 108 | Subcommand::Add { name, common, .. } | Subcommand::Run { name, common, .. } => (name, common), 109 | }; 110 | prepare_logging( 111 | name, 112 | common.log_dir.as_ref(), 113 | console, 114 | common.log_rotate.unwrap_or_default(), 115 | common.log_retain.unwrap_or(2), 116 | common.log_as.as_ref(), 117 | common.log_cmd_as.as_ref(), 118 | )?; 119 | } 120 | 121 | debug!("********** LAUNCH **********"); 122 | debug!("{:?}", cli); 123 | 124 | match cli.sub { 125 | Subcommand::Add { 126 | name, 127 | cwd, 128 | dependencies, 129 | common: opts, 130 | } => match control::add_service(name, cwd, &dependencies, opts) { 131 | Ok(_) => (), 132 | Err(_) => std::process::exit(1), 133 | }, 134 | Subcommand::Run { name, .. } => match service::run(name) { 135 | Ok(_) => (), 136 | Err(e) => { 137 | error!("Failed to run the service:\n{:#?}", e); 138 | // We wouldn't have a console if the Windows service manager 139 | // ran this, but if we failed here, then it's likely the user 140 | // tried to run it directly, so try showing them the error: 141 | println!("Failed to run the service:\n{:#?}", e); 142 | std::process::exit(1) 143 | } 144 | }, 145 | } 146 | debug!("Finished successfully"); 147 | Ok(()) 148 | } 149 | 150 | #[cfg(not(windows))] 151 | fn main() { 152 | panic!("This program is only intended to run on Windows."); 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shawl 2 | 3 | Shawl is a wrapper for running arbitrary programs as Windows services, written in Rust. 4 | It handles the Windows service API for you 5 | so that your program only needs to respond to ctrl-C/SIGINT. 6 | If you're creating a project that needs to run as a service, 7 | simply bundle Shawl with your project, set it as the entry point, 8 | and pass the command to run via CLI. 9 | 10 | ## Installation 11 | * Prebuilt binaries are available on the 12 | [releases page](https://github.com/mtkennerly/shawl/releases). 13 | It's portable, so you can simply download it and put it anywhere 14 | without going through an installer. 15 | * If you have Rust installed, you can run `cargo install --locked shawl`. 16 | * If you have [Scoop](https://scoop.sh): 17 | * To install: `scoop bucket add extras && scoop install shawl` 18 | * To update: `scoop update && scoop update shawl` 19 | * If you have [Winget](https://github.com/microsoft/winget-cli). 20 | * To install: `winget install -e --id mtkennerly.shawl` 21 | * To update: `winget upgrade -e --id mtkennerly.shawl` 22 | 23 | ## Usage 24 | Here is an example of creating a service wrapped with Shawl 25 | (note that `--` separates Shawl's own options from the command that you'd like it to run): 26 | 27 | * Using Shawl's `add` command: 28 | * `shawl add --name my-app -- C:/path/my-app.exe` 29 | * Using the Windows `sc` command for more control: 30 | * `sc create my-app binPath= "C:/path/shawl.exe run --name my-app -- C:/path/my-app.exe"` 31 | * Then start or configure the service as normal: 32 | * ``` 33 | sc config my-app start= auto 34 | sc start my-app 35 | ``` 36 | 37 | Shawl will inspect the state of your program in order to report the correct status to Windows: 38 | 39 | * By default, when your program exits, Shawl will restart it if the exit code is nonzero. 40 | You can customize this behavior with `--(no-)restart` for all exit codes 41 | or `--restart-if(-not)` for specific exit codes. 42 | Note that these four options are mutually exclusive. 43 | * When the service is requested to stop, Shawl sends your program a ctrl-C event, 44 | then waits up to 3000 milliseconds (based on `--stop-timeout`) 45 | before forcibly killing the process if necessary. 46 | * In either case, if Shawl is not restarting your program, 47 | then it reports the exit code to Windows as a service-specific error, 48 | unless the exit code is 0 or a code you've configured with `--pass`. 49 | 50 | ### CLI 51 | You can view the full command line help text in [docs/cli.md](./docs/cli.md). 52 | 53 | ### Logging 54 | Shawl creates a log file for each service, 55 | `shawl_for__*.log` (based on the `--name`), 56 | in the same location as the Shawl executable, 57 | with both its own messages and the output from the commands that it runs. 58 | If anything goes wrong, you can read the log to find out more. 59 | You can disable all logging with `--no-log`, 60 | and you can disable just the command logs with `--no-log-cmd`. 61 | By default, each log file is limited to 2 MB, and up to 2 rotated copies will be retained. 62 | 63 | ### Accounts 64 | Bear in mind that the default account for new services is the Local System account, 65 | which has a different `PATH` environment variable than your user account. 66 | If you configure Shawl to run a command like `npm start`, 67 | that means `npm` needs to be in the Local System account's `PATH`, 68 | or you could also change the account used by the service instead. 69 | 70 | Also note that running a service with a Local System account is as **dangerous** as running a Unix service as root. 71 | This greatly increases the risk of your system being hacked 72 | if you expose a port to the public for the service you are going to wrap. 73 | It is recommended that you use a restricted account, such as 74 | [Network Service](https://learn.microsoft.com/en-us/windows/win32/services/networkservice-account), 75 | to run services. 76 | To do this, first grant the Network Service account read, write, and execute permissions on Shawl's installation directory, 77 | and then execute `sc config my-app obj= "NT AUTHORITY\Network Service" password= ""`. 78 | If the service needs to read and write files, 79 | you may also need to grant the Network Service permissions to the directory that the service wants to access. 80 | More information about Windows service user accounts [can be found here](https://stackoverflow.com/questions/510170). 81 | 82 | ### Working directory 83 | The default working directory for Windows services is `C:\Windows\System32`. 84 | You may want to take the precaution of setting a different working directory 85 | by using Shawl's `--cwd` option. 86 | 87 | ### Recovery 88 | If you want to use the service recovery feature of Windows itself 89 | when Shawl gives up trying to restart the wrapped command, 90 | then make sure to turn on the "enable actions for stops with errors" option in the service properties. 91 | 92 | ## Comparison with other tools 93 | Shawl differs from existing solutions like 94 | [WinSW](https://github.com/kohsuke/winsw) and [NSSM](https://nssm.cc) 95 | in that they require running a special install command to prepare the service. 96 | That can be inconvenient in cases like installing a service via an MSI, 97 | where you would need to run a `CustomAction`. 98 | With Shawl, you can configure the service however you want, 99 | such as with the normal MSI `ServiceInstall` or by running `sc create`, 100 | because Shawl doesn't have any special setup of its own. 101 | The `shawl add` command is just an optional convenience. 102 | 103 | ## Development 104 | Please refer to [CONTRIBUTING.md](CONTRIBUTING.md). 105 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | This is the raw help text for the command line interface. 2 | 3 | ## `--help` 4 | ``` 5 | Wrap arbitrary commands as Windows services 6 | 7 | Usage: shawl.exe 8 | shawl.exe 9 | 10 | Commands: 11 | add 12 | Add a new service 13 | run 14 | Run a command as a service; only works when launched by the Windows service manager 15 | help 16 | Print this message or the help of the given subcommand(s) 17 | 18 | Options: 19 | -h, --help 20 | Print help 21 | -V, --version 22 | Print version 23 | ``` 24 | 25 | ## `add --help` 26 | ``` 27 | Add a new service 28 | 29 | Usage: shawl.exe add [OPTIONS] --name -- ... 30 | 31 | Arguments: 32 | ... 33 | Command to run as a service 34 | 35 | Options: 36 | --pass 37 | Exit codes that should be considered successful (comma-separated) [default: 0] 38 | --restart 39 | Always restart the command regardless of the exit code 40 | --no-restart 41 | Never restart the command regardless of the exit code 42 | --restart-if 43 | Restart the command if the exit code is one of these (comma-separated) 44 | --restart-if-not 45 | Restart the command if the exit code is not one of these (comma-separated) 46 | --restart-delay 47 | How long to wait before restarting the wrapped process 48 | --stop-timeout 49 | How long to wait in milliseconds between sending the wrapped process a ctrl-C event and 50 | forcibly killing it [default: 3000] 51 | --no-log 52 | Disable all of Shawl's logging 53 | --no-log-cmd 54 | Disable logging of output from the command running as a service 55 | --log-dir 56 | Write log file to a custom directory. This directory will be created if it doesn't exist 57 | --log-as 58 | Use a different name for the main log file. Set this to just the desired base name of the 59 | log file. For example, `--log-as shawl` would result in a log file named 60 | `shawl_rCURRENT.log` instead of the normal `shawl_for__rCURRENT.log` pattern 61 | --log-cmd-as 62 | Use a separate log file for the wrapped command's stdout and stderr. Set this to just the 63 | desired base name of the log file. For example, `--log-cmd-as foo` would result in a log 64 | file named `foo_rCURRENT.log`. The output will be logged as-is without any additional log 65 | template 66 | --log-rotate 67 | Threshold for rotating log files. Valid options: `daily`, `hourly`, `bytes=n` (every N 68 | bytes) [default: bytes=2097152] 69 | --log-retain 70 | How many old log files to retain [default: 2] 71 | --pass-start-args 72 | Append the service start arguments to the command 73 | --env 74 | Additional environment variable in the format 'KEY=value' (repeatable) 75 | --path 76 | Additional directory to append to the PATH environment variable (repeatable) 77 | --path-prepend 78 | Additional directory to prepend to the PATH environment variable (repeatable) 79 | --priority 80 | Process priority of the command to run as a service [possible values: realtime, high, 81 | above-normal, normal, below-normal, idle] 82 | --kill-process-tree 83 | Kill the entire process tree when the service stops. Uses a Windows Job Object to track 84 | and terminate all child processes automatically 85 | --cwd 86 | Working directory in which to run the command. You may provide a relative path, and it 87 | will be converted to an absolute one 88 | --dependencies 89 | Other services that must be started first (comma-separated) 90 | --name 91 | Name of the service to create 92 | -h, --help 93 | Print help 94 | ``` 95 | 96 | ## `run --help` 97 | ``` 98 | Run a command as a service; only works when launched by the Windows service manager 99 | 100 | Usage: shawl.exe run [OPTIONS] -- ... 101 | 102 | Arguments: 103 | ... 104 | Command to run as a service 105 | 106 | Options: 107 | --pass 108 | Exit codes that should be considered successful (comma-separated) [default: 0] 109 | --restart 110 | Always restart the command regardless of the exit code 111 | --no-restart 112 | Never restart the command regardless of the exit code 113 | --restart-if 114 | Restart the command if the exit code is one of these (comma-separated) 115 | --restart-if-not 116 | Restart the command if the exit code is not one of these (comma-separated) 117 | --restart-delay 118 | How long to wait before restarting the wrapped process 119 | --stop-timeout 120 | How long to wait in milliseconds between sending the wrapped process a ctrl-C event and 121 | forcibly killing it [default: 3000] 122 | --no-log 123 | Disable all of Shawl's logging 124 | --no-log-cmd 125 | Disable logging of output from the command running as a service 126 | --log-dir 127 | Write log file to a custom directory. This directory will be created if it doesn't exist 128 | --log-as 129 | Use a different name for the main log file. Set this to just the desired base name of the 130 | log file. For example, `--log-as shawl` would result in a log file named 131 | `shawl_rCURRENT.log` instead of the normal `shawl_for__rCURRENT.log` pattern 132 | --log-cmd-as 133 | Use a separate log file for the wrapped command's stdout and stderr. Set this to just the 134 | desired base name of the log file. For example, `--log-cmd-as foo` would result in a log 135 | file named `foo_rCURRENT.log`. The output will be logged as-is without any additional log 136 | template 137 | --log-rotate 138 | Threshold for rotating log files. Valid options: `daily`, `hourly`, `bytes=n` (every N 139 | bytes) [default: bytes=2097152] 140 | --log-retain 141 | How many old log files to retain [default: 2] 142 | --pass-start-args 143 | Append the service start arguments to the command 144 | --env 145 | Additional environment variable in the format 'KEY=value' (repeatable) 146 | --path 147 | Additional directory to append to the PATH environment variable (repeatable) 148 | --path-prepend 149 | Additional directory to prepend to the PATH environment variable (repeatable) 150 | --priority 151 | Process priority of the command to run as a service [possible values: realtime, high, 152 | above-normal, normal, below-normal, idle] 153 | --kill-process-tree 154 | Kill the entire process tree when the service stops. Uses a Windows Job Object to track 155 | and terminate all child processes automatically 156 | --cwd 157 | Working directory in which to run the command. Must be an absolute path 158 | --name 159 | Name of the service; used in logging, but does not need to match real name [default: 160 | Shawl] 161 | -h, --help 162 | Print help 163 | ``` 164 | -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all(test, windows))] 2 | speculate::speculate! { 3 | fn parent() -> String { 4 | format!("{}/target/debug/shawl.exe", env!("CARGO_MANIFEST_DIR")) 5 | } 6 | 7 | fn child() -> String { 8 | format!("{}/target/debug/shawl-child.exe", env!("CARGO_MANIFEST_DIR")) 9 | } 10 | 11 | fn log_file() -> String { 12 | format!("{}/target/debug/shawl_for_shawl_rCURRENT.log", env!("CARGO_MANIFEST_DIR")) 13 | } 14 | 15 | fn log_file_custom_dir() -> String { 16 | format!("{}/target/debug/log_dir/shawl_for_shawl_rCURRENT.log", env!("CARGO_MANIFEST_DIR")) 17 | } 18 | 19 | fn log_custom_dir() -> String { 20 | format!("{}/target/debug/log_dir", env!("CARGO_MANIFEST_DIR")) 21 | } 22 | 23 | fn delete_log() { 24 | if log_exists() { 25 | std::fs::remove_file(log_file()).unwrap(); 26 | } 27 | if std::path::Path::new(&log_custom_dir()).is_dir() { 28 | std::fs::remove_dir_all(log_custom_dir()).unwrap(); 29 | } 30 | } 31 | 32 | fn log_exists() -> bool { 33 | std::path::Path::new(&log_file()).exists() 34 | } 35 | 36 | fn run_cmd(args: &[&str]) -> std::process::Output { 37 | let out = std::process::Command::new(args[0]) 38 | .args(args[1..].iter()) 39 | .output() 40 | .unwrap(); 41 | std::thread::sleep(std::time::Duration::from_secs(1)); 42 | out 43 | } 44 | 45 | fn run_shawl(args: &[&str]) -> std::process::Output { 46 | let out = std::process::Command::new(parent()) 47 | .args(args) 48 | .output() 49 | .unwrap(); 50 | std::thread::sleep(std::time::Duration::from_secs(1)); 51 | out 52 | } 53 | 54 | before { 55 | run_cmd(&["sc", "stop", "shawl"]); 56 | run_cmd(&["sc", "delete", "shawl"]); 57 | delete_log(); 58 | } 59 | 60 | after { 61 | run_cmd(&["sc", "stop", "shawl"]); 62 | run_cmd(&["sc", "delete", "shawl"]); 63 | } 64 | 65 | describe "shawl add" { 66 | it "works with minimal arguments" { 67 | let shawl_output = run_shawl(&["add", "--name", "shawl", "--", &child()]); 68 | assert_eq!(shawl_output.status.code(), Some(0)); 69 | 70 | let sc_output = run_cmd(&["sc", "qc", "shawl"]); 71 | let pattern = regex::Regex::new( 72 | r"BINARY_PATH_NAME *: .+shawl\.exe run --name shawl -- .+shawl-child\.exe" 73 | ).unwrap(); 74 | assert!(pattern.is_match(&String::from_utf8_lossy(&sc_output.stdout))); 75 | } 76 | 77 | it "handles command parts with spaces" { 78 | let shawl_output = run_shawl(&["add", "--name", "shawl", "--", "foo bar", "--baz"]); 79 | assert_eq!(shawl_output.status.code(), Some(0)); 80 | 81 | let sc_output = run_cmd(&["sc", "qc", "shawl"]); 82 | let pattern = regex::Regex::new( 83 | r#"BINARY_PATH_NAME *: .+shawl\.exe run --name shawl -- "foo bar" --baz"# 84 | ).unwrap(); 85 | assert!(pattern.is_match(&String::from_utf8_lossy(&sc_output.stdout))); 86 | } 87 | 88 | it "rejects nonexistent --cwd path" { 89 | let shawl_output = run_shawl(&["add", "--name", "shawl", "--cwd", "shawl-fake", "--", &child()]); 90 | assert_eq!(shawl_output.status.code(), Some(2)); 91 | } 92 | } 93 | 94 | describe "shawl run" { 95 | it "handles a successful command" { 96 | run_shawl(&["add", "--name", "shawl", "--", &child()]); 97 | run_cmd(&["sc", "start", "shawl"]); 98 | run_cmd(&["sc", "stop", "shawl"]); 99 | 100 | let sc_output = run_cmd(&["sc", "query", "shawl"]); 101 | let stdout = String::from_utf8_lossy(&sc_output.stdout); 102 | 103 | assert!(stdout.contains("STATE : 1 STOPPED")); 104 | assert!(stdout.contains("WIN32_EXIT_CODE : 0 (0x0)")); 105 | } 106 | 107 | it "reports a --pass code as success" { 108 | run_shawl(&["add", "--name", "shawl", "--pass", "1", "--", &child(), "--exit", "1"]); 109 | run_cmd(&["sc", "start", "shawl"]); 110 | run_cmd(&["sc", "stop", "shawl"]); 111 | 112 | let sc_output = run_cmd(&["sc", "query", "shawl"]); 113 | let stdout = String::from_utf8_lossy(&sc_output.stdout); 114 | 115 | assert!(stdout.contains("STATE : 1 STOPPED")); 116 | assert!(stdout.contains("WIN32_EXIT_CODE : 0 (0x0)")); 117 | } 118 | 119 | it "reports a service-specific error for a failing command" { 120 | run_shawl(&["add", "--name", "shawl", "--", &child(), "--exit", "7"]); 121 | run_cmd(&["sc", "start", "shawl"]); 122 | run_cmd(&["sc", "stop", "shawl"]); 123 | 124 | let sc_output = run_cmd(&["sc", "query", "shawl"]); 125 | let stdout = String::from_utf8_lossy(&sc_output.stdout); 126 | 127 | assert!(stdout.contains("STATE : 1 STOPPED")); 128 | assert!(stdout.contains("WIN32_EXIT_CODE : 1066 (0x42a)")); 129 | assert!(stdout.contains("SERVICE_EXIT_CODE : 7 (0x7)")); 130 | } 131 | 132 | it "handles a command that times out on stop" { 133 | run_shawl(&["add", "--name", "shawl", "--", &child(), "--infinite"]); 134 | run_cmd(&["sc", "start", "shawl"]); 135 | run_cmd(&["sc", "stop", "shawl"]); 136 | std::thread::sleep(std::time::Duration::from_secs(4)); 137 | 138 | let sc_output = run_cmd(&["sc", "query", "shawl"]); 139 | let stdout = String::from_utf8_lossy(&sc_output.stdout); 140 | println!(">>>>>>> {}", stdout); 141 | 142 | assert!(stdout.contains("STATE : 1 STOPPED")); 143 | assert!(stdout.contains("WIN32_EXIT_CODE : 0 (0x0)")); 144 | } 145 | 146 | it "logs command output by default" { 147 | run_shawl(&["add", "--name", "shawl", "--", &child()]); 148 | run_cmd(&["sc", "start", "shawl"]); 149 | run_cmd(&["sc", "stop", "shawl"]); 150 | 151 | let log = std::fs::read_to_string(log_file()).unwrap(); 152 | assert!(log.contains("stdout: \"shawl-child message on stdout\"")); 153 | assert!(log.contains("stderr: \"shawl-child message on stderr\"")); 154 | } 155 | 156 | it "disables all logging with --no-log" { 157 | run_shawl(&["add", "--name", "shawl", "--no-log", "--", &child()]); 158 | run_cmd(&["sc", "start", "shawl"]); 159 | run_cmd(&["sc", "stop", "shawl"]); 160 | 161 | assert!(!log_exists()); 162 | } 163 | 164 | it "disables command logging with --no-log-cmd" { 165 | run_shawl(&["add", "--name", "shawl", "--no-log-cmd", "--", &child()]); 166 | run_cmd(&["sc", "start", "shawl"]); 167 | run_cmd(&["sc", "stop", "shawl"]); 168 | 169 | let log = std::fs::read_to_string(log_file()).unwrap(); 170 | assert!(!log.contains("shawl-child message on stdout")); 171 | } 172 | 173 | it "creates log file in custom dir with --log-dir" { 174 | run_shawl(&["add", "--name", "shawl", "--log-dir", &log_custom_dir(), "--", &child()]); 175 | run_cmd(&["sc", "start", "shawl"]); 176 | run_cmd(&["sc", "stop", "shawl"]); 177 | 178 | let log = std::fs::read_to_string(log_file_custom_dir()).unwrap(); 179 | assert!(log.contains("shawl-child message on stdout")); 180 | assert!(!log_exists()); // Ensure log file hasn't been created next to the .exe 181 | } 182 | 183 | it "can pass arguments through successfully" { 184 | run_shawl(&["add", "--name", "shawl", "--pass-start-args", "--", &child()]); 185 | run_cmd(&["sc", "start", "shawl", "--test"]); 186 | run_cmd(&["sc", "stop", "shawl"]); 187 | 188 | let log = std::fs::read_to_string(log_file()).unwrap(); 189 | assert!(log.contains("stdout: \"shawl-child test option received\"")); 190 | } 191 | 192 | it "can resolve relative commands with bare executable name and --cwd" { 193 | std::fs::create_dir(log_custom_dir()).unwrap(); 194 | std::fs::copy(child(), format!("{}/shawl-child-copy.exe", log_custom_dir())).unwrap(); 195 | 196 | run_shawl(&["add", "--name", "shawl", "--cwd", &log_custom_dir(), "--", "shawl-child-copy.exe"]); 197 | run_cmd(&["sc", "start", "shawl"]); 198 | run_cmd(&["sc", "stop", "shawl"]); 199 | 200 | let log = std::fs::read_to_string(log_file()).unwrap(); 201 | // Example log content, without escaping: "PATH: C:\tmp;\\?\C:\git\shawl\target" 202 | let pattern = regex::Regex::new( 203 | &format!(r#"PATH: .+{}"#, &log_custom_dir().replace('/', "\\").replace('\\', "\\\\\\\\")) 204 | ).unwrap(); 205 | assert!(pattern.is_match(&log)); 206 | } 207 | 208 | it "adds directories to the PATH from --path" { 209 | std::fs::create_dir(log_custom_dir()).unwrap(); 210 | let extra_path = env!("CARGO_MANIFEST_DIR"); 211 | 212 | run_shawl(&["add", "--name", "shawl", "--path", extra_path, "--", &child()]); 213 | run_cmd(&["sc", "start", "shawl"]); 214 | run_cmd(&["sc", "stop", "shawl"]); 215 | 216 | let log = std::fs::read_to_string(log_file()).unwrap(); 217 | // Example log content, without escaping: "PATH: C:\tmp;\\?\C:\git\shawl\target" 218 | let pattern = regex::Regex::new( 219 | &format!(r#"PATH: .+{}"#, &extra_path.replace('/', "\\").replace('\\', "\\\\\\\\")) 220 | ).unwrap(); 221 | assert!(pattern.is_match(&log)); 222 | } 223 | 224 | it "loads environment variables from --env" { 225 | run_shawl(&["add", "--name", "shawl", "--env", "SHAWL_FROM_CLI=custom value", "--", &child()]); 226 | run_cmd(&["sc", "start", "shawl"]); 227 | run_cmd(&["sc", "stop", "shawl"]); 228 | 229 | let log = std::fs::read_to_string(log_file()).unwrap(); 230 | let pattern = regex::Regex::new( 231 | r#"env\.SHAWL_FROM_CLI: Ok\(\\"custom value\\"\)"# 232 | ).unwrap(); 233 | assert!(pattern.is_match(&log)); 234 | } 235 | 236 | it "waits between restarts with --restart-delay" { 237 | run_shawl(&["add", "--name", "shawl", "--restart-delay", "180", "--env", "RUST_LOG=shawl=debug", "--", &child(), "--exit", "1"]); 238 | run_cmd(&["sc", "start", "shawl"]); 239 | std::thread::sleep(std::time::Duration::from_millis(500)); 240 | run_cmd(&["sc", "stop", "shawl"]); 241 | 242 | let log = std::fs::read_to_string(log_file()).unwrap(); 243 | assert!(log.contains("Delaying 180 ms before restart")); 244 | assert!(log.lines().filter(|line| line.contains("Sleeping another")).count() > 1); 245 | assert!(log.contains("Restart delay is complete")); 246 | } 247 | } 248 | 249 | describe "kill process tree" { 250 | 251 | // returns ALL shawl-child.exe processes (child + grandchild) 252 | fn find_tree_processes() -> Vec { 253 | use sysinfo::System; 254 | 255 | let mut s = System::new_all(); 256 | s.refresh_all(); 257 | 258 | s.processes() 259 | .iter() 260 | .filter(|(_, p)| p.name().eq_ignore_ascii_case("shawl-child.exe")) 261 | .map(|(pid, _)| pid.as_u32()) 262 | .collect() 263 | } 264 | 265 | before { 266 | run_cmd(&["sc", "stop", "shawl_tree"]); 267 | run_cmd(&["sc", "delete", "shawl_tree"]); 268 | } 269 | 270 | after { 271 | run_cmd(&["sc", "stop", "shawl_tree"]); 272 | run_cmd(&["sc", "delete", "shawl_tree"]); 273 | } 274 | 275 | it "kills child + grandchild processes when --kill-process-tree is enabled" { 276 | // Register service with tree-spawning option 277 | run_shawl(&[ 278 | "add", "--name", "shawl_tree", 279 | "--kill-process-tree", 280 | "--", 281 | &child(), "--spawn-grandchild" 282 | ]); 283 | 284 | run_cmd(&["sc", "start", "shawl_tree"]); 285 | std::thread::sleep(std::time::Duration::from_millis(800)); 286 | 287 | // BEFORE STOP: expect child + grandchild processes 288 | let before = find_tree_processes(); 289 | assert!( 290 | before.len() >= 2, 291 | "Expected at least 2 processes (child + grandchild), got: {:?}", 292 | before 293 | ); 294 | 295 | // Stop service - job object should kill the entire process tree 296 | run_cmd(&["sc", "stop", "shawl_tree"]); 297 | std::thread::sleep(std::time::Duration::from_millis(800)); 298 | 299 | // AFTER STOP: ALL shawl-child.exe processes must be gone 300 | let after = find_tree_processes(); 301 | assert!( 302 | after.is_empty(), 303 | "All child + grandchild processes must be terminated, remaining: {:?}", 304 | after 305 | ); 306 | } 307 | 308 | it "does not break normal execution when --kill-process-tree is set without grandchildren" { 309 | run_shawl(&[ 310 | "add", "--name", "shawl_tree", 311 | "--kill-process-tree", 312 | "--", 313 | &child() 314 | ]); 315 | 316 | run_cmd(&["sc", "start", "shawl_tree"]); 317 | run_cmd(&["sc", "stop", "shawl_tree"]); 318 | 319 | let sc_output = run_cmd(&["sc", "query", "shawl_tree"]); 320 | let stdout = String::from_utf8_lossy(&sc_output.stdout); 321 | 322 | assert!(stdout.contains("STATE : 1 STOPPED")); 323 | assert!(stdout.contains("WIN32_EXIT_CODE : 0")); 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/control.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::CommonOpts; 2 | use log::error; 3 | use std::io::Write; 4 | 5 | pub fn add_service(name: String, cwd: Option, dependencies: &[String], opts: CommonOpts) -> Result<(), ()> { 6 | let shawl_path = quote( 7 | &std::env::current_exe() 8 | .expect("Unable to determine Shawl location") 9 | .to_string_lossy(), 10 | ); 11 | let shawl_args = construct_shawl_run_args(&name, &cwd, &opts); 12 | let prepared_command = prepare_command(&opts.command); 13 | 14 | let mut cmd = std::process::Command::new("sc"); 15 | cmd.arg("create").arg(&name); 16 | 17 | if !dependencies.is_empty() { 18 | cmd.arg("depend="); 19 | cmd.arg(quote(&dependencies.join("/"))); 20 | } 21 | 22 | let output = cmd 23 | .arg("binPath=") 24 | .arg(format!( 25 | "{} {} -- {}", 26 | shawl_path, 27 | shawl_args.join(" "), 28 | prepared_command.join(" ") 29 | )) 30 | .output() 31 | .expect("Failed to create the service"); 32 | match output.status.code() { 33 | Some(0) => Ok(()), 34 | Some(x) => { 35 | error!("Failed to create the service. Error code: {}.", x); 36 | error!("SC stdout:\n{}", String::from_utf8_lossy(&output.stdout)); 37 | error!("SC stderr:\n{}", String::from_utf8_lossy(&output.stderr)); 38 | Err(()) 39 | } 40 | None => { 41 | error!("Failed to create the service. Output:"); 42 | std::io::stderr().write_all(&output.stdout).unwrap(); 43 | std::io::stderr().write_all(&output.stderr).unwrap(); 44 | Err(()) 45 | } 46 | } 47 | } 48 | 49 | fn construct_shawl_run_args(name: &str, cwd: &Option, opts: &CommonOpts) -> Vec { 50 | let mut shawl_args = vec!["run".to_string(), "--name".to_string(), quote(name)]; 51 | if let Some(delay) = opts.restart_delay { 52 | shawl_args.push("--restart-delay".to_string()); 53 | shawl_args.push(delay.to_string()); 54 | } 55 | if let Some(st) = opts.stop_timeout { 56 | shawl_args.push("--stop-timeout".to_string()); 57 | shawl_args.push(st.to_string()); 58 | } 59 | if opts.restart { 60 | shawl_args.push("--restart".to_string()); 61 | } 62 | if opts.no_restart { 63 | shawl_args.push("--no-restart".to_string()); 64 | } 65 | if !opts.restart_if.is_empty() { 66 | shawl_args.push("--restart-if".to_string()); 67 | shawl_args.push( 68 | opts.restart_if 69 | .iter() 70 | .map(|x| x.to_string()) 71 | .collect::>() 72 | .join(","), 73 | ); 74 | } 75 | if !opts.restart_if_not.is_empty() { 76 | shawl_args.push("--restart-if-not".to_string()); 77 | shawl_args.push( 78 | opts.restart_if_not 79 | .iter() 80 | .map(|x| x.to_string()) 81 | .collect::>() 82 | .join(","), 83 | ); 84 | }; 85 | if let Some(pass) = &opts.pass { 86 | shawl_args.push("--pass".to_string()); 87 | shawl_args.push(pass.iter().map(|x| x.to_string()).collect::>().join(",")); 88 | } 89 | if let Some(cwd) = &cwd { 90 | shawl_args.push("--cwd".to_string()); 91 | shawl_args.push(quote(cwd)); 92 | }; 93 | if opts.no_log { 94 | shawl_args.push("--no-log".to_string()); 95 | } 96 | if opts.no_log_cmd { 97 | shawl_args.push("--no-log-cmd".to_string()); 98 | } 99 | if let Some(log_dir) = &opts.log_dir { 100 | shawl_args.push("--log-dir".to_string()); 101 | shawl_args.push(quote(log_dir)); 102 | } 103 | if let Some(log_as) = &opts.log_as { 104 | shawl_args.push("--log-as".to_string()); 105 | shawl_args.push(quote(log_as)); 106 | } 107 | if let Some(log_cmd_as) = &opts.log_cmd_as { 108 | shawl_args.push("--log-cmd-as".to_string()); 109 | shawl_args.push(quote(log_cmd_as)); 110 | } 111 | if let Some(log_rotate) = &opts.log_rotate { 112 | shawl_args.push("--log-rotate".to_string()); 113 | shawl_args.push(log_rotate.to_cli()); 114 | } 115 | if let Some(log_retain) = &opts.log_retain { 116 | shawl_args.push("--log-retain".to_string()); 117 | shawl_args.push(log_retain.to_string()); 118 | } 119 | if opts.pass_start_args { 120 | shawl_args.push("--pass-start-args".to_string()); 121 | } 122 | if !opts.env.is_empty() { 123 | for (x, y) in &opts.env { 124 | shawl_args.push("--env".to_string()); 125 | shawl_args.push(quote(&format!("{}={}", x, y))); 126 | } 127 | } 128 | if !opts.path.is_empty() { 129 | for path in &opts.path { 130 | shawl_args.push("--path".to_string()); 131 | shawl_args.push(quote(path)); 132 | } 133 | } 134 | if !opts.path_prepend.is_empty() { 135 | for path in &opts.path_prepend { 136 | shawl_args.push("--path-prepend".to_string()); 137 | shawl_args.push(quote(path)); 138 | } 139 | } 140 | if let Some(priority) = opts.priority { 141 | shawl_args.push("--priority".to_string()); 142 | shawl_args.push(priority.to_cli()); 143 | } 144 | if opts.kill_process_tree { 145 | shawl_args.push("--kill-process-tree".to_string()); 146 | } 147 | shawl_args 148 | } 149 | 150 | fn prepare_command(command: &[String]) -> Vec { 151 | command.iter().map(|x| quote(x)).collect::>() 152 | } 153 | 154 | fn quote(text: &str) -> String { 155 | if text.contains(' ') { 156 | format!("\"{}\"", text) 157 | } else { 158 | text.to_owned() 159 | } 160 | } 161 | 162 | #[cfg(test)] 163 | speculate::speculate! { 164 | fn s(text: &str) -> String { 165 | text.to_string() 166 | } 167 | 168 | describe "construct_shawl_run_args" { 169 | it "works with minimal input" { 170 | assert_eq!( 171 | construct_shawl_run_args( 172 | &s("shawl"), 173 | &None, 174 | &CommonOpts::default() 175 | ), 176 | vec!["run", "--name", "shawl"], 177 | ); 178 | } 179 | 180 | it "does not use the command" { 181 | assert_eq!( 182 | construct_shawl_run_args( 183 | &s("shawl"), 184 | &None, 185 | &CommonOpts { 186 | command: vec![s("foo")], 187 | ..Default::default() 188 | } 189 | ), 190 | vec!["run", "--name", "shawl"], 191 | ); 192 | } 193 | 194 | it "handles --name with spaces" { 195 | assert_eq!( 196 | construct_shawl_run_args( 197 | &s("C:/Program Files/shawl"), 198 | &None, 199 | &CommonOpts::default() 200 | ), 201 | vec!["run", "--name", "\"C:/Program Files/shawl\""], 202 | ); 203 | } 204 | 205 | it "handles --restart" { 206 | assert_eq!( 207 | construct_shawl_run_args( 208 | &s("shawl"), 209 | &None, 210 | &CommonOpts { 211 | restart: true, 212 | ..Default::default() 213 | } 214 | ), 215 | vec!["run", "--name", "shawl", "--restart"], 216 | ); 217 | } 218 | 219 | it "handles --no-restart" { 220 | assert_eq!( 221 | construct_shawl_run_args( 222 | &s("shawl"), 223 | &None, 224 | &CommonOpts { 225 | no_restart: true, 226 | ..Default::default() 227 | } 228 | ), 229 | vec!["run", "--name", "shawl", "--no-restart"], 230 | ); 231 | } 232 | 233 | it "handles --restart-if with one code" { 234 | assert_eq!( 235 | construct_shawl_run_args( 236 | &s("shawl"), 237 | &None, 238 | &CommonOpts { 239 | restart_if: vec![0], 240 | ..Default::default() 241 | } 242 | ), 243 | vec!["run", "--name", "shawl", "--restart-if", "0"], 244 | ); 245 | } 246 | 247 | it "handles --restart-if with multiple codes" { 248 | assert_eq!( 249 | construct_shawl_run_args( 250 | &s("shawl"), 251 | &None, 252 | &CommonOpts { 253 | restart_if: vec![1, 10], 254 | ..Default::default() 255 | } 256 | ), 257 | vec!["run", "--name", "shawl", "--restart-if", "1,10"], 258 | ); 259 | } 260 | 261 | it "handles --restart-if-not with one code" { 262 | assert_eq!( 263 | construct_shawl_run_args( 264 | &s("shawl"), 265 | &None, 266 | &CommonOpts { 267 | restart_if_not: vec![0], 268 | ..Default::default() 269 | } 270 | ), 271 | vec!["run", "--name", "shawl", "--restart-if-not", "0"], 272 | ); 273 | } 274 | 275 | it "handles --restart-if-not with multiple codes" { 276 | assert_eq!( 277 | construct_shawl_run_args( 278 | &s("shawl"), 279 | &None, 280 | &CommonOpts { 281 | restart_if_not: vec![1, 10], 282 | ..Default::default() 283 | } 284 | ), 285 | vec!["run", "--name", "shawl", "--restart-if-not", "1,10"], 286 | ); 287 | } 288 | 289 | it "handles --pass with one code" { 290 | assert_eq!( 291 | construct_shawl_run_args( 292 | &s("shawl"), 293 | &None, 294 | &CommonOpts { 295 | pass: Some(vec![0]), 296 | ..Default::default() 297 | } 298 | ), 299 | vec!["run", "--name", "shawl", "--pass", "0"], 300 | ); 301 | } 302 | 303 | it "handles --pass with multiple codes" { 304 | assert_eq!( 305 | construct_shawl_run_args( 306 | &s("shawl"), 307 | &None, 308 | &CommonOpts { 309 | pass: Some(vec![1, 10]), 310 | ..Default::default() 311 | } 312 | ), 313 | vec!["run", "--name", "shawl", "--pass", "1,10"], 314 | ); 315 | } 316 | 317 | it "handles --restart-delay" { 318 | assert_eq!( 319 | construct_shawl_run_args( 320 | &s("shawl"), 321 | &None, 322 | &CommonOpts { 323 | restart_delay: Some(1500), 324 | ..Default::default() 325 | } 326 | ), 327 | vec!["run", "--name", "shawl", "--restart-delay", "1500"], 328 | ); 329 | } 330 | 331 | it "handles --stop-timeout" { 332 | assert_eq!( 333 | construct_shawl_run_args( 334 | &s("shawl"), 335 | &None, 336 | &CommonOpts { 337 | stop_timeout: Some(3000), 338 | ..Default::default() 339 | } 340 | ), 341 | vec!["run", "--name", "shawl", "--stop-timeout", "3000"], 342 | ); 343 | } 344 | 345 | it "handles --cwd without spaces" { 346 | assert_eq!( 347 | construct_shawl_run_args( 348 | &s("shawl"), 349 | &Some(s("C:/foo")), 350 | &CommonOpts::default() 351 | ), 352 | vec!["run", "--name", "shawl", "--cwd", "C:/foo"], 353 | ); 354 | } 355 | 356 | it "handles --cwd with spaces" { 357 | assert_eq!( 358 | construct_shawl_run_args( 359 | &s("shawl"), 360 | &Some(s("C:/Program Files/foo")), 361 | &CommonOpts::default() 362 | ), 363 | vec!["run", "--name", "shawl", "--cwd", "\"C:/Program Files/foo\""], 364 | ); 365 | } 366 | 367 | it "handles --no-log" { 368 | assert_eq!( 369 | construct_shawl_run_args( 370 | &s("shawl"), 371 | &None, 372 | &CommonOpts { 373 | no_log: true, 374 | ..Default::default() 375 | } 376 | ), 377 | vec!["run", "--name", "shawl", "--no-log"], 378 | ); 379 | } 380 | it "handles --no-log-cmd" { 381 | assert_eq!( 382 | construct_shawl_run_args( 383 | &s("shawl"), 384 | &None, 385 | &CommonOpts { 386 | no_log_cmd: true, 387 | ..Default::default() 388 | } 389 | ), 390 | vec!["run", "--name", "shawl", "--no-log-cmd"], 391 | ); 392 | } 393 | 394 | it "handles --log-as" { 395 | assert_eq!( 396 | construct_shawl_run_args( 397 | &s("shawl"), 398 | &None, 399 | &CommonOpts { 400 | log_as: Some("foo".to_string()), 401 | ..Default::default() 402 | } 403 | ), 404 | vec!["run", "--name", "shawl", "--log-as", "foo"], 405 | ); 406 | } 407 | 408 | it "handles --log-cmd-as" { 409 | assert_eq!( 410 | construct_shawl_run_args( 411 | &s("shawl"), 412 | &None, 413 | &CommonOpts { 414 | log_cmd_as: Some("foo".to_string()), 415 | ..Default::default() 416 | } 417 | ), 418 | vec!["run", "--name", "shawl", "--log-cmd-as", "foo"], 419 | ); 420 | } 421 | 422 | it "handles --log-dir without spaces" { 423 | assert_eq!( 424 | construct_shawl_run_args( 425 | &s("shawl"), 426 | &None, 427 | &CommonOpts { 428 | log_dir: Some("C:/foo".to_string()), 429 | ..Default::default() 430 | } 431 | ), 432 | vec!["run", "--name", "shawl", "--log-dir", "C:/foo"], 433 | ); 434 | } 435 | 436 | it "handles --log-dir with spaces" { 437 | assert_eq!( 438 | construct_shawl_run_args( 439 | &s("shawl"), 440 | &None, 441 | &CommonOpts { 442 | log_dir: Some("C:/foo bar/hello".to_string()), 443 | ..Default::default() 444 | } 445 | ), 446 | vec!["run", "--name", "shawl", "--log-dir", "\"C:/foo bar/hello\""], 447 | ); 448 | } 449 | 450 | it "handles --log-rotate" { 451 | assert_eq!( 452 | construct_shawl_run_args( 453 | &s("shawl"), 454 | &None, 455 | &CommonOpts { 456 | log_rotate: Some(crate::cli::LogRotation::Daily), 457 | ..Default::default() 458 | } 459 | ), 460 | vec!["run", "--name", "shawl", "--log-rotate", "daily"], 461 | ); 462 | } 463 | 464 | it "handles --log-retain" { 465 | assert_eq!( 466 | construct_shawl_run_args( 467 | &s("shawl"), 468 | &None, 469 | &CommonOpts { 470 | log_retain: Some(5), 471 | ..Default::default() 472 | } 473 | ), 474 | vec!["run", "--name", "shawl", "--log-retain", "5"], 475 | ); 476 | } 477 | 478 | it "handles --pass-start-args" { 479 | assert_eq!( 480 | construct_shawl_run_args( 481 | &s("shawl"), 482 | &None, 483 | &CommonOpts { 484 | pass_start_args: true, 485 | ..Default::default() 486 | } 487 | ), 488 | vec!["run", "--name", "shawl", "--pass-start-args"], 489 | ); 490 | } 491 | 492 | it "handles --env without spaces" { 493 | assert_eq!( 494 | construct_shawl_run_args( 495 | &s("shawl"), 496 | &None, 497 | &CommonOpts { 498 | env: vec![(s("FOO"), s("bar"))], 499 | ..Default::default() 500 | } 501 | ), 502 | vec!["run", "--name", "shawl", "--env", "FOO=bar"], 503 | ); 504 | } 505 | 506 | it "handles --env with spaces" { 507 | assert_eq!( 508 | construct_shawl_run_args( 509 | &s("shawl"), 510 | &None, 511 | &CommonOpts { 512 | env: vec![(s("FOO"), s("bar baz"))], 513 | ..Default::default() 514 | } 515 | ), 516 | vec!["run", "--name", "shawl", "--env", "\"FOO=bar baz\""], 517 | ); 518 | } 519 | 520 | it "handles --env multiple times" { 521 | assert_eq!( 522 | construct_shawl_run_args( 523 | &s("shawl"), 524 | &None, 525 | &CommonOpts { 526 | env: vec![(s("FOO"), s("1")), (s("BAR"), s("2"))], 527 | ..Default::default() 528 | } 529 | ), 530 | vec!["run", "--name", "shawl", "--env", "FOO=1", "--env", "BAR=2"], 531 | ); 532 | } 533 | 534 | it "handles --path without spaces" { 535 | assert_eq!( 536 | construct_shawl_run_args( 537 | &s("shawl"), 538 | &None, 539 | &CommonOpts { 540 | path: vec![s("C:/foo")], 541 | ..Default::default() 542 | } 543 | ), 544 | vec!["run", "--name", "shawl", "--path", "C:/foo"], 545 | ); 546 | } 547 | 548 | it "handles --path with spaces" { 549 | assert_eq!( 550 | construct_shawl_run_args( 551 | &s("shawl"), 552 | &None, 553 | &CommonOpts { 554 | path: vec![s("C:/foo bar")], 555 | ..Default::default() 556 | } 557 | ), 558 | vec!["run", "--name", "shawl", "--path", "\"C:/foo bar\""], 559 | ); 560 | } 561 | 562 | it "handles --path multiple times" { 563 | assert_eq!( 564 | construct_shawl_run_args( 565 | &s("shawl"), 566 | &None, 567 | &CommonOpts { 568 | path: vec![s("C:/foo"), s("C:/bar")], 569 | ..Default::default() 570 | } 571 | ), 572 | vec!["run", "--name", "shawl", "--path", "C:/foo", "--path", "C:/bar"], 573 | ); 574 | } 575 | 576 | it "handles --priority" { 577 | assert_eq!( 578 | construct_shawl_run_args( 579 | &s("shawl"), 580 | &None, 581 | &CommonOpts { 582 | priority: Some(crate::cli::Priority::AboveNormal), 583 | ..Default::default() 584 | } 585 | ), 586 | vec!["run", "--name", "shawl", "--priority", "above-normal"], 587 | ); 588 | } 589 | 590 | it "handles --kill-process-tree" { 591 | assert_eq!( 592 | construct_shawl_run_args( 593 | &s("shawl"), 594 | &None, 595 | &CommonOpts { 596 | kill_process_tree: true, 597 | ..Default::default() 598 | } 599 | ), 600 | vec!["run", "--name", "shawl", "--kill-process-tree"], 601 | ); 602 | } 603 | } 604 | 605 | describe "prepare_command" { 606 | it "handles commands without inner spaces" { 607 | assert_eq!( 608 | prepare_command(&[s("cat"), s("file")]), 609 | vec![s("cat"), s("file")], 610 | ); 611 | } 612 | 613 | it "handles commands with inner spaces" { 614 | assert_eq!( 615 | prepare_command(&[s("cat"), s("some file")]), 616 | vec![s("cat"), s("\"some file\"")], 617 | ); 618 | } 619 | } 620 | } 621 | -------------------------------------------------------------------------------- /src/service.rs: -------------------------------------------------------------------------------- 1 | use crate::{cli, process_job::ProcessJob}; 2 | use log::{debug, error, info}; 3 | use std::{io::BufRead, os::windows::process::CommandExt}; 4 | use windows_service::{ 5 | define_windows_service, 6 | service::{ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType}, 7 | service_control_handler::{self, ServiceControlHandlerResult}, 8 | service_dispatcher, 9 | }; 10 | 11 | const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; 12 | 13 | define_windows_service!(ffi_service_main, service_main); 14 | 15 | enum ProcessStatus { 16 | Running, 17 | Exited(i32), 18 | Terminated, 19 | } 20 | 21 | fn check_process(child: &mut std::process::Child) -> Result> { 22 | match child.try_wait() { 23 | Ok(None) => Ok(ProcessStatus::Running), 24 | Ok(Some(status)) => match status.code() { 25 | Some(code) => Ok(ProcessStatus::Exited(code)), 26 | None => Ok(ProcessStatus::Terminated), 27 | }, 28 | Err(e) => Err(Box::new(e)), 29 | } 30 | } 31 | 32 | fn should_restart_exited_command( 33 | code: i32, 34 | restart: bool, 35 | no_restart: bool, 36 | restart_if: &[i32], 37 | restart_if_not: &[i32], 38 | ) -> bool { 39 | if !restart_if.is_empty() { 40 | restart_if.contains(&code) 41 | } else if !restart_if_not.is_empty() { 42 | !restart_if_not.contains(&code) 43 | } else { 44 | restart || !no_restart && code != 0 45 | } 46 | } 47 | 48 | fn should_restart_terminated_command(restart: bool, _no_restart: bool) -> bool { 49 | restart 50 | } 51 | 52 | pub fn run(name: String) -> windows_service::Result<()> { 53 | service_dispatcher::start(name, ffi_service_main) 54 | } 55 | 56 | fn service_main(mut arguments: Vec) { 57 | unsafe { 58 | // Windows services don't start with a console, so we have to 59 | // allocate one in order to send ctrl-C to children. 60 | if windows::Win32::System::Console::AllocConsole().is_err() { 61 | error!( 62 | "Windows AllocConsole failed with code {:?}", 63 | windows::Win32::Foundation::GetLastError() 64 | ); 65 | }; 66 | } 67 | if !arguments.is_empty() { 68 | // first argument is the service name 69 | arguments.remove(0); 70 | } 71 | let _ = run_service(arguments); 72 | } 73 | 74 | #[allow(clippy::cognitive_complexity)] 75 | pub fn run_service(start_arguments: Vec) -> windows_service::Result<()> { 76 | let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel(); 77 | let cli = cli::evaluate_cli(); 78 | let (name, cwd, opts) = match cli.sub { 79 | cli::Subcommand::Run { 80 | name, 81 | cwd, 82 | common: opts, 83 | } => (name, cwd, opts), 84 | _ => { 85 | // Can't get here. 86 | return Ok(()); 87 | } 88 | }; 89 | let pass = &opts.pass.unwrap_or_else(|| vec![0]); 90 | let stop_timeout = &opts.stop_timeout.unwrap_or(3000_u64); 91 | let mut service_exit_code = ServiceExitCode::NO_ERROR; 92 | 93 | let ignore_ctrlc = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); 94 | let ignore_ctrlc2 = ignore_ctrlc.clone(); 95 | ctrlc::set_handler(move || { 96 | if !ignore_ctrlc2.load(std::sync::atomic::Ordering::SeqCst) { 97 | std::process::abort(); 98 | } 99 | }) 100 | .expect("Unable to create ctrl-C handler"); 101 | 102 | let event_handler = move |control_event| -> ServiceControlHandlerResult { 103 | match control_event { 104 | ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, 105 | ServiceControl::Stop => { 106 | info!("Received stop event"); 107 | shutdown_tx.send(()).unwrap(); 108 | ServiceControlHandlerResult::NoError 109 | } 110 | ServiceControl::Shutdown => { 111 | info!("Received shutdown event"); 112 | shutdown_tx.send(()).unwrap(); 113 | ServiceControlHandlerResult::NoError 114 | } 115 | _ => ServiceControlHandlerResult::NotImplemented, 116 | } 117 | }; 118 | 119 | let status_handle = service_control_handler::register(name, event_handler)?; 120 | 121 | status_handle.set_service_status(ServiceStatus { 122 | service_type: SERVICE_TYPE, 123 | current_state: ServiceState::Running, 124 | controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, 125 | exit_code: ServiceExitCode::NO_ERROR, 126 | checkpoint: 0, 127 | wait_hint: std::time::Duration::default(), 128 | process_id: None, 129 | })?; 130 | 131 | let mut command = opts.command.into_iter(); 132 | let program = command.next().unwrap(); 133 | let mut args: Vec<_> = command.map(std::ffi::OsString::from).collect(); 134 | if opts.pass_start_args { 135 | args.extend(start_arguments); 136 | } 137 | 138 | let priority = match opts.priority { 139 | Some(x) => x.to_windows().0, 140 | None => windows::Win32::System::Threading::INHERIT_CALLER_PRIORITY.0, 141 | }; 142 | 143 | let mut restart_after: Option = None; 144 | 145 | // Create a process job that kills all child processes when closed (if kill_process_tree is enabled) 146 | let mut process_job: Option = if opts.kill_process_tree { 147 | match ProcessJob::create_kill_on_close() { 148 | Ok(pj) => { 149 | info!("Created process job for process group management"); 150 | Some(pj) 151 | } 152 | Err(e) => { 153 | error!("Failed to create process job: {:?}", e); 154 | None 155 | } 156 | } 157 | } else { 158 | None 159 | }; 160 | 161 | debug!("Entering main service loop"); 162 | 'outer: loop { 163 | if let Some(delay) = restart_after { 164 | match shutdown_rx.recv_timeout(std::time::Duration::from_millis(1)) { 165 | Ok(_) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { 166 | info!("Cancelling before launch"); 167 | break 'outer; 168 | } 169 | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => (), 170 | }; 171 | 172 | let now = std::time::Instant::now(); 173 | if now < delay { 174 | let step = (delay - now).min(std::time::Duration::from_millis(50)); 175 | debug!("Sleeping another {} ms", step.as_millis()); 176 | std::thread::sleep(step); 177 | continue; 178 | } else { 179 | info!("Restart delay is complete"); 180 | restart_after = None; 181 | } 182 | } 183 | 184 | info!("Launching command"); 185 | let should_log_cmd = !&opts.no_log_cmd; 186 | let mut child_cmd = std::process::Command::new(&program); 187 | let mut path_env = std::env::var("PATH").ok(); 188 | 189 | child_cmd 190 | .args(&args) 191 | .creation_flags(priority) 192 | .stdout(if should_log_cmd { 193 | std::process::Stdio::piped() 194 | } else { 195 | std::process::Stdio::null() 196 | }) 197 | .stderr(if should_log_cmd { 198 | std::process::Stdio::piped() 199 | } else { 200 | std::process::Stdio::null() 201 | }); 202 | for (key, value) in &opts.env { 203 | child_cmd.env(key, value); 204 | } 205 | if !opts.path.is_empty() { 206 | let simplified: Vec<_> = opts.path.iter().map(|x| crate::simplify_path(x)).collect(); 207 | path_env = match path_env { 208 | Some(path) => Some(format!("{};{}", path, simplified.join(";"))), 209 | None => Some(simplified.join(";").to_string()), 210 | }; 211 | } 212 | if !opts.path_prepend.is_empty() { 213 | let simplified: Vec<_> = opts.path_prepend.iter().map(|x| crate::simplify_path(x)).collect(); 214 | path_env = match path_env { 215 | Some(path) => Some(format!("{};{}", simplified.join(";"), path)), 216 | None => Some(simplified.join(";").to_string()), 217 | }; 218 | } 219 | if let Some(active_cwd) = &cwd { 220 | let active_cwd = crate::simplify_path(active_cwd); 221 | child_cmd.current_dir(&active_cwd); 222 | path_env = match path_env { 223 | Some(path) => Some(format!("{};{}", path, active_cwd)), 224 | None => Some(active_cwd), 225 | }; 226 | } 227 | if let Some(path_env) = path_env { 228 | child_cmd.env("PATH", path_env); 229 | } 230 | 231 | let mut child = match child_cmd.spawn() { 232 | Ok(c) => c, 233 | Err(e) => { 234 | error!("Unable to launch command: {}", e); 235 | service_exit_code = match e.raw_os_error() { 236 | Some(win_code) => ServiceExitCode::Win32(win_code as u32), 237 | None => ServiceExitCode::Win32(windows::Win32::Foundation::ERROR_PROCESS_ABORTED.0), 238 | }; 239 | break; 240 | } 241 | }; 242 | 243 | // Assign process to job (if kill_process_tree is enabled) 244 | if let Some(ref pj) = process_job { 245 | if let Err(e) = pj.assign(&child) { 246 | error!("Failed to assign process to job: {:?}", e); 247 | } else { 248 | debug!("Assigned process (PID: {}) to job", child.id()); 249 | } 250 | } 251 | 252 | // Log stdout. 253 | let output_logs_need_target = opts.log_cmd_as.is_some(); 254 | let stdout_option = child.stdout.take(); 255 | let stdout_logger = std::thread::spawn(move || { 256 | if !should_log_cmd { 257 | return; 258 | } 259 | if let Some(stdout) = stdout_option { 260 | std::io::BufReader::new(stdout).lines().for_each(|line| match line { 261 | Ok(ref x) if !x.is_empty() => { 262 | if output_logs_need_target { 263 | debug!(target: "{shawl-cmd}", "{}", x); 264 | } else { 265 | debug!("stdout: {:?}", x); 266 | } 267 | } 268 | _ => (), 269 | }); 270 | } 271 | }); 272 | 273 | // Log stderr. 274 | let stderr_option = child.stderr.take(); 275 | let stderr_logger = std::thread::spawn(move || { 276 | if !should_log_cmd { 277 | return; 278 | } 279 | if let Some(stderr) = stderr_option { 280 | std::io::BufReader::new(stderr).lines().for_each(|line| match line { 281 | Ok(ref x) if !x.is_empty() => { 282 | if output_logs_need_target { 283 | debug!(target: "{shawl-cmd}", "{}", x); 284 | } else { 285 | debug!("stderr: {:?}", x); 286 | } 287 | } 288 | _ => (), 289 | }); 290 | } 291 | }); 292 | 293 | 'inner: loop { 294 | match shutdown_rx.recv_timeout(std::time::Duration::from_secs(1)) { 295 | Ok(_) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { 296 | status_handle.set_service_status(ServiceStatus { 297 | service_type: SERVICE_TYPE, 298 | current_state: ServiceState::StopPending, 299 | controls_accepted: ServiceControlAccept::empty(), 300 | exit_code: ServiceExitCode::NO_ERROR, 301 | checkpoint: 0, 302 | wait_hint: std::time::Duration::from_millis(opts.stop_timeout.unwrap_or(3000) + 1000), 303 | process_id: None, 304 | })?; 305 | 306 | ignore_ctrlc.store(true, std::sync::atomic::Ordering::SeqCst); 307 | info!("Sending ctrl-C to command"); 308 | unsafe { 309 | if windows::Win32::System::Console::GenerateConsoleCtrlEvent( 310 | windows::Win32::System::Console::CTRL_C_EVENT, 311 | 0, 312 | ) 313 | .is_err() 314 | { 315 | error!( 316 | "Windows GenerateConsoleCtrlEvent failed with code {:?}", 317 | windows::Win32::Foundation::GetLastError() 318 | ); 319 | }; 320 | } 321 | 322 | let start_time = std::time::Instant::now(); 323 | loop { 324 | match check_process(&mut child) { 325 | Ok(ProcessStatus::Running) => { 326 | if start_time.elapsed().as_millis() < (*stop_timeout).into() { 327 | std::thread::sleep(std::time::Duration::from_millis(50)) 328 | } else { 329 | info!("Killing command because stop timeout expired"); 330 | if let Some(pj) = process_job.take() { 331 | // Drop the job, which will terminate all child processes 332 | info!("Dropping process job to terminate all child processes"); 333 | drop(pj); 334 | } else { 335 | // Fallback to standard kill 336 | let _ = child.kill(); 337 | } 338 | service_exit_code = ServiceExitCode::NO_ERROR; 339 | break; 340 | } 341 | } 342 | Ok(ProcessStatus::Exited(code)) => { 343 | info!( 344 | "Command exited after {:?} ms with code {:?}", 345 | start_time.elapsed().as_millis(), 346 | code 347 | ); 348 | service_exit_code = if pass.contains(&code) { 349 | ServiceExitCode::NO_ERROR 350 | } else { 351 | ServiceExitCode::ServiceSpecific(code as u32) 352 | }; 353 | break; 354 | } 355 | _ => { 356 | info!("Command exited within stop timeout"); 357 | break; 358 | } 359 | } 360 | } 361 | 362 | ignore_ctrlc.store(false, std::sync::atomic::Ordering::SeqCst); 363 | break 'outer; 364 | } 365 | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => (), 366 | }; 367 | 368 | match check_process(&mut child) { 369 | Ok(ProcessStatus::Running) => (), 370 | Ok(ProcessStatus::Exited(code)) => { 371 | info!("Command exited with code {:?}", code); 372 | service_exit_code = if pass.contains(&code) { 373 | ServiceExitCode::NO_ERROR 374 | } else { 375 | ServiceExitCode::ServiceSpecific(code as u32) 376 | }; 377 | if should_restart_exited_command( 378 | code, 379 | opts.restart, 380 | opts.no_restart, 381 | &opts.restart_if, 382 | &opts.restart_if_not, 383 | ) { 384 | break 'inner; 385 | } else { 386 | break 'outer; 387 | } 388 | } 389 | Ok(ProcessStatus::Terminated) => { 390 | info!("Command was terminated by a signal"); 391 | service_exit_code = ServiceExitCode::Win32(windows::Win32::Foundation::ERROR_PROCESS_ABORTED.0); 392 | if should_restart_terminated_command(opts.restart, opts.no_restart) { 393 | break 'inner; 394 | } else { 395 | break 'outer; 396 | } 397 | } 398 | Err(e) => { 399 | info!("Error trying to determine command status: {:?}", e); 400 | service_exit_code = ServiceExitCode::Win32(windows::Win32::Foundation::ERROR_PROCESS_ABORTED.0); 401 | break 'inner; 402 | } 403 | } 404 | } 405 | 406 | if let Err(e) = stdout_logger.join() { 407 | error!("Unable to join stdout logger thread: {:?}", e); 408 | } 409 | if let Err(e) = stderr_logger.join() { 410 | error!("Unable to join stderr logger thread: {:?}", e); 411 | } 412 | 413 | if let Some(delay) = opts.restart_delay { 414 | info!("Delaying {delay} ms before restart"); 415 | restart_after = Some(std::time::Instant::now() + std::time::Duration::from_millis(delay)); 416 | } 417 | } 418 | debug!("Exited main service loop"); 419 | 420 | status_handle.set_service_status(ServiceStatus { 421 | service_type: SERVICE_TYPE, 422 | current_state: ServiceState::Stopped, 423 | controls_accepted: ServiceControlAccept::empty(), 424 | exit_code: service_exit_code, 425 | checkpoint: 0, 426 | wait_hint: std::time::Duration::default(), 427 | process_id: None, 428 | })?; 429 | 430 | Ok(()) 431 | } 432 | 433 | #[cfg(test)] 434 | speculate::speculate! { 435 | describe "should_restart_exited_command" { 436 | it "handles --restart" { 437 | assert!(should_restart_exited_command(5, true, false, &[], &[])); 438 | } 439 | 440 | it "handles --no-restart" { 441 | assert!(!should_restart_exited_command(0, false, true, &[], &[])); 442 | } 443 | 444 | it "handles --restart-if" { 445 | assert!(should_restart_exited_command(0, false, false, &[0], &[])); 446 | assert!(!should_restart_exited_command(1, false, false, &[0], &[])); 447 | } 448 | 449 | it "handles --restart-if-not" { 450 | assert!(!should_restart_exited_command(0, false, false, &[], &[0])); 451 | assert!(should_restart_exited_command(1, false, false, &[], &[0])); 452 | } 453 | 454 | it "restarts nonzero by default" { 455 | assert!(!should_restart_exited_command(0, false, false, &[], &[])); 456 | assert!(should_restart_exited_command(1, false, false, &[], &[])); 457 | } 458 | } 459 | 460 | describe "should_restart_terminated_command" { 461 | it "only restarts with --restart" { 462 | assert!(!should_restart_terminated_command(false, false)); 463 | assert!(should_restart_terminated_command(true, false)); 464 | assert!(!should_restart_terminated_command(false, true)); 465 | } 466 | } 467 | 468 | describe "process_job" { 469 | it "can create a process job" { 470 | assert!(ProcessJob::create_kill_on_close().is_ok()); 471 | } 472 | 473 | it "kills the assigned process when the job is dropped" { 474 | use std::{thread, time::Duration}; 475 | 476 | // Create job 477 | let job = ProcessJob::create_kill_on_close().unwrap(); 478 | 479 | // Spawn long-running dummy command 480 | let mut child = std::process::Command::new("cmd") 481 | .args(&["/C", "timeout", "/t", "60", "/nobreak"]) 482 | .spawn() 483 | .unwrap(); 484 | 485 | // Assign to job 486 | assert!(job.assign(&child).is_ok()); 487 | 488 | // Drop the job → should terminate the child 489 | drop(job); 490 | 491 | // Give Windows a small time window to process the kill 492 | thread::sleep(Duration::from_millis(150)); 493 | 494 | // Child must be dead 495 | let status = child.try_wait() 496 | .expect("Failed to poll child process status"); 497 | 498 | assert!( 499 | status.is_some(), 500 | "Child process should have been terminated when ProcessJob was dropped" 501 | ); 502 | } 503 | 504 | it "kills child and grandchild processes when job is dropped" { 505 | use std::{thread, time::Duration}; 506 | use sysinfo::{System, Pid}; 507 | 508 | let job = ProcessJob::create_kill_on_close().unwrap(); 509 | 510 | // Parent process spawns a grandchild 511 | let child = std::process::Command::new("powershell") 512 | .arg("-NoProfile") 513 | .arg("-Command") 514 | .arg("Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep 60'; Start-Sleep 60") 515 | .spawn() 516 | .expect("Failed to spawn parent process"); 517 | 518 | job.assign(&child).expect("Failed to assign job"); 519 | 520 | let parent_pid = child.id(); 521 | 522 | // Let grandchildren spawn 523 | thread::sleep(Duration::from_millis(300)); 524 | 525 | let mut system = System::new_all(); 526 | system.refresh_all(); 527 | 528 | // Find grandchildren (children of the parent PID) 529 | let grandchildren: Vec = system 530 | .processes() 531 | .iter() 532 | .filter(|(_, p)| p.parent() == Some(Pid::from_u32(parent_pid))) 533 | .map(|(pid, _)| pid.as_u32()) 534 | .collect(); 535 | 536 | assert!( 537 | !grandchildren.is_empty(), 538 | "Expected parent to spawn at least one grandchild" 539 | ); 540 | 541 | // Drop job -> kill whole tree 542 | drop(job); 543 | 544 | thread::sleep(Duration::from_millis(300)); 545 | system.refresh_all(); 546 | 547 | // Parent should be dead 548 | let parent_alive = system.process(Pid::from_u32(parent_pid)).is_some(); 549 | assert!(!parent_alive, "Parent should be terminated"); 550 | 551 | // Grandchildren should be dead too 552 | for gc_pid in grandchildren { 553 | let alive = system.process(Pid::from_u32(gc_pid)).is_some(); 554 | assert!( 555 | !alive, 556 | "Grandchild process {} should also be terminated", 557 | gc_pid 558 | ); 559 | } 560 | } 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "android-tzdata" 16 | version = "0.1.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 19 | 20 | [[package]] 21 | name = "android_system_properties" 22 | version = "0.1.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 | dependencies = [ 26 | "libc", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.15" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.8" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.5" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.1" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 64 | dependencies = [ 65 | "windows-sys 0.52.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.4" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 73 | dependencies = [ 74 | "anstyle", 75 | "windows-sys 0.52.0", 76 | ] 77 | 78 | [[package]] 79 | name = "autocfg" 80 | version = "1.3.0" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 83 | 84 | [[package]] 85 | name = "bitflags" 86 | version = "2.6.0" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 89 | 90 | [[package]] 91 | name = "bumpalo" 92 | version = "3.16.0" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 95 | 96 | [[package]] 97 | name = "cc" 98 | version = "1.1.21" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" 101 | dependencies = [ 102 | "shlex", 103 | ] 104 | 105 | [[package]] 106 | name = "cfg-if" 107 | version = "1.0.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 110 | 111 | [[package]] 112 | name = "cfg_aliases" 113 | version = "0.2.1" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 116 | 117 | [[package]] 118 | name = "chrono" 119 | version = "0.4.38" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" 122 | dependencies = [ 123 | "android-tzdata", 124 | "iana-time-zone", 125 | "num-traits", 126 | "windows-targets", 127 | ] 128 | 129 | [[package]] 130 | name = "clap" 131 | version = "4.5.20" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" 134 | dependencies = [ 135 | "clap_builder", 136 | "clap_derive", 137 | ] 138 | 139 | [[package]] 140 | name = "clap_builder" 141 | version = "4.5.20" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" 144 | dependencies = [ 145 | "anstream", 146 | "anstyle", 147 | "clap_lex", 148 | "strsim", 149 | "terminal_size", 150 | ] 151 | 152 | [[package]] 153 | name = "clap_derive" 154 | version = "4.5.18" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 157 | dependencies = [ 158 | "heck", 159 | "proc-macro2 1.0.86", 160 | "quote 1.0.37", 161 | "syn 2.0.77", 162 | ] 163 | 164 | [[package]] 165 | name = "clap_lex" 166 | version = "0.7.2" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 169 | 170 | [[package]] 171 | name = "colorchoice" 172 | version = "1.0.2" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 175 | 176 | [[package]] 177 | name = "core-foundation-sys" 178 | version = "0.8.7" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 181 | 182 | [[package]] 183 | name = "crossbeam-deque" 184 | version = "0.8.6" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 187 | dependencies = [ 188 | "crossbeam-epoch", 189 | "crossbeam-utils", 190 | ] 191 | 192 | [[package]] 193 | name = "crossbeam-epoch" 194 | version = "0.9.18" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 197 | dependencies = [ 198 | "crossbeam-utils", 199 | ] 200 | 201 | [[package]] 202 | name = "crossbeam-utils" 203 | version = "0.8.21" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 206 | 207 | [[package]] 208 | name = "ctrlc" 209 | version = "3.4.5" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" 212 | dependencies = [ 213 | "nix", 214 | "windows-sys 0.59.0", 215 | ] 216 | 217 | [[package]] 218 | name = "dunce" 219 | version = "1.0.5" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 222 | 223 | [[package]] 224 | name = "either" 225 | version = "1.15.0" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 228 | 229 | [[package]] 230 | name = "errno" 231 | version = "0.3.9" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 234 | dependencies = [ 235 | "libc", 236 | "windows-sys 0.52.0", 237 | ] 238 | 239 | [[package]] 240 | name = "flexi_logger" 241 | version = "0.29.3" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "719236bdbcf6033a3395165f797076b31056018e6723ccff616eb25fc9c99de1" 244 | dependencies = [ 245 | "chrono", 246 | "log", 247 | "nu-ansi-term", 248 | "regex", 249 | "thiserror", 250 | ] 251 | 252 | [[package]] 253 | name = "heck" 254 | version = "0.5.0" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 257 | 258 | [[package]] 259 | name = "iana-time-zone" 260 | version = "0.1.61" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 263 | dependencies = [ 264 | "android_system_properties", 265 | "core-foundation-sys", 266 | "iana-time-zone-haiku", 267 | "js-sys", 268 | "wasm-bindgen", 269 | "windows-core 0.52.0", 270 | ] 271 | 272 | [[package]] 273 | name = "iana-time-zone-haiku" 274 | version = "0.1.2" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 277 | dependencies = [ 278 | "cc", 279 | ] 280 | 281 | [[package]] 282 | name = "is_terminal_polyfill" 283 | version = "1.70.1" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 286 | 287 | [[package]] 288 | name = "js-sys" 289 | version = "0.3.70" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" 292 | dependencies = [ 293 | "wasm-bindgen", 294 | ] 295 | 296 | [[package]] 297 | name = "libc" 298 | version = "0.2.159" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" 301 | 302 | [[package]] 303 | name = "linux-raw-sys" 304 | version = "0.4.14" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 307 | 308 | [[package]] 309 | name = "log" 310 | version = "0.4.22" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 313 | 314 | [[package]] 315 | name = "memchr" 316 | version = "2.7.4" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 319 | 320 | [[package]] 321 | name = "nix" 322 | version = "0.29.0" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" 325 | dependencies = [ 326 | "bitflags", 327 | "cfg-if", 328 | "cfg_aliases", 329 | "libc", 330 | ] 331 | 332 | [[package]] 333 | name = "ntapi" 334 | version = "0.4.1" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" 337 | dependencies = [ 338 | "winapi", 339 | ] 340 | 341 | [[package]] 342 | name = "nu-ansi-term" 343 | version = "0.50.1" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" 346 | dependencies = [ 347 | "windows-sys 0.52.0", 348 | ] 349 | 350 | [[package]] 351 | name = "num-traits" 352 | version = "0.2.19" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 355 | dependencies = [ 356 | "autocfg", 357 | ] 358 | 359 | [[package]] 360 | name = "once_cell" 361 | version = "1.19.0" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 364 | 365 | [[package]] 366 | name = "proc-macro2" 367 | version = "0.4.30" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" 370 | dependencies = [ 371 | "unicode-xid", 372 | ] 373 | 374 | [[package]] 375 | name = "proc-macro2" 376 | version = "1.0.86" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 379 | dependencies = [ 380 | "unicode-ident", 381 | ] 382 | 383 | [[package]] 384 | name = "quote" 385 | version = "0.6.13" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" 388 | dependencies = [ 389 | "proc-macro2 0.4.30", 390 | ] 391 | 392 | [[package]] 393 | name = "quote" 394 | version = "1.0.37" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 397 | dependencies = [ 398 | "proc-macro2 1.0.86", 399 | ] 400 | 401 | [[package]] 402 | name = "rayon" 403 | version = "1.11.0" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" 406 | dependencies = [ 407 | "either", 408 | "rayon-core", 409 | ] 410 | 411 | [[package]] 412 | name = "rayon-core" 413 | version = "1.13.0" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" 416 | dependencies = [ 417 | "crossbeam-deque", 418 | "crossbeam-utils", 419 | ] 420 | 421 | [[package]] 422 | name = "regex" 423 | version = "1.11.0" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" 426 | dependencies = [ 427 | "aho-corasick", 428 | "memchr", 429 | "regex-automata", 430 | "regex-syntax", 431 | ] 432 | 433 | [[package]] 434 | name = "regex-automata" 435 | version = "0.4.8" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 438 | dependencies = [ 439 | "aho-corasick", 440 | "memchr", 441 | "regex-syntax", 442 | ] 443 | 444 | [[package]] 445 | name = "regex-syntax" 446 | version = "0.8.5" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 449 | 450 | [[package]] 451 | name = "rustix" 452 | version = "0.38.37" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" 455 | dependencies = [ 456 | "bitflags", 457 | "errno", 458 | "libc", 459 | "linux-raw-sys", 460 | "windows-sys 0.52.0", 461 | ] 462 | 463 | [[package]] 464 | name = "serde" 465 | version = "1.0.210" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 468 | dependencies = [ 469 | "serde_derive", 470 | ] 471 | 472 | [[package]] 473 | name = "serde_derive" 474 | version = "1.0.210" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 477 | dependencies = [ 478 | "proc-macro2 1.0.86", 479 | "quote 1.0.37", 480 | "syn 2.0.77", 481 | ] 482 | 483 | [[package]] 484 | name = "shawl" 485 | version = "1.8.0" 486 | dependencies = [ 487 | "clap", 488 | "ctrlc", 489 | "dunce", 490 | "flexi_logger", 491 | "log", 492 | "regex", 493 | "speculate", 494 | "sysinfo", 495 | "windows 0.58.0", 496 | "windows-service", 497 | "winres", 498 | ] 499 | 500 | [[package]] 501 | name = "shlex" 502 | version = "1.3.0" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 505 | 506 | [[package]] 507 | name = "speculate" 508 | version = "0.1.2" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "3cb45d32c801cddf308603926b7c3760db4fa2c66d84c64376b64eec860d005e" 511 | dependencies = [ 512 | "proc-macro2 0.4.30", 513 | "quote 0.6.13", 514 | "syn 0.14.9", 515 | "unicode-xid", 516 | ] 517 | 518 | [[package]] 519 | name = "strsim" 520 | version = "0.11.1" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 523 | 524 | [[package]] 525 | name = "syn" 526 | version = "0.14.9" 527 | source = "registry+https://github.com/rust-lang/crates.io-index" 528 | checksum = "261ae9ecaa397c42b960649561949d69311f08eeaea86a65696e6e46517cf741" 529 | dependencies = [ 530 | "proc-macro2 0.4.30", 531 | "quote 0.6.13", 532 | "unicode-xid", 533 | ] 534 | 535 | [[package]] 536 | name = "syn" 537 | version = "2.0.77" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" 540 | dependencies = [ 541 | "proc-macro2 1.0.86", 542 | "quote 1.0.37", 543 | "unicode-ident", 544 | ] 545 | 546 | [[package]] 547 | name = "sysinfo" 548 | version = "0.31.4" 549 | source = "registry+https://github.com/rust-lang/crates.io-index" 550 | checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" 551 | dependencies = [ 552 | "core-foundation-sys", 553 | "libc", 554 | "memchr", 555 | "ntapi", 556 | "rayon", 557 | "windows 0.57.0", 558 | ] 559 | 560 | [[package]] 561 | name = "terminal_size" 562 | version = "0.4.0" 563 | source = "registry+https://github.com/rust-lang/crates.io-index" 564 | checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" 565 | dependencies = [ 566 | "rustix", 567 | "windows-sys 0.59.0", 568 | ] 569 | 570 | [[package]] 571 | name = "thiserror" 572 | version = "1.0.64" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" 575 | dependencies = [ 576 | "thiserror-impl", 577 | ] 578 | 579 | [[package]] 580 | name = "thiserror-impl" 581 | version = "1.0.64" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" 584 | dependencies = [ 585 | "proc-macro2 1.0.86", 586 | "quote 1.0.37", 587 | "syn 2.0.77", 588 | ] 589 | 590 | [[package]] 591 | name = "toml" 592 | version = "0.5.11" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" 595 | dependencies = [ 596 | "serde", 597 | ] 598 | 599 | [[package]] 600 | name = "unicode-ident" 601 | version = "1.0.13" 602 | source = "registry+https://github.com/rust-lang/crates.io-index" 603 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 604 | 605 | [[package]] 606 | name = "unicode-xid" 607 | version = "0.1.0" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" 610 | 611 | [[package]] 612 | name = "utf8parse" 613 | version = "0.2.2" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 616 | 617 | [[package]] 618 | name = "wasm-bindgen" 619 | version = "0.2.93" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" 622 | dependencies = [ 623 | "cfg-if", 624 | "once_cell", 625 | "wasm-bindgen-macro", 626 | ] 627 | 628 | [[package]] 629 | name = "wasm-bindgen-backend" 630 | version = "0.2.93" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" 633 | dependencies = [ 634 | "bumpalo", 635 | "log", 636 | "once_cell", 637 | "proc-macro2 1.0.86", 638 | "quote 1.0.37", 639 | "syn 2.0.77", 640 | "wasm-bindgen-shared", 641 | ] 642 | 643 | [[package]] 644 | name = "wasm-bindgen-macro" 645 | version = "0.2.93" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" 648 | dependencies = [ 649 | "quote 1.0.37", 650 | "wasm-bindgen-macro-support", 651 | ] 652 | 653 | [[package]] 654 | name = "wasm-bindgen-macro-support" 655 | version = "0.2.93" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" 658 | dependencies = [ 659 | "proc-macro2 1.0.86", 660 | "quote 1.0.37", 661 | "syn 2.0.77", 662 | "wasm-bindgen-backend", 663 | "wasm-bindgen-shared", 664 | ] 665 | 666 | [[package]] 667 | name = "wasm-bindgen-shared" 668 | version = "0.2.93" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" 671 | 672 | [[package]] 673 | name = "widestring" 674 | version = "1.1.0" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" 677 | 678 | [[package]] 679 | name = "winapi" 680 | version = "0.3.9" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 683 | dependencies = [ 684 | "winapi-i686-pc-windows-gnu", 685 | "winapi-x86_64-pc-windows-gnu", 686 | ] 687 | 688 | [[package]] 689 | name = "winapi-i686-pc-windows-gnu" 690 | version = "0.4.0" 691 | source = "registry+https://github.com/rust-lang/crates.io-index" 692 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 693 | 694 | [[package]] 695 | name = "winapi-x86_64-pc-windows-gnu" 696 | version = "0.4.0" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 699 | 700 | [[package]] 701 | name = "windows" 702 | version = "0.57.0" 703 | source = "registry+https://github.com/rust-lang/crates.io-index" 704 | checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" 705 | dependencies = [ 706 | "windows-core 0.57.0", 707 | "windows-targets", 708 | ] 709 | 710 | [[package]] 711 | name = "windows" 712 | version = "0.58.0" 713 | source = "registry+https://github.com/rust-lang/crates.io-index" 714 | checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" 715 | dependencies = [ 716 | "windows-core 0.58.0", 717 | "windows-targets", 718 | ] 719 | 720 | [[package]] 721 | name = "windows-core" 722 | version = "0.52.0" 723 | source = "registry+https://github.com/rust-lang/crates.io-index" 724 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 725 | dependencies = [ 726 | "windows-targets", 727 | ] 728 | 729 | [[package]] 730 | name = "windows-core" 731 | version = "0.57.0" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" 734 | dependencies = [ 735 | "windows-implement 0.57.0", 736 | "windows-interface 0.57.0", 737 | "windows-result 0.1.2", 738 | "windows-targets", 739 | ] 740 | 741 | [[package]] 742 | name = "windows-core" 743 | version = "0.58.0" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" 746 | dependencies = [ 747 | "windows-implement 0.58.0", 748 | "windows-interface 0.58.0", 749 | "windows-result 0.2.0", 750 | "windows-strings", 751 | "windows-targets", 752 | ] 753 | 754 | [[package]] 755 | name = "windows-implement" 756 | version = "0.57.0" 757 | source = "registry+https://github.com/rust-lang/crates.io-index" 758 | checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" 759 | dependencies = [ 760 | "proc-macro2 1.0.86", 761 | "quote 1.0.37", 762 | "syn 2.0.77", 763 | ] 764 | 765 | [[package]] 766 | name = "windows-implement" 767 | version = "0.58.0" 768 | source = "registry+https://github.com/rust-lang/crates.io-index" 769 | checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" 770 | dependencies = [ 771 | "proc-macro2 1.0.86", 772 | "quote 1.0.37", 773 | "syn 2.0.77", 774 | ] 775 | 776 | [[package]] 777 | name = "windows-interface" 778 | version = "0.57.0" 779 | source = "registry+https://github.com/rust-lang/crates.io-index" 780 | checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" 781 | dependencies = [ 782 | "proc-macro2 1.0.86", 783 | "quote 1.0.37", 784 | "syn 2.0.77", 785 | ] 786 | 787 | [[package]] 788 | name = "windows-interface" 789 | version = "0.58.0" 790 | source = "registry+https://github.com/rust-lang/crates.io-index" 791 | checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" 792 | dependencies = [ 793 | "proc-macro2 1.0.86", 794 | "quote 1.0.37", 795 | "syn 2.0.77", 796 | ] 797 | 798 | [[package]] 799 | name = "windows-result" 800 | version = "0.1.2" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" 803 | dependencies = [ 804 | "windows-targets", 805 | ] 806 | 807 | [[package]] 808 | name = "windows-result" 809 | version = "0.2.0" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" 812 | dependencies = [ 813 | "windows-targets", 814 | ] 815 | 816 | [[package]] 817 | name = "windows-service" 818 | version = "0.7.0" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" 821 | dependencies = [ 822 | "bitflags", 823 | "widestring", 824 | "windows-sys 0.52.0", 825 | ] 826 | 827 | [[package]] 828 | name = "windows-strings" 829 | version = "0.1.0" 830 | source = "registry+https://github.com/rust-lang/crates.io-index" 831 | checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" 832 | dependencies = [ 833 | "windows-result 0.2.0", 834 | "windows-targets", 835 | ] 836 | 837 | [[package]] 838 | name = "windows-sys" 839 | version = "0.52.0" 840 | source = "registry+https://github.com/rust-lang/crates.io-index" 841 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 842 | dependencies = [ 843 | "windows-targets", 844 | ] 845 | 846 | [[package]] 847 | name = "windows-sys" 848 | version = "0.59.0" 849 | source = "registry+https://github.com/rust-lang/crates.io-index" 850 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 851 | dependencies = [ 852 | "windows-targets", 853 | ] 854 | 855 | [[package]] 856 | name = "windows-targets" 857 | version = "0.52.6" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 860 | dependencies = [ 861 | "windows_aarch64_gnullvm", 862 | "windows_aarch64_msvc", 863 | "windows_i686_gnu", 864 | "windows_i686_gnullvm", 865 | "windows_i686_msvc", 866 | "windows_x86_64_gnu", 867 | "windows_x86_64_gnullvm", 868 | "windows_x86_64_msvc", 869 | ] 870 | 871 | [[package]] 872 | name = "windows_aarch64_gnullvm" 873 | version = "0.52.6" 874 | source = "registry+https://github.com/rust-lang/crates.io-index" 875 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 876 | 877 | [[package]] 878 | name = "windows_aarch64_msvc" 879 | version = "0.52.6" 880 | source = "registry+https://github.com/rust-lang/crates.io-index" 881 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 882 | 883 | [[package]] 884 | name = "windows_i686_gnu" 885 | version = "0.52.6" 886 | source = "registry+https://github.com/rust-lang/crates.io-index" 887 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 888 | 889 | [[package]] 890 | name = "windows_i686_gnullvm" 891 | version = "0.52.6" 892 | source = "registry+https://github.com/rust-lang/crates.io-index" 893 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 894 | 895 | [[package]] 896 | name = "windows_i686_msvc" 897 | version = "0.52.6" 898 | source = "registry+https://github.com/rust-lang/crates.io-index" 899 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 900 | 901 | [[package]] 902 | name = "windows_x86_64_gnu" 903 | version = "0.52.6" 904 | source = "registry+https://github.com/rust-lang/crates.io-index" 905 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 906 | 907 | [[package]] 908 | name = "windows_x86_64_gnullvm" 909 | version = "0.52.6" 910 | source = "registry+https://github.com/rust-lang/crates.io-index" 911 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 912 | 913 | [[package]] 914 | name = "windows_x86_64_msvc" 915 | version = "0.52.6" 916 | source = "registry+https://github.com/rust-lang/crates.io-index" 917 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 918 | 919 | [[package]] 920 | name = "winres" 921 | version = "0.1.12" 922 | source = "registry+https://github.com/rust-lang/crates.io-index" 923 | checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" 924 | dependencies = [ 925 | "toml", 926 | ] 927 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | pub fn evaluate_cli() -> Cli { 4 | Cli::parse() 5 | } 6 | 7 | fn parse_canonical_path(path: &str) -> Result { 8 | Ok(std::fs::canonicalize(path)?.to_string_lossy().to_string()) 9 | } 10 | 11 | fn parse_ensured_directory(path: &str) -> Result { 12 | std::fs::create_dir_all(path)?; 13 | Ok(std::fs::canonicalize(path)?.to_string_lossy().to_string()) 14 | } 15 | 16 | macro_rules! possible_values { 17 | ($t: ty, $options: ident) => {{ 18 | use clap::builder::{PossibleValuesParser, TypedValueParser}; 19 | PossibleValuesParser::new(<$t>::$options).map(|s| s.parse::<$t>().unwrap()) 20 | }}; 21 | } 22 | 23 | #[derive(Debug)] 24 | pub enum CliError { 25 | InvalidEnvVar { specification: String }, 26 | } 27 | 28 | impl std::error::Error for CliError {} 29 | 30 | impl std::fmt::Display for CliError { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | match self { 33 | Self::InvalidEnvVar { specification } => { 34 | write!(f, "Invalid KEY=value formatting in '{}'", specification) 35 | } 36 | } 37 | } 38 | } 39 | 40 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 41 | pub enum Priority { 42 | Realtime, 43 | High, 44 | AboveNormal, 45 | #[default] 46 | Normal, 47 | BelowNormal, 48 | Idle, 49 | } 50 | 51 | impl Priority { 52 | pub const ALL: &'static [&'static str] = &["realtime", "high", "above-normal", "normal", "below-normal", "idle"]; 53 | } 54 | 55 | impl Priority { 56 | pub fn to_cli(self) -> String { 57 | match self { 58 | Self::Realtime => "realtime", 59 | Self::High => "high", 60 | Self::AboveNormal => "above-normal", 61 | Self::Normal => "normal", 62 | Self::BelowNormal => "below-normal", 63 | Self::Idle => "idle", 64 | } 65 | .to_string() 66 | } 67 | 68 | pub fn to_windows(self) -> windows::Win32::System::Threading::PROCESS_CREATION_FLAGS { 69 | match self { 70 | Self::Realtime => windows::Win32::System::Threading::REALTIME_PRIORITY_CLASS, 71 | Self::High => windows::Win32::System::Threading::HIGH_PRIORITY_CLASS, 72 | Self::AboveNormal => windows::Win32::System::Threading::ABOVE_NORMAL_PRIORITY_CLASS, 73 | Self::Normal => windows::Win32::System::Threading::NORMAL_PRIORITY_CLASS, 74 | Self::BelowNormal => windows::Win32::System::Threading::BELOW_NORMAL_PRIORITY_CLASS, 75 | Self::Idle => windows::Win32::System::Threading::IDLE_PRIORITY_CLASS, 76 | } 77 | } 78 | } 79 | 80 | impl std::str::FromStr for Priority { 81 | type Err = String; 82 | 83 | fn from_str(s: &str) -> Result { 84 | match s { 85 | "realtime" => Ok(Self::Realtime), 86 | "high" => Ok(Self::High), 87 | "above-normal" => Ok(Self::AboveNormal), 88 | "normal" => Ok(Self::Normal), 89 | "below-normal" => Ok(Self::BelowNormal), 90 | "idle" => Ok(Self::Idle), 91 | _ => Err(format!("invalid priority: {}", s)), 92 | } 93 | } 94 | } 95 | 96 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 97 | pub enum LogRotation { 98 | Bytes(u64), 99 | Daily, 100 | Hourly, 101 | } 102 | 103 | impl LogRotation { 104 | pub fn to_cli(self) -> String { 105 | match self { 106 | LogRotation::Bytes(bytes) => format!("bytes={}", bytes), 107 | LogRotation::Daily => "daily".to_string(), 108 | LogRotation::Hourly => "hourly".to_string(), 109 | } 110 | } 111 | } 112 | 113 | impl std::str::FromStr for LogRotation { 114 | type Err = String; 115 | 116 | fn from_str(s: &str) -> Result { 117 | match s { 118 | "daily" => return Ok(Self::Daily), 119 | "hourly" => return Ok(Self::Hourly), 120 | _ => {} 121 | } 122 | 123 | if s.starts_with("bytes=") { 124 | let parts: Vec<_> = s.splitn(2, '=').collect(); 125 | match parts[1].parse::() { 126 | Ok(bytes) => return Ok(Self::Bytes(bytes)), 127 | Err(e) => return Err(format!("Unable to parse log rotation as bytes: {:?}", e)), 128 | } 129 | } 130 | 131 | Err(format!("Unable to parse log rotation: {}", s)) 132 | } 133 | } 134 | 135 | impl Default for LogRotation { 136 | fn default() -> Self { 137 | Self::Bytes(1024 * 1024 * 2) 138 | } 139 | } 140 | 141 | fn parse_env_var(value: &str) -> Result<(String, String), CliError> { 142 | let parts: Vec<&str> = value.splitn(2, '=').collect(); 143 | if parts.len() != 2 { 144 | return Err(CliError::InvalidEnvVar { 145 | specification: value.to_string(), 146 | }); 147 | } 148 | Ok((parts[0].to_string(), parts[1].to_string())) 149 | } 150 | 151 | fn styles() -> clap::builder::styling::Styles { 152 | use clap::builder::styling::{AnsiColor, Effects, Styles}; 153 | 154 | Styles::styled() 155 | .header(AnsiColor::Yellow.on_default() | Effects::BOLD) 156 | .usage(AnsiColor::Yellow.on_default() | Effects::BOLD) 157 | .literal(AnsiColor::Green.on_default() | Effects::BOLD) 158 | .placeholder(AnsiColor::Green.on_default()) 159 | } 160 | 161 | #[derive(clap::Parser, Clone, Debug, Default, PartialEq, Eq)] 162 | pub struct CommonOpts { 163 | /// Exit codes that should be considered successful (comma-separated) [default: 0] 164 | #[clap( 165 | long, 166 | value_name = "codes", 167 | value_delimiter = ',', 168 | number_of_values = 1, 169 | allow_hyphen_values(true) 170 | )] 171 | pub pass: Option>, 172 | 173 | /// Always restart the command regardless of the exit code 174 | #[clap( 175 | long, 176 | conflicts_with("no_restart"), 177 | conflicts_with("restart_if"), 178 | conflicts_with("restart_if_not") 179 | )] 180 | pub restart: bool, 181 | 182 | /// Never restart the command regardless of the exit code 183 | #[clap( 184 | long, 185 | conflicts_with("restart"), 186 | conflicts_with("restart_if"), 187 | conflicts_with("restart_if_not") 188 | )] 189 | pub no_restart: bool, 190 | 191 | /// Restart the command if the exit code is one of these (comma-separated) 192 | #[clap( 193 | long, 194 | conflicts_with("restart"), 195 | conflicts_with("no_restart"), 196 | conflicts_with("restart_if_not"), 197 | value_name = "codes", 198 | value_delimiter = ',', 199 | number_of_values = 1, 200 | allow_hyphen_values(true) 201 | )] 202 | pub restart_if: Vec, 203 | 204 | /// Restart the command if the exit code is not one of these (comma-separated) 205 | #[clap( 206 | long, 207 | conflicts_with("restart"), 208 | conflicts_with("no_restart"), 209 | conflicts_with("restart_if"), 210 | value_name = "codes", 211 | value_delimiter = ',', 212 | number_of_values = 1, 213 | allow_hyphen_values(true) 214 | )] 215 | pub restart_if_not: Vec, 216 | 217 | /// How long to wait before restarting the wrapped process 218 | #[clap(long, value_name = "ms")] 219 | pub restart_delay: Option, 220 | 221 | /// How long to wait in milliseconds between sending the wrapped process 222 | /// a ctrl-C event and forcibly killing it [default: 3000] 223 | #[clap(long, value_name = "ms")] 224 | pub stop_timeout: Option, 225 | 226 | /// Disable all of Shawl's logging 227 | #[clap(long)] 228 | pub no_log: bool, 229 | 230 | /// Disable logging of output from the command running as a service 231 | #[clap(long)] 232 | pub no_log_cmd: bool, 233 | 234 | /// Write log file to a custom directory. This directory will be created if it doesn't exist. 235 | #[clap(long, value_name = "path", value_parser = parse_ensured_directory)] 236 | pub log_dir: Option, 237 | 238 | /// Use a different name for the main log file. 239 | /// Set this to just the desired base name of the log file. 240 | /// For example, `--log-as shawl` would result in a log file named `shawl_rCURRENT.log` 241 | /// instead of the normal `shawl_for__rCURRENT.log` pattern. 242 | #[clap(long)] 243 | pub log_as: Option, 244 | 245 | /// Use a separate log file for the wrapped command's stdout and stderr. 246 | /// Set this to just the desired base name of the log file. 247 | /// For example, `--log-cmd-as foo` would result in a log file named `foo_rCURRENT.log`. 248 | /// The output will be logged as-is without any additional log template. 249 | #[clap(long)] 250 | pub log_cmd_as: Option, 251 | 252 | /// Threshold for rotating log files. Valid options: 253 | /// `daily`, `hourly`, `bytes=n` (every N bytes) 254 | /// [default: bytes=2097152] 255 | #[clap(long)] 256 | pub log_rotate: Option, 257 | 258 | /// How many old log files to retain [default: 2] 259 | #[clap(long)] 260 | pub log_retain: Option, 261 | 262 | /// Append the service start arguments to the command 263 | #[clap(long)] 264 | pub pass_start_args: bool, 265 | 266 | /// Additional environment variable in the format 'KEY=value' (repeatable) 267 | #[clap(long, number_of_values = 1, value_parser = parse_env_var)] 268 | pub env: Vec<(String, String)>, 269 | 270 | /// Additional directory to append to the PATH environment variable (repeatable) 271 | #[clap(long, number_of_values = 1, value_parser = parse_canonical_path)] 272 | pub path: Vec, 273 | 274 | /// Additional directory to prepend to the PATH environment variable (repeatable) 275 | #[clap(long, value_name = "path", number_of_values = 1, value_parser = parse_canonical_path)] 276 | pub path_prepend: Vec, 277 | 278 | /// Process priority of the command to run as a service 279 | #[clap(long, value_parser = possible_values!(Priority, ALL))] 280 | pub priority: Option, 281 | 282 | /// Kill the entire process tree when the service stops. 283 | /// Uses a Windows Job Object to track and terminate all child processes automatically. 284 | #[clap(long)] 285 | pub kill_process_tree: bool, 286 | 287 | /// Command to run as a service 288 | #[clap(required(true), last(true))] 289 | pub command: Vec, 290 | } 291 | 292 | #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] 293 | pub enum Subcommand { 294 | #[clap(about = "Add a new service")] 295 | Add { 296 | #[clap(flatten)] 297 | common: CommonOpts, 298 | 299 | /// Working directory in which to run the command. You may provide a 300 | /// relative path, and it will be converted to an absolute one 301 | #[clap(long, value_name = "path", value_parser = parse_canonical_path)] 302 | cwd: Option, 303 | 304 | /// Other services that must be started first (comma-separated) 305 | #[clap(long, value_delimiter = ',')] 306 | dependencies: Vec, 307 | 308 | /// Name of the service to create 309 | #[clap(long)] 310 | name: String, 311 | }, 312 | #[clap(about = "Run a command as a service; only works when launched by the Windows service manager")] 313 | Run { 314 | #[clap(flatten)] 315 | common: CommonOpts, 316 | 317 | /// Working directory in which to run the command. Must be an absolute path 318 | #[clap(long, value_name = "path")] 319 | cwd: Option, 320 | 321 | /// Name of the service; used in logging, but does not need to match real name 322 | #[clap(long, default_value = "Shawl")] 323 | name: String, 324 | }, 325 | } 326 | 327 | #[derive(clap::Parser, Clone, Debug, PartialEq, Eq)] 328 | #[clap( 329 | name = "shawl", 330 | version, 331 | about = "Wrap arbitrary commands as Windows services", 332 | max_term_width = 100, 333 | subcommand_negates_reqs = true, 334 | next_line_help = true, 335 | styles = styles() 336 | )] 337 | pub struct Cli { 338 | #[clap(subcommand)] 339 | pub sub: Subcommand, 340 | } 341 | 342 | #[cfg(test)] 343 | speculate::speculate! { 344 | fn check_args(args: &[&str], expected: Cli) { 345 | assert_eq!( 346 | expected, 347 | Cli::parse_from(args) 348 | ); 349 | } 350 | 351 | fn check_args_err(args: &[&str], error: clap::error::ErrorKind) { 352 | let result = Cli::try_parse_from(args); 353 | assert!(result.is_err()); 354 | assert_eq!(result.unwrap_err().kind(), error); 355 | } 356 | 357 | fn s(text: &str) -> String { 358 | text.to_string() 359 | } 360 | 361 | fn p(path: &str) -> String { 362 | std::fs::canonicalize(&path).unwrap().to_string_lossy().to_string() 363 | } 364 | 365 | describe "run subcommand" { 366 | it "works with minimal arguments" { 367 | check_args( 368 | &["shawl", "run", "--", "foo"], 369 | Cli { 370 | sub: Subcommand::Run { 371 | name: s("Shawl"), 372 | cwd: None, 373 | common: CommonOpts { 374 | command: vec![s("foo")], 375 | ..Default::default() 376 | } 377 | } 378 | }, 379 | ); 380 | } 381 | 382 | it "requires a command" { 383 | check_args_err( 384 | &["shawl", "run"], 385 | clap::error::ErrorKind::MissingRequiredArgument, 386 | ); 387 | } 388 | 389 | it "accepts --pass" { 390 | check_args( 391 | &["shawl", "run", "--pass", "1,2", "--", "foo"], 392 | Cli { 393 | sub: Subcommand::Run { 394 | name: s("Shawl"), 395 | cwd: None, 396 | common: CommonOpts { 397 | pass: Some(vec![1, 2]), 398 | command: vec![s("foo")], 399 | ..Default::default() 400 | } 401 | } 402 | }, 403 | ); 404 | } 405 | 406 | it "accepts --pass with leading negative" { 407 | check_args( 408 | &["shawl", "run", "--pass", "-1", "--", "foo"], 409 | Cli { 410 | sub: Subcommand::Run { 411 | name: s("Shawl"), 412 | cwd: None, 413 | common: CommonOpts { 414 | pass: Some(vec![-1]), 415 | command: vec![s("foo")], 416 | ..Default::default() 417 | } 418 | } 419 | }, 420 | ); 421 | } 422 | 423 | it "rejects --pass without value" { 424 | check_args_err( 425 | &["shawl", "run", "--pass", "--", "foo"], 426 | clap::error::ErrorKind::UnknownArgument, 427 | ); 428 | } 429 | 430 | it "accepts --restart" { 431 | check_args( 432 | &["shawl", "run", "--restart", "--", "foo"], 433 | Cli { 434 | sub: Subcommand::Run { 435 | name: s("Shawl"), 436 | cwd: None, 437 | common: CommonOpts { 438 | restart: true, 439 | command: vec![s("foo")], 440 | ..Default::default() 441 | } 442 | } 443 | }, 444 | ); 445 | } 446 | 447 | it "rejects --restart with conflicting options" { 448 | for case in [ 449 | vec!["shawl", "run", "--restart", "--no-restart", "--", "foo"], 450 | vec!["shawl", "run", "--restart", "--restart-if", "1", "--", "foo"], 451 | vec!["shawl", "run", "--restart", "--restart-if-not", "1", "--", "foo"], 452 | ] { 453 | check_args_err( 454 | &case, 455 | clap::error::ErrorKind::ArgumentConflict, 456 | ); 457 | } 458 | } 459 | 460 | it "accepts --no-restart" { 461 | check_args( 462 | &["shawl", "run", "--no-restart", "--", "foo"], 463 | Cli { 464 | sub: Subcommand::Run { 465 | name: s("Shawl"), 466 | cwd: None, 467 | common: CommonOpts { 468 | no_restart: true, 469 | command: vec![s("foo")], 470 | ..Default::default() 471 | } 472 | } 473 | }, 474 | ); 475 | } 476 | 477 | it "rejects --no-restart with conflicting options" { 478 | for case in [ 479 | vec!["shawl", "run", "--no-restart", "--restart", "--", "foo"], 480 | vec!["shawl", "run", "--no-restart", "--restart-if", "1", "--", "foo"], 481 | vec!["shawl", "run", "--no-restart", "--restart-if-not", "1", "--", "foo"], 482 | ] { 483 | check_args_err( 484 | &case, 485 | clap::error::ErrorKind::ArgumentConflict, 486 | ); 487 | } 488 | } 489 | 490 | it "accepts --restart-if" { 491 | check_args( 492 | &["shawl", "run", "--restart-if", "1,2", "--", "foo"], 493 | Cli { 494 | sub: Subcommand::Run { 495 | name: s("Shawl"), 496 | cwd: None, 497 | common: CommonOpts { 498 | restart_if: vec![1, 2], 499 | command: vec![s("foo")], 500 | ..Default::default() 501 | } 502 | } 503 | }, 504 | ); 505 | } 506 | 507 | it "accepts --restart-if with leading negative" { 508 | check_args( 509 | &["shawl", "run", "--restart-if", "-1", "--", "foo"], 510 | Cli { 511 | sub: Subcommand::Run { 512 | name: s("Shawl"), 513 | cwd: None, 514 | common: CommonOpts { 515 | restart_if: vec![-1], 516 | command: vec![s("foo")], 517 | ..Default::default() 518 | } 519 | } 520 | }, 521 | ); 522 | } 523 | 524 | it "rejects --restart-if without value" { 525 | check_args_err( 526 | &["shawl", "run", "--restart-if", "--", "foo"], 527 | clap::error::ErrorKind::UnknownArgument, 528 | ); 529 | } 530 | 531 | it "rejects --restart-if with conflicting options" { 532 | for case in [ 533 | vec!["shawl", "run", "--restart-if", "0", "--restart", "--", "foo"], 534 | vec!["shawl", "run", "--restart-if", "0", "--no-restart", "--", "foo"], 535 | vec!["shawl", "run", "--restart-if", "0", "--restart-if-not", "1", "--", "foo"], 536 | ] { 537 | check_args_err( 538 | &case, 539 | clap::error::ErrorKind::ArgumentConflict, 540 | ); 541 | } 542 | } 543 | 544 | it "accepts --restart-if-not" { 545 | check_args( 546 | &["shawl", "run", "--restart-if-not", "1,2", "--", "foo"], 547 | Cli { 548 | sub: Subcommand::Run { 549 | name: s("Shawl"), 550 | cwd: None, 551 | common: CommonOpts { 552 | restart_if_not: vec![1, 2], 553 | command: vec![s("foo")], 554 | ..Default::default() 555 | } 556 | } 557 | }, 558 | ); 559 | } 560 | 561 | it "accepts --restart-if-not with leading negative" { 562 | check_args( 563 | &["shawl", "run", "--restart-if-not", "-1", "--", "foo"], 564 | Cli { 565 | sub: Subcommand::Run { 566 | name: s("Shawl"), 567 | cwd: None, 568 | common: CommonOpts { 569 | restart_if_not: vec![-1], 570 | command: vec![s("foo")], 571 | ..Default::default() 572 | } 573 | } 574 | }, 575 | ); 576 | } 577 | 578 | it "rejects --restart-if-not without value" { 579 | check_args_err( 580 | &["shawl", "run", "--restart-if-not", "--", "foo"], 581 | clap::error::ErrorKind::UnknownArgument, 582 | ); 583 | } 584 | 585 | it "rejects --restart-if-not with conflicting options" { 586 | for case in [ 587 | vec!["shawl", "run", "--restart-if-not", "0", "--restart", "--", "foo"], 588 | vec!["shawl", "run", "--restart-if-not", "0", "--no-restart", "--", "foo"], 589 | vec!["shawl", "run", "--restart-if-not", "0", "--restart-if", "1", "--", "foo"], 590 | ] { 591 | check_args_err( 592 | &case, 593 | clap::error::ErrorKind::ArgumentConflict, 594 | ); 595 | } 596 | } 597 | 598 | it "accepts --restart-delay" { 599 | check_args( 600 | &["shawl", "run", "--restart-delay", "1500", "--", "foo"], 601 | Cli { 602 | sub: Subcommand::Run { 603 | name: s("Shawl"), 604 | cwd: None, 605 | common: CommonOpts { 606 | restart_delay: Some(1500), 607 | command: vec![s("foo")], 608 | ..Default::default() 609 | } 610 | } 611 | }, 612 | ); 613 | } 614 | 615 | it "accepts --stop-timeout" { 616 | check_args( 617 | &["shawl", "run", "--stop-timeout", "500", "--", "foo"], 618 | Cli { 619 | sub: Subcommand::Run { 620 | name: s("Shawl"), 621 | cwd: None, 622 | common: CommonOpts { 623 | stop_timeout: Some(500), 624 | command: vec![s("foo")], 625 | ..Default::default() 626 | } 627 | } 628 | }, 629 | ); 630 | } 631 | 632 | it "accepts --name" { 633 | check_args( 634 | &["shawl", "run", "--name", "custom-name", "--", "foo"], 635 | Cli { 636 | sub: Subcommand::Run { 637 | name: s("custom-name"), 638 | cwd: None, 639 | common: CommonOpts { 640 | command: vec![s("foo")], 641 | ..Default::default() 642 | } 643 | } 644 | }, 645 | ); 646 | } 647 | } 648 | 649 | describe "add subcommand" { 650 | it "works with minimal arguments" { 651 | check_args( 652 | &["shawl", "add", "--name", "custom-name", "--", "foo"], 653 | Cli { 654 | sub: Subcommand::Add { 655 | name: s("custom-name"), 656 | cwd: None, 657 | dependencies: vec![], 658 | common: CommonOpts { 659 | command: vec![s("foo")], 660 | ..Default::default() 661 | } 662 | } 663 | }, 664 | ); 665 | } 666 | 667 | it "requires a command" { 668 | check_args_err( 669 | &["shawl", "add", "--name", "foo"], 670 | clap::error::ErrorKind::MissingRequiredArgument, 671 | ); 672 | } 673 | 674 | it "requires a name" { 675 | check_args_err( 676 | &["shawl", "add", "--", "foo"], 677 | clap::error::ErrorKind::MissingRequiredArgument, 678 | ); 679 | } 680 | 681 | it "accepts --pass" { 682 | check_args( 683 | &["shawl", "add", "--pass", "1,2", "--name", "foo", "--", "foo"], 684 | Cli { 685 | sub: Subcommand::Add { 686 | name: s("foo"), 687 | cwd: None, 688 | dependencies: vec![], 689 | common: CommonOpts { 690 | pass: Some(vec![1, 2]), 691 | command: vec![s("foo")], 692 | ..Default::default() 693 | } 694 | } 695 | }, 696 | ); 697 | } 698 | 699 | it "accepts --restart" { 700 | check_args( 701 | &["shawl", "add", "--restart", "--name", "foo", "--", "foo"], 702 | Cli { 703 | sub: Subcommand::Add { 704 | name: s("foo"), 705 | cwd: None, 706 | dependencies: vec![], 707 | common: CommonOpts { 708 | restart: true, 709 | command: vec![s("foo")], 710 | ..Default::default() 711 | } 712 | } 713 | }, 714 | ); 715 | } 716 | 717 | it "accepts --no-restart" { 718 | check_args( 719 | &["shawl", "add", "--no-restart", "--name", "foo", "--", "foo"], 720 | Cli { 721 | sub: Subcommand::Add { 722 | name: s("foo"), 723 | cwd: None, 724 | dependencies: vec![], 725 | common: CommonOpts { 726 | no_restart: true, 727 | command: vec![s("foo")], 728 | ..Default::default() 729 | } 730 | } 731 | }, 732 | ); 733 | } 734 | 735 | it "accepts --restart-if" { 736 | check_args( 737 | &["shawl", "add", "--restart-if", "1,2", "--name", "foo", "--", "foo"], 738 | Cli { 739 | sub: Subcommand::Add { 740 | name: s("foo"), 741 | cwd: None, 742 | dependencies: vec![], 743 | common: CommonOpts { 744 | restart_if: vec![1, 2], 745 | command: vec![s("foo")], 746 | ..Default::default() 747 | } 748 | } 749 | }, 750 | ); 751 | } 752 | 753 | it "accepts --restart-if-not" { 754 | check_args( 755 | &["shawl", "add", "--restart-if-not", "1,2", "--name", "foo", "--", "foo"], 756 | Cli { 757 | sub: Subcommand::Add { 758 | name: s("foo"), 759 | cwd: None, 760 | dependencies: vec![], 761 | common: CommonOpts { 762 | restart_if_not: vec![1, 2], 763 | command: vec![s("foo")], 764 | ..Default::default() 765 | } 766 | } 767 | }, 768 | ); 769 | } 770 | 771 | it "accepts --stop-timeout" { 772 | check_args( 773 | &["shawl", "add", "--stop-timeout", "500", "--name", "foo", "--", "foo"], 774 | Cli { 775 | sub: Subcommand::Add { 776 | name: s("foo"), 777 | cwd: None, 778 | dependencies: vec![], 779 | common: CommonOpts { 780 | stop_timeout: Some(500), 781 | command: vec![s("foo")], 782 | ..Default::default() 783 | } 784 | } 785 | }, 786 | ); 787 | } 788 | 789 | it "accepts --no-log" { 790 | check_args( 791 | &["shawl", "run", "--no-log", "--", "foo"], 792 | Cli { 793 | sub: Subcommand::Run { 794 | name: s("Shawl"), 795 | cwd: None, 796 | common: CommonOpts { 797 | no_log: true, 798 | command: vec![s("foo")], 799 | ..Default::default() 800 | } 801 | } 802 | }, 803 | ); 804 | } 805 | 806 | it "accepts --no-log-cmd" { 807 | check_args( 808 | &["shawl", "run", "--no-log-cmd", "--", "foo"], 809 | Cli { 810 | sub: Subcommand::Run { 811 | name: s("Shawl"), 812 | cwd: None, 813 | common: CommonOpts { 814 | no_log_cmd: true, 815 | command: vec![s("foo")], 816 | ..Default::default() 817 | } 818 | } 819 | }, 820 | ); 821 | } 822 | 823 | it "accepts --log-as" { 824 | check_args( 825 | &["shawl", "run", "--log-as", "foo", "--", "foo"], 826 | Cli { 827 | sub: Subcommand::Run { 828 | name: s("Shawl"), 829 | cwd: None, 830 | common: CommonOpts { 831 | log_as: Some("foo".to_string()), 832 | command: vec![s("foo")], 833 | ..Default::default() 834 | } 835 | } 836 | }, 837 | ); 838 | } 839 | 840 | it "accepts --log-cmd-as" { 841 | check_args( 842 | &["shawl", "run", "--log-cmd-as", "foo", "--", "foo"], 843 | Cli { 844 | sub: Subcommand::Run { 845 | name: s("Shawl"), 846 | cwd: None, 847 | common: CommonOpts { 848 | log_cmd_as: Some("foo".to_string()), 849 | command: vec![s("foo")], 850 | ..Default::default() 851 | } 852 | } 853 | }, 854 | ); 855 | } 856 | 857 | it "accepts --log-rotate bytes=n" { 858 | check_args( 859 | &["shawl", "run", "--log-rotate", "bytes=123", "--", "foo"], 860 | Cli { 861 | sub: Subcommand::Run { 862 | name: s("Shawl"), 863 | cwd: None, 864 | common: CommonOpts { 865 | log_rotate: Some(LogRotation::Bytes(123)), 866 | command: vec![s("foo")], 867 | ..Default::default() 868 | } 869 | } 870 | }, 871 | ); 872 | } 873 | 874 | it "accepts --log-rotate daily" { 875 | check_args( 876 | &["shawl", "run", "--log-rotate", "daily", "--", "foo"], 877 | Cli { 878 | sub: Subcommand::Run { 879 | name: s("Shawl"), 880 | cwd: None, 881 | common: CommonOpts { 882 | log_rotate: Some(LogRotation::Daily), 883 | command: vec![s("foo")], 884 | ..Default::default() 885 | } 886 | } 887 | }, 888 | ); 889 | } 890 | 891 | it "accepts --log-rotate hourly" { 892 | check_args( 893 | &["shawl", "run", "--log-rotate", "hourly", "--", "foo"], 894 | Cli { 895 | sub: Subcommand::Run { 896 | name: s("Shawl"), 897 | cwd: None, 898 | common: CommonOpts { 899 | log_rotate: Some(LogRotation::Hourly), 900 | command: vec![s("foo")], 901 | ..Default::default() 902 | } 903 | } 904 | }, 905 | ); 906 | } 907 | 908 | it "accepts --log-retain" { 909 | check_args( 910 | &["shawl", "run", "--log-retain", "5", "--", "foo"], 911 | Cli { 912 | sub: Subcommand::Run { 913 | name: s("Shawl"), 914 | cwd: None, 915 | common: CommonOpts { 916 | log_retain: Some(5), 917 | command: vec![s("foo")], 918 | ..Default::default() 919 | } 920 | } 921 | }, 922 | ); 923 | } 924 | 925 | it "accepts --log-dir" { 926 | let path = env!("CARGO_MANIFEST_DIR"); 927 | check_args( 928 | &["shawl", "run", "--log-dir", path, "--", "foo"], 929 | Cli { 930 | sub: Subcommand::Run { 931 | name: s("Shawl"), 932 | cwd: None, 933 | common: CommonOpts { 934 | log_dir: Some(p(path)), 935 | command: vec![s("foo")], 936 | ..Default::default() 937 | } 938 | } 939 | }, 940 | ); 941 | } 942 | 943 | it "accepts --pass-start-args" { 944 | check_args( 945 | &["shawl", "run", "--pass-start-args", "--", "foo"], 946 | Cli { 947 | sub: Subcommand::Run { 948 | name: s("Shawl"), 949 | cwd: None, 950 | common: CommonOpts { 951 | pass_start_args: true, 952 | command: vec![s("foo")], 953 | ..Default::default() 954 | } 955 | } 956 | }, 957 | ); 958 | } 959 | 960 | it "accepts --kill-process-tree" { 961 | check_args( 962 | &["shawl", "run", "--kill-process-tree", "--", "foo"], 963 | Cli { 964 | sub: Subcommand::Run { 965 | name: s("Shawl"), 966 | cwd: None, 967 | common: CommonOpts { 968 | kill_process_tree: true, 969 | command: vec![s("foo")], 970 | ..Default::default() 971 | } 972 | } 973 | }, 974 | ); 975 | } 976 | 977 | it "accepts --env" { 978 | check_args( 979 | &["shawl", "add", "--env", "FOO=bar", "--name", "foo", "--", "foo"], 980 | Cli { 981 | sub: Subcommand::Add { 982 | name: s("foo"), 983 | cwd: None, 984 | dependencies: vec![], 985 | common: CommonOpts { 986 | env: vec![(s("FOO"), s("bar"))], 987 | command: vec![s("foo")], 988 | ..Default::default() 989 | } 990 | } 991 | }, 992 | ); 993 | } 994 | 995 | it "accepts --env multiple times" { 996 | check_args( 997 | &["shawl", "add", "--env", "FOO=1", "--env", "BAR=2", "--name", "foo", "--", "foo"], 998 | Cli { 999 | sub: Subcommand::Add { 1000 | name: s("foo"), 1001 | cwd: None, 1002 | dependencies: vec![], 1003 | common: CommonOpts { 1004 | env: vec![(s("FOO"), s("1")), (s("BAR"), s("2"))], 1005 | command: vec![s("foo")], 1006 | ..Default::default() 1007 | } 1008 | } 1009 | }, 1010 | ); 1011 | } 1012 | 1013 | it "accepts --path" { 1014 | let path = env!("CARGO_MANIFEST_DIR"); 1015 | check_args( 1016 | &["shawl", "add", "--path", path, "--name", "foo", "--", "foo"], 1017 | Cli { 1018 | sub: Subcommand::Add { 1019 | name: s("foo"), 1020 | cwd: None, 1021 | dependencies: vec![], 1022 | common: CommonOpts { 1023 | path: vec![p(path)], 1024 | command: vec![s("foo")], 1025 | ..Default::default() 1026 | } 1027 | } 1028 | }, 1029 | ); 1030 | } 1031 | 1032 | it "accepts --path multiple times" { 1033 | let path1 = format!("{}/target", env!("CARGO_MANIFEST_DIR")); 1034 | let path2 = format!("{}/src", env!("CARGO_MANIFEST_DIR")); 1035 | check_args( 1036 | &["shawl", "add", "--path", &path1, "--path", &path2, "--name", "foo", "--", "foo"], 1037 | Cli { 1038 | sub: Subcommand::Add { 1039 | name: s("foo"), 1040 | cwd: None, 1041 | dependencies: vec![], 1042 | common: CommonOpts { 1043 | path: vec![p(&path1), p(&path2)], 1044 | command: vec![s("foo")], 1045 | ..Default::default() 1046 | } 1047 | } 1048 | }, 1049 | ); 1050 | } 1051 | 1052 | it "accepts --path-prepend" { 1053 | let path = env!("CARGO_MANIFEST_DIR"); 1054 | check_args( 1055 | &["shawl", "add", "--path-prepend", path, "--name", "foo", "--", "foo"], 1056 | Cli { 1057 | sub: Subcommand::Add { 1058 | name: s("foo"), 1059 | cwd: None, 1060 | dependencies: vec![], 1061 | common: CommonOpts { 1062 | path_prepend: vec![p(path)], 1063 | command: vec![s("foo")], 1064 | ..Default::default() 1065 | } 1066 | } 1067 | }, 1068 | ); 1069 | } 1070 | 1071 | it "accepts --path-prepend multiple times" { 1072 | let path1 = format!("{}/target", env!("CARGO_MANIFEST_DIR")); 1073 | let path2 = format!("{}/src", env!("CARGO_MANIFEST_DIR")); 1074 | check_args( 1075 | &["shawl", "add", "--path-prepend", &path1, "--path-prepend", &path2, "--name", "foo", "--", "foo"], 1076 | Cli { 1077 | sub: Subcommand::Add { 1078 | name: s("foo"), 1079 | cwd: None, 1080 | dependencies: vec![], 1081 | common: CommonOpts { 1082 | path_prepend: vec![p(&path1), p(&path2)], 1083 | command: vec![s("foo")], 1084 | ..Default::default() 1085 | } 1086 | } 1087 | }, 1088 | ); 1089 | } 1090 | } 1091 | } 1092 | --------------------------------------------------------------------------------