@kzheart_/mc-pilot 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,24 @@
1
- import { access, readFile, writeFile } from "node:fs/promises";
1
+ import { realpathSync } from "node:fs";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
- export const PROJECT_FILE_NAME = "mct.project.json";
4
- export function resolveProjectFilePath(cwd) {
5
- return path.join(cwd, PROJECT_FILE_NAME);
4
+ import { resolveProjectConfigPath, resolveProjectScreenshotsDir } from "./paths.js";
5
+ export const PROJECT_FILE_NAME = "project.json";
6
+ export function normalizeProjectRoot(cwd) {
7
+ try {
8
+ return realpathSync(cwd);
9
+ }
10
+ catch {
11
+ return path.resolve(cwd);
12
+ }
6
13
  }
7
- export async function loadProjectFile(cwd) {
8
- const filePath = resolveProjectFilePath(cwd);
14
+ export function slugifyProjectId(cwd) {
15
+ return cwd.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-");
16
+ }
17
+ export function resolveProjectFilePath(projectId) {
18
+ return resolveProjectConfigPath(projectId);
19
+ }
20
+ export async function loadProjectFileById(projectId) {
21
+ const filePath = resolveProjectFilePath(projectId);
9
22
  try {
10
23
  await access(filePath);
11
24
  }
@@ -15,10 +28,52 @@ export async function loadProjectFile(cwd) {
15
28
  const raw = await readFile(filePath, "utf8");
16
29
  return JSON.parse(raw);
17
30
  }
18
- export async function writeProjectFile(cwd, project) {
19
- const filePath = resolveProjectFilePath(cwd);
31
+ export async function loadProjectFileForCwd(cwd) {
32
+ const projectId = slugifyProjectId(normalizeProjectRoot(cwd));
33
+ const projectFile = await loadProjectFileById(projectId);
34
+ if (!projectFile) {
35
+ return null;
36
+ }
37
+ return {
38
+ projectId,
39
+ filePath: resolveProjectFilePath(projectId),
40
+ projectFile
41
+ };
42
+ }
43
+ export async function loadProjectFileForId(projectId) {
44
+ const projectFile = await loadProjectFileById(projectId);
45
+ if (!projectFile) {
46
+ return null;
47
+ }
48
+ return {
49
+ projectId,
50
+ filePath: resolveProjectFilePath(projectId),
51
+ projectFile
52
+ };
53
+ }
54
+ export async function writeProjectFile(projectId, project) {
55
+ const filePath = resolveProjectFilePath(projectId);
56
+ await mkdir(path.dirname(filePath), { recursive: true });
20
57
  await writeFile(filePath, `${JSON.stringify(project, null, 2)}\n`, "utf8");
21
58
  }
59
+ export function createDefaultProjectFile(cwd, projectName) {
60
+ const rootDir = normalizeProjectRoot(cwd);
61
+ const projectId = slugifyProjectId(rootDir);
62
+ return {
63
+ projectId,
64
+ project: projectName,
65
+ rootDir,
66
+ profiles: {},
67
+ screenshot: {
68
+ outputDir: resolveProjectScreenshotsDir(projectId)
69
+ },
70
+ timeout: {
71
+ serverReady: 120,
72
+ clientReady: 60,
73
+ default: 10
74
+ }
75
+ };
76
+ }
22
77
  export function resolveProfile(projectFile, profileName) {
23
78
  const name = profileName ?? projectFile.defaultProfile;
24
79
  if (!name) {
@@ -6,5 +6,10 @@ export declare class StateStore {
6
6
  readJson<T>(name: string, fallback: T): Promise<T>;
7
7
  writeJson(name: string, value: unknown): Promise<void>;
8
8
  remove(name: string): Promise<void>;
9
+ withLock<T>(name: string, task: () => Promise<T>, options?: {
10
+ timeoutMs?: number;
11
+ staleMs?: number;
12
+ }): Promise<T>;
13
+ private cleanupStaleLock;
9
14
  }
10
15
  export declare function resolveStateDir(stateDir: string | undefined, cwd: string): string;
@@ -1,5 +1,22 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import process from "node:process";
5
+ const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
6
+ const DEFAULT_LOCK_STALE_MS = 60_000;
7
+ const LOCK_POLL_INTERVAL_MS = 50;
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function isPidRunning(pid) {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
3
20
  export class StateStore {
4
21
  rootDir;
5
22
  constructor(rootDir) {
@@ -25,12 +42,74 @@ export class StateStore {
25
42
  async writeJson(name, value) {
26
43
  await this.ensure();
27
44
  const target = path.join(this.rootDir, name);
45
+ const tempTarget = `${target}.${process.pid}.${randomUUID()}.tmp`;
28
46
  const content = JSON.stringify(value, null, 2);
29
- await writeFile(target, `${content}\n`, "utf8");
47
+ await writeFile(tempTarget, `${content}\n`, "utf8");
48
+ await rename(tempTarget, target);
30
49
  }
31
50
  async remove(name) {
32
51
  await rm(path.join(this.rootDir, name), { force: true });
33
52
  }
53
+ async withLock(name, task, options = {}) {
54
+ await this.ensure();
55
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS);
56
+ const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
57
+ const safeName = name.replace(/[\\/]/g, "-");
58
+ const lockDir = path.join(this.rootDir, `${safeName}.lock`);
59
+ const ownerPath = path.join(lockDir, "owner.json");
60
+ while (true) {
61
+ try {
62
+ await mkdir(lockDir);
63
+ const owner = {
64
+ pid: process.pid,
65
+ acquiredAt: new Date().toISOString(),
66
+ acquiredAtMs: Date.now()
67
+ };
68
+ await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, "utf8");
69
+ break;
70
+ }
71
+ catch (error) {
72
+ const lockExists = typeof error === "object"
73
+ && error !== null
74
+ && "code" in error
75
+ && error.code === "EEXIST";
76
+ if (!lockExists) {
77
+ throw error;
78
+ }
79
+ if (await this.cleanupStaleLock(lockDir, ownerPath, staleMs)) {
80
+ continue;
81
+ }
82
+ if (Date.now() >= deadline) {
83
+ throw new Error(`Timed out waiting for lock ${safeName}`);
84
+ }
85
+ await sleep(LOCK_POLL_INTERVAL_MS);
86
+ }
87
+ }
88
+ try {
89
+ return await task();
90
+ }
91
+ finally {
92
+ await rm(lockDir, { recursive: true, force: true });
93
+ }
94
+ }
95
+ async cleanupStaleLock(lockDir, ownerPath, staleMs) {
96
+ try {
97
+ const raw = await readFile(ownerPath, "utf8");
98
+ const owner = JSON.parse(raw);
99
+ const pid = Number(owner.pid);
100
+ const acquiredAtMs = typeof owner.acquiredAtMs === "number" ? owner.acquiredAtMs : NaN;
101
+ const ownerAlive = Number.isInteger(pid) && pid > 0 ? isPidRunning(pid) : false;
102
+ const isStale = Number.isFinite(acquiredAtMs) && Date.now() - acquiredAtMs > staleMs;
103
+ if (!ownerAlive || isStale) {
104
+ await rm(lockDir, { recursive: true, force: true });
105
+ return true;
106
+ }
107
+ }
108
+ catch {
109
+ // The owner file may not exist yet while another process is finalizing lock acquisition.
110
+ }
111
+ return false;
112
+ }
34
113
  }
35
114
  export function resolveStateDir(stateDir, cwd) {
36
115
  if (!stateDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Minecraft plugin/mod automated testing CLI – control a real Minecraft client to simulate player actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -126,9 +126,17 @@ async function readJson(filePath) {
126
126
  return JSON.parse(await readFile(filePath, "utf8"));
127
127
  }
128
128
 
129
- async function syncBuiltMod(instanceRoot, repoRoot, variantId) {
130
- const artifactName = `mct-client-mod-${variantId.endsWith("-fabric") ? "fabric" : variantId.split("-").pop()}-${variantId.replace(/-fabric$|-forge$|-neoforge$/, "")}.jar`;
131
- const sourceJar = path.join(repoRoot, "client-mod", "versions", variantId, "build", "libs", artifactName);
129
+ function getLocalBuildArtifactPath(repoRoot, variant) {
130
+ const artifactName = `mct-client-mod-${variant.loader ?? "fabric"}-${variant.minecraftVersion}.jar`;
131
+ const gradleModule = variant.gradleModule || `version-${variant.minecraftVersion}`;
132
+ return {
133
+ artifactName,
134
+ sourceJar: path.join(repoRoot, "client-mod", gradleModule, "build", "libs", artifactName)
135
+ };
136
+ }
137
+
138
+ async function syncBuiltMod(instanceRoot, repoRoot, variant) {
139
+ const { artifactName, sourceJar } = getLocalBuildArtifactPath(repoRoot, variant);
132
140
  const targetDir = path.join(instanceRoot, "minecraft", "mods");
133
141
  const targetJar = path.join(targetDir, artifactName);
134
142
 
@@ -193,7 +201,7 @@ async function ensureAutomationOptions(gameDir, server) {
193
201
  }
194
202
 
195
203
  async function buildLaunchSpec(options) {
196
- const repoRoot = process.cwd();
204
+ const repoRoot = path.resolve(__dirname, "..", "..");
197
205
  const defaultVariant = getDefaultVariant();
198
206
  const minecraftVersion = process.env.MCT_CLIENT_VERSION || options["minecraft-version"] || defaultVariant.minecraftVersion;
199
207
  const modVariantId = process.env.MCT_CLIENT_MOD_VARIANT || options["mod-variant"] || `${minecraftVersion}-fabric`;
@@ -206,7 +214,7 @@ async function buildLaunchSpec(options) {
206
214
  const nativesDir = options["natives-dir"] || path.join(instanceRoot, "natives");
207
215
  const gameDir = path.join(instanceRoot, "minecraft");
208
216
  const packMeta = await readJson(path.join(instanceRoot, "mmc-pack.json"));
209
- await syncBuiltMod(instanceRoot, repoRoot, modVariantId);
217
+ await syncBuiltMod(instanceRoot, repoRoot, selectedVariant);
210
218
  const componentMetas = new Map();
211
219
  for (const component of packMeta.components) {
212
220
  const componentMetaPath = path.join(metaRoot, component.uid, `${component.version}.json`);