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
181 changes: 134 additions & 47 deletions dream-server/dream-cli
Original file line number Diff line number Diff line change
Expand Up @@ -572,83 +572,169 @@ cmd_start() {
fi
}

cmd_repair() {
cmd_dry_run() {
check_install
cd "$INSTALL_DIR"
sr_load

local service="${1:-}"
if [[ -z "$service" ]]; then
error "Usage: dream repair <service> (e.g. dream repair perplexica)"
echo -e "${BLUE}━━━ Dream Update — Dry Run ━━━${NC}"
echo -e "${CYAN}Preview only. No changes will be applied.${NC}"
echo ""

# ── version ──────────────────────────────────────────────────────────────
local cur_ver="0.0.0"
if [[ -f "$INSTALL_DIR/.env" ]]; then
local _v
_v=$(grep '^DREAM_VERSION=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
[[ -n "$_v" ]] && cur_ver="$_v"
fi
if [[ "$cur_ver" == "0.0.0" && -f "$INSTALL_DIR/.version" ]]; then
local _vf
_vf=$(jq -r '.version // empty' "$INSTALL_DIR/.version" 2>/dev/null)
[[ -n "$_vf" ]] && cur_ver="$_vf"
fi

service=$(resolve_service "$service")
local container="${SERVICE_CONTAINERS[$service]:-dream-$service}"
# Try dashboard API first; fall back to direct GitHub query.
local api_json=""
local dashboard_port="${DASHBOARD_PORT:-3002}"
local api_key=""
api_key=$(grep '^DASHBOARD_API_KEY=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
if [[ -n "$api_key" ]]; then
api_json=$(curl -sf --max-time 5 \
-H "X-API-Key: ${api_key}" \
"http://localhost:${dashboard_port}/api/update/dry-run" 2>/dev/null || true)
fi

warn "This will destroy all data for $service and recreate it from scratch."
read -p " Continue? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log "Repair cancelled."
return 0
local latest_ver="" changelog_url="" update_status=""
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.current_version' >/dev/null 2>&1; then
latest_ver=$(echo "$api_json" | jq -r '.latest_version // empty')
changelog_url=$(echo "$api_json" | jq -r '.changelog_url // empty')
local api_available
api_available=$(echo "$api_json" | jq -r '.update_available // false')
[[ "$api_available" == "true" ]] && update_status="update available" || update_status="up to date"
else
# Direct GitHub fallback
local gh_resp
gh_resp=$(curl -sf --max-time 8 \
"https://api.github.com/repos/Light-Heart-Labs/DreamServer/releases/latest" 2>/dev/null || true)
latest_ver=$(echo "$gh_resp" | jq -r '.tag_name // empty' 2>/dev/null | sed 's/^v//')
changelog_url=$(echo "$gh_resp" | jq -r '.html_url // empty' 2>/dev/null)
if [[ -n "$latest_ver" ]]; then
if _semver_lt "$cur_ver" "$latest_ver"; then
update_status="update available"
else
update_status="up to date"
fi
fi
fi

echo -e "${CYAN}Version:${NC}"
echo " Installed : v${cur_ver}"
if [[ -n "$latest_ver" ]]; then
echo " Available : v${latest_ver}"
if [[ "$update_status" == "update available" ]]; then
echo -e " Status : ${YELLOW}${update_status}${NC}"
else
echo -e " Status : ${GREEN}${update_status}${NC}"
fi
[[ -n "$changelog_url" ]] && echo " Changelog : ${changelog_url}"
else
echo " Available : (could not reach GitHub)"
fi
echo ""

# ── image tags ────────────────────────────────────────────────────────────
echo -e "${CYAN}Configured image tags (would be pulled):${NC}"
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"

log "Repairing $service..."

# Stop and remove container
log "Stopping $service..."
docker stop "$container" 2>/dev/null || warn "$container not running"
docker rm "$container" 2>/dev/null || warn "$container not found"

# Find and remove service volumes
local volumes
volumes=$(docker compose "${flags[@]}" config --volumes 2>/dev/null | grep -iE "^${service}([-_]|$)" || true)
local project
project=$(basename "$INSTALL_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]-')
for vol in $volumes; do
local full_vol="${project}_${vol}"
if docker volume inspect "$full_vol" >/dev/null 2>&1; then
log "Removing volume $full_vol..."
docker volume rm "$full_vol" || warn "Could not remove volume $full_vol"
fi
# Prefer API response; fall back to parsing compose files directly.
# Use process substitution so flags like images_shown update in this shell (not a pipe subshell).
local images_shown=false
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.images | length > 0' >/dev/null 2>&1; then
local _img_line _any_api=false
while IFS= read -r _img_line || [[ -n "$_img_line" ]]; do
[[ -z "$_img_line" ]] && continue
echo " ${_img_line}"
_any_api=true
done < <(echo "$api_json" | jq -r '.images[]' | sort -u)
[[ "$_any_api" == "true" ]] && images_shown=true
fi
if [[ "$images_shown" == "false" ]]; then
local _img_line _any_compose=false
while IFS= read -r _img_line || [[ -n "$_img_line" ]]; do
[[ -z "$_img_line" ]] && continue
echo " ${_img_line}"
_any_compose=true
done < <(docker compose "${flags[@]}" config 2>/dev/null \
| grep -E '^\s+image:' | sed 's/.*image:\s*//' | sort -u)
[[ "$_any_compose" == "true" ]] && images_shown=true
fi
[[ "$images_shown" == "false" ]] && echo " (could not resolve compose config)"

echo ""
echo -e "${CYAN}Currently running image digests:${NC}"
docker compose "${flags[@]}" images 2>/dev/null \
| awk 'NR>1 {printf " %-30s %s\n", $1, $4}' \
|| echo " (services not running)"
echo ""

# ── model / GGUF ─────────────────────────────────────────────────────────
echo -e "${CYAN}Model configuration (.env):${NC}"
local -a model_keys=(TIER LLM_MODEL GGUF_FILE CTX_SIZE GPU_BACKEND N_GPU_LAYERS)
local key
for key in "${model_keys[@]}"; do
local val
val=$(grep "^${key}=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
printf " %-16s %s\n" "${key}:" "${val:-(not set)}"
done
echo ""

# ── .env keys the update path reads/writes ────────────────────────────────
echo -e "${CYAN}.env keys the update path will read or write:${NC}"
local -a update_keys=(DREAM_VERSION TIER LLM_MODEL GGUF_FILE CTX_SIZE GPU_BACKEND N_GPU_LAYERS)

# Recreate container
log "Recreating $service..."
docker compose "${flags[@]}" up -d "$service"

# Run service-specific repair script if one exists
local repair_script="$INSTALL_DIR/scripts/repair/repair-${service}.sh"
if [[ -x "$repair_script" ]]; then
local port="${SERVICE_PORTS[$service]:-}"
local model="${LLM_MODEL:-qwen3-14b}"
log "Running config repair for $service..."
bash "$repair_script" "http://localhost:${port}" "$model" && \
success "$service repaired and configured" || \
warn "$service started but config seed failed — may need manual setup"
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.env_keys' >/dev/null 2>&1; then
echo "$api_json" | jq -r '.env_keys | to_entries[] | " \(.key): \(.value)"' 2>/dev/null \
|| true
else
success "$service repaired"
for key in "${update_keys[@]}"; do
local val
val=$(grep "^${key}=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
printf " %-16s %s\n" "${key}:" "${val:-(not set)}"
done
fi
echo ""

# ── summary ───────────────────────────────────────────────────────────────
echo -e "${CYAN}To apply this update:${NC} dream update"
echo ""
echo -e "${YELLOW}Dry run complete. Nothing was changed.${NC}"
}

cmd_update() {
check_install
cd "$INSTALL_DIR"

# Parse flags
local dry_run="false"
local force_flag="false"
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run|-n) dry_run="true"; shift ;;
--force|-f) force_flag="true"; shift ;;
*) args+=("$1"); shift ;;
--*) error "Unknown option: $1" ;;
-*) error "Unknown option: $1" ;;
*) error "Unexpected argument: $1" ;;
esac
done

if [[ "$dry_run" == "true" ]]; then
cmd_dry_run
return 0
fi

local flags_str
flags_str=$(get_compose_flags)
local -a flags
Expand Down Expand Up @@ -1910,6 +1996,7 @@ ${CYAN}Examples:${NC}
# Export preset for sharing
dream preset import shared.tar.gz
# Import preset from file
dream update --dry-run # Preview changes without applying
dream backup # Create a backup
dream backup -c # Create compressed backup
dream backup -l # List all backups
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
Expand Down Expand Up @@ -81,7 +82,97 @@ async def get_release_manifest():
}


