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.
Files changed (65) hide show
  1. package/README.md +123 -15
  2. package/bridges/bg3-se/README.md +15 -0
  3. package/bridges/cp2077-cet/ModWrightBridge/probes.lua +113 -0
  4. package/bridges/cp2077-cet/README.md +17 -1
  5. package/bridges/cp2077-cet/bridge.json +1 -1
  6. package/dist/core/bridge/pending.js +8 -1
  7. package/dist/core/bridge/stage.js +2 -2
  8. package/dist/core/build/run.js +14 -0
  9. package/dist/core/build/stage.js +3 -2
  10. package/dist/core/build/steps/core.js +4 -4
  11. package/dist/core/deploy/plan.js +68 -1
  12. package/dist/core/deploy/reload.js +5 -3
  13. package/dist/core/ini.js +26 -0
  14. package/dist/core/knowledge/facts.js +0 -12
  15. package/dist/core/ledger/hooks.js +22 -5
  16. package/dist/core/logs/registry.js +19 -0
  17. package/dist/core/logs/scan.js +65 -10
  18. package/dist/core/logs/triage.js +79 -2
  19. package/dist/core/managers/mo2.js +121 -0
  20. package/dist/core/managers/vortex.js +52 -0
  21. package/dist/core/ownership/index.js +364 -0
  22. package/dist/core/project/index.js +2 -1
  23. package/dist/core/project/load.js +7 -25
  24. package/dist/core/project/outside-root.js +36 -0
  25. package/dist/core/project/schema.js +38 -1
  26. package/dist/core/safety/backup.js +2 -2
  27. package/dist/core/safety/execute.js +12 -4
  28. package/dist/core/safety/restore.js +62 -0
  29. package/dist/core/testplan/registry.js +51 -2
  30. package/dist/core/text/converters/index.js +1 -1
  31. package/dist/core/text/toolchain.js +2 -2
  32. package/dist/core/toolchain/locate.js +13 -1
  33. package/dist/core/toolchain/run.js +6 -4
  34. package/dist/core/toolchain/types.js +1 -1
  35. package/dist/core/trust.js +105 -0
  36. package/dist/core/writes/index.js +206 -0
  37. package/dist/index.js +33 -9
  38. package/dist/server.js +252 -34
  39. package/dist/surfaces/baldursgate3/surface.js +3 -0
  40. package/dist/surfaces/baldursgate3/validators/story.js +3 -3
  41. package/dist/surfaces/baldursgate3/writes.js +152 -0
  42. package/dist/surfaces/cyberpunk2077/compat.js +8 -4
  43. package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +2 -2
  44. package/dist/surfaces/cyberpunk2077/logs.js +86 -6
  45. package/dist/surfaces/cyberpunk2077/surface.js +13 -1
  46. package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
  47. package/dist/surfaces/cyberpunk2077/writes.js +67 -0
  48. package/dist/surfaces/eldenring/loaders.js +7 -1
  49. package/dist/surfaces/skyrimse/loadorder.js +2 -26
  50. package/dist/surfaces/skyrimse/surface.js +1 -0
  51. package/dist/surfaces/stardewvalley/logs.js +41 -0
  52. package/dist/surfaces/stardewvalley/surface.js +2 -0
  53. package/dist/surfaces/stardewvalley/writes.js +136 -0
  54. package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +29 -0
  55. package/knowledge/baldursgate3/osiris.tags.yaml +78 -0
  56. package/knowledge/cyberpunk2077/cet.rtti-binding.yaml +227 -0
  57. package/knowledge/cyberpunk2077/cet.sandbox.yaml +22 -0
  58. package/knowledge/cyberpunk2077/codeware.overview.yaml +63 -16
  59. package/knowledge/cyberpunk2077/codeware.systems.yaml +152 -33
  60. package/knowledge/cyberpunk2077/entities.queries.yaml +275 -0
  61. package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +30 -4
  62. package/knowledge/cyberpunk2077/rtti.game-systems.yaml +224 -0
  63. package/package.json +1 -1
  64. package/scaffolds/baldursgate3/new-project.yaml +4 -1
  65. 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 looksAbsolute(p) {
20
- return path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
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
- const escapes = (p) => p !== undefined && (looksAbsolute(p) || p.split(/[\\/]/).includes(".."));
119
- for (const target of project.deploy?.targets ?? []) {
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 = modProjectSchema.safeParse(json);
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(originalAbsPath);
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 fs.writeFile(path.join(this.backupDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), "utf8");
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 pathExists(op.to)) {
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
- await manifestWriter.flush(now());
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
  }