feat: add authenticated resumable terminal

This commit is contained in:
archipelago
2026-10-09 07:59:12 -04:00
parent f50521072d
commit 16c84c450e
4 changed files with 414 additions and 270 deletions
+20
View File
@@ -14,6 +14,7 @@ mod remote_input;
mod remote_relay; mod remote_relay;
mod rental_playback; mod rental_playback;
mod routstr_proxy; mod routstr_proxy;
mod terminal;
mod websocket; mod websocket;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
@@ -426,6 +427,16 @@ impl ApiHandler {
.await; .await;
} }
// Owner terminal attachment — the browser socket is disposable; the
// authenticated tmux session survives reconnects and browser closes.
if method == Method::GET && path == "/ws/terminal" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_terminal_websocket(req).await;
}
// Remote input WebSocket — companion app sends keyboard/mouse events // Remote input WebSocket — companion app sends keyboard/mouse events
if method == Method::GET && path == "/ws/remote-input" { if method == Method::GET && path == "/ws/remote-input" {
if !self.is_authenticated(req.headers()).await { if !self.is_authenticated(req.headers()).await {
@@ -544,6 +555,15 @@ impl ApiHandler {
.unwrap()) .unwrap())
} }
(Method::GET, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
terminal::list_response().await
}
(Method::POST, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
terminal::create(&body_bytes).await
}
// Node message — P2P endpoint (authenticated by source validation, not cookie) // Node message — P2P endpoint (authenticated by source validation, not cookie)
(Method::POST, "/archipelago/node-message") => { (Method::POST, "/archipelago/node-message") => {
Self::handle_node_message(body_bytes).await Self::handle_node_message(body_bytes).await
@@ -0,0 +1,257 @@
//! Owner-authenticated terminal sessions backed by private tmux processes.
//!
//! Browser connections are disposable attachments. The tmux process and its
//! metadata remain on the node so a reconnect resumes the same workspace.
use anyhow::{anyhow, Result};
use futures_util::{SinkExt, StreamExt};
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
use super::{build_response, ApiHandler};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionRecord {
pub schema: u8,
pub id: String,
pub name: String,
pub workspace: String,
pub state: String,
pub tmux: String,
pub updated_at: i64,
}
#[derive(Debug, Deserialize)]
struct CreateRequest {
name: Option<String>,
workspace: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ClientMessage {
#[serde(rename = "type")]
kind: String,
data: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
}
pub(crate) fn state_dir() -> PathBuf {
std::env::var_os("ARCHY_SESSION_STATE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
PathBuf::from(xdg).join("archipelago/sessions")
} else if let Some(home) = std::env::var_os("HOME") {
let user_dir = PathBuf::from(home).join(".local/state/archipelago/sessions");
if user_dir.exists() {
user_dir
} else {
PathBuf::from("/var/lib/archipelago/sessions")
}
} else {
PathBuf::from("/var/lib/archipelago/sessions")
}
})
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
async fn read_record(dir: &Path, id: &str) -> Result<SessionRecord> {
if !valid_id(id) {
return Err(anyhow!("invalid session id"));
}
let bytes = tokio::fs::read(dir.join(format!("{id}.json"))).await?;
Ok(serde_json::from_slice(&bytes)?)
}
async fn tmux_alive(name: &str) -> bool {
Command::new("tmux")
.args(["has-session", "-t", name])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
}
async fn write_record(dir: &Path, record: &SessionRecord) -> Result<()> {
tokio::fs::create_dir_all(dir).await?;
let tmp = dir.join(format!(".{}.tmp-{}", record.id, Uuid::new_v4()));
let final_path = dir.join(format!("{}.json", record.id));
tokio::fs::write(&tmp, serde_json::to_vec_pretty(record)?).await?;
tokio::fs::rename(tmp, final_path).await?;
Ok(())
}
pub(crate) async fn list(dir: &Path) -> Result<Vec<SessionRecord>> {
let mut out = Vec::new();
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(error) => return Err(error.into()),
};
while let Some(entry) = entries.next_entry().await? {
if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = tokio::fs::read(entry.path()).await else {
continue;
};
let Ok(mut record) = serde_json::from_slice::<SessionRecord>(&bytes) else {
continue;
};
record.state = if tmux_alive(&record.tmux).await {
"detached".into()
} else if record.state == "running" {
"interrupted".into()
} else {
record.state.clone()
};
out.push(record);
}
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(out)
}
pub(crate) async fn list_response() -> Result<Response<hyper::Body>> {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(
&list(&state_dir()).await?,
)?))?)
}
pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
let request: CreateRequest = serde_json::from_slice(body).unwrap_or(CreateRequest {
name: None,
workspace: None,
});
let workspace_was_requested = request.workspace.is_some();
let workspace = request.workspace.unwrap_or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("Work")
.to_string_lossy()
.into_owned()
});
let workspace_path = PathBuf::from(&workspace);
if !workspace_was_requested {
tokio::fs::create_dir_all(&workspace_path).await?;
}
if !workspace_path.is_absolute() || !workspace_path.is_dir() || workspace_path == Path::new("/")
{
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"workspace must be an existing non-root directory"}"#),
));
}
let id = format!("s-{}", Uuid::new_v4().simple());
let tmux_name = format!("archy-{id}");
let output = Command::new("tmux")
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
.output()
.await?;
if !output.status.success() {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"error":"tmux could not start the session"}"#),
));
}
let name = request
.name
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| "Work".into());
let record = SessionRecord {
schema: 1,
id,
name,
workspace,
state: "running".into(),
tmux: tmux_name,
updated_at: chrono::Utc::now().timestamp(),
};
write_record(&state_dir(), &record).await?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header("Content-Type", "application/json")
.body(hyper::Body::from(serde_json::to_vec(&record)?))?)
}
pub(crate) async fn websocket(req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let id = req
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find_map(|part| part.strip_prefix("session="))
})
.unwrap_or("")
.to_string();
let record = read_record(&state_dir(), &id).await?;
if !tmux_alive(&record.tmux).await {
return Ok(build_response(
StatusCode::CONFLICT,
"application/json",
hyper::Body::from(r#"{"error":"session is not running"}"#),
));
}
let (response, ws_fut) =
hyper_ws_listener::create_ws(req).map_err(|e| anyhow!("WebSocket upgrade failed: {e}"))?;
if let Some(ws_fut) = ws_fut {
tokio::spawn(async move {
let Ok(Ok(stream)) = ws_fut.await else { return };
let (mut tx, mut rx) = stream.split();
let mut interval = tokio::time::interval(std::time::Duration::from_millis(150));
let mut last = String::new();
loop {
tokio::select! {
_ = interval.tick() => {
let output = Command::new("tmux").args(["capture-pane", "-p", "-e", "-t", &record.tmux, "-S", "-250"]).output().await;
if let Ok(output) = output {
let text = String::from_utf8_lossy(&output.stdout).into_owned();
if text != last { last = text.clone(); if tx.send(Message::Text(serde_json::json!({"type":"output", "data":text}).to_string())).await.is_err() { break; } }
} else { break; }
}
message = rx.next() => match message {
Some(Ok(Message::Text(text))) => {
let Ok(message) = serde_json::from_str::<ClientMessage>(&text) else { continue };
match message.kind.as_str() {
"input" => if let Some(data) = message.data { let mut command = Command::new("tmux"); command.args(["send-keys", "-t", &record.tmux]); if data == "\u{3}" { command.arg("C-c"); } else if data == "\u{4}" { command.arg("C-d"); } else { command.args(["-l", "--", &data]); } let _ = command.output().await; },
"resize" => if let (Some(cols), Some(rows)) = (message.cols, message.rows) { let _ = Command::new("tmux").args(["resize-window", "-t", &record.tmux, "-x", &cols.to_string(), "-y", &rows.to_string()]).output().await; },
"ping" => { let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.into())).await; },
_ => {}
}
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break,
_ => {}
}
}
}
});
}
Ok(response)
}
impl ApiHandler {
pub(super) async fn handle_terminal_websocket(
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
websocket(req).await
}
}
+47
View File
@@ -0,0 +1,47 @@
# Terminal UAT deployment runbook
Status: prepared, 2026-10-09. This runbook targets the physical Framework node
(`framework-pt`) and does not authorize an OTA, catalog publication, or wallet
mutation.
## Candidate contents
Build and record the backend binary, dashboard bundle, and source commit from
the isolated terminal worktree. The backend must have `ARCHY_SESSION_STATE_DIR`
set to the developer account's shared state directory (or use the default
resolution in `api/handler/terminal.rs`). Preserve the existing web root and
service binary before any replacement.
## Preconditions
1. Verify the Framework hostname and SSH host key through the operator's
approved connection mechanism. A plain `ssh framework-pt` must not be used
until host-key verification is available.
2. Capture service/container state, boot ID, running binary digest, served UI
digest, and the existing `/var/lib/archipelago/support` layout without
printing credentials, wallet files, or environment contents.
3. Create a timestamped protected rollback directory under
`/var/lib/archipelago/support/terminal-uat-<timestamp>`.
## Acceptance flow
- Open the dashboard as the node owner; unauthenticated requests to
`/api/terminal/sessions` and `/ws/terminal` return 401.
- Create a named session, type `printf 'uat\n'`, close the terminal, reopen it,
and resume the same session without a duplicate tmux process.
- Refresh the browser and reconnect after a temporary network interruption.
- Open a second owner browser and verify inventory visibility; verify only one
active attachment sends input at a time before enabling transfer controls.
- Confirm explicit End stops the tmux process but preserves the workspace.
- Reboot acceptance is separate: processes may stop, metadata must remain, and
the UI must call this interrupted rather than a live resume.
- Verify the Omarchy-derived agent skill files and app starter are present in
the candidate source/artifact; do not treat a local npm install failure as a
successful app build.
## Rollback
Stop exposing the new dashboard before restoring the previous UI/backend pair.
Restore only from the protected receipt, verify the previous hashes and health,
and leave terminal session metadata/workspaces untouched unless the operator
explicitly requests session cleanup.
+90 -270
View File
@@ -1,298 +1,118 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="cli-popup"> <Transition name="cli-popup">
<div <div v-if="cliStore.isOpen" class="fixed inset-0 z-[2500] flex items-center justify-center p-4" @click.self="cliStore.close()">
v-if="cliStore.isOpen" <div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
class="fixed inset-0 z-[2500] flex items-center justify-center p-4" <section class="terminal-panel relative z-10 w-full max-w-5xl overflow-hidden" aria-label="Archipelago terminal">
@click.self="cliStore.close()" <header class="terminal-header">
> <div class="terminal-title"><span class="terminal-dot"></span><span>Archipelago terminal</span><span class="terminal-host">{{ activeSession?.name || 'No session' }}</span></div>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div> <div class="terminal-actions">
<div <select v-model="selectedId" class="terminal-select" aria-label="Terminal session" @change="connectSelected">
ref="panelRef" <option value="">Select session</option>
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col" <option v-for="session in sessions" :key="session.id" :value="session.id">{{ session.name }} · {{ session.state }}</option>
:style="panelStyle" </select>
@mousedown="onPanelMouseDown" <button class="terminal-button" type="button" @click="createSession">New</button>
> <button class="terminal-close" type="button" aria-label="Close terminal" @click="closeTerminal">Close</button>
<!-- Header: terminal icon + title + app switcher -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div> </div>
<div class="flex items-center gap-3 flex-1 min-w-0"> </header>
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="terminal-body">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <div v-if="error" class="terminal-empty terminal-error"><strong>Terminal unavailable</strong><span>{{ error }}</span><button class="terminal-button" type="button" @click="loadSessions">Retry</button></div>
</svg> <div v-else-if="!sessions.length" class="terminal-empty"><strong>Create your first developer session</strong><span>Sessions stay running when this window closes and can be resumed later.</span><button class="terminal-button terminal-primary" type="button" @click="createSession">Start session</button></div>
<span class="text-white font-medium">CLI Access</span> <div v-else-if="!selectedId" class="terminal-empty"><strong>Choose a session</strong><span>Resume an existing workspace or start a new one.</span></div>
</div> <pre v-else ref="outputRef" class="terminal-output" tabindex="0" @click="focusInput">{{ output }}</pre>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div> </div>
<footer v-if="selectedId" class="terminal-input-bar">
<!-- Content: mock CLI in dev, SSH instructions in production --> <span class="terminal-prompt">›</span><input ref="inputRef" class="terminal-input" aria-label="Terminal input" autocomplete="off" autocapitalize="off" spellcheck="false" @keydown="onKeydown" @blur="keepFocus" />
<div class="flex-1 overflow-hidden flex flex-col min-h-0"> <span class="terminal-status" :class="{ connected: connected }">{{ connected ? 'Connected' : 'Reconnecting…' }}</span>
<!-- Mock CLI interface (dev mode only) --> </footer>
<div </section>
v-if="isDev"
class="flex-1 flex flex-col min-h-0 p-4 bg-black/80 rounded-b-lg font-mono text-sm"
>
<div ref="outputRef" class="flex-1 overflow-y-auto text-green-400/90 whitespace-pre-wrap break-words mb-2 min-h-0">{{ mockOutput }}</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-amber-400">archipelago@node</span>
<span class="text-white/60">~</span>
<span class="text-white/40">$</span>
<input
ref="cliInputRef"
v-model="mockCommand"
type="text"
class="flex-1 bg-transparent text-white outline-none border-none"
placeholder=" "
@keydown.enter="runMockCommand"
/>
</div>
</div>
<!-- SSH instructions (production only) -->
<div v-else class="flex-1 overflow-y-auto p-4 space-y-4">
<p class="text-white/80 text-sm">
Connect to this node via SSH to access the command line. Use the same host as this web interface.
</p>
<div class="space-y-3">
<div class="p-3 rounded-lg bg-white/5 font-mono text-sm">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">SSH Command</div>
<div class="flex items-center gap-2 flex-wrap">
<code class="text-green-400 break-all">{{ sshCommand }}</code>
<button
type="button"
class="shrink-0 px-2 py-1 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white text-xs transition-colors"
@click="copyCommand"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<div class="p-3 rounded-lg bg-white/5 text-sm space-y-1">
<div class="text-white/50 text-xs uppercase tracking-wider mb-2">Connection Details</div>
<div class="flex flex-col gap-1.5 text-white/80">
<div class="flex justify-between gap-4">
<span class="text-white/50">Host</span>
<span class="font-mono text-green-400">{{ host }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">User</span>
<span class="font-mono">archipelago</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-white/50">Password</span>
<span class="font-mono">archipelago</span>
</div>
</div>
</div>
<p class="text-white/50 text-xs">
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
</p>
<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]">F</kbd> to open this anytime.
</p>
</div>
</div>
</div>
</div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useCLIStore } from '@/stores/cli' import { useCLIStore } from '@/stores/cli'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
type Session = { id: string; name: string; workspace: string; state: string; updated_at: number }
const cliStore = useCLIStore() const cliStore = useCLIStore()
const panelRef = ref<HTMLElement | null>(null) const sessions = ref<Session[]>([])
const dragHandleRef = ref<HTMLElement | null>(null) const selectedId = ref('')
const output = ref('')
const error = ref('')
const connected = ref(false)
const outputRef = ref<HTMLElement | null>(null) const outputRef = ref<HTMLElement | null>(null)
const cliInputRef = ref<HTMLInputElement | null>(null) const inputRef = ref<HTMLInputElement | null>(null)
const copied = ref(false) let socket: WebSocket | null = null
const mockCommand = ref('') const activeSession = computed(() => sessions.value.find(session => session.id === selectedId.value))
const mockOutput = ref(` ╔═══════════════════════════════════════════════════════════╗ const wsUrl = (id: string) => `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/terminal?session=${encodeURIComponent(id)}`
║ 🏝️ ARCHIPELAGO BITCOIN NODE OS ║
║ Your sovereign Bitcoin infrastructure ║
╚═══════════════════════════════════════════════════════════╝
System Status: async function loadSessions() {
───────────────────────────────────────────────────────────── error.value = ''
Mode: 🟢 Installed
Podman: 🟢 Installed
Bitcoin: 🟢 Running (blocks: syncing)
Lightning: 🟡 Stopped
Main Menu:
─────────────────────────────────────────────────────────────
r) Refresh - Update IP/status
w) Open Web UI - Launch graphical interface
1) Install to Disk - Permanently install Archipelago
2) Setup Bitcoin Core - Configure Bitcoin full node
3) Setup Lightning (LND) - Configure Lightning Network
4) Setup BTCPay Server - Bitcoin payment processor
5) View Logs - Monitor running services
6) Network Settings - Configure networking
7) System Info - View system information
q) Quit
`)
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const SAVED_POSITION_KEY = 'archipelago-cli-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
const isDev = import.meta.env.DEV
const host = computed(() => window.location.hostname)
const sshCommand = computed(() => `ssh archipelago@${host.value}`)
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
function loadSavedPosition() {
try { try {
const raw = localStorage.getItem(SAVED_POSITION_KEY) const response = await fetch('/api/terminal/sessions', { credentials: 'include' })
if (raw) { if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to use the developer terminal.' : `Session service returned ${response.status}.`)
const parsed = JSON.parse(raw) sessions.value = await response.json()
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 } if (!selectedId.value && sessions.value[0]) selectedId.value = sessions.value[0].id
} else { if (selectedId.value) connectSelected()
savedPosition.value = null } catch (cause) { error.value = cause instanceof Error ? cause.message : 'Could not load terminal sessions.' }
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved CLI position', e)
savedPosition.value = null
}
} }
function savePosition(x: number, y: number) { async function createSession() {
savedPosition.value = { x, y } error.value = ''
try { try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y })) const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) })
} catch (e) { if (!response.ok) throw new Error('Could not create a developer session.')
if (import.meta.env.DEV) console.warn('Failed to save CLI position', e) const session = await response.json()
sessions.value = [session, ...sessions.value]
selectedId.value = session.id
connectSelected()
} catch (cause) { error.value = cause instanceof Error ? cause.message : 'Could not create terminal session.' }
}
function closeTerminal() { disconnect(); cliStore.close() }
function disconnect() { socket?.close(); socket = null; connected.value = false }
function connectSelected() {
disconnect(); output.value = ''
if (!selectedId.value) return
socket = new WebSocket(wsUrl(selectedId.value))
socket.onopen = () => { connected.value = true; focusInput() }
socket.onclose = () => { connected.value = false }
socket.onerror = () => { connected.value = false }
socket.onmessage = event => {
try { const message = JSON.parse(event.data); if (message.type === 'output') { output.value = message.data; nextTick(() => { if (outputRef.value) outputRef.value.scrollTop = outputRef.value.scrollHeight }) } } catch { /* ignore malformed frames */ }
} }
} }
function runMockCommand() { function send(data: string) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })) }
const cmd = mockCommand.value.trim() function onKeydown(event: KeyboardEvent) {
if (!cmd) return if (event.ctrlKey && event.key.toLowerCase() === 'c') { event.preventDefault(); send('\u0003'); return }
mockOutput.value += `\n archipelago@node ~ $ ${cmd}\n` if (event.ctrlKey && event.key.toLowerCase() === 'd') { event.preventDefault(); send('\u0004'); return }
const lower = cmd.toLowerCase() const special: Record<string, string> = { Enter: '\n', Backspace: '\u007f', Tab: '\t', ArrowUp: '\u001b[A', ArrowDown: '\u001b[B', ArrowRight: '\u001b[C', ArrowLeft: '\u001b[D' }
if (lower === 'r' || lower === 'refresh') { if (special[event.key]) { event.preventDefault(); send(special[event.key]); return }
mockOutput.value += ` Status refreshed.\n` if (event.key.length === 1 && !event.metaKey && !event.altKey) { event.preventDefault(); send(event.key) }
} else if (lower === 'w' || lower.startsWith('web')) {
mockOutput.value += ` Opening Web UI... (press C to return to CLI)\n`
} else if (lower === 'q' || lower === 'quit' || lower === 'exit') {
mockOutput.value += ` Goodbye! 🏝️\n`
cliStore.close()
} else if (lower === 'help' || lower === '?') {
mockOutput.value += ` Type r, w, 1-7, or q. Press C to switch to Web UI.\n`
} else {
mockOutput.value += ` Unknown command. Type 'help' or 'r' for menu.\n`
}
mockCommand.value = ''
nextTick(() => {
outputRef.value?.scrollTo({ top: outputRef.value.scrollHeight, behavior: 'smooth' })
})
} }
function focusInput() { nextTick(() => inputRef.value?.focus()) }
async function copyCommand() { function keepFocus() { if (cliStore.isOpen) setTimeout(focusInput, 0) }
try { watch(() => cliStore.isOpen, open => { if (open) loadSessions(); else disconnect() })
await navigator.clipboard.writeText(sshCommand.value) onBeforeUnmount(disconnect)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = sshCommand.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
useModalKeyboard(panelRef, computed(() => cliStore.isOpen), () => cliStore.close())
watch(
() => cliStore.isOpen,
(open) => {
if (open) {
loadSavedPosition()
if (isDev) {
nextTick(() => cliInputRef.value?.focus())
}
}
}
)
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script> </script>
<style scoped> <style scoped>
.cli-popup-enter-active, .terminal-panel { display: flex; flex-direction: column; max-height: min(760px, calc(100vh - 2rem)); min-height: min(540px, calc(100vh - 2rem)); border: 1px solid rgba(247,147,26,.28); border-radius: 18px; background: #0b0e12; box-shadow: 0 28px 90px rgba(0,0,0,.65); color: #f6f7f9; }
.cli-popup-leave-active { .terminal-header, .terminal-input-bar { display: flex; align-items: center; gap: 12px; padding: 13px 16px; background: rgba(255,255,255,.045); }
transition: opacity 0.2s ease; .terminal-header { justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,.1); }
} .terminal-title, .terminal-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
.cli-popup-enter-from, .terminal-title { font-weight: 650; } .terminal-host { color: rgba(255,255,255,.45); font-size: 12px; font-weight: 400; }
.cli-popup-leave-to { .terminal-dot { width: 9px; height: 9px; border-radius: 50%; background: #f7931a; box-shadow: 0 0 12px #f7931a; }
opacity: 0; .terminal-body { flex: 1; min-height: 0; overflow: hidden; background: #050608; }
} .terminal-output { height: 100%; margin: 0; overflow: auto; padding: 18px; color: #d7f7d3; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; outline: none; }
.terminal-input-bar { border-top: 1px solid rgba(255,255,255,.1); } .terminal-prompt { color: #f7931a; font-size: 20px; } .terminal-input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: white; font: 13px ui-monospace, monospace; }
.terminal-status { color: #e8a7a7; font-size: 11px; } .terminal-status.connected { color: #92d6a0; }
.terminal-select, .terminal-button, .terminal-close { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.07); color: white; padding: 0 11px; font-size: 12px; }
.terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); } .terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; } .terminal-close { color: rgba(255,255,255,.65); }
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
@media (max-width: 640px) { .terminal-panel { min-height: calc(100vh - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
</style> </style>