modwright 0.1.0 → 0.1.2
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 +91 -13
- package/bridges/bg3-se/README.md +15 -0
- package/bridges/cp2077-cet/README.md +14 -0
- 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/steps/core.js +4 -4
- package/dist/core/deploy/plan.js +68 -1
- package/dist/core/deploy/reload.js +5 -3
- package/dist/core/ledger/hooks.js +22 -5
- package/dist/core/logs/scan.js +22 -6
- package/dist/core/project/index.js +1 -0
- package/dist/core/project/load.js +3 -26
- 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/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/index.js +33 -7
- package/dist/server.js +110 -21
- package/dist/surfaces/cyberpunk2077/compat.js +8 -4
- package/dist/surfaces/cyberpunk2077/logs.js +4 -3
- package/dist/surfaces/cyberpunk2077/surface.js +2 -1
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +9 -3
- package/dist/surfaces/eldenring/loaders.js +7 -1
- package/package.json +5 -1
- package/scaffolds/baldursgate3/new-project.yaml +4 -1
- package/scaffolds/cyberpunk2077/new-project.yaml +4 -1
package/README.md
CHANGED
|
@@ -15,8 +15,9 @@ The core is generic. Each game is a **surface**: one module that declares
|
|
|
15
15
|
where things live and what the rules are, so every tool works for every
|
|
16
16
|
supported game.
|
|
17
17
|
|
|
18
|
-
**Status:** 0.x
|
|
19
|
-
|
|
18
|
+
**Status:** 0.x, and the API is not stable yet. How far each game is tested
|
|
19
|
+
differs a lot; "Supported games" says which surfaces have been used against
|
|
20
|
+
a real install and which are built from documentation alone.
|
|
20
21
|
|
|
21
22
|
## Why
|
|
22
23
|
|
|
@@ -65,16 +66,26 @@ pointed at once.
|
|
|
65
66
|
|
|
66
67
|
## Supported games
|
|
67
68
|
|
|
68
|
-
| Surface | Game | Mod systems modeled |
|
|
69
|
-
|
|
70
|
-
| `cyberpunk2077` | Cyberpunk 2077 | legacy archives, REDmod, redscript, TweakXL, ArchiveXL, RED4ext, CET; the TweakDB vanilla index; garment, vehicle, tweak and packaging validators; the CET bridge |
|
|
71
|
-
| `baldursgate3` | Baldur's Gate 3 | `.pak` mods, loose overrides, Script Extender, the native mod loader; the stats, GUID and handle vanilla index; stats, story, progression and visual-bank validators; the Script Extender bridge |
|
|
72
|
-
| `skyrimse` | Skyrim Special Edition | plugins, BSA archives, SKSE plugins, loose files |
|
|
73
|
-
| `eldenring` | Elden Ring | me3 profiles, Mod Engine 2 overlay and DLLs, UXM loose files; paramdef-typed param exports, the `.me3` profile, package-layout and DCX rules |
|
|
74
|
-
| `valheim` | Valheim | BepInEx plugins, patchers, config, MonoMod, the Doorstop loader; Thunderstore packaging, plugin-source and config rules; the vanilla index from Jötunn's dumps |
|
|
75
|
-
| `stardewvalley` | Stardew Valley | SMAPI mods and Content Patcher packs, the load-order override lists, SMAPI's log; the manifest and `content.json` rules; the wiki schema index, built from the wiki's data pages, which `check_toolchain` fetches for you |
|
|
76
|
-
| `subnautica2` | Subnautica 2 | `~mods` paks, LogicMods, UE4SS mods, DLC-style plugins; triplet, mount-point, TOC and UE4SS-shape rules |
|
|
77
|
-
| `nivalisnights` | Nivalis Nights | BepInEx 6 IL2CPP plugins, patchers, config, generated interop, MelonLoader mods; the vanilla index (items, recipes, vendors, venues, NPCs, by GUID) built from your install with Cpp2IL and UnityPy |
|
|
69
|
+
| Surface | Game | Tested | Mod systems modeled |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| `cyberpunk2077` | Cyberpunk 2077 | field-tested | legacy archives, REDmod, redscript, TweakXL, ArchiveXL, RED4ext, CET; the TweakDB vanilla index; garment, vehicle, tweak and packaging validators; the CET bridge |
|
|
72
|
+
| `baldursgate3` | Baldur's Gate 3 | field-tested | `.pak` mods, loose overrides, Script Extender, the native mod loader; the stats, GUID and handle vanilla index; stats, story, progression and visual-bank validators; the Script Extender bridge |
|
|
73
|
+
| `skyrimse` | Skyrim Special Edition | research only | plugins, BSA archives, SKSE plugins, loose files |
|
|
74
|
+
| `eldenring` | Elden Ring | research only | me3 profiles, Mod Engine 2 overlay and DLLs, UXM loose files; paramdef-typed param exports, the `.me3` profile, package-layout and DCX rules |
|
|
75
|
+
| `valheim` | Valheim | research only | BepInEx plugins, patchers, config, MonoMod, the Doorstop loader; Thunderstore packaging, plugin-source and config rules; the vanilla index from Jötunn's dumps |
|
|
76
|
+
| `stardewvalley` | Stardew Valley | research only | SMAPI mods and Content Patcher packs, the load-order override lists, SMAPI's log; the manifest and `content.json` rules; the wiki schema index, built from the wiki's data pages, which `check_toolchain` fetches for you |
|
|
77
|
+
| `subnautica2` | Subnautica 2 | research only | `~mods` paks, LogicMods, UE4SS mods, DLC-style plugins; triplet, mount-point, TOC and UE4SS-shape rules |
|
|
78
|
+
| `nivalisnights` | Nivalis Nights | checked on an install | BepInEx 6 IL2CPP plugins, patchers, config, generated interop, MelonLoader mods; the vanilla index (items, recipes, vendors, venues, NPCs, by GUID) built from your install with Cpp2IL and UnityPy |
|
|
79
|
+
|
|
80
|
+
- **Field-tested:** used against real installs, real mod projects and the
|
|
81
|
+
running game, through the in-game bridge, over many field runs.
|
|
82
|
+
- **Checked on an install:** detection, logs, compatibility and the vanilla
|
|
83
|
+
index were checked against an installed copy of the game. No mod project
|
|
84
|
+
has been built or deployed through it yet, and it has no bridge.
|
|
85
|
+
- **Research only:** written from the game's modding documentation, its
|
|
86
|
+
community tools' sources and their published file formats, and tested
|
|
87
|
+
against fixtures. No install was behind it. Expect gaps, and please report
|
|
88
|
+
them.
|
|
78
89
|
|
|
79
90
|
Installs are auto-detected from Steam (every library folder), plus GOG and
|
|
80
91
|
Epic paths where they apply. Override with `installPath` on any tool, or set
|
|
@@ -94,7 +105,7 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
|
|
|
94
105
|
| `read_log` | Tail a mod-related log (redscript, RED4ext, CET, SKSE, BG3SE, BepInEx, SMAPI and more). |
|
|
95
106
|
| `triage_logs` | Read every mod log for the current run and classify lines against known failure signatures. It returns one verdict: which systems loaded, what failed first, and the usual fix. |
|
|
96
107
|
| `check_compat` | Detect the game build, loaders, frameworks and tools, and check them against cited compatibility floors. |
|
|
97
|
-
| `check_toolchain` | Locate the external tools (LSLib divine, WolvenKit CLI, Blender, Cpp2IL and others), report each one's path and version (never guessed), and say what each feature needs. It also remembers a tool or data folder you point it at,
|
|
108
|
+
| `check_toolchain` | Locate the external tools (LSLib divine, WolvenKit CLI, Blender, Cpp2IL and others), report each one's path and version (never guessed), and say what each feature needs. It also remembers a tool or data folder you point it at, installs the tools ModWright can install, and trusts a project to run its own build commands. |
|
|
98
109
|
| `project_info` | Load a `modwright.json` mod project: sources, build, deploy targets, dependencies and publish targets, with paths resolved and structural warnings. |
|
|
99
110
|
| `lookup` | Search the knowledge base of game and toolchain facts (each with a status and a source), the validator catalogue, or the game's vanilla index you built locally. |
|
|
100
111
|
| `build_index` | Extract and index the game's own data into a local SQLite file in ModWright's cache. Never redistributed. |
|
|
@@ -114,6 +125,23 @@ Epic paths where they apply. Override with `installPath` on any tool, or set
|
|
|
114
125
|
Every tool that writes is **dry-run by default**: it describes the plan and
|
|
115
126
|
writes nothing until you apply it.
|
|
116
127
|
|
|
128
|
+
## How much to trust an answer
|
|
129
|
+
|
|
130
|
+
Every fact `lookup` returns carries a status and a source:
|
|
131
|
+
|
|
132
|
+
| Status | Means |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `verified` | Someone observed it in the game; the fact says when. |
|
|
135
|
+
| `community` | A wiki, README, issue or forum post states it, cited by URL, and it has not been re-checked here. |
|
|
136
|
+
| `inferred` | Derived from reading code or from other facts. |
|
|
137
|
+
| `unverified` | Written from documentation or reasoning, and never observed. |
|
|
138
|
+
| `contradicted` | Once believed and later shown wrong. It is kept, pointing at what replaced it, so the mistake is not made again. |
|
|
139
|
+
|
|
140
|
+
A validator that cannot run is reported as **skipped**, with the reason,
|
|
141
|
+
never as passed. That happens when the vanilla index is not built, a tool is
|
|
142
|
+
missing, or the project has nothing of the kind it checks. An in-game test
|
|
143
|
+
whose probe is `unverified` can pass, but its result stays below `verified`.
|
|
144
|
+
|
|
117
145
|
## Workflow prompts
|
|
118
146
|
|
|
119
147
|
Seven workflow prompts walk the authoring loop in any MCP client
|
|
@@ -173,6 +201,12 @@ project:
|
|
|
173
201
|
`check_toolchain action=install tool=cpp2il` shows exactly what it would
|
|
174
202
|
fetch (URL, size, sha256, destination); `mode=apply` installs it. Downloads
|
|
175
203
|
are pinned and checked before anything is written.
|
|
204
|
+
- **Trusted projects** are listed in `trusted-projects.json`, beside that
|
|
205
|
+
config. A project's `exec` build steps and `toolchain` overrides run only
|
|
206
|
+
after you trust it once: `check_toolchain action=trust projectPath=<mod>`
|
|
207
|
+
lists what it would allow, and `mode=apply` records it. Until then an
|
|
208
|
+
applied build, convert, template extract or index build that would run
|
|
209
|
+
them is refused. `MODWRIGHT_TRUST_ALL=1` trusts every project, for CI.
|
|
176
210
|
- **Overrides:** `MODWRIGHT_CACHE_DIR` moves the cache; `MODWRIGHT_HOME`
|
|
177
211
|
moves everything.
|
|
178
212
|
- **Project state** stays in the project's own `.modwright/`.
|
|
@@ -205,6 +239,50 @@ produce confident nonsense:
|
|
|
205
239
|
- **Redistributing game data.** Indexes and templates are built from your
|
|
206
240
|
own install, on your machine, and never shipped.
|
|
207
241
|
|
|
242
|
+
## Security
|
|
243
|
+
|
|
244
|
+
ModWright runs programs on your machine and writes into your game folders.
|
|
245
|
+
What it runs, and where it writes:
|
|
246
|
+
|
|
247
|
+
- **Tools it finds.** It runs the external tools it locates (LSLib divine,
|
|
248
|
+
WolvenKit CLI, Blender and the others `check_toolchain` lists). They come
|
|
249
|
+
from a `MODWRIGHT_TOOL_*` variable, your per-user config, its own managed
|
|
250
|
+
installs, PATH or common install locations. Managed installs are pinned
|
|
251
|
+
and their sha256 is checked before anything is written.
|
|
252
|
+
- **A project's own commands.** A mod project's `exec` build steps and its
|
|
253
|
+
`toolchain` overrides are programs the project's author chose. They run
|
|
254
|
+
only after you trust that project on this machine:
|
|
255
|
+
`check_toolchain action=trust projectPath=<mod>` lists what it would allow,
|
|
256
|
+
and `mode=apply` records it. Read the project's `modwright.json` first,
|
|
257
|
+
above all for a mod you cloned from someone else. Trust belongs to the
|
|
258
|
+
folder, not to the file's content, so a trusted project that later gains
|
|
259
|
+
a step (after a `git pull`) is not asked again.
|
|
260
|
+
- **Where it writes.** Every writer is dry-run until you apply it, and it
|
|
261
|
+
backs up what it replaces. A build writes under the project's
|
|
262
|
+
`build.outDir`, and a deploy writes under the game's mod roots. A project
|
|
263
|
+
path that is absolute or climbs out with `..` is refused unless that entry
|
|
264
|
+
sets `"allowOutsideRoot": true`. `rollback` restores only under the
|
|
265
|
+
project and the game's mod roots, unless you pass `allowOutsideRoots`.
|
|
266
|
+
- **The in-game bridges.** While a verification bridge is deployed and the
|
|
267
|
+
game runs, anything that can write the bridge's request folder can run Lua
|
|
268
|
+
in the game. There is no token. Deploy a bridge only while you verify,
|
|
269
|
+
remove it afterwards, and never ship it (the release validators block a
|
|
270
|
+
build that contains it). The bridge READMEs under `bridges/` describe each
|
|
271
|
+
one's trust model.
|
|
272
|
+
|
|
273
|
+
To report a security problem, use **Report a vulnerability** on
|
|
274
|
+
[modwright-community's Security tab](https://github.com/jtrachtenberg/modwright-community/security),
|
|
275
|
+
not a public issue.
|
|
276
|
+
|
|
277
|
+
## Reporting a problem
|
|
278
|
+
|
|
279
|
+
Bug reports, game and feature requests and questions go to
|
|
280
|
+
[modwright-community](https://github.com/jtrachtenberg/modwright-community):
|
|
281
|
+
[open an issue](https://github.com/jtrachtenberg/modwright-community/issues/new/choose),
|
|
282
|
+
or ask in [Discussions](https://github.com/jtrachtenberg/modwright-community/discussions).
|
|
283
|
+
Include `npx modwright --version`, the game, the tool call and what it
|
|
284
|
+
returned. Release notes are in that repository's CHANGELOG.
|
|
285
|
+
|
|
208
286
|
## License
|
|
209
287
|
|
|
210
288
|
MIT; see LICENSE. Files ModWright generates into your own projects are
|
package/bridges/bg3-se/README.md
CHANGED
|
@@ -82,6 +82,21 @@ No Lua parser is vendored. Syntax is checked with `luac -p` under Lua 5.1 and
|
|
|
82
82
|
dispatch-table keys equal the catalogue's probe ids, that the `PROTOCOL_VERSION` literals
|
|
83
83
|
agree, that `bridge.json` lists every shipped file, and that every `.lua` carries the banner.
|
|
84
84
|
|
|
85
|
+
## Trust model
|
|
86
|
+
|
|
87
|
+
While this bridge is deployed and the game is running, anything that can write a file into
|
|
88
|
+
its request folder (`%LOCALAPPDATA%\Larian Studios\Baldur's Gate 3\Script Extender`) can run
|
|
89
|
+
Lua inside the game. A request names its own probes and its own grants, and `bg3.lua.eval`
|
|
90
|
+
runs whatever code a request carries once the request grants the `session` tier. There is no
|
|
91
|
+
token. The nonce and the request's `armedUntil` expiry only stop a consumed or stale request
|
|
92
|
+
from running again; they do not say who wrote it. So:
|
|
93
|
+
|
|
94
|
+
- deploy the bridge only to a machine and a profile you control, and only while you are
|
|
95
|
+
verifying a mod;
|
|
96
|
+
- remove `Mods/ModWrightBridge` from the profile when you are done, before playing normally
|
|
97
|
+
or sharing the profile;
|
|
98
|
+
- never ship it. `bg3.history.bridge-never-ships` blocks a release build that contains it.
|
|
99
|
+
|
|
85
100
|
## What this bridge never does
|
|
86
101
|
|
|
87
102
|
- No Osiris **action**, ever, and no forced cast: `Osi.UseSpell` was observed to re-cast,
|
|
@@ -155,6 +155,20 @@ The Lua is kept inside the 5.1-compatible subset shipped CET mods use, and check
|
|
|
155
155
|
against both dialects — CET embeds a 5.4-era sol2, but nothing on disk pins which dialect
|
|
156
156
|
features are available, so the intersection is the safe target.
|
|
157
157
|
|
|
158
|
+
## Trust model
|
|
159
|
+
|
|
160
|
+
While this bridge is deployed and the game is running, anything that can write a file into
|
|
161
|
+
its write root (the folders listed under "The `io` write-root discovery") can run Lua inside
|
|
162
|
+
the game, and so can anything that can type into the CET console. A request names its own
|
|
163
|
+
probes and its own grants, and `cp2077.lua.eval` runs whatever code a request carries once
|
|
164
|
+
the request grants the `session` tier. There is no token. The nonce and the request's expiry
|
|
165
|
+
only stop a consumed or stale request from running again; they do not say who wrote it. So:
|
|
166
|
+
|
|
167
|
+
- deploy the bridge only to an install you control, and only while you are verifying a mod;
|
|
168
|
+
- remove `bin/x64/plugins/cyber_engine_tweaks/mods/ModWrightBridge` when you are done,
|
|
169
|
+
before playing normally;
|
|
170
|
+
- never ship it. `cp2077.history.bridge-never-ships` blocks a release build that contains it.
|
|
171
|
+
|
|
158
172
|
## What this bridge never does
|
|
159
173
|
|
|
160
174
|
* It never observes: no CET listener API is attested, so `effect: "observe"` is `unsupported`
|
|
@@ -51,7 +51,14 @@ export async function readPendingArm(projectRoot, id) {
|
|
|
51
51
|
catch {
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
let json;
|
|
55
|
+
try {
|
|
56
|
+
json = JSON.parse(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const parsed = pendingArmSchema.safeParse(json);
|
|
55
62
|
return parsed.success ? parsed.data : undefined;
|
|
56
63
|
}
|
|
57
64
|
export async function listPendingArms(projectRoot, game, opts) {
|
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { isDirectory, pathExists } from "../fsutil.js";
|
|
5
5
|
import { parseProject } from "../project/schema.js";
|
|
6
6
|
import { PROJECT_FILE } from "../project/types.js";
|
|
7
|
-
import { executePlan, planCopy, planFile, planMkdir } from "../safety/index.js";
|
|
7
|
+
import { executePlan, planCopy, planDelete, planFile, planMkdir } from "../safety/index.js";
|
|
8
8
|
const BRIDGE_DIR_BY_GAME = {
|
|
9
9
|
baldursgate3: "bg3-se",
|
|
10
10
|
cyberpunk2077: "cp2077-cet",
|
|
@@ -77,7 +77,7 @@ export async function planStageBridge(projectRoot, game) {
|
|
|
77
77
|
}
|
|
78
78
|
const to = stagedBridgeDir(projectRoot, game);
|
|
79
79
|
const registration = await planDeployTargetRegistration(projectRoot, game);
|
|
80
|
-
const operations = [planMkdir(path.dirname(to)), planCopy(from, to)];
|
|
80
|
+
const operations = [planMkdir(path.dirname(to)), ...(await pathExists(to) ? [planDelete(to)] : []), planCopy(from, to)];
|
|
81
81
|
if (registration.op)
|
|
82
82
|
operations.push(registration.op);
|
|
83
83
|
return {
|
package/dist/core/build/run.js
CHANGED
|
@@ -2,7 +2,9 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { pathExists, walk } from "../fsutil.js";
|
|
4
4
|
import { normalizeBuildSteps } from "../project/build-steps.js";
|
|
5
|
+
import { outsideRootPaths } from "../project/outside-root.js";
|
|
5
6
|
import { locateTool as coreLocateTool, TOOL_SPECS } from "../toolchain/index.js";
|
|
7
|
+
import { describeNeeds, isProjectTrusted, trustRefusal } from "../trust.js";
|
|
6
8
|
import { buildContext, runValidators } from "../validate/index.js";
|
|
7
9
|
import { runAssertAbsentStep, runAssertPresentStep, runExecStep, runZipStep } from "./steps/core.js";
|
|
8
10
|
import { runBlenderExportStep, runVerifyArchiveListingStep, runWolvenkitImportStep, runWolvenkitPackStep, } from "./steps/cyberpunk.js";
|
|
@@ -168,6 +170,18 @@ export async function runBuild(project, surface, options) {
|
|
|
168
170
|
throw new Error(`--only named no matching step id(s): ${options.only.join(", ")}. Known ids: ${active.map(({ step }) => step.id).filter(Boolean).join(", ") || "(none declared)"}`);
|
|
169
171
|
}
|
|
170
172
|
const skippedVariant = gatedOut.map(stepName);
|
|
173
|
+
const outside = outsideRootPaths(project.project, selected.map(({ step }) => step)).filter((e) => e.kind === "build-step" && !e.allowed);
|
|
174
|
+
if (outside.length > 0) {
|
|
175
|
+
throw new Error(`${project.project.name}: ${outside.map((e) => e.message).join("; ")}. A build writes only under build.outDir and runs only inside the project; ` +
|
|
176
|
+
`set "allowOutsideRoot": true on the step if this is intended.`);
|
|
177
|
+
}
|
|
178
|
+
if (options.mode === "apply") {
|
|
179
|
+
const exec = selected.flatMap(({ step, stepIndex }) => (step.step === "exec" ? [`"${stepName({ step, stepIndex })}" (${step.command})`] : []));
|
|
180
|
+
const what = describeNeeds({ toolchain: Object.keys(project.project.toolchain ?? {}), exec });
|
|
181
|
+
if (what && !(await (options.isTrusted ?? ((root) => isProjectTrusted(root)))(project.root))) {
|
|
182
|
+
throw new Error(trustRefusal(project.root, what));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
171
185
|
const version = options.version ?? project.project.version ?? "0.0.0";
|
|
172
186
|
const stagingDir = project.build.staging
|
|
173
187
|
? path.resolve(project.root, project.build.staging)
|
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { isDirectory, walk } from "../../fsutil.js";
|
|
4
4
|
import { globToRegExp } from "../../validate/context.js";
|
|
5
5
|
import { runTool } from "../../toolchain/run.js";
|
|
6
|
+
import { DEFAULT_TOOL_TIMEOUT_MS } from "../../toolchain/types.js";
|
|
6
7
|
import { TOOL_SPECS } from "../../toolchain/specs.js";
|
|
7
8
|
import { directoryEntry, pathEntries } from "../manifest.js";
|
|
8
9
|
import { resolveOutDirPath, resolveSourceOrStagePath, resolveStageId } from "../paths.js";
|
|
@@ -86,9 +87,8 @@ export async function runZipStep(step, ctx) {
|
|
|
86
87
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
87
88
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
88
89
|
await writeZipFile(out, entries.map((entry) => ({ name: entry.name, read: () => fs.readFile(entry.abs) })));
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
throw new BuildStepError(`zip: wrote an empty archive: ${out}`, step.id, step.step);
|
|
90
|
+
if (entries.length === 0)
|
|
91
|
+
throw new BuildStepError(`zip: nothing to archive under ${from}`, step.id, step.step);
|
|
92
92
|
const outputs = await pathEntries([out]);
|
|
93
93
|
return {
|
|
94
94
|
inputs: [await directoryEntry(from)],
|
|
@@ -128,7 +128,7 @@ async function resolveCommandLocation(step, ctx) {
|
|
|
128
128
|
}
|
|
129
129
|
return { id: "exec", label, found: true, path: resolveSourceOrStagePath(step.command, ctx), usedBy: [] };
|
|
130
130
|
}
|
|
131
|
-
export const EXEC_DEFAULT_TIMEOUT_MS =
|
|
131
|
+
export const EXEC_DEFAULT_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS;
|
|
132
132
|
export async function runExecStep(step, ctx) {
|
|
133
133
|
const location = await resolveCommandLocation(step, ctx);
|
|
134
134
|
const command = location.path;
|
package/dist/core/deploy/plan.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { pathExists, walk } from "../fsutil.js";
|
|
3
|
+
import { isDirectory, pathExists, walk } from "../fsutil.js";
|
|
4
4
|
import { globToRegExp, isExcludedByGlobs } from "../validate/index.js";
|
|
5
|
+
import { outsideRootPaths } from "../project/outside-root.js";
|
|
5
6
|
import { findRunningProcesses } from "../process.js";
|
|
6
7
|
import { executePlan, isIdenticalFile, planCopy, planDelete, planJunction, planMkdir, planUnlink, sha256File } from "../safety/index.js";
|
|
7
8
|
import { classifyReload } from "./reload.js";
|
|
@@ -158,6 +159,60 @@ export async function planDeploy(inputs) {
|
|
|
158
159
|
backupRoot,
|
|
159
160
|
};
|
|
160
161
|
}
|
|
162
|
+
if (inputs.variant !== undefined) {
|
|
163
|
+
const named = new Set([
|
|
164
|
+
...deploy.targets.flatMap((t) => (t.variant ? [t.variant] : [])),
|
|
165
|
+
...Object.keys(project.project.build?.variants ?? {}),
|
|
166
|
+
]);
|
|
167
|
+
if (!named.has(inputs.variant)) {
|
|
168
|
+
const known = [...named].sort();
|
|
169
|
+
return {
|
|
170
|
+
blocked: [
|
|
171
|
+
{
|
|
172
|
+
code: "unknown-variant",
|
|
173
|
+
message: `${project.project.name}: variant "${inputs.variant}" is named by no deploy target and no build.variants entry` +
|
|
174
|
+
(known.length > 0 ? ` (known: ${known.join(", ")})` : " (this project declares none)") +
|
|
175
|
+
"; nothing was deployed rather than silently deploying only the ungated targets.",
|
|
176
|
+
detail: { variant: inputs.variant, known },
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
confidence: { level: "high", assumptions: [], verifiedBy: ["modwright.json"] },
|
|
180
|
+
targets: [],
|
|
181
|
+
skipped: [],
|
|
182
|
+
deferred: [],
|
|
183
|
+
junctions: [],
|
|
184
|
+
pruned: [],
|
|
185
|
+
skippedDestDirs: [],
|
|
186
|
+
excluded: [],
|
|
187
|
+
warnings,
|
|
188
|
+
writtenPaths: [],
|
|
189
|
+
processCheck: emptyProcessCheck,
|
|
190
|
+
backupRoot,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const outside = outsideRootPaths(project.project).filter((e) => (e.kind === "deploy-target" || e.kind === "junction") && !e.allowed);
|
|
195
|
+
if (outside.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
blocked: outside.map((e) => ({
|
|
198
|
+
code: "outside-root",
|
|
199
|
+
message: `${e.message}; a deploy writes only under the game's mod roots. Set "allowOutsideRoot": true on that entry if this is intended.`,
|
|
200
|
+
detail: { kind: e.kind, value: e.value },
|
|
201
|
+
})),
|
|
202
|
+
confidence: { level: "high", assumptions: [], verifiedBy: ["modwright.json"] },
|
|
203
|
+
targets: [],
|
|
204
|
+
skipped: [],
|
|
205
|
+
deferred: [],
|
|
206
|
+
junctions: [],
|
|
207
|
+
pruned: [],
|
|
208
|
+
skippedDestDirs: [],
|
|
209
|
+
excluded: [],
|
|
210
|
+
warnings,
|
|
211
|
+
writtenPaths: [],
|
|
212
|
+
processCheck: emptyProcessCheck,
|
|
213
|
+
backupRoot,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
161
216
|
const roots = inputs.roots ?? (inputs.install ? surface.modRoots(inputs.install) : undefined);
|
|
162
217
|
if (!roots) {
|
|
163
218
|
return {
|
|
@@ -202,6 +257,7 @@ export async function planDeploy(inputs) {
|
|
|
202
257
|
}
|
|
203
258
|
if (target.mode && target.mode !== deployMode) {
|
|
204
259
|
skipped.push({ rootId: target.rootId, from: target.from, reason: `target is mode "${target.mode}", this deploy is mode "${deployMode}"` });
|
|
260
|
+
variantExcludedTargets.add(target);
|
|
205
261
|
const skippedRoot = roots.find((r) => r.id === target.rootId);
|
|
206
262
|
if (skippedRoot && (target.locked ?? defaultLocked(target.rootId))) {
|
|
207
263
|
notDeployedLocked.push({ target, rootPath: skippedRoot.path });
|
|
@@ -365,7 +421,18 @@ export async function planDeploy(inputs) {
|
|
|
365
421
|
}
|
|
366
422
|
}
|
|
367
423
|
else {
|
|
424
|
+
const fold = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
|
|
368
425
|
for (const j of junctions) {
|
|
426
|
+
if (!(await isDirectory(j.target))) {
|
|
427
|
+
blocked.push({
|
|
428
|
+
code: "junction-target-missing",
|
|
429
|
+
message: `deploy.junctions: ${j.linkPath} would point at ${j.target}, which is not a directory. Build or create it first, or fix the junction's target.`,
|
|
430
|
+
detail: { linkPath: j.linkPath, target: j.target },
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (j.before === "link" && j.linkTargetBefore !== undefined && fold(j.linkTargetBefore) === fold(j.target))
|
|
435
|
+
continue;
|
|
369
436
|
if (j.before === "directory" || j.before === "file") {
|
|
370
437
|
warnings.push(`${j.linkPath} is a real ${j.before}, not a link. It will be backed up and replaced by a link to ${j.target}.`);
|
|
371
438
|
}
|
|
@@ -331,9 +331,11 @@ export function classifyReload(surfaceId, project, writtenPaths, options = {}) {
|
|
|
331
331
|
.map((c) => RANK[c.action]);
|
|
332
332
|
if (unclassified.length > 0)
|
|
333
333
|
ranked.push(RANK.restart);
|
|
334
|
-
const overall =
|
|
335
|
-
? "
|
|
336
|
-
:
|
|
334
|
+
const overall = writtenPaths.length === 0
|
|
335
|
+
? "none"
|
|
336
|
+
: ranked.length === 0
|
|
337
|
+
? "unknown"
|
|
338
|
+
: Object.keys(RANK).find((a) => RANK[a] === Math.max(...ranked));
|
|
337
339
|
const notes = [];
|
|
338
340
|
if (writtenPaths.length === 0) {
|
|
339
341
|
notes.push("nothing was written, so there is nothing to reload");
|
|
@@ -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) =>
|
|
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 =
|
|
17
|
-
const ran = ranFiles ?
|
|
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
|
|
45
|
+
const allRan = !ran || [...files].every((f) => anyMatch(ran, f));
|
|
29
46
|
if (!allRan)
|
|
30
47
|
continue;
|
|
31
|
-
const anyBlocked = [...files].some((f) => blocked
|
|
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 });
|
package/dist/core/logs/scan.js
CHANGED
|
@@ -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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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: [] };
|
|
113
129
|
}
|
|
114
130
|
}
|
|
115
131
|
if (startOffset === 0 && text.charCodeAt(0) === 0xfeff)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export { modProjectSchema, parseProject } from "./schema.js";
|
|
3
3
|
export { findProjectFile, loadProject } from "./load.js";
|
|
4
|
+
export { escapesRoot, outsideRootPaths } from "./outside-root.js";
|
|
4
5
|
export { describeProject } from "./describe.js";
|
|
5
6
|
export { defaultArtifactName, normalizeBuildSteps } from "./build-steps.js";
|
|
@@ -4,6 +4,7 @@ import { isDirectory, pathExists } from "../fsutil.js";
|
|
|
4
4
|
import { getSurface, surfaceIds } from "../../surfaces/index.js";
|
|
5
5
|
import { parseProject } from "./schema.js";
|
|
6
6
|
import { PROJECT_FILE } from "./types.js";
|
|
7
|
+
import { looksAbsolute, outsideRootPaths } from "./outside-root.js";
|
|
7
8
|
export async function findProjectFile(startDir) {
|
|
8
9
|
let dir = path.resolve(startDir);
|
|
9
10
|
for (;;) {
|
|
@@ -16,9 +17,6 @@ export async function findProjectFile(startDir) {
|
|
|
16
17
|
dir = parent;
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
|
-
function looksAbsolute(p) {
|
|
20
|
-
return path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
|
|
21
|
-
}
|
|
22
20
|
function isWithin(parent, child) {
|
|
23
21
|
const rel = path.relative(parent, child);
|
|
24
22
|
return rel === "" || (!(rel === ".." || rel.startsWith(`..${path.sep}`)) && !path.isAbsolute(rel));
|
|
@@ -115,29 +113,8 @@ export async function loadProject(fileOrDir) {
|
|
|
115
113
|
}
|
|
116
114
|
}
|
|
117
115
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (escapes(target.into)) {
|
|
121
|
-
warnings.push(`deploy target "${target.from}": into "${target.into}" leaves the "${target.rootId}" root (absolute or "..")`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
for (const junction of project.deploy?.junctions ?? []) {
|
|
125
|
-
if (escapes(junction.linkPath)) {
|
|
126
|
-
warnings.push(`junction linkPath "${junction.linkPath}" leaves the "${junction.rootId}" root (absolute or "..")`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (const step of project.build?.steps ?? []) {
|
|
130
|
-
if (typeof step !== "object" || step === null)
|
|
131
|
-
continue;
|
|
132
|
-
const s = step;
|
|
133
|
-
if ((s.step === "wolvenkit-pack" || s.step === "divine-create-package") && escapes(s.out)) {
|
|
134
|
-
warnings.push(`build step ${s.step}: out "${s.out}" leaves build.outDir (absolute or "..")`);
|
|
135
|
-
}
|
|
136
|
-
if (s.step === "zip" && escapes(s.name))
|
|
137
|
-
warnings.push(`build step zip: name "${s.name}" leaves build.outDir (absolute or "..")`);
|
|
138
|
-
if (s.step === "exec" && s.cwd !== undefined && !s.cwd.includes("${stage}") && escapes(s.cwd)) {
|
|
139
|
-
warnings.push(`build step exec: cwd "${s.cwd}" leaves the project (absolute or "..")`);
|
|
140
|
-
}
|
|
116
|
+
for (const entry of outsideRootPaths(project)) {
|
|
117
|
+
warnings.push(entry.allowed ? `${entry.message} — allowed by allowOutsideRoot` : `${entry.message}; build and deploy refuse it unless the entry sets "allowOutsideRoot": true`);
|
|
141
118
|
}
|
|
142
119
|
const toolchain = project.toolchain
|
|
143
120
|
? Object.fromEntries(Object.entries(project.toolchain).map(([id, p]) => [id, looksAbsolute(p) ? p : resolveFromRoot(p)]))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
export function looksAbsolute(p) {
|
|
3
|
+
return path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\");
|
|
4
|
+
}
|
|
5
|
+
export function escapesRoot(p) {
|
|
6
|
+
return p !== undefined && (looksAbsolute(p) || p.split(/[\\/]/).includes(".."));
|
|
7
|
+
}
|
|
8
|
+
export function outsideRootPaths(project, steps = project.build?.steps) {
|
|
9
|
+
const found = [];
|
|
10
|
+
for (const target of project.deploy?.targets ?? []) {
|
|
11
|
+
if (escapesRoot(target.into)) {
|
|
12
|
+
found.push({ kind: "deploy-target", value: target.into, allowed: target.allowOutsideRoot === true, message: `deploy target "${target.from}": into "${target.into}" leaves the "${target.rootId}" root (absolute or "..")` });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
for (const junction of project.deploy?.junctions ?? []) {
|
|
16
|
+
if (escapesRoot(junction.linkPath)) {
|
|
17
|
+
found.push({ kind: "junction", value: junction.linkPath, allowed: junction.allowOutsideRoot === true, message: `junction linkPath "${junction.linkPath}" leaves the "${junction.rootId}" root (absolute or "..")` });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (const step of steps ?? []) {
|
|
21
|
+
if (typeof step !== "object" || step === null)
|
|
22
|
+
continue;
|
|
23
|
+
const s = step;
|
|
24
|
+
const label = `build step ${s.id ? `"${s.id}" (${s.step})` : s.step}`;
|
|
25
|
+
const allowed = s.allowOutsideRoot === true;
|
|
26
|
+
if ((s.step === "wolvenkit-pack" || s.step === "divine-create-package") && escapesRoot(s.out)) {
|
|
27
|
+
found.push({ kind: "build-step", value: s.out, allowed, message: `${label}: out "${s.out}" leaves build.outDir (absolute or "..")` });
|
|
28
|
+
}
|
|
29
|
+
if (s.step === "zip" && escapesRoot(s.name))
|
|
30
|
+
found.push({ kind: "build-step", value: s.name, allowed, message: `${label}: name "${s.name}" leaves build.outDir (absolute or "..")` });
|
|
31
|
+
if (s.step === "exec" && s.cwd !== undefined && !s.cwd.includes("${stage}") && escapesRoot(s.cwd)) {
|
|
32
|
+
found.push({ kind: "build-step", value: s.cwd, allowed, message: `${label}: cwd "${s.cwd}" leaves the project (absolute or "..")` });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|