mellos-mapping 0.24.0 → 0.26.0

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/dist/web.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);
2
+ import { createRequire as __mellosCreateRequire } from 'node:module'; const require = __mellosCreateRequire(import.meta.url);
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
5
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -2271,7 +2271,7 @@ var require_websocket = __commonJS({
2271
2271
  var http = __require("http");
2272
2272
  var net = __require("net");
2273
2273
  var tls = __require("tls");
2274
- var { randomBytes: randomBytes2, createHash: createHash4 } = __require("crypto");
2274
+ var { randomBytes: randomBytes2, createHash: createHash3 } = __require("crypto");
2275
2275
  var { Duplex, Readable } = __require("stream");
2276
2276
  var { URL: URL2 } = __require("url");
2277
2277
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -2939,7 +2939,7 @@ var require_websocket = __commonJS({
2939
2939
  abortHandshake(websocket, socket, "Invalid Upgrade header");
2940
2940
  return;
2941
2941
  }
2942
- const digest = createHash4("sha1").update(key + GUID).digest("base64");
2942
+ const digest = createHash3("sha1").update(key + GUID).digest("base64");
2943
2943
  if (res.headers["sec-websocket-accept"] !== digest) {
2944
2944
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
2945
2945
  return;
@@ -3308,7 +3308,7 @@ var require_websocket_server = __commonJS({
3308
3308
  var EventEmitter = __require("events");
3309
3309
  var http = __require("http");
3310
3310
  var { Duplex } = __require("stream");
3311
- var { createHash: createHash4 } = __require("crypto");
3311
+ var { createHash: createHash3 } = __require("crypto");
3312
3312
  var extension2 = require_extension();
3313
3313
  var PerMessageDeflate2 = require_permessage_deflate();
3314
3314
  var subprotocol2 = require_subprotocol();
@@ -3615,7 +3615,7 @@ var require_websocket_server = __commonJS({
3615
3615
  );
3616
3616
  }
3617
3617
  if (this._state > RUNNING) return abortHandshake(socket, 503);
3618
- const digest = createHash4("sha1").update(key + GUID).digest("base64");
3618
+ const digest = createHash3("sha1").update(key + GUID).digest("base64");
3619
3619
  const headers = [
3620
3620
  "HTTP/1.1 101 Switching Protocols",
3621
3621
  "Upgrade: websocket",
@@ -3703,9 +3703,9 @@ var require_websocket_server = __commonJS({
3703
3703
  });
3704
3704
 
3705
3705
  // src/web/cli.ts
3706
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync3, rmSync as rmSync4, statSync as statSync2 } from "node:fs";
3706
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5, realpathSync as realpathSync4, rmSync as rmSync3, statSync as statSync2 } from "node:fs";
3707
3707
  import { dirname as dirname9, join as join7, resolve as resolve2 } from "node:path";
3708
- import { fileURLToPath, pathToFileURL } from "node:url";
3708
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "node:url";
3709
3709
  import { homedir } from "node:os";
3710
3710
 
3711
3711
  // src/domain/types.ts
@@ -4299,33 +4299,38 @@ function stripBom(text) {
4299
4299
  }
4300
4300
 
4301
4301
  // src/store/migration.ts
4302
- import { dirname as dirname3, join as join2 } from "node:path";
4303
- var LEGACY_STATE_FILE_RELATIVE_PATH = join2(".claude", "mellos-mapping.json");
4302
+ import { dirname as dirname4, join as join3 } from "node:path";
4304
4303
 
4305
- // src/store/maps.ts
4306
- import { readFileSync } from "node:fs";
4307
- function loadMapFile(path) {
4308
- let text;
4309
- try {
4310
- text = readFileSync(path, "utf8");
4311
- } catch (e) {
4312
- const code = e.code;
4313
- if (code === "ENOENT") return err({ kind: "not-found", path });
4314
- throw e;
4304
+ // src/store/transaction.ts
4305
+ import { closeSync, fstatSync, lstatSync, mkdirSync as mkdirSync2, openSync, realpathSync } from "node:fs";
4306
+ import { basename as basename2, dirname as dirname3, join as join2 } from "node:path";
4307
+
4308
+ // src/store/native-lock.ts
4309
+ import { existsSync as existsSync2 } from "node:fs";
4310
+ import { createRequire } from "node:module";
4311
+ import { fileURLToPath } from "node:url";
4312
+ var loaded;
4313
+ function nativeLock() {
4314
+ if (loaded) return loaded;
4315
+ if (Number(process.versions.napi ?? 0) < 9) {
4316
+ throw new Error("Project locks require Node-API 9 (Node 18.17+ or 20.3+).");
4315
4317
  }
4316
- let raw;
4317
- try {
4318
- raw = JSON.parse(stripBom(text));
4319
- } catch (e) {
4320
- return err({ kind: "malformed-json", path, detail: e.message });
4318
+ const candidates = [
4319
+ new URL("./native-lock.cjs", import.meta.url),
4320
+ new URL("../../dist/native-lock.cjs", import.meta.url)
4321
+ ];
4322
+ const entry = candidates.find((candidate) => existsSync2(candidate));
4323
+ if (!entry) throw new Error("The installed package is missing dist/native-lock.cjs; reinstall the complete package.");
4324
+ const backend = createRequire(import.meta.url)(fileURLToPath(entry));
4325
+ if (typeof backend.tryLock !== "function" || typeof backend.unlock !== "function") {
4326
+ throw new Error("The installed native lock backend is invalid.");
4321
4327
  }
4322
- return parseMap(raw, path);
4328
+ loaded = backend;
4329
+ return backend;
4323
4330
  }
4324
4331
 
4325
4332
  // src/store/transaction.ts
4326
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
4327
- import { dirname as dirname4, join as join3 } from "node:path";
4328
- import { randomUUID, createHash } from "node:crypto";
4333
+ var STORE_LOCK_PROTOCOL = "os-file-v1";
4329
4334
  var LedgerError = class extends Error {
4330
4335
  constructor(code, message, details = {}) {
4331
4336
  super(message);
@@ -4334,72 +4339,112 @@ var LedgerError = class extends Error {
4334
4339
  }
4335
4340
  };
4336
4341
  function storeDirectory(file) {
4337
- const dir = dirname4(file);
4338
- return dir.endsWith("/pages") || dir.endsWith("\\pages") ? dirname4(dir) : dir;
4339
- }
4340
- function deadOwner(lock) {
4342
+ const dir = dirname3(file);
4343
+ if (basename2(dir).toLowerCase() === "pages") return dirname3(dir);
4344
+ let canonical = dir;
4341
4345
  try {
4342
- const owner = JSON.parse(readFileSync2(join3(lock, "owner.json"), "utf8"));
4343
- if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) return false;
4344
- try {
4345
- process.kill(owner.pid, 0);
4346
- return false;
4347
- } catch (e) {
4348
- return e.code === "ESRCH";
4349
- }
4350
- } catch {
4351
- return false;
4346
+ canonical = realpathSync.native(dir);
4347
+ } catch (error) {
4348
+ if (error.code !== "ENOENT") throw error;
4349
+ }
4350
+ return basename2(canonical).toLowerCase() === "pages" ? dirname3(canonical) : canonical;
4351
+ }
4352
+ function checkLockPath(lock) {
4353
+ const entry = lstatSync(lock, { throwIfNoEntry: false });
4354
+ if (entry?.isDirectory()) {
4355
+ throw new LedgerError(
4356
+ "LOCK_MIGRATION_REQUIRED",
4357
+ `Legacy directory lock at ${lock}. Stop all old MCP servers, viewers and watchers for this project, then move that directory aside for inspection and retry. Never remove the new regular lock file.`,
4358
+ { path: lock }
4359
+ );
4360
+ }
4361
+ if (entry && !entry.isFile()) {
4362
+ throw new LedgerError("LOCK_UNAVAILABLE", `Project lock must be a regular file, not a symlink or special file: ${lock}`, { path: lock });
4352
4363
  }
4353
4364
  }
4354
4365
  function withStoreLock(file, action) {
4366
+ let backend;
4367
+ try {
4368
+ backend = nativeLock();
4369
+ } catch (error) {
4370
+ throw new LedgerError("LOCK_UNAVAILABLE", `Cannot load the operating-system lock backend: ${error instanceof Error ? error.message : String(error)}`);
4371
+ }
4355
4372
  const dir = storeDirectory(file);
4356
4373
  mkdirSync2(dir, { recursive: true });
4357
- const lock = join3(dir, ".write-lock");
4358
- const owner = join3(lock, "owner.json");
4359
- const token = randomUUID();
4374
+ const lock = join2(dir, ".write-lock");
4375
+ checkLockPath(lock);
4376
+ let fd;
4360
4377
  try {
4361
- mkdirSync2(lock);
4378
+ fd = openSync(lock, "a+", 384);
4362
4379
  } catch (error) {
4363
- if (error.code !== "EEXIST") throw error;
4364
- if (!deadOwner(lock)) throw new LedgerError("BUSY", `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
4365
- try {
4366
- mkdirSync2(join3(lock, ".reap"));
4367
- } catch {
4368
- throw new LedgerError("BUSY", "Another process is recovering the writer lock.");
4369
- }
4370
- if (!deadOwner(lock)) {
4371
- rmSync3(join3(lock, ".reap"), { recursive: true, force: true });
4372
- throw new LedgerError("BUSY", "Writer ownership changed.");
4373
- }
4374
- rmSync3(lock, { recursive: true });
4375
- try {
4376
- mkdirSync2(lock);
4377
- } catch {
4378
- throw new LedgerError("BUSY", "Another writer acquired the recovered lock.");
4379
- }
4380
+ checkLockPath(lock);
4381
+ throw new LedgerError("LOCK_UNAVAILABLE", `Cannot open project lock ${lock}: ${error instanceof Error ? error.message : String(error)}`, { path: lock });
4380
4382
  }
4381
- let initialized = false;
4383
+ let acquired = false;
4382
4384
  try {
4383
- writeFileSync2(owner, JSON.stringify({ pid: process.pid, token }), { flag: "wx" });
4384
- initialized = true;
4385
+ checkLockPath(lock);
4386
+ if (!fstatSync(fd).isFile()) throw new LedgerError("LOCK_UNAVAILABLE", `Project lock is not a regular file: ${lock}`);
4387
+ try {
4388
+ acquired = backend.tryLock(fd);
4389
+ } catch (error) {
4390
+ throw new LedgerError("LOCK_UNAVAILABLE", `Cannot acquire project lock ${lock}: ${error instanceof Error ? error.message : String(error)}`, { path: lock });
4391
+ }
4392
+ if (!acquired) throw new LedgerError("BUSY", `Another writer owns ${lock}; retry after it completes.`);
4385
4393
  return action();
4386
4394
  } finally {
4387
4395
  try {
4388
- if (!initialized || JSON.parse(readFileSync2(owner, "utf8")).token === token) rmSync3(lock, { recursive: true });
4396
+ if (acquired) backend.unlock(fd);
4389
4397
  } catch {
4398
+ warnLockCleanup(`Could not explicitly unlock ${lock}; closing its file handle.`);
4399
+ } finally {
4400
+ try {
4401
+ closeSync(fd);
4402
+ } catch {
4403
+ warnLockCleanup(`Could not close the project lock handle for ${lock}; restart this process before retrying writes.`);
4404
+ }
4390
4405
  }
4391
4406
  }
4392
4407
  }
4408
+ function warnLockCleanup(message) {
4409
+ try {
4410
+ process.stderr.write(`mellos-mapping: ${message}
4411
+ `);
4412
+ } catch {
4413
+ }
4414
+ }
4415
+
4416
+ // src/store/migration.ts
4417
+ var LEGACY_STATE_FILE_RELATIVE_PATH = join3(".claude", "mellos-mapping.json");
4418
+
4419
+ // src/store/maps.ts
4420
+ import { readFileSync } from "node:fs";
4421
+ function loadMapFile(path) {
4422
+ let text;
4423
+ try {
4424
+ text = readFileSync(path, "utf8");
4425
+ } catch (e) {
4426
+ const code = e.code;
4427
+ if (code === "ENOENT") return err({ kind: "not-found", path });
4428
+ throw e;
4429
+ }
4430
+ let raw;
4431
+ try {
4432
+ raw = JSON.parse(stripBom(text));
4433
+ } catch (e) {
4434
+ return err({ kind: "malformed-json", path, detail: e.message });
4435
+ }
4436
+ return parseMap(raw, path);
4437
+ }
4393
4438
 
4394
4439
  // src/web/launcher.ts
4395
4440
  import { spawn } from "node:child_process";
4396
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
4441
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
4397
4442
  import { dirname as dirname6, join as join4 } from "node:path";
4398
4443
 
4399
4444
  // src/web/source.ts
4400
- import { createHash as createHash2 } from "node:crypto";
4445
+ import { createHash } from "node:crypto";
4401
4446
  import { statSync } from "node:fs";
4402
- import { basename as basename2, dirname as dirname5 } from "node:path";
4447
+ import { basename as basename3, dirname as dirname5 } from "node:path";
4403
4448
  function readWebSnapshot(defaultFile) {
4404
4449
  const pages = listPageFiles(defaultFile).map((file) => {
4405
4450
  const id = pageIdOfFile(defaultFile, file) ?? "";
@@ -4414,41 +4459,46 @@ function readWebSnapshot(defaultFile) {
4414
4459
  }
4415
4460
  });
4416
4461
  if (pages.length === 0) pages.push({ id: "", title: "\u7B49\u5F85\u7B2C\u4E00\u5F20\u5730\u56FE", modified: 0, map: EMPTY_MAP });
4417
- const value = { project: basename2(dirname5(dirname5(defaultFile))), pages };
4418
- return { revision: createHash2("sha256").update(JSON.stringify(value)).digest("hex"), value };
4462
+ const value = { project: basename3(dirname5(dirname5(defaultFile))), pages };
4463
+ return { revision: createHash("sha256").update(JSON.stringify(value)).digest("hex"), value };
4419
4464
  }
4420
4465
 
4421
4466
  // src/web/launcher.ts
4422
4467
  var webRuntimeFile = (defaultFile) => join4(dirname6(defaultFile), "web", "server.json");
4423
- async function runningWebUrl(defaultFile) {
4468
+ async function runningWebRuntime(defaultFile) {
4424
4469
  try {
4425
- const info = JSON.parse(readFileSync3(webRuntimeFile(defaultFile), "utf8"));
4470
+ const info = JSON.parse(readFileSync2(webRuntimeFile(defaultFile), "utf8"));
4426
4471
  if (!Number.isInteger(info.port) || info.port < 1 || info.port > 65535 || !/^[a-f0-9]{48}$/.test(info.token)) return void 0;
4427
4472
  const url = `http://127.0.0.1:${info.port}/${info.token}/`;
4428
4473
  const response = await fetch(`${url}api/health`, { signal: AbortSignal.timeout(700) });
4429
- if (response.ok && (await response.json()).file === defaultFile) return url;
4474
+ if (response.ok) {
4475
+ const health = await response.json();
4476
+ if (health.file === defaultFile) return { url, health };
4477
+ }
4430
4478
  } catch {
4431
4479
  }
4432
4480
  return void 0;
4433
4481
  }
4482
+ async function runningWebUrl(defaultFile) {
4483
+ return (await runningWebRuntime(defaultFile))?.url;
4484
+ }
4434
4485
  async function openWebPreview(defaultFile, entry, page, terminal = false) {
4435
4486
  if (page !== void 0 && (!ID_RULE.test(page) || !readWebSnapshot(defaultFile).value.pages.some((p) => p.id === page))) throw new Error(`No map page named "${page}".`);
4436
- let url = await runningWebUrl(defaultFile);
4437
- if (url) {
4438
- const health = await fetch(`${url}api/health`, { signal: AbortSignal.timeout(2e3) });
4439
- const info = await health.json();
4440
- if (!info.formats?.includes(2) || terminal && !info.surfaces?.includes("web-terminal")) {
4441
- await fetch(`${url}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
4487
+ let runtime = await runningWebRuntime(defaultFile);
4488
+ if (runtime) {
4489
+ const { url: url2, health } = runtime;
4490
+ if (health.lockProtocol === STORE_LOCK_PROTOCOL && (!health.formats?.includes(2) || terminal && !health.surfaces?.includes("web-terminal"))) {
4491
+ await fetch(`${url2}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
4442
4492
  const deadline = Date.now() + 3e3;
4443
4493
  while (await runningWebUrl(defaultFile)) {
4444
4494
  if (Date.now() > deadline) throw new Error("Old web viewer is still stopping. Retry opening the map.");
4445
4495
  await new Promise((resolve3) => setTimeout(resolve3, 100));
4446
4496
  }
4447
- url = void 0;
4497
+ runtime = void 0;
4448
4498
  }
4449
4499
  }
4450
- if (!url) {
4451
- if (!existsSync2(entry)) throw new Error(`Web runtime missing: ${entry}. Run npm run build or reinstall the plugin.`);
4500
+ if (!runtime) {
4501
+ if (!existsSync3(entry)) throw new Error(`Web runtime missing: ${entry}. Run npm run build or reinstall the plugin.`);
4452
4502
  const child = spawn(process.execPath, [entry, "--serve", defaultFile], { detached: true, windowsHide: true, stdio: "ignore" });
4453
4503
  let failure;
4454
4504
  child.on("error", (error) => {
@@ -4456,13 +4506,20 @@ async function openWebPreview(defaultFile, entry, page, terminal = false) {
4456
4506
  });
4457
4507
  child.unref();
4458
4508
  const deadline = Date.now() + 8e3;
4459
- while (!url && Date.now() < deadline) {
4509
+ while (!runtime && Date.now() < deadline) {
4460
4510
  if (failure) throw failure;
4461
4511
  await new Promise((resolve3) => setTimeout(resolve3, 100));
4462
- url = await runningWebUrl(defaultFile);
4512
+ runtime = await runningWebRuntime(defaultFile);
4463
4513
  }
4464
- if (!url) throw new Error("Web preview did not start. Run the web CLI directly to inspect the error.");
4514
+ if (!runtime) throw new Error("Web preview did not start. Run the web CLI directly to inspect the error.");
4465
4515
  }
4516
+ if (runtime.health.lockProtocol !== STORE_LOCK_PROTOCOL) {
4517
+ throw new LedgerError(
4518
+ "LOCK_MIGRATION_REQUIRED",
4519
+ "The running web viewer uses an older storage-lock protocol. Stop the viewer with mellos-mapping-web <project-directory> --stop, then reopen it using the updated runtime."
4520
+ );
4521
+ }
4522
+ const { url } = runtime;
4466
4523
  const query = new URLSearchParams();
4467
4524
  if (terminal) query.set("view", "terminal");
4468
4525
  if (page !== void 0) query.set("page", page);
@@ -4474,8 +4531,8 @@ import { randomBytes } from "node:crypto";
4474
4531
  import { createServer } from "node:http";
4475
4532
 
4476
4533
  // src/preview/publisher.ts
4477
- import { createHash as createHash3 } from "node:crypto";
4478
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync, rmdirSync } from "node:fs";
4534
+ import { createHash as createHash2 } from "node:crypto";
4535
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, realpathSync as realpathSync2, rmdirSync } from "node:fs";
4479
4536
  import { dirname as dirname7, join as join5, resolve } from "node:path";
4480
4537
 
4481
4538
  // src/semantics/vocabulary.ts
@@ -5201,7 +5258,7 @@ function previewFile(defaultFile, page) {
5201
5258
  }
5202
5259
  function save(path, contents) {
5203
5260
  try {
5204
- if (readFileSync4(path, "utf8") === contents) return;
5261
+ if (readFileSync3(path, "utf8") === contents) return;
5205
5262
  } catch (error) {
5206
5263
  if (error.code !== "ENOENT") throw error;
5207
5264
  }
@@ -5210,8 +5267,8 @@ function save(path, contents) {
5210
5267
  }
5211
5268
  function ownedDirectory(path) {
5212
5269
  mkdirSync3(path, { recursive: true });
5213
- const expected = join5(realpathSync(dirname7(path)), path.slice(dirname7(path).length + 1));
5214
- const actual = realpathSync(path);
5270
+ const expected = join5(realpathSync2(dirname7(path)), path.slice(dirname7(path).length + 1));
5271
+ const actual = realpathSync2(path);
5215
5272
  if (process.platform === "win32" ? actual.toLowerCase() !== expected.toLowerCase() : actual !== expected) {
5216
5273
  throw new Error(`Preview directory redirects outside its parent: ${path}`);
5217
5274
  }
@@ -5233,7 +5290,7 @@ function acquireLock(directory) {
5233
5290
  function createPreviewPublisher(defaultFile) {
5234
5291
  const directory = previewDirectory(defaultFile);
5235
5292
  const enabledFile = join5(directory, ENABLED);
5236
- const enabled = () => existsSync3(enabledFile);
5293
+ const enabled = () => existsSync4(enabledFile);
5237
5294
  const refresh = (page) => {
5238
5295
  try {
5239
5296
  const path = previewFile(defaultFile, page);
@@ -5244,9 +5301,9 @@ function createPreviewPublisher(defaultFile) {
5244
5301
  for (const source of listPageFiles(defaultFile)) {
5245
5302
  const slug = pageIdOfFile(defaultFile, source);
5246
5303
  if (slug !== void 0 && !ID_RULE.test(slug)) throw new Error(`Invalid map page filename: ${source}`);
5247
- const loaded = loadMapFile(source);
5248
- if (!loaded.ok) throw new Error(describeStoreError(loaded.error));
5249
- pages.push({ page: slug, map: loaded.value });
5304
+ const loaded2 = loadMapFile(source);
5305
+ if (!loaded2.ok) throw new Error(describeStoreError(loaded2.error));
5306
+ pages.push({ page: slug, map: loaded2.value });
5250
5307
  }
5251
5308
  if (page !== void 0 && !pages.some((p) => p.page === page)) return err(`No map page named "${page}".`);
5252
5309
  if (page === void 0 && !pages.some((p) => p.page === void 0)) pages.unshift({ page: void 0, map: EMPTY_MAP });
@@ -5255,7 +5312,7 @@ function createPreviewPublisher(defaultFile) {
5255
5312
  const present = /* @__PURE__ */ new Set();
5256
5313
  for (const item of pages) {
5257
5314
  const svg = renderMapSvg(item.map);
5258
- const digest = createHash3("sha256").update(svg).digest("hex");
5315
+ const digest = createHash2("sha256").update(svg).digest("hex");
5259
5316
  const image = `images/${digest}.svg`;
5260
5317
  save(join5(images, `${digest}.svg`), svg);
5261
5318
  const filename = documentName(item.page);
@@ -5441,15 +5498,15 @@ import { spawn as spawn3 } from "node:child_process";
5441
5498
  import { win32 } from "node:path";
5442
5499
 
5443
5500
  // src/support/star-reminder.ts
5444
- import { existsSync as existsSync4, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "node:fs";
5445
- import { basename as basename3, dirname as dirname8, join as join6 } from "node:path";
5501
+ import { existsSync as existsSync5, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "node:fs";
5502
+ import { basename as basename4, dirname as dirname8, join as join6 } from "node:path";
5446
5503
  var STAR_URL = "https://github.com/GuangminJu/mellos-mapping";
5447
5504
  var DAY_MS = 864e5;
5448
5505
  function isNpmInstallation(entry) {
5449
5506
  try {
5450
- const root = dirname8(dirname8(realpathSync2(entry)));
5451
- if (basename3(dirname8(root)).toLowerCase() !== "node_modules" || existsSync4(join6(root, ".git")) || existsSync4(join6(root, ".codex-plugin")) || existsSync4(join6(root, ".claude-plugin"))) return false;
5452
- const pkg = JSON.parse(readFileSync5(join6(root, "package.json"), "utf8"));
5507
+ const root = dirname8(dirname8(realpathSync3(entry)));
5508
+ if (basename4(dirname8(root)).toLowerCase() !== "node_modules" || existsSync5(join6(root, ".git")) || existsSync5(join6(root, ".codex-plugin")) || existsSync5(join6(root, ".claude-plugin"))) return false;
5509
+ const pkg = JSON.parse(readFileSync4(join6(root, "package.json"), "utf8"));
5453
5510
  return pkg.name === "mellos-mapping" && pkg.bin?.mmap === "dist/mmap.mjs";
5454
5511
  } catch {
5455
5512
  return false;
@@ -5479,7 +5536,7 @@ function createStarReminder(options) {
5479
5536
  const day = Math.floor(now / DAY_MS);
5480
5537
  let state;
5481
5538
  try {
5482
- const saved2 = JSON.parse(readFileSync5(file, "utf8"));
5539
+ const saved2 = JSON.parse(readFileSync4(file, "utf8"));
5483
5540
  if (!validState(saved2)) return false;
5484
5541
  state = saved2;
5485
5542
  } catch (error) {
@@ -5595,7 +5652,7 @@ async function startWebService(defaultFile, assets, options = {}) {
5595
5652
  }
5596
5653
  }
5597
5654
  if (req.method === "GET" && route === "api/health") {
5598
- send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, formats: [1, 2], surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5655
+ send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, formats: [1, 2], lockProtocol: STORE_LOCK_PROTOCOL, surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5599
5656
  return;
5600
5657
  }
5601
5658
  if (req.method === "POST" && route === "api/star-reminder/open") {
@@ -5714,23 +5771,23 @@ async function startWebService(defaultFile, assets, options = {}) {
5714
5771
 
5715
5772
  // src/web/cli.ts
5716
5773
  async function runWeb(args) {
5717
- const entry = fileURLToPath(import.meta.url);
5774
+ const entry = fileURLToPath2(import.meta.url);
5718
5775
  if (args[0] === "--serve" && args.length === 2) {
5719
5776
  const file2 = resolve2(args[1]);
5720
5777
  if (await runningWebUrl(file2)) return;
5721
5778
  const directory = dirname9(webRuntimeFile(file2));
5722
5779
  mkdirSync4(directory, { recursive: true });
5723
- if (realpathSync3(directory).toLowerCase() !== join7(realpathSync3(dirname9(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5780
+ if (realpathSync4(directory).toLowerCase() !== join7(realpathSync4(dirname9(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5724
5781
  const assets = join7(dirname9(entry), "web");
5725
5782
  let service;
5726
5783
  service = await startWebService(file2, {
5727
- html: readFileSync6(join7(assets, "index.html"), "utf8"),
5728
- javascript: readFileSync6(join7(assets, "app.js"), "utf8"),
5729
- css: readFileSync6(join7(assets, "app.css"), "utf8"),
5730
- terminal: { html: readFileSync6(join7(assets, "terminal.html"), "utf8"), javascript: readFileSync6(join7(assets, "terminal.js"), "utf8"), css: readFileSync6(join7(assets, "terminal.css"), "utf8"), xtermCss: readFileSync6(join7(assets, "xterm.css"), "utf8") }
5784
+ html: readFileSync5(join7(assets, "index.html"), "utf8"),
5785
+ javascript: readFileSync5(join7(assets, "app.js"), "utf8"),
5786
+ css: readFileSync5(join7(assets, "app.css"), "utf8"),
5787
+ terminal: { html: readFileSync5(join7(assets, "terminal.html"), "utf8"), javascript: readFileSync5(join7(assets, "terminal.js"), "utf8"), css: readFileSync5(join7(assets, "terminal.css"), "utf8"), xtermCss: readFileSync5(join7(assets, "xterm.css"), "utf8") }
5731
5788
  }, { starReminder: createStarReminder({ entry, userBase: homedir(), env: process.env }), terminalWorker: join7(dirname9(entry), "terminal-worker.mjs"), onClose: () => {
5732
5789
  try {
5733
- if (JSON.parse(readFileSync6(webRuntimeFile(file2), "utf8")).token === service.token) rmSync4(webRuntimeFile(file2));
5790
+ if (JSON.parse(readFileSync5(webRuntimeFile(file2), "utf8")).token === service.token) rmSync3(webRuntimeFile(file2));
5734
5791
  } catch {
5735
5792
  }
5736
5793
  } });
@@ -5761,7 +5818,7 @@ async function runWeb(args) {
5761
5818
  else throw new Error(usage);
5762
5819
  }
5763
5820
  const project = resolve2(args[0]);
5764
- if (!existsSync5(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5821
+ if (!existsSync6(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5765
5822
  const file = join7(project, STATE_FILE_RELATIVE_PATH);
5766
5823
  if (stop) {
5767
5824
  const url = await runningWebUrl(file);
@@ -5771,7 +5828,7 @@ async function runWeb(args) {
5771
5828
  }
5772
5829
  console.log(JSON.stringify({ surface: terminal ? "web-terminal" : "web", url: await openWebPreview(file, entry, page, terminal), visibility: "unconfirmed" }));
5773
5830
  }
5774
- if (process.argv[1] && existsSync5(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync3(process.argv[1])).href) {
5831
+ if (process.argv[1] && existsSync6(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync4(process.argv[1])).href) {
5775
5832
  runWeb(process.argv.slice(2)).catch((error) => {
5776
5833
  console.error(String(error));
5777
5834
  process.exitCode = 1;
package/docs/codex.md CHANGED
@@ -6,7 +6,10 @@ has its own skill and includes no Claude SessionStart hook or MCP configuration.
6
6
 
7
7
  ## Install and update
8
8
 
9
- Requires Node.js 18+ and a Codex CLI with plugin commands (tested with 0.153.4).
9
+ Requires Node.js 18.17+ on the 18.x line, or 20.3+, and a Codex CLI with plugin
10
+ commands (tested with 0.153.4). Supported platforms are Windows 10+, macOS 13.0+
11
+ and Linux with glibc 2.28+, each on x64 or arm64. The OS must also meet the selected
12
+ Node.js version's requirements. Native lock bindings ship with the package.
10
13
  From the `chatgpt-app` branch run `node install.mjs`; from `main` run
11
14
  `node install.mjs chatgpt-app`. Committed bundles need no build or npm dependencies.
12
15
 
@@ -0,0 +1,115 @@
1
+ # Project locking
2
+
3
+ Map writes have three separate guarantees: atomic file replacement keeps a
4
+ reader from seeing half a saved map, a project lock serializes cooperating
5
+ writers, and an optional revision check rejects edits computed from stale
6
+ data. None of these replaces the others.
7
+
8
+ ## One stable lock for every page
9
+
10
+ The default page and all named pages use the same fixed regular file,
11
+ `.mellos/.write-lock`, for their project.
12
+ `withStoreLock` acquires an OS exclusive lock without waiting before reading
13
+ the current graph. MCP graph mutations, HTTP viewer mutations and watcher
14
+ mutations all use this boundary. A competing writer receives `BUSY` and can
15
+ retry after the active operation completes; acquisition does not start a
16
+ background poll or queue.
17
+
18
+ The lock file is independent of the map JSON files. A successful map save
19
+ replaces its target through a private sibling temporary file, while the lock
20
+ file keeps the same identity. Do not delete, rename or replace the lock file:
21
+ doing so can leave processes locking different files for the same project.
22
+ Keeping the file after an operation is deliberate; its presence is not proof
23
+ that a writer is active.
24
+
25
+ Normal completion and errors release the lock. When the owning process exits,
26
+ including a crash, the OS releases its lock. The new protocol does not use
27
+ directory creation, an `owner.json` PID, a `.reap` directory, or elapsed time
28
+ to decide ownership or reclaim a lock. Failure to acquire the required OS lock
29
+ must not cause a write to continue without it.
30
+
31
+ ## A lock does not validate an earlier read
32
+
33
+ Each operation loads the current map, checks a supplied `expectedRevision`,
34
+ applies its changes and saves while holding the project lock. This prevents
35
+ two cooperating writers from both saving independently loaded copies at once.
36
+ The lock does not remain held across separate MCP read and write calls.
37
+
38
+ When an edit depends on data returned by `mmap_read`, include that page's
39
+ revision in the write. If another operation changed the map in between, the
40
+ write returns `CONFLICT` without applying the edit. Read again, reconsider the
41
+ change, and submit it with the new revision. Use `expectedRevision: "absent"`
42
+ when declaring a page that must not already exist.
43
+
44
+ Omitting `expectedRevision` preserves compatibility, but skips that comparison.
45
+ For example, if two sessions read the same `context`, revise different parts,
46
+ and then write their complete objects without a revision, the later write
47
+ replaces the earlier one. The project lock serializes both writes; it cannot
48
+ infer which fields the caller intended to preserve.
49
+
50
+ ## Scope
51
+
52
+ The lock is a contract among participating writers. Hand edits, direct calls
53
+ to low-level `saveMapFile`, and older running processes do not acquire this
54
+ new lock. A filesystem must support the OS lock used by the runtime; file
55
+ replacement alone is not an alternative locking protocol.
56
+
57
+ The bundled native bindings require Node.js `^18.17.0 || >=20.3.0` (N-API 9).
58
+ Releases include bindings for Windows 10+ (Server 2016+ on x64), macOS 13.0+
59
+ and Linux with glibc 2.28+, each on x64 and arm64; users do not compile them
60
+ during installation. A missing binding or unsupported platform returns
61
+ `LOCK_UNAVAILABLE` instead of falling back to directory locks or continuing
62
+ without a lock.
63
+
64
+ macOS 13.0 is the deployment target recorded in both shipped Mach-O addons.
65
+ The OS must also meet the selected Node.js version's requirements: for example,
66
+ [Node.js 24 requires macOS 13.5+](https://github.com/nodejs/node/blob/v24.0.0/BUILDING.md#platform-list).
67
+ The Linux addons require only `libc.so.6`, with maximum symbol versions
68
+ `GLIBC_2.14` on x64 and `GLIBC_2.17` on arm64; they have no `GLIBCXX` or `CXXABI`
69
+ dependency. The higher glibc 2.28 baseline comes from the supported official
70
+ Node.js binaries, which also require kernel 4.18+ and libstdc++ providing
71
+ `GLIBCXX_3.4.25`; see the [Node.js 18.17 platform requirements](https://github.com/nodejs/node/blob/v18.17.0/BUILDING.md#platform-list).
72
+ The Windows addons import system `KERNEL32.dll` and `ntdll.dll`; the supported
73
+ Windows floor follows Node.js, rather than the older PE header version.
74
+
75
+ Pages organize separate efforts, but share the project lock. This does not
76
+ turn legacy `mmap_remove {pages:[...]}` into a multi-page transaction: that API
77
+ still reports partial deletion and does not accept `expectedRevision`. Use
78
+ per-page `deletePage` for revision-checked deletion. See the
79
+ [persistent-map API guide](map-api.md) for the data contract.
80
+
81
+ ## Upgrade from directory locks
82
+
83
+ Earlier runtimes used a directory named `.mellos/.write-lock`, containing
84
+ `owner.json` and sometimes `.reap`. New writers use a regular file at that same
85
+ path. If they find a directory there, they return `LOCK_MIGRATION_REQUIRED`.
86
+ They do not reclaim it automatically, even when the recorded PID appears dead
87
+ or its timestamp is old.
88
+
89
+ 1. Stop every old MCP server, HTTP viewer service and native watcher using this
90
+ project. Updating files or opening a new browser tab does not stop those
91
+ processes. Stop and restart existing HTTP services explicitly; there is no
92
+ hot upgrade of the locking protocol.
93
+ New clients check the viewer's health response for `lockProtocol:
94
+ "os-file-v1"`; an older service returns a migration error on reuse rather
95
+ than silently providing a viewer with different write semantics. Use
96
+ `mellos-mapping-web <project-directory> --stop` before reopening it.
97
+ 2. Inspect `.mellos/.write-lock`. If it is a **directory**, and all old writers
98
+ have stopped, move that directory to a separate backup location. Preserve
99
+ it for diagnosis. If it is already a **regular file**, leave it in place:
100
+ it belongs to the new protocol and must not be deleted or renamed. If the
101
+ path is absent, no old directory needs moving.
102
+ 3. Start the updated runtime and retry the operation. It creates the permanent
103
+ regular file if absent, acquires the OS lock and then performs the write.
104
+ Reconnect viewer tabs using the URL returned by the restarted service.
105
+
106
+ Using the same path prevents old and new writers from silently choosing
107
+ different locks. The new runtime's atomic file creation competes with the old
108
+ runtime's directory creation. If the old directory wins, the new runtime
109
+ requires migration. If the regular file wins, an old writer cannot create its
110
+ directory or read `owner.json` beneath the file and returns `BUSY` instead of
111
+ writing. Do not remove the new file to make an old client proceed; upgrade and
112
+ restart that client.
113
+
114
+ Do not automate this migration by deleting everything named `.write-lock`.
115
+ The old directory and the new permanent file require different treatment.