From e55cff15eecaaed7e4dd635f47ebd1789d52f601 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 13 Jan 2026 10:25:07 -0800 Subject: [PATCH] Rename 'script' to 'command' with intelligent path resolution - Rename script argument to command throughout codebase - Add resolve_command() function for smart path/command handling - Paths (containing /, ~, or .) are expanded to absolute paths and validated - Commands (no /) are searched on $PATH and kept as-is in plist - Add comprehensive test suite with 13 new tests for path resolution - Verify all resolved paths stay within expected boundaries - Update README with command resolution details and examples Co-Authored-By: Claude Sonnet 4.5 --- README.md | 72 +++++++++--- mcquack.py | 175 +++++++++++++++++++-------- test_mcquack.py | 305 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 488 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 6a330ae..a0f5893 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mcquack -A simple macOS LaunchAgent manager for executable scripts. +A simple macOS LaunchAgent manager for executable commands and scripts. Named after Launchpad McQuack, the fearless (if accident-prone) pilot from DuckTales. @@ -28,7 +28,10 @@ mcquack list # Create and load an executable as a LaunchAgent mcquack create /path/to/your/script -# Create with additional arguments for the script (note the -- separator) +# Or use a command from $PATH +mcquack create mycommand + +# Create with additional arguments (note the -- separator) mcquack create /path/to/your/script -- --arg1 value1 --arg2 # Kickstart (immediately run) the LaunchAgent @@ -47,24 +50,51 @@ mcquack unload /path/to/your/script mcquack delete /path/to/your/script ``` -### Passing Arguments to Your Script +### Command Resolution: Paths vs Commands -When passing arguments to your script with `create`, you **must** use `--` to separate -mcquack's options from arguments intended for your script: +mcquack intelligently handles two types of arguments: + +**Paths** (contains `/`, `~`, or `.`): +- Relative paths: `./script.sh`, `../bin/tool` +- Absolute paths: `/usr/local/bin/script` +- Home paths: `~/bin/myscript` +- All paths are expanded and resolved to absolute paths +- Symlinks are resolved to their targets + +**Commands** (no `/`): +- Searched on `$PATH`: `python`, `node`, `mycommand` +- Stored as-is (not expanded to absolute paths) +- Must exist on `$PATH` or creation fails ```bash -# Correct: passes --config and --debug to your script +# Path examples (expanded to absolute paths in plist) +mcquack create ./myscript.sh +mcquack create ~/bin/backup.sh +mcquack create ../tools/monitor + +# Command examples (kept as-is in plist) +mcquack create python -- -m http.server 8000 +mcquack create node -- server.js +``` + +### Passing Arguments to Your Command + +When passing arguments to your command with `create`, you **must** use `--` to separate +mcquack's options from arguments intended for your command: + +```bash +# Correct: passes --config and --debug to your command mcquack create my_script.sh -- --config /path/to/config --debug # Wrong: --help is interpreted as mcquack's --help flag, shows help instead mcquack create my_script.sh --help -# Correct: passes --help as an argument to your script +# Correct: passes --help as an argument to your command mcquack create my_script.sh -- --help ``` The `--` separator ensures that flags like `--help`, `--verbose`, etc. are passed to your -script rather than being interpreted by mcquack itself. +command rather than being interpreted by mcquack itself. To modify arguments after creation, use `mcquack edit` to open the plist in your editor. @@ -73,13 +103,29 @@ To modify arguments after creation, use `mcquack edit` to open the plist in your mcquack creates plist files in `~/Library/LaunchAgents/` with the naming convention: ``` -mcquack.eblume..plist +mcquack.eblume..plist ``` -The generated plist configures the script to: -- Run at load -- Keep alive (restart if it exits) -- Log stdout/stderr to `~/Library/Logs/mcquack..{out,err}.log` +The generated plist configures the command to: +- Run at load (or on schedule if `--interval` or `--calendar` specified) +- Keep alive (restart if it exits, unless scheduled) +- Log stdout/stderr to `~/Library/Logs/mcquack..{out,err}.log` + +### Path Resolution Details + +When you provide a path (containing `/`, `~`, or `.`), mcquack: +1. Expands `~` to your home directory +2. Resolves relative paths to absolute paths +3. Resolves `..` and `.` components +4. Resolves symlinks to their targets +5. Validates the file exists and is executable +6. Stores the absolute path in the plist + +When you provide a command name (no `/`), mcquack: +1. Searches for the command on your `$PATH` +2. Validates the command is executable +3. Stores the command name as-is in the plist (not the full path) +4. Lets launchd resolve the command at runtime using its environment ## Development diff --git a/mcquack.py b/mcquack.py index d257fb2..7b242ed 100755 --- a/mcquack.py +++ b/mcquack.py @@ -7,6 +7,7 @@ import os import plistlib +import shutil import subprocess from pathlib import Path from typing import Annotated @@ -20,15 +21,66 @@ LOGS_DIR = Path.home() / "Library" / "Logs" PLIST_PREFIX = "mcquack.eblume" -def get_plist_path(script_path: Path) -> Path: - """Get the plist path for a given script.""" - label = f"{PLIST_PREFIX}.{script_path.stem}" +def get_plist_path(command_path: Path) -> Path: + """Get the plist path for a given command.""" + label = f"{PLIST_PREFIX}.{command_path.stem}" return LAUNCH_AGENTS_DIR / f"{label}.plist" -def get_label(script_path: Path) -> str: - """Get the launchd label for a given script.""" - return f"{PLIST_PREFIX}.{script_path.stem}" +def get_label(command_path: Path) -> str: + """Get the launchd label for a given command.""" + return f"{PLIST_PREFIX}.{command_path.stem}" + + +def resolve_command(command: str) -> tuple[Path, bool]: + """Resolve a command to a Path and indicate if it should be kept as command name. + + Returns: + tuple: (resolved_path, keep_as_command_name) + - resolved_path: The Path to the executable + - keep_as_command_name: True if command should remain as-is (not expanded to absolute) + + If command is a path (contains / or starts with ~ or .): + - Expands ~ and resolves to absolute path + - Validates existence and executability + - Returns (resolved_path, False) to use absolute path in plist + + If command is not a path (no /): + - Searches $PATH using shutil.which() + - Returns (path_to_command, True) to keep command name as-is + - Raises error if not found on PATH + """ + # Check if this is a path (contains / or starts with ~ or .) + is_path = "/" in command or command.startswith("~") or command.startswith(".") + + if is_path: + # Treat as path: expand and resolve + path = Path(command).expanduser().resolve() + + if not path.exists(): + typer.echo(f"Error: {path} does not exist.", err=True) + raise typer.Exit(1) + + if not os.access(path, os.X_OK): + typer.echo(f"Error: {path} is not executable.", err=True) + raise typer.Exit(1) + + return (path, False) + else: + # Not a path: search $PATH + which_result = shutil.which(command) + + if which_result is None: + typer.echo(f"Error: command '{command}' not found in PATH.", err=True) + raise typer.Exit(1) + + # Verify the found command is executable + if not os.access(which_result, os.X_OK): + typer.echo(f"Error: {which_result} is not executable.", err=True) + raise typer.Exit(1) + + # Return the command name as-is (don't expand to absolute path) + return (Path(command), True) def parse_calendar(calendar_str: str) -> dict[str, int]: @@ -79,22 +131,34 @@ def parse_calendar(calendar_str: str) -> dict[str, int]: def create_plist_dict( - script_path: Path, + command_path: Path, args: list[str], + program_arg: str | None = None, interval: int | None = None, throttle: int | None = None, calendar: list[dict[str, int]] | None = None, ) -> dict: - """Create a plist dictionary for a script.""" - label = get_label(script_path) - program_args = [str(script_path.resolve())] + args + """Create a plist dictionary for a command. + + Args: + command_path: Path used for label generation (stem) + args: Additional arguments to pass to the program + program_arg: The actual program path/command to execute (if None, uses command_path.resolve()) + interval: StartInterval value + throttle: ThrottleInterval value + calendar: StartCalendarInterval value + """ + label = get_label(command_path) + # Use program_arg if provided, otherwise fall back to resolved command_path + program = program_arg if program_arg is not None else str(command_path.resolve()) + program_args = [program] + args plist = { "Label": label, "ProgramArguments": program_args, "RunAtLoad": True, - "StandardOutPath": str(LOGS_DIR / f"mcquack.{script_path.stem}.out.log"), - "StandardErrorPath": str(LOGS_DIR / f"mcquack.{script_path.stem}.err.log"), + "StandardOutPath": str(LOGS_DIR / f"mcquack.{command_path.stem}.out.log"), + "StandardErrorPath": str(LOGS_DIR / f"mcquack.{command_path.stem}.err.log"), } # If scheduling is used, don't keep alive (let the schedule handle it) @@ -135,18 +199,18 @@ def list_agents() -> None: plist = plistlib.load(f) label = plist.get("Label", "unknown") program_args = plist.get("ProgramArguments", []) - script = program_args[0] if program_args else "unknown" - typer.echo(f"{label}: {script}") + command = program_args[0] if program_args else "unknown" + typer.echo(f"{label}: {command}") except Exception as e: typer.echo(f"{plist_path.name}: error reading ({e})") @app.command() def create( - script: Annotated[Path, typer.Argument(help="Path to the executable script")], - script_args: Annotated[ + command: Annotated[str, typer.Argument(help="Command or path to the executable")], + command_args: Annotated[ list[str] | None, - typer.Argument(help="Arguments to pass to the script (must come after '--')"), + typer.Argument(help="Arguments to pass to the command (must come after '--')"), ] = None, interval: Annotated[ int | None, @@ -174,15 +238,19 @@ def create( ) -> None: """Create and load a LaunchAgent for a script. - To pass arguments to your script, use '--' to separate mcquack options from - script arguments. For example: + The command argument can be: + - A path (absolute or relative, with / or ~ or .): will be expanded and validated + - A command name (no /): will be searched on $PATH + + To pass arguments to your command, use '--' to separate mcquack options from + command arguments. For example: mcquack create my_script.sh -- --config /path/to/config Without '--', options like --help apply to mcquack itself: mcquack create my_script.sh --help # Shows this help - mcquack create my_script.sh -- --help # Passes --help to the script + mcquack create my_script.sh -- --help # Passes --help to the command Scheduling options: @@ -191,15 +259,11 @@ def create( mcquack create script.sh --calendar "Hour=2,Minute=0" # Every day at 2 AM mcquack create script.sh --calendar "Weekday=1,Hour=9,Minute=0" # Mondays at 9 AM """ - script_path = script.resolve() + # Resolve command to path and determine if it should be kept as command name + resolved_path, keep_as_command_name = resolve_command(command) - if not script_path.exists(): - typer.echo(f"Error: {script_path} does not exist.", err=True) - raise typer.Exit(1) - - if not os.access(script_path, os.X_OK): - typer.echo(f"Error: {script_path} is not executable.", err=True) - raise typer.Exit(1) + # For label generation, use the original command name (stem of the resolved path) + command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path # Validate mutually exclusive options if interval is not None and calendar is not None: @@ -209,7 +273,7 @@ def create( ) raise typer.Exit(1) - plist_path = get_plist_path(script_path) + plist_path = get_plist_path(command_path) if plist_path.exists(): typer.echo( @@ -231,9 +295,13 @@ def create( LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True) + # Determine what to use in ProgramArguments + program_arg = command if keep_as_command_name else str(resolved_path) + plist = create_plist_dict( - script_path, - script_args or [], + command_path, + command_args or [], + program_arg=program_arg, interval=interval, throttle=throttle, calendar=parsed_calendar, @@ -252,22 +320,23 @@ def create( typer.echo(f"Warning: launchctl load failed: {result.stderr}", err=True) raise typer.Exit(1) - typer.echo(f"Loaded: {get_label(script_path)}") + typer.echo(f"Loaded: {get_label(command_path)}") @app.command() def launch( - script: Annotated[Path, typer.Argument(help="Path to the executable script")], + command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Kickstart a LaunchAgent.""" - script_path = script.resolve() - plist_path = get_plist_path(script_path) + resolved_path, _ = resolve_command(command) + command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path + plist_path = get_plist_path(command_path) if not plist_path.exists(): typer.echo(f"Error: {plist_path} does not exist. Use 'create' first.", err=True) raise typer.Exit(1) - label = get_label(script_path) + label = get_label(command_path) uid = os.getuid() result = subprocess.run( @@ -285,11 +354,12 @@ def launch( @app.command() def show( - script: Annotated[Path, typer.Argument(help="Path to the executable script")], + command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Show the arguments configured in a plist.""" - script_path = script.resolve() - plist_path = get_plist_path(script_path) + resolved_path, _ = resolve_command(command) + command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path + plist_path = get_plist_path(command_path) if not plist_path.exists(): typer.echo(f"Error: {plist_path} does not exist.", err=True) @@ -322,15 +392,16 @@ def show( @app.command() def edit( - script: Annotated[Path, typer.Argument(help="Path to the executable script")], + command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Edit a plist in $EDITOR. - Opens the plist file for the given script in your default editor. + Opens the plist file for the given command in your default editor. After editing, the agent is automatically reloaded. """ - script_path = script.resolve() - plist_path = get_plist_path(script_path) + resolved_path, _ = resolve_command(command) + command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path + plist_path = get_plist_path(command_path) if not plist_path.exists(): typer.echo(f"Error: {plist_path} does not exist. Use 'create' first.", err=True) @@ -357,16 +428,17 @@ def edit( typer.echo(f"Warning: launchctl load failed: {result.stderr}", err=True) raise typer.Exit(1) - typer.echo(f"Reloaded: {get_label(script_path)}") + typer.echo(f"Reloaded: {get_label(command_path)}") @app.command() def unload( - script: Annotated[Path, typer.Argument(help="Path to the executable script")], + command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Unload a LaunchAgent.""" - script_path = script.resolve() - plist_path = get_plist_path(script_path) + resolved_path, _ = resolve_command(command) + command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path + plist_path = get_plist_path(command_path) if not plist_path.exists(): typer.echo(f"Error: {plist_path} does not exist.", err=True) @@ -379,16 +451,17 @@ def unload( typer.echo(f"Error: launchctl unload failed: {result.stderr}", err=True) raise typer.Exit(1) - typer.echo(f"Unloaded: {get_label(script_path)}") + typer.echo(f"Unloaded: {get_label(command_path)}") @app.command() def delete( - script: Annotated[Path, typer.Argument(help="Path to the executable script")], + command: Annotated[str, typer.Argument(help="Command or path to the executable")], ) -> None: """Delete a LaunchAgent plist.""" - script_path = script.resolve() - plist_path = get_plist_path(script_path) + resolved_path, _ = resolve_command(command) + command_path = Path(command).expanduser() if ("/" in command or command.startswith("~") or command.startswith(".")) else resolved_path + plist_path = get_plist_path(command_path) if not plist_path.exists(): typer.echo(f"Error: {plist_path} does not exist.", err=True) diff --git a/test_mcquack.py b/test_mcquack.py index 4402d1c..27bc70f 100644 --- a/test_mcquack.py +++ b/test_mcquack.py @@ -1,5 +1,6 @@ """Tests for mcquack.""" +import os import plistlib import subprocess import xml.etree.ElementTree as ET @@ -495,6 +496,310 @@ class TestThrottleInterval: assert "ThrottleInterval: 60" in result.stdout +class TestCommandResolution: + """Tests for command argument resolution (path vs PATH lookup).""" + + def test_absolute_path_resolved(self, mock_dirs, mock_launchctl, tmp_path): + """Test that absolute paths are resolved (symlinks, etc).""" + # Create a script in a subdirectory + subdir = tmp_path / "scripts" + subdir.mkdir() + script = subdir / "myscript.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Create command should work with absolute path + result = runner.invoke(app, ["create", str(script)]) + assert result.exit_code == 0 + + # Verify plist contains the resolved absolute path + plist_path = get_plist_path(mock_dirs, script_name="myscript") + program_args = read_plist_args(plist_path) + # Should be absolute and resolved + assert Path(program_args[0]).is_absolute() + assert program_args[0] == str(script.resolve()) + # Verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_relative_path_expanded(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that relative paths are expanded to absolute paths.""" + # Create a script in a subdirectory + subdir = tmp_path / "scripts" + subdir.mkdir() + script = subdir / "myscript.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Change to tmp_path so we can use relative path + monkeypatch.chdir(tmp_path) + + # Use relative path + result = runner.invoke(app, ["create", "scripts/myscript.sh"]) + assert result.exit_code == 0 + + # Verify plist contains absolute path + plist_path = get_plist_path(mock_dirs, script_name="myscript") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + assert program_args[0] == str(script.resolve()) + # Verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_tilde_path_expanded(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that ~ in paths is expanded.""" + # Create a script + script = tmp_path / "myscript.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Use path with ~ (mock HOME to tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + + result = runner.invoke(app, ["create", "~/myscript.sh"]) + assert result.exit_code == 0 + + # Verify plist contains expanded absolute path + plist_path = get_plist_path(mock_dirs, script_name="myscript") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + assert "~" not in program_args[0] + # Verify resolved path is within tmp_path (our mocked HOME) + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_dotdot_path_resolved(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that .. in paths is resolved.""" + # Create directory structure: tmp_path/a/b/ and tmp_path/scripts/ + a_dir = tmp_path / "a" / "b" + a_dir.mkdir(parents=True) + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir() + + script = scripts_dir / "myscript.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Change to a/b and reference script with ../.. + monkeypatch.chdir(a_dir) + + result = runner.invoke(app, ["create", "../../scripts/myscript.sh"]) + assert result.exit_code == 0 + + # Verify plist contains resolved path without .. + plist_path = get_plist_path(mock_dirs, script_name="myscript") + program_args = read_plist_args(plist_path) + assert ".." not in program_args[0] + assert program_args[0] == str(script.resolve()) + # Verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_command_on_path_not_expanded(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that command names (no /) found on PATH are NOT expanded to absolute paths.""" + # Create a script in a directory that will be on PATH + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "mycommand" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Add bin_dir to PATH + monkeypatch.setenv("PATH", str(bin_dir) + ":" + os.environ.get("PATH", "")) + + # Use just the command name + result = runner.invoke(app, ["create", "mycommand"]) + assert result.exit_code == 0 + + # Verify plist contains just the command name, NOT the absolute path + plist_path = get_plist_path(mock_dirs, script_name="mycommand") + program_args = read_plist_args(plist_path) + assert program_args[0] == "mycommand" + assert not Path(program_args[0]).is_absolute() + + def test_command_not_on_path_fails(self, mock_dirs, mock_launchctl): + """Test that command names (no /) NOT on PATH fail.""" + result = runner.invoke(app, ["create", "nonexistent_command"]) + assert result.exit_code == 1 + assert "not found" in result.output.lower() or "does not exist" in result.output.lower() + + def test_command_in_cwd_as_path(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that executable in CWD accessed as path (./script) is expanded.""" + script = tmp_path / "myscript.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + monkeypatch.chdir(tmp_path) + + # Use ./ prefix to indicate it's a path + result = runner.invoke(app, ["create", "./myscript.sh"]) + assert result.exit_code == 0 + + # Should be expanded to absolute path + plist_path = get_plist_path(mock_dirs, script_name="myscript") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + assert program_args[0] == str(script.resolve()) + # Verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_command_in_cwd_and_path_prefers_path(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that when a command is in both CWD and PATH, using command name finds PATH version.""" + # Create script in CWD + cwd_script = tmp_path / "cwd" + cwd_script.mkdir() + cwd_executable = cwd_script / "mycommand" + cwd_executable.write_text("#!/bin/bash\necho 'from cwd'\n") + cwd_executable.chmod(cwd_executable.stat().st_mode | 0o111) + + # Create script on PATH + path_dir = tmp_path / "bin" + path_dir.mkdir() + path_executable = path_dir / "mycommand" + path_executable.write_text("#!/bin/bash\necho 'from path'\n") + path_executable.chmod(path_executable.stat().st_mode | 0o111) + + # Set PATH and CWD + monkeypatch.setenv("PATH", str(path_dir) + ":" + os.environ.get("PATH", "")) + monkeypatch.chdir(cwd_script) + + # Use just the command name - should find PATH version + result = runner.invoke(app, ["create", "mycommand"]) + assert result.exit_code == 0 + + # Should NOT be expanded (stays as command name) + plist_path = get_plist_path(mock_dirs, script_name="mycommand") + program_args = read_plist_args(plist_path) + assert program_args[0] == "mycommand" + + def test_command_in_cwd_not_on_path_as_path_works(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that executable in CWD but not on PATH works when accessed as path.""" + script = tmp_path / "localscript" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + monkeypatch.chdir(tmp_path) + + # Use ./localscript to indicate it's a path + result = runner.invoke(app, ["create", "./localscript"]) + assert result.exit_code == 0 + + # Should be expanded + plist_path = get_plist_path(mock_dirs, script_name="localscript") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + # Verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_symlink_resolved(self, mock_dirs, mock_launchctl, tmp_path): + """Test that symlinks are resolved to their targets.""" + # Create actual script + real_script = tmp_path / "real_script.sh" + real_script.write_text("#!/bin/bash\necho 'hello'\n") + real_script.chmod(real_script.stat().st_mode | 0o111) + + # Create symlink + symlink = tmp_path / "link_to_script" + symlink.symlink_to(real_script) + + result = runner.invoke(app, ["create", str(symlink)]) + assert result.exit_code == 0 + + # Plist should contain resolved path (to real_script) + plist_path = get_plist_path(mock_dirs, script_name="link_to_script") + program_args = read_plist_args(plist_path) + assert program_args[0] == str(real_script.resolve()) + # Verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_tilde_with_subdirectory_navigation(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that ~/subdir/.. patterns resolve correctly within tmp_path.""" + # Create directory structure: tmp_path/scripts/bin/ and tmp_path/tools/ + scripts_dir = tmp_path / "scripts" / "bin" + scripts_dir.mkdir(parents=True) + tools_dir = tmp_path / "tools" + tools_dir.mkdir() + + script = tools_dir / "mytool.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Mock HOME to tmp_path + monkeypatch.setenv("HOME", str(tmp_path)) + + # Use ~/scripts/../tools/mytool.sh (navigates through ~ and ..) + result = runner.invoke(app, ["create", "~/scripts/../tools/mytool.sh"]) + assert result.exit_code == 0 + + # Verify plist contains resolved absolute path + plist_path = get_plist_path(mock_dirs, script_name="mytool") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + assert "~" not in program_args[0] + assert ".." not in program_args[0] + assert program_args[0] == str(script.resolve()) + # Critical: verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_complex_dotdot_navigation_stays_in_tmpdir(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test that complex .. navigation resolves within tmp_path.""" + # Create structure: tmp_path/a/b/c/d/ and tmp_path/x/y/ + deep_dir = tmp_path / "a" / "b" / "c" / "d" + deep_dir.mkdir(parents=True) + target_dir = tmp_path / "x" / "y" + target_dir.mkdir(parents=True) + + script = target_dir / "target.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Change to deep directory + monkeypatch.chdir(deep_dir) + + # Use ../../../../x/y/target.sh to navigate up and across + result = runner.invoke(app, ["create", "../../../../x/y/target.sh"]) + assert result.exit_code == 0 + + # Verify plist contains resolved path + plist_path = get_plist_path(mock_dirs, script_name="target") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + assert ".." not in program_args[0] + assert program_args[0] == str(script.resolve()) + # Critical: verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + def test_tilde_and_dotdot_combination(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): + """Test combining ~ and .. in the same path resolves within tmp_path.""" + # Create structure: tmp_path/home/user/bin/ and tmp_path/opt/ + user_bin = tmp_path / "home" / "user" / "bin" + user_bin.mkdir(parents=True) + opt_dir = tmp_path / "opt" + opt_dir.mkdir() + + script = opt_dir / "script.sh" + script.write_text("#!/bin/bash\necho 'hello'\n") + script.chmod(script.stat().st_mode | 0o111) + + # Mock HOME to tmp_path/home/user + home_dir = tmp_path / "home" / "user" + monkeypatch.setenv("HOME", str(home_dir)) + + # Use ~/bin/../../../opt/script.sh (~ expands, then navigate with .. back to tmp_path) + # ~/bin = tmp_path/home/user/bin + # ../../.. navigates: bin -> user -> home -> tmp_path + # then opt/script.sh = tmp_path/opt/script.sh + result = runner.invoke(app, ["create", "~/bin/../../../opt/script.sh"]) + assert result.exit_code == 0 + + # Verify resolution + plist_path = get_plist_path(mock_dirs, script_name="script") + program_args = read_plist_args(plist_path) + assert Path(program_args[0]).is_absolute() + assert "~" not in program_args[0] + assert ".." not in program_args[0] + assert program_args[0] == str(script.resolve()) + # Critical: verify resolved path is within tmp_path + assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) + + class TestStartCalendarInterval: """Tests for StartCalendarInterval scheduling."""