chore: remove CLAUDE.md and stale config files

This commit is contained in:
Dorian
2026-04-12 12:11:00 -04:00
parent 29ff413559
commit 18284e1592
13 changed files with 198 additions and 249 deletions

View File

@@ -1,127 +0,0 @@
# Quick Build Guide - Archipelago Beta Release
## Prerequisites
Make sure you have:
- Docker or Podman installed
- `xorriso` installed (for ISO creation)
- Access to dev server: archipelago@192.168.1.228
**Note**: When building on the target server with `sudo`, the script will automatically install missing dependencies (`xorriso`, `podman`).
## Build Auto-Installer ISO
### Option 1: Build on Target Server (Recommended)
```bash
# SSH to target server
ssh archipelago@192.168.1.228
# Navigate to project
cd ~/archy/image-recipe
# Run build (auto-installs missing deps)
sudo ./build-auto-installer-iso.sh
# Copy ISO back to your Mac
# On your Mac:
scp archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-auto-installer-*.iso .
```
### Option 2: Build from Mac (requires Docker)
**Important**: This requires Docker Desktop installed on macOS.
```bash
cd /Users/dorian/Projects/archy/image-recipe
# Capture current live server state
DEV_SERVER=archipelago@192.168.1.228 ./build-auto-installer-iso.sh
# ISO will be created in: results/archipelago-auto-installer-*.iso
```
## What the ISO Includes
✅ Complete Debian 13 root filesystem
✅ Pre-built Archipelago backend
✅ Pre-built frontend (web UI)
**Prepackaged container images** (Bitcoin Knots, LND, UIs, and other bundled apps), loaded on first boot
✅ Nginx configuration (HTTPS ready)
✅ Auto-installer that:
- Detects internal disk
- Creates partitions (EFI + root)
- Extracts pre-built system
- Installs bootloader
- Reboots to working system
## What Users Need to Do Post-Install
1. **Start apps from the Web UI** Container images are prepackaged and loaded on first boot. Bitcoin Knots + UI, LND + UI, and other bundled apps are ready to start from the Web UI without manual `podman run`. No need to pull or deploy core containers.
2. **Access Web UI** Navigate to `http://[server-ip]`
## Testing the ISO
```bash
# Use VirtualBox, QEMU, or real hardware
qemu-system-x86_64 \
-m 4G \
-cdrom results/archipelago-auto-installer-*.iso \
-hda archipelago-test.qcow2 \
-boot d
```
## Important Notes
⚠️ **The auto-installer will ERASE the target disk!**
⚠️ Make sure to test on a non-production machine first
⚠️ Minimum 20GB disk space required (500GB+ recommended for Bitcoin)
## Build from Source (Alternative)
If you want to build everything from scratch instead of capturing the live server:
```bash
BUILD_FROM_SOURCE=1 ./build-auto-installer-iso.sh
```
This will:
- Build backend from Rust source
- Build frontend with `npm run build`
- Create fresh SSL certificates
- Generate default configs
## Troubleshooting
**ISO won't boot:**
- Ensure UEFI mode is enabled
- Try disabling Secure Boot
**Installer hangs:**
- Check the auto-start script fix is applied (see DEPLOYMENT.md)
**Backend doesn't detect containers:**
- Verify `/etc/sudoers.d/archipelago-podman` exists
- Check backend can run `sudo podman ps`
## Version Naming
ISOs are automatically named with timestamp:
```
archipelago-auto-installer-YYYYMMDD-HHMMSS.iso
```
For releases, rename to:
```
archipelago-v0.1.0-beta.1.iso
```
## Next Steps After Building
1. Test the ISO on VM
2. Verify web UI loads
3. Test container deployment
4. Document any issues
5. Tag the release in git
6. Upload ISO to distribution point

View File

