Add setup wizard and configurable git hosting

- First-run setup wizard prompts for projects dir and git hosting
- Git Hosting section in Settings dialog (provider, URL, owner, token)
- Supports GitHub, GitLab, and Gitea
- new-project script reads from ~/.config/development-hub/settings.json
- Environment variables still work as fallback (GITEA_URL, GITEA_TOKEN, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-09 22:26:00 -04:00
parent 095e185364
commit 46c487cf1b
4 changed files with 396 additions and 52 deletions

View File

@ -18,15 +18,38 @@ set -e
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HUB_ROOT="$(dirname "$SCRIPT_DIR")"
PROJECTS_ROOT="$HOME/PycharmProjects"
PROJECT_DOCS_ROOT="$PROJECTS_ROOT/project-docs"
TEMPLATES_DIR="$HUB_ROOT/templates"
CONFIG_DIR="$HOME/.config/development-hub"
SETTINGS_FILE="$CONFIG_DIR/settings.json"
TOKEN_FILE="$CONFIG_DIR/gitea-token"
TEMPLATES_DIR="$HUB_ROOT/templates"
GITEA_URL="https://gitea.brrd.tech"
GITEA_CLONE_URL="https://gitea.brrd.tech" # Use HTTPS for cloning (SSH port 222 often unavailable)
GITEA_OWNER="rob"
# Load settings from JSON file if it exists
load_settings() {
if [[ -f "$SETTINGS_FILE" ]]; then
# Use python to parse JSON (more portable than jq)
PROJECTS_ROOT=$(python3 -c "import json; d=json.load(open('$SETTINGS_FILE')); print(d.get('default_project_path', '$HOME/PycharmProjects'))" 2>/dev/null || echo "$HOME/PycharmProjects")
GIT_HOST_TYPE=$(python3 -c "import json; d=json.load(open('$SETTINGS_FILE')); print(d.get('git_host_type', ''))" 2>/dev/null || echo "")
GIT_HOST_URL=$(python3 -c "import json; d=json.load(open('$SETTINGS_FILE')); print(d.get('git_host_url', ''))" 2>/dev/null || echo "")
GIT_HOST_OWNER=$(python3 -c "import json; d=json.load(open('$SETTINGS_FILE')); print(d.get('git_host_owner', ''))" 2>/dev/null || echo "")
GIT_HOST_TOKEN=$(python3 -c "import json; d=json.load(open('$SETTINGS_FILE')); print(d.get('git_host_token', ''))" 2>/dev/null || echo "")
else
PROJECTS_ROOT="${PROJECTS_ROOT:-$HOME/PycharmProjects}"
GIT_HOST_TYPE=""
GIT_HOST_URL=""
GIT_HOST_OWNER=""
GIT_HOST_TOKEN=""
fi
# Allow environment variables to override
PROJECTS_ROOT="${PROJECTS_ROOT_OVERRIDE:-$PROJECTS_ROOT}"
GIT_HOST_URL="${GITEA_URL:-$GIT_HOST_URL}"
GIT_HOST_OWNER="${GITEA_OWNER:-$GIT_HOST_OWNER}"
GIT_HOST_TOKEN="${GITEA_TOKEN:-$GIT_HOST_TOKEN}"
PROJECT_DOCS_ROOT="$PROJECTS_ROOT/project-docs"
}
load_settings
# Colors
RED='\033[0;31m'
@ -176,58 +199,83 @@ prompt_for_missing() {
fi
}
load_gitea_token() {
# Check environment variable first
if [[ -n "$GITEA_TOKEN" ]]; then
log_info "Using GITEA_TOKEN from environment"
load_git_token() {
# Already loaded from settings?
if [[ -n "$GIT_HOST_TOKEN" ]]; then
log_info "Using token from settings"
return 0
fi
# Check token file
# Check legacy token file
if [[ -f "$TOKEN_FILE" ]]; then
GITEA_TOKEN=$(cat "$TOKEN_FILE")
log_info "Loaded Gitea token from $TOKEN_FILE"
GIT_HOST_TOKEN=$(cat "$TOKEN_FILE")
log_info "Loaded token from $TOKEN_FILE"
return 0
fi
# Check if git hosting is configured
if [[ -z "$GIT_HOST_URL" ]] || [[ -z "$GIT_HOST_OWNER" ]]; then
log_error "Git hosting not configured"
echo ""
echo "Please configure git hosting in Development Hub:"
echo " 1. Run 'development-hub'"
echo " 2. Go to Settings"
echo " 3. Configure Git Hosting section"
echo ""
log_info "Or use --skip-gitea to skip repository creation"
exit 1
fi
# Prompt user to create token
log_warn "No Gitea API token found"
log_warn "No API token found"
echo ""
echo "To create a token:"
echo " 1. Go to $GITEA_URL/user/settings/applications"
if [[ "$GIT_HOST_TYPE" == "github" ]]; then
echo " 1. Go to https://github.com/settings/tokens"
echo " 2. Generate a new token with 'repo' scope"
elif [[ "$GIT_HOST_TYPE" == "gitlab" ]]; then
echo " 1. Go to $GIT_HOST_URL/-/profile/personal_access_tokens"
echo " 2. Generate a new token with 'api' scope"
else
echo " 1. Go to $GIT_HOST_URL/user/settings/applications"
echo " 2. Generate a new token with 'repo' scope"
fi
echo " 3. Copy the token"
echo ""
read -rsp "Paste your Gitea API token: " GITEA_TOKEN
read -rsp "Paste your API token: " GIT_HOST_TOKEN
echo ""
if [[ -z "$GITEA_TOKEN" ]]; then
log_error "Token is required for Gitea API access"
if [[ -z "$GIT_HOST_TOKEN" ]]; then
log_error "Token is required for API access"
log_info "Use --skip-gitea to skip repository creation"
exit 1
fi
# Save token for future use
mkdir -p "$CONFIG_DIR"
echo "$GITEA_TOKEN" > "$TOKEN_FILE"
echo "$GIT_HOST_TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
log_success "Token saved to $TOKEN_FILE"
}
create_gitea_repo() {
create_git_repo() {
local name="$1"
local description="$2"
log_step "Creating Gitea repository: $name"
log_step "Creating repository: $name on $GIT_HOST_TYPE"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would create repo: $GITEA_URL/$GITEA_OWNER/$name"
log_info "[DRY RUN] Would create repo: $GIT_HOST_URL/$GIT_HOST_OWNER/$name"
return 0
fi
local response
response=$(curl -s -w "\n%{http_code}" -X POST "$GITEA_URL/api/v1/user/repos" \
-H "Authorization: token $GITEA_TOKEN" \
local response http_code body
if [[ "$GIT_HOST_TYPE" == "github" ]]; then
# GitHub API
response=$(curl -s -w "\n%{http_code}" -X POST "https://api.github.com/user/repos" \
-H "Authorization: token $GIT_HOST_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$name\",
@ -235,16 +283,37 @@ create_gitea_repo() {
\"private\": false,
\"auto_init\": false
}")
elif [[ "$GIT_HOST_TYPE" == "gitlab" ]]; then
# GitLab API
response=$(curl -s -w "\n%{http_code}" -X POST "$GIT_HOST_URL/api/v4/projects" \
-H "PRIVATE-TOKEN: $GIT_HOST_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$name\",
\"description\": \"$description\",
\"visibility\": \"public\",
\"initialize_with_readme\": false
}")
else
# Gitea API (default)
response=$(curl -s -w "\n%{http_code}" -X POST "$GIT_HOST_URL/api/v1/user/repos" \
-H "Authorization: token $GIT_HOST_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$name\",
\"description\": \"$description\",
\"private\": false,
\"auto_init\": false
}")
fi
local http_code
http_code=$(echo "$response" | tail -n1)
local body
body=$(echo "$response" | sed '$d')
if [[ "$http_code" == "201" ]]; then
log_success "Created repository: $GITEA_URL/$GITEA_OWNER/$name"
elif [[ "$http_code" == "409" ]]; then
log_warn "Repository already exists on Gitea"
log_success "Created repository: $GIT_HOST_URL/$GIT_HOST_OWNER/$name"
elif [[ "$http_code" == "409" ]] || [[ "$http_code" == "422" ]]; then
log_warn "Repository already exists"
else
log_error "Failed to create repository (HTTP $http_code)"
echo "$body" | head -5
@ -267,7 +336,7 @@ create_local_project() {
cd "$project_dir"
git init --quiet
git remote add origin "$GITEA_CLONE_URL/$GITEA_OWNER/$name.git"
git remote add origin "$GIT_HOST_URL/$GIT_HOST_OWNER/$name.git"
log_success "Created local project with git"
}
@ -289,8 +358,8 @@ apply_template() {
-e "s|{{PROJECT_TAGLINE}}|$tagline|g" \
-e "s|{{YEAR}}|$year|g" \
-e "s|{{DATE}}|$date|g" \
-e "s|{{GITEA_URL}}|$GITEA_URL|g" \
-e "s|{{GITEA_OWNER}}|$GITEA_OWNER|g" \
-e "s|{{GITEA_URL}}|$GIT_HOST_URL|g" \
-e "s|{{GITEA_OWNER}}|$GIT_HOST_OWNER|g" \
"$template" > "$output"
}
@ -390,12 +459,12 @@ update_build_script() {
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would add to PROJECT_CONFIG:"
log_info " PROJECT_CONFIG[\"$name\"]=\"$title|$tagline|$GITEA_OWNER|$name|$name\""
log_info " PROJECT_CONFIG[\"$name\"]=\"$title|$tagline|$GIT_HOST_OWNER|$name|$name\""
return 0
fi
# Find the last PROJECT_CONFIG line and add after it
local config_line="PROJECT_CONFIG[\"$name\"]=\"$title|$tagline|$GITEA_OWNER|$name|$name\""
local config_line="PROJECT_CONFIG[\"$name\"]=\"$title|$tagline|$GIT_HOST_OWNER|$name|$name\""
# Check if already exists
if grep -q "PROJECT_CONFIG\[\"$name\"\]" "$build_script"; then
@ -468,7 +537,7 @@ print_summary() {
echo ""
echo "Locations:"
echo " Local: $PROJECTS_ROOT/$name/"
echo " Gitea: $GITEA_URL/$GITEA_OWNER/$name"
echo " Remote: $GIT_HOST_URL/$GIT_HOST_OWNER/$name"
echo " Docs: $PROJECT_DOCS_ROOT/docs/projects/$name/"
echo ""
echo "Next steps:"
@ -476,14 +545,10 @@ print_summary() {
echo " 2. Start developing!"
echo ""
if [[ "$deployed" == true ]]; then
echo "Public docs are live at:"
echo " https://pages.brrd.tech/$GITEA_OWNER/$name/"
echo "Public docs deployed."
else
echo "To publish documentation:"
echo " $PROJECT_DOCS_ROOT/scripts/build-public-docs.sh $name --deploy"
echo ""
echo "Public docs will be available at:"
echo " https://pages.brrd.tech/$GITEA_OWNER/$name/"
fi
echo ""
}
@ -504,10 +569,10 @@ main() {
echo ""
fi
# Load Gitea token (unless skipping)
# Load token and create repo (unless skipping)
if [[ "$SKIP_GITEA" != true ]]; then
load_gitea_token
create_gitea_repo "$PROJECT_NAME" "$PROJECT_TAGLINE"
load_git_token
create_git_repo "$PROJECT_NAME" "$PROJECT_TAGLINE"
fi
create_local_project "$PROJECT_NAME"

View File

@ -7,6 +7,8 @@ from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
from development_hub.main_window import MainWindow
from development_hub.settings import Settings
from development_hub.dialogs import SetupWizardDialog
from development_hub.styles import DARK_THEME
@ -28,6 +30,13 @@ def main() -> int:
Exit code (0 for success).
"""
app = DevelopmentHubApp(sys.argv)
# Check for first-run setup
settings = Settings()
if not settings.get("setup_completed", False):
wizard = SetupWizardDialog()
wizard.exec()
window = MainWindow()
# Handle Ctrl+C gracefully

View File

@ -576,6 +576,45 @@ class SettingsDialog(QDialog):
layout.addWidget(docs_group)
# Git hosting settings
git_group = QGroupBox("Git Hosting")
git_layout = QFormLayout(git_group)
self.git_type_combo = QComboBox()
self.git_type_combo.addItem("Not configured", "")
current_type = self.settings.git_host_type
current_type_index = 0
for i, (value, label) in enumerate(Settings.GIT_HOST_CHOICES):
self.git_type_combo.addItem(label, value)
if value == current_type:
current_type_index = i + 1
self.git_type_combo.setCurrentIndex(current_type_index)
self.git_type_combo.currentIndexChanged.connect(self._on_git_type_changed)
git_layout.addRow("Provider:", self.git_type_combo)
self.git_url_edit = QLineEdit()
self.git_url_edit.setText(self.settings.git_host_url)
self.git_url_edit.setPlaceholderText("https://github.com or https://gitea.example.com")
git_layout.addRow("URL:", self.git_url_edit)
self.git_owner_edit = QLineEdit()
self.git_owner_edit.setText(self.settings.git_host_owner)
self.git_owner_edit.setPlaceholderText("username or organization")
git_layout.addRow("Owner:", self.git_owner_edit)
self.git_token_edit = QLineEdit()
self.git_token_edit.setText(self.settings.git_host_token)
self.git_token_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.git_token_edit.setPlaceholderText("API token with repo scope")
git_layout.addRow("Token:", self.git_token_edit)
token_help = QLabel('<a href="#">How to create a token</a>')
token_help.setOpenExternalLinks(False)
token_help.linkActivated.connect(self._show_token_help)
git_layout.addRow("", token_help)
layout.addWidget(git_group)
layout.addStretch()
# Buttons
@ -614,6 +653,39 @@ class SettingsDialog(QDialog):
row = self.paths_list.row(current)
self.paths_list.takeItem(row)
def _on_git_type_changed(self, index: int):
"""Update URL placeholder based on git type."""
git_type = self.git_type_combo.currentData()
if git_type == "github":
self.git_url_edit.setPlaceholderText("https://github.com")
if not self.git_url_edit.text():
self.git_url_edit.setText("https://github.com")
elif git_type == "gitlab":
self.git_url_edit.setPlaceholderText("https://gitlab.com or self-hosted URL")
if not self.git_url_edit.text():
self.git_url_edit.setText("https://gitlab.com")
elif git_type == "gitea":
self.git_url_edit.setPlaceholderText("https://gitea.example.com")
def _show_token_help(self):
"""Show help dialog for creating tokens."""
git_type = self.git_type_combo.currentData()
if git_type == "github":
url = "https://github.com/settings/tokens"
msg = "Go to GitHub Settings → Developer settings → Personal access tokens\n\nCreate a token with 'repo' scope."
elif git_type == "gitlab":
url = "https://gitlab.com/-/profile/personal_access_tokens"
msg = "Go to GitLab Settings → Access Tokens\n\nCreate a token with 'api' scope."
elif git_type == "gitea":
base_url = self.git_url_edit.text() or "https://your-gitea-instance"
url = f"{base_url}/user/settings/applications"
msg = f"Go to {url}\n\nGenerate a new token with 'repo' scope."
else:
msg = "Select a git provider first."
url = None
QMessageBox.information(self, "Creating an API Token", msg)
def _save(self):
"""Save settings and close."""
# Save search paths
@ -626,6 +698,151 @@ class SettingsDialog(QDialog):
self.settings.deploy_docs_after_creation = self.deploy_checkbox.isChecked()
self.settings.preferred_editor = self.editor_combo.currentData()
self.settings.auto_start_docs_server = self.auto_docs_server_checkbox.isChecked()
# Save git hosting settings
self.settings.git_host_type = self.git_type_combo.currentData()
self.settings.git_host_url = self.git_url_edit.text().rstrip("/")
self.settings.git_host_owner = self.git_owner_edit.text()
self.settings.git_host_token = self.git_token_edit.text()
self.accept()
class SetupWizardDialog(QDialog):
"""First-run setup wizard for configuring Development Hub."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Welcome to Development Hub")
self.setMinimumWidth(500)
self.settings = Settings()
self._setup_ui()
def _setup_ui(self):
"""Set up the wizard UI."""
layout = QVBoxLayout(self)
# Welcome message
welcome = QLabel(
"<h2>Welcome to Development Hub!</h2>"
"<p>Let's set up a few things to get you started.</p>"
)
welcome.setWordWrap(True)
layout.addWidget(welcome)
# Projects directory
projects_group = QGroupBox("Projects Directory")
projects_layout = QHBoxLayout(projects_group)
self.projects_dir_edit = QLineEdit()
self.projects_dir_edit.setText(str(self.settings.default_project_path))
projects_layout.addWidget(self.projects_dir_edit)
browse_btn = QPushButton("Browse...")
browse_btn.clicked.connect(self._browse_projects_dir)
projects_layout.addWidget(browse_btn)
layout.addWidget(projects_group)
# Git hosting settings
git_group = QGroupBox("Git Hosting (for creating new projects)")
git_layout = QFormLayout(git_group)
self.git_type_combo = QComboBox()
for value, label in Settings.GIT_HOST_CHOICES:
self.git_type_combo.addItem(label, value)
self.git_type_combo.currentIndexChanged.connect(self._on_git_type_changed)
git_layout.addRow("Provider:", self.git_type_combo)
self.git_url_edit = QLineEdit()
self.git_url_edit.setPlaceholderText("https://github.com")
git_layout.addRow("URL:", self.git_url_edit)
self.git_owner_edit = QLineEdit()
self.git_owner_edit.setPlaceholderText("your username or organization")
git_layout.addRow("Owner:", self.git_owner_edit)
self.git_token_edit = QLineEdit()
self.git_token_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.git_token_edit.setPlaceholderText("API token (optional - needed for creating repos)")
git_layout.addRow("Token:", self.git_token_edit)
skip_note = QLabel(
"<small>You can skip git setup and configure it later in Settings.</small>"
)
skip_note.setStyleSheet("color: #888;")
git_layout.addRow("", skip_note)
layout.addWidget(git_group)
layout.addStretch()
# Buttons
button_layout = QHBoxLayout()
skip_btn = QPushButton("Skip Setup")
skip_btn.clicked.connect(self._skip)
button_layout.addWidget(skip_btn)
button_layout.addStretch()
finish_btn = QPushButton("Finish Setup")
finish_btn.setDefault(True)
finish_btn.clicked.connect(self._finish)
button_layout.addWidget(finish_btn)
layout.addLayout(button_layout)
# Set initial URL based on default selection
self._on_git_type_changed(0)
def _browse_projects_dir(self):
"""Browse for projects directory."""
path = QFileDialog.getExistingDirectory(
self,
"Select Projects Directory",
self.projects_dir_edit.text() or str(Path.home()),
)
if path:
self.projects_dir_edit.setText(path)
def _on_git_type_changed(self, index: int):
"""Update URL based on git type."""
git_type = self.git_type_combo.currentData()
if git_type == "github":
self.git_url_edit.setText("https://github.com")
elif git_type == "gitlab":
self.git_url_edit.setText("https://gitlab.com")
elif git_type == "gitea":
self.git_url_edit.setText("")
self.git_url_edit.setPlaceholderText("https://gitea.example.com")
def _skip(self):
"""Skip setup and just set first_run flag."""
self.settings.set("setup_completed", True)
self.reject()
def _finish(self):
"""Save settings and finish setup."""
# Save projects directory
projects_dir = self.projects_dir_edit.text()
if projects_dir:
self.settings.default_project_path = Path(projects_dir)
self.settings.project_search_paths = [projects_dir]
# Save git settings if provided
git_type = self.git_type_combo.currentData()
git_url = self.git_url_edit.text().rstrip("/")
git_owner = self.git_owner_edit.text()
git_token = self.git_token_edit.text()
if git_type and git_url and git_owner:
self.settings.git_host_type = git_type
self.settings.git_host_url = git_url
self.settings.git_host_owner = git_owner
self.settings.git_host_token = git_token
self.settings.set("setup_completed", True)
self.accept()

View File

@ -19,6 +19,11 @@ class Settings:
"project_search_paths": [str(Path.home() / "PycharmProjects")],
"project_ignore_folders": ["trash", "project-docs", ".cache", "__pycache__", "node_modules"],
"auto_start_docs_server": True,
# Git hosting settings
"git_host_type": "", # "gitea", "github", "gitlab", ""
"git_host_url": "", # e.g., "https://gitea.example.com" or "https://github.com"
"git_host_owner": "", # username or organization
"git_host_token": "", # API token (stored in settings, not ideal but simple)
}
# Available editor choices with display names
@ -31,6 +36,13 @@ class Settings:
("subl", "Sublime Text"),
]
# Git hosting provider choices
GIT_HOST_CHOICES = [
("gitea", "Gitea"),
("github", "GitHub"),
("gitlab", "GitLab"),
]
def __new__(cls):
"""Singleton pattern."""
if cls._instance is None:
@ -120,6 +132,47 @@ class Settings:
def auto_start_docs_server(self, value: bool):
self.set("auto_start_docs_server", value)
@property
def git_host_type(self) -> str:
"""Git hosting type (gitea, github, gitlab)."""
return self.get("git_host_type", "")
@git_host_type.setter
def git_host_type(self, value: str):
self.set("git_host_type", value)
@property
def git_host_url(self) -> str:
"""Git host URL."""
return self.get("git_host_url", "")
@git_host_url.setter
def git_host_url(self, value: str):
self.set("git_host_url", value)
@property
def git_host_owner(self) -> str:
"""Git host username or organization."""
return self.get("git_host_owner", "")
@git_host_owner.setter
def git_host_owner(self, value: str):
self.set("git_host_owner", value)
@property
def git_host_token(self) -> str:
"""Git host API token."""
return self.get("git_host_token", "")
@git_host_token.setter
def git_host_token(self, value: str):
self.set("git_host_token", value)
@property
def is_git_configured(self) -> bool:
"""Check if git hosting is configured."""
return bool(self.git_host_type and self.git_host_url and self.git_host_owner)
def save_session(self, state: dict):
"""Save session state to file."""
self._session_file.parent.mkdir(parents=True, exist_ok=True)