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.
@@ -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 = modProjectSchema.safeParse(json);
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(originalAbsPath);
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 fs.writeFile(path.join(this.backupDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), "utf8");
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 pathExists(op.to)) {
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
- await manifestWriter.flush(now());
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: path.join(path.dirname(destAbs), ".modwright", "backups"),
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.kill();
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 timer = options.timeoutMs
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 ${options.timeoutMs}ms.`,
176
+ message: `${location.label} did not finish within ${timeoutMs}ms.`,
175
177
  },
176
178
  });
177
- }, options.timeoutMs)
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 ?? 30_000);
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
- this.child.stdin.write(`${raw}\n`);
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
- if (!active || active.exited) {
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
- if (msg.id !== undefined && msg.id !== null && msg.method !== undefined) {
177
- const key = idKey(msg.id);
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 {