secubox-openwrt/package/secubox/secubox-app-streamlit-forge/files/usr/sbin/slforge
CyberMind-FR e17c73e343 feat(nfo): Add Module Manifest system with batch generation
Introduce flat-file UCI-style NFO manifest format for Streamlit apps
and MetaBlog sites. Enables AI context integration, search indexing,
and mesh publishing metadata.

New features:
- NFO parser library with parse/validate/update/export functions
- NFO validator with type-specific schema validation (streamlit/metablog)
- Batch generation: slforge nfo init-all, metablogizerctl nfo init-all
- RPCD methods: nfo_read, nfo_write, nfo_validate
- Reusable LuCI NFO viewer component with collapsible sections
- LuCI editor modal in Streamlit Forge overview
- Hub generator enhanced with NFO metadata (descriptions, capabilities)
- Metacatalog search with --category and --capability filters

New files:
- nfo-parser.sh, nfo-validator.sh (shell libraries)
- nfo-viewer.js (LuCI component)
- NFO-SPEC.md (specification)
- install.sh (universal NFO-based installer)
- nfo-template.nfo (templates for streamlit/metablog)

Deployed and tested: 136 NFO files generated (107 MetaBlogs, 29 Streamlit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-14 08:51:09 +01:00

1334 lines
34 KiB
Bash

#!/bin/sh
# slforge - Streamlit Forge CLI
# Manage Streamlit apps: create, deploy, publish
. /lib/functions.sh
# Load config
config_load streamlit-forge
config_get GITEA_URL main gitea_url 'http://127.0.0.1:3001'
config_get GITEA_ORG main gitea_org 'streamlit-apps'
config_get APPS_DIR main apps_dir '/srv/streamlit/apps'
config_get PREVIEWS_DIR main previews_dir '/srv/streamlit/previews'
config_get TEMPLATES_DIR main templates_dir '/usr/share/streamlit-forge/templates'
config_get BASE_DOMAIN main base_domain 'apps.secubox.in'
config_get DEFAULT_PORT main default_port_start '8501'
config_get DEFAULT_MEMORY main default_memory '512M'
# Load Gitea token from gitea config if available
config_load gitea
config_get GITEA_TOKEN main api_token ''
config_get GITEA_URL main url "$GITEA_URL"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_err() { echo -e "${RED}[ERROR]${NC} $1"; }
# NFO Parser library
NFO_PARSER="/usr/share/streamlit-forge/lib/nfo-parser.sh"
[ -f "$NFO_PARSER" ] && . "$NFO_PARSER"
# Launcher integration
LAUNCHER_TRACKING_DIR="/tmp/streamlit-access"
# Track app access (for launcher idle detection)
track_access() {
local name="$1"
mkdir -p "$LAUNCHER_TRACKING_DIR"
touch "$LAUNCHER_TRACKING_DIR/$name"
}
# Check if launcher is managing this app
is_launcher_managed() {
local enabled
config_load streamlit-launcher 2>/dev/null || return 1
config_get enabled global enabled '0'
[ "$enabled" = "1" ]
}
usage() {
cat <<EOF
Streamlit Forge - App Publishing Platform
Usage: slforge <command> [options]
App Management:
create <name> [opts] Create new Streamlit app
--from-upload <zip> Create from uploaded ZIP file
--from-git <url> Create from Git repository
--from-template <tpl> Create from template (basic, dashboard, data-viewer)
list List all apps
info <app> Show app details
delete <app> Remove app and all data
Instance Control:
start <app> [--port N] Start app instance
stop <app> Stop app instance
restart <app> Restart app instance
status [app] Show instance status
logs <app> [-f] View app logs
shell <app> Open shell in app container
Configuration:
config <app> list List app config
config <app> get <key> Get config value
config <app> set <k> <v> Set config value
sync-config <app> Sync UCI <-> app config
Source Management:
edit <app> Open in Gitea web editor
pull <app> Pull latest from Gitea
push <app> Push changes to Gitea
Preview & Publishing:
preview <app> Generate preview screenshot
expose <app> [--domain] Create vhost + SSL
hide <app> Remove public access
Mesh AppStore:
publish <app> Publish to mesh catalog
unpublish <app> Remove from mesh catalog
catalog Browse mesh catalog
install <app@node> Install from mesh
Launcher Integration:
launcher status Show launcher status
launcher priority <app> <n> Set app priority (higher=keep longer)
launcher always-on <app> Mark app as always-on (never auto-stop)
Module Manifest (NFO):
nfo init <app> Generate README.nfo for app
nfo info <app> Show NFO summary
nfo edit <app> Edit README.nfo
nfo validate <app> Validate NFO file
nfo json <app> Export NFO as JSON
nfo install <path> Install app from directory with NFO
Templates:
templates List available templates
Examples:
slforge create myapp --from-template dashboard
slforge start myapp --port 8510
slforge expose myapp --domain myapp.gk2.secubox.in
slforge publish myapp
EOF
}
# Get next available port
get_next_port() {
local port=$DEFAULT_PORT
while netstat -tln 2>/dev/null | grep -q ":$port " || \
grep -q "option port '$port'" /etc/config/streamlit-forge 2>/dev/null; do
port=$((port + 1))
done
echo "$port"
}
# Check if app exists
app_exists() {
local name="$1"
[ -d "$APPS_DIR/$name" ] || uci -q get streamlit-forge."$name" >/dev/null 2>&1
}
# Get app UCI section
get_app_config() {
local name="$1"
local key="$2"
uci -q get streamlit-forge."$name"."$key"
}
# Set app UCI config
set_app_config() {
local name="$1"
local key="$2"
local value="$3"
uci set streamlit-forge."$name"."$key"="$value"
uci commit streamlit-forge
}
# ============================================
# Gitea Integration Functions (Phase 2)
# ============================================
# Call Gitea API
gitea_api() {
local method="$1"
local endpoint="$2"
local data="$3"
if [ -z "$GITEA_TOKEN" ]; then
log_err "Gitea API token not configured"
return 1
fi
local url="${GITEA_URL}/api/v1${endpoint}"
if [ "$method" = "GET" ]; then
curl -s -H "Authorization: token $GITEA_TOKEN" "$url"
elif [ "$method" = "POST" ]; then
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" "$url"
elif [ "$method" = "DELETE" ]; then
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$url"
fi
}
# Check if Gitea org exists, create if not
gitea_ensure_org() {
local org="$GITEA_ORG"
# Check if org exists
local result=$(gitea_api GET "/orgs/$org" 2>/dev/null)
if echo "$result" | grep -q '"id"'; then
return 0
fi
# Create org
log_info "Creating Gitea organization: $org"
gitea_api POST "/orgs" "{\"username\":\"$org\",\"description\":\"Streamlit Forge Apps\"}" >/dev/null
}
# Create Gitea repo for app
gitea_create_repo() {
local name="$1"
local app_dir="$APPS_DIR/$name"
gitea_ensure_org
# Check if repo exists
local result=$(gitea_api GET "/repos/$GITEA_ORG/$name" 2>/dev/null)
if echo "$result" | grep -q '"id"'; then
log_info "Repository already exists: $GITEA_ORG/$name"
return 0
fi
# Create repo
log_info "Creating repository: $GITEA_ORG/$name"
gitea_api POST "/orgs/$GITEA_ORG/repos" \
"{\"name\":\"$name\",\"description\":\"Streamlit app: $name\",\"private\":false,\"auto_init\":false}" >/dev/null
# Initialize git in app directory
cd "$app_dir/src"
if [ ! -d ".git" ]; then
git init -q
git add -A
git commit -q -m "Initial commit from Streamlit Forge"
fi
# Add remote
local remote_url="${GITEA_URL}/${GITEA_ORG}/${name}.git"
git remote remove origin 2>/dev/null
git remote add origin "$remote_url"
# Push
git push -u origin main 2>/dev/null || git push -u origin master 2>/dev/null
# Save repo info to UCI
set_app_config "$name" gitea_repo "$name"
set_app_config "$name" gitea_branch "$(git rev-parse --abbrev-ref HEAD)"
log_ok "Created and pushed to: $remote_url"
}
# Open Gitea editor
cmd_edit() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local gitea_repo=$(get_app_config "$name" gitea_repo)
# Create repo if doesn't exist
if [ -z "$gitea_repo" ]; then
log_info "Setting up Gitea repository..."
gitea_create_repo "$name"
gitea_repo="$name"
fi
local branch=$(get_app_config "$name" gitea_branch)
[ -z "$branch" ] && branch="main"
local edit_url="${GITEA_URL}/${GITEA_ORG}/${gitea_repo}/_edit/${branch}/app.py"
echo ""
log_ok "Edit your app at:"
echo " $edit_url"
echo ""
echo "After editing, run: slforge pull $name"
echo ""
}
# Pull from Gitea
cmd_pull() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local app_dir="$APPS_DIR/$name"
local gitea_repo=$(get_app_config "$name" gitea_repo)
if [ -z "$gitea_repo" ]; then
log_err "No Gitea repository configured for $name"
log_info "Run: slforge edit $name to set up the repository"
return 1
fi
cd "$app_dir/src"
if [ ! -d ".git" ]; then
log_err "Not a git repository. Setting up..."
git init -q
git remote add origin "${GITEA_URL}/${GITEA_ORG}/${gitea_repo}.git"
fi
log_info "Pulling latest changes..."
git fetch origin 2>/dev/null
git pull origin "$(git rev-parse --abbrev-ref HEAD)" 2>/dev/null || {
# Try with reset for forced sync
git reset --hard origin/"$(git rev-parse --abbrev-ref HEAD)" 2>/dev/null
}
# Update timestamp
set_app_config "$name" last_pull "$(date -Iseconds)"
# Check if app is running and restart
local port=$(get_app_config "$name" port)
if [ -n "$port" ] && netstat -tln 2>/dev/null | grep -q ":$port "; then
log_info "App is running, restarting to apply changes..."
cmd_restart "$name"
fi
log_ok "Pulled latest for $name"
}
# Push to Gitea
cmd_push() {
local name="$1"
shift
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local message="Update from Streamlit Forge"
while [ $# -gt 0 ]; do
case "$1" in
-m|--message) message="$2"; shift 2 ;;
*) shift ;;
esac
done
local app_dir="$APPS_DIR/$name"
local gitea_repo=$(get_app_config "$name" gitea_repo)
if [ -z "$gitea_repo" ]; then
log_info "Setting up Gitea repository..."
gitea_create_repo "$name"
return 0
fi
cd "$app_dir/src"
if [ ! -d ".git" ]; then
log_err "Not a git repository"
return 1
fi
# Check for changes
if git diff --quiet && git diff --cached --quiet; then
log_info "No changes to push"
return 0
fi
log_info "Pushing changes..."
git add -A
git commit -m "$message" 2>/dev/null
git push origin "$(git rev-parse --abbrev-ref HEAD)" 2>/dev/null || {
log_err "Failed to push. Check remote access."
return 1
}
# Update timestamp
set_app_config "$name" last_push "$(date -Iseconds)"
log_ok "Pushed changes for $name"
}
# Generate preview screenshot
cmd_preview() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local port=$(get_app_config "$name" port)
# Check if running
if ! netstat -tln 2>/dev/null | grep -q ":$port "; then
log_warn "App not running. Starting temporarily..."
cmd_start "$name" --quiet
sleep 5
fi
mkdir -p "$PREVIEWS_DIR"
local preview_file="$PREVIEWS_DIR/${name}.png"
local preview_html="$PREVIEWS_DIR/${name}.html"
# Method 1: Try wkhtmltoimage if available (best quality)
if command -v wkhtmltoimage >/dev/null 2>&1; then
log_info "Generating preview with wkhtmltoimage..."
wkhtmltoimage --width 1280 --height 800 --quality 85 \
"http://127.0.0.1:$port" "$preview_file" 2>/dev/null
if [ -f "$preview_file" ]; then
log_ok "Preview saved: $preview_file"
set_app_config "$name" preview "$preview_file"
return 0
fi
fi
# Method 2: Capture HTML content for later rendering
log_info "Capturing page content..."
curl -s -m 10 "http://127.0.0.1:$port" > "$preview_html" 2>/dev/null
if [ -s "$preview_html" ]; then
log_ok "HTML preview saved: $preview_html"
set_app_config "$name" preview "$preview_html"
# Generate a simple placeholder PNG using SVG
cat > "${preview_file%.*}.svg" <<-SVGEOF
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">
<rect width="100%" height="100%" fill="#1a1a2e"/>
<text x="50%" y="45%" text-anchor="middle" fill="#00ff88" font-size="24" font-family="monospace">📊 $name</text>
<text x="50%" y="60%" text-anchor="middle" fill="#666" font-size="14" font-family="sans-serif">Streamlit App</text>
<text x="50%" y="75%" text-anchor="middle" fill="#444" font-size="12" font-family="sans-serif">Port: $port</text>
</svg>
SVGEOF
log_info "SVG placeholder: ${preview_file%.*}.svg"
return 0
fi
log_err "Failed to generate preview"
return 1
}
# ============================================
# End Gitea Integration Functions
# ============================================
# Create app from template
cmd_create() {
local name="$1"
shift
[ -z "$name" ] && { log_err "App name required"; return 1; }
# Check if exists
app_exists "$name" && { log_err "App '$name' already exists"; return 1; }
local from_upload="" from_git="" from_template="basic"
while [ $# -gt 0 ]; do
case "$1" in
--from-upload) from_upload="$2"; shift 2 ;;
--from-git) from_git="$2"; shift 2 ;;
--from-template) from_template="$2"; shift 2 ;;
*) shift ;;
esac
done
local app_dir="$APPS_DIR/$name"
mkdir -p "$app_dir/src" "$app_dir/data"
if [ -n "$from_upload" ]; then
# Extract ZIP
log_info "Extracting from $from_upload..."
if [ -f "$from_upload" ]; then
unzip -q "$from_upload" -d "$app_dir/src" 2>/dev/null || {
log_err "Failed to extract ZIP"
rm -rf "$app_dir"
return 1
}
# Move files if extracted to subdirectory
local subdir=$(ls -1 "$app_dir/src" 2>/dev/null | head -1)
if [ -d "$app_dir/src/$subdir" ] && [ "$subdir" != "." ]; then
mv "$app_dir/src/$subdir"/* "$app_dir/src/" 2>/dev/null
rmdir "$app_dir/src/$subdir" 2>/dev/null
fi
else
log_err "File not found: $from_upload"
rm -rf "$app_dir"
return 1
fi
elif [ -n "$from_git" ]; then
# Clone from Git
log_info "Cloning from $from_git..."
git clone "$from_git" "$app_dir/src" 2>/dev/null || {
log_err "Failed to clone repository"
rm -rf "$app_dir"
return 1
}
else
# Use template
local tpl_dir="$TEMPLATES_DIR/$from_template"
if [ -d "$tpl_dir" ]; then
log_info "Creating from template: $from_template"
cp -r "$tpl_dir"/* "$app_dir/src/"
# Replace placeholders
find "$app_dir/src" -type f -name "*.py" -exec sed -i "s/APPNAME/$name/g" {} \;
find "$app_dir/src" -type f -name "*.md" -exec sed -i "s/APPNAME/$name/g" {} \;
else
# Create minimal app
log_info "Creating minimal Streamlit app"
cat > "$app_dir/src/app.py" <<-PYEOF
import streamlit as st
st.set_page_config(page_title="$name", page_icon="🚀")
st.title("$name")
st.write("Welcome to your new Streamlit app!")
st.sidebar.header("Navigation")
page = st.sidebar.radio("Go to", ["Home", "About"])
if page == "Home":
st.header("Home")
st.write("Edit src/app.py to customize this app.")
else:
st.header("About")
st.write("Created with Streamlit Forge")
PYEOF
cat > "$app_dir/src/requirements.txt" <<-REQEOF
streamlit>=1.30.0
REQEOF
fi
fi
# Create UCI config
local port=$(get_next_port)
uci set streamlit-forge."$name"=app
uci set streamlit-forge."$name".name="$name"
uci set streamlit-forge."$name".enabled='0'
uci set streamlit-forge."$name".port="$port"
uci set streamlit-forge."$name".entrypoint='app.py'
uci set streamlit-forge."$name".memory="$DEFAULT_MEMORY"
uci set streamlit-forge."$name".created="$(date +%Y-%m-%d)"
uci commit streamlit-forge
# Create Gitea repo if token available
if [ -n "$GITEA_TOKEN" ]; then
gitea_create_repo "$name" 2>/dev/null || log_warn "Gitea repo creation skipped"
fi
# Generate README.nfo manifest
cmd_nfo init "$name" 2>/dev/null || log_warn "NFO generation skipped"
log_ok "Created app: $name"
log_info " Directory: $app_dir"
log_info " Port: $port"
log_info " Manifest: $app_dir/README.nfo"
log_info " Start with: slforge start $name"
}
# List all apps
cmd_list() {
echo ""
echo "Streamlit Apps"
echo "=============="
echo ""
local found=0
for app_dir in "$APPS_DIR"/*/; do
[ -d "$app_dir" ] || continue
local name=$(basename "$app_dir")
local enabled=$(get_app_config "$name" enabled)
local port=$(get_app_config "$name" port)
local domain=$(get_app_config "$name" domain)
local status="stopped"
# Check if running by port
if [ -n "$port" ] && netstat -tln 2>/dev/null | grep -q ":$port "; then
status="running"
fi
printf " %-20s %-8s port:%-5s %s\n" "$name" "[$status]" "${port:-?}" "${domain:-}"
found=1
done
[ "$found" = "0" ] && echo " No apps found. Create one with: slforge create <name>"
echo ""
}
# Show app info
cmd_info() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local app_dir="$APPS_DIR/$name"
echo ""
echo "App: $name"
echo "========================================="
echo ""
echo "Directory: $app_dir"
echo "Enabled: $(get_app_config "$name" enabled)"
echo "Port: $(get_app_config "$name" port)"
echo "Memory: $(get_app_config "$name" memory)"
echo "Domain: $(get_app_config "$name" domain)"
echo "Entrypoint: $(get_app_config "$name" entrypoint)"
echo "Created: $(get_app_config "$name" created)"
echo ""
# Check status by port
local port=$(get_app_config "$name" port)
if [ -n "$port" ] && netstat -tln 2>/dev/null | grep -q ":$port "; then
echo "Status: RUNNING on port $port"
else
echo "Status: STOPPED"
fi
# Show NFO info if available
local nfo_file="$app_dir/README.nfo"
if [ -f "$nfo_file" ] && [ -f "$NFO_PARSER" ]; then
nfo_parse "$nfo_file"
echo ""
echo "Module Info (from NFO):"
echo " Category: $(nfo_get tags category)"
echo " Keywords: $(nfo_get tags keywords)"
echo " Author: $(nfo_get identity author)"
local prompt=$(nfo_get dynamics prompt_context | head -1)
[ -n "$prompt" ] && echo " AI Context: ${prompt:0:60}..."
elif [ -f "$nfo_file" ]; then
echo ""
echo "Manifest: $nfo_file (run: slforge nfo info $name)"
fi
# Show files
if [ -d "$app_dir/src" ]; then
echo ""
echo "Source Files:"
ls -la "$app_dir/src" 2>/dev/null | head -10
fi
echo ""
}
# Start app instance
cmd_start() {
local name="$1"
shift
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local port=$(get_app_config "$name" port)
local quiet=0
while [ $# -gt 0 ]; do
case "$1" in
--port) port="$2"; shift 2 ;;
--quiet) quiet=1; shift ;;
*) shift ;;
esac
done
local app_dir="$APPS_DIR/$name"
local entrypoint=$(get_app_config "$name" entrypoint)
[ -z "$entrypoint" ] && entrypoint="app.py"
# Check if already running
if [ -f "/var/run/streamlit-$name.pid" ]; then
local pid=$(cat "/var/run/streamlit-$name.pid")
if kill -0 "$pid" 2>/dev/null; then
[ "$quiet" = "0" ] && log_warn "App $name already running (PID: $pid)"
return 0
fi
fi
[ "$quiet" = "0" ] && log_info "Starting $name on port $port..."
# Check for Streamlit in LXC container
if lxc-info -n streamlit 2>/dev/null | grep -q "RUNNING"; then
# Run in Streamlit LXC container
# Note: /srv/streamlit/apps is mounted at /srv/apps inside container
# Create wrapper script to avoid shell escaping issues
cat > "$APPS_DIR/.slforge-start.sh" <<-'STARTSCRIPT'
#!/bin/sh
cd "$1"
[ -f requirements.txt ] && pip3 install -q -r requirements.txt 2>/dev/null
nohup streamlit run "$2" --server.port "$3" --server.address 0.0.0.0 --server.headless true > /tmp/streamlit-$4.log 2>&1 &
echo $!
STARTSCRIPT
chmod +x "$APPS_DIR/.slforge-start.sh"
lxc-attach -n streamlit -- /srv/apps/.slforge-start.sh \
"/srv/apps/$name/src" "$entrypoint" "$port" "$name" \
> "/var/run/streamlit-$name.pid"
else
# Run directly on host
cd "$app_dir/src"
if [ -f requirements.txt ]; then
pip3 install -q -r requirements.txt 2>/dev/null
fi
nohup streamlit run "$entrypoint" \
--server.port "$port" \
--server.address 0.0.0.0 \
--server.headless true \
> "/var/log/streamlit-$name.log" 2>&1 &
echo $! > "/var/run/streamlit-$name.pid"
fi
# Update UCI
set_app_config "$name" enabled '1'
set_app_config "$name" port "$port"
# Wait for port to become available
sleep 3
if netstat -tln 2>/dev/null | grep -q ":$port "; then
[ "$quiet" = "0" ] && log_ok "Started $name"
[ "$quiet" = "0" ] && log_info "URL: http://192.168.255.1:$port"
# Track access for launcher idle detection
track_access "$name"
return 0
fi
[ "$quiet" = "0" ] && log_err "Failed to start $name"
return 1
}
# Stop app instance
cmd_stop() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if [ -f "/var/run/streamlit-$name.pid" ]; then
local pid=$(cat "/var/run/streamlit-$name.pid")
if kill -0 "$pid" 2>/dev/null; then
log_info "Stopping $name (PID: $pid)..."
kill "$pid" 2>/dev/null
sleep 1
kill -9 "$pid" 2>/dev/null
fi
rm -f "/var/run/streamlit-$name.pid"
fi
set_app_config "$name" enabled '0'
log_ok "Stopped $name"
}
# Restart app
cmd_restart() {
local name="$1"
cmd_stop "$name"
sleep 1
cmd_start "$name"
}
# Show status
cmd_status() {
local name="$1"
if [ -n "$name" ]; then
cmd_info "$name"
else
cmd_list
fi
}
# View logs
cmd_logs() {
local name="$1"
local follow=0
[ -z "$name" ] && { log_err "App name required"; return 1; }
shift
[ "$1" = "-f" ] && follow=1
local logfile="/var/log/streamlit-$name.log"
if [ ! -f "$logfile" ]; then
log_err "No logs found for $name"
return 1
fi
if [ "$follow" = "1" ]; then
tail -f "$logfile"
else
tail -100 "$logfile"
fi
}
# Delete app
cmd_delete() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
# Stop if running
cmd_stop "$name" 2>/dev/null
# Remove files
rm -rf "$APPS_DIR/$name"
rm -rf "$PREVIEWS_DIR/$name"
rm -f "/var/log/streamlit-$name.log"
# Remove UCI config
uci delete streamlit-forge."$name" 2>/dev/null
uci commit streamlit-forge
log_ok "Deleted app: $name"
}
# Expose app with vhost + SSL
cmd_expose() {
local name="$1"
shift
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
local domain=""
while [ $# -gt 0 ]; do
case "$1" in
--domain) domain="$2"; shift 2 ;;
*) shift ;;
esac
done
# Default domain
[ -z "$domain" ] && domain="$name.$BASE_DOMAIN"
local port=$(get_app_config "$name" port)
log_info "Exposing $name at $domain..."
# Create HAProxy vhost
if command -v haproxyctl >/dev/null 2>&1; then
haproxyctl vhost add "$domain" 2>/dev/null
# Add mitmproxy route
local routes_file="/srv/mitmproxy/haproxy-routes.json"
if [ -f "$routes_file" ]; then
# Add route using jsonfilter + sed
local tmp=$(mktemp)
cat "$routes_file" | sed "s/}$/,\"$domain\":[\"192.168.255.1\",$port]}/" > "$tmp"
mv "$tmp" "$routes_file"
fi
log_info "Requesting SSL certificate..."
haproxyctl ssl request "$domain" 2>/dev/null
else
log_warn "haproxyctl not available - manual vhost setup required"
fi
# Update UCI
set_app_config "$name" domain "$domain"
log_ok "Exposed at: https://$domain"
}
# Hide app (remove public access)
cmd_hide() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
local domain=$(get_app_config "$name" domain)
if [ -n "$domain" ] && command -v haproxyctl >/dev/null 2>&1; then
log_info "Removing vhost: $domain"
haproxyctl vhost remove "$domain" 2>/dev/null
fi
set_app_config "$name" domain ""
log_ok "Hidden app: $name"
}
# List templates
cmd_templates() {
echo ""
echo "Available Templates"
echo "==================="
echo ""
for tpl in "$TEMPLATES_DIR"/*/; do
[ -d "$tpl" ] || continue
local name=$(basename "$tpl")
local desc=""
[ -f "$tpl/README.md" ] && desc=$(head -1 "$tpl/README.md" | sed 's/^#\s*//')
printf " %-15s %s\n" "$name" "$desc"
done
echo ""
echo "Usage: slforge create myapp --from-template <template>"
echo ""
}
# Config management
cmd_config() {
local name="$1"
local action="$2"
local key="$3"
local value="$4"
[ -z "$name" ] && { log_err "App name required"; return 1; }
case "$action" in
list)
echo ""
echo "Config for: $name"
echo "================="
uci show streamlit-forge."$name" 2>/dev/null | sed "s/streamlit-forge.$name./ /"
echo ""
;;
get)
[ -z "$key" ] && { log_err "Key required"; return 1; }
get_app_config "$name" "$key"
;;
set)
[ -z "$key" ] && { log_err "Key required"; return 1; }
[ -z "$value" ] && { log_err "Value required"; return 1; }
set_app_config "$name" "$key" "$value"
log_ok "Set $name.$key = $value"
;;
*)
log_err "Unknown config action: $action"
return 1
;;
esac
}
# Publish to mesh catalog
cmd_publish() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
if ! app_exists "$name"; then
log_err "App not found: $name"
return 1
fi
log_info "Publishing $name to mesh catalog..."
# Generate manifest
local manifest_dir="/usr/share/secubox/plugins/catalog"
mkdir -p "$manifest_dir"
local port=$(get_app_config "$name" port)
local domain=$(get_app_config "$name" domain)
cat > "$manifest_dir/streamlit-$name.json" <<-MANIFEST
{
"id": "streamlit-$name",
"name": "$name",
"type": "streamlit-app",
"version": "1.0.0",
"category": "apps",
"runtime": "streamlit",
"maturity": "community",
"description": "Streamlit app: $name",
"packages": [],
"capabilities": ["streamlit", "web-ui"],
"requirements": {
"arch": ["aarch64", "x86_64"],
"min_ram_mb": 256
},
"network": {
"inbound_ports": [$port],
"protocols": ["http"]
},
"actions": {
"start": "slforge start $name",
"stop": "slforge stop $name",
"status": "slforge status $name"
}
}
MANIFEST
set_app_config "$name" published_mesh '1'
log_ok "Published $name to mesh catalog"
}
# Unpublish from mesh
cmd_unpublish() {
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
rm -f "/usr/share/secubox/plugins/catalog/streamlit-$name.json"
set_app_config "$name" published_mesh '0'
log_ok "Unpublished $name from mesh catalog"
}
# Launcher integration commands
cmd_launcher() {
local action="$1"
shift
case "$action" in
status)
if command -v streamlit-launcherctl >/dev/null 2>&1; then
streamlit-launcherctl status
else
log_warn "Launcher not installed"
log_info "Install with: opkg install secubox-app-streamlit-launcher"
fi
;;
priority)
local app="$1"
local priority="$2"
[ -z "$app" ] || [ -z "$priority" ] && {
log_err "Usage: slforge launcher priority <app> <priority>"
return 1
}
if command -v streamlit-launcherctl >/dev/null 2>&1; then
streamlit-launcherctl priority "$app" "$priority"
else
log_err "Launcher not installed"
return 1
fi
;;
always-on)
local app="$1"
[ -z "$app" ] && {
log_err "Usage: slforge launcher always-on <app>"
return 1
}
if command -v streamlit-launcherctl >/dev/null 2>&1; then
streamlit-launcherctl priority "$app" 100 1
log_ok "App $app marked as always-on"
else
log_err "Launcher not installed"
return 1
fi
;;
*)
log_err "Unknown launcher action: $action"
echo "Usage: slforge launcher <status|priority|always-on>"
return 1
;;
esac
}
# NFO Module Manifest commands
cmd_nfo() {
local action="$1"
shift
case "$action" in
init)
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
app_exists "$name" || { log_err "App not found: $name"; return 1; }
local app_dir="$APPS_DIR/$name"
local nfo_file="$app_dir/README.nfo"
if [ -f "$nfo_file" ]; then
log_warn "README.nfo already exists"
echo " $nfo_file"
return 0
fi
log_info "Generating README.nfo for $name..."
# Get existing config
local port=$(get_app_config "$name" port)
local memory=$(get_app_config "$name" memory)
local domain=$(get_app_config "$name" domain)
local created=$(get_app_config "$name" created)
# Create NFO from template
if [ -f "$NFO_PARSER" ]; then
nfo_create "$nfo_file" "$name" "$name" "streamlit" "A Streamlit application"
else
# Fallback: copy template directly
local tpl="/usr/share/streamlit-forge/nfo-template.nfo"
if [ -f "$tpl" ]; then
sed -e "s/{{APP_ID}}/$name/g" \
-e "s/{{APP_NAME}}/$name/g" \
-e "s/{{SHORT_DESC}}/A Streamlit application/g" \
-e "s/{{VERSION}}/1.0.0/g" \
-e "s/{{DATE}}/$(date +%Y-%m-%d)/g" \
"$tpl" > "$nfo_file"
fi
fi
# Update with actual runtime values
if [ -f "$nfo_file" ]; then
sed -i "s/^port=.*/port=$port/" "$nfo_file"
sed -i "s/^memory=.*/memory=$memory/" "$nfo_file"
[ -n "$domain" ] && sed -i "s/^domain_prefix=.*/domain_prefix=${domain%%.*}/" "$nfo_file"
log_ok "Created: $nfo_file"
else
log_err "Failed to create NFO file"
return 1
fi
;;
info)
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
app_exists "$name" || { log_err "App not found: $name"; return 1; }
local nfo_file="$APPS_DIR/$name/README.nfo"
if [ ! -f "$nfo_file" ]; then
log_warn "No README.nfo found for $name"
log_info "Generate with: slforge nfo init $name"
return 1
fi
if [ -f "$NFO_PARSER" ]; then
nfo_parse "$nfo_file"
nfo_summary
else
# Fallback: simple display
echo ""
echo "═══════════════════════════════════════════════"
grep -E "^(name|version|short|category|type|port)=" "$nfo_file" | head -10
echo "═══════════════════════════════════════════════"
echo ""
fi
;;
edit)
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
app_exists "$name" || { log_err "App not found: $name"; return 1; }
local nfo_file="$APPS_DIR/$name/README.nfo"
if [ ! -f "$nfo_file" ]; then
log_info "Creating README.nfo first..."
cmd_nfo init "$name"
fi
# Open in editor
local editor="${EDITOR:-vi}"
$editor "$nfo_file"
;;
validate)
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
app_exists "$name" || { log_err "App not found: $name"; return 1; }
local nfo_file="$APPS_DIR/$name/README.nfo"
if [ ! -f "$nfo_file" ]; then
log_err "No README.nfo found"
return 1
fi
if [ -f "$NFO_PARSER" ]; then
if nfo_validate "$nfo_file"; then
log_ok "NFO is valid"
else
log_err "NFO validation failed"
return 1
fi
else
# Basic check
if grep -q "^\[identity\]" "$nfo_file" && grep -q "^id=" "$nfo_file"; then
log_ok "NFO appears valid (basic check)"
else
log_err "NFO missing required sections"
return 1
fi
fi
;;
json)
local name="$1"
[ -z "$name" ] && { log_err "App name required"; return 1; }
app_exists "$name" || { log_err "App not found: $name"; return 1; }
local nfo_file="$APPS_DIR/$name/README.nfo"
if [ ! -f "$nfo_file" ]; then
log_err "No README.nfo found"
return 1
fi
if [ -f "$NFO_PARSER" ]; then
nfo_parse "$nfo_file"
nfo_to_json
else
log_err "NFO parser not available"
return 1
fi
;;
install)
local path="$1"
[ -z "$path" ] && { log_err "Path required"; return 1; }
[ ! -d "$path" ] && { log_err "Directory not found: $path"; return 1; }
local nfo_file="$path/README.nfo"
if [ ! -f "$nfo_file" ]; then
log_err "No README.nfo found in $path"
return 1
fi
log_info "Installing module from: $path"
# Run installer if present
if [ -f "$path/install.sh" ]; then
sh "$path/install.sh"
elif [ -f "/usr/share/streamlit-forge/install.sh" ]; then
# Use system installer
NFO_FILE="$nfo_file" SCRIPT_DIR="$path" sh /usr/share/streamlit-forge/install.sh
else
log_err "No installer available"
return 1
fi
;;
init-all)
log_info "Generating NFO manifests for all Streamlit apps..."
echo ""
local success=0
local skipped=0
local failed=0
for app_dir in "$APPS_DIR"/*/; do
[ -d "$app_dir" ] || continue
local name=$(basename "$app_dir")
# Skip hidden directories
[ "${name#.}" != "$name" ] && continue
local nfo_file="$app_dir/README.nfo"
if [ -f "$nfo_file" ]; then
log_info "[$name] NFO already exists, skipping"
skipped=$((skipped + 1))
continue
fi
log_info "[$name] Generating README.nfo..."
# Get existing config
local port=$(get_app_config "$name" port)
local memory=$(get_app_config "$name" memory)
local domain=$(get_app_config "$name" domain)
# Create NFO from template
if [ -f "$NFO_PARSER" ]; then
. "$NFO_PARSER"
nfo_create "$nfo_file" "$name" "$name" "streamlit" "A Streamlit application"
else
# Fallback: copy template directly
local tpl="/usr/share/streamlit-forge/nfo-template.nfo"
if [ -f "$tpl" ]; then
sed -e "s/{{APP_ID}}/$name/g" \
-e "s/{{APP_NAME}}/$name/g" \
-e "s/{{SHORT_DESC}}/A Streamlit application/g" \
-e "s/{{VERSION}}/1.0.0/g" \
-e "s/{{DATE}}/$(date +%Y-%m-%d)/g" \
"$tpl" > "$nfo_file"
fi
fi
# Update with actual runtime values
if [ -f "$nfo_file" ]; then
[ -n "$port" ] && sed -i "s/^port=.*/port=$port/" "$nfo_file"
[ -n "$memory" ] && sed -i "s/^memory=.*/memory=$memory/" "$nfo_file"
[ -n "$domain" ] && sed -i "s/^domain_prefix=.*/domain_prefix=${domain%%.*}/" "$nfo_file"
log_ok " Created: $nfo_file"
success=$((success + 1))
else
log_err " Failed to create NFO"
failed=$((failed + 1))
fi
done
echo ""
echo "========================================"
echo "NFO generation complete"
echo " Created: $success"
echo " Skipped: $skipped (already exist)"
echo " Failed: $failed"
echo "========================================"
;;
*)
log_err "Unknown NFO action: $action"
echo "Usage: slforge nfo <init|init-all|info|edit|validate|json|install>"
return 1
;;
esac
}
# Internal: get next available port (for installer)
_next_port() {
get_next_port
}
# Main command router
case "$1" in
create) shift; cmd_create "$@" ;;
list) cmd_list ;;
info) shift; cmd_info "$@" ;;
delete) shift; cmd_delete "$@" ;;
start) shift; cmd_start "$@" ;;
stop) shift; cmd_stop "$@" ;;
restart) shift; cmd_restart "$@" ;;
status) shift; cmd_status "$@" ;;
logs) shift; cmd_logs "$@" ;;
config) shift; cmd_config "$@" ;;
edit) shift; cmd_edit "$@" ;;
pull) shift; cmd_pull "$@" ;;
push) shift; cmd_push "$@" ;;
preview) shift; cmd_preview "$@" ;;
expose) shift; cmd_expose "$@" ;;
hide) shift; cmd_hide "$@" ;;
publish) shift; cmd_publish "$@" ;;
unpublish) shift; cmd_unpublish "$@" ;;
templates) cmd_templates ;;
launcher) shift; cmd_launcher "$@" ;;
nfo) shift; cmd_nfo "$@" ;;
_next_port) _next_port ;;
help|--help|-h|"")
usage ;;
*)
log_err "Unknown command: $1"
usage
exit 1 ;;
esac