From d86f2bea039c67aef7311fc9edde180c2648b811 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 23 Apr 2026 13:21:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20apps.logs=20=E2=80=94=20compose-aw?= =?UTF-8?q?are=20runtime=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds apps.logs MCP tool + session REST endpoint for tailing runtime container logs. Unblocks cold-start debugging for agent-deployed compose apps (Twenty, Cal.com, Plane, etc.) where Coolify's own /applications/{uuid}/logs endpoint returns empty. Architecture: - dockerfile / nixpacks / static apps → Coolify's REST logs API - dockercompose apps → SSH into Coolify host, `docker logs` per service New SSH path uses a dedicated `vibn-logs` user (docker group, no sudo, no pty, no port-forwarding, single ed25519 key). Private key lives in COOLIFY_SSH_PRIVATE_KEY_B64 on the vibn-frontend Coolify app; authorized_key is installed by scripts/setup-vibn-logs-user.sh on the Coolify host. Tool shape: params: { uuid, service?, lines? (default 200, max 5000) } returns: { uuid, buildPack, source: 'coolify_api'|'ssh_docker'|'empty', services: { [name]: { container, lines, bytes, logs, status? } }, warnings: string[], truncated: boolean } Made-with: Cursor --- app/api/mcp/route.ts | 32 +++ .../[slug]/apps/[uuid]/logs/route.ts | 49 ++++ lib/coolify-logs.ts | 211 ++++++++++++++++++ lib/coolify-ssh.ts | 133 +++++++++++ lib/coolify.ts | 14 ++ package-lock.json | 100 +++++++++ package.json | 2 + 7 files changed, 541 insertions(+) create mode 100644 app/api/workspaces/[slug]/apps/[uuid]/logs/route.ts create mode 100644 lib/coolify-logs.ts create mode 100644 lib/coolify-ssh.ts diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index b66d793..5323f5d 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -26,6 +26,7 @@ import { getWorkspaceGcsHmacCredentials, } from '@/lib/workspace-gcs'; import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage'; +import { getApplicationRuntimeLogs } from '@/lib/coolify-logs'; import { deployApplication, getApplicationInProject, @@ -97,6 +98,7 @@ export async function GET() { 'apps.deployments', 'apps.domains.list', 'apps.domains.set', + 'apps.logs', 'apps.envs.list', 'apps.envs.upsert', 'apps.envs.delete', @@ -189,6 +191,8 @@ export async function POST(request: Request) { return await toolAppsDomainsList(principal, params); case 'apps.domains.set': return await toolAppsDomainsSet(principal, params); + case 'apps.logs': + return await toolAppsLogs(principal, params); case 'databases.list': return await toolDatabasesList(principal); @@ -400,6 +404,34 @@ async function toolAppsDeployments(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + if (!appUuid) { + return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + } + await getApplicationInProject(appUuid, projectUuid); + + const linesRaw = Number(params.lines ?? 200); + const lines = Number.isFinite(linesRaw) ? linesRaw : 200; + const serviceRaw = params.service; + const service = typeof serviceRaw === 'string' && serviceRaw.trim() ? serviceRaw.trim() : undefined; + + const result = await getApplicationRuntimeLogs(appUuid, { lines, service }); + return NextResponse.json({ result }); +} + async function toolAppsEnvsList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; diff --git a/app/api/workspaces/[slug]/apps/[uuid]/logs/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/logs/route.ts new file mode 100644 index 0000000..158ebc6 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/logs/route.ts @@ -0,0 +1,49 @@ +/** + * GET /api/workspaces/[slug]/apps/[uuid]/logs + * + * Runtime logs for a Coolify app. Compose-aware: Coolify's REST API + * for non-compose build packs, SSH into the Coolify host for per- + * service `docker logs` on compose apps. + * + * Query params: + * lines – tail lines per container (default 200, max 5000) + * service – limit to one compose service (optional) + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { getApplicationInProject, TenantError } from '@/lib/coolify'; +import { getApplicationRuntimeLogs } from '@/lib/coolify-logs'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string; uuid: string }> } +) { + const { slug, uuid } = await params; + const principal = await requireWorkspacePrincipal(request, { targetSlug: slug }); + if (principal instanceof NextResponse) return principal; + + const ws = principal.workspace; + if (!ws.coolify_project_uuid) { + return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 }); + } + + const url = new URL(request.url); + const linesRaw = Number(url.searchParams.get('lines') ?? '200'); + const lines = Number.isFinite(linesRaw) ? linesRaw : 200; + const service = url.searchParams.get('service') ?? undefined; + + try { + await getApplicationInProject(uuid, ws.coolify_project_uuid); + const result = await getApplicationRuntimeLogs(uuid, { lines, service }); + return NextResponse.json(result); + } catch (err) { + if (err instanceof TenantError) { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + return NextResponse.json( + { error: 'Failed to fetch runtime logs', details: err instanceof Error ? err.message : String(err) }, + { status: 502 } + ); + } +} diff --git a/lib/coolify-logs.ts b/lib/coolify-logs.ts new file mode 100644 index 0000000..b64ed98 --- /dev/null +++ b/lib/coolify-logs.ts @@ -0,0 +1,211 @@ +/** + * Unified runtime-logs fetcher for Coolify apps. + * + * Coolify's `/applications/{uuid}/logs` REST endpoint works well for + * single-container build packs (dockerfile, nixpacks, static) but + * returns empty for `dockercompose` — it has no way to pick which of + * N services to tail. + * + * For compose apps we SSH into the Coolify host and call `docker logs` + * directly against each compose-managed container (Coolify names them + * `{service}-{appUuid}-{shortHash}`). The SSH user is dedicated to this + * purpose — a member of the `docker` group, no sudo, single ed25519 + * key. + * + * The agent-facing shape is always `{ services: {...}, source, warnings }` + * regardless of which path was taken. That way the MCP tool response is + * stable whether Coolify's API grows per-service support later or not. + */ + +import { getApplication, getApplicationRuntimeLogsFromApi, type CoolifyApplication } from '@/lib/coolify'; +import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh'; + +export type LogsSource = 'coolify_api' | 'ssh_docker' | 'empty'; + +export interface ApplicationRuntimeLogs { + uuid: string; + buildPack: string; + source: LogsSource; + /** + * Per-service logs. For non-compose apps there's a single entry keyed + * by the application name (or "app"). For compose apps, one entry per + * service (`server`, `db`, `redis`, etc.). + */ + services: Record; + warnings: string[]; + truncated: boolean; +} + +export interface ServiceLogs { + container: string | null; + lines: number; + bytes: number; + logs: string; + /** Raw `docker ps` status string when we fetched over SSH. */ + status?: string; +} + +const DEFAULT_LINES = 200; +const MAX_LINES = 5000; + +export interface GetLogsOpts { + /** Limit to a specific compose service (ignored for non-compose apps). */ + service?: string; + /** Number of tail lines per container. Clamped to [1, 5000]. */ + lines?: number; +} + +/** + * Shell-escape a single token for bash. We never pass user input through + * here — values are derived from Coolify state (uuids, service names) or + * our own integer line counts — but defense-in-depth is cheap. + */ +function sq(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +export async function getApplicationRuntimeLogs( + uuid: string, + opts: GetLogsOpts = {}, +): Promise { + const lines = Math.max(1, Math.min(opts.lines ?? DEFAULT_LINES, MAX_LINES)); + const app = await getApplication(uuid); + const buildPack = (app.build_pack ?? 'unknown') as string; + const warnings: string[] = []; + + // ── Compose path (needs SSH) ───────────────────────────────────── + if (buildPack === 'dockercompose') { + if (!isCoolifySshConfigured()) { + warnings.push( + 'COOLIFY_SSH_* not configured — cannot tail per-service logs for compose apps. ' + + 'Set COOLIFY_SSH_HOST / COOLIFY_SSH_PRIVATE_KEY_B64 / COOLIFY_SSH_USER to enable.', + ); + return { + uuid, + buildPack, + source: 'empty', + services: {}, + warnings, + truncated: false, + }; + } + return await fetchComposeLogsViaSsh(app, lines, opts.service, warnings); + } + + // ── Single-container path (Coolify API) ───────────────────────── + try { + const res = await getApplicationRuntimeLogsFromApi(uuid, lines); + const raw = (res?.logs ?? '').toString(); + const serviceName = (app.name as string) || 'app'; + return { + uuid, + buildPack, + source: raw ? 'coolify_api' : 'empty', + services: { + [serviceName]: { + container: null, + lines: raw ? raw.split('\n').length : 0, + bytes: Buffer.byteLength(raw, 'utf8'), + logs: raw, + }, + }, + warnings: raw + ? warnings + : [...warnings, 'Coolify returned empty logs. App may not be running yet, or this is a known Coolify limitation for this runtime.'], + truncated: false, + }; + } catch (err) { + warnings.push(`coolify_api error: ${err instanceof Error ? err.message : String(err)}`); + // Fall through to SSH if we have it — handy for dockerfile apps too + if (!isCoolifySshConfigured()) { + return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: false }; + } + return await fetchComposeLogsViaSsh(app, lines, opts.service, warnings); + } +} + +async function fetchComposeLogsViaSsh( + app: CoolifyApplication, + lines: number, + filterService: string | undefined, + warnings: string[], +): Promise { + const uuid = app.uuid as string; + const buildPack = (app.build_pack ?? 'dockercompose') as string; + + // 1) Enumerate containers that belong to this app. Coolify's naming + // convention always includes the app uuid in the container name + // (either as suffix for compose or as the name itself for single- + // container apps). + const ps = await runOnCoolifyHost( + `docker ps -a --filter ${sq('name=' + uuid)} --format '{{.Names}}\t{{.Status}}'`, + { timeoutMs: 8_000 }, + ); + if (ps.code !== 0) { + warnings.push(`docker ps exited ${ps.code}: ${ps.stderr.trim()}`); + return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: ps.truncated }; + } + + const containers = ps.stdout + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .map(l => { + const [name, ...rest] = l.split('\t'); + return { name, status: rest.join('\t') }; + }); + + if (containers.length === 0) { + warnings.push('No containers found on Coolify host for this app uuid. Deployment may not have started yet.'); + return { uuid, buildPack, source: 'ssh_docker', services: {}, warnings, truncated: false }; + } + + // 2) Derive a service name from each container. + // + // Coolify names compose containers like `{service}-{appUuid}-{short}` + // For single-container apps it's just `{appSlug}-{appUuid}` or similar. + // We strip the uuid to recover the service name. + const services: Record = {}; + let anyTruncated = false; + + const targets = containers.filter(c => { + if (!filterService) return true; + return c.name.startsWith(filterService + '-') || c.name === filterService; + }); + if (filterService && targets.length === 0) { + warnings.push(`No container matched service=${filterService}. Available: ${containers.map(c => c.name).join(', ')}`); + } + + for (const c of targets) { + // service name = name with -- suffix stripped + let service = c.name; + const idx = c.name.indexOf(`-${uuid}`); + if (idx > 0) service = c.name.slice(0, idx); + // Guard against empty or duplicate service keys + if (!service) service = c.name; + if (services[service]) service = c.name; + + const logsRes = await runOnCoolifyHost( + `docker logs --tail ${Math.floor(lines)} --timestamps ${sq(c.name)} 2>&1`, + { timeoutMs: 10_000, maxBytes: 1_000_000 }, + ); + if (logsRes.truncated) anyTruncated = true; + const text = logsRes.stdout; // stderr is merged via `2>&1` + services[service] = { + container: c.name, + lines: text ? text.split('\n').length : 0, + bytes: Buffer.byteLength(text, 'utf8'), + logs: text, + status: c.status, + }; + } + + return { + uuid, + buildPack, + source: 'ssh_docker', + services, + warnings, + truncated: anyTruncated, + }; +} diff --git a/lib/coolify-ssh.ts b/lib/coolify-ssh.ts new file mode 100644 index 0000000..a2d9371 --- /dev/null +++ b/lib/coolify-ssh.ts @@ -0,0 +1,133 @@ +/** + * One-shot SSH-exec helper into the Coolify host. + * + * Used to pull docker runtime data that the Coolify REST API doesn't + * surface cleanly (per-service logs for compose apps, container state, + * etc.). Strictly command-in / string-out — we never hold a persistent + * channel and we never expose an interactive shell to callers. + * + * Required env (injected into vibn-frontend at deploy time): + * COOLIFY_SSH_HOST e.g. 34.19.250.135 or coolify.vibnai.com + * COOLIFY_SSH_PORT default 22 + * COOLIFY_SSH_USER default "vibn-logs" + * COOLIFY_SSH_PRIVATE_KEY_B64 base64-encoded PEM private key + * + * The authorised key lives in ~vibn-logs/.ssh/authorized_keys on the + * Coolify host. That user must be a member of the `docker` group so + * `docker logs`/`docker ps` work without sudo; it should NOT have + * sudo rights. + */ + +import { Client, type ConnectConfig } from 'ssh2'; + +const DEFAULT_EXEC_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB – more than enough for `docker logs --tail 2000` + +export interface CoolifySshResult { + code: number | null; + stdout: string; + stderr: string; + truncated: boolean; +} + +export interface CoolifySshOptions { + /** Millis to wait for the command to finish before killing the channel. */ + timeoutMs?: number; + /** Cap combined stdout+stderr. Extra bytes are dropped and `truncated=true`. */ + maxBytes?: number; +} + +function loadConnectConfig(): ConnectConfig { + const host = process.env.COOLIFY_SSH_HOST; + const keyB64 = process.env.COOLIFY_SSH_PRIVATE_KEY_B64; + if (!host) throw new Error('COOLIFY_SSH_HOST is not set'); + if (!keyB64) throw new Error('COOLIFY_SSH_PRIVATE_KEY_B64 is not set'); + const privateKey = Buffer.from(keyB64, 'base64').toString('utf8'); + const port = Number(process.env.COOLIFY_SSH_PORT ?? 22); + const username = process.env.COOLIFY_SSH_USER ?? 'vibn-logs'; + return { + host, + port, + username, + privateKey, + readyTimeout: 8_000, + // We accept the host key on first connect; we're always connecting + // to infra we own, but a future hardening pass should pin the host + // key fingerprint via `hostVerifier`. + }; +} + +/** + * Run a single command on the Coolify host and collect its output. + * + * The helper enforces: + * - a wall-clock timeout (default 10s) + * - a combined-output byte cap (default 1 MB) + * - always-close semantics (connection is destroyed on return / throw) + */ +export function runOnCoolifyHost( + command: string, + opts: CoolifySshOptions = {}, +): Promise { + const cfg = loadConnectConfig(); + const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS; + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; + + return new Promise((resolve, reject) => { + const conn = new Client(); + let settled = false; + let stdout = ''; + let stderr = ''; + let truncated = false; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { conn.end(); } catch { /* noop */ } + fn(); + }; + const timer = setTimeout(() => { + settle(() => reject(new Error(`[coolify-ssh] timeout after ${timeoutMs}ms running: ${command}`))); + }, timeoutMs); + + conn + .on('ready', () => { + conn.exec(command, (err, stream) => { + if (err) return settle(() => reject(err)); + stream + .on('close', (code: number | null) => { + settle(() => resolve({ code, stdout, stderr, truncated })); + }) + .on('data', (chunk: Buffer) => { + if (stdout.length + stderr.length + chunk.length > maxBytes) { + truncated = true; + const room = Math.max(0, maxBytes - stdout.length - stderr.length); + stdout += chunk.slice(0, room).toString('utf8'); + } else { + stdout += chunk.toString('utf8'); + } + }) + .stderr.on('data', (chunk: Buffer) => { + if (stdout.length + stderr.length + chunk.length > maxBytes) { + truncated = true; + const room = Math.max(0, maxBytes - stdout.length - stderr.length); + stderr += chunk.slice(0, room).toString('utf8'); + } else { + stderr += chunk.toString('utf8'); + } + }); + }); + }) + .on('error', (err) => settle(() => reject(err))) + .connect(cfg); + }); +} + +/** + * True if the env is configured to talk to the Coolify host. + * Callers use this to gracefully degrade when SSH isn't available + * (e.g. local dev without the key pair mounted). + */ +export function isCoolifySshConfigured(): boolean { + return !!(process.env.COOLIFY_SSH_HOST && process.env.COOLIFY_SSH_PRIVATE_KEY_B64); +} diff --git a/lib/coolify.ts b/lib/coolify.ts index 2f2aeab..7cb0c58 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -59,6 +59,7 @@ export interface CoolifyApplication { environment_id?: number; environment_name?: string; environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } }; + build_pack?: string; } /** @@ -548,6 +549,19 @@ export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs: return coolifyFetch(`/deployments/${deploymentUuid}/logs`); } +/** + * Coolify's "runtime logs" endpoint. Returns `{ logs: "…" }` for simple + * Dockerfile/nixpacks apps; returns an empty string for `dockercompose` + * apps (Coolify v4 doesn't know which of the compose services to tail). + * Use coolify-logs.getApplicationRuntimeLogs for the compose-aware path. + */ +export async function getApplicationRuntimeLogsFromApi( + uuid: string, + lines = 200, +): Promise<{ logs: string }> { + return coolifyFetch(`/applications/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`); +} + export async function listApplicationDeployments(uuid: string): Promise { // Coolify v4 nests this under /deployments/applications/{uuid} // and returns { count, deployments }. Normalize to a flat array. diff --git a/package-lock.json b/package-lock.json index aaf6297..501dfc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "react-dom": "^19.2.4", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0", "tsx": "^4.20.6", "uuid": "^13.0.0", @@ -56,6 +57,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ssh2": "^1.15.5", "eslint": "^9", "eslint-config-next": "16.0.1", "firebase-admin": "^13.6.0", @@ -5586,6 +5588,33 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -6498,6 +6527,15 @@ "node": ">=8" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assistant-cloud": { "version": "0.1.20", "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.20.tgz", @@ -6654,6 +6692,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -6751,6 +6798,15 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -7065,6 +7121,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12287,6 +12357,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -14326,6 +14403,23 @@ "node": ">= 10.x" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -14936,6 +15030,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index b9ad88a..198809a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "react-dom": "^19.2.4", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0", "tsx": "^4.20.6", "uuid": "^13.0.0", @@ -72,6 +73,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ssh2": "^1.15.5", "eslint": "^9", "eslint-config-next": "16.0.1", "firebase-admin": "^13.6.0",