@limno/core 0.1.3 → 0.1.4

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.
Files changed (3) hide show
  1. package/dist/index.cjs +174 -41
  2. package/dist/index.js +173 -40
  3. package/package.json +4 -4
package/dist/index.cjs CHANGED
@@ -59,8 +59,8 @@ function jsxName(name) {
59
59
  return `${jsxName(name.object)}.${name.property.name}`;
60
60
  }
61
61
  var isCapitalized = (s) => /^[A-Z]/.test(s);
62
- function componentName(path4, fileBase) {
63
- let p = path4.getFunctionParent();
62
+ function componentName(path5, fileBase) {
63
+ let p = path5.getFunctionParent();
64
64
  while (p) {
65
65
  const node = p.node;
66
66
  let name;
@@ -102,8 +102,8 @@ function stampSource(code, id, relFile) {
102
102
  const fileBase = relFile.split("/").pop().replace(/\.[jt]sx?$/, "").replace(/^./, (c) => c.toUpperCase());
103
103
  let touched = false;
104
104
  traverse(ast, {
105
- JSXOpeningElement(path4) {
106
- const node = path4.node;
105
+ JSXOpeningElement(path5) {
106
+ const node = path5.node;
107
107
  const loc = node.loc;
108
108
  if (!loc || node.name.end == null) return;
109
109
  const name = jsxName(node.name);
@@ -111,7 +111,7 @@ function stampSource(code, id, relFile) {
111
111
  const host = node.name.type === "JSXIdentifier" && !isCapitalized(name);
112
112
  if (host) {
113
113
  if (hasAttr(node, "data-limno-src")) return;
114
- const comp = componentName(path4, fileBase);
114
+ const comp = componentName(path5, fileBase);
115
115
  s.appendLeft(node.name.end, ` data-limno-src="${escapeAttr(where)}"${comp ? ` data-limno-comp="${escapeAttr(comp)}"` : ""}`);
116
116
  touched = true;
117
117
  } else {
@@ -223,9 +223,9 @@ async function stampTemplate(code, id, relFile, root) {
223
223
 
224
224
  // src/service.ts
225
225
  var import_node_crypto = require("crypto");
226
- var import_node_fs2 = require("fs");
227
- var import_node_module2 = require("module");
228
- var import_node_path3 = __toESM(require("path"), 1);
226
+ var import_node_fs3 = require("fs");
227
+ var import_node_module3 = require("module");
228
+ var import_node_path4 = __toESM(require("path"), 1);
229
229
  var import_spec = require("@limno/spec");
230
230
 
231
231
  // src/runner.ts
@@ -546,22 +546,129 @@ function createRunner(root, folder, onState) {
546
546
  };
547
547
  }
548
548
 
549
+ // src/update.ts
550
+ var import_node_child_process2 = require("child_process");
551
+ var import_node_fs2 = require("fs");
552
+ var import_node_module2 = require("module");
553
+ var import_node_os2 = __toESM(require("os"), 1);
554
+ var import_node_path3 = __toESM(require("path"), 1);
555
+ var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
556
+ var DAY = 24 * 60 * 60 * 1e3;
557
+ function currentVersion() {
558
+ try {
559
+ return require2("../package.json").version;
560
+ } catch {
561
+ return "0.0.0";
562
+ }
563
+ }
564
+ var newer = (a, b) => {
565
+ const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
566
+ const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
567
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
568
+ return false;
569
+ };
570
+ function packageManager(root) {
571
+ let dir = root;
572
+ for (; ; ) {
573
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
574
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "yarn.lock"))) return "yarn";
575
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "bun.lockb")) || (0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "bun.lock"))) return "bun";
576
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "package-lock.json"))) return "npm";
577
+ const up = import_node_path3.default.dirname(dir);
578
+ if (up === dir) return "npm";
579
+ dir = up;
580
+ }
581
+ }
582
+ function listedPackages(root) {
583
+ try {
584
+ const pkg = JSON.parse((0, import_node_fs2.readFileSync)(import_node_path3.default.join(root, "package.json"), "utf8"));
585
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
586
+ } catch {
587
+ return [];
588
+ }
589
+ }
590
+ function installArgs(pm, pkgs) {
591
+ const at = pkgs.map((p) => `${p}@latest`);
592
+ if (pm === "pnpm") return ["pnpm", ["add", "-D", ...at]];
593
+ if (pm === "yarn") return ["yarn", ["add", "-D", ...at]];
594
+ if (pm === "bun") return ["bun", ["add", "-d", ...at]];
595
+ return ["npm", ["install", "-D", ...at]];
596
+ }
597
+ var cacheFile = () => import_node_path3.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
598
+ async function latestVersion() {
599
+ try {
600
+ const cached = JSON.parse((0, import_node_fs2.readFileSync)(cacheFile(), "utf8"));
601
+ if (Date.now() - cached.at < DAY) return cached.latest;
602
+ } catch {
603
+ }
604
+ try {
605
+ const res = await fetch("https://registry.npmjs.org/-/package/@limno/core/dist-tags", { signal: AbortSignal.timeout(4e3) });
606
+ if (!res.ok) return null;
607
+ const latest = (await res.json()).latest ?? null;
608
+ if (latest) {
609
+ (0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(cacheFile()), { recursive: true });
610
+ (0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
611
+ }
612
+ return latest;
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+ function createUpdater(root) {
618
+ let installing = null;
619
+ async function info() {
620
+ const current = currentVersion();
621
+ if (process.env.LIMNO_NO_UPDATE_CHECK) return { current, latest: null, available: false, command: null };
622
+ const latest = await latestVersion();
623
+ const pkgs = listedPackages(root);
624
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
625
+ const available = !!latest && newer(latest, current) && pkgs.length > 0;
626
+ return { current, latest, available, command: available ? `${cmd} ${args.join(" ")}` : null };
627
+ }
628
+ function install() {
629
+ if (installing) return installing;
630
+ const pkgs = listedPackages(root);
631
+ if (!pkgs.length) return Promise.resolve({ ok: false, output: "No @limno packages found in package.json." });
632
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
633
+ installing = new Promise((resolve) => {
634
+ let output = "";
635
+ const child = (0, import_node_child_process2.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
636
+ child.stdout?.on("data", (d) => output += d);
637
+ child.stderr?.on("data", (d) => output += d);
638
+ child.on("error", (e) => resolve({ ok: false, output: String(e) }));
639
+ child.on("close", (code) => {
640
+ if (code === 0) {
641
+ try {
642
+ (0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
643
+ } catch {
644
+ }
645
+ }
646
+ resolve({ ok: code === 0, output: output.trim().split("\n").slice(-12).join("\n") });
647
+ });
648
+ }).finally(() => {
649
+ installing = null;
650
+ });
651
+ return installing;
652
+ }
653
+ return { info, install };
654
+ }
655
+
549
656
  // src/service.ts
550
657
  var ROUTE = "/__limno";
551
- var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
658
+ var require3 = (0, import_node_module3.createRequire)(importMetaUrl);
552
659
  var STALE_MS = 3e4;
553
660
  function readJson(file) {
554
661
  try {
555
- return JSON.parse((0, import_node_fs2.readFileSync)(file, "utf8"));
662
+ return JSON.parse((0, import_node_fs3.readFileSync)(file, "utf8"));
556
663
  } catch {
557
664
  return null;
558
665
  }
559
666
  }
560
667
  function writeAtomic(file, data) {
561
- (0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(file), { recursive: true });
668
+ (0, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(file), { recursive: true });
562
669
  const tmp = `${file}.${process.pid}.tmp`;
563
- (0, import_node_fs2.writeFileSync)(tmp, data);
564
- (0, import_node_fs2.renameSync)(tmp, file);
670
+ (0, import_node_fs3.writeFileSync)(tmp, data);
671
+ (0, import_node_fs3.renameSync)(tmp, file);
565
672
  }
566
673
  function body(req) {
567
674
  return new Promise((resolve, reject) => {
@@ -579,12 +686,12 @@ function send(res, status, payload, type = "application/json") {
579
686
  }
580
687
  var hash = (s) => (0, import_node_crypto.createHash)("sha1").update(s).digest("hex");
581
688
  function overlayFile() {
582
- return require2.resolve("@limno/overlay/dist/overlay.js");
689
+ return require3.resolve("@limno/overlay/dist/overlay.js");
583
690
  }
584
691
  function createLimnoService({ root, dir = ".limno" }) {
585
- const folder = import_node_path3.default.join(root, dir);
586
- const file = import_node_path3.default.join(folder, "changeset.json");
587
- const presenceFile = import_node_path3.default.join(folder, "agent.json");
692
+ const folder = import_node_path4.default.join(root, dir);
693
+ const file = import_node_path4.default.join(folder, "changeset.json");
694
+ const presenceFile = import_node_path4.default.join(folder, "agent.json");
588
695
  let lastWrittenHash = "";
589
696
  const clients = /* @__PURE__ */ new Set();
590
697
  function broadcast(event, data) {
@@ -595,6 +702,7 @@ data: ${JSON.stringify(data)}
595
702
  for (const c of clients) c.write(msg);
596
703
  }
597
704
  const runner = createRunner(root, folder, (state) => broadcast("runner", state));
705
+ const updater = createUpdater(root);
598
706
  function setupInfo() {
599
707
  return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
600
708
  }
@@ -612,23 +720,23 @@ data: ${JSON.stringify(data)}
612
720
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
613
721
  }
614
722
  function installConfig(target) {
615
- const dest = target === "claude" ? import_node_path3.default.join(root, ".mcp.json") : import_node_path3.default.join(root, ".cursor", "mcp.json");
723
+ const dest = target === "claude" ? import_node_path4.default.join(root, ".mcp.json") : import_node_path4.default.join(root, ".cursor", "mcp.json");
616
724
  let config = {};
617
- if ((0, import_node_fs2.existsSync)(dest)) {
725
+ if ((0, import_node_fs3.existsSync)(dest)) {
618
726
  try {
619
- config = JSON.parse((0, import_node_fs2.readFileSync)(dest, "utf8"));
727
+ config = JSON.parse((0, import_node_fs3.readFileSync)(dest, "utf8"));
620
728
  } catch {
621
- throw new Error(`${import_node_path3.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
729
+ throw new Error(`${import_node_path4.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
622
730
  }
623
731
  }
624
732
  const { command, args } = setupInfo();
625
733
  const portable = args.map((a, i) => {
626
734
  if (args[i - 1] === "--root") return ".";
627
- return import_node_path3.default.isAbsolute(a) ? import_node_path3.default.relative(root, a).split(import_node_path3.default.sep).join("/") : a;
735
+ return import_node_path4.default.isAbsolute(a) ? import_node_path4.default.relative(root, a).split(import_node_path4.default.sep).join("/") : a;
628
736
  });
629
737
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
630
738
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
631
- return import_node_path3.default.relative(root, dest);
739
+ return import_node_path4.default.relative(root, dest);
632
740
  }
633
741
  let lastAgent = "";
634
742
  function pushAgent() {
@@ -639,27 +747,45 @@ data: ${JSON.stringify(data)}
639
747
  broadcast("agent", status);
640
748
  }
641
749
  function onChangeSetFile() {
642
- const raw = (0, import_node_fs2.existsSync)(file) ? (0, import_node_fs2.readFileSync)(file, "utf8") : "";
750
+ const raw = (0, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : "";
643
751
  if (!raw || hash(raw) === lastWrittenHash) return;
644
752
  const set = readJson(file);
645
753
  if (set) broadcast("changeset", set);
646
754
  }
647
- (0, import_node_fs2.mkdirSync)(folder, { recursive: true });
755
+ (0, import_node_fs3.mkdirSync)(folder, { recursive: true });
648
756
  let watcher = null;
757
+ let watchedIno = -1;
649
758
  let debounce;
650
- try {
651
- watcher = (0, import_node_fs2.watch)(folder, (_event, name) => {
652
- if (!name || name.endsWith(".tmp")) return;
653
- clearTimeout(debounce);
654
- debounce = setTimeout(() => {
655
- if (name === "changeset.json") onChangeSetFile();
656
- if (name === "agent.json") pushAgent();
657
- }, 40);
658
- });
659
- watcher.unref?.();
660
- } catch {
759
+ function startWatching() {
760
+ watcher?.close();
761
+ watcher = null;
762
+ try {
763
+ watchedIno = (0, import_node_fs3.statSync)(folder).ino;
764
+ watcher = (0, import_node_fs3.watch)(folder, (_event, name) => {
765
+ if (!name || name.endsWith(".tmp")) return;
766
+ clearTimeout(debounce);
767
+ debounce = setTimeout(() => {
768
+ if (name === "changeset.json") onChangeSetFile();
769
+ if (name === "agent.json") pushAgent();
770
+ }, 40);
771
+ });
772
+ watcher.unref?.();
773
+ } catch {
774
+ }
661
775
  }
662
- const presenceTimer = setInterval(pushAgent, 1e4);
776
+ startWatching();
777
+ const presenceTimer = setInterval(() => {
778
+ let ino = -1;
779
+ try {
780
+ ino = (0, import_node_fs3.statSync)(folder).ino;
781
+ } catch {
782
+ }
783
+ if (ino !== -1 && ino !== watchedIno) {
784
+ startWatching();
785
+ onChangeSetFile();
786
+ }
787
+ pushAgent();
788
+ }, 1e4);
663
789
  presenceTimer.unref();
664
790
  const keepAlive = setInterval(() => {
665
791
  for (const c of clients) c.write(": ping\n\n");
@@ -668,8 +794,8 @@ data: ${JSON.stringify(data)}
668
794
  async function openInEditor(spec) {
669
795
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
670
796
  if (!m) return false;
671
- const target = import_node_path3.default.resolve(root, m[1]);
672
- if (!target.startsWith(root + import_node_path3.default.sep) || !(0, import_node_fs2.existsSync)(target)) return false;
797
+ const target = import_node_path4.default.resolve(root, m[1]);
798
+ if (!target.startsWith(root + import_node_path4.default.sep) || !(0, import_node_fs3.existsSync)(target)) return false;
673
799
  const { default: launch } = await import("launch-editor");
674
800
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
675
801
  return true;
@@ -679,7 +805,7 @@ data: ${JSON.stringify(data)}
679
805
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
680
806
  try {
681
807
  if (url === `${ROUTE}/overlay.js`) {
682
- return send(res, 200, (0, import_node_fs2.readFileSync)(overlayFile(), "utf8"), "text/javascript");
808
+ return send(res, 200, (0, import_node_fs3.readFileSync)(overlayFile(), "utf8"), "text/javascript");
683
809
  }
684
810
  if (url === `${ROUTE}/events`) {
685
811
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -716,6 +842,13 @@ data: ${JSON.stringify(data)}
716
842
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
717
843
  }
718
844
  }
845
+ if (url === `${ROUTE}/update` && req.method === "GET") {
846
+ return send(res, 200, await updater.info());
847
+ }
848
+ if (url === `${ROUTE}/update` && req.method === "POST") {
849
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
850
+ return send(res, 200, await updater.install());
851
+ }
719
852
  if (url === `${ROUTE}/runner` && req.method === "GET") {
720
853
  return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
721
854
  }
@@ -743,7 +876,7 @@ data: ${JSON.stringify(data)}
743
876
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
744
877
  }
745
878
  if (url === `${ROUTE}/info`) {
746
- return send(res, 200, { name: "limno", root, file: import_node_path3.default.relative(root, file) });
879
+ return send(res, 200, { name: "limno", root, file: import_node_path4.default.relative(root, file) });
747
880
  }
748
881
  send(res, 404, { error: "not found" });
749
882
  } catch (err) {
package/dist/index.js CHANGED
@@ -10,8 +10,8 @@ function jsxName(name) {
10
10
  return `${jsxName(name.object)}.${name.property.name}`;
11
11
  }
12
12
  var isCapitalized = (s) => /^[A-Z]/.test(s);
13
- function componentName(path4, fileBase) {
14
- let p = path4.getFunctionParent();
13
+ function componentName(path5, fileBase) {
14
+ let p = path5.getFunctionParent();
15
15
  while (p) {
16
16
  const node = p.node;
17
17
  let name;
@@ -53,8 +53,8 @@ function stampSource(code, id, relFile) {
53
53
  const fileBase = relFile.split("/").pop().replace(/\.[jt]sx?$/, "").replace(/^./, (c) => c.toUpperCase());
54
54
  let touched = false;
55
55
  traverse(ast, {
56
- JSXOpeningElement(path4) {
57
- const node = path4.node;
56
+ JSXOpeningElement(path5) {
57
+ const node = path5.node;
58
58
  const loc = node.loc;
59
59
  if (!loc || node.name.end == null) return;
60
60
  const name = jsxName(node.name);
@@ -62,7 +62,7 @@ function stampSource(code, id, relFile) {
62
62
  const host = node.name.type === "JSXIdentifier" && !isCapitalized(name);
63
63
  if (host) {
64
64
  if (hasAttr(node, "data-limno-src")) return;
65
- const comp = componentName(path4, fileBase);
65
+ const comp = componentName(path5, fileBase);
66
66
  s.appendLeft(node.name.end, ` data-limno-src="${escapeAttr(where)}"${comp ? ` data-limno-comp="${escapeAttr(comp)}"` : ""}`);
67
67
  touched = true;
68
68
  } else {
@@ -174,9 +174,9 @@ async function stampTemplate(code, id, relFile, root) {
174
174
 
175
175
  // src/service.ts
176
176
  import { createHash } from "crypto";
177
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, watch, writeFileSync as writeFileSync2 } from "fs";
178
- import { createRequire as createRequire2 } from "module";
179
- import path3 from "path";
177
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync, statSync, watch, writeFileSync as writeFileSync3 } from "fs";
178
+ import { createRequire as createRequire3 } from "module";
179
+ import path4 from "path";
180
180
  import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
181
181
 
182
182
  // src/runner.ts
@@ -497,21 +497,128 @@ function createRunner(root, folder, onState) {
497
497
  };
498
498
  }
499
499
 
500
+ // src/update.ts
501
+ import { spawn as spawn2 } from "child_process";
502
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
503
+ import { createRequire as createRequire2 } from "module";
504
+ import os2 from "os";
505
+ import path3 from "path";
506
+ var require2 = createRequire2(import.meta.url);
507
+ var DAY = 24 * 60 * 60 * 1e3;
508
+ function currentVersion() {
509
+ try {
510
+ return require2("../package.json").version;
511
+ } catch {
512
+ return "0.0.0";
513
+ }
514
+ }
515
+ var newer = (a, b) => {
516
+ const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
517
+ const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
518
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
519
+ return false;
520
+ };
521
+ function packageManager(root) {
522
+ let dir = root;
523
+ for (; ; ) {
524
+ if (existsSync(path3.join(dir, "pnpm-lock.yaml"))) return "pnpm";
525
+ if (existsSync(path3.join(dir, "yarn.lock"))) return "yarn";
526
+ if (existsSync(path3.join(dir, "bun.lockb")) || existsSync(path3.join(dir, "bun.lock"))) return "bun";
527
+ if (existsSync(path3.join(dir, "package-lock.json"))) return "npm";
528
+ const up = path3.dirname(dir);
529
+ if (up === dir) return "npm";
530
+ dir = up;
531
+ }
532
+ }
533
+ function listedPackages(root) {
534
+ try {
535
+ const pkg = JSON.parse(readFileSync2(path3.join(root, "package.json"), "utf8"));
536
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
537
+ } catch {
538
+ return [];
539
+ }
540
+ }
541
+ function installArgs(pm, pkgs) {
542
+ const at = pkgs.map((p) => `${p}@latest`);
543
+ if (pm === "pnpm") return ["pnpm", ["add", "-D", ...at]];
544
+ if (pm === "yarn") return ["yarn", ["add", "-D", ...at]];
545
+ if (pm === "bun") return ["bun", ["add", "-d", ...at]];
546
+ return ["npm", ["install", "-D", ...at]];
547
+ }
548
+ var cacheFile = () => path3.join(os2.homedir(), ".limno", "update-check.json");
549
+ async function latestVersion() {
550
+ try {
551
+ const cached = JSON.parse(readFileSync2(cacheFile(), "utf8"));
552
+ if (Date.now() - cached.at < DAY) return cached.latest;
553
+ } catch {
554
+ }
555
+ try {
556
+ const res = await fetch("https://registry.npmjs.org/-/package/@limno/core/dist-tags", { signal: AbortSignal.timeout(4e3) });
557
+ if (!res.ok) return null;
558
+ const latest = (await res.json()).latest ?? null;
559
+ if (latest) {
560
+ mkdirSync2(path3.dirname(cacheFile()), { recursive: true });
561
+ writeFileSync2(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
562
+ }
563
+ return latest;
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
568
+ function createUpdater(root) {
569
+ let installing = null;
570
+ async function info() {
571
+ const current = currentVersion();
572
+ if (process.env.LIMNO_NO_UPDATE_CHECK) return { current, latest: null, available: false, command: null };
573
+ const latest = await latestVersion();
574
+ const pkgs = listedPackages(root);
575
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
576
+ const available = !!latest && newer(latest, current) && pkgs.length > 0;
577
+ return { current, latest, available, command: available ? `${cmd} ${args.join(" ")}` : null };
578
+ }
579
+ function install() {
580
+ if (installing) return installing;
581
+ const pkgs = listedPackages(root);
582
+ if (!pkgs.length) return Promise.resolve({ ok: false, output: "No @limno packages found in package.json." });
583
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
584
+ installing = new Promise((resolve) => {
585
+ let output = "";
586
+ const child = spawn2(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
587
+ child.stdout?.on("data", (d) => output += d);
588
+ child.stderr?.on("data", (d) => output += d);
589
+ child.on("error", (e) => resolve({ ok: false, output: String(e) }));
590
+ child.on("close", (code) => {
591
+ if (code === 0) {
592
+ try {
593
+ writeFileSync2(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
594
+ } catch {
595
+ }
596
+ }
597
+ resolve({ ok: code === 0, output: output.trim().split("\n").slice(-12).join("\n") });
598
+ });
599
+ }).finally(() => {
600
+ installing = null;
601
+ });
602
+ return installing;
603
+ }
604
+ return { info, install };
605
+ }
606
+
500
607
  // src/service.ts
501
608
  var ROUTE = "/__limno";
502
- var require2 = createRequire2(import.meta.url);
609
+ var require3 = createRequire3(import.meta.url);
503
610
  var STALE_MS = 3e4;
504
611
  function readJson(file) {
505
612
  try {
506
- return JSON.parse(readFileSync2(file, "utf8"));
613
+ return JSON.parse(readFileSync3(file, "utf8"));
507
614
  } catch {
508
615
  return null;
509
616
  }
510
617
  }
511
618
  function writeAtomic(file, data) {
512
- mkdirSync2(path3.dirname(file), { recursive: true });
619
+ mkdirSync3(path4.dirname(file), { recursive: true });
513
620
  const tmp = `${file}.${process.pid}.tmp`;
514
- writeFileSync2(tmp, data);
621
+ writeFileSync3(tmp, data);
515
622
  renameSync(tmp, file);
516
623
  }
517
624
  function body(req) {
@@ -530,12 +637,12 @@ function send(res, status, payload, type = "application/json") {
530
637
  }
531
638
  var hash = (s) => createHash("sha1").update(s).digest("hex");
532
639
  function overlayFile() {
533
- return require2.resolve("@limno/overlay/dist/overlay.js");
640
+ return require3.resolve("@limno/overlay/dist/overlay.js");
534
641
  }
535
642
  function createLimnoService({ root, dir = ".limno" }) {
536
- const folder = path3.join(root, dir);
537
- const file = path3.join(folder, "changeset.json");
538
- const presenceFile = path3.join(folder, "agent.json");
643
+ const folder = path4.join(root, dir);
644
+ const file = path4.join(folder, "changeset.json");
645
+ const presenceFile = path4.join(folder, "agent.json");
539
646
  let lastWrittenHash = "";
540
647
  const clients = /* @__PURE__ */ new Set();
541
648
  function broadcast(event, data) {
@@ -546,6 +653,7 @@ data: ${JSON.stringify(data)}
546
653
  for (const c of clients) c.write(msg);
547
654
  }
548
655
  const runner = createRunner(root, folder, (state) => broadcast("runner", state));
656
+ const updater = createUpdater(root);
549
657
  function setupInfo() {
550
658
  return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
551
659
  }
@@ -563,23 +671,23 @@ data: ${JSON.stringify(data)}
563
671
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
564
672
  }
565
673
  function installConfig(target) {
566
- const dest = target === "claude" ? path3.join(root, ".mcp.json") : path3.join(root, ".cursor", "mcp.json");
674
+ const dest = target === "claude" ? path4.join(root, ".mcp.json") : path4.join(root, ".cursor", "mcp.json");
567
675
  let config = {};
568
- if (existsSync(dest)) {
676
+ if (existsSync2(dest)) {
569
677
  try {
570
- config = JSON.parse(readFileSync2(dest, "utf8"));
678
+ config = JSON.parse(readFileSync3(dest, "utf8"));
571
679
  } catch {
572
- throw new Error(`${path3.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
680
+ throw new Error(`${path4.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
573
681
  }
574
682
  }
575
683
  const { command, args } = setupInfo();
576
684
  const portable = args.map((a, i) => {
577
685
  if (args[i - 1] === "--root") return ".";
578
- return path3.isAbsolute(a) ? path3.relative(root, a).split(path3.sep).join("/") : a;
686
+ return path4.isAbsolute(a) ? path4.relative(root, a).split(path4.sep).join("/") : a;
579
687
  });
580
688
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
581
689
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
582
- return path3.relative(root, dest);
690
+ return path4.relative(root, dest);
583
691
  }
584
692
  let lastAgent = "";
585
693
  function pushAgent() {
@@ -590,27 +698,45 @@ data: ${JSON.stringify(data)}
590
698
  broadcast("agent", status);
591
699
  }
592
700
  function onChangeSetFile() {
593
- const raw = existsSync(file) ? readFileSync2(file, "utf8") : "";
701
+ const raw = existsSync2(file) ? readFileSync3(file, "utf8") : "";
594
702
  if (!raw || hash(raw) === lastWrittenHash) return;
595
703
  const set = readJson(file);
596
704
  if (set) broadcast("changeset", set);
597
705
  }
598
- mkdirSync2(folder, { recursive: true });
706
+ mkdirSync3(folder, { recursive: true });
599
707
  let watcher = null;
708
+ let watchedIno = -1;
600
709
  let debounce;
601
- try {
602
- watcher = watch(folder, (_event, name) => {
603
- if (!name || name.endsWith(".tmp")) return;
604
- clearTimeout(debounce);
605
- debounce = setTimeout(() => {
606
- if (name === "changeset.json") onChangeSetFile();
607
- if (name === "agent.json") pushAgent();
608
- }, 40);
609
- });
610
- watcher.unref?.();
611
- } catch {
710
+ function startWatching() {
711
+ watcher?.close();
712
+ watcher = null;
713
+ try {
714
+ watchedIno = statSync(folder).ino;
715
+ watcher = watch(folder, (_event, name) => {
716
+ if (!name || name.endsWith(".tmp")) return;
717
+ clearTimeout(debounce);
718
+ debounce = setTimeout(() => {
719
+ if (name === "changeset.json") onChangeSetFile();
720
+ if (name === "agent.json") pushAgent();
721
+ }, 40);
722
+ });
723
+ watcher.unref?.();
724
+ } catch {
725
+ }
612
726
  }
613
- const presenceTimer = setInterval(pushAgent, 1e4);
727
+ startWatching();
728
+ const presenceTimer = setInterval(() => {
729
+ let ino = -1;
730
+ try {
731
+ ino = statSync(folder).ino;
732
+ } catch {
733
+ }
734
+ if (ino !== -1 && ino !== watchedIno) {
735
+ startWatching();
736
+ onChangeSetFile();
737
+ }
738
+ pushAgent();
739
+ }, 1e4);
614
740
  presenceTimer.unref();
615
741
  const keepAlive = setInterval(() => {
616
742
  for (const c of clients) c.write(": ping\n\n");
@@ -619,8 +745,8 @@ data: ${JSON.stringify(data)}
619
745
  async function openInEditor(spec) {
620
746
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
621
747
  if (!m) return false;
622
- const target = path3.resolve(root, m[1]);
623
- if (!target.startsWith(root + path3.sep) || !existsSync(target)) return false;
748
+ const target = path4.resolve(root, m[1]);
749
+ if (!target.startsWith(root + path4.sep) || !existsSync2(target)) return false;
624
750
  const { default: launch } = await import("launch-editor");
625
751
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
626
752
  return true;
@@ -630,7 +756,7 @@ data: ${JSON.stringify(data)}
630
756
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
631
757
  try {
632
758
  if (url === `${ROUTE}/overlay.js`) {
633
- return send(res, 200, readFileSync2(overlayFile(), "utf8"), "text/javascript");
759
+ return send(res, 200, readFileSync3(overlayFile(), "utf8"), "text/javascript");
634
760
  }
635
761
  if (url === `${ROUTE}/events`) {
636
762
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -667,6 +793,13 @@ data: ${JSON.stringify(data)}
667
793
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
668
794
  }
669
795
  }
796
+ if (url === `${ROUTE}/update` && req.method === "GET") {
797
+ return send(res, 200, await updater.info());
798
+ }
799
+ if (url === `${ROUTE}/update` && req.method === "POST") {
800
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
801
+ return send(res, 200, await updater.install());
802
+ }
670
803
  if (url === `${ROUTE}/runner` && req.method === "GET") {
671
804
  return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
672
805
  }
@@ -694,7 +827,7 @@ data: ${JSON.stringify(data)}
694
827
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
695
828
  }
696
829
  if (url === `${ROUTE}/info`) {
697
- return send(res, 200, { name: "limno", root, file: path3.relative(root, file) });
830
+ return send(res, 200, { name: "limno", root, file: path4.relative(root, file) });
698
831
  }
699
832
  send(res, 404, { error: "not found" });
700
833
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limno/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Limno's framework-neutral dev tooling: JSX source stamping, the change-set store and the endpoints the editor talks to.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -42,9 +42,9 @@
42
42
  "dependencies": {
43
43
  "@babel/parser": "^7.29.0",
44
44
  "@babel/traverse": "^7.29.0",
45
- "@limno/mcp": "0.1.1",
46
- "@limno/overlay": "0.1.3",
47
- "@limno/spec": "0.1.1",
45
+ "@limno/mcp": "0.1.2",
46
+ "@limno/overlay": "0.1.4",
47
+ "@limno/spec": "0.1.2",
48
48
  "launch-editor": "^2.14.1",
49
49
  "magic-string": "^0.30.17"
50
50
  },