modwright 0.1.1 → 0.1.3
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/README.md +123 -15
- package/bridges/bg3-se/README.md +15 -0
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
- package/bridges/cp2077-cet/README.md +17 -1
- package/bridges/cp2077-cet/bridge.json +1 -1
- package/dist/core/bridge/pending.js +8 -1
- package/dist/core/bridge/stage.js +2 -2
- package/dist/core/build/run.js +14 -0
- package/dist/core/build/stage.js +3 -2
- package/dist/core/build/steps/core.js +4 -4
- package/dist/core/deploy/plan.js +68 -1
- package/dist/core/deploy/reload.js +5 -3
- package/dist/core/ini.js +26 -0
- package/dist/core/knowledge/facts.js +0 -12
- package/dist/core/ledger/hooks.js +22 -5
- package/dist/core/logs/registry.js +19 -0
- package/dist/core/logs/scan.js +65 -10
- package/dist/core/logs/triage.js +79 -2
- package/dist/core/managers/mo2.js +121 -0
- package/dist/core/managers/vortex.js +52 -0
- package/dist/core/ownership/index.js +364 -0
- package/dist/core/project/index.js +2 -1
- package/dist/core/project/load.js +7 -25
- package/dist/core/project/outside-root.js +36 -0
- package/dist/core/project/schema.js +38 -1
- package/dist/core/safety/backup.js +2 -2
- package/dist/core/safety/execute.js +12 -4
- package/dist/core/safety/restore.js +62 -0
- package/dist/core/testplan/registry.js +51 -2
- package/dist/core/text/converters/index.js +1 -1
- package/dist/core/text/toolchain.js +2 -2
- package/dist/core/toolchain/locate.js +13 -1
- package/dist/core/toolchain/run.js +6 -4
- package/dist/core/toolchain/types.js +1 -1
- package/dist/core/trust.js +105 -0
- package/dist/core/writes/index.js +206 -0
- package/dist/index.js +33 -9
- package/dist/server.js +252 -34
- package/dist/surfaces/baldursgate3/surface.js +3 -0
- package/dist/surfaces/baldursgate3/validators/story.js +3 -3
- package/dist/surfaces/baldursgate3/writes.js +152 -0
- package/dist/surfaces/cyberpunk2077/compat.js +8 -4
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
- package/dist/surfaces/cyberpunk2077/logs.js +86 -6
- package/dist/surfaces/cyberpunk2077/surface.js +13 -1
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
- package/dist/surfaces/cyberpunk2077/writes.js +67 -0
- package/dist/surfaces/eldenring/loaders.js +7 -1
- package/dist/surfaces/skyrimse/loadorder.js +2 -26
- package/dist/surfaces/skyrimse/surface.js +1 -0
- package/dist/surfaces/stardewvalley/logs.js +41 -0
- package/dist/surfaces/stardewvalley/surface.js +2 -0
- package/dist/surfaces/stardewvalley/writes.js +136 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
- package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
- package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
- package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
- package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
- package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
- package/package.json +1 -1
- package/scaffolds/baldursgate3/new-project.yaml +4 -1
- package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isDirectory, pathExists, walk } from "../fsutil.js";
|
|
4
|
+
import { findMo2Instance, readMo2ModMeta, readMo2Modlist } from "../managers/mo2.js";
|
|
5
|
+
import { readVortexManifests } from "../managers/vortex.js";
|
|
6
|
+
import { listMods } from "../mods.js";
|
|
7
|
+
const MANAGER_EVIDENCE = new Set(["mo2", "mo2-overwrite", "vortex"]);
|
|
8
|
+
function isManagerBookkeeping(relative) {
|
|
9
|
+
const base = relative.slice(relative.lastIndexOf("/") + 1).toLowerCase();
|
|
10
|
+
return /^vortex\.deployment\.(?:[^.]+\.)?json$/.test(base) || base === "__folder_managed_by_vortex";
|
|
11
|
+
}
|
|
12
|
+
function keyOf(p) {
|
|
13
|
+
return p.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function gamePath(install, abs) {
|
|
16
|
+
const rel = path.relative(install.root, abs);
|
|
17
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
18
|
+
return abs.split(path.sep).join("/");
|
|
19
|
+
return rel.split(path.sep).join("/");
|
|
20
|
+
}
|
|
21
|
+
export async function buildOwnershipIndex(surface, install, options = {}) {
|
|
22
|
+
const perMod = options.fileLimitPerMod ?? 20000;
|
|
23
|
+
const index = {
|
|
24
|
+
install: install.root,
|
|
25
|
+
sources: [],
|
|
26
|
+
unattributedRoots: [],
|
|
27
|
+
looseRoots: [],
|
|
28
|
+
entries: new Map(),
|
|
29
|
+
physical: new Map(),
|
|
30
|
+
};
|
|
31
|
+
const add = (display, claim) => {
|
|
32
|
+
const key = keyOf(display);
|
|
33
|
+
let entry = index.entries.get(key);
|
|
34
|
+
if (!entry) {
|
|
35
|
+
entry = { path: display, claims: [] };
|
|
36
|
+
index.entries.set(key, entry);
|
|
37
|
+
}
|
|
38
|
+
entry.claims.push(claim);
|
|
39
|
+
if (claim.physicalPath)
|
|
40
|
+
index.physical.set(keyOf(claim.physicalPath), key);
|
|
41
|
+
};
|
|
42
|
+
const { mods, roots } = await listMods(surface, install, { fileLimit: perMod });
|
|
43
|
+
if (surface.looseUnitOf)
|
|
44
|
+
index.looseUnitOf = (rootId, rel) => surface.looseUnitOf(rootId, rel);
|
|
45
|
+
const rootById = new Map(roots.map((r) => [r.id, r]));
|
|
46
|
+
const modRootsReport = { id: "mod-roots", active: true, detail: "", mods: 0, files: 0 };
|
|
47
|
+
const flatRoots = [];
|
|
48
|
+
for (const mod of mods) {
|
|
49
|
+
const root = rootById.get(mod.rootId);
|
|
50
|
+
if (!root)
|
|
51
|
+
continue;
|
|
52
|
+
if (root.layout === "flat") {
|
|
53
|
+
flatRoots.push({ rootId: root.id, path: root.path, files: mod.files });
|
|
54
|
+
if (surface.looseUnitOf) {
|
|
55
|
+
index.looseRoots.push({ rootId: root.id, path: root.path });
|
|
56
|
+
for (const file of mod.files) {
|
|
57
|
+
const unit = surface.looseUnitOf(root.id, file);
|
|
58
|
+
if (!unit)
|
|
59
|
+
continue;
|
|
60
|
+
add(gamePath(install, path.join(root.path, file)), { mod: unit, modId: `${root.id}:${unit}`, enabled: true, evidence: "loose-unit" });
|
|
61
|
+
modRootsReport.files++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
modRootsReport.mods++;
|
|
67
|
+
if (mod.warnings?.some((w) => w.includes("truncated")))
|
|
68
|
+
modRootsReport.truncated = true;
|
|
69
|
+
const base = { mod: mod.name, modId: mod.id, enabled: true };
|
|
70
|
+
if (mod.manifest?.version)
|
|
71
|
+
base.version = mod.manifest.version;
|
|
72
|
+
if (root.layout === "file") {
|
|
73
|
+
add(gamePath(install, mod.path), { ...base, evidence: "mod-file" });
|
|
74
|
+
modRootsReport.files++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const file of mod.files) {
|
|
78
|
+
add(gamePath(install, path.join(mod.path, file)), { ...base, evidence: "mod-folder" });
|
|
79
|
+
modRootsReport.files++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
modRootsReport.detail =
|
|
83
|
+
`${modRootsReport.mods} mod(s) in ${surface.name}'s own mod roots: a mod folder owns the files under it, ` +
|
|
84
|
+
"and in a file-per-mod root the file is the mod.";
|
|
85
|
+
index.sources.push(modRootsReport);
|
|
86
|
+
let mo2Covered = false;
|
|
87
|
+
if (surface.managedDataDir !== undefined) {
|
|
88
|
+
const report = { id: "mo2", active: false, detail: "", mods: 0, files: 0 };
|
|
89
|
+
const findOptions = {};
|
|
90
|
+
if (options.mo2Instance)
|
|
91
|
+
findOptions.instancePath = options.mo2Instance;
|
|
92
|
+
if (options.mo2InstancesRoot)
|
|
93
|
+
findOptions.instancesRoot = options.mo2InstancesRoot;
|
|
94
|
+
const instance = await findMo2Instance(install, findOptions);
|
|
95
|
+
if (!instance) {
|
|
96
|
+
report.detail = options.mo2Instance
|
|
97
|
+
? `No ModOrganizer.ini at ${options.mo2Instance}.`
|
|
98
|
+
: "No global Mod Organizer 2 instance manages this install. A portable instance is only found " +
|
|
99
|
+
"when named (mo2Instance).";
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const modlist = await readMo2Modlist(instance.profileDir);
|
|
103
|
+
if (!modlist) {
|
|
104
|
+
report.detail = `MO2 instance ${instance.instanceDir} found, but profile "${instance.profileName}" has no readable modlist.txt.`;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
report.active = true;
|
|
108
|
+
mo2Covered = true;
|
|
109
|
+
const problems = [];
|
|
110
|
+
const dataDir = path.join(install.root, surface.managedDataDir);
|
|
111
|
+
let budget = options.totalFileLimit ?? 400000;
|
|
112
|
+
const walkInto = async (dir, claim) => {
|
|
113
|
+
const result = await walk(dir, { limit: Math.min(perMod, Math.max(budget, 0)) });
|
|
114
|
+
if (result.truncated)
|
|
115
|
+
report.truncated = true;
|
|
116
|
+
let count = 0;
|
|
117
|
+
for (const file of result.files) {
|
|
118
|
+
if (file.toLowerCase() === "meta.ini")
|
|
119
|
+
continue;
|
|
120
|
+
add(gamePath(install, path.join(dataDir, file)), { ...claim, physicalPath: path.join(dir, file) });
|
|
121
|
+
count++;
|
|
122
|
+
}
|
|
123
|
+
budget -= result.files.length;
|
|
124
|
+
report.files += count;
|
|
125
|
+
return count;
|
|
126
|
+
};
|
|
127
|
+
for (const entry of modlist) {
|
|
128
|
+
if (entry.foreign || entry.separator)
|
|
129
|
+
continue;
|
|
130
|
+
if (budget <= 0) {
|
|
131
|
+
report.truncated = true;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const modDir = path.join(instance.modsDir, entry.name);
|
|
135
|
+
if (!(await isDirectory(modDir))) {
|
|
136
|
+
problems.push(`"${entry.name}" is in modlist.txt but has no folder in ${instance.modsDir}.`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const meta = await readMo2ModMeta(modDir);
|
|
140
|
+
const claim = {
|
|
141
|
+
mod: entry.name,
|
|
142
|
+
modId: `mo2:${entry.name}`,
|
|
143
|
+
evidence: "mo2",
|
|
144
|
+
enabled: entry.enabled,
|
|
145
|
+
priority: entry.priority,
|
|
146
|
+
};
|
|
147
|
+
if (meta.nexusModId !== undefined)
|
|
148
|
+
claim.nexusModId = meta.nexusModId;
|
|
149
|
+
if (meta.version)
|
|
150
|
+
claim.version = meta.version;
|
|
151
|
+
await walkInto(modDir, claim);
|
|
152
|
+
report.mods++;
|
|
153
|
+
}
|
|
154
|
+
if (await pathExists(instance.overwriteDir)) {
|
|
155
|
+
const top = modlist.reduce((max, e) => Math.max(max, e.priority), 0) + 1;
|
|
156
|
+
await walkInto(instance.overwriteDir, {
|
|
157
|
+
mod: "Overwrite",
|
|
158
|
+
modId: "mo2:Overwrite",
|
|
159
|
+
evidence: "mo2-overwrite",
|
|
160
|
+
enabled: true,
|
|
161
|
+
priority: top,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
report.detail =
|
|
165
|
+
`MO2 ${instance.portable ? "portable" : "global"} instance ${instance.instanceDir}, profile ` +
|
|
166
|
+
`"${instance.profileName}": ${report.mods} mod folder(s) mapped onto ${surface.managedDataDir}/ in ` +
|
|
167
|
+
"modlist.txt priority order (top line wins; Overwrite above all). The real game folder under MO2 " +
|
|
168
|
+
"holds only the base game and files MO2 does not manage.";
|
|
169
|
+
if (problems.length > 0)
|
|
170
|
+
report.problems = problems.slice(0, 25);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
index.sources.push(report);
|
|
174
|
+
}
|
|
175
|
+
const vortexDirs = [install.root];
|
|
176
|
+
if (surface.managedDataDir)
|
|
177
|
+
vortexDirs.push(path.join(install.root, surface.managedDataDir));
|
|
178
|
+
const vortex = { id: "vortex", active: false, detail: "", mods: 0, files: 0 };
|
|
179
|
+
const vortexMods = new Set();
|
|
180
|
+
const vortexProblems = [];
|
|
181
|
+
const manifestNames = [];
|
|
182
|
+
for (const dir of [...new Set(vortexDirs)]) {
|
|
183
|
+
const { manifests, unreadable } = await readVortexManifests(dir);
|
|
184
|
+
for (const bad of unreadable)
|
|
185
|
+
vortexProblems.push(`${bad.file}: ${bad.error}`);
|
|
186
|
+
for (const manifest of manifests) {
|
|
187
|
+
manifestNames.push(gamePath(install, manifest.file));
|
|
188
|
+
for (const file of manifest.files) {
|
|
189
|
+
const targetDir = file.target
|
|
190
|
+
? path.isAbsolute(file.target)
|
|
191
|
+
? file.target
|
|
192
|
+
: path.join(manifest.deployDir, file.target)
|
|
193
|
+
: manifest.deployDir;
|
|
194
|
+
const claim = { mod: file.source, modId: `vortex:${file.source}`, evidence: "vortex", enabled: true };
|
|
195
|
+
if (manifest.stagingPath)
|
|
196
|
+
claim.physicalPath = path.join(manifest.stagingPath, file.source, file.relPath);
|
|
197
|
+
if (file.merged?.length)
|
|
198
|
+
claim.merged = file.merged;
|
|
199
|
+
add(gamePath(install, path.join(targetDir, file.relPath)), claim);
|
|
200
|
+
vortexMods.add(file.source);
|
|
201
|
+
vortex.files++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
vortex.mods = vortexMods.size;
|
|
206
|
+
if (manifestNames.length > 0) {
|
|
207
|
+
vortex.active = true;
|
|
208
|
+
vortex.detail =
|
|
209
|
+
`Vortex deployment manifest(s) ${manifestNames.join(", ")}: each deployed file with the mod it came ` +
|
|
210
|
+
"from. Vortex resolved conflicts before deploying, so a manifest names the winner only.";
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
vortex.detail = "No Vortex deployment manifest in the game folder.";
|
|
214
|
+
}
|
|
215
|
+
if (vortexProblems.length > 0)
|
|
216
|
+
vortex.problems = vortexProblems;
|
|
217
|
+
index.sources.push(vortex);
|
|
218
|
+
if (!mo2Covered) {
|
|
219
|
+
for (const root of flatRoots) {
|
|
220
|
+
const unclaimed = root.files.filter((f) => !isManagerBookkeeping(f) && !index.entries.has(keyOf(gamePath(install, path.join(root.path, f)))));
|
|
221
|
+
if (unclaimed.length > 0)
|
|
222
|
+
index.unattributedRoots.push({ rootId: root.rootId, path: root.path, files: unclaimed.length });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return index;
|
|
226
|
+
}
|
|
227
|
+
export function verdictFor(entry) {
|
|
228
|
+
const claims = entry.claims;
|
|
229
|
+
const mo2 = claims.filter((c) => c.evidence === "mo2" || c.evidence === "mo2-overwrite");
|
|
230
|
+
const vortex = claims.filter((c) => c.evidence === "vortex");
|
|
231
|
+
const local = claims.filter((c) => !MANAGER_EVIDENCE.has(c.evidence));
|
|
232
|
+
const inactive = claims.filter((c) => !c.enabled);
|
|
233
|
+
if (mo2.length > 0) {
|
|
234
|
+
const enabled = mo2.filter((c) => c.enabled).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
235
|
+
const winner = enabled[0];
|
|
236
|
+
const overridden = winner ? [...enabled.slice(1), ...local] : [];
|
|
237
|
+
const verdict = {
|
|
238
|
+
path: entry.path,
|
|
239
|
+
overridden,
|
|
240
|
+
inactive,
|
|
241
|
+
contested: enabled.length + local.length > 1,
|
|
242
|
+
basis: winner
|
|
243
|
+
? "MO2 priority: the highest enabled mod in the profile's modlist.txt wins (Overwrite above all), and every mod folder overrides the real game folder."
|
|
244
|
+
: "Only disabled MO2 mods provide this path; the game sees " +
|
|
245
|
+
(local.length > 0 ? "the real game folder's copy." : "no file here."),
|
|
246
|
+
};
|
|
247
|
+
if (winner)
|
|
248
|
+
verdict.winner = winner;
|
|
249
|
+
else if (local.length === 1)
|
|
250
|
+
verdict.winner = local[0];
|
|
251
|
+
return verdict;
|
|
252
|
+
}
|
|
253
|
+
if (vortex.length > 0) {
|
|
254
|
+
const verdict = {
|
|
255
|
+
path: entry.path,
|
|
256
|
+
winner: vortex[vortex.length - 1],
|
|
257
|
+
overridden: [],
|
|
258
|
+
inactive,
|
|
259
|
+
contested: new Set(vortex.map((c) => c.modId)).size > 1,
|
|
260
|
+
basis: "Vortex deployment manifest: this is the copy Vortex deployed. Mods that lost the conflict are not recorded there" +
|
|
261
|
+
(vortex.some((c) => c.merged?.length) ? "; the file was merged from the mods in `merged`." : "."),
|
|
262
|
+
};
|
|
263
|
+
return verdict;
|
|
264
|
+
}
|
|
265
|
+
const distinct = new Set(local.map((c) => c.modId));
|
|
266
|
+
if (distinct.size <= 1) {
|
|
267
|
+
const verdict = {
|
|
268
|
+
path: entry.path,
|
|
269
|
+
overridden: [],
|
|
270
|
+
inactive,
|
|
271
|
+
contested: false,
|
|
272
|
+
basis: local[0]?.evidence === "mod-file"
|
|
273
|
+
? "The file is itself the mod (a file-per-mod root)."
|
|
274
|
+
: local[0]?.evidence === "loose-unit"
|
|
275
|
+
? "The unpacked mod whose own folder in the loose-file root holds this path (e.g. BG3's Data/Mods/<Mod>); no manager deployed it."
|
|
276
|
+
: "The only mod whose folder holds this path.",
|
|
277
|
+
};
|
|
278
|
+
if (local[0])
|
|
279
|
+
verdict.winner = local[0];
|
|
280
|
+
return verdict;
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
path: entry.path,
|
|
284
|
+
overridden: [],
|
|
285
|
+
inactive,
|
|
286
|
+
contested: true,
|
|
287
|
+
basis: "Several mods provide this path and ModWright does not model which one the game reads here.",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function looseFallback(index, query, q) {
|
|
291
|
+
if (!index.looseUnitOf)
|
|
292
|
+
return undefined;
|
|
293
|
+
const absolute = path.isAbsolute(query) || /^[a-z]:\//i.test(q);
|
|
294
|
+
for (const root of index.looseRoots) {
|
|
295
|
+
const candidates = absolute ? [path.resolve(query)] : [path.resolve(index.install, q), path.resolve(root.path, q)];
|
|
296
|
+
for (const abs of candidates) {
|
|
297
|
+
const rel = path.relative(root.path, abs);
|
|
298
|
+
if (rel.startsWith("..") || path.isAbsolute(rel) || !existsSync(abs))
|
|
299
|
+
continue;
|
|
300
|
+
const unit = index.looseUnitOf(root.rootId, rel.split(path.sep).join("/"));
|
|
301
|
+
if (!unit)
|
|
302
|
+
continue;
|
|
303
|
+
const display = path.relative(index.install, abs).split(path.sep).join("/");
|
|
304
|
+
return verdictFor({ path: display, claims: [{ mod: unit, modId: `${root.rootId}:${unit}`, enabled: true, evidence: "loose-unit" }] });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
export function lookupOwnership(index, query, limit = 20) {
|
|
310
|
+
const q = query.trim().replace(/\\/g, "/");
|
|
311
|
+
const found = (matchedBy, keys) => {
|
|
312
|
+
const results = keys.slice(0, limit).map((k) => verdictFor(index.entries.get(k)));
|
|
313
|
+
const out = { query, matchedBy, results };
|
|
314
|
+
if (keys.length > limit)
|
|
315
|
+
out.omitted = keys.length - limit;
|
|
316
|
+
return out;
|
|
317
|
+
};
|
|
318
|
+
if (path.isAbsolute(query) || /^[a-z]:\//i.test(q)) {
|
|
319
|
+
const physicalKey = index.physical.get(keyOf(path.resolve(query)));
|
|
320
|
+
if (physicalKey)
|
|
321
|
+
return found("physical", [physicalKey]);
|
|
322
|
+
const rel = path.relative(index.install, path.resolve(query));
|
|
323
|
+
const key = keyOf(!rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : q);
|
|
324
|
+
if (index.entries.has(key))
|
|
325
|
+
return found("path", [key]);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const key = keyOf(q.replace(/^\.?\//, ""));
|
|
329
|
+
if (index.entries.has(key))
|
|
330
|
+
return found("path", [key]);
|
|
331
|
+
if (key.includes("/")) {
|
|
332
|
+
const suffix = `/${key}`;
|
|
333
|
+
const keys = [...index.entries.keys()].filter((k) => k.endsWith(suffix));
|
|
334
|
+
if (keys.length > 0)
|
|
335
|
+
return found("suffix", keys);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const keys = [...index.entries.keys()].filter((k) => k.slice(k.lastIndexOf("/") + 1) === key);
|
|
339
|
+
if (keys.length > 0)
|
|
340
|
+
return found("basename", keys);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const loose = looseFallback(index, query, q);
|
|
344
|
+
if (loose)
|
|
345
|
+
return { query, matchedBy: "path", results: [loose] };
|
|
346
|
+
return {
|
|
347
|
+
query,
|
|
348
|
+
matchedBy: "none",
|
|
349
|
+
results: [],
|
|
350
|
+
note: "No mod claims this path. It is a base-game file, was placed by hand into a loose-file folder with " +
|
|
351
|
+
"no manager (see unattributedRoots), sits inside a packed archive (not indexed yet), or the path is misspelled.",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
export function managerConflicts(index, limit = 100) {
|
|
355
|
+
const contested = [];
|
|
356
|
+
for (const entry of index.entries.values()) {
|
|
357
|
+
const enabledMo2 = entry.claims.filter((c) => (c.evidence === "mo2" || c.evidence === "mo2-overwrite") && c.enabled);
|
|
358
|
+
if (enabledMo2.length < 2)
|
|
359
|
+
continue;
|
|
360
|
+
contested.push(verdictFor(entry));
|
|
361
|
+
}
|
|
362
|
+
contested.sort((a, b) => b.overridden.length - a.overridden.length || a.path.localeCompare(b.path));
|
|
363
|
+
return { total: contested.length, conflicts: contested.slice(0, limit) };
|
|
364
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export { modProjectSchema, parseProject } from "./schema.js";
|
|
3
|
-
export { findProjectFile, loadProject } from "./load.js";
|
|
3
|
+
export { findProjectFile, loadProject, locateProjectFile } from "./load.js";
|
|
4
|
+
export { escapesRoot, outsideRootPaths } from "./outside-root.js";
|
|
4
5
|
export { describeProject } from "./describe.js";
|
|
5
6
|
export { defaultArtifactName, normalizeBuildSteps } from "./build-steps.js";
|
|
@@ -4,6 +4,7 @@ import { isDirectory, pathExists } from "../fsutil.js";
|
|
|
4
4
|
import { getSurface, surfaceIds } from "../../surfaces/index.js";
|
|
5
5
|
import { parseProject } from "./schema.js";
|
|
6
6
|
import { PROJECT_FILE } from "./types.js";
|
|
7
|
+
import { looksAbsolute, outsideRootPaths } from "./outside-root.js";
|
|
7
8
|
export async function findProjectFile(startDir) {
|
|
8
9
|
let dir = path.resolve(startDir);
|
|
9
10
|
for (;;) {
|
|
@@ -16,8 +17,10 @@ export async function findProjectFile(startDir) {
|
|
|
16
17
|
dir = parent;
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
|
-
function
|
|
20
|
-
|
|
20
|
+
export async function locateProjectFile(start) {
|
|
21
|
+
if (start.toLowerCase().endsWith(".json"))
|
|
22
|
+
return (await pathExists(start)) ? start : undefined;
|
|
23
|
+
return findProjectFile(start);
|
|
21
24
|
}
|
|
22
25
|
function isWithin(parent, child) {
|
|
23
26
|
const rel = path.relative(parent, child);
|
|
@@ -115,29 +118,8 @@ export async function loadProject(fileOrDir) {
|
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (escapes(target.into)) {
|
|
121
|
-
warnings.push(`deploy target "${target.from}": into "${target.into}" leaves the "${target.rootId}" root (absolute or "..")`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
for (const junction of project.deploy?.junctions ?? []) {
|
|
125
|
-
if (escapes(junction.linkPath)) {
|
|
126
|
-
warnings.push(`junction linkPath "${junction.linkPath}" leaves the "${junction.rootId}" root (absolute or "..")`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (const step of project.build?.steps ?? []) {
|
|
130
|
-
if (typeof step !== "object" || step === null)
|
|
131
|
-
continue;
|
|
132
|
-
const s = step;
|
|
133
|
-
if ((s.step === "wolvenkit-pack" || s.step === "divine-create-package") && escapes(s.out)) {
|
|
134
|
-
warnings.push(`build step ${s.step}: out "${s.out}" leaves build.outDir (absolute or "..")`);
|
|
135
|
-
}
|
|
136
|
-
if (s.step === "zip" && escapes(s.name))
|
|
137
|
-
warnings.push(`build step zip: name "${s.name}" leaves build.outDir (absolute or "..")`);
|
|
138
|
-
if (s.step === "exec" && s.cwd !== undefined && !s.cwd.includes("${stage}") && escapes(s.cwd)) {
|
|
139
|
-
warnings.push(`build step exec: cwd "${s.cwd}" leaves the project (absolute or "..")`);
|
|
140
|
-
}
|
|
121
|
+
for (const entry of outsideRootPaths(project)) {
|
|
122
|
+
warnings.push(entry.allowed ? `${entry.message} — allowed by allowOutsideRoot` : `${entry.message}; build and deploy refuse it unless the entry sets "allowOutsideRoot": true`);
|
|
141
123
|
}
|
|
142
124
|
const toolchain = project.toolchain
|
|
143
125
|
? Object.fromEntries(Object.entries(project.toolchain).map(([id, p]) => [id, looksAbsolute(p) ? p : resolveFromRoot(p)]))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
export function looksAbsolute(p) {
|
|
3
|
+
return path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
|
|
4
|
+
}
|
|
5
|
+
export function escapesRoot(p) {
|
|
6
|
+
return p !== undefined && (looksAbsolute(p) || p.split(/[\\/]/).includes(".."));
|
|
7
|
+
}
|
|
8
|
+
export function outsideRootPaths(project, steps = project.build?.steps) {
|
|
9
|
+
const found = [];
|
|
10
|
+
for (const target of project.deploy?.targets ?? []) {
|
|
11
|
+
if (escapesRoot(target.into)) {
|
|
12
|
+
found.push({ kind: "deploy-target", value: target.into, allowed: target.allowOutsideRoot === true, message: `deploy target "${target.from}": into "${target.into}" leaves the "${target.rootId}" root (absolute or "..")` });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
for (const junction of project.deploy?.junctions ?? []) {
|
|
16
|
+
if (escapesRoot(junction.linkPath)) {
|
|
17
|
+
found.push({ kind: "junction", value: junction.linkPath, allowed: junction.allowOutsideRoot === true, message: `junction linkPath "${junction.linkPath}" leaves the "${junction.rootId}" root (absolute or "..")` });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (const step of steps ?? []) {
|
|
21
|
+
if (typeof step !== "object" || step === null)
|
|
22
|
+
continue;
|
|
23
|
+
const s = step;
|
|
24
|
+
const label = `build step ${s.id ? `"${s.id}" (${s.step})` : s.step}`;
|
|
25
|
+
const allowed = s.allowOutsideRoot === true;
|
|
26
|
+
if ((s.step === "wolvenkit-pack" || s.step === "divine-create-package") && escapesRoot(s.out)) {
|
|
27
|
+
found.push({ kind: "build-step", value: s.out, allowed, message: `${label}: out "${s.out}" leaves build.outDir (absolute or "..")` });
|
|
28
|
+
}
|
|
29
|
+
if (s.step === "zip" && escapesRoot(s.name))
|
|
30
|
+
found.push({ kind: "build-step", value: s.name, allowed, message: `${label}: name "${s.name}" leaves build.outDir (absolute or "..")` });
|
|
31
|
+
if (s.step === "exec" && s.cwd !== undefined && !s.cwd.includes("${stage}") && escapesRoot(s.cwd)) {
|
|
32
|
+
found.push({ kind: "build-step", value: s.cwd, allowed, message: `${label}: cwd "${s.cwd}" leaves the project (absolute or "..")` });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
@@ -68,6 +68,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
68
68
|
.object({
|
|
69
69
|
...stepBase,
|
|
70
70
|
step: z.literal("zip"),
|
|
71
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
71
72
|
from: z.string(),
|
|
72
73
|
name: z.string(),
|
|
73
74
|
})
|
|
@@ -94,6 +95,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
94
95
|
.object({
|
|
95
96
|
...stepBase,
|
|
96
97
|
step: z.literal("wolvenkit-pack"),
|
|
98
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
97
99
|
from: z.string(),
|
|
98
100
|
out: z.string(),
|
|
99
101
|
verbosity: z.enum(["Minimal", "Normal", "Diagnostic"]).default("Minimal"),
|
|
@@ -120,6 +122,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
120
122
|
.object({
|
|
121
123
|
...stepBase,
|
|
122
124
|
step: z.literal("divine-create-package"),
|
|
125
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
123
126
|
from: z.string(),
|
|
124
127
|
out: z.string(),
|
|
125
128
|
})
|
|
@@ -137,6 +140,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
137
140
|
.object({
|
|
138
141
|
...stepBase,
|
|
139
142
|
step: z.literal("exec"),
|
|
143
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
140
144
|
command: z.string(),
|
|
141
145
|
args: z.array(z.string()).default([]),
|
|
142
146
|
cwd: z.string().optional(),
|
|
@@ -166,6 +170,7 @@ const deployTargetSchema = z
|
|
|
166
170
|
.object({
|
|
167
171
|
rootId: z.string().min(1),
|
|
168
172
|
from: z.string().min(1),
|
|
173
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
169
174
|
into: z.string().optional(),
|
|
170
175
|
waitForGameExit: z.boolean().optional(),
|
|
171
176
|
backup: z.boolean().optional(),
|
|
@@ -179,6 +184,7 @@ const deployTargetSchema = z
|
|
|
179
184
|
const junctionSchema = z
|
|
180
185
|
.object({
|
|
181
186
|
linkPath: z.string().min(1),
|
|
187
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
182
188
|
rootId: z.string().min(1),
|
|
183
189
|
target: z.string().min(1),
|
|
184
190
|
})
|
|
@@ -240,6 +246,37 @@ export const modProjectSchema = z
|
|
|
240
246
|
testPlans: z.array(z.string().min(1)).optional(),
|
|
241
247
|
})
|
|
242
248
|
.strict();
|
|
249
|
+
function checkVariantGates(doc, ctx) {
|
|
250
|
+
const build = doc.build;
|
|
251
|
+
if (!build?.steps)
|
|
252
|
+
return;
|
|
253
|
+
const known = Object.keys(build.variants ?? {});
|
|
254
|
+
const knownText = known.length > 0 ? `declared build.variants: ${known.join(", ")}` : "build.variants declares none";
|
|
255
|
+
build.steps.forEach((step, i) => {
|
|
256
|
+
if (typeof step === "string")
|
|
257
|
+
return;
|
|
258
|
+
const label = step.id ? `"${step.id}"` : `#${i} (${step.step})`;
|
|
259
|
+
if (step.variant !== undefined && !known.includes(step.variant)) {
|
|
260
|
+
ctx.addIssue({
|
|
261
|
+
code: z.ZodIssueCode.custom,
|
|
262
|
+
path: ["build", "steps", i, "variant"],
|
|
263
|
+
message: `step ${label} is gated on variant "${step.variant}", which no build.variants entry declares (${knownText}); it could never run`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (step.step !== "stage")
|
|
267
|
+
return;
|
|
268
|
+
step.sources.forEach((entry, j) => {
|
|
269
|
+
if (typeof entry === "string" || entry.variant === undefined || known.includes(entry.variant))
|
|
270
|
+
return;
|
|
271
|
+
ctx.addIssue({
|
|
272
|
+
code: z.ZodIssueCode.custom,
|
|
273
|
+
path: ["build", "steps", i, "sources", j, "variant"],
|
|
274
|
+
message: `stage step ${label} source entry "${entry.source ?? entry.output}" is gated on variant "${entry.variant}", which no build.variants entry declares (${knownText}); it could never be staged`,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const modProjectSchemaChecked = modProjectSchema.superRefine((doc, ctx) => checkVariantGates(doc, ctx));
|
|
243
280
|
function formatPath(segments) {
|
|
244
281
|
if (segments.length === 0)
|
|
245
282
|
return "(root)";
|
|
@@ -255,7 +292,7 @@ function formatPath(segments) {
|
|
|
255
292
|
return out;
|
|
256
293
|
}
|
|
257
294
|
export function parseProject(json) {
|
|
258
|
-
const result =
|
|
295
|
+
const result = modProjectSchemaChecked.safeParse(json);
|
|
259
296
|
if (!result.success) {
|
|
260
297
|
const lines = result.error.issues.map((issue) => `${formatPath(issue.path)}: ${issue.message}`);
|
|
261
298
|
throw new Error(`Invalid modwright.json:\n${lines.join("\n")}`);
|
|
@@ -113,7 +113,7 @@ export class ManifestWriter {
|
|
|
113
113
|
async addFile(originalAbsPath) {
|
|
114
114
|
const dest = backupDestFor(this.backupDir, originalAbsPath);
|
|
115
115
|
await copyFilePreservingMtime(originalAbsPath, dest);
|
|
116
|
-
const { sha256, sizeBytes } = await sha256File(
|
|
116
|
+
const { sha256, sizeBytes } = await sha256File(dest);
|
|
117
117
|
const backupPath = path.relative(this.backupDir, dest).split(path.sep).join("/");
|
|
118
118
|
this.entries.push({ originalPath: originalAbsPath, backupPath, sizeBytes, sha256 });
|
|
119
119
|
}
|
|
@@ -159,7 +159,7 @@ export class ManifestWriter {
|
|
|
159
159
|
}
|
|
160
160
|
async flush(now) {
|
|
161
161
|
const manifest = { purpose: this.purpose, time: now.toISOString(), entries: this.entries };
|
|
162
|
-
await
|
|
162
|
+
await writeFileAtomic(path.join(this.backupDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2));
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
export async function readManifest(backupDir) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { pathExists } from "../fsutil.js";
|
|
4
3
|
import { userDataDir } from "../home.js";
|
|
5
4
|
import { normalizePlan } from "./plan.js";
|
|
6
5
|
import { isLockError } from "./restore.js";
|
|
@@ -41,7 +40,7 @@ async function applyOp(nop, ensureBackup) {
|
|
|
41
40
|
if (nop.skipEligible)
|
|
42
41
|
return { op, status: "skipped" };
|
|
43
42
|
let backedUpTo;
|
|
44
|
-
if (await
|
|
43
|
+
if (await lstatExists(op.to)) {
|
|
45
44
|
backedUpTo = await (await ensureBackup()).addPath(op.to);
|
|
46
45
|
}
|
|
47
46
|
await copyTreePreservingMtime(op.from, op.to);
|
|
@@ -157,8 +156,14 @@ export async function executePlan(plan, options) {
|
|
|
157
156
|
results.push({ op, status: "failed", error: errorMessage(err) });
|
|
158
157
|
}
|
|
159
158
|
}
|
|
159
|
+
let manifestError;
|
|
160
160
|
if (manifestWriter?.hasEntries) {
|
|
161
|
-
|
|
161
|
+
try {
|
|
162
|
+
await manifestWriter.flush(now());
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
manifestError = errorMessage(err);
|
|
166
|
+
}
|
|
162
167
|
}
|
|
163
168
|
else if (backupDir) {
|
|
164
169
|
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => { });
|
|
@@ -183,6 +188,9 @@ export async function executePlan(plan, options) {
|
|
|
183
188
|
operations: results,
|
|
184
189
|
backupDir,
|
|
185
190
|
confidence: options.confidence,
|
|
186
|
-
next
|
|
191
|
+
next: manifestError
|
|
192
|
+
? `The operations above ran, but the backup's manifest could not be written (${manifestError}). The replaced files are under ${backupDir}; rollback cannot restore them without a manifest.${next ? ` ${next}` : ""}`
|
|
193
|
+
: next,
|
|
194
|
+
...(manifestError ? { manifestError } : {}),
|
|
187
195
|
};
|
|
188
196
|
}
|