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
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
const SERVER_ENTRY = process.env.MODWRIGHT_SERVER_ENTRY ?? path.join(here, "server.js");
|
|
9
9
|
const STAMP_FILE = process.env.MODWRIGHT_BUILD_STAMP ?? path.join(here, ".build-stamp");
|
|
10
10
|
const STAMP_POLL_MS = Number(process.env.MODWRIGHT_STAMP_POLL_MS ?? 2000);
|
|
11
|
-
const RETIRE_GRACE_MS = Number(process.env.MODWRIGHT_RETIRE_GRACE_MS ??
|
|
11
|
+
const RETIRE_GRACE_MS = Number(process.env.MODWRIGHT_RETIRE_GRACE_MS ?? 15 * 60_000);
|
|
12
12
|
const INIT_TIMEOUT_MS = 20_000;
|
|
13
13
|
const RESTART_BASE_MS = Number(process.env.MODWRIGHT_RESTART_BASE_MS ?? 500);
|
|
14
14
|
const RESTART_MAX_DELAY_MS = 30_000;
|
|
@@ -68,15 +68,31 @@ class ServerProcess {
|
|
|
68
68
|
this.child.on("exit", () => {
|
|
69
69
|
this.exited = true;
|
|
70
70
|
});
|
|
71
|
+
this.child.stdin.on("error", (err) => {
|
|
72
|
+
this.stdinBroken = true;
|
|
73
|
+
log(`server generation ${this.generation}: stdin ${err.code ?? err.message}; treating it as gone`);
|
|
74
|
+
});
|
|
75
|
+
this.child.on("error", (err) => {
|
|
76
|
+
this.exited = true;
|
|
77
|
+
log(`server generation ${this.generation}: ${err.message}`);
|
|
78
|
+
});
|
|
71
79
|
}
|
|
80
|
+
stdinBroken = false;
|
|
72
81
|
onMessage(listener) {
|
|
73
82
|
this.listeners.add(listener);
|
|
74
83
|
return () => this.listeners.delete(listener);
|
|
75
84
|
}
|
|
76
85
|
send(raw) {
|
|
77
|
-
if (this.exited || !this.child.stdin || this.child.stdin.destroyed)
|
|
86
|
+
if (this.exited || this.stdinBroken || !this.child.stdin || this.child.stdin.destroyed)
|
|
78
87
|
return false;
|
|
79
|
-
|
|
88
|
+
try {
|
|
89
|
+
this.child.stdin.write(`${raw}\n`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
this.stdinBroken = true;
|
|
93
|
+
log(`server generation ${this.generation}: write failed (${err.message}); treating it as gone`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
80
96
|
return true;
|
|
81
97
|
}
|
|
82
98
|
kill() {
|
|
@@ -100,7 +116,6 @@ class Supervisor {
|
|
|
100
116
|
swapQueued = false;
|
|
101
117
|
closing = false;
|
|
102
118
|
initializeRaw;
|
|
103
|
-
initializeId;
|
|
104
119
|
initializedSeen = false;
|
|
105
120
|
routes = new Map();
|
|
106
121
|
serverRequests = new Map();
|
|
@@ -135,7 +150,6 @@ class Supervisor {
|
|
|
135
150
|
const active = this.active;
|
|
136
151
|
if (msg.method === "initialize" && msg.id !== undefined && msg.id !== null) {
|
|
137
152
|
this.initializeRaw = raw;
|
|
138
|
-
this.initializeId = msg.id;
|
|
139
153
|
}
|
|
140
154
|
else if (msg.method === "notifications/initialized") {
|
|
141
155
|
this.initializedSeen = true;
|
|
@@ -157,7 +171,7 @@ class Supervisor {
|
|
|
157
171
|
}
|
|
158
172
|
}
|
|
159
173
|
}
|
|
160
|
-
|
|
174
|
+
const answerNotRunning = () => {
|
|
161
175
|
if (msg.id !== undefined && msg.id !== null && msg.method !== undefined) {
|
|
162
176
|
const response = {
|
|
163
177
|
jsonrpc: "2.0",
|
|
@@ -171,14 +185,23 @@ class Supervisor {
|
|
|
171
185
|
};
|
|
172
186
|
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
173
187
|
}
|
|
188
|
+
};
|
|
189
|
+
if (!active || active.exited) {
|
|
190
|
+
answerNotRunning();
|
|
174
191
|
return;
|
|
175
192
|
}
|
|
176
|
-
|
|
177
|
-
|
|
193
|
+
const key = msg.id !== undefined && msg.id !== null && msg.method !== undefined ? idKey(msg.id) : undefined;
|
|
194
|
+
if (key !== undefined) {
|
|
178
195
|
this.routes.set(key, active);
|
|
179
196
|
active.pending.add(key);
|
|
180
197
|
}
|
|
181
|
-
active.send(raw)
|
|
198
|
+
if (!active.send(raw)) {
|
|
199
|
+
if (key !== undefined) {
|
|
200
|
+
this.routes.delete(key);
|
|
201
|
+
active.pending.delete(key);
|
|
202
|
+
}
|
|
203
|
+
answerNotRunning();
|
|
204
|
+
}
|
|
182
205
|
}
|
|
183
206
|
fromServer(server, msg, raw) {
|
|
184
207
|
if (msg.id !== undefined && msg.id !== null && msg.method === undefined) {
|
|
@@ -285,6 +308,7 @@ class Supervisor {
|
|
|
285
308
|
}
|
|
286
309
|
if (this.initializedSeen) {
|
|
287
310
|
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n`);
|
|
311
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/prompts/list_changed" })}\n`);
|
|
288
312
|
}
|
|
289
313
|
}
|
|
290
314
|
finally {
|
package/dist/server.js
CHANGED
|
@@ -3,10 +3,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { pathExists, tail } from "./core/fsutil.js";
|
|
6
|
-
import { describeProject, findProjectFile, loadProject } from "./core/project/index.js";
|
|
6
|
+
import { describeProject, findProjectFile, loadProject, locateProjectFile } from "./core/project/index.js";
|
|
7
7
|
import { userDataDir } from "./core/home.js";
|
|
8
8
|
import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
|
|
9
9
|
import { loadUserConfig } from "./core/userconfig.js";
|
|
10
|
+
import { describeNeeds, isProjectTrusted, planTrust, projectTrustNeeds, trustRefusal } from "./core/trust.js";
|
|
10
11
|
import { runCompat } from "./core/compat/check.js";
|
|
11
12
|
import { SignatureRegistry, triageLogs } from "./core/logs/index.js";
|
|
12
13
|
import { buildContext, createValidatorRegistry, runValidators } from "./core/validate/index.js";
|
|
@@ -16,12 +17,13 @@ import { armBridge, bridgeCandidateDirs, bridgeStatus, cetLogCandidates, listPen
|
|
|
16
17
|
import { runTestPlan } from "./core/testplan/index.js";
|
|
17
18
|
import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
|
|
18
19
|
import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
|
|
19
|
-
import { pathExists as fileExists } from "./core/fsutil.js";
|
|
20
20
|
import { executePlan, listBackups, restoreBackup } from "./core/safety/index.js";
|
|
21
21
|
import { readTextIfExists } from "./core/fsutil.js";
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { promises as fs } from "node:fs";
|
|
24
24
|
import { crossCheckIdentities, findConflicts, listMods, sharedIdentities } from "./core/mods.js";
|
|
25
|
+
import { buildOwnershipIndex, lookupOwnership, managerConflicts } from "./core/ownership/index.js";
|
|
26
|
+
import { collectWrites, contestedKeys, duplicateCopies, whatTouches, whoTouches } from "./core/writes/index.js";
|
|
25
27
|
import { resolveLogs, resolveRoots } from "./core/surfaceutil.js";
|
|
26
28
|
import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds } from "./surfaces/index.js";
|
|
27
29
|
import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
|
|
@@ -183,8 +185,12 @@ server.registerTool("find_conflicts", {
|
|
|
183
185
|
installPath: installPathArg,
|
|
184
186
|
rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
|
|
185
187
|
limit: z.number().int().positive().default(100).describe("Max conflicts to return."),
|
|
188
|
+
mo2Instance: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
|
|
186
192
|
},
|
|
187
|
-
}, async ({ game, installPath, rootIds, limit }) => {
|
|
193
|
+
}, async ({ game, installPath, rootIds, limit, mo2Instance }) => {
|
|
188
194
|
const surface = getSurface(game);
|
|
189
195
|
if (!surface)
|
|
190
196
|
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
@@ -202,10 +208,22 @@ server.registerTool("find_conflicts", {
|
|
|
202
208
|
identityUnreadableCount = scanned.unreadable.length;
|
|
203
209
|
identityUnreadable = scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT);
|
|
204
210
|
}
|
|
211
|
+
let managed;
|
|
212
|
+
if (surface.managedDataDir !== undefined) {
|
|
213
|
+
const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
|
|
214
|
+
const mo2 = index.sources.find((src) => src.id === "mo2");
|
|
215
|
+
if (mo2?.active) {
|
|
216
|
+
const found = managerConflicts(index, limit);
|
|
217
|
+
managed = { source: mo2.detail, total: found.total, conflicts: found.conflicts };
|
|
218
|
+
if (found.total > limit)
|
|
219
|
+
managed.truncated = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
205
222
|
return json({
|
|
206
223
|
game: surface.id,
|
|
207
224
|
modsAnalyzed: mods.length,
|
|
208
225
|
conflictCount: conflicts.length,
|
|
226
|
+
managerConflicts: managed,
|
|
209
227
|
skippedRoots,
|
|
210
228
|
suppressedNoiseFiles,
|
|
211
229
|
truncated: conflicts.length > limit,
|
|
@@ -215,10 +233,108 @@ server.registerTool("find_conflicts", {
|
|
|
215
233
|
identityUnreadable,
|
|
216
234
|
identityUnreadableTruncated: identityUnreadableCount > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
|
|
217
235
|
note: "File-level analysis, and only for roots where mods share one deploy tree. " +
|
|
218
|
-
"See skippedRoots for what was excluded and why."
|
|
236
|
+
"See skippedRoots for what was excluded and why." +
|
|
237
|
+
(managed
|
|
238
|
+
? " managerConflicts are between Mod Organizer 2's mod folders, each with the winner the game sees and the mods it overrides."
|
|
239
|
+
: ""),
|
|
219
240
|
guidance: surface.guidance,
|
|
220
241
|
});
|
|
221
242
|
});
|
|
243
|
+
server.registerTool("owner_of", {
|
|
244
|
+
title: "Which mod owns this file",
|
|
245
|
+
description: "Say which installed mod a file comes from, and which copy the game actually sees when several provide it. " +
|
|
246
|
+
"Accepts absolute paths, paths relative to the game folder, a trailing part of a path (SKSE/Plugins/foo.dll), " +
|
|
247
|
+
"or a bare file name — e.g. a DLL named in a crash log, a path from triage_logs. Sources: the game's own mod " +
|
|
248
|
+
"roots (a mod folder owns what is under it), Mod Organizer 2 (the active profile's mod folders in priority " +
|
|
249
|
+
"order, plus Overwrite; for games where the mapping is known), and Vortex deployment manifests. Each result " +
|
|
250
|
+
"says how the winner was decided; `sources` says what each source could see, and `unattributedRoots` lists " +
|
|
251
|
+
"loose-file folders whose files no source can attribute. Files inside packed archives are not indexed yet.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
game: gameArg,
|
|
254
|
+
installPath: installPathArg,
|
|
255
|
+
paths: z.array(z.string().min(1)).min(1).max(50).describe("Files to attribute."),
|
|
256
|
+
mo2Instance: z
|
|
257
|
+
.string()
|
|
258
|
+
.optional()
|
|
259
|
+
.describe("A portable Mod Organizer 2 instance (its folder or ModOrganizer.ini); global instances are found automatically."),
|
|
260
|
+
limit: z.number().int().positive().max(200).default(20).describe("Max matches per path (a bare file name can match many)."),
|
|
261
|
+
},
|
|
262
|
+
}, async ({ game, installPath, paths, mo2Instance, limit }) => {
|
|
263
|
+
const surface = getSurface(game);
|
|
264
|
+
if (!surface)
|
|
265
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
266
|
+
const install = await resolveInstall(surface, installPath);
|
|
267
|
+
if (!install)
|
|
268
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
269
|
+
const index = await buildOwnershipIndex(surface, install, mo2Instance ? { mo2Instance } : {});
|
|
270
|
+
return json({
|
|
271
|
+
game: surface.id,
|
|
272
|
+
install: install.root,
|
|
273
|
+
sources: index.sources,
|
|
274
|
+
indexedPaths: index.entries.size,
|
|
275
|
+
unattributedRoots: index.unattributedRoots.length > 0 ? index.unattributedRoots : undefined,
|
|
276
|
+
results: paths.map((p) => lookupOwnership(index, p, limit)),
|
|
277
|
+
note: "Owners come only from what is on disk: a mod folder, a manager's own records. Nothing is inferred from " +
|
|
278
|
+
"file names. A path no mod claims is reported as such, with the likely reasons.",
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
server.registerTool("who_touches", {
|
|
282
|
+
title: "Which mods write this record or asset",
|
|
283
|
+
description: "Read every installed mod's data and report who writes a given key — at the level the game merges on, not the " +
|
|
284
|
+
"file level: a TweakDB record or flat (Cyberpunk 2077, TweakXL YAML and .tweak files), a stats entry or field " +
|
|
285
|
+
"(Baldur's Gate 3, inside paks and unpacked mods), an asset or data entry/field (Stardew Valley Content Patcher " +
|
|
286
|
+
"packs, Includes followed). Give `key` for who writes it (a record returns its flats too; an asset its entries), " +
|
|
287
|
+
"`mod` for everything one mod writes and where another mod writes the same key, or neither for every key two or " +
|
|
288
|
+
"more mods contend for. This says who touches a key, not who wins: the winner depends on the game's load order. " +
|
|
289
|
+
"Writes behind a Content Patcher When, or with {{tokens}}, are marked conditional.",
|
|
290
|
+
inputSchema: {
|
|
291
|
+
game: gameArg,
|
|
292
|
+
installPath: installPathArg,
|
|
293
|
+
key: z.string().min(1).optional().describe("A record, flat, stats entry, asset or entry, e.g. Items.Preset_Masamune_Default, stats:Projectile_Fireball, Data/Objects#128."),
|
|
294
|
+
mod: z.string().min(1).optional().describe("A mod (or unit) id or name, for what it writes."),
|
|
295
|
+
rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
|
|
296
|
+
limit: z.number().int().positive().max(500).default(50).describe("Max keys (or writes per mod) to return."),
|
|
297
|
+
},
|
|
298
|
+
}, async ({ game, installPath, key, mod, rootIds, limit }) => {
|
|
299
|
+
const surface = getSurface(game);
|
|
300
|
+
if (!surface)
|
|
301
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
302
|
+
const install = await resolveInstall(surface, installPath);
|
|
303
|
+
if (!install)
|
|
304
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
305
|
+
if (!surface.extractWrites) {
|
|
306
|
+
return json({
|
|
307
|
+
game: surface.id,
|
|
308
|
+
supported: false,
|
|
309
|
+
note: `No write-set extraction for ${surface.name} yet (supported: Cyberpunk 2077 tweaks, Baldur's Gate 3 stats, Stardew Valley Content Patcher).`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const collection = await collectWrites(surface, install, rootIds ? { rootIds } : {});
|
|
313
|
+
const { errors, ...coverage } = collection.coverage;
|
|
314
|
+
const copies = duplicateCopies(collection);
|
|
315
|
+
const result = key
|
|
316
|
+
? { mode: "key", ...whoTouches(collection, key, limit) }
|
|
317
|
+
: mod
|
|
318
|
+
? { mode: "mod", query: mod, writers: whatTouches(collection, mod, limit) }
|
|
319
|
+
: { mode: "contested", ...contestedKeys(collection, limit) };
|
|
320
|
+
return json({
|
|
321
|
+
game: surface.id,
|
|
322
|
+
install: install.root,
|
|
323
|
+
coverage: {
|
|
324
|
+
...coverage,
|
|
325
|
+
writers: collection.writers.length,
|
|
326
|
+
errorCount: errors.length,
|
|
327
|
+
errors: errors.slice(0, 25),
|
|
328
|
+
},
|
|
329
|
+
...(copies.length > 0 ? { duplicateCopies: copies } : {}),
|
|
330
|
+
...result,
|
|
331
|
+
note: "Static: read from the mods' files, never from the running game. 'contested' means two or more mods write the " +
|
|
332
|
+
"key and not only by appending; which one the game ends up with depends on load order, which this tool does " +
|
|
333
|
+
"not decide. A mod the game does not load (marked 'inactive') is listed but never contests, and copies of one " +
|
|
334
|
+
"mod ('duplicateCopies') count once: the game loads one of them. Code that writes at runtime (redscript, CET, " +
|
|
335
|
+
"SMAPI C# mods, Script Extender Lua) is not seen here.",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
222
338
|
server.registerTool("load_order", {
|
|
223
339
|
title: "Read load order",
|
|
224
340
|
description: "Read the game's explicit load order and cross-check it against what is " +
|
|
@@ -340,9 +456,7 @@ server.registerTool("project_info", {
|
|
|
340
456
|
}, async ({ path: start }) => {
|
|
341
457
|
const from = start ?? process.cwd();
|
|
342
458
|
try {
|
|
343
|
-
const file =
|
|
344
|
-
? from
|
|
345
|
-
: await findProjectFile(from);
|
|
459
|
+
const file = await locateProjectFile(from);
|
|
346
460
|
if (!file) {
|
|
347
461
|
return json({
|
|
348
462
|
found: false,
|
|
@@ -368,9 +482,11 @@ server.registerTool("check_toolchain", {
|
|
|
368
482
|
"with data=<id> fetches a data folder ModWright can fetch (Stardew Valley's wiki pages) and remembers it; " +
|
|
369
483
|
"action 'remember' records a tool's path in the " +
|
|
370
484
|
"per-user config so every session finds it (no environment variable, no restart); " +
|
|
371
|
-
"'forget' drops it.
|
|
485
|
+
"'forget' drops it. 'trust' records a project root (projectPath) as allowed to run its own toolchain overrides and " +
|
|
486
|
+
"exec build steps; until then an applied build, convert, template extract or index build that would run them is " +
|
|
487
|
+
"refused. 'untrust' removes it. All of these are dry-run unless mode is 'apply'.",
|
|
372
488
|
inputSchema: {
|
|
373
|
-
action: z.enum(["check", "remember", "forget", "install"]).default("check").describe("check (default) reports; remember/forget change the per-user config; install fetches a pinned tool into ModWright's tools folder (dry-run shows the file, source, size and sha256 first), or with data a fetchable data folder (dry-run lists every URL first)."),
|
|
489
|
+
action: z.enum(["check", "remember", "forget", "install", "trust", "untrust"]).default("check").describe("check (default) reports; remember/forget change the per-user config; trust/untrust change the trusted-projects list for projectPath; install fetches a pinned tool into ModWright's tools folder (dry-run shows the file, source, size and sha256 first), or with data a fetchable data folder (dry-run lists every URL first)."),
|
|
374
490
|
game: gameArg.optional().describe("Restrict to tools used by this game (check only)."),
|
|
375
491
|
projectPath: z
|
|
376
492
|
.string()
|
|
@@ -380,9 +496,47 @@ server.registerTool("check_toolchain", {
|
|
|
380
496
|
toolPath: z.string().optional().describe("remember: absolute path to the executable, or the folder that directly holds it."),
|
|
381
497
|
data: z.string().optional().describe("remember/forget/install: a data folder id instead of a tool, e.g. 'valheim.jotunn' (a Jotunn checkout) or 'stardewvalley.wiki' (saved wiki pages); check_toolchain game=<id> lists the ones a game reads."),
|
|
382
498
|
dataPath: z.string().optional().describe("remember with data: absolute path to that folder. install with data: where to save it (default: ModWright's per-user data folder)."),
|
|
383
|
-
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("remember/forget: dry-run shows the config change; apply writes it (backing up the old file)."),
|
|
499
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("remember/forget/trust/untrust: dry-run shows the config change; apply writes it (backing up the old file)."),
|
|
384
500
|
},
|
|
385
501
|
}, async ({ action, game, projectPath, tool, toolPath, data, dataPath, mode }) => {
|
|
502
|
+
if (action === "trust" || action === "untrust") {
|
|
503
|
+
if (tool || toolPath || data || dataPath)
|
|
504
|
+
return fail(`${action} takes projectPath only.`);
|
|
505
|
+
if (!projectPath)
|
|
506
|
+
return fail(`${action} needs projectPath: the mod project's modwright.json or its folder.`);
|
|
507
|
+
let resolved;
|
|
508
|
+
try {
|
|
509
|
+
resolved = await resolveProject(projectPath);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
return fail(`Could not load project: ${error.message}`);
|
|
513
|
+
}
|
|
514
|
+
const planned = await planTrust(action, resolved.root, resolved.project.name);
|
|
515
|
+
if (!planned.ok)
|
|
516
|
+
return fail(planned.error);
|
|
517
|
+
const report = await executePlan(planned.plan, {
|
|
518
|
+
mode,
|
|
519
|
+
confidence: { level: "high", assumptions: ["The trusted-projects list is ModWright's own file; nothing in a game or a project is touched."] },
|
|
520
|
+
});
|
|
521
|
+
const needs = projectTrustNeeds(resolved.project);
|
|
522
|
+
return json({
|
|
523
|
+
action,
|
|
524
|
+
project: resolved.project.name,
|
|
525
|
+
root: planned.root,
|
|
526
|
+
mode,
|
|
527
|
+
applied: report.applied,
|
|
528
|
+
file: planned.file,
|
|
529
|
+
runs: { exec: needs.exec, toolchain: Object.fromEntries(needs.toolchain.map((id) => [id, resolved.project.toolchain?.[id]])) },
|
|
530
|
+
...(planned.previous ? { previous: planned.previous } : {}),
|
|
531
|
+
operations: report.operations,
|
|
532
|
+
...(report.backupDir ? { backupDir: report.backupDir } : {}),
|
|
533
|
+
note: report.applied
|
|
534
|
+
? action === "trust"
|
|
535
|
+
? `Trusted. Applied builds, converts, extracts and index builds may now run this project's exec steps and toolchain overrides.`
|
|
536
|
+
: `No longer trusted. Its exec steps and toolchain overrides are refused until it is trusted again.`
|
|
537
|
+
: `Dry run: nothing written. Read what it runs (runs) first; repeat with mode 'apply' to ${action} it.`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
386
540
|
if ((action === "remember" || action === "forget") && data) {
|
|
387
541
|
if (tool)
|
|
388
542
|
return fail("Give either tool or data, not both.");
|
|
@@ -501,9 +655,12 @@ server.registerTool("check_toolchain", {
|
|
|
501
655
|
}
|
|
502
656
|
let projectOverrides;
|
|
503
657
|
let project;
|
|
658
|
+
let projectRoot;
|
|
504
659
|
if (projectPath) {
|
|
505
660
|
try {
|
|
506
|
-
|
|
661
|
+
const loaded = await loadProject(projectPath);
|
|
662
|
+
project = loaded.project;
|
|
663
|
+
projectRoot = loaded.root;
|
|
507
664
|
projectOverrides = project.toolchain;
|
|
508
665
|
}
|
|
509
666
|
catch (error) {
|
|
@@ -521,11 +678,13 @@ server.registerTool("check_toolchain", {
|
|
|
521
678
|
if (spec && !located.has(id))
|
|
522
679
|
located.set(id, await locateTool(spec, { projectOverrides, probe: "trusted", userTools: userConfig.config.tools }));
|
|
523
680
|
}
|
|
524
|
-
const indexBuilt = surface.index ? await
|
|
681
|
+
const indexBuilt = surface.index ? await pathExists(resolveIndexFile(surface.id).file) : undefined;
|
|
525
682
|
features = featureReadiness(surface, [...located.values()], { ...(project ? { project } : {}), ...(indexBuilt !== undefined ? { indexBuilt } : {}), userData: userConfig.config.data });
|
|
526
683
|
}
|
|
684
|
+
const trusted = projectRoot ? await isProjectTrusted(projectRoot) : undefined;
|
|
527
685
|
return json({
|
|
528
686
|
game: surfaceId,
|
|
687
|
+
...(projectRoot ? { project: { root: projectRoot, trusted, ...(trusted ? {} : { note: "Not trusted: an applied build, convert, extract or index build refuses to run its exec steps or toolchain overrides until check_toolchain action=trust." }) } } : {}),
|
|
529
688
|
...(features ? { summary: readinessSummary(features), features } : {}),
|
|
530
689
|
found: tools.filter((t) => t.found).length,
|
|
531
690
|
missing: tools.filter((t) => !t.found).map((t) => t.id),
|
|
@@ -556,7 +715,11 @@ server.registerTool("triage_logs", {
|
|
|
556
715
|
"which failed and why, with the first error's explanation and usual fix. Pass the " +
|
|
557
716
|
"returned offsets and offsetKeys back as offsets/offsetKeys for incremental triage after " +
|
|
558
717
|
"the next launch (the keys let a re-created log of identical size be read again). " +
|
|
559
|
-
"Stale and zero-byte logs are reported as such rather than trusted."
|
|
718
|
+
"Stale and zero-byte logs are reported as such rather than trusted. Where a framework " +
|
|
719
|
+
"logs who did what (SMAPI's 'X edited Data/Objects', TweakXL's file read order, ArchiveXL " +
|
|
720
|
+
"merges and localization overwrites, Red Hot Tools' missing resources), the verdict's " +
|
|
721
|
+
"`attribution` groups those lines by subject, contested subjects first: the log's own answer " +
|
|
722
|
+
"to 'which mod is doing this'. Pass attributionFor to narrow it to one asset, record or mod.",
|
|
560
723
|
inputSchema: {
|
|
561
724
|
game: gameArg,
|
|
562
725
|
installPath: installPathArg,
|
|
@@ -574,15 +737,20 @@ server.registerTool("triage_logs", {
|
|
|
574
737
|
.describe("Per log id file identities (offsetKeys) from the same previous result."),
|
|
575
738
|
maxLines: z.number().int().positive().max(20000).optional(),
|
|
576
739
|
includeFindings: z.boolean().default(true),
|
|
740
|
+
attributionFor: z
|
|
741
|
+
.string()
|
|
742
|
+
.optional()
|
|
743
|
+
.describe("Keep only attribution entries whose subject, actor or content pack contains this text " +
|
|
744
|
+
"(case-insensitive): an asset or record for 'who touched this', a mod name for 'what did it touch'."),
|
|
577
745
|
},
|
|
578
|
-
}, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings }) => {
|
|
746
|
+
}, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings, attributionFor }) => {
|
|
579
747
|
const surface = getSurface(game);
|
|
580
748
|
if (!surface)
|
|
581
749
|
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
582
750
|
const install = await resolveInstall(surface, installPath);
|
|
583
751
|
if (!install)
|
|
584
752
|
return fail(`No install detected for ${surface.name}.`);
|
|
585
|
-
const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines });
|
|
753
|
+
const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines }, { attributionFor });
|
|
586
754
|
return json({
|
|
587
755
|
...verdict,
|
|
588
756
|
signatureCount: registry.forGame(surface.id).length,
|
|
@@ -632,7 +800,7 @@ server.registerTool("load_order_diff", {
|
|
|
632
800
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
633
801
|
let snapshotDir = path.join(userDataDir(), "snapshots");
|
|
634
802
|
if (projectPath) {
|
|
635
|
-
const projectFile =
|
|
803
|
+
const projectFile = await locateProjectFile(projectPath);
|
|
636
804
|
if (!projectFile)
|
|
637
805
|
return fail(`No modwright.json found at or above ${projectPath}.`);
|
|
638
806
|
snapshotDir = path.join(path.dirname(path.resolve(projectFile)), ".modwright", "snapshots");
|
|
@@ -686,7 +854,7 @@ function getStore(gameId, reopen = false) {
|
|
|
686
854
|
legacyStores.set(gameId, legacy);
|
|
687
855
|
else
|
|
688
856
|
legacyStores.delete(gameId);
|
|
689
|
-
pending =
|
|
857
|
+
pending = pathExists(file).then((exists) => (exists ? IndexStore.open(file) : undefined));
|
|
690
858
|
stores.set(gameId, pending);
|
|
691
859
|
}
|
|
692
860
|
return pending;
|
|
@@ -867,10 +1035,15 @@ server.registerTool("build_index", {
|
|
|
867
1035
|
let toolOverrides;
|
|
868
1036
|
if (projectPath) {
|
|
869
1037
|
try {
|
|
870
|
-
const file =
|
|
1038
|
+
const file = await locateProjectFile(projectPath);
|
|
871
1039
|
if (!file)
|
|
872
1040
|
return fail(`No modwright.json found at or above ${projectPath}.`);
|
|
873
|
-
|
|
1041
|
+
const loaded = await loadProject(file);
|
|
1042
|
+
toolOverrides = loaded.project.toolchain;
|
|
1043
|
+
const used = Object.keys(toolOverrides ?? {}).filter((id) => TOOL_SPECS.find((s) => s.id === id)?.usedBy.includes(surface.id));
|
|
1044
|
+
if (used.length > 0 && !(await isProjectTrusted(loaded.root))) {
|
|
1045
|
+
return fail(trustRefusal(loaded.root, `its modwright.json overrides the tool path(s) for ${used.join(", ")}`));
|
|
1046
|
+
}
|
|
874
1047
|
}
|
|
875
1048
|
catch (error) {
|
|
876
1049
|
return fail(`Could not load project for tool overrides: ${error.message}`);
|
|
@@ -881,7 +1054,7 @@ server.registerTool("build_index", {
|
|
|
881
1054
|
if (legacy) {
|
|
882
1055
|
return json({ game: surface.id, rebuilt: false, file: legacy.file, legacyIndex: legacy.file, note: `${legacyIndexHint(surface.id, legacy)} lookup already uses it. Pass force to build a fresh index in the new location instead.` });
|
|
883
1056
|
}
|
|
884
|
-
if (!force && (await
|
|
1057
|
+
if (!force && (await pathExists(file))) {
|
|
885
1058
|
const existing = await surface.index.info(install);
|
|
886
1059
|
if (!existing.stale)
|
|
887
1060
|
return json({ ...existing, note: "Index already exists; pass force to rebuild." });
|
|
@@ -960,7 +1133,10 @@ server.registerTool("validate", {
|
|
|
960
1133
|
});
|
|
961
1134
|
const findings = includeInfo ? report.findings : report.findings.filter((finding) => finding.severity !== "info");
|
|
962
1135
|
const blockingFiles = report.findings.filter((f) => f.severity === "block" && !f.suppressedBy).map((f) => f.file);
|
|
963
|
-
const
|
|
1136
|
+
const narrowed = Boolean(ids?.length) || Boolean(groups?.length) || cheapOnly;
|
|
1137
|
+
const ledger = narrowed
|
|
1138
|
+
? { skipped: true, applied: [], refused: [], reason: "a run narrowed by rule, groups or cheapOnly never promotes a claim; run the full set" }
|
|
1139
|
+
: await applyValidateLedgerHook(resolved.root, blockingFiles, changedFiles, "validate");
|
|
964
1140
|
return json({
|
|
965
1141
|
...report,
|
|
966
1142
|
findings: findings.slice(0, 200),
|
|
@@ -1025,7 +1201,13 @@ server.registerTool("build", {
|
|
|
1025
1201
|
const ledger = mode === "apply" && !result.failed && result.manifestPath
|
|
1026
1202
|
? await applyBuildLedgerHook(resolved.root, result.manifestPath, "build")
|
|
1027
1203
|
: { skipped: true, applied: [], refused: [] };
|
|
1028
|
-
|
|
1204
|
+
let trust;
|
|
1205
|
+
if (mode === "dry-run") {
|
|
1206
|
+
const what = describeNeeds(projectTrustNeeds(resolved.project));
|
|
1207
|
+
if (what && !(await isProjectTrusted(resolved.root)))
|
|
1208
|
+
trust = { trusted: false, note: trustRefusal(resolved.root, what) };
|
|
1209
|
+
}
|
|
1210
|
+
return json({ ...result, summary, ledger, ...(trust ? { trust } : {}) });
|
|
1029
1211
|
});
|
|
1030
1212
|
const RELOAD_PATH_LIMIT = 200;
|
|
1031
1213
|
function trimReload(reload) {
|
|
@@ -1113,13 +1295,19 @@ server.registerTool("rollback", {
|
|
|
1113
1295
|
"purpose, entry count, how many of those entries are junction pointers rather than content, and total " +
|
|
1114
1296
|
"size. With a backupId it restores that backup: dry-run by default, and every file's sha256 is re-checked " +
|
|
1115
1297
|
"against the manifest immediately before it is written back. A junction is recreated as a junction, never " +
|
|
1116
|
-
"as a copy of what it pointed at."
|
|
1298
|
+
"as a copy of what it pointed at. A restore writes only under the project and the game's detected mod roots unless " +
|
|
1299
|
+
"allowOutsideRoots is set.",
|
|
1117
1300
|
inputSchema: {
|
|
1118
1301
|
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1119
1302
|
backupId: z.string().optional().describe("The stamp naming one backup directory under .modwright/backups (from this tool's own listing). Omit to list."),
|
|
1120
1303
|
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run reports what would be restored without writing a byte."),
|
|
1304
|
+
installPath: installPathArg,
|
|
1305
|
+
allowOutsideRoots: z
|
|
1306
|
+
.boolean()
|
|
1307
|
+
.default(false)
|
|
1308
|
+
.describe("Restore entries outside the project and the game's mod roots too. Off by default: a backup that names any other path is refused whole."),
|
|
1121
1309
|
},
|
|
1122
|
-
}, async ({ path: start, backupId, mode }) => {
|
|
1310
|
+
}, async ({ path: start, backupId, mode, installPath, allowOutsideRoots }) => {
|
|
1123
1311
|
const resolved = await projectOrFail(start);
|
|
1124
1312
|
if ("isError" in resolved)
|
|
1125
1313
|
return resolved;
|
|
@@ -1141,17 +1329,30 @@ server.registerTool("rollback", {
|
|
|
1141
1329
|
if (!(await pathExists(backupDir))) {
|
|
1142
1330
|
return fail(`No backup "${backupId}" under ${backupRoot}. Call rollback with no backupId to list what is there.`);
|
|
1143
1331
|
}
|
|
1332
|
+
let confineTo;
|
|
1333
|
+
let noInstall = false;
|
|
1334
|
+
if (!allowOutsideRoots) {
|
|
1335
|
+
const surface = getSurface(resolved.project.game);
|
|
1336
|
+
const install = surface ? await resolveInstall(surface, installPath).catch(() => undefined) : undefined;
|
|
1337
|
+
noInstall = !install;
|
|
1338
|
+
confineTo = [resolved.root, ...(surface && install ? surface.modRoots(install).map((r) => r.path) : [])];
|
|
1339
|
+
}
|
|
1144
1340
|
try {
|
|
1145
|
-
const report = await restoreBackup(backupDir, { mode });
|
|
1146
|
-
|
|
1341
|
+
const report = await restoreBackup(backupDir, { mode, ...(confineTo ? { confineTo } : {}) });
|
|
1342
|
+
const operations = report.operations.map(({ op, ...rest }) => ({
|
|
1343
|
+
...rest,
|
|
1344
|
+
op: "content" in op ? { kind: op.kind, path: op.path, bytes: rest.sizeBytes } : op,
|
|
1345
|
+
}));
|
|
1346
|
+
return json({ ...report, operations, backupId, backupDir });
|
|
1147
1347
|
}
|
|
1148
1348
|
catch (error) {
|
|
1149
|
-
|
|
1349
|
+
const hint = noInstall ? " The game's install was not detected, so only the project counts; pass installPath to add its mod roots." : "";
|
|
1350
|
+
return fail(`rollback failed: ${error.message}${hint}`);
|
|
1150
1351
|
}
|
|
1151
1352
|
});
|
|
1152
1353
|
server.registerTool("reload_plan", {
|
|
1153
1354
|
title: "Classify what a set of deployed paths needs to take effect",
|
|
1154
|
-
description: "Map deployed paths to a reload recommendation — hot, hot-degraded, save-reload, restart or unknown — " +
|
|
1355
|
+
description: "Map deployed paths to a reload recommendation — hot, hot-degraded, save-reload, restart or unknown (none when nothing was written) — " +
|
|
1155
1356
|
"using the project's own reload.rules first and then the per-surface table, and reduce them to the single " +
|
|
1156
1357
|
"most conservative answer with its caveats. An unmatched path is reported as unknown ('restart to be sure'), " +
|
|
1157
1358
|
"never promoted. ModWright never launches the game and never sends a reload command; this is a " +
|
|
@@ -1234,12 +1435,18 @@ server.registerTool("bridge", {
|
|
|
1234
1435
|
timeoutSeconds: z.number().positive().max(300).optional().describe("'poll': override the computed budget; still capped at 300s."),
|
|
1235
1436
|
},
|
|
1236
1437
|
}, async ({ action, game, installPath, path: start, mode, probes, armForSeconds, grant, allowMutate, windowSeconds, planId, planPath, runId, id, nonce, probeCount, timeoutSeconds }) => {
|
|
1438
|
+
const planFields = { planId, planPath, runId };
|
|
1439
|
+
const givenPlan = Object.entries(planFields).filter(([, v]) => v !== undefined);
|
|
1440
|
+
if (givenPlan.length > 0 && givenPlan.length < 3) {
|
|
1441
|
+
const missing = Object.entries(planFields).filter(([, v]) => v === undefined).map(([k]) => k);
|
|
1442
|
+
return fail(`planId, planPath and runId go together; ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} missing.`);
|
|
1443
|
+
}
|
|
1237
1444
|
let projectGame;
|
|
1238
1445
|
let projectRoot;
|
|
1239
1446
|
if (start) {
|
|
1240
1447
|
try {
|
|
1241
1448
|
const from = start;
|
|
1242
|
-
const file =
|
|
1449
|
+
const file = await locateProjectFile(from);
|
|
1243
1450
|
if (!file)
|
|
1244
1451
|
return fail(`No modwright.json found at or above ${from}.`);
|
|
1245
1452
|
const loaded = await loadProject(file);
|
|
@@ -1540,7 +1747,7 @@ const TEMPLATE_SURFACES = ["baldursgate3", "cyberpunk2077"];
|
|
|
1540
1747
|
const TEMPLATE_PERMISSIONS = ["never-redistribute", "maintainer-own", "declared-dependency"];
|
|
1541
1748
|
async function resolveProject(start) {
|
|
1542
1749
|
const from = start ?? process.cwd();
|
|
1543
|
-
const file =
|
|
1750
|
+
const file = await locateProjectFile(from);
|
|
1544
1751
|
if (!file)
|
|
1545
1752
|
throw new NoProjectError(`No modwright.json found at or above ${from}.`);
|
|
1546
1753
|
return loadProject(file);
|
|
@@ -1566,15 +1773,24 @@ server.registerTool("convert", {
|
|
|
1566
1773
|
},
|
|
1567
1774
|
}, async ({ conversionId, source, dest, mode, verifyRoundTrip, path: projectPath }) => {
|
|
1568
1775
|
let overrides;
|
|
1569
|
-
|
|
1776
|
+
let projectTrust;
|
|
1777
|
+
let backupRoot;
|
|
1778
|
+
const projectStart = projectPath ?? (path.isAbsolute(dest) ? (await findProjectFile(path.dirname(dest))) : undefined);
|
|
1779
|
+
if (projectStart) {
|
|
1570
1780
|
try {
|
|
1571
|
-
|
|
1781
|
+
const resolved = await resolveProject(projectStart);
|
|
1782
|
+
backupRoot = path.join(resolved.root, ".modwright", "backups");
|
|
1783
|
+
if (projectPath) {
|
|
1784
|
+
overrides = resolved.project.toolchain;
|
|
1785
|
+
if (overrides && Object.keys(overrides).length > 0)
|
|
1786
|
+
projectTrust = { root: resolved.root, trusted: await isProjectTrusted(resolved.root) };
|
|
1787
|
+
}
|
|
1572
1788
|
}
|
|
1573
1789
|
catch {
|
|
1574
1790
|
}
|
|
1575
1791
|
}
|
|
1576
1792
|
try {
|
|
1577
|
-
const report = await runConvert(conversionId, source, dest, { mode, verifyRoundTrip, locateTool: defaultTextLocateTool(overrides, mode === "apply" ? "all" : "none") });
|
|
1793
|
+
const report = await runConvert(conversionId, source, dest, { mode, verifyRoundTrip, locateTool: defaultTextLocateTool(overrides, mode === "apply" ? "all" : "none", projectTrust), ...(backupRoot ? { backupRoot } : {}) });
|
|
1578
1794
|
return json(report);
|
|
1579
1795
|
}
|
|
1580
1796
|
catch (error) {
|
|
@@ -1667,7 +1883,9 @@ server.registerTool("template", {
|
|
|
1667
1883
|
catch (error) {
|
|
1668
1884
|
return fail(`Could not load project: ${error.message}`);
|
|
1669
1885
|
}
|
|
1670
|
-
const
|
|
1886
|
+
const overrides = resolved.project.toolchain;
|
|
1887
|
+
const projectTrust = action === "extract" && overrides && Object.keys(overrides).length > 0 ? { root: resolved.root, trusted: await isProjectTrusted(resolved.root) } : undefined;
|
|
1888
|
+
const locateTool = defaultTextLocateTool(overrides, action === "extract" && mode === "apply" ? "all" : action === "extract" ? "none" : "trusted", projectTrust);
|
|
1671
1889
|
const templateGameVersion = async (surfaceId) => {
|
|
1672
1890
|
const gameSurface = getSurface(surfaceId);
|
|
1673
1891
|
if (!gameSurface)
|
|
@@ -6,6 +6,7 @@ import { dedupeByRoot, logs, roots } from "../../core/surfaceutil.js";
|
|
|
6
6
|
import { bg3Index } from "./index/index.js";
|
|
7
7
|
import { scanModIdentities as scanBg3ModIdentities } from "./identity.js";
|
|
8
8
|
import { SIGNATURES } from "./logs.js";
|
|
9
|
+
import { extractBg3Writes } from "./writes.js";
|
|
9
10
|
import { describeModSettingsDiff, diffModSettings, parseModSettings } from "./modsettings.js";
|
|
10
11
|
import { BRIDGE_VALIDATORS } from "./validators/bridge.js";
|
|
11
12
|
import { FILE_VALIDATORS } from "./validators/files.js";
|
|
@@ -112,6 +113,8 @@ export const baldursgate3 = {
|
|
|
112
113
|
};
|
|
113
114
|
},
|
|
114
115
|
logSignatures: SIGNATURES,
|
|
116
|
+
extractWrites: (mod, root) => extractBg3Writes(mod, root),
|
|
117
|
+
looseUnitOf: (rootId, rel) => (rootId === "loose" ? /^(?:Mods|Public|Generated\/Public)\/([^/]+)\//i.exec(rel)?.[1] : undefined),
|
|
115
118
|
index: bg3Index,
|
|
116
119
|
validators: [
|
|
117
120
|
...ITEM_VALIDATORS,
|
|
@@ -228,8 +228,9 @@ const osirisNamingConvention = {
|
|
|
228
228
|
const base = path.posix.basename(lower(ctx.target.path), ".txt");
|
|
229
229
|
if (!/^[a-z0-9]+(_[a-z0-9]+){2,}$/i.test(base)) {
|
|
230
230
|
findings.push(finding(osirisNamingConvention.id, "info", ctx.target.path, `Goal file name '${path.basename(ctx.target.path)}' does not obviously follow the Act_Region_Situation convention. ` +
|
|
231
|
-
`This is a naming convention, not a known build failure
|
|
232
|
-
`
|
|
231
|
+
`This is a naming convention, not a known build failure: LSLib's story compiler only warns on a name missing ` +
|
|
232
|
+
`its DB/PROC/QRY prefix (bg3.dialogue.osiris-goals.prefix-convention-is-a-compiler-warning-in-lslib), ` +
|
|
233
|
+
`so this stays informational.`, osirisNamingConvention.evidence));
|
|
233
234
|
}
|
|
234
235
|
const text = stripBom(await ctx.readText(ctx.target.path)).replace(/\r\n/g, "\n");
|
|
235
236
|
const badPrefixRe = /\b(?!DB_|QRY_|PROC_)([A-Za-z][A-Za-z0-9_]*)\s*\(/g;
|
|
@@ -482,7 +483,6 @@ const questAddQuestCloseDiscouraged = {
|
|
|
482
483
|
return goal.questAddClose.map((occ) => finding(questAddQuestCloseDiscouraged.id, "info", targetPath, `${occ.name}(...) is used here. The documentation recommends relying on QuestUpdate instead of manually adding/closing quests.`, questAddQuestCloseDiscouraged.evidence, { line: occ.line }));
|
|
483
484
|
},
|
|
484
485
|
};
|
|
485
|
-
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
486
486
|
function collectOrigins(doc) {
|
|
487
487
|
const origins = [];
|
|
488
488
|
for (const region of doc.regions) {
|