Browse Source

Add admin web UI to orly-launcher with NIP-98 authentication

- Add embedded Svelte admin web UI for process monitoring and control
- Implement NIP-98 HTTP authentication middleware for secure API access
- Add binary update/rollback system with versioned symlinks
- Add admin API endpoints: status, config, binaries, update, restart, rollback
- Update CI workflow to build all binaries for AMD64 and ARM64 architectures
- Add launcher-web Makefile target for building admin UI separately

Files modified:
- .gitea/workflows/go.yml: Build all binaries and launcher admin UI
- Makefile: Add launcher-web and orly-launcher-no-web targets
- cmd/orly-launcher/auth.go: NIP-98 authentication middleware
- cmd/orly-launcher/config.go: Admin UI configuration (port, owners)
- cmd/orly-launcher/main.go: Start admin server, updated help text
- cmd/orly-launcher/server.go: Admin HTTP server with API endpoints
- cmd/orly-launcher/supervisor.go: GetProcessStatuses, RestartAll methods
- cmd/orly-launcher/updater.go: Binary version management with symlinks
- cmd/orly-launcher/web.go: Embedded admin UI serving
- cmd/orly-launcher/web/: Svelte admin UI (dashboard, config, update pages)
- pkg/version/version: Bump to v0.55.11

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.55.11
woikos 4 months ago
parent
commit
3ef1ce9a3a
No known key found for this signature in database
  1. 157
      .gitea/workflows/go.yml
  2. 27
      Makefile
  3. 82
      cmd/orly-launcher/auth.go
  4. 45
      cmd/orly-launcher/config.go
  5. 76
      cmd/orly-launcher/main.go
  6. 316
      cmd/orly-launcher/server.go
  7. 129
      cmd/orly-launcher/supervisor.go
  8. 287
      cmd/orly-launcher/updater.go
  9. 89
      cmd/orly-launcher/web.go
  10. 272
      cmd/orly-launcher/web/bun.lock
  11. 0
      cmd/orly-launcher/web/dist/.gitkeep
  12. 7
      cmd/orly-launcher/web/dist/bundle.css
  13. 14
      cmd/orly-launcher/web/dist/bundle.js
  14. 12
      cmd/orly-launcher/web/dist/index.html
  15. 24
      cmd/orly-launcher/web/package.json
  16. 12
      cmd/orly-launcher/web/public/index.html
  17. 35
      cmd/orly-launcher/web/rollup.config.js
  18. 185
      cmd/orly-launcher/web/src/App.svelte
  19. 454
      cmd/orly-launcher/web/src/LoginModal.svelte
  20. 151
      cmd/orly-launcher/web/src/api.js
  21. 149
      cmd/orly-launcher/web/src/components/Header.svelte
  22. 117
      cmd/orly-launcher/web/src/components/ProcessCard.svelte
  23. 7
      cmd/orly-launcher/web/src/main.js
  24. 300
      cmd/orly-launcher/web/src/pages/Config.svelte
  25. 202
      cmd/orly-launcher/web/src/pages/Dashboard.svelte
  26. 430
      cmd/orly-launcher/web/src/pages/Update.svelte
  27. 16
      cmd/orly-launcher/web/src/stores.js
  28. 2
      pkg/version/version

157
.gitea/workflows/go.yml

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
# This workflow will build a golang project for Gitea Actions
# Using inline commands to avoid external action dependencies
# This workflow builds and releases ORLY binaries for Gitea
#
# NOTE: All builds use CGO_ENABLED=0 since p8k library uses purego (not CGO)
# The library dynamically loads libsecp256k1 at runtime via purego
@ -10,9 +9,10 @@ @@ -10,9 +9,10 @@
# git tag v1.2.3
# git push origin v1.2.3
# 3. The workflow will automatically:
# - Build binaries for Linux AMD64
# - Build all binaries for Linux AMD64 and ARM64
# - Build the launcher admin web UI
# - Run tests
# - Create a Gitea release with the binaries
# - Create a Gitea release with all binaries
# - Generate checksums
name: Go
@ -31,14 +31,10 @@ jobs: @@ -31,14 +31,10 @@ jobs:
set -e
echo "Cloning repository..."
echo "GITHUB_REF_NAME=${GITHUB_REF_NAME}"
echo "GITHUB_SERVER_URL=${GITHUB_SERVER_URL}"
echo "GITHUB_REPOSITORY=${GITHUB_REPOSITORY}"
echo "GITHUB_WORKSPACE=${GITHUB_WORKSPACE}"
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git ${GITHUB_WORKSPACE}
cd ${GITHUB_WORKSPACE}
echo "Cloned successfully. Last commit:"
git log -1
ls -la
- name: Set up Go
run: |
@ -60,7 +56,7 @@ jobs: @@ -60,7 +56,7 @@ jobs:
export PATH="$BUN_INSTALL/bin:$PATH"
bun --version
- name: Build Web UI
- name: Build Main Web UI
run: |
set -e
export BUN_INSTALL="$HOME/.bun"
@ -70,52 +66,92 @@ jobs: @@ -70,52 +66,92 @@ jobs:
bun install
echo "Building web app..."
bun run build
echo "Verifying dist directory was created..."
ls -lah dist/
echo "Web UI build complete"
echo "Main web UI build complete"
- name: Build (Pure Go + purego)
- name: Build Launcher Admin Web UI
run: |
set -e
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
cd ${GITHUB_WORKSPACE}/cmd/orly-launcher/web
echo "Installing launcher admin dependencies..."
bun install
echo "Building launcher admin UI..."
bun run build
ls -lah dist/
echo "Launcher admin UI build complete"
- name: Build All Packages
run: |
set -e
export PATH=/usr/local/go/bin:$PATH
cd ${GITHUB_WORKSPACE}
echo "Building with CGO_ENABLED=0..."
echo "Building all packages..."
CGO_ENABLED=0 go build -v ./...
- name: Test (Pure Go + purego)
- name: Test
run: |
set -e
export PATH=/usr/local/go/bin:$PATH
cd ${GITHUB_WORKSPACE}
echo "Running tests..."
# libsecp256k1.so is included in the repository
chmod +x libsecp256k1.so
# Set LD_LIBRARY_PATH so tests can find the library
export LD_LIBRARY_PATH=${GITHUB_WORKSPACE}:${LD_LIBRARY_PATH}
# Run tests but don't fail the build on test failures (some tests may need specific env)
CGO_ENABLED=0 go test -v $(go list ./... | grep -v '/cmd/benchmark/external/' | xargs -n1 sh -c 'ls $0/*_test.go 1>/dev/null 2>&1 && echo $0' | grep .) || echo "Some tests failed, continuing..."
- name: Build Release Binaries (Pure Go + purego)
- name: Build Release Binaries
run: |
set -e
export PATH=/usr/local/go/bin:$PATH
cd ${GITHUB_WORKSPACE}
# Extract version from tag (e.g., v1.2.3 -> 1.2.3)
VERSION=${GITHUB_REF_NAME#v}
echo "Building release binaries for version $VERSION (pure Go + purego)"
echo "Building release binaries for version $VERSION"
# Create directory for binaries
mkdir -p release-binaries
# Copy libsecp256k1.so from repository to release binaries
# List of binaries to build
BINARIES=(
"orly:."
"orly-db-badger:./cmd/orly-db-badger"
"orly-db-neo4j:./cmd/orly-db-neo4j"
"orly-acl-follows:./cmd/orly-acl-follows"
"orly-acl-managed:./cmd/orly-acl-managed"
"orly-acl-curation:./cmd/orly-acl-curation"
"orly-launcher:./cmd/orly-launcher"
"orly-sync-negentropy:./cmd/orly-sync-negentropy"
)
# Build for AMD64
echo "Building for Linux AMD64..."
for entry in "${BINARIES[@]}"; do
name="${entry%%:*}"
path="${entry##*:}"
echo " Building ${name}..."
GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags "-s -w" -o "release-binaries/${name}-${VERSION}-linux-amd64" "${path}"
done
# Build for ARM64
echo "Building for Linux ARM64..."
for entry in "${BINARIES[@]}"; do
name="${entry%%:*}"
path="${entry##*:}"
echo " Building ${name}..."
GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
go build -ldflags "-s -w" -o "release-binaries/${name}-${VERSION}-linux-arm64" "${path}"
done
# Copy libsecp256k1.so for AMD64
cp libsecp256k1.so release-binaries/libsecp256k1-linux-amd64.so
chmod +x release-binaries/libsecp256k1-linux-amd64.so
# Build for Linux AMD64 (pure Go + purego dynamic loading)
echo "Building Linux AMD64 (pure Go + purego dynamic loading)..."
GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags "-s -w" -o release-binaries/orly-${VERSION}-linux-amd64 .
# Note: ARM64 libsecp256k1 must be built separately and added to repo
if [ -f libsecp256k1-arm64.so ]; then
cp libsecp256k1-arm64.so release-binaries/libsecp256k1-linux-arm64.so
chmod +x release-binaries/libsecp256k1-linux-arm64.so
fi
# Create checksums
cd release-binaries
@ -130,75 +166,78 @@ jobs: @@ -130,75 +166,78 @@ jobs:
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -e # Exit on any error
export PATH=/usr/local/go/bin:$PATH
set -e
cd ${GITHUB_WORKSPACE}
# Validate GITEA_TOKEN is set
if [ -z "${GITEA_TOKEN}" ]; then
echo "ERROR: GITEA_TOKEN secret is not set!"
echo "Please configure the GITEA_TOKEN secret in repository settings."
exit 1
fi
VERSION=${GITHUB_REF_NAME}
VERSION_NUM=${GITHUB_REF_NAME#v}
REPO_OWNER=$(echo ${GITHUB_REPOSITORY} | cut -d'/' -f1)
REPO_NAME=$(echo ${GITHUB_REPOSITORY} | cut -d'/' -f2)
echo "Creating release for ${REPO_OWNER}/${REPO_NAME} version ${VERSION}"
# Verify release binaries exist
if [ ! -f "release-binaries/orly-${VERSION#v}-linux-amd64" ]; then
echo "ERROR: Release binary not found!"
ls -la release-binaries/ || echo "release-binaries directory does not exist"
exit 1
fi
# Use Gitea API directly (more reliable than tea CLI)
cd ${GITHUB_WORKSPACE}
API_URL="${GITHUB_SERVER_URL}/api/v1"
echo "Creating release via Gitea API..."
echo "API URL: ${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases"
# Create the release
RELEASE_BODY="## ORLY Release ${VERSION}
### Binaries Included
- **orly** - Main relay binary
- **orly-db-badger** - Badger database server
- **orly-db-neo4j** - Neo4j database server
- **orly-acl-follows** - Follows ACL server
- **orly-acl-managed** - Managed ACL server
- **orly-acl-curation** - Curation ACL server
- **orly-launcher** - Process supervisor with admin UI
- **orly-sync-negentropy** - Negentropy sync service
- **libsecp256k1** - Required shared library
### Architectures
- Linux AMD64 (x86_64)
- Linux ARM64 (aarch64)
### Installation
1. Download the appropriate binaries for your architecture
2. Make them executable: \`chmod +x orly-*\`
3. Place libsecp256k1 in \`/usr/local/lib/\` or set \`LD_LIBRARY_PATH\`
4. Run with: \`./orly-launcher\` (for split mode) or \`./orly\` (standalone)
"
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Release ${VERSION}\", \"body\": \"Automated release ${VERSION}\"}" \
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Release ${VERSION}\", \"body\": $(echo "$RELEASE_BODY" | jq -Rs .)}" \
"${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases")
echo "Release response: ${RELEASE_RESPONSE}"
# Extract release ID
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "${RELEASE_ID}" ]; then
echo "ERROR: Failed to create release or extract release ID"
echo "Full response: ${RELEASE_RESPONSE}"
echo "ERROR: Failed to create release"
exit 1
fi
echo "Release created with ID: ${RELEASE_ID}"
# Upload assets
for ASSET in release-binaries/orly-${VERSION#v}-linux-amd64 release-binaries/libsecp256k1-linux-amd64.so release-binaries/SHA256SUMS.txt; do
# Upload all assets
for ASSET in release-binaries/*; do
FILENAME=$(basename "${ASSET}")
echo "Uploading ${FILENAME}..."
UPLOAD_RESPONSE=$(curl -s -X POST \
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${ASSET}" \
"${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${FILENAME}")
echo "Upload response for ${FILENAME}: ${UPLOAD_RESPONSE}"
"${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
done
echo "Release ${VERSION} created successfully with all assets!"
# Verify release exists
VERIFY=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${VERSION}")
echo "Verification: ${VERIFY}" | head -c 500
echo "Release ${VERSION} created successfully!"
# Verify
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${VERSION}" | head -c 500

27
Makefile

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
.PHONY: orly-sync-negentropy all-sync arm64-sync
.PHONY: quick-deploy quick-deploy-restart deploy-both deploy-both-restart deploy-new list-releases rollback
.PHONY: orly-unified orly-unified-full arm64-unified install-unified
.PHONY: launcher-web orly-launcher-no-web
# Build flags
CGO_ENABLED ?= 0
@ -90,7 +91,15 @@ orly-acl-curation: @@ -90,7 +91,15 @@ orly-acl-curation:
$(BUILD_FLAGS) go build -o $(ORLY_ACL_CURATION) ./cmd/orly-acl-curation
# Process supervisor/launcher
orly-launcher:
orly-launcher: launcher-web
$(BUILD_FLAGS) go build -o $(ORLY_LAUNCHER) ./cmd/orly-launcher
# Build launcher admin web UI
launcher-web:
cd cmd/orly-launcher/web && bun install && bun run build
# Build launcher without web UI (for development)
orly-launcher-no-web:
$(BUILD_FLAGS) go build -o $(ORLY_LAUNCHER) ./cmd/orly-launcher
# === Unified Binary (New Architecture) ===
@ -253,13 +262,15 @@ help: @@ -253,13 +262,15 @@ help:
@echo " orly-acl - Build monolithic ACL server"
@echo ""
@echo " Core:"
@echo " orly - Build main relay binary"
@echo " orly-launcher - Build process supervisor"
@echo " proto - Generate protobuf code"
@echo " web - Rebuild embedded web UI"
@echo " test - Run test suite"
@echo " clean - Remove build artifacts"
@echo " help - Show this help"
@echo " orly - Build main relay binary"
@echo " orly-launcher - Build process supervisor with admin UI"
@echo " orly-launcher-no-web - Build launcher without admin UI"
@echo " proto - Generate protobuf code"
@echo " web - Rebuild main embedded web UI"
@echo " launcher-web - Rebuild launcher admin web UI"
@echo " test - Run test suite"
@echo " clean - Remove build artifacts"
@echo " help - Show this help"
@echo ""
@echo " Sync Services:"
@echo " orly-sync-negentropy - Build NIP-77 negentropy sync server"

82
cmd/orly-launcher/auth.go

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
package main
import (
"encoding/hex"
"net/http"
"strings"
"git.mleku.dev/mleku/nostr/httpauth"
"lol.mleku.dev/chk"
)
// AuthMiddleware provides NIP-98 authentication for the admin API.
type AuthMiddleware struct {
owners map[string]struct{}
}
// NewAuthMiddleware creates a new auth middleware with the given owner pubkeys.
func NewAuthMiddleware(owners []string) *AuthMiddleware {
ownerMap := make(map[string]struct{}, len(owners))
for _, o := range owners {
// Normalize to lowercase hex
ownerMap[strings.ToLower(o)] = struct{}{}
}
return &AuthMiddleware{owners: ownerMap}
}
// RequireAuth wraps a handler to require NIP-98 authentication from an owner.
func (a *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Validate NIP-98 authentication
valid, pubkeyBytes, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication required"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Convert pubkey bytes to hex string
pubkeyHex := hex.EncodeToString(pubkeyBytes)
// Check if pubkey is in owners list
if !a.IsOwner(pubkeyHex) {
http.Error(w, "Not authorized - owner access required", http.StatusForbidden)
return
}
// Authentication successful, call the next handler
next(w, r)
}
}
// IsOwner checks if the given pubkey is in the owners list.
func (a *AuthMiddleware) IsOwner(pubkey string) bool {
if len(a.owners) == 0 {
// No owners configured - deny all access
return false
}
_, ok := a.owners[strings.ToLower(pubkey)]
return ok
}
// AddOwner adds a pubkey to the owners list.
func (a *AuthMiddleware) AddOwner(pubkey string) {
a.owners[strings.ToLower(pubkey)] = struct{}{}
}
// RemoveOwner removes a pubkey from the owners list.
func (a *AuthMiddleware) RemoveOwner(pubkey string) {
delete(a.owners, strings.ToLower(pubkey))
}
// Owners returns the list of owner pubkeys.
func (a *AuthMiddleware) Owners() []string {
owners := make([]string, 0, len(a.owners))
for o := range a.owners {
owners = append(owners, o)
}
return owners
}

45
cmd/orly-launcher/config.go

@ -3,6 +3,8 @@ package main @@ -3,6 +3,8 @@ package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/adrg/xdg"
@ -82,6 +84,16 @@ type Config struct { @@ -82,6 +84,16 @@ type Config struct {
// SyncReadyTimeout is how long to wait for sync services to be ready
SyncReadyTimeout time.Duration
// Admin UI configuration
// AdminEnabled controls whether to run the admin HTTP server
AdminEnabled bool
// AdminPort is the port for the admin HTTP server
AdminPort int
// AdminOwners is a list of pubkeys (hex) allowed to access the admin UI
AdminOwners []string
// BinDir is the directory for versioned binary management
BinDir string
}
func loadConfig() (*Config, error) {
@ -93,6 +105,9 @@ func loadConfig() (*Config, error) { @@ -93,6 +105,9 @@ func loadConfig() (*Config, error) {
defaultDBBinary := "orly-db-" + dbBackend
defaultACLBinary := "orly-acl-" + aclMode
// Parse admin owners (comma-separated hex pubkeys)
adminOwners := parseOwnersList(getEnvOrDefault("ORLY_LAUNCHER_OWNERS", ""))
cfg := &Config{
DBBackend: dbBackend,
DBBinary: getEnvOrDefault("ORLY_LAUNCHER_DB_BINARY", defaultDBBinary),
@ -126,11 +141,41 @@ func loadConfig() (*Config, error) { @@ -126,11 +141,41 @@ func loadConfig() (*Config, error) {
NegentropyListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_NEGENTROPY_LISTEN", "127.0.0.1:50064"),
SyncReadyTimeout: parseDuration("ORLY_LAUNCHER_SYNC_READY_TIMEOUT", 30*time.Second),
// Admin UI configuration
AdminEnabled: getEnvOrDefault("ORLY_LAUNCHER_ADMIN_ENABLED", "true") == "true",
AdminPort: parseInt("ORLY_LAUNCHER_ADMIN_PORT", 8080),
AdminOwners: adminOwners,
BinDir: getEnvOrDefault("ORLY_LAUNCHER_BIN_DIR", filepath.Join(xdg.DataHome, "orly", "bin")),
}
return cfg, nil
}
func parseOwnersList(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
var owners []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
owners = append(owners, p)
}
}
return owners
}
func parseInt(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return defaultValue
}
func getEnvOrDefault(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v

76
cmd/orly-launcher/main.go

@ -54,6 +54,30 @@ func main() { @@ -54,6 +54,30 @@ func main() {
log.I.F("relay binary: %s", cfg.RelayBinary)
log.I.F("database listen: %s", cfg.DBListen)
// Start admin server if enabled
var adminServer *AdminServer
if cfg.AdminEnabled {
adminServer = NewAdminServer(cfg, supervisor)
// Ensure binary directory structure exists
if err := adminServer.updater.EnsureDirectories(); chk.E(err) {
log.W.F("failed to create binary directories: %v", err)
}
go func() {
if err := adminServer.Start(ctx); err != nil {
// Don't exit on admin server error, just log it
log.W.F("admin server stopped: %v", err)
}
}()
log.I.F("admin UI available at http://localhost:%d/admin", cfg.AdminPort)
if len(cfg.AdminOwners) > 0 {
log.I.F("admin owners: %v", cfg.AdminOwners)
} else {
log.W.F("no admin owners configured - admin API access disabled")
}
}
if err := supervisor.Start(); chk.E(err) {
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
os.Exit(1)
@ -73,7 +97,7 @@ func main() { @@ -73,7 +97,7 @@ func main() {
func printHelp() {
fmt.Printf(`orly-launcher %s
Process supervisor for split-mode deployment of ORLY relay.
Process supervisor for split-mode deployment of ORLY relay with admin web UI.
Usage: orly-launcher [command]
@ -82,27 +106,51 @@ Commands: @@ -82,27 +106,51 @@ Commands:
version, -v, --version Show version
Environment Variables:
ORLY_LAUNCHER_DB_BINARY Path to orly-db binary (default: orly-db)
ORLY_LAUNCHER_RELAY_BINARY Path to orly binary (default: orly)
ORLY_LAUNCHER_DB_LISTEN Address for database server (default: 127.0.0.1:50051)
ORLY_LAUNCHER_DB_READY_TIMEOUT Timeout waiting for DB ready (default: 30s)
ORLY_LAUNCHER_STOP_TIMEOUT Timeout for graceful stop (default: 10s)
ORLY_DATA_DIR Data directory (passed to orly-db)
ORLY_DB_LOG_LEVEL Database log level (passed to orly-db)
Process Management:
ORLY_LAUNCHER_DB_BINARY Path to orly-db binary (default: orly-db-{backend})
ORLY_LAUNCHER_RELAY_BINARY Path to orly binary (default: orly)
ORLY_LAUNCHER_ACL_BINARY Path to orly-acl binary (default: orly-acl-{mode})
ORLY_LAUNCHER_DB_BACKEND Database backend: badger, neo4j (default: badger)
ORLY_LAUNCHER_DB_LISTEN Address for database server (default: 127.0.0.1:50051)
ORLY_LAUNCHER_ACL_LISTEN Address for ACL server (default: 127.0.0.1:50052)
ORLY_LAUNCHER_ACL_ENABLED Enable ACL server (default: false)
ORLY_ACL_MODE ACL mode: follows, managed, curation (default: follows)
ORLY_LAUNCHER_DB_READY_TIMEOUT Timeout waiting for DB ready (default: 30s)
ORLY_LAUNCHER_STOP_TIMEOUT Timeout for graceful stop (default: 30s)
ORLY_DATA_DIR Data directory (passed to orly-db)
ORLY_LOG_LEVEL Log level for all processes (default: info)
Admin UI:
ORLY_LAUNCHER_ADMIN_ENABLED Enable admin HTTP server (default: true)
ORLY_LAUNCHER_ADMIN_PORT Admin server port (default: 8080)
ORLY_LAUNCHER_OWNERS Comma-separated hex pubkeys for admin access
ORLY_LAUNCHER_BIN_DIR Directory for versioned binaries
The launcher will:
1. Start the database server (orly-db)
2. Wait for the database to be ready
3. Start the relay (orly) with ORLY_DB_TYPE=grpc
4. Monitor both processes and restart if they crash
5. On shutdown, stop relay first, then database
1. Start the admin HTTP server (optional)
2. Start the database server (orly-db)
3. Wait for the database to be ready
4. Start the ACL server if enabled (orly-acl)
5. Start sync services if enabled
6. Start the relay (orly) with ORLY_DB_TYPE=grpc
7. Monitor all processes and restart if they crash
8. On shutdown, stop in reverse dependency order
Admin UI Features:
- View process status and versions
- Update binaries from release URLs
- Edit configuration
- Restart/rollback binaries
Example:
# Start with default binaries in PATH
orly-launcher
# Start with admin access for a specific pubkey
ORLY_LAUNCHER_OWNERS=abc123... orly-launcher
# Start with custom binary paths
ORLY_LAUNCHER_DB_BINARY=/opt/orly/orly-db \
ORLY_LAUNCHER_DB_BINARY=/opt/orly/orly-db-badger \
ORLY_LAUNCHER_RELAY_BINARY=/opt/orly/orly \
orly-launcher
`, version.V)

316
cmd/orly-launcher/server.go

@ -0,0 +1,316 @@ @@ -0,0 +1,316 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// AdminServer provides HTTP endpoints for managing the launcher.
type AdminServer struct {
cfg *Config
supervisor *Supervisor
updater *Updater
auth *AuthMiddleware
server *http.Server
startTime time.Time
}
// NewAdminServer creates a new admin HTTP server.
func NewAdminServer(cfg *Config, supervisor *Supervisor) *AdminServer {
return &AdminServer{
cfg: cfg,
supervisor: supervisor,
updater: NewUpdater(cfg.BinDir),
auth: NewAuthMiddleware(cfg.AdminOwners),
startTime: time.Now(),
}
}
// Start starts the admin HTTP server.
func (s *AdminServer) Start(ctx context.Context) error {
mux := http.NewServeMux()
// Public endpoints
mux.HandleFunc("/admin", s.serveUI)
mux.HandleFunc("/admin/", s.serveUI)
// Authenticated API endpoints
mux.HandleFunc("/api/status", s.auth.RequireAuth(s.handleStatus))
mux.HandleFunc("/api/config", s.auth.RequireAuth(s.handleConfig))
mux.HandleFunc("/api/binaries", s.auth.RequireAuth(s.handleBinaries))
mux.HandleFunc("/api/update", s.auth.RequireAuth(s.handleUpdate))
mux.HandleFunc("/api/restart", s.auth.RequireAuth(s.handleRestart))
mux.HandleFunc("/api/rollback", s.auth.RequireAuth(s.handleRollback))
addr := fmt.Sprintf(":%d", s.cfg.AdminPort)
s.server = &http.Server{
Addr: addr,
Handler: mux,
}
log.I.F("starting admin server on %s", addr)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.server.Shutdown(shutdownCtx)
}()
return s.server.ListenAndServe()
}
// StatusResponse is the response for GET /api/status
type StatusResponse struct {
Version string `json:"version"`
Uptime string `json:"uptime"`
Processes []ProcessStatus `json:"processes"`
}
// ProcessStatus represents the status of a single managed process.
type ProcessStatus struct {
Name string `json:"name"`
Binary string `json:"binary"`
Version string `json:"version"`
Status string `json:"status"`
PID int `json:"pid"`
Restarts int `json:"restarts"`
StartedAt string `json:"started_at,omitempty"`
}
func (s *AdminServer) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
uptime := time.Since(s.startTime).Round(time.Second).String()
processes := s.supervisor.GetProcessStatuses()
response := StatusResponse{
Version: s.updater.CurrentVersion(),
Uptime: uptime,
Processes: processes,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// ConfigResponse is the response for GET /api/config
type ConfigResponse struct {
DBBackend string `json:"db_backend"`
DBBinary string `json:"db_binary"`
RelayBinary string `json:"relay_binary"`
ACLBinary string `json:"acl_binary"`
DBListen string `json:"db_listen"`
ACLListen string `json:"acl_listen"`
ACLEnabled bool `json:"acl_enabled"`
ACLMode string `json:"acl_mode"`
DataDir string `json:"data_dir"`
LogLevel string `json:"log_level"`
DistributedSyncEnabled bool `json:"distributed_sync_enabled"`
ClusterSyncEnabled bool `json:"cluster_sync_enabled"`
RelayGroupEnabled bool `json:"relay_group_enabled"`
NegentropyEnabled bool `json:"negentropy_enabled"`
AdminOwners []string `json:"admin_owners"`
BinDir string `json:"bin_dir"`
}
func (s *AdminServer) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleGetConfig(w, r)
case http.MethodPost:
s.handleSetConfig(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *AdminServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
response := ConfigResponse{
DBBackend: s.cfg.DBBackend,
DBBinary: s.cfg.DBBinary,
RelayBinary: s.cfg.RelayBinary,
ACLBinary: s.cfg.ACLBinary,
DBListen: s.cfg.DBListen,
ACLListen: s.cfg.ACLListen,
ACLEnabled: s.cfg.ACLEnabled,
ACLMode: s.cfg.ACLMode,
DataDir: s.cfg.DataDir,
LogLevel: s.cfg.LogLevel,
DistributedSyncEnabled: s.cfg.DistributedSyncEnabled,
ClusterSyncEnabled: s.cfg.ClusterSyncEnabled,
RelayGroupEnabled: s.cfg.RelayGroupEnabled,
NegentropyEnabled: s.cfg.NegentropyEnabled,
AdminOwners: s.auth.Owners(),
BinDir: s.cfg.BinDir,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (s *AdminServer) handleSetConfig(w http.ResponseWriter, r *http.Request) {
// TODO: Implement config update (requires restart)
http.Error(w, "Config update not implemented yet", http.StatusNotImplemented)
}
// BinariesResponse is the response for GET /api/binaries
type BinariesResponse struct {
CurrentVersion string `json:"current_version"`
AvailableVersions []VersionInfo `json:"available_versions"`
}
func (s *AdminServer) handleBinaries(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := BinariesResponse{
CurrentVersion: s.updater.CurrentVersion(),
AvailableVersions: s.updater.ListVersions(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UpdateRequest is the request body for POST /api/update
type UpdateRequest struct {
Version string `json:"version"`
URLs map[string]string `json:"urls"` // binary name -> download URL
}
// UpdateResponse is the response for POST /api/update
type UpdateResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Version string `json:"version"`
DownloadedFiles []string `json:"downloaded_files"`
}
func (s *AdminServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req UpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Version == "" {
http.Error(w, "Version is required", http.StatusBadRequest)
return
}
if len(req.URLs) == 0 {
http.Error(w, "At least one binary URL is required", http.StatusBadRequest)
return
}
// Perform the update
downloadedFiles, err := s.updater.Update(req.Version, req.URLs)
if chk.E(err) {
response := UpdateResponse{
Success: false,
Message: err.Error(),
Version: req.Version,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(response)
return
}
response := UpdateResponse{
Success: true,
Message: fmt.Sprintf("Successfully updated to version %s", req.Version),
Version: req.Version,
DownloadedFiles: downloadedFiles,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// RestartResponse is the response for POST /api/restart
type RestartResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func (s *AdminServer) handleRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Signal supervisor to restart all processes
go func() {
if err := s.supervisor.RestartAll(); chk.E(err) {
log.E.F("restart failed: %v", err)
}
}()
response := RestartResponse{
Success: true,
Message: "Restart initiated",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// RollbackResponse is the response for POST /api/rollback
type RollbackResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
PreviousVersion string `json:"previous_version"`
CurrentVersion string `json:"current_version"`
}
func (s *AdminServer) handleRollback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
previousVersion := s.updater.CurrentVersion()
if err := s.updater.Rollback(); chk.E(err) {
response := RollbackResponse{
Success: false,
Message: err.Error(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(response)
return
}
response := RollbackResponse{
Success: true,
Message: "Rollback successful - restart required to apply",
PreviousVersion: previousVersion,
CurrentVersion: s.updater.CurrentVersion(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (s *AdminServer) serveUI(w http.ResponseWriter, r *http.Request) {
s.serveAdminUI(w, r)
}

129
cmd/orly-launcher/supervisor.go

@ -826,3 +826,132 @@ func (s *Supervisor) startNegentropy() error { @@ -826,3 +826,132 @@ func (s *Supervisor) startNegentropy() error {
log.I.F("started negentropy service (pid %d)", cmd.Process.Pid)
return nil
}
// GetProcessStatuses returns the status of all managed processes.
func (s *Supervisor) GetProcessStatuses() []ProcessStatus {
s.mu.Lock()
defer s.mu.Unlock()
var statuses []ProcessStatus
// Database process
if s.dbProc != nil {
statuses = append(statuses, s.getProcessStatus(s.dbProc, s.cfg.DBBinary))
}
// ACL process
if s.cfg.ACLEnabled && s.aclProc != nil {
statuses = append(statuses, s.getProcessStatus(s.aclProc, s.cfg.ACLBinary))
}
// Sync services
if s.cfg.DistributedSyncEnabled && s.distributedSyncProc != nil {
statuses = append(statuses, s.getProcessStatus(s.distributedSyncProc, s.cfg.DistributedSyncBinary))
}
if s.cfg.ClusterSyncEnabled && s.clusterSyncProc != nil {
statuses = append(statuses, s.getProcessStatus(s.clusterSyncProc, s.cfg.ClusterSyncBinary))
}
if s.cfg.RelayGroupEnabled && s.relayGroupProc != nil {
statuses = append(statuses, s.getProcessStatus(s.relayGroupProc, s.cfg.RelayGroupBinary))
}
if s.cfg.NegentropyEnabled && s.negentropyProc != nil {
statuses = append(statuses, s.getProcessStatus(s.negentropyProc, s.cfg.NegentropyBinary))
}
// Relay process
if s.relayProc != nil {
statuses = append(statuses, s.getProcessStatus(s.relayProc, s.cfg.RelayBinary))
}
return statuses
}
func (s *Supervisor) getProcessStatus(p *Process, binaryPath string) ProcessStatus {
status := "stopped"
pid := 0
p.mu.Lock()
defer p.mu.Unlock()
if p.cmd != nil && p.cmd.Process != nil {
// Check if process is still running
select {
case <-p.exited:
status = "stopped"
default:
status = "running"
pid = p.cmd.Process.Pid
}
}
return ProcessStatus{
Name: p.name,
Binary: binaryPath,
Version: "", // Will be filled by caller if needed
Status: status,
PID: pid,
Restarts: p.restarts,
}
}
// RestartAll stops all processes and starts them again.
func (s *Supervisor) RestartAll() error {
log.I.F("restarting all processes...")
// Stop in reverse dependency order
s.mu.Lock()
if s.relayProc != nil {
s.mu.Unlock()
s.stopProcess(s.relayProc, 5*time.Second)
s.mu.Lock()
}
s.mu.Unlock()
s.stopSyncServices()
s.mu.Lock()
if s.cfg.ACLEnabled && s.aclProc != nil {
s.mu.Unlock()
s.stopProcess(s.aclProc, 5*time.Second)
s.mu.Lock()
}
if s.dbProc != nil {
s.mu.Unlock()
s.stopProcess(s.dbProc, s.cfg.StopTimeout)
s.mu.Lock()
}
s.mu.Unlock()
// Small delay to ensure ports are released
time.Sleep(500 * time.Millisecond)
// Start again in dependency order
if err := s.startDB(); err != nil {
return fmt.Errorf("failed to restart database: %w", err)
}
if err := s.waitForDBReady(s.cfg.DBReadyTimeout); err != nil {
return fmt.Errorf("database not ready after restart: %w", err)
}
if s.cfg.ACLEnabled {
if err := s.startACL(); err != nil {
return fmt.Errorf("failed to restart ACL: %w", err)
}
if err := s.waitForACLReady(s.cfg.ACLReadyTimeout); err != nil {
return fmt.Errorf("ACL not ready after restart: %w", err)
}
}
if err := s.startSyncServices(); err != nil {
return fmt.Errorf("failed to restart sync services: %w", err)
}
if err := s.startRelay(); err != nil {
return fmt.Errorf("failed to restart relay: %w", err)
}
log.I.F("all processes restarted successfully")
return nil
}

287
cmd/orly-launcher/updater.go

@ -0,0 +1,287 @@ @@ -0,0 +1,287 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// Updater manages versioned binary updates with symlinks.
// Directory structure:
//
// ~/.local/share/orly/bin/
// versions/
// v0.55.10/
// orly
// orly-db-badger
// orly-acl-follows
// orly-launcher
// v0.55.11/
// ...
// current -> versions/v0.55.11 (symlink)
// orly -> current/orly (symlink)
// orly-db-badger -> current/orly-db-badger (symlink)
// ...
type Updater struct {
binDir string // Base directory for binaries
versionsDir string // Directory containing version subdirectories
}
// VersionInfo contains information about an installed version.
type VersionInfo struct {
Version string `json:"version"`
InstalledAt time.Time `json:"installed_at"`
IsCurrent bool `json:"is_current"`
Binaries []string `json:"binaries"`
}
// NewUpdater creates a new Updater.
func NewUpdater(binDir string) *Updater {
return &Updater{
binDir: binDir,
versionsDir: filepath.Join(binDir, "versions"),
}
}
// CurrentVersion returns the currently active version.
func (u *Updater) CurrentVersion() string {
currentLink := filepath.Join(u.binDir, "current")
target, err := os.Readlink(currentLink)
if err != nil {
return "unknown"
}
// Extract version from path like "versions/v0.55.10"
return filepath.Base(target)
}
// ListVersions returns all installed versions.
func (u *Updater) ListVersions() []VersionInfo {
var versions []VersionInfo
entries, err := os.ReadDir(u.versionsDir)
if err != nil {
return versions
}
currentVersion := u.CurrentVersion()
for _, entry := range entries {
if !entry.IsDir() {
continue
}
versionDir := filepath.Join(u.versionsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// List binaries in this version
binaries, _ := u.listBinaries(versionDir)
versions = append(versions, VersionInfo{
Version: entry.Name(),
InstalledAt: info.ModTime(),
IsCurrent: entry.Name() == currentVersion,
Binaries: binaries,
})
}
// Sort by version descending (newest first)
sort.Slice(versions, func(i, j int) bool {
return versions[i].Version > versions[j].Version
})
return versions
}
// listBinaries returns the list of binary files in a directory.
func (u *Updater) listBinaries(dir string) ([]string, error) {
var binaries []string
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
// Check if executable
if info.Mode()&0111 != 0 {
binaries = append(binaries, entry.Name())
}
}
return binaries, nil
}
// Update downloads binaries from URLs and installs them as a new version.
func (u *Updater) Update(version string, urls map[string]string) ([]string, error) {
// Create version directory
versionDir := filepath.Join(u.versionsDir, version)
if err := os.MkdirAll(versionDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create version directory: %w", err)
}
var downloadedFiles []string
// Download each binary
for name, url := range urls {
destPath := filepath.Join(versionDir, name)
log.I.F("downloading %s from %s", name, url)
if err := u.downloadFile(destPath, url); chk.E(err) {
// Clean up on failure
os.RemoveAll(versionDir)
return nil, fmt.Errorf("failed to download %s: %w", name, err)
}
// Make executable
if err := os.Chmod(destPath, 0755); err != nil {
os.RemoveAll(versionDir)
return nil, fmt.Errorf("failed to chmod %s: %w", name, err)
}
downloadedFiles = append(downloadedFiles, name)
}
// Update symlinks
if err := u.activateVersion(version); chk.E(err) {
return downloadedFiles, fmt.Errorf("failed to activate version: %w", err)
}
log.I.F("successfully updated to version %s", version)
return downloadedFiles, nil
}
// downloadFile downloads a file from a URL.
func (u *Updater) downloadFile(destPath, url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// activateVersion updates symlinks to point to the specified version.
func (u *Updater) activateVersion(version string) error {
versionDir := filepath.Join(u.versionsDir, version)
// Verify version directory exists
if _, err := os.Stat(versionDir); os.IsNotExist(err) {
return fmt.Errorf("version %s not found", version)
}
// Update 'current' symlink
currentLink := filepath.Join(u.binDir, "current")
tempLink := currentLink + ".tmp"
// Create new symlink to temp location
relPath, _ := filepath.Rel(u.binDir, versionDir)
if err := os.Symlink(relPath, tempLink); err != nil {
return fmt.Errorf("failed to create temp symlink: %w", err)
}
// Atomic rename
if err := os.Rename(tempLink, currentLink); err != nil {
os.Remove(tempLink)
return fmt.Errorf("failed to update current symlink: %w", err)
}
// Update individual binary symlinks
binaries, err := u.listBinaries(versionDir)
if err != nil {
return fmt.Errorf("failed to list binaries: %w", err)
}
for _, binary := range binaries {
binaryLink := filepath.Join(u.binDir, binary)
tempBinaryLink := binaryLink + ".tmp"
targetPath := filepath.Join("current", binary)
// Create new symlink
if err := os.Symlink(targetPath, tempBinaryLink); err != nil {
log.W.F("failed to create symlink for %s: %v", binary, err)
continue
}
// Atomic rename
if err := os.Rename(tempBinaryLink, binaryLink); err != nil {
os.Remove(tempBinaryLink)
log.W.F("failed to update symlink for %s: %v", binary, err)
}
}
return nil
}
// Rollback reverts to the previous version.
func (u *Updater) Rollback() error {
versions := u.ListVersions()
if len(versions) < 2 {
return fmt.Errorf("no previous version available for rollback")
}
// Find current version index
currentVersion := u.CurrentVersion()
var previousVersion string
for i, v := range versions {
if v.Version == currentVersion && i+1 < len(versions) {
previousVersion = versions[i+1].Version
break
}
}
if previousVersion == "" {
return fmt.Errorf("could not determine previous version")
}
log.I.F("rolling back from %s to %s", currentVersion, previousVersion)
return u.activateVersion(previousVersion)
}
// GetBinaryVersion attempts to get the version from a binary using -v flag.
func (u *Updater) GetBinaryVersion(binaryPath string) string {
cmd := exec.Command(binaryPath, "-v")
output, err := cmd.Output()
if err != nil {
// Try --version
cmd = exec.Command(binaryPath, "--version")
output, err = cmd.Output()
if err != nil {
return "unknown"
}
}
return strings.TrimSpace(string(output))
}
// EnsureDirectories creates the required directory structure.
func (u *Updater) EnsureDirectories() error {
return os.MkdirAll(u.versionsDir, 0755)
}

89
cmd/orly-launcher/web.go

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
package main
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:web/dist all:web/public
var adminFS embed.FS
// getAdminFS returns the embedded filesystem for the admin UI.
func getAdminFS() (http.FileSystem, error) {
// Try dist first (built assets)
distFS, err := fs.Sub(adminFS, "web/dist")
if err == nil {
// Check if dist has content
entries, _ := fs.ReadDir(distFS, ".")
if len(entries) > 0 {
return http.FS(distFS), nil
}
}
// Fall back to public (template)
publicFS, err := fs.Sub(adminFS, "web/public")
if err != nil {
return nil, err
}
return http.FS(publicFS), nil
}
// serveAdminUI serves the embedded admin web UI.
func (s *AdminServer) serveAdminUI(w http.ResponseWriter, r *http.Request) {
fsys, err := getAdminFS()
if err != nil {
http.Error(w, "Admin UI not available", http.StatusInternalServerError)
return
}
// Strip /admin prefix from path
urlPath := r.URL.Path
if strings.HasPrefix(urlPath, "/admin") {
urlPath = strings.TrimPrefix(urlPath, "/admin")
if urlPath == "" {
urlPath = "/"
}
}
// Try to serve the file
filePath := strings.TrimPrefix(urlPath, "/")
if filePath == "" {
filePath = "index.html"
}
// Check if file exists
f, err := fsys.Open(filePath)
if err != nil {
// Serve index.html for SPA routing
filePath = "index.html"
f, err = fsys.Open(filePath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
}
f.Close()
// Set content type
switch path.Ext(filePath) {
case ".html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
case ".json":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".png":
w.Header().Set("Content-Type", "image/png")
}
// Serve the file
r.URL.Path = "/" + filePath
http.FileServer(fsys).ServeHTTP(w, r)
}

272
cmd/orly-launcher/web/bun.lock

@ -0,0 +1,272 @@ @@ -0,0 +1,272 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "orly-launcher-admin",
"dependencies": {
"argon2-browser": "^1.18.0",
"nostr-tools": "^2.1.4",
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"rollup": "^4.9.0",
"rollup-plugin-css-only": "^4.5.2",
"rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.1.6",
"svelte": "^4.2.8",
},
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
"@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@25.0.8", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "glob": "^8.0.3", "is-reference": "1.2.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
"@rollup/plugin-terser": ["@rollup/plugin-terser@0.4.4", "", { "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.56.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.56.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
"@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="],
"@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argon2-browser": ["argon2-browser@1.18.0", "", {}, "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"livereload": ["livereload@0.9.3", "", { "dependencies": { "chokidar": "^3.5.0", "livereload-js": "^3.3.1", "opts": ">= 1.2.0", "ws": "^7.4.3" }, "bin": { "livereload": "bin/livereload.js" } }, "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw=="],
"livereload-js": ["livereload-js@3.4.1", "", {}, "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="],
"minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nostr-tools": ["nostr-tools@2.20.0", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Kq/2lMyeOdGvpDsYH2an8HP4H0aFCqwKythhTzxfgZTVv4L3NOgrJw2SxH8jkWlH8xPhWxGfN6lFtC+EAa2qYQ=="],
"nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"opts": ["opts@2.0.2", "", {}, "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="],
"rollup-plugin-css-only": ["rollup-plugin-css-only@4.5.5", "", { "dependencies": { "@rollup/pluginutils": "5" }, "peerDependencies": { "rollup": "<5" } }, "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ=="],
"rollup-plugin-livereload": ["rollup-plugin-livereload@2.0.5", "", { "dependencies": { "livereload": "^0.9.1" } }, "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA=="],
"rollup-plugin-svelte": ["rollup-plugin-svelte@7.2.3", "", { "dependencies": { "@rollup/pluginutils": "^4.1.0", "resolve.exports": "^2.0.0" }, "peerDependencies": { "rollup": ">=2.0.0", "svelte": ">=3.5.0" } }, "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
"smob": ["smob@1.5.0", "", {}, "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@4.2.20", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q=="],
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
"@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"code-red/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"periscopic/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"periscopic/is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"rollup-plugin-svelte/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"svelte/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"svelte/is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"rollup-plugin-svelte/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
}
}

0
cmd/orly-launcher/web/dist/.gitkeep vendored

7
cmd/orly-launcher/web/dist/bundle.css vendored

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
header.svelte-1bc06ax{background:var(--card-bg);border-bottom:1px solid var(--border-color);padding:0 20px}.header-content.svelte-1bc06ax{max-width:1200px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:60px}h1.svelte-1bc06ax{font-size:1.25rem;font-weight:600;color:var(--text-color)}nav.svelte-1bc06ax{display:flex;gap:4px}.nav-btn.svelte-1bc06ax{padding:8px 16px;background:none;border:none;border-radius:4px;color:var(--muted-color);cursor:pointer;font-size:0.9rem}.nav-btn.svelte-1bc06ax:hover{background:var(--border-color);color:var(--text-color)}.nav-btn.active.svelte-1bc06ax{background:var(--primary);color:white}.user-section.svelte-1bc06ax{display:flex;align-items:center;gap:12px}.pubkey.svelte-1bc06ax{font-family:monospace;font-size:0.85rem;color:var(--muted-color)}.logout-btn.svelte-1bc06ax,.login-header-btn.svelte-1bc06ax{padding:6px 14px;font-size:0.85rem;border-radius:4px;cursor:pointer}.logout-btn.svelte-1bc06ax{background:none;border:1px solid var(--border-color);color:var(--text-color)}.logout-btn.svelte-1bc06ax:hover{background:var(--border-color)}.login-header-btn.svelte-1bc06ax{background:var(--primary);border:none;color:white}.login-header-btn.svelte-1bc06ax:hover{background:var(--primary-hover)}
.modal-overlay.svelte-rhbu32.svelte-rhbu32{position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0, 0, 0, 0.5);display:flex;justify-content:center;align-items:center;z-index:1000}.modal.svelte-rhbu32.svelte-rhbu32{background:var(--card-bg, #fff);border-radius:8px;box-shadow:0 4px 20px rgba(0, 0, 0, 0.3);width:90%;max-width:450px;border:1px solid var(--border-color, #e0e0e0)}.modal-header.svelte-rhbu32.svelte-rhbu32{display:flex;justify-content:space-between;align-items:center;padding:20px;border-bottom:1px solid var(--border-color, #e0e0e0)}.modal-header.svelte-rhbu32 h2.svelte-rhbu32{margin:0;color:var(--text-color, #333);font-size:1.25rem}.close-btn.svelte-rhbu32.svelte-rhbu32{background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--text-color, #333);padding:0;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:50%}.close-btn.svelte-rhbu32.svelte-rhbu32:hover{background-color:var(--border-color, #e0e0e0)}.tab-container.svelte-rhbu32.svelte-rhbu32{padding:20px}.tabs.svelte-rhbu32.svelte-rhbu32{display:flex;border-bottom:1px solid var(--border-color, #e0e0e0);margin-bottom:20px}.tab-btn.svelte-rhbu32.svelte-rhbu32{flex:1;padding:12px 16px;background:none;border:none;cursor:pointer;color:var(--text-color, #333);font-size:1rem;border-bottom:2px solid transparent}.tab-btn.svelte-rhbu32.svelte-rhbu32:hover{background-color:var(--border-color, #e0e0e0)}.tab-btn.active.svelte-rhbu32.svelte-rhbu32{border-bottom-color:var(--primary, #00bcd4);color:var(--primary, #00bcd4)}.tab-content.svelte-rhbu32.svelte-rhbu32{min-height:180px}.extension-login.svelte-rhbu32.svelte-rhbu32,.nsec-login.svelte-rhbu32.svelte-rhbu32{display:flex;flex-direction:column;gap:16px}.extension-login.svelte-rhbu32 p.svelte-rhbu32,.nsec-login.svelte-rhbu32 p.svelte-rhbu32{margin:0;color:var(--muted-color, #666);line-height:1.5}.login-btn.svelte-rhbu32.svelte-rhbu32{padding:12px 24px;background:var(--primary, #00bcd4);color:white;border:none;border-radius:6px;cursor:pointer;font-size:1rem}.login-btn.svelte-rhbu32.svelte-rhbu32:hover:not(:disabled){background:var(--primary-hover, #00acc1)}.login-btn.svelte-rhbu32.svelte-rhbu32:disabled{background:#ccc;cursor:not-allowed}.nsec-input.svelte-rhbu32.svelte-rhbu32{padding:12px;border:1px solid var(--border-color, #e0e0e0);border-radius:6px;font-size:1rem;background:var(--card-bg, #fff);color:var(--text-color, #333)}.nsec-input.svelte-rhbu32.svelte-rhbu32:focus{outline:none;border-color:var(--primary, #00bcd4)}.generate-btn.svelte-rhbu32.svelte-rhbu32{padding:10px 20px;background:var(--success, #4caf50);color:white;border:none;border-radius:6px;cursor:pointer;font-size:0.95rem}.generate-btn.svelte-rhbu32.svelte-rhbu32:hover:not(:disabled){opacity:0.9}.generate-btn.svelte-rhbu32.svelte-rhbu32:disabled{background:#ccc;cursor:not-allowed}.generated-info.svelte-rhbu32.svelte-rhbu32{background:var(--bg-color, #f5f5f5);padding:12px;border-radius:6px;border:1px solid var(--border-color, #e0e0e0)}.generated-info.svelte-rhbu32 label.svelte-rhbu32{display:block;font-size:0.85rem;color:var(--muted-color, #666);margin-bottom:6px}.generated-info.svelte-rhbu32 code.svelte-rhbu32{display:block;word-break:break-all;font-size:0.8rem;color:var(--text-color, #333)}.message.svelte-rhbu32.svelte-rhbu32{padding:10px;border-radius:4px;margin-top:16px;text-align:center}.error-message.svelte-rhbu32.svelte-rhbu32{background:#ffebee;color:#c62828;border:1px solid #ffcdd2}.success-message.svelte-rhbu32.svelte-rhbu32{background:#e8f5e9;color:#2e7d32;border:1px solid #c8e6c9}.dark-theme.svelte-rhbu32 .error-message.svelte-rhbu32{background:#4a2c2a;color:#ffcdd2}.dark-theme.svelte-rhbu32 .success-message.svelte-rhbu32{background:#2e4a2e;color:#a5d6a7}
.process-card.svelte-xh5u5u{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:16px}.process-header.svelte-xh5u5u{display:flex;align-items:center;gap:8px;margin-bottom:12px}.status-indicator.svelte-xh5u5u{font-size:1.2rem}.process-name.svelte-xh5u5u{font-weight:600;font-size:1rem;color:var(--text-color)}.process-details.svelte-xh5u5u{display:flex;flex-direction:column;gap:6px}.detail-row.svelte-xh5u5u{display:flex;justify-content:space-between;font-size:0.85rem}.label.svelte-xh5u5u{color:var(--muted-color)}.value.svelte-xh5u5u{color:var(--text-color);font-family:monospace}.value.binary.svelte-xh5u5u{font-size:0.75rem;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.value.warning.svelte-xh5u5u{color:var(--warning)}
.dashboard.svelte-17dya06.svelte-17dya06{padding:20px 0}.page-header.svelte-17dya06.svelte-17dya06{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header.svelte-17dya06 h2.svelte-17dya06{font-size:1.5rem;color:var(--text-color)}.actions.svelte-17dya06.svelte-17dya06{display:flex;gap:8px}.refresh-btn.svelte-17dya06.svelte-17dya06,.restart-btn.svelte-17dya06.svelte-17dya06{padding:8px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}.refresh-btn.svelte-17dya06.svelte-17dya06{background:var(--card-bg);border:1px solid var(--border-color);color:var(--text-color)}.refresh-btn.svelte-17dya06.svelte-17dya06:hover:not(:disabled){background:var(--border-color)}.restart-btn.svelte-17dya06.svelte-17dya06{background:var(--warning);border:none;color:white}.restart-btn.svelte-17dya06.svelte-17dya06:hover:not(:disabled){opacity:0.9}.restart-btn.svelte-17dya06.svelte-17dya06:disabled,.refresh-btn.svelte-17dya06.svelte-17dya06:disabled{opacity:0.5;cursor:not-allowed}.error-banner.svelte-17dya06.svelte-17dya06{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.status-summary.svelte-17dya06.svelte-17dya06{display:grid;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr));gap:16px;margin-bottom:32px}.summary-card.svelte-17dya06.svelte-17dya06{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:16px;display:flex;flex-direction:column;gap:4px}.summary-card.svelte-17dya06 .label.svelte-17dya06{font-size:0.85rem;color:var(--muted-color)}.summary-card.svelte-17dya06 .value.svelte-17dya06{font-size:1.25rem;font-weight:600;color:var(--text-color)}h3.svelte-17dya06.svelte-17dya06{font-size:1.1rem;color:var(--text-color);margin-bottom:16px}.processes-grid.svelte-17dya06.svelte-17dya06{display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:16px}.loading.svelte-17dya06.svelte-17dya06{text-align:center;color:var(--muted-color);padding:40px}
.config-page.svelte-1kruta9.svelte-1kruta9{padding:20px 0}.page-header.svelte-1kruta9.svelte-1kruta9{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header.svelte-1kruta9 h2.svelte-1kruta9{font-size:1.5rem;color:var(--text-color)}.refresh-btn.svelte-1kruta9.svelte-1kruta9{padding:8px 16px;background:var(--card-bg);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-size:0.9rem}.refresh-btn.svelte-1kruta9.svelte-1kruta9:hover:not(:disabled){background:var(--border-color)}.refresh-btn.svelte-1kruta9.svelte-1kruta9:disabled{opacity:0.5;cursor:not-allowed}.error-banner.svelte-1kruta9.svelte-1kruta9{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.config-sections.svelte-1kruta9.svelte-1kruta9{display:flex;flex-direction:column;gap:24px}.config-section.svelte-1kruta9.svelte-1kruta9{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:20px}.config-section.svelte-1kruta9 h3.svelte-1kruta9{font-size:1.1rem;color:var(--text-color);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-color)}.config-grid.svelte-1kruta9.svelte-1kruta9{display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:16px}.config-item.svelte-1kruta9.svelte-1kruta9{display:flex;flex-direction:column;gap:4px}.config-item.full-width.svelte-1kruta9.svelte-1kruta9{grid-column:1 / -1}.config-item.svelte-1kruta9 .label.svelte-1kruta9{font-size:0.85rem;color:var(--muted-color)}.config-item.svelte-1kruta9 .value.svelte-1kruta9{font-size:0.95rem;color:var(--text-color)}.config-item.svelte-1kruta9 .value.mono.svelte-1kruta9{font-family:monospace;font-size:0.85rem}.config-item.svelte-1kruta9 .value.bool.svelte-1kruta9{font-weight:500}.config-item.svelte-1kruta9 .value.bool.enabled.svelte-1kruta9{color:var(--success)}.owners-list.svelte-1kruta9.svelte-1kruta9{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}.owner.svelte-1kruta9.svelte-1kruta9{font-size:0.75rem;background:var(--bg-color);padding:4px 8px;border-radius:4px;word-break:break-all}.no-owners.svelte-1kruta9.svelte-1kruta9{color:var(--muted-color);font-style:italic}.config-note.svelte-1kruta9.svelte-1kruta9{margin-top:24px;padding:16px;background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px}.config-note.svelte-1kruta9 p.svelte-1kruta9{color:var(--muted-color);font-size:0.9rem;margin:0}.loading.svelte-1kruta9.svelte-1kruta9{text-align:center;color:var(--muted-color);padding:40px}
.update-page.svelte-1ig49gt.svelte-1ig49gt{padding:20px 0}.page-header.svelte-1ig49gt.svelte-1ig49gt{margin-bottom:24px}.page-header.svelte-1ig49gt h2.svelte-1ig49gt{font-size:1.5rem;color:var(--text-color)}.error-banner.svelte-1ig49gt.svelte-1ig49gt{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.success-banner.svelte-1ig49gt.svelte-1ig49gt{background:#e8f5e9;color:#2e7d32;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #c8e6c9}.current-version.svelte-1ig49gt.svelte-1ig49gt,.update-form.svelte-1ig49gt.svelte-1ig49gt,.versions-list.svelte-1ig49gt.svelte-1ig49gt{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:20px;margin-bottom:24px}h3.svelte-1ig49gt.svelte-1ig49gt{font-size:1.1rem;color:var(--text-color);margin-bottom:16px}.version-info.svelte-1ig49gt.svelte-1ig49gt{display:flex;align-items:center;justify-content:space-between}.version.svelte-1ig49gt.svelte-1ig49gt{font-size:1.5rem;font-weight:600;font-family:monospace;color:var(--text-color)}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt{padding:8px 16px;background:var(--warning);border:none;color:white;border-radius:4px;cursor:pointer}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){opacity:0.9}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt:disabled{opacity:0.5;cursor:not-allowed}.form-group.svelte-1ig49gt.svelte-1ig49gt{margin-bottom:20px}.form-group.svelte-1ig49gt>label.svelte-1ig49gt{display:block;font-size:0.9rem;color:var(--text-color);margin-bottom:8px;font-weight:500}.form-group.svelte-1ig49gt input[type="text"].svelte-1ig49gt{width:100%;padding:10px 12px;border:1px solid var(--border-color);border-radius:4px;font-size:0.95rem;background:var(--bg-color);color:var(--text-color)}.form-group.svelte-1ig49gt input.svelte-1ig49gt:focus{outline:none;border-color:var(--primary)}.url-header.svelte-1ig49gt.svelte-1ig49gt{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.url-header.svelte-1ig49gt label.svelte-1ig49gt{font-size:0.9rem;color:var(--text-color);font-weight:500}.helper-btn.svelte-1ig49gt.svelte-1ig49gt{padding:4px 12px;font-size:0.8rem;background:var(--card-bg);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);cursor:pointer}.helper-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){background:var(--border-color)}.url-input.svelte-1ig49gt.svelte-1ig49gt{display:flex;gap:12px;align-items:center;margin-bottom:8px}.binary-name.svelte-1ig49gt.svelte-1ig49gt{width:140px;font-family:monospace;font-size:0.85rem;color:var(--muted-color)}.url-input.svelte-1ig49gt input.svelte-1ig49gt{flex:1;padding:8px 12px;border:1px solid var(--border-color);border-radius:4px;font-size:0.85rem;background:var(--bg-color);color:var(--text-color)}.update-btn.svelte-1ig49gt.svelte-1ig49gt{width:100%;padding:12px;background:var(--primary);border:none;color:white;border-radius:6px;font-size:1rem;cursor:pointer}.update-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){background:var(--primary-hover)}.update-btn.svelte-1ig49gt.svelte-1ig49gt:disabled{opacity:0.5;cursor:not-allowed}table.svelte-1ig49gt.svelte-1ig49gt{width:100%;border-collapse:collapse}th.svelte-1ig49gt.svelte-1ig49gt,td.svelte-1ig49gt.svelte-1ig49gt{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border-color)}th.svelte-1ig49gt.svelte-1ig49gt{font-size:0.85rem;color:var(--muted-color);font-weight:500}td.svelte-1ig49gt.svelte-1ig49gt{font-size:0.9rem;color:var(--text-color)}.version-cell.svelte-1ig49gt.svelte-1ig49gt{font-family:monospace}tr.current.svelte-1ig49gt.svelte-1ig49gt{background:rgba(0, 188, 212, 0.1)}.current-badge.svelte-1ig49gt.svelte-1ig49gt{background:var(--primary);color:white;padding:2px 8px;border-radius:4px;font-size:0.75rem}
*{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;background:var(--bg-color);color:var(--text-color);min-height:100vh}main.svelte-4k9oqz.svelte-4k9oqz{--bg-color:#f5f5f5;--card-bg:#ffffff;--text-color:#333333;--muted-color:#666666;--border-color:#e0e0e0;--primary:#00bcd4;--primary-hover:#00acc1;--success:#4caf50;--error:#f44336;--warning:#ff9800;min-height:100vh;background:var(--bg-color)}main.dark-theme.svelte-4k9oqz.svelte-4k9oqz{--bg-color:#1a1a1a;--card-bg:#2d2d2d;--text-color:#e0e0e0;--muted-color:#999999;--border-color:#444444}.content.svelte-4k9oqz.svelte-4k9oqz{max-width:1200px;margin:0 auto;padding:20px}.login-prompt.svelte-4k9oqz.svelte-4k9oqz{text-align:center;padding:60px 20px}.login-prompt.svelte-4k9oqz h2.svelte-4k9oqz{font-size:2rem;margin-bottom:16px;color:var(--text-color)}.login-prompt.svelte-4k9oqz p.svelte-4k9oqz{color:var(--muted-color);margin-bottom:24px}.login-btn.svelte-4k9oqz.svelte-4k9oqz{padding:12px 32px;font-size:1rem;background:var(--primary);color:white;border:none;border-radius:6px;cursor:pointer;transition:background 0.2s}.login-btn.svelte-4k9oqz.svelte-4k9oqz:hover{background:var(--primary-hover)}

14
cmd/orly-launcher/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

12
cmd/orly-launcher/web/dist/index.html vendored

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ORLY Launcher Admin</title>
<link rel="stylesheet" href="/admin/bundle.css">
</head>
<body>
<script src="/admin/bundle.js"></script>
</body>
</html>

24
cmd/orly-launcher/web/package.json

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
{
"name": "orly-launcher-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "rollup -c -w",
"build": "rollup -c"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"rollup": "^4.9.0",
"rollup-plugin-css-only": "^4.5.2",
"rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.1.6",
"svelte": "^4.2.8"
},
"dependencies": {
"nostr-tools": "^2.1.4",
"argon2-browser": "^1.18.0"
}
}

12
cmd/orly-launcher/web/public/index.html

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ORLY Launcher Admin</title>
<link rel="stylesheet" href="/admin/bundle.css">
</head>
<body>
<script src="/admin/bundle.js"></script>
</body>
</html>

35
cmd/orly-launcher/web/rollup.config.js

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import css from 'rollup-plugin-css-only';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'src/main.js',
output: {
sourcemap: !production,
format: 'iife',
name: 'app',
file: 'dist/bundle.js'
},
plugins: [
svelte({
compilerOptions: {
dev: !production
}
}),
css({ output: 'bundle.css' }),
resolve({
browser: true,
dedupe: ['svelte'],
exportConditions: ['svelte']
}),
commonjs(),
production && terser()
],
watch: {
clearScreen: false
}
};

185
cmd/orly-launcher/web/src/App.svelte

@ -0,0 +1,185 @@ @@ -0,0 +1,185 @@
<script>
import { onMount } from 'svelte';
import Header from './components/Header.svelte';
import LoginModal from './LoginModal.svelte';
import Dashboard from './pages/Dashboard.svelte';
import Config from './pages/Config.svelte';
import Update from './pages/Update.svelte';
import { isLoggedIn, userPubkey, userSigner, authMethod } from './stores.js';
let currentPage = 'dashboard';
let showLoginModal = false;
let isDarkTheme = false;
onMount(() => {
// Check for stored auth
const storedMethod = localStorage.getItem('launcher_auth_method');
const storedPubkey = localStorage.getItem('launcher_pubkey');
if (storedMethod === 'extension' && storedPubkey) {
// Try to restore extension session
if (window.nostr) {
window.nostr.getPublicKey().then(pk => {
if (pk === storedPubkey) {
$isLoggedIn = true;
$userPubkey = pk;
$userSigner = window.nostr;
$authMethod = 'extension';
}
}).catch(() => {
// Extension not available, clear stored auth
localStorage.removeItem('launcher_auth_method');
localStorage.removeItem('launcher_pubkey');
});
}
}
// Check for dark theme preference
isDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
});
function handleLogin(event) {
const { method, pubkey, signer, privateKey } = event.detail;
$isLoggedIn = true;
$userPubkey = pubkey;
$userSigner = signer;
$authMethod = method;
localStorage.setItem('launcher_auth_method', method);
localStorage.setItem('launcher_pubkey', pubkey);
if (method === 'nsec' && privateKey) {
// Store encrypted key (handled by LoginModal)
}
showLoginModal = false;
}
function handleLogout() {
$isLoggedIn = false;
$userPubkey = '';
$userSigner = null;
$authMethod = '';
localStorage.removeItem('launcher_auth_method');
localStorage.removeItem('launcher_pubkey');
localStorage.removeItem('launcher_privkey_encrypted');
}
function navigateTo(page) {
currentPage = page;
}
</script>
<main class:dark-theme={isDarkTheme}>
<Header
{currentPage}
isLoggedIn={$isLoggedIn}
userPubkey={$userPubkey}
on:navigate={(e) => navigateTo(e.detail)}
on:login={() => showLoginModal = true}
on:logout={handleLogout}
/>
<div class="content">
{#if !$isLoggedIn}
<div class="login-prompt">
<h2>ORLY Launcher Admin</h2>
<p>Please login to manage the relay services.</p>
<button class="login-btn" on:click={() => showLoginModal = true}>
Login with Nostr
</button>
</div>
{:else if currentPage === 'dashboard'}
<Dashboard />
{:else if currentPage === 'config'}
<Config />
{:else if currentPage === 'update'}
<Update />
{/if}
</div>
<LoginModal
bind:showModal={showLoginModal}
{isDarkTheme}
on:login={handleLogin}
on:close={() => showLoginModal = false}
/>
</main>
<style>
:global(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(body) {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
}
main {
--bg-color: #f5f5f5;
--card-bg: #ffffff;
--text-color: #333333;
--muted-color: #666666;
--border-color: #e0e0e0;
--primary: #00bcd4;
--primary-hover: #00acc1;
--success: #4caf50;
--error: #f44336;
--warning: #ff9800;
min-height: 100vh;
background: var(--bg-color);
}
main.dark-theme {
--bg-color: #1a1a1a;
--card-bg: #2d2d2d;
--text-color: #e0e0e0;
--muted-color: #999999;
--border-color: #444444;
}
.content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.login-prompt {
text-align: center;
padding: 60px 20px;
}
.login-prompt h2 {
font-size: 2rem;
margin-bottom: 16px;
color: var(--text-color);
}
.login-prompt p {
color: var(--muted-color);
margin-bottom: 24px;
}
.login-btn {
padding: 12px 32px;
font-size: 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.login-btn:hover {
background: var(--primary-hover);
}
</style>

454
cmd/orly-launcher/web/src/LoginModal.svelte

@ -0,0 +1,454 @@ @@ -0,0 +1,454 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { nsecEncode, npubEncode, decode } from 'nostr-tools/nip19';
import { finalizeEvent } from 'nostr-tools/pure';
const dispatch = createEventDispatcher();
export let showModal = false;
export let isDarkTheme = false;
let activeTab = 'extension';
let nsecInput = '';
let isLoading = false;
let errorMessage = '';
let successMessage = '';
let generatedNsec = '';
let generatedNpub = '';
function closeModal() {
showModal = false;
nsecInput = '';
errorMessage = '';
successMessage = '';
generatedNsec = '';
generatedNpub = '';
dispatch('close');
}
function switchTab(tab) {
activeTab = tab;
errorMessage = '';
successMessage = '';
generatedNsec = '';
generatedNpub = '';
}
async function generateNewKey() {
errorMessage = '';
successMessage = '';
try {
const secretKey = generateSecretKey();
const nsec = nsecEncode(secretKey);
const pubkey = getPublicKey(secretKey);
const npub = npubEncode(pubkey);
generatedNsec = nsec;
generatedNpub = npub;
nsecInput = nsec;
successMessage = 'New key generated!';
} catch (error) {
errorMessage = 'Failed to generate key: ' + error.message;
}
}
async function loginWithExtension() {
isLoading = true;
errorMessage = '';
successMessage = '';
try {
if (!window.nostr) {
throw new Error('No Nostr extension found. Please install nos2x or Alby.');
}
const pubkey = await window.nostr.getPublicKey();
if (pubkey) {
successMessage = 'Successfully logged in with extension!';
dispatch('login', {
method: 'extension',
pubkey: pubkey,
signer: window.nostr,
});
setTimeout(closeModal, 500);
}
} catch (error) {
errorMessage = error.message;
} finally {
isLoading = false;
}
}
async function loginWithNsec() {
isLoading = true;
errorMessage = '';
successMessage = '';
try {
if (!nsecInput.trim()) {
throw new Error('Please enter your nsec');
}
const trimmed = nsecInput.trim();
// Decode nsec
let decoded;
try {
decoded = decode(trimmed);
} catch {
throw new Error('Invalid nsec format');
}
if (decoded.type !== 'nsec') {
throw new Error('Please enter an nsec (private key)');
}
const secretKey = decoded.data;
const publicKey = getPublicKey(secretKey);
// Create a signer that uses the secret key
const signer = {
getPublicKey: async () => publicKey,
signEvent: async (event) => {
return finalizeEvent(event, secretKey);
}
};
successMessage = 'Successfully logged in!';
dispatch('login', {
method: 'nsec',
pubkey: publicKey,
privateKey: trimmed,
signer: signer,
});
setTimeout(closeModal, 500);
} catch (error) {
errorMessage = error.message;
} finally {
isLoading = false;
}
}
function handleKeydown(event) {
if (event.key === 'Escape') {
closeModal();
}
if (event.key === 'Enter' && activeTab === 'nsec') {
loginWithNsec();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if showModal}
<div
class="modal-overlay"
on:click={closeModal}
on:keydown={(e) => e.key === 'Escape' && closeModal()}
role="button"
tabindex="0"
>
<div
class="modal"
class:dark-theme={isDarkTheme}
on:click|stopPropagation
on:keydown|stopPropagation
>
<div class="modal-header">
<h2>Login to Launcher Admin</h2>
<button class="close-btn" on:click={closeModal}>&times;</button>
</div>
<div class="tab-container">
<div class="tabs">
<button
class="tab-btn"
class:active={activeTab === 'extension'}
on:click={() => switchTab('extension')}
>
Extension
</button>
<button
class="tab-btn"
class:active={activeTab === 'nsec'}
on:click={() => switchTab('nsec')}
>
Nsec
</button>
</div>
<div class="tab-content">
{#if activeTab === 'extension'}
<div class="extension-login">
<p>Login using a NIP-07 browser extension like nos2x or Alby.</p>
<button
class="login-btn"
on:click={loginWithExtension}
disabled={isLoading}
>
{isLoading ? 'Connecting...' : 'Login with Extension'}
</button>
</div>
{:else}
<div class="nsec-login">
<p>Enter your nsec or generate a new key pair.</p>
<button
class="generate-btn"
on:click={generateNewKey}
disabled={isLoading}
>
Generate New Key
</button>
{#if generatedNpub}
<div class="generated-info">
<label>Your new public key (npub):</label>
<code>{generatedNpub}</code>
</div>
{/if}
<input
type="password"
placeholder="nsec1..."
bind:value={nsecInput}
disabled={isLoading}
class="nsec-input"
/>
<button
class="login-btn"
on:click={loginWithNsec}
disabled={isLoading || !nsecInput.trim()}
>
{isLoading ? 'Logging in...' : 'Login with Nsec'}
</button>
</div>
{/if}
{#if errorMessage}
<div class="message error-message">{errorMessage}</div>
{/if}
{#if successMessage}
<div class="message success-message">{successMessage}</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: var(--card-bg, #fff);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 450px;
border: 1px solid var(--border-color, #e0e0e0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.modal-header h2 {
margin: 0;
color: var(--text-color, #333);
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color, #333);
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close-btn:hover {
background-color: var(--border-color, #e0e0e0);
}
.tab-container {
padding: 20px;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #e0e0e0);
margin-bottom: 20px;
}
.tab-btn {
flex: 1;
padding: 12px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--text-color, #333);
font-size: 1rem;
border-bottom: 2px solid transparent;
}
.tab-btn:hover {
background-color: var(--border-color, #e0e0e0);
}
.tab-btn.active {
border-bottom-color: var(--primary, #00bcd4);
color: var(--primary, #00bcd4);
}
.tab-content {
min-height: 180px;
}
.extension-login,
.nsec-login {
display: flex;
flex-direction: column;
gap: 16px;
}
.extension-login p,
.nsec-login p {
margin: 0;
color: var(--muted-color, #666);
line-height: 1.5;
}
.login-btn {
padding: 12px 24px;
background: var(--primary, #00bcd4);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
}
.login-btn:hover:not(:disabled) {
background: var(--primary-hover, #00acc1);
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.nsec-input {
padding: 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 1rem;
background: var(--card-bg, #fff);
color: var(--text-color, #333);
}
.nsec-input:focus {
outline: none;
border-color: var(--primary, #00bcd4);
}
.generate-btn {
padding: 10px 20px;
background: var(--success, #4caf50);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
}
.generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
.generate-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.generated-info {
background: var(--bg-color, #f5f5f5);
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
}
.generated-info label {
display: block;
font-size: 0.85rem;
color: var(--muted-color, #666);
margin-bottom: 6px;
}
.generated-info code {
display: block;
word-break: break-all;
font-size: 0.8rem;
color: var(--text-color, #333);
}
.message {
padding: 10px;
border-radius: 4px;
margin-top: 16px;
text-align: center;
}
.error-message {
background: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.success-message {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.dark-theme .error-message {
background: #4a2c2a;
color: #ffcdd2;
}
.dark-theme .success-message {
background: #2e4a2e;
color: #a5d6a7;
}
</style>

151
cmd/orly-launcher/web/src/api.js

@ -0,0 +1,151 @@ @@ -0,0 +1,151 @@
/**
* API helper functions for ORLY Launcher admin endpoints
*/
/**
* Get the API base URL (same as current page)
*/
export function getApiBase() {
return window.location.origin;
}
/**
* Create NIP-98 authentication header
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} method - HTTP method
* @param {string} url - Request URL
* @returns {Promise<string|null>} Base64 encoded auth header or null
*/
export async function createNIP98Auth(signer, pubkey, method, url) {
if (!signer || !pubkey) {
return null;
}
try {
// Create unsigned auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method.toUpperCase()],
],
content: "",
};
// Sign using the signer
const signedEvent = await signer.signEvent(authEvent);
// Use URL-safe base64 encoding
const json = JSON.stringify(signedEvent);
const base64 = btoa(json).replace(/\+/g, '-').replace(/\//g, '_');
return base64;
} catch (error) {
console.error("createNIP98Auth error:", error);
return null;
}
}
/**
* Make an authenticated API request
* @param {string} path - API path
* @param {object} options - Fetch options
* @param {object} signer - Signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<Response>}
*/
async function authFetch(path, options = {}, signer, pubkey) {
const url = `${getApiBase()}${path}`;
const method = options.method || 'GET';
const authHeader = await createNIP98Auth(signer, pubkey, method, url);
const headers = {
...options.headers,
};
if (authHeader) {
headers['Authorization'] = `Nostr ${authHeader}`;
}
return fetch(url, { ...options, headers });
}
/**
* Fetch launcher status
*/
export async function fetchStatus(signer, pubkey) {
const response = await authFetch('/api/status', {}, signer, pubkey);
if (!response.ok) {
throw new Error(`Failed to fetch status: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch launcher configuration
*/
export async function fetchConfig(signer, pubkey) {
const response = await authFetch('/api/config', {}, signer, pubkey);
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch available binaries
*/
export async function fetchBinaries(signer, pubkey) {
const response = await authFetch('/api/binaries', {}, signer, pubkey);
if (!response.ok) {
throw new Error(`Failed to fetch binaries: ${response.statusText}`);
}
return response.json();
}
/**
* Update binaries from URLs
*/
export async function updateBinaries(signer, pubkey, version, urls) {
const response = await authFetch('/api/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version, urls }),
}, signer, pubkey);
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || `Update failed: ${response.statusText}`);
}
return response.json();
}
/**
* Restart all services
*/
export async function restartServices(signer, pubkey) {
const response = await authFetch('/api/restart', {
method: 'POST',
}, signer, pubkey);
if (!response.ok) {
throw new Error(`Restart failed: ${response.statusText}`);
}
return response.json();
}
/**
* Rollback to previous version
*/
export async function rollbackVersion(signer, pubkey) {
const response = await authFetch('/api/rollback', {
method: 'POST',
}, signer, pubkey);
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || `Rollback failed: ${response.statusText}`);
}
return response.json();
}

