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
|
@@ -68,6 +68,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
68
68
|
.object({
|
|
69
69
|
...stepBase,
|
|
70
70
|
step: z.literal("zip"),
|
|
71
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
71
72
|
from: z.string(),
|
|
72
73
|
name: z.string(),
|
|
73
74
|
})
|
|
@@ -94,6 +95,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
94
95
|
.object({
|
|
95
96
|
...stepBase,
|
|
96
97
|
step: z.literal("wolvenkit-pack"),
|
|
98
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
97
99
|
from: z.string(),
|
|
98
100
|
out: z.string(),
|
|
99
101
|
verbosity: z.enum(["Minimal", "Normal", "Diagnostic"]).default("Minimal"),
|
|
@@ -120,6 +122,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
120
122
|
.object({
|
|
121
123
|
...stepBase,
|
|
122
124
|
step: z.literal("divine-create-package"),
|
|
125
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
123
126
|
from: z.string(),
|
|
124
127
|
out: z.string(),
|
|
125
128
|
})
|
|
@@ -137,6 +140,7 @@ const buildStepSchema = z.discriminatedUnion("step", [
|
|
|
137
140
|
.object({
|
|
138
141
|
...stepBase,
|
|
139
142
|
step: z.literal("exec"),
|
|
143
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
140
144
|
command: z.string(),
|
|
141
145
|
args: z.array(z.string()).default([]),
|
|
142
146
|
cwd: z.string().optional(),
|
|
@@ -166,6 +170,7 @@ const deployTargetSchema = z
|
|
|
166
170
|
.object({
|
|
167
171
|
rootId: z.string().min(1),
|
|
168
172
|
from: z.string().min(1),
|
|
173
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
169
174
|
into: z.string().optional(),
|
|
170
175
|
waitForGameExit: z.boolean().optional(),
|
|
171
176
|
backup: z.boolean().optional(),
|
|
@@ -179,6 +184,7 @@ const deployTargetSchema = z
|
|
|
179
184
|
const junctionSchema = z
|
|
180
185
|
.object({
|
|
181
186
|
linkPath: z.string().min(1),
|
|
187
|
+
allowOutsideRoot: z.boolean().optional(),
|
|
182
188
|
rootId: z.string().min(1),
|
|
183
189
|
target: z.string().min(1),
|
|
184
190
|
})
|
|
@@ -240,6 +246,37 @@ export const modProjectSchema = z
|
|
|
240
246
|
testPlans: z.array(z.string().min(1)).optional(),
|
|
241
247
|
})
|
|
242
248
|
.strict();
|
|
249
|
+
function checkVariantGates(doc, ctx) {
|
|
250
|
+
const build = doc.build;
|
|
251
|
+
if (!build?.steps)
|
|
252
|
+
return;
|
|
253
|
+
const known = Object.keys(build.variants ?? {});
|
|
254
|
+
const knownText = known.length > 0 ? `declared build.variants: ${known.join(", ")}` : "build.variants declares none";
|
|
255
|
+
build.steps.forEach((step, i) => {
|
|
256
|
+
if (typeof step === "string")
|
|
257
|
+
return;
|
|
258
|
+
const label = step.id ? `"${step.id}"` : `#${i} (${step.step})`;
|
|
259
|
+
if (step.variant !== undefined && !known.includes(step.variant)) {
|
|
260
|
+
ctx.addIssue({
|
|
261
|
+
code: z.ZodIssueCode.custom,
|
|
262
|
+
path: ["build", "steps", i, "variant"],
|
|
263
|
+
message: `step ${label} is gated on variant "${step.variant}", which no build.variants entry declares (${knownText}); it could never run`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (step.step !== "stage")
|
|
267
|
+
return;
|
|
268
|
+
step.sources.forEach((entry, j) => {
|
|
269
|
+
if (typeof entry === "string" || entry.variant === undefined || known.includes(entry.variant))
|
|
270
|
+
return;
|
|
271
|
+
ctx.addIssue({
|
|
272
|
+
code: z.ZodIssueCode.custom,
|
|
273
|
+
path: ["build", "steps", i, "sources", j, "variant"],
|
|
274
|
+
message: `stage step ${label} source entry "${entry.source ?? entry.output}" is gated on variant "${entry.variant}", which no build.variants entry declares (${knownText}); it could never be staged`,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const modProjectSchemaChecked = modProjectSchema.superRefine((doc, ctx) => checkVariantGates(doc, ctx));
|
|
243
280
|
function formatPath(segments) {
|
|
244
281
|
if (segments.length === 0)
|
|
245
282
|
return "(root)";
|
|
@@ -255,7 +292,7 @@ function formatPath(segments) {
|
|
|
255
292
|
return out;
|
|
256
293
|
}
|
|
257
294
|
export function parseProject(json) {
|
|
258
|
-
const result =
|
|
295
|
+
const result = modProjectSchemaChecked.safeParse(json);
|
|
259
296
|
if (!result.success) {
|
|
260
297
|
const lines = result.error.issues.map((issue) => `${formatPath(issue.path)}: ${issue.message}`);
|
|
261
298
|
throw new Error(`Invalid modwright.json:\n${lines.join("\n")}`);
|
|
@@ -113,7 +113,7 @@ export class ManifestWriter {
|
|
|
113
113
|
async addFile(originalAbsPath) {
|
|
114
114
|
const dest = backupDestFor(this.backupDir, originalAbsPath);
|
|
115
115
|
await copyFilePreservingMtime(originalAbsPath, dest);
|
|
116
|
-
const { sha256, sizeBytes } = await sha256File(
|
|
116
|
+
const { sha256, sizeBytes } = await sha256File(dest);
|
|
117
117
|
const backupPath = path.relative(this.backupDir, dest).split(path.sep).join("/");
|
|
118
118
|
this.entries.push({ originalPath: originalAbsPath, backupPath, sizeBytes, sha256 });
|
|
119
119
|
}
|
|
@@ -159,7 +159,7 @@ export class ManifestWriter {
|
|
|
159
159
|
}
|
|
160
160
|
async flush(now) {
|
|
161
161
|
const manifest = { purpose: this.purpose, time: now.toISOString(), entries: this.entries };
|
|
162
|
-
await
|
|
162
|
+
await writeFileAtomic(path.join(this.backupDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2));
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
export async function readManifest(backupDir) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { pathExists } from "../fsutil.js";
|
|
4
3
|
import { userDataDir } from "../home.js";
|
|
5
4
|
import { normalizePlan } from "./plan.js";
|
|
6
5
|
import { isLockError } from "./restore.js";
|
|
@@ -41,7 +40,7 @@ async function applyOp(nop, ensureBackup) {
|
|
|
41
40
|
if (nop.skipEligible)
|
|
42
41
|
return { op, status: "skipped" };
|
|
43
42
|
let backedUpTo;
|
|
44
|
-
if (await
|
|
43
|
+
if (await lstatExists(op.to)) {
|
|
45
44
|
backedUpTo = await (await ensureBackup()).addPath(op.to);
|
|
46
45
|
}
|
|
47
46
|
await copyTreePreservingMtime(op.from, op.to);
|
|
@@ -157,8 +156,14 @@ export async function executePlan(plan, options) {
|
|
|
157
156
|
results.push({ op, status: "failed", error: errorMessage(err) });
|
|
158
157
|
}
|
|
159
158
|
}
|
|
159
|
+
let manifestError;
|
|
160
160
|
if (manifestWriter?.hasEntries) {
|
|
161
|
-
|
|
161
|
+
try {
|
|
162
|
+
await manifestWriter.flush(now());
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
manifestError = errorMessage(err);
|
|
166
|
+
}
|
|
162
167
|
}
|
|
163
168
|
else if (backupDir) {
|
|
164
169
|
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => { });
|
|
@@ -183,6 +188,9 @@ export async function executePlan(plan, options) {
|
|
|
183
188
|
operations: results,
|
|
184
189
|
backupDir,
|
|
185
190
|
confidence: options.confidence,
|
|
186
|
-
next
|
|
191
|
+
next: manifestError
|
|
192
|
+
? `The operations above ran, but the backup's manifest could not be written (${manifestError}). The replaced files are under ${backupDir}; rollback cannot restore them without a manifest.${next ? ` ${next}` : ""}`
|
|
193
|
+
: next,
|
|
194
|
+
...(manifestError ? { manifestError } : {}),
|
|
187
195
|
};
|
|
188
196
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
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() {
|
|
@@ -157,7 +173,7 @@ class Supervisor {
|
|
|
157
173
|
}
|
|
158
174
|
}
|
|
159
175
|
}
|
|
160
|
-
|
|
176
|
+
const answerNotRunning = () => {
|
|
161
177
|
if (msg.id !== undefined && msg.id !== null && msg.method !== undefined) {
|
|
162
178
|
const response = {
|
|
163
179
|
jsonrpc: "2.0",
|
|
@@ -171,14 +187,23 @@ class Supervisor {
|
|
|
171
187
|
};
|
|
172
188
|
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
173
189
|
}
|
|
190
|
+
};
|
|
191
|
+
if (!active || active.exited) {
|
|
192
|
+
answerNotRunning();
|
|
174
193
|
return;
|
|
175
194
|
}
|
|
176
|
-
|
|
177
|
-
|
|
195
|
+
const key = msg.id !== undefined && msg.id !== null && msg.method !== undefined ? idKey(msg.id) : undefined;
|
|
196
|
+
if (key !== undefined) {
|
|
178
197
|
this.routes.set(key, active);
|
|
179
198
|
active.pending.add(key);
|
|
180
199
|
}
|
|
181
|
-
active.send(raw)
|
|
200
|
+
if (!active.send(raw)) {
|
|
201
|
+
if (key !== undefined) {
|
|
202
|
+
this.routes.delete(key);
|
|
203
|
+
active.pending.delete(key);
|
|
204
|
+
}
|
|
205
|
+
answerNotRunning();
|
|
206
|
+
}
|
|
182
207
|
}
|
|
183
208
|
fromServer(server, msg, raw) {
|
|
184
209
|
if (msg.id !== undefined && msg.id !== null && msg.method === undefined) {
|
|
@@ -285,6 +310,7 @@ class Supervisor {
|
|
|
285
310
|
}
|
|
286
311
|
if (this.initializedSeen) {
|
|
287
312
|
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n`);
|
|
313
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/prompts/list_changed" })}\n`);
|
|
288
314
|
}
|
|
289
315
|
}
|
|
290
316
|
finally {
|