diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a96ce3e..c87ff42 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,13 @@ jobs: cs50/cli:amd64 cs50/cli:canary-amd64 + - name: Check out tests + uses: actions/checkout@v7 + + - name: Smoke-test linux/amd64 build + run: | + bash tests/smoke.sh cs50/cli:canary-amd64 + - name: Push linux/amd64 build to Docker Hub if: ${{ github.ref == 'refs/heads/main' }} run: | @@ -68,6 +75,13 @@ jobs: cs50/cli:arm64 cs50/cli:canary-arm64 + - name: Check out tests + uses: actions/checkout@v7 + + - name: Smoke-test linux/arm64 build + run: | + bash tests/smoke.sh cs50/cli:canary-arm64 + - name: Push linux/arm64 build to Docker Hub if: ${{ github.ref == 'refs/heads/main' }} run: | diff --git a/Dockerfile b/Dockerfile index 83f5a26..3ff4866 100644 --- a/Dockerfile +++ b/Dockerfile @@ -188,16 +188,21 @@ RUN apt update && \ apt install --no-install-recommends --no-install-suggests --yes \ astyle \ bash-completion \ + bsdextrautils `# For col, for help50` \ build-essential `# dpkg-dev, libc, gcc, g++, make, etc.` \ ca-certificates \ clang \ clang-format \ + colorized-logs `# For help50` \ coreutils `# For fold` \ cowsay \ curl \ dos2unix \ dnsutils `# For nslookup` \ + expect `# For help50` \ + file `# For help50` \ fonts-noto-color-emoji `# For render50` \ + fzf `# For help50` \ gdb \ git \ git-lfs \ @@ -247,7 +252,6 @@ RUN pip3 install --no-cache-dir \ cs50==9.5.0 \ Flask \ Flask-Session \ - help50 \ pytest \ render50 \ setuptools \ diff --git a/Makefile b/Makefile index 728d8eb..f000d0e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,10 @@ rebuild: docker build --build-arg VCS_REF=$(shell git rev-parse HEAD) --no-cache --tag $(IMAGE) . run: - docker run --env LANG=$(LANG) --env LOCAL_WORKSPACE_FOLDER="$(PWD)" --interactive --publish-all --rm --security-opt seccomp=unconfined --tty --volume "$(PWD)":/mnt --volume /var/run/docker.sock:/var/run/docker-host.sock --workdir /mnt $(IMAGE) bash --login || true + docker run --env LANG=$(LANG) --env LOCAL_WORKSPACE_FOLDER="$(PWD)" --env WORKDIR=/mnt --interactive --publish-all --rm --security-opt seccomp=unconfined --tty --volume "$(PWD)":/mnt --volume /var/run/docker.sock:/var/run/docker-host.sock --workdir /mnt $(IMAGE) bash --login || true + +smoke: + bash tests/smoke.sh $(IMAGE) squash: depends docker-squash --tag $(IMAGE) $(IMAGE) diff --git a/etc/profile.d/cli.sh b/etc/profile.d/cli.sh index 050baff..d4efd2b 100644 --- a/etc/profile.d/cli.sh +++ b/etc/profile.d/cli.sh @@ -1,5 +1,5 @@ # If not root -if [ "$(whoami)" != "root" ]; then +if [ `id -u` -ne 0 ]; then # $PATH export PATH="/opt/cs50/bin":"/opt/bin":"$PATH" @@ -61,4 +61,9 @@ if [ "$(whoami)" != "root" ]; then # Valgrind export VALGRIND_OPTS="--memcheck:leak-check=full --memcheck:show-leak-kinds=all --memcheck:track-origins=yes" + + # Start help50 if enabled (only for interactive shells with a terminal, else `script` would hang, e.g., in `bash --login -c`) + if [[ $- == *i* && -t 0 ]] && help50 is-enabled > /dev/null; then + help50 start + fi fi diff --git a/etc/profile.d/help50.sh b/etc/profile.d/help50.sh new file mode 100644 index 0000000..13684c8 --- /dev/null +++ b/etc/profile.d/help50.sh @@ -0,0 +1,193 @@ +# If not started +if [[ -z "$HELP50" ]]; then + return +fi + +# Directory with helpers +HELPERS="/opt/cs50/lib/help50" + +# Library +. /opt/cs50/lib/cli + +# Ignore duplicates (but not commands that begin with spaces) +export HISTCONTROL="ignoredups" + +function _help50() { + + # Get exit status of last command + local status=$? + + # Get last command line, independent of user's actual history + histfile=$(mktemp) + HISTFILE=$histfile history -a + local argv=$(HISTFILE=$histfile history 1 | cut -c 8-) # Could technically contain multiple commands, separated by ; or && + rm --force $histfile + + # If run via `help50 COMMAND`, it's COMMAND that ran, so that's what helpers should see + if [[ "$argv" =~ ^help50[[:space:]]+([^[:space:]].*)$ ]]; then + local wrapped="${BASH_REMATCH[1]}" + if ! [[ "$wrapped" =~ ^(disable|enable|is-enabled|start|status|stop|-h|--help)([[:space:]]|$) ]]; then + argv="$wrapped" + fi + fi + local argv0=$(echo "$argv" | awk '{print $1}') # Assume for simplicity it's just a single command + + # Remove any of these aliases + for name in n no y yes; do + unalias $name 2> /dev/null + done + + # If last command was ./* + # touch foo.c && make foo && touch foo.c && ./foo + if [[ "$argv" =~ ^\./(.*)$ ]]; then + local src="${BASH_REMATCH[1]}.c" + local dst="${BASH_REMATCH[1]}" + if [[ -f "$src" && $(file --brief --mime-type "$src") == "text/x-c" ]]; then + if [[ -x "$dst" && $(file --brief --mime-type "$dst") == "application/x-pie-executable" ]]; then + if [[ "$src" -nt "$dst" ]]; then + _helpful "It looks like \`$src\` has changed. Did you mean to run \`make $dst\` again?" + fi + fi + fi + fi + + # If last command erred (and is not ctl-c or ctl-z) + # https://tldp.org/LDP/abs/html/exitcodes.html + if [[ $status -ne 0 && $status -ne 130 && $status -ne 148 ]]; then + + # Read typescript from disk, bounded: at most the first 64K (where the command line is + # echoed) and the last 1M (where errors tend to be), so that a program that printed a + # great deal before failing doesn't stall the prompt while the whole file is read + local typescript + if [[ $(stat -c %s "$HELP50" 2> /dev/null || echo 0) -gt $((65536 + 1048576)) ]]; then + typescript=$(head -c 65536 "$HELP50"; echo; echo "[... output omitted ...]"; tail -c 1048576 "$HELP50") + else + typescript=$(cat "$HELP50") + fi + + # Remove script's own output (if this is user's first command) + typescript=$(echo "$typescript" | sed '1{/^Script started on .*/d}') + + # Cap typescript, else `read` below is slow. Keep the first few lines, where the + # command line itself is echoed (found below), plus the last 1K lines, where + # errors tend to be (tracebacks, `make: *** Error`, segfaults); a long-running + # program that prints a lot and then crashes would otherwise lose its error. + typescript=$(echo "$typescript" | cut -b 1-1048576) + local total=$(echo "$typescript" | wc -l) + if [[ $total -gt 1088 ]]; then + typescript=$(echo "$typescript" | head -n 64; echo "[... $((total - 1088)) lines omitted ...]"; echo "$typescript" | tail -n 1024) + fi + + # Remove ANSI characters + typescript=$(echo "$typescript" | ansi2txt) + + # Remove control characters + # https://superuser.com/a/237154 + typescript=$(echo "$typescript" | col -bp) + + # Remove everything through the command line itself, as echoed by the terminal. + # It's usually the first line, but tab completion, history browsing, etc. echo + # more before it (e.g., a listing of completions, then a redrawn prompt), which + # would otherwise be mistaken for the command's output. So look for the first + # line that ends with the command (per history), joining any line continuations + # (and dropping their PS2 prompts) along the way; if not found, fall back to + # removing just the first (logical) line. + local command="${argv%"${argv##*[![:space:]]}"}" # Right-trimmed + local after_first="" after_command="" logical="" first="" found="" + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -n "$found" ]] && after_command+="$line"$'\n' + [[ -n "$first" ]] && after_first+="$line"$'\n' + [[ -n "$found" ]] && continue + [[ -n "$logical" ]] && line="${line#"$PS2"}" + if [[ "$line" =~ \\$ ]]; then + logical+="${line%\\}" + continue + fi + logical+="$line" + first=1 + local trimmed="${logical%"${logical##*[![:space:]]}"}" + if [[ -n "$command" && "$trimmed" == *"$command" ]]; then + found=1 + fi + logical="" + done <<< "$typescript" + if [[ -n "$found" ]]; then + typescript="$after_command" + else + typescript="$after_first" + fi + + # Try to get help, giving each helper a few seconds at most, lest a slow or stuck helper stall + # the prompt. Note that timeout runs the helper in its own process group, so ctl-c at the + # terminal no longer reaches the helper (it did before); the timeout itself is the bound. + # Not --foreground, which would restore ctl-c but stop timeout from killing the helper's + # children, so an orphaned child holding stdout open could stall the prompt indefinitely. + for helper in $HELPERS/*; do + if [[ -f $helper && -x $helper ]]; then + local help=$(timeout -k 1 5 $helper $argv <<< "$typescript") + if [[ -n "$help" ]]; then + break + fi + fi + done + if [[ -n "$help" ]]; then # If helpful + _helpful "$help" + elif [[ $status -ne 0 ]]; then # If helpless + + # Pass the output (capped, e.g., since ddb50 rejects > 10,000 characters, keeping the end, + # where errors tend to be) and the command line itself, so that whatever explains the + # output can see what was run + _helpless "$(echo "$typescript" | tail -c 8192)" "$argv" + fi + else + _helped + fi + + # Truncate typescript + truncate -s 0 $HELP50 +} + +function _rhetorical() { + _alert "That was a rhetorical question. <3" +} + +# Run `help50 COMMAND` in this very shell, as though COMMAND had been typed directly, so that +# aliases (e.g., rm -i), functions, and builtins like cd behave exactly as they would have; +# /opt/cs50/bin/help50 handles the rest (and COMMAND too, outside of a help50 session) +function help50() { + case "$1" in + disable|enable|is-enabled|start|status|stop|""|-h|--help) + command help50 "$@" + ;; + *) + eval "$(printf '%q ' "$@")" + ;; + esac +} + +# Default helpers, overridable (e.g., by cs50/codespace) by defining them before this file is sourced: +# _helped last command succeeded +# _helpful ADVICE a helper had advice for the failed command +# _helpless OUTPUT CMD no helper had advice; OUTPUT is the failed command's (cleaned, capped) +# output, possibly empty, and CMD its command line +if ! type _helped >/dev/null 2>&1; then + function _helped() { :; } # Silent +fi +if ! type _helpful >/dev/null 2>&1; then + function _helpful() { + + # Intercept accidental invocation of `yes` and `n`, which are actual programs + for name in n no y yes; do + alias $name=_rhetorical + done + + # Output help + local output=$(_ansi "$1") + _alert "$output" + } +fi +if ! type _helpless >/dev/null 2>&1; then + function _helpless() { :; } # Silent +fi + +export PROMPT_COMMAND=_help50 diff --git a/opt/cs50/bin/help50 b/opt/cs50/bin/help50 new file mode 100755 index 0000000..f296249 --- /dev/null +++ b/opt/cs50/bin/help50 @@ -0,0 +1,137 @@ +#!/bin/bash + +function _disable() { + touch /tmp/help50.lock +} + +function _is-enabled() { + + # Kill switch: set in the environment (e.g., an organization-wide Codespaces secret) to + # turn help50 off for everyone at their next login, without rebuilding an image. Values + # that read as false (0, false, no, off) count as unset, so that setting the secret to 0 + # re-enables help50 just as deleting it would, rather than silently keeping it off + case "${HELP50_DISABLED,,}" in + ""|0|false|no|off) ;; + *) + echo "disabled (HELP50_DISABLED=$HELP50_DISABLED; unset it to re-enable)" + return 1 + ;; + esac + if [[ -f /tmp/help50.lock ]]; then + echo disabled + return 1 + else + echo enabled + return 0 + fi +} + +function _enable() { + rm --force /tmp/help50.lock +} + +function _start() { + + # If already helping + if [[ -n "$HELP50" ]]; then + return 0 + fi + + # Uniquely identify typescript using PID of parent shell to help + local HELP50=/tmp/help50.$PPID + + # Start `script` in background, using ; instead of && for command, + # else if user logs out (as via ctl-d) after a non-0 command, bash exits with 127 + set -o monitor + HELP50=$HELP50 script --append --command "bash --login ; exit 1" --flush --quiet --return $HELP50 + local status=$? + + # No longer helping + rm --force $HELP50 + + # If `script` was killed, in which case `exit 1` above won't execute + if [[ $status -ne 1 ]]; then + + # Without this, prompt ends up below and to right of "Session terminated, killing shell... ...killed." + echo -e "\r" + + # Else if `script` exited on its own, as via ctl-d or `logout` + else + + # Kill parent shell, since user presumably wants to exit + kill -SIGHUP $PPID + fi +} + +function _status() { + if [[ -n "$HELP50" ]]; then + echo started + return 0 + else + echo stopped + return 1 + fi +} + +function _stop() { + + # If not helping + if [[ -z "$HELP50" ]]; then + return 0 + fi + + # Kill grandparent process (i.e., `script` itself) + local ppid=$(ps -o ppid= -p $$) # bash --login + local gppid=$(ps -o ppid= -p $ppid) # sh -c + local ggppid=$(ps -o ppid= -p $gppid) # script + kill -SIGTERM $ggppid +} + +# Run a command as though typed directly. Students used to run `help50 make foo`; help now +# arrives automatically after any failed command, so just run the command, with the same +# exit status, and let the shell's prompt hook handle the rest. (Within a help50 session, +# /etc/profile.d/help50.sh defines a `help50` function that runs the command in the shell +# itself instead, so that aliases, functions, and `cd` behave as they would directly.) +function _run() { + if [[ -n "$(type -P -- "$1")" ]]; then + exec "$@" + elif [[ "$1" == */* || -n "$(type -t -- "$1")" ]]; then # Path, builtin, or keyword, e.g., ./foo.c, cd + + # Let bash itself report errors (e.g., Permission denied, Is a directory, No such + # file or directory), as it would have directly, so that helpers recognize them + bash -c "$(printf '%q ' "$@")" 2> >(sed 's/^bash: line [0-9]*: /bash: /' >&2) + local status=$? + wait $! 2> /dev/null # For sed to finish, lest its output arrive after the prompt + exit $status + else # Fail the way the shell would, so that helpers recognize it + echo "bash: $1: command not found" >&2 + exit 127 + fi +} + +function _usage() { + echo "Usage: help50 COMMAND [ARGS...]" + echo + echo "Runs COMMAND. If it fails, help appears automatically, as it now does for any" + echo "command you run, so you can also just run COMMAND directly." + echo + echo "Other usage: help50 [disable|enable|is-enabled|start|status|stop]" +} + +# Parse argument +case "$1" in + disable|enable|is-enabled|start|status|stop) + + # Not for root + if [[ `id -u` -eq 0 ]]; then + exit 1 + fi + _${1} + ;; + ""|-h|--help) + _usage + ;; + *) + _run "$@" + ;; +esac diff --git a/opt/cs50/bin/http-server b/opt/cs50/bin/http-server index a0f1c26..ea82a17 100755 --- a/opt/cs50/bin/http-server +++ b/opt/cs50/bin/http-server @@ -1,5 +1,7 @@ #!/bin/bash +. /opt/cs50/lib/cli + # Default options a="-a 0.0.0.0" c="-c-1" @@ -9,22 +11,16 @@ port="-p 8080" options="--no-dotfiles" t="-t0" -# Formatting -bold=$(tput bold) -normal=$(tput sgr0) - # Check for app.py or wsgi.py if [[ -f app.py ]] || [[ -f wsgi.py ]]; then - read -p "Are you sure you want to run ${bold}http-server${normal} and not ${bold}flask${normal}? [y/N] " -r - if [[ ! "${REPLY,,}" =~ ^y|yes$ ]]; then + if ! _sure "Are you sure you want to run \`http-server\` and not \`flask\`?"; then exit 1 fi fi # Check for path if [[ $# -eq 1 ]] && [[ $1 != -* ]] && [[ ! $1 =~ ^\./?$ ]]; then - read -p "Are you sure you want to serve ${bold}${1}${normal} and not your current directory? [y/N] " -r - if [[ ! "${REPLY,,}" =~ ^y|yes$ ]]; then + if ! _sure "Are you sure you want to serve \`${1}\` and not your current directory?"; then exit 1 fi fi diff --git a/opt/cs50/bin/make b/opt/cs50/bin/make index 4658e3e..1548007 100755 --- a/opt/cs50/bin/make +++ b/opt/cs50/bin/make @@ -1,23 +1,18 @@ #!/bin/bash -# Ensure no targets end with .c -args="" -invalid_args=0 -for arg; do - case "$arg" in - (*.c) arg=${arg%.c}; invalid_args=1;; - esac - args="$args $arg" -done -if [ $invalid_args -eq 1 ]; then - echo "Did you mean 'make$args'?" - exit 1 -fi +# If a single target and not an option +if [[ $# -eq 1 ]] && [[ "$1" != -* ]]; then + + # If target ends with .c or is a directory + if [[ "$1" == *?.c || -d "$1" ]]; then -# Run make -if [[ -d "$1" ]]; then - echo "$1 is a directory" - exit 1 -else - /usr/bin/make -B -s $* + # Don't suppress "Nothing to be done" with --silent + /usr/bin/make "$1" + + # Else make exits with 0 + exit 1 + fi fi + +# Don't echo recipes +/usr/bin/make --always-make --silent "$@" diff --git a/opt/cs50/bin/sqlite3 b/opt/cs50/bin/sqlite3 index 3c1c0e7..b2dd85e 100755 --- a/opt/cs50/bin/sqlite3 +++ b/opt/cs50/bin/sqlite3 @@ -1,8 +1,6 @@ #!/bin/bash -# Formatting -bold=$(tput bold) -normal=$(tput sgr0) +. /opt/cs50/lib/cli # If data is coming from stdin (pipe or redirection) if [[ -p /dev/stdin || ! -t 0 ]]; then @@ -12,8 +10,7 @@ fi # If no command-line argument if [[ $# -eq 0 ]]; then - read -p "Are you sure you want to run ${bold}sqlite3${normal} without a command-line argument (e.g., the filename of a database)? [y/N] " -r - if [[ ! "${REPLY,,}" =~ ^y|yes$ ]]; then + if ! _sure "Are you sure you want to run \`sqlite3\` without a command-line argument (e.g., the filename of a database)?"; then exit 1 fi @@ -21,13 +18,11 @@ if [[ $# -eq 0 ]]; then elif [[ $# -eq 1 ]] && [[ ! "$1" =~ ^- ]]; then if [[ ! -f "$1" ]]; then if [[ ! "$1" =~ \.db$ ]]; then - read -p "Are you sure you want to create ${bold}$1${normal}? SQLite filenames usually end in ${bold}.db${normal}. [y/N] " -r - if [[ ! "${REPLY,,}" =~ ^y|yes$ ]]; then + if ! _sure "Are you sure you want to create \`$1\`? SQLite filenames usually end in \`.db\`."; then exit 1 fi else - read -p "Are you sure you want to create ${bold}$1${normal}? [y/N] " -r - if [[ ! "${REPLY,,}" =~ ^y|yes$ ]]; then + if ! _sure "Are you sure you want to create \`$1\`?"; then exit 1 fi fi diff --git a/opt/cs50/bin/valgrind b/opt/cs50/bin/valgrind index 60a6429..f318aab 100755 --- a/opt/cs50/bin/valgrind +++ b/opt/cs50/bin/valgrind @@ -1,12 +1,10 @@ #!/bin/bash -# Formatting -bold=$(tput bold) -normal=$(tput sgr0) +. /opt/cs50/lib/cli # If run on Python program if [[ "$1" == "python" || "$1" == *.py ]]; then - echo "Afraid ${bold}valgrind${normal} does not support Python programs!" + _alert "$(_ansi "Afraid \`valgrind\` does not support Python programs!")" exit 1 fi diff --git a/opt/cs50/lib/cli b/opt/cs50/lib/cli new file mode 100644 index 0000000..6df5353 --- /dev/null +++ b/opt/cs50/lib/cli @@ -0,0 +1,79 @@ +function _alert() { + echo -e "\033[33m${1}\033[39m" # Yellow +} + +function _ansi() { + + # If command-line arguments (don't test -t 0, else arguments are ignored whenever stdin is redirected) + if [[ $# -gt 0 ]]; then + input="$*" + + # If standard input + else + input=$(cat) + fi + + # Format backticks as bold + local bold=$(printf '\033[1m') + local normal=$(printf '\033[22m') + echo "$input" | sed "s/\`\\([^\`]*\\)\`/${bold}\\1${normal}/g" | _fold +} + +function _find() { + + # Usage + if [[ $# -eq 1 ]]; then # Files AND directories + local path="$1" + local type="" + elif [[ $# -eq 3 && "$1" == "-type" && "$2" =~ ^[df]$ ]]; then # Files OR directories + local type="$1 $2" + local path="$3" + else + return + fi + + # Find $path in descendants of $WORKDIR, excluding hidden directories, most recently modified first + paths=$(find "$WORKDIR" -not -path "*/.*" -name "$path" -printf "%T+ %p\n" $type | sort -nr | awk '{print $2}' 2> /dev/null) + + # Count paths + local count=$(echo "$paths" | grep -c .) + + # If just one + if [[ "$count" -eq 1 ]]; then + + # Resolve absolute path to relative path + realpath --relative-to=. "$(dirname "$paths")" + fi +} + +function _fold() { + + # If command-line arguments (don't test -t 0, else arguments are ignored whenever stdin is redirected) + if [[ $# -gt 0 ]]; then + input="$*" + + # If standard input + else + input=$(cat) + fi + + # Wrap long lines (default to 80 columns if no terminal, e.g., when piped) + local cols=$(tput cols 2> /dev/null) + [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 + echo "$input" | fold --spaces --width=$cols +} + +function _sure() { + if [[ $# -ne 1 ]]; then + return 1 + fi + local prompt=$(echo "$1" | _ansi) + while true; do + read -p "$prompt [y/N] " -r + if [[ "${REPLY,,}" =~ ^(y|yes)$ ]]; then + return 0 + else + return 1 + fi + done +} diff --git a/opt/cs50/lib/help50/bash b/opt/cs50/lib/help50/bash new file mode 100755 index 0000000..bf00e0b --- /dev/null +++ b/opt/cs50/lib/help50/bash @@ -0,0 +1,114 @@ +#!/bin/bash + +output=$(cat) + +# touch foo.py && foo.py +regex="bash: (.*\.py): command not found" +if [[ "$output" =~ $regex ]]; then + + # If file exists + if [[ -f "${BASH_REMATCH[1]}" ]]; then + echo "Did you mean to run \`python ${BASH_REMATCH[1]}\`?" + exit + fi +fi + +# mkdir foo && (foo || 1s || .\foo) +regex="bash: (.*): command not found" +if [[ "$output" =~ $regex ]]; then + + # If directory exists + if [[ -d "${BASH_REMATCH[1]}" ]]; then + echo "Did you mean to run \`cd ${BASH_REMATCH[1]}\`?" + exit + fi + + # If typo + if [[ "${BASH_REMATCH[1]}" == "1s" ]]; then + echo "Did you mean to run \`ls\` (which starts with a lowercase L)?" + exit + fi + + # If uppercase + argv0="${BASH_REMATCH[1],,}" # Lowercase it + if command -v "$argv0" &> /dev/null; then + echo "Did you mean to run \`$argv0\`, in lowercase instead?" + exit + fi + + # If CS50 command + if [[ "${BASH_REMATCH[1]}" =~ ^(check|style|submit)$ && "$2" == "50" ]]; then + echo "Did you mean to run \`${BASH_REMATCH[1]}50\`, without a space, instead?" + exit + fi + + # If backslash instead of forward slash + if [[ "${BASH_REMATCH[1]}" =~ ^\.(.*) ]]; then + if [[ -f "./${BASH_REMATCH[1]}" ]]; then + echo "Did you mean to run \`./${BASH_REMATCH[1]}\`, with a forward slash instead?" + exit + fi + fi +fi + +# mkdir foo && ./foo +regex="bash: \./([^:]*): Is a directory" +if [[ "$output" =~ $regex ]]; then + echo "Cannot execute a directory. Did you mean to run \`cd ${BASH_REMATCH[1]}\`?" + exit +fi + +# touch foo && cd foo +regex="bash: cd: (.*): Not a directory" +if [[ "$output" =~ $regex ]]; then + file="${BASH_REMATCH[1]}" + echo "Looks like you're trying to change directories, but \`$file\` isn't a directory." + exit +fi + +# touch foo.c && ./foo.c +regex="bash: \./((.*)\.c): Permission denied" +if [[ "$output" =~ $regex ]]; then + + # If file exists + if [[ -f "${BASH_REMATCH[1]}" ]]; then + echo "Did you mean to run \`make ${BASH_REMATCH[2]}\` and then \`./${BASH_REMATCH[2]}\`?" + exit + fi +fi + +# touch foo.py && ./foo.py +regex="bash: \./(.*\.py): Permission denied" +if [[ "$output" =~ $regex ]]; then + + # If file exists + if [[ -f "${BASH_REMATCH[1]}" ]]; then + echo "Did you mean to run \`python ${BASH_REMATCH[1]}\`?" + exit + fi +fi + +# echo "int main(void) {}" > foo && ./foo +regex="bash: \./([^\.]*): Permission denied" +if [[ "$output" =~ $regex ]]; then + if [[ $(file --brief --mime-type "${BASH_REMATCH[1]}") == "text/x-c" ]]; then + echo "Did you mean to give \`"${BASH_REMATCH[1]}"\` a name of \`"${BASH_REMATCH[1]}".c\` (and then compile it with \`make\`) instead?" + exit + fi +fi + +# touch foo && /.foo +regex="bash: /\.([^:]*): No such file or directory" +if [[ "$output" =~ $regex ]]; then + if [[ -f "${BASH_REMATCH[1]}" ]]; then + echo "Did you mean to run \`./${BASH_REMATCH[1]}\`?" + exit + fi +fi + +# int main(void) || do { +regex="bash: syntax error near unexpected token \`.*'" +if [[ "$output" =~ $regex ]]; then + echo "Did you mean to type that in a file instead of your terminal window?" + exit +fi diff --git a/opt/cs50/lib/help50/cd b/opt/cs50/lib/help50/cd new file mode 100755 index 0000000..8fa9262 --- /dev/null +++ b/opt/cs50/lib/help50/cd @@ -0,0 +1,27 @@ +#!/bin/bash + +. /opt/cs50/lib/cli + +output=$(cat) + +# mkdir -p foo/bar && cd bar +regex="cd: (.*): No such file or directory" +if [[ "$output" =~ $regex ]]; then + + # Search recursively for directory + dir="${BASH_REMATCH[1]}" + parent=$(_find -type d "$dir") + echo -n "There isn't a directory called \`$dir\` in your current directory." + if [[ ! -z "$parent" ]]; then + echo " Did you mean to run \`cd $parent\` first?" + else + echo + fi +fi + +# cd.. || cd. +regex="bash: cd\.\.?: command not found" +if [[ "$output" =~ $regex ]]; then + echo "Did you mean to run \`cd ..\` instead?" + exit +fi diff --git a/opt/cs50/lib/help50/clang b/opt/cs50/lib/help50/clang new file mode 100755 index 0000000..8c7aa69 --- /dev/null +++ b/opt/cs50/lib/help50/clang @@ -0,0 +1,14 @@ +#!/bin/bash + +output=$(cat) + +# touch helpers.c && make helpers +regex="undefined reference to \`main'" +if [[ "$output" =~ $regex ]]; then + regex="make: \*\*\* \[: (.*)\] Error 1" + if [[ "$output" =~ $regex ]]; then + file="${BASH_REMATCH[1]}.c" + echo "Looks like \`$file\` does not have a \`main\` function. Did you mean to \`make\` something else?" + exit + fi +fi diff --git a/opt/cs50/lib/help50/make b/opt/cs50/lib/help50/make new file mode 100755 index 0000000..db79c38 --- /dev/null +++ b/opt/cs50/lib/help50/make @@ -0,0 +1,45 @@ +#!/bin/bash + +. /opt/cs50/lib/cli + +output=$(cat) + +regex="make: Nothing to be done for '(.*)'" +if [[ "$output" =~ $regex ]]; then + + # If target is a directory + if [[ -d "${BASH_REMATCH[1]}" ]]; then + echo "Cannot run \`make\` on a directory. Did you mean to run \`cd ${BASH_REMATCH[1]}\` instead?" + exit + fi + + # If target ends with .c + if [[ "${BASH_REMATCH[1]}" == *?.c ]]; then + base="${BASH_REMATCH[1]%.c}" + if [[ -n "$base" && ! -d "$base" ]]; then + echo "Did you mean to run \`make ${base}\` instead?" + exit + fi + fi + +fi + +regex="make: \*\*\* No rule to make target '(.*)'" +if [[ "$output" =~ $regex ]]; then + + # If no .c file for target + file="${BASH_REMATCH[1]}" + [[ "$file" == *.c ]] || file="$file.c" + if [[ ! -f "$file" ]]; then + + # Search recursively for .c file + dir=$(_find -type f "$file") + echo -n "There isn't a file called \`$file\` in your current directory." + if [[ ! -z "$dir" ]]; then + echo " Did you mean to run \`cd $dir\` first?" + else + echo + fi + exit + fi +fi diff --git a/opt/cs50/lib/help50/python b/opt/cs50/lib/help50/python new file mode 100755 index 0000000..c47be5d --- /dev/null +++ b/opt/cs50/lib/help50/python @@ -0,0 +1,65 @@ +#!/bin/bash + +. /opt/cs50/lib/cli + +output=$(cat) + +# touch cs50.py && python -c "import cs50; cs50.get_int" && python -c "from cs50 import get_int" +regex="AttributeError: module 'cs50' has no attribute '.*'|ImportError: cannot import name '.*' from 'cs50'" +if [[ "$output" =~ $regex ]]; then + if [[ -f cs50.py ]]; then + echo "You have a file called \`cs50.py\` that is \"shadowing\" CS50's own. Best to rename that file with \`mv\`." + exit + fi +fi + +# touch re.py && python -c "import re; re.search" && python -c "from re import search" +regex="AttributeError: module 're' has no attribute '.*'|ImportError: cannot import name '.*' from 're'" +if [[ "$output" =~ $regex ]]; then + if [[ -f re.py ]]; then + echo "You have a file called \`re.py\` that is \"shadowing\" Python's own. Best to rename that file with \`mv\`." + exit + fi +fi + +# touch re.py && python -c "import string; string.digits" && python -c "from string import digits" +regex="AttributeError: module 'string' has no attribute '.*'|ImportError: cannot import name '.*' from 'string'" +if [[ "$output" =~ $regex ]]; then + if [[ -f string.py ]]; then + echo "You have a file called \`string.py\` that is \"shadowing\" Python's own. Best to rename that file with \`mv\`." + exit + fi +fi + +# mkdir -p foo/bar && touch foo/bar/baz.py && python baz.py +regex="python: can't open file '(.*\.py)': \[Errno 2\] No such file or directory" +if [[ "$output" =~ $regex ]]; then + + # Relative path from $PWD (file doesn't exist, so don't require it to) + path=$(realpath --canonicalize-missing --relative-to=. "${BASH_REMATCH[1]}") + + # If command was `python baz.py` (i.e., without a dirname) + if [[ -n "$path" && "$path" == $(basename "$path") ]]; then + dir=$(_find -type f "$path") + echo -n "There isn't a file called \`$path\` in your current directory." + if [[ ! -z "$dir" ]]; then + echo " Did you mean to \`cd $dir\` first?" + else + echo + fi + exit + fi + + # If command was `python bar/baz.py` (i.e., with a dirname) + if [[ -n "$path" ]]; then + base=$(basename "$path") + dir=$(_find -type f "$base") + echo -n "There isn't a file called \`$path\` in your current directory." + if [[ ! -z "$dir" ]]; then + echo " Did you mean to run \`python $dir/$base\` instead?" + else + echo + fi + exit + fi +fi diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..3431339 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Smoke-tests a built cs50/cli image. Usage: tests/smoke.sh [IMAGE] +# Each check has a timeout so that a regression that hangs the shell fails loudly. + +set -o errexit -o nounset -o pipefail + +IMAGE="${1:-cs50/cli}" +run() { timeout 60 docker run --rm "$@"; } + +echo "Checking $IMAGE" + +echo "- non-interactive login shell exits (help50 must not start without a terminal)" +run "$IMAGE" bash --login -c 'echo ok' | grep -qx ok +echo true | run --interactive "$IMAGE" bash --login + +echo "- runtime dependencies of help50 are installed" +run "$IMAGE" bash --login -c 'for c in ansi2txt col file script fold; do command -v "$c" > /dev/null || { echo "missing $c" >&2; exit 1; }; done' + +echo "- help50 controller is the Bash version, on PATH, and enabled by default" +run "$IMAGE" bash --login -c 'test "$(type -P help50)" = /opt/cs50/bin/help50 && help50 is-enabled && test "$(help50 status)" = stopped' > /dev/null + +echo "- wrappers print their message even when stdin is redirected" +run "$IMAGE" bash --login -c 'valgrind python x.py < /dev/null; test $? -eq 1' 2>&1 | grep -q 'does not support Python' + +echo "- _fold wraps without a terminal" +run "$IMAGE" bash --login -c '. /opt/cs50/lib/cli; TERM= _fold "$(printf "a %.0s" {1..100})"' | head -n 1 | grep -qE '^.{1,80}$' + +echo "- _helpless gets the end of a failed command's long output, plus its command line" +run "$IMAGE" bash --login -c ' + export HELP50=$(mktemp) + _helpless() { printf "%s" "$1" > /tmp/output; printf "%s" "$2" > /tmp/cmd; } + . /etc/profile.d/help50.sh + + # A command that prints 3000 lines and then fails, as script(1) records it + { printf "$ ./slow\r\n"; seq 3000 | sed "s/$/\r/"; printf "Error: boom\r\n"; } > "$HELP50" + set -o history; history -s ./slow; set +o history + false; _help50 + test "$(cat /tmp/cmd)" = ./slow && + test "$(head -n 1 /tmp/output)" = 1 && + grep -qx "\[... 1914 lines omitted ...\]" /tmp/output && + ! grep -qx 1000 /tmp/output && + test "$(tail -n 1 /tmp/output)" = "Error: boom" || exit 1 + + # A command that fails without output + : > "$HELP50" + set -o history; history -s ./slow; set +o history + false; _help50 + test "$(cat /tmp/cmd)" = ./slow && test ! -s /tmp/output || exit 1 + + # A command run via help50 is reported as the command itself + : > "$HELP50" + set -o history; history -s "help50 ./slow"; set +o history + false; _help50 + test "$(cat /tmp/cmd)" = ./slow || exit 1 +' + +echo "- the prompt hook stays fast after a failed command printed a huge amount of output" +run "$IMAGE" bash --login -c ' + export HELP50=$(mktemp) + _helpless() { printf "%s" "$1" > /tmp/output; } + . /etc/profile.d/help50.sh + + # 35 MB (4 million lines) of output, then an error, as script(1) records it. + # Reading the whole file into a variable took ~2.4 s here and scaled linearly. + { printf "$ ./huge\r\n"; seq 1 4000000 | sed "s/$/\r/"; printf "Error: boom\r\n"; } > "$HELP50" + size=$(stat -c %s "$HELP50") + set -o history; history -s ./huge; set +o history + start=$(date +%s%N); false; _help50; elapsed=$(( ($(date +%s%N) - start) / 1000000 )) + echo " hook took ${elapsed} ms for a ${size}-byte typescript" + test "$elapsed" -lt 1000 && + test "$(tail -n 1 /tmp/output)" = "Error: boom" || exit 1 +' + +echo "- a helper that hangs cannot stall the prompt" +run --user root "$IMAGE" bash --login -c ' + printf "#!/bin/bash\ncat > /dev/null\nsleep 60\n" > /opt/cs50/lib/help50/zz_hang && chmod 755 /opt/cs50/lib/help50/zz_hang + su ubuntu -c "bash --login -c '"'"' + export HELP50=\$(mktemp); . /etc/profile.d/help50.sh + printf \"\$ ./x\\r\\nsome error\\r\\n\" > \"\$HELP50\" + set -o history; history -s ./x; set +o history + start=\$(date +%s); false; _help50; elapsed=\$(( \$(date +%s) - start )) + echo \"hook took \${elapsed} s with a hung helper\"; test \"\$elapsed\" -lt 15 + '"'"'" +' + +echo "- HELP50_DISABLED in the environment keeps help50 from starting, and says so" +run "$IMAGE" bash --login -c 'help50 is-enabled | grep -qx enabled' +run --env HELP50_DISABLED=1 "$IMAGE" bash --login -c ' + out=$(help50 is-enabled); test $? -eq 1 && [[ "$out" == *HELP50_DISABLED=1* && "$out" == *unset* ]] || exit 1' +# Values that read as false count as unset, so that setting the secret to 0 re-enables help50, as deleting it would +for value in 0 false FALSE no off ""; do + run --env HELP50_DISABLED="$value" "$IMAGE" bash --login -c 'help50 is-enabled | grep -qx enabled' +done +# The lock file (help50 disable) is still honored when the environment doesn't disable +run --env HELP50_DISABLED=0 "$IMAGE" bash --login -c 'help50 disable && ! help50 is-enabled && help50 enable && help50 is-enabled' > /dev/null +# In an interactive shell on a pty (script provides one), help50 starts by default but not when disabled +run "$IMAGE" bash -c 'echo "help50 status; exit" | script -qc "bash --login -i" /dev/null' | grep -q '^started' +run --env HELP50_DISABLED=1 "$IMAGE" bash -c 'echo "help50 status; exit" | script -qc "bash --login -i" /dev/null' | grep -q '^stopped' + +echo "- help50 COMMAND runs COMMAND, with its exit status" +run "$IMAGE" bash --login -c 'help50 true && ! help50 false && test "$(help50 echo x)" = x' +run "$IMAGE" bash --login -c 'help50 valgrind python x.py < /dev/null; test $? -eq 1' 2>&1 | grep -q 'does not support Python' + +echo "- help50 COMMAND fails as COMMAND would have, so that helpers recognize the error" +run "$IMAGE" bash --login -c 'out=$(help50 1s 2>&1); test $? -eq 127 && test "$out" = "bash: 1s: command not found"' +run "$IMAGE" bash --login -c 'out=$(help50 --version 2>&1); test $? -eq 127 && test "$out" = "bash: --version: command not found"' +run "$IMAGE" bash --login -c 'out=$(help50 cd nothere 2>&1); test $? -eq 1 && test "$out" = "bash: cd: nothere: No such file or directory"' +run "$IMAGE" bash --login -c 'cd "$(mktemp -d)" && touch foo.c && out=$(help50 ./foo.c 2>&1); test $? -eq 126 && test "$out" = "bash: ./foo.c: Permission denied"' +run "$IMAGE" bash --login -c 'cd "$(mktemp -d)" && mkdir foo && out=$(help50 ./foo 2>&1); test $? -eq 126 && test "$out" = "bash: ./foo: Is a directory"' + +echo "- help50 alone prints usage and exits 0" +run "$IMAGE" bash --login -c 'help50 | grep -q "^Usage: help50 COMMAND" && help50 -h > /dev/null && help50 --help > /dev/null' + +echo "- root (as via sudo) may run help50 COMMAND but not its subcommands" +run "$IMAGE" bash --login -c 'test "$(sudo help50 echo x)" = x && test -z "$(sudo help50 is-enabled 2>&1)"' + +echo "- within a help50 session, help50 COMMAND runs in the shell itself, so cd and aliases apply" +run "$IMAGE" bash --login -c ' + export HELP50=$(mktemp) + . /etc/profile.d/help50.sh + shopt -s expand_aliases + alias rm="echo aliased" + help50 cd /tmp && test "$PWD" = /tmp || exit 1 + test "$(help50 rm x)" = "aliased x" || exit 1 + help50 false; test $? -eq 1 || exit 1 + out=$(help50 cd nothere 2>&1); test $? -eq 1 && [[ "$out" == *"cd: nothere: No such file or directory" ]] || exit 1 + help50 is-enabled > /dev/null && test "$(help50 status)" = started || exit 1 +' + +echo "OK"