@@ -1,59 +0,0 @@
# CLAUDE.md — Archipelago (Archy)
Archipelago is a **Bitcoin Node OS** — bootable, self-sovereign personal server. Flash to USB, install on hardware, manage via web UI.
**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 13
**Version**: 1.3.5 | **Target**: x86_64 and ARM64
## Beta Freeze (2026-03-18)
Phase 1: Feature Testing (internal). Feature set is locked.
Only: bug fixes, security hardening, ISO build fixes, UI polish, testing.
Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md`
## Quick Reference
```bash
cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100)
cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/)
./scripts/deploy-to-target.sh --live # Deploy to live server (.228)
```
## Architecture
```
Debian 13
├── Podman (rootless, user archipelago)
├── Nginx (80/443 → backend, app proxies)
├── Rust Backend (core/) on 127.0.0.1:5678
└── Vue.js UI (neode-ui/)
```
**Data paths**: `/var/lib/archipelago/{app-id}/` (data), `/opt/archipelago/web-ui/` (frontend), `/usr/local/bin/archipelago` (binary)
## Critical Rules
1. Do not build Rust on macOS — deploy script handles cross-compilation via rsync + remote build.
2. Always deploy after changes — `./scripts/deploy-to-target.sh --live`
3. Frontend builds to `web/dist/neode-ui/` — not `neode-ui/dist/`
4. Container images: `scripts/image-versions.sh` is the single source of truth. All scripts use `$*_IMAGE` variables, not hardcoded registry paths.
5. Type-check before committing — `cd neode-ui && npx vue-tsc -b --noEmit`
## App Integration Checklist
When adding/fixing apps, check all of these:
- `core/archipelago/src/api/rpc/package/` — config, capabilities, deps
- `neode-ui/src/views/marketplace/marketplaceData.ts` — marketplace entry
- `image-recipe/configs/nginx-archipelago.conf` — proxy rules (HTTP + HTTPS)
- `scripts/image-versions.sh` — pinned image version
- `scripts/first-boot-containers.sh` — first boot creation
- `scripts/deploy-to-target.sh` — deploy logic
## Git
Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`)
Push to: `git push tx1138 main`
## Compact Instructions
When compacting, preserve: list of modified files, test results, deploy target state, current branch, infrastructure IPs.

View File

@@ -1,46 +0,0 @@
# Demo Deployment via Portainer
Deploy Archipelago with the **mock backend** for demos. No real node required.
## Quick Deploy (Portainer)
1. In Portainer: **Stacks****Add stack**
2. Name: `archy-demo`
3. **Web editor** → paste contents of `docker-compose.demo.yml`
4. Or **Build from repository**: use this repo URL and set Compose path to `docker-compose.demo.yml`
5. Deploy
**Access:** http://your-host:4848
## Mock Backend
- Uses the Node.js mock backend (not the Rust backend)
- Pre-loaded apps, fake data, simulated install/start/stop
- **Login password:** `password123`
## Port
Default: **4848**. To change, edit the ports mapping in `docker-compose.demo.yml`:
```yaml
ports:
- "YOUR_PORT:80"
```
## Chat (Claude AI)
Set `ANTHROPIC_API_KEY` in the Portainer stack environment to enable real AI chat:
1. In the stack editor, add under **Environment variables**:
- `ANTHROPIC_API_KEY` = your Anthropic API key (starts with `sk-ant-api...`)
2. Redeploy the stack
Without this key, chat shows a "not configured" error. The key is passed to the `neode-backend` container which proxies requests to `api.anthropic.com`.
## Dev Mode
`VITE_DEV_MODE=existing` skips setup/onboarding and goes straight to login. For other flows:
- `setup` Password setup screen first
- `onboarding` Experimental onboarding flow
- `existing` Login only (default for demo)

View File

@@ -9,6 +9,7 @@ impl ApiHandler {
#[derive(serde::Deserialize)]
struct Incoming {
from_pubkey: Option<String>,
from_name: Option<String>,
message: Option<String>,
signature: Option<String>,
#[serde(default)]
@@ -67,7 +68,8 @@ impl ApiHandler {
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
node_msg::store_received(&clean_from, &clean_msg).await;
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
}
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
}

View File

@@ -288,6 +288,7 @@ impl RpcHandler {
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.send" => self.handle_mesh_send(params).await,
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.configure" => self.handle_mesh_configure(params).await,
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,

View File

@@ -40,6 +40,42 @@ impl RpcHandler {
}))
}
/// mesh.send-channel — Send a text message to a mesh channel (broadcast).
pub(in crate::api::rpc) async fn handle_mesh_send_channel(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let channel = params
.get("channel")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u8;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_channel_message(channel, message).await?;
info!(channel, "Sent mesh channel message");
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"channel": channel,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;

View File

@@ -101,12 +101,16 @@ impl RpcHandler {
|| format!("{}.onion", n.onion) == onion)
.map(|n| n.pubkey.clone());
// Include our node name so the recipient can display it
let node_name = data.server_info.name.clone();
node_message::send_to_peer(
onion,
&pubkey,
message,
Some(node_id.signing_key()),
recipient_pubkey.as_deref(),
node_name.as_deref(),
).await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
}

View File