_VALID_ACTIONS = {"check", "backup", "update"}
_UPDATE_ENV_KEYS = {
"DREAM_VERSION", "TIER", "LLM_MODEL", "GGUF_FILE",
"CTX_SIZE", "GPU_BACKEND", "N_GPU_LAYERS",
}


@router.get("/api/update/dry-run", dependencies=[Depends(verify_api_key)])
async def get_update_dry_run():
"""Preview what a dream update would change without applying anything.

Returns version comparison, configured image tags, and the .env keys
that the update process reads or writes. No containers are started,
stopped, or re-created.
"""
import urllib.request
import urllib.error

install_path = Path(INSTALL_DIR)

# ── current version ───────────────────────────────────────────────────────
current = "0.0.0"
env_file = install_path / ".env"
version_file = install_path / ".version"

if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("DREAM_VERSION="):
current = line.split("=", 1)[1].strip()
break
if current == "0.0.0" and version_file.exists():
try:
raw = version_file.read_text().strip()
parsed = json.loads(raw) if raw.startswith("{") else None
current = (parsed or {}).get("version", raw) or raw or "0.0.0"
except (json.JSONDecodeError, OSError):
pass

# ── latest version from GitHub ────────────────────────────────────────────
latest: Optional[str] = None
changelog_url: Optional[str] = None
update_available = False

try:
req = urllib.request.Request(
"https://api.github.com/repos/Light-Heart-Labs/DreamServer/releases/latest",
headers={"Accept": "application/vnd.github.v3+json"},
)
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read())
latest = data.get("tag_name", "").lstrip("v") or None
changelog_url = data.get("html_url") or None
if latest:
def _parts(v: str) -> list[int]:
return ([int(x) for x in v.split(".") if x.isdigit()][:3] + [0, 0, 0])[:3]
update_available = _parts(latest) > _parts(current)
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError, ValueError):
pass

# ── configured image tags from compose files ──────────────────────────────
images: list[str] = []
for compose_file in sorted(install_path.glob("docker-compose*.yml")):
try:
for line in compose_file.read_text().splitlines():
stripped = line.strip()
if stripped.startswith("image:"):
tag = stripped.split(":", 1)[1].strip()
if tag and tag not in images:
images.append(tag)
except OSError:
pass

# ── .env keys relevant to the update path ────────────────────────────────
env_snapshot: dict[str, str] = {}
if env_file.exists():
for line in env_file.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
if key in _UPDATE_ENV_KEYS:
env_snapshot[key] = val

return {
"dry_run": True,
"current_version": current,
"latest_version": latest,
"update_available": update_available,
"changelog_url": changelog_url,
"images": images,
"env_keys": env_snapshot,
}


@router.post("/api/update")
Expand Down
Loading