ops-jrz1/scripts/learner-add.sh
Dan 29ce3a9fa5 Fix learner-add.sh for NixOS compatibility
- Use 'users' group instead of per-user groups
- Remove shell specification (NixOS has different paths)
- Use 'ip' command instead of 'hostname -I' for IP detection
2025-12-29 00:08:18 -05:00

307 lines
8.3 KiB
Bash
Executable file

#!/usr/bin/env bash
# learner-add.sh - Add a new learner account for maubot plugin development
# Usage: learner-add.sh <username> <ssh-pubkey>
#
# Creates:
# - Unix user account with SSH key
# - ~/plugins directory with hello-world starter plugin
# - Symlink in maubot plugins directory
# - Outputs onboarding instructions
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MAUBOT_PLUGINS_DIR="/var/lib/maubot/plugins"
TEMPLATE_DIR="${SCRIPT_DIR}/../templates/plugin-skeleton"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
usage() {
echo "Usage: $0 <username> <ssh-pubkey>"
echo ""
echo "Arguments:"
echo " username - Learner's username (alphanumeric, 3-16 chars)"
echo " ssh-pubkey - SSH public key (starts with ssh-ed25519, ssh-rsa, etc.)"
echo ""
echo "Example:"
echo " $0 alice 'ssh-ed25519 AAAA... alice@laptop'"
exit 1
}
validate_username() {
local username="$1"
if [[ ! "$username" =~ ^[a-z][a-z0-9_-]{2,15}$ ]]; then
log_error "Invalid username: must be 3-16 chars, start with letter, alphanumeric/underscore/dash only"
exit 1
fi
# Check if user already exists
if id "$username" &>/dev/null; then
log_error "User '$username' already exists"
exit 1
fi
}
validate_ssh_key() {
local key="$1"
if [[ ! "$key" =~ ^ssh-(ed25519|rsa|ecdsa) ]]; then
log_error "Invalid SSH key: must start with ssh-ed25519, ssh-rsa, or ssh-ecdsa"
exit 1
fi
}
create_user() {
local username="$1"
local ssh_key="$2"
log_info "Creating user '$username'..."
# Create user with home directory
# NixOS: don't specify shell (uses default), group is 'users'
useradd -m -g users "$username"
# Set up SSH key
local ssh_dir="/home/$username/.ssh"
mkdir -p "$ssh_dir"
echo "$ssh_key" > "$ssh_dir/authorized_keys"
chmod 700 "$ssh_dir"
chmod 600 "$ssh_dir/authorized_keys"
chown -R "$username:users" "$ssh_dir"
log_info "User created with SSH access"
}
setup_plugin_directory() {
local username="$1"
local plugin_name="hello-${username}"
local user_plugins_dir="/home/$username/plugins"
local plugin_dir="$user_plugins_dir/$plugin_name"
log_info "Setting up plugin directory..."
# Create plugins directory
mkdir -p "$plugin_dir"
mkdir -p "$plugin_dir/dist"
# Python module names must use underscores, not hyphens
local module_name="${plugin_name//-/_}"
# Copy template if exists, otherwise create from scratch
if [[ -d "$TEMPLATE_DIR" ]]; then
cp -r "$TEMPLATE_DIR/"* "$plugin_dir/"
# Rename MODULE_NAME directory to actual module name
if [[ -d "$plugin_dir/MODULE_NAME" ]]; then
mv "$plugin_dir/MODULE_NAME" "$plugin_dir/$module_name"
fi
# Replace placeholders in all files
find "$plugin_dir" -type f -exec sed -i "s/MODULE_NAME/$module_name/g" {} \;
find "$plugin_dir" -type f -exec sed -i "s/USERNAME/$username/g" {} \;
else
# Create minimal starter plugin
create_starter_plugin "$plugin_dir" "$module_name" "$username"
fi
# Set ownership
chown -R "$username:users" "$user_plugins_dir"
# Make plugin dir readable by maubot group
chmod 755 "/home/$username"
chmod 755 "$user_plugins_dir"
chmod -R 755 "$plugin_dir"
log_info "Plugin directory created at $plugin_dir"
}
create_starter_plugin() {
local plugin_dir="$1"
local module_name="$2"
local username="$3"
# Create module directory
mkdir -p "$plugin_dir/$module_name"
# maubot.yaml
cat > "$plugin_dir/maubot.yaml" << EOF
maubot: 0.4.0
id: xyz.clarun.${module_name}
version: 0.1.0
license: MIT
modules:
- ${module_name}
main_class: ${module_name}.Bot
EOF
# Python module __init__.py
cat > "$plugin_dir/$module_name/__init__.py" << EOF
from .bot import Bot
__all__ = ["Bot"]
EOF
# Main bot.py
cat > "$plugin_dir/$module_name/bot.py" << EOF
from maubot import Plugin, MessageEvent
from maubot.handlers import command
class Bot(Plugin):
"""Hello world bot for $username"""
@command.new("hello")
async def hello_command(self, evt: MessageEvent) -> None:
"""Respond to !hello command"""
await evt.reply(f"Hello from ${username}'s bot!")
@command.new("ping")
async def ping_command(self, evt: MessageEvent) -> None:
"""Respond to !ping command"""
await evt.reply("Pong!")
EOF
# Makefile
cat > "$plugin_dir/Makefile" << 'EOF'
PLUGIN_NAME := $(shell grep '^id:' maubot.yaml | cut -d: -f2 | tr -d ' ')
PLUGIN_VERSION := $(shell grep '^version:' maubot.yaml | cut -d: -f2 | tr -d ' ')
MBP_FILE := dist/$(PLUGIN_NAME)-$(PLUGIN_VERSION).mbp
.PHONY: build reload clean help
help:
@echo "Available targets:"
@echo " build - Build the .mbp plugin file"
@echo " reload - Reload plugin in maubot (requires instance configured)"
@echo " clean - Remove built files"
@echo " dev - Build and reload"
build:
@echo "Building $(PLUGIN_NAME)..."
@mkdir -p dist
@rm -f $(MBP_FILE)
@zip -r $(MBP_FILE) maubot.yaml $(shell grep -E '^ - ' maubot.yaml | sed 's/ - //')
@echo "Built: $(MBP_FILE)"
reload:
@echo "Reloading plugin..."
@echo "TODO: Add maubot API reload command"
clean:
rm -rf dist/*.mbp
dev: build reload
EOF
# README
cat > "$plugin_dir/README.md" << EOF
# $module_name
Hello world maubot plugin for $username.
## Quick Start
1. Edit \`$module_name/bot.py\` to add your bot logic
2. Run \`make build\` to create the .mbp file
3. Upload to maubot via admin UI (http://localhost:29316)
## Commands
- \`!hello\` - Says hello
- \`!ping\` - Responds with pong
## Files
- \`maubot.yaml\` - Plugin manifest
- \`$module_name/bot.py\` - Main bot code
- \`Makefile\` - Build commands
EOF
}
create_maubot_symlink() {
local username="$1"
local plugin_name="hello-${username}"
local plugin_dir="/home/$username/plugins/$plugin_name"
local symlink_path="$MAUBOT_PLUGINS_DIR/${username}-${plugin_name}.mbp"
log_info "Creating maubot plugin symlink..."
# Note: Symlink will point to the built .mbp file
# For now, we'll link to the dist directory's expected output
# The learner will need to run 'make build' first
# We could also configure maubot to load from source directories
# but .mbp symlinks are simpler for now
log_warn "Learner must run 'make build' before plugin appears in maubot"
}
print_onboarding() {
local username="$1"
local server_ip
# NixOS: use ip command instead of hostname -I
server_ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
echo ""
echo "=========================================="
echo " Onboarding Instructions for $username"
echo "=========================================="
echo ""
echo "1. Install VS Code with Remote-SSH extension"
echo ""
echo "2. Add this to your SSH config (~/.ssh/config):"
echo ""
echo " Host maubot-dev"
echo " HostName ${server_ip:-<server-ip>}"
echo " User $username"
echo " LocalForward 29316 127.0.0.1:29316"
echo ""
echo "3. Connect via VS Code:"
echo " - Press F1 -> 'Remote-SSH: Connect to Host'"
echo " - Select 'maubot-dev'"
echo ""
echo "4. Open your plugin folder:"
echo " /home/$username/plugins/hello-$username"
echo ""
echo "5. Build and test:"
echo " make build # Build the plugin"
echo " make reload # Reload in maubot"
echo ""
echo "6. Test in Matrix:"
echo " Join #learners-sandbox and try !hello"
echo ""
echo "=========================================="
}
main() {
if [[ $# -lt 2 ]]; then
usage
fi
local username="$1"
local ssh_key="$2"
# Must run as root
if [[ $EUID -ne 0 ]]; then
log_error "This script must be run as root"
exit 1
fi
validate_username "$username"
validate_ssh_key "$ssh_key"
create_user "$username" "$ssh_key"
setup_plugin_directory "$username"
create_maubot_symlink "$username"
print_onboarding "$username"
log_info "Learner '$username' setup complete!"
}
main "$@"