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:
parent
4b1039ee7d
commit
e55cff15ee
3 changed files with 488 additions and 64 deletions
72
README.md
72
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.<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
|
||||
|
||||
|
|
|
|||
175
mcquack.py
175
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)
|
||||
|
|
|
|||
305
test_mcquack.py
305
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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue