From 3992a5ada941ac61dfdda807e97de5492ae5aebd Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:36:29 -0400 Subject: [PATCH 1/2] Run `help50 COMMAND` as though COMMAND were typed directly Students used to run `help50 make foo`. Help now arrives automatically after any failed command, so run COMMAND with the same exit status and let the prompt hook handle the rest: help50 make foo behaves exactly like make foo. Builtins go through bash -c with the shell's error wording preserved; an unknown command fails with the shell's own "command not found" message so helpers match it. Bare help50 prints usage (exit 0) explaining the new behaviour. The subcommands (start/stop/...) are unchanged; only they require non-root. --- opt/cs50/bin/help50 | 56 ++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/opt/cs50/bin/help50 b/opt/cs50/bin/help50 index 7b88dc5..c534430 100755 --- a/opt/cs50/bin/help50 +++ b/opt/cs50/bin/help50 @@ -1,10 +1,5 @@ #!/bin/bash -# If root -if [[ `id -u` -eq 0 ]]; then - exit 1 -fi - function _disable() { touch /tmp/help50.lock } @@ -80,28 +75,43 @@ function _stop() { 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. +function _run() { + if [[ -n "$(type -P "$1")" ]]; then + exec "$@" + elif [[ -n "$(type -t "$1")" ]]; then # Builtin or keyword, e.g., cd + exec bash -c "$(printf '%q ' "$@")" 2> >(sed 's/^bash: line [0-9]*: /bash: /' >&2) + 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) - _disable - ;; - enable) - _enable - ;; - is-enabled) - _is-enabled - ;; - start) - _start - ;; - status) - _status + disable|enable|is-enabled|start|status|stop) + + # Not for root + if [[ `id -u` -eq 0 ]]; then + exit 1 + fi + _${1} ;; - stop) - _stop + ""|-h|--help) + _usage ;; *) - echo "Usage: $0 [disable|enable|is-enabled|start|status|stop]" - exit 1 + _run "$@" ;; esac From 1b189f730b440d25f07ba293a2d8bbae6a031ae8 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:40:03 -0400 Subject: [PATCH 2/2] fix(review): make help50 COMMAND match a direct run, and test it Within a help50 session, define help50 as a shell function that evals COMMAND in the calling shell, so aliases (rm -i), functions, and cd behave exactly as they would directly, and no stderr is rewritten through an unwaited sed. In the script (outside a session), route path-shaped names (./foo.c, ./dir) through bash -c so the shell's own Permission denied / Is a directory errors reach the helpers instead of a synthesized "command not found"; wait for sed before exiting; pass -- to type so option-like commands don't leak usage noise. In the prompt hook, treat `help50 COMMAND` as COMMAND when deriving argv, so helpers that look at positional words (e.g., `check 50`) and the ./ re-make hint still fire. Smoke-test the passthrough: exit statuses, exact error text for unknown, option-like, builtin, and path-shaped commands, usage, sudo, the in-shell function, and the hook's argv handling. --- etc/profile.d/help50.sh | 22 ++++++++++++++++++++++ opt/cs50/bin/help50 | 16 ++++++++++++---- tests/smoke.sh | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/etc/profile.d/help50.sh b/etc/profile.d/help50.sh index 7e9eded..3d21308 100644 --- a/etc/profile.d/help50.sh +++ b/etc/profile.d/help50.sh @@ -22,6 +22,14 @@ function _help50() { 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 @@ -132,6 +140,20 @@ 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 diff --git a/opt/cs50/bin/help50 b/opt/cs50/bin/help50 index c534430..2bf77d3 100755 --- a/opt/cs50/bin/help50 +++ b/opt/cs50/bin/help50 @@ -77,12 +77,20 @@ function _stop() { # 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. +# 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 + if [[ -n "$(type -P -- "$1")" ]]; then exec "$@" - elif [[ -n "$(type -t "$1")" ]]; then # Builtin or keyword, e.g., cd - exec bash -c "$(printf '%q ' "$@")" 2> >(sed 's/^bash: line [0-9]*: /bash: /' >&2) + 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 diff --git a/tests/smoke.sh b/tests/smoke.sh index 10d4a36..c8511cf 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -46,6 +46,42 @@ run "$IMAGE" bash --login -c ' 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 "- 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"