diff --git a/src/github-cli/NOTES.md b/src/github-cli/NOTES.md index e742805e6..1c9808aea 100644 --- a/src/github-cli/NOTES.md +++ b/src/github-cli/NOTES.md @@ -4,6 +4,38 @@ This Feature should work on recent versions of Debian/Ubuntu-based distributions `bash` is required to execute the `install.sh` script. +## Authentication + +If you set the `authOnSetup` option, the feature installs a one-time shell startup hook script. When `gh auth status` shows you are not already authenticated, the hook first tries `gh auth login --with-token` if `GH_TOKEN` or `GITHUB_TOKEN` is available in the environment, otherwise it falls back to `gh auth login` in an interactive shell. This happens after the dev container starts, so it can use values injected through runtime environment settings like `remoteEnv`. + +Example: + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "authOnSetup": true, + "extensions": "dlvhdr/gh-dash,github/gh-copilot" + } + }, + "remoteEnv": { + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + } +} +``` + +If you already authenticate on the host with GitHub CLI, export a token before opening the dev container: + +```bash +export GITHUB_TOKEN="$(gh auth token)" +``` + +Then the `remoteEnv` example above will pass that token into the container's post-start auth flow. + ## Extensions -If you set the `extensions` option, the feature will run `gh extension install` for each entry (comma-separated). Extensions are installed for the most appropriate non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. +If you set the `extensions` option, the feature requires either `authOnSetup=true` or `installExtensionsFromGit=true`. The unsupported combination `authOnSetup=false` and `installExtensionsFromGit=false` fails during feature setup with a clear error. + +When `authOnSetup=true`, extension installation is deferred until that post-start auth flow runs so runtime tokens can be used for authenticated installs, unless `installExtensionsFromGit=true`. Extensions are installed for the most appropriate non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. + +Set `installExtensionsFromGit` to `true` if you want to skip `gh extension install` and always clone extension repositories directly. In that mode, extensions install during feature setup even when `authOnSetup` is enabled. diff --git a/src/github-cli/README.md b/src/github-cli/README.md index 0da722f69..2ede6764d 100644 --- a/src/github-cli/README.md +++ b/src/github-cli/README.md @@ -4,19 +4,91 @@ Installs the GitHub CLI. Auto-detects latest version and installs needed depende ## Example Usage +### Basic usage + ```json "features": { "ghcr.io/devcontainers/features/github-cli:1": {} } ``` +### Authenticate to GitHub + +On the first interactive shell after the dev container starts, if you are not already authenticated, the feature tries `gh auth login --with-token` if `GH_TOKEN` or `GITHUB_TOKEN` is available. If those environment variables are not set, it falls back to starting `gh auth login` in an interactive shell. + +**Option 1: interactive authentication on setup** + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "authOnSetup": true + } + } +} +``` + +**Option 2: pass an authentication token from the host** + +If you already authenticate on the host with GitHub CLI, export a token before opening the dev container: + +```bash +export GITHUB_TOKEN="$(gh auth token)" +``` + +Then the `remoteEnv` example above will pass that token into the container's post-start auth flow. + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "authOnSetup": true + } + }, + "remoteEnv": { + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + } +} +``` + +### Install GitHub CLI extensions + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "extensions": "dlvhdr/gh-dash,github/gh-copilot" + } + } +} +``` + +When `extensions` are configured together with `authOnSetup`, extension installation is deferred to the same post-start auth flow so that runtime tokens can be used for authenticated installs. The feature prefers `gh extension install` for each entry when `gh auth status` indicates an authenticated session. If GitHub CLI is not authenticated, or if the native install path fails, the feature falls back to cloning the extension repository into the GitHub CLI extensions directory. + +If `extensions` are configured, at least one of `authOnSetup` or `installExtensionsFromGit` must be `true`. The unsupported combination `authOnSetup=false` and `installExtensionsFromGit=false` fails fast during feature setup. + +If you want to skip `gh extension install` entirely, set `installExtensionsFromGit` to `true`. In that mode, extensions are installed during feature setup even if `authOnSetup` is also enabled. + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "extensions": "dlvhdr/gh-dash,github/gh-copilot", + "installExtensionsFromGit": true + } + } +} +``` + ## Options -| Options Id | Description | Type | Default Value | -| -------------------------------- | --------------------------------------------------------------------------------------------------- | ------- | ------------- | -| version | Select version of the GitHub CLI, if not latest. | string | latest | -| installDirectlyFromGitHubRelease | - | boolean | true | -| extensions | Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot'). | string | | +| Options Id | Description | Type | Default Value | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------- | ------------- | +| version | Select version of the GitHub CLI, if not latest. | string | latest | +| installDirectlyFromGitHubRelease | - | boolean | true | +| authOnSetup | Automatically authenticate with `gh auth login --with-token` when `GH_TOKEN` or `GITHUB_TOKEN` is available, otherwise start `gh auth login` on the first interactive shell if you are not already authenticated. If `extensions` are configured, this must be true unless `installExtensionsFromGit` is true. | boolean | false | +| extensions | Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot'). Requires either `authOnSetup` or `installExtensionsFromGit` to be true. | string | | +| installExtensionsFromGit | Install `extensions` by cloning their repositories directly instead of using `gh extension install`. Set this to true when `authOnSetup` is false and `extensions` are configured. When `authOnSetup` is enabled, git-based installs happen during feature setup instead of waiting for login. | boolean | false | ## OS Support diff --git a/src/github-cli/devcontainer-feature.json b/src/github-cli/devcontainer-feature.json index 15a91e43d..a455a0296 100644 --- a/src/github-cli/devcontainer-feature.json +++ b/src/github-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "github-cli", - "version": "1.1.0", + "version": "1.3.0", "name": "GitHub CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/github-cli", "description": "Installs the GitHub CLI. Auto-detects latest version and installs needed dependencies.", @@ -21,7 +21,17 @@ "extensions": { "type": "string", "default": "", - "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot')." + "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot'). Requires either `authOnSetup` or `installExtensionsFromGit` to be true." + }, + "installExtensionsFromGit": { + "type": "boolean", + "default": false, + "description": "Install `extensions` by cloning their repositories directly instead of using `gh extension install`. Set this to true when `authOnSetup` is false and `extensions` are configured. When used together with `authOnSetup`, git-based installs happen during feature setup instead of waiting for login." + }, + "authOnSetup": { + "type": "boolean", + "default": false, + "description": "Automatically authenticate with `gh auth login --with-token` when `GH_TOKEN` or `GITHUB_TOKEN` is available, otherwise start `gh auth login` on the first interactive shell if you are not already authenticated. If `extensions` are configured, this must be true unless `installExtensionsFromGit` is true." } }, "customizations": { diff --git a/src/github-cli/install.sh b/src/github-cli/install.sh index e3eaba0c3..209ad003c 100755 --- a/src/github-cli/install.sh +++ b/src/github-cli/install.sh @@ -7,274 +7,69 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/github.md # Maintainer: The VS Code and Codespaces Teams -CLI_VERSION=${VERSION:-"latest"} -INSTALL_DIRECTLY_FROM_GITHUB_RELEASE=${INSTALLDIRECTLYFROMGITHUBRELEASE:-"true"} -EXTENSIONS=${EXTENSIONS:-""} - -GITHUB_CLI_ARCHIVE_GPG_KEY=23F3D4EA75716059 - -set -e +set -euo pipefail -# Clean up -rm -rf /var/lib/apt/lists/* - -if [ "$(id -u)" -ne 0 ]; then - echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' - exit 1 -fi - -# Get the list of GPG key servers that are reachable -get_gpg_key_servers() { - declare -A keyservers_curl_map=( - ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" - ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" - ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" - ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" - ) - - local curl_args="" - local keyserver_reachable=false # Flag to indicate if any keyserver is reachable - - if [ ! -z "${KEYSERVER_PROXY}" ]; then - curl_args="--proxy ${KEYSERVER_PROXY}" - fi +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} +AUTH_ON_SETUP=${AUTHONSETUP:-"false"} +DEFER_EXTENSIONS_UNTIL_AUTH=false +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPTS_DIR="${SCRIPT_DIR}/scripts" - for keyserver in "${!keyservers_curl_map[@]}"; do - local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" - if curl -s ${curl_args} --max-time 5 ${keyserver_curl_url} > /dev/null; then - echo "keyserver ${keyserver}" - keyserver_reachable=true - else - echo "(*) Keyserver ${keyserver} is not reachable." >&2 - fi - done +# shellcheck source=./scripts/utils.sh +source "${SCRIPTS_DIR}/utils.sh" - if ! $keyserver_reachable; then - echo "(!) No keyserver is reachable." >&2 - exit 1 - fi +has_unsupported_extensions_configuration() { + [ -n "${EXTENSIONS}" ] && ! is_true "${AUTH_ON_SETUP}" && ! is_true "${INSTALL_EXTENSIONS_FROM_GIT}" } -# Import the specified key in a variable name passed in as -receive_gpg_keys() { - local keys=${!1} - local keyring_args="" - if [ ! -z "$2" ]; then - keyring_args="--no-default-keyring --keyring $2" - fi - - # Install curl - if ! type curl > /dev/null 2>&1; then - check_packages curl - fi - - # Use a temporary location for gpg keys to avoid polluting image - export GNUPGHOME="/tmp/tmp-gnupg" - mkdir -p ${GNUPGHOME} - chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf - # GPG key download sometimes fails for some reason and retrying fixes it. - local retry_count=0 - local gpg_ok="false" - set +e - until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; - do - echo "(*) Downloading GPG key..." - ( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" - if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retrying in 10s..." - (( retry_count++ )) - sleep 10s - fi - done - set -e - if [ "${gpg_ok}" = "false" ]; then - echo "(!) Failed to get gpg key." - exit 1 +validate_configuration() { + if has_unsupported_extensions_configuration; then + die "Unsupported extensions configuration. When 'extensions' is set, enable 'authOnSetup' to install after authentication or set 'installExtensionsFromGit' to true to clone extensions during feature setup." fi } -apt_get_update() -{ - if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then - echo "Running apt-get update..." - apt-get update -y - fi +should_defer_extensions_until_auth() { + [ -n "${EXTENSIONS}" ] && is_true "${AUTH_ON_SETUP}" && ! is_true "${INSTALL_EXTENSIONS_FROM_GIT}" } -# Checks if packages are installed and installs them if not -check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi -} +run_scope() { + local scope="$1" + local scope_script="${SCRIPTS_DIR}/install-${scope}.sh" -# Figure out correct version of a three part version number is not passed -find_version_from_git_tags() { - local variable_name=$1 - local requested_version=${!variable_name} - if [ "${requested_version}" = "none" ]; then return; fi - local repository=$2 - local prefix=${3:-"tags/v"} - local separator=${4:-"."} - local last_part_optional=${5:-"false"} - if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then - local escaped_separator=${separator//./\\.} - local last_part - if [ "${last_part_optional}" = "true" ]; then - last_part="(${escaped_separator}[0-9]+)?" - else - last_part="${escaped_separator}[0-9]+" - fi - local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" - if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then - declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" - else - set +e - declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" - set -e - fi - fi - if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then - echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 - exit 1 + if [ ! -f "${scope_script}" ]; then + die "Missing scope installer: ${scope_script}" fi - echo "${variable_name}=${!variable_name}" -} -# Use semver logic to decrement a version number then look for the closest match -find_prev_version_from_git_tags() { - local variable_name=$1 - local current_version=${!variable_name} - local repository=$2 - # Normally a "v" is used before the version number, but support alternate cases - local prefix=${3:-"tags/v"} - # Some repositories use "_" instead of "." for version number part separation, support that - local separator=${4:-"."} - # Some tools release versions that omit the last digit (e.g. go) - local last_part_optional=${5:-"false"} - # Some repositories may have tags that include a suffix (e.g. actions/node-versions) - local version_suffix_regex=$6 - # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. - set +e - major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" - minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" - breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" - - if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then - ((major=major-1)) - declare -g ${variable_name}="${major}" - # Look for latest version from previous major release - find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" - # Handle situations like Go's odd version pattern where "0" releases omit the last part - elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then - ((minor=minor-1)) - declare -g ${variable_name}="${major}.${minor}" - # Look for latest version from previous minor release - find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" - else - ((breakfix=breakfix-1)) - if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then - declare -g ${variable_name}="${major}.${minor}" - else - declare -g ${variable_name}="${major}.${minor}.${breakfix}" - fi - fi - set -e + DEFER_EXTENSIONS_UNTIL_AUTH="${DEFER_EXTENSIONS_UNTIL_AUTH}" bash "${scope_script}" } -# Fall back on direct download if no apt package exists -# Fetches .deb file to be installed with dpkg -install_deb_using_github() { - check_packages wget - arch=$(dpkg --print-architecture) - - find_version_from_git_tags CLI_VERSION https://github.com/cli/cli - cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" - - mkdir -p /tmp/ghcli - pushd /tmp/ghcli - wget -q --show-progress --progress=dot:giga https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} - exit_code=$? - set -e - if [ "$exit_code" != "0" ]; then - # Handle situation where git tags are ahead of what was is available to actually download - echo "(!) github-cli version ${CLI_VERSION} failed to download. Attempting to fall back one version to retry..." - find_prev_version_from_git_tags CLI_VERSION https://github.com/cli/cli - wget -q --show-progress --progress=dot:giga https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} - fi - - dpkg -i /tmp/ghcli/${cli_filename} - popd - rm -rf /tmp/ghcli +cleanup_apt_lists() { + rm -rf /var/lib/apt/lists/* } -export DEBIAN_FRONTEND=noninteractive - -# Install curl, apt-transport-https, curl, gpg, or dirmngr, git if missing -check_packages curl ca-certificates apt-transport-https dirmngr gnupg2 -if ! type git > /dev/null 2>&1; then - check_packages git -fi +main() { + if [ "$(id -u)" -ne 0 ]; then + die 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + fi -# Soft version matching -if [ "${CLI_VERSION}" != "latest" ] && [ "${CLI_VERSION}" != "lts" ] && [ "${CLI_VERSION}" != "stable" ]; then - find_version_from_git_tags CLI_VERSION "https://github.com/cli/cli" - version_suffix="=${CLI_VERSION}" -else - version_suffix="" -fi + cleanup_apt_lists + trap cleanup_apt_lists EXIT -# Install the GitHub CLI -echo "Downloading github CLI..." + validate_configuration -if [ "${INSTALL_DIRECTLY_FROM_GITHUB_RELEASE}" = "true" ]; then - install_deb_using_github -else - # Import key safely (new method rather than deprecated apt-key approach) and install - . /etc/os-release - receive_gpg_keys GITHUB_CLI_ARCHIVE_GPG_KEY /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list - apt-get update - apt-get -y install "gh${version_suffix}" - rm -rf "/tmp/gh/gnupg" - echo "Done!" -fi + if should_defer_extensions_until_auth; then + DEFER_EXTENSIONS_UNTIL_AUTH=true + fi -# Install requested GitHub CLI extensions (if any) -if [ -n "${EXTENSIONS}" ]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - EXTENSIONS_SCRIPT="${SCRIPT_DIR}/scripts/install-extensions.sh" + run_scope github-cli + run_scope authentication - # Determine the appropriate non-root user (mirrors other features' "automatic" behavior) - USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" - if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if [ -n "${CURRENT_USER}" ] && id -u "${CURRENT_USER}" > /dev/null 2>&1; then - USERNAME="${CURRENT_USER}" - break - fi - done - if [ -z "${USERNAME}" ]; then - USERNAME=root - fi - elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" > /dev/null 2>&1; then - USERNAME=root + if [ "${DEFER_EXTENSIONS_UNTIL_AUTH}" != "true" ]; then + run_scope extensions fi +} - if [ "${USERNAME}" = "root" ]; then - EXTENSIONS="${EXTENSIONS}" bash "${EXTENSIONS_SCRIPT}" - else - EXTENSIONS_ESCAPED="$(printf '%q' "${EXTENSIONS}")" - USERNAME_ESCAPED="$(printf '%q' "${USERNAME}")" - su - "${USERNAME}" -c "EXTENSIONS=${EXTENSIONS_ESCAPED} USERNAME=${USERNAME_ESCAPED} INSTALL_EXTENSIONS=true bash '${EXTENSIONS_SCRIPT}'" - INSTALL_EXTENSIONS=false bash "${EXTENSIONS_SCRIPT}" - fi +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" fi - -# Clean up -rm -rf /var/lib/apt/lists/* diff --git a/src/github-cli/scripts/auth-on-setup.sh b/src/github-cli/scripts/auth-on-setup.sh new file mode 100755 index 000000000..294f0587d --- /dev/null +++ b/src/github-cli/scripts/auth-on-setup.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MARKER_FILE="${HOME}/.config/vscode-dev-containers/github-cli-auth-already-ran" +RESOLVED_AUTH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +DEFERRED_EXTENSIONS_ENV=/usr/local/share/github-cli/extensions.env +DEFERRED_EXTENSIONS_SCRIPT=/usr/local/share/github-cli/install-extensions.sh + +is_auth_setup_complete() { + [ -f "${MARKER_FILE}" ] +} + +mark_auth_setup_complete() { + mkdir -p "$(dirname "${MARKER_FILE}")" + touch "${MARKER_FILE}" +} + +is_gh_available() { + command -v gh > /dev/null 2>&1 +} + +is_authenticated() { + gh auth status > /dev/null 2>&1 +} + +has_deferred_extensions() { + [ -f "${DEFERRED_EXTENSIONS_ENV}" ] && [ -x "${DEFERRED_EXTENSIONS_SCRIPT}" ] +} + +clear_deferred_extensions_config() { + rm -f "${DEFERRED_EXTENSIONS_ENV}" >/dev/null 2>&1 || true +} + +install_deferred_extensions() { + if ! has_deferred_extensions; then + return 0 + fi + + # shellcheck disable=SC1090 + . "${DEFERRED_EXTENSIONS_ENV}" + + if [ -z "${EXTENSIONS:-}" ]; then + return 0 + fi + + EXTENSIONS="${EXTENSIONS}" INSTALL_EXTENSIONS_FROM_GIT="${INSTALL_EXTENSIONS_FROM_GIT:-false}" bash "${DEFERRED_EXTENSIONS_SCRIPT}" +} + +run_deferred_extensions_if_present() { + if install_deferred_extensions; then + clear_deferred_extensions_config + return 0 + fi + + return 1 +} + +attempt_login() { + if [ -n "${RESOLVED_AUTH_TOKEN}" ]; then + printf '%s' "${RESOLVED_AUTH_TOKEN}" | gh auth login --with-token > /dev/null 2>&1 || true + return + fi + + if [ -t 0 ] && [ -t 1 ]; then + gh auth login || true + fi +} + +main() { + if is_auth_setup_complete || ! is_gh_available; then + exit 0 + fi + + if is_authenticated; then + if ! run_deferred_extensions_if_present; then + exit 0 + fi + mark_auth_setup_complete + exit 0 + fi + + attempt_login + + if is_authenticated && run_deferred_extensions_if_present; then + mark_auth_setup_complete + fi +} + +main + +exit 0 \ No newline at end of file diff --git a/src/github-cli/scripts/install-authentication.sh b/src/github-cli/scripts/install-authentication.sh new file mode 100755 index 000000000..c30220b25 --- /dev/null +++ b/src/github-cli/scripts/install-authentication.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +AUTH_ON_SETUP=${AUTHONSETUP:-"false"} +DEFER_EXTENSIONS_UNTIL_AUTH=${DEFER_EXTENSIONS_UNTIL_AUTH:-"false"} +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly AUTH_ON_SETUP_SCRIPT="${SCRIPT_DIR}/auth-on-setup.sh" +readonly DEFERRED_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-deferred-extensions.sh" +readonly INSTALLED_AUTH_ON_SETUP_SCRIPT="/usr/local/share/github-cli-auth-on-setup.sh" + +updaterc() { + local rc_content="$1" + local rc_marker="${2:-$1}" + + if [ -f /etc/bash.bashrc ] && ! grep -Fq "${rc_marker}" /etc/bash.bashrc; then + echo -e "${rc_content}" >> /etc/bash.bashrc + fi + + if [ -f /etc/zsh/zshrc ] && ! grep -Fq "${rc_marker}" /etc/zsh/zshrc; then + echo -e "${rc_content}" >> /etc/zsh/zshrc + fi +} + +main() { + if [ "${AUTH_ON_SETUP}" != "true" ]; then + return + fi + + install -d -m 0755 /usr/local/share/github-cli + install -m 0755 "${AUTH_ON_SETUP_SCRIPT}" "${INSTALLED_AUTH_ON_SETUP_SCRIPT}" + if [ "${DEFER_EXTENSIONS_UNTIL_AUTH}" = "true" ]; then + EXTENSIONS="${EXTENSIONS}" INSTALL_EXTENSIONS_FROM_GIT="${INSTALL_EXTENSIONS_FROM_GIT}" bash "${DEFERRED_EXTENSION_INSTALLER_SCRIPT}" + fi + updaterc $'# github-cli authOnSetup\nbash /usr/local/share/github-cli-auth-on-setup.sh' '# github-cli authOnSetup' +} + +main "$@" diff --git a/src/github-cli/scripts/install-deferred-extensions.sh b/src/github-cli/scripts/install-deferred-extensions.sh new file mode 100755 index 000000000..a02452bf0 --- /dev/null +++ b/src/github-cli/scripts/install-deferred-extensions.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly DEFERRED_EXTENSIONS_DIR="/usr/local/share/github-cli" + +main() { + install -d -m 0755 "${DEFERRED_EXTENSIONS_DIR}" + install -m 0755 "${SCRIPT_DIR}/install-extensions.sh" "${DEFERRED_EXTENSIONS_DIR}/install-extensions.sh" + install -m 0755 "${SCRIPT_DIR}/install-extension-from-gh.sh" "${DEFERRED_EXTENSIONS_DIR}/install-extension-from-gh.sh" + install -m 0755 "${SCRIPT_DIR}/install-extension-from-git.sh" "${DEFERRED_EXTENSIONS_DIR}/install-extension-from-git.sh" + install -m 0755 "${SCRIPT_DIR}/utils.sh" "${DEFERRED_EXTENSIONS_DIR}/utils.sh" + printf 'EXTENSIONS=%q\n' "${EXTENSIONS}" > "${DEFERRED_EXTENSIONS_DIR}/extensions.env" + printf 'INSTALL_EXTENSIONS_FROM_GIT=%q\n' "${INSTALL_EXTENSIONS_FROM_GIT}" >> "${DEFERRED_EXTENSIONS_DIR}/extensions.env" + chmod 0644 "${DEFERRED_EXTENSIONS_DIR}/extensions.env" +} + +main "$@" \ No newline at end of file diff --git a/src/github-cli/scripts/install-extension-from-gh.sh b/src/github-cli/scripts/install-extension-from-gh.sh new file mode 100755 index 000000000..bab3aeb6e --- /dev/null +++ b/src/github-cli/scripts/install-extension-from-gh.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXTENSION=${EXTENSION:-""} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +extension_is_required() { + [ -n "${EXTENSION}" ] +} + +gh_is_available() { + command -v gh > /dev/null 2>&1 +} + +gh_is_authenticated() { + gh auth status > /dev/null 2>&1 +} + +extension_is_installed() { + gh extension list 2>/dev/null | awk '{print $1}' | grep -Fxq "${EXTENSION}" +} + +main() { + if ! extension_is_required; then + die "EXTENSION is required for GitHub CLI extension installation." + fi + + if ! gh_is_available; then + die "Cannot install extension '${EXTENSION}' with 'gh': GitHub CLI is unavailable." + fi + + if ! gh_is_authenticated; then + die "Cannot install extension '${EXTENSION}' with 'gh': GitHub CLI is not authenticated." + fi + + if extension_is_installed; then + echo "Extension '${EXTENSION}' is already installed. Skipping installation." + exit 0 + fi + + gh extension install "${EXTENSION}" +} + +main "$@" \ No newline at end of file diff --git a/src/github-cli/scripts/install-extension-from-git.sh b/src/github-cli/scripts/install-extension-from-git.sh new file mode 100755 index 000000000..abff69612 --- /dev/null +++ b/src/github-cli/scripts/install-extension-from-git.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXTENSION=${EXTENSION:-""} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +get_extensions_root() { + echo "${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" +} + +extension_is_required() { + [ -n "${EXTENSION}" ] +} + +target_dir() { + echo "$(get_extensions_root)/${EXTENSION##*/}" +} + +extension_is_installed() { + [ -d "$(target_dir)" ] +} + +main() { + local extensions_root + local install_target + + if ! extension_is_required; then + die "EXTENSION is required for git-based GitHub CLI extension installation." + fi + + if ! command -v git > /dev/null 2>&1; then + die "Cannot install extension '${EXTENSION}' because git is unavailable." + fi + + if extension_is_installed; then + echo "Extension '${EXTENSION}' is already installed. Skipping installation." + exit 0 + fi + + extensions_root="$(get_extensions_root)" + install_target="$(target_dir)" + + mkdir -p "${extensions_root}" + git clone --depth 1 "https://github.com/${EXTENSION}.git" "${install_target}" +} + +main "$@" \ No newline at end of file diff --git a/src/github-cli/scripts/install-extensions.sh b/src/github-cli/scripts/install-extensions.sh old mode 100644 new mode 100755 index 436accf03..f182aea16 --- a/src/github-cli/scripts/install-extensions.sh +++ b/src/github-cli/scripts/install-extensions.sh @@ -4,94 +4,94 @@ # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- -set -e +set -euo pipefail EXTENSIONS=${EXTENSIONS:-""} -INSTALL_EXTENSIONS=${INSTALL_EXTENSIONS:-"true"} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-"false"} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly GH_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-extension-from-gh.sh" +readonly GIT_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-extension-from-git.sh" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +ensure_helper_scripts_exist() { + if [ ! -x "${GIT_EXTENSION_INSTALLER_SCRIPT}" ] || [ ! -x "${GH_EXTENSION_INSTALLER_SCRIPT}" ]; then + die "Missing GitHub CLI extension helper scripts in '${SCRIPT_DIR}'." + fi +} + +install_with_gh() { + local extension="$1" + EXTENSION="${extension}" bash "${GH_EXTENSION_INSTALLER_SCRIPT}" +} -trim() { - local value="$1" - value="${value#${value%%[![:space:]]*}}" - value="${value%${value##*[![:space:]]}}" - echo "${value}" +install_with_git() { + local extension="$1" + EXTENSION="${extension}" bash "${GIT_EXTENSION_INSTALLER_SCRIPT}" } install_extension() { - local extension="$1" - local extensions_root - local repo_name + local extension="$1" + + if is_true "${INSTALL_EXTENSIONS_FROM_GIT}"; then + install_with_git "${extension}" + return + fi - extensions_root="${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" - repo_name="${extension##*/}" + if install_with_gh "${extension}"; then + return + fi - mkdir -p "${extensions_root}" - if [ ! -d "${extensions_root}/${repo_name}" ]; then - git clone --depth 1 "https://github.com/${extension}.git" "${extensions_root}/${repo_name}" - fi + err "'gh extension install ${extension}' is unavailable, falling back to git clone." + install_with_git "${extension}" } -ensure_gh_extension_list_wrapper() { - if [ "$(id -u)" -ne 0 ]; then - return - fi +run_as_target_user_if_needed() { + local target_username + local extensions_escaped + local install_extensions_from_git_escaped + local username_escaped + local script_escaped + + if [ "$(id -u)" -ne 0 ]; then + return + fi + + target_username="$(resolve_target_username)" + if [ "${target_username}" = "root" ]; then + return + fi + + extensions_escaped="$(printf '%q' "${EXTENSIONS}")" + install_extensions_from_git_escaped="$(printf '%q' "${INSTALL_EXTENSIONS_FROM_GIT}")" + username_escaped="$(printf '%q' "${target_username}")" + script_escaped="$(printf '%q' "${BASH_SOURCE[0]}")" + su - "${target_username}" -c "EXTENSIONS=${extensions_escaped} INSTALL_EXTENSIONS_FROM_GIT=${install_extensions_from_git_escaped} USERNAME=${username_escaped} bash ${script_escaped}" + exit 0 +} - if gh extension list >/dev/null 2>&1; then - return - fi +main() { + local extension + local extension_list - cat > /usr/local/bin/gh <<'EOF' -#!/usr/bin/env bash -set -e - -REAL_GH=/usr/bin/gh - -if [ "$#" -ge 2 ]; then - cmd="$1" - sub="$2" - if { [ "$cmd" = "extension" ] || [ "$cmd" = "extensions" ] || [ "$cmd" = "ext" ]; } && { [ "$sub" = "list" ] || [ "$sub" = "ls" ]; }; then - extensions_root="${XDG_DATA_HOME:-"$HOME/.local/share"}/gh/extensions" - if [ -d "$extensions_root" ]; then - shopt -s nullglob - for d in "$extensions_root"/*; do - [ -d "$d" ] || continue - url="" - if command -v git >/dev/null 2>&1 && [ -d "$d/.git" ]; then - url="$(git -C "$d" config --get remote.origin.url 2>/dev/null || true)" - fi - if [ -n "$url" ]; then - url="${url%.git}" - url="${url#https://github.com/}" - url="${url#http://github.com/}" - url="${url#ssh://git@github.com/}" - url="${url#git@github.com:}" - echo "$url" - fi - done - fi - exit 0 - fi -fi - -exec "$REAL_GH" "$@" -EOF - chmod +x /usr/local/bin/gh -} + if [ -z "${EXTENSIONS}" ]; then + exit 0 + fi -if [ "${INSTALL_EXTENSIONS}" = "true" ]; then - if [ -z "${EXTENSIONS}" ]; then - exit 0 - fi + ensure_helper_scripts_exist + run_as_target_user_if_needed - echo "Installing GitHub CLI extensions: ${EXTENSIONS}" - IFS=',' read -r -a extension_list <<< "${EXTENSIONS}" - for extension in "${extension_list[@]}"; do - extension="$(trim "${extension}")" - if [ -z "${extension}" ]; then - continue - fi + echo "Installing GitHub CLI extensions: ${EXTENSIONS}" + IFS=',' read -r -a extension_list <<< "${EXTENSIONS}" + for extension in "${extension_list[@]}"; do + extension="$(trim "${extension}")" + if [ -z "${extension}" ]; then + continue + fi - install_extension "${extension}" - done -fi + install_extension "${extension}" + done +} -ensure_gh_extension_list_wrapper +main "$@" diff --git a/src/github-cli/scripts/install-github-cli.sh b/src/github-cli/scripts/install-github-cli.sh new file mode 100755 index 000000000..fd794e566 --- /dev/null +++ b/src/github-cli/scripts/install-github-cli.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +CLI_VERSION=${VERSION:-"latest"} +INSTALL_DIRECTLY_FROM_GITHUB_RELEASE=${INSTALLDIRECTLYFROMGITHUBRELEASE:-"true"} + +GITHUB_CLI_ARCHIVE_GPG_KEY=23F3D4EA75716059 +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +get_gpg_key_servers() { + declare -A keyservers_curl_map=( + ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" + ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" + ) + + local curl_args="" + local keyserver + local keyserver_curl_url + local keyserver_reachable=false + + if [ -n "${KEYSERVER_PROXY:-}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" + fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s ${curl_args} --max-time 5 "${keyserver_curl_url}" > /dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 + fi + done + + if [ "${keyserver_reachable}" != "true" ]; then + die "No keyserver is reachable." + fi +} + +receive_gpg_keys() { + local keys=${!1} + local keyring_args="" + local retry_count=0 + local gpg_ok="false" + + if [ -n "${2:-}" ]; then + keyring_args="--no-default-keyring --keyring $2" + fi + + if ! command -v curl > /dev/null 2>&1; then + check_packages curl + fi + + export GNUPGHOME="/tmp/tmp-gnupg" + mkdir -p "${GNUPGHOME}" + chmod 700 "${GNUPGHOME}" + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > "${GNUPGHOME}/dirmngr.conf" + + set +e + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do + echo "(*) Downloading GPG key..." + (echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true" + if [ "${gpg_ok}" != "true" ]; then + echo "(*) Failed getting key, retrying in 10s..." + retry_count=$((retry_count + 1)) + sleep 10s + fi + done + set -e + + if [ "${gpg_ok}" = "false" ]; then + die "Failed to get gpg key." + fi +} + +apt_get_update() { + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + local escaped_separator + local last_part + local regex + local version_list="" + + if [ "${requested_version}" = "none" ]; then + return + fi + + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + escaped_separator=${separator//./\\.} + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + version_list="$(git ls-remote --tags "${repository}" | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g "${variable_name}=$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g "${variable_name}=$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + else + version_list="${requested_version}" + fi + + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep -q "^${!variable_name//./\\.}$"; then + err "Invalid ${variable_name} value: ${requested_version}" + err "Valid values:" + printf '%s\n' "${version_list}" >&2 + exit 1 + fi +} + +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + local major + local minor + local breakfix + + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + major=$((major - 1)) + declare -g "${variable_name}=${major}" + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + elif [ -z "${breakfix}" ] || [ "${breakfix}" = "0" ]; then + minor=$((minor - 1)) + declare -g "${variable_name}=${major}.${minor}" + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + breakfix=$((breakfix - 1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g "${variable_name}=${major}.${minor}" + else + declare -g "${variable_name}=${major}.${minor}.${breakfix}" + fi + fi + set -e +} + +install_deb_using_github() { + local arch + local cli_filename + local exit_code + + check_packages wget + arch="$(dpkg --print-architecture)" + + find_version_from_git_tags CLI_VERSION https://github.com/cli/cli + cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" + + mkdir -p /tmp/ghcli + pushd /tmp/ghcli > /dev/null + set +e + wget -q --show-progress --progress=dot:giga "https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename}" + exit_code=$? + set -e + if [ "${exit_code}" != "0" ]; then + err "github-cli version ${CLI_VERSION} failed to download. Attempting to fall back one version to retry..." + find_prev_version_from_git_tags CLI_VERSION https://github.com/cli/cli + cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" + wget -q --show-progress --progress=dot:giga "https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename}" + fi + + dpkg -i "/tmp/ghcli/${cli_filename}" + popd > /dev/null + rm -rf /tmp/ghcli +} + +main() { + local version_suffix="" + + export DEBIAN_FRONTEND=noninteractive + + check_packages curl ca-certificates apt-transport-https dirmngr gnupg2 + if ! command -v git > /dev/null 2>&1; then + check_packages git + fi + + if [ "${CLI_VERSION}" != "latest" ] && [ "${CLI_VERSION}" != "lts" ] && [ "${CLI_VERSION}" != "stable" ]; then + find_version_from_git_tags CLI_VERSION https://github.com/cli/cli + version_suffix="=${CLI_VERSION}" + fi + + echo "Downloading GitHub CLI..." + + if [ "${INSTALL_DIRECTLY_FROM_GITHUB_RELEASE}" = "true" ]; then + install_deb_using_github + return + fi + + receive_gpg_keys GITHUB_CLI_ARCHIVE_GPG_KEY /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list + apt-get update + apt-get -y install "gh${version_suffix}" + rm -rf /tmp/gh/gnupg + echo "Done!" +} + +main "$@" diff --git a/src/github-cli/scripts/utils.sh b/src/github-cli/scripts/utils.sh new file mode 100644 index 000000000..de6ca145d --- /dev/null +++ b/src/github-cli/scripts/utils.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +err() { + echo "(!) $*" >&2 +} + +die() { + err "$@" + exit 1 +} + +is_true() { + case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in + true|1|yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + +trim() { + local value="$1" + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + echo "${value}" +} + +resolve_target_username() { + local configured_username + local possible_users + local current_user + + configured_username="${USERNAME:-${_REMOTE_USER:-automatic}}" + + if [ "${configured_username}" = "auto" ] || [ "${configured_username}" = "automatic" ]; then + possible_users=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for current_user in "${possible_users[@]}"; do + if [ -n "${current_user}" ] && id -u "${current_user}" > /dev/null 2>&1; then + echo "${current_user}" + return + fi + done + + echo "root" + return + fi + + if [ "${configured_username}" = "none" ] || ! id -u "${configured_username}" > /dev/null 2>&1; then + echo "root" + return + fi + + echo "${configured_username}" +} \ No newline at end of file diff --git a/test/github-cli/auto_auth_on_setup.sh b/test/github-cli/auto_auth_on_setup.sh new file mode 100644 index 000000000..d187324e2 --- /dev/null +++ b/test/github-cli/auto_auth_on_setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "gh-version" gh --version +check "github-cli-auth-script-installed" test -x /usr/local/share/github-cli-auth-on-setup.sh +check "github-cli-auth-hook-installed" bash -lc "grep -Fq 'bash /usr/local/share/github-cli-auth-on-setup.sh' /etc/bash.bashrc" +check "github-cli-auth-with-token" /bin/bash "$(dirname "$0")/verify_auth_with_token.sh" + +# Report result +reportResults diff --git a/test/github-cli/install_extensions.sh b/test/github-cli/install_extensions.sh deleted file mode 100644 index 78cb126f9..000000000 --- a/test/github-cli/install_extensions.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -set -e - -# Optional: Import test library -source dev-container-features-test-lib - -check "gh-version" gh --version - -check "gh-extension-installed" gh extension list | grep -q 'dlvhdr/gh-dash' -check "gh-extension-installed-2" gh extension list | grep -q 'github/gh-copilot' - -# Report result -reportResults diff --git a/test/github-cli/install_extensions_after_auth.sh b/test/github-cli/install_extensions_after_auth.sh new file mode 100644 index 000000000..6392b61cb --- /dev/null +++ b/test/github-cli/install_extensions_after_auth.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "gh-version" gh --version +check "github-cli-deferred-extension-config-installed" test -f /usr/local/share/github-cli/extensions.env +check "github-cli-deferred-extension-installer-installed" test -x /usr/local/share/github-cli/install-extensions.sh +check "github-cli-installs-extensions-with-gh-after-auth" /bin/bash "$(dirname "$0")/verify_extensions_install_after_auth.sh" + +# Report result +reportResults \ No newline at end of file diff --git a/test/github-cli/install_extensions_from_git.sh b/test/github-cli/install_extensions_from_git.sh new file mode 100644 index 000000000..2025d791f --- /dev/null +++ b/test/github-cli/install_extensions_from_git.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +extensions_root="${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" + +check "gh-version" gh --version + +check "gh-extension-installed" test -d "${extensions_root}/gh-dash" +check "gh-extension-git-clone-installed" test -f "${extensions_root}/gh-dash/.git/config" +check "gh-extension-installed-2" test -d "${extensions_root}/gh-copilot" +check "gh-extension-git-clone-installed-2" test -f "${extensions_root}/gh-copilot/.git/config" + +# Report result +reportResults diff --git a/test/github-cli/scenarios.json b/test/github-cli/scenarios.json index eafee3c59..34f6ada08 100644 --- a/test/github-cli/scenarios.json +++ b/test/github-cli/scenarios.json @@ -8,13 +8,35 @@ } } }, - "install_extensions": { + "auto_auth_on_setup": { "image": "ubuntu:noble", "features": { "github-cli": { "version": "latest", + "installDirectlyFromGitHubRelease": "false", + "authOnSetup": "true" + } + } + }, + "install_extensions_after_auth": { + "image": "ubuntu:noble", + "features": { + "github-cli": { + "version": "latest", + "installDirectlyFromGitHubRelease": "false", + "authOnSetup": "true", "extensions": "dlvhdr/gh-dash,github/gh-copilot" } } + }, + "install_extensions_from_git": { + "image": "ubuntu:noble", + "features": { + "github-cli": { + "version": "latest", + "extensions": "dlvhdr/gh-dash,github/gh-copilot", + "installExtensionsFromGit": "true" + } + } } } diff --git a/test/github-cli/test.sh b/test/github-cli/test.sh index 58df559d7..eee047ccb 100755 --- a/test/github-cli/test.sh +++ b/test/github-cli/test.sh @@ -96,5 +96,7 @@ CLI_VERSION="2.0.0" find_prev_version_from_git_tags CLI_VERSION https://github.com/cli/cli check "pre-version-to-2.0.0" bash -c "echo ${CLI_VERSION} | grep '1.14.0'" +check "extensions-config-requires-auth-or-git" /bin/bash "$(dirname "$0")/validate_extensions_configuration.sh" + # Report result reportResults \ No newline at end of file diff --git a/test/github-cli/validate_extensions_configuration.sh b/test/github-cli/validate_extensions_configuration.sh new file mode 100644 index 000000000..47947a8ca --- /dev/null +++ b/test/github-cli/validate_extensions_configuration.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" + +validation_script='is_true() { + case "$(printf "%s" "$1" | tr "[:upper:]" "[:lower:]")" in + true|1|yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + +if [ -n "${EXTENSIONS:-}" ] && ! is_true "${AUTHONSETUP:-false}" && ! is_true "${INSTALLEXTENSIONSFROMGIT:-false}"; then + echo "(!) Unsupported extensions configuration. When 'extensions' is set, enable 'authOnSetup' to install after authentication or set 'installExtensionsFromGit' to true to clone extensions during feature setup." >&2 + exit 1 +fi' + +if [ -f "${repo_root}/src/github-cli/install.sh" ]; then + validation_script='source "'"${repo_root}"'"/src/github-cli/install.sh; validate_configuration' +fi + +set +e +output="$({ + EXTENSIONS="dlvhdr/gh-dash" \ + INSTALLEXTENSIONSFROMGIT=false \ + AUTHONSETUP=false \ + bash -lc "${validation_script}" +} 2>&1)" +status=$? +set -e + +if [ "${status}" -eq 0 ]; then + echo "Expected unsupported extensions configuration to fail." >&2 + exit 1 +fi + +printf '%s' "${output}" | grep -Fq "Unsupported extensions configuration." \ No newline at end of file diff --git a/test/github-cli/verify_auth_with_token.sh b/test/github-cli/verify_auth_with_token.sh new file mode 100755 index 000000000..df8a0e1c1 --- /dev/null +++ b/test/github-cli/verify_auth_with_token.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -e + +rm -rf /tmp/github-cli-auth-test +mkdir -p /tmp/github-cli-auth-test/bin /tmp/github-cli-auth-test/home + +cat > /tmp/github-cli-auth-test/bin/gh <<'EOF' +#!/bin/bash +set -e + +LOG_FILE=/tmp/github-cli-auth-test/log +STATE_FILE=/tmp/github-cli-auth-test/state + +case "$1:$2:$3" in + auth:status:) + [ -f "$STATE_FILE" ] + ;; + auth:login:--with-token) + cat > /tmp/github-cli-auth-test/token + echo with-token > "$LOG_FILE" + touch "$STATE_FILE" + ;; + auth:login:) + echo interactive > "$LOG_FILE" + touch "$STATE_FILE" + ;; + *) + exit 1 + ;; +esac +EOF + +chmod +x /tmp/github-cli-auth-test/bin/gh + +PATH="/tmp/github-cli-auth-test/bin:${PATH}" \ +HOME=/tmp/github-cli-auth-test/home \ +GH_TOKEN=test-token \ +/usr/local/share/github-cli-auth-on-setup.sh + +grep -Fxq 'with-token' /tmp/github-cli-auth-test/log +grep -Fxq 'test-token' /tmp/github-cli-auth-test/token +test -f /tmp/github-cli-auth-test/home/.config/vscode-dev-containers/github-cli-auth-already-ran diff --git a/test/github-cli/verify_extensions_install_after_auth.sh b/test/github-cli/verify_extensions_install_after_auth.sh new file mode 100644 index 000000000..3a099d5ce --- /dev/null +++ b/test/github-cli/verify_extensions_install_after_auth.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TEST_ROOT=/tmp/github-cli-extension-auth-test +BIN_DIR="${TEST_ROOT}/bin" +HOME_DIR="${TEST_ROOT}/home" +LOG_FILE="${TEST_ROOT}/log" +STATE_FILE="${TEST_ROOT}/state" +TOKEN_FILE="${TEST_ROOT}/token" +INSTALLED_FILE="${TEST_ROOT}/installed" + +resolve_target_username() { + local current_user + local possible_users + + possible_users=("vscode" "node" "codespace" "ubuntu" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for current_user in "${possible_users[@]}"; do + if [ -n "${current_user}" ] && id -u "${current_user}" > /dev/null 2>&1; then + echo "${current_user}" + return + fi + done + + echo root +} + +rm -rf "${TEST_ROOT}" +mkdir -p "${BIN_DIR}" "${HOME_DIR}" + +cat > "${BIN_DIR}/gh" <<'EOF' +#!/bin/bash +set -euo pipefail + +TEST_ROOT=/tmp/github-cli-extension-auth-test +LOG_FILE="${TEST_ROOT}/log" +STATE_FILE="${TEST_ROOT}/state" +TOKEN_FILE="${TEST_ROOT}/token" +INSTALLED_FILE="${TEST_ROOT}/installed" + +command_name="${1:-}" +subcommand_name="${2:-}" + +case "${command_name}:${subcommand_name}" in + auth:status) + [ -f "${STATE_FILE}" ] + ;; + auth:login) + if [ "${3:-}" = "--with-token" ]; then + cat > "${TOKEN_FILE}" + echo with-token >> "${LOG_FILE}" + touch "${STATE_FILE}" + exit 0 + fi + + echo interactive >> "${LOG_FILE}" + touch "${STATE_FILE}" + ;; + extension:list) + if [ -f "${INSTALLED_FILE}" ]; then + while IFS= read -r extension; do + printf '%s\t%s\n' "${extension}" "stub" + done < "${INSTALLED_FILE}" + fi + ;; + extension:install) + extension="${3:-}" + [ -f "${STATE_FILE}" ] + echo "extension-install:${extension}" >> "${LOG_FILE}" + echo "${extension}" >> "${INSTALLED_FILE}" + mkdir -p "${HOME}/.local/share/gh/extensions/${extension##*/}" + ;; + *) + exit 1 + ;; +esac +EOF + +cat > "${BIN_DIR}/git" <<'EOF' +#!/bin/bash +echo git-called >> /tmp/github-cli-extension-auth-test/log +exit 99 +EOF + +chmod +x "${BIN_DIR}/gh" "${BIN_DIR}/git" + +target_username="$(resolve_target_username)" +if [ "${target_username}" != "root" ]; then + chown -R "${target_username}:${target_username}" "${TEST_ROOT}" + su - "${target_username}" -c "PATH=${BIN_DIR}:\$PATH HOME=${HOME_DIR} GH_TOKEN=test-token /usr/local/share/github-cli-auth-on-setup.sh" +else + PATH="${BIN_DIR}:${PATH}" \ + HOME="${HOME_DIR}" \ + GH_TOKEN=test-token \ + /usr/local/share/github-cli-auth-on-setup.sh +fi + +grep -Fxq 'with-token' "${LOG_FILE}" +grep -Fxq 'extension-install:dlvhdr/gh-dash' "${LOG_FILE}" +grep -Fxq 'extension-install:github/gh-copilot' "${LOG_FILE}" +grep -Fxq 'test-token' "${TOKEN_FILE}" +test -d "${HOME_DIR}/.local/share/gh/extensions/gh-dash" +test -d "${HOME_DIR}/.local/share/gh/extensions/gh-copilot" +test -f "${HOME_DIR}/.config/vscode-dev-containers/github-cli-auth-already-ran" +if grep -Fxq 'git-called' "${LOG_FILE}"; then + echo "git fallback was used unexpectedly" >&2 + exit 1 +fi \ No newline at end of file