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:main": "tsc -p tsconfig.main.json",
|
||||||
"build": "yarn build:renderer && yarn build:main",
|
"build": "yarn build:renderer && yarn build:main",
|
||||||
"pack": "yarn build && electron-builder --dir",
|
"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": {
|
"dependencies": {
|
||||||
"electron": "^30.0.0",
|
"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,
|
serverRequestTimeoutMs: 4000,
|
||||||
commandTimeoutMs: 4000,
|
commandTimeoutMs: 4000,
|
||||||
installTimeoutMs: 10 * 60 * 1000,
|
installTimeoutMs: 10 * 60 * 1000,
|
||||||
healthcheckIntervalMs: 10000
|
healthcheckIntervalMs: 10000,
|
||||||
|
modRepoUrl: "https://gitea.pandoli365.com/art/spt-mods"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SERVER_HEALTHCHECK_URL = new URL(
|
export const SERVER_HEALTHCHECK_URL = new URL(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue