Update package.json and config.ts for new simulation feature. Added a build command for mod synchronization and included a new configuration for the mod repository URL.
This commit is contained in:
parent
7c649b76c4
commit
099ba63e33
|
|
@ -10,7 +10,8 @@
|
|||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build": "yarn build:renderer && yarn build:main",
|
||||
"pack": "yarn build && electron-builder --dir",
|
||||
"dist": "yarn build && electron-builder"
|
||||
"dist": "yarn build && electron-builder",
|
||||
"mod:sync:sim": "yarn build:main && node dist/main/modSyncSim.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "^30.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { APP_CONFIG } from "../shared/config";
|
||||
|
||||
type SyncPlan = {
|
||||
sourceRoot?: string;
|
||||
directories?: Array<{ from: string; to: string }>;
|
||||
preserve?: string[];
|
||||
processNames?: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_PROCESS_NAMES = [
|
||||
"EscapeFromTarkov.exe",
|
||||
"EscapeFromTarkov",
|
||||
"EFT.exe",
|
||||
"BattlEye",
|
||||
"BsgLauncher.exe"
|
||||
];
|
||||
|
||||
const DEFAULT_PRESERVE_PREFIXES = ["user/profiles", "user/settings", "user/launcher"];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (flag: string) => args.includes(flag);
|
||||
const getArgValue = (flag: string) => {
|
||||
const index = args.indexOf(flag);
|
||||
if (index < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return args[index + 1];
|
||||
};
|
||||
|
||||
const dryRun = hasFlag("--dry-run");
|
||||
const checkProcess = hasFlag("--check-process");
|
||||
const cleanRepo = !hasFlag("--no-clean");
|
||||
|
||||
const rootDir = path.resolve(getArgValue("--root") ?? process.cwd());
|
||||
const repoDir = path.resolve(getArgValue("--repo-dir") ?? path.join(rootDir, ".spt-mods-repo"));
|
||||
const targetDir = path.resolve(getArgValue("--target-dir") ?? path.join(rootDir, "mods"));
|
||||
const planPath = path.resolve(getArgValue("--plan") ?? path.join(rootDir, "mod-sync.plan.json"));
|
||||
const repoUrl = getArgValue("--repo-url") ?? APP_CONFIG.modRepoUrl;
|
||||
|
||||
const log = (message: string) => {
|
||||
console.info(`[mod-sync-sim] ${message}`);
|
||||
};
|
||||
|
||||
const runCommand = async (
|
||||
command: string,
|
||||
commandArgs: string[],
|
||||
options: { cwd?: string; timeoutMs?: number } = {}
|
||||
) => {
|
||||
return await new Promise<{ ok: boolean; output: string; error?: string }>((resolve) => {
|
||||
execFile(
|
||||
command,
|
||||
commandArgs,
|
||||
{ cwd: options.cwd, timeout: options.timeoutMs ?? 5 * 60 * 1000 },
|
||||
(error, stdout, stderr) => {
|
||||
const output = `${stdout ?? ""}${stderr ?? ""}`.trim();
|
||||
if (error) {
|
||||
resolve({ ok: false, output, error: error.message });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, output });
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const pathExists = async (targetPath: string) => {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readPlan = async (): Promise<SyncPlan | null> => {
|
||||
if (!(await pathExists(planPath))) {
|
||||
return null;
|
||||
}
|
||||
const raw = await fs.readFile(planPath, "utf8");
|
||||
try {
|
||||
return JSON.parse(raw) as SyncPlan;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`mod-sync.plan.json parse failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const listProcessNames = async (): Promise<string[]> => {
|
||||
if (process.platform === "win32") {
|
||||
const result = await runCommand("tasklist", ["/FO", "CSV"]);
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
return result.output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.split(",")[0]?.replace(/"/g, "").trim())
|
||||
.filter((name) => Boolean(name));
|
||||
}
|
||||
|
||||
const result = await runCommand("ps", ["-A", "-o", "comm"]);
|
||||
if (!result.ok) {
|
||||
return [];
|
||||
}
|
||||
return result.output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((name) => Boolean(name));
|
||||
};
|
||||
|
||||
const checkTarkovProcess = async (plan: SyncPlan | null) => {
|
||||
if (!checkProcess) {
|
||||
log("개발 모드(dev mode)로 프로세스 체크를 스킵합니다.");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
const processNames = (plan?.processNames ?? DEFAULT_PROCESS_NAMES).map((name) =>
|
||||
name.toLowerCase()
|
||||
);
|
||||
const running = (await listProcessNames()).map((name) => name.toLowerCase());
|
||||
const matches = processNames.filter((name) => running.includes(name));
|
||||
|
||||
if (matches.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
message: `타르코프 관련 프로세스가 실행 중입니다: ${matches.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, skipped: false };
|
||||
};
|
||||
|
||||
const ensureRepo = async () => {
|
||||
const gitDir = path.join(repoDir, ".git");
|
||||
if (await pathExists(gitDir)) {
|
||||
log("기존 모드 저장소(repo)를 사용합니다.");
|
||||
const fetchResult = await runCommand("git", ["fetch", "--tags", "--prune"], {
|
||||
cwd: repoDir
|
||||
});
|
||||
if (!fetchResult.ok) {
|
||||
throw new Error(`git fetch failed: ${fetchResult.error ?? fetchResult.output}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await fs.mkdir(repoDir, { recursive: true });
|
||||
}
|
||||
|
||||
log(`모드 저장소(repo) 클론(clone): ${repoUrl}`);
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
const cloneResult = await runCommand("git", ["clone", repoUrl, repoDir]);
|
||||
if (!cloneResult.ok) {
|
||||
throw new Error(`git clone failed: ${cloneResult.error ?? cloneResult.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRepoVersion = async () => {
|
||||
const tagResult = await runCommand("git", ["tag", "--list", "v*.*.*", "--sort=-v:refname"], {
|
||||
cwd: repoDir
|
||||
});
|
||||
if (!tagResult.ok) {
|
||||
throw new Error(`git tag list failed: ${tagResult.error ?? tagResult.output}`);
|
||||
}
|
||||
|
||||
const latestTag = tagResult.output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => Boolean(line));
|
||||
|
||||
if (!latestTag) {
|
||||
throw new Error("v*.*.* 태그(tag)를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
const commitResult = await runCommand("git", ["rev-parse", "HEAD"], { cwd: repoDir });
|
||||
if (!commitResult.ok) {
|
||||
throw new Error(`git rev-parse failed: ${commitResult.error ?? commitResult.output}`);
|
||||
}
|
||||
|
||||
return {
|
||||
tag: latestTag,
|
||||
commit: commitResult.output
|
||||
};
|
||||
};
|
||||
|
||||
const pullLfs = async () => {
|
||||
const result = await runCommand("git", ["lfs", "pull"], { cwd: repoDir });
|
||||
if (!result.ok) {
|
||||
throw new Error(`git lfs pull failed: ${result.error ?? result.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePath = (value: string) => value.split(path.sep).join("/");
|
||||
|
||||
const shouldPreserve = (relativePath: string, preservePrefixes: string[]) => {
|
||||
const normalized = normalizePath(relativePath);
|
||||
return preservePrefixes.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`));
|
||||
};
|
||||
|
||||
const copyDir = async (
|
||||
source: string,
|
||||
destination: string,
|
||||
preservePrefixes: string[],
|
||||
baseDest: string
|
||||
) => {
|
||||
const entries = await fs.readdir(source, { withFileTypes: true });
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destPath = path.join(destination, entry.name);
|
||||
const destRelative = normalizePath(path.relative(baseDest, destPath));
|
||||
|
||||
if (shouldPreserve(destRelative, preservePrefixes)) {
|
||||
log(`보존(preserve) 경로 스킵: ${destRelative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDir(sourcePath, destPath, preservePrefixes, baseDest);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
log(`심볼릭 링크(symlink) 스킵: ${destRelative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
log(`파일 복사(copy) 시뮬레이션: ${destRelative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
}
|
||||
};
|
||||
|
||||
const moveDir = async (source: string, destination: string, preservePrefixes: string[]) => {
|
||||
const destExists = await pathExists(destination);
|
||||
if (!destExists && !dryRun) {
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
}
|
||||
|
||||
await copyDir(source, destination, preservePrefixes, targetDir);
|
||||
if (!dryRun) {
|
||||
await fs.rm(source, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSourceRoot = async (plan: SyncPlan | null) => {
|
||||
const planRoot = plan?.sourceRoot ? path.join(repoDir, plan.sourceRoot) : null;
|
||||
if (planRoot && (await pathExists(planRoot))) {
|
||||
return planRoot;
|
||||
}
|
||||
|
||||
const modsRoot = path.join(repoDir, "mods");
|
||||
if (await pathExists(modsRoot)) {
|
||||
return modsRoot;
|
||||
}
|
||||
|
||||
return repoDir;
|
||||
};
|
||||
|
||||
const syncDirectories = async (plan: SyncPlan | null) => {
|
||||
const preservePrefixes = plan?.preserve ?? DEFAULT_PRESERVE_PREFIXES;
|
||||
const mapping = plan?.directories;
|
||||
|
||||
if (mapping && mapping.length > 0) {
|
||||
for (const entry of mapping) {
|
||||
const sourcePath = path.join(repoDir, entry.from);
|
||||
const destPath = path.join(targetDir, entry.to);
|
||||
if (!(await pathExists(sourcePath))) {
|
||||
log(`소스 경로 누락: ${sourcePath}`);
|
||||
continue;
|
||||
}
|
||||
log(`디렉토리 이동(move): ${entry.from} -> ${entry.to}`);
|
||||
await moveDir(sourcePath, destPath, preservePrefixes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRoot = await resolveSourceRoot(plan);
|
||||
const entries = await fs.readdir(sourceRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const sourcePath = path.join(sourceRoot, entry.name);
|
||||
const destPath = path.join(targetDir, entry.name);
|
||||
log(`디렉토리 이동(move): ${entry.name} -> ${path.relative(rootDir, destPath)}`);
|
||||
await moveDir(sourcePath, destPath, preservePrefixes);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanRepoDir = async () => {
|
||||
if (!cleanRepo) {
|
||||
return;
|
||||
}
|
||||
if (dryRun) {
|
||||
log(`저장소 삭제(clean) 시뮬레이션: ${repoDir}`);
|
||||
return;
|
||||
}
|
||||
await fs.rm(repoDir, { recursive: true, force: true });
|
||||
log("저장소 삭제(clean) 완료.");
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
log(`root=${rootDir}`);
|
||||
log(`repo=${repoDir}`);
|
||||
log(`target=${targetDir}`);
|
||||
log(`plan=${planPath}`);
|
||||
|
||||
const plan = await readPlan();
|
||||
|
||||
const processCheck = await checkTarkovProcess(plan);
|
||||
if (!processCheck.ok) {
|
||||
throw new Error(processCheck.message ?? "process check failed");
|
||||
}
|
||||
|
||||
await ensureRepo();
|
||||
const version = await getRepoVersion();
|
||||
log(`mod version(tag): ${version.tag}`);
|
||||
log(`mod version(commit): ${version.commit}`);
|
||||
|
||||
log("git lfs pull 시작...");
|
||||
await pullLfs();
|
||||
log("git lfs pull 완료.");
|
||||
|
||||
await syncDirectories(plan);
|
||||
await cleanRepoDir();
|
||||
|
||||
log("모드 동기화 시뮬레이션(simulation) 완료.");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[mod-sync-sim] 실패(error): ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -5,7 +5,8 @@ export const APP_CONFIG = {
|
|||
serverRequestTimeoutMs: 4000,
|
||||
commandTimeoutMs: 4000,
|
||||
installTimeoutMs: 10 * 60 * 1000,
|
||||
healthcheckIntervalMs: 10000
|
||||
healthcheckIntervalMs: 10000,
|
||||
modRepoUrl: "https://gitea.pandoli365.com/art/spt-mods"
|
||||
} as const;
|
||||
|
||||
export const SERVER_HEALTHCHECK_URL = new URL(
|
||||
|
|
|
|||
Loading…
Reference in New Issue