fix: profile import file upload, startup health check, sidebar scroll, node-pty fallback
- Change profile import from server path input to browser file upload (multipart) - Fix startup script to wait for health check before opening browser - Add overflow scroll with hidden scrollbar to sidebar nav - Graceful degradation when node-pty fails to load (WSL compatibility) - Remove rename button from profile cards - Restrict profile name input to English letters, numbers, hyphens - Use raw.githubusercontent.com URLs in README setup script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import { createReadStream, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { tmpdir, homedir } from 'os'
|
||||
import YAML from 'js-yaml'
|
||||
@@ -242,20 +243,66 @@ profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/import - Import profile from archive
|
||||
// POST /api/profiles/import - Import profile from uploaded archive
|
||||
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
|
||||
const { archive, name } = ctx.request.body as { archive?: string; name?: string }
|
||||
|
||||
if (!archive) {
|
||||
const contentType = ctx.get('content-type') || ''
|
||||
if (!contentType.startsWith('multipart/form-data')) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing archive path' }
|
||||
ctx.body = { error: 'Expected multipart/form-data' }
|
||||
return
|
||||
}
|
||||
|
||||
const boundary = '--' + contentType.split('boundary=')[1]
|
||||
if (!boundary || boundary === '--undefined') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing boundary' }
|
||||
return
|
||||
}
|
||||
|
||||
const tmpDir = join(tmpdir(), 'hermes-import')
|
||||
await mkdir(tmpDir, { recursive: true })
|
||||
|
||||
// Read raw body and parse multipart
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of ctx.req) chunks.push(chunk)
|
||||
const body = Buffer.concat(chunks).toString('latin1')
|
||||
const parts = body.split(boundary).slice(1, -1)
|
||||
|
||||
let archivePath = ''
|
||||
|
||||
for (const part of parts) {
|
||||
const headerEnd = part.indexOf('\r\n\r\n')
|
||||
if (headerEnd === -1) continue
|
||||
const header = part.substring(0, headerEnd)
|
||||
const data = part.substring(headerEnd + 4, part.length - 2)
|
||||
|
||||
const filenameMatch = header.match(/filename="([^"]+)"/)
|
||||
if (!filenameMatch) continue
|
||||
|
||||
const filename = filenameMatch[1]
|
||||
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
|
||||
if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue
|
||||
|
||||
archivePath = join(tmpDir, filename)
|
||||
await writeFile(archivePath, Buffer.from(data, 'binary'))
|
||||
break
|
||||
}
|
||||
|
||||
if (!archivePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await hermesCli.importProfile(archive, name)
|
||||
const result = await hermesCli.importProfile(archivePath)
|
||||
|
||||
// Clean up temp file
|
||||
try { unlinkSync(archivePath) } catch { }
|
||||
|
||||
ctx.body = { success: true, message: result.trim() }
|
||||
} catch (err: any) {
|
||||
try { unlinkSync(archivePath) } catch { }
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import { existsSync } from 'fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { getToken } from '../../services/auth'
|
||||
|
||||
let pty: any = null
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
pty = require('node-pty')
|
||||
} catch {
|
||||
console.warn('[Terminal] node-pty failed to load, terminal feature disabled')
|
||||
}
|
||||
|
||||
// ─── Shell detection ────────────────────────────────────────────
|
||||
|
||||
function findShell(): string {
|
||||
@@ -29,7 +36,7 @@ function shellName(shell: string): string {
|
||||
|
||||
interface PtySession {
|
||||
id: string
|
||||
pty: pty.IPty
|
||||
pty: { pid: number; onData: (cb: (data: string) => void) => void; onExit: (cb: (e: { exitCode: number }) => void) => void; write: (data: string) => void; kill: (signal?: string) => void; resize: (cols: number, rows: number) => void }
|
||||
shell: string
|
||||
pid: number
|
||||
createdAt: number
|
||||
@@ -49,7 +56,7 @@ function generateId(): string {
|
||||
|
||||
function createSession(shell: string): PtySession {
|
||||
const id = generateId()
|
||||
let ptyProcess: pty.IPty
|
||||
let ptyProcess: PtySession['pty']
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
@@ -75,6 +82,11 @@ function createSession(shell: string): PtySession {
|
||||
// ─── WebSocket server setup ─────────────────────────────────────
|
||||
|
||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
if (!pty) {
|
||||
console.warn('[Terminal] node-pty not available, skipping terminal WebSocket setup')
|
||||
return
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
const defaultShell = findShell()
|
||||
|
||||
@@ -111,7 +123,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
// ─── PTY output → WebSocket ──────────────────────────────────
|
||||
|
||||
function attachPtyOutput(session: PtySession) {
|
||||
session.pty.onData((data) => {
|
||||
session.pty.onData((data: string) => {
|
||||
if (ws.readyState !== ws.OPEN) return
|
||||
if (conn.activeSessionId === session.id) {
|
||||
ws.send(data)
|
||||
@@ -130,7 +142,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
}
|
||||
})
|
||||
|
||||
session.pty.onExit(({ exitCode }) => {
|
||||
session.pty.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
conn.outputBuffers.delete(session.id)
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
||||
|
||||
Reference in New Issue
Block a user