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
@@ -20,18 +20,6 @@ export function idPrefixFor(game) {
20
20
  }
21
21
  const ID_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)+$/;
22
22
  const TOPIC_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)*$/;
23
- const KNOWN_FACT_KEYS = [
24
- "id",
25
- "claim",
26
- "status",
27
- "verified_on",
28
- "verified_by",
29
- "source",
30
- "tags",
31
- "detail",
32
- "supersedes",
33
- "related",
34
- ];
35
23
  const factStatusSchema = z.enum(FACT_STATUSES);
36
24
  const optionalString = () => z
37
25
  .string()
@@ -5,16 +5,33 @@ const NONE = { skipped: true, applied: [], refused: [] };
5
5
  function toPosix(p) {
6
6
  return p.split(path.sep).join("/");
7
7
  }
8
+ function relPosix(projectRoot, f) {
9
+ return toPosix(path.isAbsolute(f) ? path.relative(projectRoot, f) : f);
10
+ }
8
11
  function claimFileSet(claim, projectRoot) {
9
12
  const files = claim.scope?.files ?? [];
10
- return new Set(files.map((f) => toPosix(path.isAbsolute(f) ? path.relative(projectRoot, f) : f)));
13
+ return new Set(files.map((f) => relPosix(projectRoot, f)));
14
+ }
15
+ function sameFile(a, b) {
16
+ if (a === b)
17
+ return true;
18
+ const lower = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
19
+ const x = lower(a);
20
+ const y = lower(b);
21
+ return x.endsWith(`/${y}`) || y.endsWith(`/${x}`);
22
+ }
23
+ function anyMatch(files, f) {
24
+ for (const other of files)
25
+ if (sameFile(other, f))
26
+ return true;
27
+ return false;
11
28
  }
12
29
  export async function applyValidateLedgerHook(projectRoot, blockingFindingFiles, ranFiles, tool = "validate", options = {}) {
13
30
  const loaded = await loadClaims(projectRoot);
14
31
  if (!loaded.exists)
15
32
  return NONE;
16
- const blocked = new Set(blockingFindingFiles.map(toPosix));
17
- const ran = ranFiles ? new Set(ranFiles.map(toPosix)) : undefined;
33
+ const blocked = blockingFindingFiles.map((f) => relPosix(projectRoot, f));
34
+ const ran = ranFiles ? ranFiles.map((f) => relPosix(projectRoot, f)) : undefined;
18
35
  const by = options.by ?? "validate";
19
36
  let claims = loaded.claims;
20
37
  const applied = [];
@@ -25,10 +42,10 @@ export async function applyValidateLedgerHook(projectRoot, blockingFindingFiles,
25
42
  const files = claimFileSet(claim, projectRoot);
26
43
  if (files.size === 0)
27
44
  continue;
28
- const allRan = !ran || [...files].every((f) => ran.has(f));
45
+ const allRan = !ran || [...files].every((f) => anyMatch(ran, f));
29
46
  if (!allRan)
30
47
  continue;
31
- const anyBlocked = [...files].some((f) => blocked.has(f));
48
+ const anyBlocked = [...files].some((f) => anyMatch(blocked, f));
32
49
  if (anyBlocked)
33
50
  continue;
34
51
  const result = applyClaimTransition({ claims, id: claim.id, to: "checker-clean", tool, by, now: options.now });
@@ -21,6 +21,25 @@ export class SignatureRegistry {
21
21
  }
22
22
  }
23
23
  }
24
+ if (sig.attribution) {
25
+ const spec = sig.attribution;
26
+ const groups = namedGroupsOf(sig.pattern);
27
+ for (const field of ["subject", "subjectFallback", "actor", "via", "detail"]) {
28
+ const name = spec[field];
29
+ if (name !== undefined && !groups.has(name)) {
30
+ throw new Error(`Signature "${sig.id}" attribution ${field} "${name}" has no named group "(?<${name}>...)" in its pattern`);
31
+ }
32
+ }
33
+ if (spec.setsContext && spec.subject === undefined && spec.actor === undefined) {
34
+ throw new Error(`Signature "${sig.id}" sets attribution context but captures neither subject nor actor`);
35
+ }
36
+ if (spec.setsContext && spec.alsoFinding) {
37
+ throw new Error(`Signature "${sig.id}" cannot both set attribution context and report a finding`);
38
+ }
39
+ if (spec.subject !== undefined && spec.subjectLabel !== undefined) {
40
+ throw new Error(`Signature "${sig.id}" attribution names both a subject group and a subjectLabel`);
41
+ }
42
+ }
24
43
  seenInBatch.add(sig.id);
