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:
이정수 2026-01-30 11:08:51 +09:00
parent 7c649b76c4
commit 099ba63e33
3 changed files with 351 additions and 2 deletions

View File

@ -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",

347
src/main/modSyncSim.ts Normal file
View File

@ -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;
});

View File

@ -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(