import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { commandOk, runCommand, tailFile } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { startJob } from "./jobs"; export interface ComposeRuntimeEnv { envFile: string; logDir: string; logDay: string; logPrefix: string; } export interface ContainerStatus { id: string; name: string; image: string; status: string; ports: string; } const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "project-manager", "baidu-netdisk", "oa-event-flow"] as const; export type RebuildableService = typeof rebuildableServices[number]; export function isRebuildableService(value: string | undefined): value is RebuildableService { return rebuildableServices.some((service) => service === value); } export function resolveComposeCommand(config: UniDeskConfig, envFile: string): string[] { const composeFile = rootPath(config.docker.composeFile); if (commandOk(["docker", "compose", "version"], repoRoot)) { return ["docker", "compose", "--env-file", envFile, "-f", composeFile, "-p", config.docker.projectName]; } if (commandOk(["docker-compose", "--version"], repoRoot)) { return ["docker-compose", "--env-file", envFile, "-f", composeFile, "-p", config.docker.projectName]; } throw new Error("Neither docker compose plugin nor docker-compose binary is available"); } function pad(value: number): string { return String(value).padStart(2, "0"); } function localDateParts(date: Date): { day: string; stamp: string } { const day = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`; const stamp = `${day}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; return { day, stamp }; } function envValue(value: string): string { if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; return JSON.stringify(value); } export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): ComposeRuntimeEnv { const stateDir = rootPath(config.paths.stateDir); mkdirSync(stateDir, { recursive: true }); const envFile = join(stateDir, "docker-compose.env"); const previousRaw = existsSync(envFile) ? readFileSync(envFile, "utf8") : ""; const previousValue = (key: string): string => previousRaw.match(new RegExp(`^${key}=(.*)$`, "m"))?.[1]?.replace(/^"|"$/g, "") ?? ""; const runtimeSecret = (key: string): string => process.env[key] ?? previousValue(key); const runtimeSecretWithDefault = (key: string, defaultValue: string, legacyDefault = ""): string => { if (process.env[key] !== undefined) return process.env[key] || defaultValue; const previous = previousValue(key); if (previous.length > 0 && previous !== legacyDefault) return previous; return defaultValue; }; let logRoot: string; let logDay: string; let logPrefix: string; if (!freshLogPrefix && previousRaw.length > 0) { const previousLogDir = previousValue("UNIDESK_LOG_DIR"); const previousLogRoot = previousLogDir && /^\d{8}$/u.test(basename(previousLogDir)) ? dirname(previousLogDir) : previousLogDir; logRoot = previousLogRoot || rootPath(config.paths.logsDir); logPrefix = previousValue("UNIDESK_LOG_PREFIX") || localDateParts(new Date()).stamp; logDay = previousValue("UNIDESK_LOG_DAY") || logPrefix.match(/^\d{8}/u)?.[0] || localDateParts(new Date()).day; } else { const parts = localDateParts(new Date()); logRoot = resolve(rootPath(config.paths.logsDir)); logDay = parts.day; logPrefix = parts.stamp; } logRoot = resolve(logRoot); mkdirSync(join(logRoot, logDay), { recursive: true }); chmodSync(logRoot, 0o777); chmodSync(join(logRoot, logDay), 0o777); const labels = JSON.stringify(config.providerGateway.labels); const microservices = JSON.stringify(config.microservices); const restrictedHostBind = config.network.restrictedHostAccess?.bindHost || "127.0.0.1"; const lines = { UNIDESK_PUBLIC_HOST: config.network.publicHost, UNIDESK_CORE_PORT: String(config.network.core.port), UNIDESK_FRONTEND_PORT: String(config.network.frontend.port), UNIDESK_DATABASE_PORT: String(config.network.database.port), UNIDESK_DATABASE_BIND_HOST: runtimeSecretWithDefault("UNIDESK_DATABASE_BIND_HOST", restrictedHostBind, "127.0.0.1"), UNIDESK_PROVIDER_INGRESS_PORT: String(config.network.providerIngress.port), UNIDESK_DATABASE_USER: config.database.user, UNIDESK_DATABASE_PASSWORD: config.database.password, UNIDESK_DATABASE_NAME: config.database.name, UNIDESK_DATABASE_VOLUME: config.database.volume, UNIDESK_DATABASE_VOLUME_SIZE: config.database.volumeSize, UNIDESK_PROVIDER_TOKEN: config.providerGateway.token, UNIDESK_PROVIDER_ID: config.providerGateway.id, UNIDESK_PROVIDER_NAME: config.providerGateway.name, UNIDESK_PROVIDER_LABELS_JSON: labels, UNIDESK_MICROSERVICES_JSON: microservices, UNIDESK_AUTH_USERNAME: config.auth.username, UNIDESK_AUTH_PASSWORD: config.auth.password, UNIDESK_SESSION_SECRET: config.auth.sessionSecret, UNIDESK_SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds), UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs), UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000", UNIDESK_TASK_PENDING_TIMEOUT_MS: "600000", UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs), UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs), UNIDESK_MONITOR_DISK_PATH: config.providerGateway.metrics.diskPath, UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT: config.providerGateway.upgrade.hostProjectRoot, UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH: config.providerGateway.upgrade.workspacePath, UNIDESK_PROVIDER_UPGRADE_COMPOSE_FILE: config.providerGateway.upgrade.composeFile, UNIDESK_PROVIDER_UPGRADE_ENV_FILE: config.providerGateway.upgrade.composeEnvFile, UNIDESK_PROVIDER_UPGRADE_COMPOSE_PROJECT: config.providerGateway.upgrade.composeProject, UNIDESK_PROVIDER_UPGRADE_SERVICE: config.providerGateway.upgrade.service, UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE: config.providerGateway.upgrade.runnerImage, UNIDESK_LOG_DIR: logRoot, UNIDESK_LOG_DAY: logDay, UNIDESK_LOG_PREFIX: logPrefix, UNIDESK_LOG_RETENTION_BYTES: runtimeSecret("UNIDESK_LOG_RETENTION_BYTES") || "1GiB", UNIDESK_HOST_ROOT_SSH_DIR: process.env.UNIDESK_HOST_ROOT_SSH_DIR || "/root/.ssh", UNIDESK_HOST_SSH_KEY_DIR: config.sshForwarding.keyDir, UNIDESK_HOST_SSH_HOST: config.sshForwarding.host, UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port), UNIDESK_HOST_SSH_USER: config.sshForwarding.user, UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED") || "true", UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy", UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE") || "private", UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID") || "645275593", UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID"), UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES") || "10", UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS") || "30000", UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS") || "15000", UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS") || "3", UNIDESK_CODE_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_API_KEY") || runtimeSecret("MINIMAX_API_KEY"), UNIDESK_CODE_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_MODEL") || runtimeSecret("MINIMAX_MODEL") || "MiniMax-M2.7", UNIDESK_CODE_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_API_BASE") || runtimeSecret("MINIMAX_API_BASE") || "https://api.minimaxi.com/v1", UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecretWithDefault("UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS", "90000", "60000"), UNIDESK_CODE_QUEUE_REMOTE_WORKDIR: runtimeSecret("UNIDESK_CODE_QUEUE_REMOTE_WORKDIR") || "/home/ubuntu", UNIDESK_CODE_QUEUE_EXECUTION_PROVIDER_IDS: runtimeSecret("UNIDESK_CODE_QUEUE_EXECUTION_PROVIDER_IDS") || "D601", UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID") || "D601", UNIDESK_CODE_QUEUE_DEV_CONTAINER_IMAGE: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_IMAGE"), UNIDESK_CODE_QUEUE_DEV_CONTAINER_WORKDIR: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_WORKDIR") || "/home/ubuntu", UNIDESK_OA_EVENT_FLOW_BASE_URL: runtimeSecret("UNIDESK_OA_EVENT_FLOW_BASE_URL") || "http://oa-event-flow:4255", UNIDESK_OA_EVENT_FLOW_PORT: runtimeSecret("UNIDESK_OA_EVENT_FLOW_PORT") || "4255", UNIDESK_OA_EVENT_FLOW_BIND_HOST: runtimeSecretWithDefault("UNIDESK_OA_EVENT_FLOW_BIND_HOST", restrictedHostBind, "127.0.0.1"), UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED") || "true", UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy", UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE") || "private", UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID") || "645275593", UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID"), UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS") || "12000", UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS") || "15000", UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS") || "3", UNIDESK_BAIDU_NETDISK_CLIENT_ID: runtimeSecret("UNIDESK_BAIDU_NETDISK_CLIENT_ID"), UNIDESK_BAIDU_NETDISK_CLIENT_SECRET: runtimeSecret("UNIDESK_BAIDU_NETDISK_CLIENT_SECRET"), UNIDESK_BAIDU_NETDISK_TOKEN_KEY: runtimeSecret("UNIDESK_BAIDU_NETDISK_TOKEN_KEY"), UNIDESK_BAIDU_NETDISK_APP_ROOT: runtimeSecret("UNIDESK_BAIDU_NETDISK_APP_ROOT") || "/apps/UniDeskBaiduNetdisk", OPENAI_API_KEY: runtimeSecret("OPENAI_API_KEY"), CRS_OAI_KEY: runtimeSecret("CRS_OAI_KEY"), }; writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8"); return { envFile, logDir: logRoot, logDay, logPrefix }; } export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType } { const runtimeEnv = writeComposeEnv(config, false); const compose = resolveComposeCommand(config, runtimeEnv.envFile); const command = [...compose, "config", "-q"]; return { runtimeEnv, command, result: runCommand(command, repoRoot) }; } export function startStack(config: UniDeskConfig): unknown { const runtimeEnv = writeComposeEnv(config, true); const compose = resolveComposeCommand(config, runtimeEnv.envFile); const containers = dockerContainers(config); const occupiedPorts = fixedPorts(config).filter((item) => item.listening); if (occupiedPorts.length > 0 && containers.length === 0) { throw new Error(`Fixed UniDesk port is occupied before start: ${occupiedPorts.map((p) => `${p.name}:${p.port}`).join(", ")}`); } const upCommand = [...compose, "up", "-d", "--build", "--remove-orphans"]; const script = [ "set -euo pipefail", restrictedHostAccessScript(config), shellJoin(upCommand), ].filter((line) => line.length > 0).join("\n"); const command = ["bash", "-lc", composeLockedScript(script)]; const job = startJob("server_start", command, "Build and start UniDesk services without tearing down running queue containers"); return { job, runtimeEnv, command, ports: fixedPorts(config) }; } export function stopStack(config: UniDeskConfig): unknown { const runtimeEnv = writeComposeEnv(config, false); const compose = resolveComposeCommand(config, runtimeEnv.envFile); const downCommand = [...compose, "down", "--remove-orphans"]; const command = ["bash", "-lc", composeLockedScript(`set -euo pipefail; ${shellJoin(downCommand)}`)]; const job = startJob("server_stop", command, "Stop all UniDesk Docker services managed by the fixed compose project"); return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) }; } export function rebuildService(config: UniDeskConfig, service: RebuildableService): unknown { const runtimeEnv = writeComposeEnv(config, false); const compose = resolveComposeCommand(config, runtimeEnv.envFile); const buildCommand = [...compose, "build", service]; const listServiceContainersCommand = [ "docker", "ps", "-q", "--filter", `label=com.docker.compose.project=${config.docker.projectName}`, "--filter", `label=com.docker.compose.service=${service}`, "--filter", "label=com.docker.compose.oneoff=False", ]; const upCommand = [...compose, "up", "-d", "--no-deps", "--force-recreate", service]; const restoreCommand = [...compose, "up", "-d", "--no-deps", service]; const listAllServiceContainersCommand = [...listServiceContainersCommand]; listAllServiceContainersCommand[2] = "-a"; const lockPath = composeLockPath(); const watchdogLog = rootPath(".state", "jobs", "compose-rebuild-watchdog.log"); const watchdogInnerScript = [ "set -euo pipefail", "sleep 20", `cid=$(${shellJoin(listServiceContainersCommand)} || true)`, `if [ -z "$cid" ]; then echo "$(date -Is) compose_rebuild_watchdog_restore service=${service}" >> ${shellQuote(watchdogLog)}; ${shellJoin(restoreCommand)} >> ${shellQuote(watchdogLog)} 2>&1 || true; fi`, ].join("\n"); const watchdogScript = `set -euo pipefail; ${shellJoin(["flock", "-w", "300", lockPath, "bash", "-lc", watchdogInnerScript])} || true`; const validateScript = [ "ready=0", "for attempt in $(seq 1 60); do", `cid=$(${shellJoin(listServiceContainersCommand)} || true)`, "if [ -n \"$cid\" ]; then", "health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $cid 2>/dev/null | head -1 || true)", `echo "service_container_probe service=${service} attempt=$attempt cid=$cid health=$health"`, "if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi", "else", `echo "service_container_probe service=${service} attempt=$attempt cid=missing"`, "fi", "sleep 1", "done", "if [ \"$ready\" != \"1\" ]; then", `echo "service_container_not_ready service=${service}" >&2`, `${shellJoin(listAllServiceContainersCommand)} --format '{{.ID}} {{.Names}} {{.Status}}' >&2 || true`, "exit 1", "fi", ].join("\n"); const script = [ "set -euo pipefail", `echo ${shellJoin(["rebuild_service", service, "build_first_then_force_recreate_with_validation"])}`, restrictedHostAccessScript(config), shellJoin(buildCommand), `nohup bash -lc ${shellQuote(watchdogScript)} >/dev/null 2>&1 &`, shellJoin(upCommand), validateScript, ].join("\n"); const command = ["bash", "-lc", composeLockedScript(script)]; const job = startJob("server_rebuild", command, `Rebuild and validate UniDesk ${service} with serialized Docker Compose mutation`); return { job, runtimeEnv, service, command, strategy: { buildBeforeReplace: true, replaceScope: { projectLabel: config.docker.projectName, serviceLabel: service, }, noDeps: true, forceRecreate: true, composeMutationLock: rootPath(".state", "locks", "server-compose.lock"), jobRunner: "local", postUpValidation: true, namedVolumesPreserved: true, }, }; } function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> { return [ { name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) }, { name: "provider-ingress", port: config.network.providerIngress.port, listening: isPortListening(config.network.providerIngress.port) }, ]; } function restrictedHostAccessScript(config: UniDeskConfig): string { const access = config.network.restrictedHostAccess; if (access === undefined || access.bindHost === "127.0.0.1") return ""; const ports = [ { name: "database", containerPort: config.network.database.containerPort }, { name: "oa-event-flow", containerPort: 4255 }, ]; return [ "iptables -N DOCKER-USER 2>/dev/null || true", ...ports.flatMap((port) => [ ...access.allowedSourceCidrs.map((source) => [ `iptables -C DOCKER-USER -s ${shellQuote(source)} -p tcp --dport ${port.containerPort} -j ACCEPT 2>/dev/null`, ` || iptables -I DOCKER-USER 1 -s ${shellQuote(source)} -p tcp --dport ${port.containerPort} -j ACCEPT`, ].join(" \\\n")), [ `iptables -C DOCKER-USER -p tcp --dport ${port.containerPort} -j DROP 2>/dev/null \\`, " || {", " return_line=$(iptables -L DOCKER-USER --line-numbers | awk '$2==\"RETURN\" {print $1; exit}')", " if [ -n \"$return_line\" ]; then", ` iptables -I DOCKER-USER "$return_line" -p tcp --dport ${port.containerPort} -j DROP`, " else", ` iptables -A DOCKER-USER -p tcp --dport ${port.containerPort} -j DROP`, " fi", " }", ].join("\n"), `echo ${shellJoin(["restricted_host_access", port.name, String(port.containerPort), access.allowedSourceCidrs.join(",")])}`, ]), ].join("\n"); } function shellJoin(args: string[]): string { return args.map(shellQuote).join(" "); } function shellQuote(arg: string): string { return `'${arg.replace(/'/g, `'\\''`)}'`; } function composeLockedScript(innerScript: string): string { const lockPath = composeLockPath(); return [ "set -euo pipefail", `mkdir -p ${shellQuote(rootPath(".state", "locks"))}`, `echo ${shellJoin(["compose_lock_wait", lockPath])}`, shellJoin(["flock", lockPath, "bash", "-lc", innerScript]), ].join("; "); } function composeLockPath(): string { const lockDir = rootPath(".state", "locks"); mkdirSync(lockDir, { recursive: true }); return join(lockDir, "server-compose.lock"); } function isPortListening(port: number): boolean { const result = runCommand(["ss", "-ltn"], repoRoot); if (result.exitCode !== 0) return false; return result.stdout.split("\n").some((line) => line.includes(`:${port} `) || line.includes(`:${port}\t`)); } export function dockerContainers(config: UniDeskConfig): ContainerStatus[] { const result = runCommand([ "docker", "ps", "-a", "--filter", `label=com.docker.compose.project=${config.docker.projectName}`, "--filter", "label=com.docker.compose.oneoff=False", "--format", "{{json .}}", ], repoRoot); if (result.exitCode !== 0 || result.stdout.trim().length === 0) return []; return result.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line) as Record) .map((row) => ({ id: row.ID ?? "", name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "", ports: row.Ports ?? "" })); } async function probe(url: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2500); try { const res = await fetch(url, { signal: controller.signal }); const body = await res.text(); return { ok: res.ok, status: res.status, body: body.slice(0, 1200) }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { clearTimeout(timer); } } function dockerExecJson(container: string, code: string): unknown { const result = runCommand(["docker", "exec", container, "bun", "-e", code], repoRoot); if (result.exitCode !== 0) { return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; } try { return JSON.parse(result.stdout.trim()) as unknown; } catch { return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; } } function dockerExec(config: UniDeskConfig, container: string, command: string[]): unknown { const result = runCommand(["docker", "exec", container, ...command], repoRoot); return { ok: result.exitCode === 0, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) }; } export async function stackStatus(config: UniDeskConfig): Promise { const runtimeEnv = writeComposeEnv(config, false); const runtimeRaw = existsSync(runtimeEnv.envFile) ? readFileSync(runtimeEnv.envFile, "utf8") : ""; const runtimeValue = (key: string): string => runtimeRaw.match(new RegExp(`^${key}=(.*)$`, "m"))?.[1]?.replace(/^"|"$/g, "") ?? ""; const databaseBindHost = runtimeValue("UNIDESK_DATABASE_BIND_HOST") || "127.0.0.1"; const oaEventFlowBindHost = runtimeValue("UNIDESK_OA_EVENT_FLOW_BIND_HOST") || "127.0.0.1"; const oaEventFlowPort = Number(runtimeValue("UNIDESK_OA_EVENT_FLOW_PORT") || "4255"); const coreHealth = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/health').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})"); const overview = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/api/overview').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})"); return { runtimeEnv, publicPorts: fixedPorts(config), blockedPublicPorts: [ { name: "backend-core-rest", port: config.network.core.port, listening: isPortListening(config.network.core.port), expected: "not-listening" }, ], restrictedHostPorts: [ { name: "database", bindHost: databaseBindHost, hostPort: config.network.database.port, containerPort: config.network.database.containerPort, listening: isPortListening(config.network.database.port), expected: databaseBindHost === "127.0.0.1" ? "local-only" : "restricted-to-code-queue-provider", }, { name: "oa-event-flow", bindHost: oaEventFlowBindHost, hostPort: oaEventFlowPort, containerPort: 4255, listening: isPortListening(oaEventFlowPort), expected: oaEventFlowBindHost === "127.0.0.1" ? "local-only" : "restricted-to-code-queue-provider", }, ], internalPorts: [ { name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null }, { name: "database", containerPort: config.network.database.containerPort, hostPort: null }, { name: "project-manager", containerPort: 4233, hostPort: null }, { name: "baidu-netdisk", containerPort: 4244, hostPort: null }, { name: "oa-event-flow", containerPort: 4255, hostPort: null }, ], containers: dockerContainers(config), health: { core: coreHealth, frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`), providerIngress: await probe(`http://127.0.0.1:${config.network.providerIngress.port}/health`), database: dockerExec(config, "unidesk-database", ["pg_isready", "-U", config.database.user, "-d", config.database.name]), overview, }, urls: { frontend: `http://${config.network.publicHost}:${config.network.frontend.port}`, providerIngress: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`, internalCore: `http://backend-core:${config.network.core.containerPort}`, internalDatabase: `postgres://${config.database.user}:***@database:${config.network.database.containerPort}/${config.database.name}`, }, }; } function listLogFiles(root: string): string[] { if (!existsSync(root)) return []; const entries = readdirSync(root, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const path = join(root, entry.name); if (entry.isDirectory()) files.push(...listLogFiles(path)); if (entry.isFile()) files.push(path); } return files.sort(); } export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown { const logRoot = rootPath(config.paths.logsDir); const runtimeEnv = writeComposeEnv(config, false); const allFiles = listLogFiles(logRoot); const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix)); const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12); const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) })); const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "project-manager-backend", "baidu-netdisk-backend", "oa-event-flow-backend"]; const docker = containerNames.map((name) => { const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot); return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) }; }); return { logRoot, runtimeEnv, files, docker }; }