fanluc 2.0.0 → 2.4.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/README.md +83 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-hiYMdxpV.js → abnfDiagram-O67JEVCF-Di2iDQfl.js} +1 -1
- package/dist/assets/{arc-DokRg_wy.js → arc-wXq_yD5s.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-UzHYe9Bx.js → architectureDiagram-NJMV4G6O-CTXbg-3W.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DqM5UILT.js → blockDiagram-BEXU5L5S-DSDoL3RP.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Cxkdm7sV.js → c4Diagram-YGBWAQC7-3sFM3iyw.js} +1 -1
- package/dist/assets/channel-atnI5OIR.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-Nl4zmLlG.js → chunk-2Q5K7J3B-DUOKiJNI.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-BCol78O2.js → chunk-3FUC2YCW-ablWb8_c.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-1u4YRv2Y.js → chunk-5VM5RSS4-qmlSVZ_1.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BG9YIV2h.js → chunk-GTNCS2PH-PLlC7cNS.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BTKfkJAH.js → chunk-GWA4HPMP-DK-1a6YJ.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DCjElfA7.js → chunk-JWPE2WC7-D9zynFKs.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-QoJORlVn.js → chunk-NETBCI7D-BvzPMYyE.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-Da-T8ct9.js → chunk-WEXAMYUT-CzcfZFWB.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CLu69Uei.js → chunk-XXDRQBXY-DznN-rwG.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-DIXavbI9.js → classDiagram-v2-NBCMYWYE-D4jem0hV.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-DuWclG8-.js → cose-bilkent-JH36ORCC-VALsUQJS.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-D2CG5-xd.js → cynefin-OW5HDTMX-DK77QSI8.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-goI7_Qfg.js → cynefinDiagram-VND7K2PF-CdYXfGNR.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BmOaZ0cm.js → dagre-6A5THRUB-DeeUQzDV.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-DjDJFMw3.js → diagram-22UHCM2B-B9shZ914.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-J6z81NH3.js → diagram-3UASUU5V-gSHvaXBI.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-B1MPU8ai.js → diagram-ATOU4E4O-BrCI5HzS.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-X5Uss0Yv.js → diagram-CDSNMT55-XtVlMRq0.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CRDqfYy1.js → diagram-MLGK6HIB-BVa6KsvA.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-bhJtyiOq.js → diagram-MPIPVDR6-C8P23P2T.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-Dn1mZAV8.js → ebnfDiagram-ZINNZB2B-BrooaQj5.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-C93BlBEU.js → elk-276RUBZZ-B-qiKlkF.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-PuzjAvhD.js → erDiagram-OPXOYQCR-CL1bp2Mf.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-oRhFgO0p.js → flowDiagram-KWPJA3E3-BMQvaQ-5.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-DKiU1XjU.js → ganttDiagram-FUAMR5RP-PLnTGRI8.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-CWBMI0vb.js → gitGraphDiagram-X574FWY7-CiTN3Joc.js} +1 -1
- package/dist/assets/index-BEKLwzQT.css +1 -0
- package/dist/assets/index-C8gDJtDK.js +2841 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-C5Hd4Afz.js → infoDiagram-VRGFBTTK-ComkS_96.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BdD6ulRK.js → ishikawaDiagram-OU5B5YK6-D-dNoDFJ.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-CzT-uvks.js → journeyDiagram-ZHPQQLJL-BMm9BQM8.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-BkrazFMR.js → kanban-definition-PNTS6WVX-D5If-l_D.js} +1 -1
- package/dist/assets/{linear-B0wSM1Dr.js → linear-DXAHb0Ag.js} +1 -1
- package/dist/assets/{mindmap-definition-NLK3R4M7-Df1Acqkf.js → mindmap-definition-NLK3R4M7-CIdwCXTJ.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-DK_F7h_0.js → pegDiagram-GJSIUBJH-MY7FoVQh.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-BMXyNb9R.js → pieDiagram-5QR66LMP-C7lWWAtM.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Dfu7Z8so.js → quadrantDiagram-O4NWA36T-jHqCwvi8.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-CfnBD-aQ.js → railroadDiagram-XR7U4H2S-BwoIA8Ln.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DrvsZJPP.js → requirementDiagram-PLB6GJNP-Cw8w0Rwb.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-DvdVDpT1.js → sankeyDiagram-IPEJSGJF-DTRo1Yxy.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-D1UTrvZH.js → sequenceDiagram-PO4LG4MO-DqZddXsc.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CJrOBcai.js → sizeCapture-INFHLROL-BAEQ4XP5.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-CX8VmtqD.js → stateDiagram-v2-GCMORJYK-CYB4CouD.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DUWmhiK6.js → swimlanes-2SLR337P-Dw53LU13.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-Dzkep6h_.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-CBovVdOU.js → timeline-definition-EJHVYXUP-Chk-unGo.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-LD5N5BAG.js → usecaseDiagram-POWQR4AR-D_JpD-4l.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-DdwfzB3A.js → vennDiagram-UO4OBE2U-BnPO8gk8.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-BMHK4bav.js → wardleyDiagram-VNRHLVJA-CD0z1l8R.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-q1BNzMC-.js → xychartDiagram-PMCCYNJV-CHEqj1Pe.js} +1 -1
- package/dist/cli.js +52 -57
- package/dist/index.html +4 -4
- package/dist/server.cjs +2144 -841
- package/dist/server.cjs.map +4 -4
- package/dist/tui.js +3220 -3310
- package/docs/fanluc_ui.md +120 -40
- package/docs/guide-connect.md +233 -4
- package/docs/run-cmd-security.md +26 -0
- package/index.html +2 -2
- package/mcp.json +3 -0
- package/package.json +88 -92
- package/searxng/docker-compose.yml +3 -3
- package/vite.config.ts +2 -2
- package/dist/assets/channel-SPfNTvmj.js +0 -1
- package/dist/assets/index-BylpTP-E.css +0 -1
- package/dist/assets/index-DcpVteR3.js +0 -3237
- package/dist/assets/permissionHelper--88tJJlz.js +0 -3
- package/dist/assets/swimlanesDiagram-TC7HE7FX-DMQy1553.js +0 -8
package/dist/server.cjs
CHANGED
|
@@ -4,10 +4,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
5
|
var __getProtoOf = Object.getPrototypeOf;
|
|
6
6
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
-
var __export = (target, all) => {
|
|
8
|
-
for (var name in all)
|
|
9
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
-
};
|
|
11
7
|
var __copyProps = (to, from, except, desc) => {
|
|
12
8
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
9
|
for (let key of __getOwnPropNames(from))
|
|
@@ -24,391 +20,1833 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
20
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
21
|
mod
|
|
26
22
|
));
|
|
27
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
23
|
|
|
29
24
|
// server.ts
|
|
30
|
-
var server_exports = {};
|
|
31
|
-
__export(server_exports, {
|
|
32
|
-
WORKSPACE_DIR: () => WORKSPACE_DIR,
|
|
33
|
-
startServer: () => startServer
|
|
34
|
-
});
|
|
35
|
-
module.exports = __toCommonJS(server_exports);
|
|
36
25
|
var import_express = __toESM(require("express"), 1);
|
|
37
|
-
var
|
|
38
|
-
var
|
|
39
|
-
var import_os = __toESM(require("os"), 1);
|
|
40
|
-
var import_child_process = require("child_process");
|
|
26
|
+
var import_path3 = __toESM(require("path"), 1);
|
|
27
|
+
var import_fs3 = __toESM(require("fs"), 1);
|
|
41
28
|
var import_vite = require("vite");
|
|
42
29
|
var import_google_translate_api_x = require("google-translate-api-x");
|
|
43
30
|
var import_jsdom = require("jsdom");
|
|
44
31
|
var import_readability = require("@mozilla/readability");
|
|
45
32
|
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
46
|
-
var
|
|
33
|
+
var import_promises2 = __toESM(require("node:dns/promises"), 1);
|
|
47
34
|
var import_node_net = __toESM(require("node:net"), 1);
|
|
35
|
+
var import_client = require("@modelcontextprotocol/sdk/client/index.js");
|
|
48
36
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
37
|
+
// localExec.ts
|
|
38
|
+
var import_fs2 = __toESM(require("fs"), 1);
|
|
39
|
+
var import_os2 = __toESM(require("os"), 1);
|
|
40
|
+
var import_path2 = __toESM(require("path"), 1);
|
|
41
|
+
var import_promises = require("stream/promises");
|
|
42
|
+
|
|
43
|
+
// permissions.ts
|
|
44
|
+
var import_fs = __toESM(require("fs"), 1);
|
|
45
|
+
var import_os = __toESM(require("os"), 1);
|
|
46
|
+
var import_path = __toESM(require("path"), 1);
|
|
47
|
+
var DEFAULTS = {
|
|
48
|
+
modes: { create: "ask", move: "allow", delete: "ask", archive: "allow", install: "ask", edit: "allow" },
|
|
49
|
+
limits: { maxCreateMB: "", maxDeleteMB: "", maxInstallMB: "", maxToolCalls: "" }
|
|
50
|
+
};
|
|
51
|
+
var LABEL = {
|
|
52
|
+
create: "create file/folder",
|
|
53
|
+
move: "move/rename",
|
|
54
|
+
delete: "delete",
|
|
55
|
+
archive: "zip/unzip",
|
|
56
|
+
install: "install/uninstall libraries",
|
|
57
|
+
edit: "edit file"
|
|
58
|
+
};
|
|
59
|
+
var FILE = import_path.default.join(import_os.default.homedir(), ".fanluc", "permissions.json");
|
|
60
|
+
var cache = null;
|
|
61
|
+
function getPerms() {
|
|
62
|
+
if (cache) return cache;
|
|
63
|
+
let raw = {};
|
|
75
64
|
try {
|
|
76
|
-
|
|
65
|
+
raw = JSON.parse(import_fs.default.readFileSync(FILE, "utf-8"));
|
|
77
66
|
} catch {
|
|
78
67
|
}
|
|
68
|
+
cache = sanitize(raw);
|
|
69
|
+
return cache;
|
|
70
|
+
}
|
|
71
|
+
function sanitize(raw) {
|
|
72
|
+
const out = JSON.parse(JSON.stringify(DEFAULTS));
|
|
73
|
+
for (const k of Object.keys(DEFAULTS.modes)) {
|
|
74
|
+
const v = raw?.modes?.[k];
|
|
75
|
+
if (v === "allow" || v === "ask" || v === "deny") out.modes[k] = v;
|
|
76
|
+
}
|
|
77
|
+
for (const k of Object.keys(DEFAULTS.limits)) {
|
|
78
|
+
const v = raw?.limits?.[k];
|
|
79
|
+
if (typeof v === "string" || typeof v === "number") out.limits[k] = String(v).trim().replace(/[^0-9.]/g, "").slice(0, 12);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function setPerms(raw) {
|
|
84
|
+
cache = sanitize(raw);
|
|
79
85
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
import_fs.default.mkdirSync(import_path.default.dirname(FILE), { recursive: true });
|
|
87
|
+
import_fs.default.writeFileSync(FILE, JSON.stringify(cache, null, 2), { mode: 384 });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.warn("[permissions] save failed:", e);
|
|
82
90
|
}
|
|
83
|
-
return
|
|
91
|
+
return cache;
|
|
84
92
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{ pattern: /\bmkfs\b|\bdd\s+if=.*of=\/dev\/|\bshred\b/i, reason: "disk-level destructive command" },
|
|
94
|
-
{ pattern: /\b(shutdown|reboot|halt|poweroff)\b/i, reason: "system power control" },
|
|
95
|
-
{ pattern: /\bchmod\s+.*777\s+\//i, reason: "world-writable permission change on root" },
|
|
96
|
-
{ pattern: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(bash|sh|zsh)\b/i, reason: "piping a remote script straight into a shell" },
|
|
97
|
-
{ pattern: /\/etc\/(passwd|shadow|sudoers)\b/i, reason: "reading system credential files" },
|
|
98
|
-
{ pattern: /\.env\b/i, reason: "accessing .env (may contain server secrets, not exposed to run_cmd on purpose)" },
|
|
99
|
-
{ pattern: /\bnc\b.*-e\b|\/dev\/tcp\//i, reason: "raw reverse-shell / netcat exec pattern" },
|
|
100
|
-
{ pattern: /\bcrontab\b/i, reason: "persistence via crontab" },
|
|
101
|
-
// --- Windows / PowerShell ---
|
|
102
|
-
{ pattern: /\b(rd|rmdir)\b[^&|;]*\/s/i, reason: "Windows rd/rmdir /s recursive delete" },
|
|
103
|
-
{ pattern: /\b(del|erase)\b[^&|;]*\/s/i, reason: "Windows del/erase /s recursive delete" },
|
|
104
|
-
{ pattern: /\b(Format-Volume|Format-C|Clear-Disk|Remove-Partition)\b/i, reason: "Windows disk destructive command" },
|
|
105
|
-
{ pattern: /\b(iwr|Invoke-WebRequest|Invoke-RestMethod|curl|wget)\b[^\n|;]*\|\s*(iex|Invoke-Expression)\b/i, reason: "PowerShell piping remote script into Invoke-Expression (iwr | iex)" },
|
|
106
|
-
{ pattern: /\biex\b|\bInvoke-Expression\b/i, reason: "PowerShell Invoke-Expression" },
|
|
107
|
-
{ pattern: /\b(Start-Process|Invoke-Item)\b.*\b(powershell|cmd|wscript|cscript)\b/i, reason: "PowerShell spawning shell via Start-Process" },
|
|
108
|
-
{ pattern: /-EncodedCommand\b/i, reason: "PowerShell EncodedCommand (obfuscated script)" },
|
|
109
|
-
{ pattern: /\bFromBase64String\b/i, reason: "PowerShell FromBase64String (obfuscated)" }
|
|
110
|
-
];
|
|
111
|
-
function tokenizeSegments(cmd) {
|
|
112
|
-
const segments = [];
|
|
93
|
+
function limitBytes(s) {
|
|
94
|
+
const n = parseFloat(s);
|
|
95
|
+
return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;
|
|
96
|
+
}
|
|
97
|
+
var fmt = (b) => b >= 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1024))} KB`;
|
|
98
|
+
var WIN = process.platform === "win32";
|
|
99
|
+
function splitCommand(cmd) {
|
|
100
|
+
const segs = [];
|
|
113
101
|
let tokens = [];
|
|
102
|
+
let redirects = [];
|
|
114
103
|
let cur = "";
|
|
115
|
-
let
|
|
116
|
-
let
|
|
117
|
-
const
|
|
118
|
-
|
|
104
|
+
let has = false;
|
|
105
|
+
let heredocEnd = null;
|
|
106
|
+
const pendingHeredocs = [];
|
|
107
|
+
const endWord = () => {
|
|
108
|
+
if (has) tokens.push(cur);
|
|
119
109
|
cur = "";
|
|
120
|
-
|
|
110
|
+
has = false;
|
|
121
111
|
};
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
if (tokens.length)
|
|
112
|
+
const endSeg = () => {
|
|
113
|
+
endWord();
|
|
114
|
+
if (tokens.length || redirects.length) segs.push({ tokens, redirects });
|
|
125
115
|
tokens = [];
|
|
126
|
-
|
|
116
|
+
redirects = [];
|
|
117
|
+
};
|
|
118
|
+
const readWord = (i2) => {
|
|
119
|
+
while (i2 < cmd.length && (cmd[i2] === " " || cmd[i2] === " ")) i2++;
|
|
120
|
+
let w = "";
|
|
121
|
+
let q = null;
|
|
122
|
+
while (i2 < cmd.length) {
|
|
123
|
+
const c = cmd[i2];
|
|
124
|
+
if (q) {
|
|
125
|
+
if (c === q) q = null;
|
|
126
|
+
else w += c;
|
|
127
|
+
} else if (c === "'" || c === '"') q = c;
|
|
128
|
+
else if (/[\s;&|<>()]/.test(c)) break;
|
|
129
|
+
else w += c;
|
|
130
|
+
i2++;
|
|
131
|
+
}
|
|
132
|
+
return { word: w, next: i2 };
|
|
127
133
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
let i = 0;
|
|
135
|
+
let quote = null;
|
|
136
|
+
while (i < cmd.length) {
|
|
137
|
+
const c = cmd[i];
|
|
138
|
+
if (quote) {
|
|
139
|
+
if (c === quote) quote = null;
|
|
140
|
+
else if (c === "\\" && quote === '"' && !WIN && i + 1 < cmd.length) {
|
|
141
|
+
cur += cmd[++i];
|
|
142
|
+
} else cur += c;
|
|
133
143
|
i++;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (c === "'" || c === '"') {
|
|
147
|
+
quote = c;
|
|
148
|
+
has = true;
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (c === "\\" && !WIN && i + 1 < cmd.length) {
|
|
153
|
+
cur += cmd[i + 1];
|
|
154
|
+
has = true;
|
|
155
|
+
i += 2;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (c === "\n") {
|
|
159
|
+
endSeg();
|
|
160
|
+
i++;
|
|
161
|
+
if (pendingHeredocs.length) {
|
|
162
|
+
for (const h of pendingHeredocs) {
|
|
163
|
+
while (i < cmd.length) {
|
|
164
|
+
let e = cmd.indexOf("\n", i);
|
|
165
|
+
if (e < 0) e = cmd.length;
|
|
166
|
+
const line = cmd.slice(i, e);
|
|
167
|
+
i = Math.min(cmd.length, e + 1);
|
|
168
|
+
if ((h.strip ? line.trim() : line.replace(/\r$/, "")) === h.marker) break;
|
|
140
169
|
}
|
|
141
|
-
break;
|
|
142
170
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
171
|
+
pendingHeredocs.length = 0;
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (c === ";" || c === "|" || c === "&") {
|
|
176
|
+
if ((c === "&" || c === "|") && cmd[i + 1] === c) i++;
|
|
177
|
+
endSeg();
|
|
178
|
+
i++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (c === "(" || c === ")") {
|
|
182
|
+
endSeg();
|
|
183
|
+
i++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (c === "<") {
|
|
187
|
+
if (cmd[i + 1] === "<" && cmd[i + 2] !== "<") {
|
|
188
|
+
let j = i + 2;
|
|
189
|
+
let strip = false;
|
|
190
|
+
if (cmd[j] === "-") {
|
|
191
|
+
strip = true;
|
|
192
|
+
j++;
|
|
147
193
|
}
|
|
148
|
-
|
|
194
|
+
const { word, next } = readWord(j);
|
|
195
|
+
if (word) pendingHeredocs.push({ marker: word, strip });
|
|
196
|
+
i = next;
|
|
197
|
+
} else {
|
|
198
|
+
const { next } = readWord(i + (cmd[i + 1] === "<" ? 3 : 1));
|
|
199
|
+
i = next;
|
|
149
200
|
}
|
|
150
201
|
continue;
|
|
151
202
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
203
|
+
if (c === ">") {
|
|
204
|
+
let fd = "";
|
|
205
|
+
if (has && /^\d+$/.test(cur)) {
|
|
206
|
+
fd = cur;
|
|
207
|
+
cur = "";
|
|
208
|
+
has = false;
|
|
209
|
+
} else endWord();
|
|
210
|
+
let j = i + 1;
|
|
211
|
+
if (cmd[j] === ">") j++;
|
|
212
|
+
if (cmd[j] === "&") {
|
|
213
|
+
const r2 = readWord(j + 1);
|
|
214
|
+
i = r2.next;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const r = readWord(j);
|
|
218
|
+
i = r.next;
|
|
219
|
+
if (fd !== "2" && r.word) redirects.push(r.word);
|
|
154
220
|
continue;
|
|
155
221
|
}
|
|
156
|
-
if (
|
|
157
|
-
|
|
222
|
+
if (c === " " || c === " " || c === "\r") {
|
|
223
|
+
endWord();
|
|
224
|
+
i++;
|
|
158
225
|
continue;
|
|
159
226
|
}
|
|
160
|
-
|
|
161
|
-
|
|
227
|
+
cur += c;
|
|
228
|
+
has = true;
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
endSeg();
|
|
232
|
+
void heredocEnd;
|
|
233
|
+
return segs;
|
|
234
|
+
}
|
|
235
|
+
var base = (s) => import_path.default.basename(s.replace(/\\/g, "/")).toLowerCase().replace(/\.(exe|cmd|bat|ps1|com)$/, "");
|
|
236
|
+
var NPM_INST = {
|
|
237
|
+
npm: { inst: ["install", "i", "in", "ins", "inst", "add", "ci", "update", "up", "upgrade", "link", "ln", "rebuild", "dedupe"], un: ["uninstall", "remove", "rm", "r", "un", "unlink"] },
|
|
238
|
+
cnpm: { inst: ["install", "i", "add", "update"], un: ["uninstall", "remove", "rm"] },
|
|
239
|
+
pnpm: { inst: ["add", "install", "i", "update", "up", "upgrade", "link", "ln", "import", "dlx"], un: ["remove", "rm", "uninstall", "un", "unlink"] },
|
|
240
|
+
yarn: { inst: ["add", "install", "upgrade", "up", "global", "dlx", "link", ""], un: ["remove"] },
|
|
241
|
+
bun: { inst: ["add", "install", "i", "update", "link", "x"], un: ["remove", "rm", "unlink"] }
|
|
242
|
+
};
|
|
243
|
+
var SYS_PM = /* @__PURE__ */ new Set(["apt", "apt-get", "aptitude", "dnf", "yum", "zypper", "pacman", "apk", "brew", "choco", "winget", "scoop", "snap", "flatpak", "nix-env", "port", "pkg", "emerge", "xbps-install"]);
|
|
244
|
+
var SYS_INST = /* @__PURE__ */ new Set(["install", "add", "upgrade", "dist-upgrade", "full-upgrade", "reinstall", "update-all"]);
|
|
245
|
+
var SYS_UN = /* @__PURE__ */ new Set(["remove", "uninstall", "purge", "autoremove", "del", "erase"]);
|
|
246
|
+
var PIP_VALUE_FLAGS = /* @__PURE__ */ new Set(["-r", "--requirement", "-e", "--editable", "-c", "--constraint", "-i", "--index-url", "--extra-index-url", "-t", "--target", "--prefix", "-f", "--find-links", "--python", "--root", "--src", "--cache-dir", "--proxy"]);
|
|
247
|
+
function pkgArgs(args, valueFlags) {
|
|
248
|
+
const pkgs = [];
|
|
249
|
+
let explicit = true;
|
|
250
|
+
for (let i = 0; i < args.length; i++) {
|
|
251
|
+
const a = args[i];
|
|
252
|
+
if (a.startsWith("-")) {
|
|
253
|
+
if (a === "-r" || a === "--requirement" || a === "-e" || a === "--editable" || a === "-c" || a === "--constraint") explicit = false;
|
|
254
|
+
if (valueFlags.has(a)) i++;
|
|
162
255
|
continue;
|
|
163
256
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
257
|
+
pkgs.push(a);
|
|
258
|
+
}
|
|
259
|
+
return { pkgs, explicit };
|
|
260
|
+
}
|
|
261
|
+
function installInfo(name, args) {
|
|
262
|
+
const global = args.some((a) => a === "-g" || a === "--global" || a === "global");
|
|
263
|
+
const subIdx = args.findIndex((a) => !a.startsWith("-"));
|
|
264
|
+
const sub = subIdx >= 0 ? args[subIdx].toLowerCase() : "";
|
|
265
|
+
const after = subIdx >= 0 ? args.slice(subIdx + 1) : [];
|
|
266
|
+
if (NPM_INST[name]) {
|
|
267
|
+
const t = NPM_INST[name];
|
|
268
|
+
const un = t.un.includes(sub);
|
|
269
|
+
if (!un && !t.inst.includes(sub)) return null;
|
|
270
|
+
if (name === "yarn" && sub === "" && args.length > 0 && !args.every((a) => a.startsWith("-"))) return null;
|
|
271
|
+
const p = pkgArgs(after.filter((a) => a !== "global"), /* @__PURE__ */ new Set(["--prefix", "--registry", "-w", "--workspace", "--cwd"]));
|
|
272
|
+
const noPkgsMeansAll = !un && p.pkgs.length === 0;
|
|
273
|
+
return { mgr: name, uninstall: un, pkgs: p.pkgs, explicit: !noPkgsMeansAll && p.explicit, global };
|
|
274
|
+
}
|
|
275
|
+
if (name === "npx" || name === "pnpx" || name === "bunx") {
|
|
276
|
+
const p = args.filter((a) => !a.startsWith("-"))[0];
|
|
277
|
+
return { mgr: "npm", uninstall: false, pkgs: p ? [p] : [], explicit: !!p, global: false };
|
|
278
|
+
}
|
|
279
|
+
if (name === "pip" || name === "pip3" || name === "pipx" || name === "pip3.11" || name === "pip3.12") {
|
|
280
|
+
const un = sub === "uninstall";
|
|
281
|
+
const inst = sub === "install" || sub === "download" || name === "pipx" && (sub === "run" || sub === "inject" || sub === "upgrade");
|
|
282
|
+
if (!un && !inst) return null;
|
|
283
|
+
const p = pkgArgs(after, PIP_VALUE_FLAGS);
|
|
284
|
+
return { mgr: "pip", uninstall: un, pkgs: p.pkgs, explicit: p.explicit && p.pkgs.length > 0, global: false };
|
|
285
|
+
}
|
|
286
|
+
if (/^python(\d(\.\d+)?)?$|^py$/.test(name)) {
|
|
287
|
+
const m = args.indexOf("-m");
|
|
288
|
+
if (m >= 0 && args[m + 1] === "pip") return installInfo("pip", args.slice(m + 2));
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
if (name === "uv") {
|
|
292
|
+
if (sub === "pip") return installInfo("pip", after);
|
|
293
|
+
if (sub === "add") return { mgr: "pip", uninstall: false, ...(() => {
|
|
294
|
+
const p = pkgArgs(after, /* @__PURE__ */ new Set());
|
|
295
|
+
return { pkgs: p.pkgs, explicit: p.explicit && p.pkgs.length > 0 };
|
|
296
|
+
})(), global: false };
|
|
297
|
+
if (sub === "remove") return { mgr: "pip", uninstall: true, pkgs: after.filter((a) => !a.startsWith("-")), explicit: false, global: false };
|
|
298
|
+
if (sub === "sync" || sub === "lock") return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: false };
|
|
299
|
+
if (sub === "tool") {
|
|
300
|
+
const s2 = after.find((a) => !a.startsWith("-"));
|
|
301
|
+
if (s2 === "install" || s2 === "upgrade") return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: true };
|
|
302
|
+
if (s2 === "uninstall") return { mgr: "pip", uninstall: true, pkgs: [], explicit: false, global: true };
|
|
303
|
+
}
|
|
203
304
|
return null;
|
|
204
305
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return
|
|
306
|
+
if (name === "uvx") return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: false };
|
|
307
|
+
if (name === "poetry" || name === "pdm" || name === "pipenv") {
|
|
308
|
+
if (["add", "install", "update", "sync"].includes(sub)) return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: false };
|
|
309
|
+
if (sub === "remove") return { mgr: "pip", uninstall: true, pkgs: [], explicit: false, global: false };
|
|
310
|
+
return null;
|
|
210
311
|
}
|
|
211
|
-
if (
|
|
212
|
-
|
|
312
|
+
if (name === "conda" || name === "mamba" || name === "micromamba") {
|
|
313
|
+
if (["install", "update", "upgrade"].includes(sub)) return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: false };
|
|
314
|
+
if (["remove", "uninstall"].includes(sub)) return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: false };
|
|
315
|
+
return null;
|
|
213
316
|
}
|
|
317
|
+
if (SYS_PM.has(name)) {
|
|
318
|
+
const first = args[0] || "";
|
|
319
|
+
if (name === "pacman") {
|
|
320
|
+
if (/^-S/.test(first) && !/^-Ss|^-Si|^-Sq/.test(first)) return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: true };
|
|
321
|
+
if (/^-R/.test(first)) return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: true };
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
if (SYS_INST.has(sub)) return { mgr: name, uninstall: false, pkgs: after.filter((a) => !a.startsWith("-")), explicit: false, global: true };
|
|
325
|
+
if (SYS_UN.has(sub)) return { mgr: name, uninstall: true, pkgs: after.filter((a) => !a.startsWith("-")), explicit: false, global: true };
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
if (name === "cargo") {
|
|
329
|
+
if (sub === "install" || sub === "add") return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: sub === "install" };
|
|
330
|
+
if (sub === "uninstall" || sub === "remove") return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: sub === "uninstall" };
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (name === "go" && (sub === "install" || sub === "get")) return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: false };
|
|
334
|
+
if (name === "gem" || name === "bundle" || name === "composer" || name === "dotnet" || name === "pecl") {
|
|
335
|
+
if (["install", "add", "require", "update"].includes(sub) || name === "dotnet" && sub === "tool") return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: false };
|
|
336
|
+
if (["uninstall", "remove"].includes(sub)) return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: false };
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
if (/^install-(module|package|script|packageprovider)$/.test(name)) return { mgr: "ps", uninstall: false, pkgs: [], explicit: false, global: true };
|
|
340
|
+
if (/^uninstall-(module|package|script)$/.test(name)) return { mgr: "ps", uninstall: true, pkgs: [], explicit: false, global: true };
|
|
214
341
|
return null;
|
|
215
342
|
}
|
|
216
|
-
var
|
|
217
|
-
var
|
|
218
|
-
var
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const targets = [];
|
|
237
|
-
for (let i = 1; i < seg.tokens.length; i++) {
|
|
238
|
-
const tok = seg.tokens[i];
|
|
239
|
-
if (tok.startsWith("-") && tok.length > 1 && !/^-\d/.test(tok)) {
|
|
240
|
-
const body = tok.slice(1);
|
|
241
|
-
const colon = body.indexOf(":");
|
|
242
|
-
const name = colon >= 0 ? body.slice(0, colon) : body;
|
|
243
|
-
const inlineVal = colon >= 0 ? body.slice(colon + 1) : null;
|
|
244
|
-
if (flagMatches(name, "recurse", 1)) {
|
|
245
|
-
recurse = !(inlineVal && /^\$?false$/i.test(inlineVal));
|
|
246
|
-
} else if (flagMatches(name, "force", 2)) {
|
|
247
|
-
force = !(inlineVal && /^\$?false$/i.test(inlineVal));
|
|
248
|
-
} else if (VALUE_FLAGS_TARGET.some((f) => flagMatches(name, f, 1))) {
|
|
249
|
-
const v = inlineVal ?? seg.tokens[++i];
|
|
250
|
-
if (v) targets.push(v);
|
|
251
|
-
} else if (VALUE_FLAGS_SKIP.some((f) => flagMatches(name, f, 1))) {
|
|
252
|
-
if (inlineVal === null) i++;
|
|
253
|
-
}
|
|
343
|
+
var NULL_TARGETS = /^(\/dev\/(null|stdout|stderr|tty|fd\/\d+)|nul|\$null|&\d+)$/i;
|
|
344
|
+
var WRAPPERS = /* @__PURE__ */ new Set(["sudo", "doas", "time", "nohup", "command", "exec", "nice", "ionice", "stdbuf", "env", "builtin"]);
|
|
345
|
+
var SHELLS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "dash", "ksh", "fish"]);
|
|
346
|
+
function classify(cmd, cwd, depth = 0) {
|
|
347
|
+
const out = [];
|
|
348
|
+
if (depth > 3 || !cmd.trim()) return out;
|
|
349
|
+
for (const m of cmd.matchAll(/\$\(([^()]*)\)/g)) out.push(...classify(m[1], cwd, depth + 1));
|
|
350
|
+
for (const m of cmd.matchAll(/`([^`]*)`/g)) out.push(...classify(m[1], cwd, depth + 1));
|
|
351
|
+
for (const seg of splitCommand(cmd)) {
|
|
352
|
+
let t = seg.tokens.slice();
|
|
353
|
+
let viaXargs = false;
|
|
354
|
+
while (t.length) {
|
|
355
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0])) {
|
|
356
|
+
t.shift();
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const n = base(t[0]);
|
|
360
|
+
if (WRAPPERS.has(n)) {
|
|
361
|
+
t.shift();
|
|
362
|
+
while (t.length && (t[0].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]))) t.shift();
|
|
254
363
|
continue;
|
|
255
364
|
}
|
|
256
|
-
|
|
365
|
+
if (n === "xargs") {
|
|
366
|
+
viaXargs = true;
|
|
367
|
+
t.shift();
|
|
368
|
+
while (t.length && t[0].startsWith("-")) t.shift();
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
if (t.length) {
|
|
374
|
+
const name = base(t[0]);
|
|
375
|
+
const args = t.slice(1);
|
|
376
|
+
const nonFlag = args.filter((a) => !a.startsWith("-"));
|
|
377
|
+
const sub = (nonFlag[0] || "").toLowerCase();
|
|
378
|
+
const has = (re) => args.some((a) => re.test(a));
|
|
379
|
+
if (SHELLS.has(name)) {
|
|
380
|
+
const i = args.findIndex((a) => /^-[a-z]*c$/.test(a));
|
|
381
|
+
if (i >= 0 && args[i + 1]) out.push(...classify(args[i + 1], cwd, depth + 1));
|
|
382
|
+
} else if (name === "powershell" || name === "pwsh") {
|
|
383
|
+
const i = args.findIndex((a) => /^-(c|command)$/i.test(a));
|
|
384
|
+
if (i >= 0) out.push(...classify(args.slice(i + 1).join(" "), cwd, depth + 1));
|
|
385
|
+
} else if (name === "cmd") {
|
|
386
|
+
const i = args.findIndex((a) => /^\/(c|k)$/i.test(a));
|
|
387
|
+
if (i >= 0) out.push(...classify(args.slice(i + 1).join(" "), cwd, depth + 1));
|
|
388
|
+
}
|
|
389
|
+
const flagLikeWin = (a) => WIN && /^\/[a-z?]$/i.test(a);
|
|
390
|
+
if (["rm", "rmdir", "del", "erase", "rd", "unlink", "shred", "trash", "trash-put", "remove-item", "ri"].includes(name)) {
|
|
391
|
+
const targets = [];
|
|
392
|
+
for (let i = 0; i < args.length; i++) {
|
|
393
|
+
const a = args[i];
|
|
394
|
+
if (/^-(path|literalpath)$/i.test(a)) {
|
|
395
|
+
if (args[i + 1]) targets.push(args[++i]);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (a.startsWith("-") || flagLikeWin(a)) continue;
|
|
399
|
+
targets.push(a);
|
|
400
|
+
}
|
|
401
|
+
out.push({ cat: "delete", targets, dynamic: viaXargs || targets.length === 0 });
|
|
402
|
+
} else if (name === "git" && sub === "clean") {
|
|
403
|
+
out.push({ cat: "delete", dynamic: true });
|
|
404
|
+
} else if (name === "git" && sub === "rm") {
|
|
405
|
+
out.push({ cat: "delete", targets: nonFlag.slice(1), dynamic: nonFlag.length < 2 });
|
|
406
|
+
} else if (name === "find" && (args.includes("-delete") || args.includes("-exec") && /(^|\/)(rm|rmdir)$/.test(args[args.indexOf("-exec") + 1] || ""))) {
|
|
407
|
+
out.push({ cat: "delete", dynamic: true });
|
|
408
|
+
}
|
|
409
|
+
if (["mv", "move", "ren", "rename", "move-item", "mi", "rename-item", "rni"].includes(name) || name === "git" && sub === "mv") {
|
|
410
|
+
out.push({ cat: "move" });
|
|
411
|
+
}
|
|
412
|
+
if (["zip", "unzip", "tar", "7z", "7za", "7zr", "7zz", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd", "unzstd", "rar", "unrar", "compress-archive", "expand-archive"].includes(name)) {
|
|
413
|
+
out.push({ cat: "archive" });
|
|
414
|
+
} else if (/^python(\d(\.\d+)?)?$|^py$/.test(name) && args[0] === "-m" && (args[1] === "zipfile" || args[1] === "tarfile")) {
|
|
415
|
+
out.push({ cat: "archive" });
|
|
416
|
+
}
|
|
417
|
+
const inst = installInfo(name, args);
|
|
418
|
+
if (inst) out.push({ cat: "install", install: inst });
|
|
419
|
+
if (["mkdir", "md", "touch", "new-item", "ni", "mkfifo", "cp", "copy", "copy-item", "cpi", "xcopy", "robocopy", "ln", "fallocate", "virtualenv"].includes(name)) {
|
|
420
|
+
out.push({ cat: "create" });
|
|
421
|
+
} else if (name === "git" && (sub === "clone" || sub === "init" || sub === "worktree")) {
|
|
422
|
+
out.push({ cat: "create" });
|
|
423
|
+
} else if (name === "curl" && has(/^(-[a-zA-Z]*[oOJ][a-zA-Z]*|--output|--remote-name|--create-dirs)$/)) {
|
|
424
|
+
out.push({ cat: "create" });
|
|
425
|
+
} else if (name === "wget" && !(args.includes("-O-") || args.some((a, i) => a === "-O" && args[i + 1] === "-") || has(/^-\w*O-$/))) {
|
|
426
|
+
out.push({ cat: "create" });
|
|
427
|
+
} else if (name === "dd" && has(/^of=/)) {
|
|
428
|
+
out.push({ cat: "create" });
|
|
429
|
+
} else if (name === "cargo" && (sub === "new" || sub === "init")) {
|
|
430
|
+
out.push({ cat: "create" });
|
|
431
|
+
} else if (name === "dotnet" && sub === "new") {
|
|
432
|
+
out.push({ cat: "create" });
|
|
433
|
+
} else if ((name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") && (sub === "init" || sub === "create")) {
|
|
434
|
+
out.push({ cat: "create" });
|
|
435
|
+
} else if (name === "uv" && (sub === "venv" || sub === "init")) {
|
|
436
|
+
out.push({ cat: "create" });
|
|
437
|
+
} else if (/^python(\d(\.\d+)?)?$|^py$/.test(name) && args[0] === "-m" && args[1] === "venv") {
|
|
438
|
+
out.push({ cat: "create" });
|
|
439
|
+
} else if (name === "tee") {
|
|
440
|
+
for (const f of nonFlag) pushWriteTarget(out, f, cwd);
|
|
441
|
+
}
|
|
442
|
+
if (name === "sed" && has(/^(-[a-zA-Z]*i[a-zA-Z.]*|--in-place(=.*)?)$/)) out.push({ cat: "edit" });
|
|
443
|
+
else if (name === "perl" && has(/^-[a-zA-Z]*i[a-zA-Z.]*$/)) out.push({ cat: "edit" });
|
|
444
|
+
else if (name === "awk" && args.some((a, i) => a === "-i" && /inplace/.test(args[i + 1] || ""))) out.push({ cat: "edit" });
|
|
445
|
+
else if (["set-content", "sc", "add-content", "ac", "out-file", "clear-content", "patch", "sponge", "dos2unix", "unix2dos", "truncate"].includes(name)) out.push({ cat: "edit" });
|
|
446
|
+
else if (name === "git" && ["apply", "am", "restore"].includes(sub)) out.push({ cat: "edit" });
|
|
447
|
+
else if (name === "git" && sub === "reset" && args.includes("--hard")) out.push({ cat: "edit" });
|
|
448
|
+
}
|
|
449
|
+
for (const r of seg.redirects) pushWriteTarget(out, r, cwd);
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
function pushWriteTarget(out, target, cwd) {
|
|
454
|
+
if (!target || NULL_TARGETS.test(target)) return;
|
|
455
|
+
if (/[$`]/.test(target)) {
|
|
456
|
+
out.push({ cat: "edit" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const abs = resolveToken(target, cwd);
|
|
460
|
+
out.push({ cat: import_fs.default.existsSync(abs) ? "edit" : "create" });
|
|
461
|
+
}
|
|
462
|
+
function resolveToken(tok, cwd) {
|
|
463
|
+
let t = tok;
|
|
464
|
+
if (t === "~" || t.startsWith("~/") || t.startsWith("~\\")) t = import_path.default.join(import_os.default.homedir(), t.slice(1));
|
|
465
|
+
return import_path.default.resolve(cwd, t);
|
|
466
|
+
}
|
|
467
|
+
async function sizeOf(p, budget = { n: 3e5 }) {
|
|
468
|
+
let st;
|
|
469
|
+
try {
|
|
470
|
+
st = await import_fs.default.promises.lstat(p);
|
|
471
|
+
} catch {
|
|
472
|
+
return { bytes: 0, exists: false, complete: true };
|
|
473
|
+
}
|
|
474
|
+
if (!st.isDirectory()) return { bytes: st.size, exists: true, complete: true };
|
|
475
|
+
let bytes = 0;
|
|
476
|
+
let complete = true;
|
|
477
|
+
let entries = [];
|
|
478
|
+
try {
|
|
479
|
+
entries = await import_fs.default.promises.readdir(p);
|
|
480
|
+
} catch {
|
|
481
|
+
return { bytes: 0, exists: true, complete: false };
|
|
482
|
+
}
|
|
483
|
+
for (const e of entries) {
|
|
484
|
+
if (--budget.n < 0) {
|
|
485
|
+
complete = false;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
const r = await sizeOf(import_path.default.join(p, e), budget);
|
|
489
|
+
bytes += r.bytes;
|
|
490
|
+
if (!r.complete) complete = false;
|
|
491
|
+
}
|
|
492
|
+
return { bytes, exists: true, complete };
|
|
493
|
+
}
|
|
494
|
+
async function expandTargets(tokens, cwd) {
|
|
495
|
+
const paths = [];
|
|
496
|
+
let unknown = false;
|
|
497
|
+
for (const tok of tokens) {
|
|
498
|
+
if (/[$`]/.test(tok)) {
|
|
499
|
+
unknown = true;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const abs = resolveToken(tok, cwd);
|
|
503
|
+
const dir = import_path.default.dirname(abs);
|
|
504
|
+
const leaf = import_path.default.basename(abs);
|
|
505
|
+
if (/[*?[]/.test(dir)) {
|
|
506
|
+
unknown = true;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (/[*?[]/.test(leaf)) {
|
|
510
|
+
try {
|
|
511
|
+
const re = new RegExp(
|
|
512
|
+
"^" + leaf.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
|
|
513
|
+
);
|
|
514
|
+
for (const f of await import_fs.default.promises.readdir(dir)) if (re.test(f) && !(f.startsWith(".") && !leaf.startsWith("."))) paths.push(import_path.default.join(dir, f));
|
|
515
|
+
} catch {
|
|
516
|
+
unknown = true;
|
|
517
|
+
}
|
|
518
|
+
} else paths.push(abs);
|
|
519
|
+
}
|
|
520
|
+
return { paths, unknown };
|
|
521
|
+
}
|
|
522
|
+
async function fetchJson(url, ms = 6e3) {
|
|
523
|
+
const ac = new AbortController();
|
|
524
|
+
const t = setTimeout(() => ac.abort(), ms);
|
|
525
|
+
try {
|
|
526
|
+
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json" } });
|
|
527
|
+
if (!r.ok) return null;
|
|
528
|
+
return await r.json();
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
} finally {
|
|
532
|
+
clearTimeout(t);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function estimateInstallBytes(info) {
|
|
536
|
+
if (!info.explicit || info.pkgs.length === 0) return null;
|
|
537
|
+
let total = 0;
|
|
538
|
+
for (const spec of info.pkgs) {
|
|
539
|
+
if (/^(\.|\/|file:|git|http|github:|[a-z]:\\)/i.test(spec) || spec.includes("/") && !spec.startsWith("@")) return null;
|
|
540
|
+
if (info.mgr === "npm" || NPM_INST[info.mgr]) {
|
|
541
|
+
const m = spec.match(/^(@?[^@]+)(?:@(.+))?$/);
|
|
542
|
+
if (!m) return null;
|
|
543
|
+
const name = m[1];
|
|
544
|
+
const ver = m[2] && /^\d[\w.\-+]*$/.test(m[2]) ? m[2] : "latest";
|
|
545
|
+
const j = await fetchJson(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${ver}`);
|
|
546
|
+
const sz = j?.dist?.unpackedSize;
|
|
547
|
+
if (typeof sz !== "number") return null;
|
|
548
|
+
total += sz;
|
|
549
|
+
} else if (info.mgr === "pip") {
|
|
550
|
+
const m = spec.match(/^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?(?:==([\w.\-+]+))?/);
|
|
551
|
+
if (!m) return null;
|
|
552
|
+
const j = await fetchJson(m[2] ? `https://pypi.org/pypi/${m[1]}/${m[2]}/json` : `https://pypi.org/pypi/${m[1]}/json`);
|
|
553
|
+
const files = j?.urls || [];
|
|
554
|
+
if (!files.length) return null;
|
|
555
|
+
const wheels = files.filter((f) => f.packagetype === "bdist_wheel");
|
|
556
|
+
const pool = wheels.length ? wheels : files;
|
|
557
|
+
total += Math.min(...pool.map((f) => Number(f.size) || Infinity));
|
|
558
|
+
} else return null;
|
|
559
|
+
}
|
|
560
|
+
return Number.isFinite(total) ? total : null;
|
|
561
|
+
}
|
|
562
|
+
var pending = /* @__PURE__ */ new Map();
|
|
563
|
+
var seq = 0;
|
|
564
|
+
var APPROVAL_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
565
|
+
function requestApproval(info) {
|
|
566
|
+
return new Promise((resolve) => {
|
|
567
|
+
const id = `ap${(++seq).toString(36)}${Date.now().toString(36).slice(-4)}`;
|
|
568
|
+
const timer = setTimeout(() => {
|
|
569
|
+
pending.delete(id);
|
|
570
|
+
resolve({ allow: false, remember: false, timedOut: true });
|
|
571
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
572
|
+
pending.set(id, {
|
|
573
|
+
info: { ...info, id, createdAt: Date.now() },
|
|
574
|
+
resolve: (d) => {
|
|
575
|
+
clearTimeout(timer);
|
|
576
|
+
pending.delete(id);
|
|
577
|
+
resolve(d);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
async function evaluatePermission(command, cwd, opts) {
|
|
583
|
+
const cfg = getPerms();
|
|
584
|
+
const findings = classify(command, cwd);
|
|
585
|
+
const cats = new Set(findings.map((f) => f.cat));
|
|
586
|
+
for (const c of cats) {
|
|
587
|
+
if (cfg.modes[c] === "deny") {
|
|
588
|
+
return { ok: false, message: `Blocked by the user's permission settings: "${LABEL[c]}" is set to DENY. Don't retry this; use a different approach or tell the user.` };
|
|
257
589
|
}
|
|
258
|
-
|
|
259
|
-
|
|
590
|
+
}
|
|
591
|
+
const askCats = new Set([...cats].filter((c) => cfg.modes[c] === "ask"));
|
|
592
|
+
const modeAsk = new Set(askCats);
|
|
593
|
+
const reasons = [];
|
|
594
|
+
const delLimit = limitBytes(cfg.limits.maxDeleteMB);
|
|
595
|
+
if (delLimit) {
|
|
596
|
+
let total = 0;
|
|
597
|
+
for (const f of findings) {
|
|
598
|
+
if (f.cat === "delete") {
|
|
599
|
+
if (f.dynamic) {
|
|
600
|
+
askCats.add("delete");
|
|
601
|
+
reasons.push("can't tell from the command how much will be deleted");
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const { paths, unknown } = await expandTargets(f.targets || [], cwd);
|
|
605
|
+
if (unknown) {
|
|
606
|
+
askCats.add("delete");
|
|
607
|
+
reasons.push("some delete targets can't be resolved (variables / complex globs)");
|
|
608
|
+
}
|
|
609
|
+
for (const p of paths) {
|
|
610
|
+
const s = await sizeOf(p);
|
|
611
|
+
total += s.bytes;
|
|
612
|
+
if (!s.complete) {
|
|
613
|
+
askCats.add("delete");
|
|
614
|
+
reasons.push(`${import_path.default.basename(p)} is too large to measure fully`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} else if (f.cat === "install" && f.install?.uninstall) {
|
|
618
|
+
const i = f.install;
|
|
619
|
+
if (i.global || !(NPM_INST[i.mgr] || i.mgr === "npm") || i.pkgs.length === 0) {
|
|
620
|
+
askCats.add("install");
|
|
621
|
+
reasons.push("can't measure how much the uninstall removes");
|
|
622
|
+
} else {
|
|
623
|
+
for (const p of i.pkgs) {
|
|
624
|
+
const s = await sizeOf(import_path.default.join(cwd, "node_modules", p.replace(/@[^/]+$/, "")));
|
|
625
|
+
total += s.bytes;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (total > delLimit) {
|
|
631
|
+
return { ok: false, message: `Blocked: this would delete about ${fmt(total)}, over the user's limit of ${cfg.limits.maxDeleteMB} MB. Don't retry; tell the user.` };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const insLimit = limitBytes(cfg.limits.maxInstallMB);
|
|
635
|
+
if (insLimit) {
|
|
636
|
+
let total = 0;
|
|
637
|
+
let unknown = false;
|
|
638
|
+
let any = false;
|
|
639
|
+
for (const f of findings) {
|
|
640
|
+
if (f.cat === "install" && f.install && !f.install.uninstall) {
|
|
641
|
+
any = true;
|
|
642
|
+
const b = await estimateInstallBytes(f.install);
|
|
643
|
+
if (b == null) unknown = true;
|
|
644
|
+
else total += b;
|
|
645
|
+
}
|
|
260
646
|
}
|
|
261
|
-
if (
|
|
262
|
-
return
|
|
647
|
+
if (any && total > insLimit) {
|
|
648
|
+
return { ok: false, message: `Blocked: the named packages alone are about ${fmt(total)}, over the user's install limit of ${cfg.limits.maxInstallMB} MB. Don't retry; tell the user.` };
|
|
263
649
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
650
|
+
if (any && unknown) {
|
|
651
|
+
askCats.add("install");
|
|
652
|
+
reasons.push("install size can't be verified beforehand");
|
|
267
653
|
}
|
|
268
654
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
655
|
+
if (askCats.size > 0) {
|
|
656
|
+
const d = await requestApproval({ command, cwd, categories: [...askCats].map((c) => LABEL[c]), reasons: [...new Set(reasons)] });
|
|
657
|
+
if (!d.allow) {
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
message: d.timedOut ? "The user did not answer the permission prompt in time, so the command was NOT run. Ask them first, then retry." : "The user denied this command. Don't retry it; tell the user what you wanted to do or choose another approach."
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (d.remember) {
|
|
664
|
+
const next = getPerms();
|
|
665
|
+
for (const c of modeAsk) next.modes[c] = "allow";
|
|
666
|
+
setPerms(next);
|
|
667
|
+
}
|
|
272
668
|
}
|
|
273
|
-
|
|
669
|
+
const crLimit = limitBytes(cfg.limits.maxCreateMB);
|
|
670
|
+
if (crLimit && !cats.has("install")) {
|
|
671
|
+
return opts.canUlimit ? { ok: true, ulimitMb: crLimit / 1024 / 1024 } : { ok: true, scanBytes: crLimit };
|
|
672
|
+
}
|
|
673
|
+
return { ok: true };
|
|
274
674
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
675
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build", ".next", "target"]);
|
|
676
|
+
async function removeOversizeNewFiles(root, sinceMs, limit) {
|
|
677
|
+
const removed = [];
|
|
678
|
+
let visits = 0;
|
|
679
|
+
async function walk(dir) {
|
|
680
|
+
let ents = [];
|
|
681
|
+
try {
|
|
682
|
+
ents = await import_fs.default.promises.readdir(dir, { withFileTypes: true });
|
|
683
|
+
} catch {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
for (const e of ents) {
|
|
687
|
+
if (++visits > 6e4) return;
|
|
688
|
+
const full = import_path.default.join(dir, e.name);
|
|
689
|
+
if (e.isDirectory()) {
|
|
690
|
+
if (!SKIP_DIRS.has(e.name)) await walk(full);
|
|
691
|
+
} else if (e.isFile()) {
|
|
692
|
+
try {
|
|
693
|
+
const st = await import_fs.default.promises.stat(full);
|
|
694
|
+
if (st.size > limit && st.birthtimeMs >= sinceMs - 1e3) {
|
|
695
|
+
await import_fs.default.promises.rm(full, { force: true });
|
|
696
|
+
removed.push(`${import_path.default.relative(root, full)} (${fmt(st.size)})`);
|
|
697
|
+
}
|
|
698
|
+
} catch {
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
278
702
|
}
|
|
279
|
-
|
|
703
|
+
await walk(root);
|
|
704
|
+
return removed;
|
|
705
|
+
}
|
|
706
|
+
function registerPermissionRoutes(app, requireLocal2) {
|
|
707
|
+
app.get("/api/permissions", requireLocal2, (_req, res) => res.json(getPerms()));
|
|
708
|
+
app.post("/api/permissions", requireLocal2, (req, res) => res.json(setPerms(req.body)));
|
|
709
|
+
app.get("/api/permissions/pending", requireLocal2, (_req, res) => res.json({ pending: [...pending.values()].map((p) => p.info) }));
|
|
710
|
+
app.post("/api/permissions/decide", requireLocal2, (req, res) => {
|
|
711
|
+
const { id, allow, remember } = req.body || {};
|
|
712
|
+
const p = pending.get(String(id));
|
|
713
|
+
if (!p) return res.status(404).json({ error: "No such pending request (maybe it already timed out)" });
|
|
714
|
+
p.resolve({ allow: !!allow, remember: !!remember && !!allow });
|
|
715
|
+
return res.json({ ok: true });
|
|
716
|
+
});
|
|
280
717
|
}
|
|
281
718
|
|
|
282
|
-
//
|
|
283
|
-
var
|
|
284
|
-
var
|
|
285
|
-
var
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return import_path.default.join(process.cwd(), "workspace");
|
|
293
|
-
}
|
|
294
|
-
var WORKSPACE_DIR = resolveWorkspaceDir();
|
|
295
|
-
function isInsideWorkspace(p) {
|
|
296
|
-
const rel = import_path.default.relative(WORKSPACE_DIR, import_path.default.resolve(p));
|
|
297
|
-
return rel === "" || !rel.startsWith("..") && !import_path.default.isAbsolute(rel);
|
|
298
|
-
}
|
|
299
|
-
try {
|
|
300
|
-
import_fs.default.mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
301
|
-
} catch (e) {
|
|
302
|
-
console.error("Failed to create workspace dir:", e);
|
|
303
|
-
}
|
|
304
|
-
var SAFE_ENV = IS_WINDOWS ? {
|
|
305
|
-
PATH: process.env.PATH,
|
|
306
|
-
SystemRoot: process.env.SystemRoot,
|
|
307
|
-
windir: process.env.windir,
|
|
308
|
-
COMSPEC: process.env.COMSPEC,
|
|
309
|
-
OS: process.env.OS,
|
|
310
|
-
SystemDrive: process.env.SystemDrive,
|
|
311
|
-
TEMP: process.env.TEMP,
|
|
312
|
-
TMP: process.env.TMP,
|
|
313
|
-
// On Windows, don't override HOME/USERPROFILE to WORKSPACE_DIR — that causes
|
|
314
|
-
// PowerShell to create AppData\Local inside the workspace (polluting project).
|
|
315
|
-
// Keep original USERPROFILE/HOME so profile data stays in real home.
|
|
316
|
-
HOME: process.env.HOME || process.env.USERPROFILE || WORKSPACE_DIR,
|
|
317
|
-
USERPROFILE: process.env.USERPROFILE || process.env.HOME || WORKSPACE_DIR,
|
|
318
|
-
APPDATA: process.env.APPDATA,
|
|
319
|
-
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
320
|
-
PYTHONDONTWRITEBYTECODE: "1",
|
|
321
|
-
PATHEXT: process.env.PATHEXT,
|
|
322
|
-
LANG: process.env.LANG || "en_US.UTF-8"
|
|
323
|
-
} : {
|
|
324
|
-
PATH: process.env.PATH,
|
|
325
|
-
HOME: WORKSPACE_DIR,
|
|
326
|
-
LANG: process.env.LANG || "en_US.UTF-8",
|
|
327
|
-
PYTHONDONTWRITEBYTECODE: "1"
|
|
328
|
-
};
|
|
329
|
-
var permissionGrants = { file: 0, shell: 0 };
|
|
330
|
-
var GRANT_TTL_MS = Number(process.env.FANLUC_GRANT_TTL_MS) > 0 ? Number(process.env.FANLUC_GRANT_TTL_MS) : 5 * 60 * 1e3;
|
|
331
|
-
function isGranted(type) {
|
|
332
|
-
return permissionGrants[type] > Date.now();
|
|
719
|
+
// localExec.ts
|
|
720
|
+
var import_child_process = require("child_process");
|
|
721
|
+
var HOME_DIR = process.env.FANLUC_HOME || import_path2.default.join(import_os2.default.homedir(), ".fanluc");
|
|
722
|
+
var CONFIG_FILE = import_path2.default.join(HOME_DIR, "config.json");
|
|
723
|
+
function readConfig() {
|
|
724
|
+
try {
|
|
725
|
+
return JSON.parse(import_fs2.default.readFileSync(CONFIG_FILE, "utf-8")) || {};
|
|
726
|
+
} catch {
|
|
727
|
+
return {};
|
|
728
|
+
}
|
|
333
729
|
}
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
730
|
+
function writeConfig(cfg) {
|
|
731
|
+
try {
|
|
732
|
+
import_fs2.default.mkdirSync(HOME_DIR, { recursive: true });
|
|
733
|
+
import_fs2.default.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
734
|
+
} catch (e) {
|
|
735
|
+
console.warn("[workspace] could not save config:", e?.message || e);
|
|
736
|
+
}
|
|
338
737
|
}
|
|
339
|
-
function
|
|
340
|
-
|
|
738
|
+
function isDir(p) {
|
|
739
|
+
try {
|
|
740
|
+
return import_fs2.default.statSync(p).isDirectory();
|
|
741
|
+
} catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
341
744
|
}
|
|
342
|
-
|
|
343
|
-
|
|
745
|
+
function pickInitialWorkspace() {
|
|
746
|
+
const forced = process.env.WORKSPACE_DIR || process.env.WORKSPACE;
|
|
747
|
+
if (forced && isDir(forced)) return import_path2.default.resolve(forced);
|
|
748
|
+
const saved = readConfig().workspace;
|
|
749
|
+
if (saved && isDir(saved)) return import_path2.default.resolve(saved);
|
|
750
|
+
const fallback = import_path2.default.join(process.cwd(), "workspace");
|
|
344
751
|
try {
|
|
345
|
-
|
|
346
|
-
FIREJAIL_AVAILABLE = true;
|
|
347
|
-
console.log("[run_cmd] firejail detected \u2014 sandboxing run_cmd with it.");
|
|
752
|
+
import_fs2.default.mkdirSync(fallback, { recursive: true });
|
|
348
753
|
} catch {
|
|
349
|
-
console.warn(
|
|
350
|
-
"[run_cmd] firejail not found \u2014 run_cmd will still be jailed to " + WORKSPACE_DIR + " with a stripped env + blocklist, but for real OS-level isolation install firejail (`sudo apt-get install -y firejail`) or run this whole server inside a Docker container."
|
|
351
|
-
);
|
|
352
754
|
}
|
|
353
|
-
|
|
354
|
-
|
|
755
|
+
return fallback;
|
|
756
|
+
}
|
|
757
|
+
var currentWorkspace = pickInitialWorkspace();
|
|
758
|
+
function getWorkspace() {
|
|
759
|
+
return currentWorkspace;
|
|
355
760
|
}
|
|
356
|
-
var
|
|
357
|
-
|
|
358
|
-
|
|
761
|
+
var workspaceListeners = [];
|
|
762
|
+
function onWorkspaceChange(cb) {
|
|
763
|
+
workspaceListeners.push(cb);
|
|
764
|
+
}
|
|
765
|
+
function setWorkspace(dir) {
|
|
766
|
+
if (!dir || typeof dir !== "string") return { ok: false, error: "Missing path" };
|
|
767
|
+
const abs = import_path2.default.resolve(dir.trim().replace(/^~(?=$|[\\/])/, import_os2.default.homedir()));
|
|
768
|
+
if (!isDir(abs)) return { ok: false, error: `Folder does not exist: ${abs}` };
|
|
359
769
|
try {
|
|
360
|
-
(
|
|
361
|
-
ZIP_AVAILABLE = true;
|
|
770
|
+
import_fs2.default.accessSync(abs, import_fs2.default.constants.R_OK | import_fs2.default.constants.W_OK);
|
|
362
771
|
} catch {
|
|
772
|
+
return { ok: false, error: `No read/write permission: ${abs}` };
|
|
363
773
|
}
|
|
774
|
+
currentWorkspace = abs;
|
|
775
|
+
const cfg = readConfig();
|
|
776
|
+
const recent = [abs, ...(cfg.recent || []).filter((r) => r !== abs)].filter(isDir).slice(0, 10);
|
|
777
|
+
writeConfig({ workspace: abs, recent });
|
|
778
|
+
console.log(`[workspace] now: ${abs}`);
|
|
779
|
+
for (const cb of workspaceListeners) {
|
|
780
|
+
try {
|
|
781
|
+
cb(abs);
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return { ok: true, workspace: abs };
|
|
786
|
+
}
|
|
787
|
+
function resolveInWorkspace(rel) {
|
|
788
|
+
const clean = String(rel || "").trim().replace(/^(\.\/|\/|\\)+/, "");
|
|
789
|
+
const abs = import_path2.default.resolve(currentWorkspace, clean);
|
|
790
|
+
if (abs === currentWorkspace) return abs;
|
|
791
|
+
return abs.startsWith(currentWorkspace + import_path2.default.sep) ? abs : null;
|
|
792
|
+
}
|
|
793
|
+
function agentDir(workspaceId) {
|
|
794
|
+
if (!workspaceId || typeof workspaceId !== "string") return currentWorkspace;
|
|
795
|
+
const safeId = workspaceId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
|
|
796
|
+
if (!safeId) return currentWorkspace;
|
|
797
|
+
const dir = import_path2.default.join(currentWorkspace, ".fanluc", "agents", safeId);
|
|
364
798
|
try {
|
|
365
|
-
|
|
366
|
-
UNZIP_AVAILABLE = true;
|
|
799
|
+
import_fs2.default.mkdirSync(dir, { recursive: true });
|
|
367
800
|
} catch {
|
|
368
801
|
}
|
|
369
|
-
|
|
802
|
+
return dir;
|
|
803
|
+
}
|
|
804
|
+
function which(name) {
|
|
370
805
|
try {
|
|
371
|
-
|
|
372
|
-
|
|
806
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
807
|
+
const out = (0, import_child_process.execFileSync)(finder, [name], { stdio: ["ignore", "pipe", "ignore"] }).toString().split(/\r?\n/)[0].trim();
|
|
808
|
+
return out || null;
|
|
373
809
|
} catch {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function shellFromBinary(bin) {
|
|
814
|
+
const base2 = import_path2.default.basename(bin).toLowerCase();
|
|
815
|
+
if (base2.includes("pwsh") || base2.includes("powershell")) {
|
|
816
|
+
return {
|
|
817
|
+
bin,
|
|
818
|
+
kind: "powershell",
|
|
819
|
+
label: base2.includes("pwsh") ? "PowerShell 7 (pwsh)" : "Windows PowerShell",
|
|
820
|
+
args: (cmd) => ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd]
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (base2 === "cmd.exe" || base2 === "cmd") {
|
|
824
|
+
return { bin, kind: "cmd", label: "cmd.exe", args: (cmd) => ["/d", "/s", "/c", cmd] };
|
|
374
825
|
}
|
|
826
|
+
return { bin, kind: "posix", label: base2.includes("bash") ? "bash" : base2, args: (cmd) => ["-c", cmd] };
|
|
827
|
+
}
|
|
828
|
+
function detectShell() {
|
|
829
|
+
const override = process.env.FANLUC_SHELL;
|
|
830
|
+
if (override) return shellFromBinary(override);
|
|
831
|
+
if (process.platform === "win32") {
|
|
832
|
+
const pf = process.env.ProgramFiles || "C:\\Program Files";
|
|
833
|
+
const pf86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
|
834
|
+
const local = process.env.LOCALAPPDATA || "";
|
|
835
|
+
const gitBash = [
|
|
836
|
+
import_path2.default.join(pf, "Git", "bin", "bash.exe"),
|
|
837
|
+
import_path2.default.join(pf86, "Git", "bin", "bash.exe"),
|
|
838
|
+
local && import_path2.default.join(local, "Programs", "Git", "bin", "bash.exe")
|
|
839
|
+
].filter(Boolean);
|
|
840
|
+
for (const c of gitBash) if (import_fs2.default.existsSync(c)) return { ...shellFromBinary(c), label: "Git Bash" };
|
|
841
|
+
const pwsh = which("pwsh");
|
|
842
|
+
if (pwsh) return shellFromBinary(pwsh);
|
|
843
|
+
return shellFromBinary(which("powershell") || "powershell.exe");
|
|
844
|
+
}
|
|
845
|
+
const bash = which("bash");
|
|
846
|
+
return shellFromBinary(bash || "/bin/sh");
|
|
847
|
+
}
|
|
848
|
+
var SHELL = detectShell();
|
|
849
|
+
function systemInfo() {
|
|
850
|
+
return {
|
|
851
|
+
platform: process.platform,
|
|
852
|
+
arch: process.arch,
|
|
853
|
+
os: `${import_os2.default.type()} ${import_os2.default.release()}`,
|
|
854
|
+
shell: SHELL.label,
|
|
855
|
+
shellKind: SHELL.kind,
|
|
856
|
+
home: import_os2.default.homedir(),
|
|
857
|
+
workspace: currentWorkspace,
|
|
858
|
+
node: process.version,
|
|
859
|
+
python: which("python3") ? "python3" : which("python") ? "python" : null,
|
|
860
|
+
git: !!which("git")
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
var STRIP_ENV = new Set(
|
|
864
|
+
["GEMINI_API_KEY", "TOKEN", "ADMIN_TOKEN", "ACCESS_TOKEN", ...(process.env.STRIP_ENV || "").split(",").map((s) => s.trim())].filter(Boolean)
|
|
865
|
+
);
|
|
866
|
+
function buildEnv() {
|
|
867
|
+
const env = {};
|
|
868
|
+
for (const [k, v] of Object.entries(process.env)) if (!STRIP_ENV.has(k)) env[k] = v;
|
|
869
|
+
return {
|
|
870
|
+
...env,
|
|
871
|
+
PYTHONIOENCODING: "utf-8",
|
|
872
|
+
PYTHONUTF8: "1",
|
|
873
|
+
NO_COLOR: "1",
|
|
874
|
+
FORCE_COLOR: "0",
|
|
875
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
876
|
+
// fail instead of hanging on a credential prompt
|
|
877
|
+
GIT_PAGER: "cat",
|
|
878
|
+
PAGER: "cat",
|
|
879
|
+
npm_config_yes: "true",
|
|
880
|
+
DEBIAN_FRONTEND: "noninteractive",
|
|
881
|
+
MPLBACKEND: env.MPLBACKEND || "Agg"
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
var CATASTROPHIC = [
|
|
885
|
+
{ pattern: /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf][a-z]*\s+(-[a-z]+\s+)*(--no-preserve-root\s+)?(\/|~|\$HOME|\/\*|~\/\*)\s*(;|&|\||$)/i, reason: "recursive delete of / or home" },
|
|
886
|
+
{ pattern: /\bmkfs(\.\w+)?\b/i, reason: "formatting a filesystem" },
|
|
887
|
+
{ pattern: /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk|mmcblk)/i, reason: "writing raw to a disk device" },
|
|
888
|
+
{ pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;/, reason: "fork bomb" },
|
|
889
|
+
{ pattern: /\bformat\s+[a-z]:/i, reason: "formatting a drive" },
|
|
890
|
+
{ pattern: /\b(rd|rmdir)\s+\/s\s+(\/q\s+)?[a-z]:\\?\s*$/i, reason: "deleting a whole drive" },
|
|
891
|
+
{ pattern: /\bRemove-Item\b[^\n]*-Recurse[^\n]*\s[a-z]:\\?\s*$/i, reason: "deleting a whole drive" }
|
|
892
|
+
];
|
|
893
|
+
function findBlocked(command) {
|
|
894
|
+
if (process.env.FANLUC_UNRESTRICTED === "1") return null;
|
|
895
|
+
for (const { pattern, reason } of CATASTROPHIC) if (pattern.test(command)) return reason;
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
function killTree(child) {
|
|
899
|
+
const pid = child.pid;
|
|
900
|
+
if (!pid) return;
|
|
375
901
|
try {
|
|
376
|
-
(
|
|
377
|
-
|
|
902
|
+
if (process.platform === "win32") {
|
|
903
|
+
(0, import_child_process.spawn)("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
904
|
+
} else {
|
|
905
|
+
try {
|
|
906
|
+
process.kill(-pid, "SIGTERM");
|
|
907
|
+
} catch {
|
|
908
|
+
child.kill("SIGTERM");
|
|
909
|
+
}
|
|
910
|
+
setTimeout(() => {
|
|
911
|
+
try {
|
|
912
|
+
process.kill(-pid, "SIGKILL");
|
|
913
|
+
} catch {
|
|
914
|
+
try {
|
|
915
|
+
child.kill("SIGKILL");
|
|
916
|
+
} catch {
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}, 2e3).unref?.();
|
|
920
|
+
}
|
|
378
921
|
} catch {
|
|
379
922
|
}
|
|
380
923
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
924
|
+
var CAN_ULIMIT = process.platform !== "win32" && SHELL.kind === "posix";
|
|
925
|
+
function spawnShell(command, cwd) {
|
|
926
|
+
return (0, import_child_process.spawn)(SHELL.bin, SHELL.args(command), {
|
|
927
|
+
cwd,
|
|
928
|
+
env: buildEnv(),
|
|
929
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
930
|
+
// no stdin: interactive prompts fail fast instead of hanging
|
|
931
|
+
detached: process.platform !== "win32",
|
|
932
|
+
// own process group so we can kill the whole tree
|
|
933
|
+
windowsHide: true
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
var MAX_CAPTURE = 2 * 1024 * 1024;
|
|
937
|
+
var MAX_RETURN = Number(process.env.FANLUC_MAX_RETURN) > 0 ? Number(process.env.FANLUC_MAX_RETURN) : 1e5;
|
|
938
|
+
function runCommand(command, cwd, timeoutMs) {
|
|
939
|
+
return new Promise((resolve) => {
|
|
940
|
+
let child;
|
|
941
|
+
try {
|
|
942
|
+
child = spawnShell(command, cwd);
|
|
943
|
+
} catch (e) {
|
|
944
|
+
return resolve({ stdout: "", stderr: `Failed to start shell (${SHELL.bin}): ${e?.message || e}`, exitCode: 127, timedOut: false, truncated: false });
|
|
389
945
|
}
|
|
946
|
+
let stdout = "";
|
|
947
|
+
let stderr = "";
|
|
948
|
+
let truncated = false;
|
|
949
|
+
let timedOut = false;
|
|
950
|
+
const add = (which2, d) => {
|
|
951
|
+
const s = d.toString("utf-8");
|
|
952
|
+
if (which2 === "o") stdout += s;
|
|
953
|
+
else stderr += s;
|
|
954
|
+
if (stdout.length + stderr.length > MAX_CAPTURE) {
|
|
955
|
+
truncated = true;
|
|
956
|
+
stdout = stdout.slice(-MAX_CAPTURE / 2);
|
|
957
|
+
stderr = stderr.slice(-MAX_CAPTURE / 2);
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
child.stdout?.on("data", (d) => add("o", d));
|
|
961
|
+
child.stderr?.on("data", (d) => add("e", d));
|
|
962
|
+
const timer = setTimeout(() => {
|
|
963
|
+
timedOut = true;
|
|
964
|
+
killTree(child);
|
|
965
|
+
}, timeoutMs);
|
|
966
|
+
child.on("error", (err) => {
|
|
967
|
+
clearTimeout(timer);
|
|
968
|
+
resolve({ stdout, stderr: stderr + `
|
|
969
|
+
[spawn error: ${err.message}]`, exitCode: 127, timedOut, truncated });
|
|
970
|
+
});
|
|
971
|
+
child.on("close", (code) => {
|
|
972
|
+
clearTimeout(timer);
|
|
973
|
+
resolve({ stdout, stderr, exitCode: code, timedOut, truncated });
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
var BG_JOBS = /* @__PURE__ */ new Map();
|
|
978
|
+
var BG_MAX_LOG = 3e5;
|
|
979
|
+
var BG_MAX_CONCURRENT = 20;
|
|
980
|
+
var BG_EVICT_MS = 30 * 60 * 1e3;
|
|
981
|
+
var bgSeq = 0;
|
|
982
|
+
function bgAppend(job, chunk) {
|
|
983
|
+
if (!chunk) return;
|
|
984
|
+
job.log += chunk;
|
|
985
|
+
if (job.log.length > BG_MAX_LOG) {
|
|
986
|
+
job.log = job.log.slice(job.log.length - BG_MAX_LOG);
|
|
987
|
+
job.truncated = true;
|
|
390
988
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
989
|
+
}
|
|
990
|
+
function bgStatus(job) {
|
|
991
|
+
if (job.status === "exited") return `exited (code ${job.exitCode})`;
|
|
992
|
+
return job.status;
|
|
993
|
+
}
|
|
994
|
+
function lastLine(text) {
|
|
995
|
+
const lines = text.split("\n").map((l) => l.trimEnd());
|
|
996
|
+
for (let i = lines.length - 1; i >= 0; i--) if (lines[i].trim()) return lines[i];
|
|
997
|
+
return "(no output yet)";
|
|
998
|
+
}
|
|
999
|
+
function bgEvictLater(job) {
|
|
1000
|
+
setTimeout(() => {
|
|
1001
|
+
if (BG_JOBS.get(job.id) === job) BG_JOBS.delete(job.id);
|
|
1002
|
+
}, BG_EVICT_MS).unref?.();
|
|
1003
|
+
}
|
|
1004
|
+
function killAllJobs() {
|
|
1005
|
+
for (const j of BG_JOBS.values()) if (j.status === "running") killTree(j.child);
|
|
1006
|
+
}
|
|
1007
|
+
process.on("exit", killAllJobs);
|
|
1008
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1009
|
+
process.on(sig, () => {
|
|
1010
|
+
killAllJobs();
|
|
1011
|
+
process.exit(0);
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
function isLoopbackAddr(ip) {
|
|
1015
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
1016
|
+
}
|
|
1017
|
+
function requireLocal(req, res, next) {
|
|
1018
|
+
if (process.env.ALLOW_REMOTE_EXEC === "1") return next();
|
|
1019
|
+
const ip = String(req.socket?.remoteAddress || "");
|
|
1020
|
+
const viaProxy = !!req.headers["x-forwarded-for"] || !!req.headers["x-forwarded-host"];
|
|
1021
|
+
const host = String(req.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
|
|
1022
|
+
const extra = (process.env.ALLOWED_HOSTS || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1023
|
+
const hostOk = host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost") || extra.includes(host);
|
|
1024
|
+
if (isLoopbackAddr(ip) && !viaProxy && hostOk) return next();
|
|
1025
|
+
return res.status(403).json({
|
|
1026
|
+
error: "Local only: running commands / browsing folders is allowed only from the machine running the server (open http://localhost:PORT). Set ALLOW_REMOTE_EXEC=1 to override (dangerous).",
|
|
1027
|
+
formattedText: "Blocked: this request did not come from localhost."
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([
|
|
1031
|
+
"node_modules",
|
|
1032
|
+
".git",
|
|
1033
|
+
".hg",
|
|
1034
|
+
".svn",
|
|
1035
|
+
"dist",
|
|
1036
|
+
"build",
|
|
1037
|
+
"out",
|
|
1038
|
+
".next",
|
|
1039
|
+
".nuxt",
|
|
1040
|
+
".cache",
|
|
1041
|
+
".venv",
|
|
1042
|
+
"venv",
|
|
1043
|
+
"env",
|
|
1044
|
+
"__pycache__",
|
|
1045
|
+
".pytest_cache",
|
|
1046
|
+
".mypy_cache",
|
|
1047
|
+
"target",
|
|
1048
|
+
"vendor",
|
|
1049
|
+
".gradle",
|
|
1050
|
+
".idea",
|
|
1051
|
+
".vscode",
|
|
1052
|
+
"coverage",
|
|
1053
|
+
".turbo",
|
|
1054
|
+
".fanluc"
|
|
1055
|
+
]);
|
|
1056
|
+
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
1057
|
+
"zip",
|
|
1058
|
+
"gz",
|
|
1059
|
+
"tar",
|
|
1060
|
+
"tgz",
|
|
1061
|
+
"rar",
|
|
1062
|
+
"7z",
|
|
1063
|
+
"png",
|
|
1064
|
+
"jpg",
|
|
1065
|
+
"jpeg",
|
|
1066
|
+
"gif",
|
|
1067
|
+
"webp",
|
|
1068
|
+
"bmp",
|
|
1069
|
+
"ico",
|
|
1070
|
+
"pdf",
|
|
1071
|
+
"exe",
|
|
1072
|
+
"dll",
|
|
1073
|
+
"so",
|
|
1074
|
+
"dylib",
|
|
1075
|
+
"bin",
|
|
1076
|
+
"apk",
|
|
1077
|
+
"iso",
|
|
1078
|
+
"mp3",
|
|
1079
|
+
"mp4",
|
|
1080
|
+
"mkv",
|
|
1081
|
+
"avi",
|
|
1082
|
+
"mov",
|
|
1083
|
+
"wav",
|
|
1084
|
+
"flac",
|
|
1085
|
+
"ogg",
|
|
1086
|
+
"woff",
|
|
1087
|
+
"woff2",
|
|
1088
|
+
"ttf",
|
|
1089
|
+
"otf",
|
|
1090
|
+
"class",
|
|
1091
|
+
"jar",
|
|
1092
|
+
"pyc",
|
|
1093
|
+
"sqlite",
|
|
1094
|
+
"db"
|
|
1095
|
+
]);
|
|
1096
|
+
var MAX_FILES = 5e3;
|
|
1097
|
+
var VIEW_LIMIT = 70 * 1024 * 1024;
|
|
1098
|
+
var MAX_INLINE_FILE = 256 * 1024 * 1024;
|
|
1099
|
+
async function listWorkspaceFiles(root) {
|
|
1100
|
+
const files = {};
|
|
1101
|
+
let count = 0;
|
|
1102
|
+
let truncated = false;
|
|
1103
|
+
async function walk(dir, rel) {
|
|
1104
|
+
if (truncated) return;
|
|
1105
|
+
let entries = [];
|
|
1106
|
+
try {
|
|
1107
|
+
entries = await import_fs2.default.promises.readdir(dir, { withFileTypes: true });
|
|
1108
|
+
} catch {
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1112
|
+
for (const entry of entries) {
|
|
1113
|
+
if (truncated) return;
|
|
1114
|
+
const name = entry.name;
|
|
1115
|
+
if (entry.isDirectory()) {
|
|
1116
|
+
if (SKIP_DIRS2.has(name) || name.startsWith(".")) continue;
|
|
1117
|
+
await walk(import_path2.default.join(dir, name), rel ? `${rel}/${name}` : name);
|
|
1118
|
+
} else if (entry.isFile()) {
|
|
1119
|
+
if (name.startsWith(".")) continue;
|
|
1120
|
+
if (count >= MAX_FILES) {
|
|
1121
|
+
truncated = true;
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const full = import_path2.default.join(dir, name);
|
|
1125
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
1126
|
+
let st;
|
|
1127
|
+
try {
|
|
1128
|
+
st = await import_fs2.default.promises.stat(full);
|
|
1129
|
+
} catch {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
1133
|
+
let content;
|
|
1134
|
+
if (BINARY_EXT.has(ext)) content = `[Binary File: ${name} (${(st.size / 1024).toFixed(1)} KB)]`;
|
|
1135
|
+
else if (st.size > MAX_INLINE_FILE || st.size > VIEW_LIMIT && !/\.html?$/i.test(name))
|
|
1136
|
+
content = `[Large File: ${(st.size / 1024).toFixed(1)} KB \u2014 read it with run_cmd (cat / sed -n)]`;
|
|
1137
|
+
else {
|
|
1138
|
+
try {
|
|
1139
|
+
content = await import_fs2.default.promises.readFile(full, "utf-8");
|
|
1140
|
+
} catch {
|
|
1141
|
+
content = `[Unreadable File: ${(st.size / 1024).toFixed(1)} KB]`;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
files[relPath] = { path: relPath, name, content, size: st.size, language: ext, updatedAt: st.mtimeMs };
|
|
1145
|
+
count++;
|
|
401
1146
|
}
|
|
402
1147
|
}
|
|
403
|
-
|
|
404
|
-
|
|
1148
|
+
}
|
|
1149
|
+
await walk(root, "");
|
|
1150
|
+
return { files, truncated, count };
|
|
1151
|
+
}
|
|
1152
|
+
var KV_FILE = import_path2.default.join(import_os2.default.homedir(), ".fanluc", "state.json");
|
|
1153
|
+
var kvCache = null;
|
|
1154
|
+
function kvLoad() {
|
|
1155
|
+
if (kvCache) return kvCache;
|
|
1156
|
+
try {
|
|
1157
|
+
kvCache = JSON.parse(import_fs2.default.readFileSync(KV_FILE, "utf-8")) || {};
|
|
1158
|
+
} catch {
|
|
1159
|
+
kvCache = {};
|
|
1160
|
+
}
|
|
1161
|
+
return kvCache;
|
|
1162
|
+
}
|
|
1163
|
+
var kvTimer = null;
|
|
1164
|
+
function kvSave(data) {
|
|
1165
|
+
kvCache = data;
|
|
1166
|
+
if (kvTimer) return;
|
|
1167
|
+
kvTimer = setTimeout(() => {
|
|
1168
|
+
kvTimer = null;
|
|
1169
|
+
try {
|
|
1170
|
+
import_fs2.default.mkdirSync(import_path2.default.dirname(KV_FILE), { recursive: true });
|
|
1171
|
+
const tmp = KV_FILE + ".tmp";
|
|
1172
|
+
import_fs2.default.writeFileSync(tmp, JSON.stringify(kvCache), { mode: 384 });
|
|
1173
|
+
import_fs2.default.renameSync(tmp, KV_FILE);
|
|
1174
|
+
} catch (e) {
|
|
1175
|
+
console.warn("[kv] save failed:", e);
|
|
1176
|
+
}
|
|
1177
|
+
}, 300);
|
|
1178
|
+
}
|
|
1179
|
+
process.on("exit", () => {
|
|
1180
|
+
if (kvTimer) {
|
|
1181
|
+
try {
|
|
1182
|
+
import_fs2.default.mkdirSync(import_path2.default.dirname(KV_FILE), { recursive: true });
|
|
1183
|
+
import_fs2.default.writeFileSync(KV_FILE, JSON.stringify(kvCache), { mode: 384 });
|
|
1184
|
+
} catch {
|
|
405
1185
|
}
|
|
406
1186
|
}
|
|
407
|
-
|
|
1187
|
+
});
|
|
1188
|
+
function clampTimeout(v) {
|
|
1189
|
+
const n = Number(v);
|
|
1190
|
+
if (!Number.isFinite(n) || n <= 0) return 12e4;
|
|
1191
|
+
return Math.min(6e5, Math.max(1e3, n));
|
|
408
1192
|
}
|
|
409
|
-
function
|
|
410
|
-
|
|
1193
|
+
function listRoots() {
|
|
1194
|
+
const roots = [];
|
|
1195
|
+
if (process.platform === "win32") {
|
|
1196
|
+
for (let c = 65; c <= 90; c++) {
|
|
1197
|
+
const d = `${String.fromCharCode(c)}:\\`;
|
|
1198
|
+
try {
|
|
1199
|
+
if (import_fs2.default.existsSync(d)) roots.push({ name: d, path: d });
|
|
1200
|
+
} catch {
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
} else {
|
|
1204
|
+
roots.push({ name: "/", path: "/" });
|
|
1205
|
+
}
|
|
1206
|
+
return roots;
|
|
1207
|
+
}
|
|
1208
|
+
function registerLocalRoutes(app) {
|
|
1209
|
+
registerPermissionRoutes(app, requireLocal);
|
|
1210
|
+
app.get("/api/workspace", requireLocal, (_req, res) => {
|
|
1211
|
+
const cfg = readConfig();
|
|
1212
|
+
res.json({
|
|
1213
|
+
workspace: currentWorkspace,
|
|
1214
|
+
name: import_path2.default.basename(currentWorkspace) || currentWorkspace,
|
|
1215
|
+
recent: (cfg.recent || []).filter(isDir),
|
|
1216
|
+
home: import_os2.default.homedir(),
|
|
1217
|
+
roots: listRoots(),
|
|
1218
|
+
system: systemInfo()
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
app.post("/api/workspace/set", requireLocal, (req, res) => {
|
|
1222
|
+
const r = setWorkspace(String(req.body?.path || ""));
|
|
1223
|
+
if (!r.ok) return res.status(400).json(r);
|
|
1224
|
+
return res.json({ ok: true, workspace: r.workspace, name: import_path2.default.basename(r.workspace) || r.workspace });
|
|
1225
|
+
});
|
|
1226
|
+
app.get("/api/fs/browse", requireLocal, async (req, res) => {
|
|
1227
|
+
try {
|
|
1228
|
+
const raw = String(req.query.path || "").trim();
|
|
1229
|
+
const target = import_path2.default.resolve(raw ? raw.replace(/^~(?=$|[\\/])/, import_os2.default.homedir()) : import_os2.default.homedir());
|
|
1230
|
+
if (!isDir(target)) return res.status(404).json({ error: `Not a folder: ${target}` });
|
|
1231
|
+
const showHidden = req.query.hidden === "1";
|
|
1232
|
+
let entries = [];
|
|
1233
|
+
try {
|
|
1234
|
+
entries = await import_fs2.default.promises.readdir(target, { withFileTypes: true });
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
return res.status(403).json({ error: e?.message || "Cannot read folder" });
|
|
1237
|
+
}
|
|
1238
|
+
const dirs = entries.filter((e) => {
|
|
1239
|
+
if (!e.isDirectory() && !(e.isSymbolicLink() && isDir(import_path2.default.join(target, e.name)))) return false;
|
|
1240
|
+
return showHidden || !e.name.startsWith(".");
|
|
1241
|
+
}).map((e) => e.name).sort((a, b) => a.localeCompare(b));
|
|
1242
|
+
const parent = import_path2.default.dirname(target);
|
|
1243
|
+
res.json({
|
|
1244
|
+
path: target,
|
|
1245
|
+
parent: parent === target ? null : parent,
|
|
1246
|
+
dirs,
|
|
1247
|
+
fileCount: entries.filter((e) => e.isFile()).length,
|
|
1248
|
+
roots: listRoots()
|
|
1249
|
+
});
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
res.status(500).json({ error: e?.message || "browse failed" });
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
app.post("/api/fs/mkdir", requireLocal, async (req, res) => {
|
|
1255
|
+
try {
|
|
1256
|
+
const parent = import_path2.default.resolve(String(req.body?.parent || ""));
|
|
1257
|
+
const name = String(req.body?.name || "").trim();
|
|
1258
|
+
if (!name || /[\\/:*?"<>|]/.test(name) || name === "." || name === "..") return res.status(400).json({ error: "Invalid folder name" });
|
|
1259
|
+
if (!isDir(parent)) return res.status(404).json({ error: "Parent folder not found" });
|
|
1260
|
+
const full = import_path2.default.join(parent, name);
|
|
1261
|
+
await import_fs2.default.promises.mkdir(full, { recursive: false });
|
|
1262
|
+
res.json({ ok: true, path: full });
|
|
1263
|
+
} catch (e) {
|
|
1264
|
+
res.status(500).json({ error: e?.message || "mkdir failed" });
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
app.get("/api/system-info", requireLocal, (_req, res) => res.json(systemInfo()));
|
|
1268
|
+
app.post("/api/run-cmd", requireLocal, async (req, res) => {
|
|
1269
|
+
if (process.env.DISABLE_RUN_CMD === "1") {
|
|
1270
|
+
const msg = "run_cmd is disabled on this server (DISABLE_RUN_CMD=1).";
|
|
1271
|
+
return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
|
|
1272
|
+
}
|
|
1273
|
+
const { command, workspaceId, timeoutMs } = req.body || {};
|
|
1274
|
+
if (!command || typeof command !== "string" || !command.trim()) {
|
|
1275
|
+
return res.status(400).json({ error: "Missing command", formattedText: "Error: missing command" });
|
|
1276
|
+
}
|
|
1277
|
+
const trimmed = command.trim();
|
|
1278
|
+
const blocked = findBlocked(trimmed);
|
|
1279
|
+
if (blocked) {
|
|
1280
|
+
const msg = `Command blocked as catastrophic: ${blocked}. (Set FANLUC_UNRESTRICTED=1 to disable this guard.)`;
|
|
1281
|
+
return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
|
|
1282
|
+
}
|
|
1283
|
+
const cwd = agentDir(workspaceId);
|
|
1284
|
+
const limit = clampTimeout(timeoutMs);
|
|
1285
|
+
const ev = await evaluatePermission(trimmed, cwd, { canUlimit: CAN_ULIMIT });
|
|
1286
|
+
if (!ev.ok) {
|
|
1287
|
+
return res.json({ stdout: "", stderr: ev.message, formattedText: `$ ${trimmed}
|
|
1288
|
+
${ev.message}`, blocked: true });
|
|
1289
|
+
}
|
|
1290
|
+
const startedAt = Date.now();
|
|
1291
|
+
const toRun = ev.ulimitMb ? `ulimit -f ${Math.max(1, Math.floor(ev.ulimitMb * 1024))}; ${trimmed}` : trimmed;
|
|
1292
|
+
const r = await runCommand(toRun, cwd, limit);
|
|
1293
|
+
const out = r.stdout.slice(-MAX_RETURN);
|
|
1294
|
+
const errOut = r.stderr.slice(-MAX_RETURN);
|
|
1295
|
+
const notes = [];
|
|
1296
|
+
if (r.truncated || r.stdout.length > MAX_RETURN || r.stderr.length > MAX_RETURN) notes.push("[output truncated \u2014 showing the end]");
|
|
1297
|
+
if (ev.ulimitMb && r.exitCode === 153) notes.push("[stopped: a file would exceed the user's max file-size limit (permission settings)]");
|
|
1298
|
+
if (ev.scanBytes) {
|
|
1299
|
+
const removed = await removeOversizeNewFiles(cwd, startedAt, ev.scanBytes);
|
|
1300
|
+
if (removed.length) notes.push(`[removed new files over the user's max file-size limit: ${removed.join(", ")}]`);
|
|
1301
|
+
}
|
|
1302
|
+
if (r.timedOut) notes.push(`[timed out after ${Math.round(limit / 1e3)}s and was killed \u2014 use run_cmd_bg for long-running commands]`);
|
|
1303
|
+
const body = `${out}${errOut ? (out ? "\n" : "") + errOut : ""}`.trim() || "(no output)";
|
|
1304
|
+
const formattedText = `$ ${trimmed}
|
|
1305
|
+
${body}${notes.length ? "\n" + notes.join("\n") : ""}${!r.timedOut && r.exitCode ? `
|
|
1306
|
+
[exit code ${r.exitCode}]` : ""}`;
|
|
1307
|
+
return res.json({ stdout: out, stderr: errOut, exitCode: r.exitCode, timedOut: r.timedOut, formattedText });
|
|
1308
|
+
});
|
|
1309
|
+
app.post("/api/run-cmd-bg", requireLocal, async (req, res) => {
|
|
1310
|
+
if (process.env.DISABLE_RUN_CMD === "1") {
|
|
1311
|
+
return res.json({ id: null, formattedText: "run_cmd_bg is disabled on this server.", blocked: true });
|
|
1312
|
+
}
|
|
1313
|
+
const { command, workspaceId } = req.body || {};
|
|
1314
|
+
if (!command || typeof command !== "string" || !command.trim()) {
|
|
1315
|
+
return res.status(400).json({ error: "Missing command", formattedText: "Error: missing command" });
|
|
1316
|
+
}
|
|
1317
|
+
const trimmed = command.trim();
|
|
1318
|
+
const blocked = findBlocked(trimmed);
|
|
1319
|
+
if (blocked) return res.json({ id: null, formattedText: `Command blocked as catastrophic: ${blocked}.`, blocked: true });
|
|
1320
|
+
const ev = await evaluatePermission(trimmed, agentDir(workspaceId), { canUlimit: CAN_ULIMIT });
|
|
1321
|
+
if (!ev.ok) return res.json({ id: null, formattedText: `$ ${trimmed}
|
|
1322
|
+
${ev.message}`, blocked: true });
|
|
1323
|
+
const running = [...BG_JOBS.values()].filter((j) => j.status === "running").length;
|
|
1324
|
+
if (running >= BG_MAX_CONCURRENT) {
|
|
1325
|
+
return res.json({ id: null, formattedText: `Too many background commands running (max ${BG_MAX_CONCURRENT}). Kill one with kill_cmd_bg first.`, blocked: true });
|
|
1326
|
+
}
|
|
1327
|
+
let child;
|
|
1328
|
+
try {
|
|
1329
|
+
child = spawnShell(ev.ulimitMb ? `ulimit -f ${Math.max(1, Math.floor(ev.ulimitMb * 1024))}; ${trimmed}` : trimmed, agentDir(workspaceId));
|
|
1330
|
+
} catch (e) {
|
|
1331
|
+
return res.json({ id: null, formattedText: `Failed to start background command: ${e?.message || e}`, blocked: true });
|
|
1332
|
+
}
|
|
1333
|
+
const id = `bg${(++bgSeq).toString(36)}${Date.now().toString(36).slice(-4)}`;
|
|
1334
|
+
const job = { id, command: trimmed, child, log: "", truncated: false, status: "running", exitCode: null, startedAt: Date.now(), endedAt: null };
|
|
1335
|
+
BG_JOBS.set(id, job);
|
|
1336
|
+
child.stdout?.on("data", (d) => bgAppend(job, d.toString()));
|
|
1337
|
+
child.stderr?.on("data", (d) => bgAppend(job, d.toString()));
|
|
1338
|
+
child.on("error", (err) => {
|
|
1339
|
+
bgAppend(job, `
|
|
1340
|
+
[process error: ${err.message}]
|
|
1341
|
+
`);
|
|
1342
|
+
job.status = "error";
|
|
1343
|
+
job.endedAt = Date.now();
|
|
1344
|
+
bgEvictLater(job);
|
|
1345
|
+
});
|
|
1346
|
+
child.on("exit", (code, signal) => {
|
|
1347
|
+
job.exitCode = code;
|
|
1348
|
+
job.endedAt = Date.now();
|
|
1349
|
+
if (job.status === "running") job.status = "exited";
|
|
1350
|
+
if (signal) bgAppend(job, `
|
|
1351
|
+
[terminated by signal ${signal}]
|
|
1352
|
+
`);
|
|
1353
|
+
bgEvictLater(job);
|
|
1354
|
+
});
|
|
1355
|
+
return res.json({ id, formattedText: `Started background command ${id}: ${trimmed}` });
|
|
1356
|
+
});
|
|
1357
|
+
app.post("/api/kill-cmd-bg", requireLocal, (req, res) => {
|
|
1358
|
+
const { id } = req.body || {};
|
|
1359
|
+
const job = id && BG_JOBS.get(id);
|
|
1360
|
+
if (!job) return res.json({ formattedText: `No background command with id "${id}" (it may have finished and been cleaned up).` });
|
|
1361
|
+
if (job.status !== "running") return res.json({ formattedText: `${id} is not running (status: ${bgStatus(job)}).` });
|
|
1362
|
+
job.status = "killed";
|
|
1363
|
+
killTree(job.child);
|
|
1364
|
+
return res.json({ formattedText: `Killed ${id} (${job.command}).` });
|
|
1365
|
+
});
|
|
1366
|
+
app.post("/api/read-cmd-bg-log", requireLocal, (req, res) => {
|
|
1367
|
+
const { id, offset, limit } = req.body || {};
|
|
1368
|
+
const job = id && BG_JOBS.get(id);
|
|
1369
|
+
if (!job) return res.json({ formattedText: `No background command with id "${id}" (it may have finished and been cleaned up).` });
|
|
1370
|
+
const lines = job.log.split("\n");
|
|
1371
|
+
const off = Math.max(1, parseInt(offset, 10) || 1);
|
|
1372
|
+
const lim = Math.max(1, Math.min(2e3, parseInt(limit, 10) || 200));
|
|
1373
|
+
const slice = lines.slice(off - 1, off - 1 + lim);
|
|
1374
|
+
const header = `[${id}] status: ${bgStatus(job)} | command: ${job.command} | log lines: ${lines.length}${job.truncated ? " (older lines were trimmed)" : ""}`;
|
|
1375
|
+
return res.json({ formattedText: `${header}
|
|
1376
|
+
${slice.join("\n") || "(nothing at this offset)"}` });
|
|
1377
|
+
});
|
|
1378
|
+
app.post("/api/list-cmd-bg", requireLocal, (_req, res) => {
|
|
1379
|
+
const jobs = [...BG_JOBS.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
1380
|
+
if (!jobs.length) return res.json({ formattedText: "No background commands.", jobs: [] });
|
|
1381
|
+
const rows = jobs.map((j) => ({ id: j.id, command: j.command, status: bgStatus(j), lastLogLine: lastLine(j.log) }));
|
|
1382
|
+
return res.json({ formattedText: rows.map((r) => `- ${r.id} | ${r.status} | ${r.command}
|
|
1383
|
+
last: ${r.lastLogLine}`).join("\n"), jobs: rows });
|
|
1384
|
+
});
|
|
1385
|
+
app.post("/api/agent-workspace-export", requireLocal, async (req, res) => {
|
|
1386
|
+
const safeId = typeof req.body?.workspaceId === "string" ? req.body.workspaceId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) : "";
|
|
1387
|
+
if (!safeId) return res.status(400).json({ error: "Missing workspaceId" });
|
|
1388
|
+
const dir = import_path2.default.join(currentWorkspace, ".fanluc", "agents", safeId);
|
|
1389
|
+
if (!import_fs2.default.existsSync(dir)) return res.status(404).json({ error: "This agent has no workspace files yet." });
|
|
1390
|
+
try {
|
|
1391
|
+
const { default: JSZip } = await import("jszip");
|
|
1392
|
+
const zip = new JSZip();
|
|
1393
|
+
const walk = async (d, rel) => {
|
|
1394
|
+
for (const e of await import_fs2.default.promises.readdir(d, { withFileTypes: true })) {
|
|
1395
|
+
const p = import_path2.default.join(d, e.name);
|
|
1396
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
1397
|
+
if (e.isDirectory()) await walk(p, r);
|
|
1398
|
+
else if (e.isFile()) zip.file(r, await import_fs2.default.promises.readFile(p));
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
await walk(dir, "");
|
|
1402
|
+
const buf = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
1403
|
+
res.setHeader("Content-Type", "application/zip");
|
|
1404
|
+
res.setHeader("Content-Disposition", `attachment; filename="agent-${safeId}-workspace.zip"`);
|
|
1405
|
+
res.send(buf);
|
|
1406
|
+
} catch (e) {
|
|
1407
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
app.post("/api/agent-workspace-clear", requireLocal, (req, res) => {
|
|
1411
|
+
const safeId = typeof req.body?.workspaceId === "string" ? req.body.workspaceId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) : "";
|
|
1412
|
+
if (!safeId) return res.status(400).json({ error: "Missing workspaceId" });
|
|
1413
|
+
const dir = import_path2.default.join(currentWorkspace, ".fanluc", "agents", safeId);
|
|
1414
|
+
try {
|
|
1415
|
+
import_fs2.default.rmSync(dir, { recursive: true, force: true });
|
|
1416
|
+
import_fs2.default.mkdirSync(dir, { recursive: true });
|
|
1417
|
+
res.json({ ok: true, formattedText: `Cleared workspace for agent ${safeId}.` });
|
|
1418
|
+
} catch (e) {
|
|
1419
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
app.post("/api/install-pack", (_req, res) => {
|
|
1423
|
+
res.json({
|
|
1424
|
+
ok: true,
|
|
1425
|
+
formattedText: "[INSTALL_PACK_V1 STATUS: SUCCESS]\nThis build runs commands directly on the user's machine. There is nothing to install automatically: use run_cmd with the tool's own installer (pip install X, npm i X, winget/brew/apt) when something is missing."
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
app.post("/api/upload-workspace-file", requireLocal, async (req, res) => {
|
|
1429
|
+
try {
|
|
1430
|
+
const { name, content, encoding = "utf-8" } = req.body || {};
|
|
1431
|
+
if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing file name" });
|
|
1432
|
+
const filePath = resolveInWorkspace(name);
|
|
1433
|
+
if (!filePath || filePath === currentWorkspace) return res.status(403).json({ error: "Invalid file path outside workspace" });
|
|
1434
|
+
await import_fs2.default.promises.mkdir(import_path2.default.dirname(filePath), { recursive: true });
|
|
1435
|
+
let buffer;
|
|
1436
|
+
if ((encoding === "base64" || name.toLowerCase().endsWith(".zip")) && typeof content === "string") {
|
|
1437
|
+
buffer = Buffer.from(content.includes(",") ? content.split(",")[1] : content, "base64");
|
|
1438
|
+
} else if (typeof content === "string") {
|
|
1439
|
+
buffer = Buffer.from(content, "utf-8");
|
|
1440
|
+
} else {
|
|
1441
|
+
buffer = Buffer.from("");
|
|
1442
|
+
}
|
|
1443
|
+
await import_fs2.default.promises.writeFile(filePath, buffer);
|
|
1444
|
+
const st = await import_fs2.default.promises.stat(filePath);
|
|
1445
|
+
return res.json({ success: true, path: import_path2.default.relative(currentWorkspace, filePath).split(import_path2.default.sep).join("/"), name: import_path2.default.basename(filePath), size: st.size, updatedAt: st.mtimeMs });
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
return res.status(500).json({ error: err?.message || "Failed to upload workspace file" });
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
app.post("/api/delete-workspace-file", requireLocal, async (req, res) => {
|
|
1451
|
+
try {
|
|
1452
|
+
const p = req.body?.path;
|
|
1453
|
+
if (!p || typeof p !== "string") return res.status(400).json({ error: "Missing file path" });
|
|
1454
|
+
const target = resolveInWorkspace(p);
|
|
1455
|
+
if (!target || target === currentWorkspace) return res.status(403).json({ error: "Invalid file path outside workspace" });
|
|
1456
|
+
if (import_fs2.default.existsSync(target)) await import_fs2.default.promises.rm(target, { recursive: true, force: true });
|
|
1457
|
+
return res.json({ success: true, path: p });
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
return res.status(500).json({ error: err?.message || "Failed to delete workspace file" });
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
app.post("/api/workspace/upload-raw", requireLocal, async (req, res) => {
|
|
1463
|
+
try {
|
|
1464
|
+
const rel = String(req.query.path || "");
|
|
1465
|
+
if (!rel) return res.status(400).json({ error: "Missing path" });
|
|
1466
|
+
const target = resolveInWorkspace(rel);
|
|
1467
|
+
if (!target || target === currentWorkspace) return res.status(403).json({ error: "Invalid file path outside workspace" });
|
|
1468
|
+
await import_fs2.default.promises.mkdir(import_path2.default.dirname(target), { recursive: true });
|
|
1469
|
+
await (0, import_promises.pipeline)(req, import_fs2.default.createWriteStream(target));
|
|
1470
|
+
const st = await import_fs2.default.promises.stat(target);
|
|
1471
|
+
return res.json({ success: true, path: import_path2.default.relative(currentWorkspace, target).split(import_path2.default.sep).join("/"), size: st.size, updatedAt: st.mtimeMs });
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
return res.status(500).json({ error: err?.message || "Upload failed" });
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
app.post("/api/workspace/reveal", requireLocal, async (req, res) => {
|
|
1477
|
+
try {
|
|
1478
|
+
const target = resolveInWorkspace(String(req.body?.path || ""));
|
|
1479
|
+
if (!target || !import_fs2.default.existsSync(target)) return res.status(404).json({ error: "Not found" });
|
|
1480
|
+
let bin;
|
|
1481
|
+
let args;
|
|
1482
|
+
if (process.platform === "win32") {
|
|
1483
|
+
bin = "explorer.exe";
|
|
1484
|
+
args = [`/select,${target}`];
|
|
1485
|
+
} else if (process.platform === "darwin") {
|
|
1486
|
+
bin = "open";
|
|
1487
|
+
args = ["-R", target];
|
|
1488
|
+
} else {
|
|
1489
|
+
bin = "xdg-open";
|
|
1490
|
+
args = [import_path2.default.dirname(target)];
|
|
1491
|
+
}
|
|
1492
|
+
const child = (0, import_child_process.spawn)(bin, args, { detached: true, stdio: "ignore", windowsHide: false });
|
|
1493
|
+
child.on("error", () => {
|
|
1494
|
+
});
|
|
1495
|
+
child.unref();
|
|
1496
|
+
return res.json({ ok: true });
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
return res.status(500).json({ error: err?.message || "Could not open folder" });
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
app.get("/api/workspace/file", requireLocal, async (req, res) => {
|
|
1502
|
+
try {
|
|
1503
|
+
const target = resolveInWorkspace(String(req.query.path || ""));
|
|
1504
|
+
if (!target || target === currentWorkspace || !import_fs2.default.existsSync(target)) return res.status(404).json({ error: "Not found" });
|
|
1505
|
+
const st = await import_fs2.default.promises.stat(target);
|
|
1506
|
+
if (!st.isFile()) return res.status(400).json({ error: "Not a file" });
|
|
1507
|
+
res.setHeader("Content-Type", "application/octet-stream");
|
|
1508
|
+
res.setHeader("Content-Length", String(st.size));
|
|
1509
|
+
res.setHeader("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(import_path2.default.basename(target))}`);
|
|
1510
|
+
import_fs2.default.createReadStream(target).on("error", () => res.destroy()).pipe(res);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
return res.status(500).json({ error: err?.message || "Download failed" });
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
app.get("/api/kv", requireLocal, (_req, res) => res.json({ data: kvLoad() }));
|
|
1516
|
+
app.post("/api/kv", requireLocal, (req, res) => {
|
|
1517
|
+
const { set, remove } = req.body || {};
|
|
1518
|
+
const data = kvLoad();
|
|
1519
|
+
if (set && typeof set === "object") {
|
|
1520
|
+
for (const [k, v] of Object.entries(set)) if (typeof v === "string") data[k] = v;
|
|
1521
|
+
}
|
|
1522
|
+
if (Array.isArray(remove)) for (const k of remove) delete data[String(k)];
|
|
1523
|
+
kvSave(data);
|
|
1524
|
+
return res.json({ ok: true });
|
|
1525
|
+
});
|
|
1526
|
+
app.get("/api/workspace-files", requireLocal, async (_req, res) => {
|
|
1527
|
+
try {
|
|
1528
|
+
const { files, truncated, count } = await listWorkspaceFiles(currentWorkspace);
|
|
1529
|
+
return res.json({ files, truncated, count, workspace: currentWorkspace });
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
return res.status(500).json({ error: err?.message || "Failed to list workspace files" });
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// localSearch.ts
|
|
1537
|
+
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
1538
|
+
function detectLocale() {
|
|
1539
|
+
const env = process.env.SEARCH_LANG;
|
|
1540
|
+
if (env) return env;
|
|
1541
|
+
try {
|
|
1542
|
+
return Intl.DateTimeFormat().resolvedOptions().locale || "en-US";
|
|
1543
|
+
} catch {
|
|
1544
|
+
return "en-US";
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
var LOCALE = detectLocale();
|
|
1548
|
+
var LANG = LOCALE.split("-")[0] || "en";
|
|
1549
|
+
var REGION = (LOCALE.split("-")[1] || "US").toUpperCase();
|
|
1550
|
+
var ACCEPT_LANGUAGE = `${LOCALE},${LANG};q=0.9,en;q=0.8`;
|
|
1551
|
+
function decodeEntities(s) {
|
|
1552
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/�?39;|'/g, "'").replace(/ /g, " ").replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(parseInt(d, 10)));
|
|
1553
|
+
}
|
|
1554
|
+
function stripTags(s) {
|
|
1555
|
+
return decodeEntities(s.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
|
|
1556
|
+
}
|
|
1557
|
+
async function getText(url, ms, headers = {}) {
|
|
1558
|
+
const resp = await fetch(url, {
|
|
1559
|
+
headers: { "User-Agent": UA, "Accept-Language": ACCEPT_LANGUAGE, ...headers },
|
|
1560
|
+
signal: AbortSignal.timeout(ms)
|
|
1561
|
+
});
|
|
1562
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
1563
|
+
return await resp.text();
|
|
1564
|
+
}
|
|
1565
|
+
async function getJson(url, ms, headers = {}) {
|
|
1566
|
+
const resp = await fetch(url, {
|
|
1567
|
+
headers: { "User-Agent": UA, Accept: "application/json", "Accept-Language": ACCEPT_LANGUAGE, ...headers },
|
|
1568
|
+
signal: AbortSignal.timeout(ms)
|
|
1569
|
+
});
|
|
1570
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
1571
|
+
return await resp.json();
|
|
1572
|
+
}
|
|
1573
|
+
var health = /* @__PURE__ */ new Map();
|
|
1574
|
+
function engineStatus() {
|
|
1575
|
+
const out = {};
|
|
1576
|
+
const now = Date.now();
|
|
1577
|
+
for (const [k, h] of health) out[k] = { ok: h.until <= now, fails: h.fails, retryInSec: Math.max(0, Math.round((h.until - now) / 1e3)), lastError: h.lastError };
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
async function runEngine(name, fn, timeoutMs = 5e3) {
|
|
1581
|
+
const h = health.get(name);
|
|
1582
|
+
if (h && h.until > Date.now()) return [];
|
|
1583
|
+
try {
|
|
1584
|
+
const res = await Promise.race([
|
|
1585
|
+
fn(),
|
|
1586
|
+
new Promise((_r, rej) => setTimeout(() => rej(new Error(`timeout ${timeoutMs}ms`)), timeoutMs).unref?.())
|
|
1587
|
+
]);
|
|
1588
|
+
if (res.length === 0) {
|
|
1589
|
+
const cur = health.get(name) || { fails: 0, until: 0 };
|
|
1590
|
+
cur.fails += 0.5;
|
|
1591
|
+
if (cur.fails >= 3) cur.until = Date.now() + 2 * 60 * 1e3;
|
|
1592
|
+
health.set(name, cur);
|
|
1593
|
+
} else {
|
|
1594
|
+
health.delete(name);
|
|
1595
|
+
}
|
|
1596
|
+
return res;
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
const cur = health.get(name) || { fails: 0, until: 0 };
|
|
1599
|
+
cur.fails += 1;
|
|
1600
|
+
cur.lastError = e?.message || String(e);
|
|
1601
|
+
if (cur.fails >= 2) cur.until = Date.now() + Math.min(30, 2 ** (cur.fails - 1)) * 60 * 1e3;
|
|
1602
|
+
health.set(name, cur);
|
|
1603
|
+
return [];
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
1607
|
+
async function cached(key, ttlMs, fn, shouldCache = () => true) {
|
|
1608
|
+
const hit = cache2.get(key);
|
|
1609
|
+
if (hit && hit.exp > Date.now()) return hit.value;
|
|
1610
|
+
const value = await fn();
|
|
1611
|
+
if (shouldCache(value)) {
|
|
1612
|
+
cache2.set(key, { exp: Date.now() + ttlMs, value });
|
|
1613
|
+
if (cache2.size > 300) cache2.delete(cache2.keys().next().value);
|
|
1614
|
+
}
|
|
1615
|
+
return value;
|
|
1616
|
+
}
|
|
1617
|
+
function dedupeKey(url) {
|
|
1618
|
+
try {
|
|
1619
|
+
const u = new URL(url);
|
|
1620
|
+
return (u.hostname.replace(/^www\./, "") + u.pathname.replace(/\/$/, "")).toLowerCase();
|
|
1621
|
+
} catch {
|
|
1622
|
+
return url.toLowerCase();
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
function fuseResults(lists) {
|
|
1626
|
+
const K = 60;
|
|
1627
|
+
const map = /* @__PURE__ */ new Map();
|
|
1628
|
+
for (const list of lists) {
|
|
1629
|
+
list.forEach((hit, i) => {
|
|
1630
|
+
const key = dedupeKey(hit.url);
|
|
1631
|
+
const add = 1 / (K + i + 1);
|
|
1632
|
+
const cur = map.get(key);
|
|
1633
|
+
if (cur) {
|
|
1634
|
+
cur.score += add;
|
|
1635
|
+
if ((hit.snippet || "").length > (cur.hit.snippet || "").length) cur.hit = { ...cur.hit, snippet: hit.snippet };
|
|
1636
|
+
cur.hit.engine += `+${hit.engine}`;
|
|
1637
|
+
} else map.set(key, { hit: { ...hit }, score: add });
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
return [...map.values()].sort((a, b) => b.score - a.score).map((x) => x.hit);
|
|
1641
|
+
}
|
|
1642
|
+
async function searchMojeek(query, count) {
|
|
1643
|
+
const html = await getText(`https://www.mojeek.com/search?q=${encodeURIComponent(query.trim())}&lb=${LANG}`, 5e3);
|
|
1644
|
+
const out = [];
|
|
1645
|
+
const re = /<li[^>]*>\s*<h2>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*class="title"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/li>/gi;
|
|
1646
|
+
const re2 = /<a[^>]+class="title"[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>([\s\S]*?)(?=<a[^>]+class="title"|<\/ul>)/gi;
|
|
1647
|
+
let m;
|
|
1648
|
+
while ((m = re2.exec(html)) !== null && out.length < count * 2) {
|
|
1649
|
+
const title = stripTags(m[2]);
|
|
1650
|
+
const sn = m[3].match(/<p[^>]*class="s"[^>]*>([\s\S]*?)<\/p>/i);
|
|
1651
|
+
const snippet = sn ? stripTags(sn[1]) : title;
|
|
1652
|
+
if (title) out.push({ url: decodeEntities(m[1]), title, snippet, engine: "mojeek" });
|
|
1653
|
+
}
|
|
1654
|
+
void re;
|
|
1655
|
+
return out;
|
|
1656
|
+
}
|
|
1657
|
+
async function searchBingLocal(query, count) {
|
|
1658
|
+
const html = await getText(
|
|
1659
|
+
`https://www.bing.com/search?q=${encodeURIComponent(query.trim())}&count=${Math.min(30, count * 3)}&setlang=${encodeURIComponent(LOCALE)}&cc=${REGION}`,
|
|
1660
|
+
5e3
|
|
1661
|
+
);
|
|
1662
|
+
if (/captcha|unusual traffic|verify you are a human|\/ck\/a\?!&&p=.*challenge/i.test(html) && !/class="b_algo"/.test(html)) throw new Error("bing captcha");
|
|
1663
|
+
const out = [];
|
|
1664
|
+
const re = /<li class="b_algo"[^>]*>[\s\S]*?<h2[^>]*>[\s\S]*?<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>([\s\S]*?)<\/li>/gi;
|
|
1665
|
+
let m;
|
|
1666
|
+
while ((m = re.exec(html)) !== null && out.length < count * 2) {
|
|
1667
|
+
const url = decodeEntities(m[1]);
|
|
1668
|
+
if (!url.startsWith("http")) continue;
|
|
1669
|
+
const title = stripTags(m[2]);
|
|
1670
|
+
const p = m[3].match(/<p[^>]*>([\s\S]*?)<\/p>/i) || m[3].match(/<div class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
|
|
1671
|
+
out.push({ url, title, snippet: p ? stripTags(p[1]) : title, engine: "bing" });
|
|
1672
|
+
}
|
|
1673
|
+
return out;
|
|
1674
|
+
}
|
|
1675
|
+
async function searchWikipedia(query, count) {
|
|
1676
|
+
const langs = LANG === "en" ? ["en"] : [LANG, "en"];
|
|
1677
|
+
const lists = await Promise.all(
|
|
1678
|
+
langs.map(async (lg) => {
|
|
1679
|
+
try {
|
|
1680
|
+
const j = await getJson(
|
|
1681
|
+
`https://${lg}.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query.trim())}&srlimit=${Math.min(5, count)}&format=json&utf8=1`,
|
|
1682
|
+
4e3
|
|
1683
|
+
);
|
|
1684
|
+
return (j?.query?.search || []).map((r) => ({
|
|
1685
|
+
url: `https://${lg}.wikipedia.org/wiki/${encodeURIComponent(String(r.title).replace(/ /g, "_"))}`,
|
|
1686
|
+
title: String(r.title),
|
|
1687
|
+
snippet: stripTags(String(r.snippet || r.title)),
|
|
1688
|
+
engine: "wikipedia"
|
|
1689
|
+
}));
|
|
1690
|
+
} catch {
|
|
1691
|
+
return [];
|
|
1692
|
+
}
|
|
1693
|
+
})
|
|
1694
|
+
);
|
|
1695
|
+
return lists.flat().slice(0, count);
|
|
1696
|
+
}
|
|
1697
|
+
async function raceUntilEnough(tasks, enough, deadlineMs = 4500, graceMs = 350) {
|
|
1698
|
+
const collected = tasks.map(() => []);
|
|
1699
|
+
return new Promise((resolve) => {
|
|
1700
|
+
let done = false;
|
|
1701
|
+
let graceTimer = null;
|
|
1702
|
+
let pending2 = tasks.length;
|
|
1703
|
+
const finish = () => {
|
|
1704
|
+
if (done) return;
|
|
1705
|
+
done = true;
|
|
1706
|
+
clearTimeout(deadline);
|
|
1707
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
1708
|
+
resolve(collected.map((c) => c.slice()));
|
|
1709
|
+
};
|
|
1710
|
+
const deadline = setTimeout(finish, deadlineMs);
|
|
1711
|
+
tasks.forEach((t, i) => {
|
|
1712
|
+
t.then((r) => {
|
|
1713
|
+
collected[i] = r;
|
|
1714
|
+
}).catch(() => {
|
|
1715
|
+
}).finally(() => {
|
|
1716
|
+
pending2--;
|
|
1717
|
+
if (pending2 === 0) return finish();
|
|
1718
|
+
if (!done && !graceTimer && enough(collected)) graceTimer = setTimeout(finish, graceMs);
|
|
1719
|
+
});
|
|
1720
|
+
});
|
|
1721
|
+
if (tasks.length === 0) finish();
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
var STOP = /* @__PURE__ */ new Set(["a", "an", "the", "of", "and", "or", "in", "on", "for", "to", "with", "image", "images", "photo", "photos", "picture", "pictures", "pic", "anh", "hinh"]);
|
|
1725
|
+
function norm(s) {
|
|
1726
|
+
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0111/g, "d").replace(/\u0110/g, "D").toLowerCase();
|
|
1727
|
+
}
|
|
1728
|
+
function matchesAllKeywords(query, text) {
|
|
1729
|
+
const toks = norm(query).split(/[^a-z0-9\u0080-\uffff]+/).filter((t) => t && !STOP.has(t));
|
|
1730
|
+
if (toks.length === 0) return false;
|
|
1731
|
+
const hay = norm(text);
|
|
1732
|
+
return toks.every((t) => {
|
|
1733
|
+
if (/[^\x00-\x7f]/.test(t)) return hay.includes(t);
|
|
1734
|
+
return new RegExp(`(^|[^a-z0-9])${t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(s|es)?($|[^a-z0-9])`).test(hay);
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
async function imagesSearxng(baseUrl, query, count) {
|
|
1738
|
+
if (!baseUrl) return [];
|
|
1739
|
+
const params = new URLSearchParams({ q: query.trim(), format: "json", categories: "images", pageno: "1" });
|
|
1740
|
+
const j = await getJson(`${baseUrl.replace(/\/$/, "")}/search?${params}`, 4e3);
|
|
1741
|
+
const out = [];
|
|
1742
|
+
for (const x of Array.isArray(j.results) ? j.results : []) {
|
|
1743
|
+
const src = String(x.img_src || "");
|
|
1744
|
+
if (!/^https?:\/\//i.test(src)) continue;
|
|
1745
|
+
out.push({ title: String(x.title || x.content || query), src, thumb: String(x.thumbnail_src || x.thumbnail || src), page: String(x.url || src), engine: "searxng" });
|
|
1746
|
+
if (out.length >= count * 2) break;
|
|
1747
|
+
}
|
|
1748
|
+
return out;
|
|
1749
|
+
}
|
|
1750
|
+
async function imagesBing(query, count) {
|
|
1751
|
+
const html = await getText(
|
|
1752
|
+
`https://www.bing.com/images/async?q=${encodeURIComponent(query.trim())}&first=0&count=${Math.min(35, count * 3)}&mmasync=1&setlang=${encodeURIComponent(LOCALE)}`,
|
|
1753
|
+
6e3
|
|
1754
|
+
);
|
|
1755
|
+
const out = [];
|
|
1756
|
+
const tagRe = /<a\b[^>]*\bclass="[^"]*\biusc\b[^"]*"[^>]*>/gi;
|
|
1757
|
+
let t;
|
|
1758
|
+
while ((t = tagRe.exec(html)) !== null && out.length < count * 2) {
|
|
1759
|
+
const mm = t[0].match(/\bm="([^"]*)"/) || t[0].match(/\bm='([^']*)'/);
|
|
1760
|
+
if (!mm) continue;
|
|
1761
|
+
try {
|
|
1762
|
+
const j = JSON.parse(decodeEntities(mm[1]));
|
|
1763
|
+
const src = String(j.murl || "");
|
|
1764
|
+
if (!/^https?:\/\//i.test(src)) continue;
|
|
1765
|
+
out.push({ title: decodeEntities(String(j.t || j.desc || query)).replace(/<[^>]*>/g, ""), src, thumb: String(j.turl || src), page: String(j.purl || src), engine: "bing-img" });
|
|
1766
|
+
} catch {
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return out;
|
|
1770
|
+
}
|
|
1771
|
+
async function imagesWikimedia(query, count) {
|
|
1772
|
+
const params = new URLSearchParams({
|
|
1773
|
+
action: "query",
|
|
1774
|
+
format: "json",
|
|
1775
|
+
generator: "search",
|
|
1776
|
+
gsrnamespace: "6",
|
|
1777
|
+
gsrlimit: String(Math.min(20, count * 2)),
|
|
1778
|
+
gsrsearch: query.trim(),
|
|
1779
|
+
prop: "imageinfo",
|
|
1780
|
+
iiprop: "url|mime",
|
|
1781
|
+
iiurlwidth: "480",
|
|
1782
|
+
origin: "*"
|
|
1783
|
+
});
|
|
1784
|
+
const j = await getJson(`https://commons.wikimedia.org/w/api.php?${params}`, 5e3);
|
|
1785
|
+
const pages = Object.values(j?.query?.pages || {});
|
|
1786
|
+
pages.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
1787
|
+
const out = [];
|
|
1788
|
+
for (const p of pages) {
|
|
1789
|
+
const ii = p.imageinfo?.[0];
|
|
1790
|
+
if (!ii || !/^image\/(jpeg|png|webp|gif)$/.test(ii.mime || "")) continue;
|
|
1791
|
+
if (!matchesAllKeywords(query, String(p.title || "").replace(/^File:/, ""))) continue;
|
|
1792
|
+
out.push({ title: String(p.title || query).replace(/^File:/, ""), src: ii.url, thumb: ii.thumburl || ii.url, page: ii.descriptionurl || ii.url, engine: "wikimedia" });
|
|
1793
|
+
}
|
|
1794
|
+
return out;
|
|
1795
|
+
}
|
|
1796
|
+
async function imagesOpenverse(query, count) {
|
|
1797
|
+
const j = await getJson(`https://api.openverse.org/v1/images/?q=${encodeURIComponent(query.trim())}&page_size=${Math.min(20, count * 2)}`, 6e3);
|
|
1798
|
+
const out = [];
|
|
1799
|
+
for (const x of Array.isArray(j.results) ? j.results : []) {
|
|
1800
|
+
const src = String(x.url || "");
|
|
1801
|
+
if (!/^https?:\/\//i.test(src)) continue;
|
|
1802
|
+
const text = `${x.title || ""} ${(Array.isArray(x.tags) ? x.tags : []).map((t) => t?.name || "").join(" ")}`;
|
|
1803
|
+
if (!matchesAllKeywords(query, text)) continue;
|
|
1804
|
+
out.push({ title: String(x.title || query), src, thumb: String(x.thumbnail || src), page: String(x.foreign_landing_url || src), engine: "openverse" });
|
|
1805
|
+
}
|
|
1806
|
+
return out;
|
|
1807
|
+
}
|
|
1808
|
+
async function searchImages(query, count, searxUrl = "") {
|
|
1809
|
+
const key = `img:${LOCALE}:${searxUrl}:${query.toLowerCase().trim()}:${count}`;
|
|
1810
|
+
return cached(
|
|
1811
|
+
key,
|
|
1812
|
+
10 * 60 * 1e3,
|
|
1813
|
+
async () => {
|
|
1814
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1815
|
+
const merged = [];
|
|
1816
|
+
const push = (list) => {
|
|
1817
|
+
for (const h of list) {
|
|
1818
|
+
const k = h.src.split("?")[0].toLowerCase();
|
|
1819
|
+
if (seen.has(k)) continue;
|
|
1820
|
+
seen.add(k);
|
|
1821
|
+
merged.push(h);
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
const lists = await raceUntilEnough(
|
|
1825
|
+
[
|
|
1826
|
+
searxUrl ? runEngine("img:searxng", () => imagesSearxng(searxUrl, query, count), 4e3) : Promise.resolve([]),
|
|
1827
|
+
runEngine("img:bing", () => imagesBing(query, count), 4e3),
|
|
1828
|
+
runEngine("img:wikimedia", () => imagesWikimedia(query, count), 4e3),
|
|
1829
|
+
runEngine("img:openverse", () => imagesOpenverse(query, count), 4e3)
|
|
1830
|
+
],
|
|
1831
|
+
(c) => c.reduce((n, a) => n + a.length, 0) >= count,
|
|
1832
|
+
4500
|
|
1833
|
+
);
|
|
1834
|
+
const main = [lists[0], lists[1]];
|
|
1835
|
+
const maxLen = Math.max(...main.map((a) => a.length), 0);
|
|
1836
|
+
for (let i = 0; i < maxLen; i++) for (const a of main) if (a[i]) push([a[i]]);
|
|
1837
|
+
push(lists[2]);
|
|
1838
|
+
push(lists[3]);
|
|
1839
|
+
return merged.slice(0, count);
|
|
1840
|
+
},
|
|
1841
|
+
(v) => v.length > 0
|
|
1842
|
+
);
|
|
411
1843
|
}
|
|
1844
|
+
|
|
1845
|
+
// server.ts
|
|
1846
|
+
var import_stdio = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
1847
|
+
var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
1848
|
+
var import_sse = require("@modelcontextprotocol/sdk/client/sse.js");
|
|
1849
|
+
import_dotenv.default.config();
|
|
412
1850
|
var DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
413
1851
|
var BLOCKED_HOST_SUFFIXES = [
|
|
414
1852
|
"facebook.com",
|
|
@@ -544,68 +1982,6 @@ function stripInnerTags(str) {
|
|
|
544
1982
|
if (!str) return "";
|
|
545
1983
|
return str.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
546
1984
|
}
|
|
547
|
-
async function searchDuckDuckGo(query, count) {
|
|
548
|
-
const results = [];
|
|
549
|
-
try {
|
|
550
|
-
const q = encodeURIComponent(query.trim());
|
|
551
|
-
const resp = await fetch(`https://html.duckduckgo.com/html/?q=${q}&kl=wt-wt`, {
|
|
552
|
-
headers: {
|
|
553
|
-
"User-Agent": DESKTOP_UA,
|
|
554
|
-
"Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
|
|
555
|
-
}
|
|
556
|
-
});
|
|
557
|
-
if (!resp.ok) return results;
|
|
558
|
-
const html = await resp.text();
|
|
559
|
-
const resultRegex = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
560
|
-
let m;
|
|
561
|
-
while ((m = resultRegex.exec(html)) !== null && results.length < count * 2) {
|
|
562
|
-
let url = m[1];
|
|
563
|
-
if (url.startsWith("//duckduckgo.com/l/")) {
|
|
564
|
-
const uddgMatch = url.match(/[?&]uddg=([^&]+)/);
|
|
565
|
-
if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
|
|
566
|
-
} else if (url.startsWith("//")) {
|
|
567
|
-
url = "https:" + url;
|
|
568
|
-
}
|
|
569
|
-
if (!url.startsWith("http")) continue;
|
|
570
|
-
const title = decodeHtmlEntities(stripInnerTags(m[2]));
|
|
571
|
-
const snippet = decodeHtmlEntities(stripInnerTags(m[3])) || title;
|
|
572
|
-
if (!title && !snippet) continue;
|
|
573
|
-
results.push({ url, title, snippet, engine: "duckduckgo" });
|
|
574
|
-
}
|
|
575
|
-
} catch (err) {
|
|
576
|
-
console.warn("DuckDuckGo search error:", err);
|
|
577
|
-
}
|
|
578
|
-
return results;
|
|
579
|
-
}
|
|
580
|
-
async function searchBing(query, count) {
|
|
581
|
-
const results = [];
|
|
582
|
-
try {
|
|
583
|
-
const q = encodeURIComponent(query.trim());
|
|
584
|
-
const resp = await fetch(`https://www.bing.com/search?q=${q}&count=${Math.min(30, count * 3)}&setlang=en-US`, {
|
|
585
|
-
headers: {
|
|
586
|
-
"User-Agent": DESKTOP_UA,
|
|
587
|
-
"Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
|
|
588
|
-
}
|
|
589
|
-
});
|
|
590
|
-
if (!resp.ok) return results;
|
|
591
|
-
const html = await resp.text();
|
|
592
|
-
const resultRegex = /<li class="b_algo"[^>]*>[\s\S]*?<h2>[\s\S]*?<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>([\s\S]*?)<\/li>/gi;
|
|
593
|
-
let m;
|
|
594
|
-
while ((m = resultRegex.exec(html)) !== null && results.length < count * 2) {
|
|
595
|
-
const url = m[1];
|
|
596
|
-
if (!url.startsWith("http")) continue;
|
|
597
|
-
const title = decodeHtmlEntities(stripInnerTags(m[2]));
|
|
598
|
-
const bodyBlock = m[3] || "";
|
|
599
|
-
const snippetMatch = bodyBlock.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || bodyBlock.match(/<div class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
|
|
600
|
-
const snippet = snippetMatch ? decodeHtmlEntities(stripInnerTags(snippetMatch[1])) : title;
|
|
601
|
-
if (!title && !snippet) continue;
|
|
602
|
-
results.push({ url, title, snippet, engine: "bing" });
|
|
603
|
-
}
|
|
604
|
-
} catch (err) {
|
|
605
|
-
console.warn("Bing search error:", err);
|
|
606
|
-
}
|
|
607
|
-
return results;
|
|
608
|
-
}
|
|
609
1985
|
async function searchYahooJP(query, count) {
|
|
610
1986
|
const results = [];
|
|
611
1987
|
const q = encodeURIComponent(query.trim());
|
|
@@ -614,28 +1990,33 @@ async function searchYahooJP(query, count) {
|
|
|
614
1990
|
headers: {
|
|
615
1991
|
"User-Agent": DESKTOP_UA,
|
|
616
1992
|
"Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
|
|
617
|
-
}
|
|
1993
|
+
},
|
|
1994
|
+
signal: AbortSignal.timeout(5e3)
|
|
618
1995
|
});
|
|
619
1996
|
if (response.ok) {
|
|
620
1997
|
const html = await response.text();
|
|
621
|
-
const
|
|
1998
|
+
const cardRegex = /<a\s+[^>]*href="([^"]+)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)<\/h3>[\s\S]*?(?:<(?:p|div)[^>]*class="[^"]*(?:summary|Snippet|desc|text)[^"]*"[^>]*>([\s\S]*?)<\/(?:p|div)>)?/gi;
|
|
622
1999
|
let match;
|
|
623
|
-
|
|
2000
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2001
|
+
while ((match = cardRegex.exec(html)) !== null && results.length < count) {
|
|
624
2002
|
const url = match[1];
|
|
625
|
-
if (url.startsWith("http")
|
|
626
|
-
|
|
627
|
-
|
|
2003
|
+
if (!url || !url.startsWith("http") || url.includes("search.yahoo.co.jp") || url === "https://www.yahoo.co.jp" || url === "https://www.yahoo.co.jp/") continue;
|
|
2004
|
+
if (seen.has(url)) continue;
|
|
2005
|
+
seen.add(url);
|
|
2006
|
+
const title = decodeHtmlEntities(stripInnerTags(match[2]));
|
|
2007
|
+
const snippet = match[3] ? decodeHtmlEntities(stripInnerTags(match[3])) : title;
|
|
2008
|
+
if (title) {
|
|
628
2009
|
results.push({ url, title, snippet, engine: "yahoo" });
|
|
629
2010
|
}
|
|
630
2011
|
}
|
|
631
2012
|
}
|
|
632
|
-
} catch (
|
|
633
|
-
console.warn("Yahoo search primary endpoint error:", err);
|
|
2013
|
+
} catch (_err) {
|
|
634
2014
|
}
|
|
635
2015
|
if (results.length === 0) {
|
|
636
2016
|
try {
|
|
637
2017
|
const newsRes = await fetch(`https://news.search.yahoo.com/search?p=${q}`, {
|
|
638
|
-
headers: { "User-Agent": DESKTOP_UA }
|
|
2018
|
+
headers: { "User-Agent": DESKTOP_UA },
|
|
2019
|
+
signal: AbortSignal.timeout(5e3)
|
|
639
2020
|
});
|
|
640
2021
|
if (newsRes.ok) {
|
|
641
2022
|
const newsHtml = await newsRes.text();
|
|
@@ -653,22 +2034,11 @@ async function searchYahooJP(query, count) {
|
|
|
653
2034
|
}
|
|
654
2035
|
}
|
|
655
2036
|
}
|
|
656
|
-
} catch (
|
|
657
|
-
console.warn("Yahoo news fallback error:", err);
|
|
2037
|
+
} catch (_err) {
|
|
658
2038
|
}
|
|
659
2039
|
}
|
|
660
2040
|
return results;
|
|
661
2041
|
}
|
|
662
|
-
function interleaveResults(arrays) {
|
|
663
|
-
const out = [];
|
|
664
|
-
const maxLen = Math.max(0, ...arrays.map((a) => a.length));
|
|
665
|
-
for (let i = 0; i < maxLen; i++) {
|
|
666
|
-
for (const arr of arrays) {
|
|
667
|
-
if (arr[i]) out.push(arr[i]);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
return out;
|
|
671
|
-
}
|
|
672
2042
|
function normalizeForDedupe(url) {
|
|
673
2043
|
try {
|
|
674
2044
|
const u = new URL(url);
|
|
@@ -691,12 +2061,12 @@ function mergeAndFilterResults(all, count) {
|
|
|
691
2061
|
}
|
|
692
2062
|
if (isBlockedHost(hostname)) continue;
|
|
693
2063
|
if (looksLikeNonHtmlFile(r.url)) continue;
|
|
694
|
-
const
|
|
695
|
-
if (seen.has(
|
|
2064
|
+
const dedupeKey2 = normalizeForDedupe(r.url);
|
|
2065
|
+
if (seen.has(dedupeKey2)) continue;
|
|
696
2066
|
const domainKey = hostname.replace(/^www\./, "");
|
|
697
2067
|
const domainCount = perDomainCount.get(domainKey) || 0;
|
|
698
2068
|
if (domainCount >= 2) continue;
|
|
699
|
-
seen.add(
|
|
2069
|
+
seen.add(dedupeKey2);
|
|
700
2070
|
perDomainCount.set(domainKey, domainCount + 1);
|
|
701
2071
|
filtered.push({ ...r, url: applyDomainRewrite(r.url), score: reputationScore(hostname) });
|
|
702
2072
|
}
|
|
@@ -706,7 +2076,106 @@ function mergeAndFilterResults(all, count) {
|
|
|
706
2076
|
snippet: r.snippet && r.snippet.trim() || r.title || r.url
|
|
707
2077
|
}));
|
|
708
2078
|
}
|
|
709
|
-
var MAX_FETCH_BYTES =
|
|
2079
|
+
var MAX_FETCH_BYTES = Number(process.env.FANLUC_MAX_FETCH_MB) > 0 ? Number(process.env.FANLUC_MAX_FETCH_MB) * 1024 * 1024 : 30 * 1024 * 1024;
|
|
2080
|
+
var DOCS_VIRTUAL_HOST = "fanluc.docs";
|
|
2081
|
+
var DOCS_VIRTUAL_URL = `https://${DOCS_VIRTUAL_HOST}/`;
|
|
2082
|
+
var MONDK_MSH_VIRTUAL_HOST = "mondk-msh.docs";
|
|
2083
|
+
var MONDK_MSH_VIRTUAL_URL = `https://${MONDK_MSH_VIRTUAL_HOST}/`;
|
|
2084
|
+
var DOCS_MAX_CHARS = 4e4;
|
|
2085
|
+
var VIRTUAL_DOCS_SITES = [
|
|
2086
|
+
{
|
|
2087
|
+
host: DOCS_VIRTUAL_HOST,
|
|
2088
|
+
url: DOCS_VIRTUAL_URL,
|
|
2089
|
+
trigger: /fanluc/i,
|
|
2090
|
+
snippet: "Official FANLUC docs (detailed interface/UI description, creator mondk, msh, connection guide) - fetch this URL to read them in full."
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
host: MONDK_MSH_VIRTUAL_HOST,
|
|
2094
|
+
url: MONDK_MSH_VIRTUAL_URL,
|
|
2095
|
+
trigger: /\b(?:mondk|msh)\b/i,
|
|
2096
|
+
snippet: "Official docs about the author mondk and the msh project (docs/mondk.md and docs/msh.md) - fetch this URL to read them in full."
|
|
2097
|
+
}
|
|
2098
|
+
];
|
|
2099
|
+
function injectDocsResult(query, results, maxCount) {
|
|
2100
|
+
const sites = VIRTUAL_DOCS_SITES.filter((site) => site.trigger.test(query || ""));
|
|
2101
|
+
if (sites.length === 0) return results;
|
|
2102
|
+
const rest = results.filter((r) => !sites.some((site) => r.url.toLowerCase().includes(site.host)));
|
|
2103
|
+
const keepCount = Math.max(1, maxCount - sites.length);
|
|
2104
|
+
const kept = rest.length > keepCount && maxCount > sites.length ? rest.slice(0, keepCount) : rest;
|
|
2105
|
+
return [...sites.map((site) => ({ url: site.url, snippet: site.snippet })), ...kept];
|
|
2106
|
+
}
|
|
2107
|
+
async function readDocsFile(name) {
|
|
2108
|
+
const docsDir = import_path3.default.join(process.cwd(), "docs");
|
|
2109
|
+
let files = [];
|
|
2110
|
+
try {
|
|
2111
|
+
files = (await import_fs3.default.promises.readdir(docsDir)).filter((f) => /\.(md|txt)$/i.test(f));
|
|
2112
|
+
} catch {
|
|
2113
|
+
return null;
|
|
2114
|
+
}
|
|
2115
|
+
const file = files.find((f) => f.toLowerCase() === name || f.toLowerCase() === `${name}.md`) || files.find((f) => f.toLowerCase().includes(name));
|
|
2116
|
+
if (!file) return null;
|
|
2117
|
+
return { file, text: await import_fs3.default.promises.readFile(import_path3.default.join(docsDir, file), "utf-8") };
|
|
2118
|
+
}
|
|
2119
|
+
async function readMondkMshPage(pathname) {
|
|
2120
|
+
let name = "";
|
|
2121
|
+
try {
|
|
2122
|
+
name = decodeURIComponent(pathname || "");
|
|
2123
|
+
} catch {
|
|
2124
|
+
name = pathname || "";
|
|
2125
|
+
}
|
|
2126
|
+
name = name.replace(/^\/+|\/+$/g, "").toLowerCase().replace(/\.md$/, "");
|
|
2127
|
+
const pages = ["mondk", "msh"];
|
|
2128
|
+
if (name && name !== "index") {
|
|
2129
|
+
if (!pages.includes(name)) {
|
|
2130
|
+
return {
|
|
2131
|
+
title: "Not found",
|
|
2132
|
+
text: `Page '${name}' not found. Available: ${pages.map((n) => MONDK_MSH_VIRTUAL_URL + n).join(", ")}`
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
const doc = await readDocsFile(name);
|
|
2136
|
+
if (!doc) return { title: "Not found", text: `docs/${name}.md was not found on this server.` };
|
|
2137
|
+
return { title: doc.file, text: doc.text.slice(0, DOCS_MAX_CHARS) };
|
|
2138
|
+
}
|
|
2139
|
+
const parts = [];
|
|
2140
|
+
for (const n of pages) {
|
|
2141
|
+
const doc = await readDocsFile(n);
|
|
2142
|
+
parts.push(`=== docs/${doc ? doc.file : n + ".md"} ===
|
|
2143
|
+
${doc ? doc.text.trim() : "(file not found)"}`);
|
|
2144
|
+
}
|
|
2145
|
+
return { title: "MONDK and MSH docs", text: parts.join("\n\n").slice(0, DOCS_MAX_CHARS) };
|
|
2146
|
+
}
|
|
2147
|
+
async function readVirtualDocsPage(pathname) {
|
|
2148
|
+
const docsDir = import_path3.default.join(process.cwd(), "docs");
|
|
2149
|
+
let files = [];
|
|
2150
|
+
try {
|
|
2151
|
+
files = (await import_fs3.default.promises.readdir(docsDir)).filter((f) => /\.(md|txt)$/i.test(f));
|
|
2152
|
+
} catch {
|
|
2153
|
+
}
|
|
2154
|
+
let name = "";
|
|
2155
|
+
try {
|
|
2156
|
+
name = decodeURIComponent(pathname || "");
|
|
2157
|
+
} catch {
|
|
2158
|
+
name = pathname || "";
|
|
2159
|
+
}
|
|
2160
|
+
name = name.replace(/^\/+|\/+$/g, "").toLowerCase().replace(/\.md$/, "");
|
|
2161
|
+
const find = (n) => files.find((f) => f.toLowerCase() === n || f.toLowerCase() === `${n}.md`) || files.find((f) => f.toLowerCase().includes(n));
|
|
2162
|
+
const read = (f) => import_fs3.default.promises.readFile(import_path3.default.join(docsDir, f), "utf-8");
|
|
2163
|
+
if (!name || name === "index") {
|
|
2164
|
+
const ui = find("fanluc");
|
|
2165
|
+
const pages = ["fanluc", "guide", "mondk", "msh"].filter((n) => find(n));
|
|
2166
|
+
const index = "FANLUC docs index:\n" + pages.map((n) => `- ${DOCS_VIRTUAL_URL}${n === "fanluc" ? "" : n}`).join("\n") + "\n\n";
|
|
2167
|
+
const body = ui ? await read(ui) : "(fanluc ui doc not found)";
|
|
2168
|
+
return { title: "FANLUC docs", text: (index + body).slice(0, DOCS_MAX_CHARS) };
|
|
2169
|
+
}
|
|
2170
|
+
const file = find(name);
|
|
2171
|
+
if (!file) {
|
|
2172
|
+
return {
|
|
2173
|
+
title: "Not found",
|
|
2174
|
+
text: `Page '${name}' not found. Available: ${["fanluc", "guide", "mondk", "msh"].map((n) => DOCS_VIRTUAL_URL + (n === "fanluc" ? "" : n)).join(", ")}`
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
return { title: file, text: (await read(file)).slice(0, DOCS_MAX_CHARS) };
|
|
2178
|
+
}
|
|
710
2179
|
async function fetchWithByteLimit(url, signal, maxBytes) {
|
|
711
2180
|
const response = await fetch(url, {
|
|
712
2181
|
signal,
|
|
@@ -765,20 +2234,19 @@ function extractReadableContent(html, url) {
|
|
|
765
2234
|
var SEARXNG_URL = (process.env.SEARXNG_URL || process.env.SEARXNG || "").replace(/\/$/, "");
|
|
766
2235
|
async function searchSearXNG(query, count, categories = "general", baseUrl = SEARXNG_URL) {
|
|
767
2236
|
const results = [];
|
|
768
|
-
if (!baseUrl) return results;
|
|
2237
|
+
if (!baseUrl || !baseUrl.startsWith("http")) return results;
|
|
769
2238
|
try {
|
|
770
2239
|
const params = new URLSearchParams({ q: query.trim(), format: "json", categories, pageno: "1" });
|
|
771
2240
|
const url = `${baseUrl}/search?${params.toString()}`;
|
|
772
2241
|
const resp = await fetch(url, {
|
|
773
|
-
signal: AbortSignal.timeout(6e3),
|
|
774
2242
|
headers: {
|
|
775
2243
|
"User-Agent": DESKTOP_UA,
|
|
776
2244
|
"Accept": "application/json",
|
|
777
2245
|
"Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
|
|
778
|
-
}
|
|
2246
|
+
},
|
|
2247
|
+
signal: AbortSignal.timeout(3e3)
|
|
779
2248
|
});
|
|
780
2249
|
if (!resp.ok) {
|
|
781
|
-
console.warn(`SearXNG search failed ${resp.status} for "${query}"`);
|
|
782
2250
|
return results;
|
|
783
2251
|
}
|
|
784
2252
|
const j = await resp.json();
|
|
@@ -807,8 +2275,7 @@ async function searchSearXNG(query, count, categories = "general", baseUrl = SEA
|
|
|
807
2275
|
results.push({ url: url2, title, snippet, engine: "searxng" });
|
|
808
2276
|
}
|
|
809
2277
|
}
|
|
810
|
-
} catch (
|
|
811
|
-
console.warn("SearXNG search error:", err);
|
|
2278
|
+
} catch (_err) {
|
|
812
2279
|
}
|
|
813
2280
|
return results;
|
|
814
2281
|
}
|
|
@@ -824,10 +2291,10 @@ function isPrivateIp(ip) {
|
|
|
824
2291
|
async function assertPublicUrl(raw) {
|
|
825
2292
|
const u = new URL(raw);
|
|
826
2293
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("Only http/https URLs are allowed");
|
|
827
|
-
if (process.env.ALLOW_PRIVATE_URLS
|
|
2294
|
+
if (process.env.ALLOW_PRIVATE_URLS !== "0") return u;
|
|
828
2295
|
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
829
2296
|
if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) throw new Error("Private/local hosts are not allowed");
|
|
830
|
-
const addrs = import_node_net.default.isIP(host) ? [{ address: host }] : await
|
|
2297
|
+
const addrs = import_node_net.default.isIP(host) ? [{ address: host }] : await import_promises2.default.lookup(host, { all: true });
|
|
831
2298
|
for (const a of addrs) if (isPrivateIp(a.address)) throw new Error("Private/local hosts are not allowed");
|
|
832
2299
|
return u;
|
|
833
2300
|
}
|
|
@@ -853,7 +2320,11 @@ function mcpAdminGuard(req, res, next) {
|
|
|
853
2320
|
}
|
|
854
2321
|
function resolveEnvVars(str) {
|
|
855
2322
|
if (typeof str !== "string") return str;
|
|
856
|
-
return str.replace(/\$\{([^}]+)\}/g, (_, name) =>
|
|
2323
|
+
return str.replace(/\$\{([^}]+)\}/g, (_, name) => {
|
|
2324
|
+
const n = String(name).trim();
|
|
2325
|
+
if (n === "WORKSPACE") return getWorkspace();
|
|
2326
|
+
return process.env[n] ?? "";
|
|
2327
|
+
});
|
|
857
2328
|
}
|
|
858
2329
|
function resolveEnvObject(obj) {
|
|
859
2330
|
if (!obj || typeof obj !== "object") return obj;
|
|
@@ -879,28 +2350,66 @@ async function connectMcp(name, cfg) {
|
|
|
879
2350
|
const entry = { client: null, tools: [], status: "connecting", config: cfg, type };
|
|
880
2351
|
mcp.set(name, entry);
|
|
881
2352
|
try {
|
|
882
|
-
let
|
|
2353
|
+
let client;
|
|
883
2354
|
if (type === "http") {
|
|
884
2355
|
const rawUrl = cfg.url;
|
|
885
2356
|
if (!rawUrl) throw new Error("Missing url for http/remote MCP server");
|
|
886
|
-
const resolvedUrl = resolveEnvVars(String(rawUrl));
|
|
2357
|
+
const resolvedUrl = new URL(resolveEnvVars(String(rawUrl)));
|
|
887
2358
|
const headers = resolveEnvObject(cfg.headers) || {};
|
|
888
|
-
|
|
2359
|
+
const mk = () => new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
|
|
2360
|
+
try {
|
|
2361
|
+
client = mk();
|
|
2362
|
+
await client.connect(new import_streamableHttp.StreamableHTTPClientTransport(resolvedUrl, { requestInit: { headers } }), { timeout: 3e4 });
|
|
2363
|
+
} catch (e1) {
|
|
2364
|
+
try {
|
|
2365
|
+
client = mk();
|
|
2366
|
+
await client.connect(new import_sse.SSEClientTransport(resolvedUrl, { requestInit: { headers } }), { timeout: 3e4 });
|
|
2367
|
+
} catch {
|
|
2368
|
+
throw e1;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
889
2371
|
} else {
|
|
890
2372
|
let command = cfg.command;
|
|
891
2373
|
let args = Array.isArray(cfg.args) ? cfg.args : [];
|
|
892
|
-
|
|
2374
|
+
const userEnv = resolveEnvObject(cfg.env || cfg.environment) || {};
|
|
893
2375
|
if (Array.isArray(command)) {
|
|
894
2376
|
args = command.slice(1);
|
|
895
2377
|
command = command[0];
|
|
896
2378
|
}
|
|
897
2379
|
if (!command) throw new Error("Missing command for stdio/local MCP server");
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
2380
|
+
let resolvedCommand = resolveEnvVars(String(command)).trim();
|
|
2381
|
+
let resolvedArgs = args.map((a) => resolveEnvVars(String(a)));
|
|
2382
|
+
if (!resolvedArgs.length && /\s/.test(resolvedCommand)) {
|
|
2383
|
+
const parts = resolvedCommand.split(/\s+/);
|
|
2384
|
+
resolvedCommand = parts[0];
|
|
2385
|
+
resolvedArgs = parts.slice(1);
|
|
2386
|
+
}
|
|
2387
|
+
const baseEnv = {};
|
|
2388
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
2389
|
+
if (typeof v === "string" && !["GEMINI_API_KEY", "TOKEN", "ADMIN_TOKEN", "ACCESS_TOKEN"].includes(k)) baseEnv[k] = v;
|
|
2390
|
+
}
|
|
2391
|
+
const cwd = cfg.cwd ? resolveEnvVars(String(cfg.cwd)) : getWorkspace();
|
|
2392
|
+
const transport = new import_stdio.StdioClientTransport({
|
|
2393
|
+
command: resolvedCommand,
|
|
2394
|
+
args: resolvedArgs,
|
|
2395
|
+
env: { ...baseEnv, ...userEnv },
|
|
2396
|
+
cwd,
|
|
2397
|
+
stderr: "pipe"
|
|
2398
|
+
});
|
|
2399
|
+
let errTail = "";
|
|
2400
|
+
transport.stderr?.on?.("data", (d) => {
|
|
2401
|
+
errTail = (errTail + d.toString()).slice(-1500);
|
|
2402
|
+
});
|
|
2403
|
+
client = new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
|
|
2404
|
+
try {
|
|
2405
|
+
await client.connect(transport, { timeout: 12e4 });
|
|
2406
|
+
} catch (e) {
|
|
2407
|
+
const tail = errTail.trim();
|
|
2408
|
+
throw new Error(`${e?.message || e}${tail ? `
|
|
2409
|
+
--- server stderr ---
|
|
2410
|
+
${tail}` : ""}`);
|
|
2411
|
+
}
|
|
901
2412
|
}
|
|
902
|
-
const client = new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
|
|
903
|
-
await client.connect(transport);
|
|
904
2413
|
const toolsRes = await client.listTools();
|
|
905
2414
|
const tools = Array.isArray(toolsRes?.tools) ? toolsRes.tools : [];
|
|
906
2415
|
const truncated = tools.map((t) => ({
|
|
@@ -915,45 +2424,15 @@ async function connectMcp(name, cfg) {
|
|
|
915
2424
|
mcp.set(name, { client: null, tools: [], status: "failed", error: msg, config: cfg, type });
|
|
916
2425
|
}
|
|
917
2426
|
}
|
|
918
|
-
function getFanlucConfigDir() {
|
|
919
|
-
if (process.env.FANLUC_CONFIG_DIR?.trim()) return process.env.FANLUC_CONFIG_DIR.trim();
|
|
920
|
-
if (IS_WINDOWS) {
|
|
921
|
-
const appData = process.env.APPDATA;
|
|
922
|
-
if (appData) return import_path.default.join(appData, "fanluc");
|
|
923
|
-
return import_path.default.join(import_os.default.homedir(), "AppData", "Roaming", "fanluc");
|
|
924
|
-
}
|
|
925
|
-
const xdg = process.env.XDG_CONFIG_HOME;
|
|
926
|
-
if (xdg) return import_path.default.join(xdg, "fanluc");
|
|
927
|
-
return import_path.default.join(import_os.default.homedir(), ".config", "fanluc");
|
|
928
|
-
}
|
|
929
|
-
function mcpConfigPath() {
|
|
930
|
-
const dir = getFanlucConfigDir();
|
|
931
|
-
try {
|
|
932
|
-
import_fs.default.mkdirSync(dir, { recursive: true });
|
|
933
|
-
} catch {
|
|
934
|
-
}
|
|
935
|
-
return import_path.default.join(dir, "mcp.json");
|
|
936
|
-
}
|
|
937
|
-
function readMcpServersFile(mcpPath) {
|
|
938
|
-
if (!import_fs.default.existsSync(mcpPath)) return {};
|
|
939
|
-
const raw = import_fs.default.readFileSync(mcpPath, "utf-8").replace(/^\uFEFF/, "");
|
|
940
|
-
if (!raw.trim()) return {};
|
|
941
|
-
const j = JSON.parse(stripJsonc(raw));
|
|
942
|
-
if (j.mcpServers) return j.mcpServers;
|
|
943
|
-
if (j.mcp && j.mcp.servers) return j.mcp.servers;
|
|
944
|
-
if (j.mcp && typeof j.mcp === "object") return j.mcp;
|
|
945
|
-
return j;
|
|
946
|
-
}
|
|
947
2427
|
function loadMcpConfigSync() {
|
|
948
|
-
const configDir = getFanlucConfigDir();
|
|
949
2428
|
const candidates = [
|
|
950
|
-
|
|
951
|
-
|
|
2429
|
+
import_path3.default.join(process.cwd(), "mcp.json"),
|
|
2430
|
+
import_path3.default.join(process.cwd(), "mcp.jsonc")
|
|
952
2431
|
];
|
|
953
2432
|
for (const p of candidates) {
|
|
954
2433
|
try {
|
|
955
|
-
if (
|
|
956
|
-
const raw =
|
|
2434
|
+
if (import_fs3.default.existsSync(p)) {
|
|
2435
|
+
const raw = import_fs3.default.readFileSync(p, "utf-8");
|
|
957
2436
|
const cleaned = stripJsonc(raw);
|
|
958
2437
|
const j = JSON.parse(cleaned);
|
|
959
2438
|
if (j.mcpServers && typeof j.mcpServers === "object") return j.mcpServers;
|
|
@@ -981,9 +2460,7 @@ async function initMcpFromFile() {
|
|
|
981
2460
|
return;
|
|
982
2461
|
}
|
|
983
2462
|
console.log(`[MCP] loading ${entries.length} server(s) from mcp.json`);
|
|
984
|
-
|
|
985
|
-
await connectMcp(name, cfg);
|
|
986
|
-
}
|
|
2463
|
+
await Promise.all(entries.map(([name, cfg]) => connectMcp(name, cfg)));
|
|
987
2464
|
}
|
|
988
2465
|
function getMcpToolsForModel() {
|
|
989
2466
|
const out = [];
|
|
@@ -1003,18 +2480,26 @@ function getMcpToolsForModel() {
|
|
|
1003
2480
|
}
|
|
1004
2481
|
async function startServer() {
|
|
1005
2482
|
const app = (0, import_express.default)();
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
2483
|
+
const portArgIdx = process.argv.indexOf("--port");
|
|
2484
|
+
const cliPort = portArgIdx !== -1 && process.argv[portArgIdx + 1] ? Number(process.argv[portArgIdx + 1]) : null;
|
|
2485
|
+
const PORT = cliPort || (process.env.APP_PORT ? Number(process.env.APP_PORT) : 3e3);
|
|
2486
|
+
app.use(import_express.default.json({ limit: "2gb" }));
|
|
2487
|
+
app.use("/api", (req, res, next) => req.path === "/health" ? next() : requireLocal(req, res, next));
|
|
2488
|
+
onWorkspaceChange(() => {
|
|
2489
|
+
for (const [name, entry] of mcp.entries()) {
|
|
2490
|
+
if (entry.status === "disabled") continue;
|
|
2491
|
+
if (/colab-mcp|StudioMCP|Roblox.mcp\.bat/i.test(JSON.stringify(entry.config || {}))) continue;
|
|
2492
|
+
if (JSON.stringify(entry.config || {}).includes("${WORKSPACE}") || entry.type === "stdio" && !entry.config?.cwd) {
|
|
2493
|
+
const old = entry.client;
|
|
2494
|
+
connectMcp(name, entry.config).finally(() => {
|
|
2495
|
+
try {
|
|
2496
|
+
old?.close();
|
|
2497
|
+
} catch {
|
|
2498
|
+
}
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
1014
2501
|
}
|
|
1015
|
-
}
|
|
1016
|
-
const PORT = Number(process.env.PORT) || 3e3;
|
|
1017
|
-
app.use(import_express.default.json({ limit: "50mb" }));
|
|
2502
|
+
});
|
|
1018
2503
|
initMcpFromFile().catch((e) => console.warn("[MCP] init failed:", e));
|
|
1019
2504
|
app.post("/api/proxy-chat", async (req, res) => {
|
|
1020
2505
|
try {
|
|
@@ -1025,7 +2510,7 @@ async function startServer() {
|
|
|
1025
2510
|
const headers = {
|
|
1026
2511
|
"Content-Type": "application/json",
|
|
1027
2512
|
"ngrok-skip-browser-warning": "true",
|
|
1028
|
-
"User-Agent": "
|
|
2513
|
+
"User-Agent": "fanluc/2.4.0",
|
|
1029
2514
|
"Authorization": `Bearer ${apiKey || ""}`,
|
|
1030
2515
|
...customHeaders || {}
|
|
1031
2516
|
};
|
|
@@ -1084,50 +2569,45 @@ async function startServer() {
|
|
|
1084
2569
|
return res.status(400).json({ error: "Missing query parameter" });
|
|
1085
2570
|
}
|
|
1086
2571
|
const maxCount = Math.min(15, Math.max(1, parseInt(count) || 7));
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
maxCount
|
|
1112
|
-
);
|
|
1113
|
-
if (results.length === 0) {
|
|
1114
|
-
const rawAll = [...ddgResults, ...bingResults, ...yahooResults];
|
|
1115
|
-
results = rawAll.slice(0, maxCount).map((r) => ({ url: r.url, snippet: r.snippet || r.title || r.url }));
|
|
1116
|
-
}
|
|
1117
|
-
const formattedText = results.length > 0 ? `${results.length} results:
|
|
2572
|
+
const searxUrl = await resolveSearxUrl(req);
|
|
2573
|
+
const key = `web:${LOCALE}:${searxUrl}:${query.toLowerCase().trim()}:${maxCount}`;
|
|
2574
|
+
const payload = await cached(
|
|
2575
|
+
key,
|
|
2576
|
+
10 * 60 * 1e3,
|
|
2577
|
+
async () => {
|
|
2578
|
+
const per = Math.min(20, maxCount + 6);
|
|
2579
|
+
const lists = await raceUntilEnough(
|
|
2580
|
+
[
|
|
2581
|
+
searxUrl ? runEngine("searxng", () => searchSearXNG(query, per, "general", searxUrl), 4e3) : Promise.resolve([]),
|
|
2582
|
+
runEngine("bing", () => searchBingLocal(query, per), 4e3),
|
|
2583
|
+
runEngine("mojeek", () => searchMojeek(query, per), 4e3),
|
|
2584
|
+
runEngine("wikipedia", () => searchWikipedia(query, 5), 4e3),
|
|
2585
|
+
runEngine("yahoo", () => searchYahooJP(query, per), 4e3)
|
|
2586
|
+
],
|
|
2587
|
+
(c) => c.reduce((n, a) => n + a.length, 0) >= maxCount,
|
|
2588
|
+
4500
|
|
2589
|
+
);
|
|
2590
|
+
const fused = fuseResults(lists);
|
|
2591
|
+
let results = mergeAndFilterResults(fused, maxCount);
|
|
2592
|
+
if (results.length === 0) results = fused.slice(0, maxCount).map((r) => ({ url: r.url, snippet: r.snippet || r.title || r.url }));
|
|
2593
|
+
results = injectDocsResult(query, results, maxCount);
|
|
2594
|
+
const names = ["searxng", "bing", "mojeek", "wikipedia", "yahoo"].filter((_, i) => lists[i].length > 0);
|
|
2595
|
+
const formattedText = results.length > 0 ? `${results.length} results:
|
|
1118
2596
|
` + results.map((r) => `${r.url}: ${r.snippet}`).join("\n") : `0 results:
|
|
1119
2597
|
(no results found for "${query}")`;
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
});
|
|
2598
|
+
return { count: results.length, results, formattedText, engine: names.join("+") || "none" };
|
|
2599
|
+
},
|
|
2600
|
+
(v) => v.count > 0
|
|
2601
|
+
);
|
|
2602
|
+
return res.json(payload);
|
|
1126
2603
|
} catch (error) {
|
|
1127
2604
|
console.error("Web search error:", error);
|
|
1128
2605
|
return res.status(500).json({ error: error?.message || "Web search failed" });
|
|
1129
2606
|
}
|
|
1130
2607
|
});
|
|
2608
|
+
app.get("/api/search-status", (_req, res) => {
|
|
2609
|
+
res.json({ locale: LOCALE, searxng: SEARXNG_URL || null, engines: engineStatus() });
|
|
2610
|
+
});
|
|
1131
2611
|
app.post("/api/searxng-search", async (req, res) => {
|
|
1132
2612
|
try {
|
|
1133
2613
|
const { query, count = 7, categories = "general" } = req.body;
|
|
@@ -1162,20 +2642,9 @@ async function startServer() {
|
|
|
1162
2642
|
const { query, count = 6 } = req.body;
|
|
1163
2643
|
if (!query || typeof query !== "string") return res.status(400).json({ error: "Missing query" });
|
|
1164
2644
|
const maxCount = Math.min(12, Math.max(1, parseInt(count) || 6));
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
src: r._img_src || r.url,
|
|
1169
|
-
thumb: r._thumb || r._img_src || r.url,
|
|
1170
|
-
page: r.url
|
|
1171
|
-
})).filter((x) => x.src && x.src.startsWith("http"));
|
|
1172
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1173
|
-
images = images.filter((x) => {
|
|
1174
|
-
if (seen.has(x.src)) return false;
|
|
1175
|
-
seen.add(x.src);
|
|
1176
|
-
return true;
|
|
1177
|
-
});
|
|
1178
|
-
const formattedText = images.length ? `\u0110\xE3 hi\u1EC3n th\u1ECB ${images.length} \u1EA3nh v\u1EC1 "${query}"` : `Kh\xF4ng t\xECm th\u1EA5y \u1EA3nh cho "${query}"`;
|
|
2645
|
+
const hits = await searchImages(query, maxCount, await resolveSearxUrl(req));
|
|
2646
|
+
const images = hits.map((h) => ({ title: h.title || query, src: h.src, thumb: h.thumb, page: h.page }));
|
|
2647
|
+
const formattedText = images.length ? `Showing ${images.length} images for "${query}"` : `No images found for "${query}"`;
|
|
1179
2648
|
return res.json({ count: images.length, images, formattedText });
|
|
1180
2649
|
} catch (e) {
|
|
1181
2650
|
console.error("Image search error:", e);
|
|
@@ -1193,7 +2662,7 @@ async function startServer() {
|
|
|
1193
2662
|
for (let hop = 0; hop < 4; hop++) {
|
|
1194
2663
|
const h = new URL(cur).hostname;
|
|
1195
2664
|
if (isBlockedHost(h)) return res.status(403).send("Blocked host");
|
|
1196
|
-
resp = await fetch(cur, { redirect: "manual", headers: { "User-Agent": DESKTOP_UA, "Accept": "image/*,*/*" } });
|
|
2665
|
+
resp = await fetch(cur, { redirect: "manual", headers: { "User-Agent": DESKTOP_UA, "Accept": "image/*,*/*", "Referer": new URL(cur).origin + "/" } });
|
|
1197
2666
|
const loc = resp.headers.get("location");
|
|
1198
2667
|
if (resp.status >= 300 && resp.status < 400 && loc) {
|
|
1199
2668
|
cur = new URL(loc, cur).toString();
|
|
@@ -1234,7 +2703,11 @@ async function startServer() {
|
|
|
1234
2703
|
// connected / failed / disabled / connecting
|
|
1235
2704
|
tools: v.tools,
|
|
1236
2705
|
toolCount: v.tools.length,
|
|
1237
|
-
error: v.error || null
|
|
2706
|
+
error: v.error || null,
|
|
2707
|
+
command: v.config?.command || "",
|
|
2708
|
+
args: Array.isArray(v.config?.args) ? v.config.args.join(" ") : v.config?.args || "",
|
|
2709
|
+
url: v.config?.url || "",
|
|
2710
|
+
headers: v.config?.headers || void 0
|
|
1238
2711
|
}));
|
|
1239
2712
|
const modelTools = getMcpToolsForModel();
|
|
1240
2713
|
res.json({ servers: list, modelTools, count: list.length });
|
|
@@ -1252,7 +2725,19 @@ async function startServer() {
|
|
|
1252
2725
|
if (!entry) return res.status(404).json({ error: `MCP server "${server}" not found` });
|
|
1253
2726
|
if (entry.status !== "connected" || !entry.client) return res.status(400).json({ error: `MCP server "${server}" not connected (${entry.status})${entry.error ? ": " + entry.error : ""}` });
|
|
1254
2727
|
const actualTool = toolName.includes("_") && toolName.startsWith(server + "_") ? toolName.slice(server.length + 1) : toolName;
|
|
1255
|
-
const
|
|
2728
|
+
const callOpts = { timeout: 18e4 };
|
|
2729
|
+
let result;
|
|
2730
|
+
try {
|
|
2731
|
+
result = await entry.client.callTool({ name: actualTool, arguments: toolArgs }, void 0, callOpts);
|
|
2732
|
+
} catch (err) {
|
|
2733
|
+
if (/closed|not connected|EPIPE|terminated|ECONNRESET|fetch failed/i.test(String(err?.message || err))) {
|
|
2734
|
+
await connectMcp(server, entry.config);
|
|
2735
|
+
const again = mcp.get(server);
|
|
2736
|
+
if (again?.status === "connected" && again.client) {
|
|
2737
|
+
result = await again.client.callTool({ name: actualTool, arguments: toolArgs }, void 0, callOpts);
|
|
2738
|
+
} else throw err;
|
|
2739
|
+
} else throw err;
|
|
2740
|
+
}
|
|
1256
2741
|
return res.json(result);
|
|
1257
2742
|
} catch (e) {
|
|
1258
2743
|
console.error("MCP call error:", e);
|
|
@@ -1355,17 +2840,6 @@ async function startServer() {
|
|
|
1355
2840
|
}
|
|
1356
2841
|
mcp.set(server, { client: null, tools: [], status: "disabled", config: newCfg, type: normalizeMcpType(newCfg.type) });
|
|
1357
2842
|
}
|
|
1358
|
-
try {
|
|
1359
|
-
const mcpPath = mcpConfigPath();
|
|
1360
|
-
const existing = readMcpServersFile(mcpPath);
|
|
1361
|
-
if (existing[server]) {
|
|
1362
|
-
existing[server] = { ...existing[server], enabled: newEnabled };
|
|
1363
|
-
delete existing[server].disabled;
|
|
1364
|
-
import_fs.default.writeFileSync(mcpPath, JSON.stringify({ mcpServers: existing }, null, 2), "utf-8");
|
|
1365
|
-
}
|
|
1366
|
-
} catch (e) {
|
|
1367
|
-
console.warn("[MCP] could not persist toggle:", e?.message);
|
|
1368
|
-
}
|
|
1369
2843
|
const entry = mcp.get(server);
|
|
1370
2844
|
return res.json({ name: server, status: entry.status, enabled: newEnabled });
|
|
1371
2845
|
} catch (e) {
|
|
@@ -1390,16 +2864,23 @@ async function startServer() {
|
|
|
1390
2864
|
else if (typeof args === "string" && args.trim()) newCfg.args = String(args).trim().split(/\s+/);
|
|
1391
2865
|
if (env && typeof env === "object") newCfg.env = env;
|
|
1392
2866
|
}
|
|
1393
|
-
const mcpPath =
|
|
2867
|
+
const mcpPath = import_path3.default.join(process.cwd(), "mcp.json");
|
|
1394
2868
|
let existing = {};
|
|
1395
2869
|
try {
|
|
1396
|
-
|
|
2870
|
+
if (import_fs3.default.existsSync(mcpPath)) {
|
|
2871
|
+
const raw = import_fs3.default.readFileSync(mcpPath, "utf-8");
|
|
2872
|
+
const j = JSON.parse(stripJsonc(raw));
|
|
2873
|
+
if (j.mcpServers) existing = j.mcpServers;
|
|
2874
|
+
else if (j.mcp && j.mcp.servers) existing = j.mcp.servers;
|
|
2875
|
+
else if (j.mcp && typeof j.mcp === "object") existing = j.mcp;
|
|
2876
|
+
else existing = j;
|
|
2877
|
+
}
|
|
1397
2878
|
} catch (e) {
|
|
1398
2879
|
return res.status(500).json({ error: "mcp.json is invalid, refusing to overwrite: " + (e?.message || e) });
|
|
1399
2880
|
}
|
|
1400
2881
|
existing[cleanName] = newCfg;
|
|
1401
2882
|
const toWrite = { mcpServers: existing };
|
|
1402
|
-
|
|
2883
|
+
import_fs3.default.writeFileSync(mcpPath, JSON.stringify(toWrite, null, 2));
|
|
1403
2884
|
await connectMcp(cleanName, newCfg);
|
|
1404
2885
|
const entry = mcp.get(cleanName);
|
|
1405
2886
|
return res.json({ name: cleanName, status: entry.status, toolCount: entry.tools.length, error: entry.error || null });
|
|
@@ -1407,6 +2888,93 @@ async function startServer() {
|
|
|
1407
2888
|
return res.status(500).json({ error: e?.message });
|
|
1408
2889
|
}
|
|
1409
2890
|
});
|
|
2891
|
+
app.post("/api/mcp/edit", mcpAdminGuard, async (req, res) => {
|
|
2892
|
+
try {
|
|
2893
|
+
const { oldName, name, type, command, args, url, headers, env, enabled = true } = req.body || {};
|
|
2894
|
+
if (!oldName || typeof oldName !== "string") return res.status(400).json({ error: "Missing oldName" });
|
|
2895
|
+
if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing name" });
|
|
2896
|
+
const cleanOldName = oldName.trim();
|
|
2897
|
+
const cleanName = name.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2898
|
+
if (!cleanName) return res.status(400).json({ error: "Invalid name" });
|
|
2899
|
+
const newCfg = { type: type || (url ? "http" : "stdio"), enabled: !!enabled };
|
|
2900
|
+
if (newCfg.type === "http" || newCfg.type === "remote") {
|
|
2901
|
+
if (!url || !String(url).startsWith("http")) return res.status(400).json({ error: "Invalid url for http server" });
|
|
2902
|
+
newCfg.url = String(url);
|
|
2903
|
+
if (headers && typeof headers === "object") newCfg.headers = headers;
|
|
2904
|
+
} else {
|
|
2905
|
+
if (!command || typeof command !== "string") return res.status(400).json({ error: "Missing command for stdio server" });
|
|
2906
|
+
newCfg.command = String(command);
|
|
2907
|
+
if (Array.isArray(args) && args.length) newCfg.args = args.map(String);
|
|
2908
|
+
else if (typeof args === "string" && args.trim()) newCfg.args = String(args).trim().split(/\s+/);
|
|
2909
|
+
if (env && typeof env === "object") newCfg.env = env;
|
|
2910
|
+
}
|
|
2911
|
+
const mcpPath = import_path3.default.join(process.cwd(), "mcp.json");
|
|
2912
|
+
let existing = {};
|
|
2913
|
+
try {
|
|
2914
|
+
if (import_fs3.default.existsSync(mcpPath)) {
|
|
2915
|
+
const raw = import_fs3.default.readFileSync(mcpPath, "utf-8");
|
|
2916
|
+
const j = JSON.parse(stripJsonc(raw));
|
|
2917
|
+
if (j.mcpServers) existing = j.mcpServers;
|
|
2918
|
+
else if (j.mcp && j.mcp.servers) existing = j.mcp.servers;
|
|
2919
|
+
else if (j.mcp && typeof j.mcp === "object") existing = j.mcp;
|
|
2920
|
+
else existing = j;
|
|
2921
|
+
}
|
|
2922
|
+
} catch (e) {
|
|
2923
|
+
return res.status(500).json({ error: "mcp.json is invalid, refusing to overwrite: " + (e?.message || e) });
|
|
2924
|
+
}
|
|
2925
|
+
if (cleanOldName !== cleanName || mcp.has(cleanOldName)) {
|
|
2926
|
+
delete existing[cleanOldName];
|
|
2927
|
+
const old = mcp.get(cleanOldName);
|
|
2928
|
+
try {
|
|
2929
|
+
await old?.client?.close?.();
|
|
2930
|
+
} catch {
|
|
2931
|
+
}
|
|
2932
|
+
mcp.delete(cleanOldName);
|
|
2933
|
+
}
|
|
2934
|
+
existing[cleanName] = newCfg;
|
|
2935
|
+
import_fs3.default.writeFileSync(mcpPath, JSON.stringify({ mcpServers: existing }, null, 2));
|
|
2936
|
+
if (newCfg.enabled) {
|
|
2937
|
+
await connectMcp(cleanName, newCfg);
|
|
2938
|
+
} else {
|
|
2939
|
+
mcp.set(cleanName, { client: null, tools: [], status: "disabled", config: newCfg, type: normalizeMcpType(newCfg.type) });
|
|
2940
|
+
}
|
|
2941
|
+
const entry = mcp.get(cleanName);
|
|
2942
|
+
return res.json({ name: cleanName, status: entry?.status || "disabled", toolCount: entry?.tools?.length || 0, error: entry?.error || null });
|
|
2943
|
+
} catch (e) {
|
|
2944
|
+
return res.status(500).json({ error: e?.message });
|
|
2945
|
+
}
|
|
2946
|
+
});
|
|
2947
|
+
app.post("/api/mcp/delete", mcpAdminGuard, async (req, res) => {
|
|
2948
|
+
try {
|
|
2949
|
+
const { name } = req.body || {};
|
|
2950
|
+
if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing name" });
|
|
2951
|
+
const cleanName = name.trim();
|
|
2952
|
+
const mcpPath = import_path3.default.join(process.cwd(), "mcp.json");
|
|
2953
|
+
let existing = {};
|
|
2954
|
+
if (import_fs3.default.existsSync(mcpPath)) {
|
|
2955
|
+
try {
|
|
2956
|
+
const raw = import_fs3.default.readFileSync(mcpPath, "utf-8");
|
|
2957
|
+
const j = JSON.parse(stripJsonc(raw));
|
|
2958
|
+
if (j.mcpServers) existing = j.mcpServers;
|
|
2959
|
+
else if (j.mcp && j.mcp.servers) existing = j.mcp.servers;
|
|
2960
|
+
else if (j.mcp && typeof j.mcp === "object") existing = j.mcp;
|
|
2961
|
+
else existing = j;
|
|
2962
|
+
} catch {
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
delete existing[cleanName];
|
|
2966
|
+
import_fs3.default.writeFileSync(mcpPath, JSON.stringify({ mcpServers: existing }, null, 2));
|
|
2967
|
+
const old = mcp.get(cleanName);
|
|
2968
|
+
try {
|
|
2969
|
+
await old?.client?.close?.();
|
|
2970
|
+
} catch {
|
|
2971
|
+
}
|
|
2972
|
+
mcp.delete(cleanName);
|
|
2973
|
+
return res.json({ success: true });
|
|
2974
|
+
} catch (e) {
|
|
2975
|
+
return res.status(500).json({ error: e?.message });
|
|
2976
|
+
}
|
|
2977
|
+
});
|
|
1410
2978
|
app.post("/api/fetch-url", async (req, res) => {
|
|
1411
2979
|
const errorFallback = "you entered an incorrect URL, are missing characters, or the website no longer exists";
|
|
1412
2980
|
try {
|
|
@@ -1420,6 +2988,7 @@ async function startServer() {
|
|
|
1420
2988
|
}
|
|
1421
2989
|
const urlMatch = url.match(/https?:\/\/[^\s"']+/);
|
|
1422
2990
|
let targetUrl = urlMatch ? urlMatch[0] : url.trim();
|
|
2991
|
+
if (/^(?:fanluc|mondk-msh)\.docs(?:[\/?#]|$)/i.test(targetUrl)) targetUrl = "https://" + targetUrl;
|
|
1423
2992
|
try {
|
|
1424
2993
|
new URL(targetUrl);
|
|
1425
2994
|
} catch {
|
|
@@ -1435,6 +3004,18 @@ async function startServer() {
|
|
|
1435
3004
|
hostname = new URL(targetUrl).hostname.toLowerCase();
|
|
1436
3005
|
} catch {
|
|
1437
3006
|
}
|
|
3007
|
+
if (hostname === DOCS_VIRTUAL_HOST || hostname === MONDK_MSH_VIRTUAL_HOST) {
|
|
3008
|
+
const pathname = new URL(targetUrl).pathname;
|
|
3009
|
+
const doc = hostname === MONDK_MSH_VIRTUAL_HOST ? await readMondkMshPage(pathname) : await readVirtualDocsPage(pathname);
|
|
3010
|
+
return res.json({
|
|
3011
|
+
url: targetUrl,
|
|
3012
|
+
title: doc.title,
|
|
3013
|
+
text: doc.text,
|
|
3014
|
+
formattedText: `fetched url: ${targetUrl}
|
|
3015
|
+
title: ${doc.title}
|
|
3016
|
+
text: ${doc.text}`
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
1438
3019
|
if (isBlockedHost(hostname)) {
|
|
1439
3020
|
const blockedMsg = `This site (${hostname}) blocks automated crawling or requires login, so its content cannot be reliably extracted. Try a different, publicly accessible source instead.`;
|
|
1440
3021
|
return res.json({
|
|
@@ -1513,9 +3094,9 @@ ${titlePart}text: ${extracted}`;
|
|
|
1513
3094
|
return res.status(400).json({ error: "Missing name parameter" });
|
|
1514
3095
|
}
|
|
1515
3096
|
const cleanName = name.trim().toLowerCase().replace(/^['"`]|['"`]$/g, "");
|
|
1516
|
-
const docsDir =
|
|
3097
|
+
const docsDir = import_path3.default.join(process.cwd(), "docs");
|
|
1517
3098
|
try {
|
|
1518
|
-
const files = await
|
|
3099
|
+
const files = await import_fs3.default.promises.readdir(docsDir);
|
|
1519
3100
|
let targetFile = files.find(
|
|
1520
3101
|
(f) => f.toLowerCase() === cleanName || f.toLowerCase() === `${cleanName}.md`
|
|
1521
3102
|
);
|
|
@@ -1527,7 +3108,7 @@ ${titlePart}text: ${extracted}`;
|
|
|
1527
3108
|
else targetFile = files.find((f) => f.toLowerCase().includes(cleanName));
|
|
1528
3109
|
}
|
|
1529
3110
|
if (targetFile) {
|
|
1530
|
-
const content = await
|
|
3111
|
+
const content = await import_fs3.default.promises.readFile(import_path3.default.join(docsDir, targetFile), "utf-8");
|
|
1531
3112
|
return res.json({
|
|
1532
3113
|
found: true,
|
|
1533
3114
|
fileName: targetFile,
|
|
@@ -1646,329 +3227,51 @@ ${content}`
|
|
|
1646
3227
|
res.status(500).json({ error: "Failed to fetch weather" });
|
|
1647
3228
|
}
|
|
1648
3229
|
});
|
|
1649
|
-
|
|
1650
|
-
const m = (process.env.FANLUC_PERMISSION || "").trim();
|
|
1651
|
-
if (m === "readOnly" || m === "autoEdit" || m === "ask") return m;
|
|
1652
|
-
try {
|
|
1653
|
-
const cfgDir2 = getFanlucConfigDir();
|
|
1654
|
-
const p = import_path.default.join(cfgDir2, "settings.json");
|
|
1655
|
-
if (import_fs.default.existsSync(p)) {
|
|
1656
|
-
const raw = import_fs.default.readFileSync(p, "utf-8").replace(/^\uFEFF/, "");
|
|
1657
|
-
const j = JSON.parse(raw);
|
|
1658
|
-
if (j.permission === "readOnly" || j.permission === "autoEdit" || j.permission === "ask") return j.permission;
|
|
1659
|
-
}
|
|
1660
|
-
} catch {
|
|
1661
|
-
}
|
|
1662
|
-
return "ask";
|
|
1663
|
-
}
|
|
1664
|
-
function loadFanlucMdSync() {
|
|
1665
|
-
const candidates = [
|
|
1666
|
-
import_path.default.join(WORKSPACE_DIR, "FANLUC.md"),
|
|
1667
|
-
import_path.default.join(WORKSPACE_DIR, "fanluc.md"),
|
|
1668
|
-
import_path.default.join(WORKSPACE_DIR, ".fanluc", "FANLUC.md")
|
|
1669
|
-
];
|
|
1670
|
-
for (const p of candidates) {
|
|
1671
|
-
try {
|
|
1672
|
-
if (import_fs.default.existsSync(p) && import_fs.default.statSync(p).isFile()) {
|
|
1673
|
-
const raw = import_fs.default.readFileSync(p, "utf-8").replace(/^\uFEFF/, "");
|
|
1674
|
-
const MAX = 6e3;
|
|
1675
|
-
const content = raw.length > MAX ? raw.slice(0, MAX) + "\n\n[... truncated ...]" : raw;
|
|
1676
|
-
return { path: p, content };
|
|
1677
|
-
}
|
|
1678
|
-
} catch {
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
return null;
|
|
1682
|
-
}
|
|
1683
|
-
app.get("/api/fanluc-md", (_req, res) => {
|
|
1684
|
-
const md = loadFanlucMdSync();
|
|
1685
|
-
if (!md) return res.json({ found: false, path: null, content: "" });
|
|
1686
|
-
return res.json({ found: true, path: md.path, content: md.content });
|
|
1687
|
-
});
|
|
1688
|
-
app.get("/api/config", (_req, res) => {
|
|
1689
|
-
const cfgDir2 = getFanlucConfigDir();
|
|
1690
|
-
const perm = getPermissionMode();
|
|
1691
|
-
const ws = WORKSPACE_DIR;
|
|
1692
|
-
const md = loadFanlucMdSync();
|
|
1693
|
-
res.json({
|
|
1694
|
-
workspace: ws,
|
|
1695
|
-
configDir: cfgDir2,
|
|
1696
|
-
permission: perm,
|
|
1697
|
-
fanlucMd: md ? { path: md.path, length: md.content.length } : null,
|
|
1698
|
-
platform: process.platform
|
|
1699
|
-
});
|
|
1700
|
-
});
|
|
1701
|
-
app.get("/api/permission/status", (_req, res) => {
|
|
1702
|
-
const perm = getPermissionMode();
|
|
1703
|
-
res.json({
|
|
1704
|
-
permission: perm,
|
|
1705
|
-
fileGranted: isGranted("file"),
|
|
1706
|
-
shellGranted: isGranted("shell"),
|
|
1707
|
-
ttlMs: GRANT_TTL_MS
|
|
1708
|
-
});
|
|
1709
|
-
});
|
|
1710
|
-
app.post("/api/permission/approve", (req, res) => {
|
|
1711
|
-
const { type } = req.body || {};
|
|
1712
|
-
const t = type === "file" ? "file" : type === "shell" ? "shell" : "all";
|
|
1713
|
-
const perm = getPermissionMode();
|
|
1714
|
-
if (perm === "readOnly") {
|
|
1715
|
-
return res.status(403).json({ error: "readOnly: cannot grant permission" });
|
|
1716
|
-
}
|
|
1717
|
-
grantPermission(t);
|
|
1718
|
-
res.json({ ok: true, granted: t, until: Date.now() + GRANT_TTL_MS });
|
|
1719
|
-
});
|
|
1720
|
-
app.post("/api/permission/mode", (req, res) => {
|
|
1721
|
-
const m = String(req.body?.mode || "");
|
|
1722
|
-
if (m !== "ask" && m !== "autoEdit" && m !== "readOnly") return res.status(400).json({ error: "mode must be ask | autoEdit | readOnly" });
|
|
1723
|
-
process.env.FANLUC_PERMISSION = m;
|
|
1724
|
-
return res.json({ ok: true, permission: m });
|
|
1725
|
-
});
|
|
1726
|
-
app.post("/api/permission/reset", (_req, res) => {
|
|
1727
|
-
clearGrants();
|
|
1728
|
-
res.json({ ok: true });
|
|
1729
|
-
});
|
|
1730
|
-
app.post("/api/run-cmd", (req, res) => {
|
|
1731
|
-
if (process.env.DISABLE_RUN_CMD === "1") {
|
|
1732
|
-
const msg = "run_cmd is disabled on this server.";
|
|
1733
|
-
return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
|
|
1734
|
-
}
|
|
1735
|
-
const perm = getPermissionMode();
|
|
1736
|
-
const needShellGrant = perm === "ask" || perm === "autoEdit";
|
|
1737
|
-
if (perm === "readOnly" || needShellGrant && !isGranted("shell")) {
|
|
1738
|
-
const hint = perm === "readOnly" ? "readOnly \u2014 shell disabled" : `permission '${perm}' requires confirmation via web ConfirmModal or POST /api/permission/approve {type:'shell'}`;
|
|
1739
|
-
const msg = `Blocked by permission mode '${perm}': shell execution requires confirmation. ${hint}`;
|
|
1740
|
-
return res.status(403).json({ stdout: "", stderr: msg, formattedText: msg, blocked: true, needConfirm: true, permission: perm });
|
|
1741
|
-
}
|
|
1742
|
-
const { command } = req.body || {};
|
|
1743
|
-
if (!command || typeof command !== "string" || !command.trim()) {
|
|
1744
|
-
return res.status(400).json({ error: "Missing command", formattedText: "Error: missing command" });
|
|
1745
|
-
}
|
|
1746
|
-
const trimmed = preprocessCommand(command.trim());
|
|
1747
|
-
const blockedReason = findBlockedPattern2(trimmed);
|
|
1748
|
-
if (blockedReason) {
|
|
1749
|
-
const msg = `Command blocked for safety: ${blockedReason}.`;
|
|
1750
|
-
return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
|
|
1751
|
-
}
|
|
1752
|
-
const TIMEOUT_MS = 2e4;
|
|
1753
|
-
const MAX_BUFFER = 2 * 1024 * 1024;
|
|
1754
|
-
let bin;
|
|
1755
|
-
let args;
|
|
1756
|
-
if (IS_WINDOWS) {
|
|
1757
|
-
bin = "powershell.exe";
|
|
1758
|
-
const PS_UTF8 = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false);$OutputEncoding=[Console]::OutputEncoding;$PSDefaultParameterValues['Get-Content:Encoding']='utf8';$PSDefaultParameterValues['Set-Content:Encoding']='utf8';$PSDefaultParameterValues['Add-Content:Encoding']='utf8';$PSDefaultParameterValues['Out-File:Encoding']='utf8';";
|
|
1759
|
-
args = ["-NoProfile", "-Command", PS_UTF8 + trimmed];
|
|
1760
|
-
} else if (FIREJAIL_AVAILABLE) {
|
|
1761
|
-
bin = "firejail";
|
|
1762
|
-
args = [
|
|
1763
|
-
"--quiet",
|
|
1764
|
-
"--noprofile",
|
|
1765
|
-
`--private=${WORKSPACE_DIR}`,
|
|
1766
|
-
"--net=none",
|
|
1767
|
-
"--rlimit-fsize=50000000",
|
|
1768
|
-
"--rlimit-as=1073741824",
|
|
1769
|
-
"bash",
|
|
1770
|
-
"-c",
|
|
1771
|
-
trimmed
|
|
1772
|
-
];
|
|
1773
|
-
} else {
|
|
1774
|
-
bin = "bash";
|
|
1775
|
-
args = ["-c", trimmed];
|
|
1776
|
-
}
|
|
1777
|
-
(0, import_child_process.execFile)(
|
|
1778
|
-
bin,
|
|
1779
|
-
args,
|
|
1780
|
-
{ cwd: WORKSPACE_DIR, timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER, env: SAFE_ENV },
|
|
1781
|
-
(err, stdout, stderr) => {
|
|
1782
|
-
const out = (stdout || "").toString().slice(0, 2e4);
|
|
1783
|
-
const errOut = (stderr || "").toString().slice(0, 2e4);
|
|
1784
|
-
if (err && err.killed) {
|
|
1785
|
-
const msg = `Command timed out (${TIMEOUT_MS / 1e3}s limit).`;
|
|
1786
|
-
return res.json({ stdout: out, stderr: msg, formattedText: `$ ${trimmed}
|
|
1787
|
-
${out}
|
|
1788
|
-
${msg}`.trim(), timedOut: true });
|
|
1789
|
-
}
|
|
1790
|
-
const exitCode = err ? typeof err.code === "number" ? err.code : 1 : 0;
|
|
1791
|
-
const body = `${out}${errOut ? (out ? "\n" : "") + errOut : ""}`.trim() || "(no output)";
|
|
1792
|
-
const formattedText = `$ ${trimmed}
|
|
1793
|
-
${body}`;
|
|
1794
|
-
return res.json({ stdout: out, stderr: errOut, exitCode, formattedText });
|
|
1795
|
-
}
|
|
1796
|
-
);
|
|
1797
|
-
});
|
|
1798
|
-
app.post("/api/upload-workspace-file", import_express.default.json({ limit: "10mb" }), async (req, res) => {
|
|
1799
|
-
const perm = getPermissionMode();
|
|
1800
|
-
if (perm === "readOnly" || perm === "ask" && !isGranted("file")) {
|
|
1801
|
-
const hint = perm === "ask" ? "requires confirmation via ConfirmModal or POST /api/permission/approve {type:'file'}" : "readOnly";
|
|
1802
|
-
return res.status(403).json({ error: `Blocked by permission mode '${perm}': file write ${hint}`, needConfirm: perm === "ask", permission: perm });
|
|
1803
|
-
}
|
|
1804
|
-
try {
|
|
1805
|
-
const { name, content, encoding = "utf-8" } = req.body || {};
|
|
1806
|
-
if (!name || typeof name !== "string") {
|
|
1807
|
-
return res.status(400).json({ error: "Missing file name" });
|
|
1808
|
-
}
|
|
1809
|
-
const cleanName = name.trim().replace(/^(\.\/|\/)+/, "");
|
|
1810
|
-
const filePath = import_path.default.join(WORKSPACE_DIR, cleanName);
|
|
1811
|
-
const dir = import_path.default.dirname(filePath);
|
|
1812
|
-
if (!isInsideWorkspace(dir)) {
|
|
1813
|
-
return res.status(403).json({ error: "Invalid file path outside workspace" });
|
|
1814
|
-
}
|
|
1815
|
-
await import_fs.default.promises.mkdir(dir, { recursive: true });
|
|
1816
|
-
let buffer;
|
|
1817
|
-
if ((encoding === "base64" || cleanName.toLowerCase().endsWith(".zip")) && typeof content === "string") {
|
|
1818
|
-
let rawBase64 = content;
|
|
1819
|
-
if (rawBase64.includes(",")) {
|
|
1820
|
-
rawBase64 = rawBase64.split(",")[1];
|
|
1821
|
-
}
|
|
1822
|
-
buffer = Buffer.from(rawBase64, "base64");
|
|
1823
|
-
} else if (typeof content === "string") {
|
|
1824
|
-
buffer = Buffer.from(content, "utf-8");
|
|
1825
|
-
} else {
|
|
1826
|
-
buffer = Buffer.from("");
|
|
1827
|
-
}
|
|
1828
|
-
await import_fs.default.promises.writeFile(filePath, buffer);
|
|
1829
|
-
const stats = await import_fs.default.promises.stat(filePath);
|
|
1830
|
-
return res.json({
|
|
1831
|
-
success: true,
|
|
1832
|
-
path: cleanName,
|
|
1833
|
-
name: import_path.default.basename(cleanName),
|
|
1834
|
-
size: stats.size,
|
|
1835
|
-
updatedAt: stats.mtimeMs
|
|
1836
|
-
});
|
|
1837
|
-
} catch (err) {
|
|
1838
|
-
console.error("Upload workspace file error:", err);
|
|
1839
|
-
return res.status(500).json({ error: err?.message || "Failed to upload workspace file" });
|
|
1840
|
-
}
|
|
1841
|
-
});
|
|
1842
|
-
app.post("/api/delete-workspace-file", import_express.default.json(), async (req, res) => {
|
|
1843
|
-
const perm = getPermissionMode();
|
|
1844
|
-
if (perm === "readOnly" || perm === "ask" && !isGranted("file")) {
|
|
1845
|
-
const hint = perm === "ask" ? "requires confirmation via ConfirmModal or POST /api/permission/approve {type:'file'}" : "readOnly";
|
|
1846
|
-
return res.status(403).json({ error: `Blocked by permission mode '${perm}': file delete ${hint}`, needConfirm: perm === "ask", permission: perm });
|
|
1847
|
-
}
|
|
1848
|
-
try {
|
|
1849
|
-
const { path: filePathParam } = req.body || {};
|
|
1850
|
-
if (!filePathParam || typeof filePathParam !== "string") {
|
|
1851
|
-
return res.status(400).json({ error: "Missing file path" });
|
|
1852
|
-
}
|
|
1853
|
-
const cleanName = filePathParam.trim().replace(/^(\.\/|\/)+/, "");
|
|
1854
|
-
const targetPath = import_path.default.join(WORKSPACE_DIR, cleanName);
|
|
1855
|
-
if (!isInsideWorkspace(targetPath)) {
|
|
1856
|
-
return res.status(403).json({ error: "Invalid file path outside workspace" });
|
|
1857
|
-
}
|
|
1858
|
-
if (import_fs.default.existsSync(targetPath)) {
|
|
1859
|
-
await import_fs.default.promises.rm(targetPath, { recursive: true, force: true });
|
|
1860
|
-
}
|
|
1861
|
-
return res.json({ success: true, path: cleanName });
|
|
1862
|
-
} catch (err) {
|
|
1863
|
-
console.error("Delete workspace file error:", err);
|
|
1864
|
-
return res.status(500).json({ error: err?.message || "Failed to delete workspace file" });
|
|
1865
|
-
}
|
|
1866
|
-
});
|
|
1867
|
-
app.get("/api/workspace-files", async (_req, res) => {
|
|
1868
|
-
try {
|
|
1869
|
-
const filesMap = {};
|
|
1870
|
-
async function walkDir(currentDir, relativeDir = "") {
|
|
1871
|
-
let entries = [];
|
|
1872
|
-
try {
|
|
1873
|
-
entries = await import_fs.default.promises.readdir(currentDir, { withFileTypes: true });
|
|
1874
|
-
} catch {
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
for (const entry of entries) {
|
|
1878
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1879
|
-
const fullPath = import_path.default.join(currentDir, entry.name);
|
|
1880
|
-
const relPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
1881
|
-
if (entry.isDirectory()) {
|
|
1882
|
-
await walkDir(fullPath, relPath);
|
|
1883
|
-
} else if (entry.isFile()) {
|
|
1884
|
-
try {
|
|
1885
|
-
const stats = await import_fs.default.promises.stat(fullPath);
|
|
1886
|
-
if (stats.size <= 2 * 1024 * 1024) {
|
|
1887
|
-
const ext = entry.name.split(".").pop()?.toLowerCase() || "";
|
|
1888
|
-
const isBinary = ["zip", "gz", "tar", "tgz", "rar", "7z", "png", "jpg", "jpeg", "gif", "pdf", "exe", "bin", "apk", "iso"].includes(ext);
|
|
1889
|
-
let content = "";
|
|
1890
|
-
let fileEncoding = "utf-8";
|
|
1891
|
-
if (isBinary) {
|
|
1892
|
-
content = `[Binary File: ${entry.name} (${(stats.size / 1024).toFixed(1)} KB)]`;
|
|
1893
|
-
} else {
|
|
1894
|
-
try {
|
|
1895
|
-
const dec = decodeTextBuffer(await import_fs.default.promises.readFile(fullPath));
|
|
1896
|
-
content = dec.text;
|
|
1897
|
-
fileEncoding = dec.encoding;
|
|
1898
|
-
} catch {
|
|
1899
|
-
content = `[Unreadable File: ${(stats.size / 1024).toFixed(1)} KB]`;
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
filesMap[relPath] = {
|
|
1903
|
-
path: relPath,
|
|
1904
|
-
name: entry.name,
|
|
1905
|
-
content,
|
|
1906
|
-
size: stats.size,
|
|
1907
|
-
language: ext,
|
|
1908
|
-
encoding: fileEncoding,
|
|
1909
|
-
updatedAt: stats.mtimeMs
|
|
1910
|
-
};
|
|
1911
|
-
} else {
|
|
1912
|
-
filesMap[relPath] = {
|
|
1913
|
-
path: relPath,
|
|
1914
|
-
name: entry.name,
|
|
1915
|
-
content: `[Large File: ${(stats.size / 1024).toFixed(1)} KB]`,
|
|
1916
|
-
size: stats.size,
|
|
1917
|
-
language: entry.name.split(".").pop()?.toLowerCase() || "binary",
|
|
1918
|
-
updatedAt: stats.mtimeMs
|
|
1919
|
-
};
|
|
1920
|
-
}
|
|
1921
|
-
} catch {
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
await walkDir(WORKSPACE_DIR);
|
|
1927
|
-
return res.json({ files: filesMap });
|
|
1928
|
-
} catch (err) {
|
|
1929
|
-
return res.status(500).json({ error: err?.message || "Failed to list workspace files" });
|
|
1930
|
-
}
|
|
1931
|
-
});
|
|
3230
|
+
registerLocalRoutes(app);
|
|
1932
3231
|
app.get("/api/health", (_req, res) => {
|
|
1933
|
-
res.json({ status: "ok"
|
|
3232
|
+
res.json({ status: "ok" });
|
|
1934
3233
|
});
|
|
1935
3234
|
if (process.env.NODE_ENV !== "production") {
|
|
1936
3235
|
const vite = await (0, import_vite.createServer)({
|
|
1937
|
-
server: { middlewareMode: true },
|
|
3236
|
+
server: { middlewareMode: true, hmr: false },
|
|
1938
3237
|
appType: "spa"
|
|
1939
3238
|
});
|
|
1940
3239
|
app.use(vite.middlewares);
|
|
1941
3240
|
} else {
|
|
1942
|
-
const distPath =
|
|
3241
|
+
const distPath = import_path3.default.join(process.cwd(), "dist");
|
|
1943
3242
|
app.use(import_express.default.static(distPath));
|
|
1944
3243
|
app.get("*", (_req, res) => {
|
|
1945
|
-
res.sendFile(
|
|
3244
|
+
res.sendFile(import_path3.default.join(distPath, "index.html"));
|
|
1946
3245
|
});
|
|
1947
3246
|
}
|
|
1948
|
-
const HOST = process.env.HOST || "
|
|
1949
|
-
const cfgDir = getFanlucConfigDir();
|
|
1950
|
-
const permMode = getPermissionMode();
|
|
1951
|
-
const fanlucMd = loadFanlucMdSync();
|
|
1952
|
-
console.log(`[fanluc] Workspace : ${WORKSPACE_DIR}`);
|
|
1953
|
-
console.log(`[fanluc] Config dir: ${cfgDir}`);
|
|
1954
|
-
console.log(`[fanluc] Permission: ${permMode}`);
|
|
1955
|
-
if (fanlucMd) console.log(`[fanluc] FANLUC.md : ${fanlucMd.path} (${fanlucMd.content.length} chars)`);
|
|
1956
|
-
else console.log(`[fanluc] FANLUC.md : (none)`);
|
|
3247
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
1957
3248
|
app.listen(PORT, HOST, () => {
|
|
1958
|
-
|
|
1959
|
-
console.log(`
|
|
1960
|
-
|
|
3249
|
+
console.log(`Server running on http://localhost:${PORT} (bound to ${HOST})`);
|
|
3250
|
+
console.log(`Workspace: ${getWorkspace()} | shell: ${SHELL.label}`);
|
|
3251
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(HOST)) {
|
|
3252
|
+
console.warn("[security] HOST is not loopback. Commands run WITHOUT a sandbox: anyone who can reach this port can run commands on this PC.");
|
|
3253
|
+
}
|
|
1961
3254
|
});
|
|
1962
3255
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
3256
|
+
var shuttingDown = false;
|
|
3257
|
+
function closeAllMcp() {
|
|
3258
|
+
if (shuttingDown) return;
|
|
3259
|
+
shuttingDown = true;
|
|
3260
|
+
for (const entry of mcp.values()) {
|
|
3261
|
+
try {
|
|
3262
|
+
entry.client?.close();
|
|
3263
|
+
} catch {
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
1968
3266
|
}
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
3267
|
+
process.on("SIGINT", () => {
|
|
3268
|
+
closeAllMcp();
|
|
3269
|
+
setTimeout(() => process.exit(0), 300);
|
|
3270
|
+
});
|
|
3271
|
+
process.on("SIGTERM", () => {
|
|
3272
|
+
closeAllMcp();
|
|
3273
|
+
setTimeout(() => process.exit(0), 300);
|
|
1973
3274
|
});
|
|
3275
|
+
process.on("exit", closeAllMcp);
|
|
3276
|
+
startServer();
|
|
1974
3277
|
//# sourceMappingURL=server.cjs.map
|