diff --git a/.gitignore b/.gitignore index 9730673..c836afd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env -stuff \ No newline at end of file +stuff +config/ \ No newline at end of file diff --git a/src/ai/install.sh b/src/ai/install.sh index 4649212..cf972f9 100644 --- a/src/ai/install.sh +++ b/src/ai/install.sh @@ -17,9 +17,11 @@ rm -rf "${TEMP_INSTALL_DIR}" mkdir -p "${TEMP_INSTALL_DIR}" cp -r "${DUPLO_SKILLS_DIR}"/* "${TEMP_INSTALL_DIR}/" -# Install duplo-skills globally as root +# Install duplo-skills globally as root from a packed tarball (avoids global symlinks to /tmp) cd "${TEMP_INSTALL_DIR}" -npm install -g . +TARBALL="$(npm pack --silent)" +npm install -g "${TARBALL}" +rm -f "${TARBALL}" # Cleanup rm -rf "${TEMP_INSTALL_DIR}" diff --git a/src/ai/scripts/duplo-skills/index.js b/src/ai/scripts/duplo-skills/index.js index b1ab2f5..5d8dc03 100644 --- a/src/ai/scripts/duplo-skills/index.js +++ b/src/ai/scripts/duplo-skills/index.js @@ -110,11 +110,19 @@ function calculateChecksum(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex'); } -function findAssetWithChecksum(assets, skillName) { - const skillAsset = assets.find(a => a.name === `${skillName}.skill`); - const checksumAsset = assets.find(a => a.name === `${skillName}.skill.sha256`); - - return { skillAsset, checksumAsset }; +function findSkillAsset(assets, skillName) { + return assets.find(a => a.name === `${skillName}.skill`); +} + +function expectedSha256FromAssetDigest(asset) { + if (!asset || !asset.digest) return null; + if (typeof asset.digest !== 'string') return null; + if (!asset.digest.startsWith('sha256:')) return null; + return asset.digest.slice('sha256:'.length); +} + +function findChecksumAsset(assets, skillName) { + return assets.find(a => a.name === `${skillName}.skill.sha256`); } async function downloadSkill(installDir, skillName) { @@ -125,8 +133,8 @@ async function downloadSkill(installDir, skillName) { const release = await getRelease(version); console.log(`Found release: ${release.tag_name}`); - // Find the skill asset and checksum - const { skillAsset, checksumAsset } = findAssetWithChecksum(release.assets, skillName); + // Find the skill asset + const skillAsset = findSkillAsset(release.assets, skillName); if (!skillAsset) { throw new Error(`Skill "${skillName}.skill" not found in release ${release.tag_name}`); @@ -135,19 +143,29 @@ async function downloadSkill(installDir, skillName) { console.log(`Downloading ${skillAsset.name}...`); const skillData = await httpsGet(skillAsset.browser_download_url); - // Verify checksum if available - if (checksumAsset) { - console.log(`Verifying checksum...`); + // Verify checksum (prefer GitHub's asset digest when available) + console.log(`Verifying checksum...`); + const actualHash = calculateChecksum(skillData); + const digestHash = expectedSha256FromAssetDigest(skillAsset); + + if (digestHash) { + if (digestHash !== actualHash) { + throw new Error(`Checksum mismatch!\n Expected: ${digestHash}\n Got: ${actualHash}`); + } + console.log(`✓ Checksum verified`); + } else { + const checksumAsset = findChecksumAsset(release.assets, skillName); + if (!checksumAsset) { + throw new Error(`No checksum available for "${skillName}.skill" (missing asset digest and "${skillName}.skill.sha256")`); + } + const expectedChecksum = await httpsGet(checksumAsset.browser_download_url); const expectedHash = expectedChecksum.toString().trim().split(/\s+/)[0]; - const actualHash = calculateChecksum(skillData); - + if (expectedHash !== actualHash) { throw new Error(`Checksum mismatch!\n Expected: ${expectedHash}\n Got: ${actualHash}`); } console.log(`✓ Checksum verified`); - } else { - console.log(`⚠ No checksum available for verification`); } // Ensure install directory exists diff --git a/src/ai/scripts/install-nodejs.sh b/src/ai/scripts/install-nodejs.sh index 27ebd33..b4cdce0 100644 --- a/src/ai/scripts/install-nodejs.sh +++ b/src/ai/scripts/install-nodejs.sh @@ -1,46 +1,112 @@ #!/usr/bin/env bash -set -e +set -euo pipefail + +MIN_NODE_MAJOR="${MIN_NODE_MAJOR:-18}" echo "Checking Node.js installation..." -# Check if node is already installed -if command -v node &> /dev/null; then - NODE_VERSION=$(node --version) - echo "✓ Node.js already installed: ${NODE_VERSION}" - exit 0 -fi +node_major() { + node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo "0" +} + +has_node() { + command -v node >/dev/null 2>&1 +} -echo "Node.js not found, attempting installation..." - -# Try to use nvm if available -if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then - echo "Found nvm, loading it..." - # shellcheck disable=SC1091 - source "${NVM_DIR:-$HOME/.nvm}/nvm.sh" - - if command -v nvm &> /dev/null; then - echo "Installing Node.js LTS via nvm..." - nvm install --lts - nvm use --lts - echo "✓ Node.js installed via nvm" - node --version - exit 0 +has_python() { + command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1 +} + +ensure_min_node() { + if ! has_node; then + return 1 fi -fi -# Fallback to apt package manager -if command -v apt-get &> /dev/null; then - echo "Installing Node.js via apt..." + local major + major="$(node_major)" + if [[ "$major" -ge "$MIN_NODE_MAJOR" ]]; then + echo "✓ Node.js already installed: v$(node -p 'process.versions.node')" + return 0 + fi + + echo "Node.js is installed but too old (major=$major, need >= $MIN_NODE_MAJOR)." + return 1 +} + +load_nvm() { + local candidates=( + "${NVM_DIR:-$HOME/.nvm}/nvm.sh" + "$HOME/.nvm/nvm.sh" + "/usr/local/share/nvm/nvm.sh" + ) + + for nvm_sh in "${candidates[@]}"; do + if [[ -s "$nvm_sh" ]]; then + echo "Found nvm at $nvm_sh, loading it..." + # shellcheck disable=SC1090,SC1091 + source "$nvm_sh" + if command -v nvm >/dev/null 2>&1; then + return 0 + fi + fi + done + + return 1 +} + +install_with_nvm() { + if ! load_nvm; then + return 1 + fi + + echo "Installing Node.js LTS via nvm..." + nvm install --lts + nvm use --lts + + ensure_min_node +} + +install_with_apt_nodesource() { + if ! command -v apt-get >/dev/null 2>&1; then + return 1 + fi + + echo "Installing Node.js LTS via apt (NodeSource)..." export DEBIAN_FRONTEND=noninteractive - + apt-get update - apt-get install -y nodejs npm - - echo "✓ Node.js installed via apt" - node --version + apt-get install -y --no-install-recommends ca-certificates curl gnupg + + curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - + apt-get install -y --no-install-recommends nodejs + + apt-get clean + rm -rf /var/lib/apt/lists/* + + ensure_min_node +} + +if ensure_min_node; then + exit 0 +fi + +echo "Node.js not present or too old; attempting installation..." + +# Prefer nvm when available (works well with images that pre-install nvm) +if install_with_nvm; then + exit 0 +fi + +# Fall back to apt-based install of LTS +if install_with_apt_nodesource; then exit 0 fi -# If we got here, we couldn't install Node.js -echo "ERROR: Could not install Node.js. Neither nvm nor apt-get are available." +echo "ERROR: Could not install a usable Node.js (>= ${MIN_NODE_MAJOR})." +if ! command -v apt-get >/dev/null 2>&1; then + echo "Reason: apt-get not available." +fi +if ! has_python; then + echo "Note: Some installers require Python; consider adding the devcontainers Python feature or using a base image with Python." +fi exit 1 diff --git a/test/ai/test.sh b/test/ai/test.sh index 777becf..9857f46 100644 --- a/test/ai/test.sh +++ b/test/ai/test.sh @@ -5,6 +5,9 @@ set -e # shellcheck disable=SC1091 source dev-container-features-test-lib +# Require a modern Node for these CLIs +check "node >= 18" node -e "process.exit(Number(process.versions.node.split('.')[0]) >= 18 ? 0 : 1)" + # Test that Node.js is installed check "node installed" node --version @@ -20,5 +23,41 @@ check "duplo-skills help" duplo-skills --help # Test that duplo-skills shows version check "duplo-skills version" duplo-skills --version +# Test that duplo-skills can download and verify checksums from ai-ops (latest release) +check "duplo-skills download + checksum" bash -lc ' + set -euo pipefail + INSTALL_DIR=/tmp/duplo-skills-test + rm -rf "$INSTALL_DIR" + + SKILL="$( + node -e " + const https = require(\"https\"); + const req = https.request( + \"https://api.github.com/repos/duplocloud/ai-ops/releases/latest\", + { headers: { \"User-Agent\": \"duplo-skills-test\" } }, + (res) => { + let data = \"\"; + res.on(\"data\", (d) => (data += d)); + res.on(\"end\", () => { + if (res.statusCode !== 200) process.exit(1); + const json = JSON.parse(data); + const skill = (json.assets || []) + .filter((a) => typeof a.name === \"string\" && a.name.endsWith(\".skill\") && typeof a.digest === \"string\" && a.digest.startsWith(\"sha256:\")) + .map((a) => a.name.replace(/\\.skill$/, \"\"))[0]; + if (!skill) process.exit(2); + process.stdout.write(skill); + }); + } + ); + req.on(\"error\", () => process.exit(3)); + req.end(); + " + )" + + out="$(duplo-skills --dir "$INSTALL_DIR" --skill "$SKILL" 2>&1)" + echo "$out" | grep -q "Checksum verified" + test -f "$INSTALL_DIR/${SKILL}.skill" +' + # Report results reportResults