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 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-01-13 10:25:07 -08:00
commit e55cff15ee
3 changed files with 488 additions and 64 deletions

View file

@ -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.<scriptname>.plist
mcquack.eblume.<commandname>.plist
```
The generated plist configures the script to:
- Run at load
- Keep alive (restart if it exits)
- Log stdout/stderr to `~/Library/Logs/mcquack.<scriptname>.{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.<commandname>.{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

View file

@ -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)

View file

@ -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."""