Archipelago v1.7.129-alpha

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 1595a02a7a
2058 changed files with 470069 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Create simple placeholder icons using ImageMagick or fallback to SVG
cd "$(dirname "$0")/.."
ICON_DIR="public/assets/img/app-icons"
# Create endurain placeholder
if command -v convert &> /dev/null; then
convert -size 512x512 xc:none -fill "rgba(100,150,255,200)" -draw "circle 256,256 256,100" -pointsize 200 -fill white -gravity center -annotate +0+0 "E" "$ICON_DIR/endurain.png" 2>/dev/null && echo "✅ Created endurain.png"
elif command -v magick &> /dev/null; then
magick -size 512x512 xc:none -fill "rgba(100,150,255,200)" -draw "circle 256,256 256,100" -pointsize 200 -fill white -gravity center -annotate +0+0 "E" "$ICON_DIR/endurain.png" 2>/dev/null && echo "✅ Created endurain.png"
else
# Fallback: Create simple SVG
cat > "$ICON_DIR/endurain.svg" << 'SVGEOF'
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="156" fill="rgba(100,150,255,200)"/>
<text x="256" y="256" font-size="200" fill="white" text-anchor="middle" dominant-baseline="central" font-weight="bold">E</text>
</svg>
SVGEOF
echo "✅ Created endurain.svg"
fi
# Create morphos-server placeholder
if command -v convert &> /dev/null; then
convert -size 512x512 xc:none -fill "rgba(150,100,255,200)" -draw "rectangle 100,100 412,412" -pointsize 200 -fill white -gravity center -annotate +0+0 "M" "$ICON_DIR/morphos-server.png" 2>/dev/null && echo "✅ Created morphos-server.png"
elif command -v magick &> /dev/null; then
magick -size 512x512 xc:none -fill "rgba(150,100,255,200)" -draw "rectangle 100,100 412,412" -pointsize 200 -fill white -gravity center -annotate +0+0 "M" "$ICON_DIR/morphos-server.png" 2>/dev/null && echo "✅ Created morphos-server.png"
else
# Fallback: Create simple SVG
cat > "$ICON_DIR/morphos-server.svg" << 'SVGEOF'
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<rect x="100" y="100" width="312" height="312" rx="40" fill="rgba(150,100,255,200)"/>
<text x="256" y="256" font-size="200" fill="white" text-anchor="middle" dominant-baseline="central" font-weight="bold">M</text>
</svg>
SVGEOF
echo "✅ Created morphos-server.svg"
fi
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Script to download app icons from GitHub repositories
* Downloads icons for all dummy apps from Start9Labs/{app-id}-startos repos
*/
import fs from 'fs'
import path from 'path'
import https from 'https'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const appIds = [
'bitcoin',
'btcpay-server',
'homeassistant',
'grafana',
'endurain',
'fedimint',
'morphos-server',
'lightning-stack',
'mempool',
'ollama',
'searxng',
'penpot'
]
// Map app IDs to their Start9 repo names (some might differ)
const repoMap = {
'bitcoin': 'bitcoind-startos',
'btcpay-server': 'btcpayserver-startos',
'homeassistant': 'home-assistant-startos',
'grafana': 'grafana-startos',
'lightning-stack': 'lnd-startos',
'mempool': 'mempool-startos',
'searxng': 'searxng-startos',
'penpot': 'penpot-startos',
}
// Custom icon URLs for apps without Start9 repos
const customIconUrls = {
'fedimint': [
'https://raw.githubusercontent.com/fedibtc/fedimint-ui/master/apps/router/public/favicon.svg',
],
// Official bark project avatar (Ark protocol wallet daemon)
'bark': [
'https://gitlab.com/uploads/-/system/project/avatar/75519706/bark-smiling-square-white-2.jpg',
],
}
const iconDir = path.join(__dirname, '../public/assets/img/app-icons')
// Ensure directory exists
if (!fs.existsSync(iconDir)) {
fs.mkdirSync(iconDir, { recursive: true })
}
function downloadFile(url, filepath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(filepath)
https.get(url, (response) => {
if (response.statusCode === 200) {
response.pipe(file)
file.on('finish', () => {
file.close()
console.log(`✅ Downloaded: ${path.basename(filepath)}`)
resolve()
})
} else if (response.statusCode === 404) {
file.close()
fs.unlinkSync(filepath) // Delete empty file
console.log(`⚠️ Not found: ${url}`)
reject(new Error(`404: ${url}`))
} else {
file.close()
fs.unlinkSync(filepath)
reject(new Error(`HTTP ${response.statusCode}: ${url}`))
}
}).on('error', (err) => {
file.close()
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath)
}
reject(err)
})
})
}
async function downloadIcon(appId) {
const targetExt = 'webp' // Prefer webp for consistency with mempool, etc.
const fallbackExts = ['webp', 'png', 'svg']
const filepath = path.join(iconDir, `${appId}.webp`)
// Skip if file already exists
if (appId === 'fedimint' && fs.existsSync(path.join(iconDir, 'fedimint.png'))) {
console.log(`⏭️ Skipping ${appId} (fedimint.png exists)`)
return true
}
for (const ext of fallbackExts) {
const fp = path.join(iconDir, `${appId}.${ext}`)
if (fs.existsSync(fp)) {
console.log(`⏭️ Skipping ${appId} (already exists)`)
return true
}
}
// Try custom URLs first (e.g. fedimint from fedimint-ui)
if (customIconUrls[appId]) {
for (const url of customIconUrls[appId]) {
try {
const ext = url.endsWith('.svg') ? 'svg' : (url.endsWith('.png') ? 'png' : 'webp')
const fp = path.join(iconDir, `${appId}.${ext}`)
await downloadFile(url, fp)
return true
} catch (err) {
continue
}
}
}
const repoName = repoMap[appId] || `${appId}-startos`
const iconPaths = ['icon.png', 'icon.svg', 'assets/icon.png', 'assets/icon.svg']
for (const iconPath of iconPaths) {
const url = `https://raw.githubusercontent.com/Start9Labs/${repoName}/main/${iconPath}`
const extension = iconPath.endsWith('.svg') ? 'svg' : 'png'
const fp = path.join(iconDir, `${appId}.${extension}`)
try {
await downloadFile(url, fp)
return true
} catch (err) {
continue
}
}
console.log(`❌ Failed to download icon for ${appId}`)
return false
}
async function main() {
console.log('Downloading app icons from GitHub...\n')
const results = {
success: [],
failed: []
}
for (const appId of appIds) {
try {
const success = await downloadIcon(appId)
if (success) {
results.success.push(appId)
} else {
results.failed.push(appId)
}
// Small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 500))
} catch (err) {
console.error(`Error downloading ${appId}:`, err.message)
results.failed.push(appId)
}
}
console.log(`\n✅ Successfully downloaded ${results.success.length} icons`)
if (results.failed.length > 0) {
console.log(`❌ Failed to download ${results.failed.length} icons:`, results.failed.join(', '))
}
}
main().catch(console.error)
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* Generate "Welcome Noderunner" speech using ElevenLabs AI voice.
* Slower, softer, sci-fi style with reverb/echo effects.
*
* Usage:
* ELEVENLABS_API_KEY=your_key node scripts/generate-welcome-speech.js
*
* Optional voice ID (browse https://elevenlabs.io/voice-library/sensual):
* ELEVENLABS_VOICE_ID=voice_id node scripts/generate-welcome-speech.js
*/
import { writeFileSync, mkdirSync, readFileSync, unlinkSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const API_KEY = process.env.ELEVENLABS_API_KEY
// Sarah - mature, reassuring, confident female (softer than Rachel)
const VOICE_ID = process.env.ELEVENLABS_VOICE_ID || 'EXAVITQu4vr4xnSDxMaL'
const OUTPUT_PATH = join(__dirname, '../public/assets/audio/welcome-noderunner.mp3')
const RAW_PATH = join(__dirname, '../public/assets/audio/welcome-noderunner-raw.mp3')
if (!API_KEY) {
console.error('Set ELEVENLABS_API_KEY (get a free key at elevenlabs.io)')
process.exit(1)
}
// Slower (0.78), softer (higher stability 0.65), more expressive (style 0.6)
const res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}?output_format=mp3_44100_128`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'xi-api-key': API_KEY,
},
body: JSON.stringify({
text: 'Welcome Noderunner',
model_id: 'eleven_multilingual_v2',
voice_settings: {
stability: 0.65,
similarity_boost: 0.8,
style: 0.6,
use_speaker_boost: true,
speed: 0.7,
},
}),
}
)
if (!res.ok) {
const err = await res.text()
console.error('ElevenLabs API error:', res.status, err)
process.exit(1)
}
const buf = Buffer.from(await res.arrayBuffer())
mkdirSync(dirname(OUTPUT_PATH), { recursive: true })
writeFileSync(RAW_PATH, buf)
// Add sci-fi reverb: dense short delays that blend (no distinct echo)
try {
execSync(
`ffmpeg -y -i "${RAW_PATH}" -af "aecho=0.6:0.15:25|45|70:0.55|0.45|0.35,highpass=f=80,equalizer=f=4000:t=q:w=1:g=-1" -q:a 2 "${OUTPUT_PATH}" 2>/dev/null`,
{ stdio: 'pipe' }
)
unlinkSync(RAW_PATH)
} catch {
writeFileSync(OUTPUT_PATH, buf)
try { unlinkSync(RAW_PATH) } catch {}
}
console.log('Generated:', OUTPUT_PATH)
console.log('Add this file to git and deploy.')
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env node
// Demo/real RPC parity harness (FED-04).
//
// STATIC stage: cross-references every mesh.*/federation.* RPC method the UI
// actually calls against the cases mock-backend.js implements, and reports any
// the demo would answer with "Method not found".
//
// LIVE stage: boots mock-backend.js on an ephemeral port and drives a scripted
// JSON-RPC sequence, asserting the handlers mutate demo state rather than
// returning bare acknowledgements.
//
// Exits non-zero on any failure. There are deliberately no `|| fallback`
// escapes anywhere in here: a failed spawn, a failed fetch or a missing field
// must fail the run, never produce a green run that measured nothing.
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
const HERE = dirname(fileURLToPath(import.meta.url))
const UI_ROOT = resolve(HERE, '..')
const MOCK = join(UI_ROOT, 'mock-backend.js')
const SRC = join(UI_ROOT, 'src')
// Methods the UI calls that the mock is knowingly allowed to be missing.
// Task 2 emptied this: any gap now fails the run.
const KNOWN_GAPS = []
let failures = 0
const fail = (msg) => {
console.error(`${msg}`)
failures++
}
const pass = (msg) => console.log(`${msg}`)
// ── STATIC ──────────────────────────────────────────────────────────────────
// Only `method: 'mesh.x'` / `method: "mesh.x"` counts as a call site. A bare
// string literal is NOT enough: Mesh.vue and Federation.vue use the same
// dotted names as resource-cache keys (`key: 'mesh.self-did'`,
// `key: 'federation.nodes'`), which are not RPC methods at all. Matching those
// would report permanent phantom gaps and make this harness unfailable.
const CALL_RE = /method:\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g
const CASE_RE = /case\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g
function walk(dir) {
const out = []
for (const name of readdirSync(dir)) {
const p = join(dir, name)
const st = statSync(p)
if (st.isDirectory()) out.push(...walk(p))
else if (/\.(ts|js|vue)$/.test(name)) out.push(p)
}
return out
}
function collect(re, text, into) {
let m
while ((m = re.exec(text)) !== null) into.add(m[1])
}
console.log('STATIC: cross-referencing UI call sites against mock handlers')
const called = new Set()
for (const file of walk(SRC)) collect(new RegExp(CALL_RE), readFileSync(file, 'utf8'), called)
const handled = new Set()
collect(new RegExp(CASE_RE), readFileSync(MOCK, 'utf8'), handled)
const missing = [...called].filter((m) => !handled.has(m)).sort()
const unexpected = missing.filter((m) => !KNOWN_GAPS.includes(m))
console.log(` UI calls ${called.size} mesh/federation methods; mock implements ${handled.size}`)
if (missing.length === 0) {
pass('every UI-called mesh/federation method has a mock handler')
} else {
for (const m of unexpected) fail(`no mock handler for ${m}`)
for (const m of missing.filter((x) => KNOWN_GAPS.includes(x))) {
console.log(` … known gap (allowed): ${m}`)
}
}
// ── LIVE ────────────────────────────────────────────────────────────────────
const freePort = () =>
new Promise((res, rej) => {
const srv = createServer()
srv.on('error', rej)
srv.listen(0, '127.0.0.1', () => {
const { port } = srv.address()
srv.close(() => res(port))
})
})
const port = await freePort()
console.log(`LIVE: booting mock-backend.js on :${port}`)
const child = spawn(process.execPath, [MOCK], {
cwd: UI_ROOT,
env: { ...process.env, MOCK_BACKEND_PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.resume()
child.stderr.resume()
let childExited = false
child.on('exit', () => {
childExited = true
})
const base = `http://127.0.0.1:${port}`
const rpc = async (method, params) => {
const resp = await fetch(`${base}/rpc/v1`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
if (!resp.ok) throw new Error(`${method} → HTTP ${resp.status}`)
const body = await resp.json()
if (body.error) throw new Error(`${method} → RPC error ${body.error.code}: ${body.error.message}`)
return body.result
}
try {
// Poll until the server answers (bounded).
const deadline = Date.now() + 10000
for (;;) {
if (childExited) throw new Error('mock-backend exited before becoming ready')
try {
await rpc('server.echo', { message: 'parity-probe' })
break
} catch {
if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 10s')
await new Promise((r) => setTimeout(r, 200))
}
}
pass('mock-backend answered on the ephemeral port')
// 1. Peer aliasing round-trip (the FED-04 headline: renaming a peer sticks).
const peers = await rpc('mesh.contacts-list')
const target = peers.contacts.find((c) => c.pubkey)
if (!target) throw new Error('mesh.contacts-list returned no contact with a pubkey')
if (target.alias !== null) fail(`expected a fresh contact to have a null alias, got ${target.alias}`)
await rpc('mesh.contacts-save', { pubkey: target.pubkey, alias: 'Base Camp' })
const after = await rpc('mesh.contacts-list')
const renamed = after.contacts.find((c) => c.pubkey === target.pubkey)
if (renamed?.alias === 'Base Camp') pass('mesh.contacts-save → contacts-list round-trips the alias')
else fail(`alias did not persist: expected 'Base Camp', got ${JSON.stringify(renamed?.alias)}`)
// The daemon omits peers with no pubkey_hex; so must the demo.
if (after.contacts.every((c) => c.pubkey)) pass('contacts-list omits peers without a pubkey, as the daemon does')
else fail('contacts-list returned a contact with no pubkey')
// 2. Scheduling: schedule → list → cancel → list.
const fireAt = Math.floor(Date.now() / 1000) + 3600
const scheduled = await rpc('mesh.schedule-message', {
body: 'mesh parity check',
fire_at: fireAt,
contact_id: 1,
})
if (typeof scheduled.id !== 'number') fail('mesh.schedule-message did not return a numeric id')
const listed = await rpc('mesh.list-scheduled')
if (listed.messages.some((m) => m.id === scheduled.id)) pass('mesh.schedule-message → list-scheduled shows the entry')
else fail('scheduled message missing from mesh.list-scheduled')
const cancelled = await rpc('mesh.cancel-scheduled', { id: scheduled.id })
if (cancelled.cancelled !== true) fail('mesh.cancel-scheduled did not report a cancellation')
const afterCancel = await rpc('mesh.list-scheduled')
if (!afterCancel.messages.some((m) => m.id === scheduled.id)) pass('mesh.cancel-scheduled removed the entry')
else fail('cancelled message still present in mesh.list-scheduled')
// Cancelling an unknown id must report false, not throw or lie.
const ghost = await rpc('mesh.cancel-scheduled', { id: 999999 })
if (ghost.cancelled === false) pass('cancel-scheduled reports false for an unknown id')
else fail(`cancel-scheduled lied about an unknown id: ${JSON.stringify(ghost)}`)
// 3. clear-all empties this session's sent messages.
await rpc('mesh.send-content-inline', {
contact_id: 1,
mime: 'text/plain',
filename: 'parity.txt',
bytes_b64: Buffer.from('parity').toString('base64'),
})
const withMsg = await rpc('mesh.messages', { limit: 500 })
const cleared = await rpc('mesh.clear-all')
if (cleared.status !== 'cleared') fail(`mesh.clear-all returned ${JSON.stringify(cleared)}`)
const afterClear = await rpc('mesh.messages', { limit: 500 })
if (afterClear.count < withMsg.count) pass('mesh.clear-all dropped this session\'s messages')
else fail(`mesh.clear-all did not reduce the message count (${withMsg.count}${afterClear.count})`)
// 4. Assistant config round-trip.
const status = await rpc('mesh.assistant-status')
for (const k of ['enabled', 'model', 'trusted_only', 'backend', 'allowed_contacts', 'default_model', 'ollama_detected', 'claude_available', 'models', 'denied_askers']) {
if (!(k in status)) fail(`mesh.assistant-status missing field '${k}'`)
}
const configured = await rpc('mesh.assistant-configure', { enabled: true, model: 'llama3.2:3b' })
if (configured.enabled === true && configured.model === 'llama3.2:3b') pass('mesh.assistant-configure applies and echoes settings')
else fail(`assistant-configure did not apply: ${JSON.stringify(configured)}`)
const restatus = await rpc('mesh.assistant-status')
if (restatus.enabled === true) pass('assistant config persists across calls')
else fail('assistant config did not persist')
// 5. federation.cancel-request removes the outbound request.
const pending = await rpc('federation.list-pending-requests')
const outbound = pending.requests.find((r) => r.outbound && r.state === 'sent')
if (!outbound) {
fail('no outbound/sent request in the demo seed — cancel-request is unexercisable')
} else {
const res = await rpc('federation.cancel-request', { id: outbound.id })
if (res.cancelled !== true || res.notified !== true) fail(`cancel-request returned ${JSON.stringify(res)}`)
const post = await rpc('federation.list-pending-requests')
if (!post.requests.some((r) => r.id === outbound.id)) pass('federation.cancel-request removed the request')
else fail('cancelled request still listed as pending')
}
// 6. Chat mutations are observable on the next mesh.messages read — the
// whole point of FED-04's "not a bare ok acknowledgement" requirement.
const sent = await rpc('mesh.send-content-inline', {
contact_id: 1,
mime: 'text/plain',
filename: 'mutate.txt',
bytes_b64: Buffer.from('mutate me').toString('base64'),
})
const targetSeq = sent.message_id
await rpc('mesh.send-reaction', {
contact_id: 1,
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
target_seq: targetSeq,
emoji: '🔥',
})
let msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const reaction = msgs.find(
(m) => m.message_type === 'reaction' && m.typed_payload?.target?.sender_seq === targetSeq,
)
if (reaction?.typed_payload?.emoji === '🔥') pass('mesh.send-reaction is visible as a reaction message with a target key')
else fail('reaction did not appear in mesh.messages with the UI-expected shape')
await rpc('mesh.send-reply', {
contact_id: 1,
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
target_seq: targetSeq,
text: 'replying to that',
})
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const reply = msgs.find((m) => m.message_type === 'reply' && m.typed_payload?.target?.sender_seq === targetSeq)
if (reply?.plaintext === 'replying to that') pass('mesh.send-reply is visible as a reply carrying its target')
else fail('reply did not appear with a target key')
await rpc('mesh.edit-message', { contact_id: 1, target_seq: targetSeq, new_text: 'edited text' })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const edited = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
if (edited?.plaintext === 'edited text' && edited?.typed_payload?.edited_at)
pass('mesh.edit-message rewrote the text and set the edited marker')
else fail(`edit not applied: ${JSON.stringify(edited?.plaintext)}`)
await rpc('mesh.delete-message', { contact_id: 1, target_seq: targetSeq })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const deleted = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
// The daemon tombstones in place rather than removing the row.
if (deleted && deleted.message_type === 'delete' && deleted.typed_payload?.deleted === true)
pass('mesh.delete-message tombstoned in place, as apply_local_delete does')
else fail(`delete representation wrong: ${JSON.stringify(deleted)}`)
const beforeForward = (await rpc('mesh.messages', { limit: 500 })).count
await rpc('mesh.forward-message', { contact_id: 2, source_message_id: targetSeq })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const forwarded = msgs.filter((m) => m.peer_contact_id === 2 && m.direction === 'sent')
if (msgs.length > beforeForward && forwarded.length > 0) pass('mesh.forward-message pushed a copy for the destination peer')
else fail('forward did not create a message for the destination peer')
await rpc('mesh.send-channel', { channel: 3, message: 'channel broadcast' })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
if (msgs.some((m) => m.channel === 3 && m.plaintext === 'channel broadcast'))
pass('mesh.send-channel pushed a channel-addressed message')
else fail('channel message not visible in mesh.messages')
// 7. federation.notify-did-change reports per-peer results.
const notified = await rpc('federation.notify-did-change', {
old_did: 'did:key:zOld',
new_did: 'did:key:zNew',
proof_signature: 'demo-sig',
proof_message: 'demo-msg',
})
if (Array.isArray(notified.results) && notified.notified === notified.results.length) {
pass('federation.notify-did-change reports one result per node')
} else {
fail(`notify-did-change shape wrong: ${JSON.stringify(notified)}`)
}
} catch (err) {
fail(err.message)
} finally {
child.kill('SIGTERM')
}
if (failures > 0) {
console.error(`\nFAILED: ${failures} parity problem(s)`)
process.exit(1)
}
console.log('\nOK: demo/real mesh + federation RPC parity holds')