feat: initialize unidesk platform
This commit is contained in:
+133
@@ -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;
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" } }),
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user