feat: initialize unidesk platform

This commit is contained in:
Codex
2026-05-04 11:09:35 +00:00
commit caa80ee5e7
56 changed files with 3273 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
import { readConfig } from "./src/config";
import { debugDispatch, debugHealth } from "./src/debug";
import { stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
import { runE2E } from "./src/e2e";
import { emitError, emitJson } from "./src/output";
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
import { runChecks } from "./src/check";
const args = process.argv.slice(2);
const commandName = args.join(" ") || "help";
function help(): unknown {
return {
entry: "bun scripts/cli.ts",
output: "json",
commands: [
{ command: "help", description: "List supported commands." },
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
{ command: "check", description: "Run config, TypeScript, file presence, and docker-compose config checks." },
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, and provider gateway." },
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
{ command: "debug health", description: "Probe core, overview, nodes, and frontend using real HTTP endpoints." },
{ command: "debug dispatch [providerId] [docker.ps|echo]", description: "Submit a real dispatch request to core for CLI debugging." },
{ command: "e2e run", description: "Run public API, database, provider, and Playwright frontend E2E checks." },
],
};
}
function numberOption(name: string, defaultValue: number): number {
const index = args.indexOf(name);
if (index === -1) return defaultValue;
const raw = args[index + 1];
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return value;
}
function latestJobId(): string {
const jobs = listJobs();
if (jobs.length === 0) throw new Error("No jobs found");
return jobs[0].id;
}
async function main(): Promise<void> {
const [top, sub, third, fourth] = args;
if (top === undefined || top === "help" || top === "--help" || top === "-h") {
emitJson(commandName, help());
return;
}
if (top === "internal" && sub === "run-job") {
if (!third) throw new Error("internal run-job requires job id");
emitJson(commandName, await runJob(third));
return;
}
const config = readConfig();
if (top === "config" && sub === "show") {
emitJson(commandName, { config });
return;
}
if (top === "check") {
const result = runChecks(config);
emitJson(commandName, result, result.ok);
if (!result.ok) process.exitCode = 1;
return;
}
if (top === "server") {
if (sub === "start") {
emitJson(commandName, startStack(config));
return;
}
if (sub === "stop") {
emitJson(commandName, stopStack(config));
return;
}
if (sub === "status") {
emitJson(commandName, await stackStatus(config));
return;
}
if (sub === "logs") {
emitJson(commandName, stackLogs(config, numberOption("--tail-bytes", 3000)));
return;
}
}
if (top === "job") {
if (sub === "list") {
emitJson(commandName, { jobs: listJobs() });
return;
}
if (sub === "status") {
const id = third === "latest" || third === undefined ? latestJobId() : third;
emitJson(commandName, { job: jobWithTail(readJob(id), numberOption("--tail-bytes", 12000)) });
return;
}
}
if (top === "debug") {
if (sub === "health") {
emitJson(commandName, await debugHealth(config));
return;
}
if (sub === "dispatch") {
const providerId = third ?? config.providerGateway.id;
const dispatchCommand = fourth === "docker.ps" || fourth === "echo" ? fourth : "docker.ps";
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand));
return;
}
}
if (top === "e2e" && sub === "run") {
const result = await runE2E(config);
const ok = (result as { ok?: unknown }).ok === true;
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
throw new Error(`Unknown command: ${commandName}`);
}
main().catch((error) => {
emitError(commandName, error);
process.exitCode = 1;
});
+59
View File
@@ -0,0 +1,59 @@
import { existsSync } from "node:fs";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { composeConfig } from "./docker";
interface CheckItem {
name: string;
ok: boolean;
detail: unknown;
}
function fileItem(path: string): CheckItem {
const absolute = rootPath(path);
return { name: `file:${path}`, ok: existsSync(absolute), detail: absolute };
}
function commandItem(name: string, command: string[]): CheckItem {
const result = runCommand(command, repoRoot);
return {
name,
ok: result.exitCode === 0,
detail: {
command,
exitCode: result.exitCode,
stdoutTail: result.stdout.slice(-4000),
stderrTail: result.stderr.slice(-4000),
},
};
}
export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckItem[] } {
const items: CheckItem[] = [
{ name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } },
fileItem("scripts/cli.ts"),
fileItem("AGENTS.md"),
fileItem("TEST.md"),
fileItem("docker-compose.yml"),
fileItem("src/components/backend-core/src/index.ts"),
fileItem("src/components/frontend/src/index.ts"),
fileItem("src/components/provider-gateway/src/index.ts"),
fileItem("scripts/src/e2e.ts"),
commandItem("bun:version", ["bun", "--version"]),
commandItem("typescript:scripts", ["bunx", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"]),
commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"]),
];
const compose = composeConfig(config);
items.push({
name: "docker-compose:config",
ok: compose.result.exitCode === 0,
detail: {
command: compose.command,
exitCode: compose.result.exitCode,
stdoutTail: compose.result.stdout.slice(-4000),
stderrTail: compose.result.stderr.slice(-4000),
runtimeEnv: compose.runtimeEnv,
},
});
return { ok: items.every((item) => item.ok), items };
}
+55
View File
@@ -0,0 +1,55 @@
import { spawn, spawnSync } from "node:child_process";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
export interface CommandResult {
command: string[];
cwd: string;
exitCode: number | null;
stdout: string;
stderr: string;
}
export function runCommand(command: string[], cwd: string): CommandResult {
const result = spawnSync(command[0], command.slice(1), {
cwd,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 8,
});
return {
command,
cwd,
exitCode: result.status,
stdout: result.stdout ?? "",
stderr: result.stderr ?? result.error?.message ?? "",
};
}
export function commandOk(command: string[], cwd: string): boolean {
return runCommand(command, cwd).exitCode === 0;
}
export async function runCommandToFiles(command: string[], cwd: string, stdoutFile: string, stderrFile: string): Promise<number | null> {
const stdout = createWriteStream(stdoutFile, { flags: "a" });
const stderr = createWriteStream(stderrFile, { flags: "a" });
stdout.write(`$ ${command.map((part) => JSON.stringify(part)).join(" ")}\n`);
const child = spawn(command[0], command.slice(1), { cwd, env: process.env });
child.stdout.pipe(stdout, { end: false });
child.stderr.pipe(stderr, { end: false });
const exitCode = await new Promise<number | null>((resolve) => {
child.on("close", (code) => resolve(code));
child.on("error", (error) => {
stderr.write(`${error.stack ?? error.message}\n`);
resolve(127);
});
});
stdout.write(`\n[exit_code=${exitCode}]\n`);
stdout.end();
stderr.end();
return exitCode;
}
export function tailFile(path: string, maxBytes = 8192): string {
if (!existsSync(path)) return "";
const content = readFileSync(path);
return content.subarray(Math.max(0, content.length - maxBytes)).toString("utf8");
}
+117
View File
@@ -0,0 +1,117 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export interface UniDeskConfig {
project: { name: string; timezone: string };
runtime: { typescript: "bun"; bunVersion: string };
network: {
host: string;
publicHost: string;
core: { port: number; containerPort: number };
frontend: { port: number; containerPort: number };
database: { port: number; containerPort: number };
};
database: { user: string; password: string; name: string; volume: string; volumeSize: string };
providerGateway: {
id: string;
name: string;
token: string;
labels: Record<string, unknown>;
heartbeatIntervalMs: number;
reconnectBaseMs: number;
reconnectMaxMs: number;
};
docker: { composeFile: string; projectName: string };
paths: { stateDir: string; logsDir: string; docsReferenceDir: string };
sshForwarding: { mode: string; keyDir: string; host: string; port: number; user: string };
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
export const repoRoot = join(moduleDir, "..", "..");
export function rootPath(...parts: string[]): string {
return join(repoRoot, ...parts);
}
function asRecord(value: unknown, name: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${name} must be an object`);
}
return value as Record<string, unknown>;
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value;
}
function numberField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path}.${key} must be a positive number`);
return value;
}
function portPair(obj: Record<string, unknown>, key: string): { port: number; containerPort: number } {
const value = asRecord(obj[key], `network.${key}`);
return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) };
}
export function readConfig(): UniDeskConfig {
const configPath = rootPath("config.json");
if (!existsSync(configPath)) throw new Error(`config.json not found at ${configPath}`);
const raw = readFileSync(configPath, "utf8");
const parsed = asRecord(JSON.parse(raw) as unknown, "config.json");
const project = asRecord(parsed.project, "project");
const runtime = asRecord(parsed.runtime, "runtime");
const network = asRecord(parsed.network, "network");
const database = asRecord(parsed.database, "database");
const providerGateway = asRecord(parsed.providerGateway, "providerGateway");
const docker = asRecord(parsed.docker, "docker");
const paths = asRecord(parsed.paths, "paths");
const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding");
const labels = asRecord(providerGateway.labels, "providerGateway.labels");
const typescript = stringField(runtime, "typescript", "runtime");
if (typescript !== "bun") throw new Error("runtime.typescript must be bun");
return {
project: { name: stringField(project, "name", "project"), timezone: stringField(project, "timezone", "project") },
runtime: { typescript, bunVersion: stringField(runtime, "bunVersion", "runtime") },
network: {
host: stringField(network, "host", "network"),
publicHost: stringField(network, "publicHost", "network"),
core: portPair(network, "core"),
frontend: portPair(network, "frontend"),
database: portPair(network, "database"),
},
database: {
user: stringField(database, "user", "database"),
password: stringField(database, "password", "database"),
name: stringField(database, "name", "database"),
volume: stringField(database, "volume", "database"),
volumeSize: stringField(database, "volumeSize", "database"),
},
providerGateway: {
id: stringField(providerGateway, "id", "providerGateway"),
name: stringField(providerGateway, "name", "providerGateway"),
token: stringField(providerGateway, "token", "providerGateway"),
labels,
heartbeatIntervalMs: numberField(providerGateway, "heartbeatIntervalMs", "providerGateway"),
reconnectBaseMs: numberField(providerGateway, "reconnectBaseMs", "providerGateway"),
reconnectMaxMs: numberField(providerGateway, "reconnectMaxMs", "providerGateway"),
},
docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") },
paths: {
stateDir: stringField(paths, "stateDir", "paths"),
logsDir: stringField(paths, "logsDir", "paths"),
docsReferenceDir: stringField(paths, "docsReferenceDir", "paths"),
},
sshForwarding: {
mode: stringField(sshForwarding, "mode", "sshForwarding"),
keyDir: stringField(sshForwarding, "keyDir", "sshForwarding"),
host: stringField(sshForwarding, "host", "sshForwarding"),
port: numberField(sshForwarding, "port", "sshForwarding"),
user: stringField(sshForwarding, "user", "sshForwarding"),
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import { type UniDeskConfig } from "./config";
async function readJson(url: string, init?: RequestInit): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
const text = await res.text();
return { ok: res.ok, status: res.status, body: text.length > 0 ? JSON.parse(text) : null };
} finally {
clearTimeout(timer);
}
}
export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
return {
core: await readJson(`http://127.0.0.1:${config.network.core.port}/health`),
overview: await readJson(`http://127.0.0.1:${config.network.core.port}/api/overview`),
nodes: await readJson(`http://127.0.0.1:${config.network.core.port}/api/nodes`),
frontend: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`),
};
}
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise<unknown> {
return readJson(`http://127.0.0.1:${config.network.core.port}/api/dispatch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ providerId, command, payload: { source: "cli-debug" } }),
});
}
+213
View File
@@ -0,0 +1,213 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { basename, 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;
logPrefix: string;
}
export interface ContainerStatus {
id: string;
name: string;
image: string;
status: string;
ports: string;
}
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");
if (!freshLogPrefix && existsSync(envFile)) {
const raw = readFileSync(envFile, "utf8");
const logDir = raw.match(/^UNIDESK_LOG_DIR=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? rootPath(config.paths.logsDir);
const logPrefix = raw.match(/^UNIDESK_LOG_PREFIX=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? localDateParts(new Date()).stamp;
return { envFile, logDir, logPrefix };
}
const parts = localDateParts(new Date());
const logDir = resolve(rootPath(config.paths.logsDir, parts.day));
mkdirSync(logDir, { recursive: true });
chmodSync(logDir, 0o777);
const labels = JSON.stringify(config.providerGateway.labels);
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_USER: config.database.user,
UNIDESK_DATABASE_PASSWORD: config.database.password,
UNIDESK_DATABASE_NAME: config.database.name,
UNIDESK_PROVIDER_TOKEN: config.providerGateway.token,
UNIDESK_PROVIDER_ID: config.providerGateway.id,
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
UNIDESK_PROVIDER_LABELS_JSON: labels,
UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs),
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
UNIDESK_LOG_DIR: logDir,
UNIDESK_LOG_PREFIX: parts.stamp,
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
};
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
return { envFile, logDir, logPrefix: parts.stamp };
}
export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType<typeof runCommand> } {
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 downCommand = [...compose, "down", "--remove-orphans"];
const upCommand = [...compose, "up", "-d", "--build"];
const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`];
const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, and provider gateway 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 command = [...compose, "down", "--remove-orphans"];
const job = startJob("server_stop", command, "Stop all UniDesk Docker services managed by the fixed compose project");
return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) };
}
function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> {
return [
{ name: "backend-core", port: config.network.core.port, listening: isPortListening(config.network.core.port) },
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },
{ name: "database", port: config.network.database.port, listening: isPortListening(config.network.database.port) },
];
}
function shellJoin(args: string[]): string {
return args.map((arg) => `'${arg.replace(/'/g, `'\\''`)}'`).join(" ");
}
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}`,
"--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<string, string>)
.map((row) => ({ id: row.ID ?? "", name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "", ports: row.Ports ?? "" }));
}
async function probe(url: string): Promise<unknown> {
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);
}
}
export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
const runtimeEnv = writeComposeEnv(config, false);
return {
runtimeEnv,
ports: fixedPorts(config),
containers: dockerContainers(config),
health: {
core: await probe(`http://127.0.0.1:${config.network.core.port}/health`),
frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`),
overview: await probe(`http://127.0.0.1:${config.network.core.port}/api/overview`),
},
urls: {
core: `http://${config.network.publicHost}:${config.network.core.port}`,
frontend: `http://${config.network.publicHost}:${config.network.frontend.port}`,
database: `postgres://${config.database.user}:***@${config.network.publicHost}:${config.network.database.port}/${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"];
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 };
}
+154
View File
@@ -0,0 +1,154 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
type CheckStatus = "passed" | "failed";
interface E2ECheck {
name: string;
status: CheckStatus;
detail: unknown;
}
interface PublicUrls {
frontendUrl: string;
coreUrl: string;
databaseHost: string;
databasePort: number;
}
function publicUrls(config: UniDeskConfig): PublicUrls {
return {
frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`,
coreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
databaseHost: config.network.publicHost,
databasePort: config.network.database.port,
};
}
async function fetchJson(url: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, { signal: controller.signal });
const text = await response.text();
return { ok: response.ok, status: response.status, body: text.length > 0 ? JSON.parse(text) : null };
} finally {
clearTimeout(timer);
}
}
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
checks.push({ name, status: passed ? "passed" : "failed", detail });
}
function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: string; stderr: string; exitCode: number | null } {
const result = runCommand([
"docker",
"exec",
"unidesk-database",
"psql",
"-U",
config.database.user,
"-d",
config.database.name,
"-v",
"ON_ERROR_STOP=1",
"-tA",
"-c",
sql,
], repoRoot);
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
}
async function apiChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const overview = await fetchJson(`${urls.coreUrl}/api/overview`);
const nodes = await fetchJson(`${urls.coreUrl}/api/nodes`);
addCheck(checks, "core:public-overview", (overview as { ok?: boolean; body?: { ok?: boolean; onlineNodeCount?: number } }).ok === true && (overview as { body?: { ok?: boolean; onlineNodeCount?: number } }).body?.ok === true && ((overview as { body?: { onlineNodeCount?: number } }).body?.onlineNodeCount ?? 0) >= 1, overview);
const nodeList = (nodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
addCheck(checks, "core:public-nodes", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), nodes);
}
function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): string {
const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
const markerSql = `
CREATE TABLE IF NOT EXISTS unidesk_e2e_markers (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO unidesk_e2e_markers (id, source) VALUES ('${markerId}', 'cli-e2e');
SELECT 'marker=' || id FROM unidesk_e2e_markers WHERE id = '${markerId}';
SELECT 'marker_count=' || count(*) FROM unidesk_e2e_markers;
SELECT 'online_main_server=' || count(*) FROM unidesk_nodes WHERE provider_id = '${config.providerGateway.id}' AND status = 'online';
`;
const marker = runPsql(config, markerSql);
addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker);
addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker);
const publicProbe = runCommand([
"docker",
"run",
"--rm",
"postgres:16-alpine",
"pg_isready",
"-h",
urls.databaseHost,
"-p",
String(urls.databasePort),
"-U",
config.database.user,
], repoRoot);
addCheck(checks, "database:public-port", publicProbe.exitCode === 0, {
exitCode: publicProbe.exitCode,
stdout: publicProbe.stdout.trim(),
stderr: publicProbe.stderr.trim(),
});
return markerId;
}
async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<{ screenshotPath: string; bodyText: string; consoleErrors: string[] }> {
const e2eDir = rootPath(".state", "e2e");
mkdirSync(e2eDir, { recursive: true });
const screenshotPath = join(e2eDir, `${new Date().toISOString().replace(/[-:.TZ]/g, "")}_frontend.png`);
const consoleErrors: string[] = [];
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 920 } });
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(error.message));
await page.goto(urls.frontendUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForFunction(() => document.querySelector("#conn-text")?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 });
await page.waitForSelector("text=Online Nodes", { timeout: 5000 });
const bodyText = await page.locator("body").innerText({ timeout: 5000 });
const nodeCount = await page.locator("#node-count").innerText({ timeout: 5000 });
const metricText = await page.locator("#metric-grid").innerText({ timeout: 5000 });
await page.screenshot({ path: screenshotPath, fullPage: true });
addCheck(checks, "frontend:public-page-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name), { nodeCount, metricText, screenshotPath });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {
await browser.close();
}
}
export async function runE2E(config: UniDeskConfig): Promise<unknown> {
const checks: E2ECheck[] = [];
const urls = publicUrls(config);
await apiChecks(config, urls, checks);
const markerId = databaseChecks(config, urls, checks);
const frontend = await frontendCheck(config, urls, checks);
const ok = checks.every((check) => check.status === "passed");
return {
ok,
urls,
markerId,
screenshotPath: frontend.screenshotPath,
checks,
};
}
+95
View File
@@ -0,0 +1,95 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommandToFiles, tailFile } from "./command";
export type JobStatus = "queued" | "running" | "succeeded" | "failed";
export interface JobRecord {
id: string;
name: string;
status: JobStatus;
command: string[];
cwd: string;
createdAt: string;
startedAt: string | null;
finishedAt: string | null;
exitCode: number | null;
stdoutFile: string;
stderrFile: string;
note: string;
}
function jobsDir(): string {
const dir = rootPath(".state", "jobs");
mkdirSync(dir, { recursive: true });
return dir;
}
function jobPath(id: string): string {
return join(jobsDir(), `${id}.json`);
}
function writeJob(job: JobRecord): void {
writeFileSync(jobPath(job.id), `${JSON.stringify(job, null, 2)}\n`, "utf8");
}
export function readJob(id: string): JobRecord {
const path = jobPath(id);
if (!existsSync(path)) throw new Error(`job not found: ${id}`);
return JSON.parse(readFileSync(path, "utf8")) as JobRecord;
}
export function listJobs(): JobRecord[] {
return readdirSync(jobsDir())
.filter((name) => name.endsWith(".json"))
.map((name) => JSON.parse(readFileSync(join(jobsDir(), name), "utf8")) as JobRecord)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function startJob(name: string, command: string[], note: string): JobRecord {
const id = `${name}_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(16).slice(2, 8)}`;
const stdoutFile = rootPath(".state", "jobs", `${id}.stdout.log`);
const stderrFile = rootPath(".state", "jobs", `${id}.stderr.log`);
const job: JobRecord = {
id,
name,
status: "queued",
command,
cwd: repoRoot,
createdAt: new Date().toISOString(),
startedAt: null,
finishedAt: null,
exitCode: null,
stdoutFile,
stderrFile,
note,
};
writeJob(job);
const child = spawn(process.execPath, [rootPath("scripts", "cli.ts"), "internal", "run-job", id], {
cwd: repoRoot,
detached: true,
stdio: "ignore",
env: process.env,
});
child.unref();
return job;
}
export async function runJob(id: string): Promise<JobRecord> {
const job = readJob(id);
job.status = "running";
job.startedAt = new Date().toISOString();
writeJob(job);
const exitCode = await runCommandToFiles(job.command, job.cwd, job.stdoutFile, job.stderrFile);
job.exitCode = exitCode;
job.status = exitCode === 0 ? "succeeded" : "failed";
job.finishedAt = new Date().toISOString();
writeJob(job);
return job;
}
export function jobWithTail(job: JobRecord, maxBytes = 12000): JobRecord & { stdoutTail: string; stderrTail: string } {
return { ...job, stdoutTail: tailFile(job.stdoutFile, maxBytes), stderrTail: tailFile(job.stderrFile, maxBytes) };
}
+31
View File
@@ -0,0 +1,31 @@
export interface JsonEnvelope<T> {
ok: boolean;
command: string;
data?: T;
error?: unknown;
}
export function emitJson<T>(command: string, data: T, ok = true): void {
const envelope: JsonEnvelope<T> = { ok, command, data };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
}
export function emitError(command: string, error: unknown): void {
const payload = error instanceof Error
? { name: error.name, message: error.message, stack: error.stack ?? null }
: { message: String(error) };
const envelope: JsonEnvelope<never> = { ok: false, command, error: payload };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
}
function safeStdoutWrite(text: string): void {
try {
process.stdout.write(text);
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE") {
process.exitCode = 0;
return;
}
throw error;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["cli.ts", "src/**/*.ts"]
}