IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

service.ts

Buka raw
import "server-only";

import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { access, stat } from "node:fs/promises";
import { z } from "zod";
import { withServerTiming } from "@/lib/server/logging/logger";
import type {
  TerminalExecuteRequest,
  TerminalExecuteResult,
  TerminalOutputLine,
  TerminalServiceErrorCode,
} from "@/lib/shared/contracts/terminal";

const COMMAND_TIMEOUT_MS = 12_000;
const MAX_OUTPUT_BYTES = 128_000;

const executeSchema = z.object({
  command: z.string().trim().min(1).max(1024),
  cwd: z.string().trim().max(1024).optional(),
  history: z.array(z.string().max(1024)).max(200).optional(),
});

const ALLOWED_COMMANDS = new Set([
  "ls",
  "cd",
  "pwd",
  "cat",
  "echo",
  "whoami",
  "hostname",
  "uname",
  "uptime",
  "df",
  "free",
  "docker",
  "ping",
  "ip",
  "date",
  "history",
  "help",
  "clear",
  "exit",
]);

type RequestContext = {
  requestId?: string;
};

type ExecutionResult = {
  stdout: string;
  stderr: string;
  exitCode: number | null;
  timedOut: boolean;
};

export class TerminalServiceError extends Error {
  readonly code: TerminalServiceErrorCode;
  readonly statusCode: number;

  constructor(
    message: string,
    options?: {
      code?: TerminalServiceErrorCode;
      statusCode?: number;
      cause?: unknown;
    },
  ) {
    super(message, {
      cause: options?.cause,
    });

    this.name = "TerminalServiceError";
    this.code = options?.code ?? "execution_failed";
    this.statusCode = options?.statusCode ?? 500;
  }
}

function tokenizeCommand(command: string) {
  const tokens = command.match(/"([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+/g);
  if (!tokens) return [];

  return tokens.map((token) => {
    if (
      (token.startsWith('"') && token.endsWith('"')) ||
      (token.startsWith("'") && token.endsWith("'"))
    ) {
      return token.slice(1, -1);
    }

    return token;
  });
}

function validateInput(input: unknown) {
  const parsed = executeSchema.safeParse(input);
  if (!parsed.success) {
    throw new TerminalServiceError("Invalid terminal execute payload", {
      code: "invalid_request",
      statusCode: 400,
      cause: parsed.error,
    });
  }

  return parsed.data satisfies TerminalExecuteRequest;
}

function toOutputLine(type: TerminalOutputLine["type"], content: string): TerminalOutputLine {
  return {
    type,
    content,
  };
}

/**
 * Matches ANSI / VT100 escape sequences that programs emit for colours,
 * cursor movement, window titles, etc.  Stripping them keeps the HTTP
 * terminal output readable as plain text.
 *
 * Covers:
 *   CSI sequences  ESC [ … final-byte   (colours, erase, cursor, …)
 *   OSC sequences  ESC ] … BEL          (window title, hyperlinks, …)
 */
const ANSI_ESCAPE_RE =
  /\u001b(?:\[\??[0-9;]*[A-Za-z]|\][^\u0007]*\u0007|[()][AB012]|[<=>])/g;

function trimOutput(content: string) {
  return content
    .replace(ANSI_ESCAPE_RE, "") // strip ANSI escape sequences
    .replace(/\r\n/g, "\n") // normalise Windows CRLF
    .replace(/\r/g, "") // strip bare CR (progress-bar overwrites)
    .trimEnd();
}

function toHomePath(rawPath: string) {
  const home = os.homedir();
  if (rawPath === "~") return home;
  if (rawPath.startsWith("~/")) return path.join(home, rawPath.slice(2));
  return rawPath;
}

async function resolveWorkingDirectory(candidate: string | undefined) {
  const fallback = os.homedir();
  if (!candidate) return fallback;

  const resolved = path.resolve(toHomePath(candidate));

  try {
    const info = await stat(resolved);
    if (!info.isDirectory()) {
      return fallback;
    }

    await access(resolved);
    return resolved;
  } catch {
    return fallback;
  }
}

async function handleCd(currentDirectory: string, target: string | undefined) {
  const nextPath = target ? path.resolve(currentDirectory, toHomePath(target)) : os.homedir();

  try {
    const info = await stat(nextPath);
    if (!info.isDirectory()) {
      return {
        cwd: currentDirectory,
        lines: [
          toOutputLine("error", `cd: ${target ?? ""}: Not a directory`),
        ],
      };
    }

    await access(nextPath);
    return {
      cwd: nextPath,
      lines: [] satisfies TerminalOutputLine[],
    };
  } catch {
    return {
      cwd: currentDirectory,
      lines: [
        toOutputLine(
          "error",
          `cd: ${target ?? ""}: No such file or directory`,
        ),
      ],
    };
  }
}

function renderHistory(history: string[] | undefined) {
  const items = history ?? [];
  if (items.length === 0) {
    return [toOutputLine("info", "No command history yet.")];
  }

  const rendered = items
    .slice(-200)
    .map((command, index) => `${String(index + 1).padStart(4, " ")}  ${command}`)
    .join("\n");

  return [toOutputLine("output", rendered)];
}

function renderHelp() {
  return [
    toOutputLine(
      "info",
      `Supported commands:
  help           Show this help message
  ls [path]      List directory contents
  cd [path]      Change directory
  pwd            Print working directory
  cat <file>     Display file contents
  echo <text>    Print text
  whoami         Print current user
  hostname       Print hostname
  uname -a       Print system information
  uptime         Show system uptime
  df -h          Show disk usage
  free -h        Show memory usage
  docker ...     Run docker commands
  ping <host>    Ping a host
  ip addr        Show network interfaces
  date           Print current date
  history        Show command history
  clear          Clear the terminal

Note: commands run in non-interactive mode with a timeout.`,
    ),
  ];
}

function executeExternalCommand(
  command: string,
  args: string[],
  cwd: string,
): Promise<ExecutionResult> {
  return new Promise<ExecutionResult>((resolve, reject) => {
    const child = spawn(command, args, {
      cwd,
      shell: false,
      env: {
        ...process.env,
        TERM: "xterm-256color",
      },
    });

    let stdout = "";
    let stderr = "";
    let timedOut = false;

    const timeout = setTimeout(() => {
      timedOut = true;
      child.kill("SIGTERM");
      setTimeout(() => child.kill("SIGKILL"), 250).unref();
    }, COMMAND_TIMEOUT_MS);

    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString();
      if (stdout.length > MAX_OUTPUT_BYTES) {
        stdout = `${stdout.slice(0, MAX_OUTPUT_BYTES)}\n\n[output truncated]`;
      }
    });

    child.stderr.on("data", (chunk) => {
      stderr += chunk.toString();
      if (stderr.length > MAX_OUTPUT_BYTES) {
        stderr = `${stderr.slice(0, MAX_OUTPUT_BYTES)}\n\n[output truncated]`;
      }
    });

    child.on("error", (error) => {
      clearTimeout(timeout);
      reject(error);
    });

    child.on("close", (exitCode) => {
      clearTimeout(timeout);
      resolve({
        stdout: trimOutput(stdout),
        stderr: trimOutput(stderr),
        exitCode,
        timedOut,
      });
    });
  });
}

