@limno/core 0.1.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/LICENSE.md +110 -0
- package/dist/index.cjs +543 -0
- package/dist/index.d.cts +74 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +494 -0
- package/dist/loader.cjs +133 -0
- package/package.json +54 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as magic_string from 'magic-string';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stamp every JSX host element with where it is written and which component renders it,
|
|
6
|
+
* and every component usage with its call site. React passes data-* props through untouched,
|
|
7
|
+
* so the attributes cost nothing at runtime and only exist in dev.
|
|
8
|
+
*/
|
|
9
|
+
declare function stampSource(code: string, id: string, relFile: string): {
|
|
10
|
+
code: string;
|
|
11
|
+
map: magic_string.SourceMap;
|
|
12
|
+
} | null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Source stamping for template languages: Vue single-file components and Svelte components.
|
|
16
|
+
* Every plain HTML element in a template gets `data-limno-src` (file:line:col) and
|
|
17
|
+
* `data-limno-comp` (the component, i.e. the file). Each framework's own parser is loaded from
|
|
18
|
+
* the project, so positions match exactly what the framework compiles.
|
|
19
|
+
*/
|
|
20
|
+
declare const TEMPLATE_FILES: RegExp;
|
|
21
|
+
/** Stamp a .vue / .svelte file. Returns null when there's nothing to do. */
|
|
22
|
+
declare function stampTemplate(code: string, id: string, relFile: string, root: string): Promise<{
|
|
23
|
+
code: string;
|
|
24
|
+
map: magic_string.SourceMap;
|
|
25
|
+
} | null>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The framework-neutral half of Limno's dev tooling: the change-set store in the project
|
|
29
|
+
* (.limno/), the agent presence check, and the /__limno/* endpoints the overlay talks to.
|
|
30
|
+
* Bundler integrations (Vite, Next.js, webpack/Rspack) mount `handle` in their dev server or
|
|
31
|
+
* run it on its own port via `startServer`.
|
|
32
|
+
*/
|
|
33
|
+
declare const ROUTE = "/__limno";
|
|
34
|
+
interface LimnoServiceOptions {
|
|
35
|
+
/** Project root: where .limno/ lives and source paths are relative to. */
|
|
36
|
+
root: string;
|
|
37
|
+
/** Directory (relative to root) for change sets. Default ".limno". */
|
|
38
|
+
dir?: string;
|
|
39
|
+
}
|
|
40
|
+
interface AgentStatus {
|
|
41
|
+
connected: boolean;
|
|
42
|
+
client: {
|
|
43
|
+
name: string;
|
|
44
|
+
version: string;
|
|
45
|
+
} | null;
|
|
46
|
+
lastSeen: number | null;
|
|
47
|
+
setup: {
|
|
48
|
+
command: string;
|
|
49
|
+
args: string[];
|
|
50
|
+
root: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
declare function overlayFile(): string;
|
|
54
|
+
interface LimnoService {
|
|
55
|
+
root: string;
|
|
56
|
+
/** Connect-style middleware: handles /__limno/*, otherwise calls next (or 404s). */
|
|
57
|
+
handle(req: IncomingMessage, res: ServerResponse, next?: () => void): void;
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
declare function createLimnoService({ root, dir }: LimnoServiceOptions): LimnoService;
|
|
61
|
+
|
|
62
|
+
interface LimnoServer {
|
|
63
|
+
/** Base URL of the server, e.g. http://127.0.0.1:41234 */
|
|
64
|
+
url: string;
|
|
65
|
+
close(): void;
|
|
66
|
+
}
|
|
67
|
+
declare function startServer(options: LimnoServiceOptions): Promise<LimnoServer>;
|
|
68
|
+
|
|
69
|
+
/** Files Limno stamps with source locations by default: JSX in .jsx/.tsx, and in .js/.mjs (common in Create React App, Gatsby and plain-JS Next.js apps). */
|
|
70
|
+
declare const SOURCE_FILES: RegExp;
|
|
71
|
+
/** True for files that should be stamped: JSX/TSX outside node_modules. */
|
|
72
|
+
declare function shouldStamp(file: string, include?: RegExp): boolean;
|
|
73
|
+
|
|
74
|
+
export { type AgentStatus, type LimnoServer, type LimnoService, type LimnoServiceOptions, ROUTE, SOURCE_FILES, TEMPLATE_FILES, createLimnoService, overlayFile, shouldStamp, stampSource, stampTemplate, startServer };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as magic_string from 'magic-string';
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stamp every JSX host element with where it is written and which component renders it,
|
|
6
|
+
* and every component usage with its call site. React passes data-* props through untouched,
|
|
7
|
+
* so the attributes cost nothing at runtime and only exist in dev.
|
|
8
|
+
*/
|
|
9
|
+
declare function stampSource(code: string, id: string, relFile: string): {
|
|
10
|
+
code: string;
|
|
11
|
+
map: magic_string.SourceMap;
|
|
12
|
+
} | null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Source stamping for template languages: Vue single-file components and Svelte components.
|
|
16
|
+
* Every plain HTML element in a template gets `data-limno-src` (file:line:col) and
|
|
17
|
+
* `data-limno-comp` (the component, i.e. the file). Each framework's own parser is loaded from
|
|
18
|
+
* the project, so positions match exactly what the framework compiles.
|
|
19
|
+
*/
|
|
20
|
+
declare const TEMPLATE_FILES: RegExp;
|
|
21
|
+
/** Stamp a .vue / .svelte file. Returns null when there's nothing to do. */
|
|
22
|
+
declare function stampTemplate(code: string, id: string, relFile: string, root: string): Promise<{
|
|
23
|
+
code: string;
|
|
24
|
+
map: magic_string.SourceMap;
|
|
25
|
+
} | null>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The framework-neutral half of Limno's dev tooling: the change-set store in the project
|
|
29
|
+
* (.limno/), the agent presence check, and the /__limno/* endpoints the overlay talks to.
|
|
30
|
+
* Bundler integrations (Vite, Next.js, webpack/Rspack) mount `handle` in their dev server or
|
|
31
|
+
* run it on its own port via `startServer`.
|
|
32
|
+
*/
|
|
33
|
+
declare const ROUTE = "/__limno";
|
|
34
|
+
interface LimnoServiceOptions {
|
|
35
|
+
/** Project root: where .limno/ lives and source paths are relative to. */
|
|
36
|
+
root: string;
|
|
37
|
+
/** Directory (relative to root) for change sets. Default ".limno". */
|
|
38
|
+
dir?: string;
|
|
39
|
+
}
|
|
40
|
+
interface AgentStatus {
|
|
41
|
+
connected: boolean;
|
|
42
|
+
client: {
|
|
43
|
+
name: string;
|
|
44
|
+
version: string;
|
|
45
|
+
} | null;
|
|
46
|
+
lastSeen: number | null;
|
|
47
|
+
setup: {
|
|
48
|
+
command: string;
|
|
49
|
+
args: string[];
|
|
50
|
+
root: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
declare function overlayFile(): string;
|
|
54
|
+
interface LimnoService {
|
|
55
|
+
root: string;
|
|
56
|
+
/** Connect-style middleware: handles /__limno/*, otherwise calls next (or 404s). */
|
|
57
|
+
handle(req: IncomingMessage, res: ServerResponse, next?: () => void): void;
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
declare function createLimnoService({ root, dir }: LimnoServiceOptions): LimnoService;
|
|
61
|
+
|
|
62
|
+
interface LimnoServer {
|
|
63
|
+
/** Base URL of the server, e.g. http://127.0.0.1:41234 */
|
|
64
|
+
url: string;
|
|
65
|
+
close(): void;
|
|
66
|
+
}
|
|
67
|
+
declare function startServer(options: LimnoServiceOptions): Promise<LimnoServer>;
|
|
68
|
+
|
|
69
|
+
/** Files Limno stamps with source locations by default: JSX in .jsx/.tsx, and in .js/.mjs (common in Create React App, Gatsby and plain-JS Next.js apps). */
|
|
70
|
+
declare const SOURCE_FILES: RegExp;
|
|
71
|
+
/** True for files that should be stamped: JSX/TSX outside node_modules. */
|
|
72
|
+
declare function shouldStamp(file: string, include?: RegExp): boolean;
|
|
73
|
+
|
|
74
|
+
export { type AgentStatus, type LimnoServer, type LimnoService, type LimnoServiceOptions, ROUTE, SOURCE_FILES, TEMPLATE_FILES, createLimnoService, overlayFile, shouldStamp, stampSource, stampTemplate, startServer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// src/transform.ts
|
|
2
|
+
import { parse } from "@babel/parser";
|
|
3
|
+
import _traverse from "@babel/traverse";
|
|
4
|
+
import MagicString from "magic-string";
|
|
5
|
+
var traverse = _traverse.default ?? _traverse;
|
|
6
|
+
var SKIP_COMPOSITES = /* @__PURE__ */ new Set(["Fragment", "React.Fragment", "Suspense", "React.Suspense", "StrictMode", "React.StrictMode", "Profiler", "React.Profiler", "Activity"]);
|
|
7
|
+
function jsxName(name) {
|
|
8
|
+
if (name.type === "JSXIdentifier") return name.name;
|
|
9
|
+
if (name.type === "JSXNamespacedName") return `${name.namespace.name}:${name.name.name}`;
|
|
10
|
+
return `${jsxName(name.object)}.${name.property.name}`;
|
|
11
|
+
}
|
|
12
|
+
var isCapitalized = (s) => /^[A-Z]/.test(s);
|
|
13
|
+
function componentName(path3, fileBase) {
|
|
14
|
+
let p = path3.getFunctionParent();
|
|
15
|
+
while (p) {
|
|
16
|
+
const node = p.node;
|
|
17
|
+
let name;
|
|
18
|
+
if ((node.type === "FunctionDeclaration" || node.type === "FunctionExpression") && node.id) name = node.id.name;
|
|
19
|
+
if (!name) {
|
|
20
|
+
let parent = p.parentPath;
|
|
21
|
+
while (parent && parent.node.type === "CallExpression") parent = parent.parentPath;
|
|
22
|
+
if (parent?.node.type === "VariableDeclarator" && parent.node.id.type === "Identifier") name = parent.node.id.name;
|
|
23
|
+
else if (parent?.node.type === "ExportDefaultDeclaration") name = fileBase;
|
|
24
|
+
else if (parent?.node.type === "ClassMethod" || parent?.node.type === "ObjectMethod") name = void 0;
|
|
25
|
+
}
|
|
26
|
+
if (!name && p.node.type === "ClassMethod") {
|
|
27
|
+
const cls = p.findParent((q) => q.isClassDeclaration());
|
|
28
|
+
name = cls?.node.id?.name;
|
|
29
|
+
}
|
|
30
|
+
if (name && isCapitalized(name)) return name;
|
|
31
|
+
p = p.parentPath ? p.parentPath.getFunctionParent() : null;
|
|
32
|
+
}
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
function hasAttr(node, name) {
|
|
36
|
+
return node.attributes.some((a) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === name);
|
|
37
|
+
}
|
|
38
|
+
var escapeAttr = (s) => s.replace(/&/g, "&").replace(/"/g, """);
|
|
39
|
+
function stampSource(code, id, relFile) {
|
|
40
|
+
if (!code.includes("<")) return null;
|
|
41
|
+
const isTs = /\.tsx$/.test(id);
|
|
42
|
+
let ast;
|
|
43
|
+
try {
|
|
44
|
+
ast = parse(code, {
|
|
45
|
+
sourceType: "module",
|
|
46
|
+
plugins: isTs ? ["jsx", "typescript"] : ["jsx"],
|
|
47
|
+
errorRecovery: true
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const s = new MagicString(code);
|
|
53
|
+
const fileBase = relFile.split("/").pop().replace(/\.[jt]sx?$/, "").replace(/^./, (c) => c.toUpperCase());
|
|
54
|
+
let touched = false;
|
|
55
|
+
traverse(ast, {
|
|
56
|
+
JSXOpeningElement(path3) {
|
|
57
|
+
const node = path3.node;
|
|
58
|
+
const loc = node.loc;
|
|
59
|
+
if (!loc || node.name.end == null) return;
|
|
60
|
+
const name = jsxName(node.name);
|
|
61
|
+
const where = `${relFile}:${loc.start.line}:${loc.start.column + 1}`;
|
|
62
|
+
const host = node.name.type === "JSXIdentifier" && !isCapitalized(name);
|
|
63
|
+
if (host) {
|
|
64
|
+
if (hasAttr(node, "data-limno-src")) return;
|
|
65
|
+
const comp = componentName(path3, fileBase);
|
|
66
|
+
s.appendLeft(node.name.end, ` data-limno-src="${escapeAttr(where)}"${comp ? ` data-limno-comp="${escapeAttr(comp)}"` : ""}`);
|
|
67
|
+
touched = true;
|
|
68
|
+
} else {
|
|
69
|
+
if (SKIP_COMPOSITES.has(name) || /\.(Provider|Consumer)$/.test(name) || hasAttr(node, "data-limno-callsite")) return;
|
|
70
|
+
s.appendLeft(node.name.end, ` data-limno-callsite="${escapeAttr(where)}"`);
|
|
71
|
+
touched = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (!touched) return null;
|
|
76
|
+
return { code: s.toString(), map: s.generateMap({ hires: "boundary", source: id, includeContent: true }) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/templates.ts
|
|
80
|
+
import { createRequire } from "module";
|
|
81
|
+
import path from "path";
|
|
82
|
+
import { pathToFileURL } from "url";
|
|
83
|
+
import MagicString2 from "magic-string";
|
|
84
|
+
var TEMPLATE_FILES = /\.(vue|svelte)$/;
|
|
85
|
+
var SKIP = /* @__PURE__ */ new Set(["script", "style", "template", "slot", "noscript"]);
|
|
86
|
+
var escapeAttr2 = (s) => s.replace(/&/g, "&").replace(/"/g, """);
|
|
87
|
+
function lineStarts(code) {
|
|
88
|
+
const starts = [0];
|
|
89
|
+
for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
|
|
90
|
+
return starts;
|
|
91
|
+
}
|
|
92
|
+
function position(starts, offset) {
|
|
93
|
+
let lo = 0;
|
|
94
|
+
let hi = starts.length - 1;
|
|
95
|
+
while (lo < hi) {
|
|
96
|
+
const mid = lo + hi + 1 >> 1;
|
|
97
|
+
if (starts[mid] <= offset) lo = mid;
|
|
98
|
+
else hi = mid - 1;
|
|
99
|
+
}
|
|
100
|
+
return { line: lo + 1, column: offset - starts[lo] + 1 };
|
|
101
|
+
}
|
|
102
|
+
async function load(root, specifiers) {
|
|
103
|
+
for (const from of [path.join(root, "package.json"), import.meta.url]) {
|
|
104
|
+
const req = createRequire(from);
|
|
105
|
+
for (const spec of specifiers) {
|
|
106
|
+
try {
|
|
107
|
+
const mod = await import(pathToFileURL(req.resolve(spec)).href);
|
|
108
|
+
return mod.default && typeof mod.default === "object" ? { ...mod.default, ...mod } : mod;
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
async function vueTags(code, root) {
|
|
116
|
+
const sfc = await load(root, ["vue/compiler-sfc", "@vue/compiler-sfc"]);
|
|
117
|
+
if (!sfc) return null;
|
|
118
|
+
const template = sfc.parse(code).descriptor.template;
|
|
119
|
+
if (!template?.ast || template.lang && template.lang !== "html") return null;
|
|
120
|
+
const tags = [];
|
|
121
|
+
const walk = (n) => {
|
|
122
|
+
if (n.type === 1 && n.tagType === 0 && n.tag && !SKIP.has(n.tag)) {
|
|
123
|
+
tags.push({ start: n.loc.start.offset, at: n.loc.start.offset + 1 + n.tag.length });
|
|
124
|
+
}
|
|
125
|
+
n.children?.forEach(walk);
|
|
126
|
+
n.branches?.forEach(walk);
|
|
127
|
+
};
|
|
128
|
+
template.ast.children?.forEach(walk);
|
|
129
|
+
return tags;
|
|
130
|
+
}
|
|
131
|
+
async function svelteTags(code, root) {
|
|
132
|
+
const compiler = await load(root, ["svelte/compiler"]);
|
|
133
|
+
if (!compiler?.parse) return null;
|
|
134
|
+
let ast;
|
|
135
|
+
try {
|
|
136
|
+
ast = compiler.parse(code, { modern: true });
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const tags = [];
|
|
141
|
+
const seen = /* @__PURE__ */ new Set();
|
|
142
|
+
const walk = (n) => {
|
|
143
|
+
if (!n || typeof n !== "object" || seen.has(n)) return;
|
|
144
|
+
seen.add(n);
|
|
145
|
+
const node = n;
|
|
146
|
+
if ((node.type === "RegularElement" || node.type === "Element") && node.name && node.start != null && !SKIP.has(node.name) && !node.name.includes(":")) {
|
|
147
|
+
tags.push({ start: node.start, at: node.start + 1 + node.name.length });
|
|
148
|
+
}
|
|
149
|
+
for (const [k, v] of Object.entries(node)) if (k !== "parent" && k !== "metadata") walk(v);
|
|
150
|
+
};
|
|
151
|
+
walk(ast.fragment ?? ast.html);
|
|
152
|
+
return tags;
|
|
153
|
+
}
|
|
154
|
+
async function stampTemplate(code, id, relFile, root) {
|
|
155
|
+
const ext = path.extname(id);
|
|
156
|
+
let tags = null;
|
|
157
|
+
try {
|
|
158
|
+
if (ext === ".vue") tags = await vueTags(code, root);
|
|
159
|
+
else if (ext === ".svelte") tags = await svelteTags(code, root);
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!tags?.length) return null;
|
|
164
|
+
const component = escapeAttr2(path.basename(id, ext).replace(/^./, (c) => c.toUpperCase()));
|
|
165
|
+
const starts = lineStarts(code);
|
|
166
|
+
const s = new MagicString2(code);
|
|
167
|
+
for (const t of tags) {
|
|
168
|
+
const { line, column } = position(starts, t.start);
|
|
169
|
+
if (code.slice(t.at, t.at + 200).split(">")[0].includes("data-limno-src")) continue;
|
|
170
|
+
s.appendLeft(t.at, ` data-limno-src="${escapeAttr2(`${relFile}:${line}:${column}`)}" data-limno-comp="${component}"`);
|
|
171
|
+
}
|
|
172
|
+
return { code: s.toString(), map: s.generateMap({ hires: "boundary", source: id, includeContent: true }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/service.ts
|
|
176
|
+
import { createHash } from "crypto";
|
|
177
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, watch, writeFileSync } from "fs";
|
|
178
|
+
import { createRequire as createRequire2 } from "module";
|
|
179
|
+
import path2 from "path";
|
|
180
|
+
import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
|
|
181
|
+
var ROUTE = "/__limno";
|
|
182
|
+
var require2 = createRequire2(import.meta.url);
|
|
183
|
+
var STALE_MS = 3e4;
|
|
184
|
+
function readJson(file) {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function writeAtomic(file, data) {
|
|
192
|
+
mkdirSync(path2.dirname(file), { recursive: true });
|
|
193
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
194
|
+
writeFileSync(tmp, data);
|
|
195
|
+
renameSync(tmp, file);
|
|
196
|
+
}
|
|
197
|
+
function body(req) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
let data = "";
|
|
200
|
+
req.on("data", (c) => data += c);
|
|
201
|
+
req.on("end", () => resolve(data));
|
|
202
|
+
req.on("error", reject);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function send(res, status, payload, type = "application/json") {
|
|
206
|
+
res.statusCode = status;
|
|
207
|
+
res.setHeader("Content-Type", type);
|
|
208
|
+
res.setHeader("Cache-Control", "no-store");
|
|
209
|
+
res.end(typeof payload === "string" ? payload : JSON.stringify(payload));
|
|
210
|
+
}
|
|
211
|
+
var hash = (s) => createHash("sha1").update(s).digest("hex");
|
|
212
|
+
function overlayFile() {
|
|
213
|
+
return require2.resolve("@limno/overlay/dist/overlay.js");
|
|
214
|
+
}
|
|
215
|
+
function createLimnoService({ root, dir = ".limno" }) {
|
|
216
|
+
const folder = path2.join(root, dir);
|
|
217
|
+
const file = path2.join(folder, "changeset.json");
|
|
218
|
+
const presenceFile = path2.join(folder, "agent.json");
|
|
219
|
+
let lastWrittenHash = "";
|
|
220
|
+
const clients = /* @__PURE__ */ new Set();
|
|
221
|
+
function broadcast(event, data) {
|
|
222
|
+
const msg = `event: ${event}
|
|
223
|
+
data: ${JSON.stringify(data)}
|
|
224
|
+
|
|
225
|
+
`;
|
|
226
|
+
for (const c of clients) c.write(msg);
|
|
227
|
+
}
|
|
228
|
+
function setupInfo() {
|
|
229
|
+
let cli = null;
|
|
230
|
+
for (const from of [path2.join(root, "package.json"), import.meta.url]) {
|
|
231
|
+
try {
|
|
232
|
+
cli = path2.join(path2.dirname(createRequire2(from).resolve("@limno/mcp/package.json")), "dist", "cli.js");
|
|
233
|
+
if (existsSync(cli)) break;
|
|
234
|
+
cli = null;
|
|
235
|
+
} catch {
|
|
236
|
+
cli = null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return cli ? { command: "node", args: [cli, "--root", root], root } : { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
|
|
240
|
+
}
|
|
241
|
+
function agentStatus() {
|
|
242
|
+
const presence = readJson(presenceFile);
|
|
243
|
+
let alive = !!presence?.pid;
|
|
244
|
+
if (alive) {
|
|
245
|
+
try {
|
|
246
|
+
process.kill(presence.pid, 0);
|
|
247
|
+
} catch {
|
|
248
|
+
alive = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const connected = alive && Date.now() - (presence?.lastSeen ?? 0) < STALE_MS;
|
|
252
|
+
return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
|
|
253
|
+
}
|
|
254
|
+
function installConfig(target) {
|
|
255
|
+
const dest = target === "claude" ? path2.join(root, ".mcp.json") : path2.join(root, ".cursor", "mcp.json");
|
|
256
|
+
let config = {};
|
|
257
|
+
if (existsSync(dest)) {
|
|
258
|
+
try {
|
|
259
|
+
config = JSON.parse(readFileSync(dest, "utf8"));
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error(`${path2.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const { command, args } = setupInfo();
|
|
265
|
+
const portable = args.map((a, i) => {
|
|
266
|
+
if (args[i - 1] === "--root") return ".";
|
|
267
|
+
return path2.isAbsolute(a) ? path2.relative(root, a).split(path2.sep).join("/") : a;
|
|
268
|
+
});
|
|
269
|
+
config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
|
|
270
|
+
writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
|
|
271
|
+
return path2.relative(root, dest);
|
|
272
|
+
}
|
|
273
|
+
let lastAgent = "";
|
|
274
|
+
function pushAgent() {
|
|
275
|
+
const status = agentStatus();
|
|
276
|
+
const key = JSON.stringify([status.connected, status.client]);
|
|
277
|
+
if (key === lastAgent) return;
|
|
278
|
+
lastAgent = key;
|
|
279
|
+
broadcast("agent", status);
|
|
280
|
+
}
|
|
281
|
+
function onChangeSetFile() {
|
|
282
|
+
const raw = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
283
|
+
if (!raw || hash(raw) === lastWrittenHash) return;
|
|
284
|
+
const set = readJson(file);
|
|
285
|
+
if (set) broadcast("changeset", set);
|
|
286
|
+
}
|
|
287
|
+
mkdirSync(folder, { recursive: true });
|
|
288
|
+
let watcher = null;
|
|
289
|
+
let debounce;
|
|
290
|
+
try {
|
|
291
|
+
watcher = watch(folder, (_event, name) => {
|
|
292
|
+
if (!name || name.endsWith(".tmp")) return;
|
|
293
|
+
clearTimeout(debounce);
|
|
294
|
+
debounce = setTimeout(() => {
|
|
295
|
+
if (name === "changeset.json") onChangeSetFile();
|
|
296
|
+
if (name === "agent.json") pushAgent();
|
|
297
|
+
}, 40);
|
|
298
|
+
});
|
|
299
|
+
watcher.unref?.();
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
const presenceTimer = setInterval(pushAgent, 1e4);
|
|
303
|
+
presenceTimer.unref();
|
|
304
|
+
const keepAlive = setInterval(() => {
|
|
305
|
+
for (const c of clients) c.write(": ping\n\n");
|
|
306
|
+
}, 25e3);
|
|
307
|
+
keepAlive.unref();
|
|
308
|
+
async function openInEditor(spec) {
|
|
309
|
+
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
|
|
310
|
+
if (!m) return false;
|
|
311
|
+
const target = path2.resolve(root, m[1]);
|
|
312
|
+
if (!target.startsWith(root + path2.sep) || !existsSync(target)) return false;
|
|
313
|
+
const { default: launch } = await import("launch-editor");
|
|
314
|
+
launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
async function handle(req, res, next) {
|
|
318
|
+
const [url, query = ""] = (req.url ?? "").split("?");
|
|
319
|
+
if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
|
|
320
|
+
try {
|
|
321
|
+
if (url === `${ROUTE}/overlay.js`) {
|
|
322
|
+
return send(res, 200, readFileSync(overlayFile(), "utf8"), "text/javascript");
|
|
323
|
+
}
|
|
324
|
+
if (url === `${ROUTE}/events`) {
|
|
325
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
326
|
+
res.write(": connected\n\n");
|
|
327
|
+
clients.add(res);
|
|
328
|
+
req.on("close", () => clients.delete(res));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (url === `${ROUTE}/changeset` && req.method === "GET") {
|
|
332
|
+
return send(res, 200, readJson(file) ?? emptyChangeSet());
|
|
333
|
+
}
|
|
334
|
+
if (url === `${ROUTE}/changeset` && req.method === "PUT") {
|
|
335
|
+
const incoming = JSON.parse(await body(req));
|
|
336
|
+
const merged = mergeStatuses(incoming, readJson(file));
|
|
337
|
+
merged.updatedAt = Date.now();
|
|
338
|
+
const raw = JSON.stringify(merged, null, 2);
|
|
339
|
+
lastWrittenHash = hash(raw);
|
|
340
|
+
writeAtomic(file, raw);
|
|
341
|
+
return send(res, 200, merged);
|
|
342
|
+
}
|
|
343
|
+
if (url === `${ROUTE}/prompt` && req.method === "GET") {
|
|
344
|
+
const set = readJson(file) ?? emptyChangeSet();
|
|
345
|
+
return send(res, 200, renderPrompt(set, { mcp: false, statuses: ["draft", "queued", "in_progress", "differs", "failed"] }), "text/markdown; charset=utf-8");
|
|
346
|
+
}
|
|
347
|
+
if (url === `${ROUTE}/agent` && req.method === "GET") {
|
|
348
|
+
return send(res, 200, agentStatus());
|
|
349
|
+
}
|
|
350
|
+
if (url === `${ROUTE}/agent/install` && req.method === "POST") {
|
|
351
|
+
const { target } = JSON.parse(await body(req));
|
|
352
|
+
if (target !== "claude" && target !== "cursor") return send(res, 400, { error: "unknown target" });
|
|
353
|
+
try {
|
|
354
|
+
return send(res, 200, { written: installConfig(target) });
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (url === `${ROUTE}/open`) {
|
|
360
|
+
const spec = new URLSearchParams(query).get("file") ?? "";
|
|
361
|
+
return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
|
|
362
|
+
}
|
|
363
|
+
if (url === `${ROUTE}/info`) {
|
|
364
|
+
return send(res, 200, { name: "limno", root, file: path2.relative(root, file) });
|
|
365
|
+
}
|
|
366
|
+
send(res, 404, { error: "not found" });
|
|
367
|
+
} catch (err) {
|
|
368
|
+
send(res, 500, { error: String(err) });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
root,
|
|
373
|
+
handle: (req, res, next) => void handle(req, res, next),
|
|
374
|
+
close() {
|
|
375
|
+
watcher?.close();
|
|
376
|
+
clearInterval(presenceTimer);
|
|
377
|
+
clearInterval(keepAlive);
|
|
378
|
+
for (const c of clients) c.end();
|
|
379
|
+
clients.clear();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/server.ts
|
|
385
|
+
import { createHash as createHash2 } from "crypto";
|
|
386
|
+
import http from "http";
|
|
387
|
+
var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
|
|
388
|
+
function allowedOrigin(origin) {
|
|
389
|
+
if (!origin) return null;
|
|
390
|
+
try {
|
|
391
|
+
const u = new URL(origin);
|
|
392
|
+
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.test(u.host) ? origin : null;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function preferredPort(root) {
|
|
398
|
+
return 41e3 + parseInt(createHash2("sha1").update(root).digest("hex").slice(0, 6), 16) % 900;
|
|
399
|
+
}
|
|
400
|
+
async function ours(port, root) {
|
|
401
|
+
try {
|
|
402
|
+
const res = await fetch(`http://127.0.0.1:${port}${ROUTE}/info`, { signal: AbortSignal.timeout(800) });
|
|
403
|
+
const info = await res.json();
|
|
404
|
+
return info.name === "limno" && info.root === root;
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
var running = /* @__PURE__ */ new Map();
|
|
410
|
+
function startServer(options) {
|
|
411
|
+
const key = `${options.root}|${options.dir ?? ""}`;
|
|
412
|
+
let p = running.get(key);
|
|
413
|
+
if (!p) {
|
|
414
|
+
p = listen(options);
|
|
415
|
+
running.set(key, p);
|
|
416
|
+
}
|
|
417
|
+
return p;
|
|
418
|
+
}
|
|
419
|
+
async function listen(options) {
|
|
420
|
+
const service = createLimnoService(options);
|
|
421
|
+
const server = http.createServer((req, res) => {
|
|
422
|
+
if (!LOOPBACK.test(req.headers.host ?? "")) {
|
|
423
|
+
res.statusCode = 403;
|
|
424
|
+
return res.end();
|
|
425
|
+
}
|
|
426
|
+
const origin = allowedOrigin(req.headers.origin);
|
|
427
|
+
if (req.headers.origin && !origin) {
|
|
428
|
+
res.statusCode = 403;
|
|
429
|
+
return res.end();
|
|
430
|
+
}
|
|
431
|
+
if (origin) {
|
|
432
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
433
|
+
res.setHeader("Vary", "Origin");
|
|
434
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS");
|
|
435
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
436
|
+
if (req.headers["access-control-request-private-network"]) res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
437
|
+
}
|
|
438
|
+
if (req.method === "OPTIONS") {
|
|
439
|
+
res.statusCode = 204;
|
|
440
|
+
return res.end();
|
|
441
|
+
}
|
|
442
|
+
service.handle(req, res);
|
|
443
|
+
});
|
|
444
|
+
server.unref();
|
|
445
|
+
const start = preferredPort(options.root);
|
|
446
|
+
for (let port = start; port < start + 40; port++) {
|
|
447
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
448
|
+
const onError = (err) => {
|
|
449
|
+
server.off("listening", onListening);
|
|
450
|
+
if (err.code === "EADDRINUSE") resolve("busy");
|
|
451
|
+
else reject(err);
|
|
452
|
+
};
|
|
453
|
+
const onListening = () => {
|
|
454
|
+
server.off("error", onError);
|
|
455
|
+
resolve("ok");
|
|
456
|
+
};
|
|
457
|
+
server.once("error", onError);
|
|
458
|
+
server.once("listening", onListening);
|
|
459
|
+
server.listen(port, "127.0.0.1");
|
|
460
|
+
});
|
|
461
|
+
if (outcome === "ok") {
|
|
462
|
+
return {
|
|
463
|
+
url: `http://127.0.0.1:${port}`,
|
|
464
|
+
close: () => {
|
|
465
|
+
service.close();
|
|
466
|
+
server.close();
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (await ours(port, options.root)) {
|
|
471
|
+
service.close();
|
|
472
|
+
return { url: `http://127.0.0.1:${port}`, close: () => void 0 };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
service.close();
|
|
476
|
+
throw new Error("Limno: no free local port for its dev server.");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/files.ts
|
|
480
|
+
var SOURCE_FILES = /\.(jsx|tsx|js|mjs)$/;
|
|
481
|
+
function shouldStamp(file, include = SOURCE_FILES) {
|
|
482
|
+
return include.test(file) && !/[\\/]node_modules[\\/]/.test(file) && !file.startsWith("\0");
|
|
483
|
+
}
|
|
484
|
+
export {
|
|
485
|
+
ROUTE,
|
|
486
|
+
SOURCE_FILES,
|
|
487
|
+
TEMPLATE_FILES,
|
|
488
|
+
createLimnoService,
|
|
489
|
+
overlayFile,
|
|
490
|
+
shouldStamp,
|
|
491
|
+
stampSource,
|
|
492
|
+
stampTemplate,
|
|
493
|
+
startServer
|
|
494
|
+
};
|