Spaces:
Build error
Build error
Add missing scripts folder
Browse files- scripts/generate-sbom.sh +194 -0
- scripts/prepare-pyodide.js +201 -0
scripts/generate-sbom.sh
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# generate-sbom.sh — Generate a clean CycloneDX SBOM using Syft
|
| 4 |
+
#
|
| 5 |
+
# Produces a single SBOM from resolved manifests only — no directory scanning,
|
| 6 |
+
# no venv pollution, no local state. Works identically locally and in CI.
|
| 7 |
+
#
|
| 8 |
+
# How it works:
|
| 9 |
+
# 1. Python: uv pip compile resolves all transitive deps from requirements.txt
|
| 10 |
+
# 2. JavaScript: package-lock.json already contains the full resolved tree
|
| 11 |
+
# 3. Syft scans these resolved files, not the filesystem
|
| 12 |
+
#
|
| 13 |
+
# Usage:
|
| 14 |
+
# ./scripts/generate-sbom.sh # generate sbom.cdx.json from manifests
|
| 15 |
+
# ./scripts/generate-sbom.sh docker # generate from Docker image (best license coverage)
|
| 16 |
+
# ./scripts/generate-sbom.sh docker IMG # generate from a specific image
|
| 17 |
+
# ./scripts/generate-sbom.sh validate # validate existing SBOM
|
| 18 |
+
#
|
| 19 |
+
# Requirements:
|
| 20 |
+
# - syft (brew install syft)
|
| 21 |
+
# - uv (brew install uv)
|
| 22 |
+
#
|
| 23 |
+
|
| 24 |
+
set -euo pipefail
|
| 25 |
+
|
| 26 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 27 |
+
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
| 28 |
+
|
| 29 |
+
RED='\033[0;31m'
|
| 30 |
+
GREEN='\033[0;32m'
|
| 31 |
+
DIM='\033[2m'
|
| 32 |
+
BOLD='\033[1m'
|
| 33 |
+
RESET='\033[0m'
|
| 34 |
+
|
| 35 |
+
info() { echo -e "${BOLD}${GREEN}▸${RESET} $1"; }
|
| 36 |
+
warn() { echo -e "${BOLD}${RED}▸${RESET} $1"; }
|
| 37 |
+
dim() { echo -e "${DIM} $1${RESET}"; }
|
| 38 |
+
|
| 39 |
+
OUTPUT="$ROOT_DIR/sbom.cdx.json"
|
| 40 |
+
|
| 41 |
+
check_deps() {
|
| 42 |
+
local missing=()
|
| 43 |
+
command -v syft &>/dev/null || missing+=("syft")
|
| 44 |
+
command -v uv &>/dev/null || missing+=("uv")
|
| 45 |
+
if [[ ${#missing[@]} -gt 0 ]]; then
|
| 46 |
+
warn "Missing: ${missing[*]}. Install with: brew install ${missing[*]}"
|
| 47 |
+
exit 1
|
| 48 |
+
fi
|
| 49 |
+
dim "Using $(syft --version), $(uv --version)"
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
generate() {
|
| 53 |
+
info "Generating SBOM from resolved manifests..."
|
| 54 |
+
check_deps
|
| 55 |
+
|
| 56 |
+
local VERSION
|
| 57 |
+
VERSION="$(python3 -c "import json; print(json.load(open('$ROOT_DIR/package.json'))['version'])")"
|
| 58 |
+
|
| 59 |
+
local WORK_DIR
|
| 60 |
+
WORK_DIR="$(mktemp -d)"
|
| 61 |
+
trap 'rm -rf "$WORK_DIR"' RETURN
|
| 62 |
+
|
| 63 |
+
# --- Python: resolve all transitive deps without installing ---
|
| 64 |
+
dim "Resolving Python transitive deps (uv pip compile)..."
|
| 65 |
+
uv pip compile "$ROOT_DIR/backend/requirements.txt" \
|
| 66 |
+
--python-version 3.11 \
|
| 67 |
+
--quiet \
|
| 68 |
+
> "$WORK_DIR/requirements-resolved.txt" 2>/dev/null
|
| 69 |
+
|
| 70 |
+
# --- JavaScript: package-lock.json is already fully resolved ---
|
| 71 |
+
if [[ -f "$ROOT_DIR/package-lock.json" ]]; then
|
| 72 |
+
cp "$ROOT_DIR/package-lock.json" "$WORK_DIR/package-lock.json"
|
| 73 |
+
# Syft needs package.json alongside the lockfile
|
| 74 |
+
cp "$ROOT_DIR/package.json" "$WORK_DIR/package.json"
|
| 75 |
+
else
|
| 76 |
+
warn "package-lock.json not found — JS deps will be skipped"
|
| 77 |
+
fi
|
| 78 |
+
|
| 79 |
+
# --- Scan only the resolved files ---
|
| 80 |
+
dim "Scanning resolved manifests with Syft..."
|
| 81 |
+
syft scan "dir:$WORK_DIR" \
|
| 82 |
+
--output "cyclonedx-json=$OUTPUT" \
|
| 83 |
+
--source-name open-webui \
|
| 84 |
+
--source-version "$VERSION" \
|
| 85 |
+
--quiet
|
| 86 |
+
|
| 87 |
+
# Print summary
|
| 88 |
+
python3 -c "
|
| 89 |
+
import json
|
| 90 |
+
with open('$OUTPUT') as f:
|
| 91 |
+
data = json.load(f)
|
| 92 |
+
comps = data.get('components', [])
|
| 93 |
+
py = [c for c in comps if 'pypi' in c.get('purl', '')]
|
| 94 |
+
js = [c for c in comps if 'npm' in c.get('purl', '')]
|
| 95 |
+
with_lic = sum(1 for c in comps if c.get('licenses'))
|
| 96 |
+
print(f' {len(comps)} total ({len(py)} Python, {len(js)} JavaScript)')
|
| 97 |
+
print(f' {with_lic}/{len(comps)} with license info')
|
| 98 |
+
print(f' Serial: {data.get(\"serialNumber\", \"none\")}')
|
| 99 |
+
print(f' Timestamp: {data.get(\"metadata\", {}).get(\"timestamp\", \"none\")}')
|
| 100 |
+
"
|
| 101 |
+
|
| 102 |
+
info "SBOM written → sbom.cdx.json"
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
generate_docker() {
|
| 106 |
+
local IMAGE="${1:-ghcr.io/open-webui/open-webui:latest}"
|
| 107 |
+
info "Generating SBOM from Docker image: $IMAGE"
|
| 108 |
+
|
| 109 |
+
if ! command -v syft &>/dev/null; then
|
| 110 |
+
warn "syft is not installed. Install with: brew install syft"
|
| 111 |
+
exit 1
|
| 112 |
+
fi
|
| 113 |
+
|
| 114 |
+
dim "Pulling and scanning image..."
|
| 115 |
+
syft scan "docker:$IMAGE" \
|
| 116 |
+
--output "cyclonedx-json=$OUTPUT" \
|
| 117 |
+
--quiet
|
| 118 |
+
|
| 119 |
+
python3 -c "
|
| 120 |
+
import json
|
| 121 |
+
with open('$OUTPUT') as f:
|
| 122 |
+
data = json.load(f)
|
| 123 |
+
comps = data.get('components', [])
|
| 124 |
+
with_lic = sum(1 for c in comps if c.get('licenses'))
|
| 125 |
+
print(f' {len(comps)} total components')
|
| 126 |
+
print(f' {with_lic}/{len(comps)} with license info ({round(with_lic/max(len(comps),1)*100)}%)')
|
| 127 |
+
"
|
| 128 |
+
|
| 129 |
+
info "SBOM written → sbom.cdx.json"
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
validate() {
|
| 133 |
+
info "Validating SBOM..."
|
| 134 |
+
|
| 135 |
+
python3 -c "
|
| 136 |
+
import json, sys
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
with open('$OUTPUT') as f:
|
| 140 |
+
data = json.load(f)
|
| 141 |
+
except FileNotFoundError:
|
| 142 |
+
print(' ✗ sbom.cdx.json not found — run ./scripts/generate-sbom.sh first')
|
| 143 |
+
sys.exit(1)
|
| 144 |
+
|
| 145 |
+
issues = []
|
| 146 |
+
if data.get('bomFormat') != 'CycloneDX':
|
| 147 |
+
issues.append('Not CycloneDX format')
|
| 148 |
+
if not data.get('specVersion'):
|
| 149 |
+
issues.append('Missing specVersion')
|
| 150 |
+
if not data.get('serialNumber'):
|
| 151 |
+
issues.append('Missing serial number')
|
| 152 |
+
|
| 153 |
+
components = data.get('components', [])
|
| 154 |
+
|
| 155 |
+
# Check for phantom local packages
|
| 156 |
+
phantoms = []
|
| 157 |
+
for c in components:
|
| 158 |
+
for ref in c.get('externalReferences', []):
|
| 159 |
+
url = ref.get('url', '')
|
| 160 |
+
if 'file://' in url and '/Users/' in url:
|
| 161 |
+
phantoms.append(c['name'])
|
| 162 |
+
if phantoms:
|
| 163 |
+
issues.append(f'Phantom local packages: {phantoms}')
|
| 164 |
+
|
| 165 |
+
with_lic = sum(1 for c in components if c.get('licenses'))
|
| 166 |
+
lic_pct = round(with_lic / max(len(components), 1) * 100)
|
| 167 |
+
|
| 168 |
+
if issues:
|
| 169 |
+
print(f' ✗ {len(components)} components, {lic_pct}% licensed')
|
| 170 |
+
for i in issues:
|
| 171 |
+
print(f' ✗ {i}')
|
| 172 |
+
sys.exit(1)
|
| 173 |
+
else:
|
| 174 |
+
print(f' ✓ {len(components)} components, {lic_pct}% licensed — PASS')
|
| 175 |
+
"
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
# --- Main ---
|
| 179 |
+
cd "$ROOT_DIR"
|
| 180 |
+
TARGET="${1:-generate}"
|
| 181 |
+
|
| 182 |
+
case "$TARGET" in
|
| 183 |
+
generate) generate ;;
|
| 184 |
+
docker) generate_docker "${2:-}" ;;
|
| 185 |
+
validate) validate ;;
|
| 186 |
+
*)
|
| 187 |
+
warn "Unknown target: $TARGET"
|
| 188 |
+
echo "Usage: $0 [generate|docker [IMAGE]|validate]"
|
| 189 |
+
exit 1
|
| 190 |
+
;;
|
| 191 |
+
esac
|
| 192 |
+
|
| 193 |
+
echo ""
|
| 194 |
+
info "Done."
|
scripts/prepare-pyodide.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const packages = [
|
| 2 |
+
'micropip',
|
| 3 |
+
'packaging',
|
| 4 |
+
'requests',
|
| 5 |
+
'beautifulsoup4',
|
| 6 |
+
'numpy',
|
| 7 |
+
'pandas',
|
| 8 |
+
'matplotlib',
|
| 9 |
+
'scikit-learn',
|
| 10 |
+
'scipy',
|
| 11 |
+
'regex',
|
| 12 |
+
'sympy',
|
| 13 |
+
'tiktoken',
|
| 14 |
+
'seaborn',
|
| 15 |
+
'pytz',
|
| 16 |
+
'black',
|
| 17 |
+
'openai',
|
| 18 |
+
'openpyxl'
|
| 19 |
+
];
|
| 20 |
+
|
| 21 |
+
// Pure-Python packages whose wheels must be downloaded from PyPI and saved into
|
| 22 |
+
// static/pyodide/ so that the browser can install them offline via micropip.
|
| 23 |
+
// Packages already provided by the Pyodide distribution (click, platformdirs,
|
| 24 |
+
// typing_extensions, etc.) do NOT need to be listed here.
|
| 25 |
+
const pypiPackages = ['black', 'pathspec', 'mypy_extensions', 'pytokens'];
|
| 26 |
+
|
| 27 |
+
import { loadPyodide } from 'pyodide';
|
| 28 |
+
import { setGlobalDispatcher, ProxyAgent } from 'undici';
|
| 29 |
+
import { writeFile, readFile, copyFile, readdir, rmdir, access } from 'fs/promises';
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Loading network proxy configurations from the environment variables.
|
| 33 |
+
* And the proxy config with lowercase name has the highest priority to use.
|
| 34 |
+
*/
|
| 35 |
+
function initNetworkProxyFromEnv() {
|
| 36 |
+
// we assume all subsequent requests in this script are HTTPS:
|
| 37 |
+
// https://cdn.jsdelivr.net
|
| 38 |
+
// https://pypi.org
|
| 39 |
+
// https://files.pythonhosted.org
|
| 40 |
+
const allProxy = process.env.all_proxy || process.env.ALL_PROXY;
|
| 41 |
+
const httpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
|
| 42 |
+
const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY;
|
| 43 |
+
const preferedProxy = httpsProxy || allProxy || httpProxy;
|
| 44 |
+
/**
|
| 45 |
+
* use only http(s) proxy because socks5 proxy is not supported currently:
|
| 46 |
+
* @see https://github.com/nodejs/undici/issues/2224
|
| 47 |
+
*/
|
| 48 |
+
if (!preferedProxy || !preferedProxy.startsWith('http')) return;
|
| 49 |
+
let preferedProxyURL;
|
| 50 |
+
try {
|
| 51 |
+
preferedProxyURL = new URL(preferedProxy).toString();
|
| 52 |
+
} catch {
|
| 53 |
+
console.warn(`Invalid network proxy URL: "${preferedProxy}"`);
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
const dispatcher = new ProxyAgent({ uri: preferedProxyURL });
|
| 57 |
+
setGlobalDispatcher(dispatcher);
|
| 58 |
+
console.log(`Initialized network proxy "${preferedProxy}" from env`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function downloadPackages() {
|
| 62 |
+
console.log('Setting up pyodide + micropip');
|
| 63 |
+
|
| 64 |
+
let pyodide;
|
| 65 |
+
try {
|
| 66 |
+
pyodide = await loadPyodide({
|
| 67 |
+
packageCacheDir: 'static/pyodide'
|
| 68 |
+
});
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error('Failed to load Pyodide:', err);
|
| 71 |
+
return;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const packageJson = JSON.parse(await readFile('package.json'));
|
| 75 |
+
const pyodideVersion = packageJson.dependencies.pyodide.replace('^', '');
|
| 76 |
+
|
| 77 |
+
try {
|
| 78 |
+
const pyodidePackageJson = JSON.parse(await readFile('static/pyodide/package.json'));
|
| 79 |
+
const pyodidePackageVersion = pyodidePackageJson.version.replace('^', '');
|
| 80 |
+
|
| 81 |
+
if (pyodideVersion !== pyodidePackageVersion) {
|
| 82 |
+
console.log('Pyodide version mismatch, removing static/pyodide directory');
|
| 83 |
+
await rmdir('static/pyodide', { recursive: true });
|
| 84 |
+
}
|
| 85 |
+
} catch (err) {
|
| 86 |
+
console.log('Pyodide package not found, proceeding with download.', err);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
console.log('Loading micropip package');
|
| 91 |
+
await pyodide.loadPackage('micropip');
|
| 92 |
+
|
| 93 |
+
const micropip = pyodide.pyimport('micropip');
|
| 94 |
+
console.log('Downloading Pyodide packages:', packages);
|
| 95 |
+
|
| 96 |
+
try {
|
| 97 |
+
for (const pkg of packages) {
|
| 98 |
+
console.log(`Installing package: ${pkg}`);
|
| 99 |
+
await micropip.install(pkg);
|
| 100 |
+
}
|
| 101 |
+
} catch (err) {
|
| 102 |
+
console.error('Package installation failed:', err);
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
console.log('Pyodide packages downloaded, freezing into lock file');
|
| 107 |
+
|
| 108 |
+
try {
|
| 109 |
+
const lockFile = await micropip.freeze();
|
| 110 |
+
await writeFile('static/pyodide/pyodide-lock.json', lockFile);
|
| 111 |
+
} catch (err) {
|
| 112 |
+
console.error('Failed to write lock file:', err);
|
| 113 |
+
}
|
| 114 |
+
} catch (err) {
|
| 115 |
+
console.error('Failed to load or install micropip:', err);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
async function copyPyodide() {
|
| 120 |
+
console.log('Copying Pyodide files into static directory');
|
| 121 |
+
// Copy all files from node_modules/pyodide to static/pyodide
|
| 122 |
+
for await (const entry of await readdir('node_modules/pyodide')) {
|
| 123 |
+
await copyFile(`node_modules/pyodide/${entry}`, `static/pyodide/${entry}`);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* Download pure-Python wheels from PyPI and save them into static/pyodide/.
|
| 129 |
+
* Also injects entries into pyodide-lock.json so that micropip resolves these
|
| 130 |
+
* packages from the local server instead of fetching them from the internet.
|
| 131 |
+
*/
|
| 132 |
+
async function downloadPyPIWheels() {
|
| 133 |
+
const lockPath = 'static/pyodide/pyodide-lock.json';
|
| 134 |
+
let lockData;
|
| 135 |
+
try {
|
| 136 |
+
lockData = JSON.parse(await readFile(lockPath, 'utf-8'));
|
| 137 |
+
} catch {
|
| 138 |
+
console.warn('Could not read pyodide-lock.json, skipping PyPI wheel download');
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
for (const pkg of pypiPackages) {
|
| 143 |
+
console.log(`Fetching PyPI metadata for: ${pkg}`);
|
| 144 |
+
const res = await fetch(`https://pypi.org/pypi/${pkg}/json`);
|
| 145 |
+
if (!res.ok) {
|
| 146 |
+
console.error(`Failed to fetch PyPI metadata for ${pkg}: ${res.status}`);
|
| 147 |
+
continue;
|
| 148 |
+
}
|
| 149 |
+
const meta = await res.json();
|
| 150 |
+
const version = meta.info.version;
|
| 151 |
+
const files = meta.urls || [];
|
| 152 |
+
// Find the pure-Python wheel (py3-none-any)
|
| 153 |
+
const wheel = files.find(
|
| 154 |
+
(f) => f.filename.endsWith('.whl') && f.filename.includes('py3-none-any')
|
| 155 |
+
);
|
| 156 |
+
if (!wheel) {
|
| 157 |
+
console.warn(`No pure-Python wheel found for ${pkg}==${version}, skipping`);
|
| 158 |
+
continue;
|
| 159 |
+
}
|
| 160 |
+
const dest = `static/pyodide/${wheel.filename}`;
|
| 161 |
+
// Download wheel if not already present
|
| 162 |
+
try {
|
| 163 |
+
await access(dest);
|
| 164 |
+
console.log(` Already exists: ${wheel.filename}`);
|
| 165 |
+
} catch {
|
| 166 |
+
console.log(` Downloading: ${wheel.filename}`);
|
| 167 |
+
const wheelRes = await fetch(wheel.url);
|
| 168 |
+
if (!wheelRes.ok) {
|
| 169 |
+
console.error(` Failed to download ${wheel.filename}: ${wheelRes.status}`);
|
| 170 |
+
continue;
|
| 171 |
+
}
|
| 172 |
+
const buffer = Buffer.from(await wheelRes.arrayBuffer());
|
| 173 |
+
await writeFile(dest, buffer);
|
| 174 |
+
console.log(` Saved: ${dest} (${buffer.length} bytes)`);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// Inject into pyodide-lock.json so micropip resolves locally
|
| 178 |
+
const normalizedName = pkg.replace(/-/g, '_');
|
| 179 |
+
if (!lockData.packages[normalizedName]) {
|
| 180 |
+
lockData.packages[normalizedName] = {
|
| 181 |
+
name: normalizedName,
|
| 182 |
+
version: version,
|
| 183 |
+
file_name: wheel.filename,
|
| 184 |
+
install_dir: 'site',
|
| 185 |
+
sha256: wheel.digests?.sha256 || '',
|
| 186 |
+
package_type: 'package',
|
| 187 |
+
imports: [normalizedName],
|
| 188 |
+
depends: []
|
| 189 |
+
};
|
| 190 |
+
console.log(` Added ${normalizedName}==${version} to pyodide-lock.json`);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
await writeFile(lockPath, JSON.stringify(lockData, null, 2));
|
| 195 |
+
console.log('Updated pyodide-lock.json with PyPI packages');
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
initNetworkProxyFromEnv();
|
| 199 |
+
await downloadPackages();
|
| 200 |
+
await copyPyodide();
|
| 201 |
+
await downloadPyPIWheels();
|