export async function executeTerminalCommand(
  input: unknown,
  context?: RequestContext,
): Promise<TerminalExecuteResult> {
  const validated = validateInput(input);

  return withServerTiming(
    {
      layer: "service",
      action: "terminal.execute",
      requestId: context?.requestId,
    },
    async () => {
      const startedAt = performance.now();
      const cwd = await resolveWorkingDirectory(validated.cwd);
      const tokens = tokenizeCommand(validated.command);
      const command = tokens[0];
      const args = tokens.slice(1);

      if (!command) {
        throw new TerminalServiceError("Command cannot be empty", {
          code: "invalid_request",
          statusCode: 400,
        });
      }

      if (!ALLOWED_COMMANDS.has(command)) {
        throw new TerminalServiceError(
          `Command '${command}' is not allowed in this terminal`,
          {
            code: "forbidden_command",
            statusCode: 403,
          },
        );
      }

      if (command === "help") {
        return {
          cwd,
          lines: renderHelp(),
          exitCode: 0,
          durationMs: Number((performance.now() - startedAt).toFixed(2)),
        };
      }

      if (command === "history") {
        return {
          cwd,
          lines: renderHistory(validated.history),
          exitCode: 0,
          durationMs: Number((performance.now() - startedAt).toFixed(2)),
        };
      }

      if (command === "clear") {
        return {
          cwd,
          lines: [toOutputLine("info", "__CLEAR__")],
          exitCode: 0,
          durationMs: Number((performance.now() - startedAt).toFixed(2)),
        };
      }

      if (command === "cd") {
        const next = await handleCd(cwd, args[0]);
        return {
          cwd: next.cwd,
          lines: next.lines,
          exitCode: next.lines.length === 0 ? 0 : 1,
          durationMs: Number((performance.now() - startedAt).toFixed(2)),
        };
      }

      try {
        const result = await executeExternalCommand(command, args, cwd);
        const lines: TerminalOutputLine[] = [];

        if (result.stdout) {
          lines.push(toOutputLine("output", result.stdout));
        }

        if (result.stderr) {
          lines.push(toOutputLine("error", result.stderr));
        }

        if (result.timedOut) {
          lines.push(
            toOutputLine(
              "error",
              `Command timed out after ${COMMAND_TIMEOUT_MS}ms`,
            ),
          );
          throw new TerminalServiceError("Terminal command timed out", {
            code: "timeout",
            statusCode: 408,
          });
        }

        if (result.exitCode !== 0 && !result.stderr) {
          lines.push(
            toOutputLine(
              "error",
              `Command exited with code ${result.exitCode}`,
            ),
          );
        }

        return {
          cwd,
          lines,
          exitCode: result.exitCode,
          durationMs: Number((performance.now() - startedAt).toFixed(2)),
        };
      } catch (error) {
        if (error instanceof TerminalServiceError) {
          throw error;
        }

        if (error instanceof Error && "code" in error && error.code === "ENOENT") {
          throw new TerminalServiceError(`Command not found: ${command}`, {
            code: "command_not_found",
            statusCode: 404,
            cause: error,
          });
        }

        throw new TerminalServiceError("Failed to execute terminal command", {
          code: "execution_failed",
          statusCode: 500,
          cause: error,
        });
      }
    },
  );
}
byte
10.638
baris
428
Jenis
ts
Commit terakhir
16 Oktober 2026