@kzheart_/mc-pilot 0.9.0 → 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.
- package/data/variants.json +12 -12
- package/dist/commands/client.js +1 -1
- package/dist/download/VersionMatrix.js +14 -14
- package/dist/download/client/ClientDownloader.js +1 -1
- package/dist/instance/ClientInstanceManager.d.ts +1 -0
- package/dist/instance/ClientInstanceManager.js +105 -98
- package/dist/instance/ServerInstanceManager.d.ts +5 -0
- package/dist/instance/ServerInstanceManager.js +85 -3
- package/dist/util/command-log.d.ts +12 -0
- package/dist/util/command-log.js +13 -0
- package/dist/util/global-state.d.ts +4 -0
- package/dist/util/global-state.js +22 -0
- package/dist/util/net.d.ts +1 -0
- package/dist/util/net.js +14 -12
- package/dist/util/state.d.ts +5 -0
- package/dist/util/state.js +81 -2
- package/package.json +1 -1
- package/scripts/launch-fabric-client.mjs +13 -5
package/data/variants.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"loader": "fabric",
|
|
10
10
|
"support": "ready",
|
|
11
11
|
"validation": "verified",
|
|
12
|
-
"modVersion": "0.
|
|
12
|
+
"modVersion": "0.9.1",
|
|
13
13
|
"fabricLoaderVersion": "0.16.14",
|
|
14
14
|
"yarnMappings": "1.18.2+build.4",
|
|
15
15
|
"javaVersion": 17,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"loader": "fabric",
|
|
22
22
|
"support": "ready",
|
|
23
23
|
"validation": "verified",
|
|
24
|
-
"modVersion": "0.
|
|
24
|
+
"modVersion": "0.9.1",
|
|
25
25
|
"fabricLoaderVersion": "0.16.14",
|
|
26
26
|
"yarnMappings": "1.20.1+build.10",
|
|
27
27
|
"javaVersion": 17,
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"loader": "forge",
|
|
34
34
|
"support": "planned",
|
|
35
35
|
"validation": "planned",
|
|
36
|
-
"modVersion": "0.
|
|
36
|
+
"modVersion": "0.9.1",
|
|
37
37
|
"forgeVersion": "47.3.0",
|
|
38
38
|
"javaVersion": 17
|
|
39
39
|
},
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"loader": "fabric",
|
|
44
44
|
"support": "ready",
|
|
45
45
|
"validation": "verified",
|
|
46
|
-
"modVersion": "0.
|
|
46
|
+
"modVersion": "0.9.1",
|
|
47
47
|
"fabricLoaderVersion": "0.16.14",
|
|
48
48
|
"yarnMappings": "1.20.2+build.4",
|
|
49
49
|
"javaVersion": 17,
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"loader": "forge",
|
|
56
56
|
"support": "planned",
|
|
57
57
|
"validation": "planned",
|
|
58
|
-
"modVersion": "0.
|
|
58
|
+
"modVersion": "0.9.1",
|
|
59
59
|
"forgeVersion": "48.1.0",
|
|
60
60
|
"javaVersion": 17
|
|
61
61
|
},
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"loader": "fabric",
|
|
66
66
|
"support": "ready",
|
|
67
67
|
"validation": "verified",
|
|
68
|
-
"modVersion": "0.
|
|
68
|
+
"modVersion": "0.9.1",
|
|
69
69
|
"fabricLoaderVersion": "0.16.14",
|
|
70
70
|
"yarnMappings": "1.20.4+build.3",
|
|
71
71
|
"javaVersion": 17,
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"loader": "forge",
|
|
78
78
|
"support": "planned",
|
|
79
79
|
"validation": "planned",
|
|
80
|
-
"modVersion": "0.
|
|
80
|
+
"modVersion": "0.9.1",
|
|
81
81
|
"forgeVersion": "49.0.49",
|
|
82
82
|
"javaVersion": 17
|
|
83
83
|
},
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"loader": "neoforge",
|
|
88
88
|
"support": "planned",
|
|
89
89
|
"validation": "planned",
|
|
90
|
-
"modVersion": "0.
|
|
90
|
+
"modVersion": "0.9.1",
|
|
91
91
|
"neoforgeVersion": "20.4.237",
|
|
92
92
|
"javaVersion": 17
|
|
93
93
|
},
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"loader": "fabric",
|
|
98
98
|
"support": "ready",
|
|
99
99
|
"validation": "verified",
|
|
100
|
-
"modVersion": "0.
|
|
100
|
+
"modVersion": "0.9.1",
|
|
101
101
|
"fabricLoaderVersion": "0.16.14",
|
|
102
102
|
"yarnMappings": "1.21.1+build.3",
|
|
103
103
|
"javaVersion": 21,
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"loader": "neoforge",
|
|
110
110
|
"support": "planned",
|
|
111
111
|
"validation": "planned",
|
|
112
|
-
"modVersion": "0.
|
|
112
|
+
"modVersion": "0.9.1",
|
|
113
113
|
"neoforgeVersion": "21.1.77",
|
|
114
114
|
"javaVersion": 21
|
|
115
115
|
},
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"loader": "fabric",
|
|
120
120
|
"support": "ready",
|
|
121
121
|
"validation": "verified",
|
|
122
|
-
"modVersion": "0.
|
|
122
|
+
"modVersion": "0.9.1",
|
|
123
123
|
"fabricLoaderVersion": "0.16.14",
|
|
124
124
|
"yarnMappings": "1.21.4+build.1",
|
|
125
125
|
"javaVersion": 21,
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"loader": "neoforge",
|
|
132
132
|
"support": "planned",
|
|
133
133
|
"validation": "planned",
|
|
134
|
-
"modVersion": "0.
|
|
134
|
+
"modVersion": "0.9.1",
|
|
135
135
|
"neoforgeVersion": "21.4.75",
|
|
136
136
|
"javaVersion": 21
|
|
137
137
|
}
|
package/dist/commands/client.js
CHANGED
|
@@ -91,7 +91,7 @@ export function createClientCommand() {
|
|
|
91
91
|
sourcePath = cacheArtifactPath;
|
|
92
92
|
}
|
|
93
93
|
catch {
|
|
94
|
-
const modVersion = variant.modVersion ?? "0.1
|
|
94
|
+
const modVersion = variant.modVersion ?? "0.9.1";
|
|
95
95
|
const baseUrl = process.env.MCT_MOD_DOWNLOAD_BASE_URL || "https://github.com/kzheart/mc-pilot/releases/download";
|
|
96
96
|
const downloadUrl = `${baseUrl}/v${modVersion}/${artifactFileName}`;
|
|
97
97
|
await downloadFile(downloadUrl, cacheArtifactPath, fetch);
|
|
@@ -10,9 +10,9 @@ const VERSION_MATRIX = [
|
|
|
10
10
|
spigot: { supported: true, requiresBuildTools: true }
|
|
11
11
|
},
|
|
12
12
|
clients: {
|
|
13
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
13
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
|
|
14
14
|
forge: { supported: false, notes: "不支持此版本" },
|
|
15
|
-
neoforge: { supported: true, loaderVersion: "21.4.x", modVersion: "0.
|
|
15
|
+
neoforge: { supported: true, loaderVersion: "21.4.x", modVersion: "0.9.1" }
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
{
|
|
@@ -25,9 +25,9 @@ const VERSION_MATRIX = [
|
|
|
25
25
|
spigot: { supported: true, requiresBuildTools: true }
|
|
26
26
|
},
|
|
27
27
|
clients: {
|
|
28
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
28
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
|
|
29
29
|
forge: { supported: false, notes: "不支持此版本" },
|
|
30
|
-
neoforge: { supported: true, loaderVersion: "21.1.x", modVersion: "0.
|
|
30
|
+
neoforge: { supported: true, loaderVersion: "21.1.x", modVersion: "0.9.1" }
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
{
|
|
@@ -40,8 +40,8 @@ const VERSION_MATRIX = [
|
|
|
40
40
|
spigot: { supported: true, requiresBuildTools: true }
|
|
41
41
|
},
|
|
42
42
|
clients: {
|
|
43
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
44
|
-
forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.
|
|
43
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
|
|
44
|
+
forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.9.1" },
|
|
45
45
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
46
46
|
}
|
|
47
47
|
},
|
|
@@ -55,7 +55,7 @@ const VERSION_MATRIX = [
|
|
|
55
55
|
spigot: { supported: true, requiresBuildTools: true }
|
|
56
56
|
},
|
|
57
57
|
clients: {
|
|
58
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
58
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
|
|
59
59
|
forge: { supported: false, notes: "当前未接入此 loader" },
|
|
60
60
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
61
61
|
}
|
|
@@ -70,7 +70,7 @@ const VERSION_MATRIX = [
|
|
|
70
70
|
spigot: { supported: true, requiresBuildTools: true }
|
|
71
71
|
},
|
|
72
72
|
clients: {
|
|
73
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
73
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
|
|
74
74
|
forge: { supported: false, notes: "当前未接入此 loader" },
|
|
75
75
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
76
76
|
}
|
|
@@ -85,8 +85,8 @@ const VERSION_MATRIX = [
|
|
|
85
85
|
spigot: { supported: true, requiresBuildTools: true }
|
|
86
86
|
},
|
|
87
87
|
clients: {
|
|
88
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
89
|
-
forge: { supported: true, loaderVersion: "47.x", modVersion: "0.
|
|
88
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
|
|
89
|
+
forge: { supported: true, loaderVersion: "47.x", modVersion: "0.9.1" },
|
|
90
90
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
91
91
|
}
|
|
92
92
|
},
|
|
@@ -100,8 +100,8 @@ const VERSION_MATRIX = [
|
|
|
100
100
|
spigot: { supported: true, requiresBuildTools: true }
|
|
101
101
|
},
|
|
102
102
|
clients: {
|
|
103
|
-
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.
|
|
104
|
-
forge: { supported: true, loaderVersion: "40.x", modVersion: "0.
|
|
103
|
+
fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
|
|
104
|
+
forge: { supported: true, loaderVersion: "40.x", modVersion: "0.9.1" },
|
|
105
105
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
106
106
|
}
|
|
107
107
|
},
|
|
@@ -116,7 +116,7 @@ const VERSION_MATRIX = [
|
|
|
116
116
|
},
|
|
117
117
|
clients: {
|
|
118
118
|
fabric: { supported: false, notes: "当前未接入此版本 mod" },
|
|
119
|
-
forge: { supported: true, loaderVersion: "36.x", modVersion: "0.
|
|
119
|
+
forge: { supported: true, loaderVersion: "36.x", modVersion: "0.9.1" },
|
|
120
120
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
121
121
|
}
|
|
122
122
|
},
|
|
@@ -131,7 +131,7 @@ const VERSION_MATRIX = [
|
|
|
131
131
|
},
|
|
132
132
|
clients: {
|
|
133
133
|
fabric: { supported: false, notes: "不支持此版本" },
|
|
134
|
-
forge: { supported: true, loaderVersion: "14.23.x", modVersion: "0.
|
|
134
|
+
forge: { supported: true, loaderVersion: "14.23.x", modVersion: "0.9.1" },
|
|
135
135
|
neoforge: { supported: false, notes: "不支持此版本" }
|
|
136
136
|
}
|
|
137
137
|
}
|
|
@@ -51,7 +51,7 @@ export async function resolveArtifact(cwd, variant, cacheManager, fetchImpl = fe
|
|
|
51
51
|
}
|
|
52
52
|
catch { }
|
|
53
53
|
// 3. Download from GitHub Releases
|
|
54
|
-
const modVersion = variant.modVersion ?? "0.1
|
|
54
|
+
const modVersion = variant.modVersion ?? "0.9.1";
|
|
55
55
|
const releaseTag = `v${modVersion}`;
|
|
56
56
|
const downloadUrl = `${GITHUB_RELEASE_BASE_URL}/${releaseTag}/${artifactFileName}`;
|
|
57
57
|
try {
|
|
@@ -19,114 +19,107 @@ export class ClientInstanceManager {
|
|
|
19
19
|
this.globalState = globalState;
|
|
20
20
|
}
|
|
21
21
|
async create(options) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
return this.globalState.withClientLock(async () => {
|
|
23
|
+
const instanceDir = resolveClientInstanceDir(options.name);
|
|
24
|
+
await mkdir(instanceDir, { recursive: true });
|
|
25
|
+
const wsPort = options.wsPort ?? (await this.findAvailablePort());
|
|
26
|
+
const meta = {
|
|
27
|
+
name: options.name,
|
|
28
|
+
loader: options.loader ?? "fabric",
|
|
29
|
+
mcVersion: options.version,
|
|
30
|
+
wsPort,
|
|
31
|
+
account: options.account,
|
|
32
|
+
headless: options.headless,
|
|
33
|
+
launchArgs: options.launchArgs,
|
|
34
|
+
env: options.env,
|
|
35
|
+
createdAt: new Date().toISOString()
|
|
36
|
+
};
|
|
37
|
+
await writeFile(path.join(instanceDir, INSTANCE_FILE), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
38
|
+
return meta;
|
|
39
|
+
});
|
|
38
40
|
}
|
|
39
41
|
async launch(clientName, options = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
return this.globalState.withClientLock(async () => {
|
|
43
|
+
const state = await this.globalState.readClientState();
|
|
44
|
+
const existing = state.clients[clientName];
|
|
45
|
+
if (existing && isProcessRunning(existing.pid)) {
|
|
46
|
+
if (options.force) {
|
|
47
|
+
await this.stopTrackedClient(state, clientName, existing);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
throw new MctError({
|
|
51
|
+
code: "CLIENT_ALREADY_RUNNING",
|
|
52
|
+
message: `Client ${clientName} is already running. Pass --force to kill and relaunch.`,
|
|
53
|
+
details: existing
|
|
54
|
+
}, 3);
|
|
55
|
+
}
|
|
46
56
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}, 3);
|
|
57
|
+
const meta = await this.loadMeta(clientName);
|
|
58
|
+
const instanceDir = resolveClientInstanceDir(clientName);
|
|
59
|
+
const wsPort = options.wsPort ?? meta.wsPort;
|
|
60
|
+
if (!meta.launchArgs || meta.launchArgs.length === 0) {
|
|
61
|
+
throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
|
|
53
62
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const wsPort = options.wsPort ?? meta.wsPort;
|
|
58
|
-
if (!meta.launchArgs || meta.launchArgs.length === 0) {
|
|
59
|
-
throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
|
|
60
|
-
}
|
|
61
|
-
// Kill any existing processes on the port
|
|
62
|
-
const listeningPids = getListeningPids(wsPort);
|
|
63
|
-
for (const pid of listeningPids) {
|
|
64
|
-
killProcessTree(pid);
|
|
65
|
-
}
|
|
66
|
-
if (listeningPids.length > 0) {
|
|
67
|
-
const deadline = Date.now() + 10_000;
|
|
68
|
-
while (Date.now() < deadline) {
|
|
69
|
-
if (getListeningPids(wsPort).length === 0) {
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
63
|
+
const listeningPids = getListeningPids(wsPort);
|
|
64
|
+
for (const pid of listeningPids) {
|
|
65
|
+
killProcessTree(pid);
|
|
73
66
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
cwd: minecraftDir,
|
|
83
|
-
detached: true,
|
|
84
|
-
stdio: ["ignore", stdout, stdout],
|
|
85
|
-
env: {
|
|
86
|
-
...process.env,
|
|
87
|
-
...meta.env,
|
|
88
|
-
MCT_CLIENT_NAME: clientName,
|
|
89
|
-
MCT_CLIENT_VERSION: meta.mcVersion,
|
|
90
|
-
MCT_CLIENT_ACCOUNT: options.account ?? meta.account ?? "",
|
|
91
|
-
MCT_CLIENT_SERVER: options.server ?? "",
|
|
92
|
-
MCT_CLIENT_WS_PORT: String(wsPort),
|
|
93
|
-
MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false)
|
|
67
|
+
if (listeningPids.length > 0) {
|
|
68
|
+
const deadline = Date.now() + 10_000;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
if (getListeningPids(wsPort).length === 0) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
74
|
+
}
|
|
94
75
|
}
|
|
76
|
+
const launchCommand = [process.execPath, getLaunchScriptPath(), ...meta.launchArgs];
|
|
77
|
+
const minecraftDir = path.join(instanceDir, "minecraft");
|
|
78
|
+
const logsDir = path.join(resolveMctHome(), "logs");
|
|
79
|
+
mkdirSync(logsDir, { recursive: true });
|
|
80
|
+
const logPath = path.join(logsDir, `client-${clientName}.log`);
|
|
81
|
+
const stdout = openSync(logPath, "a");
|
|
82
|
+
const child = spawn(launchCommand[0], launchCommand.slice(1), {
|
|
83
|
+
cwd: minecraftDir,
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: ["ignore", stdout, stdout],
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
...meta.env,
|
|
89
|
+
MCT_CLIENT_NAME: clientName,
|
|
90
|
+
MCT_CLIENT_VERSION: meta.mcVersion,
|
|
91
|
+
MCT_CLIENT_ACCOUNT: options.account ?? meta.account ?? "",
|
|
92
|
+
MCT_CLIENT_SERVER: options.server ?? "",
|
|
93
|
+
MCT_CLIENT_WS_PORT: String(wsPort),
|
|
94
|
+
MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false)
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
child.unref();
|
|
98
|
+
const entry = {
|
|
99
|
+
pid: child.pid ?? 0,
|
|
100
|
+
name: clientName,
|
|
101
|
+
wsPort,
|
|
102
|
+
startedAt: new Date().toISOString(),
|
|
103
|
+
logPath,
|
|
104
|
+
instanceDir
|
|
105
|
+
};
|
|
106
|
+
state.defaultClient ??= clientName;
|
|
107
|
+
state.clients[clientName] = entry;
|
|
108
|
+
await this.globalState.writeClientState(state);
|
|
109
|
+
return entry;
|
|
95
110
|
});
|
|
96
|
-
child.unref();
|
|
97
|
-
const entry = {
|
|
98
|
-
pid: child.pid ?? 0,
|
|
99
|
-
name: clientName,
|
|
100
|
-
wsPort,
|
|
101
|
-
startedAt: new Date().toISOString(),
|
|
102
|
-
logPath,
|
|
103
|
-
instanceDir
|
|
104
|
-
};
|
|
105
|
-
state.defaultClient ??= clientName;
|
|
106
|
-
state.clients[clientName] = entry;
|
|
107
|
-
await this.globalState.writeClientState(state);
|
|
108
|
-
return entry;
|
|
109
111
|
}
|
|
110
112
|
async stop(clientName) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (isProcessRunning(entry.pid)) {
|
|
117
|
-
killProcessTree(entry.pid);
|
|
118
|
-
}
|
|
119
|
-
for (const pid of getListeningPids(entry.wsPort)) {
|
|
120
|
-
if (pid !== entry.pid) {
|
|
121
|
-
killProcessTree(pid);
|
|
113
|
+
return this.globalState.withClientLock(async () => {
|
|
114
|
+
const state = await this.globalState.readClientState();
|
|
115
|
+
const entry = state.clients[clientName];
|
|
116
|
+
if (!entry) {
|
|
117
|
+
return { stopped: false, alreadyStopped: true, name: clientName };
|
|
122
118
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
await this.globalState.writeClientState(state);
|
|
129
|
-
return { stopped: true, name: clientName, pid: entry.pid };
|
|
119
|
+
await this.stopTrackedClient(state, clientName, entry);
|
|
120
|
+
await this.globalState.writeClientState(state);
|
|
121
|
+
return { stopped: true, name: clientName, pid: entry.pid };
|
|
122
|
+
});
|
|
130
123
|
}
|
|
131
124
|
async isAlreadyRunning(clientName) {
|
|
132
125
|
const state = await this.globalState.readClientState();
|
|
@@ -340,4 +333,18 @@ export class ClientInstanceManager {
|
|
|
340
333
|
}
|
|
341
334
|
return port;
|
|
342
335
|
}
|
|
336
|
+
async stopTrackedClient(state, clientName, entry) {
|
|
337
|
+
if (isProcessRunning(entry.pid)) {
|
|
338
|
+
killProcessTree(entry.pid);
|
|
339
|
+
}
|
|
340
|
+
for (const pid of getListeningPids(entry.wsPort)) {
|
|
341
|
+
if (pid !== entry.pid) {
|
|
342
|
+
killProcessTree(pid);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
delete state.clients[clientName];
|
|
346
|
+
if (state.defaultClient === clientName) {
|
|
347
|
+
state.defaultClient = Object.keys(state.clients)[0];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
343
350
|
}
|
|
@@ -70,6 +70,10 @@ export declare class ServerInstanceManager {
|
|
|
70
70
|
reachable: boolean;
|
|
71
71
|
host: string;
|
|
72
72
|
port: number;
|
|
73
|
+
phase: string;
|
|
74
|
+
logPath: string;
|
|
75
|
+
lastLine: string | null;
|
|
76
|
+
recentLines: string[];
|
|
73
77
|
}>;
|
|
74
78
|
exec(serverName: string, command: string): Promise<{
|
|
75
79
|
sent: boolean;
|
|
@@ -99,6 +103,7 @@ export declare class ServerInstanceManager {
|
|
|
99
103
|
timedOut: boolean;
|
|
100
104
|
}>;
|
|
101
105
|
private requireRuntimeEntry;
|
|
106
|
+
private describeStartup;
|
|
102
107
|
private requireRunning;
|
|
103
108
|
list(): Promise<ServerInstanceMeta[]>;
|
|
104
109
|
static listAll(globalState: GlobalStateStore): Promise<ServerInstanceMeta[]>;
|
|
@@ -4,11 +4,12 @@ import { spawn, execSync } from "node:child_process";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { resolveMctHome, resolveProjectDir, resolveServerInstanceDir } from "../util/paths.js";
|
|
6
6
|
import { MctError } from "../util/errors.js";
|
|
7
|
-
import {
|
|
7
|
+
import { isTcpPortReachable } from "../util/net.js";
|
|
8
8
|
import { isProcessRunning, killProcessTree } from "../util/process.js";
|
|
9
9
|
import { copyFileIfMissing } from "../download/DownloadUtils.js";
|
|
10
10
|
const INSTANCE_FILE = "instance.json";
|
|
11
11
|
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
12
|
+
const SERVER_READY_POLL_MS = 500;
|
|
12
13
|
export function stripAnsiCodes(text) {
|
|
13
14
|
return text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
14
15
|
}
|
|
@@ -108,7 +109,7 @@ export class ServerInstanceManager {
|
|
|
108
109
|
// then exec java with stdin reading from the FIFO
|
|
109
110
|
const child = spawn("bash", [
|
|
110
111
|
"-c",
|
|
111
|
-
'exec 3<>"$MCT_STDIN_PIPE"; exec java "$@"
|
|
112
|
+
'exec 3<>"$MCT_STDIN_PIPE"; exec java "$@" 0<&3',
|
|
112
113
|
"mct-server",
|
|
113
114
|
...jvmArgs, "-jar", jarFile, "nogui"
|
|
114
115
|
], {
|
|
@@ -206,7 +207,51 @@ export class ServerInstanceManager {
|
|
|
206
207
|
if (!entry) {
|
|
207
208
|
throw new MctError({ code: "SERVER_NOT_RUNNING", message: `Server ${stateKey} is not running` }, 5);
|
|
208
209
|
}
|
|
209
|
-
|
|
210
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
211
|
+
let snapshot = await this.describeStartup(entry.logPath);
|
|
212
|
+
while (Date.now() < deadline) {
|
|
213
|
+
if (!isProcessRunning(entry.pid)) {
|
|
214
|
+
throw new MctError({
|
|
215
|
+
code: "SERVER_EXITED",
|
|
216
|
+
message: `Server ${stateKey} exited before becoming ready (${snapshot.phase})`,
|
|
217
|
+
details: {
|
|
218
|
+
pid: entry.pid,
|
|
219
|
+
host: "127.0.0.1",
|
|
220
|
+
port: entry.port,
|
|
221
|
+
phase: snapshot.phase,
|
|
222
|
+
logPath: snapshot.logPath,
|
|
223
|
+
lastLine: snapshot.lastLine,
|
|
224
|
+
recentLines: snapshot.recentLines
|
|
225
|
+
}
|
|
226
|
+
}, 5);
|
|
227
|
+
}
|
|
228
|
+
if (await isTcpPortReachable("127.0.0.1", entry.port)) {
|
|
229
|
+
snapshot = await this.describeStartup(entry.logPath);
|
|
230
|
+
return {
|
|
231
|
+
reachable: true,
|
|
232
|
+
host: "127.0.0.1",
|
|
233
|
+
port: entry.port,
|
|
234
|
+
phase: snapshot.phase,
|
|
235
|
+
logPath: snapshot.logPath,
|
|
236
|
+
lastLine: snapshot.lastLine,
|
|
237
|
+
recentLines: snapshot.recentLines
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
snapshot = await this.describeStartup(entry.logPath);
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, SERVER_READY_POLL_MS));
|
|
242
|
+
}
|
|
243
|
+
throw new MctError({
|
|
244
|
+
code: "TIMEOUT",
|
|
245
|
+
message: `Timed out waiting for 127.0.0.1:${entry.port} (${snapshot.phase})`,
|
|
246
|
+
details: {
|
|
247
|
+
host: "127.0.0.1",
|
|
248
|
+
port: entry.port,
|
|
249
|
+
phase: snapshot.phase,
|
|
250
|
+
logPath: snapshot.logPath,
|
|
251
|
+
lastLine: snapshot.lastLine,
|
|
252
|
+
recentLines: snapshot.recentLines
|
|
253
|
+
}
|
|
254
|
+
}, 2);
|
|
210
255
|
}
|
|
211
256
|
async exec(serverName, command) {
|
|
212
257
|
const entry = await this.requireRunning(serverName);
|
|
@@ -338,6 +383,21 @@ export class ServerInstanceManager {
|
|
|
338
383
|
}
|
|
339
384
|
return entry;
|
|
340
385
|
}
|
|
386
|
+
async describeStartup(logPath) {
|
|
387
|
+
const raw = await readFile(logPath, "utf8").catch(() => "");
|
|
388
|
+
const recentLines = raw
|
|
389
|
+
.split(/\r?\n/)
|
|
390
|
+
.map((line) => stripAnsiCodes(line).trim())
|
|
391
|
+
.filter((line) => line.length > 0)
|
|
392
|
+
.slice(-10);
|
|
393
|
+
const lastLine = recentLines[recentLines.length - 1] ?? null;
|
|
394
|
+
return {
|
|
395
|
+
phase: detectServerStartupPhase(recentLines),
|
|
396
|
+
logPath,
|
|
397
|
+
recentLines,
|
|
398
|
+
lastLine
|
|
399
|
+
};
|
|
400
|
+
}
|
|
341
401
|
async requireRunning(serverName) {
|
|
342
402
|
const entry = await this.requireRuntimeEntry(serverName);
|
|
343
403
|
if (!isProcessRunning(entry.pid)) {
|
|
@@ -430,3 +490,25 @@ export class ServerInstanceManager {
|
|
|
430
490
|
return port;
|
|
431
491
|
}
|
|
432
492
|
}
|
|
493
|
+
function detectServerStartupPhase(lines) {
|
|
494
|
+
const joined = lines.join("\n");
|
|
495
|
+
if (/Done \(.+\)! For help, type "help"/.test(joined)) {
|
|
496
|
+
return "ready";
|
|
497
|
+
}
|
|
498
|
+
if (/Preparing start region|Preparing level/.test(joined)) {
|
|
499
|
+
return "initializing-world";
|
|
500
|
+
}
|
|
501
|
+
if (/Starting Minecraft server on/.test(joined)) {
|
|
502
|
+
return "binding-port";
|
|
503
|
+
}
|
|
504
|
+
if (/Loading libraries, please wait|Starting org\.bukkit\.craftbukkit\.Main|Starting minecraft server version/.test(joined)) {
|
|
505
|
+
return "bootstrapping";
|
|
506
|
+
}
|
|
507
|
+
if (/Downloading |Applying patches/.test(joined)) {
|
|
508
|
+
return "downloading";
|
|
509
|
+
}
|
|
510
|
+
if (lines.length > 0) {
|
|
511
|
+
return "starting";
|
|
512
|
+
}
|
|
513
|
+
return "waiting-for-log";
|
|
514
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface CommandLogEntry {
|
|
2
|
+
t: number;
|
|
3
|
+
iso: string;
|
|
4
|
+
argv: string[];
|
|
5
|
+
cwd: string;
|
|
6
|
+
projectId: string | null;
|
|
7
|
+
exitCode: number;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
errorCode?: string;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function appendCommandHistory(entry: CommandLogEntry): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveCommandsLogPath } from "./paths.js";
|
|
4
|
+
export async function appendCommandHistory(entry) {
|
|
5
|
+
try {
|
|
6
|
+
const filePath = resolveCommandsLogPath();
|
|
7
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
8
|
+
await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// Never block the command on logging failure
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -2,8 +2,12 @@ import { StateStore } from "./state.js";
|
|
|
2
2
|
import type { GlobalClientState, GlobalServerState } from "./instance-types.js";
|
|
3
3
|
export declare class GlobalStateStore extends StateStore {
|
|
4
4
|
constructor();
|
|
5
|
+
withClientLock<T>(task: () => Promise<T>): Promise<T>;
|
|
6
|
+
withServerLock<T>(task: () => Promise<T>): Promise<T>;
|
|
5
7
|
readServerState(): Promise<GlobalServerState>;
|
|
6
8
|
writeServerState(state: GlobalServerState): Promise<void>;
|
|
7
9
|
readClientState(): Promise<GlobalClientState>;
|
|
8
10
|
writeClientState(state: GlobalClientState): Promise<void>;
|
|
11
|
+
updateClientState<T>(mutate: (state: GlobalClientState) => Promise<T> | T): Promise<T>;
|
|
12
|
+
updateServerState<T>(mutate: (state: GlobalServerState) => Promise<T> | T): Promise<T>;
|
|
9
13
|
}
|
|
@@ -6,6 +6,12 @@ export class GlobalStateStore extends StateStore {
|
|
|
6
6
|
constructor() {
|
|
7
7
|
super(resolveGlobalStateDir());
|
|
8
8
|
}
|
|
9
|
+
async withClientLock(task) {
|
|
10
|
+
return this.withLock("clients", task);
|
|
11
|
+
}
|
|
12
|
+
async withServerLock(task) {
|
|
13
|
+
return this.withLock("servers", task);
|
|
14
|
+
}
|
|
9
15
|
async readServerState() {
|
|
10
16
|
return this.readJson(SERVERS_STATE_FILE, { servers: {} });
|
|
11
17
|
}
|
|
@@ -18,4 +24,20 @@ export class GlobalStateStore extends StateStore {
|
|
|
18
24
|
async writeClientState(state) {
|
|
19
25
|
await this.writeJson(CLIENTS_STATE_FILE, state);
|
|
20
26
|
}
|
|
27
|
+
async updateClientState(mutate) {
|
|
28
|
+
return this.withClientLock(async () => {
|
|
29
|
+
const state = await this.readClientState();
|
|
30
|
+
const result = await mutate(state);
|
|
31
|
+
await this.writeClientState(state);
|
|
32
|
+
return result;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async updateServerState(mutate) {
|
|
36
|
+
return this.withServerLock(async () => {
|
|
37
|
+
const state = await this.readServerState();
|
|
38
|
+
const result = await mutate(state);
|
|
39
|
+
await this.writeServerState(state);
|
|
40
|
+
return result;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
21
43
|
}
|
package/dist/util/net.d.ts
CHANGED
package/dist/util/net.js
CHANGED
|
@@ -5,21 +5,23 @@ function wait(ms) {
|
|
|
5
5
|
setTimeout(resolve, ms);
|
|
6
6
|
});
|
|
7
7
|
}
|
|
8
|
+
export async function isTcpPortReachable(host, port) {
|
|
9
|
+
return await new Promise((resolve) => {
|
|
10
|
+
const socket = net.createConnection({ host, port });
|
|
11
|
+
socket.once("connect", () => {
|
|
12
|
+
socket.destroy();
|
|
13
|
+
resolve(true);
|
|
14
|
+
});
|
|
15
|
+
socket.once("error", () => {
|
|
16
|
+
socket.destroy();
|
|
17
|
+
resolve(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
8
21
|
export async function waitForTcpPort(host, port, timeoutSeconds) {
|
|
9
22
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
10
23
|
while (Date.now() < deadline) {
|
|
11
|
-
|
|
12
|
-
const socket = net.createConnection({ host, port });
|
|
13
|
-
socket.once("connect", () => {
|
|
14
|
-
socket.destroy();
|
|
15
|
-
resolve(true);
|
|
16
|
-
});
|
|
17
|
-
socket.once("error", () => {
|
|
18
|
-
socket.destroy();
|
|
19
|
-
resolve(false);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
if (reachable) {
|
|
24
|
+
if (await isTcpPortReachable(host, port)) {
|
|
23
25
|
return {
|
|
24
26
|
reachable: true,
|
|
25
27
|
host,
|
package/dist/util/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/util/state.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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
|
@@ -126,9 +126,17 @@ async function readJson(filePath) {
|
|
|
126
126
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
const artifactName = `mct-client-mod-${
|
|
131
|
-
const
|
|
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 =
|
|
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,
|
|
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`);
|