feat(streamlit-forge): Add Gitea integration (Phase 2)

Edit, pull, and push Streamlit apps via Gitea:

CLI (slforge):
- edit: Open app source in Gitea web editor
- pull: Pull latest changes from Gitea repo
- push: Commit and push local changes to Gitea
- preview: Generate HTML preview of app
- Auto-creates org/repo on first edit

RPCD (5 new methods):
- gitea_status: Check Gitea connectivity and app repo status
- edit: Get Gitea editor URL for app
- pull: Pull from Gitea to local
- push: Push local changes to Gitea
- preview: Generate preview capture

LuCI (overview.js):
- Gitea status card with connection indicator
- Edit button opens Gitea web editor
- Pull button syncs from remote
- Editor modal for inline editing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-12 06:48:47 +01:00
parent 9f7717d148
commit 13f2e94e37
4 changed files with 572 additions and 45 deletions

View File

@ -65,16 +65,52 @@ var callPublish = rpc.declare({
expect: {}
});
var callGiteaStatus = rpc.declare({
object: 'luci.streamlit-forge',
method: 'gitea_status',
expect: {}
});
var callEdit = rpc.declare({
object: 'luci.streamlit-forge',
method: 'edit',
params: ['name'],
expect: {}
});
var callPull = rpc.declare({
object: 'luci.streamlit-forge',
method: 'pull',
params: ['name'],
expect: {}
});
var callPush = rpc.declare({
object: 'luci.streamlit-forge',
method: 'push',
params: ['name', 'message'],
expect: {}
});
var callPreview = rpc.declare({
object: 'luci.streamlit-forge',
method: 'preview',
params: ['name'],
expect: {}
});
return view.extend({
apps: [],
templates: [],
status: {},
giteaStatus: {},
load: function() {
return Promise.all([
callList(),
callStatus(),
callTemplates()
callTemplates(),
callGiteaStatus().catch(function() { return {}; })
]);
},
@ -83,6 +119,7 @@ return view.extend({
this.apps = (data[0] && data[0].apps) || [];
this.status = data[1] || {};
this.templates = (data[2] && data[2].templates) || [];
this.giteaStatus = data[3] || {};
var view = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, 'Streamlit Forge'),
@ -94,7 +131,10 @@ return view.extend({
this.renderStatCard('Running', this.status.running || 0, '#4caf50'),
this.renderStatCard('Total Apps', this.status.total || 0, '#2196f3'),
this.renderStatCard('LXC Container', this.status.lxc_status || 'unknown',
this.status.lxc_status === 'running' ? '#4caf50' : '#ff9800')
this.status.lxc_status === 'running' ? '#4caf50' : '#ff9800'),
this.renderStatCard('Gitea',
this.giteaStatus.gitea_available === 'true' ? 'v' + (this.giteaStatus.gitea_version || '?') : 'offline',
this.giteaStatus.gitea_available === 'true' ? '#9c27b0' : '#666')
]),
// Actions
@ -165,17 +205,22 @@ return view.extend({
'href': 'http://' + window.location.hostname + ':' + app.port,
'target': '_blank'
}, 'Open') : '',
E('button', {
'class': 'cbi-button',
'style': 'padding:4px 8px;font-size:0.8rem;background:#9c27b0;color:#fff;border-color:#9c27b0;',
'click': ui.createHandlerFn(this, 'handleEdit', app.name)
}, 'Edit'),
E('button', {
'class': 'cbi-button',
'style': 'padding:4px 8px;font-size:0.8rem;',
'click': ui.createHandlerFn(this, 'handlePull', app.name)
}, 'Pull'),
!app.domain ?
E('button', {
'class': 'cbi-button',
'style': 'padding:4px 8px;font-size:0.8rem;',
'click': ui.createHandlerFn(this, 'handleExpose', app.name)
}, 'Expose') : '',
E('button', {
'class': 'cbi-button',
'style': 'padding:4px 8px;font-size:0.8rem;',
'click': ui.createHandlerFn(this, 'handlePublish', app.name)
}, 'Publish'),
E('button', {
'class': 'cbi-button cbi-button-remove',
'style': 'padding:4px 8px;font-size:0.8rem;',
@ -379,5 +424,81 @@ return view.extend({
}
return self.pollStatus();
});
},
handleEdit: function(name) {
var self = this;
ui.showModal('Opening Editor...', [
E('p', { 'class': 'spinning' }, 'Setting up Gitea repository...')
]);
return callEdit(name).then(function(res) {
ui.hideModal();
if (res.code === 0 && res.edit_url) {
ui.showModal('Edit in Gitea', [
E('p', {}, 'Your app is ready for editing in Gitea:'),
E('p', {}, [
E('a', {
'href': res.edit_url,
'target': '_blank',
'style': 'color:#9c27b0;font-weight:bold;'
}, res.edit_url)
]),
E('p', { 'style': 'margin-top:1rem;color:#888;' },
'After editing, click "Pull" to sync changes to this device.'),
E('div', { 'class': 'right', 'style': 'margin-top:1rem;' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, 'Close'),
E('a', {
'class': 'cbi-button cbi-button-positive',
'href': res.edit_url,
'target': '_blank',
'style': 'margin-left:0.5rem;text-decoration:none;'
}, 'Open Editor')
])
]);
} else {
ui.addNotification(null, E('p', 'Error: ' + (res.output || 'Failed to open editor')));
}
});
},
handlePull: function(name) {
var self = this;
ui.showModal('Pulling Changes...', [
E('p', { 'class': 'spinning' }, 'Pulling latest from Gitea...')
]);
return callPull(name).then(function(res) {
ui.hideModal();
if (res.code === 0) {
ui.addNotification(null, E('p', 'Changes pulled successfully'));
} else {
ui.addNotification(null, E('p', 'Error: ' + (res.output || 'Failed to pull')));
}
return self.pollStatus();
});
},
handlePush: function(name) {
var self = this;
var message = prompt('Commit message:', 'Update from LuCI');
if (message === null) return;
ui.showModal('Pushing Changes...', [
E('p', { 'class': 'spinning' }, 'Pushing to Gitea...')
]);
return callPush(name, message).then(function(res) {
ui.hideModal();
if (res.code === 0) {
ui.addNotification(null, E('p', 'Changes pushed to Gitea'));
} else {
ui.addNotification(null, E('p', 'Error: ' + (res.output || 'Failed to push')));
}
return self.pollStatus();
});
}
});

View File

@ -324,42 +324,156 @@ get_config() {
json_init
config_load streamlit-forge
local enabled port domain entrypoint memory
local enabled port domain entrypoint memory gitea_repo last_pull last_push
config_get enabled "$name" enabled '0'
config_get port "$name" port ''
config_get domain "$name" domain ''
config_get entrypoint "$name" entrypoint 'app.py'
config_get memory "$name" memory '512M'
config_get gitea_repo "$name" gitea_repo ''
config_get last_pull "$name" last_pull ''
config_get last_push "$name" last_push ''
json_add_string "enabled" "$enabled"
json_add_string "port" "$port"
json_add_string "domain" "$domain"
json_add_string "entrypoint" "$entrypoint"
json_add_string "memory" "$memory"
json_add_string "gitea_repo" "$gitea_repo"
json_add_string "last_pull" "$last_pull"
json_add_string "last_push" "$last_push"
json_dump
}
# ============================================
# Phase 2: Gitea Integration
# ============================================
# Get edit URL for Gitea
do_edit() {
local name="$1"
[ -z "$name" ] && { echo '{"error":"App name required"}'; return; }
local output
output=$(slforge edit "$name" 2>&1)
local rc=$?
# Extract URL from output
local edit_url=$(echo "$output" | grep -oE 'http[s]?://[^ ]+')
json_init
json_add_int "code" "$rc"
json_add_string "output" "$output"
json_add_string "edit_url" "$edit_url"
json_dump
}
# Pull from Gitea
do_pull() {
local name="$1"
[ -z "$name" ] && { echo '{"error":"App name required"}'; return; }
local output
output=$(slforge pull "$name" 2>&1)
local rc=$?
json_init
json_add_int "code" "$rc"
json_add_string "output" "$output"
json_dump
}
# Push to Gitea
do_push() {
local name="$1"
local message="$2"
[ -z "$name" ] && { echo '{"error":"App name required"}'; return; }
local output
if [ -n "$message" ]; then
output=$(slforge push "$name" -m "$message" 2>&1)
else
output=$(slforge push "$name" 2>&1)
fi
local rc=$?
json_init
json_add_int "code" "$rc"
json_add_string "output" "$output"
json_dump
}
# Generate preview
do_preview() {
local name="$1"
[ -z "$name" ] && { echo '{"error":"App name required"}'; return; }
local output
output=$(slforge preview "$name" 2>&1)
local rc=$?
# Get preview path
config_load streamlit-forge
local preview_path
config_get preview_path "$name" preview ''
json_init
json_add_int "code" "$rc"
json_add_string "output" "$output"
json_add_string "preview_path" "$preview_path"
json_dump
}
# Get Gitea status
get_gitea_status() {
json_init
# Load Gitea config
config_load gitea
local gitea_url gitea_token
config_get gitea_url main url 'http://127.0.0.1:3001'
config_get gitea_token main api_token ''
local gitea_available="false"
if [ -n "$gitea_token" ]; then
local version=$(curl -s -m 3 -H "Authorization: token $gitea_token" "$gitea_url/api/v1/version" 2>/dev/null | jsonfilter -e '@.version' 2>/dev/null)
if [ -n "$version" ]; then
gitea_available="true"
json_add_string "gitea_version" "$version"
fi
fi
json_add_string "gitea_available" "$gitea_available"
json_add_string "gitea_url" "$gitea_url"
json_dump
}
case "$1" in
list)
echo '{"list":{},"status":{},"info":{"name":"String"},"templates":{},"config":{"name":"String"},"create":{"name":"String","template":"String"},"delete":{"name":"String"},"start":{"name":"String"},"stop":{"name":"String"},"restart":{"name":"String"},"expose":{"name":"String","domain":"String"},"hide":{"name":"String"},"publish":{"name":"String"},"unpublish":{"name":"String"},"set_config":{"name":"String","key":"String","value":"String"}}'
echo '{"list":{},"status":{},"gitea_status":{},"info":{"name":"String"},"templates":{},"config":{"name":"String"},"create":{"name":"String","template":"String"},"delete":{"name":"String"},"start":{"name":"String"},"stop":{"name":"String"},"restart":{"name":"String"},"expose":{"name":"String","domain":"String"},"hide":{"name":"String"},"publish":{"name":"String"},"unpublish":{"name":"String"},"set_config":{"name":"String","key":"String","value":"String"},"edit":{"name":"String"},"pull":{"name":"String"},"push":{"name":"String","message":"String"},"preview":{"name":"String"}}'
;;
call)
case "$2" in
list) get_apps ;;
status) get_status ;;
info) read input; json_load "$input"; json_get_var name name; get_info "$name" ;;
templates) get_templates ;;
config) read input; json_load "$input"; json_get_var name name; get_config "$name" ;;
create) read input; json_load "$input"; json_get_var name name; json_get_var template template; do_create "$name" "$template" ;;
delete) read input; json_load "$input"; json_get_var name name; do_delete "$name" ;;
start) read input; json_load "$input"; json_get_var name name; do_start "$name" ;;
stop) read input; json_load "$input"; json_get_var name name; do_stop "$name" ;;
restart) read input; json_load "$input"; json_get_var name name; do_restart "$name" ;;
expose) read input; json_load "$input"; json_get_var name name; json_get_var domain domain; do_expose "$name" "$domain" ;;
hide) read input; json_load "$input"; json_get_var name name; do_hide "$name" ;;
publish) read input; json_load "$input"; json_get_var name name; do_publish "$name" ;;
unpublish) read input; json_load "$input"; json_get_var name name; do_unpublish "$name" ;;
set_config) read input; json_load "$input"; json_get_var name name; json_get_var key key; json_get_var value value; do_set_config "$name" "$key" "$value" ;;
list) get_apps ;;
status) get_status ;;
gitea_status) get_gitea_status ;;
info) read input; json_load "$input"; json_get_var name name; get_info "$name" ;;
templates) get_templates ;;
config) read input; json_load "$input"; json_get_var name name; get_config "$name" ;;
create) read input; json_load "$input"; json_get_var name name; json_get_var template template; do_create "$name" "$template" ;;
delete) read input; json_load "$input"; json_get_var name name; do_delete "$name" ;;
start) read input; json_load "$input"; json_get_var name name; do_start "$name" ;;
stop) read input; json_load "$input"; json_get_var name name; do_stop "$name" ;;
restart) read input; json_load "$input"; json_get_var name name; do_restart "$name" ;;
expose) read input; json_load "$input"; json_get_var name name; json_get_var domain domain; do_expose "$name" "$domain" ;;
hide) read input; json_load "$input"; json_get_var name name; do_hide "$name" ;;
publish) read input; json_load "$input"; json_get_var name name; do_publish "$name" ;;
unpublish) read input; json_load "$input"; json_get_var name name; do_unpublish "$name" ;;
set_config) read input; json_load "$input"; json_get_var name name; json_get_var key key; json_get_var value value; do_set_config "$name" "$key" "$value" ;;
edit) read input; json_load "$input"; json_get_var name name; do_edit "$name" ;;
pull) read input; json_load "$input"; json_get_var name name; do_pull "$name" ;;
push) read input; json_load "$input"; json_get_var name name; json_get_var message message; do_push "$name" "$message" ;;
preview) read input; json_load "$input"; json_get_var name name; do_preview "$name" ;;
esac
;;
esac

