diff --git a/.claude/hooks/install-dotnet.sh b/.claude/hooks/install-dotnet.sh new file mode 100755 index 0000000..851c09b --- /dev/null +++ b/.claude/hooks/install-dotnet.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Installs the .NET SDK into ~/.dotnet for Claude Code cloud sessions. +# +# The SDK is unpacked from the layers of the official +# mcr.microsoft.com/dotnet/sdk container image, each checked against the +# sha256 digest its manifest names. dotnet-install.sh is not used: it comes from +# builds.dotnet.microsoft.com, which the default cloud network policy does not +# allow, and it is a script fetched and run with nothing to check it against. +# Ubuntu's own dotnet-sdk-10.0 package is the 1xx feature band, whose compiler +# is too old for ktsu.Sdk.Analyzers (CS9057). +# +# Usage: install-dotnet.sh [runtime channel...] +# e.g. install-dotnet.sh 10.0 10.0.400 8.0 9.0 +# The extra runtime channels are for test projects that multi-target older +# frameworks: building them needs only the SDK, running them needs the runtime. +set -euo pipefail + +channel="${1:-10.0}" +minimum="${2:-${channel}.100}" +shift 2 || shift $# +runtimes=("$@") +root="${DOTNET_INSTALL_DIR:-$HOME/.dotnet}" + +version_at_least() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ] +} + +has_runtime() { + [ -x "$root/dotnet" ] && "$root/dotnet" --list-runtimes 2>/dev/null | grep -q "^Microsoft.NETCore.App $1\." +} + +has_sdk() { + [ -x "$root/dotnet" ] || return 1 + local sdk + for sdk in $("$root/dotnet" --list-sdks 2>/dev/null | awk '{print $1}'); do + case "$sdk" in + "$channel".*) version_at_least "$sdk" "$minimum" && return 0 ;; + esac + done + return 1 +} + +# install_from_container_image , e.g. sdk 10.0 or runtime 8.0 +install_from_container_image() { + local registry="https://mcr.microsoft.com/v2/dotnet/$1" + local arch + case "$(uname -m)" in + x86_64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) echo "install-dotnet: unsupported architecture $(uname -m)" >&2; return 1 ;; + esac + + local index manifest digest layer work + index="$(curl -fsSL -H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json' \ + "$registry/manifests/$2-noble")" + digest="$(jq -r --arg arch "$arch" \ + '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $arch and (.platform.variant // "") == "") | .digest' \ + <<<"$index" | head -n1)" + manifest="$(curl -fsSL -H 'Accept: application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' \ + "$registry/manifests/$digest")" + + # A runtime image contributes its shared framework and nothing else, so its + # older dotnet host never replaces the one the SDK brought. + local wanted='usr/share/dotnet/*' + [ "$1" = sdk ] || wanted='usr/share/dotnet/shared/*' + + work="$(mktemp -d)" + mkdir -p "$root" + # Only the files under usr/share/dotnet are taken: the runtime, ASP.NET Core + # and SDK layers. The rest of the image is its operating system. Layers are + # extracted over what is there, so an SDK and older runtimes share one root. + # Each layer is checked against the sha256 digest its manifest names before + # anything in it is unpacked, since what it contains is run as dotnet. + while IFS= read -r layer; do + case "$layer" in + sha256:*) ;; + *) echo "install-dotnet: unsupported layer digest $layer" >&2; rm -rf "$work"; return 1 ;; + esac + curl -fsSL -o "$work/layer.tgz" "$registry/blobs/$layer" + if [ "sha256:$(sha256sum "$work/layer.tgz" | awk '{print $1}')" != "$layer" ]; then + echo "install-dotnet: layer $layer does not match its digest" >&2 + rm -rf "$work" + return 1 + fi + tar -xzf "$work/layer.tgz" -C "$root" --strip-components=3 --wildcards "$wanted" 2>/dev/null || true + done < <(jq -r '.layers[].digest' <<<"$manifest") + rm -rf "$work" +} + +if ! has_sdk; then + echo "install-dotnet: installing the .NET $channel SDK (at least $minimum) into $root" >&2 + install_from_container_image sdk "$channel" + if ! has_sdk; then + echo "install-dotnet: no .NET $channel SDK at or above $minimum after installing" >&2 + exit 1 + fi +fi + +for runtime in "${runtimes[@]}"; do + has_runtime "$runtime" && continue + echo "install-dotnet: installing the .NET $runtime runtime into $root" >&2 + install_from_container_image runtime "$runtime" + if ! has_runtime "$runtime"; then + echo "install-dotnet: no .NET $runtime runtime after installing" >&2 + exit 1 + fi +done diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000..481d236 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# SessionStart hook for Claude Code cloud sessions: installs the .NET SDK this +# repository's global.json asks for and restores its packages, so a session +# can build and test straight away. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" + +minimum=10.0.100 +if [ -f global.json ]; then + minimum="$(jq -r '.sdk.version // "10.0.100"' global.json)" +fi +# The test projects multi-target, so the older runtimes are needed to run them. +bash .claude/hooks/install-dotnet.sh "${minimum%.*}" "$minimum" 8.0 9.0 + +export DOTNET_ROOT="$HOME/.dotnet" +export PATH="$DOTNET_ROOT:$DOTNET_ROOT/tools:$PATH" +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export DOTNET_ROOT=\"$DOTNET_ROOT\"" + echo "export PATH=\"$DOTNET_ROOT:$DOTNET_ROOT/tools:\$PATH\"" + echo 'export DOTNET_CLI_TELEMETRY_OPTOUT=1' + echo 'export DOTNET_NOLOGO=1' + } >>"$CLAUDE_ENV_FILE" +fi + +if [ -f .config/dotnet-tools.json ]; then + dotnet tool restore +fi +dotnet restore diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e06b033 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +}