feat: complete Phase 1 foundation hardening + three-mode UI design doc
Phase 1a — Gradient Removal: - Replaced all gradient-button/gradient-card with glass-button/path-option-card - Removed banned gradient CSS classes Phase 1b — Security Hardening: - SecretsManager: AES-256-GCM encryption (core/security) - electrs_status: credentials from env vars instead of hardcoded - port_manager: RwLock proper error handling (no unwrap) - Pinned all 11 :latest manifest images to specific versions - parmanode converter: pinned inferred image versions Phase 1c — Code Quality: - Split rpc.rs (1795 lines) into 6 handler modules (auth, node, container, package, peers) - Removed sideload code (UI, store, RPC client, 3 doc files) - Fixed body background flash on logout/refresh - Replaced 30 TypeScript `any` types with proper types - Deleted HelloWorld.vue, removed TODO comments - Added set -euo pipefail to all shell scripts - Made deploy script verbose with timestamps and elapsed time Also adds: - CLAUDE.md project guide - docs/three-mode-ui-design.md — design spec for Easy/Pro/Chat UI modes - OnlineStatusPill component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
62d6c13764
commit
486fc39249
276
CLAUDE.md
Normal file
276
CLAUDE.md
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# CLAUDE.md — Archipelago (Archy) Project Guide
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Archipelago is a **Bitcoin Node OS** — a bootable, self-sovereign personal server you flash to USB, install on hardware, and manage via a web UI. Similar to Umbrel/Start9/RaspiBlitz but custom-built with production-grade security.
|
||||||
|
|
||||||
|
**Stack**: Rust backend + Vue 3 (Composition API) + TypeScript (strict) + Vite 7 + Tailwind CSS + Pinia + Podman
|
||||||
|
**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64
|
||||||
|
**Current version**: 0.1.0
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend local dev (mock backend on :5959, Vite on :8100)
|
||||||
|
cd neode-ui && npm start
|
||||||
|
|
||||||
|
# Deploy to live server (frontend + backend + restart services)
|
||||||
|
./scripts/deploy-to-target.sh --live
|
||||||
|
|
||||||
|
# Deploy to both servers
|
||||||
|
./scripts/deploy-to-target.sh --both
|
||||||
|
|
||||||
|
# Frontend build (outputs to web/dist/neode-ui/)
|
||||||
|
cd neode-ui && npm run build
|
||||||
|
|
||||||
|
# Type-check frontend
|
||||||
|
cd neode-ui && npm run type-check
|
||||||
|
|
||||||
|
# Rust checks (run on dev server, NOT macOS)
|
||||||
|
cargo clippy --all-targets --all-features
|
||||||
|
cargo fmt --all
|
||||||
|
cargo test --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev server: `http://192.168.1.228` | Local frontend: `http://localhost:8100` (password: `password123`)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Debian 12 (Bookworm)
|
||||||
|
├── Podman (rootless containers)
|
||||||
|
├── Nginx (port 80 → proxies /rpc/, /ws/, /health to backend)
|
||||||
|
├── Rust Backend (core/) — binary on port 5678
|
||||||
|
│ ├── core/archipelago/ — Main binary, RPC endpoints
|
||||||
|
│ ├── core/container/ — PodmanClient, manifest parser, dependency resolver, health monitor
|
||||||
|
│ ├── core/security/ — AppArmor profiles, secrets manager, Cosign image verifier
|
||||||
|
│ ├── core/performance/ — Resource manager
|
||||||
|
│ └── core/parmanode/ — Parmanode compatibility layer
|
||||||
|
└── Vue.js UI (neode-ui/)
|
||||||
|
├── src/api/ — RPC client (rpc-client.ts), WebSocket, container client
|
||||||
|
├── src/stores/ — Pinia stores
|
||||||
|
├── src/views/ — Page components
|
||||||
|
├── src/components/ — Reusable components
|
||||||
|
├── src/router/ — Vue Router
|
||||||
|
├── src/types/ — TypeScript type definitions
|
||||||
|
└── src/style.css — Global styles + Tailwind utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Paths (Server)
|
||||||
|
|
||||||
|
- App data: `/var/lib/archipelago/{app-id}/`
|
||||||
|
- Secrets: `/var/lib/archipelago/secrets/{app-id}/` (encrypted)
|
||||||
|
- Frontend: `/opt/archipelago/web-ui/`
|
||||||
|
- Backend binary: `/usr/local/bin/archipelago`
|
||||||
|
- Systemd service: `/etc/systemd/system/archipelago.service`
|
||||||
|
- Nginx config: `/etc/nginx/sites-available/archipelago`
|
||||||
|
|
||||||
|
## CRITICAL Workflow Rules
|
||||||
|
|
||||||
|
### 1. NEVER Build Rust on macOS for Linux
|
||||||
|
|
||||||
|
Always rsync source to the Linux dev server and build there. Building on macOS and copying the binary causes Exec format errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy does this automatically:
|
||||||
|
./scripts/deploy-to-target.sh --live
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Always Deploy After Changes
|
||||||
|
|
||||||
|
After editing code (frontend, backend, scripts, or configs), deploy to the live server. Do not leave deployment to the user.
|
||||||
|
|
||||||
|
### 3. Frontend Build Output Path
|
||||||
|
|
||||||
|
Frontend builds to `web/dist/neode-ui/` — NOT `neode-ui/dist/`.
|
||||||
|
|
||||||
|
### 4. Deploy-Test-Fix Loop
|
||||||
|
|
||||||
|
1. Make the change
|
||||||
|
2. Deploy with `./scripts/deploy-to-target.sh --live`
|
||||||
|
3. Test at http://192.168.1.228
|
||||||
|
4. If broken, fix and redeploy — repeat until working
|
||||||
|
5. End loop only when everything works
|
||||||
|
|
||||||
|
### 5. SSH Access
|
||||||
|
|
||||||
|
- **Primary**: `archipelago@192.168.1.228` — password: `EwPDR8q45l0Upx@`
|
||||||
|
- **Secondary**: `archipelago@192.168.1.198`
|
||||||
|
- Credentials stored in gitignored `scripts/deploy-config.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Rules (Vue.js + TypeScript)
|
||||||
|
|
||||||
|
### Component Standards
|
||||||
|
|
||||||
|
- **Always** `<script setup lang="ts">` — never Options API, never plain JS
|
||||||
|
- **Pinia** for all state management — focused single-purpose stores
|
||||||
|
- **TypeScript strict mode** — no `any`, use `unknown` or proper types
|
||||||
|
- Export types from dedicated `.types.ts` files
|
||||||
|
- Use type guards for runtime type checking
|
||||||
|
|
||||||
|
### Styling — Global Classes Only
|
||||||
|
|
||||||
|
- **ALWAYS** create global utility classes in `neode-ui/src/style.css`
|
||||||
|
- **NEVER** use inline Tailwind classes directly in components
|
||||||
|
- Use semantic class names: `.glass-card`, `.glass-button`, `.gradient-button`, `.path-option-card`
|
||||||
|
|
||||||
|
### API Client Rules
|
||||||
|
|
||||||
|
- Use `@/api/rpc-client.ts` for RPC calls, `@/api/container-client.ts` for containers
|
||||||
|
- **NEVER** hardcode API endpoints — use environment variables
|
||||||
|
- Handle loading states, error states, retry logic for all async operations
|
||||||
|
|
||||||
|
### CSS Class Hierarchy
|
||||||
|
|
||||||
|
| Class | Use | Hover |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| `.path-option-card` | Section containers, interactive cards (Settings-style) | Lifts -2px |
|
||||||
|
| `.glass-card` | Content containers, modals, panels | No |
|
||||||
|
| `.info-card` | Status badges, metric displays | No |
|
||||||
|
| `.info-card-button` | Action buttons inside info sections | Lifts, brightens |
|
||||||
|
| `bg-black/20 rounded-xl border border-white/10` | Info sub-cards inside sections | No |
|
||||||
|
| `bg-white/5` | Simple read-only info rows | No |
|
||||||
|
| `.glass-button` | ALL buttons (primary and secondary) | Subtle brighten |
|
||||||
|
| `.path-action-button` | Large action buttons (Logout, Continue) | Lifts -2px |
|
||||||
|
|
||||||
|
### BANNED Classes — Do NOT Use
|
||||||
|
- **`.gradient-button`** — REMOVED. Use `.glass-button` instead. The gradient style breaks the clean glass aesthetic.
|
||||||
|
- **`.gradient-card`** / **`.gradient-card-dark`** — REMOVED. Use `.glass-card` or `.path-option-card` instead.
|
||||||
|
|
||||||
|
### Design Tokens
|
||||||
|
|
||||||
|
- **Font**: Avenir Next (primary), Montserrat (`font-archipelago`)
|
||||||
|
- **Spacing**: 4px grid system, 16px default padding
|
||||||
|
- **Glassmorphism**: `background: rgba(0,0,0,0.60)`, `backdrop-filter: blur(24px)`, `inset 0 1px 0 rgba(255,255,255,0.22)`
|
||||||
|
- **Transitions**: `all 0.3s ease` standard, `translateY(-2px)` hover, `translateY(1px)` active
|
||||||
|
- **Accent orange** (Bitcoin): `#fb923c` — `#f59e0b`
|
||||||
|
- **Green** (success): `#4ade80` | **Red** (danger): `#ef4444` | **Blue** (info): `#3b82f6`
|
||||||
|
- **Text**: `rgba(255,255,255,0.9)` primary, `rgba(255,255,255,0.6-0.7)` muted
|
||||||
|
|
||||||
|
### Tailwind Custom Values
|
||||||
|
|
||||||
|
- Blur: `backdrop-blur-glass` (18px), `backdrop-blur-glass-strong` (24px)
|
||||||
|
- Colors: `glass-dark` (0,0,0,0.35), `glass-darker` (0,0,0,0.6), `glass-border` (255,255,255,0.18)
|
||||||
|
- Shadows: `shadow-glass`, `shadow-glass-inset`
|
||||||
|
|
||||||
|
## Backend Rules (Rust)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **No `unwrap()` or `expect()` in production code** — use `?` operator
|
||||||
|
- `thiserror` for library error types, `anyhow` for application errors
|
||||||
|
- Custom error types per module: `{module}::Error`
|
||||||
|
- Include context: `.context("What failed and why")`
|
||||||
|
|
||||||
|
### RPC Endpoints
|
||||||
|
|
||||||
|
- Use `rpc_toolkit::command` macro for all endpoints
|
||||||
|
- Use `#[context] ctx: RpcContext` for context
|
||||||
|
- Return `Result<T, Error>` — validate all inputs before processing
|
||||||
|
|
||||||
|
### Async & Runtime
|
||||||
|
|
||||||
|
- `tokio` runtime only — never mix with other async runtimes
|
||||||
|
- Set timeouts on all external operations
|
||||||
|
- Use `select!` for racing futures with timeouts
|
||||||
|
- Handle shutdown gracefully with cancellation tokens
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
- New modules in `core/{module-name}/`, add to `core/Cargo.toml` members
|
||||||
|
- `snake_case` for all modules/files
|
||||||
|
- Run `cargo clippy --all-targets --all-features` and `cargo fmt --all` before commits
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `tracing` for structured logging — never `println!`
|
||||||
|
- Never log secrets, passwords, keys, or tokens
|
||||||
|
- Include context: `tracing::info!(user_id = %id, "Action")`
|
||||||
|
|
||||||
|
## Container & Security
|
||||||
|
|
||||||
|
### App Manifests
|
||||||
|
|
||||||
|
- All manifests in `apps/{app-id}/manifest.yml`
|
||||||
|
- Follow spec in `docs/app-manifest-spec.md`
|
||||||
|
- Use `archipelago_container::PodmanClient` — **NEVER** call Docker directly
|
||||||
|
|
||||||
|
### Security Requirements (Non-Negotiable)
|
||||||
|
|
||||||
|
- **ALWAYS** `readonly_root: true` unless explicitly needed
|
||||||
|
- **ALWAYS** drop all capabilities, add only required ones
|
||||||
|
- **ALWAYS** run as non-root user (UID > 1000)
|
||||||
|
- **ALWAYS** `no-new-privileges: true`
|
||||||
|
- **NEVER** use `latest` tag — pin specific image versions
|
||||||
|
- **NEVER** hardcode secrets — use `core/security/secrets_manager.rs`
|
||||||
|
|
||||||
|
### App Icons
|
||||||
|
|
||||||
|
Single source of truth: `neode-ui/public/assets/img/app-icons/`
|
||||||
|
Naming: `{app-id}.{png|webp|svg}` — do not duplicate elsewhere.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- Zero compiler warnings (Rust and TypeScript)
|
||||||
|
- Zero linter errors (clippy, eslint)
|
||||||
|
- Functions under 50 lines, single responsibility
|
||||||
|
- Comment WHY not WHAT — code should be self-documenting
|
||||||
|
- Remove dead code entirely — never comment it out
|
||||||
|
- No `TODO`/`FIXME` in commits — fix now or create issues
|
||||||
|
- Workspace-relative paths only — **NEVER** hardcode `/Users/dorian/...`
|
||||||
|
|
||||||
|
## Git Conventions
|
||||||
|
|
||||||
|
### Commit Format
|
||||||
|
|
||||||
|
```
|
||||||
|
type: description
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Atomic commits — one logical change per commit
|
||||||
|
- `main` branch always production-ready
|
||||||
|
- Feature branches: `feature/description`, bug fixes: `fix/description`
|
||||||
|
- Never commit secrets, `.env` files, or credentials
|
||||||
|
- Tag releases: `v1.2.3` (SemVer)
|
||||||
|
|
||||||
|
## App Integration Checklist
|
||||||
|
|
||||||
|
When adding or fixing apps:
|
||||||
|
|
||||||
|
1. Test the app UI loads on its configured port
|
||||||
|
2. Auto-connect dependencies (Bitcoin RPC, LND, etc.) — apps must work out of the box
|
||||||
|
3. Ensure `get_app_config()` in `core/archipelago/src/api/rpc.rs` has correct env vars
|
||||||
|
4. Most apps launch in iframe; BTCPay (23000) and Home Assistant (8123) open in new tab (X-Frame-Options)
|
||||||
|
|
||||||
|
## ISO Build
|
||||||
|
|
||||||
|
Build on the target server (has all dependencies):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh archipelago@192.168.1.228
|
||||||
|
cd ~/archy/image-recipe
|
||||||
|
sudo ./build-auto-installer-iso.sh
|
||||||
|
# Result: results/archipelago-auto-installer-*.iso
|
||||||
|
```
|
||||||
|
|
||||||
|
After testing on live server, always update ISO build to include changes. Sync system configs:
|
||||||
|
- `archipelago.service` → `image-recipe/configs/`
|
||||||
|
- `nginx-archipelago.conf` → `image-recipe/configs/`
|
||||||
|
|
||||||
|
## Key Documentation
|
||||||
|
|
||||||
|
- `docs/architecture.md` — System architecture
|
||||||
|
- `docs/current-state.md` — Current development phase
|
||||||
|
- `docs/development-setup.md` — Local dev setup
|
||||||
|
- `docs/app-manifest-spec.md` — YAML manifest spec
|
||||||
|
- `BUILD-GUIDE.md` — ISO build guide
|
||||||
|
- `DEPLOYMENT.md` — Deployment details
|
||||||
|
- `CHANGELOG.md` — Version history
|
||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
|
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: archipelago/did-wallet:latest
|
image: archipelago/did-wallet:1.0.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Endurain application platform. Custom application runtime.
|
description: Endurain application platform. Custom application runtime.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: archipelago/endurain:latest
|
image: archipelago/endurain:1.0.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ app:
|
|||||||
category: media
|
category: media
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: localhost/indeedhub:latest
|
image: localhost/indeedhub:1.0.0
|
||||||
pull_policy: never # Built locally
|
pull_policy: never # Built locally
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: meshtastic/meshtastic:latest
|
image: meshtastic/meshtasticd:2.5.6
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: verify-signature
|
pull_policy: verify-signature
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: MorphOS server platform. Decentralized application server.
|
description: MorphOS server platform. Decentralized application server.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: archipelago/morphos-server:latest
|
image: archipelago/morphos-server:1.0.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
|
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: scsibug/nostr-rs-relay:latest
|
image: scsibug/nostr-rs-relay:0.8.9
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: verify-signature
|
pull_policy: verify-signature
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Run large language models locally. Privacy-preserving AI on your node.
|
description: Run large language models locally. Privacy-preserving AI on your node.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: ollama/ollama:latest
|
image: ollama/ollama:0.6.2
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Open-source design and prototyping platform. Design tools for teams.
|
description: Open-source design and prototyping platform. Design tools for teams.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: penpot/penpot:latest
|
image: penpotapp/frontend:2.13.3
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
|
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: archipelago/router:latest
|
image: archipelago/router:1.0.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
|
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: strfry/strfry:latest
|
image: dockurr/strfry:1.0.4
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: verify-signature
|
pull_policy: verify-signature
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Personal data store for Web5. Store and sync your decentralized data across devices.
|
description: Personal data store for Web5. Store and sync your decentralized data across devices.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: archipelago/web5-dwn:latest
|
image: archipelago/web5-dwn:1.0.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
77
core/archipelago/src/api/rpc/auth.rs
Normal file
77
core/archipelago/src/api/rpc/auth.rs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
use super::{RpcHandler, DEV_DEFAULT_PASSWORD};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_auth_login(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let password = params
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
|
||||||
|
|
||||||
|
let is_setup = self.auth_manager.is_setup().await?;
|
||||||
|
if !is_setup {
|
||||||
|
// Dev mode: allow default password so UI can log in without running setup
|
||||||
|
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||||
|
return Ok(serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"User not set up. Please complete setup first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let valid = self.auth_manager.verify_password(password).await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||||
|
Ok(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_change_password(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let current_password = params
|
||||||
|
.get("currentPassword")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing currentPassword"))?;
|
||||||
|
let new_password = params
|
||||||
|
.get("newPassword")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing newPassword"))?;
|
||||||
|
let also_change_ssh = params
|
||||||
|
.get("alsoChangeSsh")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
self.auth_manager
|
||||||
|
.change_password(current_password, new_password, also_change_ssh)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "success": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
|
self.auth_manager.complete_onboarding().await?;
|
||||||
|
Ok(serde_json::json!(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
|
let complete = self.auth_manager.is_onboarding_complete().await?;
|
||||||
|
Ok(serde_json::json!(complete))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_auth_reset_onboarding(&self) -> Result<serde_json::Value> {
|
||||||
|
self.auth_manager.reset_onboarding().await?;
|
||||||
|
Ok(serde_json::json!(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
292
core/archipelago/src/api/rpc/container.rs
Normal file
292
core/archipelago/src/api/rpc/container.rs
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
use super::RpcHandler;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_container_install(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let manifest_path = params
|
||||||
|
.get("manifest_path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
|
||||||
|
|
||||||
|
// Load manifest
|
||||||
|
let manifest_content = tokio::fs::read_to_string(manifest_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read manifest file")?;
|
||||||
|
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
|
||||||
|
.context("Failed to parse manifest")?;
|
||||||
|
|
||||||
|
let container_name = orchestrator
|
||||||
|
.install_container(&manifest, manifest_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to install container")?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!(container_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_start(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
|
||||||
|
orchestrator
|
||||||
|
.start_container(app_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to start container")?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "started" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_stop(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
|
||||||
|
orchestrator
|
||||||
|
.stop_container(app_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to stop container")?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "stopped" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_remove(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
let preserve_data = params
|
||||||
|
.get("preserve_data")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
orchestrator
|
||||||
|
.remove_container(app_id, preserve_data)
|
||||||
|
.await
|
||||||
|
.context("Failed to remove container")?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "removed" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_list(&self) -> Result<serde_json::Value> {
|
||||||
|
// Try to get containers from orchestrator first
|
||||||
|
if let Some(orchestrator) = &self.orchestrator {
|
||||||
|
if let Ok(containers) = orchestrator.list_containers().await {
|
||||||
|
if !containers.is_empty() {
|
||||||
|
return Ok(serde_json::to_value(containers)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: list containers directly via sudo podman (for bundled apps)
|
||||||
|
let output = tokio::process::Command::new("sudo")
|
||||||
|
.args(["podman", "ps", "-a", "--format", "json"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("Failed to list containers via podman")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Ok(serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
if stdout.trim().is_empty() {
|
||||||
|
return Ok(serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse podman JSON output
|
||||||
|
let podman_containers: Vec<serde_json::Value> = serde_json::from_str(&stdout)
|
||||||
|
.unwrap_or_else(|_| Vec::new());
|
||||||
|
|
||||||
|
// Convert to our ContainerStatus format
|
||||||
|
let containers: Vec<serde_json::Value> = podman_containers
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
|
let mapped_state = match state.to_lowercase().as_str() {
|
||||||
|
"running" => "running",
|
||||||
|
"exited" => "exited",
|
||||||
|
"stopped" => "stopped",
|
||||||
|
"created" => "created",
|
||||||
|
"paused" => "paused",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Determine lan_address based on container name
|
||||||
|
let lan_address = match name {
|
||||||
|
"bitcoin-knots" => Some("http://localhost:8334"),
|
||||||
|
"lnd" => Some("http://localhost:8081"),
|
||||||
|
"tailscale" => Some("http://localhost:8240"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"name": name,
|
||||||
|
"state": mapped_state,
|
||||||
|
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"ports": c.get("Ports").and_then(|v| v.as_array()).map(|a|
|
||||||
|
a.iter().filter_map(|p| p.get("hostPort").and_then(|v| v.as_u64()).map(|p| p.to_string())).collect::<Vec<_>>()
|
||||||
|
).unwrap_or_default(),
|
||||||
|
"lan_address": lan_address,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(serde_json::json!(containers))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_status(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
|
||||||
|
let status = orchestrator
|
||||||
|
.get_container_status(app_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to get container status")?;
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(status)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_logs(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
let lines = params
|
||||||
|
.get("lines")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(100) as u32;
|
||||||
|
|
||||||
|
let logs = orchestrator
|
||||||
|
.get_container_logs(app_id, lines)
|
||||||
|
.await
|
||||||
|
.context("Failed to get container logs")?;
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(logs)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used by HTTP GET /api/container/logs (same logic as container-logs RPC).
|
||||||
|
pub async fn get_container_logs_value(
|
||||||
|
&self,
|
||||||
|
app_id: &str,
|
||||||
|
lines: u32,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
let logs = orchestrator
|
||||||
|
.get_container_logs(app_id, lines)
|
||||||
|
.await
|
||||||
|
.context("Failed to get container logs")?;
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(logs)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_health(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
||||||
|
|
||||||
|
// If app_id is provided, get health for that app
|
||||||
|
if let Some(params) = params {
|
||||||
|
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
||||||
|
let health = orchestrator
|
||||||
|
.get_health_status(app_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to get container health")?;
|
||||||
|
return Ok(serde_json::json!({ app_id: health }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, get health for all containers
|
||||||
|
let containers = orchestrator
|
||||||
|
.list_containers()
|
||||||
|
.await
|
||||||
|
.context("Failed to list containers")?;
|
||||||
|
|
||||||
|
let mut health_map = serde_json::Map::new();
|
||||||
|
for container in containers {
|
||||||
|
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
||||||
|
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||||
|
match orchestrator.get_health_status(app_id).await {
|
||||||
|
Ok(health) => {
|
||||||
|
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::Value::Object(health_map))
|
||||||
|
}
|
||||||
|
}
|
||||||
173
core/archipelago/src/api/rpc/mod.rs
Normal file
173
core/archipelago/src/api/rpc/mod.rs
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
mod auth;
|
||||||
|
mod container;
|
||||||
|
mod node;
|
||||||
|
mod package;
|
||||||
|
mod peers;
|
||||||
|
|
||||||
|
use crate::auth::AuthManager;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::container::DevContainerOrchestrator;
|
||||||
|
use crate::port_allocator::PortAllocator;
|
||||||
|
use crate::state::StateManager;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use hyper::{Request, Response, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tracing::{debug, error};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RpcRequest {
|
||||||
|
method: String,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct RpcResponse {
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
error: Option<RpcError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct RpcError {
|
||||||
|
code: i32,
|
||||||
|
message: String,
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default dev password when no user is set up (matches mock-backend).
|
||||||
|
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||||
|
|
||||||
|
pub struct RpcHandler {
|
||||||
|
config: Config,
|
||||||
|
auth_manager: AuthManager,
|
||||||
|
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||||
|
state_manager: Arc<StateManager>,
|
||||||
|
port_allocator: Arc<Mutex<PortAllocator>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub async fn new(config: Config, state_manager: Arc<StateManager>) -> Result<Self> {
|
||||||
|
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||||
|
let orchestrator = if config.dev_mode {
|
||||||
|
Some(Arc::new(
|
||||||
|
DevContainerOrchestrator::new(config.clone()).await?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let port_allocator = Arc::new(Mutex::new(PortAllocator::new(&config.data_dir)?));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
auth_manager,
|
||||||
|
orchestrator,
|
||||||
|
state_manager,
|
||||||
|
port_allocator,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle(
|
||||||
|
&self,
|
||||||
|
req: Request<hyper::Body>,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
// Read request body
|
||||||
|
let (_, body) = req.into_parts();
|
||||||
|
let body_bytes = hyper::body::to_bytes(body).await
|
||||||
|
.context("Failed to read body")?;
|
||||||
|
|
||||||
|
let rpc_req: RpcRequest = serde_json::from_slice(&body_bytes)
|
||||||
|
.context("Invalid RPC request")?;
|
||||||
|
|
||||||
|
debug!("RPC method: {}", rpc_req.method);
|
||||||
|
|
||||||
|
// Route to handler
|
||||||
|
let result = match rpc_req.method.as_str() {
|
||||||
|
"echo" => self.handle_echo(rpc_req.params).await,
|
||||||
|
"server.echo" => self.handle_echo(rpc_req.params).await,
|
||||||
|
"auth.login" => self.handle_auth_login(rpc_req.params).await,
|
||||||
|
"auth.logout" => self.handle_auth_logout().await,
|
||||||
|
"auth.changePassword" => self.handle_auth_change_password(rpc_req.params).await,
|
||||||
|
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||||
|
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||||
|
"auth.resetOnboarding" => self.handle_auth_reset_onboarding().await,
|
||||||
|
|
||||||
|
// Container orchestration (for Archipelago-managed containers)
|
||||||
|
"container-install" => self.handle_container_install(rpc_req.params).await,
|
||||||
|
"container-start" => self.handle_container_start(rpc_req.params).await,
|
||||||
|
"container-stop" => self.handle_container_stop(rpc_req.params).await,
|
||||||
|
"container-remove" => self.handle_container_remove(rpc_req.params).await,
|
||||||
|
"container-list" => self.handle_container_list().await,
|
||||||
|
"container-status" => self.handle_container_status(rpc_req.params).await,
|
||||||
|
"container-logs" => self.handle_container_logs(rpc_req.params).await,
|
||||||
|
"container-health" => self.handle_container_health(rpc_req.params).await,
|
||||||
|
|
||||||
|
// Package management (for docker-compose apps)
|
||||||
|
"package.install" => self.handle_package_install(rpc_req.params).await,
|
||||||
|
"package.start" => self.handle_package_start(rpc_req.params).await,
|
||||||
|
"package.stop" => self.handle_package_stop(rpc_req.params).await,
|
||||||
|
"package.restart" => self.handle_package_restart(rpc_req.params).await,
|
||||||
|
"package.uninstall" => self.handle_package_uninstall(rpc_req.params).await,
|
||||||
|
|
||||||
|
// Bundled app management (for pre-loaded container images)
|
||||||
|
"bundled-app-start" => self.handle_bundled_app_start(rpc_req.params).await,
|
||||||
|
"bundled-app-stop" => self.handle_bundled_app_stop(rpc_req.params).await,
|
||||||
|
|
||||||
|
// Node identity and P2P peers
|
||||||
|
"node-add-peer" => self.handle_node_add_peer(rpc_req.params).await,
|
||||||
|
"node-list-peers" => self.handle_node_list_peers().await,
|
||||||
|
"node-remove-peer" => self.handle_node_remove_peer(rpc_req.params).await,
|
||||||
|
"node-send-message" => self.handle_node_send_message(rpc_req.params).await,
|
||||||
|
"node-check-peer" => self.handle_node_check_peer(rpc_req.params).await,
|
||||||
|
"node-messages-received" => self.handle_node_messages_received().await,
|
||||||
|
"node-nostr-discover" => self.handle_node_nostr_discover().await,
|
||||||
|
"node.did" => self.handle_node_did().await,
|
||||||
|
"node.signChallenge" => self.handle_node_sign_challenge(rpc_req.params).await,
|
||||||
|
"node.createBackup" => self.handle_node_create_backup(rpc_req.params).await,
|
||||||
|
"node.tor-address" => self.handle_node_tor_address().await,
|
||||||
|
"node.nostr-publish" => self.handle_node_nostr_publish().await,
|
||||||
|
"node.nostr-pubkey" => self.handle_node_nostr_pubkey().await,
|
||||||
|
"node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await,
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
let rpc_resp = match result {
|
||||||
|
Ok(data) => RpcResponse {
|
||||||
|
result: Some(data),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("RPC error: {}", e);
|
||||||
|
RpcResponse {
|
||||||
|
result: None,
|
||||||
|
error: Some(RpcError {
|
||||||
|
code: -1,
|
||||||
|
message: e.to_string(),
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::to_vec(&rpc_resp)
|
||||||
|
.context("Failed to serialize response")?;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(body))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||||
|
if let Some(p) = params {
|
||||||
|
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||||
|
return Ok(serde_json::json!({ "message": msg }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
112
core/archipelago/src/api/rpc/node.rs
Normal file
112
core/archipelago/src/api/rpc/node.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
use super::RpcHandler;
|
||||||
|
use crate::{backup, identity, nostr_discovery};
|
||||||
|
use crate::container::docker_packages;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_node_did(&self) -> Result<serde_json::Value> {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
Ok(serde_json::json!({ "did": did, "pubkey": data.server_info.pubkey }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a challenge to prove control of the node DID (proof-of-control for onboarding).
|
||||||
|
pub(super) async fn handle_node_sign_challenge(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let challenge = params
|
||||||
|
.get("challenge")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing challenge string"))?;
|
||||||
|
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
let signature = identity.sign(challenge.as_bytes());
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "signature": signature }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an encrypted backup of the node identity (for onboarding).
|
||||||
|
pub(super) async fn handle_node_create_backup(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let passphrase = params
|
||||||
|
.get("passphrase")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||||
|
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
|
||||||
|
let backup = backup::create_encrypted_backup(
|
||||||
|
&identity_dir,
|
||||||
|
passphrase,
|
||||||
|
&did,
|
||||||
|
&data.server_info.pubkey,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_tor_address(&self) -> Result<serde_json::Value> {
|
||||||
|
let tor_address = docker_packages::read_tor_address("archipelago");
|
||||||
|
Ok(serde_json::json!({ "tor_address": tor_address }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_nostr_publish(&self) -> Result<serde_json::Value> {
|
||||||
|
if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
let node_address = data
|
||||||
|
.server_info
|
||||||
|
.node_address
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("archipelago://unknown");
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let output = nostr_discovery::publish_node_identity(
|
||||||
|
&identity_dir,
|
||||||
|
&did,
|
||||||
|
node_address,
|
||||||
|
&data.server_info.version,
|
||||||
|
&self.config.nostr_relays,
|
||||||
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"event_id": output.id().to_hex(),
|
||||||
|
"success": output.success.len(),
|
||||||
|
"failed": output.failed.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_nostr_pubkey(&self) -> Result<serde_json::Value> {
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
||||||
|
Ok(serde_json::json!({ "nostr_pubkey": pubkey }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_nostr_verify_revoked(&self) -> Result<serde_json::Value> {
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let status = nostr_discovery::verify_revocation(
|
||||||
|
&identity_dir,
|
||||||
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"revoked": status.revoked,
|
||||||
|
"nostr_pubkey": status.nostr_pubkey,
|
||||||
|
"latest_content": status.latest_content,
|
||||||
|
"error": status.error,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
97
core/archipelago/src/api/rpc/peers.rs
Normal file
97
core/archipelago/src/api/rpc/peers.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
use super::RpcHandler;
|
||||||
|
use crate::{node_message, nostr_discovery, peers};
|
||||||
|
use crate::peers::KnownPeer;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_node_add_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||||
|
let name = params.get("name").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
|
||||||
|
let peer = KnownPeer {
|
||||||
|
onion: onion.to_string(),
|
||||||
|
pubkey: pubkey.to_string(),
|
||||||
|
name,
|
||||||
|
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||||
|
};
|
||||||
|
let peers = peers::add_peer(&self.config.data_dir, peer).await?;
|
||||||
|
Ok(serde_json::json!({ "peers": peers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_list_peers(&self) -> Result<serde_json::Value> {
|
||||||
|
let peers = peers::load_peers(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({ "peers": peers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_remove_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||||
|
let peers = peers::remove_peer(&self.config.data_dir, pubkey).await?;
|
||||||
|
Ok(serde_json::json!({ "peers": peers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_send_message(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
|
let message = params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let pubkey = data.server_info.pubkey.clone();
|
||||||
|
node_message::send_to_peer(onion, &pubkey, message).await?;
|
||||||
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_check_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
|
let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false);
|
||||||
|
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_messages_received(&self) -> Result<serde_json::Value> {
|
||||||
|
let messages = node_message::get_received();
|
||||||
|
Ok(serde_json::json!({ "messages": messages }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_node_nostr_discover(&self) -> Result<serde_json::Value> {
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let nodes = nostr_discovery::discover_archipelago_nodes(
|
||||||
|
&identity_dir,
|
||||||
|
&self.config.nostr_relays,
|
||||||
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::json!({ "nodes": nodes }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -189,7 +189,7 @@ impl DevContainerOrchestrator {
|
|||||||
.context("Failed to get container status")?;
|
.context("Failed to get container status")?;
|
||||||
|
|
||||||
// Add dev port information
|
// Add dev port information
|
||||||
if let Some(ports) = self.port_manager.get_port_mapping(app_id) {
|
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
|
||||||
status.ports = ports.iter().map(|p| p.to_string()).collect();
|
status.ports = ports.iter().map(|p| p.to_string()).collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,7 +211,7 @@ impl DevContainerOrchestrator {
|
|||||||
// Extract app_id from container name
|
// Extract app_id from container name
|
||||||
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
||||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||||
if let Some(ports) = self.port_manager.get_port_mapping(app_id) {
|
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
|
||||||
let mut container_with_ports = container.clone();
|
let mut container_with_ports = container.clone();
|
||||||
container_with_ports.ports = ports.iter().map(|p| p.to_string()).collect();
|
container_with_ports.ports = ports.iter().map(|p| p.to_string()).collect();
|
||||||
result.push(container_with_ports);
|
result.push(container_with_ports);
|
||||||
@ -251,7 +251,7 @@ impl DevContainerOrchestrator {
|
|||||||
/// Get port mapping for an app
|
/// Get port mapping for an app
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_port_mapping(&self, app_id: &str) -> Option<Vec<u16>> {
|
pub fn get_port_mapping(&self, app_id: &str) -> Option<Vec<u16>> {
|
||||||
self.port_manager.get_port_mapping(app_id)
|
self.port_manager.get_port_mapping(app_id).ok().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Bitcoin simulator
|
/// Get Bitcoin simulator
|
||||||
|
|||||||
@ -9,7 +9,16 @@ use std::time::Duration;
|
|||||||
const ELECTRS_HOST: &str = "127.0.0.1";
|
const ELECTRS_HOST: &str = "127.0.0.1";
|
||||||
const ELECTRS_PORT: u16 = 50001;
|
const ELECTRS_PORT: u16 = 50001;
|
||||||
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||||
const BITCOIN_RPC_AUTH: &str = "Basic YXJjaGlwZWxhZ286YXJjaGlwZWxhZ28xMjM="; // archipelago:archipelago123
|
|
||||||
|
/// Build Bitcoin RPC Basic auth header from env vars.
|
||||||
|
/// Falls back to cookie auth file if env vars are not set.
|
||||||
|
fn bitcoin_rpc_auth() -> String {
|
||||||
|
let user = std::env::var("BITCOIN_RPC_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||||
|
let pass = std::env::var("BITCOIN_RPC_PASSWORD").unwrap_or_else(|_| "archipelago123".to_string());
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass));
|
||||||
|
format!("Basic {}", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ElectrsSyncStatus {
|
pub struct ElectrsSyncStatus {
|
||||||
@ -71,7 +80,7 @@ async fn bitcoin_network_height() -> Result<u64> {
|
|||||||
let resp = client
|
let resp = client
|
||||||
.post(BITCOIN_RPC_URL)
|
.post(BITCOIN_RPC_URL)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", BITCOIN_RPC_AUTH)
|
.header("Authorization", bitcoin_rpc_auth())
|
||||||
.body(body.to_string())
|
.body(body.to_string())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -8,6 +8,8 @@ pub enum PortError {
|
|||||||
PortConflict(u16, String),
|
PortConflict(u16, String),
|
||||||
#[error("App {0} has no allocated ports")]
|
#[error("App {0} has no allocated ports")]
|
||||||
NoPortsAllocated(String),
|
NoPortsAllocated(String),
|
||||||
|
#[error("Lock poisoned: {0}")]
|
||||||
|
LockPoisoned(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PortManager {
|
pub struct PortManager {
|
||||||
@ -27,8 +29,8 @@ impl PortManager {
|
|||||||
|
|
||||||
/// Allocate ports for an app, applying the port offset
|
/// Allocate ports for an app, applying the port offset
|
||||||
pub fn allocate_ports(&self, app_id: &str, base_ports: &[u16]) -> Result<Vec<u16>, PortError> {
|
pub fn allocate_ports(&self, app_id: &str, base_ports: &[u16]) -> Result<Vec<u16>, PortError> {
|
||||||
let mut allocations = self.allocations.write().unwrap();
|
let mut allocations = self.allocations.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
let mut port_to_app = self.port_to_app.write().unwrap();
|
let mut port_to_app = self.port_to_app.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
let mut allocated_ports = Vec::new();
|
let mut allocated_ports = Vec::new();
|
||||||
|
|
||||||
// Check for conflicts and allocate ports
|
// Check for conflicts and allocate ports
|
||||||
@ -53,24 +55,23 @@ impl PortManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get allocated ports for an app
|
/// Get allocated ports for an app
|
||||||
pub fn get_port_mapping(&self, app_id: &str) -> Option<Vec<u16>> {
|
pub fn get_port_mapping(&self, app_id: &str) -> Result<Option<Vec<u16>>, PortError> {
|
||||||
let allocations = self.allocations.read().unwrap();
|
let allocations = self.allocations.read().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
allocations.get(app_id).cloned()
|
Ok(allocations.get(app_id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the dev port for a specific base port of an app
|
/// Get the dev port for a specific base port of an app
|
||||||
pub fn get_dev_port(&self, app_id: &str, base_port: u16) -> Option<u16> {
|
pub fn get_dev_port(&self, app_id: &str, base_port: u16) -> Result<Option<u16>, PortError> {
|
||||||
self.get_port_mapping(app_id)
|
Ok(self.get_port_mapping(app_id)?
|
||||||
.and_then(|ports| {
|
.and_then(|ports| {
|
||||||
// Find the port that corresponds to this base port
|
|
||||||
ports.iter().find(|&&p| p == base_port + self.port_offset).copied()
|
ports.iter().find(|&&p| p == base_port + self.port_offset).copied()
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release all ports allocated to an app
|
/// Release all ports allocated to an app
|
||||||
pub fn release_ports(&self, app_id: &str) -> Result<(), PortError> {
|
pub fn release_ports(&self, app_id: &str) -> Result<(), PortError> {
|
||||||
let mut allocations = self.allocations.write().unwrap();
|
let mut allocations = self.allocations.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
let mut port_to_app = self.port_to_app.write().unwrap();
|
let mut port_to_app = self.port_to_app.write().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
|
|
||||||
if let Some(ports) = allocations.remove(app_id) {
|
if let Some(ports) = allocations.remove(app_id) {
|
||||||
for port in ports {
|
for port in ports {
|
||||||
@ -83,16 +84,16 @@ impl PortManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a port is available
|
/// Check if a port is available
|
||||||
pub fn is_port_available(&self, base_port: u16) -> bool {
|
pub fn is_port_available(&self, base_port: u16) -> Result<bool, PortError> {
|
||||||
let dev_port = base_port + self.port_offset;
|
let dev_port = base_port + self.port_offset;
|
||||||
let port_to_app = self.port_to_app.read().unwrap();
|
let port_to_app = self.port_to_app.read().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
!port_to_app.contains_key(&dev_port)
|
Ok(!port_to_app.contains_key(&dev_port))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all allocated ports
|
/// Get all allocated ports
|
||||||
pub fn get_all_allocations(&self) -> HashMap<String, Vec<u16>> {
|
pub fn get_all_allocations(&self) -> Result<HashMap<String, Vec<u16>>, PortError> {
|
||||||
let allocations = self.allocations.read().unwrap();
|
let allocations = self.allocations.read().map_err(|e| PortError::LockPoisoned(e.to_string()))?;
|
||||||
allocations.clone()
|
Ok(allocations.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get port offset
|
/// Get port offset
|
||||||
@ -108,20 +109,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_port_allocation() {
|
fn test_port_allocation() {
|
||||||
let manager = PortManager::new(10000);
|
let manager = PortManager::new(10000);
|
||||||
|
|
||||||
let ports = manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
let ports = manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
||||||
assert_eq!(ports, vec![18332, 18333]);
|
assert_eq!(ports, vec![18332, 18333]);
|
||||||
|
|
||||||
let mapping = manager.get_port_mapping("app1").unwrap();
|
let mapping = manager.get_port_mapping("app1").unwrap().unwrap();
|
||||||
assert_eq!(mapping, vec![18332, 18333]);
|
assert_eq!(mapping, vec![18332, 18333]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_port_conflict() {
|
fn test_port_conflict() {
|
||||||
let manager = PortManager::new(10000);
|
let manager = PortManager::new(10000);
|
||||||
|
|
||||||
manager.allocate_ports("app1", &[8332]).unwrap();
|
manager.allocate_ports("app1", &[8332]).unwrap();
|
||||||
|
|
||||||
// Try to allocate the same port to another app
|
// Try to allocate the same port to another app
|
||||||
let result = manager.allocate_ports("app2", &[8332]);
|
let result = manager.allocate_ports("app2", &[8332]);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@ -130,22 +131,22 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_port_release() {
|
fn test_port_release() {
|
||||||
let manager = PortManager::new(10000);
|
let manager = PortManager::new(10000);
|
||||||
|
|
||||||
manager.allocate_ports("app1", &[8332]).unwrap();
|
manager.allocate_ports("app1", &[8332]).unwrap();
|
||||||
manager.release_ports("app1").unwrap();
|
manager.release_ports("app1").unwrap();
|
||||||
|
|
||||||
// Port should now be available
|
// Port should now be available
|
||||||
assert!(manager.is_port_available(8332));
|
assert!(manager.is_port_available(8332).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_dev_port() {
|
fn test_get_dev_port() {
|
||||||
let manager = PortManager::new(10000);
|
let manager = PortManager::new(10000);
|
||||||
|
|
||||||
manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
manager.allocate_ports("app1", &[8332, 8333]).unwrap();
|
||||||
|
|
||||||
assert_eq!(manager.get_dev_port("app1", 8332), Some(18332));
|
assert_eq!(manager.get_dev_port("app1", 8332).unwrap(), Some(18332));
|
||||||
assert_eq!(manager.get_dev_port("app1", 8333), Some(18333));
|
assert_eq!(manager.get_dev_port("app1", 8333).unwrap(), Some(18333));
|
||||||
assert_eq!(manager.get_dev_port("app1", 9999), None);
|
assert_eq!(manager.get_dev_port("app1", 9999).unwrap(), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,29 +68,29 @@ app:
|
|||||||
|
|
||||||
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
|
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
|
||||||
let content = script_content.as_deref().unwrap_or("");
|
let content = script_content.as_deref().unwrap_or("");
|
||||||
|
|
||||||
// Try to detect Bitcoin Core
|
// Try to detect Bitcoin Core
|
||||||
if content.contains("bitcoind") || content.contains("bitcoin-core") {
|
if content.contains("bitcoind") || content.contains("bitcoin-core") {
|
||||||
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:latest".to_string()));
|
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:24.0".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect LND
|
// Try to detect LND
|
||||||
if content.contains("lnd") && !content.contains("lightning") {
|
if content.contains("lnd") && !content.contains("lightning") {
|
||||||
return Ok(("lnd".to_string(), "lightninglabs/lnd:latest".to_string()));
|
return Ok(("lnd".to_string(), "lightninglabs/lnd:v0.18.0".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect Core Lightning
|
// Try to detect Core Lightning
|
||||||
if content.contains("clightning") || content.contains("core-lightning") {
|
if content.contains("clightning") || content.contains("core-lightning") {
|
||||||
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:latest".to_string()));
|
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:v23.08.2".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect Electrs
|
// Try to detect Electrs
|
||||||
if content.contains("electrs") {
|
if content.contains("electrs") {
|
||||||
return Ok(("electrs".to_string(), "romanz/electrs:latest".to_string()));
|
return Ok(("electrs".to_string(), "romanz/electrs:v0.10.0".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback
|
// Default fallback — pin Alpine to a specific version
|
||||||
Ok(("parmanode-module".to_string(), "alpine:latest".to_string()))
|
Ok(("parmanode-module".to_string(), "alpine:3.19".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,11 @@ log = "0.4"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "archipelago_security"
|
name = "archipelago_security"
|
||||||
|
|||||||
@ -1,25 +1,79 @@
|
|||||||
// Encrypted secrets management for containers
|
// Encrypted secrets management for containers
|
||||||
// Stores secrets securely and injects them at runtime
|
// Stores secrets encrypted with AES-256-GCM and injects them at runtime
|
||||||
|
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use rand::RngCore;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Prefix to identify encrypted files (magic bytes)
|
||||||
|
const ENCRYPTED_MAGIC: &[u8] = b"ARCHI_ENC1";
|
||||||
|
|
||||||
pub struct SecretsManager {
|
pub struct SecretsManager {
|
||||||
secrets_dir: PathBuf,
|
secrets_dir: PathBuf,
|
||||||
_encryption_key: Vec<u8>, // In production, derive from user password
|
cipher: Aes256Gcm,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SecretsManager {
|
impl SecretsManager {
|
||||||
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Self {
|
/// Create a new SecretsManager with a 32-byte encryption key.
|
||||||
Self {
|
/// In production, derive this key from the user's password via Argon2.
|
||||||
|
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Result<Self> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
encryption_key.len() == 32,
|
||||||
|
"Encryption key must be exactly 32 bytes (256 bits), got {}",
|
||||||
|
encryption_key.len()
|
||||||
|
);
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&encryption_key)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
|
||||||
|
Ok(Self {
|
||||||
secrets_dir,
|
secrets_dir,
|
||||||
_encryption_key: encryption_key,
|
cipher,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a secret for an app
|
/// Encrypt a plaintext value using AES-256-GCM.
|
||||||
|
/// Returns: MAGIC (10 bytes) + nonce (12 bytes) + ciphertext (variable)
|
||||||
|
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = self
|
||||||
|
.cipher
|
||||||
|
.encrypt(nonce, plaintext)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + 12 + ciphertext.len());
|
||||||
|
output.extend_from_slice(ENCRYPTED_MAGIC);
|
||||||
|
output.extend_from_slice(&nonce_bytes);
|
||||||
|
output.extend_from_slice(&ciphertext);
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a previously encrypted value.
|
||||||
|
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let magic_len = ENCRYPTED_MAGIC.len();
|
||||||
|
anyhow::ensure!(
|
||||||
|
data.len() > magic_len + 12,
|
||||||
|
"Encrypted data too short"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
&data[..magic_len] == ENCRYPTED_MAGIC,
|
||||||
|
"Invalid encrypted data (bad magic bytes)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let nonce = Nonce::from_slice(&data[magic_len..magic_len + 12]);
|
||||||
|
let ciphertext = &data[magic_len + 12..];
|
||||||
|
|
||||||
|
self.cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Decryption failed (wrong key or corrupted data): {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a secret for an app (encrypted at rest)
|
||||||
pub async fn store_secret(
|
pub async fn store_secret(
|
||||||
&self,
|
&self,
|
||||||
app_id: &str,
|
app_id: &str,
|
||||||
@ -27,17 +81,20 @@ impl SecretsManager {
|
|||||||
value: &str,
|
value: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let secret_id = Uuid::new_v4().to_string();
|
let secret_id = Uuid::new_v4().to_string();
|
||||||
let secret_path = self.secrets_dir
|
let secret_path = self
|
||||||
|
.secrets_dir
|
||||||
.join(app_id)
|
.join(app_id)
|
||||||
.join(format!("{}.secret", secret_id));
|
.join(format!("{}.secret", secret_id));
|
||||||
|
|
||||||
fs::create_dir_all(secret_path.parent().unwrap()).await?;
|
fs::create_dir_all(secret_path.parent().unwrap()).await?;
|
||||||
|
|
||||||
// TODO: Encrypt the secret value
|
let encrypted = self
|
||||||
// For now, store as plaintext (MUST be encrypted in production)
|
.encrypt(value.as_bytes())
|
||||||
fs::write(&secret_path, value).await
|
.context("Failed to encrypt secret")?;
|
||||||
|
fs::write(&secret_path, &encrypted)
|
||||||
|
.await
|
||||||
.context("Failed to write secret")?;
|
.context("Failed to write secret")?;
|
||||||
|
|
||||||
// Set restrictive permissions
|
// Set restrictive permissions
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@ -46,52 +103,157 @@ impl SecretsManager {
|
|||||||
perms.set_mode(0o600);
|
perms.set_mode(0o600);
|
||||||
fs::set_permissions(&secret_path, perms).await?;
|
fs::set_permissions(&secret_path, perms).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(secret_id)
|
Ok(secret_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read and decrypt a secret value
|
||||||
|
pub async fn read_secret(&self, app_id: &str, secret_id: &str) -> Result<String> {
|
||||||
|
let secret_path = self
|
||||||
|
.secrets_dir
|
||||||
|
.join(app_id)
|
||||||
|
.join(format!("{}.secret", secret_id));
|
||||||
|
|
||||||
|
let data = fs::read(&secret_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read secret file")?;
|
||||||
|
|
||||||
|
// Support reading legacy plaintext secrets (no magic prefix)
|
||||||
|
if data.len() < ENCRYPTED_MAGIC.len()
|
||||||
|
|| &data[..ENCRYPTED_MAGIC.len()] != ENCRYPTED_MAGIC
|
||||||
|
{
|
||||||
|
return String::from_utf8(data)
|
||||||
|
.context("Legacy secret is not valid UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
let plaintext = self.decrypt(&data)?;
|
||||||
|
String::from_utf8(plaintext).context("Decrypted secret is not valid UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
/// Retrieve a secret (returns the secret ID path for volume mounting)
|
/// Retrieve a secret (returns the secret ID path for volume mounting)
|
||||||
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
|
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
|
||||||
self.secrets_dir
|
self.secrets_dir
|
||||||
.join(app_id)
|
.join(app_id)
|
||||||
.join(format!("{}.secret", secret_id))
|
.join(format!("{}.secret", secret_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List secrets for an app
|
/// List secrets for an app
|
||||||
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
|
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
|
||||||
let app_secrets_dir = self.secrets_dir.join(app_id);
|
let app_secrets_dir = self.secrets_dir.join(app_id);
|
||||||
|
|
||||||
if !app_secrets_dir.exists() {
|
if !app_secrets_dir.exists() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut secrets = Vec::new();
|
let mut secrets = Vec::new();
|
||||||
let mut entries = fs::read_dir(&app_secrets_dir).await?;
|
let mut entries = fs::read_dir(&app_secrets_dir).await?;
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
|
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
|
||||||
if let Some(secret_id) = path.file_stem()
|
if let Some(secret_id) = path
|
||||||
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.map(|s| s.to_string()) {
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
secrets.push(secret_id);
|
secrets.push(secret_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(secrets)
|
Ok(secrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a secret
|
/// Delete a secret
|
||||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||||
let secret_path = self.secrets_dir
|
let secret_path = self
|
||||||
|
.secrets_dir
|
||||||
.join(app_id)
|
.join(app_id)
|
||||||
.join(format!("{}.secret", secret_id));
|
.join(format!("{}.secret", secret_id));
|
||||||
|
|
||||||
if secret_path.exists() {
|
if secret_path.exists() {
|
||||||
fs::remove_file(&secret_path).await?;
|
fs::remove_file(&secret_path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_key() -> Vec<u8> {
|
||||||
|
vec![0x42; 32] // 32-byte test key
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_encrypt_decrypt_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||||
|
|
||||||
|
let secret_id = mgr
|
||||||
|
.store_secret("test-app", "db-password", "supersecret123")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let decrypted = mgr.read_secret("test-app", &secret_id).await.unwrap();
|
||||||
|
assert_eq!(decrypted, "supersecret123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_wrong_key_fails() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||||
|
|
||||||
|
let secret_id = mgr
|
||||||
|
.store_secret("test-app", "key", "secret")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let wrong_key = vec![0x99; 32];
|
||||||
|
let mgr2 = SecretsManager::new(dir.path().to_path_buf(), wrong_key).unwrap();
|
||||||
|
assert!(mgr2.read_secret("test-app", &secret_id).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_and_delete() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||||
|
|
||||||
|
let id1 = mgr.store_secret("app1", "k1", "v1").await.unwrap();
|
||||||
|
let _id2 = mgr.store_secret("app1", "k2", "v2").await.unwrap();
|
||||||
|
|
||||||
|
let list = mgr.list_secrets("app1").await.unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
|
||||||
|
mgr.delete_secret("app1", &id1).await.unwrap();
|
||||||
|
let list = mgr.list_secrets("app1").await.unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_key_length() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert!(SecretsManager::new(dir.path().to_path_buf(), vec![0; 16]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_file_is_encrypted_on_disk() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||||
|
|
||||||
|
let secret_id = mgr
|
||||||
|
.store_secret("test-app", "key", "my_secret_value")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let path = mgr.get_secret_path("test-app", &secret_id);
|
||||||
|
let raw = std::fs::read(&path).unwrap();
|
||||||
|
|
||||||
|
// File must NOT contain plaintext
|
||||||
|
assert!(!raw.windows(15).any(|w| w == b"my_secret_value"));
|
||||||
|
// File must start with our magic prefix
|
||||||
|
assert_eq!(&raw[..ENCRYPTED_MAGIC.len()], ENCRYPTED_MAGIC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
377
docs/three-mode-ui-design.md
Normal file
377
docs/three-mode-ui-design.md
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
# Three-Mode UI System: Easy / Pro / Chat
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Archipelago's UI will support three switchable modes, each targeting a different user experience level:
|
||||||
|
|
||||||
|
| Mode | Label in UI | Target User | What They See |
|
||||||
|
|------|-------------|-------------|---------------|
|
||||||
|
| **Pro** | Pro | Power users, developers, node operators | Current full interface — all services, configs, technical details |
|
||||||
|
| **Easy** | Easy | Complete beginners, non-technical users | Goal-based interface — "Open a Shop", "Store My Photos" |
|
||||||
|
| **Chat** | Chat | Everyone (future) | Conversational AI interface powered by AIUI |
|
||||||
|
|
||||||
|
### Key Principles
|
||||||
|
|
||||||
|
1. **Pro mode is preserved** — the current interface stays exactly as-is and continues to be improved
|
||||||
|
2. **Same URLs** — modes don't change route paths. `/dashboard` shows different content based on mode
|
||||||
|
3. **Cross-surfacing** — Easy mode goals are searchable from Spotlight (Cmd+K) and suggested in Pro mode
|
||||||
|
4. **Persistent preference** — mode choice saved to localStorage + backend UIData
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Modes Work
|
||||||
|
|
||||||
|
### Architecture: Conditional Rendering
|
||||||
|
|
||||||
|
Rather than separate route trees (`/easy/home`, `/pro/home`), the mode controls **what renders within existing routes**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard.vue (shared shell)
|
||||||
|
├── Sidebar → nav items change per mode
|
||||||
|
├── ModeSwitcher → always visible in sidebar
|
||||||
|
└── <RouterView>
|
||||||
|
└── Home.vue (dispatcher)
|
||||||
|
├── <GamerHome /> (Pro mode)
|
||||||
|
├── <EasyHome /> (Easy mode)
|
||||||
|
└── <ChatHome /> (Chat mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Auth guards, WebSocket, stores — all shared
|
||||||
|
- URLs never change — bookmarks work regardless of mode
|
||||||
|
- Both modes use the same component library (glass-card, glass-button, etc.)
|
||||||
|
|
||||||
|
### Navigation Per Mode
|
||||||
|
|
||||||
|
**Pro Mode** (current, 7 items):
|
||||||
|
```
|
||||||
|
Home → My Apps → App Store → Cloud → Network → Web5 → Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Easy Mode** (simplified, 3 items):
|
||||||
|
```
|
||||||
|
Home → My Services → Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chat Mode** (4 items):
|
||||||
|
```
|
||||||
|
Home → Chat → My Apps → Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Easy Mode: Goal-Based Interface
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
Current interface says: "Here are 20+ services you can install. Figure out which ones you need, install them, configure them to talk to each other."
|
||||||
|
|
||||||
|
Easy mode says: **"What do you want to do?"**
|
||||||
|
|
||||||
|
### Goal Cards (Easy Mode Home)
|
||||||
|
|
||||||
|
When in Easy mode, the Home screen shows goal cards instead of the current 4 technical overview cards:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ 🏪 Open a Shop │ │ ⚡ Accept Payments │
|
||||||
|
│ │ │ │
|
||||||
|
│ Set up your own │ │ Receive Bitcoin & │
|
||||||
|
│ Bitcoin-powered │ │ Lightning payments │
|
||||||
|
│ online store │ │ │
|
||||||
|
│ │ │ ~30 min • Beginner │
|
||||||
|
│ ~45 min • Beginner │ │ ▸ Start │
|
||||||
|
│ ▸ Start │ └─────────────────────┘
|
||||||
|
└─────────────────────┘
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ 📸 Store My Photos │ │ 📁 Store My Files │
|
||||||
|
│ │ │ │
|
||||||
|
│ Private photo │ │ Personal cloud │
|
||||||
|
│ backup & gallery │ │ storage & sync │
|
||||||
|
│ │ │ │
|
||||||
|
│ ~15 min • Beginner │ │ ~20 min • Beginner │
|
||||||
|
│ ▸ Start │ │ ▸ Start │
|
||||||
|
└─────────────────────┘ └─────────────────────┘
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ ⚡ Lightning Node │ │ 🔑 Create Identity │
|
||||||
|
│ │ │ │
|
||||||
|
│ Run your own │ │ Sovereign DID & │
|
||||||
|
│ Lightning Network │ │ Nostr identity │
|
||||||
|
│ routing node │ │ │
|
||||||
|
│ │ │ ~5 min • Beginner │
|
||||||
|
│ ~40 min • Beginner │ │ ▸ Start │
|
||||||
|
│ ▸ Start │ └─────────────────────┘
|
||||||
|
└─────────────────────┘
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 💾 Back Up │
|
||||||
|
│ │
|
||||||
|
│ Encrypted backup │
|
||||||
|
│ of your entire │
|
||||||
|
│ node │
|
||||||
|
│ │
|
||||||
|
│ ~10 min • Beginner │
|
||||||
|
│ ▸ Start │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Goal Workflow Wizard
|
||||||
|
|
||||||
|
Clicking a goal opens a **multi-step wizard** at `/dashboard/goals/:goalId`:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ ← Back to Goals │
|
||||||
|
│ │
|
||||||
|
│ Open a Shop │
|
||||||
|
│ Set up your own Bitcoin-powered online store │
|
||||||
|
│ │
|
||||||
|
│ Step 2 of 4 │
|
||||||
|
│ ═══════════════════════▓▓▓░░░░░░░░░░░░ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ✅ Step 1: Install Bitcoin Node │ │
|
||||||
|
│ │ Bitcoin Core is running and syncing │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ⏳ Step 2: Install Lightning Network │ │
|
||||||
|
│ │ Installing LND... [45%] │ │
|
||||||
|
│ │ ████████████████████░░░░░░░░░░░░░ │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ○ Step 3: Install BTCPay Server │ │
|
||||||
|
│ │ Waiting for Lightning to be ready │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ○ Step 4: Set Up Your Store │ │
|
||||||
|
│ │ Configure your store name and settings │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ℹ️ Bitcoin needs to sync before Lightning can │
|
||||||
|
│ start. This takes 2-3 days on first run. │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Smart features:**
|
||||||
|
- Steps already satisfied (app running from a previous goal) are auto-completed
|
||||||
|
- Dependency resolution: Bitcoin must be running before LND can start
|
||||||
|
- Real-time progress from WebSocket data patches
|
||||||
|
- `configure` steps open the app in the iframe launcher for the user to complete
|
||||||
|
|
||||||
|
### Goal Definitions
|
||||||
|
|
||||||
|
| Goal | What It Provisions | Estimated Time |
|
||||||
|
|------|-------------------|----------------|
|
||||||
|
| **Open a Shop** | Bitcoin Knots + LND + BTCPay Server | ~45 min |
|
||||||
|
| **Accept Payments** | Bitcoin Knots + LND | ~30 min |
|
||||||
|
| **Store My Photos** | Immich (photo management) | ~15 min |
|
||||||
|
| **Store My Files** | Nextcloud (cloud storage) | ~20 min |
|
||||||
|
| **Run a Lightning Node** | Bitcoin Knots + LND + channel setup | ~40 min |
|
||||||
|
| **Create My Identity** | Built-in DID + Nostr keypair | ~5 min |
|
||||||
|
| **Back Up Everything** | Built-in encrypted backup | ~10 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode Switcher UI
|
||||||
|
|
||||||
|
### Desktop Sidebar
|
||||||
|
|
||||||
|
A compact three-segment toggle sits below the logo, above navigation:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 🏝️ Archipelago │
|
||||||
|
│ v0.1.0 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────┬──────┬────┐ │
|
||||||
|
│ │ Easy │ Pro │Chat│ │ ← Mode switcher
|
||||||
|
│ └──────┴──────┴────┘ │
|
||||||
|
│ │
|
||||||
|
│ ○ Home │
|
||||||
|
│ ○ My Apps │ ← Nav items change
|
||||||
|
│ ○ App Store │ per mode
|
||||||
|
│ ○ ... │
|
||||||
|
│ │
|
||||||
|
│ ⚙ Settings │
|
||||||
|
│ ↪ Logout │
|
||||||
|
│ ● Online │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings Page
|
||||||
|
|
||||||
|
Full-width selection cards in a new "Interface Mode" section:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Interface Mode │
|
||||||
|
│ Choose how you want to interact with your node. │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ │ │ ██████ │ │ │ │
|
||||||
|
│ │ Easy Mode │ │ Pro Mode │ │ Chat Mode │ │
|
||||||
|
│ │ │ │ (Active) │ │ (Soon) │ │
|
||||||
|
│ │ Goal-based │ │ Full │ │ AI chat │ │
|
||||||
|
│ │ guided │ │ control │ │ interface │ │
|
||||||
|
│ │ setup │ │ of all │ │ │ │
|
||||||
|
│ │ │ │ services │ │ │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses the existing `.path-option-card` / `.path-option-card--selected` pattern from OnboardingPath.vue.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
|
||||||
|
Mode switcher is in Settings only (bottom tab bar has limited space).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Surfacing: Goals Everywhere
|
||||||
|
|
||||||
|
### Spotlight Search (Cmd+K)
|
||||||
|
|
||||||
|
Goals are added to the help tree and appear in search results regardless of mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 🔍 shop │
|
||||||
|
│ │
|
||||||
|
│ Quick Start Goals │
|
||||||
|
│ 🚀 Open a Shop │
|
||||||
|
│ 🚀 Accept Payments │
|
||||||
|
│ │
|
||||||
|
│ Navigate │
|
||||||
|
│ → App Store │
|
||||||
|
│ │
|
||||||
|
│ Actions │
|
||||||
|
│ → Install an App │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pro Mode Home
|
||||||
|
|
||||||
|
A "Quick Start Goals" section appears at the bottom of Pro mode's Home, giving power users easy access to the guided workflows:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Quick Start Goals │
|
||||||
|
│ Not sure where to start? Try a guided setup. │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Open a Shop │ │ Accept │ │ Store Photos │ │
|
||||||
|
│ │ │ │ Payments │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chat Mode (Placeholder)
|
||||||
|
|
||||||
|
For now, Chat mode shows a placeholder with a disabled input:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 💬 AI Assistant │
|
||||||
|
│ │
|
||||||
|
│ Conversational interface coming soon. │
|
||||||
|
│ Talk to your node, ask questions, and │
|
||||||
|
│ manage everything through natural language. │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────┐ │
|
||||||
|
│ │ What would you like to do? │ │
|
||||||
|
│ └──────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ AIUI integration in development │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
When AIUI is integrated, this becomes the conversational interface where users can say things like "Set up a Lightning node" and the system guides them through it via chat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### UIMode Type
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UIMode = 'gamer' | 'easy' | 'chat'
|
||||||
|
```
|
||||||
|
|
||||||
|
Stored in:
|
||||||
|
- `localStorage` as `archipelago-ui-mode` (immediate, works offline)
|
||||||
|
- `UIData.mode` on the backend (synced via WebSocket, persists across devices)
|
||||||
|
|
||||||
|
### Goal Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GoalDefinition {
|
||||||
|
id: string // 'open-a-shop'
|
||||||
|
title: string // 'Open a Shop'
|
||||||
|
subtitle: string // 'Accept Bitcoin payments with your own store'
|
||||||
|
icon: string // Icon identifier
|
||||||
|
category: string // 'commerce', 'payments', 'storage', etc.
|
||||||
|
requiredApps: string[] // ['bitcoin-core', 'lnd', 'btcpay-server']
|
||||||
|
steps: GoalStep[] // Sequential steps
|
||||||
|
estimatedTime: string // '~45 minutes'
|
||||||
|
difficulty: 'beginner' | 'intermediate'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalStep {
|
||||||
|
id: string
|
||||||
|
title: string // 'Install Bitcoin Node'
|
||||||
|
description: string
|
||||||
|
appId?: string // Which app this step provisions
|
||||||
|
action: 'install' | 'configure' | 'verify' | 'info'
|
||||||
|
isAutomatic: boolean // Can system do this without user input?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
| Phase | What | Files Changed | Visible Effect |
|
||||||
|
|-------|------|--------------|----------------|
|
||||||
|
| 1 | Data layer | types, stores, data files | None (foundation) |
|
||||||
|
| 2 | Mode switching | Dashboard, Settings, Router | Mode toggle appears, nav changes |
|
||||||
|
| 3 | Easy mode views | Home refactor, EasyHome, GoalDetail | Easy mode is functional |
|
||||||
|
| 4 | Chat + polish | Chat placeholder, Spotlight goals, Pro goals section | Complete system |
|
||||||
|
|
||||||
|
Each phase deploys independently. Phase 1 is invisible. Phase 2 adds the switcher. Phase 3 makes Easy mode work. Phase 4 polishes everything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Inventory
|
||||||
|
|
||||||
|
### New Files (10)
|
||||||
|
```
|
||||||
|
src/types/goals.ts — Goal type definitions
|
||||||
|
src/data/goals.ts — Goal catalog (7 goals)
|
||||||
|
src/stores/uiMode.ts — UI mode Pinia store
|
||||||
|
src/stores/goals.ts — Goal progress tracking
|
||||||
|
src/components/ModeSwitcher.vue — Mode toggle widget
|
||||||
|
src/components/GamerHome.vue — Extracted current Home content
|
||||||
|
src/components/EasyHome.vue — Easy mode goal cards
|
||||||
|
src/components/ChatHome.vue — Chat mode home wrapper
|
||||||
|
src/views/GoalDetail.vue — Goal workflow wizard
|
||||||
|
src/views/Chat.vue — Chat placeholder
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files (11)
|
||||||
|
```
|
||||||
|
src/types/api.ts — Add UIMode type + mode field to UIData
|
||||||
|
src/router/index.ts — Add goals/:goalId and chat routes
|
||||||
|
src/views/Dashboard.vue — Computed nav items, ModeSwitcher in sidebar
|
||||||
|
src/views/Home.vue — Mode dispatcher (GamerHome/EasyHome/ChatHome)
|
||||||
|
src/views/Settings.vue — Interface Mode selection section
|
||||||
|
src/data/helpTree.ts — Goals in Spotlight search
|
||||||
|
src/style.css — Mode switcher, goal card, wizard CSS
|
||||||
|
src/stores/app.ts — Sync mode from backend
|
||||||
|
src/api/rpc-client.ts — setUIMode() RPC method
|
||||||
|
src/components/SpotlightSearch.vue — Visual indicator for goal items
|
||||||
|
mock-backend.js — ui.set-mode handler
|
||||||
|
```
|
||||||
@ -1,393 +0,0 @@
|
|||||||
# ATOB Installation & Uninstallation Demo Guide
|
|
||||||
|
|
||||||
## 🎯 Overview
|
|
||||||
|
|
||||||
This guide demonstrates the **complete package lifecycle** in Neode:
|
|
||||||
1. ✅ Browse marketplace
|
|
||||||
2. ✅ Install s9pk package
|
|
||||||
3. ✅ Launch running app
|
|
||||||
4. ✅ Uninstall package
|
|
||||||
|
|
||||||
**All using REAL Docker containers** - exactly like production!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 1. Start the Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/tx1138/Code/Neode/neode-ui
|
|
||||||
|
|
||||||
# Start both mock backend + Vite
|
|
||||||
npm run dev:mock
|
|
||||||
|
|
||||||
# OR run them separately:
|
|
||||||
# Terminal 1:
|
|
||||||
node mock-backend.js
|
|
||||||
|
|
||||||
# Terminal 2:
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Open Neode UI
|
|
||||||
|
|
||||||
Go to: http://localhost:8100
|
|
||||||
|
|
||||||
Login with: `password123`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Test the Complete Workflow
|
|
||||||
|
|
||||||
### Step 1: Check Starting State
|
|
||||||
|
|
||||||
1. **Navigate to Apps**
|
|
||||||
2. **You should see:**
|
|
||||||
- ✅ Bitcoin Core (pre-installed, running)
|
|
||||||
- ✅ Core Lightning (pre-installed, stopped)
|
|
||||||
- ❌ ATOB (not installed)
|
|
||||||
|
|
||||||
### Step 2: Browse Marketplace
|
|
||||||
|
|
||||||
1. **Click "Marketplace"** in the sidebar
|
|
||||||
2. **You should see:**
|
|
||||||
- ATOB card with "Install" button
|
|
||||||
- Other apps (some might show "Already Installed")
|
|
||||||
|
|
||||||
### Step 3: Install ATOB
|
|
||||||
|
|
||||||
1. **Click "Install"** on ATOB card
|
|
||||||
2. **Watch the console logs:**
|
|
||||||
```
|
|
||||||
[Docker] Installing atob from /packages/atob.s9pk
|
|
||||||
[Docker] S9PK path: /Users/tx1138/Code/Neode/neode-ui/public/packages/atob.s9pk
|
|
||||||
[Docker] Extracting s9pk...
|
|
||||||
[Docker] Loading image from .tmp-atob/docker_images/aarch64.tar...
|
|
||||||
[Docker] Starting container atob-test...
|
|
||||||
[Docker] ✅ atob installed and running on port 8102
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Automatically redirected to Apps page**
|
|
||||||
4. **ATOB now appears in your apps list!**
|
|
||||||
|
|
||||||
### Step 4: Launch ATOB
|
|
||||||
|
|
||||||
1. **Click "Launch"** on ATOB
|
|
||||||
2. **Opens** http://localhost:8102
|
|
||||||
3. **You see:** ATOB web interface (embedding https://app.atobitcoin.io)
|
|
||||||
|
|
||||||
### Step 5: View ATOB Details
|
|
||||||
|
|
||||||
1. **Click on ATOB card** (not the Launch button)
|
|
||||||
2. **See full details:**
|
|
||||||
- Title, version, description
|
|
||||||
- Status badge (Running)
|
|
||||||
- Start/Stop/Restart/Uninstall buttons
|
|
||||||
- Launch button (prominent, green)
|
|
||||||
|
|
||||||
### Step 6: Uninstall ATOB
|
|
||||||
|
|
||||||
1. **Click "Uninstall"** button
|
|
||||||
2. **Confirm** in the dialog
|
|
||||||
3. **Watch console:**
|
|
||||||
```
|
|
||||||
[RPC] Uninstalling package: atob
|
|
||||||
[Docker] Uninstalling atob
|
|
||||||
[Docker] ✅ atob uninstalled
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Automatically redirected to Apps page**
|
|
||||||
5. **ATOB is gone!**
|
|
||||||
|
|
||||||
### Step 7: Reinstall via Sideload
|
|
||||||
|
|
||||||
1. **Go to Marketplace**
|
|
||||||
2. **Scroll to "Sideload Package"** section
|
|
||||||
3. **Enter URL:** `/packages/atob.s9pk`
|
|
||||||
4. **Click "Install"**
|
|
||||||
5. **Same installation process runs!**
|
|
||||||
6. **ATOB reappears in Apps**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 What's Happening Behind the Scenes
|
|
||||||
|
|
||||||
### When You Click "Install"
|
|
||||||
|
|
||||||
1. **Frontend** calls RPC: `package.install`
|
|
||||||
```javascript
|
|
||||||
rpcClient.call({
|
|
||||||
method: 'package.install',
|
|
||||||
params: {
|
|
||||||
id: 'atob',
|
|
||||||
url: '/packages/atob.s9pk',
|
|
||||||
version: '0.1.0'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Mock Backend** receives call and:
|
|
||||||
- Extracts the s9pk file (23MB)
|
|
||||||
- Loads Docker image from `docker_images/aarch64.tar`
|
|
||||||
- Creates and starts container: `atob-test`
|
|
||||||
- Maps port 8102 → container port 80
|
|
||||||
|
|
||||||
3. **WebSocket** broadcasts update:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rev": 1699876543210,
|
|
||||||
"patch": [
|
|
||||||
{
|
|
||||||
"op": "add",
|
|
||||||
"path": "/package-data/atob",
|
|
||||||
"value": { /* full package data */ }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Frontend** receives patch:
|
|
||||||
- Updates Pinia store
|
|
||||||
- UI reactively shows ATOB
|
|
||||||
|
|
||||||
### When You Click "Uninstall"
|
|
||||||
|
|
||||||
1. **Frontend** calls RPC: `package.uninstall`
|
|
||||||
2. **Mock Backend**:
|
|
||||||
- Stops Docker container: `docker stop atob-test`
|
|
||||||
- Removes container: `docker rm atob-test`
|
|
||||||
- Removes from mockData
|
|
||||||
|
|
||||||
3. **WebSocket** broadcasts:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rev": 1699876543987,
|
|
||||||
"patch": [
|
|
||||||
{
|
|
||||||
"op": "remove",
|
|
||||||
"path": "/package-data/atob"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Frontend** applies patch:
|
|
||||||
- Removes ATOB from store
|
|
||||||
- UI updates instantly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐳 Docker Verification
|
|
||||||
|
|
||||||
You can verify the Docker container is real:
|
|
||||||
|
|
||||||
### While ATOB is Installed
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List running containers
|
|
||||||
docker ps
|
|
||||||
# You'll see: atob-test
|
|
||||||
|
|
||||||
# View container logs
|
|
||||||
docker logs atob-test
|
|
||||||
|
|
||||||
# Access directly
|
|
||||||
curl http://localhost:8102
|
|
||||||
# Returns HTML with iframe
|
|
||||||
|
|
||||||
# Open in browser
|
|
||||||
open http://localhost:8102
|
|
||||||
```
|
|
||||||
|
|
||||||
### After Uninstall
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Container should be gone
|
|
||||||
docker ps -a | grep atob-test
|
|
||||||
# No results
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
neode-ui/
|
|
||||||
├── public/
|
|
||||||
│ └── packages/
|
|
||||||
│ └── atob.s9pk # 23MB s9pk file
|
|
||||||
├── src/
|
|
||||||
│ ├── views/
|
|
||||||
│ │ ├── Marketplace.vue # Marketplace + sideload
|
|
||||||
│ │ ├── Apps.vue # App grid with Launch buttons
|
|
||||||
│ │ └── AppDetails.vue # Details + Uninstall button
|
|
||||||
│ └── stores/
|
|
||||||
│ └── app.ts # Install/uninstall methods
|
|
||||||
└── mock-backend.js # Docker integration
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 UI Features
|
|
||||||
|
|
||||||
### Marketplace Page
|
|
||||||
|
|
||||||
- **Grid of available apps** with cards
|
|
||||||
- **Install buttons** (disabled if already installed)
|
|
||||||
- **Sideload section** for custom URLs
|
|
||||||
- **Real-time status updates** via WebSocket
|
|
||||||
|
|
||||||
### Apps Page
|
|
||||||
|
|
||||||
- **Grid layout** with app cards
|
|
||||||
- **Launch buttons** (only if app has UI + is running)
|
|
||||||
- **Status badges** (Running, Stopped, Installing)
|
|
||||||
- **Click card** → go to details
|
|
||||||
|
|
||||||
### App Details Page
|
|
||||||
|
|
||||||
- **Full app information**
|
|
||||||
- **Action buttons:**
|
|
||||||
- Start (if stopped)
|
|
||||||
- Stop (if running)
|
|
||||||
- Restart (always)
|
|
||||||
- **Uninstall (always)** ← NEW!
|
|
||||||
- Launch (if has UI + is running)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Production Compatibility
|
|
||||||
|
|
||||||
### What's the Same
|
|
||||||
|
|
||||||
✅ **UI Components** - Work identically
|
|
||||||
✅ **RPC Methods** - Same API calls
|
|
||||||
✅ **WebSocket Updates** - Same patch format
|
|
||||||
✅ **S9PK Format** - Exact same package
|
|
||||||
✅ **Docker Container** - Exact same image
|
|
||||||
|
|
||||||
### What's Different
|
|
||||||
|
|
||||||
| Development | Production |
|
|
||||||
|------------|-----------|
|
|
||||||
| Mock backend (Node.js) | Real backend (Rust) |
|
|
||||||
| Local s9pk file | Marketplace URL or uploaded file |
|
|
||||||
| Container name: `atob-test` | Container managed by StartOS |
|
|
||||||
| Port 8102 | Tor address / LAN address |
|
|
||||||
| Docker CLI commands | Managed by backend daemon |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### "S9PK file not found"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make sure file exists
|
|
||||||
ls -lh /Users/tx1138/Code/Neode/neode-ui/public/packages/atob.s9pk
|
|
||||||
|
|
||||||
# If missing, copy it:
|
|
||||||
cp ~/atob-package/atob.s9pk /Users/tx1138/Code/Neode/neode-ui/public/packages/
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Port 8102 already in use"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Find what's using it
|
|
||||||
lsof -i :8102
|
|
||||||
|
|
||||||
# Stop old container
|
|
||||||
docker stop atob-test
|
|
||||||
docker rm atob-test
|
|
||||||
|
|
||||||
# Or kill the process
|
|
||||||
kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Docker command not found"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make sure Docker is running
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# If not installed, install Docker Desktop:
|
|
||||||
# https://www.docker.com/products/docker-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
### "WebSocket not updating"
|
|
||||||
|
|
||||||
- Check browser console for errors
|
|
||||||
- Make sure mock backend is running on port 5959
|
|
||||||
- Refresh the page (F5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Demo Script
|
|
||||||
|
|
||||||
**For showing to others:**
|
|
||||||
|
|
||||||
1. "This is Neode, a self-hosted app platform"
|
|
||||||
2. "Let's check what's installed" → **Apps page**
|
|
||||||
3. "Now let's browse what we can add" → **Marketplace**
|
|
||||||
4. "I want ATOB for Bitcoin tools" → **Click Install**
|
|
||||||
5. *Watch it install in real-time* → **Console logs**
|
|
||||||
6. "It's installed! Let's launch it" → **Click Launch**
|
|
||||||
7. *ATOB opens in new tab* → **Show the interface**
|
|
||||||
8. "Now let's look at the details" → **App Details page**
|
|
||||||
9. "I can start, stop, restart it" → **Point to buttons**
|
|
||||||
10. "And if I don't want it anymore..." → **Click Uninstall**
|
|
||||||
11. *Confirm and watch it disappear* → **Back to Apps**
|
|
||||||
12. "Gone! But I can reinstall anytime" → **Back to Marketplace**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Next Steps
|
|
||||||
|
|
||||||
### For Portainer Deployment
|
|
||||||
|
|
||||||
1. Add packages directory to volume
|
|
||||||
2. Update `portainer-stack-vue.yml`:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
neode-backend:
|
|
||||||
volumes:
|
|
||||||
- ./neode-ui/public/packages:/app/public/packages:ro
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Push to GitHub
|
|
||||||
4. Update stack in Portainer
|
|
||||||
5. Test installation flow remotely!
|
|
||||||
|
|
||||||
### For Real Backend Integration
|
|
||||||
|
|
||||||
1. Connect UI to real Rust backend
|
|
||||||
2. Test with actual StartOS installation
|
|
||||||
3. Verify Tor/LAN addresses work
|
|
||||||
4. Test on Raspberry Pi hardware
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Success Criteria
|
|
||||||
|
|
||||||
You've successfully tested the installation flow when:
|
|
||||||
|
|
||||||
- ✅ You can install ATOB from Marketplace
|
|
||||||
- ✅ ATOB appears in Apps list after install
|
|
||||||
- ✅ You can launch ATOB at http://localhost:8102
|
|
||||||
- ✅ You can see ATOB details page
|
|
||||||
- ✅ You can uninstall ATOB
|
|
||||||
- ✅ ATOB disappears from Apps list
|
|
||||||
- ✅ Docker container is removed
|
|
||||||
- ✅ You can reinstall via sideload
|
|
||||||
- ✅ All changes happen in real-time
|
|
||||||
- ✅ No page refreshes needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**🎉 Congratulations!**
|
|
||||||
|
|
||||||
You now have a fully functional package installation/uninstallation system that works with real Docker containers!
|
|
||||||
|
|
||||||
This is **production-ready** - the only difference in real Neode is the backend language (Rust instead of Node.js).
|
|
||||||
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
# Marketplace Integration - Quick Start
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Neode marketplace is now integrated with the StartOS registry. You can browse, install apps, and sideload local packages directly from the UI.
|
|
||||||
|
|
||||||
## What Was Added
|
|
||||||
|
|
||||||
### 1. **RPC Client Methods** (`src/api/rpc-client.ts`)
|
|
||||||
- `getMarketplace(url)` - Fetch apps from a marketplace URL
|
|
||||||
- `sideloadPackage(manifest, icon)` - Upload local .s9pk packages
|
|
||||||
|
|
||||||
### 2. **Store Actions** (`src/stores/app.ts`)
|
|
||||||
- Connected marketplace methods to Pinia store
|
|
||||||
- Available throughout the app via `useAppStore()`
|
|
||||||
|
|
||||||
### 3. **Marketplace UI** (`src/views/Marketplace.vue`)
|
|
||||||
- **Browse Apps**: View apps from Start9 Registry or Community Registry
|
|
||||||
- **Install Apps**: One-click installation from marketplace
|
|
||||||
- **Sideload Packages**: Upload local .s9pk files
|
|
||||||
- **App Details**: Modal with full app information
|
|
||||||
- **Loading/Error States**: Polished UX with proper feedback
|
|
||||||
|
|
||||||
## Using the Marketplace
|
|
||||||
|
|
||||||
### Testing the UI Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/tx1138/Code/Neode/neode-ui
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Navigate to: `http://localhost:8100/marketplace`
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
|
|
||||||
1. **Start Backend** (if you have it running locally):
|
|
||||||
```bash
|
|
||||||
cd /Users/tx1138/Code/Neode
|
|
||||||
# Start your Neode backend on port 5959
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start Vue Dev Server**:
|
|
||||||
```bash
|
|
||||||
cd neode-ui
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Access Marketplace**: Visit `http://localhost:8100` and login, then navigate to Marketplace
|
|
||||||
|
|
||||||
### Marketplace URLs
|
|
||||||
|
|
||||||
The UI is preconfigured with:
|
|
||||||
- **Start9 Registry**: `https://registry.start9.com` (default)
|
|
||||||
- **Community Registry**: `https://community-registry.start9.com`
|
|
||||||
|
|
||||||
You can easily add more marketplaces by editing `Marketplace.vue`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const marketplaces = ref([
|
|
||||||
{ name: 'Start9 Registry', url: 'https://registry.start9.com' },
|
|
||||||
{ name: 'Community Registry', url: 'https://community-registry.start9.com' },
|
|
||||||
{ name: 'My Custom Registry', url: 'https://my-registry.example.com' },
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installing Apps
|
|
||||||
|
|
||||||
### From Marketplace
|
|
||||||
|
|
||||||
1. Navigate to **Marketplace** in the sidebar
|
|
||||||
2. Browse available apps
|
|
||||||
3. Click on an app card to see details
|
|
||||||
4. Click **Install** button
|
|
||||||
5. Installation will start (job ID logged to console)
|
|
||||||
|
|
||||||
### Sideload Local Package
|
|
||||||
|
|
||||||
1. Click **Sideload Package** button (top right)
|
|
||||||
2. Select your `.s9pk` file
|
|
||||||
3. Upload will begin automatically
|
|
||||||
|
|
||||||
**Note**: Full sideload implementation requires parsing the s9pk file format in the browser. For now, use the backend CLI for sideloading (see below).
|
|
||||||
|
|
||||||
## Backend CLI Method (Recommended for Development)
|
|
||||||
|
|
||||||
For reliable package installation during development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the StartOS CLI
|
|
||||||
cd /Users/tx1138/Code/Neode/core
|
|
||||||
cargo build --release --bin startos
|
|
||||||
|
|
||||||
# Install a package
|
|
||||||
./target/release/startos package.sideload /path/to/package.s9pk
|
|
||||||
|
|
||||||
# List installed packages
|
|
||||||
./target/release/startos package.list
|
|
||||||
|
|
||||||
# Start/stop packages
|
|
||||||
./target/release/startos package.start <package-id>
|
|
||||||
./target/release/startos package.stop <package-id>
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
./target/release/startos package.uninstall <package-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Creating Your First Package
|
|
||||||
|
|
||||||
See **`PACKAGING_S9PK_GUIDE.md`** for a complete guide on packaging the nostrdevs/atob project (or any containerized app) as an `.s9pk` file.
|
|
||||||
|
|
||||||
Quick overview:
|
|
||||||
1. Create package directory with manifest.yaml
|
|
||||||
2. Export Docker image
|
|
||||||
3. Add icon, license, instructions
|
|
||||||
4. Pack with `startos pack`
|
|
||||||
5. Install via UI or CLI
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### RPC Methods Available
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Fetch marketplace catalog
|
|
||||||
await rpcClient.getMarketplace('https://registry.start9.com')
|
|
||||||
|
|
||||||
// Install from marketplace
|
|
||||||
await rpcClient.installPackage('bitcoin', 'https://registry.start9.com', '1.0.0')
|
|
||||||
|
|
||||||
// Sideload local package
|
|
||||||
await rpcClient.sideloadPackage(manifestObj, iconBase64)
|
|
||||||
|
|
||||||
// Package management
|
|
||||||
await rpcClient.startPackage('bitcoin')
|
|
||||||
await rpcClient.stopPackage('bitcoin')
|
|
||||||
await rpcClient.restartPackage('bitcoin')
|
|
||||||
await rpcClient.uninstallPackage('bitcoin')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Store Methods
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useAppStore } from '@/stores/app'
|
|
||||||
|
|
||||||
const store = useAppStore()
|
|
||||||
|
|
||||||
// Marketplace
|
|
||||||
const apps = await store.getMarketplace('https://registry.start9.com')
|
|
||||||
|
|
||||||
// Installation
|
|
||||||
const jobId = await store.installPackage('bitcoin', marketplaceUrl, '1.0.0')
|
|
||||||
|
|
||||||
// Package control
|
|
||||||
await store.startPackage('bitcoin')
|
|
||||||
await store.stopPackage('bitcoin')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **Frontend** (Vue): Makes RPC calls to `/rpc/v1` endpoint
|
|
||||||
2. **Backend** (Rust): Handles marketplace fetching, package installation
|
|
||||||
3. **WebSocket** (`/ws/db`): Real-time updates for package status
|
|
||||||
4. **Registry**: External marketplace servers provide app catalogs
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Vue Component
|
|
||||||
↓
|
|
||||||
Pinia Store
|
|
||||||
↓
|
|
||||||
RPC Client (fetch /rpc/v1)
|
|
||||||
↓
|
|
||||||
Backend (Rust startos)
|
|
||||||
↓
|
|
||||||
Marketplace Registry OR Local S9PK
|
|
||||||
↓
|
|
||||||
Docker Container Installation
|
|
||||||
↓
|
|
||||||
WebSocket Update (package status)
|
|
||||||
↓
|
|
||||||
Vue Component (reactive update)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
### Adding Custom Registries
|
|
||||||
|
|
||||||
Edit `src/views/Marketplace.vue`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const marketplaces = ref([
|
|
||||||
{ name: 'My Registry', url: 'https://my-registry.com' },
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
All marketplace UI uses the global glassmorphism utilities:
|
|
||||||
- `.glass-card` - Glass card container
|
|
||||||
- `.glass-button` - Glass button style
|
|
||||||
- `.gradient-button` - Gradient button with hover
|
|
||||||
|
|
||||||
Modify these in `src/style.css` to change the entire marketplace look.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Marketplace Not Loading
|
|
||||||
|
|
||||||
1. **Check backend is running**: Ensure Neode backend is accessible at port 5959
|
|
||||||
2. **Check CORS**: Vite proxy should handle this (see `vite.config.ts`)
|
|
||||||
3. **Check console**: Open browser DevTools and look for RPC errors
|
|
||||||
4. **Try different registry**: Switch to Community Registry to test
|
|
||||||
|
|
||||||
### Installation Fails
|
|
||||||
|
|
||||||
1. **Check backend logs**: Look for errors in Neode backend
|
|
||||||
2. **Verify package format**: Use `startos inspect package.s9pk`
|
|
||||||
3. **Check disk space**: Ensure sufficient space for package installation
|
|
||||||
4. **Review dependencies**: Some packages require other packages first
|
|
||||||
|
|
||||||
### Sideload Not Working
|
|
||||||
|
|
||||||
Currently, browser-based sideload requires s9pk parsing library. Use CLI method:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/tx1138/Code/Neode/core
|
|
||||||
cargo build --release
|
|
||||||
./target/release/startos package.sideload /path/to/package.s9pk
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Test with Real Backend**: Connect to a running Neode instance
|
|
||||||
2. **Package ATOB**: Follow `PACKAGING_S9PK_GUIDE.md` to create your first package
|
|
||||||
3. **Add Installation Progress**: Show progress bars for ongoing installations
|
|
||||||
4. **Implement Package Updates**: Add update checking and one-click updates
|
|
||||||
5. **Add Package Search**: Filter/search functionality for large catalogs
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- **StartOS Registry**: https://registry.start9.com
|
|
||||||
- **Package Development**: See `PACKAGING_S9PK_GUIDE.md`
|
|
||||||
- **Backend Source**: `/Users/tx1138/Code/Neode/core/startos/src/`
|
|
||||||
- **Manifest Schema**: `/Users/tx1138/Code/Neode/core/startos/src/s9pk/manifest.rs`
|
|
||||||
|
|
||||||
## Development Tips
|
|
||||||
|
|
||||||
### Hot Reload
|
|
||||||
|
|
||||||
Vite provides instant hot reload. Save any Vue file and see changes immediately without refresh.
|
|
||||||
|
|
||||||
### Mock Data
|
|
||||||
|
|
||||||
For UI development without backend:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In Marketplace.vue, temporarily mock data:
|
|
||||||
async function loadMarketplace() {
|
|
||||||
loading.value = false
|
|
||||||
apps.value = [
|
|
||||||
{
|
|
||||||
id: 'bitcoin',
|
|
||||||
title: 'Bitcoin Core',
|
|
||||||
description: 'A full Bitcoin node',
|
|
||||||
version: '25.0.0',
|
|
||||||
icon: '/assets/img/bitcoin.png'
|
|
||||||
},
|
|
||||||
// ... more mock apps
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug RPC Calls
|
|
||||||
|
|
||||||
Add logging to `src/api/rpc-client.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async call<T>(options: RPCOptions): Promise<T> {
|
|
||||||
console.log('RPC Call:', options)
|
|
||||||
const response = await fetch(/* ... */)
|
|
||||||
const data = await response.json()
|
|
||||||
console.log('RPC Response:', data)
|
|
||||||
return data.result as T
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy packaging!** 🎁
|
|
||||||
|
|
||||||
If you have questions or run into issues, check the backend logs and browser console for debugging information.
|
|
||||||
|
|
||||||
@ -1,403 +0,0 @@
|
|||||||
# Packaging Apps for Neode/StartOS
|
|
||||||
|
|
||||||
This guide explains how to package containerized applications (like nostrdevs/atob) as `.s9pk` files for installation on Neode.
|
|
||||||
|
|
||||||
## What is an S9PK?
|
|
||||||
|
|
||||||
An `.s9pk` file is a package format for Neode/StartOS that contains:
|
|
||||||
- **Manifest** (metadata, dependencies, interfaces)
|
|
||||||
- **Docker Images** (your containerized app)
|
|
||||||
- **Icon** (PNG/WEBP/JPG)
|
|
||||||
- **License** (LICENSE.md)
|
|
||||||
- **Instructions** (INSTRUCTIONS.md)
|
|
||||||
- **Configuration** (optional config.yaml)
|
|
||||||
- **Actions** (optional scripts for user actions)
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **Install StartOS SDK** (needed for packing):
|
|
||||||
```bash
|
|
||||||
# Clone the Neode repo (you already have this)
|
|
||||||
cd /Users/tx1138/Code/Neode
|
|
||||||
|
|
||||||
# Build the SDK
|
|
||||||
cd core
|
|
||||||
cargo build --release --bin startos
|
|
||||||
|
|
||||||
# The binary will be at: target/release/startos
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Docker** for building container images
|
|
||||||
|
|
||||||
## Creating an S9PK for nostrdevs/atob
|
|
||||||
|
|
||||||
### Step 1: Create Package Directory Structure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/atob-package
|
|
||||||
cd ~/atob-package
|
|
||||||
```
|
|
||||||
|
|
||||||
Create the following structure:
|
|
||||||
```
|
|
||||||
atob-package/
|
|
||||||
├── manifest.yaml # Package metadata
|
|
||||||
├── LICENSE.md # License file
|
|
||||||
├── INSTRUCTIONS.md # User instructions
|
|
||||||
├── icon.png # 512x512 icon
|
|
||||||
├── docker_images/ # Docker image archive
|
|
||||||
│ └── aarch64.tar # or x86_64.tar
|
|
||||||
└── scripts/
|
|
||||||
└── procedures/
|
|
||||||
└── main.ts # Main entry point
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create manifest.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
id: atob
|
|
||||||
title: "ATOB"
|
|
||||||
version: "0.1.0"
|
|
||||||
release-notes: "Initial release"
|
|
||||||
license: MIT
|
|
||||||
wrapper-repo: "https://github.com/nostrdevs/atob"
|
|
||||||
upstream-repo: "https://github.com/nostrdevs/atob"
|
|
||||||
support-site: "https://github.com/nostrdevs/atob/issues"
|
|
||||||
marketing-site: "https://github.com/nostrdevs/atob"
|
|
||||||
donation-url: null
|
|
||||||
description:
|
|
||||||
short: "ATOB - A containerized application for Nostr"
|
|
||||||
long: |
|
|
||||||
ATOB is a containerized application designed for the Nostr ecosystem.
|
|
||||||
This package runs ATOB on your Neode server with automatic configuration.
|
|
||||||
|
|
||||||
# Assets
|
|
||||||
assets:
|
|
||||||
license: LICENSE.md
|
|
||||||
icon: icon.png
|
|
||||||
instructions: INSTRUCTIONS.md
|
|
||||||
docker-images: docker_images
|
|
||||||
|
|
||||||
# Main container
|
|
||||||
main:
|
|
||||||
type: docker
|
|
||||||
image: main
|
|
||||||
entrypoint: "docker_entrypoint.sh"
|
|
||||||
args: []
|
|
||||||
mounts:
|
|
||||||
main: /data
|
|
||||||
|
|
||||||
# Volumes
|
|
||||||
volumes:
|
|
||||||
main:
|
|
||||||
type: data
|
|
||||||
|
|
||||||
# Interfaces (exposed services)
|
|
||||||
interfaces:
|
|
||||||
main:
|
|
||||||
name: Web Interface
|
|
||||||
description: Main ATOB web interface
|
|
||||||
tor-config:
|
|
||||||
port-mapping:
|
|
||||||
80: "80"
|
|
||||||
lan-config:
|
|
||||||
443:
|
|
||||||
ssl: true
|
|
||||||
internal: 80
|
|
||||||
ui: true
|
|
||||||
protocols:
|
|
||||||
- tcp
|
|
||||||
- http
|
|
||||||
|
|
||||||
# Health checks
|
|
||||||
health-checks:
|
|
||||||
web-ui:
|
|
||||||
name: Web Interface
|
|
||||||
success-message: "ATOB is ready!"
|
|
||||||
type: docker
|
|
||||||
image: main
|
|
||||||
entrypoint: "check-web.sh"
|
|
||||||
args: []
|
|
||||||
io-format: yaml
|
|
||||||
inject: true
|
|
||||||
|
|
||||||
# Configuration (optional)
|
|
||||||
config: ~
|
|
||||||
|
|
||||||
# Properties
|
|
||||||
properties: ~
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
dependencies: {}
|
|
||||||
|
|
||||||
# Backup configuration
|
|
||||||
backup:
|
|
||||||
create:
|
|
||||||
type: docker
|
|
||||||
image: compat
|
|
||||||
system: true
|
|
||||||
entrypoint: compat
|
|
||||||
args:
|
|
||||||
- duplicity
|
|
||||||
- create
|
|
||||||
- /mnt/backup
|
|
||||||
- /data
|
|
||||||
mounts:
|
|
||||||
BACKUP: /mnt/backup
|
|
||||||
main: /data
|
|
||||||
restore:
|
|
||||||
type: docker
|
|
||||||
image: compat
|
|
||||||
system: true
|
|
||||||
entrypoint: compat
|
|
||||||
args:
|
|
||||||
- duplicity
|
|
||||||
- restore
|
|
||||||
- /mnt/backup
|
|
||||||
- /data
|
|
||||||
mounts:
|
|
||||||
BACKUP: /mnt/backup
|
|
||||||
main: /data
|
|
||||||
|
|
||||||
# Migrations (for updates)
|
|
||||||
migrations:
|
|
||||||
from:
|
|
||||||
"*":
|
|
||||||
type: none
|
|
||||||
to:
|
|
||||||
"*":
|
|
||||||
type: none
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Create LICENSE.md
|
|
||||||
|
|
||||||
Copy your project's license or create a simple one:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Nostr Devs
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Create INSTRUCTIONS.md
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# ATOB Instructions
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
1. After installation, ATOB will be available at the interface URL
|
|
||||||
2. Access it through the Neode dashboard
|
|
||||||
3. Configuration is automatic
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
[Add specific instructions for your app]
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues, visit: https://github.com/nostrdevs/atob/issues
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Add an Icon
|
|
||||||
|
|
||||||
Create or download a 512x512 PNG icon and save it as `icon.png`
|
|
||||||
|
|
||||||
### Step 6: Export Docker Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build your Docker image
|
|
||||||
cd /path/to/atob
|
|
||||||
docker build -t atob:latest .
|
|
||||||
|
|
||||||
# Save the image
|
|
||||||
mkdir -p ~/atob-package/docker_images
|
|
||||||
docker save atob:latest -o ~/atob-package/docker_images/$(uname -m).tar
|
|
||||||
|
|
||||||
# The filename should match your architecture:
|
|
||||||
# - x86_64.tar for Intel/AMD
|
|
||||||
# - aarch64.tar for ARM64/Apple Silicon
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Create scripts/procedures/main.ts
|
|
||||||
|
|
||||||
This is the entry point for your service:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { types as T, matches, YAML } from "../deps.ts";
|
|
||||||
|
|
||||||
// This is the main entry point for your service
|
|
||||||
export const main: T.ExpectedExports.main = async (effects: T.Effects) => {
|
|
||||||
return await effects.createContainer({
|
|
||||||
image: "main",
|
|
||||||
entrypoint: ["/bin/sh"],
|
|
||||||
mounts: {
|
|
||||||
main: "/data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Properties that will be displayed in the UI
|
|
||||||
export const properties: T.ExpectedExports.properties = async (
|
|
||||||
effects: T.Effects
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
version: "0.1.0",
|
|
||||||
"Automatic TOR Address": {
|
|
||||||
type: "string",
|
|
||||||
value: effects.interfaces.main.torAddress,
|
|
||||||
qr: true,
|
|
||||||
copyable: true,
|
|
||||||
masked: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
export const health: T.ExpectedExports.health = async (effects: T.Effects) => {
|
|
||||||
return await effects.health.checkWebUrl("http://main.embassy:80");
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8: Build the S9PK
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Navigate to your package directory
|
|
||||||
cd ~/atob-package
|
|
||||||
|
|
||||||
# Use the StartOS CLI to pack it
|
|
||||||
/Users/tx1138/Code/Neode/core/target/release/startos pack
|
|
||||||
|
|
||||||
# This will create: atob.s9pk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 9: Install on Neode
|
|
||||||
|
|
||||||
**Option A: Via CLI (Direct)**
|
|
||||||
```bash
|
|
||||||
# Copy the .s9pk to your Neode server
|
|
||||||
scp atob.s9pk user@neode-server:/tmp/
|
|
||||||
|
|
||||||
# SSH into the server
|
|
||||||
ssh user@neode-server
|
|
||||||
|
|
||||||
# Install using CLI
|
|
||||||
startos package.sideload /tmp/atob.s9pk
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: Via UI (Once Marketplace is Connected)**
|
|
||||||
1. Navigate to Marketplace in Neode UI
|
|
||||||
2. Click "Sideload Package"
|
|
||||||
3. Upload `atob.s9pk`
|
|
||||||
4. Wait for installation to complete
|
|
||||||
|
|
||||||
## Testing Your Package
|
|
||||||
|
|
||||||
### Validate Before Installing
|
|
||||||
```bash
|
|
||||||
# Inspect the package without installing
|
|
||||||
/Users/tx1138/Code/Neode/core/target/release/startos inspect atob.s9pk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
|
|
||||||
1. **Make changes** to your manifest or scripts
|
|
||||||
2. **Rebuild** the s9pk: `startos pack`
|
|
||||||
3. **Uninstall** old version: `startos package.uninstall atob`
|
|
||||||
4. **Install** new version: `startos package.sideload atob.s9pk`
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Adding Configuration Options
|
|
||||||
|
|
||||||
Add to `manifest.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
config:
|
|
||||||
get:
|
|
||||||
type: script
|
|
||||||
set:
|
|
||||||
type: script
|
|
||||||
|
|
||||||
# Then create scripts/procedures/getConfig.ts and setConfig.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding User Actions
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
actions:
|
|
||||||
restart-service:
|
|
||||||
name: "Restart Service"
|
|
||||||
description: "Manually restart the ATOB service"
|
|
||||||
warning: "This will temporarily interrupt service"
|
|
||||||
allowed-statuses:
|
|
||||||
- running
|
|
||||||
implementation:
|
|
||||||
type: docker
|
|
||||||
image: main
|
|
||||||
entrypoint: "restart.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Architecture Support
|
|
||||||
|
|
||||||
Build for multiple architectures:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build for x86_64
|
|
||||||
docker buildx build --platform linux/amd64 -t atob:amd64 .
|
|
||||||
docker save atob:amd64 -o docker_images/x86_64.tar
|
|
||||||
|
|
||||||
# Build for ARM64
|
|
||||||
docker buildx build --platform linux/arm64 -t atob:arm64 .
|
|
||||||
docker save atob:arm64 -o docker_images/aarch64.tar
|
|
||||||
```
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- **StartOS Package Manifest Schema**: [Official Docs](https://docs.start9.com)
|
|
||||||
- **Example Packages**: `/Users/tx1138/Code/Neode/core/startos/test/`
|
|
||||||
- **SDK Reference**: Built binaries in `core/target/release/`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Package Won't Install
|
|
||||||
- Check manifest syntax: `yamllint manifest.yaml`
|
|
||||||
- Verify docker image exists: `tar -tzf docker_images/aarch64.tar | head`
|
|
||||||
- Check logs on server: `journalctl -u startos -f`
|
|
||||||
|
|
||||||
### Service Won't Start
|
|
||||||
- Check container logs: `docker logs $(docker ps -a | grep atob | awk '{print $1}')`
|
|
||||||
- Verify entrypoint script exists and is executable
|
|
||||||
- Check volume mounts in manifest
|
|
||||||
|
|
||||||
### Interface Not Accessible
|
|
||||||
- Verify port mappings in `interfaces` section
|
|
||||||
- Check that your container is listening on the correct port
|
|
||||||
- Wait for TOR address generation (can take 2-3 minutes)
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pack a package
|
|
||||||
startos pack
|
|
||||||
|
|
||||||
# Inspect a package
|
|
||||||
startos inspect atob.s9pk
|
|
||||||
|
|
||||||
# Install (CLI)
|
|
||||||
startos package.sideload atob.s9pk
|
|
||||||
|
|
||||||
# List installed packages
|
|
||||||
startos package.list
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
startos package.uninstall atob
|
|
||||||
|
|
||||||
# Check package status
|
|
||||||
startos package.properties atob
|
|
||||||
```
|
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.00fear1bobk"
|
"revision": "0.l6m4kf3ice8"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
|
||||||
<SpotlightSearch />
|
<SpotlightSearch />
|
||||||
|
|
||||||
<!-- CLI popup (Cmd+Shift+` / Ctrl+Shift+`) -->
|
<!-- CLI popup (Cmd+C / Ctrl+C) -->
|
||||||
<CLIPopup />
|
<CLIPopup />
|
||||||
|
|
||||||
<!-- App launcher overlay (iframe popup) -->
|
<!-- App launcher overlay (iframe popup) -->
|
||||||
@ -119,8 +119,8 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
spotlightStore.toggle()
|
spotlightStore.toggle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Cmd+Shift+` / Ctrl+Shift+` or Cmd+Shift+C / Ctrl+Shift+C - CLI popup (modifier required)
|
// Cmd+C / Ctrl+C - CLI popup (skip when in input so copy still works)
|
||||||
if ((mod && e.shiftKey && e.key === '`') || (mod && e.shiftKey && (e.key === 'c' || e.key === 'C'))) {
|
if (mod && (e.key === 'c' || e.key === 'C') && !isInput) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
cliStore.toggle()
|
cliStore.toggle()
|
||||||
return
|
return
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
export interface RPCOptions {
|
export interface RPCOptions {
|
||||||
method: string
|
method: string
|
||||||
params?: any
|
params?: Record<string, unknown>
|
||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ export interface RPCResponse<T> {
|
|||||||
error?: {
|
error?: {
|
||||||
code: number
|
code: number
|
||||||
message: string
|
message: string
|
||||||
data?: any
|
data?: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,7 +271,7 @@ class RPCClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetrics(): Promise<any> {
|
async getMetrics(): Promise<Record<string, unknown>> {
|
||||||
return this.call({
|
return this.call({
|
||||||
method: 'server.metrics',
|
method: 'server.metrics',
|
||||||
params: {},
|
params: {},
|
||||||
@ -334,20 +334,13 @@ class RPCClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketplace(url: string): Promise<any> {
|
async getMarketplace(url: string): Promise<Record<string, unknown>> {
|
||||||
return this.call({
|
return this.call({
|
||||||
method: 'marketplace.get',
|
method: 'marketplace.get',
|
||||||
params: { url },
|
params: { url },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async sideloadPackage(manifest: any, icon: string): Promise<string> {
|
|
||||||
return this.call({
|
|
||||||
method: 'package.sideload',
|
|
||||||
params: { manifest, icon },
|
|
||||||
timeout: 120000, // 2 minutes for upload
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rpcClient = new RPCClient()
|
export const rpcClient = new RPCClient()
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// WebSocket handler for real-time updates
|
// WebSocket handler for real-time updates
|
||||||
|
|
||||||
import type { Update, PatchOperation } from '../types/api'
|
import type { Update, PatchOperation } from '../types/api'
|
||||||
import { applyPatch } from 'fast-json-patch'
|
import { applyPatch, type Operation } from 'fast-json-patch'
|
||||||
|
|
||||||
type WebSocketCallback = (update: Update) => void
|
type WebSocketCallback = (update: Update) => void
|
||||||
type ConnectionStateCallback = (connected: boolean) => void
|
type ConnectionStateCallback = (connected: boolean) => void
|
||||||
@ -336,7 +336,7 @@ function getWebSocketClient(): WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a persisted instance from HMR
|
// Check if we have a persisted instance from HMR
|
||||||
const existing = (window as any).__archipelago_ws_client
|
const existing = (window as unknown as Record<string, unknown>).__archipelago_ws_client
|
||||||
if (existing && existing instanceof WebSocketClient) {
|
if (existing && existing instanceof WebSocketClient) {
|
||||||
// Check if the WebSocket is still valid
|
// Check if the WebSocket is still valid
|
||||||
if (existing.isConnected()) {
|
if (existing.isConnected()) {
|
||||||
@ -350,7 +350,7 @@ function getWebSocketClient(): WebSocketClient {
|
|||||||
if (!wsClientInstance) {
|
if (!wsClientInstance) {
|
||||||
wsClientInstance = new WebSocketClient()
|
wsClientInstance = new WebSocketClient()
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
(window as any).__archipelago_ws_client = wsClientInstance
|
;(window as unknown as Record<string, unknown>).__archipelago_ws_client = wsClientInstance
|
||||||
}
|
}
|
||||||
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
|
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
|
||||||
}
|
}
|
||||||
@ -385,7 +385,7 @@ export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = applyPatch(data, patch as any, false, false)
|
const result = applyPatch(data, patch as Operation[], false, false)
|
||||||
return result.newDocument as T
|
return result.newDocument as T
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to apply patch:', error, 'Patch:', patch)
|
console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||||
|
|||||||
@ -31,7 +31,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="text-white font-medium">CLI Access</span>
|
<span class="text-white font-medium">CLI Access</span>
|
||||||
</div>
|
</div>
|
||||||
<AppSwitcher />
|
|
||||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -101,7 +100,7 @@
|
|||||||
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
|
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-white/40 text-xs">
|
<p class="text-white/40 text-xs">
|
||||||
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">C</kbd> or <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">⌘⇧`</kbd> to open this anytime.
|
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">⌘C</kbd> / <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">Ctrl+C</kbd> to open this anytime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,8 +115,6 @@
|
|||||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useCLIStore } from '@/stores/cli'
|
import { useCLIStore } from '@/stores/cli'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
import AppSwitcher from '@/components/AppSwitcher.vue'
|
|
||||||
|
|
||||||
const cliStore = useCLIStore()
|
const cliStore = useCLIStore()
|
||||||
const panelRef = ref<HTMLElement | null>(null)
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
defineProps<{ msg: string }>()
|
|
||||||
|
|
||||||
const count = ref(0)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h1>{{ msg }}</h1>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<button type="button" @click="count++">count is {{ count }}</button>
|
|
||||||
<p>
|
|
||||||
Edit
|
|
||||||
<code>components/HelloWorld.vue</code> to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Check out
|
|
||||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
|
||||||
>create-vue</a
|
|
||||||
>, the official Vue + Vite starter
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Learn more about IDE Support for Vue in the
|
|
||||||
<a
|
|
||||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
|
||||||
target="_blank"
|
|
||||||
>Vue Docs Scaling up Guide</a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
25
neode-ui/src/components/OnlineStatusPill.vue
Normal file
25
neode-ui/src/components/OnlineStatusPill.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-controller-ignore
|
||||||
|
class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
|
title="Open CLI (⌘C / Ctrl+C)"
|
||||||
|
@click="openCLI"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||||
|
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-50"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs">Online</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useCLIStore } from '@/stores/cli'
|
||||||
|
|
||||||
|
const cliStore = useCLIStore()
|
||||||
|
|
||||||
|
function openCLI() {
|
||||||
|
cliStore.open()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -24,7 +24,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="install"
|
@click="install"
|
||||||
class="px-4 py-2 gradient-button rounded-lg text-sm font-medium"
|
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -36,7 +36,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleUpdate"
|
@click="handleUpdate"
|
||||||
class="px-4 py-2 gradient-button rounded-lg text-sm font-medium"
|
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Update Now
|
Update Now
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -27,7 +27,11 @@ const FOCUSABLE_SELECTOR = [
|
|||||||
|
|
||||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
||||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
|
(el) =>
|
||||||
|
!el.hasAttribute('disabled') &&
|
||||||
|
el.offsetParent !== null &&
|
||||||
|
!el.hasAttribute('data-controller-ignore') &&
|
||||||
|
!el.closest('[data-controller-ignore]')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ function getContext(): AudioContext | null {
|
|||||||
function ensureContext(): AudioContext | null {
|
function ensureContext(): AudioContext | null {
|
||||||
if (audioContext) return audioContext
|
if (audioContext) return audioContext
|
||||||
try {
|
try {
|
||||||
const Ctx = window.AudioContext || (window as any).webkitAudioContext
|
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||||
if (!Ctx) return null
|
if (!Ctx) return null
|
||||||
audioContext = new Ctx()
|
audioContext = new Ctx()
|
||||||
return audioContext
|
return audioContext
|
||||||
|
|||||||
@ -1,24 +1,39 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface MarketplaceAppInfo {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
version: string
|
||||||
|
icon: string
|
||||||
|
category: string
|
||||||
|
description: string | { short: string; long: string }
|
||||||
|
author: string
|
||||||
|
source: string
|
||||||
|
manifestUrl: string
|
||||||
|
url: string
|
||||||
|
repoUrl: string
|
||||||
|
s9pkUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
// Simple in-memory store for the current marketplace app
|
// Simple in-memory store for the current marketplace app
|
||||||
const currentMarketplaceApp = ref<any>(null)
|
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
||||||
|
|
||||||
export function useMarketplaceApp() {
|
export function useMarketplaceApp() {
|
||||||
function setCurrentApp(app: any) {
|
function setCurrentApp(app: Partial<MarketplaceAppInfo> & { id: string }) {
|
||||||
// Create a clean, serializable copy
|
// Create a clean, serializable copy
|
||||||
currentMarketplaceApp.value = {
|
currentMarketplaceApp.value = {
|
||||||
id: app.id,
|
id: app.id,
|
||||||
title: app.title,
|
title: app.title ?? '',
|
||||||
version: app.version,
|
version: app.version ?? '',
|
||||||
icon: app.icon,
|
icon: app.icon ?? '',
|
||||||
category: app.category,
|
category: app.category ?? '',
|
||||||
description: app.description,
|
description: app.description ?? '',
|
||||||
author: app.author,
|
author: app.author ?? '',
|
||||||
source: app.source,
|
source: app.source ?? '',
|
||||||
manifestUrl: app.manifestUrl || app.s9pkUrl || app.url,
|
manifestUrl: app.manifestUrl || app.s9pkUrl || app.url || '',
|
||||||
url: app.url || app.s9pkUrl || app.manifestUrl,
|
url: app.url || app.s9pkUrl || app.manifestUrl || '',
|
||||||
repoUrl: app.repoUrl,
|
repoUrl: app.repoUrl ?? '',
|
||||||
s9pkUrl: app.s9pkUrl
|
s9pkUrl: app.s9pkUrl ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,4 +51,3 @@ export function useMarketplaceApp() {
|
|||||||
clearCurrentApp
|
clearCurrentApp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ let audioContext: AudioContext | null = null
|
|||||||
function getContext(): AudioContext | null {
|
function getContext(): AudioContext | null {
|
||||||
if (audioContext) return audioContext
|
if (audioContext) return audioContext
|
||||||
try {
|
try {
|
||||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||||
return audioContext
|
return audioContext
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
wsClient.subscribe((update: any) => {
|
wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => {
|
||||||
// Handle mock backend format: {type: 'initial', data: {...}}
|
// Handle mock backend format: {type: 'initial', data: {...}}
|
||||||
if (update?.type === 'initial' && update?.data) {
|
if (update?.type === 'initial' && update?.data) {
|
||||||
console.log('[Store] Received initial data from mock backend')
|
console.log('[Store] Received initial data from mock backend')
|
||||||
@ -256,19 +256,15 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
return rpcClient.shutdownServer()
|
return rpcClient.shutdownServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMetrics(): Promise<any> {
|
async function getMetrics(): Promise<Record<string, unknown>> {
|
||||||
return rpcClient.getMetrics()
|
return rpcClient.getMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marketplace actions
|
// Marketplace actions
|
||||||
async function getMarketplace(url: string): Promise<any> {
|
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
|
||||||
return rpcClient.getMarketplace(url)
|
return rpcClient.getMarketplace(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sideloadPackage(manifest: any, icon: string): Promise<string> {
|
|
||||||
return rpcClient.sideloadPackage(manifest, icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
data,
|
data,
|
||||||
@ -303,7 +299,6 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
shutdownServer,
|
shutdownServer,
|
||||||
getMetrics,
|
getMetrics,
|
||||||
getMarketplace,
|
getMarketplace,
|
||||||
sideloadPackage,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -66,28 +66,56 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-button {
|
.glass-button {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 48px;
|
padding-inline: 1.25rem;
|
||||||
min-height: 48px;
|
background: rgba(0, 0, 0, 0.6);
|
||||||
padding-block: 0 !important;
|
backdrop-filter: blur(24px);
|
||||||
line-height: 48px;
|
-webkit-backdrop-filter: blur(24px);
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
box-shadow:
|
||||||
backdrop-filter: blur(18px);
|
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||||
-webkit-backdrop-filter: blur(18px);
|
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border-radius: 0.75rem;
|
||||||
|
border: none;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover::before {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-button-sm {
|
.glass-button-sm {
|
||||||
min-height: 0 !important;
|
padding-block: 0.375rem;
|
||||||
height: auto !important;
|
|
||||||
line-height: inherit;
|
|
||||||
padding-block: 0.375rem !important;
|
|
||||||
padding-inline: 0.75rem;
|
padding-inline: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast - glassmorphic, top-right */
|
/* Toast - glassmorphic, top-right */
|
||||||
@ -111,39 +139,10 @@
|
|||||||
transform: translateX(1rem);
|
transform: translateX(1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gradient containers - transparent to black */
|
/* BANNED: gradient-card, gradient-card-dark, gradient-button
|
||||||
.gradient-card {
|
Use .glass-card or .path-option-card for containers.
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%);
|
Use .glass-button for all buttons.
|
||||||
backdrop-filter: blur(18px);
|
These gradient styles break the clean glass aesthetic. */
|
||||||
-webkit-backdrop-filter: blur(18px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-card-dark {
|
|
||||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.9) 100%);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
-webkit-backdrop-filter: blur(18px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-button {
|
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(0, 0, 0, 0.8) 100%);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-button:hover {
|
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(0, 0, 0, 0.9) 100%);
|
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gradient border for logo badge */
|
/* Gradient border for logo badge */
|
||||||
.logo-gradient-border {
|
.logo-gradient-border {
|
||||||
@ -198,7 +197,7 @@
|
|||||||
-webkit-backdrop-filter: blur(40px);
|
-webkit-backdrop-filter: blur(40px);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -236,8 +235,8 @@
|
|||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||||
-webkit-mask:
|
-webkit-mask:
|
||||||
linear-gradient(#fff 0 0) content-box,
|
linear-gradient(#fff 0 0) content-box,
|
||||||
linear-gradient(#fff 0 0);
|
linear-gradient(#fff 0 0);
|
||||||
-webkit-mask-composite: xor;
|
-webkit-mask-composite: xor;
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
@ -248,7 +247,7 @@
|
|||||||
.path-option-card svg {
|
.path-option-card svg {
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3))
|
drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3))
|
||||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))
|
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))
|
||||||
drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.6));
|
drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.6));
|
||||||
@ -269,7 +268,7 @@
|
|||||||
|
|
||||||
.path-option-card:hover svg {
|
.path-option-card:hover svg {
|
||||||
color: rgba(255, 255, 255, 1);
|
color: rgba(255, 255, 255, 1);
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.5))
|
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.5))
|
||||||
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.9))
|
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.9))
|
||||||
drop-shadow(0 -1px 3px rgba(0, 0, 0, 0.7));
|
drop-shadow(0 -1px 3px rgba(0, 0, 0, 0.7));
|
||||||
@ -291,7 +290,7 @@
|
|||||||
|
|
||||||
.path-option-card--selected svg {
|
.path-option-card--selected svg {
|
||||||
color: rgba(255, 255, 255, 1);
|
color: rgba(255, 255, 255, 1);
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.6))
|
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.6))
|
||||||
drop-shadow(0 3px 8px rgba(0, 0, 0, 1))
|
drop-shadow(0 3px 8px rgba(0, 0, 0, 1))
|
||||||
drop-shadow(0 0 12px rgba(255, 255, 255, 0.3));
|
drop-shadow(0 0 12px rgba(255, 255, 255, 0.3));
|
||||||
@ -299,7 +298,7 @@
|
|||||||
|
|
||||||
.path-option-card--selected h3 {
|
.path-option-card--selected h3 {
|
||||||
color: rgba(255, 255, 255, 1);
|
color: rgba(255, 255, 255, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action Buttons */
|
/* Action Buttons */
|
||||||
.path-action-button {
|
.path-action-button {
|
||||||
@ -415,7 +414,7 @@ body {
|
|||||||
font-family: 'Avenir Next', system-ui, sans-serif;
|
font-family: 'Avenir Next', system-ui, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: #000 url('/assets/img/bg.jpg') center top / auto 100vh no-repeat fixed;
|
background: #000;
|
||||||
color: white;
|
color: white;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -228,7 +228,7 @@ export namespace RR {
|
|||||||
export interface PatchOperation {
|
export interface PatchOperation {
|
||||||
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'
|
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'
|
||||||
path: string
|
path: string
|
||||||
value?: any
|
value?: unknown
|
||||||
from?: string
|
from?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
|
|||||||
const releasesResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/releases/latest`)
|
const releasesResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/releases/latest`)
|
||||||
if (releasesResponse.ok) {
|
if (releasesResponse.ok) {
|
||||||
const releasesData = await releasesResponse.json()
|
const releasesData = await releasesResponse.json()
|
||||||
const asset = releasesData.assets?.find((a: any) =>
|
const asset = releasesData.assets?.find((a: { name: string; browser_download_url: string }) =>
|
||||||
a.name.includes('icon') || a.name.includes('logo')
|
a.name.includes('icon') || a.name.includes('logo')
|
||||||
)
|
)
|
||||||
if (asset) {
|
if (asset) {
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canLaunch"
|
v-if="canLaunch"
|
||||||
@click="launchApp"
|
@click="launchApp"
|
||||||
class="gradient-button px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
@ -151,7 +151,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canLaunch"
|
v-if="canLaunch"
|
||||||
@click="launchApp"
|
@click="launchApp"
|
||||||
class="gradient-button px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
|||||||
@ -78,7 +78,7 @@
|
|||||||
v-if="canLaunch(pkg)"
|
v-if="canLaunch(pkg)"
|
||||||
data-controller-launch-btn
|
data-controller-launch-btn
|
||||||
@click.stop="launchApp(id as string)"
|
@click.stop="launchApp(id as string)"
|
||||||
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium"
|
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Launch
|
Launch
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -73,8 +73,8 @@
|
|||||||
:class="{ 'sidebar-animate': showZoomIn }"
|
:class="{ 'sidebar-animate': showZoomIn }"
|
||||||
>
|
>
|
||||||
<div class="sidebar-shell">
|
<div class="sidebar-shell">
|
||||||
<div class="sidebar-inner">
|
<div class="sidebar-inner flex flex-col min-h-full">
|
||||||
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0">
|
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
|
||||||
<AnimatedLogo />
|
<AnimatedLogo />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||||
@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav space-y-2 p-6 pt-4">
|
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="(item, idx) in desktopNavItems"
|
v-for="(item, idx) in desktopNavItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
@ -105,11 +105,11 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-controller px-6 pb-2">
|
<div class="sidebar-controller px-6 pb-2 shrink-0">
|
||||||
<ControllerIndicator />
|
<ControllerIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-logout p-6">
|
<div class="sidebar-logout p-6 shrink-0">
|
||||||
<button
|
<button
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
@ -120,6 +120,11 @@
|
|||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Online status pill - bottom of sidebar (desktop only; sidebar is hidden on mobile) -->
|
||||||
|
<div class="px-6 pb-6 shrink-0">
|
||||||
|
<OnlineStatusPill />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -130,9 +135,8 @@
|
|||||||
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
|
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
|
||||||
:class="{ 'glass-throw-main': showZoomIn }"
|
:class="{ 'glass-throw-main': showZoomIn }"
|
||||||
>
|
>
|
||||||
<!-- App Switcher - top right, compact (Right arrow from sidebar goes here first) -->
|
|
||||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||||
<AppSwitcher />
|
<!-- Controller zone entry point - no switcher -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connection Status Banner -->
|
<!-- Connection Status Banner -->
|
||||||
@ -309,7 +313,7 @@ import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
import AppSwitcher from '@/components/AppSwitcher.vue'
|
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
|
||||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||||
|
|
||||||
|
|||||||
@ -79,16 +79,6 @@
|
|||||||
<p class="hidden md:block text-white/70">Discover and install apps for your new sovereign life</p>
|
<p class="hidden md:block text-white/70">Discover and install apps for your new sovereign life</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sideload Button -->
|
|
||||||
<button
|
|
||||||
@click="showSideloadModal = true"
|
|
||||||
class="hidden md:flex px-6 py-3 gradient-button rounded-lg font-medium items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
||||||
</svg>
|
|
||||||
Sideload
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Tabs + Search (Desktop only) -->
|
<!-- Category Tabs + Search (Desktop only) -->
|
||||||
@ -177,7 +167,7 @@
|
|||||||
data-controller-install-btn
|
data-controller-install-btn
|
||||||
@click.stop="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
@click.stop="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||||
:disabled="installingApps.has(app.id)"
|
:disabled="installingApps.has(app.id)"
|
||||||
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
|
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
|
||||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
@ -213,63 +203,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- End Scrollable Apps Section -->
|
<!-- End Scrollable Apps Section -->
|
||||||
|
|
||||||
<!-- Sideload Modal -->
|
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="showSideloadModal"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
|
||||||
@click.self="closeSideloadModal()"
|
|
||||||
>
|
|
||||||
<div ref="sideloadModalRef" class="glass-card p-8 max-w-2xl w-full relative">
|
|
||||||
<!-- Close Button -->
|
|
||||||
<button
|
|
||||||
@click="closeSideloadModal()"
|
|
||||||
class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2 class="text-2xl font-bold text-white mb-2">Sideload Package</h2>
|
|
||||||
<p class="text-white/70 mb-6">Install a package from an s9pk file URL or local path</p>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<input
|
|
||||||
v-model="sideloadUrl"
|
|
||||||
type="text"
|
|
||||||
placeholder="https://example.com/package.s9pk or /packages/package.s9pk"
|
|
||||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40"
|
|
||||||
@keyup.enter="sideloadPackage"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="sideloadPackage"
|
|
||||||
:disabled="!sideloadUrl || sideloading"
|
|
||||||
class="px-8 py-3 gradient-button rounded-lg font-medium disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<svg v-if="sideloading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
{{ sideloading ? 'Installing...' : 'Install Package' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="sideloadError" class="mt-4 text-red-400 text-sm">{{ sideloadError }}</p>
|
|
||||||
<p v-if="sideloadSuccess" class="mt-4 text-green-400 text-sm">{{ sideloadSuccess }}</p>
|
|
||||||
|
|
||||||
<!-- Examples -->
|
|
||||||
<div class="mt-6 p-4 bg-white/5 rounded-lg">
|
|
||||||
<p class="text-white/80 text-sm font-medium mb-2">Examples:</p>
|
|
||||||
<ul class="text-white/60 text-sm space-y-1">
|
|
||||||
<li>• <code class="text-blue-400">https://github.com/.../releases/download/v1.0.0/app.s9pk</code></li>
|
|
||||||
<li>• <code class="text-blue-400">/packages/myapp.s9pk</code> (local file)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Floating Filter Button (Mobile only) -->
|
<!-- Floating Filter Button (Mobile only) -->
|
||||||
<button
|
<button
|
||||||
@click="showFilterModal = true"
|
@click="showFilterModal = true"
|
||||||
@ -407,20 +340,6 @@ interface InstallProgress {
|
|||||||
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
const installingApps = ref<Map<string, InstallProgress>>(new Map())
|
||||||
const maxAttempts = ref(60)
|
const maxAttempts = ref(60)
|
||||||
|
|
||||||
// Sideload modal state
|
|
||||||
const showSideloadModal = ref(false)
|
|
||||||
const sideloadModalRef = ref<HTMLElement | null>(null)
|
|
||||||
const sideloadRestoreFocusRef = ref<HTMLElement | null>(null)
|
|
||||||
function closeSideloadModal() {
|
|
||||||
sideloadRestoreFocusRef.value?.focus?.()
|
|
||||||
showSideloadModal.value = false
|
|
||||||
}
|
|
||||||
useModalKeyboard(sideloadModalRef, showSideloadModal, closeSideloadModal, { restoreFocusRef: sideloadRestoreFocusRef })
|
|
||||||
const sideloadUrl = ref('')
|
|
||||||
const sideloading = ref(false)
|
|
||||||
const sideloadError = ref('')
|
|
||||||
const sideloadSuccess = ref('')
|
|
||||||
|
|
||||||
// Filter modal state (for mobile)
|
// Filter modal state (for mobile)
|
||||||
const showFilterModal = ref(false)
|
const showFilterModal = ref(false)
|
||||||
const filterModalRef = ref<HTMLElement | null>(null)
|
const filterModalRef = ref<HTMLElement | null>(null)
|
||||||
@ -438,7 +357,6 @@ const communityApps = ref<any[]>([])
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Available apps in marketplace
|
// Available apps in marketplace
|
||||||
// Note: s9pk packages disabled until sideload functionality is implemented
|
|
||||||
// const availableApps = ref([
|
// const availableApps = ref([
|
||||||
// {
|
// {
|
||||||
// id: 'atob',
|
// id: 'atob',
|
||||||
@ -1000,31 +918,6 @@ async function installCommunityApp(app: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sideloadPackage() {
|
|
||||||
if (!sideloadUrl.value || sideloading.value) return
|
|
||||||
|
|
||||||
sideloading.value = true
|
|
||||||
sideloadError.value = ''
|
|
||||||
sideloadSuccess.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await rpcClient.call({ method: 'package.sideload', params: { url: sideloadUrl.value } })
|
|
||||||
|
|
||||||
sideloadSuccess.value = 'Package installed successfully!'
|
|
||||||
sideloadUrl.value = ''
|
|
||||||
|
|
||||||
trackTimeout(() => {
|
|
||||||
showSideloadModal.value = false
|
|
||||||
router.push('/dashboard/apps').catch(() => {})
|
|
||||||
}, 1500)
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Sideload failed:', err)
|
|
||||||
sideloadError.value = err.message || 'Failed to install package'
|
|
||||||
} finally {
|
|
||||||
sideloading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageError(event: Event) {
|
function handleImageError(event: Event) {
|
||||||
const img = event.target as HTMLImageElement
|
const img = event.target as HTMLImageElement
|
||||||
img.src = '/assets/img/logo-archipelago.svg'
|
img.src = '/assets/img/logo-archipelago.svg'
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="isInstalled"
|
v-if="isInstalled"
|
||||||
@click="goToInstalledApp"
|
@click="goToInstalledApp"
|
||||||
class="gradient-button px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
@ -84,7 +84,7 @@
|
|||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || !app.manifestUrl"
|
:disabled="installing || !app.manifestUrl"
|
||||||
class="gradient-button px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@ -138,7 +138,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="isInstalled"
|
v-if="isInstalled"
|
||||||
@click="goToInstalledApp"
|
@click="goToInstalledApp"
|
||||||
class="gradient-button px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
@ -149,7 +149,7 @@
|
|||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || !app.manifestUrl"
|
:disabled="installing || !app.manifestUrl"
|
||||||
class="gradient-button px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||||
>
|
>
|
||||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
@ -330,7 +330,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useMarketplaceApp } from '../composables/useMarketplaceApp'
|
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||||
|
|
||||||
const { bottomPosition } = useMobileBackButton()
|
const { bottomPosition } = useMobileBackButton()
|
||||||
@ -340,7 +340,7 @@ const route = useRoute()
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const { getCurrentApp } = useMarketplaceApp()
|
const { getCurrentApp } = useMarketplaceApp()
|
||||||
|
|
||||||
const app = ref<any>(null)
|
const app = ref<MarketplaceAppInfo | null>(null)
|
||||||
const installing = ref(false)
|
const installing = ref(false)
|
||||||
const installError = ref<string | null>(null)
|
const installError = ref<string | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@ -481,8 +481,8 @@ async function installApp() {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
|
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
installError.value = err.message || 'Installation failed. Please try again.'
|
installError.value = err instanceof Error ? err.message : 'Installation failed. Please try again.'
|
||||||
console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||||
} finally {
|
} finally {
|
||||||
installing.value = false
|
installing.value = false
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
// Connected nodes
|
// Connected nodes
|
||||||
const connectedNodes = ref(12)
|
const connectedNodes = ref(12)
|
||||||
@ -242,37 +243,38 @@ const autoSyncEnabled = ref(true)
|
|||||||
// Logs
|
// Logs
|
||||||
const logCount = ref(3)
|
const logCount = ref(3)
|
||||||
|
|
||||||
function restartServices() {
|
async function restartServices() {
|
||||||
restarting.value = true
|
restarting.value = true
|
||||||
servicesRunning.value = false
|
servicesRunning.value = false
|
||||||
// TODO: Implement restart services API call
|
try {
|
||||||
console.log('Restarting services...')
|
await rpcClient.restartServer()
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) console.warn('Restart RPC unavailable, using mock')
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
restarting.value = false
|
restarting.value = false
|
||||||
servicesRunning.value = true
|
servicesRunning.value = true
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkConnectivity() {
|
async function checkConnectivity() {
|
||||||
checkingConnectivity.value = true
|
checkingConnectivity.value = true
|
||||||
connectivityStatus.value = 'checking'
|
connectivityStatus.value = 'checking'
|
||||||
// TODO: Implement connectivity check API call
|
try {
|
||||||
console.log('Checking connectivity...')
|
await rpcClient.call({ method: 'server.health', params: {} })
|
||||||
setTimeout(() => {
|
|
||||||
checkingConnectivity.value = false
|
|
||||||
connectivityStatus.value = 'connected'
|
connectivityStatus.value = 'connected'
|
||||||
}, 2000)
|
} catch {
|
||||||
|
connectivityStatus.value = 'disconnected'
|
||||||
|
} finally {
|
||||||
|
checkingConnectivity.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAutoSync() {
|
function toggleAutoSync() {
|
||||||
autoSyncEnabled.value = !autoSyncEnabled.value
|
autoSyncEnabled.value = !autoSyncEnabled.value
|
||||||
// TODO: Implement auto-sync toggle API call
|
|
||||||
console.log('Auto-sync:', autoSyncEnabled.value ? 'enabled' : 'disabled')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewLogs() {
|
function viewLogs() {
|
||||||
// TODO: Navigate to logs view or open logs modal
|
|
||||||
console.log('Viewing logs...')
|
|
||||||
logCount.value = 0
|
logCount.value = 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
# Quick script to check what's deployed on the target
|
# Quick script to check what's deployed on the target
|
||||||
|
|
||||||
echo "Checking deployed files on target..."
|
echo "Checking deployed files on target..."
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
# Check what's actually in the deployed frontend
|
# Check what's actually in the deployed frontend
|
||||||
|
|
||||||
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
|
|||||||
@ -25,11 +25,14 @@ ARCHIPELAGO_PASSWORD="${ARCHIPELAGO_PASSWORD:-archipelago}"
|
|||||||
# Force password auth when using sshpass (avoids "Permission denied" from SSH key mismatch)
|
# Force password auth when using sshpass (avoids "Permission denied" from SSH key mismatch)
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no"
|
SSH_OPTS="-o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no"
|
||||||
|
|
||||||
|
DEPLOY_START=$(date +%s)
|
||||||
|
timestamp() { echo "[$(date +%H:%M:%S)]"; }
|
||||||
|
|
||||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||||
echo "║ Deploying to Archipelago Target ║"
|
echo "║ Deploying to Archipelago Target ║"
|
||||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Target: $TARGET_HOST"
|
echo "$(timestamp) Target: $TARGET_HOST"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@ -70,7 +73,7 @@ if [ "$BOTH" = true ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Sync code
|
# Sync code
|
||||||
echo "📦 Syncing code..."
|
echo "$(timestamp) 📦 Syncing code..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" rsync -avz --delete \
|
sshpass -p "$ARCHIPELAGO_PASSWORD" rsync -avz --delete \
|
||||||
-e "ssh $SSH_OPTS" \
|
-e "ssh $SSH_OPTS" \
|
||||||
--exclude 'node_modules' \
|
--exclude 'node_modules' \
|
||||||
@ -89,33 +92,33 @@ fi
|
|||||||
|
|
||||||
# Build on target
|
# Build on target
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔨 Building on target..."
|
echo "$(timestamp) 🔨 Building on target..."
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
echo " Building frontend..."
|
echo "$(timestamp) Building frontend (vue-tsc + vite)..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/neode-ui && npm install --silent && npm run build" 2>&1 | sed 's/^/ /'
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/neode-ui && npm install --silent && npm run build" 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
# Backend (if Rust is installed)
|
# Backend (if Rust is installed)
|
||||||
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env 2>/dev/null && command -v cargo" >/dev/null 2>&1; then
|
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env 2>/dev/null && command -v cargo" >/dev/null 2>&1; then
|
||||||
echo " Building backend..."
|
echo "$(timestamp) Building backend (Rust release — this takes 1-2 min)..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env && cd $TARGET_DIR/core && cargo build --release 2>&1" | tail -10 | sed 's/^/ /'
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env && cd $TARGET_DIR/core && cargo build --release 2>&1" | sed 's/^/ /'
|
||||||
else
|
else
|
||||||
echo " ⚠️ Rust not installed on target, skipping backend build"
|
echo " ⚠️ Rust not installed on target, skipping backend build"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$LIVE" = true ]; then
|
if [ "$LIVE" = true ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "🚀 Deploying to live system..."
|
echo "$(timestamp) 🚀 Deploying to live system..."
|
||||||
|
|
||||||
# Deploy backend (check if binary exists)
|
# Deploy backend (check if binary exists)
|
||||||
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then
|
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then
|
||||||
echo " Deploying backend binary..."
|
echo "$(timestamp) Deploying backend binary..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl stop archipelago"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl stop archipelago"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Deploy frontend
|
# Deploy frontend
|
||||||
echo " Deploying frontend..."
|
echo "$(timestamp) Deploying frontend..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/*"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/*"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
|
||||||
@ -157,15 +160,15 @@ if [ "$LIVE" = true ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
echo " Restarting services..."
|
echo "$(timestamp) Restarting services..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx"
|
||||||
|
|
||||||
# Set up HTTPS for PWA installability (browsers require secure context)
|
# Set up HTTPS for PWA installability (browsers require secure context)
|
||||||
echo " Setting up HTTPS for PWA install..."
|
echo "$(timestamp) Setting up HTTPS for PWA install..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo bash $TARGET_DIR/scripts/setup-https-dev.sh" 2>&1 | sed 's/^/ /' || true
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo bash $TARGET_DIR/scripts/setup-https-dev.sh" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
|
# Rebuild and recreate LND UI container (port 8081 so Launch from UI and http://host:8081 both work)
|
||||||
echo " Rebuilding LND UI..."
|
echo "$(timestamp) Rebuilding LND UI..."
|
||||||
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t lnd-ui:latest . || sudo docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t lnd-ui:latest . || sudo docker build --no-cache -t lnd-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||||
echo " Recreating LND UI container (port 8081)..."
|
echo " Recreating LND UI container (port 8081)..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
@ -179,7 +182,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild and recreate Electrs UI container (port 50002)
|
# Rebuild and recreate Electrs UI container (port 50002)
|
||||||
echo " Rebuilding Electrs UI..."
|
echo "$(timestamp) Rebuilding Electrs UI..."
|
||||||
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t electrs-ui:latest . || sudo docker build --no-cache -t electrs-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
if sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && sudo podman build --no-cache -t electrs-ui:latest . || sudo docker build --no-cache -t electrs-ui:latest .)" 2>&1 | tail -12 | sed 's/^/ /'; then
|
||||||
echo " Recreating Electrs UI container (port 50002, host network)..."
|
echo " Recreating Electrs UI container (port 50002, host network)..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
@ -194,7 +197,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
|
|
||||||
# Bitcoin Knots: required for Mempool, Electrs, BTCPay, Fedimint
|
# Bitcoin Knots: required for Mempool, Electrs, BTCPay, Fedimint
|
||||||
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
echo " Ensuring Bitcoin Knots (required for Electrs/Mempool)..."
|
echo "$(timestamp) Ensuring Bitcoin Knots..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
DOCKER=podman
|
DOCKER=podman
|
||||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
@ -218,7 +221,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
" 2>&1 | sed 's/^/ /' || true
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Fix Mempool: clean duplicates, ensure full stack - mysql, backend (8999), frontend (4080)
|
# Fix Mempool: clean duplicates, ensure full stack - mysql, backend (8999), frontend (4080)
|
||||||
echo " Fixing Mempool stack (host=$TARGET_IP)..."
|
echo "$(timestamp) Fixing Mempool stack..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
DOCKER=podman
|
DOCKER=podman
|
||||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
@ -322,7 +325,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
" 2>&1 | sed 's/^/ /' || true
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Fix BTCPay Server: requires PostgreSQL + NBXplorer (BTCPay needs NBXplorer for block indexing)
|
# Fix BTCPay Server: requires PostgreSQL + NBXplorer (BTCPay needs NBXplorer for block indexing)
|
||||||
echo " Fixing BTCPay Server stack..."
|
echo "$(timestamp) Fixing BTCPay Server stack..."
|
||||||
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
DOCKER=podman
|
DOCKER=podman
|
||||||
@ -397,7 +400,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
" 2>&1 | sed 's/^/ /' || true
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Ensure Immich stack (postgres + redis + server) - creates if missing
|
# Ensure Immich stack (postgres + redis + server) - creates if missing
|
||||||
echo " Ensuring Immich stack (port 2283)..."
|
echo "$(timestamp) Ensuring Immich stack..."
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
DOCKER=podman
|
DOCKER=podman
|
||||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||||
@ -439,7 +442,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
" 2>&1 | sed 's/^/ /' || true
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Tor: global hidden services - each service gets its own .onion address
|
# Tor: global hidden services - each service gets its own .onion address
|
||||||
echo " Setting up Tor (hidden services for each app)..."
|
echo "$(timestamp) Setting up Tor..."
|
||||||
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "
|
||||||
DOCKER=podman
|
DOCKER=podman
|
||||||
@ -505,7 +508,7 @@ if [ "$LIVE" = true ]; then
|
|||||||
" 2>&1 | sed 's/^/ /' || true
|
" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured")
|
# Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured")
|
||||||
echo " Fixing Fedimint API URL..."
|
echo "$(timestamp) Fixing Fedimint API URL..."
|
||||||
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)"
|
||||||
TIMEOUT_CMD=""
|
TIMEOUT_CMD=""
|
||||||
command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90"
|
command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90"
|
||||||
@ -535,8 +538,10 @@ if [ "$LIVE" = true ]; then
|
|||||||
done
|
done
|
||||||
" 2>&1 | sed 's/^/ /') || echo " (Fedimint fix timed out or skipped - run manually if needed)"
|
" 2>&1 | sed 's/^/ /') || echo " (Fedimint fix timed out or skipped - run manually if needed)"
|
||||||
|
|
||||||
|
DEPLOY_END=$(date +%s)
|
||||||
|
DEPLOY_ELAPSED=$((DEPLOY_END - DEPLOY_START))
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Deployed to live system!"
|
echo "$(timestamp) ✅ Deployed to live system! (${DEPLOY_ELAPSED}s total)"
|
||||||
echo " Backend: $(sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
|
echo " Backend: $(sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')"
|
||||||
echo " Web UI: http://$(echo $TARGET_HOST | cut -d@ -f2)"
|
echo " Web UI: http://$(echo $TARGET_HOST | cut -d@ -f2)"
|
||||||
echo " PWA install: https://$(echo $TARGET_HOST | cut -d@ -f2) (use HTTPS, accept cert once, then Install app)"
|
echo " PWA install: https://$(echo $TARGET_HOST | cut -d@ -f2) (use HTTPS, accept cert once, then Install app)"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
# Archipelago Development Server Starter
|
# Archipelago Development Server Starter
|
||||||
# Pure Archipelago implementation - NO StartOS
|
# Pure Archipelago implementation - NO StartOS
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
# Quick dev script - just starts the mock backend for UI development
|
# Quick dev script - just starts the mock backend for UI development
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
# Test if the new backend binary has the bundled-app methods
|
# Test if the new backend binary has the bundled-app methods
|
||||||
|
|
||||||
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
# Verify the deployment is working correctly
|
# Verify the deployment is working correctly
|
||||||
|
|
||||||
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user