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
|
@@ -2,6 +2,13 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { copyFilePreservingMtime, readManifest, sha256File } from "./backup.js";
|
|
4
4
|
import { isDirectory } from "../fsutil.js";
|
|
5
|
+
function outsideConfinement(entries, roots) {
|
|
6
|
+
const fold = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
|
|
7
|
+
const folded = roots.map(fold);
|
|
8
|
+
return entries
|
|
9
|
+
.map((e) => e.originalPath)
|
|
10
|
+
.filter((p) => !folded.some((root) => isUnder(root, fold(p))));
|
|
11
|
+
}
|
|
5
12
|
export async function listBackups(backupRoot) {
|
|
6
13
|
let entries;
|
|
7
14
|
try {
|
|
@@ -86,6 +93,14 @@ export async function restoreBackup(backupDir, options = {}) {
|
|
|
86
93
|
if (!("kind" in entry))
|
|
87
94
|
backupFileFor(backupDir, entry.backupPath);
|
|
88
95
|
}
|
|
96
|
+
if (options.confineTo) {
|
|
97
|
+
const outside = outsideConfinement(manifest.entries, options.confineTo);
|
|
98
|
+
if (outside.length > 0) {
|
|
99
|
+
const shown = outside.slice(0, 5).join(", ") + (outside.length > 5 ? ` and ${outside.length - 5} more` : "");
|
|
100
|
+
throw new Error(`safety: this backup would write outside the project and the game's mod roots (${shown}); nothing was restored. ` +
|
|
101
|
+
`Allowed: ${options.confineTo.join(", ")}. If the manifest is yours and the paths are right, pass allowOutsideRoots: true.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
89
104
|
if (mode === "dry-run") {
|
|
90
105
|
const operations = [];
|
|
91
106
|
for (const entry of manifest.entries) {
|
|
@@ -110,6 +125,7 @@ export async function restoreBackup(backupDir, options = {}) {
|
|
|
110
125
|
const results = [];
|
|
111
126
|
const locked = [];
|
|
112
127
|
let stopped = false;
|
|
128
|
+
const commonRoot = commonAncestor(manifest.entries.filter((e) => !("kind" in e)).map((e) => path.dirname(e.originalPath)));
|
|
113
129
|
for (const entry of manifest.entries) {
|
|
114
130
|
if ("kind" in entry) {
|
|
115
131
|
const op = { kind: "junction", path: entry.originalPath, target: entry.linkTarget };
|
|
@@ -141,6 +157,16 @@ export async function restoreBackup(backupDir, options = {}) {
|
|
|
141
157
|
if (sha256 !== entry.sha256) {
|
|
142
158
|
throw new Error(`backup of ${entry.originalPath} is corrupted: manifest says sha256 ${entry.sha256}, file on disk hashes to ${sha256}`);
|
|
143
159
|
}
|
|
160
|
+
const link = await linkAncestor(entry.originalPath, commonRoot);
|
|
161
|
+
if (link) {
|
|
162
|
+
results.push({
|
|
163
|
+
op,
|
|
164
|
+
status: "failed",
|
|
165
|
+
error: `${link} is now a junction/symlink, so restoring ${entry.originalPath} would write through it into ${await fs.realpath(link).catch(() => "its target")}. ` +
|
|
166
|
+
`Remove the link first (deploy --deployMode pak --unlink, or by hand) and run the restore again.`,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
144
170
|
await copyFilePreservingMtime(src, entry.originalPath);
|
|
145
171
|
results.push({ op, status: "applied" });
|
|
146
172
|
}
|
|
@@ -170,3 +196,39 @@ export async function restoreBackup(backupDir, options = {}) {
|
|
|
170
196
|
: "An integrity check failed before that file was restored; the backup itself is untouched, so fix the problem and retry."),
|
|
171
197
|
};
|
|
172
198
|
}
|
|
199
|
+
function commonAncestor(dirs) {
|
|
200
|
+
if (dirs.length === 0)
|
|
201
|
+
return undefined;
|
|
202
|
+
let common = path.resolve(dirs[0]);
|
|
203
|
+
for (const dir of dirs.slice(1)) {
|
|
204
|
+
let candidate = path.resolve(dir);
|
|
205
|
+
while (candidate !== common && !isUnder(common, candidate)) {
|
|
206
|
+
const parent = path.dirname(common);
|
|
207
|
+
if (parent === common)
|
|
208
|
+
return undefined;
|
|
209
|
+
common = parent;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return common;
|
|
213
|
+
}
|
|
214
|
+
function isUnder(parent, child) {
|
|
215
|
+
const rel = path.relative(parent, child);
|
|
216
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
217
|
+
}
|
|
218
|
+
async function linkAncestor(file, stopAt) {
|
|
219
|
+
let dir = path.dirname(path.resolve(file));
|
|
220
|
+
for (;;) {
|
|
221
|
+
try {
|
|
222
|
+
if ((await fs.lstat(dir)).isSymbolicLink())
|
|
223
|
+
return dir;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
}
|
|
227
|
+
if (stopAt === undefined || dir === stopAt || !isUnder(stopAt, dir))
|
|
228
|
+
return undefined;
|
|
229
|
+
const parent = path.dirname(dir);
|
|
230
|
+
if (parent === dir)
|
|
231
|
+
return undefined;
|
|
232
|
+
dir = parent;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -262,7 +262,11 @@ const CP2077_PROBES = [
|
|
|
262
262
|
summary: "Where an entity is. Attested for item objects; unverified for a sector-placed prop.",
|
|
263
263
|
citation: "cp2077.entity.transform",
|
|
264
264
|
paramsSchema: z.object({ entityRef: str() }).strict(),
|
|
265
|
-
evidence: "Observed in game: a test mod's own init.lua comment says its tag \"removes legacy dynamic cases from older experiments\" and the real case is a static sector prop, so GetTaggedIDs on it returned 0 twice at two locations — not a location miss. That tag marks legacy dev spawns only and is the wrong entry point for a sector-placed prop; a sector-placed prop needs a different probe mechanism, not just a re-run of this one. Stays unverified."
|
|
265
|
+
evidence: "Observed in game: a test mod's own init.lua comment says its tag \"removes legacy dynamic cases from older experiments\" and the real case is a static sector prop, so GetTaggedIDs on it returned 0 twice at two locations — not a location miss. That tag marks legacy dev spawns only and is the wrong entry point for a sector-placed prop; a sector-placed prop needs a different probe mechanism, not just a re-run of this one. Stays unverified. " +
|
|
266
|
+
"RTTI (rayshader/cp2077-nativedb b5d29af, game 2.31): both calls are declared on entEntity itself — " +
|
|
267
|
+
"GetWorldPosition() -> Vector4, GetWorldOrientation() -> Quaternion — and a loot-container prop's entity class " +
|
|
268
|
+
"(LootContainerObjectAnimatedByTransform) reaches entEntity through gameLootContainerBase/gameObject/entGameEntity, " +
|
|
269
|
+
"so such a prop has both methods by inheritance. The methods were never in doubt; getting a handle to the prop is.",
|
|
266
270
|
},
|
|
267
271
|
{
|
|
268
272
|
kind: "cp2077.entity.appearance",
|
|
@@ -273,6 +277,12 @@ const CP2077_PROBES = [
|
|
|
273
277
|
summary: "Acceptance regression, differential: did the sector node's appearanceName resolve?",
|
|
274
278
|
citation: "cp2077.entity.appearance",
|
|
275
279
|
paramsSchema: z.object({ tag: str(), baselineId: str() }).strict(),
|
|
280
|
+
evidence: "Composite of cp2077.entity.tagged + .components + .transform against a baseline; blocked the same way " +
|
|
281
|
+
"cp2077.entity.tagged is for a sector-placed prop (its first step). RTTI (rayshader/cp2077-nativedb " +
|
|
282
|
+
"b5d29af, game 2.31): entEntity::GetCurrentAppearanceName() -> CName exists, inherited by loot-container " +
|
|
283
|
+
"props, and entIComponent::GetAppearanceName() -> CName exists per component. That is a direct read of the " +
|
|
284
|
+
"question this row asks (did appearanceName resolve?) and a smaller call than the composite, but no CET mod " +
|
|
285
|
+
"or wiki page calls it from Lua, so it is a candidate for the next live run, not a redesign of this row.",
|
|
276
286
|
},
|
|
277
287
|
{
|
|
278
288
|
kind: "cp2077.tweakdb.record",
|
|
@@ -302,9 +312,48 @@ const CP2077_PROBES = [
|
|
|
302
312
|
context: "cet",
|
|
303
313
|
effect: "read",
|
|
304
314
|
status: "unverified",
|
|
305
|
-
summary: "An interaction component is on the entity. The component name is
|
|
315
|
+
summary: "An interaction component is on the entity. The component name is per-entity: a loot-container template " +
|
|
316
|
+
"names it \"interactions\" (class gameinteractionsComponent); nothing calls it from Lua yet.",
|
|
306
317
|
citation: "cp2077.interaction.present",
|
|
307
318
|
paramsSchema: z.object({ entityRef: str() }).strict(),
|
|
319
|
+
evidence: "A loot-container entity template's components list { $type: gameinteractionsComponent, name: \"interactions\" } " +
|
|
320
|
+
"alongside inventory, scanning, workspotMapper, root, audioSound, vision, UI_Slots, GameplayRole, state_fxes and " +
|
|
321
|
+
"targeting. RTTI (rayshader/cp2077-nativedb b5d29af): gameinteractionsComponent -> entIPlacedComponent -> " +
|
|
322
|
+
"entIComponent; entEntity::FindComponentByName(componentName: CName) -> ref<entIComponent>; no generic " +
|
|
323
|
+
"enumerate-by-class call exists on entEntity/gameObject, so exact names stay the only path. A design-time " +
|
|
324
|
+
"template field is not an in-engine observation that the component resolves on the streamed instance: stays unverified.",
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
kind: "cp2077.entity.nearby",
|
|
328
|
+
game: "cyberpunk2077",
|
|
329
|
+
context: "cet",
|
|
330
|
+
effect: "read",
|
|
331
|
+
status: "unverified",
|
|
332
|
+
summary: "Entities within a radius of an anchor entity (the player by default). filter \"npc\" (default) asks " +
|
|
333
|
+
"GetNPCsAroundObject; filter \"objects\" asks GetEntitiesAroundObject with an Obj_*-wide target search " +
|
|
334
|
+
"filter, the shape that could find a placed static prop.",
|
|
335
|
+
citation: "cp2077.entity.nearby",
|
|
336
|
+
paramsSchema: z
|
|
337
|
+
.object({
|
|
338
|
+
radius: z.number().positive(),
|
|
339
|
+
filter: z.enum(["npc", "objects"]).optional(),
|
|
340
|
+
entityRef: str().optional(),
|
|
341
|
+
tag: str().optional(),
|
|
342
|
+
index: z.number().int().positive().optional(),
|
|
343
|
+
})
|
|
344
|
+
.strict(),
|
|
345
|
+
evidence: "rayshader/cp2077-nativedb at b5d29af (v2.1.0, game 2.31; RTTI dumped by WopsS/RED4.RTTIDumper) classes.json: " +
|
|
346
|
+
"gameObject (parent entGameEntity) carries GetNPCsAroundObject(range: Float) -> array<ref<NPCPuppet>> and " +
|
|
347
|
+
"GetEntitiesAroundObject(range: Float, searchFilter: gameTargetSearchFilter) -> array<ref<entEntity>>; " +
|
|
348
|
+
"globals.json lists the filter constructors TSF_All/TSF_Any/TSF_Not/TSF_And/TSF_Or/TSF_NPC/TSF_EnemyNPC/" +
|
|
349
|
+
"TSF_Quickhackable and enums.json the mask members (TSFMV). No shipped CET mod or wiki page calls any of it, " +
|
|
350
|
+
"but CET's own source (yamashi/CyberEngineTweaks 9a8522f; knowledge facts cp2077.cet.*) pins the Lua side: " +
|
|
351
|
+
"globals are exposed bare and via Game.*, a returned struct passes back as a parameter, an undeclared enum value " +
|
|
352
|
+
"is coerced to 0 silently (so the handler builds the filter from declared members via Enum.new and TSF_Or, never " +
|
|
353
|
+
"an ORed mask), and array returns are 1-based tables with nil holes for null handles. What no source can supply " +
|
|
354
|
+
"is the live result: TSF_Any/TSF_Or semantics, whether a sector-placed prop is in the targeting set (a " +
|
|
355
|
+
"loot-container template does carry a gameTargetingComponent), and whether the game marks TSF_* isExec. Never " +
|
|
356
|
+
"run in game; stays unverified until a live run exercises it.",
|
|
308
357
|
},
|
|
309
358
|
];
|
|
310
359
|
export const PROBE_CATALOG = [...BG3_PROBES, ...CP2077_PROBES];
|
|
@@ -221,7 +221,7 @@ export async function convert(id, sourceAbs, destAbs, options = {}) {
|
|
|
221
221
|
const plan = {
|
|
222
222
|
purpose: `text convert ${entry.id}: ${path.basename(sourceAbs)} -> ${path.basename(destAbs)}`,
|
|
223
223
|
operations: [planFile(destAbs, content)],
|
|
224
|
-
backupRoot:
|
|
224
|
+
...(options.backupRoot ? { backupRoot: options.backupRoot } : {}),
|
|
225
225
|
};
|
|
226
226
|
const writeReport = await executePlan(plan, {
|
|
227
227
|
mode: "apply",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
|
|
2
|
-
export function defaultLocateTool(projectOverrides, probe = "all") {
|
|
2
|
+
export function defaultLocateTool(projectOverrides, probe = "all", projectTrust) {
|
|
3
3
|
return async (id) => {
|
|
4
4
|
const spec = TOOL_SPECS.find((s) => s.id === id);
|
|
5
5
|
if (!spec) {
|
|
@@ -11,6 +11,6 @@ export function defaultLocateTool(projectOverrides, probe = "all") {
|
|
|
11
11
|
hint: `"${id}" is not a known tool id in ModWright's toolchain catalog (see core/toolchain/specs.ts).`,
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
-
return coreLocateTool(spec, { projectOverrides, probe });
|
|
14
|
+
return coreLocateTool(spec, { projectOverrides, probe, ...(projectTrust ? { projectTrust } : {}) });
|
|
15
15
|
};
|
|
16
16
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { killTree } from "./run.js";
|
|
2
3
|
import { promises as fs } from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { StringDecoder } from "node:string_decoder";
|
|
5
6
|
import { pathExists } from "../fsutil.js";
|
|
6
7
|
import { userDataDir } from "../home.js";
|
|
8
|
+
import { trustRefusal } from "../trust.js";
|
|
7
9
|
import { loadUserConfig } from "../userconfig.js";
|
|
8
10
|
import { toolEnvironment } from "./env.js";
|
|
9
11
|
import { TOOL_SPECS } from "./specs.js";
|
|
@@ -145,7 +147,7 @@ async function runVersionProbe(execPath, versionArgs, versionPattern) {
|
|
|
145
147
|
resolve(result);
|
|
146
148
|
};
|
|
147
149
|
const timer = setTimeout(() => {
|
|
148
|
-
child
|
|
150
|
+
killTree(child);
|
|
149
151
|
finish(out);
|
|
150
152
|
}, VERSION_PROBE_TIMEOUT_MS);
|
|
151
153
|
const outDecoder = new StringDecoder("utf8");
|
|
@@ -227,6 +229,16 @@ export async function locateTool(spec, options = {}) {
|
|
|
227
229
|
return found(fromEnv, "env");
|
|
228
230
|
const override = options.projectOverrides?.[spec.id];
|
|
229
231
|
const fromProject = override ? await resolveOverride(override, spec) : undefined;
|
|
232
|
+
if (fromProject && options.projectTrust && !options.projectTrust.trusted) {
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
found: false,
|
|
236
|
+
path: fromProject,
|
|
237
|
+
source: "project",
|
|
238
|
+
untrusted: true,
|
|
239
|
+
hint: trustRefusal(options.projectTrust.root, `its modwright.json points ${spec.id} at ${fromProject}`),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
230
242
|
if (fromProject)
|
|
231
243
|
return found(fromProject, "project");
|
|
232
244
|
const userTools = options.userTools === undefined ? (await loadUserConfig()).config.tools : options.userTools;
|
|
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
5
|
import { toolEnvironment } from "./env.js";
|
|
6
|
+
import { DEFAULT_TOOL_TIMEOUT_MS } from "./types.js";
|
|
6
7
|
const MAX_CAPTURE_BYTES = 1_000_000;
|
|
7
8
|
const TRUNCATION_NOTE = "\n...[truncated]";
|
|
8
9
|
export function assertAbsolute(paths) {
|
|
@@ -86,7 +87,7 @@ export function batchFileArgv(file, args, env = process.env) {
|
|
|
86
87
|
export function isBatchFile(p) {
|
|
87
88
|
return /\.(bat|cmd)$/i.test(p);
|
|
88
89
|
}
|
|
89
|
-
function killTree(child) {
|
|
90
|
+
export function killTree(child) {
|
|
90
91
|
if (process.platform === "win32" && child.pid !== undefined) {
|
|
91
92
|
try {
|
|
92
93
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => child.kill());
|
|
@@ -151,7 +152,8 @@ export async function runTool(location, options) {
|
|
|
151
152
|
resolve(spawnFailed(location, command, args, started, err));
|
|
152
153
|
return;
|
|
153
154
|
}
|
|
154
|
-
const
|
|
155
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
|
|
156
|
+
const timer = timeoutMs
|
|
155
157
|
? setTimeout(() => {
|
|
156
158
|
if (settled)
|
|
157
159
|
return;
|
|
@@ -171,10 +173,10 @@ export async function runTool(location, options) {
|
|
|
171
173
|
stderrTruncated: stderrTruncated.value,
|
|
172
174
|
error: {
|
|
173
175
|
code: "timeout",
|
|
174
|
-
message: `${location.label} did not finish within ${
|
|
176
|
+
message: `${location.label} did not finish within ${timeoutMs}ms.`,
|
|
175
177
|
},
|
|
176
178
|
});
|
|
177
|
-
},
|
|
179
|
+
}, timeoutMs)
|
|
178
180
|
: undefined;
|
|
179
181
|
const cap = options.maxCaptureBytes ?? MAX_CAPTURE_BYTES;
|
|
180
182
|
const stdoutDecoder = new StringDecoder("utf8");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const DEFAULT_TOOL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { userConfigDir, userDataDir } from "./home.js";
|
|
5
|
+
import { planFile, planMkdir } from "./safety/index.js";
|
|
6
|
+
export const TRUST_SCHEMA_VERSION = 1;
|
|
7
|
+
export const TRUST_ALL_ENV = "MODWRIGHT_TRUST_ALL";
|
|
8
|
+
const trustEntrySchema = z.object({
|
|
9
|
+
root: z.string().min(1),
|
|
10
|
+
name: z.string().optional(),
|
|
11
|
+
trustedAt: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
const trustFileSchema = z.object({
|
|
14
|
+
schemaVersion: z.literal(TRUST_SCHEMA_VERSION),
|
|
15
|
+
projects: z.record(trustEntrySchema).default({}),
|
|
16
|
+
});
|
|
17
|
+
export function trustFilePath() {
|
|
18
|
+
return path.join(userConfigDir(), "trusted-projects.json");
|
|
19
|
+
}
|
|
20
|
+
export function trustKey(root, platform = process.platform) {
|
|
21
|
+
const resolved = path.resolve(root).replace(/[\\/]+$/, "");
|
|
22
|
+
return platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
23
|
+
}
|
|
24
|
+
export async function loadTrust(file = trustFilePath()) {
|
|
25
|
+
const empty = { schemaVersion: TRUST_SCHEMA_VERSION, projects: {} };
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = await fs.readFile(file, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const missing = error.code === "ENOENT";
|
|
32
|
+
return { trust: empty, file, exists: !missing, problems: missing ? [] : [`${file} could not be read: ${error.message}`] };
|
|
33
|
+
}
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = JSON.parse(text.replace(/^/, ""));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { trust: empty, file, exists: true, problems: [`${file} is not valid JSON (${error.message}); no project is trusted until it is fixed.`] };
|
|
40
|
+
}
|
|
41
|
+
const parsed = trustFileSchema.safeParse(raw);
|
|
42
|
+
if (!parsed.success) {
|
|
43
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
44
|
+
return { trust: empty, file, exists: true, problems: [`${file} does not match schemaVersion ${TRUST_SCHEMA_VERSION} (${issues}); no project is trusted until it is fixed.`] };
|
|
45
|
+
}
|
|
46
|
+
return { trust: parsed.data, file, exists: true, problems: [] };
|
|
47
|
+
}
|
|
48
|
+
export async function isProjectTrusted(root, options = {}) {
|
|
49
|
+
if ((options.env ?? process.env)[TRUST_ALL_ENV] === "1")
|
|
50
|
+
return true;
|
|
51
|
+
const loaded = await loadTrust(options.file);
|
|
52
|
+
return trustKey(root) in loaded.trust.projects;
|
|
53
|
+
}
|
|
54
|
+
export function projectTrustNeeds(project) {
|
|
55
|
+
const toolchain = Object.keys(project.toolchain ?? {});
|
|
56
|
+
const exec = [];
|
|
57
|
+
(project.build?.steps ?? []).forEach((step, i) => {
|
|
58
|
+
if (typeof step === "string" || step.step !== "exec")
|
|
59
|
+
return;
|
|
60
|
+
exec.push(step.id ? `"${step.id}" (${step.command})` : `#${i} (${step.command})`);
|
|
61
|
+
});
|
|
62
|
+
return { toolchain, exec };
|
|
63
|
+
}
|
|
64
|
+
export function trustRefusal(root, what) {
|
|
65
|
+
return (`${root} is not a trusted project, and ${what}. ModWright runs a project's own executables and commands only after ` +
|
|
66
|
+
`you trust it once on this machine. Read its modwright.json first, then: check_toolchain action=trust projectPath=${JSON.stringify(root)} mode=apply. ` +
|
|
67
|
+
`(${TRUST_ALL_ENV}=1 trusts every project, for CI.)`);
|
|
68
|
+
}
|
|
69
|
+
export function describeNeeds(needs) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
if (needs.exec.length > 0)
|
|
72
|
+
parts.push(`its build runs exec step(s) ${needs.exec.join(", ")}`);
|
|
73
|
+
if (needs.toolchain.length > 0)
|
|
74
|
+
parts.push(`its modwright.json overrides the tool path(s) for ${needs.toolchain.join(", ")}`);
|
|
75
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
76
|
+
}
|
|
77
|
+
export async function planTrust(action, root, name, options = {}) {
|
|
78
|
+
const file = options.file ?? trustFilePath();
|
|
79
|
+
const loaded = await loadTrust(file);
|
|
80
|
+
if (loaded.problems.length > 0)
|
|
81
|
+
return { ok: false, error: `${loaded.problems.join(" ")} Fix or remove ${file} before changing it through ModWright.` };
|
|
82
|
+
const key = trustKey(root);
|
|
83
|
+
const previous = loaded.trust.projects[key];
|
|
84
|
+
const write = (trust, purpose) => ({
|
|
85
|
+
purpose,
|
|
86
|
+
operations: [planMkdir(path.dirname(file)), planFile(file, JSON.stringify(trust, null, 2) + "\n")],
|
|
87
|
+
backupRoot: path.join(userDataDir(), "backups"),
|
|
88
|
+
});
|
|
89
|
+
if (action === "untrust") {
|
|
90
|
+
if (!previous)
|
|
91
|
+
return { ok: false, error: `${root} is not in ${file}; nothing to untrust.` };
|
|
92
|
+
const { [key]: _dropped, ...rest } = loaded.trust.projects;
|
|
93
|
+
return { ok: true, action, root: path.resolve(root), file, plan: write({ ...loaded.trust, projects: rest }, `stop trusting ${root}`), previous };
|
|
94
|
+
}
|
|
95
|
+
const entry = { root: path.resolve(root), ...(name ? { name } : {}), trustedAt: (options.now ?? (() => new Date()))().toISOString() };
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
action,
|
|
99
|
+
root: entry.root,
|
|
100
|
+
file,
|
|
101
|
+
plan: write({ ...loaded.trust, projects: { ...loaded.trust.projects, [key]: entry } }, `trust ${root} to run its toolchain overrides and exec steps`),
|
|
102
|
+
entry,
|
|
103
|
+
...(previous ? { previous } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { listMods } from "../mods.js";
|
|
2
|
+
const VALUE_CHARS = 200;
|
|
3
|
+
export function shortValue(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return undefined;
|
|
6
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
7
|
+
if (text === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (text.length <= VALUE_CHARS)
|
|
10
|
+
return value;
|
|
11
|
+
return `${text.slice(0, VALUE_CHARS)}…`;
|
|
12
|
+
}
|
|
13
|
+
export async function collectWrites(surface, install, options = {}) {
|
|
14
|
+
const collection = {
|
|
15
|
+
writers: [],
|
|
16
|
+
coverage: { modsScanned: 0, writesFound: 0, rootsScanned: [], errors: [], unitNotes: [] },
|
|
17
|
+
};
|
|
18
|
+
if (!surface.extractWrites)
|
|
19
|
+
return collection;
|
|
20
|
+
const listOptions = { fileLimit: 20000 };
|
|
21
|
+
if (options.rootIds)
|
|
22
|
+
listOptions.rootIds = options.rootIds;
|
|
23
|
+
const { mods, roots } = await listMods(surface, install, listOptions);
|
|
24
|
+
const rootById = new Map(roots.map((r) => [r.id, r]));
|
|
25
|
+
const scannedRoots = new Set();
|
|
26
|
+
const byWriter = new Map();
|
|
27
|
+
const scanned = new Set();
|
|
28
|
+
for (const mod of mods) {
|
|
29
|
+
const root = rootById.get(mod.rootId);
|
|
30
|
+
if (!root)
|
|
31
|
+
continue;
|
|
32
|
+
let extraction;
|
|
33
|
+
try {
|
|
34
|
+
extraction = await surface.extractWrites(mod, root, install);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
collection.coverage.errors.push({ writerId: mod.id, file: mod.path, error: error.message });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!extraction)
|
|
41
|
+
continue;
|
|
42
|
+
scannedRoots.add(root.id);
|
|
43
|
+
if (extraction.writes.length === 0)
|
|
44
|
+
scanned.add(mod.id);
|
|
45
|
+
if (extraction.unitNote && !collection.coverage.unitNotes.includes(extraction.unitNote)) {
|
|
46
|
+
collection.coverage.unitNotes.push(extraction.unitNote);
|
|
47
|
+
}
|
|
48
|
+
for (const err of extraction.errors)
|
|
49
|
+
collection.coverage.errors.push({ writerId: mod.id, ...err });
|
|
50
|
+
for (const write of extraction.writes) {
|
|
51
|
+
const writerId = write.unit ? `${mod.id}/${write.unit}` : mod.id;
|
|
52
|
+
scanned.add(writerId);
|
|
53
|
+
let writer = byWriter.get(writerId);
|
|
54
|
+
if (!writer) {
|
|
55
|
+
writer = { writerId, name: write.unit ?? mod.name, rootId: mod.rootId, writes: [] };
|
|
56
|
+
const state = write.unit ? extraction.unitStates?.[write.unit] : undefined;
|
|
57
|
+
const inactive = state?.inactive ?? extraction.inactive;
|
|
58
|
+
const identity = state?.identity ?? extraction.identity;
|
|
59
|
+
if (inactive)
|
|
60
|
+
writer.inactive = inactive;
|
|
61
|
+
if (identity)
|
|
62
|
+
writer.identity = identity;
|
|
63
|
+
byWriter.set(writerId, writer);
|
|
64
|
+
}
|
|
65
|
+
writer.writes.push(write);
|
|
66
|
+
collection.coverage.writesFound++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
collection.writers = [...byWriter.values()];
|
|
70
|
+
collection.coverage.modsScanned = scanned.size;
|
|
71
|
+
const inactive = collection.writers.filter((w) => w.inactive).length;
|
|
72
|
+
if (inactive > 0)
|
|
73
|
+
collection.coverage.inactiveWriters = inactive;
|
|
74
|
+
collection.coverage.rootsScanned = [...scannedRoots];
|
|
75
|
+
return collection;
|
|
76
|
+
}
|
|
77
|
+
function groupByKey(collection, keep) {
|
|
78
|
+
const byKey = new Map();
|
|
79
|
+
for (const writer of collection.writers) {
|
|
80
|
+
for (const write of writer.writes) {
|
|
81
|
+
if (!keep(write))
|
|
82
|
+
continue;
|
|
83
|
+
const k = write.key.toLowerCase();
|
|
84
|
+
let entry = byKey.get(k);
|
|
85
|
+
if (!entry) {
|
|
86
|
+
entry = { key: write.key, keyKind: write.keyKind, contested: false, writers: [] };
|
|
87
|
+
byKey.set(k, entry);
|
|
88
|
+
}
|
|
89
|
+
const row = {
|
|
90
|
+
writerId: writer.writerId,
|
|
91
|
+
name: writer.name,
|
|
92
|
+
op: write.op,
|
|
93
|
+
file: write.file,
|
|
94
|
+
certainty: write.certainty,
|
|
95
|
+
};
|
|
96
|
+
const value = shortValue(write.value);
|
|
97
|
+
if (value !== undefined)
|
|
98
|
+
row.value = value;
|
|
99
|
+
if (write.line !== undefined)
|
|
100
|
+
row.line = write.line;
|
|
101
|
+
if (write.condition !== undefined)
|
|
102
|
+
row.condition = write.condition;
|
|
103
|
+
if (writer.inactive)
|
|
104
|
+
row.inactive = writer.inactive;
|
|
105
|
+
if (writer.identity)
|
|
106
|
+
row.identity = writer.identity;
|
|
107
|
+
entry.writers.push(row);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const entry of byKey.values()) {
|
|
111
|
+
const live = entry.writers.filter((w) => !w.inactive);
|
|
112
|
+
const writers = new Set(live.map((w) => w.identity ?? w.writerId));
|
|
113
|
+
if (writers.size < 2)
|
|
114
|
+
continue;
|
|
115
|
+
if (new Set(live.map((w) => JSON.stringify([w.op, w.value]))).size === 1) {
|
|
116
|
+
entry.agree = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (live.every((w) => w.op === "append" || w.op === "remove")) {
|
|
120
|
+
const items = (op) => new Set(live.filter((w) => w.op === op).flatMap((w) => (Array.isArray(w.value) ? w.value : [w.value])).map((v) => JSON.stringify(v)));
|
|
121
|
+
const appended = items("append");
|
|
122
|
+
entry.contested = [...items("remove")].some((v) => appended.has(v));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
entry.contested = true;
|
|
126
|
+
}
|
|
127
|
+
return byKey;
|
|
128
|
+
}
|
|
129
|
+
export function whoTouches(collection, query, limit = 50) {
|
|
130
|
+
const q = query.trim().toLowerCase();
|
|
131
|
+
const all = groupByKey(collection, () => true);
|
|
132
|
+
const pick = (matchedBy, keys) => {
|
|
133
|
+
keys.sort((a, b) => Number(b.contested) - Number(a.contested) || a.key.localeCompare(b.key));
|
|
134
|
+
const result = { query, matchedBy, keys: keys.slice(0, limit) };
|
|
135
|
+
if (keys.length > limit)
|
|
136
|
+
result.omittedKeys = keys.length - limit;
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
const exact = all.get(q);
|
|
140
|
+
if (exact) {
|
|
141
|
+
const under = [...all.entries()].filter(([k]) => k !== q && isUnder(k, q)).map(([, v]) => v);
|
|
142
|
+
return pick("exact", [exact, ...under]);
|
|
143
|
+
}
|
|
144
|
+
const prefixed = [...all.entries()].filter(([k]) => isUnder(k, q)).map(([, v]) => v);
|
|
145
|
+
if (prefixed.length > 0)
|
|
146
|
+
return pick("prefix", prefixed);
|
|
147
|
+
const containing = [...all.entries()].filter(([k]) => k.includes(q)).map(([, v]) => v);
|
|
148
|
+
if (containing.length > 0)
|
|
149
|
+
return pick("substring", containing);
|
|
150
|
+
return { query, matchedBy: "none", keys: [] };
|
|
151
|
+
}
|
|
152
|
+
function isUnder(key, parent) {
|
|
153
|
+
if (!key.startsWith(parent) || key.length === parent.length)
|
|
154
|
+
return false;
|
|
155
|
+
const next = key[parent.length];
|
|
156
|
+
return next === "." || next === "#" || next === "/";
|
|
157
|
+
}
|
|
158
|
+
export function whatTouches(collection, modQuery, limit = 100) {
|
|
159
|
+
const q = modQuery.trim().toLowerCase();
|
|
160
|
+
const exact = collection.writers.filter((w) => w.writerId.toLowerCase() === q || w.name.toLowerCase() === q);
|
|
161
|
+
const matches = exact.length > 0 ? exact : collection.writers.filter((w) => w.writerId.toLowerCase().includes(q) || w.name.toLowerCase().includes(q));
|
|
162
|
+
const byKey = groupByKey(collection, () => true);
|
|
163
|
+
return matches.map((writer) => {
|
|
164
|
+
const sharedWith = [];
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
for (const write of writer.writes) {
|
|
167
|
+
const k = write.key.toLowerCase();
|
|
168
|
+
if (seen.has(k))
|
|
169
|
+
continue;
|
|
170
|
+
seen.add(k);
|
|
171
|
+
const entry = byKey.get(k);
|
|
172
|
+
if (!entry?.contested)
|
|
173
|
+
continue;
|
|
174
|
+
const others = [...new Set(entry.writers.filter((w) => w.writerId !== writer.writerId).map((w) => w.name))];
|
|
175
|
+
if (others.length > 0)
|
|
176
|
+
sharedWith.push({ key: entry.key, others });
|
|
177
|
+
}
|
|
178
|
+
const summary = {
|
|
179
|
+
writerId: writer.writerId,
|
|
180
|
+
name: writer.name,
|
|
181
|
+
rootId: writer.rootId,
|
|
182
|
+
writeCount: writer.writes.length,
|
|
183
|
+
sharedWith,
|
|
184
|
+
writes: writer.writes.slice(0, limit).map((w) => ({ ...w, value: shortValue(w.value) })),
|
|
185
|
+
};
|
|
186
|
+
if (writer.writes.length > limit)
|
|
187
|
+
summary.omittedWrites = writer.writes.length - limit;
|
|
188
|
+
return summary;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
export function duplicateCopies(collection) {
|
|
192
|
+
const byIdentity = new Map();
|
|
193
|
+
for (const writer of collection.writers) {
|
|
194
|
+
if (!writer.identity || writer.inactive)
|
|
195
|
+
continue;
|
|
196
|
+
const names = byIdentity.get(writer.identity) ?? [];
|
|
197
|
+
names.push(writer.name);
|
|
198
|
+
byIdentity.set(writer.identity, names);
|
|
199
|
+
}
|
|
200
|
+
return [...byIdentity].filter(([, names]) => names.length > 1).map(([identity, writers]) => ({ identity, writers: writers.sort() }));
|
|
201
|
+
}
|
|
202
|
+
export function contestedKeys(collection, limit = 100) {
|
|
203
|
+
const keys = [...groupByKey(collection, () => true).values()].filter((k) => k.contested);
|
|
204
|
+
keys.sort((a, b) => new Set(b.writers.map((w) => w.writerId)).size - new Set(a.writers.map((w) => w.writerId)).size || a.key.localeCompare(b.key));
|
|
205
|
+
return { total: keys.length, keys: keys.slice(0, limit) };
|
|
206
|
+
}
|