diff --git a/.claude/plans/sequential-jingling-moth.md b/.claude/plans/sequential-jingling-moth.md new file mode 100644 index 00000000..571f03ca --- /dev/null +++ b/.claude/plans/sequential-jingling-moth.md @@ -0,0 +1,244 @@ +# Manage — Claude Code Configuration Dashboard + +## Context + +You have 77 skills, 15 hooks, 17 memory files, 19 plans, and settings across 5 projects + global scope. All stored as flat files (markdown with YAML frontmatter, JSON, bash scripts) under `~/.claude/` and `{project}/.claude/`. Currently the only way to manage these is manually editing files. This project creates a visual web dashboard for browsing, creating, editing, and organizing all of it. + +**Project location**: `/Users/dorian/Projects/Manage` +**Stack**: Vue 3 + Vite + TypeScript + Tailwind + Pinia (frontend) + Express + tsx (backend) +**Design**: Glassmorphism dark theme (matching Archipelago aesthetic) + +--- + +## Architecture + +``` +Browser (localhost:5173) Express Server (localhost:3141) ++-----------------------+ +----------------------------+ +| Vue 3 SPA | fetch | /api/projects | +| +-- Dashboard | ------> | /api/skills (CRUD) | +| +-- Skills | | /api/hooks (CRUD) | +| +-- Hooks | SSE | /api/memory (CRUD) | +| +-- Memory | <------ | /api/plans (CRUD) | +| +-- Plans | | /api/settings (R/W) | +| +-- Settings | | /api/claude-md (R/W) | +| +-- CLAUDE.md | | /api/search | ++-----------------------+ | /api/events (SSE) | + +-------------+--------------+ + | chokidar + +-------------v--------------+ + | ~/.claude/ | + | ~/Projects/*/.claude/ | + +----------------------------+ +``` + +Single command start: `npm start` runs both server + Vite via concurrently. + +--- + +## Phase 1: Foundation — Project Setup + Dashboard + +### 1.1 Scaffold project +- `npm create vite@latest` with Vue + TypeScript +- Install deps: `express`, `cors`, `gray-matter`, `chokidar`, `concurrently`, `tsx`, `@vueuse/core`, `vue-router`, `pinia`, `fuse.js` +- Configure `vite.config.ts` with `@` alias and `/api` proxy to `:3141` +- Configure Tailwind with glassmorphism tokens from archy + +### 1.2 Design system (`src/style.css`) +- Port glassmorphism classes from `neode-ui/src/style.css`: `.glass-card`, `.glass-button`, `.path-option-card`, `.info-card`, `.scope-badge` +- New classes: `.skill-card`, `.hook-node`, `.memory-tree-item`, `.plan-progress-bar`, `.editor-panel` +- Background: `#0a0a0a`, accent: `#fb923c` + +### 1.3 Backend: Project discovery +- **`server/index.ts`** — Express on :3141 with CORS + JSON body parser +- **`server/lib/discovery.ts`** — Scan `~/Projects/` for dirs with `.claude/`, decode `~/.claude/projects/` encoded paths, count skills/hooks/memory/plans per project +- **`GET /api/projects`** — Return project list with counts + +### 1.4 Frontend: App shell + Dashboard +- **`AppShell.vue`** — Sidebar (project switcher + nav links) + router-view content area +- **`Sidebar.vue`** — "Global" at top, then project list; active project highlighted; click to switch scope +- **`Dashboard.vue`** — Stats row (total skills/hooks/memory/plans) + project cards grid +- **`ProjectCard.vue`** — Glass card showing project name, path, skill/hook/memory counts, click to select +- **`stores/projects.ts`** — Pinia store: `projects[]`, `activeProject`, `fetchProjects()`, `setActiveProject()` + +**Verify**: `npm start` opens browser, sidebar shows 5 projects + global, dashboard shows stats. + +--- + +## Phase 2: Skills Manager + +### 2.1 Backend +- **`server/lib/skill-parser.ts`** — Parse SKILL.md YAML frontmatter via `gray-matter`, handle both `skills/{name}/SKILL.md` (dir-based) and `skills/{name}.md` (flat) formats +- **`server/lib/fs-utils.ts`** — Safe read/write/delete/mkdir helpers with atomic writes +- **`server/routes/skills.ts`** — Full CRUD + `POST /api/skills/move` for scope transfers + +### 2.2 Frontend +- **`Skills.vue`** — Top bar: scope filter, grid/list toggle, category dropdown, search. Grid of SkillCards. FAB for "New Skill" +- **`SkillCard.vue`** — Name, description (truncated), scope badge, category color stripe, allowed-tools pills. Click opens editor. +- **`SkillEditor.vue`** — Slide-in panel: frontmatter form (name, description, category, tags, allowed-tools, disable-model-invocation toggle) + Monaco editor for markdown body + live preview +- **`InheritanceMap.vue`** — Two-column view: global skills left, project skills right, connecting lines for name-matched overrides +- **Drag-and-drop**: Drag SkillCard between global/project columns to move/copy. Uses `vue-draggable-plus`. + +**Verify**: Browse all 77 skills, create/edit/delete, drag between scopes, see inheritance. + +--- + +## Phase 3: Hooks Manager + +### 3.1 Backend +- **`server/lib/hook-parser.ts`** — Parse `settings.json` hook entries + read referenced `.sh` files. Detect orphaned scripts. +- **`server/routes/hooks.ts`** — CRUD + `PUT /toggle` for enable/disable. Creates .sh + updates settings.json atomically. + +### 3.2 Frontend +- **`Hooks.vue`** — Grouped by event type (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionEnd) +- **`HookPipeline.vue`** — Visual flow per hook: `[Event Badge] -> [Matcher Pill] -> [Script Name] -> [Action]` with CSS-drawn connecting arrows +- **`HookCard.vue`** — Event type badge (color-coded), matcher, script filename, enabled/disabled toggle switch +- **`HookEditor.vue`** — Monaco editor for `.sh` script + form for event type and matcher pattern +- Orphaned scripts in "Unlinked Scripts" section with "Link" button + +**Verify**: See all 15 hooks in pipeline view, toggle enable/disable, edit scripts, create new hook. + +--- + +## Phase 4: Memory Browser + +### 4.1 Backend +- **`server/lib/memory-parser.ts`** — Parse from both locations: `{project}/.claude/memory/` (git-tracked) and `~/.claude/projects/{encoded}/memory/` (private). Parse YAML frontmatter. +- **`server/routes/memory.ts`** — CRUD + auto-sync MEMORY.md index on create/delete + +### 4.2 Frontend +- **`Memory.vue`** — Split layout: tree panel (left 300px) + content panel (right) +- **`MemoryTree.vue`** — Collapsible tree: Project -> Scope -> Type -> Files. Type badges: user (blue), feedback (orange), project (green), reference (purple) +- **`MemoryEditor.vue`** — Frontmatter form (name, description, type dropdown) + Monaco editor + markdown preview toggle +- Search input at top filters across titles and content + +**Verify**: Browse all 17 memory files in tree, types color-coded, edit with preview, create new, MEMORY.md auto-updates. + +--- + +## Phase 5: Plans Tracker + +### 5.1 Backend +- **`server/lib/plan-parser.ts`** — Extract title from `#`, phases from `##`, tasks from `- [ ]`/`- [x]` with line numbers. Calculate completion percentages. +- **`server/routes/plans.ts`** — CRUD + `PUT /task` for toggling single checkbox by line number + +### 5.2 Frontend +- **`PlanCard.vue`** — Title, overall progress bar, phase count, "12/47 tasks" text +- **`PlanDetail.vue`** — Expanded: title, summary, phases as sections with TaskCheckboxes +- **`PhaseBar.vue`** — Segmented bar: green (done) / amber (in-progress) / gray (pending) +- **`TaskCheckbox.vue`** — Click toggles checkbox, instant API call to update file +- "Edit Raw" switches to Monaco. "New Plan" uses overnight template. + +**Verify**: See all 19 plans with progress bars, toggle checkboxes that persist, create new plan. + +--- + +## Phase 6: Settings + CLAUDE.md Editor + +### 6.1 Settings +- **`Settings.vue`** — Scope tabs (Global / Project). Sections: + - Permissions: toggle switches for allowed tools + - Hooks: visual tree of event -> matcher -> command with add/remove + - Plugins: installed plugin cards with enable/disable + - Effort Level: dropdown + - Raw JSON: toggle to edit settings.json directly in Monaco + +### 6.2 CLAUDE.md +- **`ClaudeMd.vue`** — Scope tabs. Monaco editor with markdown syntax. Live preview panel. Unsaved changes indicator. Save button. + +**Verify**: Edit settings, toggle permissions, edit CLAUDE.md with preview, confirm files updated. + +--- + +## Phase 7: Polish — File Watching, Search, Animations + +### 7.1 Live file watching +- **`server/lib/file-watcher.ts`** — chokidar watches all `.claude/` dirs. Debounce 300ms. Push SSE events. +- **`useFileWatcher.ts`** composable — EventSource connection, triggers store refresh on changes + +### 7.2 Global search +- **`GET /api/search?q=bitcoin`** — Full-text across skills, memory, plans, CLAUDE.md +- **`TopBar.vue`** — Cmd+K search input with dropdown results + +### 7.3 Drag-and-drop refinement +- `vue-draggable-plus` for skills between scopes and plan task reordering + +### 7.4 Final polish +- Loading skeletons, empty states, confirm dialogs on deletes +- Keyboard shortcuts: Cmd+K (search), Cmd+S (save), Escape (close panels) +- View transitions (fade + slide) + +**Verify**: External file edits trigger UI refresh. Cmd+K searches everything. Drag skills between scopes. + +--- + +## Project Structure + +``` +Manage/ ++-- package.json ++-- tsconfig.json ++-- vite.config.ts ++-- tailwind.config.ts ++-- index.html ++-- .gitignore ++-- server/ +| +-- index.ts +| +-- tsconfig.json +| +-- routes/ +| | +-- projects.ts, skills.ts, hooks.ts, memory.ts +| | +-- plans.ts, settings.ts, claude-md.ts, search.ts +| +-- lib/ +| | +-- discovery.ts, skill-parser.ts, hook-parser.ts +| | +-- memory-parser.ts, plan-parser.ts, settings-parser.ts +| | +-- file-watcher.ts, fs-utils.ts +| +-- types/ +| +-- index.ts ++-- src/ +| +-- main.ts, App.vue, style.css +| +-- api/client.ts +| +-- router/index.ts +| +-- stores/ (projects, skills, hooks, memory, plans, settings, search) +| +-- types/ (skill, hook, memory, plan, project, settings) +| +-- composables/ (useFileWatcher, useMarkdownPreview, useMonaco) +| +-- views/ (Dashboard, Skills, Hooks, Memory, Plans, Settings, ClaudeMd) +| +-- components/ +| +-- layout/ (AppShell, Sidebar, TopBar) +| +-- shared/ (GlassCard, GlassButton, ScopeBadge, MonacoEditor, etc.) +| +-- dashboard/ (ProjectCard, QuickStats) +| +-- skills/ (SkillCard, SkillEditor, SkillList, InheritanceMap) +| +-- hooks/ (HookPipeline, HookCard, HookEditor) +| +-- memory/ (MemoryTree, MemoryCard, MemoryEditor) +| +-- plans/ (PlanCard, PlanDetail, PhaseBar, TaskCheckbox) +| +-- settings/ (PermissionToggle, HookConfig, PluginCard) ++-- public/ + +-- favicon.svg +``` + +--- + +## Key Libraries + +| Library | Purpose | +|---------|---------| +| `express` + `cors` | Backend HTTP server | +| `tsx` | Run TypeScript server without build step | +| `concurrently` | Run server + Vite in one command | +| `gray-matter` | Parse YAML frontmatter from markdown | +| `chokidar` | Watch filesystem for live updates | +| `monaco-editor` + `@monaco-editor/loader` | Code editor (md, bash, json, yaml) | +| `marked` + `highlight.js` | Markdown rendering with syntax highlighting | +| `vue-draggable-plus` | Drag-and-drop for skills and plan tasks | +| `fuse.js` | Client-side fuzzy search | +| `@vueuse/core` | Vue utilities (useEventSource, useDebounceFn) | + +--- + +## Key Decisions + +- **Express over Bun**: More predictable on macOS, better middleware ecosystem +- **SSE over WebSocket**: File watching is server->client only. SSE auto-reconnects, simpler. +- **Monaco over CodeMirror**: VS Code-like editing for all 4 file types +- **Atomic settings.json writes**: Read-modify-write with temp file + rename +- **MEMORY.md auto-sync**: Create/delete memory files auto-updates the index +- **Both skill formats**: Parser handles dir-based and flat-file skills diff --git a/apps/indeedhub/Dockerfile b/apps/indeedhub/Dockerfile new file mode 100644 index 00000000..266914d4 --- /dev/null +++ b/apps/indeedhub/Dockerfile @@ -0,0 +1,56 @@ +# Multi-stage Dockerfile for Indeehub Frontend (Next.js) +# Build: podman build -t localhost/indeedhub:latest -f apps/indeedhub/Dockerfile /path/to/indeehub-frontend +# Run: podman run -d --name indeedhub -p 8190:3000 localhost/indeedhub:latest + +# ── Stage 1: Dependencies ── +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# ── Stage 2: Build ── +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Inject standalone output mode for containerized deployment +RUN sed -i 's/reactStrictMode: true,/reactStrictMode: true, output: "standalone",/' next.config.js + +# Build-time environment — connects to Indeehub production services +ENV NEXT_PUBLIC_APP_ENVIRONMENT=production +ENV NEXT_PUBLIC_APP_URL=http://localhost:8190 +ENV NEXT_PUBLIC_API_URL=https://api.indeehub.studio +ENV NEXT_PUBLIC_S3_PRIVATE_BUCKET=indeehub-private +ENV NEXT_PUBLIC_S3_PUBLIC_BUCKET=indeehub-public +ENV NEXT_PUBLIC_ENABLE_APPROVAL_FLOW=false +ENV NEXT_TELEMETRY_DISABLED=1 + +# Remove shaka-player .d.ts files that break the build (per package.json build script) +RUN rm -f ./node_modules/shaka-player/dist/*.d.ts +RUN npm run build + +# ── Stage 3: Runner ── +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy standalone build output +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +CMD ["node", "server.js"] diff --git a/apps/indeedhub/README.md b/apps/indeedhub/README.md index b08ebdab..62c8a2de 100644 --- a/apps/indeedhub/README.md +++ b/apps/indeedhub/README.md @@ -1,44 +1,53 @@ -# IndeedHub (Indeehub Prototype) +# Indeehub — Bitcoin Documentary Streaming Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. +Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider. + ## Building the Image -The app image is built from the **Indeehub Prototype** project. The prototype lives at `../../Indeedhub Prototype` (relative to the archy repo). +The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`. -### Option 1: Build from prototype directory - -```bash -cd "/path/to/Indeedhub Prototype" -podman build -t localhost/indeedhub:latest . -``` - -### Option 2: Use the build script +### Option 1: Use the build script ```bash # From archy repo root ./apps/indeedhub/build-from-prototype.sh ``` -### Option 3: Full deploy (build + run on server) +### Option 2: Build from source directory ```bash -cd "/path/to/Indeedhub Prototype" -./deploy-to-archipelago.sh +cd ~/Projects/indeehub-frontend +podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile . ``` -## Installing from My Apps +## Installing from App Store -1. **Build the image** using one of the options above (the image must exist before install) -2. Go to **Dashboard → App Store** (Marketplace) -3. Find **Indeehub Prototype** and click **Install** -4. The app will appear in **My Apps** once the container is running +1. **Build the image** using one of the options above (must exist before install) +2. Go to **Dashboard -> App Store** (Marketplace) +3. Find **Indeehub** and click **Install** +4. On first launch, pick a Nostr identity to sign in with +5. The app appears in **My Apps** once the container is running ## Port -- Web UI: 7777 +- Web UI: 8190 (maps to container port 3000) ## Container - Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry) -- Port: 7777 +- Runtime: Node.js 20 (Next.js standalone) +- Port: 8190 -> 3000 +- Read-only root filesystem with tmpfs for /tmp and .next/cache + +## Nostr Identity + +On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy. + +## Services + +The app connects to the following external services (configured at build time): +- Indeehub API (content, auth, streaming) +- AWS S3 (media storage via CloudFront CDN) +- Nostr relays (via NIP-07 provider from Archipelago) diff --git a/apps/indeedhub/build-from-prototype.sh b/apps/indeedhub/build-from-prototype.sh index 258a09fe..50f5bdb7 100755 --- a/apps/indeedhub/build-from-prototype.sh +++ b/apps/indeedhub/build-from-prototype.sh @@ -1,17 +1,22 @@ #!/bin/bash -# Build Indeehub image from the Indeehub Prototype project -# Usage: ./build-from-prototype.sh [path-to-prototype] +# Build Indeehub container image from the indeehub-frontend project +# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend] set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype" -PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}" +DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend" +FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}" IMAGE_TAG="localhost/indeedhub:latest" -if [ ! -d "$PROTOTYPE_DIR" ]; then - echo "❌ Indeehub Prototype not found at: $PROTOTYPE_DIR" - echo " Set path: $0 /path/to/Indeedhub\ Prototype" +if [ ! -d "$FRONTEND_DIR" ]; then + echo "Indeehub frontend not found at: $FRONTEND_DIR" + echo " Set path: $0 /path/to/indeehub-frontend" + exit 1 +fi + +if [ ! -f "$FRONTEND_DIR/package.json" ]; then + echo "No package.json found in $FRONTEND_DIR — is this the right directory?" exit 1 fi @@ -21,10 +26,10 @@ if ! command -v podman >/dev/null 2>&1; then RUNTIME="docker" fi -echo "🔨 Building Indeehub from $PROTOTYPE_DIR" -cd "$PROTOTYPE_DIR" -$RUNTIME build -t "$IMAGE_TAG" . +echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile" +$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR" -echo "✅ Built $IMAGE_TAG" +echo "Built $IMAGE_TAG" echo "" echo "You can now install Indeehub from the App Store in Archipelago." +echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG" diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml index d2443c09..c22cdf44 100644 --- a/apps/indeedhub/manifest.yml +++ b/apps/indeedhub/manifest.yml @@ -2,62 +2,62 @@ app: id: indeedhub name: Indeehub version: 0.1.0 - description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. + description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity. category: media - + container: - image: localhost/indeedhub:1.0.0 - pull_policy: never # Built locally - + image: git.tx1138.com/lfg2025/indeedhub:latest + pull_policy: always # Pull from registry; falls back to local build + dependencies: - - storage: 500Mi - + - storage: 1Gi + resources: - cpu_limit: 1 + cpu_limit: 2 memory_limit: 512Mi - disk_limit: 500Mi - + disk_limit: 1Gi + security: capabilities: [] - readonly_root: true # Static nginx content + readonly_root: true no_new_privileges: true - user: 1000 + user: 1001 seccomp_profile: default network_policy: bridge apparmor_profile: default - + ports: - - host: 7777 - container: 7777 - protocol: tcp # Web UI - + - host: 8190 + container: 3000 + protocol: tcp # Web UI (Next.js) + volumes: - type: tmpfs - target: /var/cache/nginx - options: [rw,noexec,nosuid,size=10m] + target: /tmp + options: [rw,noexec,nosuid,size=64m] - type: tmpfs - target: /var/run - options: [rw,noexec,nosuid,size=10m] - + target: /app/.next/cache + options: [rw,noexec,nosuid,size=128m] + environment: - - NGINX_HOST=localhost - - NGINX_PORT=7777 - + - NODE_ENV=production + - NEXT_TELEMETRY_DISABLED=1 + health_check: type: http - endpoint: http://localhost:7777 - path: /health + endpoint: http://localhost:3000 + path: / interval: 30s timeout: 10s retries: 3 start_period: 40s - + interfaces: main: name: Web UI - description: Stream Bitcoin documentaries + description: Stream Bitcoin documentaries with Nostr identity type: ui - port: 7777 + port: 8190 protocol: http path: / @@ -72,3 +72,4 @@ app: - streaming - media - education + - nostr diff --git a/apps/indeedhub/push-to-registry.sh b/apps/indeedhub/push-to-registry.sh new file mode 100755 index 00000000..aa746deb --- /dev/null +++ b/apps/indeedhub/push-to-registry.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Build and push Indeehub container image to a registry +# Usage: ./push-to-registry.sh [version] +# +# Environment variables: +# REGISTRY - Registry host (default: ghcr.io) +# NAMESPACE - Registry namespace (default: archipelago-os) +# RUNTIME - Container runtime (default: podman) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}" +VERSION="${1:-latest}" +REGISTRY="${REGISTRY:-git.tx1138.com}" +NAMESPACE="${NAMESPACE:-lfg2025}" +IMAGE_NAME="indeedhub" +RUNTIME="${RUNTIME:-podman}" + +FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}" +LATEST_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest" + +if [ ! -d "$FRONTEND_DIR" ]; then + echo "Indeehub frontend not found at: $FRONTEND_DIR" + echo "Set INDEEHUB_FRONTEND=/path/to/indeehub-frontend" + exit 1 +fi + +echo "=== Indeehub Container Registry Push ===" +echo "Source: $FRONTEND_DIR" +echo "Image: $FULL_TAG" +echo "Runtime: $RUNTIME" +echo "" + +# Step 1: Build for linux/amd64 (target architecture) +echo "[1/3] Building image..." +$RUNTIME build --platform linux/amd64 \ + -t "$FULL_TAG" \ + -t "$LATEST_TAG" \ + -t "localhost/${IMAGE_NAME}:latest" \ + -t "localhost/${IMAGE_NAME}:${VERSION}" \ + -f "$SCRIPT_DIR/Dockerfile" \ + "$FRONTEND_DIR" + +echo "[2/3] Pushing to registry..." +# Login check +if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then + echo "" + echo "Not logged in to $REGISTRY." + echo "Run: $RUNTIME login $REGISTRY" + exit 1 +fi + +$RUNTIME push "$FULL_TAG" +if [ "$VERSION" != "latest" ]; then + $RUNTIME push "$LATEST_TAG" +fi + +echo "" +echo "[3/3] Done!" +echo "" +echo "Image pushed: $FULL_TAG" +if [ "$VERSION" != "latest" ]; then + echo "Also tagged: $LATEST_TAG" +fi +echo "" +echo "Federated nodes can now install via:" +echo " podman pull $FULL_TAG" +echo "" +echo "Update marketplace dockerImage to: $FULL_TAG" diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs index eca0a379..f3bc2dc8 100644 --- a/core/archipelago/src/api/rpc/package.rs +++ b/core/archipelago/src/api/rpc/package.rs @@ -34,11 +34,6 @@ impl RpcHandler { return Err(anyhow::anyhow!("Invalid Docker image format")); } - // Virtual app: Indeehub (no container, opens external URL) - if package_id == "indeedhub" { - return Ok(serde_json::json!({ "success": true })); - } - // Multi-container apps: create full stack if package_id == "immich" { return self.install_immich_stack().await; @@ -117,8 +112,17 @@ impl RpcHandler { } // Pull the image (skip for local images - must be built locally first) + // For registry images, also check if a local build exists first (avoids + // pull failures when the registry image hasn't been pushed yet). let is_local_image = docker_image.starts_with("localhost/"); - if !is_local_image { + let has_local_fallback = if !is_local_image { + let local_tag = format!("localhost/{}:latest", package_id); + let check = tokio::process::Command::new("sudo") + .args(["podman", "images", "-q", &local_tag]) + .output().await.ok(); + check.map_or(false, |o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) + } else { false }; + if !is_local_image && !has_local_fallback { debug!("Pulling image: {}", docker_image); // Set package state to Installing with progress @@ -156,6 +160,9 @@ impl RpcHandler { // Mark pull as complete (100%) self.set_install_progress(package_id, 100, 100).await; + } else if has_local_fallback { + // Registry image exists locally — use the local build + debug!("Using local build for {} (skipping registry pull)", package_id); } else { // Verify local image exists let images_output = tokio::process::Command::new("sudo") @@ -165,7 +172,7 @@ impl RpcHandler { .context("Failed to check local image")?; if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() { return Err(anyhow::anyhow!( - "Local image {} not found. Run ./deploy-to-archipelago.sh from the Indeehub Prototype project on your Mac—it builds the image on this server and starts the app.", + "Local image {} not found. Build the image first or ensure the registry is reachable.", docker_image )); } @@ -342,8 +349,13 @@ printtoconsole=1\n"; run_args.push(arg); } - // Finally, the image - run_args.push(docker_image); + // Finally, the image — use local build if available, otherwise registry image + let effective_image = if has_local_fallback { + format!("localhost/{}:latest", package_id) + } else { + docker_image.to_string() + }; + run_args.push(&effective_image); debug!("Running container with args: {:?}", run_args); @@ -1723,9 +1735,9 @@ fn get_app_config( ]), ), "indeedhub" => ( - vec!["7777:7777".to_string()], + vec!["8190:3000".to_string()], vec![], - vec!["NGINX_HOST=0.0.0.0".to_string(), "NGINX_PORT=7777".to_string()], + vec!["NODE_ENV=production".to_string(), "NEXT_TELEMETRY_DISABLED=1".to_string()], None, None, ), diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 2ba41848..968c0af4 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -136,6 +136,9 @@ impl DockerPackageScanner { } else if app_id == "dwn" { debug!("Using DWN server: http://localhost:3100"); Some("http://localhost:3100".to_string()) + } else if app_id == "indeedhub" { + debug!("Using Indeehub: http://localhost:8190"); + Some("http://localhost:8190".to_string()) } else if app_id == "mempool-electrs" || app_id == "electrs" { // Electrs UI runs on host at port 50002 debug!("Using electrs-ui for mempool-electrs: http://localhost:50002"); @@ -219,66 +222,6 @@ impl DockerPackageScanner { info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state)); } - // Virtual app: Indeehub (opens external URL, no container required) - if !packages.contains_key("indeedhub") { - let metadata = get_app_metadata("indeedhub"); - let lan_address = Some("https://archipelago.indeehub.studio".to_string()); - let virtual_pkg = PackageDataEntry { - state: PackageState::Running, - static_files: StaticFiles { - license: "MIT".to_string(), - instructions: metadata.description.clone(), - icon: metadata.icon.clone(), - }, - manifest: Manifest { - id: "indeedhub".to_string(), - title: metadata.title.clone(), - version: "0.1.0".to_string(), - description: Description { - short: metadata.description.clone(), - long: metadata.description.clone(), - }, - release_notes: "Virtual app (opens archipelago.indeehub.studio)".to_string(), - license: "MIT".to_string(), - wrapper_repo: metadata.repo.clone(), - upstream_repo: metadata.repo.clone(), - support_site: metadata.repo.clone(), - marketing_site: metadata.repo.clone(), - donation_url: None, - author: Some("Indeehub Team".to_string()), - website: lan_address.clone(), - tier: Some("optional".to_string()), - interfaces: Some(Interfaces { - main: Some(MainInterface { - ui: Some("true".to_string()), - tor_config: None, - lan_config: None, - }), - }), - }, - installed: Some(InstalledPackageDataEntry { - current_dependents: HashMap::new(), - current_dependencies: HashMap::new(), - last_backup: None, - interface_addresses: { - let mut addresses = HashMap::new(); - addresses.insert( - "main".to_string(), - InterfaceAddress { - tor_address: String::new(), - lan_address: lan_address, - }, - ); - addresses - }, - status: ServiceStatus::Running, - }), - install_progress: None, - }; - packages.insert("indeedhub".to_string(), virtual_pkg); - info!("Virtual app: Indeehub (archipelago.indeehub.studio)"); - } - Ok(packages) } } @@ -505,7 +448,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { repo: "https://github.com/tailscale/tailscale".to_string(), tier: "", }, - "indeedhub" => AppMetadata { + "indeedhub" | "indeehub" => AppMetadata { title: "Indeehub".to_string(), description: "Decentralized media streaming platform".to_string(), icon: "/assets/img/app-icons/indeehub.ico".to_string(), diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 27bbb006..22bef01a 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -331,6 +331,20 @@ server { sub_filter_once on; sub_filter '' ''; } + location /app/indeedhub/ { + proxy_pass http://127.0.0.1:8190/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; + proxy_set_header Accept-Encoding ""; + sub_filter_once on; + sub_filter '' ''; + } location /app/lnd/ { proxy_pass http://127.0.0.1:8081/; proxy_http_version 1.1; diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index 8a6cd40a..5a519f09 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -231,6 +231,19 @@ location /app/electrs/ { sub_filter_once on; sub_filter '' ''; } +location /app/indeedhub/ { + proxy_pass http://127.0.0.1:8190/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Accept-Encoding ""; + sub_filter_once on; + sub_filter '' ''; +} location /app/nginx-proxy-manager/ { proxy_pass http://127.0.0.1:81/; proxy_http_version 1.1; diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index daee3e4f..f12010f6 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.09ki1c64ohs" + "revision": "0.6f1usind3cc" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/public/assets/img/app-icons/bg-appstore.jpg b/neode-ui/public/assets/img/app-icons/bg-appstore.jpg new file mode 100644 index 00000000..4b56d217 Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/bg-appstore.jpg differ diff --git a/neode-ui/public/assets/img/bg-appstore.jpg b/neode-ui/public/assets/img/bg-appstore.jpg index 2a72ee59..54c0d2e7 100644 Binary files a/neode-ui/public/assets/img/bg-appstore.jpg and b/neode-ui/public/assets/img/bg-appstore.jpg differ diff --git a/neode-ui/public/assets/img/bg-intro.jpg b/neode-ui/public/assets/img/bg-intro.jpg index 5482336b..b6cbf4f5 100644 Binary files a/neode-ui/public/assets/img/bg-intro.jpg and b/neode-ui/public/assets/img/bg-intro.jpg differ diff --git a/neode-ui/public/assets/img/bg-myapps.jpg b/neode-ui/public/assets/img/bg-myapps.jpg index 5482336b..54c0d2e7 100644 Binary files a/neode-ui/public/assets/img/bg-myapps.jpg and b/neode-ui/public/assets/img/bg-myapps.jpg differ diff --git a/neode-ui/public/assets/img/bg-settings.jpg b/neode-ui/public/assets/img/bg-settings.jpg index a55d28f4..68017a43 100644 Binary files a/neode-ui/public/assets/img/bg-settings.jpg and b/neode-ui/public/assets/img/bg-settings.jpg differ diff --git a/neode-ui/public/assets/img/bg-web5.jpg b/neode-ui/public/assets/img/bg-web5.jpg index fa9f4bc7..0603f569 100644 Binary files a/neode-ui/public/assets/img/bg-web5.jpg and b/neode-ui/public/assets/img/bg-web5.jpg differ diff --git a/neode-ui/public/assets/video/video-intro-new.mp4 b/neode-ui/public/assets/video/video-intro-new.mp4 new file mode 100644 index 00000000..b329c997 Binary files /dev/null and b/neode-ui/public/assets/video/video-intro-new.mp4 differ diff --git a/neode-ui/public/assets/video/video-intro-old.mp4 b/neode-ui/public/assets/video/video-intro-old.mp4 new file mode 100644 index 00000000..7ed41070 Binary files /dev/null and b/neode-ui/public/assets/video/video-intro-old.mp4 differ diff --git a/neode-ui/public/assets/video/video-intro.mp4 b/neode-ui/public/assets/video/video-intro.mp4 index 7ed41070..3cf7151a 100644 Binary files a/neode-ui/public/assets/video/video-intro.mp4 and b/neode-ui/public/assets/video/video-intro.mp4 differ diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 79176415..f70b3ede 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -55,6 +55,11 @@ class RPCClient { clearTimeout(timeoutId) if (!response.ok) { + // Session expired — redirect to login + if (response.status === 401 && method !== 'auth.login') { + window.location.href = '/login' + throw new Error('Session expired') + } const err = new Error(`HTTP ${response.status}: ${response.statusText}`) const isRetryable = response.status === 502 || response.status === 503 if (isRetryable && attempt < maxRetries - 1) { diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 82f4ab0f..ab976e38 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -172,12 +172,21 @@ @approve="store.approveConsent" @deny="store.denyConsent" /> + + + + + diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index c05c0ca7..4794d71d 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -370,8 +370,8 @@ "connectedNodes": "Connected Nodes", "bitcoinDomains": "Bitcoin Domain Names", "domainsSubtitle": "NIP-05 verified identities", - "copyDid": "Copy DID", - "viewDidDocument": "View DID Document", + "copyDid": "Copy", + "viewDidDocument": "View", "createDid": "Create DID", "creatingDid": "Creating...", "manageDomains": "Manage Domains", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index abfc2bca..ac49c002 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -370,8 +370,8 @@ "connectedNodes": "Nodos conectados", "bitcoinDomains": "Nombres de dominio Bitcoin", "domainsSubtitle": "Identidades verificadas NIP-05", - "copyDid": "Copiar DID", - "viewDidDocument": "Ver documento DID", + "copyDid": "Copiar", + "viewDidDocument": "Ver", "createDid": "Crear DID", "creatingDid": "Creando...", "manageDomains": "Administrar dominios", diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 608d2420..1577dad6 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -114,9 +114,10 @@ const router = createRouter({ component: () => import('../views/Cloud.vue'), }, { - path: 'cloud/peers', + path: 'cloud/peers/:peerId?', name: 'peer-files', component: () => import('../views/PeerFiles.vue'), + props: true, }, { path: 'cloud/:folderId', diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index f85268be..07eaa5fb 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -67,6 +67,7 @@ const PORT_TO_PROXY: Record = { '8176': '/app/fedimint-gateway/', '3100': '/app/dwn/', '18081': '/app/nostr-rs-relay/', + '8190': '/app/indeedhub/', } /** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. @@ -188,12 +189,32 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const origin = url.value || 'unknown' + // Check if app has a per-app identity stored (from identity picker) + const IDENTITY_KEY = 'archipelago_app_identity_' + const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_') + let appIdentityId: string | null = null + try { + const stored = localStorage.getItem(appKey) + if (stored) { + const parsed = JSON.parse(stored) as { id?: string } + appIdentityId = parsed.id || null + } + } catch { /* ignore */ } + try { let result: unknown if (method === 'getPublicKey') { - const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) - result = res.nostr_pubkey + if (appIdentityId) { + // Use the app-specific identity's Nostr key + const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({ + method: 'identity.get', params: { id: appIdentityId } + }) + result = res.nostr_pubkey + } else { + const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) + result = res.nostr_pubkey + } } else if (method === 'signEvent') { // Check if origin is pre-approved const approved = getApprovedOrigins() @@ -208,32 +229,41 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { return } } - const res = await rpcClient.call({ method: 'node.nostr-sign', params: { event: params.event } }) - result = res + if (appIdentityId) { + // Sign with the app-specific identity's Nostr key + const res = await rpcClient.call({ + method: 'identity.nostr-sign', + params: { id: appIdentityId, event: params.event } + }) + result = res + } else { + const res = await rpcClient.call({ method: 'node.nostr-sign', params: { event: params.event } }) + result = res + } } else if (method === 'getRelays') { result = {} } else if (method === 'nip04.encrypt') { const res = await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', - params: { pubkey: params.pubkey, plaintext: params.plaintext } + params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } }) result = res.ciphertext } else if (method === 'nip04.decrypt') { const res = await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', - params: { pubkey: params.pubkey, ciphertext: params.ciphertext } + params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } }) result = res.plaintext } else if (method === 'nip44.encrypt') { const res = await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', - params: { pubkey: params.pubkey, plaintext: params.plaintext } + params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } }) result = res.ciphertext } else if (method === 'nip44.decrypt') { const res = await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', - params: { pubkey: params.pubkey, ciphertext: params.ciphertext } + params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } }) result = res.plaintext } else { diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 7c94ba7d..164f43ff 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -60,6 +60,22 @@ } } +/* Scroll container bottom padding — desktop breathing room */ +.mobile-scroll-pad, +.mobile-scroll-pad-back { + padding-bottom: 6rem; +} + +/* Mobile: override with tab bar clearance */ +@media (max-width: 767px) { + .mobile-scroll-pad { + padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + env(safe-area-inset-bottom, 0px) + 16px); + } + .mobile-scroll-pad-back { + padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + env(safe-area-inset-bottom, 0px) + 64px); + } +} + /* Haptic-like press feedback for all interactive elements */ button:active:not(:disabled), [role="button"]:active, @@ -131,8 +147,14 @@ input[type="radio"]:active + * { border: 1px solid rgba(255, 255, 255, 0.08); } + /* Full-width mode switcher variant (sidebar, mobile settings) */ + .mode-switcher-full { + display: flex; + width: 100%; + } + .mode-switcher-btn { - flex: none; + flex: 1 1 0%; padding: 0.375rem 0.75rem; border-radius: 0.375rem; white-space: nowrap; @@ -146,6 +168,14 @@ input[type="radio"]:active + * { background: transparent; } + @media (max-width: 767px) { + .mode-switcher-btn { + min-height: 44px; + font-size: 0.875rem; + padding: 0.625rem 1rem; + } + } + .mode-switcher-btn:hover { color: rgba(255, 255, 255, 0.75); } @@ -235,10 +265,11 @@ input[type="radio"]:active + * { background: transparent; } - /* On mobile, pad iframe so AIUI content ends above the tab bar */ + /* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar */ @media (max-width: 767px) { .chat-iframe-mobile { - padding-bottom: var(--mobile-tab-bar-height, 72px); + height: calc(100% - var(--mobile-tab-bar-height, 72px)) !important; + flex: none; } } diff --git a/neode-ui/src/utils/dummyApps.ts b/neode-ui/src/utils/dummyApps.ts index 8884f893..c5653232 100644 --- a/neode-ui/src/utils/dummyApps.ts +++ b/neode-ui/src/utils/dummyApps.ts @@ -540,7 +540,7 @@ export const dummyApps: Record = { 'interface-addresses': { main: { 'tor-address': '', - 'lan-address': 'https://archipelago.indeehub.studio' + 'lan-address': 'http://localhost:8190' } }, status: ServiceStatus.Running diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index d719b519..ebace6d7 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -417,7 +417,7 @@ class="fixed inset-0 z-50 flex items-center justify-center p-4" @click="closeUninstallModal()" > -
+
route.params.id as string) // Web-only app detection (no container — external websites) const WEB_ONLY_APP_URLS: Record = { - 'indeedhub': 'https://archipelago.indeehub.studio', 'botfights': 'https://botfights.net', 'nwnn': 'https://nwnn.l484.com', '484-kitchen': 'https://484.kitchen', @@ -528,6 +527,7 @@ const ROUTE_TO_PACKAGE_KEY: Record = { portainer: 'portainer', 'uptime-kuma': 'uptime-kuma', tailscale: 'tailscale', + indeedhub: 'indeedhub', } /** Backend may register under variant container names */ @@ -740,8 +740,8 @@ function launchApp() { prod: 'http://localhost:8103' // Self-hosted splash screen }, 'indeedhub': { - dev: 'https://archipelago.indeehub.studio', - prod: 'https://archipelago.indeehub.studio' + dev: 'http://localhost:8190', + prod: 'http://localhost:8190' }, // Dummy apps - replace with real URLs when packaged 'bitcoin': { diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 1b2d6c3c..4d29eabe 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -1,24 +1,28 @@