@@ -509,6 +509,59 @@ impl MeshService {
Ok(msg)
}
/// Send a message to a mesh channel (broadcast).
/// Routes through the background listener which owns the serial port.
pub async fn send_channel_message(&self, channel: u8, text: &str) -> Result<MeshMessage> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected");
}
drop(status);
let payload = text.as_bytes().to_vec();
if payload.len() > protocol::MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
payload.len(),
protocol::MAX_MESSAGE_LEN
);
}
// Send through the listener's command channel
self.state
.cmd_tx
.send(listener::MeshCommand::BroadcastChannel {
channel,
payload,
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let chan_contact_id = u32::MAX - (channel as u32);
let chan_name = format!("Channel {}", channel);
let msg_id = self.state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Sent,
peer_contact_id: chan_contact_id,
peer_name: Some(chan_name),
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
encrypted: false,
};
self.state.store_message(msg.clone()).await;
{
let mut status = self.state.status.write().await;
status.messages_sent += 1;
}
Ok(msg)
}
/// Broadcast our advertisement over mesh so other nodes can discover us.
/// Sends an immediate advert via the listener's command channel.
pub async fn broadcast_identity(&self) -> Result<()> {

View File

@@ -14,6 +14,9 @@ pub struct IncomingMessage {
pub from_pubkey: String,
#[serde(default)]
pub from_onion: Option<String>,
/// Sender's node name (for display in group chat).
#[serde(default)]
pub from_name: Option<String>,
pub message: String,
pub timestamp: String,
/// "sent" or "received"
@@ -73,7 +76,7 @@ fn persist() {
}
/// Store a received message (called from HTTP handler).
pub fn store_received_sync(from_pubkey: &str, message: &str) {
pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) {
let ts = chrono::Utc::now().to_rfc3339();
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
@@ -89,6 +92,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str) {
guard.messages.push(IncomingMessage {
from_pubkey: from_pubkey.to_string(),
from_onion: None,
from_name: from_name.map(|s| s.to_string()),
message: message.to_string(),
timestamp: ts,
direction: "received".to_string(),
@@ -98,8 +102,8 @@ pub fn store_received_sync(from_pubkey: &str, message: &str) {
persist();
}
pub async fn store_received(from_pubkey: &str, message: &str) {
store_received_sync(from_pubkey, message);
pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) {
store_received_sync(from_pubkey, message, from_name);
}
/// Store a sent message (for display in Archipelago channel).
@@ -231,6 +235,7 @@ pub async fn send_to_peer(
message: &str,
signing_key: Option<&ed25519_dalek::SigningKey>,
recipient_pubkey: Option<&str>,
from_name: Option<&str>,
) -> Result<()> {
validate_onion(onion)?;
@@ -255,12 +260,15 @@ pub async fn send_to_peer(
_ => (message.to_string(), false),
};
let body = serde_json::json!({
let mut body = serde_json::json!({
"from_pubkey": from_pubkey,
"message": payload_message,
"timestamp": chrono::Utc::now().to_rfc3339(),
"encrypted": encrypted,
});
if let Some(name) = from_name {
body["from_name"] = serde_json::Value::String(name.to_string());
}
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()

View File

@@ -398,7 +398,7 @@ class RPCClient {
})
}
async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; message: string; timestamp: string }> }> {
async getReceivedMessages(): Promise<{ messages: Array<{ from_pubkey: string; from_name?: string; message: string; timestamp: string; direction?: string }> }> {
return this.call({
method: 'node-messages-received',
params: {},

View File

@@ -276,6 +276,31 @@ export const useMeshStore = defineStore('mesh', () => {
return result
}
async function sendChannelMessage(channel: number, message: string) {
const doSend = async () => {
try {
sending.value = true
error.value = null
const res = await rpcClient.call<{ sent: boolean; message_id: number; channel: number }>({
method: 'mesh.send-channel',
params: { channel, message: message.trim() },
})
if (res.sent) {
await fetchMessages()
}
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send channel message'
throw err
} finally {
sending.value = false
}
}
const result = sendQueue.then(doSend, doSend)
sendQueue = result.then(() => {}, () => {})
return result
}
async function broadcastIdentity() {
try {
error.value = null
@@ -456,6 +481,7 @@ export const useMeshStore = defineStore('mesh', () => {
fetchPeers,
fetchMessages,
sendMessage,
sendChannelMessage,
broadcastIdentity,
configure,
refreshAll,

View File

@@ -37,18 +37,34 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
// The Public channel (always available on Meshcore)
const publicChannel = { index: 0, name: 'Public' }
// Channel contact_id convention: matches backend u32::MAX - channel_index
function channelContactId(channelIndex: number): number {
return 4294967295 - channelIndex // u32::MAX - index
}
// Archipelago Channel — Tor-based messaging to all federated/peered nodes
const archChannelActive = ref(false)
const archMessages = ref<Array<{ from_pubkey: string; message: string; timestamp: string }>>([])
const archMessages = ref<Array<{ from_pubkey: string; from_name?: string; message: string; timestamp: string; direction?: string }>>([])
const archUnread = ref(0)
let archPollInterval: ReturnType<typeof setInterval> | null = null
// Federation node name cache: pubkey -> node name
const fedNodeNames = ref<Record<string, string>>({})
function openArchChannel() {
async function openArchChannel() {
activeChatPeer.value = null
activeChatChannel.value = null
archChannelActive.value = true
archUnread.value = 0
mobileShowChat.value = true
// Load federation node names for resolving pubkeys to names
try {
const res = await rpcClient.federationListNodes()
const names: Record<string, string> = {}
for (const node of res.nodes) {
if (node.pubkey) names[node.pubkey] = node.name || node.did.slice(0, 12) + '...'
}
fedNodeNames.value = names
} catch { /* non-fatal */ }
loadArchMessages()
if (!archPollInterval) {
archPollInterval = setInterval(loadArchMessages, 15000)
@@ -58,7 +74,18 @@ function openArchChannel() {
async function loadArchMessages() {
try {
const res = await rpcClient.getReceivedMessages()
archMessages.value = res.messages || []
const newMessages = res.messages || []
// Track unread: count new received messages since last load
if (archMessages.value.length > 0 && !archChannelActive.value) {
const newReceived = newMessages.filter(
m => m.direction !== 'sent' && m.from_pubkey !== 'me'
&& !archMessages.value.some(existing =>
existing.from_pubkey === m.from_pubkey && existing.timestamp === m.timestamp
)
)
archUnread.value += newReceived.length
}
archMessages.value = newMessages
} catch { /* silent */ }
}
@@ -142,6 +169,11 @@ async function handleToggleOffGrid() {
onMounted(async () => {
window.addEventListener('resize', handleResize)
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
// Start background polling for Archipelago (Tor) messages so unread count works
loadArchMessages()
if (!archPollInterval) {
archPollInterval = setInterval(loadArchMessages, 15000)
}
pollInterval = setInterval(() => {
mesh.fetchStatus()
mesh.fetchPeers()
@@ -154,6 +186,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (pollInterval) clearInterval(pollInterval)
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
})
// Active chat name for the header
@@ -177,11 +210,21 @@ const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChann
const chatMessages = computed(() => {
if (archChannelActive.value) {
return archMessages.value.map((m, i) => {
const isSent = (m as Record<string, unknown>).direction === 'sent' || m.from_pubkey === 'me'
const isSent = m.direction === 'sent' || m.from_pubkey === 'me'
let peerName = 'Unknown'
if (isSent) {
peerName = 'You'
} else if (m.from_name) {
peerName = m.from_name
} else if (fedNodeNames.value[m.from_pubkey]) {
peerName = fedNodeNames.value[m.from_pubkey]
} else {
peerName = m.from_pubkey.slice(0, 12) + '...'
}
return {
id: i,
peer_contact_id: -99,
peer_name: isSent ? 'You' : (m.from_pubkey.slice(0, 12) + '...'),
peer_name: peerName,
direction: (isSent ? 'sent' : 'received') as 'sent' | 'received',
plaintext: m.message,
timestamp: m.timestamp,
@@ -193,7 +236,7 @@ const chatMessages = computed(() => {
})
}
if (activeChatChannel.value) {
const chanId = -(activeChatChannel.value.index + 1)
const chanId = channelContactId(activeChatChannel.value.index)
return mesh.messages.filter(m => m.peer_contact_id === chanId)
}
if (activeChatPeer.value) {
@@ -236,6 +279,7 @@ function openChannelChat(channel: { index: number; name: string }) {
messageText.value = ''
activeTab.value = 'chat'
mobileShowChat.value = true
mesh.markChatRead(channelContactId(channel.index))
nextTick(() => scrollChatToBottom())
}
@@ -254,12 +298,18 @@ async function handleSendMessage() {
nextTick(() => scrollChatToBottom())
return
}
if (!activeChatPeer.value || !messageText.value.trim()) return
if (!messageText.value.trim()) return
sendError.value = ''
try {
if (activeChatChannel.value) {
await mesh.sendChannelMessage(activeChatChannel.value.index, messageText.value)
messageText.value = ''
nextTick(() => scrollChatToBottom())
} else if (activeChatPeer.value) {
await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value)
messageText.value = ''
nextTick(() => scrollChatToBottom())
}
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Send failed'
}
@@ -467,6 +517,7 @@ function truncatePubkey(hex: string | null): string {
<div class="mesh-peer-name">Public</div>
<div class="mesh-peer-sub">Mesh radio</div>
</div>
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
</div>
<div
v-for="peer in sortedPeers" :key="peer.contact_id"

View File

@@ -162,7 +162,7 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
}
// HTTP: direct port access (iframes break with proxy paths due to root-relative assets)
// HTTP: direct port access
const port = APP_PORTS[id]
if (!port) return ''
let base = `http://${window.location.hostname}:${port}`