25
44
  }
26
45
  for (const sig of signatures) {
@@ -37,14 +37,14 @@ function parseScanKey(key) {
37
37
  }
38
38
  export async function scanSource(source, signatures, scope, ctx) {
39
39
  if (!source.exists) {
40
- return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [] };
40
+ return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [], attributions: [] };
41
41
  }
42
42
  let stat;
43
43
  try {
44
44
  stat = await fs.stat(source.path);
45
45
  }
46
46
  catch {
47
- return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [] };
47
+ return { status: { source, linesScanned: 0, findingCount: 0 }, findings: [], attributions: [] };
48
48
  }
49
49
  const sizeBytes = stat.size;
50
50
  const modifiedAt = stat.mtime;
@@ -63,7 +63,7 @@ export async function scanSource(source, signatures, scope, ctx) {
63
63
  };
64
64
  if (stale !== undefined)
65
65
  status.stale = stale;
66
- return { status, findings: [] };
66
+ return { status, findings: [], attributions: [] };
67
67
  }
68
68
  const birthtimeMs = Math.round(stat.birthtimeMs);
69
69
  const mtimeMs = Math.round(stat.mtimeMs);
@@ -102,14 +102,30 @@ export async function scanSource(source, signatures, scope, ctx) {
102
102
  let text = "";
103
103
  const length = sizeBytes - startOffset;
104
104
  if (length > 0) {
105
- const handle = await fs.open(source.path, "r");
106
105
  try {
107
- const buffer = Buffer.alloc(length);
108
- await handle.read(buffer, 0, length, startOffset);
109
- text = buffer.toString("utf8");
106
+ const handle = await fs.open(source.path, "r");
107
+ try {
108
+ const buffer = Buffer.alloc(length);
109
+ await handle.read(buffer, 0, length, startOffset);
110
+ text = buffer.toString("utf8");
111
+ }
112
+ finally {
113
+ await handle.close();
114
+ }
110
115
  }
111
- finally {
112
- await handle.close();
116
+ catch (err) {
117
+ const status = {
118
+ source,
119
+ sizeBytes,
120
+ modifiedAt: modifiedAt.toISOString(),
121
+ zeroByte,
122
+ linesScanned: 0,
123
+ findingCount: 0,
124
+ unreadable: err.message,
125
+ };
126
+ if (stale !== undefined)
127
+ status.stale = stale;
128
+ return { status, findings: [], attributions: [] };
113
129
  }
114
130
  }
115
131
  if (startOffset === 0 && text.charCodeAt(0) === 0xfeff)
@@ -117,14 +133,18 @@ export async function scanSource(source, signatures, scope, ctx) {
117
133
  let lines = splitLines(text);
118
134
  if (clipped && lines.length > 0)
119
135
  lines.shift();
136
+ let headTrimmed = clipped;
120
137
  if ((!hadOffset || rotated) && lines.length > maxLines) {
121
138
  lines = lines.slice(lines.length - maxLines);
139
+ headTrimmed = true;
122
140
  }
123
141
  const sinceDate = scope.since ? new Date(scope.since) : undefined;
124
142
  let anyTimestampSeen = false;
125
143
  let lastTimestamp;
126
144
  const findingsBySignature = new Map();
127
145
  const findings = [];
146
+ const attributions = [];
147
+ const context = {};
128
148
  for (let i = 0; i < lines.length; i++) {
129
149
  const line = lines[i];
130
150
  if (sinceDate) {
@@ -141,6 +161,39 @@ export async function scanSource(source, signatures, scope, ctx) {
141
161
  const match = sig.pattern.exec(line);
142
162
  if (!match)
143
163
  continue;
164
+ const spec = sig.attribution;
165
+ if (spec) {
166
+ const group = (name) => name === undefined ? undefined : match.groups?.[name];
167
+ if (spec.setsContext) {
168
+ const subject = group(spec.subject);
169
+ const actor = group(spec.actor);
170
+ if (subject !== undefined)
171
+ context.subject = subject;
172
+ if (actor !== undefined)
173
+ context.actor = actor;
174
+ continue;
175
+ }
176
+ const subject = group(spec.subject) ?? group(spec.subjectFallback) ?? spec.subjectLabel ?? context.subject;
177
+ if (subject !== undefined) {
178
+ const record = {
179
+ sourceId: source.id,
180
+ signatureId: sig.id,
181
+ kind: spec.kind,
182
+ subject,
183
+ actor: group(spec.actor) ?? context.actor ?? "?",
184
+ lineNumber: i + 1,
185
+ };
186
+ const via = group(spec.via);
187
+ if (via !== undefined)
188
+ record.via = via;
189
+ const detail = group(spec.detail);
190
+ if (detail !== undefined)
191
+ record.detail = detail;
192
+ attributions.push(record);
193
+ }
194
+ if (!spec.alsoFinding)
195
+ continue;
196
+ }
144
197
  const existing = findingsBySignature.get(sig.id);
145
198
  if (existing) {
146
199
  existing.repeats = (existing.repeats ?? 0) + 1;
@@ -191,5 +244,7 @@ export async function scanSource(source, signatures, scope, ctx) {
191
244
  status.rewritten = true;
192
245
  if (sinceDate && !anyTimestampSeen)
193
246
  status.timestamps = false;
194
- return { status, findings };
247
+ if (headTrimmed)
248
+ status.headTrimmed = true;
249
+ return { status, findings, attributions };
195
250
  }
@@ -3,7 +3,9 @@ import { findRunningProcesses } from "../process.js";
3
3
  import { resolveLogs } from "../surfaceutil.js";
4
4
  import { scanSource } from "./scan.js";
5
5
  const FINDINGS_CAP = 200;
6
- export async function triageLogs(surface, install, registry, scope = {}) {
6
+ const ATTRIBUTION_SUBJECT_CAP = 150;
7
+ const ATTRIBUTION_ACTOR_CAP = 100;
8
+ export async function triageLogs(surface, install, registry, scope = {}, options = {}) {
7
9
  const sources = await resolveLogs(surface, install);
8
10
  const processResult = await findRunningProcesses(surface.processNames ?? []);
9
11
  const gameRunning = processResult.checked ? processResult.running.length > 0 : undefined;
@@ -34,15 +36,17 @@ export async function triageLogs(surface, install, registry, scope = {}) {
34
36
  }
35
37
  const statuses = [];
36
38
  const allFindings = [];
39
+ const allAttributions = [];
37
40
  const offsets = {};
38
41
  const offsetKeys = {};
39
42
  for (const source of sources) {
40
43
  const signatures = registry.forSource(surface.id, source.id);
41
- const { status, findings } = await scanSource(source, signatures, scope, { lastLaunch });
44
+ const { status, findings, attributions } = await scanSource(source, signatures, scope, { lastLaunch });
42
45
  if (!source.exists && missingReasons[source.id])
43
46
  status.missingReason = missingReasons[source.id];
44
47
  statuses.push(status);
45
48
  allFindings.push(...findings);
49
+ allAttributions.push(...attributions);
46
50
  if (status.endOffset !== undefined)
47
51
  offsets[source.id] = status.endOffset;
48
52
  if (status.key !== undefined)
@@ -104,8 +108,81 @@ export async function triageLogs(surface, install, registry, scope = {}) {
104
108
  verdict.truncated = true;
105
109
  if (Object.keys(systemNotes).length > 0)
106
110
  verdict.systemNotes = systemNotes;
111
+ if (allAttributions.length > 0) {
112
+ verdict.attribution = buildAttributionReport(allAttributions, statuses, options.attributionFor);
113
+ }
107
114
  return verdict;
108
115
  }
116
+ export function buildAttributionReport(records, statuses, filter) {
117
+ const needle = filter?.trim().toLowerCase();
118
+ const kept = needle
119
+ ? records.filter((r) => [r.subject, r.actor, r.via].some((v) => v !== undefined && v.toLowerCase().includes(needle)))
120
+ : records;
121
+ const bySubject = new Map();
122
+ for (const record of kept) {
123
+ let entry = bySubject.get(record.subject);
124
+ if (!entry) {
125
+ entry = { subject: record.subject, kinds: [], actors: [], order: bySubject.size };
126
+ bySubject.set(record.subject, entry);
127
+ }
128
+ if (!entry.kinds.includes(record.kind))
129
+ entry.kinds.push(record.kind);
130
+ const same = entry.actors.find((a) => a.actor === record.actor && a.via === record.via && a.kind === record.kind && a.detail === record.detail);
131
+ if (same) {
132
+ same.count++;
133
+ continue;
134
+ }
135
+ const actor = {
136
+ actor: record.actor,
137
+ kind: record.kind,
138
+ sourceId: record.sourceId,
139
+ firstLine: record.lineNumber,
140
+ count: 1,
141
+ };
142
+ if (record.via !== undefined)
143
+ actor.via = record.via;
144
+ if (record.detail !== undefined)
145
+ actor.detail = record.detail;
146
+ entry.actors.push(actor);
147
+ }
148
+ const distinctActors = (subject) => new Set(subject.actors.map((a) => `${a.actor}\u0000${a.via ?? ""}`)).size;
149
+ const isContested = (subject) => !subject.kinds.every((k) => k === "read-order") && distinctActors(subject) > 1;
150
+ const all = [...bySubject.values()];
151
+ const contested = all.filter(isContested).length;
152
+ all.sort((a, b) => Number(isContested(b)) - Number(isContested(a)) || a.order - b.order);
153
+ const subjects = all.slice(0, ATTRIBUTION_SUBJECT_CAP).map(({ order: _order, ...subject }) => {
154
+ if (subject.actors.length <= ATTRIBUTION_ACTOR_CAP)
155
+ return subject;
156
+ return {
157
+ ...subject,
158
+ actors: subject.actors.slice(0, ATTRIBUTION_ACTOR_CAP),
159
+ omittedActors: subject.actors.length - ATTRIBUTION_ACTOR_CAP,
160
+ };
161
+ });
162
+ const report = {
163
+ records: records.length,
164
+ contested,
165
+ subjects,
166
+ note: "Straight from the logs: each actor is who the log line itself names, listed in the order " +
167
+ "the log shows them acting, so for edits and reads the later actor applied last. Only what " +
168
+ "the frameworks chose to log is here; a mod that changes something without the loader " +
169
+ "logging it will not appear.",
170
+ };
171
+ if (needle)
172
+ report.filter = filter.trim();
173
+ if (all.length > ATTRIBUTION_SUBJECT_CAP)
174
+ report.omittedSubjects = all.length - ATTRIBUTION_SUBJECT_CAP;
175
+ const partial = statuses
176
+ .filter((st) => st.headTrimmed && records.some((r) => r.sourceId === st.source.id))
177
+ .map((st) => st.source.id);
178
+ if (partial.length > 0) {
179
+ report.partialSources = partial;
180
+ report.note +=
181
+ ` The start of ${partial.join(", ")} was not read (tail cap), so earlier attribution lines ` +
182
+ "are missing; pass a larger maxLines (up to 20000) for the whole run.";
183
+ }
184
+ return report;
185
+ }
109
186
  function buildHeadline(sources, statuses, findings, systems) {
110
187
  if (sources.length === 0) {
111
188
  return "No log sources are declared for this game; triage has nothing to read.";
@@ -0,0 +1,121 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { localAppData, pathExists } from "../fsutil.js";
4
+ import { parseIni } from "../ini.js";
5
+ function iniPath(value) {
6
+ return value.replace(/\\\\/g, "\\");
7
+ }
8
+ function samePath(a, b) {
9
+ const norm = (p) => path.resolve(iniPath(p)).replace(/[\\/]+$/, "").toLowerCase();
10
+ return norm(a) === norm(b);
11
+ }
12
+ export async function readMo2Instance(iniFile, portable) {
13
+ let ini;
14
+ try {
15
+ ini = parseIni(await fs.readFile(iniFile, "utf8"));
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ const instanceDir = path.dirname(iniFile);
21
+ const general = ini.get("general");
22
+ const settings = ini.get("settings");
23
+ const base = iniPath(settings?.get("base_directory") ?? instanceDir);
24
+ const configured = (key, fallback) => path.resolve(instanceDir, iniPath(settings?.get(key) ?? `%BASE_DIR%/${fallback}`).replace(/%BASE_DIR%/gi, base));
25
+ const profileName = general?.get("selected_profile") ?? "Default";
26
+ const profilesDir = configured("profiles_directory", "profiles");
27
+ const gamePath = general?.get("gamepath");
28
+ const instance = {
29
+ instanceDir,
30
+ iniPath: iniFile,
31
+ portable,
32
+ profileName,
33
+ profileDir: path.join(profilesDir, profileName),
34
+ modsDir: configured("mod_directory", "mods"),
35
+ overwriteDir: configured("overwrite_directory", "overwrite"),
36
+ downloadsDir: configured("download_directory", "downloads"),
37
+ };
38
+ if (gamePath)
39
+ instance.gamePath = iniPath(gamePath);
40
+ return instance;
41
+ }
42
+ export async function findMo2Instance(install, options = {}) {
43
+ if (options.instancePath) {
44
+ const iniFile = options.instancePath.toLowerCase().endsWith(".ini")
45
+ ? options.instancePath
46
+ : path.join(options.instancePath, "ModOrganizer.ini");
47
+ if (await pathExists(iniFile))
48
+ return readMo2Instance(iniFile, true);
49
+ return undefined;
50
+ }
51
+ const root = options.instancesRoot ?? localAppData("ModOrganizer");
52
+ let names;
53
+ try {
54
+ names = await fs.readdir(root);
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ for (const name of names) {
60
+ const iniFile = path.join(root, name, "ModOrganizer.ini");
61
+ if (!(await pathExists(iniFile)))
62
+ continue;
63
+ const instance = await readMo2Instance(iniFile, false);
64
+ if (instance?.gamePath && samePath(instance.gamePath, install.root))
65
+ return instance;
66
+ }
67
+ return undefined;
68
+ }
69
+ export function parseMo2Modlist(text) {
70
+ const lines = [];
71
+ const seen = new Set();
72
+ for (const raw of text.split(/\r?\n/)) {
73
+ const line = raw.replace(/^/, "");
74
+ if (!line.trim() || line.startsWith("#"))
75
+ continue;
76
+ const marker = line[0];
77
+ const hasMarker = marker === "+" || marker === "-" || marker === "*";
78
+ const name = (hasMarker ? line.slice(1) : line).trim();
79
+ if (!name || seen.has(name))
80
+ continue;
81
+ seen.add(name);
82
+ lines.push({ name, enabled: marker !== "-", foreign: marker === "*" });
83
+ }
84
+ return lines.map((entry, index) => ({
85
+ ...entry,
86
+ separator: entry.name.endsWith("_separator"),
87
+ priority: lines.length - index,
88
+ }));
89
+ }
90
+ export async function readMo2Modlist(profileDir) {
91
+ try {
92
+ return parseMo2Modlist(await fs.readFile(path.join(profileDir, "modlist.txt"), "utf8"));
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ export async function readMo2ModMeta(modDir) {
99
+ let ini;
100
+ try {
101
+ ini = parseIni(await fs.readFile(path.join(modDir, "meta.ini"), "utf8"));
102
+ }
103
+ catch {
104
+ return {};
105
+ }
106
+ const general = ini.get("general");
107
+ const meta = {};
108
+ const modId = Number(general?.get("modid"));
109
+ if (Number.isInteger(modId) && modId > 0)
110
+ meta.nexusModId = modId;
111
+ const version = general?.get("version");
112
+ if (version)
113
+ meta.version = version;
114
+ const installationFile = general?.get("installationfile");
115
+ if (installationFile)
116
+ meta.installationFile = installationFile;
117
+ const gameName = general?.get("gamename");
118
+ if (gameName)
119
+ meta.gameName = gameName;
120
+ return meta;
121
+ }
@@ -0,0 +1,52 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ const MANIFEST = /^vortex\.deployment\.(?:(?<type>[^.]+)\.)?json$/i;
4
+ export async function readVortexManifests(dir) {
5
+ const manifests = [];
6
+ const unreadable = [];
7
+ let names;
8
+ try {
9
+ names = await fs.readdir(dir);
10
+ }
11
+ catch {
12
+ return { manifests, unreadable };
13
+ }
14
+ for (const name of names.sort()) {
15
+ const match = MANIFEST.exec(name);
16
+ if (!match)
17
+ continue;
18
+ const file = path.join(dir, name);
19
+ try {
20
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
21
+ if (!Array.isArray(raw.files))
22
+ throw new Error("no files array");
23
+ const files = [];
24
+ for (const entry of raw.files) {
25
+ if (typeof entry?.relPath !== "string" || typeof entry.source !== "string")
26
+ continue;
27
+ const deployed = { relPath: entry.relPath, source: entry.source };
28
+ if (Array.isArray(entry.merged))
29
+ deployed.merged = entry.merged.filter((m) => typeof m === "string");
30
+ if (typeof entry.target === "string" && entry.target)
31
+ deployed.target = entry.target;
32
+ if (typeof entry.time === "number")
33
+ deployed.time = entry.time;
34
+ files.push(deployed);
35
+ }
36
+ const manifest = { file, deployDir: dir, modType: match.groups?.type ?? "", files };
37
+ if (typeof raw.gameId === "string")
38
+ manifest.gameId = raw.gameId;
39
+ if (typeof raw.deploymentMethod === "string")
40
+ manifest.deploymentMethod = raw.deploymentMethod;
41
+ if (typeof raw.deploymentTime === "number")
42
+ manifest.deploymentTime = raw.deploymentTime;
43
+ if (typeof raw.stagingPath === "string")
44
+ manifest.stagingPath = raw.stagingPath;
45
+ manifests.push(manifest);
46
+ }
47
+ catch (error) {
48
+ unreadable.push({ file, error: error.message });
49
+ }
50
+ }
51
+ return { manifests, unreadable };
52
+ }