From f1a298429d4a172d1e2610fab86cecc0533d1498 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 30 Sep 2026 02:02:23 -0600 Subject: [PATCH] Make install.sh run unattended and survive a reinstall Add a #!/bin/sh shebang and set -e, create /usr/games with mkdir -p, install git with apt-get -y, and pull an existing clone instead of failing on git clone. Assert the contract from tests/test_installScript.py, since the script needs root and can't be run by the suite or CI. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- install.sh | 24 ++++++--- tests/test_installScript.py | 100 ++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 tests/test_installScript.py diff --git a/README.md b/README.md index c20cf64..dcd6188 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Or use `./run.sh`, which also does a `git pull`, installs the dependencies (pyga To play in a pygame window instead, install pygame and change `INTERFACE_TYPE` near the top of `src/fishE.py` from `UIType.CONSOLE` to `UIType.PYGAME`. The two browser front-ends are started differently; see [Play in your browser](#play-in-your-browser) below. -On a Debian-based machine, `install.sh` (run as root) clones the game into `/usr/games/FishE` and adds a `fishe` command that runs `./run.sh` there; `uninstall.sh` removes both. +On a Debian-based machine, `install.sh` (run as root) clones the game into `/usr/games/FishE` and adds a `fishe` command that runs `./run.sh` there; `uninstall.sh` removes both. It runs without prompting and is safe to run again: a second run updates the existing clone with `git pull` rather than cloning over it. ## Features diff --git a/install.sh b/install.sh index b15c50e..d0dac76 100755 --- a/install.sh +++ b/install.sh @@ -1,12 +1,24 @@ -mkdir /usr/games +#!/bin/sh +# Usage: sudo sh install.sh +# Safe to run again: an existing install is updated rather than cloned over. +set -e + +INSTALL_DIR=/usr/games/FishE + +mkdir -p /usr/games apt-get update -apt-get install git -cd /usr/games && git clone https://github.com/Stephenson-Software/FishE +apt-get install -y git -echo "cd /usr/games/FishE && ./run.sh" > /bin/fishe -chmod +x /usr/games/FishE/run.sh +if [ -d "$INSTALL_DIR/.git" ]; then + echo "Existing install found in $INSTALL_DIR - updating it." + git -C "$INSTALL_DIR" pull +else + git clone https://github.com/Stephenson-Software/FishE "$INSTALL_DIR" +fi + +echo "cd $INSTALL_DIR && ./run.sh" > /bin/fishe +chmod +x "$INSTALL_DIR/run.sh" chmod +x /bin/fishe echo "Installation complete. Type 'fishe' to play." - diff --git a/tests/test_installScript.py b/tests/test_installScript.py new file mode 100644 index 0000000..f2dabd2 --- /dev/null +++ b/tests/test_installScript.py @@ -0,0 +1,100 @@ +"""install.sh must run unattended, and must survive being run a second time. + +The script needs root and changes /usr/games and /bin, so neither CI nor a +contributor's test run can execute it. These tests assert its contract from the +text instead: a shell to run it under, stopping at the first failure, no step +that waits on a prompt, and an existing install updated rather than cloned over. +""" + +import os + +REPOSITORY_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + +def readInstallScript(): + with open(os.path.join(REPOSITORY_ROOT, "install.sh"), encoding="utf-8") as script: + return script.read() + + +def commandLines(script): + """The lines of the script that run something, with comments and blank + lines dropped so a mention in prose can't stand in for the real thing.""" + commands = [] + for line in script.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + commands.append(stripped) + return commands + + +def test_install_sh_names_its_shell(): + # prepare + script = readInstallScript() + + # call + firstLine = script.splitlines()[0] + + # check + assert firstLine == "#!/bin/sh" + + +def test_install_sh_stops_at_the_first_failure(): + # prepare + script = readInstallScript() + + # call + commands = commandLines(script) + + # check + assert "set -e" in commands + # before anything that installs or writes, so a failed step can't leave + # /bin/fishe pointing at a directory that was never cloned + assert commands.index("set -e") < min( + index for index, command in enumerate(commands) if "apt-get" in command + ) + + +def test_install_sh_does_not_fail_on_an_existing_games_directory(): + # prepare + script = readInstallScript() + + # call + mkdirCommands = [ + command for command in commandLines(script) if command.startswith("mkdir") + ] + + # check + assert mkdirCommands + for command in mkdirCommands: + assert command.startswith("mkdir -p ") + + +def test_install_sh_does_not_wait_on_an_apt_prompt(): + # prepare + script = readInstallScript() + + # call + installCommands = [ + command for command in commandLines(script) if "apt-get install" in command + ] + + # check + assert installCommands + for command in installCommands: + assert " -y " in command + + +def test_install_sh_updates_an_existing_install_instead_of_cloning_over_it(): + # prepare + script = readInstallScript() + + # call + commands = commandLines(script) + cloneLine = next( + index for index, command in enumerate(commands) if "git clone" in command + ) + + # check + assert 'if [ -d "$INSTALL_DIR/.git" ]; then' in commands[:cloneLine] + assert 'git -C "$INSTALL_DIR" pull' in commands[:cloneLine] + assert commands[cloneLine - 1] == "else"