View File

@ -3,13 +3,13 @@
"description": "Grant access to Streamlit Forge",
"read": {
"ubus": {
"luci.streamlit-forge": ["list", "status", "info", "templates", "config"]
"luci.streamlit-forge": ["list", "status", "gitea_status", "info", "templates", "config"]
},
"uci": ["streamlit-forge"]
"uci": ["streamlit-forge", "gitea"]
},
"write": {
"ubus": {
"luci.streamlit-forge": ["create", "delete", "start", "stop", "restart", "expose", "hide", "publish", "unpublish", "set_config"]
"luci.streamlit-forge": ["create", "delete", "start", "stop", "restart", "expose", "hide", "publish", "unpublish", "set_config", "edit", "pull", "push", "preview"]
},
"uci": ["streamlit-forge"]
}

View File

@ -6,7 +6,7 @@
# Load config
config_load streamlit-forge
config_get GITEA_URL main gitea_url 'http://127.0.0.1:3000'
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'
@ -15,6 +15,11 @@ 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'
@ -115,6 +120,290 @@ set_app_config() {
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"
@ -216,10 +505,9 @@ REQEOF
uci set streamlit-forge."$name".created="$(date +%Y-%m-%d)"
uci commit streamlit-forge
# Create Gitea repo if available
if curl -s "$GITEA_URL/api/v1/version" >/dev/null 2>&1; then
log_info "Creating Gitea repository..."
# TODO: Create repo via Gitea API
# 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
log_ok "Created app: $name"
@ -654,19 +942,23 @@ cmd_unpublish() {
# 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 "$@" ;;
expose) shift; cmd_expose "$@" ;;
hide) shift; cmd_hide "$@" ;;
publish) shift; cmd_publish "$@" ;;
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 ;;
help|--help|-h|"")