Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.env
stuff
stuff
config/
6 changes: 4 additions & 2 deletions src/ai/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
46 changes: 32 additions & 14 deletions src/ai/scripts/duplo-skills/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}`);
Expand All @@ -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
Expand Down
132 changes: 99 additions & 33 deletions src/ai/scripts/install-nodejs.sh
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions test/ai/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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