From 0d9be92134017dcb5a29964eb37376486facde18 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:06:46 -0400 Subject: [PATCH 1/9] add help50 integration - codespace.sh: override help50's _helpful/_helpless/_helped hooks to relay to the help50 extension via command50, which shows a button in the terminal title bar that hands the message to the CS50 Duck; skip _helpless when the failed command produced no output (e.g., grep with no match) - codespace.sh: set WORKDIR to the workspace so help50's helpers can find misplaced files (cs50/cli defaults it to $HOME) - Dockerfile, devcontainer.json: build and install help50.vsix - devcontainer.json: add a Sysadmins terminal profile (sudo bash), which gets a root shell without help50 --- Dockerfile | 7 ++++++ devcontainer.json | 7 +++++- etc/profile.d/codespace.sh | 46 +++++++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index af014aa5..32a395ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,6 +94,13 @@ RUN npm install --global @vscode/vsce yarn && \ mv design50-1.0.0.vsix /opt/cs50/extensions && \ cd /tmp && \ rm --force --recursive design50.vsix && \ + git clone https://github.com/cs50/help50.vsix.git && \ + cd help50.vsix && \ + npm install && \ + vsce package && \ + mv help50-0.0.1.vsix /opt/cs50/extensions && \ + cd /tmp && \ + rm --force --recursive help50.vsix && \ npm uninstall --global vsce yarn diff --git a/devcontainer.json b/devcontainer.json index 3ce0bc46..ba6d181c 100644 --- a/devcontainer.json +++ b/devcontainer.json @@ -14,6 +14,7 @@ "/opt/cs50/extensions/cs50-0.0.1.vsix", "/opt/cs50/extensions/design50-1.0.0.vsix", "/opt/cs50/extensions/ddb50-2.0.0.vsix", + "/opt/cs50/extensions/help50-0.0.1.vsix", "/opt/cs50/extensions/phpliteadmin-0.0.1.vsix", "/opt/cs50/extensions/style50-0.0.1.vsix", "cs50.extension-uninstaller", @@ -189,7 +190,11 @@ "terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.gpuAcceleration": "off", "terminal.integrated.profiles.linux": { - "JavaScript Debug Terminal": null + "JavaScript Debug Terminal": null, + "Sysadmins": { + "path": "sudo", + "args": ["bash"] + } }, "terminal.integrated.persistentSessionReviveProcess": "never", "terminal.integrated.sendKeybindingsToShell": true, diff --git a/etc/profile.d/codespace.sh b/etc/profile.d/codespace.sh index f49ed703..caaa3c85 100644 --- a/etc/profile.d/codespace.sh +++ b/etc/profile.d/codespace.sh @@ -1,5 +1,8 @@ # If not root -if [ "$(whoami)" != "root" ]; then +if [ `id -u` -ne 0 ]; then + + # Library (from cs50/cli) + . /opt/cs50/lib/cli # Check if running locally and set $RepositoryName if not already set if [[ "$CODESPACES" != "true" && -z "$RepositoryName" ]]; then @@ -7,6 +10,9 @@ if [ "$(whoami)" != "root" ]; then export LOCAL_WORKSPACE_FOLDER="/workspaces/$RepositoryName" fi + # Where help50's helpers look for misplaced files (cs50/cli defaults this to $HOME) + export WORKDIR="/workspaces/$RepositoryName" + # Rewrites URLs of the form http://HOST:PORT as https://$CODESPACE_NAME.app.github.dev:PORT _hostname() { @@ -79,4 +85,42 @@ if [ "$(whoami)" != "root" ]; then http-server() { command http-server "$@" | _hostname | _version | uniq } + + # help50 hooks, called by _help50 in /etc/profile.d/help50.sh after each command. + # These relay to the help50 VS Code extension via command50, which shows a "help50" + # button in the terminal's title bar; clicking it hands the message to the CS50 Duck. + # command50 runs detached with output discarded so the prompt isn't delayed and a + # missing extension server degrades silently. + _help50_button() { + ( command50 help50.showButton "$1" "$2" > /dev/null 2>&1 & ) + _HELP50_BUTTON=1 + } + + # A helper had advice: show it here (as in cs50/cli) and let the duck repeat it + _helpful() { + for name in n no y yes; do + alias $name=_rhetorical # Intercept answers to the rhetorical question + done + _alert "$(_ansi "$1")" + _help50_button say "$1" + } + + # No helper matched: offer the duck the failed command's output to explain. + # Skip when there's no output (e.g., grep with no match, or a program exiting 1), + # since there'd be nothing to explain and most such exits aren't errors. + _helpless() { + if [[ -z "${1//[[:space:]]/}" ]]; then + return + fi + _alert "$(_ansi "🦆 Click \`help50\` above for help with that error.")" + _help50_button ask "$1" + } + + # Command succeeded: hide the button, if showing + _helped() { + if [[ -n "$_HELP50_BUTTON" ]]; then + ( command50 help50.hideButton > /dev/null 2>&1 & ) + unset _HELP50_BUTTON + fi + } fi From 6eb8a52c72b7621a3229be77b96aaa19ca6fcb18 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:06:46 -0400 Subject: [PATCH 2/9] add smoke test for built image tests/smoke.sh runs the built image under timeouts: non-interactive login shells exit (help50 must not start without a terminal), the help50 hooks are the codespace overrides and behave, help50.vsix and command50 are present, and root shells have no help50. Run in CI after each build and before any push. --- .github/workflows/main.yml | 10 ++++++++++ tests/smoke.sh | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100755 tests/smoke.sh diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9722d6d1..8e4d2ef4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,6 +42,11 @@ jobs: npm install -g @devcontainers/cli devcontainer build --workspace-folder . --config devcontainer.json --image-name cs50/codespace:${{ github.sha }} --image-name cs50/codespace:latest + - name: Smoke-test (latest) + if: ${{ github.ref == 'refs/heads/main' }} + run: | + bash tests/smoke.sh cs50/codespace:latest + - name: Push (latest) to GitHub Packages if: ${{ github.ref == 'refs/heads/main' }} run: | @@ -72,6 +77,11 @@ jobs: npm install -g @devcontainers/cli devcontainer build --workspace-folder . --config devcontainer.json --image-name cs50/codespace:${{ github.sha }} --image-name cs50/codespace:${{ github.ref_name }} + - name: Smoke-test (${{ github.ref_name }}) + if: ${{ github.ref != 'refs/heads/main' }} + run: | + bash tests/smoke.sh cs50/codespace:${{ github.ref_name }} + - name: Squash (${{ github.ref_name }}) if: ${{ github.ref != 'refs/heads/main' }} run: | diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 00000000..a71cf9a3 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Smoke-tests a built cs50/codespace 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 errtrace -o nounset -o pipefail +step="startup" +trap 'echo "FAILED: $step" >&2' ERR + +IMAGE="${1:-cs50/codespace}" +run() { timeout 60 docker run --rm --env CODESPACES=true --env RepositoryName=smoke "$@"; } +check() { step="$1"; echo "- $step"; } + +echo "Checking $IMAGE" + +check "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 + +check "help50 is inherited from cs50/cli and its hooks are overridden by codespace.sh" +run "$IMAGE" bash --login -c ' + test "$(type -P help50)" = /opt/cs50/bin/help50 && + test -f /etc/profile.d/help50.sh && + test "$WORKDIR" = /workspaces/smoke && + for f in _helped _helpful _helpless _help50_button _alert _ansi; do declare -F "$f" > /dev/null || { echo "missing $f" >&2; exit 1; }; done && + declare -f _help50_button | grep -q command50' + +check "help50 extension is packaged for installation" +run "$IMAGE" bash --login -c 'test -f /opt/cs50/extensions/help50-0.0.1.vsix && command -v command50 > /dev/null' + +check "hooks behave: advice is printed, empty output is ignored (no extension server needed)" +run "$IMAGE" bash --login -c ' + _helpful "Did you mean \`ls\`?" 2>&1 | grep -q "Did you mean" && + test -z "$(_helpless "" 2>&1)" && + test -z "$(_helpless " " 2>&1)" && + _helpless "cat: x: No such file" 2>&1 | grep -q help50' + +check "root shells (Sysadmins profile) do not start help50" +run --user root "$IMAGE" bash --login -c 'test -z "${HELP50:-}" && ! declare -F _helpful > /dev/null' + +echo "OK" From b1b8a9c4bdf22f9a544025fc0e56a2e56b281bc9 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:12:53 -0400 Subject: [PATCH 3/9] tolerate a cs50/cli base image without help50 Branch builds use cs50/cli:amd64 (main), which doesn't have help50 yet, so codespace.sh errored on every login shell sourcing /opt/cs50/lib/cli. Guard the source, and have the smoke test skip the help50 checks (visibly) when the base image lacks them, while still asserting that login shells start cleanly. --- etc/profile.d/codespace.sh | 6 ++++-- tests/smoke.sh | 44 +++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/etc/profile.d/codespace.sh b/etc/profile.d/codespace.sh index caaa3c85..a3724b2f 100644 --- a/etc/profile.d/codespace.sh +++ b/etc/profile.d/codespace.sh @@ -1,8 +1,10 @@ # If not root if [ `id -u` -ne 0 ]; then - # Library (from cs50/cli) - . /opt/cs50/lib/cli + # Library (from cs50/cli; absent in images built before help50 landed there) + if [ -f /opt/cs50/lib/cli ]; then + . /opt/cs50/lib/cli + fi # Check if running locally and set $RepositoryName if not already set if [[ "$CODESPACES" != "true" && -z "$RepositoryName" ]]; then diff --git a/tests/smoke.sh b/tests/smoke.sh index a71cf9a3..2eb27291 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -9,32 +9,42 @@ trap 'echo "FAILED: $step" >&2' ERR IMAGE="${1:-cs50/codespace}" run() { timeout 60 docker run --rm --env CODESPACES=true --env RepositoryName=smoke "$@"; } check() { step="$1"; echo "- $step"; } +skip() { echo "- SKIPPED: $1"; } echo "Checking $IMAGE" +check "login shells start without errors" +test -z "$(run "$IMAGE" bash --login -c 'true' 2>&1)" + check "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 -check "help50 is inherited from cs50/cli and its hooks are overridden by codespace.sh" -run "$IMAGE" bash --login -c ' - test "$(type -P help50)" = /opt/cs50/bin/help50 && - test -f /etc/profile.d/help50.sh && - test "$WORKDIR" = /workspaces/smoke && - for f in _helped _helpful _helpless _help50_button _alert _ansi; do declare -F "$f" > /dev/null || { echo "missing $f" >&2; exit 1; }; done && - declare -f _help50_button | grep -q command50' +# The rest applies once the cs50/cli base image carries help50 (cs50/cli#210). +# Branch builds use cs50/cli:amd64 (main), canary uses cs50/cli:canary. +if run "$IMAGE" bash --login -c 'test -f /etc/profile.d/help50.sh -a -f /opt/cs50/lib/cli'; then + + check "help50 is inherited from cs50/cli and its hooks are overridden by codespace.sh" + run "$IMAGE" bash --login -c ' + test "$(type -P help50)" = /opt/cs50/bin/help50 && + test "$WORKDIR" = /workspaces/smoke && + for f in _helped _helpful _helpless _help50_button _alert _ansi; do declare -F "$f" > /dev/null || { echo "missing $f" >&2; exit 1; }; done && + declare -f _help50_button | grep -q command50' + + check "hooks behave: advice is printed, empty output is ignored (no extension server needed)" + run "$IMAGE" bash --login -c ' + _helpful "Did you mean \`ls\`?" 2>&1 | grep -q "Did you mean" && + test -z "$(_helpless "" 2>&1)" && + test -z "$(_helpless " " 2>&1)" && + _helpless "cat: x: No such file" 2>&1 | grep -q help50' + + check "root shells (Sysadmins profile) do not start help50" + run --user root "$IMAGE" bash --login -c 'test -z "${HELP50:-}" && ! declare -F _helpful > /dev/null' +else + skip "help50 checks: base image has no help50 (cs50/cli without #210)" +fi check "help50 extension is packaged for installation" run "$IMAGE" bash --login -c 'test -f /opt/cs50/extensions/help50-0.0.1.vsix && command -v command50 > /dev/null' -check "hooks behave: advice is printed, empty output is ignored (no extension server needed)" -run "$IMAGE" bash --login -c ' - _helpful "Did you mean \`ls\`?" 2>&1 | grep -q "Did you mean" && - test -z "$(_helpless "" 2>&1)" && - test -z "$(_helpless " " 2>&1)" && - _helpless "cat: x: No such file" 2>&1 | grep -q help50' - -check "root shells (Sysadmins profile) do not start help50" -run --user root "$IMAGE" bash --login -c 'test -z "${HELP50:-}" && ! declare -F _helpful > /dev/null' - echo "OK" From 04371e657b24e3223719add8b284e1b45dc83810 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:58:08 -0400 Subject: [PATCH 4/9] hide a stale help50 button after a quiet failure Only a successful command hid the button, so a later failure with no output (e.g., grep with no match) left a button about an earlier command. Now any completed command either replaces the button or clears it. --- etc/profile.d/codespace.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/etc/profile.d/codespace.sh b/etc/profile.d/codespace.sh index a3724b2f..6463419a 100644 --- a/etc/profile.d/codespace.sh +++ b/etc/profile.d/codespace.sh @@ -97,6 +97,12 @@ if [ `id -u` -ne 0 ]; then ( command50 help50.showButton "$1" "$2" > /dev/null 2>&1 & ) _HELP50_BUTTON=1 } + _help50_hide() { + if [[ -n "$_HELP50_BUTTON" ]]; then + ( command50 help50.hideButton > /dev/null 2>&1 & ) + unset _HELP50_BUTTON + fi + } # A helper had advice: show it here (as in cs50/cli) and let the duck repeat it _helpful() { @@ -108,10 +114,11 @@ if [ `id -u` -ne 0 ]; then } # No helper matched: offer the duck the failed command's output to explain. - # Skip when there's no output (e.g., grep with no match, or a program exiting 1), - # since there'd be nothing to explain and most such exits aren't errors. + # If there's no output (e.g., grep with no match, or a program exiting 1), there's + # nothing to explain, and any button still showing is about an earlier command. _helpless() { if [[ -z "${1//[[:space:]]/}" ]]; then + _help50_hide return fi _alert "$(_ansi "🦆 Click \`help50\` above for help with that error.")" @@ -120,9 +127,6 @@ if [ `id -u` -ne 0 ]; then # Command succeeded: hide the button, if showing _helped() { - if [[ -n "$_HELP50_BUTTON" ]]; then - ( command50 help50.hideButton > /dev/null 2>&1 & ) - unset _HELP50_BUTTON - fi + _help50_hide } fi From 552b0fd9c2276cebe949523e644c7436d5be328b Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:33:19 -0400 Subject: [PATCH 5/9] don't show the help50 button when a helper already gave advice The advice is already on screen, so the button only added a click. Reserve it for failures no helper recognized, where the duck can ask cs50.ai. A recognized failure now clears any stale button like other outcomes do. --- etc/profile.d/codespace.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/etc/profile.d/codespace.sh b/etc/profile.d/codespace.sh index 6463419a..5f15a6c8 100644 --- a/etc/profile.d/codespace.sh +++ b/etc/profile.d/codespace.sh @@ -89,8 +89,9 @@ if [ `id -u` -ne 0 ]; then } # help50 hooks, called by _help50 in /etc/profile.d/help50.sh after each command. - # These relay to the help50 VS Code extension via command50, which shows a "help50" - # button in the terminal's title bar; clicking it hands the message to the CS50 Duck. + # When no local helper has advice, these relay the failed command's output to the + # help50 VS Code extension via command50, which shows a "help50" button in the + # terminal's title bar; clicking it asks the CS50 Duck to explain the error. # command50 runs detached with output discarded so the prompt isn't delayed and a # missing extension server degrades silently. _help50_button() { @@ -104,13 +105,14 @@ if [ `id -u` -ne 0 ]; then fi } - # A helper had advice: show it here (as in cs50/cli) and let the duck repeat it + # A helper had advice: show it here, as in cs50/cli. No button, since the advice is + # already on screen; a button still showing is about an earlier command. _helpful() { for name in n no y yes; do alias $name=_rhetorical # Intercept answers to the rhetorical question done _alert "$(_ansi "$1")" - _help50_button say "$1" + _help50_hide } # No helper matched: offer the duck the failed command's output to explain. From 35a2c79aba82feba3e9151ef88a8a8723f114baf Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:57:46 -0400 Subject: [PATCH 6/9] send the command line along with the output to the duck cs50/cli#246 passes the failed command's line to _helpless as a second argument. Include it, as a transcript ($ cmd, then output), so cs50.ai can see what the student ran, not just what came back. --- etc/profile.d/codespace.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/etc/profile.d/codespace.sh b/etc/profile.d/codespace.sh index 5f15a6c8..e55cc267 100644 --- a/etc/profile.d/codespace.sh +++ b/etc/profile.d/codespace.sh @@ -118,13 +118,18 @@ if [ `id -u` -ne 0 ]; then # No helper matched: offer the duck the failed command's output to explain. # If there's no output (e.g., grep with no match, or a program exiting 1), there's # nothing to explain, and any button still showing is about an earlier command. + # $1 is the output, $2 the command line; the duck gets both, as a transcript. _helpless() { if [[ -z "${1//[[:space:]]/}" ]]; then _help50_hide return fi _alert "$(_ansi "🦆 Click \`help50\` above for help with that error.")" - _help50_button ask "$1" + if [[ -n "$2" ]]; then + _help50_button ask "$ $2"$'\n'"$1" + else + _help50_button ask "$1" + fi } # Command succeeded: hide the button, if showing From 19e614b76fd17fea28fcc9202ccba7a447337aff Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:14:28 -0400 Subject: [PATCH 7/9] smoke-test the transcript _helpless sends to the duck _help50_button hands the payload to command50 with output discarded, so no check could see it. Stub it and assert both forms: with a command line, "$ cmd" then the output; without one, the output alone. Whitespace-only output still hides the button even when a command line is supplied. --- tests/smoke.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/smoke.sh b/tests/smoke.sh index 2eb27291..cafbb5fd 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -38,6 +38,15 @@ if run "$IMAGE" bash --login -c 'test -f /etc/profile.d/help50.sh -a -f /opt/cs5 test -z "$(_helpless " " 2>&1)" && _helpless "cat: x: No such file" 2>&1 | grep -q help50' + # _help50_button discards command50's output, so stub it to see the payload + check "the duck gets the command line and output as a transcript (or output alone without a command)" + run "$IMAGE" bash --login -c ' + _alert() { :; } + _help50_button() { printf "%s" "$2"; } + test "$(_helpless "cat: x: No such file" "cat x")" = "$(printf "%s\n%s" "$ cat x" "cat: x: No such file")" && + test "$(_helpless "cat: x: No such file")" = "cat: x: No such file" && + test -z "$(_helpless " " "cat x")"' + check "root shells (Sysadmins profile) do not start help50" run --user root "$IMAGE" bash --login -c 'test -z "${HELP50:-}" && ! declare -F _helpful > /dev/null' else From 0871379ce03830717d9e57e89b6955e47e9cb74c Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:29:42 +0100 Subject: [PATCH 8/9] rebuild image (cs50/cli updates) From c31dc4bdf7c448a896f638f9dec08f71be4078d4 Mon Sep 17 00:00:00 2001 From: Rongxin Liu <10591665+rongxin-liu@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:25:13 +0100 Subject: [PATCH 9/9] rebuild image (help50/ddb50 vsix updates)