149
cmd/orly-launcher/web/src/components/Header.svelte

@ -0,0 +1,149 @@ @@ -0,0 +1,149 @@
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let currentPage = 'dashboard';
export let isLoggedIn = false;
export let userPubkey = '';
function navigate(page) {
dispatch('navigate', page);
}
function formatPubkey(pk) {
if (!pk) return '';
return pk.slice(0, 8) + '...' + pk.slice(-4);
}
</script>
<header>
<div class="header-content">
<h1>ORLY Launcher</h1>
{#if isLoggedIn}
<nav>
<button
class="nav-btn"
class:active={currentPage === 'dashboard'}
on:click={() => navigate('dashboard')}
>
Dashboard
</button>
<button
class="nav-btn"
class:active={currentPage === 'config'}
on:click={() => navigate('config')}
>
Config
</button>
<button
class="nav-btn"
class:active={currentPage === 'update'}
on:click={() => navigate('update')}
>
Update
</button>
</nav>
<div class="user-section">
<span class="pubkey">{formatPubkey(userPubkey)}</span>
<button class="logout-btn" on:click={() => dispatch('logout')}>
Logout
</button>
</div>
{:else}
<button class="login-header-btn" on:click={() => dispatch('login')}>
Login
</button>
{/if}
</div>
</header>
<style>
header {
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
padding: 0 20px;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
nav {
display: flex;
gap: 4px;
}
.nav-btn {
padding: 8px 16px;
background: none;
border: none;
border-radius: 4px;
color: var(--muted-color);
cursor: pointer;
font-size: 0.9rem;
}
.nav-btn:hover {
background: var(--border-color);
color: var(--text-color);
}
.nav-btn.active {
background: var(--primary);
color: white;
}
.user-section {
display: flex;
align-items: center;
gap: 12px;
}
.pubkey {
font-family: monospace;
font-size: 0.85rem;
color: var(--muted-color);
}
.logout-btn,
.login-header-btn {
padding: 6px 14px;
font-size: 0.85rem;
border-radius: 4px;
cursor: pointer;
}
.logout-btn {
background: none;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.logout-btn:hover {
background: var(--border-color);
}
.login-header-btn {
background: var(--primary);
border: none;
color: white;
}
.login-header-btn:hover {
background: var(--primary-hover);
}
</style>

117
cmd/orly-launcher/web/src/components/ProcessCard.svelte

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
<script>
export let process;
function getStatusColor(status) {
switch (status) {
case 'running': return 'var(--success)';
case 'stopped': return 'var(--muted-color)';
case 'crashed': return 'var(--error)';
default: return 'var(--muted-color)';
}
}
function getStatusIcon(status) {
switch (status) {
case 'running': return '●';
case 'stopped': return '○';
case 'crashed': return '✗';
default: return '?';
}
}
</script>
<div class="process-card">
<div class="process-header">
<span class="status-indicator" style="color: {getStatusColor(process.status)}">
{getStatusIcon(process.status)}
</span>
<span class="process-name">{process.name}</span>
</div>
<div class="process-details">
<div class="detail-row">
<span class="label">Status:</span>
<span class="value" style="color: {getStatusColor(process.status)}">
{process.status}
</span>
</div>
{#if process.pid > 0}
<div class="detail-row">
<span class="label">PID:</span>
<span class="value">{process.pid}</span>
</div>
{/if}
<div class="detail-row">
<span class="label">Binary:</span>
<span class="value binary">{process.binary}</span>
</div>
{#if process.restarts > 0}
<div class="detail-row">
<span class="label">Restarts:</span>
<span class="value warning">{process.restarts}</span>
</div>
{/if}
</div>
</div>
<style>
.process-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
.process-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.status-indicator {
font-size: 1.2rem;
}
.process-name {
font-weight: 600;
font-size: 1rem;
color: var(--text-color);
}
.process-details {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.label {
color: var(--muted-color);
}
.value {
color: var(--text-color);
font-family: monospace;
}
.value.binary {
font-size: 0.75rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value.warning {
color: var(--warning);
}
</style>

7
cmd/orly-launcher/web/src/main.js

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.body,
});
export default app;

300
cmd/orly-launcher/web/src/pages/Config.svelte

@ -0,0 +1,300 @@ @@ -0,0 +1,300 @@
<script>
import { onMount } from 'svelte';
import { userSigner, userPubkey, configData, isLoading, error } from '../stores.js';
import { fetchConfig } from '../api.js';
onMount(async () => {
await loadConfig();
});
async function loadConfig() {
$isLoading = true;
try {
$configData = await fetchConfig($userSigner, $userPubkey);
$error = '';
} catch (e) {
$error = e.message;
} finally {
$isLoading = false;
}
}
</script>
<div class="config-page">
<div class="page-header">
<h2>Configuration</h2>
<button class="refresh-btn" on:click={loadConfig} disabled={$isLoading}>
Refresh
</button>
</div>
{#if $error}
<div class="error-banner">{$error}</div>
{/if}
{#if $configData}
<div class="config-sections">
<section class="config-section">
<h3>Database</h3>
<div class="config-grid">
<div class="config-item">
<span class="label">Backend</span>
<span class="value">{$configData.db_backend}</span>
</div>
<div class="config-item">
<span class="label">Binary</span>
<span class="value mono">{$configData.db_binary}</span>
</div>
<div class="config-item">
<span class="label">Listen Address</span>
<span class="value mono">{$configData.db_listen}</span>
</div>
<div class="config-item">
<span class="label">Data Directory</span>
<span class="value mono">{$configData.data_dir}</span>
</div>
</div>
</section>
<section class="config-section">
<h3>ACL</h3>
<div class="config-grid">
<div class="config-item">
<span class="label">Enabled</span>
<span class="value bool" class:enabled={$configData.acl_enabled}>
{$configData.acl_enabled ? 'Yes' : 'No'}
</span>
</div>
<div class="config-item">
<span class="label">Mode</span>
<span class="value">{$configData.acl_mode}</span>
</div>
<div class="config-item">
<span class="label">Binary</span>
<span class="value mono">{$configData.acl_binary}</span>
</div>
<div class="config-item">
<span class="label">Listen Address</span>
<span class="value mono">{$configData.acl_listen}</span>
</div>
</div>
</section>
<section class="config-section">
<h3>Relay</h3>
<div class="config-grid">
<div class="config-item">
<span class="label">Binary</span>
<span class="value mono">{$configData.relay_binary}</span>
</div>
<div class="config-item">
<span class="label">Log Level</span>
<span class="value">{$configData.log_level}</span>
</div>
</div>
</section>
<section class="config-section">
<h3>Sync Services</h3>
<div class="config-grid">
<div class="config-item">
<span class="label">Distributed Sync</span>
<span class="value bool" class:enabled={$configData.distributed_sync_enabled}>
{$configData.distributed_sync_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="config-item">
<span class="label">Cluster Sync</span>
<span class="value bool" class:enabled={$configData.cluster_sync_enabled}>
{$configData.cluster_sync_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="config-item">
<span class="label">Relay Group</span>
<span class="value bool" class:enabled={$configData.relay_group_enabled}>
{$configData.relay_group_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="config-item">
<span class="label">Negentropy</span>
<span class="value bool" class:enabled={$configData.negentropy_enabled}>
{$configData.negentropy_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
</section>
<section class="config-section">
<h3>Admin</h3>
<div class="config-grid">
<div class="config-item">
<span class="label">Binary Directory</span>
<span class="value mono">{$configData.bin_dir}</span>
</div>
<div class="config-item full-width">
<span class="label">Admin Owners</span>
<div class="owners-list">
{#each $configData.admin_owners || [] as owner}
<code class="owner">{owner}</code>
{:else}
<span class="no-owners">No owners configured</span>
{/each}
</div>
</div>
</div>
</section>
</div>
<div class="config-note">
<p>Configuration is loaded from environment variables. To change settings, update the environment and restart the launcher.</p>
</div>
{:else if !$error}
<div class="loading">Loading configuration...</div>
{/if}
</div>
<style>
.config-page {
padding: 20px 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
font-size: 1.5rem;
color: var(--text-color);
}
.refresh-btn {
padding: 8px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.refresh-btn:hover:not(:disabled) {
background: var(--border-color);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-banner {
background: #ffebee;
color: #c62828;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #ffcdd2;
}
.config-sections {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-section {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
}
.config-section h3 {
font-size: 1.1rem;
color: var(--text-color);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.config-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.config-item.full-width {
grid-column: 1 / -1;
}
.config-item .label {
font-size: 0.85rem;
color: var(--muted-color);
}
.config-item .value {
font-size: 0.95rem;
color: var(--text-color);
}
.config-item .value.mono {
font-family: monospace;
font-size: 0.85rem;
}
.config-item .value.bool {
font-weight: 500;
}
.config-item .value.bool.enabled {
color: var(--success);
}
.owners-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.owner {
font-size: 0.75rem;
background: var(--bg-color);
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.no-owners {
color: var(--muted-color);
font-style: italic;
}
.config-note {
margin-top: 24px;
padding: 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.config-note p {
color: var(--muted-color);
font-size: 0.9rem;
margin: 0;
}
.loading {
text-align: center;
color: var(--muted-color);
padding: 40px;
}
</style>

202
cmd/orly-launcher/web/src/pages/Dashboard.svelte

@ -0,0 +1,202 @@ @@ -0,0 +1,202 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { userSigner, userPubkey, statusData, isLoading, error } from '../stores.js';
import { fetchStatus, restartServices } from '../api.js';
import ProcessCard from '../components/ProcessCard.svelte';
let refreshInterval;
onMount(async () => {
await loadStatus();
// Auto-refresh every 5 seconds
refreshInterval = setInterval(loadStatus, 5000);
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
async function loadStatus() {
try {
$statusData = await fetchStatus($userSigner, $userPubkey);
$error = '';
} catch (e) {
$error = e.message;
}
}
async function handleRestart() {
if (!confirm('Are you sure you want to restart all services?')) {
return;
}
$isLoading = true;
try {
await restartServices($userSigner, $userPubkey);
// Wait a moment then refresh
setTimeout(loadStatus, 2000);
} catch (e) {
$error = e.message;
} finally {
$isLoading = false;
}
}
</script>
<div class="dashboard">
<div class="page-header">
<h2>Dashboard</h2>
<div class="actions">
<button class="refresh-btn" on:click={loadStatus} disabled={$isLoading}>
Refresh
</button>
<button class="restart-btn" on:click={handleRestart} disabled={$isLoading}>
Restart All
</button>
</div>
</div>
{#if $error}
<div class="error-banner">{$error}</div>
{/if}
{#if $statusData}
<div class="status-summary">
<div class="summary-card">
<span class="label">Version</span>
<span class="value">{$statusData.version}</span>
</div>
<div class="summary-card">
<span class="label">Uptime</span>
<span class="value">{$statusData.uptime}</span>
</div>
<div class="summary-card">
<span class="label">Processes</span>
<span class="value">{$statusData.processes?.length || 0}</span>
</div>
</div>
<h3>Managed Processes</h3>
<div class="processes-grid">
{#each $statusData.processes || [] as process}
<ProcessCard {process} />
{/each}
</div>
{:else if !$error}
<div class="loading">Loading status...</div>
{/if}
</div>
<style>
.dashboard {
padding: 20px 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
font-size: 1.5rem;
color: var(--text-color);
}
.actions {
display: flex;
gap: 8px;
}
.refresh-btn,
.restart-btn {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.refresh-btn {
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
}
.refresh-btn:hover:not(:disabled) {
background: var(--border-color);
}
.restart-btn {
background: var(--warning);
border: none;
color: white;
}
.restart-btn:hover:not(:disabled) {
opacity: 0.9;
}
.restart-btn:disabled,
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-banner {
background: #ffebee;
color: #c62828;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #ffcdd2;
}
.status-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.summary-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-card .label {
font-size: 0.85rem;
color: var(--muted-color);
}
.summary-card .value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
h3 {
font-size: 1.1rem;
color: var(--text-color);
margin-bottom: 16px;
}
.processes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.loading {
text-align: center;
color: var(--muted-color);
padding: 40px;
}
</style>

430
cmd/orly-launcher/web/src/pages/Update.svelte

@ -0,0 +1,430 @@ @@ -0,0 +1,430 @@
<script>
import { onMount } from 'svelte';
import { userSigner, userPubkey, binariesData, isLoading, error } from '../stores.js';
import { fetchBinaries, updateBinaries, rollbackVersion } from '../api.js';
let version = '';
let urls = {
'orly': '',
'orly-db-badger': '',
'orly-acl-follows': '',
'orly-launcher': '',
};
let updateResult = null;
let isUpdating = false;
onMount(async () => {
await loadBinaries();
});
async function loadBinaries() {
$isLoading = true;
try {
$binariesData = await fetchBinaries($userSigner, $userPubkey);
$error = '';
} catch (e) {
$error = e.message;
} finally {
$isLoading = false;
}
}
async function handleUpdate() {
// Filter out empty URLs
const filteredUrls = {};
for (const [name, url] of Object.entries(urls)) {
if (url.trim()) {
filteredUrls[name] = url.trim();
}
}
if (!version.trim()) {
$error = 'Version is required';
return;
}
if (Object.keys(filteredUrls).length === 0) {
$error = 'At least one binary URL is required';
return;
}
isUpdating = true;
updateResult = null;
$error = '';
try {
updateResult = await updateBinaries($userSigner, $userPubkey, version.trim(), filteredUrls);
await loadBinaries();
} catch (e) {
$error = e.message;
} finally {
isUpdating = false;
}
}
async function handleRollback() {
if (!confirm('Are you sure you want to rollback to the previous version?')) {
return;
}
isUpdating = true;
$error = '';
try {
const result = await rollbackVersion($userSigner, $userPubkey);
updateResult = {
success: true,
message: `Rolled back from ${result.previous_version} to ${result.current_version}. Restart services to apply.`
};
await loadBinaries();
} catch (e) {
$error = e.message;
} finally {
isUpdating = false;
}
}
function setReleaseUrls() {
// Helper to fill in URLs from a release base
const baseUrl = prompt('Enter release base URL (e.g., https://git.mleku.dev/mleku/next.orly.dev/releases/download/v0.55.11):');
if (!baseUrl) return;
const cleanBase = baseUrl.replace(/\/$/, '');
const arch = prompt('Enter architecture (amd64 or arm64):', 'amd64');
if (!arch) return;
const ver = version.trim() || baseUrl.split('/').pop();
urls['orly'] = `${cleanBase}/orly-${ver.replace('v', '')}-linux-${arch}`;
urls['orly-db-badger'] = `${cleanBase}/orly-db-badger-${ver.replace('v', '')}-linux-${arch}`;
urls['orly-acl-follows'] = `${cleanBase}/orly-acl-follows-${ver.replace('v', '')}-linux-${arch}`;
urls['orly-launcher'] = `${cleanBase}/orly-launcher-${ver.replace('v', '')}-linux-${arch}`;
if (!version.trim()) {
version = ver;
}
}
</script>
<div class="update-page">
<div class="page-header">
<h2>Update Binaries</h2>
</div>
{#if $error}
<div class="error-banner">{$error}</div>
{/if}
{#if updateResult?.success}
<div class="success-banner">
{updateResult.message}
{#if updateResult.downloaded_files?.length}
<br>Downloaded: {updateResult.downloaded_files.join(', ')}
{/if}
</div>
{/if}
<div class="current-version">
<h3>Current Version</h3>
<div class="version-info">
<span class="version">{$binariesData?.current_version || 'unknown'}</span>
<button
class="rollback-btn"
on:click={handleRollback}
disabled={isUpdating || ($binariesData?.available_versions?.length || 0) < 2}
>
Rollback
</button>
</div>
</div>
<div class="update-form">
<h3>Install New Version</h3>
<div class="form-group">
<label for="version">Version</label>
<input
type="text"
id="version"
bind:value={version}
placeholder="v0.55.11"
disabled={isUpdating}
/>
</div>
<div class="form-group">
<div class="url-header">
<label>Binary URLs</label>
<button class="helper-btn" on:click={setReleaseUrls} disabled={isUpdating}>
Fill from Release
</button>
</div>
{#each Object.keys(urls) as name}
<div class="url-input">
<span class="binary-name">{name}</span>
<input
type="text"
bind:value={urls[name]}
placeholder="https://..."
disabled={isUpdating}
/>
</div>
{/each}
</div>
<button
class="update-btn"
on:click={handleUpdate}
disabled={isUpdating}
>
{isUpdating ? 'Updating...' : 'Download & Install'}
</button>
</div>
{#if $binariesData?.available_versions?.length}
<div class="versions-list">
<h3>Installed Versions</h3>
<table>
<thead>
<tr>
<th>Version</th>
<th>Installed</th>
<th>Binaries</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each $binariesData.available_versions as ver}
<tr class:current={ver.is_current}>
<td class="version-cell">{ver.version}</td>
<td>{new Date(ver.installed_at).toLocaleString()}</td>
<td>{ver.binaries?.length || 0} files</td>
<td>
{#if ver.is_current}
<span class="current-badge">Current</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.update-page {
padding: 20px 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 1.5rem;
color: var(--text-color);
}
.error-banner {
background: #ffebee;
color: #c62828;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #ffcdd2;
}
.success-banner {
background: #e8f5e9;
color: #2e7d32;
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #c8e6c9;
}
.current-version,
.update-form,
.versions-list {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
h3 {
font-size: 1.1rem;
color: var(--text-color);
margin-bottom: 16px;
}
.version-info {
display: flex;
align-items: center;
justify-content: space-between;
}
.version {
font-size: 1.5rem;
font-weight: 600;
font-family: monospace;
color: var(--text-color);
}
.rollback-btn {
padding: 8px 16px;
background: var(--warning);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
.rollback-btn:hover:not(:disabled) {
opacity: 0.9;
}
.rollback-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-group {
margin-bottom: 20px;
}
.form-group > label {
display: block;
font-size: 0.9rem;
color: var(--text-color);
margin-bottom: 8px;
font-weight: 500;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.95rem;
background: var(--bg-color);
color: var(--text-color);
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
}
.url-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.url-header label {
font-size: 0.9rem;
color: var(--text-color);
font-weight: 500;
}
.helper-btn {
padding: 4px 12px;
font-size: 0.8rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
cursor: pointer;
}
.helper-btn:hover:not(:disabled) {
background: var(--border-color);
}
.url-input {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.binary-name {
width: 140px;
font-family: monospace;
font-size: 0.85rem;
color: var(--muted-color);
}
.url-input input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.85rem;
background: var(--bg-color);
color: var(--text-color);
}
.update-btn {
width: 100%;
padding: 12px;
background: var(--primary);
border: none;
color: white;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.update-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.update-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-size: 0.85rem;
color: var(--muted-color);
font-weight: 500;
}
td {
font-size: 0.9rem;
color: var(--text-color);
}
.version-cell {
font-family: monospace;
}
tr.current {
background: rgba(0, 188, 212, 0.1);
}
.current-badge {
background: var(--primary);
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
}
</style>

16
cmd/orly-launcher/web/src/stores.js

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
// Authentication state
export const isLoggedIn = writable(false);
export const userPubkey = writable('');
export const userSigner = writable(null);
export const authMethod = writable(''); // 'extension' or 'nsec'
// Status data
export const statusData = writable(null);
export const configData = writable(null);
export const binariesData = writable(null);
// Loading states
export const isLoading = writable(false);
export const error = writable('');

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.55.10
v0.55.11

Loading…
Cancel
Save